diff --git a/src/claude/desktop-profile.ts b/src/claude/desktop-profile.ts index e35b74cb464..c44e7ce0246 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 }; } +export function sameProfileContent(left: DesktopProfile, right: DesktopProfile): boolean { + 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))); +} + +/** 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-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/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..77e7dec61f0 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,42 @@ 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, 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; + const marked = mutatePersistedConfig(persisted => { + const profile = persisted.claudeCode?.desktopProfile; + // 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 ?? emptyDesktopProfile()), + 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 + ")" } + : 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 }); + } } } catch (error) { out.push({ client: "claude-desktop", ok: false, reason: error instanceof Error ? error.message : String(error) }); diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index e313fa2fcc6..656855e6002 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -151,6 +151,15 @@ 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. 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 a running app discarded a key. Local disconnect does not revoke the hub key or remove arbitrary 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-desktop-cli.test.ts b/tests/claude-integration/claude-desktop-cli.test.ts index a33026bf349..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,14 +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 } }; + 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(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 { 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..3f6b4b4bcb2 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 { @@ -201,6 +202,233 @@ 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(); + } + }); + + 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); + }); + + 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", () => {