From 4b901894bfe761d6c4185b8090e85dbf4927f627 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sun, 20 Sep 2026 21:12:05 +0900 Subject: [PATCH 1/6] fix(claude,server): keep Desktop applied-state markers honest across edits - Native launch fallback: strip CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST only when the admission credential is ours, so a user-owned gateway keeps its guard instead of losing it while the credential stays. - Desktop profile rebuilds: carry appliedFingerprint/appliedAt only when the desired profile is unchanged, so a saved edit no longer reports the old on-disk config as applied. The management PUT discards client-supplied markers and restores the server-owned ones only for an identical result. - ocx sync: persist the fingerprint of the exact Desktop config just written through the config-mutation lock, so the applied marker advances with the bytes instead of going stale. (cherry picked from commit 3254286503f4c06a05c6aac2feadd9b4821977bb) (cherry picked from commit 9b81802d349220043e8956929bfd545b0927c86a) --- src/claude/desktop-profile.ts | 34 ++++++++++++------- src/cli/claude.ts | 4 ++- .../management/agent-settings-routes.ts | 13 +++++-- src/server/management/config-routes.ts | 26 ++++++++++++-- tests/claude-integration/claude-cli.test.ts | 4 ++- .../claude-management-api.test.ts | 32 +++++++++++++++++ tests/clients/desktop-profile.test.ts | 33 ++++++++++-------- .../clients/sync-client-integrations.test.ts | 11 +++--- 8 files changed, 117 insertions(+), 40 deletions(-) diff --git a/src/claude/desktop-profile.ts b/src/claude/desktop-profile.ts index e35b74cb464..4bcec6ea05c 100644 --- a/src/claude/desktop-profile.ts +++ b/src/claude/desktop-profile.ts @@ -77,15 +77,6 @@ function assertExactKeys(value: Record, keys: readonly string[] } } -/** - * Applied-state markers survive every profile rebuild. - * - * `parseDesktopProfile`, `reconcileDesktopProfile` and `moveDesktopRoute` each construct a - * fresh `{ version, assignments, defaults }`, and the management routes persist whatever they - * return. Without this carry-through, saving an assignment — or merely dragging a model to - * another family — would erase the fingerprint the apply route wrote, and the GUI would report - * "not applied" for a config that is applied on disk. - */ function appliedMarkers(source: { appliedFingerprint?: unknown; appliedAt?: unknown }): { appliedFingerprint?: string; appliedAt?: string; @@ -96,6 +87,19 @@ function appliedMarkers(source: { appliedFingerprint?: unknown; appliedAt?: unkn }; } +function sameProfileContent(left: DesktopProfile, right: DesktopProfile): boolean { + return JSON.stringify(left.defaults) === JSON.stringify(right.defaults) + && JSON.stringify(Object.entries(left.assignments).sort(([a], [b]) => a.localeCompare(b))) + === JSON.stringify(Object.entries(right.assignments).sort(([a], [b]) => a.localeCompare(b))); +} + +/** Retain applied-state bookkeeping only when the desired Desktop config is unchanged. */ +export function preserveDesktopAppliedState(source: DesktopProfile, rebuilt: DesktopProfile): DesktopProfile { + return sameProfileContent(source, rebuilt) + ? { ...rebuilt, ...appliedMarkers(source) } + : rebuilt; +} + function isFamily(value: unknown): value is DesktopFamily { return typeof value === "string" && (DESKTOP_FAMILIES as readonly string[]).includes(value); } @@ -244,7 +248,8 @@ export function reconcileDesktopProfile( const current = defaults[family]; defaults[family] = current && assignments[current]?.family === family ? current : (members[0] ?? null); } - return parseDesktopProfile({ version: 1, assignments, defaults, ...appliedMarkers(profile) }); + const rebuilt = parseDesktopProfile({ version: 1, assignments, defaults }); + return preserveDesktopAppliedState(profile, rebuilt); } export function moveDesktopRoute( @@ -268,7 +273,7 @@ export function moveDesktopRoute( const destinationMembers = Object.keys(assignments).filter(key => assignments[key]!.family === family).sort(); if (makeDefault || !defaults[family] || assignments[defaults[family]!]?.family !== family) defaults[family] = route; if (!defaults[family] && destinationMembers.length > 0) defaults[family] = destinationMembers[0]!; - return parseDesktopProfile({ version: 1, assignments, defaults, ...appliedMarkers(parsed) }); + return parseDesktopProfile({ version: 1, assignments, defaults }); } export function setDesktopFamilyDefault( @@ -280,7 +285,12 @@ export function setDesktopFamilyDefault( const members = Object.keys(parsed.assignments).filter(key => parsed.assignments[key]!.family === family); if (route === null && members.length > 0) throw new DesktopProfileError("cannot clear a non-empty family default", `profile.defaults.${family}`); if (route !== null && parsed.assignments[route]?.family !== family) throw new DesktopProfileError("route is not a member of this family", `profile.defaults.${family}`); - return parseDesktopProfile({ ...parsed, defaults: { ...parsed.defaults, [family]: route } }); + const rebuilt = parseDesktopProfile({ + version: 1, + assignments: parsed.assignments, + defaults: { ...parsed.defaults, [family]: route }, + }); + return preserveDesktopAppliedState(parsed, rebuilt); } export function renderDesktopProfile( diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 3daec5744c9..846a8c921e6 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -572,7 +572,6 @@ export function claudeLaunchPreflight( */ const NATIVE_STRIPPED_LEVERS = [ "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", - "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", "CLAUDE_CODE_MAX_CONTEXT_TOKENS", "CLAUDE_CODE_AUTO_COMPACT_WINDOW", "CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", @@ -632,6 +631,9 @@ export function buildNativeClaudeEnv( } for (const name of NATIVE_STRIPPED_LEVERS) delete env[name]; + // An explicit caller-owned guard must follow a caller-owned gateway and credential; + // otherwise settings.env can replace the destination while retaining the credential. + if (hasOwnedAdmission) delete env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST; const providerNames = Object.keys(config.providers); for (const name of MODEL_ENV_SLOT_NAMES) { const value = env[name]; diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index d15b5117e3b..845a754474c 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -985,7 +985,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise let body: { profile?: unknown }; try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } try { - const { parseDesktopProfile, reconcileDesktopProfile } = await import("../../claude/desktop-profile"); + const { parseDesktopProfile, preserveDesktopAppliedState, reconcileDesktopProfile } = await import("../../claude/desktop-profile"); const parsed = parseDesktopProfile(body.profile); const current = await buildClaudeDesktopState(config); const availableRoutes = new Set(current.models.filter(item => item.available).map(item => item.route)); @@ -1008,8 +1008,15 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise throw new Error(`현재 사용할 수 없는 모델은 기본값으로 지정할 수 없습니다: ${nextDefault}`); } } - const state = await buildClaudeDesktopState(config, parsed); - config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: reconcileDesktopProfile(state.profile, state.models) }; + // Applied markers are server-owned bookkeeping. Discard client copies, then + // restore the trusted markers only if the desired profile stayed identical. + const editable = { version: 1 as const, assignments: parsed.assignments, defaults: parsed.defaults }; + const state = await buildClaudeDesktopState(config, editable); + const rebuilt = reconcileDesktopProfile(state.profile, state.models); + config.claudeCode = { + ...(config.claudeCode ?? {}), + desktopProfile: preserveDesktopAppliedState(current.profile, rebuilt), + }; saveConfigPreservingClaudeCode(config); const saved = await buildClaudeDesktopState(config); const runtimePort = Number(url.port) || config.port; diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 6db5ca3f35c..b45f8803a04 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -18,6 +18,7 @@ import { isValidProviderName, loadConfig, multiAgentGuidanceEnabled, + mutatePersistedConfig, providerBaseUrlConfigError, providerHeadersConfigError, saveConfigPreservingClaudeCode, @@ -228,9 +229,28 @@ export async function syncEnabledClientIntegrations( latest.claudeCode?.desktopProfile, nativeContextLimits(latest), ); - out.push(r.written - ? { client: "claude-desktop", ok: true, changed: true } - : { client: "claude-desktop", ok: false, reason: r.reason ?? "Claude Desktop write failed" }); + if (!r.written || !r.fingerprint) { + out.push({ client: "claude-desktop", ok: false, reason: r.reason ?? "Claude Desktop write failed" }); + } else { + const { emptyDesktopProfile } = await import("../../claude/desktop-profile"); + const marked = mutatePersistedConfig(persisted => { + const profile = persisted.claudeCode?.desktopProfile + ?? latest.claudeCode?.desktopProfile + ?? emptyDesktopProfile(); + persisted.claudeCode = { + ...(persisted.claudeCode ?? {}), + desktopProfile: { + ...profile, + appliedFingerprint: r.fingerprint, + appliedAt: new Date().toISOString(), + }, + }; + return { changed: true, value: true }; + }); + out.push(marked.status === "unavailable" + ? { client: "claude-desktop", ok: false, reason: `Claude Desktop applied marker was not saved (${marked.reason})` } + : { client: "claude-desktop", ok: true, changed: true }); + } } } catch (error) { out.push({ client: "claude-desktop", ok: false, reason: error instanceof Error ? error.message : String(error) }); diff --git a/tests/claude-integration/claude-cli.test.ts b/tests/claude-integration/claude-cli.test.ts index 900ebea069c..6e73cc8ed0f 100644 --- a/tests/claude-integration/claude-cli.test.ts +++ b/tests/claude-integration/claude-cli.test.ts @@ -126,17 +126,19 @@ describe("ocx claude native fallback", () => { expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined(); }); - test("preserves an unrelated loopback gateway and its user credential", () => { + test("preserves an unrelated gateway, its user credential, and its host-managed guard", () => { for (const baseUrl of ["http://localhost:8080", "http://127.0.0.1:10100"]) { const env = buildNativeClaudeEnv(cfg({ port: 10100 }), { ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_API_KEY: "sk-ant-user-key", + CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1", }, { preBunAnthropicSlots: ["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"], }); expect(env.ANTHROPIC_BASE_URL).toBe(baseUrl); expect(env.ANTHROPIC_API_KEY).toBe("sk-ant-user-key"); + expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe("1"); } }); diff --git a/tests/claude-integration/claude-management-api.test.ts b/tests/claude-integration/claude-management-api.test.ts index 751b838ee99..8c4a7994b16 100644 --- a/tests/claude-integration/claude-management-api.test.ts +++ b/tests/claude-integration/claude-management-api.test.ts @@ -875,6 +875,38 @@ test("Claude Desktop PUT rejects invalid JSON profile without mutating saved con } }); +test("Claude Desktop PUT clears applied markers when routing changes", async () => { + const server = startServer(0); + try { + const apply = await fetch(new URL("/api/claude-desktop/apply", server.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "static" }), + }); + expect(apply.status).toBe(200); + expect(loadConfig().claudeCode?.desktopProfile?.appliedFingerprint).toBeString(); + + const state = await fetch(new URL("/api/claude-desktop", server.url)).then(r => r.json()) as Record; + const edited = structuredClone(state.profile); + edited.assignments["mock/test-model"].family = "sonnet"; + edited.defaults.opus = Object.keys(edited.assignments) + .filter(route => edited.assignments[route].family === "opus") + .sort()[0] ?? null; + edited.defaults.sonnet = "mock/test-model"; + + const put = await fetch(new URL("/api/claude-desktop", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: edited }), + }); + expect(put.status).toBe(200); + expect(loadConfig().claudeCode?.desktopProfile).not.toHaveProperty("appliedFingerprint"); + expect(loadConfig().claudeCode?.desktopProfile).not.toHaveProperty("appliedAt"); + } finally { + await server.stop(true); + } +}); + test("Claude Desktop PUT retains but cannot move an unavailable route", async () => { const seeded = loadConfig(); seeded.claudeCode = { diff --git a/tests/clients/desktop-profile.test.ts b/tests/clients/desktop-profile.test.ts index 7aa7cdda7e1..82725d65a70 100644 --- a/tests/clients/desktop-profile.test.ts +++ b/tests/clients/desktop-profile.test.ts @@ -120,10 +120,8 @@ describe("Claude Desktop profile", () => { }); // The apply route writes `appliedFingerprint`/`appliedAt` back onto the stored profile so the - // GUI can show applied-vs-saved state. Every rebuild in this module must accept AND carry them: - // rejecting them broke the Desktop tab outright after the first apply, and silently dropping - // them would make a saved edit — or a single drag between families — report "not applied" for a - // config that is applied on disk. + // GUI can show applied-vs-saved state. Parsing and no-op rebuilds retain them, while a change to + // the desired Desktop config must clear them so the old on-disk config is not reported as current. describe("applied-state markers", () => { const applied = { appliedFingerprint: "0123456789abcdef", @@ -140,23 +138,28 @@ describe("Claude Desktop profile", () => { expect(parsed.appliedAt).toBe(applied.appliedAt); }); - test("reconcileDesktopProfile keeps them across a catalog change", () => { + test("reconcileDesktopProfile clears them across a catalog change", () => { const next = reconcileDesktopProfile(seeded(), [...models, { route: "test/new-model", label: "New" }]); - expect(next.appliedFingerprint).toBe(applied.appliedFingerprint); - expect(next.appliedAt).toBe(applied.appliedAt); + expect(next).not.toHaveProperty("appliedFingerprint"); + expect(next).not.toHaveProperty("appliedAt"); }); - test("moveDesktopRoute keeps them — the drag-and-drop path", () => { - const moved = moveDesktopRoute(seeded(), "cursor/gpt-5.6-luna", "sonnet"); - expect(moved.appliedFingerprint).toBe(applied.appliedFingerprint); - expect(moved.appliedAt).toBe(applied.appliedAt); + test("reconcileDesktopProfile keeps them when profile content is unchanged", () => { + expect(reconcileDesktopProfile(seeded(), models)).toMatchObject(applied); }); - test("setDesktopFamilyDefault keeps them", () => { + test("moveDesktopRoute clears them — the drag-and-drop path", () => { const moved = moveDesktopRoute(seeded(), "cursor/gpt-5.6-luna", "sonnet"); - const next = setDesktopFamilyDefault(moved, "sonnet", "cursor/gpt-5.6-luna"); - expect(next.appliedFingerprint).toBe(applied.appliedFingerprint); - expect(next.appliedAt).toBe(applied.appliedAt); + expect(moved).not.toHaveProperty("appliedFingerprint"); + expect(moved).not.toHaveProperty("appliedAt"); + }); + + test("setDesktopFamilyDefault clears them only when the default changes", () => { + const changed = setDesktopFamilyDefault(seeded(), "opus", "native/gpt-5.6-sol"); + expect(changed).not.toHaveProperty("appliedFingerprint"); + expect(changed).not.toHaveProperty("appliedAt"); + expect(setDesktopFamilyDefault(seeded(), "opus", "anthropic/claude-fable-5")) + .toMatchObject(applied); }); test("a profile without the markers stays without them", () => { diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index 219a1480829..53f564f408c 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -69,11 +69,12 @@ describe("ocx sync fans out to enabled native clients and owned file integration expect(fn).toContain("refreshOwnedCatalogIntegrations"); // Native clients keep their catches; the owned catalog helper isolates file clients. expect(fn.match(/catch \(error\)/g)?.length).toBe(2); - // The Desktop write gets the native context limits, same as every other Desktop - // call site. 8b672205e threaded `nativeContextLimits` through those writers and - // left this assertion naming the retired `providerContextCap` spelling, so the - // source-shape check failed against the very change it is meant to pin. expect(fn).toContain("nativeContextLimits(latest)"); + // Cleanup accepts only the fingerprint of the exact credential-bearing profile we wrote. + // Sync must durably advance that ownership marker rather than leaving the old value behind. + expect(fn).toContain("mutatePersistedConfig(persisted =>"); + expect(fn).toContain("appliedFingerprint: r.fingerprint"); + expect(fn.indexOf("nativeContextLimits(latest)")).toBeLessThan(fn.indexOf("appliedFingerprint: r.fingerprint")); // A client that is off is omitted rather than reported: the caller has to be able to // tell "left alone" from "tried and failed", so there is no skipped state to emit. expect(fn).not.toContain('"skipped"'); @@ -144,7 +145,7 @@ describe("Desktop sync rechecks persisted state after discovery", () => { writes.push(args); return outcome === "refusal" ? { written: false, path: "fixture", reason: "desktop_remote_store_active" } - : { written: true, path: "fixture" }; + : { written: true, path: "fixture", fingerprint: "0123456789abcdef" }; }, }); try { From f3548ee637004c661b8e72090052934746f8180b Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:05:08 +0900 Subject: [PATCH 2/6] fix(claude): refuse a stale applied marker when the desired profile drifted mid-sync The carried sync stamps the fingerprint of the Desktop bytes it just wrote onto the persisted profile under the config-mutation lock. If another writer saved a different desired profile between the Desktop write and the marker commit, the new profile would claim the old bytes as applied. The callback now compares the persisted profile's desired content with the profile the write actually used and skips the marker when they differ, reporting the skip instead of a clean change. Unchanged profiles still store the fingerprint. Co-authored-by: luvs01 (cherry picked from commit 4992a7740322636483dc18063c3f7393719b3abc) --- src/claude/desktop-profile.ts | 2 +- src/server/management/config-routes.ts | 19 +++- .../clients/sync-client-integrations.test.ts | 104 ++++++++++++++++++ 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/src/claude/desktop-profile.ts b/src/claude/desktop-profile.ts index 4bcec6ea05c..97b136a6925 100644 --- a/src/claude/desktop-profile.ts +++ b/src/claude/desktop-profile.ts @@ -87,7 +87,7 @@ function appliedMarkers(source: { appliedFingerprint?: unknown; appliedAt?: unkn }; } -function sameProfileContent(left: DesktopProfile, right: DesktopProfile): boolean { +export function sameProfileContent(left: DesktopProfile, right: DesktopProfile): boolean { return JSON.stringify(left.defaults) === JSON.stringify(right.defaults) && JSON.stringify(Object.entries(left.assignments).sort(([a], [b]) => a.localeCompare(b))) === JSON.stringify(Object.entries(right.assignments).sort(([a], [b]) => a.localeCompare(b))); diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index b45f8803a04..d5ef14e37ea 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -232,15 +232,20 @@ export async function syncEnabledClientIntegrations( if (!r.written || !r.fingerprint) { out.push({ client: "claude-desktop", ok: false, reason: r.reason ?? "Claude Desktop write failed" }); } else { - const { emptyDesktopProfile } = await import("../../claude/desktop-profile"); + const { emptyDesktopProfile, sameProfileContent } = await import("../../claude/desktop-profile"); + // The fingerprint belongs to the desired profile the write just used. If another + // writer saved a different desired profile between the Desktop write and this marker + // commit, stamping it would claim B is applied while the disk holds A's bytes. + const writtenProfile = latest.claudeCode?.desktopProfile ?? emptyDesktopProfile(); const marked = mutatePersistedConfig(persisted => { - const profile = persisted.claudeCode?.desktopProfile - ?? latest.claudeCode?.desktopProfile - ?? emptyDesktopProfile(); + const profile = persisted.claudeCode?.desktopProfile; + if (profile && !sameProfileContent(profile, writtenProfile)) { + return { changed: false, value: false }; + } persisted.claudeCode = { ...(persisted.claudeCode ?? {}), desktopProfile: { - ...profile, + ...(profile ?? writtenProfile), appliedFingerprint: r.fingerprint, appliedAt: new Date().toISOString(), }, @@ -248,7 +253,9 @@ export async function syncEnabledClientIntegrations( return { changed: true, value: true }; }); out.push(marked.status === "unavailable" - ? { client: "claude-desktop", ok: false, reason: `Claude Desktop applied marker was not saved (${marked.reason})` } + ? { client: "claude-desktop", ok: false, reason: "Claude Desktop applied marker was not saved (" + marked.reason + ")" } + : marked.value === false + ? { client: "claude-desktop", ok: false, reason: "Claude Desktop desired profile changed during sync; applied marker skipped" } : { client: "claude-desktop", ok: true, changed: true }); } } diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index 53f564f408c..3840f2bf0b9 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -202,6 +202,110 @@ describe("Desktop sync rechecks persisted state after discovery", () => { } }); } + test("a desired-profile change during the Desktop write keeps the new profile without the old fingerprint", async () => { + // The marker commit must not stamp the fingerprint of the profile whose bytes were + // written (A) onto a different desired profile (B) saved by a concurrent writer while + // the Desktop write was in flight. B stays persisted and the sync reports the skip. + const profileA = { + version: 1 as const, + assignments: { "mock/hidden": { family: "opus" as const, alias: "claude-opus-4-8-20260201" } }, + defaults: { opus: "mock/hidden", fable: null, sonnet: null, haiku: null }, + }; + const profileB = { + version: 1 as const, + assignments: { "mock/keep": { family: "sonnet" as const, alias: "claude-opus-4-8-20260202" } }, + defaults: { opus: null, fable: null, sonnet: "mock/keep", haiku: null }, + }; + const config: OcxConfig = { + port: 10100, + defaultProvider: "mock", + clientIntegrations: { grok: false }, + providers: { + mock: { adapter: "openai-chat", baseUrl: "https://example.test/v1", models: ["keep", "hidden"] }, + openai: { adapter: "openai-responses", baseUrl: "https://example.test/v1", contextWindow: 400_000 }, + }, + apiKeys: [{ id: "sync-key", name: "fixture", key: "ocx_old_sync_fixture", createdAt: "2026-01-01T00:00:00.000Z" }], + claudeCode: { desktopProfile: profileA }, + }; + writeFileSync(join(root, "config.json"), JSON.stringify(config)); + const models: CatalogModel[] = [ + { provider: "mock", id: "keep", contextWindow: 123_000 }, + { provider: "mock", id: "hidden", contextWindow: 456_000 }, + ]; + const writes: Parameters[] = []; + const realRefresh = ownedRefresh.refreshOwnedIntegration; + const refresh = spyOn(ownedRefresh, "refreshOwnedIntegration").mockImplementation((input, options) => + input.clientId === "mcode" + ? Promise.resolve({ client: "mcode", ok: true, changed: true }) + : realRefresh(input, options)); + const aside = spyOn(asideProfiles, "refreshAsideProfiles").mockResolvedValue([]); + try { + const results = await syncEnabledClientIntegrations(12345, config, { + fetchAllModels: async () => models, + writeDesktop3pConfig: (...args) => { + writes.push(args); + // A concurrent writer saves desired profile B while the Desktop write is in flight. + const drifted = structuredClone(config); + drifted.claudeCode = { desktopProfile: profileB }; + writeFileSync(join(root, "config.json"), JSON.stringify(drifted)); + return { written: true, path: "fixture", fingerprint: "0123456789abcdef" }; + }, + }); + expect(writes).toHaveLength(1); + const outcome = results.find(result => result.client === "claude-desktop"); + expect(outcome?.ok).toBe(false); + expect(outcome?.reason).toContain("desired profile changed during sync"); + const persisted = JSON.parse(readFileSync(join(root, "config.json"), "utf8")); + expect(persisted.claudeCode.desktopProfile).toEqual(profileB); + } finally { + refresh.mockRestore(); + aside.mockRestore(); + } + }); + + test("an unchanged desired profile still stores the written fingerprint", async () => { + const profileA = { + version: 1 as const, + assignments: { "mock/hidden": { family: "opus" as const, alias: "claude-opus-4-8-20260201" } }, + defaults: { opus: "mock/hidden", fable: null, sonnet: null, haiku: null }, + }; + const config: OcxConfig = { + port: 10100, + defaultProvider: "mock", + clientIntegrations: { grok: false }, + providers: { + mock: { adapter: "openai-chat", baseUrl: "https://example.test/v1", models: ["keep", "hidden"] }, + openai: { adapter: "openai-responses", baseUrl: "https://example.test/v1", contextWindow: 400_000 }, + }, + apiKeys: [{ id: "sync-key", name: "fixture", key: "ocx_old_sync_fixture", createdAt: "2026-01-01T00:00:00.000Z" }], + claudeCode: { desktopProfile: profileA }, + }; + writeFileSync(join(root, "config.json"), JSON.stringify(config)); + const models: CatalogModel[] = [ + { provider: "mock", id: "keep", contextWindow: 123_000 }, + { provider: "mock", id: "hidden", contextWindow: 456_000 }, + ]; + const realRefresh = ownedRefresh.refreshOwnedIntegration; + const refresh = spyOn(ownedRefresh, "refreshOwnedIntegration").mockImplementation((input, options) => + input.clientId === "mcode" + ? Promise.resolve({ client: "mcode", ok: true, changed: true }) + : realRefresh(input, options)); + const aside = spyOn(asideProfiles, "refreshAsideProfiles").mockResolvedValue([]); + try { + const results = await syncEnabledClientIntegrations(12345, config, { + fetchAllModels: async () => models, + writeDesktop3pConfig: () => ({ written: true, path: "fixture", fingerprint: "0123456789abcdef" }), + }); + expect(results.find(result => result.client === "claude-desktop")).toEqual({ client: "claude-desktop", ok: true, changed: true }); + const persisted = JSON.parse(readFileSync(join(root, "config.json"), "utf8")); + expect(persisted.claudeCode.desktopProfile.appliedFingerprint).toBe("0123456789abcdef"); + expect(persisted.claudeCode.desktopProfile.assignments).toEqual(profileA.assignments); + } finally { + refresh.mockRestore(); + aside.mockRestore(); + } + }); + }); describe("ocx sync refreshes an already-owned MCode integration", () => { From 02414cc1b94640dc748db2ff86b85029eb31e28c Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 21 Sep 2026 02:36:13 +0000 Subject: [PATCH 3/6] fix(claude-desktop): explain pre-apply gateway state (cherry picked from commit 7597bcea4a8f63fd6be072a8108567d2d22550ff) (cherry picked from commit 0abd90ea579c1d59ee7dcb666ef8037a986c21a5) --- src/cli/claude-desktop.ts | 5 +++-- tests/claude-integration/claude-desktop-cli.test.ts | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 5baaec6f995..e637ba00d96 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -398,7 +398,8 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf const applyInvocation = argv.length === 0 || command === "apply" || applyFlags.length > 0; if (applyInvocation) { const rest = argv.filter(arg => arg !== "apply"); - const parsedTarget = parseDesktopApplyArgs(rest, loadConfig()); + const preApplyConfig = loadConfig(); + const parsedTarget = parseDesktopApplyArgs(rest, preApplyConfig); if ("error" in parsedTarget) { console.error(parsedTarget.error); return 2; } const { target } = parsedTarget; try { @@ -424,7 +425,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf console.log(`Claude Desktop gateway 설정을 적용했습니다: ${result.path}`); for (const line of gatewayModeExplanation({ requestedExplicitly: applyFlags.some(flag => flag !== "--first-party"), - config: loadConfig(), + config: preApplyConfig, })) { console.log(line); } diff --git a/tests/claude-integration/claude-desktop-cli.test.ts b/tests/claude-integration/claude-desktop-cli.test.ts index a33026bf349..2d87d332b74 100644 --- a/tests/claude-integration/claude-desktop-cli.test.ts +++ b/tests/claude-integration/claude-desktop-cli.test.ts @@ -508,12 +508,16 @@ test("apply writes locally only when no proxy is running", async () => { }); test("no-arg and legacy mode flags apply Desktop config", async () => { + const config = loadConfig(); + config.claudeCode = { intercept: { enabled: false } }; + saveConfig(config); const log = spyOn(console, "log").mockImplementation(() => {}); const error = spyOn(console, "error").mockImplementation(() => {}); try { // Deterministic: no live proxy in the test environment, so apply writes locally. const noProxy = { findLiveProxyImpl: async () => null }; expect(await handleClaudeDesktopCommand([], noProxy)).toBe(0); + expect(log.mock.calls.flat().join(" ")).not.toContain("ocx claude desktop apply --first-party"); expect(await handleClaudeDesktopCommand(["--static"], noProxy)).toBe(0); expect(readFileSync(join(process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR!, "_meta.json"), "utf8")).toContain("opencodex"); expect(error).not.toHaveBeenCalled(); From 2eef89e1c781ba1a1ad4c57fb99e5e9ef818b9f3 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:59:58 +0900 Subject: [PATCH 4/6] test(claude-desktop): bound the CLI fixture's host-side work Four cases sat at the 5s timeout because fixture placement paid for work no assertion inspects: saveConfig's mutation-lock and ACL subprocess per write, and buildClaudeDesktopState's runtime probe plus bundled-catalog subprocess. Place fixture config bytes directly, seed the runtime/bundled caches through the existing test seams, and split the no-arg and --static cases into their own rows. Assertions, flags, ACL hardening on verified paths, and the 5s limit are unchanged. --- .../claude-desktop-cli.test.ts | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/tests/claude-integration/claude-desktop-cli.test.ts b/tests/claude-integration/claude-desktop-cli.test.ts index 2d87d332b74..77d74d6eca5 100644 --- a/tests/claude-integration/claude-desktop-cli.test.ts +++ b/tests/claude-integration/claude-desktop-cli.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { applyProfile as applyProfileProduction, handleClaudeDesktopCommand as handleClaudeDesktopCommandProduction, type ApplyProfileDeps } from "../../src/cli/claude-desktop"; import * as managementApi from "../../src/server/management-api"; import { buildClaudeDesktopState } from "../../src/server/management-api"; @@ -12,6 +12,8 @@ import * as lifecycleLock from "../../src/client/lifecycle-lock"; import { readClientConnectionState, clearClientConnection } from "../../src/client/state"; import { HubClientError } from "../../src/client/hub-client"; import { claudeDesktopIntegrationEnabledNow, setIntegrationEnabled } from "../../src/codex/desired-state"; +import { resetCodexRuntimeResolveCacheForTests, setCodexRuntimeResolveCacheForTests } from "../../src/codex/runtime"; +import { resetBundledCatalogCacheForTests, setBundledCatalogCacheForTests } from "../../src/codex/catalog/bundled"; import { serviceApiTokenBackupPath, serviceApiTokenFilePath, writeServiceApiTokenFile } from "../../src/lib/service-secrets"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -27,13 +29,30 @@ const applyProfile = (profile: Parameters[0], mod const handleClaudeDesktopCommand = (args: string[], deps: ApplyProfileDeps = {}) => handleClaudeDesktopCommandProduction(args, { lifecycleLockDeps: fixtureLock(), ...deps }); +// Fixture-stage config placement only: the verified writers under test still run +// saveConfig, but arranging a fixture through it pays the mutation-lock and ACL +// subprocess cost (~0.5-1s on Windows) for state no assertion inspects. +function writeFixtureConfig(config: OcxConfig): void { + const path = getConfigPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(config), { mode: 0o600 }); +} + beforeEach(() => { previousHome = process.env.OPENCODEX_HOME; previousDesktopDir = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; dir = mkdtempSync(join(tmpdir(), "ocx-desktop-cli-")); process.env.OPENCODEX_HOME = join(dir, "ocx"); process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(dir, "desktop"); - saveConfig({ + // Keep host Codex work out of the fixture: a real runtime probe plus the + // bundled-catalog subprocess cost ~1s per buildClaudeDesktopState call on this + // path, while the tests only need a deterministic catalog projection. + setCodexRuntimeResolveCacheForTests( + { runtime: { command: "codex", version: null, source: "fallback" }, failures: [] }, + { discoverAlternatives: false }, + ); + setBundledCatalogCacheForTests({ command: "codex", version: null }, null); + writeFixtureConfig({ port: 10100, defaultProvider: "mock", providers: { @@ -45,6 +64,8 @@ beforeEach(() => { afterEach(() => { restoreLocalBuild?.(); restoreLocalBuild = undefined; + resetBundledCatalogCacheForTests(); + resetCodexRuntimeResolveCacheForTests(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousDesktopDir === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; @@ -66,7 +87,7 @@ function connectDesktopFixture(blockLocalBuild = true): void { selectedClients: ["codex"], tokenEnv: "OPENCODEX_API_AUTH_TOKEN", apiKeyId: "desktop-key", tokenFingerprint: fingerprint, protocolVersion: 1, connectedAt: "2026-09-06T00:00:00.000Z", }; - saveConfig(config); + writeFixtureConfig(config); expect(readClientConnectionState().kind).toBe("connected"); if (blockLocalBuild) { const spy = spyOn(managementApi, "buildClaudeDesktopState").mockImplementation(async () => { @@ -91,7 +112,8 @@ test.each([ ["--static", "static"], ["--hybrid", "hybrid"], ["--discovery-only", "discovery"], ] as const)("connected CLI %s applies exact hub IDs without local reconciliation", async (flag, mode) => { connectDesktopFixture(); - setIntegrationEnabled("claude-desktop", false); + // Fixture placement of the disabled switch; apply itself must flip it back on. + writeFixtureConfig({ ...loadConfig(), clientIntegrations: { "claude-desktop": false } }); const log = spyOn(console, "log").mockImplementation(() => {}); const warn = spyOn(console, "warn").mockImplementation(() => {}); const error = spyOn(console, "error").mockImplementation(() => {}); @@ -507,18 +529,19 @@ test("apply writes locally only when no proxy is running", async () => { expect(existsSync(join(dir, "desktop"))).toBe(true); }); -test("no-arg and legacy mode flags apply Desktop config", async () => { +test.each([{ args: [] as string[] }, { args: ["--static"] }])("no-arg and legacy mode flags apply Desktop config: $args", async ({ args }) => { const config = loadConfig(); config.claudeCode = { intercept: { enabled: false } }; - saveConfig(config); + writeFixtureConfig(config); const log = spyOn(console, "log").mockImplementation(() => {}); const error = spyOn(console, "error").mockImplementation(() => {}); try { // Deterministic: no live proxy in the test environment, so apply writes locally. const noProxy = { findLiveProxyImpl: async () => null }; - expect(await handleClaudeDesktopCommand([], noProxy)).toBe(0); - expect(log.mock.calls.flat().join(" ")).not.toContain("ocx claude desktop apply --first-party"); - expect(await handleClaudeDesktopCommand(["--static"], noProxy)).toBe(0); + expect(await handleClaudeDesktopCommand(args, noProxy)).toBe(0); + if (args.length === 0) { + expect(log.mock.calls.flat().join(" ")).not.toContain("ocx claude desktop apply --first-party"); + } expect(readFileSync(join(process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR!, "_meta.json"), "utf8")).toContain("opencodex"); expect(error).not.toHaveBeenCalled(); } finally { From d9339151782be62737cc6fdfbebbd05e6b967582 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 23 Sep 2026 04:14:43 +0900 Subject: [PATCH 5/6] fix(management): decline applied-marker commit on concurrent desktop profile removal The sync route compared the persisted desired profile against a normalized empty profile when the desired profile had been deleted between the write and the marker commit, so a concurrent delete or empty-profile write still passed the presence guard and resurrected the profile with a fresh fingerprint. Compare presence first, then content, and decline the marker write when the persisted profile no longer matches what was written. --- src/server/management/config-routes.ts | 13 ++- structure/clients/claude-desktop.md | 8 ++ .../clients/sync-client-integrations.test.ts | 105 ++++++++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index d5ef14e37ea..77e7dec61f0 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -236,16 +236,23 @@ export async function syncEnabledClientIntegrations( // The fingerprint belongs to the desired profile the write just used. If another // writer saved a different desired profile between the Desktop write and this marker // commit, stamping it would claim B is applied while the disk holds A's bytes. - const writtenProfile = latest.claudeCode?.desktopProfile ?? emptyDesktopProfile(); + const writtenProfile = latest.claudeCode?.desktopProfile; const marked = mutatePersistedConfig(persisted => { const profile = persisted.claudeCode?.desktopProfile; - if (profile && !sameProfileContent(profile, writtenProfile)) { + // Presence first: a concurrent delete (of the profile or the whole claudeCode + // subtree) must not resurrect the written profile under a fresh fingerprint, + // and a concurrent insert must not inherit it either. Content is compared only + // when both sides carry a profile. + if ((profile == null) !== (writtenProfile == null)) { + return { changed: false, value: false }; + } + if (profile && writtenProfile && !sameProfileContent(profile, writtenProfile)) { return { changed: false, value: false }; } persisted.claudeCode = { ...(persisted.claudeCode ?? {}), desktopProfile: { - ...(profile ?? writtenProfile), + ...(profile ?? writtenProfile ?? emptyDesktopProfile()), appliedFingerprint: r.fingerprint, appliedAt: new Date().toISOString(), }, diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index e313fa2fcc6..0f82c5a2d7c 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -151,6 +151,14 @@ restores Desktop even with `--keep-catalog`; retries preserve the original catal not clear a newer connection. Authorized uninstall completes or resumes owned Desktop cleanup before removing OpenCodex state, and preserves recovery state when cleanup conflicts or fails. +The server-owned applied marker (`claudeCode.desktopProfile.appliedFingerprint` and +`appliedAt`) is committed by the config route only while the persisted desired profile still +matches the profile that was just written. Presence is compared first, then content: if a concurrent +writer deleted the profile or the whole `claudeCode` block, or replaced it with a different +profile, before the marker commit, the write is declined and reported as skipped instead of +resurrecting the removed profile with a fresh fingerprint. A profile that was absent from the start +still stores its fingerprint normally. + These guarantees concern files on disk. Fully quitting and reopening Desktop is required after apply, rotation/recovery or restoration; there is no automatic process restart or guarantee that a running app discarded a key. Local disconnect does not revoke the hub key or remove arbitrary diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index 3840f2bf0b9..6e87516fb54 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -306,6 +306,111 @@ describe("Desktop sync rechecks persisted state after discovery", () => { } }); + const runDesktopSyncWithDrift = async ( + config: OcxConfig, + drift: ((persisted: OcxConfig) => void) | null, + ) => { + writeFileSync(join(root, "config.json"), JSON.stringify(config)); + const models: CatalogModel[] = [ + { provider: "mock", id: "keep", contextWindow: 123_000 }, + { provider: "mock", id: "hidden", contextWindow: 456_000 }, + ]; + const realRefresh = ownedRefresh.refreshOwnedIntegration; + const refresh = spyOn(ownedRefresh, "refreshOwnedIntegration").mockImplementation((input, options) => + input.clientId === "mcode" + ? Promise.resolve({ client: "mcode", ok: true, changed: true }) + : realRefresh(input, options)); + const aside = spyOn(asideProfiles, "refreshAsideProfiles").mockResolvedValue([]); + try { + const results = await syncEnabledClientIntegrations(12345, config, { + fetchAllModels: async () => models, + writeDesktop3pConfig: () => { + // A concurrent writer persists its own desired state while the Desktop + // write is in flight; the marker commit must not overwrite it. + if (drift) { + const drifted = structuredClone(config); + drift(drifted); + writeFileSync(join(root, "config.json"), JSON.stringify(drifted)); + } + return { written: true, path: "fixture", fingerprint: "0123456789abcdef" }; + }, + }); + return { + outcome: results.find(result => result.client === "claude-desktop"), + persisted: JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig, + }; + } finally { + refresh.mockRestore(); + aside.mockRestore(); + } + }; + + const driftProfileA = { + version: 1 as const, + assignments: { "mock/hidden": { family: "opus" as const, alias: "claude-opus-4-8-20260201" } }, + defaults: { opus: "mock/hidden", fable: null, sonnet: null, haiku: null }, + }; + const driftBaseConfig = (claudeCode: OcxConfig["claudeCode"]): OcxConfig => ({ + port: 10100, + defaultProvider: "mock", + clientIntegrations: { grok: false }, + providers: { + mock: { adapter: "openai-chat", baseUrl: "https://example.test/v1", models: ["keep", "hidden"] }, + openai: { adapter: "openai-responses", baseUrl: "https://example.test/v1", contextWindow: 400_000 }, + }, + apiKeys: [{ id: "sync-key", name: "fixture", key: "ocx_old_sync_fixture", createdAt: "2026-01-01T00:00:00.000Z" }], + claudeCode, + }); + + test.each([ + { + name: "a deleted desired profile", + claudeCode: { desktopProfile: driftProfileA, systemEnv: false } as OcxConfig["claudeCode"], + drift: (persisted: OcxConfig) => { delete persisted.claudeCode!.desktopProfile; }, + expectPersisted: (persisted: OcxConfig) => { + expect(persisted.claudeCode).toEqual({ systemEnv: false }); + }, + }, + { + name: "a deleted claudeCode subtree", + claudeCode: { desktopProfile: driftProfileA } as OcxConfig["claudeCode"], + drift: (persisted: OcxConfig) => { delete persisted.claudeCode; }, + expectPersisted: (persisted: OcxConfig) => { + expect(persisted.claudeCode).toBeUndefined(); + }, + }, + { + name: "a deleted explicit empty profile", + claudeCode: { + desktopProfile: { + version: 1 as const, + assignments: {}, + defaults: { opus: null, fable: null, sonnet: null, haiku: null }, + }, + } as OcxConfig["claudeCode"], + drift: (persisted: OcxConfig) => { delete persisted.claudeCode!.desktopProfile; }, + expectPersisted: (persisted: OcxConfig) => { + expect(persisted.claudeCode?.desktopProfile).toBeUndefined(); + }, + }, + ])("sync does not resurrect $name removed during the Desktop write", async ({ claudeCode, drift, expectPersisted }) => { + const { outcome, persisted } = await runDesktopSyncWithDrift(driftBaseConfig(claudeCode), drift); + expect(outcome?.ok).toBe(false); + expect(outcome?.reason).toContain("desired profile changed during sync"); + expectPersisted(persisted); + }); + + test("an initially absent desired profile still stores the written fingerprint", async () => { + const { outcome, persisted } = await runDesktopSyncWithDrift( + driftBaseConfig({ systemEnv: false }), + null, + ); + expect(outcome).toEqual({ client: "claude-desktop", ok: true, changed: true }); + expect(persisted.claudeCode?.desktopProfile?.appliedFingerprint).toBe("0123456789abcdef"); + expect(persisted.claudeCode?.desktopProfile?.assignments).toEqual({}); + expect(persisted.claudeCode?.systemEnv).toBe(false); + }); + }); describe("ocx sync refreshes an already-owned MCode integration", () => { From 2f61b77dd54e07d67637224a3fc4ccf250ab4739 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 23 Sep 2026 04:35:14 +0900 Subject: [PATCH 6/6] fix(claude-desktop): compare default routes independently of key order --- src/claude/desktop-profile.ts | 2 +- structure/clients/claude-desktop.md | 3 ++- tests/clients/sync-client-integrations.test.ts | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/claude/desktop-profile.ts b/src/claude/desktop-profile.ts index 97b136a6925..c44e7ce0246 100644 --- a/src/claude/desktop-profile.ts +++ b/src/claude/desktop-profile.ts @@ -88,7 +88,7 @@ function appliedMarkers(source: { appliedFingerprint?: unknown; appliedAt?: unkn } export function sameProfileContent(left: DesktopProfile, right: DesktopProfile): boolean { - return JSON.stringify(left.defaults) === JSON.stringify(right.defaults) + return DESKTOP_FAMILIES.every(family => left.defaults[family] === right.defaults[family]) && JSON.stringify(Object.entries(left.assignments).sort(([a], [b]) => a.localeCompare(b))) === JSON.stringify(Object.entries(right.assignments).sort(([a], [b]) => a.localeCompare(b))); } diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 0f82c5a2d7c..656855e6002 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -157,7 +157,8 @@ matches the profile that was just written. Presence is compared first, then cont writer deleted the profile or the whole `claudeCode` block, or replaced it with a different profile, before the marker commit, the write is declined and reported as skipped instead of resurrecting the removed profile with a fresh fingerprint. A profile that was absent from the start -still stores its fingerprint normally. +still stores its fingerprint normally. Default-family key order does not change the desired content; +the comparison uses each family's selected route while preserving real selection changes. These guarantees concern files on disk. Fully quitting and reopening Desktop is required after apply, rotation/recovery or restoration; there is no automatic process restart or guarantee that diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index 6e87516fb54..3f6b4b4bcb2 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -411,6 +411,24 @@ describe("Desktop sync rechecks persisted state after discovery", () => { expect(persisted.claudeCode?.systemEnv).toBe(false); }); + test("sync accepts unchanged defaults with reordered keys during the Desktop write", async () => { + const { outcome, persisted } = await runDesktopSyncWithDrift( + driftBaseConfig({ desktopProfile: driftProfileA }), + config => { + const defaults = config.claudeCode!.desktopProfile!.defaults; + config.claudeCode!.desktopProfile!.defaults = { + haiku: defaults.haiku, + sonnet: defaults.sonnet, + fable: defaults.fable, + opus: defaults.opus, + }; + }, + ); + expect(outcome).toEqual({ client: "claude-desktop", ok: true, changed: true }); + expect(persisted.claudeCode?.desktopProfile?.appliedFingerprint).toBe("0123456789abcdef"); + expect(persisted.claudeCode?.desktopProfile?.defaults).toEqual(driftProfileA.defaults); + }); + }); describe("ocx sync refreshes an already-owned MCode integration", () => {