diff --git a/docs/issue-reviews/2026-09-09.md b/docs/issue-reviews/2026-09-09.md new file mode 100644 index 00000000..c2a1453d --- /dev/null +++ b/docs/issue-reviews/2026-09-09.md @@ -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). diff --git a/packages/core/src/agents/claude-app/gateway-service.ts b/packages/core/src/agents/claude-app/gateway-service.ts index 0bcea0e5..47633ca2 100644 --- a/packages/core/src/agents/claude-app/gateway-service.ts +++ b/packages/core/src/agents/claude-app/gateway-service.ts @@ -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 = {}; + 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, { diff --git a/packages/core/test/unit/agents/claude-app-gateway-sync.test.mjs b/packages/core/test/unit/agents/claude-app-gateway-sync.test.mjs index d7f5d57a..3592799c 100644 --- a/packages/core/test/unit/agents/claude-app-gateway-sync.test.mjs +++ b/packages/core/test/unit/agents/claude-app-gateway-sync.test.mjs @@ -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 },