diff --git a/src/config.ts b/src/config.ts index 3d0d1a239..3d5229a71 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2515,6 +2515,24 @@ export function claudeCodeBaselineArmed(config: OcxConfig): boolean { return claudeCodeBaseline.has(config); } +/** + * Adopt a field-scoped Claude Code write into a long-lived config snapshot. + * + * Scoped writers commit against the current file rather than serializing the + * whole snapshot. Mirror that committed subtree and rebase the hand-edit guard + * together so a later unrelated save does not mistake the scoped write for an + * outstanding in-memory mutation. + */ +export function adoptPersistedClaudeCode( + config: OcxConfig, + persistedClaudeCode: OcxConfig["claudeCode"], +): void { + config.claudeCode = structuredClone(persistedClaudeCode); + if (claudeCodeBaseline.has(config)) { + claudeCodeBaseline.set(config, structuredClone(persistedClaudeCode)); + } +} + /** * Structural compare of parsed subtrees. NOT `JSON.stringify`: key order must not * decide whether a user's hand edit survives. diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 17f7c00b3..b725e45c0 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import { + adoptPersistedClaudeCode, DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, hasOwnProvider, @@ -105,12 +106,12 @@ function persistDesktopProfileField( ): { ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" } { const outcome = mutatePersistedConfig(persisted => { persisted.claudeCode = { ...(persisted.claudeCode ?? {}), desktopProfile }; - return { changed: true, value: true }; + return { changed: true, value: structuredClone(persisted.claudeCode) }; }); // Only mirror into memory once the durable write actually landed; an // `unavailable` outcome must not leave the snapshot claiming a saved profile. if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason }; - config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile }; + adoptPersistedClaudeCode(config, outcome.value); return { ok: true }; } diff --git a/tests/native-claude-desktop-toggle.test.ts b/tests/native-claude-desktop-toggle.test.ts index 9b1844f01..eae662448 100644 --- a/tests/native-claude-desktop-toggle.test.ts +++ b/tests/native-claude-desktop-toggle.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleManagementAPI } from "../src/server/management-api"; import { setIntegrationEnabled } from "../src/codex/desired-state"; +import { armClaudeCodeBaseline, saveConfigPreservingClaudeCode } from "../src/config"; import type { ManagementApiDeps } from "../src/server/management/context"; import type { OcxConfig } from "../src/types"; @@ -244,3 +245,42 @@ test("POST /apply leaves the reused server snapshot agreeing with disk", async ( }, deps, staleSnapshot); expect(persistedIntent()).toBeUndefined(); }); + +test("POST /apply rebases the Claude hand-edit guard after its scoped profile save", async () => { + const snapshot = { + ...config(), + claudeCode: { authMode: "subscription" as const, nativePassthrough: true }, + }; + writeFileSync(join(root, "config.json"), JSON.stringify(snapshot)); + armClaudeCodeBaseline(snapshot); + + const response = await dispatch("/api/claude-desktop/apply", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "static" }), + }, { + fetchAllModels: async () => [], + writeDesktop3pConfig: () => ({ written: true, path: join(library, "applied.json"), fingerprint: "fingerprint" }), + }, snapshot); + expect(response!.status).toBe(200); + + const handEdited = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + handEdited.claudeCode = { + ...handEdited.claudeCode, + authMode: "proxy", + nativePassthrough: false, + anthropicBaseUrl: "http://127.0.0.1:19999", + }; + writeFileSync(join(root, "config.json"), JSON.stringify(handEdited)); + + snapshot.disabledModels = ["unrelated/model"]; + saveConfigPreservingClaudeCode(snapshot); + + const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(saved.claudeCode).toMatchObject({ + authMode: "proxy", + nativePassthrough: false, + anthropicBaseUrl: "http://127.0.0.1:19999", + }); + expect(saved.disabledModels).toEqual(["unrelated/model"]); +});