mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-10-31 03:52:16 +08:00
fix(claude-app): preserve preferences on restore (#1768)
This commit is contained in:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user