diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 76451289314..a15b35cfd50 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1587,6 +1587,7 @@ "claude-intercept-local-ca.test.ts": "claude-integration", "claude-intercept-proxy.test.ts": "claude-integration", "claude-intercept-settings.test.ts": "claude-integration", + "claude-desktop-first-party.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server" }, "migrated": [ diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index ec50f765ab0..e7ffcfb72ba 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -473,6 +473,12 @@ export function removeDesktop3pStandardPivot( options: Desktop3pConfigLibraryOptions & { appliedFingerprint?: string | null; unlink?: (path: string) => void; lifecycleLockDeps?: ClientLifecycleLockDeps; + /** + * The desired-state guard below exists for OFF flows racing a concurrent enable. A + * mode switch (gateway → first-party) removes the profile while the integration stays + * ON on purpose, so the caller opts out of that guard. + */ + replaceWhileEnabled?: boolean; } = {}, ): Desktop3pRemovalResult { const libraryPath = resolveDesktop3pConfigLibraryPath(options); @@ -488,7 +494,7 @@ export function removeDesktop3pStandardPivot( } const latest = readConfigDiagnostics(); if (latest.source === "fallback") return { ok: false, changed: false, kind: "unsafe", libraryPath, reason: "desktop_config_invalid" }; - if (claudeDesktopIntegrationEnabled(latest.config)) { + if (!options.replaceWhileEnabled && claudeDesktopIntegrationEnabled(latest.config)) { const observed = inspectDesktop3pConfigLibrary(options); if (observed.kind === "not_installed" || observed.kind === "no_owned_state") { return { ok: true, changed: false, kind: "noop", libraryPath }; diff --git a/src/claude/desktop-first-party.ts b/src/claude/desktop-first-party.ts new file mode 100644 index 00000000000..0946aafddd5 --- /dev/null +++ b/src/claude/desktop-first-party.ts @@ -0,0 +1,172 @@ +/** + * Claude Desktop first-party mode. + * + * Desktop has two ways to reach opencodex: + * + * - `first-party` (default): the app keeps its ordinary claude.ai login, Chat tab, connectors + * and remote control. Only the Claude Code process it spawns for the Code tab (and that + * process's subagents) is redirected, through the `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` env + * in `~/.claude/settings.json` (src/claude/intercept/settings.ts) and the server's intercept + * pair (src/claude/intercept/runtime.ts). Nothing is written under Desktop's config library. + * - `gateway`: the historical third-party deployment profile (src/claude/desktop-3p.ts). The + * whole app is switched to a gateway build; picker entries are opencodex aliases. + * + * The two are mutually exclusive on disk: applying one removes the other. The mode is persisted + * in `claudeCode.desktopMode`; installs that predate the field but already carry an applied + * gateway profile keep `gateway` until they explicitly re-apply, so an update never flips a + * working Desktop under the operator. + */ +import { getConfigDir } from "../config/paths"; +import type { OcxConfig } from "../types"; +import { claudeInterceptCaCertPath, ensureLocalInterceptCa } from "./intercept/local-ca"; +import { claudeInterceptEnabled, claudeInterceptProxyPort } from "./intercept/runtime"; +import { + applyClaudeInterceptSettings, + buildClaudeInterceptEnv, + inspectClaudeInterceptSettings, + removeClaudeInterceptSettings, + type ClaudeInterceptEnv, + type ClaudeInterceptSettingsState, + type ClaudeInterceptSettingsWrite, +} from "./intercept/settings"; + +export const CLAUDE_DESKTOP_MODES = ["first-party", "gateway"] as const; +export type ClaudeDesktopMode = typeof CLAUDE_DESKTOP_MODES[number]; +export const DEFAULT_CLAUDE_DESKTOP_MODE: ClaudeDesktopMode = "first-party"; + +export function isClaudeDesktopMode(value: unknown): value is ClaudeDesktopMode { + return typeof value === "string" && (CLAUDE_DESKTOP_MODES as readonly string[]).includes(value); +} + +type DesktopModeConfig = Pick; + +/** + * Effective Desktop mode. An explicit `claudeCode.desktopMode` wins; otherwise a persisted + * gateway apply marker (`desktopProfile.appliedFingerprint`) means a pre-existing gateway + * install and keeps `gateway`; everything else is the first-party default. + */ +export function resolveClaudeDesktopMode(config: DesktopModeConfig): ClaudeDesktopMode { + const explicit = config.claudeCode?.desktopMode; + if (isClaudeDesktopMode(explicit)) return explicit; + if (config.claudeCode?.desktopProfile?.appliedFingerprint) return "gateway"; + return DEFAULT_CLAUDE_DESKTOP_MODE; +} + +/** + * Config mutation that records the applied Desktop mode. Switching to first-party also drops + * the gateway apply marker: the profile assignments stay for a later gateway apply, but a + * stale `appliedFingerprint` must not make `resolveClaudeDesktopMode` read `gateway` again + * should the explicit marker ever go missing. + */ +export function recordClaudeDesktopMode( + config: DesktopModeConfig, + mode: ClaudeDesktopMode, +): { changed: boolean; value: true } { + const claudeCode = config.claudeCode ?? {}; + const profile = claudeCode.desktopProfile; + const dropMarker = mode === "first-party" && profile !== undefined + && (profile.appliedFingerprint !== undefined || profile.appliedAt !== undefined); + if (claudeCode.desktopMode === mode && !dropMarker) return { changed: false, value: true }; + if (dropMarker) { + const { appliedFingerprint: _fingerprint, appliedAt: _at, ...rest } = profile; + config.claudeCode = { ...claudeCode, desktopMode: mode, desktopProfile: rest }; + } else { + config.claudeCode = { ...claudeCode, desktopMode: mode }; + } + return { changed: true, value: true }; +} + +/** + * Mode an *apply* without an explicit choice should use. The first-party default only holds + * where the intercept proxy actually runs; with it disabled (or on a client role) an implied + * first-party apply would point Claude Code at a proxy that never starts, so fall back to the + * gateway profile. An explicit `desktopMode: "first-party"` is still honoured (and refused + * later with `intercept_disabled`, which names the fix). + */ +export function resolveClaudeDesktopApplyMode( + config: Pick, +): ClaudeDesktopMode { + const resolved = resolveClaudeDesktopMode(config); + if (resolved === "gateway" || isClaudeDesktopMode(config.claudeCode?.desktopMode)) return resolved; + return claudeInterceptEnabled(config) ? "first-party" : "gateway"; +} + +export interface DesktopFirstPartyTarget { + proxyPort: number; + caCertPath: string; + env: ClaudeInterceptEnv; +} + +/** The settings env a first-party apply on this machine writes (CA is created on demand). */ +export function desktopFirstPartyTarget( + config: Pick, + opencodexConfigDir = getConfigDir(), +): DesktopFirstPartyTarget { + const proxyPort = claudeInterceptProxyPort(config, config.port ?? 10100); + const caCertPath = claudeInterceptCaCertPath(opencodexConfigDir); + return { proxyPort, caCertPath, env: buildClaudeInterceptEnv(proxyPort, caCertPath) }; +} + +export interface DesktopFirstPartyInspection { + /** False when the server will not run the intercept pair (client role, intercept disabled). */ + interceptEnabled: boolean; + proxyPort: number; + caCertPath: string; + settings: ClaudeInterceptSettingsState; + /** settings.json carries exactly the env the current config would write. */ + applied: boolean; + /** Ours, but for an older port/config directory. Re-apply refreshes it. */ + stale: boolean; +} + +export interface DesktopFirstPartyOptions { + opencodexConfigDir?: string; + claudeConfigDir?: string; +} + +export function inspectDesktopFirstParty( + config: Pick, + options: DesktopFirstPartyOptions = {}, +): DesktopFirstPartyInspection { + const target = desktopFirstPartyTarget(config, options.opencodexConfigDir); + const settings = inspectClaudeInterceptSettings(target.env, options.claudeConfigDir); + return { + interceptEnabled: claudeInterceptEnabled(config), + proxyPort: target.proxyPort, + caCertPath: target.caCertPath, + settings, + applied: settings.kind === "applied", + stale: settings.kind === "stale", + }; +} + +export type DesktopFirstPartyApplyResult = + | { ok: true; changed: boolean; path: string; env: ClaudeInterceptEnv; proxyPort: number } + | { ok: false; reason: "intercept_disabled" | "ca_unavailable" | "unreadable" | "foreign_env"; path: string }; + +/** + * Write the first-party env into Claude Code's settings. Creates the local CA first so the + * path we point `NODE_EXTRA_CA_CERTS` at exists before Claude Code ever reads it. + */ +export function applyDesktopFirstParty( + config: Pick, + options: DesktopFirstPartyOptions = {}, +): DesktopFirstPartyApplyResult { + const opencodexConfigDir = options.opencodexConfigDir ?? getConfigDir(); + const target = desktopFirstPartyTarget(config, opencodexConfigDir); + if (!claudeInterceptEnabled(config)) return { ok: false, reason: "intercept_disabled", path: "" }; + try { + ensureLocalInterceptCa(opencodexConfigDir); + } catch { + return { ok: false, reason: "ca_unavailable", path: target.caCertPath }; + } + const written = applyClaudeInterceptSettings(target.env, options.claudeConfigDir); + if (!written.ok) return { ok: false, reason: written.reason, path: written.path }; + return { ok: true, changed: written.changed, path: written.path, env: target.env, proxyPort: target.proxyPort }; +} + +/** Remove the first-party env. Only values anchored on our CA path are touched. */ +export function removeDesktopFirstParty(options: DesktopFirstPartyOptions = {}): ClaudeInterceptSettingsWrite { + const caCertPath = claudeInterceptCaCertPath(options.opencodexConfigDir ?? getConfigDir()); + return removeClaudeInterceptSettings(caCertPath, options.claudeConfigDir); +} diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 7ff04dfe4e4..92efabad4d0 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -15,13 +15,23 @@ import { type DesktopFamily, type DesktopProfile, } from "../claude/desktop-profile"; -import { writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; +import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; +import { + applyDesktopFirstParty, + recordClaudeDesktopMode, + removeDesktopFirstParty, + resolveClaudeDesktopApplyMode, + type ClaudeDesktopMode, +} from "../claude/desktop-first-party"; import { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } from "../claude/desktop-policy"; import { filterCatalogVisibleModels, desktopVisibleNativeSlugs, nativeContextLimits } from "../codex/catalog"; import { buildClaudeDesktopState, fetchAllModels } from "../server/management-api"; import { findLiveProxy } from "../server/proxy-liveness"; import { CliUsageError, runtimeRequest, takeJsonFlag } from "./runtime-api"; import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; +import type { OcxConfig } from "../types"; + +const APPLY_FLAGS = ["--first-party", "--gateway", "--static", "--hybrid", "--discovery-only"]; function isFamily(value: string | undefined): value is DesktopFamily { return !!value && (DESKTOP_FAMILIES as readonly string[]).includes(value); @@ -29,7 +39,10 @@ function isFamily(value: string | undefined): value is DesktopFamily { function printDesktopHelp(): void { console.log(`Usage: - ocx claude desktop [apply] [--static|--hybrid|--discovery-only] + ocx claude desktop [apply] [--first-party | --gateway [--static|--hybrid|--discovery-only]] + --first-party (default) keep Desktop on claude.ai; route only the Code tab's Claude Code + through the local intercept proxy via ~/.claude/settings.json env + --gateway install the third-party gateway profile for the whole app ocx claude desktop show [--json] ocx claude desktop status [--json] ocx claude desktop move [--default] @@ -135,6 +148,104 @@ async function applyConnectedDesktopProfile( } } +/** Persist the operator's Desktop mode choice; a failed marker save never undoes the apply. */ +function saveDesktopMode(mode: ClaudeDesktopMode, deps: ApplyProfileDeps): boolean { + try { + return withClientLifecycleSync(() => { + const outcome = mutatePersistedConfig(current => recordClaudeDesktopMode(current, mode)); + return outcome.status !== "unavailable"; + }, deps.lifecycleLockDeps); + } catch { + return false; + } +} + +export type DesktopApplyTarget = + | { kind: "first-party" } + | { kind: "gateway"; mode: Desktop3pConfigMode }; + +/** Parse `ocx claude desktop apply` flags into a target; legacy gateway shape flags imply --gateway. */ +/** + * Default mode when no flag is given: first-party wherever the local intercept proxy can + * run; a connected client (proxy lives on the hub) or a disabled intercept falls back to + * the gateway profile rather than pointing Claude Code at a proxy that does not exist. + */ +export function defaultDesktopApplyMode( + config: Pick, + connection: ClientConnectionState = readClientConnectionState(), +): ClaudeDesktopMode { + const resolved = resolveClaudeDesktopApplyMode(config); + if (resolved === "gateway") return resolved; + return connection.kind === "connected" ? "gateway" : "first-party"; +} + +export function parseDesktopApplyArgs( + flags: string[], + config: Pick, +): { target: DesktopApplyTarget } | { error: string } { + const shapeFlags = flags.filter(arg => ["--static", "--hybrid", "--discovery-only"].includes(arg)); + const wantsFirstParty = flags.includes("--first-party"); + const wantsGateway = flags.includes("--gateway") || shapeFlags.length > 0; + if (wantsFirstParty && wantsGateway) return { error: "--first-party cannot be combined with --gateway or gateway shape flags." }; + const unknown = flags.filter(arg => !["--first-party", "--gateway", "--static", "--hybrid", "--discovery-only"].includes(arg)); + if (unknown.length > 0) return { error: `알 수 없는 인자: ${unknown.join(" ")}` }; + const kind: ClaudeDesktopMode = wantsFirstParty ? "first-party" : wantsGateway ? "gateway" : defaultDesktopApplyMode(config); + if (kind === "first-party") return { target: { kind } }; + const parsedMode = parseDesktop3pModeArgs(shapeFlags); + if ("error" in parsedMode) return parsedMode; + return { target: { kind, mode: parsedMode.mode } }; +} + +/** + * First-party apply: settings.json env only. The intercept pair the env points at runs inside + * the hub process, so this is a local-hub operation — a connected client machine cannot reach + * a loopback proxy on the hub and is refused rather than left with a dead `HTTPS_PROXY`. + */ +async function applyFirstPartyDesktop( + deps: ApplyProfileDeps, +): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> { + try { assertNoClientDisconnectPending(); } catch { return { ok: false, path: "", reason: "client_disconnect_pending" }; } + const connection = readClientConnectionState(); + if (connection.kind === "connected") return { ok: false, path: "", reason: "first_party_requires_local_hub" }; + if (connection.kind !== "disconnected") return { ok: false, path: "", reason: "client_connection_invalid" }; + const config = loadConfig(); + const desired = setIntegrationEnabled("claude-desktop", true); + if (!desired.ok) return { ok: false, path: "", reason: desired.message }; + // First-party replaces gateway; the two must never be active together. + const appliedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null; + const library = inspectDesktop3pConfigLibrary({ appliedFingerprint }); + if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { + const removed = removeDesktop3pStandardPivot({ appliedFingerprint, replaceWhileEnabled: true }); + if (!removed.ok) { + return { ok: false, path: library.selectedProfilePath ?? "", reason: removed.kind === "cleanup_incomplete" ? "gateway_cleanup_incomplete" : `gateway_profile_active:${removed.reason ?? removed.kind}` }; + } + } + const applied = applyDesktopFirstParty(config); + if (!applied.ok) return { ok: false, path: applied.path, reason: applied.reason }; + const saved = saveDesktopMode("first-party", deps); + return { + ok: true, + path: applied.path, + ...(saved ? {} : { warning: "desktop mode marker was not saved" }), + }; +} + +export async function applyDesktop( + profile: DesktopProfile | undefined, + target: DesktopApplyTarget, + deps: ApplyProfileDeps = {}, +): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> { + if (target.kind === "first-party") return applyFirstPartyDesktop(deps); + // Gateway replaces first-party; the two must never be active together. + const removed = removeDesktopFirstParty(); + if (!removed.ok) return { ok: false, path: removed.path, reason: "first_party_settings_unreadable" }; + const result = await applyProfile(profile, target.mode, deps); + if (result.ok && !saveDesktopMode("gateway", deps)) { + return { ...result, warning: [result.warning, "desktop mode marker was not saved"].filter(Boolean).join(" ") }; + } + return result; +} + export async function applyProfile( profile: DesktopProfile | undefined, mode: Desktop3pConfigMode, @@ -222,24 +333,35 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf return 0; } - // Legacy mode flags remain apply aliases and are parsed before subcommands. - const legacyFlags = argv.filter(arg => ["--static", "--hybrid", "--discovery-only"].includes(arg)); - const applyInvocation = argv.length === 0 || command === "apply" || legacyFlags.length > 0; + // Mode flags remain apply aliases and are parsed before subcommands. + const applyFlags = argv.filter(arg => APPLY_FLAGS.includes(arg)); + const applyInvocation = argv.length === 0 || command === "apply" || applyFlags.length > 0; if (applyInvocation) { - const nonMode = argv.filter(arg => !["apply", "--static", "--hybrid", "--discovery-only"].includes(arg)); - if (nonMode.length > 0) { - console.error(`알 수 없는 인자: ${nonMode.join(" ")}`); - return 2; - } - const parsedMode = parseDesktop3pModeArgs(legacyFlags); - if ("error" in parsedMode) { console.error(parsedMode.error); return 2; } + const rest = argv.filter(arg => arg !== "apply"); + const parsedTarget = parseDesktopApplyArgs(rest, loadConfig()); + if ("error" in parsedTarget) { console.error(parsedTarget.error); return 2; } + const { target } = parsedTarget; try { - const result = await applyProfile(undefined, parsedMode.mode, deps); + const result = await applyDesktop(undefined, target, deps); if (!result.ok) { console.error(`설정 적용 실패: ${result.reason ?? "unknown error"}`); + if (result.reason?.startsWith("gateway_")) { + console.error("The gateway profile could not be removed safely, so first-party mode was not applied. Turn the integration off (dashboard toggle) and retry, or keep gateway with `ocx claude desktop apply --gateway`."); + } else if (result.reason === "foreign_env") { + console.error(`~/.claude/settings.json already sets HTTPS_PROXY or NODE_EXTRA_CA_CERTS to a value opencodex does not own (${result.path}). Remove them or use --gateway.`); + } else if (result.reason === "intercept_disabled") { + console.error("First-party mode needs the Claude intercept proxy (claudeCode.intercept.enabled on a hub). Use --gateway instead."); + } else if (result.reason === "first_party_requires_local_hub") { + console.error("First-party mode runs on the hub machine only; on a connected client use --gateway."); + } return 1; } - console.log(`Claude Desktop 설정을 적용했습니다: ${result.path}`); + if (target.kind === "first-party") { + console.log(`Claude Desktop first-party 설정을 적용했습니다: ${result.path}`); + console.log("Desktop 앱 설정은 그대로이며, Code 탭의 Claude Code만 로컬 프록시를 거칩니다."); + } else { + console.log(`Claude Desktop gateway 설정을 적용했습니다: ${result.path}`); + } // The write landed; only the bookkeeping marker did not. Saying nothing // would leave the saved-vs-applied display wrong with no explanation. if (result.warning) console.warn(`⚠️ ${result.warning}`); @@ -336,7 +458,8 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf } saveLocalDesktopProfile(reconciled, config.claudeCode?.desktopProfile, connection, deps); if (flags.includes("--apply")) { - const result = await applyProfile(reconciled, "static", deps); + // A routing profile is a gateway-mode artifact: importing with --apply installs it there. + const result = await applyDesktop(reconciled, { kind: "gateway", mode: "static" }, deps); if (!result.ok) { console.error(`프로필은 저장했지만 Desktop 적용에 실패했습니다: ${result.reason ?? "unknown error"}`); return 1; } if (result.warning) console.warn(`⚠️ ${result.warning}`); } diff --git a/src/cli/ensure-desired-integrations.ts b/src/cli/ensure-desired-integrations.ts index fe94a0b76e2..6a23b5a00c5 100644 --- a/src/cli/ensure-desired-integrations.ts +++ b/src/cli/ensure-desired-integrations.ts @@ -10,7 +10,13 @@ */ import { loadConfig } from "../config"; import { stripGrokConfig, type GrokInjectResult } from "../grok/inject"; -import { removeDesktop3pStandardPivot } from "../claude/desktop-3p"; +import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } from "../claude/desktop-3p"; +import { + applyDesktopFirstParty, + inspectDesktopFirstParty, + removeDesktopFirstParty, + resolveClaudeDesktopMode, +} from "../claude/desktop-first-party"; import { claudeDesktopIntegrationEnabled, grokIntegrationEnabled, @@ -35,6 +41,10 @@ export interface EnsureDesiredIntegrationsDeps { opts?: { hostname?: string }, ) => Promise; removeDesktop3pStandardPivot: typeof removeDesktop3pStandardPivot; + removeDesktopFirstParty?: typeof removeDesktopFirstParty; + applyDesktopFirstParty?: typeof applyDesktopFirstParty; + inspectDesktopFirstParty?: typeof inspectDesktopFirstParty; + inspectDesktop3pConfigLibrary?: typeof inspectDesktop3pConfigLibrary; log?: (message: string) => void; error?: (message: string) => void; } @@ -113,16 +123,44 @@ export async function ensureGrokFenceMatchesDesired( } /** - * When Claude Desktop is durably OFF, clear any leftover owned gateway profile. - * ensure/update used to leave Claude-3p residue in place after a failed disable - * (drifted fingerprint), so the Integrations card kept looking applied/stale. + * When Claude Desktop is durably OFF, clear any leftover owned gateway profile and the + * first-party settings env. ensure/update used to leave Claude-3p residue in place after a + * failed disable (drifted fingerprint), so the Integrations card kept looking applied/stale. + * + * When it is ON in first-party mode, refresh a stale env (the intercept port follows the + * public port, so a port change would otherwise leave Claude Code pointed at a dead proxy). */ export function ensureClaudeDesktopMatchesDesired( deps: EnsureDesiredIntegrationsDeps = productionDeps, ): void { const config = deps.loadConfig(); const { log, error } = io(deps); - if (claudeDesktopIntegrationEnabled(config)) return; + if (claudeDesktopIntegrationEnabled(config)) { + if (resolveClaudeDesktopMode(config) !== "first-party") return; + const library = (deps.inspectDesktop3pConfigLibrary ?? inspectDesktop3pConfigLibrary)({ + appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null, + }); + if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { + // The mode marker and the disk disagree. Replacing a live Desktop profile is an operator + // action, not something an update hook should do silently. + error("⚠️ Claude Desktop mode is first-party but a gateway profile is still applied; run `ocx claude desktop apply --first-party` (or `--gateway`) to reconcile."); + return; + } + const seen = (deps.inspectDesktopFirstParty ?? inspectDesktopFirstParty)(config); + if (!seen.stale) return; + const applied = (deps.applyDesktopFirstParty ?? applyDesktopFirstParty)(config); + if (applied.ok && applied.changed) log(` + Claude Desktop first-party env refreshed (${applied.path})`); + else if (!applied.ok) error(`⚠️ Claude Desktop first-party env refresh skipped: ${applied.reason}.`); + return; + } + try { + const env = (deps.removeDesktopFirstParty ?? removeDesktopFirstParty)(); + if (env.ok && env.changed) log(" ↩️ Claude Desktop first-party env removed."); + else if (!env.ok) error(`⚠️ Claude Desktop first-party env cleanup skipped: ${env.reason} (${env.path}).`); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + error(`⚠️ Claude Desktop first-party env cleanup failed: ${detail}.`); + } try { const removed = deps.removeDesktop3pStandardPivot({ appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null, diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 5a7273e5a3a..1711b8b314a 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -261,7 +261,10 @@ export const configSchema = z.object({ if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) { ctx.addIssue({ code: "custom", path: ["claudeCode"], message: "claudeCode must be an object" }); } else if (claudeCode) { - const claude = claudeCode as { desktopProfile?: unknown; intercept?: unknown }; + const claude = claudeCode as { desktopProfile?: unknown; desktopMode?: unknown; intercept?: unknown }; + if (claude.desktopMode !== undefined && claude.desktopMode !== "first-party" && claude.desktopMode !== "gateway") { + ctx.addIssue({ code: "custom", path: ["claudeCode", "desktopMode"], message: "desktopMode must be \"first-party\" or \"gateway\"" }); + } if (claude.intercept !== undefined) { const intercept = claude.intercept; if (!intercept || typeof intercept !== "object" || Array.isArray(intercept)) { diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index ef553702087..91976c144fd 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -131,6 +131,17 @@ function persistDesktopProfileField( return { ok: true }; } +async function persistDesktopModeField( + config: OcxConfig, + desktopMode: "first-party" | "gateway", +): Promise<{ ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" }> { + const { recordClaudeDesktopMode } = await import("../../claude/desktop-first-party"); + const outcome = mutatePersistedConfig(persisted => recordClaudeDesktopMode(persisted, desktopMode)); + if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason }; + recordClaudeDesktopMode(config, desktopMode); + return { ok: true }; +} + export function grokApplyFlightSnapshot(): { currentBytes: number; highWaterBytes: number; active: number } { return { currentBytes: grokApplyFlight?.bytes ?? 0, @@ -1002,7 +1013,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (url.pathname === "/api/claude-desktop/apply" && req.method === "POST") { try { // #859: the CLI delegates here so the registry is built in the serving - // process. Accept an optional mode; default stays static for back-compat. + // process. `mode` selects the Desktop mode: `first-party` (default when the + // install has no gateway profile) or `gateway`; the legacy gateway shapes + // (static/hybrid/discovery) still select gateway with that library layout. let mode: "static" | "hybrid" | "discovery" = "static"; let parsed: unknown; try { @@ -1011,12 +1024,17 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + const { resolveClaudeDesktopApplyMode, applyDesktopFirstParty, removeDesktopFirstParty } = await import("../../claude/desktop-first-party"); const requested = (parsed as { mode?: unknown } | null)?.mode; + let desktopMode: "first-party" | "gateway" = resolveClaudeDesktopApplyMode(config); if (requested !== undefined) { if (requested === "static" || requested === "hybrid" || requested === "discovery") { mode = requested; + desktopMode = "gateway"; + } else if (requested === "gateway" || requested === "first-party") { + desktopMode = requested; } else { - return jsonResponse({ error: "mode must be static, hybrid, or discovery" }, 400); + return jsonResponse({ error: "mode must be first-party, gateway, static, hybrid, or discovery" }, 400); } } // #859: a delegated CLI apply carries the profile it just saved — the @@ -1032,6 +1050,63 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); } } + if (desktopMode === "first-party") { + const { setIntegrationEnabled } = await import("../../codex/desired-state"); + const desired = setIntegrationEnabled("claude-desktop", true); + if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500); + mirrorDesiredEnabledOntoSnapshot(config, "claude-desktop", true); + // First-party replaces gateway: the two must never be active together. + const { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } = await import("../../claude/desktop-3p"); + const appliedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null; + const library = inspectDesktop3pConfigLibrary({ appliedFingerprint }); + let gatewayRemoved = false; + if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { + const removed = (deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint, replaceWhileEnabled: true }); + if (!removed.ok) { + return jsonResponse({ + error: removed.kind === "cleanup_incomplete" + ? "Claude Desktop now points at standard mode, but gateway credential cleanup is incomplete; first-party env was not applied." + : "The gateway profile could not be removed safely, so first-party mode was not applied.", + code: "claude_desktop_gateway_removal_failed", + reason: removed.reason ?? removed.kind, + ...(removed.residualPaths ? { residualPaths: removed.residualPaths } : {}), + }, removed.kind === "cleanup_incomplete" ? 500 : 409); + } + gatewayRemoved = removed.changed; + } + const applied = applyDesktopFirstParty(config); + if (!applied.ok) { + const { firstPartyRefusalMessage } = await import("./native-integration-routes"); + return jsonResponse({ + error: firstPartyRefusalMessage(applied.reason, applied.path), + code: "claude_desktop_first_party_refused", + reason: applied.reason, + path: applied.path, + }, applied.reason === "foreign_env" || applied.reason === "intercept_disabled" ? 409 : 500); + } + const modeSaved = await persistDesktopModeField(config, "first-party"); + return jsonResponse({ + ok: true, + mode: "first-party", + saved: modeSaved.ok, + applied: true, + changed: applied.changed || gatewayRemoved, + gatewayRemoved, + path: applied.path, + proxyPort: applied.proxyPort, + caCertPath: applied.env.NODE_EXTRA_CA_CERTS, + ...(modeSaved.ok ? {} : { warning: `First-party env was applied, but the mode marker was not saved (${modeSaved.reason}).` }), + }); + } + // Gateway mode replaces first-party: the two must never be active together. + const firstPartyRemoved = removeDesktopFirstParty(); + if (!firstPartyRemoved.ok) { + return jsonResponse({ + error: `Claude Code settings could not be parsed (${firstPartyRemoved.path}); the first-party proxy env could not be removed before switching to gateway mode.`, + code: "claude_desktop_first_party_refused", + reason: firstPartyRemoved.reason, + }, 500); + } const { setIntegrationEnabled, claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state"); const desired = setIntegrationEnabled("claude-desktop", true); if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500); @@ -1083,6 +1158,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise nativeContextLimits(latest), ); if (!result.written) return jsonResponse({ error: result.reason ?? "Claude Desktop apply failed", saved: true, path: result.path }, 500); + const modeSaved = await persistDesktopModeField(config, "gateway"); + const modeWarning = modeSaved.ok ? undefined : `Claude Desktop was applied, but the gateway mode marker was not saved (${modeSaved.reason}).`; const { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } = await import("../../claude/desktop-policy"); const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({ platform: deps.platform ?? process.platform, @@ -1106,19 +1183,22 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise fingerprint: result.fingerprint, warning: [ `Claude Desktop was applied, but the applied marker was not saved (${marked.reason}).`, + modeWarning, policyWarning, ].filter(Boolean).join(" "), }); } } + const warning = [modeWarning, policyWarning].filter(Boolean).join(" "); return jsonResponse({ ok: true, - saved: true, + mode: "gateway", + saved: modeSaved.ok, applied: true, path: result.path, fingerprint: result.fingerprint, policyState, - ...(policyWarning ? { warning: policyWarning } : {}), + ...(warning ? { warning } : {}), }); } catch (error) { rethrowManagementBodyTooLarge(error); @@ -1131,21 +1211,39 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise try { const { claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state"); const { inspectDesktop3pConfigLibrary } = await import("../../claude/desktop-3p"); + const { resolveClaudeDesktopApplyMode, inspectDesktopFirstParty } = await import("../../claude/desktop-first-party"); + const { getClaudeInterceptState } = await import("../../claude/intercept/runtime"); const persisted = loadConfig(); const savedFingerprint = persisted.claudeCode?.desktopProfile?.appliedFingerprint ?? null; const observed = inspectDesktop3pConfigLibrary({ appliedFingerprint: savedFingerprint }); const desiredEnabled = claudeDesktopIntegrationEnabled(persisted); - const applied = observed.kind === "gateway_ours" || observed.kind === "gateway_drifted"; + const gatewayApplied = observed.kind === "gateway_ours" || observed.kind === "gateway_drifted"; + // A gateway profile on disk is what Desktop actually runs, whatever the saved mode says. + const mode = gatewayApplied ? "gateway" : resolveClaudeDesktopApplyMode(persisted); + const firstPartySeen = inspectDesktopFirstParty(persisted); + const intercept = getClaudeInterceptState(); + const firstParty = { + settingsState: firstPartySeen.settings.kind, + applied: firstPartySeen.applied, + stale: firstPartySeen.stale, + interceptEnabled: firstPartySeen.interceptEnabled, + interceptRunning: intercept !== null, + proxyPort: intercept?.proxyPort ?? firstPartySeen.proxyPort, + caCertPath: firstPartySeen.caCertPath, + }; + const applied = mode === "first-party" ? firstPartySeen.applied : gatewayApplied; // "Needs update" is only meaningful while the integration is wanted. When the // durable switch is OFF, a leftover drifted profile is residue to clear — not // a stale apply the operator should refresh. - const stale = desiredEnabled && observed.kind === "gateway_drifted"; + const stale = desiredEnabled && (mode === "first-party" ? firstPartySeen.stale : observed.kind === "gateway_drifted"); const { getDesktopHealth } = await import("../../claude/desktop-health"); const { claudeDesktopPolicyHealth, probeClaudeDesktopPolicy } = await import("../../claude/desktop-policy"); const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({ platform: deps.platform ?? process.platform, }); - const policy = claudeDesktopPolicyHealth(policyState); + // Managed-policy conflicts only matter for the gateway profile; first-party mode never + // touches Desktop's own configuration. + const policy = claudeDesktopPolicyHealth(mode === "first-party" ? "absent" : policyState); const health = { ...getDesktopHealth(), ok: policy.ok, @@ -1153,14 +1251,19 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise policy, }; const policyConflict = desiredEnabled && !policy.ok; - const baseDrift = desiredEnabled ? !applied || stale : applied || observed.kind === "unsafe"; + const firstPartyResidue = mode === "gateway" && (firstPartySeen.applied || firstPartySeen.stale); + const baseDrift = desiredEnabled + ? !applied || stale || firstPartyResidue + : applied || gatewayApplied || firstPartySeen.applied || firstPartySeen.stale || observed.kind === "unsafe"; const driftReason = policyConflict ? policy.state === "present" ? "managed_policy_present" : "managed_policy_unknown" : desiredEnabled - ? !applied ? "desired_on_not_current" : stale ? "profile_drift" : null - : applied ? "desired_off_gateway_selected" : null; + ? !applied ? "desired_on_not_current" : stale ? "profile_drift" : firstPartyResidue ? "first_party_residue" : null + : gatewayApplied ? "desired_off_gateway_selected" : firstPartySeen.applied || firstPartySeen.stale ? "desired_off_first_party_env" : null; return jsonResponse({ desiredEnabled, + mode, + firstParty, installed: observed.kind !== "not_installed", observedKind: observed.kind, applied, diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 56158bd162e..ae20731777a 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -18,13 +18,21 @@ * 011 (Claude Code), 012 (Grok). */ import { join } from "node:path"; -import { loadConfig, saveConfigPreservingClaudeCode } from "../../config"; +import { loadConfig, mutatePersistedConfig, saveConfigPreservingClaudeCode } from "../../config"; import { readRuntimePort } from "../../config/process-state"; import { desktopVisibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits } from "../../codex/catalog"; import { getCodexHome } from "../../codex/paths"; import { providerContextCap } from "../../providers/context-cap"; import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p"; +import { + applyDesktopFirstParty, + inspectDesktopFirstParty, + recordClaudeDesktopMode, + removeDesktopFirstParty, + resolveClaudeDesktopApplyMode, + type ClaudeDesktopMode, +} from "../../claude/desktop-first-party"; import { projectGrokCatalog } from "../../grok/catalog"; import { injectGrokConfig, stripGrokConfig } from "../../grok/inject"; import { inspectGrokConfig } from "../../grok/inspect"; @@ -47,7 +55,9 @@ export type NativeRefusalReason = | "write_failed" | "metadata_unreadable" | "cleanup_incomplete" - | "desired_state_changed"; + | "desired_state_changed" + | "foreign_env" + | "intercept_disabled"; export interface NativeStatus { clientId: NativeIntegrationClientId; @@ -126,7 +136,7 @@ function desktopStatus(config: ManagementContext["config"]): NativeStatus { const seen = inspectDesktop3pConfigLibrary({ appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null, }); - const state: NativeStatus["state"] = seen.kind === "gateway_ours" + const gatewayState: NativeStatus["state"] = seen.kind === "gateway_ours" ? "current" : seen.kind === "unsafe" || seen.kind === "broken" ? "unsafe" : "absent"; const disableBlocked = seen.kind === "unsafe" || seen.kind === "broken" || seen.kind === "foreign" @@ -135,6 +145,14 @@ function desktopStatus(config: ManagementContext["config"]): NativeStatus { message: "Claude Desktop configuration cannot be changed safely.", } : null; + // First-party mode lives in Claude Code's settings.json, not in Desktop's library. A leftover + // gateway profile still counts as current (it is what Desktop is actually running). + const firstParty = resolveClaudeDesktopApplyMode(config) === "first-party" && gatewayState === "absent" + ? inspectDesktopFirstParty(config) + : null; + const state: NativeStatus["state"] = firstParty + ? firstParty.settings.kind === "unreadable" ? "unsafe" : firstParty.applied ? "current" : "absent" + : gatewayState; return { clientId: "claude-desktop", state, @@ -599,6 +617,28 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { } } +export function firstPartyRefusalMessage( + reason: "intercept_disabled" | "ca_unavailable" | "unreadable" | "foreign_env", + path: string, +): string { + switch (reason) { + case "intercept_disabled": + return "First-party mode needs the Claude intercept proxy, which is off in this configuration (claudeCode.intercept.enabled / client role). Use gateway mode instead."; + case "ca_unavailable": + return `The local intercept certificate could not be created (${path}).`; + case "unreadable": + return `Claude Code settings could not be parsed (${path}); nothing was written.`; + case "foreign_env": + return `Claude Code settings already set HTTPS_PROXY or NODE_EXTRA_CA_CERTS to a value opencodex does not own (${path}); remove them first or use gateway mode.`; + } +} + +/** Record which Desktop mode is applied; `false` when the config file could not be updated. */ +function persistDesktopModeMarker(desktopMode: ClaudeDesktopMode): boolean { + const outcome = mutatePersistedConfig(persisted => recordClaudeDesktopMode(persisted, desktopMode)); + return outcome.status !== "unavailable"; +} + let claudeDesktopToggleFlight: Promise | null = null; async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { @@ -626,6 +666,11 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise` and `NODE_EXTRA_CA_CERTS=/claude-intercept/ca.pem` + into the `env` block of Claude Code's `settings.json` (via `src/claude/intercept/settings.ts`), + creating the local authority first. Only the Claude Code process Desktop spawns for the Code tab + (and its subagents, and any standalone `claude` CLI) reads that env, so only their + `api.anthropic.com` traffic reaches the [Claude intercept pair](../runtime.md#claude-intercept-pair). +- **gateway**: the existing third-party profile written by `src/claude/desktop-3p.ts`; the whole + app switches to the local gateway. It is selected explicitly (`--gateway`, dashboard, or the + legacy `--static|--hybrid|--discovery-only` shape flags, which imply it). + +`resolveClaudeDesktopMode` returns the explicit `claudeCode.desktopMode` when set; otherwise a +persisted `desktopProfile.appliedFingerprint` (an existing gateway install) keeps `gateway`, and a +fresh install resolves to `first-party`. Updates therefore never flip a working gateway install +silently, while new installs land on first-party. `resolveClaudeDesktopApplyMode` narrows an +*implied* first-party to gateway where the intercept pair cannot run (client role or +`claudeCode.intercept.enabled: false`); an explicit `first-party` is refused with +`intercept_disabled` instead of being rewritten. + +Switching is ownership-aware in both directions: a gateway apply first removes the first-party env +(only values anchored on OpenCodex's own CA path); a first-party apply first pivots an owned +gateway profile (`gateway_ours`/`gateway_drifted`) back to standard through +`removeDesktop3pStandardPivot({ replaceWhileEnabled: true })` — the durable switch stays ON, so the +OFF-flow desired-state guard is bypassed on purpose — and fails (`claude_desktop_gateway_removal_failed`) +without writing the env if that pivot cannot complete. It refuses (`foreign_env`) when +`settings.json` already carries a proxy/CA value OpenCodex does not own. +Disabling the integration (native toggle, `ocx ensure` with the durable switch OFF) removes both the +gateway profile and the first-party env. With the switch ON in first-party mode, `ocx ensure` +re-applies a stale env (the proxy port follows the public port). + +Surfaces: `ocx claude desktop apply [--first-party|--gateway]` in `src/cli/claude-desktop.ts`; +`POST /api/claude-desktop/apply` with `mode` ∈ `first-party|gateway|static|hybrid|discovery` and +`GET /api/claude-desktop/status` (`mode`, `firstParty.{applied,stale,interceptEnabled,interceptRunning,proxyPort,caCertPath}`) +in `src/server/management/agent-settings-routes.ts`; the native toggle in +`src/server/management/native-integration-routes.ts` applies the resolved mode on enable. Managed +Windows policy health only applies in gateway mode, because first-party never touches Desktop's own +configuration. Ordinary Chat-tab traffic is out of scope for both modes. + ## Connected Claude Desktop profiles The connection's local Codex readiness check follows the [selected-runtime probe contract](../runtime.md#remote-hub-hardening-ownership); general status hands its resolved command to this check instead of probing the version twice. diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts new file mode 100644 index 00000000000..7b391f98dae --- /dev/null +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -0,0 +1,268 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applyDesktopFirstParty, + inspectDesktopFirstParty, + removeDesktopFirstParty, + resolveClaudeDesktopApplyMode, + resolveClaudeDesktopMode, +} from "../../src/claude/desktop-first-party"; +import { parseDesktopApplyArgs } from "../../src/cli/claude-desktop"; +import { ensureClaudeDesktopMatchesDesired } from "../../src/cli/ensure-desired-integrations"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { setIntegrationEnabled } from "../../src/codex/desired-state"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root = ""; +let library = ""; +let claudeDir = ""; +const previous: Record = {}; +const ENV_KEYS = ["OPENCODEX_HOME", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "CLAUDE_CONFIG_DIR"] as const; + +function config(extra: Partial = {}): OcxConfig { + return { port: 10100, providers: {}, defaultProvider: "openai", ...extra } as OcxConfig; +} + +function settings(): { env?: Record; [key: string]: unknown } { + return JSON.parse(readFileSync(join(claudeDir, "settings.json"), "utf8")) as { env?: Record }; +} + +async function dispatch(path: string, init?: RequestInit, inputConfig: OcxConfig = config()) { + const url = new URL(`http://127.0.0.1:10100${path}`); + const response = await handleManagementAPI(new Request(url, { + ...init, + headers: { Host: url.host, "Content-Type": "application/json", ...(init?.headers ?? {}) }, + }), url, inputConfig, {}); + return { status: response!.status, body: await response!.json() as Record }; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-desktop-1p-")); + library = join(root, "desktop-library"); + claudeDir = join(root, "claude"); + for (const key of ENV_KEYS) previous[key] = process.env[key]; + process.env.OPENCODEX_HOME = root; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; + process.env.CLAUDE_CONFIG_DIR = claudeDir; + writeFileSync(join(root, "config.json"), JSON.stringify(config())); +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + removeTreeWithRetry(root); +}); + +test("mode resolution: explicit wins, applied gateway fingerprint keeps gateway, otherwise first-party", () => { + expect(resolveClaudeDesktopMode(config())).toBe("first-party"); + expect(resolveClaudeDesktopMode(config({ claudeCode: { desktopMode: "gateway" } }))).toBe("gateway"); + expect(resolveClaudeDesktopMode(config({ + claudeCode: { desktopProfile: { version: 1, assignments: {}, defaults: { opus: null, fable: null, sonnet: null, haiku: null }, appliedFingerprint: "abc" } }, + }))).toBe("gateway"); + expect(resolveClaudeDesktopMode(config({ + claudeCode: { + desktopMode: "first-party", + desktopProfile: { version: 1, assignments: {}, defaults: { opus: null, fable: null, sonnet: null, haiku: null }, appliedFingerprint: "abc" }, + }, + }))).toBe("first-party"); +}); + +test("implied apply mode falls back to gateway where the intercept proxy cannot run", () => { + expect(resolveClaudeDesktopApplyMode(config())).toBe("first-party"); + expect(resolveClaudeDesktopApplyMode(config({ runtimeRole: "client" }))).toBe("gateway"); + expect(resolveClaudeDesktopApplyMode(config({ claudeCode: { intercept: { enabled: false } } }))).toBe("gateway"); + // An explicit choice is never silently rewritten. + expect(resolveClaudeDesktopApplyMode(config({ runtimeRole: "client", claudeCode: { desktopMode: "first-party" } }))).toBe("first-party"); +}); + +test("CLI apply flags: default first-party, legacy shape flags imply gateway, conflicts rejected", () => { + expect(parseDesktopApplyArgs([], config())).toEqual({ target: { kind: "first-party" } }); + expect(parseDesktopApplyArgs(["--first-party"], config())).toEqual({ target: { kind: "first-party" } }); + expect(parseDesktopApplyArgs(["--gateway"], config())).toEqual({ target: { kind: "gateway", mode: "static" } }); + expect(parseDesktopApplyArgs(["--hybrid"], config())).toEqual({ target: { kind: "gateway", mode: "hybrid" } }); + expect(parseDesktopApplyArgs(["--gateway", "--discovery-only"], config())).toEqual({ target: { kind: "gateway", mode: "discovery" } }); + expect(parseDesktopApplyArgs([], config({ claudeCode: { desktopMode: "gateway" } }))).toEqual({ target: { kind: "gateway", mode: "static" } }); + expect("error" in parseDesktopApplyArgs(["--first-party", "--gateway"], config())).toBe(true); + expect("error" in parseDesktopApplyArgs(["--first-party", "--static"], config())).toBe(true); + expect("error" in parseDesktopApplyArgs(["--bogus"], config())).toBe(true); +}); + +test("first-party apply writes only the proxy env, creates the CA, and removes cleanly", () => { + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(claudeDir, "settings.json"), JSON.stringify({ theme: "dark", env: { FOO: "bar" } })); + const applied = applyDesktopFirstParty(config()); + expect(applied.ok).toBe(true); + if (!applied.ok) return; + expect(applied.proxyPort).toBe(10200); + expect(existsSync(applied.env.NODE_EXTRA_CA_CERTS)).toBe(true); + expect(applied.env.NODE_EXTRA_CA_CERTS.startsWith(root)).toBe(true); + const written = settings(); + expect(written.theme).toBe("dark"); + expect(written.env).toEqual({ + FOO: "bar", + HTTPS_PROXY: "http://127.0.0.1:10200", + NODE_EXTRA_CA_CERTS: applied.env.NODE_EXTRA_CA_CERTS, + }); + expect(inspectDesktopFirstParty(config()).applied).toBe(true); + // Desktop's own library is untouched: first-party never installs a gateway profile. + expect(existsSync(library)).toBe(false); + + // A port change makes the env stale; re-apply refreshes it. + expect(inspectDesktopFirstParty(config({ port: 10300 })).stale).toBe(true); + const refreshed = applyDesktopFirstParty(config({ port: 10300 })); + expect(refreshed.ok && refreshed.changed).toBe(true); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10400"); + + const removed = removeDesktopFirstParty(); + expect(removed).toMatchObject({ ok: true, changed: true }); + expect(settings()).toEqual({ theme: "dark", env: { FOO: "bar" } }); +}); + +test("first-party apply refuses foreign proxy env and disabled intercept", () => { + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(claudeDir, "settings.json"), JSON.stringify({ env: { HTTPS_PROXY: "http://corp-proxy:3128" } })); + expect(applyDesktopFirstParty(config())).toMatchObject({ ok: false, reason: "foreign_env" }); + expect(settings().env).toEqual({ HTTPS_PROXY: "http://corp-proxy:3128" }); + expect(removeDesktopFirstParty()).toMatchObject({ ok: true, changed: false }); + expect(applyDesktopFirstParty(config({ runtimeRole: "client" }))).toMatchObject({ ok: false, reason: "intercept_disabled" }); +}); + +test("POST /api/claude-desktop/apply defaults to first-party and gateway mode replaces it", async () => { + const first = await dispatch("/api/claude-desktop/apply", { method: "POST" }); + expect(first.status).toBe(200); + expect(first.body).toMatchObject({ ok: true, mode: "first-party", applied: true, changed: true, proxyPort: 10200 }); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(saved.claudeCode?.desktopMode).toBe("first-party"); + expect(saved.clientIntegrations?.["claude-desktop"]).not.toBe(false); + + const status = await dispatch("/api/claude-desktop/status"); + expect(status.body).toMatchObject({ + mode: "first-party", + applied: true, + stale: false, + drift: false, + firstParty: { applied: true, interceptEnabled: true, proxyPort: 10200 }, + }); + expect(status.body.health.ok).toBe(true); + + const gateway = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }); + expect(gateway.status).toBe(200); + expect(settings().env?.HTTPS_PROXY).toBeUndefined(); + expect(settings().env?.NODE_EXTRA_CA_CERTS).toBeUndefined(); + const afterGateway = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(afterGateway.claudeCode?.desktopMode).toBe("gateway"); + expect(afterGateway.claudeCode?.desktopProfile?.appliedFingerprint).toBeTruthy(); + + // Switching back replaces the gateway profile with the first-party env in one apply. + const back = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "first-party" }) }, afterGateway); + expect(back.status).toBe(200); + expect(back.body).toMatchObject({ ok: true, mode: "first-party", applied: true, gatewayRemoved: true }); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + const afterBack = await dispatch("/api/claude-desktop/status", {}, afterGateway); + expect(afterBack.body).toMatchObject({ mode: "first-party", applied: true, stale: false, drift: false, desiredEnabled: true }); + expect(["not_installed", "no_owned_state", "standard"]).toContain(afterBack.body.observedKind); + // The gateway apply marker goes with the profile: without the explicit mode field the + // saved config must still resolve to first-party, not to the gateway it just replaced. + const savedBack = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(savedBack.claudeCode?.desktopProfile?.appliedFingerprint).toBeUndefined(); + expect(savedBack.claudeCode?.desktopProfile?.appliedAt).toBeUndefined(); + expect(savedBack.claudeCode?.desktopProfile?.assignments).toBeDefined(); + expect(resolveClaudeDesktopMode({ claudeCode: { ...savedBack.claudeCode, desktopMode: undefined } })).toBe("first-party"); +}); + +test("native toggle: enable applies first-party by default and disable removes the env", async () => { + const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }); + expect(enabled.status).toBe(200); + expect(enabled.body).toMatchObject({ ok: true, changed: true, state: "current", desiredEnabled: true }); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + + const list = await dispatch("/api/native-integrations"); + const desktop = (list.body.clients as Array<{ clientId: string; state: string }>).find(client => client.clientId === "claude-desktop"); + expect(desktop?.state).toBe("current"); + + const disabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: false }) }); + expect(disabled.status).toBe(200); + expect(disabled.body).toMatchObject({ ok: true, changed: true, state: "absent", desiredEnabled: false }); + expect(settings().env?.HTTPS_PROXY).toBeUndefined(); +}); + +test("native toggle: enabling into explicit first-party pivots an applied gateway profile and saves the mode marker", async () => { + const gateway = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }); + expect(gateway.status).toBe(200); + const afterGateway = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(afterGateway.claudeCode?.desktopProfile?.appliedFingerprint).toBeTruthy(); + // The operator chose first-party in config while the gateway profile is still on disk. + const chosen = { ...afterGateway, claudeCode: { ...afterGateway.claudeCode, desktopMode: "first-party" as const } }; + writeFileSync(join(root, "config.json"), JSON.stringify(chosen)); + + const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }, chosen); + expect(enabled.status).toBe(200); + expect(enabled.body).toMatchObject({ ok: true, changed: true, state: "current", desiredEnabled: true }); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(saved.claudeCode?.desktopMode).toBe("first-party"); + expect(saved.claudeCode?.desktopProfile?.appliedFingerprint).toBeUndefined(); + const status = await dispatch("/api/claude-desktop/status", {}, chosen); + expect(status.body).toMatchObject({ mode: "first-party", applied: true, drift: false }); + expect(["not_installed", "no_owned_state", "standard"]).toContain(status.body.observedKind); +}); + +test("native toggle: enabling into gateway saves the gateway mode marker like the apply route", async () => { + // No explicit mode: the disabled intercept is what implies gateway, so the saved marker + // can only come from the toggle itself. + const chosen = config({ claudeCode: { intercept: { enabled: false } } }); + writeFileSync(join(root, "config.json"), JSON.stringify(chosen)); + const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }, chosen); + expect(enabled.status).toBe(200); + expect(enabled.body).toMatchObject({ ok: true, state: "current", message: "Claude Desktop integration enabled." }); + expect(existsSync(join(claudeDir, "settings.json"))).toBe(false); + const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(saved.claudeCode?.desktopMode).toBe("gateway"); + expect(resolveClaudeDesktopMode(saved)).toBe("gateway"); +}); + +test("ensure warns instead of touching a gateway profile that contradicts an explicit first-party marker", () => { + const logs: string[] = []; + const deps = { + loadConfig: () => config({ claudeCode: { desktopMode: "first-party" } }), + stripGrokConfig: () => ({ ok: true, changed: false, message: "" }), + syncGrokConfig: async () => ({ ok: true, changed: false, message: "" }), + removeDesktop3pStandardPivot: () => { throw new Error("must not pivot from ensure"); }, + inspectDesktop3pConfigLibrary: () => ({ kind: "gateway_ours" as const, libraryPath: library, activeProfilePath: null, ownedFiles: [] }), + applyDesktopFirstParty: () => { throw new Error("must not apply over a live gateway"); }, + log: (message: string) => { logs.push(message); }, + error: (message: string) => { logs.push(message); }, + }; + ensureClaudeDesktopMatchesDesired(deps as unknown as Parameters[0]); + expect(logs.some(line => line.includes("gateway profile is still applied"))).toBe(true); +}); + +test("ensure reconciles first-party env: refreshes when ON and stale, removes when OFF", () => { + const applied = applyDesktopFirstParty(config({ port: 10300 })); + expect(applied.ok).toBe(true); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10400"); + + const logs: string[] = []; + const deps = { + loadConfig: () => config(), + stripGrokConfig: () => ({ ok: true, changed: false, message: "" }), + syncGrokConfig: async () => ({ ok: true, changed: false, message: "" }), + removeDesktop3pStandardPivot: () => ({ ok: true as const, changed: false, kind: "noop" as const, libraryPath: library }), + log: (message: string) => { logs.push(message); }, + error: (message: string) => { logs.push(message); }, + }; + ensureClaudeDesktopMatchesDesired(deps); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + expect(logs.some(line => line.includes("first-party env refreshed"))).toBe(true); + + expect(setIntegrationEnabled("claude-desktop", false).ok).toBe(true); + ensureClaudeDesktopMatchesDesired({ ...deps, loadConfig: () => config({ clientIntegrations: { "claude-desktop": false } }) }); + expect(settings().env?.HTTPS_PROXY).toBeUndefined(); + expect(settings().env?.NODE_EXTRA_CA_CERTS).toBeUndefined(); +}); diff --git a/tests/claude-integration/claude-management-api.test.ts b/tests/claude-integration/claude-management-api.test.ts index 60bc012fa25..751b838ee99 100644 --- a/tests/claude-integration/claude-management-api.test.ts +++ b/tests/claude-integration/claude-management-api.test.ts @@ -707,7 +707,11 @@ test("Claude Desktop profile GET, PUT and apply round-trip four-family assignmen const discovery = await fetch(new URL("/v1/models?flavor=anthropic", server.url)).then(r => r.json()) as { data: Array<{ id: string }> }; expect(discovery.data.some(model => model.id === alias)).toBe(true); - const apply = await fetch(new URL("/api/claude-desktop/apply", server.url), { method: "POST" }); + const apply = await fetch(new URL("/api/claude-desktop/apply", server.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "gateway" }), + }); expect(apply.status).toBe(200); const result = await apply.json() as { path: string; applied: boolean }; expect(result.applied).toBe(true); @@ -815,6 +819,14 @@ test("Claude Desktop apply validates the mode body", async () => { expect(badProfile.status).toBe(400); expect(loadConfig()).toEqual(beforeBadProfile); + const badGatewayProfile = await fetch(new URL("/api/claude-desktop/apply", server.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "gateway", profile: { version: 2 } }), + }); + expect(badGatewayProfile.status).toBe(400); + expect(loadConfig()).toEqual(beforeBadProfile); + const hybrid = await fetch(new URL("/api/claude-desktop/apply", server.url), { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/tests/codex-integration/native-claude-desktop-toggle.test.ts b/tests/codex-integration/native-claude-desktop-toggle.test.ts index bc579cdffbf..56c28ca34da 100644 --- a/tests/codex-integration/native-claude-desktop-toggle.test.ts +++ b/tests/codex-integration/native-claude-desktop-toggle.test.ts @@ -14,12 +14,16 @@ let root = ""; let library = ""; let previousHome: string | undefined; let previousLibrary: string | undefined; +let previousClaudeConfigDir: string | undefined; +// These tests pin the gateway (third-party profile) path; first-party is covered by +// tests/claude-integration/claude-desktop-first-party.test.ts. function config(): OcxConfig { return { port: 10100, providers: {}, defaultProvider: "openai", + claudeCode: { desktopMode: "gateway" }, } as OcxConfig; } @@ -84,8 +88,10 @@ beforeEach(() => { library = join(root, "desktop-library"); previousHome = process.env.OPENCODEX_HOME; previousLibrary = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; process.env.OPENCODEX_HOME = root; process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; + process.env.CLAUDE_CONFIG_DIR = join(root, "claude"); writeFileSync(join(root, "config.json"), JSON.stringify(config())); }); @@ -94,6 +100,8 @@ afterEach(() => { else process.env.OPENCODEX_HOME = previousHome; if (previousLibrary === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previousLibrary; + if (previousClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir; removeTreeWithRetry(root); }); @@ -237,7 +245,7 @@ test("auto-apply re-reads desired state after catalog fetch and skips a concurre assignments: {}, defaults: { opus: null, fable: null, sonnet: null, haiku: null }, }; - const persisted = { ...config(), claudeCode: { desktopProfile: profile, injectAgents: false } }; + const persisted = { ...config(), claudeCode: { desktopMode: "gateway" as const, desktopProfile: profile, injectAgents: false } }; writeFileSync(join(root, "config.json"), JSON.stringify(persisted)); writeFileSync(join(root, "config.json.bak"), JSON.stringify(persisted)); writeFileSync(join(root, "config.json"), JSON.stringify(persisted)); @@ -302,7 +310,7 @@ test("explicit enable re-reads desired state after catalog fetch and skips a con }); test("explicit enable honors the Claude Desktop native-model opt-out", async () => { - const persisted = { ...config(), claudeCode: { desktopNativeModels: false } }; + const persisted = { ...config(), claudeCode: { desktopMode: "gateway" as const, desktopNativeModels: false } }; writeFileSync(join(root, "config.json"), JSON.stringify(persisted)); let nativeSlugs: string[] | undefined; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 7076cf8bf17..d40a7a47718 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1419,5 +1419,6 @@ "claude-intercept-local-ca.test.ts": "claude-integration", "claude-intercept-proxy.test.ts": "claude-integration", "claude-intercept-settings.test.ts": "claude-integration", + "claude-desktop-first-party.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server" }