diff --git a/src/config.ts b/src/config.ts index 0c63a168bad..3eeec635c64 100644 --- a/src/config.ts +++ b/src/config.ts @@ -134,7 +134,7 @@ export { } from "./config/mutation-lock"; export { armClaudeCodeBaseline, - adoptPersistedProviderIntoLiveConfig, + adoptPersistedClaudeCode, adoptPersistedProviderIntoLiveConfig, claudeCodeBaselineArmed, reconcileLiveConfigFromDisk, saveConfigPreservingClaudeCode, diff --git a/src/config/live-reconcile.ts b/src/config/live-reconcile.ts index 8715b1f146b..7ad249dcd6b 100644 --- a/src/config/live-reconcile.ts +++ b/src/config/live-reconcile.ts @@ -80,6 +80,42 @@ 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. + * + * The live subtree may already hold pending mutations a concurrent request + * assigned but has not saved yet — the Claude settings PUT yields between + * assigning `config.claudeCode` and saving. Adopt through the same three-way + * reconcile guarded saves use, so pending live leaves survive, disjoint + * committed changes merge in, and only the baseline moves wholesale to the + * committed subtree. + */ +export function adoptPersistedClaudeCode( + config: OcxConfig, + persistedClaudeCode: OcxConfig["claudeCode"], +): void { + const storedBaseline: ConfigMergeValue = claudeCodeBaseline.has(config) + ? claudeCodeBaseline.get(config) + : MISSING_CONFIG_VALUE; + const merged = reconcileConfigValue( + storedBaseline === undefined ? MISSING_CONFIG_VALUE : storedBaseline, + config.claudeCode === undefined ? MISSING_CONFIG_VALUE : config.claudeCode, + persistedClaudeCode === undefined ? MISSING_CONFIG_VALUE : persistedClaudeCode, + ); + if (merged === MISSING_CONFIG_VALUE) delete config.claudeCode; + else config.claudeCode = merged as OcxConfig["claudeCode"]; + const baseline = liveConfigBaseline.get(config); + if (baseline) baseline.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 91976c144fd..61ef8997b03 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -6,6 +6,7 @@ import { mergeModelPinnedEfforts, modelPinnedEffortsConfigError } from "../../co import { MULTI_AGENT_SURFACE_ADVISORY_VERSION, multiAgentSurfaceAdvisory, resolveMultiAgentMode } from "../../config/multi-agent-surface"; import { captureConfigTopLevelRollback, parsedConfigRebaseDeletionKeys, projectConfigRebaseProvenance } from "../../config/rebase-provenance"; import { + adoptPersistedClaudeCode, DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, deleteConfigTopLevelKey, @@ -122,12 +123,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 }; } @@ -136,9 +137,15 @@ async function persistDesktopModeField( desktopMode: "first-party" | "gateway", ): Promise<{ ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" }> { const { recordClaudeDesktopMode } = await import("../../claude/desktop-first-party"); - const outcome = mutatePersistedConfig(persisted => recordClaudeDesktopMode(persisted, desktopMode)); + const outcome = mutatePersistedConfig(persisted => { + const mutation = recordClaudeDesktopMode(persisted, desktopMode); + return { changed: mutation.changed, value: structuredClone(persisted.claudeCode) }; + }); if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason }; - recordClaudeDesktopMode(config, desktopMode); + // First-party apply ends here — no profile-marker write follows — so without + // adopting, live diverges from the armed baseline and a later whole-config + // save reads that divergence as a pending mutation and stomps hand edits. + adoptPersistedClaudeCode(config, outcome.value); return { ok: true }; } diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts index 7b391f98dae..e63e17aa8dd 100644 --- a/tests/claude-integration/claude-desktop-first-party.test.ts +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -10,6 +10,7 @@ import { resolveClaudeDesktopMode, } from "../../src/claude/desktop-first-party"; import { parseDesktopApplyArgs } from "../../src/cli/claude-desktop"; +import { armClaudeCodeBaseline, saveConfigPreservingClaudeCode } from "../../src/config"; import { ensureClaudeDesktopMatchesDesired } from "../../src/cli/ensure-desired-integrations"; import { handleManagementAPI } from "../../src/server/management-api"; import { setIntegrationEnabled } from "../../src/codex/desired-state"; @@ -243,6 +244,38 @@ test("ensure warns instead of touching a gateway profile that contradicts an exp expect(logs.some(line => line.includes("gateway profile is still applied"))).toBe(true); }); +test("first-party apply rebases the Claude hand-edit guard after its scoped mode save", async () => { + // First-party apply ends at the mode-marker write — no profile-marker save + // follows — so unless that write adopts its committed subtree, live diverges + // from the armed baseline and the next whole-config save stomps a hand edit. + const snapshot = config({ claudeCode: { authMode: "subscription" } }); + writeFileSync(join(root, "config.json"), JSON.stringify(snapshot)); + armClaudeCodeBaseline(snapshot); + + const applied = await dispatch("/api/claude-desktop/apply", { method: "POST" }, snapshot); + expect(applied.status).toBe(200); + expect(applied.body).toMatchObject({ mode: "first-party", saved: true }); + + const handEdited = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + handEdited.claudeCode = { + ...handEdited.claudeCode, + authMode: "proxy", + 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", + anthropicBaseUrl: "http://127.0.0.1:19999", + desktopMode: "first-party", + }); + expect(saved.disabledModels).toEqual(["unrelated/model"]); +}); + test("ensure reconciles first-party env: refreshes when ON and stale, removes when OFF", () => { const applied = applyDesktopFirstParty(config({ port: 10300 })); expect(applied.ok).toBe(true); diff --git a/tests/codex-integration/native-claude-desktop-toggle.test.ts b/tests/codex-integration/native-claude-desktop-toggle.test.ts index 56c28ca34da..ffe20377f0d 100644 --- a/tests/codex-integration/native-claude-desktop-toggle.test.ts +++ b/tests/codex-integration/native-claude-desktop-toggle.test.ts @@ -6,6 +6,7 @@ import { handleManagementAPI } from "../../src/server/management-api"; import { writeDesktop3pConfig, removeDesktop3pStandardPivot } from "../../src/claude/desktop-3p"; import { setIntegrationEnabled } from "../../src/codex/desired-state"; import { MANAGEMENT_JSON_BODY_MAX_BYTES } from "../../src/server/management/body"; +import { armClaudeCodeBaseline, saveConfigPreservingClaudeCode } from "../../src/config"; import type { ManagementApiDeps } from "../../src/server/management/context"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -418,3 +419,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"]); +}); diff --git a/tests/config/config-user-edits.test.ts b/tests/config/config-user-edits.test.ts index 38ac41aa378..8aef62d2073 100644 --- a/tests/config/config-user-edits.test.ts +++ b/tests/config/config-user-edits.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { armClaudeCodeBaseline, + adoptPersistedClaudeCode, adoptPersistedProviderIntoLiveConfig, deleteConfigTopLevelKey, getConfigPath, @@ -574,6 +575,22 @@ test("our own change wins a conflict and rebases the baseline", () => { expect((diskConfig().claudeCode as Record).authMode).toBe("proxy"); }); +// A scoped Desktop write commits against the file, then adopts the committed +// subtree. A live mutation still pending — a Claude settings PUT yields between +// assigning `config.claudeCode` and saving — must survive the adoption and reach +// the next save instead of being silently replaced. +test("a scoped Claude write keeps a pending live Claude edit", () => { + const live = loadConfig(); + armClaudeCodeBaseline(live); + live.claudeCode = { ...(live.claudeCode ?? {}), authMode: "proxy" }; + + adoptPersistedClaudeCode(live, { authMode: "subscription", desktopMode: "first-party" }); + + expect(live.claudeCode).toMatchObject({ authMode: "proxy", desktopMode: "first-party" }); + saveConfigPreservingClaudeCode(live); + expect(diskConfig().claudeCode).toEqual({ authMode: "proxy", desktopMode: "first-party" }); +}); + test("OAuth reconciliation keeps a pending live Claude subtree authoritative", () => { const live = loadConfig(); armClaudeCodeBaseline(live);