fix(claude-app): preserve preferences on restore (#1768)

This commit is contained in:
musistudio
2026-09-09 20:04:49 +08:00
parent b0d59afe08
commit 37ae4f51e4
3 changed files with 103 additions and 5 deletions
+22
View File
@@ -0,0 +1,22 @@
# Weekly issue verification (2026-09-09)
Scope: issues created since 2026-09-07 00:00 Asia/Shanghai, on base
`b0d59afe` (`dev/3.1`). GitHub issue timelines and PRs updated since
2026-09-06 were checked. Only #1766 / PR #1767 and #1768 / PR #1769
had matching PRs at inspection time. Changes are local; no remote review,
merge, or push was submitted.
## #1768 - Claude App preferences lost on restore
- PR reviewed: https://github.com/musistudio/claude-code-router/pull/1769
at `a259ddbcbf22ea2e62c8c2fd2185b74d1696f9bd`.
- Finding (P1): `restoreFileSnapshot(paths.metaFile, backup.metaFile)` still
replaces the entire metadata file, discarding entries and preferences
added during takeover. Root-key restoration also needs to avoid undoing
a configuration switch performed by the user while CCR is running.
- Local solution follows the PR's library-preservation approach and restores
only `deploymentMode` and `appliedId`, while CCR still owns their values.
- Regression cases: two apply/restore cycles, live library preferences,
root preferences, newly added metadata entries, originally absent keys.
Newly added regressions failed before the fix. Both Claude App gateway
service and sync suites pass after the fix (12 tests, Electron Node runtime).
@@ -235,9 +235,9 @@ export function restoreClaudeAppGatewayConfig(): void {
}
const paths = getClaudeAppGatewayPaths();
restoreFileSnapshot(paths.rootConfigFile, backup.rootConfigFile);
restoreFileSnapshot(paths.metaFile, backup.metaFile);
restoreFileSnapshot(paths.configLibraryFile, backup.configLibraryFile);
restoreClaudeAppOwnedKey(paths.rootConfigFile, backup.rootConfigFile, "deploymentMode", "3p");
restoreClaudeAppOwnedKey(paths.metaFile, backup.metaFile, "appliedId", CLAUDE_APP_CONFIG_ID);
// The inactive library entry contains preferences Claude writes during takeover.
rmSync(CLAUDE_APP_GATEWAY_BACKUP_FILE, { force: true });
}
@@ -455,6 +455,37 @@ function gatewayEndpoint(config: AppConfig): string {
return `http://${formattedHost}:${port}`;
}
function restoreClaudeAppOwnedKey(
file: string,
snapshot: ClaudeAppGatewayFileSnapshot,
key: string,
appliedValue: string
): void {
const current = readJsonRecord(file);
if (!current) {
restoreFileSnapshot(file, snapshot);
return;
}
if (current[key] !== appliedValue) {
return;
}
let previous: Record<string, unknown> = {};
if (snapshot.exists && snapshot.content) {
try {
const parsed: unknown = JSON.parse(snapshot.content);
if (isPlainRecord(parsed)) previous = parsed;
} catch {
// An invalid original file has no configuration key to restore.
}
}
if (Object.hasOwn(previous, key)) {
current[key] = previous[key];
} else {
delete current[key];
}
writeJsonFile(file, current);
}
function applyClaudeAppGatewayLibraryConfig(file: string, gatewayConfig: ClaudeAppGatewayConfig): void {
const current = readJsonRecord(file);
writeJsonFile(file, {
@@ -44,8 +44,8 @@ test("sync skips and restores the backup when no enabled claude-code profile ope
assert.equal(synced.result.message, NO_CLAUDE_APP_ENTRY_PROFILE_MESSAGE);
assert.equal(existsSync(BACKUP_FILE), false);
const libraryConfig = JSON.parse(readFileSync(claudeAppPaths().configLibraryFile, "utf8"));
assert.equal(libraryConfig.inferenceProvider, "original");
assert.equal(readFileSync(claudeAppPaths().rootConfigFile, "utf8"), '{"deploymentMode":"native"}');
assert.equal(libraryConfig.inferenceProvider, "gateway");
assert.equal(JSON.parse(readFileSync(claudeAppPaths().rootConfigFile, "utf8")).deploymentMode, "native");
cleanup(BACKUP_FILE);
});
@@ -181,6 +181,7 @@ function claudeAppPaths() {
}
function seedGatewayBackup(backupFile) {
writeJsonFile(claudeAppPaths().configLibraryFile, { inferenceProvider: "gateway" });
writeJsonFile(backupFile, {
configLibraryFile: { content: '{"inferenceProvider":"original"}', exists: true },
createdAt: "2026-01-01T00:00:00.000Z",
@@ -190,6 +191,50 @@ function seedGatewayBackup(backupFile) {
});
}
test("#1768 restore preserves live library, root preferences and metadata across restarts", async () => {
const { BACKUP_FILE, syncClaudeAppGatewayConfig } = await loadModules();
const paths = claudeAppPaths();
const enabled = createConfig({ profile: { profiles: [claudeCodeProfile({ surface: "app" })] } });
try {
writeJsonFile(paths.rootConfigFile, { deploymentMode: "native", preference: false });
writeJsonFile(paths.metaFile, { appliedId: "original", entries: [{ id: "original", name: "Original" }] });
for (let cycle = 0; cycle < 2; cycle++) {
await syncClaudeAppGatewayConfig(enabled);
const read = (file) => JSON.parse(readFileSync(file, "utf8"));
writeJsonFile(paths.configLibraryFile, { ...read(paths.configLibraryFile), chatTabEnabled: true, coworkEgressAllowedHosts: ["example.test"] });
writeJsonFile(paths.rootConfigFile, { ...read(paths.rootConfigFile), preference: true });
const meta = read(paths.metaFile);
writeJsonFile(paths.metaFile, { ...meta, userPreference: true, entries: [...meta.entries, { id: `new-${cycle}`, name: "New" }] });
await syncClaudeAppGatewayConfig(createConfig());
assert.equal(read(paths.rootConfigFile).deploymentMode, "native");
assert.equal(read(paths.rootConfigFile).preference, true);
assert.equal(read(paths.configLibraryFile).chatTabEnabled, true);
assert.deepEqual(read(paths.configLibraryFile).coworkEgressAllowedHosts, ["example.test"]);
assert.equal(read(paths.metaFile).appliedId, "original");
assert.equal(read(paths.metaFile).userPreference, true);
assert.ok(read(paths.metaFile).entries.some((entry) => entry.id === `new-${cycle}`));
assert.equal(existsSync(BACKUP_FILE), false);
}
} finally {
cleanup(BACKUP_FILE);
}
});
test("#1768 restore removes absent takeover keys while preserving newly created preferences", async () => {
const { BACKUP_FILE, syncClaudeAppGatewayConfig } = await loadModules();
const paths = claudeAppPaths();
try {
await syncClaudeAppGatewayConfig(createConfig({ profile: { profiles: [claudeCodeProfile()] } }));
writeJsonFile(paths.rootConfigFile, { deploymentMode: "3p", userPreference: true });
await syncClaudeAppGatewayConfig(createConfig());
assert.deepEqual(JSON.parse(readFileSync(paths.rootConfigFile, "utf8")), { userPreference: true });
assert.equal(JSON.parse(readFileSync(paths.metaFile, "utf8")).appliedId, undefined);
assert.equal(existsSync(paths.configLibraryFile), true);
} finally {
cleanup(BACKUP_FILE);
}
});
function seedGatewayBackupWithoutExistingFiles(backupFile) {
writeJsonFile(backupFile, {
configLibraryFile: { exists: false },