Skip to content
34 changes: 22 additions & 12 deletions src/claude/desktop-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,6 @@ function assertExactKeys(value: Record<string, unknown>, 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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions src/cli/claude-desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
}
Expand Down
4 changes: 3 additions & 1 deletion src/cli/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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];
Expand Down
13 changes: 10 additions & 3 deletions src/server/management/agent-settings-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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;
Expand Down
40 changes: 37 additions & 3 deletions src/server/management/config-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
isValidProviderName,
loadConfig,
multiAgentGuidanceEnabled,
mutatePersistedConfig,
providerBaseUrlConfigError,
providerHeadersConfigError,
saveConfigPreservingClaudeCode,
Expand Down Expand Up @@ -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) });
Expand Down
9 changes: 9 additions & 0 deletions structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion tests/claude-integration/claude-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
});

Expand Down
41 changes: 34 additions & 7 deletions tests/claude-integration/claude-desktop-cli.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -27,13 +29,30 @@ const applyProfile = (profile: Parameters<typeof applyProfileProduction>[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: {
Expand All @@ -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;
Expand All @@ -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 () => {
Expand All @@ -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(() => {});
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading