From b8850f822ddd9fc9a01c955448d591d0da619c28 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sun, 9 Aug 2026 15:39:18 +0900 Subject: [PATCH 1/4] fix(claude): rebase hand-edit guard after desktop apply --- src/config.ts | 18 +++++++++ .../management/agent-settings-routes.ts | 5 ++- tests/native-claude-desktop-toggle.test.ts | 40 +++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index 3d0d1a239fa..3d5229a71ca 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2515,6 +2515,24 @@ export function claudeCodeBaselineArmed(config: OcxConfig): boolean { return claudeCodeBaseline.has(config); } +/** + * Adopt a field-scoped Claude Code write into a long-lived config snapshot. + * + * Scoped writers commit against the current file rather than serializing the + * whole snapshot. Mirror that committed subtree and rebase the hand-edit guard + * together so a later unrelated save does not mistake the scoped write for an + * outstanding in-memory mutation. + */ +export function adoptPersistedClaudeCode( + config: OcxConfig, + persistedClaudeCode: OcxConfig["claudeCode"], +): void { + config.claudeCode = structuredClone(persistedClaudeCode); + if (claudeCodeBaseline.has(config)) { + claudeCodeBaseline.set(config, structuredClone(persistedClaudeCode)); + } +} + /** * Structural compare of parsed subtrees. NOT `JSON.stringify`: key order must not * decide whether a user's hand edit survives. diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 17f7c00b328..b725e45c04f 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import { + adoptPersistedClaudeCode, DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, hasOwnProvider, @@ -105,12 +106,12 @@ function persistDesktopProfileField( ): { ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" } { const outcome = mutatePersistedConfig(persisted => { persisted.claudeCode = { ...(persisted.claudeCode ?? {}), desktopProfile }; - return { changed: true, value: true }; + return { changed: true, value: structuredClone(persisted.claudeCode) }; }); // Only mirror into memory once the durable write actually landed; an // `unavailable` outcome must not leave the snapshot claiming a saved profile. if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason }; - config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile }; + adoptPersistedClaudeCode(config, outcome.value); return { ok: true }; } diff --git a/tests/native-claude-desktop-toggle.test.ts b/tests/native-claude-desktop-toggle.test.ts index 9b1844f01f0..eae66244826 100644 --- a/tests/native-claude-desktop-toggle.test.ts +++ b/tests/native-claude-desktop-toggle.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleManagementAPI } from "../src/server/management-api"; import { setIntegrationEnabled } from "../src/codex/desired-state"; +import { armClaudeCodeBaseline, saveConfigPreservingClaudeCode } from "../src/config"; import type { ManagementApiDeps } from "../src/server/management/context"; import type { OcxConfig } from "../src/types"; @@ -244,3 +245,42 @@ test("POST /apply leaves the reused server snapshot agreeing with disk", async ( }, deps, staleSnapshot); expect(persistedIntent()).toBeUndefined(); }); + +test("POST /apply rebases the Claude hand-edit guard after its scoped profile save", async () => { + const snapshot = { + ...config(), + claudeCode: { authMode: "subscription" as const, nativePassthrough: true }, + }; + writeFileSync(join(root, "config.json"), JSON.stringify(snapshot)); + armClaudeCodeBaseline(snapshot); + + const response = await dispatch("/api/claude-desktop/apply", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "static" }), + }, { + fetchAllModels: async () => [], + writeDesktop3pConfig: () => ({ written: true, path: join(library, "applied.json"), fingerprint: "fingerprint" }), + }, snapshot); + expect(response!.status).toBe(200); + + const handEdited = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + handEdited.claudeCode = { + ...handEdited.claudeCode, + authMode: "proxy", + nativePassthrough: false, + anthropicBaseUrl: "http://127.0.0.1:19999", + }; + writeFileSync(join(root, "config.json"), JSON.stringify(handEdited)); + + snapshot.disabledModels = ["unrelated/model"]; + saveConfigPreservingClaudeCode(snapshot); + + const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(saved.claudeCode).toMatchObject({ + authMode: "proxy", + nativePassthrough: false, + anthropicBaseUrl: "http://127.0.0.1:19999", + }); + expect(saved.disabledModels).toEqual(["unrelated/model"]); +}); From f07d4a80e9d10c238afad092df6522808d5aa096 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Mon, 21 Sep 2026 01:06:29 +0000 Subject: [PATCH 2/4] ci: retrigger checks (empty commit; dev merge conflicts) From 9713017ea5905b30c0ba20912add3a9d07611a8f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:20:58 +0000 Subject: [PATCH 3/4] fix(config): drop resurrected god-file tail, move adoptPersistedClaudeCode into live-reconcile The dev merge kept both the facade re-exports and ~660 lines of the pre-split config.ts bodies, producing 30 duplicate exports and a Bun SyntaxError (Cannot export a duplicate function name: 'websocketsEnabled') that broke every bun run entrypoint, including linux-systemd service install. The branch-only adoptPersistedClaudeCode now lives in src/config/live-reconcile.ts next to the baseline guards and also refreshes liveConfigBaseline, matching adoptPersistedProviderIntoLiveConfig; the facade re-exports it. src/config.ts stays at its 460-line cap. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/config.ts | 663 +---------------------------------- src/config/live-reconcile.ts | 20 ++ 2 files changed, 21 insertions(+), 662 deletions(-) diff --git a/src/config.ts b/src/config.ts index 418af5d711c..3eeec635c64 100644 --- a/src/config.ts +++ b/src/config.ts @@ -134,7 +134,7 @@ export { } from "./config/mutation-lock"; export { armClaudeCodeBaseline, - adoptPersistedProviderIntoLiveConfig, + adoptPersistedClaudeCode, adoptPersistedProviderIntoLiveConfig, claudeCodeBaselineArmed, reconcileLiveConfigFromDisk, saveConfigPreservingClaudeCode, @@ -458,664 +458,3 @@ export function mutatePersistedConfig( return { status: "unavailable", reason: "conflict" }; }); } - -export function websocketsEnabled(config: Pick): boolean { - return config.websockets === true; -} - -// --------------------------------------------------------------------------- -// Hand-edit protection for the `claudeCode` subtree (devlog 260726_claude_auth_auto/040 H1). -// -// `saveConfig` serializes the WHOLE config object, so ANY service-time save — a model -// visibility toggle, a 429 key rotation on the request path — rewrites `claudeCode` -// from whatever the long-lived server config happens to hold. A user who hand-edits -// `config.json` while the proxy runs then watches their edit vanish for no visible -// reason (issue #488). Enumerating `claudeCode` mutators cannot fix that; the guard has -// to live in ONE save wrapper that every live-config writer goes through. -// --------------------------------------------------------------------------- - -/** - * Baseline keyed on the CONFIG INSTANCE, never a module global: a second `loadConfig()` - * elsewhere must not refresh the baseline the long-lived server config is judged - * against, or a later stale save would masquerade as "our own change". - */ -const claudeCodeBaseline = new WeakMap(); - -/** - * The live config retains the address of the socket Bun actually opened, while - * this map retains the operator's desired address for the next process start. - * Keeping them separate prevents an unrelated live save from restoring a stale - * externally exposed bind after OAuth adopted a newer loopback disk config. - */ -type PersistedServerBinding = Pick; - -const persistedLiveServerBinding = new WeakMap(); - -/** - * Arm the baseline for a long-lived config. MANDATORY at `startServer`, not lazy on - * first save — arming lazily would lose exactly the hand edit made before that first - * save, which is the case the guard exists for. - */ -export function armClaudeCodeBaseline(config: OcxConfig): void { - claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); -} - -/** Test seam only: is this instance armed? */ -export function claudeCodeBaselineArmed(config: OcxConfig): boolean { - return claudeCodeBaseline.has(config); -} - -/** - * Adopt a field-scoped Claude Code write into a long-lived config snapshot. - * - * Scoped writers commit against the current file rather than serializing the - * whole snapshot. Mirror that committed subtree and rebase the hand-edit guard - * together so a later unrelated save does not mistake the scoped write for an - * outstanding in-memory mutation. - */ -export function adoptPersistedClaudeCode( - config: OcxConfig, - persistedClaudeCode: OcxConfig["claudeCode"], -): void { - config.claudeCode = structuredClone(persistedClaudeCode); - if (claudeCodeBaseline.has(config)) { - claudeCodeBaseline.set(config, structuredClone(persistedClaudeCode)); - } -} - -/** - * Structural compare of parsed subtrees. NOT `JSON.stringify`: key order must not - * decide whether a user's hand edit survives. - */ -function deepEqual(a: unknown, b: unknown): boolean { - if (a === b) return true; - if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false; - if (Array.isArray(a) !== Array.isArray(b)) return false; - if (Array.isArray(a) && Array.isArray(b)) { - return a.length === b.length && a.every((item, index) => deepEqual(item, b[index])); - } - const left = a as Record; - const right = b as Record; - // `undefined` values and absent keys are the same thing after a JSON round-trip. - const keys = new Set([...Object.keys(left), ...Object.keys(right)]); - for (const key of keys) { - if (left[key] === undefined && right[key] === undefined) continue; - if (!deepEqual(left[key], right[key])) return false; - } - return true; -} - -const MISSING_CONFIG_VALUE = Symbol("missing-config-value"); -type ConfigMergeValue = unknown | typeof MISSING_CONFIG_VALUE; - -function isPlainConfigRecord(value: ConfigMergeValue): value is Record { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - -function ownConfigValue(record: Record, key: string): ConfigMergeValue { - return Object.hasOwn(record, key) ? record[key] : MISSING_CONFIG_VALUE; -} - -function cloneConfigValue(value: ConfigMergeValue): ConfigMergeValue { - return value === MISSING_CONFIG_VALUE ? value : structuredClone(value); -} - -function reconcileConfigRecord( - live: Record, - baseline: Record, - persisted: Record, - skippedKeys?: ReadonlySet, -): void { - const keys = new Set([...Object.keys(baseline), ...Object.keys(live), ...Object.keys(persisted)]); - for (const key of keys) { - if (skippedKeys?.has(key)) continue; - const merged = reconcileConfigValue( - ownConfigValue(baseline, key), - ownConfigValue(live, key), - ownConfigValue(persisted, key), - ); - if (merged === MISSING_CONFIG_VALUE) delete live[key]; - else live[key] = merged; - } -} - -function reconcileConfigValue( - baseline: ConfigMergeValue, - live: ConfigMergeValue, - persisted: ConfigMergeValue, -): ConfigMergeValue { - const liveChanged = !deepEqual(live, baseline); - const persistedChanged = !deepEqual(persisted, baseline); - - if (!liveChanged) { - if (live !== MISSING_CONFIG_VALUE && Array.isArray(live) && Array.isArray(persisted)) { - live.splice(0, live.length, ...structuredClone(persisted)); - return live; - } - if (isPlainConfigRecord(live) && isPlainConfigRecord(persisted)) { - reconcileConfigRecord( - live, - isPlainConfigRecord(baseline) ? baseline : {}, - persisted, - ); - return live; - } - return cloneConfigValue(persisted); - } - - if (!persistedChanged) return live; - - if (isPlainConfigRecord(live) - && isPlainConfigRecord(persisted) - && (baseline === MISSING_CONFIG_VALUE || isPlainConfigRecord(baseline))) { - reconcileConfigRecord( - live, - isPlainConfigRecord(baseline) ? baseline : {}, - persisted, - ); - } - // Same-leaf conflicts prefer the pending live management mutation. - return live; -} - -/** - * Reconcile an async OAuth disk commit into the shared live config without erasing - * management mutations that have not saved yet. The baseline is a normalized disk - * snapshot from immediately before login; disjoint object edits merge recursively, - * while same-leaf conflicts prefer live state. - */ -export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline: OcxConfig): void { - const diagnostics = readConfigDiagnostics(); - if (diagnostics.source === "fallback") { - throw new Error(`OAuth config reconciliation failed: ${diagnostics.error ?? "invalid config file"}`); - } - const persisted = diagnostics.config; - const claudeGuardArmed = claudeCodeBaseline.has(config); - const pendingLiveClaudeMutation = claudeGuardArmed - && !deepEqual(config.claudeCode, claudeCodeBaseline.get(config)); - - persistedLiveServerBinding.set(config, { - port: persisted.port, - ...(persisted.hostname !== undefined ? { hostname: persisted.hostname } : {}), - }); - - reconcileConfigRecord( - config as unknown as Record, - persistedBaseline as unknown as Record, - persisted as unknown as Record, - new Set(["hostname", "port", ...(claudeGuardArmed ? ["claudeCode"] : [])]), - ); - - if (claudeGuardArmed && !pendingLiveClaudeMutation) { - if (persisted.claudeCode === undefined) delete config.claudeCode; - else config.claudeCode = structuredClone(persisted.claudeCode); - claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); - } -} - -/** The literal file, with no schema merge or default injection. */ -function readRawConfigJson(): Record | undefined { - try { - const configPath = getConfigPath(); - if (!existsSync(configPath)) return undefined; - const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, ""); - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; - return parsed as Record; - } catch { - // Unreadable or corrupt: behave exactly as before. Never fail a save over protection. - return undefined; - } -} - -/** - * Read only schema-valid binding fields from the literal file. Missing fields mean - * their schema defaults; malformed fields keep the last known persisted value. - */ -function readPersistedServerBinding( - raw: Record, - baseline: PersistedServerBinding, -): PersistedServerBinding { - const port = raw.port === undefined - ? 10100 - : (typeof raw.port === "number" - && Number.isInteger(raw.port) - && raw.port >= 0 - && raw.port <= 65535 - ? raw.port - : baseline.port); - const hostname = raw.hostname === undefined - ? undefined - : (typeof raw.hostname === "string" ? raw.hostname : baseline.hostname); - return { port, ...(hostname !== undefined ? { hostname } : {}) }; -} - -/** - * The save entry point for every writer holding a LIVE server config. - * - * Conflict policy, chosen deliberately: - * - disk changed, we did not → their hand edit wins; - * - disk changed AND we changed → our change wins and the baseline rebases, so the - * user's next edit starts from the new value (a three-way merge is out of scope); - * - file missing/unreadable → save what we have, no throw. - * - * Scope residual: only `claudeCode` is reconciled. A hand edit to `providers` is still - * clobbered — recorded and asserted in tests so it cannot drift into an assumed - * guarantee. - */ -export function saveConfigPreservingClaudeCode(config: OcxConfig): void { - withConfigMutationLockSync(() => { - const bindingBaseline = persistedLiveServerBinding.get(config); - const onDisk = claudeCodeBaseline.has(config) || bindingBaseline - ? readRawConfigJson() - : undefined; - if (claudeCodeBaseline.has(config)) { - if (onDisk !== undefined) { - const baseline = claudeCodeBaseline.get(config); - const persistedClaudeCode = normalizePersistedClaudeCode(onDisk.claudeCode); - const diskChanged = !deepEqual(persistedClaudeCode, baseline); - const weChanged = !deepEqual(config.claudeCode, baseline); - if (diskChanged && !weChanged) { - config.claudeCode = persistedClaudeCode; - } - } - } - const persistedBinding = bindingBaseline && onDisk - ? readPersistedServerBinding(onDisk, bindingBaseline) - : bindingBaseline; - if (persistedBinding) { - const persistedConfig: OcxConfig = { ...config, port: persistedBinding.port }; - if (persistedBinding.hostname === undefined) delete persistedConfig.hostname; - else persistedConfig.hostname = persistedBinding.hostname; - if (persistConfigUnlocked(persistedConfig)) bumpGenerationForCooperatingConfigWrite(); - persistedLiveServerBinding.set(config, persistedBinding); - } else { - if (persistConfigUnlocked(config)) bumpGenerationForCooperatingConfigWrite(); - } - if (claudeCodeBaseline.has(config)) { - claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); - } - }); -} - -export function codexAutoStartEnabled(config: Pick): boolean { - return config.codexAutoStart !== false; -} - -export const CODEX_SHIM_AUTO_RESTORE_ENV = "OPENCODEX_CODEX_SHIM_AUTO_RESTORE"; - -export function codexShimAutoRestoreEnabled( - config: Pick, - env: NodeJS.ProcessEnv = process.env, -): boolean { - return config.codexShimAutoRestore !== false && env[CODEX_SHIM_AUTO_RESTORE_ENV] !== "0"; -} - -export function multiAgentGuidanceEnabled( - config: Pick, -): boolean { - return config.multiAgentGuidanceEnabled !== false; -} - -export function getDefaultConfig(): OcxConfig { - // Fresh-install default: works out of the box with Codex's ChatGPT OAuth (no API key). - // gpt-* requests forward the caller's incoming OAuth headers to the ChatGPT backend. - // Adding extra providers (e.g. opencode-go) and switching defaultProvider is a user/runtime choice. - return { - port: 10100, - managementUsageMaxReadBytes: 64 * 1024 * 1024, - appOwnedMemoryBudgetMb: DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024), - // Fresh/re-initialized configs are already written in the current three-tier - // OpenAI shape. Mark them as such so startup does not mistake them for a - // legacy config and collide with an immutable backup from an earlier setup. - openaiProviderTierVersion: OPENAI_PROVIDER_TIER_VERSION, - providers: { - openai: { - adapter: "openai-responses", - baseUrl: "https://chatgpt.com/backend-api/codex", - authMode: "forward", - codexAccountMode: "pool", - }, - }, - defaultProvider: "openai", - subagentModels: [...DEFAULT_SUBAGENT_MODELS], - multiAgentGuidanceEnabled: true, - websockets: false, - codexAutoStart: true, - codexShimAutoRestore: true, - }; -} - -export function resolveEnvValue(value: string | undefined): string | undefined { - if (!value) return undefined; - const match = value.match(/^\$\{(\w+)\}$/); - if (match) return process.env[match[1]]; - if (value.startsWith("$")) return process.env[value.slice(1)]; - return value; -} - -/** - * Mirror `config.proxy` into HTTP(S)_PROXY env vars so Bun's native fetch routes every outbound - * provider call through the proxy — no per-callsite changes (verified: Bun honors these plus - * NO_PROXY). User-set env vars always win; localhost/127.0.0.1 are appended to NO_PROXY so the - * CLI's own health checks and running-proxy API calls stay direct. Call once per process entry - * that makes outbound provider requests (server start, catalog sync). - */ -export function applyProxyEnv(config: OcxConfig): void { - const proxy = resolveEnvValue(config.proxy); - if (!proxy) return; - if (!process.env.HTTP_PROXY?.trim() && !process.env.http_proxy?.trim()) process.env.HTTP_PROXY = proxy; - if (!process.env.HTTPS_PROXY?.trim() && !process.env.https_proxy?.trim()) process.env.HTTPS_PROXY = proxy; - const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? ""; - const entries = existing.split(",").map(s => s.trim()).filter(Boolean); - const seen = new Set(entries.map(e => e.toLowerCase())); - for (const host of ["localhost", "127.0.0.1", "::1", "[::1]"]) { - if (!seen.has(host)) { - entries.push(host); - seen.add(host); - } - } - process.env.NO_PROXY = entries.join(","); -} - -export function writePid(pid: number): void { - const dir = getConfigDir(); - // Guard before ANY directory mutation (mkdir or chmod), not just the write. - assertNotRealHomeUnderTest(dir); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true, mode: 0o700 }); - } else { - hardenConfigDir(); - } - atomicWriteFile(getPidPath(), String(pid)); -} - -export type RuntimePortState = { - pid: number; - port: number; - hostname?: string; - /** Per-process proof key; protected by the config directory and never served. */ - attestationSecret?: string; -}; - -function isValidRuntimePortState(value: unknown): value is RuntimePortState { - if (!value || typeof value !== "object") return false; - const state = value as Record; - const hostnameOk = state.hostname === undefined || typeof state.hostname === "string"; - const attestationOk = state.attestationSecret === undefined || isLocalAttestationSecret(state.attestationSecret); - return Number.isSafeInteger(state.pid) - && Number(state.pid) > 0 - && Number.isInteger(state.port) - && Number(state.port) > 0 - && Number(state.port) <= 65535 - && hostnameOk - && attestationOk; -} - -export function writeRuntimePort(state: RuntimePortState): void { - const dir = getConfigDir(); - // Guard before ANY directory mutation (mkdir or chmod), not just the write. - assertNotRealHomeUnderTest(dir); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true, mode: 0o700 }); - } else { - hardenConfigDir(); - } - atomicWriteFile(getRuntimePortPath(), JSON.stringify(state, null, 2) + "\n"); -} - -export function readPid(): number | null { - const pidPath = getPidPath(); - if (!existsSync(pidPath)) return null; - try { - const raw = readFileSync(pidPath, "utf-8").trim(); - const pid = parsePidFile(raw); - if (pid === null) return null; - try { - process.kill(pid, 0); - return isLikelyOcxStartProcess(pid) ? pid : null; - } catch (e: unknown) { - if ((e as NodeJS.ErrnoException).code === "EPERM") { - return isLikelyOcxStartProcess(pid) ? pid : null; - } - return null; - } - } catch { - return null; - } -} - -export function readRuntimePort(expectedPid?: number): RuntimePortState | null { - try { - const parsed = JSON.parse(readFileSync(getRuntimePortPath(), "utf-8")); - if (!isValidRuntimePortState(parsed)) return null; - if (expectedPid !== undefined && parsed.pid !== expectedPid) return null; - return parsed; - } catch { - return null; - } -} - -export function removePid(expectedPid?: number): void { - if (expectedPid !== undefined && readPidFileValue() !== expectedPid) return; - try { - unlinkSync(getPidPath()); - } catch { /* ignore */ } -} - -function warnConfigRepaired(configPath: string, error: z.ZodError): void { - if (warnedConfigFallbacks.has(configPath)) return; - warnedConfigFallbacks.add(configPath); - const fields = error.issues.map(i => i.path.join(".") || "config").join(", "); - console.error(`opencodex config at ${configPath}: repaired missing field(s) [${fields}] with defaults. Your providers and accounts are preserved.`); -} - -export function readPidFileValue(): number | null { - try { - return parsePidFile(readFileSync(getPidPath(), "utf-8")); - } catch { - return null; - } -} - -export function removeRuntimePort(expectedPid?: number): void { - if (expectedPid !== undefined && readRuntimePort(expectedPid) === null) return; - try { - unlinkSync(getRuntimePortPath()); - } catch { /* ignore */ } -} - -/** - * Snapshot-guarded stale-state purge: remove the pid/runtime files only when their content - * still matches what the caller saw BEFORE its liveness probe. A concurrent `ocx start` can - * write fresh records mid-probe; an unconditional purge would erase the new proxy's state. - */ -export function removePidIfValueIs(snapshot: number | null): void { - if (!existsSync(getPidPath())) return; - if (readPidFileValue() !== snapshot) return; - try { - unlinkSync(getPidPath()); - } catch { /* ignore */ } -} - -export function removeRuntimePortIfPidIs(snapshotPid: number | null): void { - const current = readRuntimePort(); - if ((current?.pid ?? null) !== snapshotPid) return; - try { - unlinkSync(getRuntimePortPath()); - } catch { /* ignore */ } -} - -export function parsePidFile(raw: string): number | null { - const trimmed = raw.trim(); - if (!/^\d+$/.test(trimmed)) return null; - const pid = Number.parseInt(trimmed, 10); - return Number.isSafeInteger(pid) && pid > 0 ? pid : null; -} - -export function isOcxStartCommandLine(commandLine: string): boolean { - const normalized = commandLine.toLowerCase().replace(/\\/g, "/"); - // "src/cli.ts" matches pre-restructure installs still running; "src/cli/index.ts" is current. - // `@bitkyc08/.opencodex-*` is npm's in-place rename of the global package during - // `npm install -g` — a Windows service wrapper can respawn from that temp tree - // mid-update, and must still count as ocx for port reclaim. - const hasOcxEntrypoint = normalized.includes("src/cli.ts") - || normalized.includes("src/cli/index.ts") - || normalized.includes("@bitkyc08/opencodex") - || /@bitkyc08\/\.opencodex-/.test(normalized) - || /(?:^|[\s/"'])(?:ocx|opencodex)(?:\.cmd)?(?:$|[\s"'])/.test(normalized); - return hasOcxEntrypoint && /(?:^|[\s"'])start(?:$|[\s"'])/.test(normalized); -} - -/** Per-process memo: waitForProxy/findLiveProxy used to spawn powershell on every 150ms poll. */ -const ocxStartProcessCache = new Map(); -let ocxStartProcessSweepCursor = 0; -let ocxStartProcessProbe: (pid: number) => void = pid => { process.kill(pid, 0); }; - -export function setOcxStartProcessProbeForTests(probe: ((pid: number) => void) | null): void { - ocxStartProcessProbe = probe ?? (pid => { process.kill(pid, 0); }); -} - -export function setOcxStartProcessCacheForTests(entries: Iterable): void { - ocxStartProcessCache.clear(); - for (const [pid, value] of entries) ocxStartProcessCache.set(pid, value); - ocxStartProcessSweepCursor = 0; -} - -export function sweepDeadOcxStartProcessCache(maxProbes = 64): number { - const pids: number[] = []; - let removed = 0; - for (const pid of ocxStartProcessCache.keys()) { - if (Number.isSafeInteger(pid) && pid > 0) pids.push(pid); - else if (ocxStartProcessCache.delete(pid)) removed += 1; - } - if (pids.length === 0 || maxProbes <= 0) { - ocxStartProcessSweepCursor = 0; - return removed; - } - const probeCount = Math.min(Math.floor(maxProbes), pids.length); - const start = ocxStartProcessSweepCursor % pids.length; - for (let offset = 0; offset < probeCount; offset += 1) { - const pid = pids[(start + offset) % pids.length]!; - try { - ocxStartProcessProbe(pid); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ESRCH") continue; - if (ocxStartProcessCache.delete(pid)) removed += 1; - } - } - ocxStartProcessSweepCursor = (start + probeCount) % pids.length; - return removed; -} - -export function ocxStartProcessCacheSizeForTests(): number { - return ocxStartProcessCache.size; -} - -function isLikelyOcxStartProcess(pid: number): boolean { - const cached = ocxStartProcessCache.get(pid); - if (cached !== undefined) return cached; - const commandLine = readProcessCommandLine(pid); - if (commandLine === undefined) return false; - const ok = isOcxStartCommandLine(commandLine); - ocxStartProcessCache.set(pid, ok); - return ok; -} - -/** - * Alive pid from the pid file without the expensive Windows command-line probe. - * Safe for liveness polls: callers still identity-check /healthz before trusting the proxy. - * Destructive stop/kill paths should keep using {@link readPid}, which verifies the cmdline. - */ -export function readAlivePid(): number | null { - const pid = readPidFileValue(); - if (pid === null) return null; - try { - process.kill(pid, 0); - return pid; - } catch (e: unknown) { - if ((e as NodeJS.ErrnoException).code === "EPERM") return pid; - return null; - } -} - -/** - * Full identity check of a KNOWN candidate pid (alive + ocx-start command line). - * Companion to {@link readAlivePid}: liveness discovery may be cheap, but any pid - * handed to a destructive caller must pass this check — and must equal the candidate - * it was asked about, so a pidfile rewrite between discovery and verification can - * never swap in a different process (TOCTOU guard). - */ -export function verifyPidIdentity(candidatePid: number): number | null { - try { - process.kill(candidatePid, 0); - } catch (e: unknown) { - if ((e as NodeJS.ErrnoException).code !== "EPERM") return null; - } - return isLikelyOcxStartProcess(candidatePid) ? candidatePid : null; -} - -function readProcessCommandLine(pid: number): string | undefined { - try { - if (process.platform === "win32") { - // Prefer WMIC over PowerShell: much faster cold start, and windowsHide avoids console flash. - // Fall back to PowerShell when WMIC is absent (newer Windows images). - const wmic = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\wbem\\WMIC.exe`; - try { - const output = execFileSync(wmic, [ - "process", "where", `ProcessId=${pid}`, "get", "CommandLine", "/VALUE", - ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true }); - const match = /^CommandLine=(.*)$/m.exec(output.replace(/\r/g, "")); - const value = match?.[1]?.trim(); - if (value) return value; - } catch { - /* WMIC missing or failed — fall through */ - } - const output = execFileSync("powershell.exe", [ - "-NoProfile", - "-NoLogo", - "-NonInteractive", - "-WindowStyle", - "Hidden", - "-Command", - `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`, - ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true }); - return output.trim() || undefined; - } - const output = execFileSync("ps", ["-p", String(pid), "-o", "command="], { - encoding: "utf-8", - stdio: ["ignore", "pipe", "ignore"], - timeout: 1000, - windowsHide: true, - }); - return output.trim() || undefined; - } catch { - return undefined; - } -} - -function warnAndBackupInvalidConfig(configPath: string, error: unknown): void { - if (warnedConfigFallbacks.has(configPath)) return; - warnedConfigFallbacks.add(configPath); - - const backupPath = backupInvalidConfig(configPath); - const reason = error instanceof z.ZodError - ? error.issues.map(issue => `${issue.path.join(".") || "config"}: ${issue.message}`).join("; ") - : error instanceof Error ? error.message : String(error); - const backupNote = backupPath ? ` A backup was written to ${backupPath}.` : ""; - console.error(`Could not load opencodex config at ${configPath}: ${reason}. Using default config.${backupNote}`); -} - -export function backupInvalidConfig(configPath: string): string | null { - if (!existsSync(configPath)) return null; - const backupPath = `${configPath}.invalid-${new Date().toISOString().replace(/[:.]/g, "-")}`; - try { - copyFileSync(configPath, backupPath); - try { chmodSync(backupPath, 0o600); } catch { /* best-effort */ } - return backupPath; - } catch { - return null; - } -} diff --git a/src/config/live-reconcile.ts b/src/config/live-reconcile.ts index 8715b1f146b..eec049171cc 100644 --- a/src/config/live-reconcile.ts +++ b/src/config/live-reconcile.ts @@ -80,6 +80,26 @@ export function claudeCodeBaselineArmed(config: OcxConfig): boolean { return claudeCodeBaseline.has(config); } +/** + * Adopt a field-scoped Claude Code write into a long-lived config snapshot. + * + * Scoped writers commit against the current file rather than serializing the + * whole snapshot. Mirror that committed subtree and rebase the hand-edit guard + * together so a later unrelated save does not mistake the scoped write for an + * outstanding in-memory mutation. + */ +export function adoptPersistedClaudeCode( + config: OcxConfig, + persistedClaudeCode: OcxConfig["claudeCode"], +): void { + config.claudeCode = structuredClone(persistedClaudeCode); + const baseline = liveConfigBaseline.get(config); + if (baseline) baseline.claudeCode = structuredClone(persistedClaudeCode); + if (claudeCodeBaseline.has(config)) { + claudeCodeBaseline.set(config, structuredClone(persistedClaudeCode)); + } +} + /** * Structural compare of parsed subtrees. NOT `JSON.stringify`: key order must not * decide whether a user's hand edit survives. From e76f464ec26ab8cac6bff835ece99180e421cff0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:42:55 +0000 Subject: [PATCH 4/4] fix(claude): keep pending live edits on desktop scoped writes adoptPersistedClaudeCode replaced the live claudeCode subtree wholesale, so a concurrent settings PUT that had assigned its subtree and yielded before saving lost its mutation while still reporting success. Reconcile baseline, live, and committed subtrees instead: pending live leaves win same-leaf conflicts and disjoint committed changes merge in, matching the guarded-save policy. persistDesktopModeField wrote claudeCode.desktopMode to disk and patched only the live object, so first-party apply (which ends at the mode write) left the armed hand-edit baseline stale; a later unrelated save then read both sides as changed and kept the stale live subtree over the operator's hand edit. Return the committed subtree from the mutation and run it through adoptPersistedClaudeCode like the profile-field writer does. Regression tests: a scoped write keeps a pending live Claude edit, and first-party apply -> hand edit -> unrelated save preserves the hand edit. Co-Authored-By: Epinephrine --- src/config/live-reconcile.ts | 18 +++++++++- .../management/agent-settings-routes.ts | 10 ++++-- .../claude-desktop-first-party.test.ts | 33 +++++++++++++++++++ tests/config/config-user-edits.test.ts | 17 ++++++++++ 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/config/live-reconcile.ts b/src/config/live-reconcile.ts index eec049171cc..7ad249dcd6b 100644 --- a/src/config/live-reconcile.ts +++ b/src/config/live-reconcile.ts @@ -87,12 +87,28 @@ export function claudeCodeBaselineArmed(config: OcxConfig): boolean { * whole snapshot. Mirror that committed subtree and rebase the hand-edit guard * together so a later unrelated save does not mistake the scoped write for an * outstanding in-memory mutation. + * + * The live subtree may already hold pending mutations a concurrent request + * assigned but has not saved yet — the Claude settings PUT yields between + * assigning `config.claudeCode` and saving. Adopt through the same three-way + * reconcile guarded saves use, so pending live leaves survive, disjoint + * committed changes merge in, and only the baseline moves wholesale to the + * committed subtree. */ export function adoptPersistedClaudeCode( config: OcxConfig, persistedClaudeCode: OcxConfig["claudeCode"], ): void { - config.claudeCode = structuredClone(persistedClaudeCode); + const storedBaseline: ConfigMergeValue = claudeCodeBaseline.has(config) + ? claudeCodeBaseline.get(config) + : MISSING_CONFIG_VALUE; + const merged = reconcileConfigValue( + storedBaseline === undefined ? MISSING_CONFIG_VALUE : storedBaseline, + config.claudeCode === undefined ? MISSING_CONFIG_VALUE : config.claudeCode, + persistedClaudeCode === undefined ? MISSING_CONFIG_VALUE : persistedClaudeCode, + ); + if (merged === MISSING_CONFIG_VALUE) delete config.claudeCode; + else config.claudeCode = merged as OcxConfig["claudeCode"]; const baseline = liveConfigBaseline.get(config); if (baseline) baseline.claudeCode = structuredClone(persistedClaudeCode); if (claudeCodeBaseline.has(config)) { diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 7bcef23c3aa..61ef8997b03 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -137,9 +137,15 @@ async function persistDesktopModeField( desktopMode: "first-party" | "gateway", ): Promise<{ ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" }> { const { recordClaudeDesktopMode } = await import("../../claude/desktop-first-party"); - const outcome = mutatePersistedConfig(persisted => recordClaudeDesktopMode(persisted, desktopMode)); + const outcome = mutatePersistedConfig(persisted => { + const mutation = recordClaudeDesktopMode(persisted, desktopMode); + return { changed: mutation.changed, value: structuredClone(persisted.claudeCode) }; + }); if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason }; - recordClaudeDesktopMode(config, desktopMode); + // First-party apply ends here — no profile-marker write follows — so without + // adopting, live diverges from the armed baseline and a later whole-config + // save reads that divergence as a pending mutation and stomps hand edits. + adoptPersistedClaudeCode(config, outcome.value); return { ok: true }; } diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts index 7b391f98dae..e63e17aa8dd 100644 --- a/tests/claude-integration/claude-desktop-first-party.test.ts +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -10,6 +10,7 @@ import { resolveClaudeDesktopMode, } from "../../src/claude/desktop-first-party"; import { parseDesktopApplyArgs } from "../../src/cli/claude-desktop"; +import { armClaudeCodeBaseline, saveConfigPreservingClaudeCode } from "../../src/config"; import { ensureClaudeDesktopMatchesDesired } from "../../src/cli/ensure-desired-integrations"; import { handleManagementAPI } from "../../src/server/management-api"; import { setIntegrationEnabled } from "../../src/codex/desired-state"; @@ -243,6 +244,38 @@ test("ensure warns instead of touching a gateway profile that contradicts an exp expect(logs.some(line => line.includes("gateway profile is still applied"))).toBe(true); }); +test("first-party apply rebases the Claude hand-edit guard after its scoped mode save", async () => { + // First-party apply ends at the mode-marker write — no profile-marker save + // follows — so unless that write adopts its committed subtree, live diverges + // from the armed baseline and the next whole-config save stomps a hand edit. + const snapshot = config({ claudeCode: { authMode: "subscription" } }); + writeFileSync(join(root, "config.json"), JSON.stringify(snapshot)); + armClaudeCodeBaseline(snapshot); + + const applied = await dispatch("/api/claude-desktop/apply", { method: "POST" }, snapshot); + expect(applied.status).toBe(200); + expect(applied.body).toMatchObject({ mode: "first-party", saved: true }); + + const handEdited = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + handEdited.claudeCode = { + ...handEdited.claudeCode, + authMode: "proxy", + anthropicBaseUrl: "http://127.0.0.1:19999", + }; + writeFileSync(join(root, "config.json"), JSON.stringify(handEdited)); + + snapshot.disabledModels = ["unrelated/model"]; + saveConfigPreservingClaudeCode(snapshot); + + const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(saved.claudeCode).toMatchObject({ + authMode: "proxy", + anthropicBaseUrl: "http://127.0.0.1:19999", + desktopMode: "first-party", + }); + expect(saved.disabledModels).toEqual(["unrelated/model"]); +}); + test("ensure reconciles first-party env: refreshes when ON and stale, removes when OFF", () => { const applied = applyDesktopFirstParty(config({ port: 10300 })); expect(applied.ok).toBe(true); diff --git a/tests/config/config-user-edits.test.ts b/tests/config/config-user-edits.test.ts index 38ac41aa378..8aef62d2073 100644 --- a/tests/config/config-user-edits.test.ts +++ b/tests/config/config-user-edits.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { armClaudeCodeBaseline, + adoptPersistedClaudeCode, adoptPersistedProviderIntoLiveConfig, deleteConfigTopLevelKey, getConfigPath, @@ -574,6 +575,22 @@ test("our own change wins a conflict and rebases the baseline", () => { expect((diskConfig().claudeCode as Record).authMode).toBe("proxy"); }); +// A scoped Desktop write commits against the file, then adopts the committed +// subtree. A live mutation still pending — a Claude settings PUT yields between +// assigning `config.claudeCode` and saving — must survive the adoption and reach +// the next save instead of being silently replaced. +test("a scoped Claude write keeps a pending live Claude edit", () => { + const live = loadConfig(); + armClaudeCodeBaseline(live); + live.claudeCode = { ...(live.claudeCode ?? {}), authMode: "proxy" }; + + adoptPersistedClaudeCode(live, { authMode: "subscription", desktopMode: "first-party" }); + + expect(live.claudeCode).toMatchObject({ authMode: "proxy", desktopMode: "first-party" }); + saveConfigPreservingClaudeCode(live); + expect(diskConfig().claudeCode).toEqual({ authMode: "proxy", desktopMode: "first-party" }); +}); + test("OAuth reconciliation keeps a pending live Claude subtree authoritative", () => { const live = loadConfig(); armClaudeCodeBaseline(live);