From fdcec8e0c0b12433acb2932c5f9c938d11df27da Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:53:22 +0900 Subject: [PATCH 1/5] fix(claude): drain parent ACL flights before intercept secret writes --- scripts/test-layout/layout-clients.json | 1 + src/claude/desktop-first-party.ts | 25 +- src/claude/desktop-picker.ts | 6 +- src/claude/first-party-settings.ts | 4 +- src/claude/intercept/local-ca.ts | 3 +- src/claude/intercept/picker-ca-startup.ts | 4 +- src/claude/intercept/picker-runtime.ts | 5 +- src/claude/intercept/runtime.ts | 4 +- src/cli/claude-desktop.ts | 11 +- src/cli/ensure-desired-integrations.ts | 8 +- src/config/paths.ts | 15 +- src/lib/windows-secret-acl.ts | 20 +- .../management/agent-settings-routes.ts | 15 +- .../management/native-integration-routes.ts | 11 +- structure/clients/claude-desktop.md | 4 +- structure/config.md | 6 +- structure/runtime.md | 6 +- .../claude-desktop-first-party.test.ts | 4 +- .../claude-intercept-acl-barrier.test.ts | 237 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 20 files changed, 347 insertions(+), 43 deletions(-) create mode 100644 tests/claude-integration/claude-intercept-acl-barrier.test.ts diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index 5420987b6e6..07b028ece83 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -1,4 +1,5 @@ { + "claude-intercept-acl-barrier.test.ts": "claude-integration", "agent-clients.test.ts": "clients", "workbuddy-selector-recovery.test.ts": "clients", "server-live-close.test.ts": "server", diff --git a/src/claude/desktop-first-party.ts b/src/claude/desktop-first-party.ts index 12a73cd5f1f..b19b41c6534 100644 --- a/src/claude/desktop-first-party.ts +++ b/src/claude/desktop-first-party.ts @@ -211,9 +211,24 @@ export function observeClaudeDesktopMode( return observed; } +const SECRET_FAILURE_CAUSES = ["EACCES", "EPERM", "ENOENT", "EEXIST", "EIO", "EBUSY", "ETIMEDOUT", + "EICACLS", "EACLIDENTITY", "local_ca_path_unsafe", "client_lifecycle_busy", "client_lifecycle_lock_failed", + "claude_intercept_proxy_token_invalid", "Error", "TypeError", "RangeError", "unknown"] as const; +export interface InterceptSecretDiagnostic { + stage: "ca" | "token"; + cause: typeof SECRET_FAILURE_CAUSES[number]; +} +function secretFailureCause(error: unknown): InterceptSecretDiagnostic["cause"] { + const code = error instanceof Error && "code" in error ? error.code : undefined; + // Allowlist labels, never arbitrary messages, paths, ACL stdout, or error-supplied names. + for (const allowed of SECRET_FAILURE_CAUSES) if (code === allowed) return allowed; + for (const allowed of ["TypeError", "RangeError", "Error"] as const) + if (error instanceof Error && error.name === allowed) return allowed; + return "unknown"; +} 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 }; + | { ok: false; reason: "intercept_disabled" | "ca_unavailable" | "unreadable" | "foreign_env"; path: string; diagnostic?: InterceptSecretDiagnostic }; /** * Write the first-party env into Claude Code's settings. Creates the local CA first so the @@ -226,11 +241,15 @@ export function applyDesktopFirstParty( const opencodexConfigDir = options.opencodexConfigDir ?? getConfigDir(); if (!claudeInterceptEnabled(config)) return { ok: false, reason: "intercept_disabled", path: "" }; let target: DesktopFirstPartyTarget; + let stage: InterceptSecretDiagnostic["stage"] = "ca"; try { ensureLocalInterceptCa(opencodexConfigDir); + stage = "token"; target = desktopFirstPartyTarget(config, opencodexConfigDir); - } catch { - return { ok: false, reason: "ca_unavailable", path: claudeInterceptCaCertPath(opencodexConfigDir) }; + } catch (error) { + const diagnostic = { stage, cause: secretFailureCause(error) }; + console.warn(`[claude-intercept] secret preparation failed: stage=${diagnostic.stage} cause=${diagnostic.cause}`); + return { ok: false, reason: "ca_unavailable", path: claudeInterceptCaCertPath(opencodexConfigDir), diagnostic }; } const written = applyClaudeInterceptSettings(target.env, options.claudeConfigDir); if (!written.ok) return { ok: false, reason: written.reason, path: written.path }; diff --git a/src/claude/desktop-picker.ts b/src/claude/desktop-picker.ts index 6c00058c423..54c359c9888 100644 --- a/src/claude/desktop-picker.ts +++ b/src/claude/desktop-picker.ts @@ -6,7 +6,7 @@ */ import type { OcxConfig } from "../types"; import { claudeDesktopIntegrationEnabled } from "../codex/desired-state"; -import { getConfigDir } from "../config/paths"; +import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths"; import { readFileSync, existsSync } from "node:fs"; import { acceptsPickerAuthority, pickerCaCertPath, pickerCaFingerprints, ensurePickerCa, issuePickerLeaf, pickerLeafCertPath, publishedPickerCaSha256 } from "./intercept/picker-ca"; import { inspectPickerTrust, trustPickerCa, untrustPickerCa } from "./intercept/picker-trust"; @@ -237,12 +237,12 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps) try { const ca = deps.persistentAuthority ? await preparePersistentPickerAuthority({ configDir: deps.configDir, store: deps.persistentAuthority.store, security: deps.security, platform }) - : ensurePickerCa(deps.configDir); + : await withConfigDirHardeningSettled(deps.configDir, () => ensurePickerCa(deps.configDir)); // The CLI path gates trust on the same profile check; a published root whose bytes do not // match the profile this process mints must not reach the keychain through the server // path either, even when a live peer published it. if (!acceptsPickerAuthority(ca.certPem)) return refuse("ca_unverified"); - issuePickerLeaf(ca, deps.configDir); + await withConfigDirHardeningSettled(deps.configDir, () => issuePickerLeaf(ca, deps.configDir)); caPath = pickerCaCertPath(deps.configDir); caSha1 = pickerCaFingerprints(ca.certPem).sha1; let trust = await inspectPickerTrust(pickerLeafCertPath(deps.configDir), caSha1, deps.security, platform); diff --git a/src/claude/first-party-settings.ts b/src/claude/first-party-settings.ts index 520bed56bb7..b8506d35561 100644 --- a/src/claude/first-party-settings.ts +++ b/src/claude/first-party-settings.ts @@ -9,6 +9,7 @@ import { resolveClaudeDesktopMode, type ClaudeDesktopModeObservation, type DesktopFirstPartyOptions, + type DesktopFirstPartyApplyResult, } from "./desktop-first-party"; import { claudeInterceptCaCertPath } from "./intercept/local-ca"; import { invalidateClaudeCodeServedCatalog } from "./intercept/cli-catalog"; @@ -39,8 +40,9 @@ export function firstPartyDesired( export type ClaudeFirstPartyReconcileResult = | { ok: true; action: "applied" | "removed" | "unchanged"; changed: boolean; path: string } - | { ok: false; reason: "intercept_disabled" | "ca_unavailable" | "unreadable" | "foreign_env"; path: string }; + | Extract; +// Synchronous leaf: async callers wrap reconciliation in withConfigDirHardeningSettled. export function reconcileClaudeFirstPartySettings( config: Pick, desired: ClaudeFirstPartyDesired, diff --git a/src/claude/intercept/local-ca.ts b/src/claude/intercept/local-ca.ts index b4a2eb54fd0..b6d7d6004b7 100644 --- a/src/claude/intercept/local-ca.ts +++ b/src/claude/intercept/local-ca.ts @@ -1,5 +1,6 @@ import { createHash, generateKeyPairSync, createPrivateKey, createPublicKey, sign, X509Certificate, type KeyObject } from "node:crypto"; import { join } from "node:path"; +import { withConfigDirHardeningSettled } from "../../config/paths"; import { withLocalCaPublication, type LocalCaFiles } from "./local-ca-files"; /** @@ -371,7 +372,7 @@ export function ensureLocalInterceptCa(configDir: string): LocalInterceptCa { * contention, before binding listeners, without blocking the main event loop. */ export async function ensureLocalInterceptCaForStartup(configDir: string): Promise { for (let attempt = 0; ; attempt += 1) { - try { return ensureLocalInterceptCa(configDir); } + try { return await withConfigDirHardeningSettled(configDir, () => ensureLocalInterceptCa(configDir)); } catch (error) { if (attempt >= 49 || !(error instanceof Error) || !("code" in error) || error.code !== "client_lifecycle_busy") throw error; diff --git a/src/claude/intercept/picker-ca-startup.ts b/src/claude/intercept/picker-ca-startup.ts index 63d1a9f292b..2967c48e8c4 100644 --- a/src/claude/intercept/picker-ca-startup.ts +++ b/src/claude/intercept/picker-ca-startup.ts @@ -1,4 +1,5 @@ import { ensurePickerCa, type PickerCa } from "./picker-ca"; +import { withConfigDirHardeningSettled } from "../../config/paths"; import { drainPendingPickerCaUntrust } from "./picker-ca-cleanup"; import type { PickerCaStore } from "./picker-ca-store"; import type { SecurityRunner } from "./picker-trust"; @@ -11,7 +12,8 @@ export async function preparePersistentPickerAuthority(options: { if (!supportsDesktopPicker(options.platform)) throw new Error("picker_ca_unsupported_platform"); const drain = () => drainPendingPickerCaUntrust(options.configDir, options.security, options.platform); if (!await drain()) throw new Error("picker_ca_pending_untrust"); - const ca = ensurePickerCa(options.configDir, { rotation: "startup", persistent: true, store: options.store }); + const ca = await withConfigDirHardeningSettled(options.configDir, () => + ensurePickerCa(options.configDir, { rotation: "startup", persistent: true, store: options.store })); if (!await drain()) throw new Error("picker_ca_pending_untrust"); return ca; } diff --git a/src/claude/intercept/picker-runtime.ts b/src/claude/intercept/picker-runtime.ts index 7b4f56310e3..2c47c84fec0 100644 --- a/src/claude/intercept/picker-runtime.ts +++ b/src/claude/intercept/picker-runtime.ts @@ -15,6 +15,7 @@ */ import { join } from "node:path"; import type { OcxConfig } from "../../types"; +import { withConfigDirHardeningSettled } from "../../config/paths"; import type { ClaudeDesktopMode } from "../desktop-first-party"; import { supportsDesktopPicker } from "../desktop-picker-platform"; import type { TunnelDecision } from "./connect-proxy"; @@ -170,8 +171,8 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker if (!ca || !leaf || !caSha1) { ca = options.persistentAuthority ? await preparePersistentPickerAuthority({ configDir: options.configDir, store: options.persistentAuthority.store, security: options.security, platform }) - : ensurePickerCa(options.configDir); - leaf = issuePickerLeaf(ca, options.configDir); + : await withConfigDirHardeningSettled(options.configDir, () => ensurePickerCa(options.configDir)); + leaf = await withConfigDirHardeningSettled(options.configDir, () => issuePickerLeaf(ca!, options.configDir)); caSha1 = pickerCaFingerprints(ca.certPem).sha1; } return { ca, leaf, sha1: caSha1 }; diff --git a/src/claude/intercept/runtime.ts b/src/claude/intercept/runtime.ts index 4e6866731a0..921694cb57f 100644 --- a/src/claude/intercept/runtime.ts +++ b/src/claude/intercept/runtime.ts @@ -1,7 +1,7 @@ import type { Server } from "bun"; import { supportsDesktopPicker } from "../desktop-picker-platform"; import type { OcxConfig } from "../../types"; -import { getConfigDir } from "../../config/paths"; +import { getConfigDir, withConfigDirHardeningSettled } from "../../config/paths"; import type { DesktopPickerController } from "../desktop-picker"; import type { ClaudeFirstPartyDesired } from "../first-party-settings"; import { classifyInterceptClient, interceptRouteFor } from "./client-class"; @@ -149,7 +149,7 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio const configDir = options.configDir ?? getConfigDir(); const ca = await ensureLocalInterceptCaForStartup(configDir); const startProxy = options.startProxy ?? startConnectProxy; - const authToken = ensureClaudeInterceptProxyToken(configDir); + const authToken = await withConfigDirHardeningSettled(configDir, () => ensureClaudeInterceptProxyToken(configDir)); const leaf = issueLocalInterceptLeaf(ca, CLAUDE_INTERCEPT_HOSTS); const listener = startClaudeInterceptListener({ leaf, diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index f10c6008125..616a0fcfc19 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -1,7 +1,7 @@ import { recordCommittedDesktopGateway } from "../claude/desktop-gateway-state"; import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; -import { getConfigDir } from "../config/paths"; +import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths"; import { loadConfig, mutatePersistedConfig, withConfigMutationLockSync } from "../config"; import { claudeDesktopIntegrationEnabledNow, setIntegrationEnabled } from "../codex/desired-state"; import { readClientConnectionState, assertClientConnectionUnchanged, assertNoClientDisconnectPending, type ClientConnectionState } from "../client/state"; @@ -99,6 +99,7 @@ type DesktopApplyResult = { warning?: string; picker?: DesktopPickerStatus; delegated?: boolean; + diagnostic?: import("../claude/desktop-first-party").InterceptSecretDiagnostic; }; type PickerRouteResponse = { @@ -155,7 +156,7 @@ async function trustPickerLocally( ): Promise<{ ok: true; callerAddedTrust: boolean; caPath: string; sha1: string } | { ok: false; reason: string }> { try { const configDir = getConfigDir(); - const paths = pickerTrustPaths(deps, configDir); + const paths = await withConfigDirHardeningSettled(configDir, () => pickerTrustPaths(deps, configDir)); if (!acceptsPickerAuthority(paths.certPem)) return { ok: false, reason: "ca_unverified" }; if (!deps.ensurePickerCaImpl) { // The bytes in ca.pem are only safe to trust when they match the authority the running @@ -426,14 +427,14 @@ async function applyFirstPartyDesktop( // Establish the replacement before deleting the working gateway. A refused // cleanup restores only our managed env keys, preserving unrelated settings. const rollback = captureDesktopFirstPartyRollback(config); - const applied = applyDesktopFirstParty(config); - if (!applied.ok) return { ok: false, path: applied.path, reason: applied.reason }; + const applied = await withConfigDirHardeningSettled(getConfigDir(), () => applyDesktopFirstParty(config)); + if (!applied.ok) return { ok: false, path: applied.path, reason: applied.reason, ...(applied.diagnostic ? { diagnostic: applied.diagnostic } : {}) }; 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) { - const restored = removed.changed || !applied.changed || rollback(); + const restored = removed.changed || !applied.changed || await withConfigDirHardeningSettled(getConfigDir(), rollback); const modeSaved = !removed.changed || saveDesktopMode("first-party", deps); const warning = [restored ? "" : "first-party settings rollback did not complete", modeSaved ? "" : "first-party is active but its mode marker was not saved"].filter(Boolean).join("; "); diff --git a/src/cli/ensure-desired-integrations.ts b/src/cli/ensure-desired-integrations.ts index 9cff9cf11f5..bf0709e5e7f 100644 --- a/src/cli/ensure-desired-integrations.ts +++ b/src/cli/ensure-desired-integrations.ts @@ -10,6 +10,7 @@ */ import { loadConfig } from "../config"; import { cliFirstPartyDesired, firstPartyDesired, reconcileClaudeFirstPartySettings } from "../claude/first-party-settings"; +import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths"; import { claudeInterceptEnabled } from "../claude/intercept/runtime"; import { removeDesktopPickerArtifacts } from "../claude/desktop-picker"; import { findLiveProxy } from "../server/proxy-liveness"; @@ -154,8 +155,9 @@ export async function ensureClaudeDesktopMatchesDesired( if (cliFirstPartyDesired(config)) { const seen = (deps.inspectDesktopFirstParty ?? inspectDesktopFirstParty)(config); if (seen.settings.kind === "absent" || seen.stale || !claudeInterceptEnabled(config)) { - const result = (deps.reconcileClaudeFirstPartySettings ?? reconcileClaudeFirstPartySettings)(config, - firstPartyDesired(config, (deps.observeClaudeDesktopMode ?? observeClaudeDesktopMode)(config))); + const result = await withConfigDirHardeningSettled(getConfigDir(), () => + (deps.reconcileClaudeFirstPartySettings ?? reconcileClaudeFirstPartySettings)(config, + firstPartyDesired(config, (deps.observeClaudeDesktopMode ?? observeClaudeDesktopMode)(config)))); if (result.ok && result.changed) log(` + Claude CLI first-party env refreshed (${result.path})`); else if (!result.ok) error(`⚠️ Claude CLI first-party env refresh skipped: ${result.reason}.`); } @@ -173,7 +175,7 @@ export async function ensureClaudeDesktopMatchesDesired( } const seen = (deps.inspectDesktopFirstParty ?? inspectDesktopFirstParty)(config); if (!seen.stale) return; - const applied = (deps.applyDesktopFirstParty ?? applyDesktopFirstParty)(config); + const applied = await withConfigDirHardeningSettled(getConfigDir(), () => (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; diff --git a/src/config/paths.ts b/src/config/paths.ts index d20166733d8..48d5c79fa7d 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -1,7 +1,7 @@ import { chmodSync, existsSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; -import { flushWindowsSecretAclReapsBeforeRemoval, hardenSecretDirAsync, windowsSecretAclApplies } from "../lib/windows-secret-acl"; +import { flushWindowsSecretAclReapsBeforeRemoval, hardenSecretDirAsync, windowsSecretAclApplies, withWindowsSecretAclSettled } from "../lib/windows-secret-acl"; import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { isPassiveRead } from "../lib/passive-read"; import { trackBackgroundWork } from "../lib/background-work"; @@ -73,6 +73,19 @@ export async function flushConfigDirHardening(dir: string = getConfigDir()): Pro if (flight) await flight; } +/** + * Intercept CA/token/picker writers call this after their last unrelated await. Parent + * inheritable ACL propagation can overwrite a child's concurrent explicit grant. Drain the + * config flight, then all overlapping registry work (including ancestors and timeout reaps). + * The final registry check and synchronous callback share a turn: promise continuations and + * newly queued hardeners cannot start icacls between that check and secret publication. + * Keep the callback synchronous; an await inside it would reopen the propagation race. + */ +export async function withConfigDirHardeningSettled(dir: string, mutate: () => T): Promise { + await flushConfigDirHardening(dir); + return withWindowsSecretAclSettled(dir, mutate); +} + /** A settled ACL deadline is not proof its child has exited; stop must drain both before removal. */ export async function flushConfigDirHardeningAndReaps(dir: string): Promise { try { await flushConfigDirHardening(dir); } diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index 050acddb9a2..e746d991030 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -493,7 +493,7 @@ function pathIsAtOrBelow(target: string, root: string): boolean { return suffix !== ".." && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix); } -function retainedWorkIsAtOrBelow(entry: AsyncAclWork, root: RemovalIdentity): boolean { +function retainedWorkIsAtOrBelow(entry: AsyncAclWork, root: RemovalIdentity, includeAncestors = false): boolean { const target = entry.identity; // Failure at capture stays conservative, even if the alias later becomes readable. if (target.canonical === undefined || root.canonical === undefined) return true; @@ -501,7 +501,23 @@ function retainedWorkIsAtOrBelow(entry: AsyncAclWork, root: RemovalIdentity): bo if (current === undefined) return true; const targets = [target.lexical, target.canonical, current]; return [root.lexical, root.canonical].some(rootPath => - targets.some(targetPath => pathIsAtOrBelow(targetPath, rootPath))); + targets.some(targetPath => pathIsAtOrBelow(targetPath, rootPath) + || (includeAncestors && pathIsAtOrBelow(rootPath, targetPath)))); +} + +/** Run a synchronous mutation only after overlapping ACL operations and retained children exit. */ +export async function withWindowsSecretAclSettled(rootPath: string, mutate: () => T): Promise { + const root = captureRemovalIdentity(rootPath); + while (true) { + const pending = [...activeAsyncAclWork.values(), ...pendingAsyncIcaclsReaps.values()] + .flatMap(entries => [...entries]) + .filter(entry => retainedWorkIsAtOrBelow(entry, root, true)) + .map(entry => entry.reap); + // No await between this final observation and the mutation. A newly queued hardener's + // microtasks cannot run icacls until the synchronous callback has completely returned. + if (pending.length === 0) return mutate(); + await Promise.all(pending); + } } /** True while an async icacls runner still owns this exact path after its caller's belt fired. */ diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 8ea546b1177..3adbdb7d426 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1105,7 +1105,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise return await runPickerTransition(config, async ops => { const { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } = await import("../../claude/desktop-3p"); const rollback = captureDesktopFirstPartyRollback(config); - const applied = applyDesktopFirstParty(config); + const { getConfigDir, withConfigDirHardeningSettled } = await import("../../config/paths"); + const applied = await withConfigDirHardeningSettled(getConfigDir(), () => applyDesktopFirstParty(config)); if (!applied.ok) { const { firstPartyRefusalMessage } = await import("./native-integration-routes"); return jsonResponse({ @@ -1113,6 +1114,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise code: "claude_desktop_first_party_refused", reason: applied.reason, path: applied.path, + ...(applied.diagnostic ? { diagnostic: applied.diagnostic } : {}), }, applied.reason === "foreign_env" || applied.reason === "intercept_disabled" ? 409 : 500); } // Remove the gateway only after the replacement env was written. @@ -1122,7 +1124,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { const removed = (deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint, replaceWhileEnabled: true }); if (!removed.ok) { - const restored = removed.changed || !applied.changed || rollback(); + const restored = removed.changed || !applied.changed || await withConfigDirHardeningSettled(getConfigDir(), rollback); const modeSaved = !removed.changed || (await persistDesktopModeField(config, "first-party")).ok; const warning = [restored ? "" : "first-party settings rollback did not complete", modeSaved ? "" : "first-party is active but its mode marker was not saved"].filter(Boolean).join("; "); @@ -1622,13 +1624,15 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (committed.pinnedMode) live.desktopMode = committed.pinnedMode; config.claudeCode = live; let result: ReturnType | undefined; - try { result = (deps.reconcileClaudeFirstPartySettings ?? reconcileClaudeFirstPartySettings)(config, - firstPartyDesired(config, observeClaudeDesktopMode(config))); } + const { getConfigDir, withConfigDirHardeningSettled } = await import("../../config/paths"); + try { result = await withConfigDirHardeningSettled(getConfigDir(), () => + (deps.reconcileClaudeFirstPartySettings ?? reconcileClaudeFirstPartySettings)(config, + firstPartyDesired(config, observeClaudeDesktopMode(config)))); } catch { /* An unexpected settings failure is mapped after conditional rollback. */ } if (!result?.ok) { let settingsRestored = true; if (body.cliFirstParty) { - try { settingsRestored = restoreSettings?.() ?? false; } catch { settingsRestored = false; } + try { settingsRestored = await withConfigDirHardeningSettled(getConfigDir(), () => restoreSettings?.() ?? false); } catch { settingsRestored = false; } let rollback: ReturnType>; try { rollback = mutatePersistedConfig(persisted => { @@ -1650,6 +1654,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } const code = result?.reason ?? "write_failed"; return jsonResponse({ error: "Claude first-party reconciliation failed", code, + ...(result?.diagnostic ? { diagnostic: result.diagnostic } : {}), ...(!body.cliFirstParty && code === "unreadable" ? { cliFirstParty: false, warnings: ["settings_residual"] } : {}), ...(body.cliFirstParty && !settingsRestored diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 4e752ceb393..34c7fe1cc81 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -38,6 +38,7 @@ import { removeDesktopFirstParty, resolveClaudeDesktopApplyMode, type ClaudeDesktopMode, + type InterceptSecretDiagnostic, } from "../../claude/desktop-first-party"; import { FIRST_PARTY_ACCOUNT_RISK } from "../../claude/desktop-risk"; import type { DesktopPickerStatus } from "../../claude/desktop-picker"; @@ -49,6 +50,7 @@ import { grokConfigPath } from "../../grok/status"; import { assertNativeTeardownOwned } from "../../integrations/native/ownership-preflight"; import type { CodexNativeRestoreResult } from "../../codex/inject"; import type { OcxConfig } from "../../types"; +import { getConfigDir, withConfigDirHardeningSettled } from "../../config/paths"; import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import { managementInferencePort, type ManagementContext } from "./context"; @@ -108,6 +110,7 @@ export interface NativeRefusalEnvelope { /** Present on every post-commit refusal; absent only for pre-commit refusals. */ desiredEnabled?: boolean; residualPaths?: string[]; + diagnostic?: InterceptSecretDiagnostic; } export type NativePostCommitRefusalEnvelope = Omit & { @@ -133,7 +136,7 @@ function postCommitRefusal( clientId: NativeIntegrationClientId, reason: NativeRefusalReason, message: string, - extra: Pick, + extra: Pick, ): Response { return jsonResponse({ error: status >= 500 ? "native integration change failed" : "native integration change refused", @@ -755,17 +758,17 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { const rollback = captureDesktopFirstPartyRollback(current); - const applied = applyDesktopFirstParty(current); + const applied = await withConfigDirHardeningSettled(getConfigDir(), () => applyDesktopFirstParty(current)); if (!applied.ok) { const reason = applied.reason === "foreign_env" || applied.reason === "intercept_disabled" ? applied.reason : "write_failed"; return postCommitRefusal(applied.reason === "unreadable" || applied.reason === "ca_unavailable" ? 500 : 409, "claude-desktop", reason, - firstPartyRefusalMessage(applied.reason, applied.path), { desiredEnabled }); + firstPartyRefusalMessage(applied.reason, applied.path), { desiredEnabled, ...(applied.diagnostic ? { diagnostic: applied.diagnostic } : {}) }); } const library = inspectDesktop3pConfigLibrary({ appliedFingerprint: fingerprint }); let gatewayRemoved = false; if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { const removed = (ctx.deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint: fingerprint, replaceWhileEnabled: true }); - if (!removed.ok && !removed.changed && applied.changed && !rollback()) { + if (!removed.ok && !removed.changed && applied.changed && !await withConfigDirHardeningSettled(getConfigDir(), rollback)) { return postCommitRefusal(500, "claude-desktop", "write_failed", "Gateway cleanup and first-party settings rollback did not complete.", { desiredEnabled }); } diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 620c266f250..3de105492c4 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -180,12 +180,12 @@ event loop. Injected probes may return a state or a promise, so isolated callers ### Picker mode: the Desktop egress proxy -The shared CONNECT primitive accepts optional `allowedTargets` authorities. It snapshots and -normalizes that list at startup; an empty list denies all, and other host/port pairs receive 403 +The shared CONNECT primitive snapshots and normalizes optional `allowedTargets` authorities at startup; an empty list denies all, and other host/port pairs receive 403 before tunnel selection or dialing. Authentication and loopback refusal remain in force. Existing Claude consumers omit this option and retain blind forwarding; it enables no new integration or certificate trust. Windows trust removal in `src/claude/intercept/picker-trust-windows.ts` requires the exact picker common name and fingerprint before deleting a current-user Root entry. Public recovery certificates are verified as self-signed picker roots before removal; legacy roots may be removed without matching the current installation extension profile. Windows local-CA publication in `src/claude/intercept/local-ca-files.ts` hardens legacy inherited DACLs only after verifying the current owner and exclusively current-user, SYSTEM or Administrators grants; already private directories skip hardening. Newly created exclusive files and directories are hardened before strict owner/ACL verification and before CA access. ACL verification, including inherited SQLite sidecar ACLs, is memoized by bigint device/inode/birthtime within one publication; birthtime distinguishes recycled file IDs while preserving same-volume rename identity. Path and descriptor identity checks remain active on every access, and removed or replaced entries retire their memo. +Native toggle, management apply, standalone CLI apply, desired-state reconciliation, startup and picker writers follow the [config-directory hardening barrier](../config.md#opencodex-home-and-live-process-state). Synchronous reconciliation and rollback leaves receive that barrier from their nearest async caller; token preparation during rollback is included. `tests/claude-integration/claude-intercept-acl-barrier.test.ts` gates real parent flights and checks that CA/token work starts only after release. First-party failures retain the public `ca_unavailable` reason and message while internal results, refusal bodies and warnings carry an allowlisted `diagnostic` with `ca` or `token` stage and cause label. Raw exceptions, ACL output, PEMs and tokens are never diagnostic fields. The authority primitive accepts `validityDays` from 1 through 3650 for short-lived callers; omitted values preserve the existing 3650-day CA lifetime. This parameter does not install trust or rotate an existing authority. Gateway remains the default Desktop mode. On macOS and Windows, picker intent defaults on only within the chosen first-party mode: either an explicit Desktop integration `false` or `claudeCode.intercept.picker: false` disables it. Default intent does not install certificate trust; startup and status only inspect trust, and explicit activation owns installation. diff --git a/structure/config.md b/structure/config.md index f3b1249b026..cfa70ca50d7 100644 --- a/structure/config.md +++ b/structure/config.md @@ -59,11 +59,11 @@ It rechecks the file before committing, retries a changed snapshot up to three t returns unavailable for missing, invalid, or persistently conflicting config. Its one-shot test seam and the mutation types remain re-exported through `src/config.ts`. Successful, repaired, and salvaged file loads record their source path in the private WeakMap owned by `src/config/rebase-provenance.ts`; defaults and synthetic objects do not acquire it. This metadata is never serialized. Inventory drift cannot turn a file-backed instance into a synthetic discovery writer. Read publication may use the detached policy projection described by the [catalog contract](catalog.md#shared-catalog), without changing the live merge baseline; changing the resolved config home refuses publication. -`src/config/paths.ts` is the single owner of `OPENCODEX_HOME` expansion and resolution. It exposes -the config directory and `config.json` path and retains the existing cache rule: a relative home is -resolved once for each distinct raw environment value, so a later working-directory change cannot +`src/config/paths.ts` owns `OPENCODEX_HOME` expansion and resolution, the config directory and `config.json` path. A relative home is resolved once per distinct raw environment value, so a later working-directory change cannot silently move the active installation. Config reads in this leaf and `src/config/load-degrade.ts` use NTFS ACLs on Windows and private POSIX modes elsewhere. Windows reads omit `chmod` before ACL hardening so repeated loads preserve successful object/freshness observations; external metadata changes still invalidate them. Directory flights keep their existing flush/reap lifecycle, and file/required-write failure policy is unchanged. `tests/windows/windows-config-read-acl-memo.test.ts` covers repeated loads, external invalidation and POSIX permissions. +Intercept secret writers use `withConfigDirHardeningSettled` from `src/config/paths.ts` after their last unrelated await. It drains the optional config-directory hardening flight and overlapping Windows ACL registry work, including ancestors and retained timeout children, then runs the synchronous writer in the same turn as the final empty-registry check. The callback contains no await: queued microtasks cannot start a new inheritable parent ACL update during child CA, token or picker publication. The registry preserves lexical and canonical alias matching; uncertain identity drains conservatively. Removal retains its separate at-or-below scope. + `src/config/process-state.ts` derives `ocx.pid` and `runtime-port.json` from that resolved directory. It owns their byte-compatible writes, parsing, expected-PID filters, cheap liveness, full OCX command identity, and snapshot-guarded removal. `RuntimePortState.attestationSecret` and `siblingOfPort` (the live owner's port, written only by a sibling instance) remain optional, diff --git a/structure/runtime.md b/structure/runtime.md index 452ef160218..e2fba8b468a 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -228,9 +228,9 @@ key ids recorded by `links.json`, never the environment token. Context relays re final deletion closes the listener, and `src/server/index/optional-listeners.ts` runs supervisor teardown before closing this listener and the Claude intercept pair. ### Claude intercept pair -At the end of the startup transaction, `startServer` also starts the optional Claude intercept pair -through `src/server/index/claude-intercept-lifecycle.ts` (fire-and-forget start, `ownsListener` for -the ingress decision, `stop` joined into the listener shutdown) from `src/claude/intercept/runtime.ts`: a loopback HTTP CONNECT proxy (`src/claude/intercept/connect-proxy.ts`) +Intercept CA startup and token creation in `src/claude/intercept/runtime.ts` follow the [config-directory hardening barrier](config.md#opencodex-home-and-live-process-state). Each synchronous writer runs after the last unrelated await with no pending overlapping ACL work, including retained ancestor children. Persistent picker preparation and subsequent leaf publication apply the same rule independently across trust/credential-store awaits. + +At the end of the startup transaction, `startServer` starts the optional Claude intercept pair through `src/server/index/claude-intercept-lifecycle.ts` (fire-and-forget start, `ownsListener` for ingress, `stop` joined into listener shutdown) from `src/claude/intercept/runtime.ts`: a loopback HTTP CONNECT proxy (`src/claude/intercept/connect-proxy.ts`) and a loopback TLS listener (`src/claude/intercept/listener.ts`) that presents a leaf for `api.anthropic.com` signed by a per-install authority (`src/claude/intercept/local-ca.ts`, persisted under `/claude-intercept/` with a 0600 key; never installed into an OS trust store). CA reads and pair publication share a directory-bound SQLite lease; persisted certificates must match their private key and verify as a self-signed CA. Directory, certificate, key and lock paths reject symlinks, nonregular files and foreign ownership; pinned descriptors and exclusive unpredictable temporary files protect reads and publication. Windows verifies ownership and permits only the current user, SYSTEM and built-in Administrators in the private-file ACL, including before writing temporary key bytes; inspection or protection failure rejects the operation. Startup retries only lease contention with bounded asynchronous backoff before binding either listener. diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts index be641968313..fd084484a7f 100644 --- a/tests/claude-integration/claude-desktop-first-party.test.ts +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -53,8 +53,8 @@ async function dispatch(path: string, init?: RequestInit, inputConfig: OcxConfig return { status: response!.status, body: await response!.json() as Record }; } -// Attach the response body to every status failure so a hosted-runner flake classifies -// itself (an environmental ca_unavailable, say, instead of a bare "Expected: 200 / Received: 500"). +// Attach the response body to status failures to preserve refusal codes and sanitized +// secret-preparation stage/cause diagnostics alongside the expected/actual status. function expectStatus(result: { status: number; body: unknown }, status: number): void { expect(result.status, `body=${JSON.stringify(result.body)}`).toBe(status); } diff --git a/tests/claude-integration/claude-intercept-acl-barrier.test.ts b/tests/claude-integration/claude-intercept-acl-barrier.test.ts new file mode 100644 index 00000000000..59f1acfd091 --- /dev/null +++ b/tests/claude-integration/claude-intercept-acl-barrier.test.ts @@ -0,0 +1,237 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as firstParty from "../../src/claude/desktop-first-party"; +import * as localCa from "../../src/claude/intercept/local-ca"; +import * as proxyAuth from "../../src/claude/intercept/proxy-auth"; +import * as configPaths from "../../src/config/paths"; +import { applyDesktop } from "../../src/cli/claude-desktop"; +import { ensureClaudeDesktopMatchesDesired } from "../../src/cli/ensure-desired-integrations"; +import { startClaudeIntercept } from "../../src/claude/intercept/runtime"; +import { startConnectProxy } from "../../src/claude/intercept/connect-proxy"; +import { flushConfigDirHardeningAndReaps, hardenConfigDir, withConfigDirHardeningSettled } from "../../src/config/paths"; +import { setLocalCaWindowsAclRunnerForTests } from "../../src/claude/intercept/local-ca-windows"; +import { readClaudeInterceptProxyToken } from "../../src/claude/intercept/proxy-auth"; +import { hardenSecretDirAsync, resetHardenedStateForTests, setAsyncIcaclsBeltSchedulerForTests, setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests, setNowForTests, setPlatformForTests, type IcaclsResult } from "../../src/lib/windows-secret-acl"; +import { setSyntheticWindowsPrincipalForTests } from "../../src/lib/windows-user-principal"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const success: IcaclsResult = { success: true, exitCode: 0, timedOut: false, stdout: "" }; +const sid = "S-1-5-21-1-2-3-1001"; +function deferred() { + let resolve!: () => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} +const keys = ["OPENCODEX_HOME", "CLAUDE_CONFIG_DIR", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "OPENCODEX_ACL_VERIFY_EXISTING"] as const; +let root: string; +let previous: (string | undefined)[]; +let entrySpy: ReturnType | undefined; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-intercept-acl-barrier-")); + previous = keys.map(key => process.env[key]); + process.env.OPENCODEX_HOME = root; + process.env.CLAUDE_CONFIG_DIR = join(root, "claude"); + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(root, "desktop"); + delete process.env.OPENCODEX_ACL_VERIFY_EXISTING; + resetHardenedStateForTests(); + setPlatformForTests("win32"); + setSyntheticWindowsPrincipalForTests(`*${sid}`); + setIcaclsRunnerForTests(() => success); + setLocalCaWindowsAclRunnerForTests(() => ({ ...success, stdout: JSON.stringify({ + owner: sid, protected: true, rules: [{ sid, type: 0, rights: 2032127 }], + }) })); +}); + +test.each(["ca", "token"] as const)("first-party failure retains a sanitized %s stage and cause", stage => { + const secret = "private-fixture-value"; + const ca = spyOn(localCa, "ensureLocalInterceptCa").mockImplementation(() => { + if (stage === "ca") throw Object.assign(new Error(secret), { code: "EACCES" }); + return {} as ReturnType; + }); + const token = spyOn(proxyAuth, "ensureClaudeInterceptProxyToken").mockImplementation(() => { + throw Object.assign(new Error(secret), { code: "EACCES" }); + }); + try { + const result = firstParty.applyDesktopFirstParty({ port: 10100 }); + expect(result).toMatchObject({ ok: false, reason: "ca_unavailable", diagnostic: { stage, cause: "EACCES" } }); + expect(JSON.stringify(result)).not.toContain(secret); + } finally { ca.mockRestore(); token.mockRestore(); } +}); +afterEach(async () => { + await flushConfigDirHardeningAndReaps(root); + entrySpy?.mockRestore(); + entrySpy = undefined; + setAsyncIcaclsRunnerForTests(null); + setAsyncIcaclsBeltSchedulerForTests(null); + setNowForTests(null); + setIcaclsRunnerForTests(null); + setLocalCaWindowsAclRunnerForTests(null); + setPlatformForTests(null); + setSyntheticWindowsPrincipalForTests(null); + resetHardenedStateForTests(); + keys.forEach((key, index) => { + if (previous[index] === undefined) delete process.env[key]; + else process.env[key] = previous[index]; + }); + removeTreeWithRetry(root); +}); + +test.each(["config root", "ancestor"])("startup CA waits for an ACL flight on its %s", async scope => { + const configDir = scope === "ancestor" ? join(root, "nested-home") : root; + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + const entered = deferred(), release = deferred(); + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + const flight = hardenSecretDirAsync(root, { required: false }); + await entered.promise; + const startup = localCa.ensureLocalInterceptCaForStartup(configDir); + try { + await Promise.resolve(); + expect(existsSync(join(configDir, "claude-intercept"))).toBe(false); + } finally { release.resolve(); await flight; await startup; } + expect(existsSync(join(configDir, "claude-intercept", "ca.pem"))).toBe(true); +}); + +test("a settled optional deadline still waits for its live ancestor child", async () => { + const configDir = join(root, "nested-home"); + mkdirSync(configDir, { mode: 0o700 }); + const entered = deferred(), release = deferred(); + let expire!: () => void; + let now = 0; + setNowForTests(() => now); + setAsyncIcaclsBeltSchedulerForTests(callback => { expire = callback; return () => {}; }); + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + const flight = hardenSecretDirAsync(root, { required: false, deadlineMs: 1_000 }); + await entered.promise; + now = 1_001; + expire(); + expect((await flight).ok).toBe(false); + let mutated = false; + const work = withConfigDirHardeningSettled(configDir, () => { mutated = true; }); + try { await Promise.resolve(); expect(mutated).toBe(false); } + finally { release.resolve(); await work; } + expect(mutated).toBe(true); +}); + +test("the settled check and secret mutation cannot be split by a queued hardener", async () => { + const events: string[] = []; + let flight: Promise | undefined; + setAsyncIcaclsRunnerForTests(async () => { events.push("icacls"); return success; }); + await withConfigDirHardeningSettled(root, () => { + events.push("mutation starts"); + flight = hardenSecretDirAsync(root, { required: true }); + events.push("mutation ends"); + }); + await flight; + expect(events.slice(0, 3)).toEqual(["mutation starts", "mutation ends", "icacls"]); +}); + +test("diagnostics discard arbitrary error codes, names and messages", () => { + const sensitive = "private-fixture-value"; + const ca = spyOn(localCa, "ensureLocalInterceptCa").mockImplementation(() => { + throw Object.assign(new Error(sensitive), { code: sensitive, name: sensitive }); + }); + const warnings = spyOn(console, "warn").mockImplementation(() => {}); + try { + const result = firstParty.applyDesktopFirstParty({ port: 10100 }); + expect(result).toMatchObject({ ok: false, reason: "ca_unavailable", diagnostic: { stage: "ca", cause: "unknown" } }); + expect(JSON.stringify(result)).not.toContain(sensitive); + expect(warnings).toHaveBeenCalledWith("[claude-intercept] secret preparation failed: stage=ca cause=unknown"); + } finally { ca.mockRestore(); warnings.mockRestore(); } +}); + +test.each(["native PUT", "agent-settings POST", "CLI-intent PUT", "offline CLI"])("%s waits for the config ACL flight before creating intercept secrets", async entry => { + const config = { port: 10100, providers: {}, defaultProvider: "openai", + claudeCode: { desktopMode: "first-party", intercept: { picker: false } } } as OcxConfig; + writeFileSync(join(root, "config.json"), JSON.stringify(config)); + const entered = deferred(), release = deferred(), atApply = deferred(); + let pending = true; + const overlapping: string[] = []; + setAsyncIcaclsRunnerForTests(async args => { + if (args[0] === root) { entered.resolve(); await release.promise; } + return success; + }); + setIcaclsRunnerForTests(args => { + if (pending && args[0]?.startsWith(join(root, "claude-intercept"))) { + overlapping.push(args[1]!); atApply.resolve(); + } + return success; + }); + const originalBarrier = configPaths.withConfigDirHardeningSettled; + entrySpy = spyOn(configPaths, "withConfigDirHardeningSettled").mockImplementation((dir: string, mutate: () => T): Promise => { + const work = originalBarrier(dir, mutate); + atApply.resolve(); return work; + }); + hardenConfigDir(); + await entered.promise; + const request = entry === "offline CLI" + ? applyDesktop(undefined, { kind: "first-party" }, { findLiveProxyImpl: async () => null }) + : (async () => { + const path = entry === "native PUT" ? "/api/native-integrations/claude-desktop" + : entry === "CLI-intent PUT" ? "/api/claude-code" : "/api/claude-desktop/apply"; + const url = new URL(`http://127.0.0.1:10100${path}`); + const response = await handleManagementAPI(new Request(url, { method: entry === "agent-settings POST" ? "POST" : "PUT", + headers: { Host: url.host, "Content-Type": "application/json" }, + body: JSON.stringify(entry === "native PUT" ? { enabled: true } : entry === "CLI-intent PUT" ? { cliFirstParty: true } : { mode: "first-party" }), + }), url, config, { ensureClaudeIntercept: async () => ({ ok: true, + state: { proxyPort: 10200, caCertPath: join(root, "claude-intercept", "ca.pem"), pickerProxyPort: null } }) }, + "admin-token", undefined, { trustedLoopback: true }); + const body = await response!.json(); + expect(response!.status, JSON.stringify(body)).toBe(200); + return body; + })(); + try { + // Observe arrival at either the real barrier or the first child ACL command. This + // handshake covers dynamic imports without sleeps or a guessed microtask count. + await atApply.promise; + expect(overlapping).toEqual([]); + expect(existsSync(join(root, "claude-intercept"))).toBe(false); + } finally { pending = false; release.resolve(); await request; } + expect(await request).toMatchObject({ ok: true }); + expect(existsSync(join(root, "claude-intercept", "ca.pem"))).toBe(true); + expect(readClaudeInterceptProxyToken(root)).not.toBeNull(); +}); + +test("desired-state CLI reconciliation waits immediately before its synchronous leaf", async () => { + const config = { port: 10100, claudeCode: { desktopMode: "first-party", cliFirstParty: true } } as OcxConfig; + const entered = deferred(), release = deferred(), inspected = deferred(); + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + hardenConfigDir(); + await entered.promise; + const original = firstParty.inspectDesktopFirstParty; + const inspection = spyOn(firstParty, "inspectDesktopFirstParty").mockImplementation((...args) => { + const result = original(...args); inspected.resolve(); return result; + }); + const work = ensureClaudeDesktopMatchesDesired({ loadConfig: () => config, log: () => {}, error: () => {} } as Parameters[0]); + try { + await inspected.promise; + expect(existsSync(join(root, "claude-intercept"))).toBe(false); + } finally { release.resolve(); await work; inspection.mockRestore(); } + expect(readClaudeInterceptProxyToken(root)).not.toBeNull(); +}); + +test("runtime token creation drains a parent flight started during the CA await", async () => { + const ca = localCa.ensureLocalInterceptCa(root); + const entered = deferred(), release = deferred(), returnedCa = deferred(); + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + const startupCa = spyOn(localCa, "ensureLocalInterceptCaForStartup").mockImplementation(async () => { + hardenConfigDir(); await entered.promise; returnedCa.resolve(); return ca; + }); + const work = startClaudeIntercept({ config: { claudeCode: { cliFirstParty: true } } as OcxConfig, + publicPort: 10100, configDir: root, dispatch: async () => new Response("ok"), pickerPlatform: "linux", + startProxy: async (_port, options) => startConnectProxy(0, options), + }); + try { + await returnedCa.promise; + await Promise.resolve(); + expect(readClaudeInterceptProxyToken(root)).toBeNull(); + } finally { + release.resolve(); + try { await (await work)?.stop(); } finally { startupCa.mockRestore(); } + } + expect(readClaudeInterceptProxyToken(root)).not.toBeNull(); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 7e5022b3319..6c2fea430f7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,5 @@ { "provider-input-modalities.test.ts": "providers", "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", + "claude-intercept-acl-barrier.test.ts": "claude-integration", "codex-catalog-heal-reanchor.test.ts": "codex-integration", "tool-identity-hash.test.ts": "adapters", "server-combo-required-tool-contract.test.ts": "server", From 32720b9711cdfa86e36e4853b06b62493534eb1b Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:33:50 +0900 Subject: [PATCH 2/5] fix(claude): bound ACL admission and guard picker cleanup steps --- src/claude/desktop-first-party.ts | 14 +- src/claude/desktop-picker.ts | 13 +- src/claude/intercept/picker-ca-cleanup.ts | 6 +- src/claude/intercept/picker-models.ts | 9 +- src/cli/claude-desktop.ts | 4 +- src/cli/ensure-desired-integrations.ts | 8 +- src/config/paths.ts | 6 +- src/lib/windows-secret-acl.ts | 44 +++- .../management/agent-settings-routes.ts | 10 +- .../management/native-integration-routes.ts | 4 +- structure/clients/claude-desktop.md | 2 +- structure/config.md | 2 +- structure/runtime.md | 2 +- .../claude-intercept-acl-barrier.test.ts | 233 +++++++++++++++++- 14 files changed, 314 insertions(+), 43 deletions(-) diff --git a/src/claude/desktop-first-party.ts b/src/claude/desktop-first-party.ts index b19b41c6534..6782b01f556 100644 --- a/src/claude/desktop-first-party.ts +++ b/src/claude/desktop-first-party.ts @@ -19,7 +19,8 @@ * into Claude Code's settings keeps `first-party` (observeClaudeDesktopMode), so moving the default * to gateway never flips a working Desktop under the operator. */ -import { getConfigDir } from "../config/paths"; +import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths"; +import { WindowsSecretAclSettleTimeoutError } from "../lib/windows-secret-acl"; import { join } from "node:path"; import type { OcxConfig } from "../types"; import { claudeConfigDir } from "./auth-detect"; @@ -230,6 +231,17 @@ 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; diagnostic?: InterceptSecretDiagnostic }; +/** Async apply/reconcile boundaries translate admission timeout into the existing refusal. */ +export async function withFirstPartySecretsSettled(configDir: string, prepare: () => T): + Promise> { + try { return await withConfigDirHardeningSettled(configDir, prepare); } + catch (error) { + if (!(error instanceof WindowsSecretAclSettleTimeoutError)) throw error; + return { ok: false, reason: "ca_unavailable", path: claudeInterceptCaCertPath(configDir), + diagnostic: { stage: "ca", cause: "ETIMEDOUT" } }; + } +} + /** * 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. diff --git a/src/claude/desktop-picker.ts b/src/claude/desktop-picker.ts index 54c359c9888..e804938bb01 100644 --- a/src/claude/desktop-picker.ts +++ b/src/claude/desktop-picker.ts @@ -222,7 +222,7 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps) if (first) return refuse(first); if (options.persist) { try { - if (!deps.persistPreference(true)) return refuse("mode_not_committed"); + if (!await withConfigDirHardeningSettled(deps.configDir, () => deps.persistPreference(true))) return refuse("mode_not_committed"); } catch { return refuse("mode_not_committed"); } } const second = check(deps.readConfig(), true); @@ -268,7 +268,10 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps) const compensationFailed = (trustedByAttempt || options.callerAddedTrust === true) && !(await compensateTrust(true)); return withTrustFailure({ ...runtimeStatus(trust), reason: "proxy_unavailable" }, compensationFailed); } - const applied = applyProfile({ ...profileOptions(deps), proxyPort }); + const applied = await withConfigDirHardeningSettled(deps.configDir, () => + check(deps.readConfig(), true) || deps.proxyPort() !== proxyPort + ? { ok: false as const, reason: "write_failed" } + : applyProfile({ ...profileOptions(deps), proxyPort })); if (!applied.ok) { const compensationFailed = (trustedByAttempt || options.callerAddedTrust === true) && !(await compensateTrust(true)); return withTrustFailure({ ...runtimeStatus(trust), reason: "profile_failed", residual: ["profile"] }, compensationFailed); @@ -288,11 +291,11 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps) const residual: string[] = []; try { deps.runtime.disarm(); } catch { residual.push("runtime"); } if (options.persist) { - try { if (!deps.persistPreference(false)) residual.push("preference"); } + try { if (!await withConfigDirHardeningSettled(deps.configDir, () => deps.persistPreference(false))) residual.push("preference"); } catch { residual.push("preference"); } } try { - const removed = removeProfile(profileOptions(deps)); + const removed = await withConfigDirHardeningSettled(deps.configDir, () => removeProfile(profileOptions(deps))); if (!removed.ok) residual.push("profile"); } catch { residual.push("profile"); } // Trust goes only once Desktop no longer selects the picker profile: a selected profile without @@ -332,7 +335,7 @@ export async function removeDesktopPickerArtifacts(options: { configDir?: string const platform = options.platform ?? process.platform; const residual: string[] = []; try { - const removed = removeDesktopPickerProfile({ configDir, ...(platform ? { platform } : {}) }); + const removed = await withConfigDirHardeningSettled(configDir, () => removeDesktopPickerProfile({ configDir, ...(platform ? { platform } : {}) })); if (!removed.ok) residual.push("profile"); } catch { residual.push("profile"); } let released = false; diff --git a/src/claude/intercept/picker-ca-cleanup.ts b/src/claude/intercept/picker-ca-cleanup.ts index f2b01cc7134..8c01f8e34a6 100644 --- a/src/claude/intercept/picker-ca-cleanup.ts +++ b/src/claude/intercept/picker-ca-cleanup.ts @@ -1,6 +1,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { withConfigDirHardeningSettled } from "../../config/paths"; import { acknowledgePendingPickerCaUntrust, pendingPickerCaHasLivePublishedOwner, @@ -16,7 +17,7 @@ export async function drainPendingPickerCaUntrust( ): Promise { const pending = readPendingPickerCaUntrust(configDir); if (!pending) return true; - if (pendingPickerCaHasLivePublishedOwner(configDir, pending)) return false; + if (await withConfigDirHardeningSettled(configDir, () => pendingPickerCaHasLivePublishedOwner(configDir, pending))) return false; const privateDir = mkdtempSync(join(tmpdir(), "ocx-picker-untrust-")); try { @@ -24,7 +25,8 @@ export async function drainPendingPickerCaUntrust( writeFileSync(publicCopy, pending.certPem, { mode: 0o600 }); const result = await untrustPickerCa(publicCopy, pending.sha1, security, platform); if (!result.ok) return false; - return acknowledgePendingPickerCaUntrust(configDir, pending, result); + // OS untrust yielded: a new parent flight may have started since the owner inspection. + return await withConfigDirHardeningSettled(configDir, () => acknowledgePendingPickerCaUntrust(configDir, pending, result)); } finally { rmSync(privateDir, { recursive: true, force: true }); } diff --git a/src/claude/intercept/picker-models.ts b/src/claude/intercept/picker-models.ts index 17ae9c3d9f9..c704f6d4f63 100644 --- a/src/claude/intercept/picker-models.ts +++ b/src/claude/intercept/picker-models.ts @@ -4,6 +4,7 @@ */ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; +import { withConfigDirHardeningSettled } from "../../config/paths"; import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../../codex/catalog"; import type { OcxClaudeDesktopProfile } from "../../types"; import { aliasForRoute, claudeCodeNativeAlias } from "../alias"; @@ -137,9 +138,11 @@ export function createPickerModelSnapshot( const models = build(await load()); const next = { models, builtAt: Date.now() }; if (persistPath) { - mkdirSync(dirname(persistPath), { recursive: true, mode: 0o700 }); - writeFileSync(persistPath, JSON.stringify(next), { encoding: "utf8", mode: 0o600 }); - chmodSync(persistPath, 0o600); + await withConfigDirHardeningSettled(dirname(persistPath), () => { + mkdirSync(dirname(persistPath), { recursive: true, mode: 0o700 }); + writeFileSync(persistPath, JSON.stringify(next), { encoding: "utf8", mode: 0o600 }); + chmodSync(persistPath, 0o600); + }); } snapshot = next; } catch { /* Keep the last good snapshot when discovery or persistence fails. */ } diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 616a0fcfc19..607860aa729 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -19,7 +19,7 @@ import { } from "../claude/desktop-profile"; import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; import { - applyDesktopFirstParty, + applyDesktopFirstParty, withFirstPartySecretsSettled, captureDesktopFirstPartyRollback, isClaudeDesktopMode, observeClaudeDesktopMode, @@ -427,7 +427,7 @@ async function applyFirstPartyDesktop( // Establish the replacement before deleting the working gateway. A refused // cleanup restores only our managed env keys, preserving unrelated settings. const rollback = captureDesktopFirstPartyRollback(config); - const applied = await withConfigDirHardeningSettled(getConfigDir(), () => applyDesktopFirstParty(config)); + const applied = await withFirstPartySecretsSettled(getConfigDir(), () => applyDesktopFirstParty(config)); if (!applied.ok) return { ok: false, path: applied.path, reason: applied.reason, ...(applied.diagnostic ? { diagnostic: applied.diagnostic } : {}) }; const appliedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null; const library = inspectDesktop3pConfigLibrary({ appliedFingerprint }); diff --git a/src/cli/ensure-desired-integrations.ts b/src/cli/ensure-desired-integrations.ts index bf0709e5e7f..07a3e1a8917 100644 --- a/src/cli/ensure-desired-integrations.ts +++ b/src/cli/ensure-desired-integrations.ts @@ -10,7 +10,7 @@ */ import { loadConfig } from "../config"; import { cliFirstPartyDesired, firstPartyDesired, reconcileClaudeFirstPartySettings } from "../claude/first-party-settings"; -import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths"; +import { getConfigDir } from "../config/paths"; import { claudeInterceptEnabled } from "../claude/intercept/runtime"; import { removeDesktopPickerArtifacts } from "../claude/desktop-picker"; import { findLiveProxy } from "../server/proxy-liveness"; @@ -18,7 +18,7 @@ import { runtimeRequest } from "./runtime-api"; import { stripGrokConfig, type GrokInjectResult } from "../grok/inject"; import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } from "../claude/desktop-3p"; import { - applyDesktopFirstParty, + applyDesktopFirstParty, withFirstPartySecretsSettled, inspectDesktopFirstParty, observeClaudeDesktopMode, removeDesktopFirstParty, @@ -155,7 +155,7 @@ export async function ensureClaudeDesktopMatchesDesired( if (cliFirstPartyDesired(config)) { const seen = (deps.inspectDesktopFirstParty ?? inspectDesktopFirstParty)(config); if (seen.settings.kind === "absent" || seen.stale || !claudeInterceptEnabled(config)) { - const result = await withConfigDirHardeningSettled(getConfigDir(), () => + const result = await withFirstPartySecretsSettled(getConfigDir(), () => (deps.reconcileClaudeFirstPartySettings ?? reconcileClaudeFirstPartySettings)(config, firstPartyDesired(config, (deps.observeClaudeDesktopMode ?? observeClaudeDesktopMode)(config)))); if (result.ok && result.changed) log(` + Claude CLI first-party env refreshed (${result.path})`); @@ -175,7 +175,7 @@ export async function ensureClaudeDesktopMatchesDesired( } const seen = (deps.inspectDesktopFirstParty ?? inspectDesktopFirstParty)(config); if (!seen.stale) return; - const applied = await withConfigDirHardeningSettled(getConfigDir(), () => (deps.applyDesktopFirstParty ?? applyDesktopFirstParty)(config)); + const applied = await withFirstPartySecretsSettled(getConfigDir(), () => (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; diff --git a/src/config/paths.ts b/src/config/paths.ts index 48d5c79fa7d..9f470b9ebd4 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -76,13 +76,15 @@ export async function flushConfigDirHardening(dir: string = getConfigDir()): Pro /** * Intercept CA/token/picker writers call this after their last unrelated await. Parent * inheritable ACL propagation can overwrite a child's concurrent explicit grant. Drain the - * config flight, then all overlapping registry work (including ancestors and timeout reaps). + * registered config flight and overlapping work (including ancestors and timeout reaps), + * within one ACL deadline. Timeout refuses admission without dropping child cleanup ownership. * The final registry check and synchronous callback share a turn: promise continuations and * newly queued hardeners cannot start icacls between that check and secret publication. * Keep the callback synchronous; an await inside it would reopen the propagation race. */ export async function withConfigDirHardeningSettled(dir: string, mutate: () => T): Promise { - await flushConfigDirHardening(dir); + // Async work registers before its factory starts, including the optional config flight. + // A separate flush here would escape the registry's total admission deadline. return withWindowsSecretAclSettled(dir, mutate); } diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index e746d991030..5841a656fb7 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -505,19 +505,39 @@ function retainedWorkIsAtOrBelow(entry: AsyncAclWork, root: RemovalIdentity, inc || (includeAncestors && pathIsAtOrBelow(rootPath, targetPath)))); } -/** Run a synchronous mutation only after overlapping ACL operations and retained children exit. */ +/** Admission can refuse without surrendering the registry's retained-child cleanup ownership. */ +export class WindowsSecretAclSettleTimeoutError extends Error { + readonly code = "ETIMEDOUT"; + readonly aclFailureOrigin = "acl_settle_timeout"; + constructor() { super("ACL settlement timed out"); this.name = "WindowsSecretAclSettleTimeoutError"; } +} + +/** Run a synchronous mutation after overlapping ACL work exits, within one harden budget. */ export async function withWindowsSecretAclSettled(rootPath: string, mutate: () => T): Promise { + const deadline = nowFn() + resolveHardenDeadlineMs(); const root = captureRemovalIdentity(rootPath); - while (true) { - const pending = [...activeAsyncAclWork.values(), ...pendingAsyncIcaclsReaps.values()] - .flatMap(entries => [...entries]) - .filter(entry => retainedWorkIsAtOrBelow(entry, root, true)) - .map(entry => entry.reap); - // No await between this final observation and the mutation. A newly queued hardener's - // microtasks cannot run icacls until the synchronous callback has completely returned. - if (pending.length === 0) return mutate(); - await Promise.all(pending); - } + let cancelDeadline: (() => void) | undefined; + let timeout: Promise | undefined; + let expired = false; + try { + while (true) { + if (expired || nowFn() >= deadline) throw new WindowsSecretAclSettleTimeoutError(); + const pending = [...activeAsyncAclWork.values(), ...pendingAsyncIcaclsReaps.values()] + .flatMap(entries => [...entries]) + .filter(entry => retainedWorkIsAtOrBelow(entry, root, true)) + .map(entry => entry.reap); + // No await between this final observation and the mutation. A newly queued hardener's + // microtasks cannot run icacls until the synchronous callback has completely returned. + if (pending.length === 0) return mutate(); + timeout ??= new Promise((_resolve, reject) => { + cancelDeadline = asyncIcaclsBeltScheduler(() => { + expired = true; + reject(new WindowsSecretAclSettleTimeoutError()); + }, Math.max(1, deadline - nowFn())); + }); + await Promise.race([Promise.all(pending), timeout]); + } + } finally { cancelDeadline?.(); } } /** True while an async icacls runner still owns this exact path after its caller's belt fired. */ @@ -566,7 +586,7 @@ export function setAsyncIcaclsRunnerForTests(runner: AsyncIcaclsRunner | null): asyncIcaclsRunner = runner ?? defaultAsyncIcaclsRunner; } -/** Test seam: fire the outer caller-facing belt without sleeping. */ +/** Test seam: drive child belts and admission deadlines without sleeping. */ export function setAsyncIcaclsBeltSchedulerForTests( scheduler: SubprocessDeadlineScheduler | null, ): void { diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 3adbdb7d426..380c9c8123b 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1062,7 +1062,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } - const { resolveClaudeDesktopApplyMode, observeClaudeDesktopMode, applyDesktopFirstParty, captureDesktopFirstPartyRollback, removeDesktopFirstParty } = await import("../../claude/desktop-first-party"); + const { resolveClaudeDesktopApplyMode, observeClaudeDesktopMode, applyDesktopFirstParty, withFirstPartySecretsSettled, captureDesktopFirstPartyRollback, removeDesktopFirstParty } = await import("../../claude/desktop-first-party"); const requested = (parsed as { mode?: unknown } | null)?.mode; let desktopMode: "first-party" | "gateway" = resolveClaudeDesktopApplyMode(config, observeClaudeDesktopMode(config)); if (requested !== undefined) { @@ -1106,7 +1106,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } = await import("../../claude/desktop-3p"); const rollback = captureDesktopFirstPartyRollback(config); const { getConfigDir, withConfigDirHardeningSettled } = await import("../../config/paths"); - const applied = await withConfigDirHardeningSettled(getConfigDir(), () => applyDesktopFirstParty(config)); + const applied = await withFirstPartySecretsSettled(getConfigDir(), () => applyDesktopFirstParty(config)); if (!applied.ok) { const { firstPartyRefusalMessage } = await import("./native-integration-routes"); return jsonResponse({ @@ -1554,7 +1554,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise return jsonResponse({ error: "cliFirstParty must be sent alone", code: "cli_first_party_not_alone" }, 400); if (body.cliFirstParty !== undefined) { const { claudeInterceptEnabled, claudeInterceptProxyPort, getClaudeInterceptState } = await import("../../claude/intercept/runtime"); - const { captureDesktopFirstPartyRollback, inspectDesktopFirstParty, observeClaudeDesktopMode, resolveClaudeDesktopMode } = await import("../../claude/desktop-first-party"); + const { captureDesktopFirstPartyRollback, inspectDesktopFirstParty, observeClaudeDesktopMode, resolveClaudeDesktopMode, withFirstPartySecretsSettled } = await import("../../claude/desktop-first-party"); const { firstPartyDesired, readFirstPartyProxyStatus, reconcileClaudeFirstPartySettings } = await import("../../claude/first-party-settings"); const { commitClaudeCodeBlock } = await import("../../claude/claude-code-block"); let bound = (deps.getClaudeInterceptState ?? getClaudeInterceptState)(); @@ -1625,14 +1625,14 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise config.claudeCode = live; let result: ReturnType | undefined; const { getConfigDir, withConfigDirHardeningSettled } = await import("../../config/paths"); - try { result = await withConfigDirHardeningSettled(getConfigDir(), () => + try { result = await withFirstPartySecretsSettled(getConfigDir(), () => (deps.reconcileClaudeFirstPartySettings ?? reconcileClaudeFirstPartySettings)(config, firstPartyDesired(config, observeClaudeDesktopMode(config)))); } catch { /* An unexpected settings failure is mapped after conditional rollback. */ } if (!result?.ok) { let settingsRestored = true; if (body.cliFirstParty) { - try { settingsRestored = await withConfigDirHardeningSettled(getConfigDir(), () => restoreSettings?.() ?? false); } catch { settingsRestored = false; } + try { settingsRestored = result?.reason === "ca_unavailable" || await withConfigDirHardeningSettled(getConfigDir(), () => restoreSettings?.() ?? false); } catch { settingsRestored = false; } let rollback: ReturnType>; try { rollback = mutatePersistedConfig(persisted => { diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 34c7fe1cc81..957b1ac8d70 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -30,7 +30,7 @@ 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, + applyDesktopFirstParty, withFirstPartySecretsSettled, captureDesktopFirstPartyRollback, inspectDesktopFirstParty, observeClaudeDesktopMode, @@ -758,7 +758,7 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { const rollback = captureDesktopFirstPartyRollback(current); - const applied = await withConfigDirHardeningSettled(getConfigDir(), () => applyDesktopFirstParty(current)); + const applied = await withFirstPartySecretsSettled(getConfigDir(), () => applyDesktopFirstParty(current)); if (!applied.ok) { const reason = applied.reason === "foreign_env" || applied.reason === "intercept_disabled" ? applied.reason : "write_failed"; return postCommitRefusal(applied.reason === "unreadable" || applied.reason === "ca_unavailable" ? 500 : 409, "claude-desktop", reason, diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 3de105492c4..a03a49389b4 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -185,7 +185,7 @@ before tunnel selection or dialing. Authentication and loopback refusal remain i Existing Claude consumers omit this option and retain blind forwarding; it enables no new integration or certificate trust. Windows trust removal in `src/claude/intercept/picker-trust-windows.ts` requires the exact picker common name and fingerprint before deleting a current-user Root entry. Public recovery certificates are verified as self-signed picker roots before removal; legacy roots may be removed without matching the current installation extension profile. Windows local-CA publication in `src/claude/intercept/local-ca-files.ts` hardens legacy inherited DACLs only after verifying the current owner and exclusively current-user, SYSTEM or Administrators grants; already private directories skip hardening. Newly created exclusive files and directories are hardened before strict owner/ACL verification and before CA access. ACL verification, including inherited SQLite sidecar ACLs, is memoized by bigint device/inode/birthtime within one publication; birthtime distinguishes recycled file IDs while preserving same-volume rename identity. Path and descriptor identity checks remain active on every access, and removed or replaced entries retire their memo. -Native toggle, management apply, standalone CLI apply, desired-state reconciliation, startup and picker writers follow the [config-directory hardening barrier](../config.md#opencodex-home-and-live-process-state). Synchronous reconciliation and rollback leaves receive that barrier from their nearest async caller; token preparation during rollback is included. `tests/claude-integration/claude-intercept-acl-barrier.test.ts` gates real parent flights and checks that CA/token work starts only after release. First-party failures retain the public `ca_unavailable` reason and message while internal results, refusal bodies and warnings carry an allowlisted `diagnostic` with `ca` or `token` stage and cause label. Raw exceptions, ACL output, PEMs and tokens are never diagnostic fields. +Native toggle, management/CLI apply, desired reconciliation, startup and picker writers follow the [config-directory hardening barrier](../config.md#opencodex-home-and-live-process-state). Cleanup gates each SQLite lease separately across OS untrust; profile and snapshot writes re-enter after trust/discovery. Admission expires within one ACL deadline without mutation or loss of retained-child ownership. `withFirstPartySecretsSettled` maps timeout to `ca_unavailable` with allowlisted `ca`/`ETIMEDOUT` diagnostics; CA startup never retries this as lease contention, and picker refusal releases its transition lock. Reconciliation and token-producing rollback leaves receive the barrier from their async caller; a CA refusal never wrote settings and needs no settings rollback. `tests/claude-integration/claude-intercept-acl-barrier.test.ts` gates parent flights and drives deadlines without sleeps. Diagnostics exclude raw exceptions, ACL output, PEMs and tokens. The authority primitive accepts `validityDays` from 1 through 3650 for short-lived callers; omitted values preserve the existing 3650-day CA lifetime. This parameter does not install trust or rotate an existing authority. Gateway remains the default Desktop mode. On macOS and Windows, picker intent defaults on only within the chosen first-party mode: either an explicit Desktop integration `false` or `claudeCode.intercept.picker: false` disables it. Default intent does not install certificate trust; startup and status only inspect trust, and explicit activation owns installation. diff --git a/structure/config.md b/structure/config.md index cfa70ca50d7..079955e02c9 100644 --- a/structure/config.md +++ b/structure/config.md @@ -62,7 +62,7 @@ test seam and the mutation types remain re-exported through `src/config.ts`. Suc `src/config/paths.ts` owns `OPENCODEX_HOME` expansion and resolution, the config directory and `config.json` path. A relative home is resolved once per distinct raw environment value, so a later working-directory change cannot silently move the active installation. Config reads in this leaf and `src/config/load-degrade.ts` use NTFS ACLs on Windows and private POSIX modes elsewhere. Windows reads omit `chmod` before ACL hardening so repeated loads preserve successful object/freshness observations; external metadata changes still invalidate them. Directory flights keep their existing flush/reap lifecycle, and file/required-write failure policy is unchanged. `tests/windows/windows-config-read-acl-memo.test.ts` covers repeated loads, external invalidation and POSIX permissions. -Intercept secret writers use `withConfigDirHardeningSettled` from `src/config/paths.ts` after their last unrelated await. It drains the optional config-directory hardening flight and overlapping Windows ACL registry work, including ancestors and retained timeout children, then runs the synchronous writer in the same turn as the final empty-registry check. The callback contains no await: queued microtasks cannot start a new inheritable parent ACL update during child CA, token or picker publication. The registry preserves lexical and canonical alias matching; uncertain identity drains conservatively. Removal retains its separate at-or-below scope. +Intercept writers use `withConfigDirHardeningSettled` from `src/config/paths.ts` after their last unrelated await. The registry includes the optional config flight, ancestors and retained timeout children; one existing ACL harden deadline bounds admission, including newly overlapping work. Expiry throws sanitized `WindowsSecretAclSettleTimeoutError` (`ETIMEDOUT`) without running the writer or relinquishing child cleanup ownership. The final empty-registry check and synchronous callback share one turn; the callback contains no await. Picker cleanup independently gates both SQLite owner inspection and post-untrust acknowledgement; profile and model-snapshot publication re-enter after trust/discovery awaits. Lexical/canonical aliases and uncertain identities drain conservatively. Removal retains its separate at-or-below scope and actual-exit requirement. `src/config/process-state.ts` derives `ocx.pid` and `runtime-port.json` from that resolved directory. It owns their byte-compatible writes, parsing, expected-PID filters, cheap liveness, full OCX command diff --git a/structure/runtime.md b/structure/runtime.md index e2fba8b468a..69413c657a4 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -228,7 +228,7 @@ key ids recorded by `links.json`, never the environment token. Context relays re final deletion closes the listener, and `src/server/index/optional-listeners.ts` runs supervisor teardown before closing this listener and the Claude intercept pair. ### Claude intercept pair -Intercept CA startup and token creation in `src/claude/intercept/runtime.ts` follow the [config-directory hardening barrier](config.md#opencodex-home-and-live-process-state). Each synchronous writer runs after the last unrelated await with no pending overlapping ACL work, including retained ancestor children. Persistent picker preparation and subsequent leaf publication apply the same rule independently across trust/credential-store awaits. +Intercept CA startup and token creation in `src/claude/intercept/runtime.ts` follow the [config-directory hardening barrier](config.md#opencodex-home-and-live-process-state). Each synchronous writer requires settled overlapping work, including retained ancestor children, within one ACL deadline; `ETIMEDOUT` refuses startup without entering the CA lease-contention retry. Persistent picker preparation, cleanup leases, leaf/profile publication and model snapshots re-enter independently after OS trust or discovery awaits. At the end of the startup transaction, `startServer` starts the optional Claude intercept pair through `src/server/index/claude-intercept-lifecycle.ts` (fire-and-forget start, `ownsListener` for ingress, `stop` joined into listener shutdown) from `src/claude/intercept/runtime.ts`: a loopback HTTP CONNECT proxy (`src/claude/intercept/connect-proxy.ts`) and a loopback TLS listener (`src/claude/intercept/listener.ts`) that presents a leaf for diff --git a/tests/claude-integration/claude-intercept-acl-barrier.test.ts b/tests/claude-integration/claude-intercept-acl-barrier.test.ts index 59f1acfd091..cf39e34179b 100644 --- a/tests/claude-integration/claude-intercept-acl-barrier.test.ts +++ b/tests/claude-integration/claude-intercept-acl-barrier.test.ts @@ -6,6 +6,13 @@ import * as firstParty from "../../src/claude/desktop-first-party"; import * as localCa from "../../src/claude/intercept/local-ca"; import * as proxyAuth from "../../src/claude/intercept/proxy-auth"; import * as configPaths from "../../src/config/paths"; +import * as pickerTrust from "../../src/claude/intercept/picker-trust"; +import { pickerCaFingerprints, pickerCaPendingUntrustPath, pickerStateDir } from "../../src/claude/intercept/picker-ca"; +import { drainPendingPickerCaUntrust } from "../../src/claude/intercept/picker-ca-cleanup"; +import { preparePersistentPickerAuthority } from "../../src/claude/intercept/picker-ca-startup"; +import { createDesktopPickerController } from "../../src/claude/desktop-picker"; +import { createPickerModelSnapshot } from "../../src/claude/intercept/picker-models"; +import type { PickerRuntime } from "../../src/claude/intercept/picker-runtime"; import { applyDesktop } from "../../src/cli/claude-desktop"; import { ensureClaudeDesktopMatchesDesired } from "../../src/cli/ensure-desired-integrations"; import { startClaudeIntercept } from "../../src/claude/intercept/runtime"; @@ -13,7 +20,7 @@ import { startConnectProxy } from "../../src/claude/intercept/connect-proxy"; import { flushConfigDirHardeningAndReaps, hardenConfigDir, withConfigDirHardeningSettled } from "../../src/config/paths"; import { setLocalCaWindowsAclRunnerForTests } from "../../src/claude/intercept/local-ca-windows"; import { readClaudeInterceptProxyToken } from "../../src/claude/intercept/proxy-auth"; -import { hardenSecretDirAsync, resetHardenedStateForTests, setAsyncIcaclsBeltSchedulerForTests, setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests, setNowForTests, setPlatformForTests, type IcaclsResult } from "../../src/lib/windows-secret-acl"; +import { hardenSecretDirAsync, resetHardenedStateForTests, setAsyncIcaclsBeltSchedulerForTests, setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests, setNowForTests, setPlatformForTests, windowsSecretAclReapPendingForPath, type IcaclsResult } from "../../src/lib/windows-secret-acl"; import { setSyntheticWindowsPrincipalForTests } from "../../src/lib/windows-user-principal"; import { handleManagementAPI } from "../../src/server/management-api"; import type { OcxConfig } from "../../src/types"; @@ -26,7 +33,7 @@ function deferred() { const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; } -const keys = ["OPENCODEX_HOME", "CLAUDE_CONFIG_DIR", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "OPENCODEX_ACL_VERIFY_EXISTING"] as const; +const keys = ["OPENCODEX_HOME", "CLAUDE_CONFIG_DIR", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "OPENCODEX_ACL_VERIFY_EXISTING", "OPENCODEX_ACL_TIMEOUT_MS"] as const; let root: string; let previous: (string | undefined)[]; let entrySpy: ReturnType | undefined; @@ -38,6 +45,7 @@ beforeEach(() => { process.env.CLAUDE_CONFIG_DIR = join(root, "claude"); process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(root, "desktop"); delete process.env.OPENCODEX_ACL_VERIFY_EXISTING; + process.env.OPENCODEX_ACL_TIMEOUT_MS = "1000"; resetHardenedStateForTests(); setPlatformForTests("win32"); setSyntheticWindowsPrincipalForTests(`*${sid}`); @@ -130,6 +138,227 @@ test("the settled check and secret mutation cannot be split by a queued hardener expect(events.slice(0, 3)).toEqual(["mutation starts", "mutation ends", "icacls"]); }); +test.each(["live-owner inspection", "post-untrust acknowledgement"])("pending cleanup gates %s with a fresh parent barrier", async step => { + mkdirSync(pickerStateDir(root)); + const ca = localCa.createCertificateAuthority({ commonName: "pending cleanup fixture" }); + writeFileSync(pickerCaPendingUntrustPath(root), JSON.stringify({ certPem: ca.certPem, ...pickerCaFingerprints(ca.certPem) })); + const entered = deferred(), release = deferred(), atStep = deferred(); + let held = false; + const overlapping: string[][] = []; + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + setIcaclsRunnerForTests(args => { + if (held && args[0]?.startsWith(pickerStateDir(root))) { overlapping.push(args); atStep.resolve(); } + return success; + }); + const barrier = configPaths.withConfigDirHardeningSettled; + entrySpy = spyOn(configPaths, "withConfigDirHardeningSettled").mockImplementation((dir: string, mutate: () => T) => { + const work = barrier(dir, mutate); + if (held) atStep.resolve(); + return work; + }); + const untrust = spyOn(pickerTrust, "untrustPickerCa").mockImplementation(async () => { + if (step === "live-owner inspection") return { ok: false }; + // Simulate a new optional parent flight during the OS await, invalidating successful + // child memos so the acknowledgement must really harden its SQLite lease again. + resetHardenedStateForTests(); + held = true; hardenConfigDir(); await entered.promise; + return { ok: true }; + }); + let work: Promise | undefined; + try { + if (step === "live-owner inspection") { held = true; hardenConfigDir(); await entered.promise; } + work = step === "live-owner inspection" + ? preparePersistentPickerAuthority({ configDir: root, platform: "win32" }).then(() => "unexpected success", error => error.message) + : drainPendingPickerCaUntrust(root); + await atStep.promise; + expect(overlapping).toEqual([]); + expect(existsSync(pickerCaPendingUntrustPath(root))).toBe(true); + if (step === "live-owner inspection") { + expect(existsSync(join(pickerStateDir(root), "ca.lock.sqlite"))).toBe(false); + expect(untrust).not.toHaveBeenCalled(); + } + } finally { held = false; release.resolve(); await work; untrust.mockRestore(); } + expect(await work).toBe(step === "live-owner inspection" ? "picker_ca_pending_untrust" : true); + expect(existsSync(pickerCaPendingUntrustPath(root))).toBe(step === "live-owner inspection"); +}); + +function aclClock() { + let now = 0; + const timers = new Map<() => void, number>(); + setNowForTests(() => now); + setAsyncIcaclsBeltSchedulerForTests((callback, milliseconds) => { + timers.set(callback, now + milliseconds); + return () => { timers.delete(callback); }; + }); + return { + advance(milliseconds: number) { + now += milliseconds; + for (const [callback, deadline] of timers) { + if (deadline <= now) { timers.delete(callback); callback(); } + } + }, + pending: () => timers.size, + }; +} +const nextTurn = () => new Promise(done => setImmediate(done)); + +test("picker profile publication drains a parent flight started during trust inspection", async () => { + const entered = deferred(), release = deferred(), atProfile = deferred(); + let held = false; + const overlapping: string[] = []; + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + setIcaclsRunnerForTests(args => { + if (held && args[0]?.startsWith(pickerStateDir(root))) overlapping.push(args[1]!); + return success; + }); + const barrier = configPaths.withConfigDirHardeningSettled; + entrySpy = spyOn(configPaths, "withConfigDirHardeningSettled").mockImplementation((dir: string, mutate: () => T) => { + const work = barrier(dir, mutate); if (held) atProfile.resolve(); return work; + }); + const inspect = spyOn(pickerTrust, "inspectPickerTrust").mockImplementation(async () => { + held = true; hardenConfigDir(); await entered.promise; return "trusted"; + }); + const runtime = { status: () => ({ desired: true, supported: true, trust: "trusted", listenerReady: true, + effective: true, latched: false, reason: "active", models: 0, snapshotAt: null, lastBootstrapAt: null }), + rearm: async () => {}, disarm: () => {}, refreshTrust: async () => "trusted" } as PickerRuntime; + const controller = createDesktopPickerController({ configDir: root, platform: "win32", runtime, + readConfig: () => ({ port: 10100, claudeCode: { desktopMode: "first-party" } }) as OcxConfig, + persistPreference: () => true, proxyPort: () => 10201, + }); + const work = controller.enable({ persist: false, context: "server" }); + // The unchanged writer may finish before the barrier exists; either path signals arrival. + void work.then(() => atProfile.resolve()); + try { + await atProfile.promise; + expect(overlapping).toEqual([]); + expect(existsSync(join(pickerStateDir(root), "profile-state.json"))).toBe(false); + } finally { held = false; release.resolve(); await work; inspect.mockRestore(); } + expect(await work).toMatchObject({ reason: "restart_required", profile: "applied", effective: true }); + expect(existsSync(join(pickerStateDir(root), "profile-state.json"))).toBe(true); +}); + +test("picker snapshot persistence drains a parent flight started during discovery", async () => { + const entered = deferred(), release = deferred(), atPersist = deferred(); + const file = join(pickerStateDir(root), "models.json"); + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + const barrier = configPaths.withConfigDirHardeningSettled; + entrySpy = spyOn(configPaths, "withConfigDirHardeningSettled").mockImplementation((dir: string, mutate: () => T) => { + const work = barrier(dir, mutate); atPersist.resolve(); return work; + }); + const snapshot = createPickerModelSnapshot(async () => { + hardenConfigDir(); await entered.promise; return { nativeSlugs: [], routedModels: [] }; + }, file); + const work = snapshot.refresh(); + void work.then(() => atPersist.resolve()); + try { + await atPersist.promise; + expect(existsSync(file)).toBe(false); + expect(snapshot.current()).toBeNull(); + } finally { release.resolve(); await work; } + expect(existsSync(file)).toBe(true); + expect(snapshot.current()?.models).toEqual([]); +}); + +test.each(["mutation", "active config flight", "startup", "offline apply", "native PUT", "agent-settings POST", "CLI-intent PUT", "picker transition"])("a retained child deadline refuses %s without publishing or losing cleanup ownership", async caller => { + const clock = aclClock(); + const entered = deferred(), release = deferred(), atAdmission = deferred(); + const config = { port: 10100, providers: {}, defaultProvider: "openai", + claudeCode: { desktopMode: "first-party", intercept: { picker: false } } } as OcxConfig; + writeFileSync(join(root, "config.json"), JSON.stringify(config)); + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + const barrier = configPaths.withConfigDirHardeningSettled; + entrySpy = spyOn(configPaths, "withConfigDirHardeningSettled").mockImplementation((dir: string, mutate: () => T) => { + const work = barrier(dir, mutate); atAdmission.resolve(); return work; + }); + hardenConfigDir(); await entered.promise; + if (caller !== "active config flight") { + clock.advance(100_000); + await configPaths.flushConfigDirHardening(root); + expect(windowsSecretAclReapPendingForPath(root)).toBe(true); + } + let mutated = false; + let outcome: unknown = "pending"; + const controller = createDesktopPickerController({ configDir: root, platform: "win32", + readConfig: () => ({ ...config, claudeCode: { desktopMode: "first-party" } }) as OcxConfig, + persistPreference: () => true, proxyPort: () => 10201, + runtime: { status: () => ({ desired: true, supported: true, trust: "unknown", listenerReady: false, + effective: false, latched: false, reason: "starting", models: 0, snapshotAt: null, lastBootstrapAt: null }) } as PickerRuntime, + }); + const work = (caller === "mutation" || caller === "active config flight" + ? configPaths.withConfigDirHardeningSettled(root, () => { mutated = true; }) + : caller === "startup" + ? localCa.ensureLocalInterceptCaForStartup(root) + : caller === "picker transition" + ? controller.enable({ persist: false, context: "server" }) + : caller === "offline apply" + ? applyDesktop(undefined, { kind: "first-party" }, { findLiveProxyImpl: async () => null }) + : (async () => { + const path = caller === "native PUT" ? "/api/native-integrations/claude-desktop" + : caller === "CLI-intent PUT" ? "/api/claude-code" : "/api/claude-desktop/apply"; + const url = new URL(`http://127.0.0.1:10100${path}`); + const response = await handleManagementAPI(new Request(url, { method: caller === "agent-settings POST" ? "POST" : "PUT", + headers: { Host: url.host, "Content-Type": "application/json" }, + body: JSON.stringify(caller === "native PUT" ? { enabled: true } : caller === "CLI-intent PUT" ? { cliFirstParty: true } : { mode: "first-party" }), + }), url, config, { ensureClaudeIntercept: async () => ({ ok: true, + state: { proxyPort: 10200, caCertPath: join(root, "claude-intercept", "ca.pem"), pickerProxyPort: null } }) }, + "admin-token", undefined, { trustedLoopback: true }); + expect(response!.status).toBe(500); + return response!.json(); + })()) + .then(value => { outcome = value; }, error => { outcome = { code: error.code, message: error.message }; }); + try { + await atAdmission.promise; await nextTurn(); + expect(outcome).toBe("pending"); + clock.advance(999); await nextTurn(); + expect(outcome).toBe("pending"); + clock.advance(1); await nextTurn(); + expect(outcome).not.toBe("pending"); + if (["mutation", "active config flight", "startup"].includes(caller)) { + expect(outcome).toMatchObject({ code: "ETIMEDOUT", message: "ACL settlement timed out" }); + } else if (caller === "picker transition") { + expect(outcome).toMatchObject({ reason: "profile_failed", effective: false }); + expect(controller.busy()).toBe(false); + expect(await controller.transition(async () => "lock released")).toBe("lock released"); + } else { + expect(outcome).toMatchObject({ diagnostic: { stage: "ca", cause: "ETIMEDOUT" } }); + if (caller === "offline apply") expect(outcome).toMatchObject({ ok: false, reason: "ca_unavailable" }); + } + expect(mutated).toBe(false); + expect(existsSync(join(root, "claude-intercept"))).toBe(false); + if (caller !== "active config flight") { + expect(windowsSecretAclReapPendingForPath(root)).toBe(true); + expect(clock.pending()).toBe(0); // No startup lease retry, timer leak, or second deadline. + } + } finally { release.resolve(); await work; } + expect(mutated).toBe(false); + expect(existsSync(join(root, "claude-intercept"))).toBe(false); +}); + +test("new overlapping work cannot renew the admission deadline", async () => { + const clock = aclClock(); + const firstEntered = deferred(), firstRelease = deferred(), secondEntered = deferred(), secondRelease = deferred(); + const later = join(root, "later"); mkdirSync(later); + setAsyncIcaclsRunnerForTests(async args => { + if (args[0] === root) { firstEntered.resolve(); await firstRelease.promise; } + else { secondEntered.resolve(); await secondRelease.promise; } + return success; + }); + const first = hardenSecretDirAsync(root, { required: false }); await firstEntered.promise; + let mutated = false, outcome: unknown = "pending"; + const admission = withConfigDirHardeningSettled(root, () => { mutated = true; }).catch(error => { outcome = error.code; }); + await nextTurn(); clock.advance(600); + const second = hardenSecretDirAsync(later, { required: false }); await secondEntered.promise; + firstRelease.resolve(); await first; await nextTurn(); + try { + expect(outcome).toBe("pending"); + clock.advance(400); await nextTurn(); + expect(outcome).toBe("ETIMEDOUT"); + expect(mutated).toBe(false); + } finally { secondRelease.resolve(); await second; await admission; } + expect(mutated).toBe(false); + expect(clock.pending()).toBe(0); +}); + test("diagnostics discard arbitrary error codes, names and messages", () => { const sensitive = "private-fixture-value"; const ca = spyOn(localCa, "ensureLocalInterceptCa").mockImplementation(() => { From af22d2eae4f982048efe93b81c431a6d56cc8527 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:46:41 +0900 Subject: [PATCH 3/5] test(claude): yield a macrotask before barrier ordering assertions A single microtask yield depended on the current await count; draining queued microtasks with setImmediate keeps the assertions meaningful if later code adds awaits (CodeRabbit review on #7777). --- .../claude-intercept-acl-barrier.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/claude-integration/claude-intercept-acl-barrier.test.ts b/tests/claude-integration/claude-intercept-acl-barrier.test.ts index cf39e34179b..9669e71b2c1 100644 --- a/tests/claude-integration/claude-intercept-acl-barrier.test.ts +++ b/tests/claude-integration/claude-intercept-acl-barrier.test.ts @@ -28,6 +28,8 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; const success: IcaclsResult = { success: true, exitCode: 0, timedOut: false, stdout: "" }; const sid = "S-1-5-21-1-2-3-1001"; +// Drain every queued microtask, however many awaits the code under test adds, without waiting on the gate. +const macrotask = () => new Promise(resolve => setImmediate(resolve)); function deferred() { let resolve!: () => void; const promise = new Promise(done => { resolve = done; }); @@ -98,7 +100,7 @@ test.each(["config root", "ancestor"])("startup CA waits for an ACL flight on it await entered.promise; const startup = localCa.ensureLocalInterceptCaForStartup(configDir); try { - await Promise.resolve(); + await macrotask(); expect(existsSync(join(configDir, "claude-intercept"))).toBe(false); } finally { release.resolve(); await flight; await startup; } expect(existsSync(join(configDir, "claude-intercept", "ca.pem"))).toBe(true); @@ -120,7 +122,7 @@ test("a settled optional deadline still waits for its live ancestor child", asyn expect((await flight).ok).toBe(false); let mutated = false; const work = withConfigDirHardeningSettled(configDir, () => { mutated = true; }); - try { await Promise.resolve(); expect(mutated).toBe(false); } + try { await macrotask(); expect(mutated).toBe(false); } finally { release.resolve(); await work; } expect(mutated).toBe(true); }); @@ -456,7 +458,7 @@ test("runtime token creation drains a parent flight started during the CA await" }); try { await returnedCa.promise; - await Promise.resolve(); + await macrotask(); expect(readClaudeInterceptProxyToken(root)).toBeNull(); } finally { release.resolve(); From 7f4056c0ad48102c481822450b01b41ce2a62894 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:26:14 +0900 Subject: [PATCH 4/5] fix(claude): admit nested Claude settings rewrites after the bind await Review r2 F3: startup migration and Desktop first-party removals rewrite the credential-bearing Claude settings file synchronously after an await. A CLAUDE_CONFIG_DIR nested in the opencodex home shares the config-root ACL flight, so gate the migration and every removal through withConfigDirHardeningSettled on the Claude settings home (removeDesktopFirstPartySettled maps a settle timeout to the existing unreadable refusal without writing). Regressions: nested-home migration and settled removal, red without the barrier, green with it. --- src/claude/desktop-first-party.ts | 19 +++++++ src/claude/intercept/runtime.ts | 5 +- src/cli/claude-desktop.ts | 4 +- src/cli/ensure-desired-integrations.ts | 4 +- .../management/agent-settings-routes.ts | 4 +- .../management/native-integration-routes.ts | 6 +-- .../claude-intercept-acl-barrier.test.ts | 49 ++++++++++++++++++- 7 files changed, 80 insertions(+), 11 deletions(-) diff --git a/src/claude/desktop-first-party.ts b/src/claude/desktop-first-party.ts index 6782b01f556..c1796ab5718 100644 --- a/src/claude/desktop-first-party.ts +++ b/src/claude/desktop-first-party.ts @@ -279,3 +279,22 @@ export function removeDesktopFirstParty( const caCertPath = claudeInterceptCaCertPath(options.opencodexConfigDir ?? getConfigDir()); return removeClaudeInterceptSettings(caCertPath, options.claudeConfigDir); } + +/** + * Remove Desktop's first-party env once ACL work overlapping the Claude settings home settles. + * A `CLAUDE_CONFIG_DIR` nested in the opencodex home shares the config-root hardening flight, so + * the credential-bearing settings rewrite gets the same admission as other intercept secrets. + * The config is read inside the same synchronous turn. A settle timeout refuses without writing. + */ +export async function removeDesktopFirstPartySettled( + readConfig: () => Pick, + options: DesktopFirstPartyOptions = {}, + remove: typeof removeDesktopFirstParty = removeDesktopFirstParty, +): Promise> { + const dir = options.claudeConfigDir ?? claudeConfigDir(); + try { return await withConfigDirHardeningSettled(dir, () => remove(readConfig(), options)); } + catch (error) { + if (!(error instanceof WindowsSecretAclSettleTimeoutError)) throw error; + return { ok: false, reason: "unreadable", path: join(dir, "settings.json") }; + } +} diff --git a/src/claude/intercept/runtime.ts b/src/claude/intercept/runtime.ts index 921694cb57f..6c5001e41b8 100644 --- a/src/claude/intercept/runtime.ts +++ b/src/claude/intercept/runtime.ts @@ -2,6 +2,7 @@ import type { Server } from "bun"; import { supportsDesktopPicker } from "../desktop-picker-platform"; import type { OcxConfig } from "../../types"; import { getConfigDir, withConfigDirHardeningSettled } from "../../config/paths"; +import { claudeConfigDir } from "../auth-detect"; import type { DesktopPickerController } from "../desktop-picker"; import type { ClaudeFirstPartyDesired } from "../first-party-settings"; import { classifyInterceptClient, interceptRouteFor } from "./client-class"; @@ -179,7 +180,9 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio // fixed-port bind leaves settings untouched; an ephemeral test binder never publishes :0 // or a probed port another process can steal. Only stale, owned env blocks are rewritten. try { - migrateClaudeInterceptSettings(buildClaudeInterceptEnv(proxy.port, claudeInterceptCaCertPath(configDir), authToken)); + // Fresh admission after the bind await: a nested CLAUDE_CONFIG_DIR shares the config-root flight. + await withConfigDirHardeningSettled(claudeConfigDir(), () => + migrateClaudeInterceptSettings(buildClaudeInterceptEnv(proxy.port, claudeInterceptCaCertPath(configDir), authToken))); } catch (error) { // Ensure/apply retries a skipped migration; upgraded clients otherwise keep hitting 407. console.warn(`[claude-intercept] settings migration skipped: ${error instanceof Error ? error.message : String(error)}`); diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 607860aa729..be66a45239c 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -24,7 +24,7 @@ import { isClaudeDesktopMode, observeClaudeDesktopMode, recordClaudeDesktopMode, - removeDesktopFirstParty, + removeDesktopFirstParty, removeDesktopFirstPartySettled, resolveClaudeDesktopMode, resolveClaudeDesktopApplyMode, type ClaudeDesktopModeObservation, @@ -463,7 +463,7 @@ export async function applyDesktop( const modeSaved = saveDesktopMode("gateway", deps); const warning = [result.warning, modeSaved ? "" : "desktop mode marker was not saved"].filter(Boolean).join(" "); // The gateway mode is committed before retiring first-party settings. - const removed = removeDesktopFirstParty(loadConfig()); + const removed = await removeDesktopFirstPartySettled(loadConfig); if (!removed.ok) return { ok: false, path: removed.path, reason: "first_party_settings_unreadable", warning: ["gateway applied; first-party cleanup remains incomplete", warning].filter(Boolean).join(" ") }; if (removed.retainedFor === "cli") { diff --git a/src/cli/ensure-desired-integrations.ts b/src/cli/ensure-desired-integrations.ts index 07a3e1a8917..3299983fa73 100644 --- a/src/cli/ensure-desired-integrations.ts +++ b/src/cli/ensure-desired-integrations.ts @@ -21,7 +21,7 @@ import { applyDesktopFirstParty, withFirstPartySecretsSettled, inspectDesktopFirstParty, observeClaudeDesktopMode, - removeDesktopFirstParty, + removeDesktopFirstParty, removeDesktopFirstPartySettled, resolveClaudeDesktopMode, } from "../claude/desktop-first-party"; import { @@ -198,7 +198,7 @@ export async function ensureClaudeDesktopMatchesDesired( error(`⚠️ Claude Desktop picker cleanup skipped: ${detail}.`); } try { - const env = (deps.removeDesktopFirstParty ?? removeDesktopFirstParty)(deps.loadConfig()); + const env = await removeDesktopFirstPartySettled(deps.loadConfig, {}, deps.removeDesktopFirstParty ?? removeDesktopFirstParty); if (env.ok && env.changed) log(" ↩️ Claude Desktop first-party env removed."); else if (env.ok && env.retainedFor === "cli") log(" = Shared first-party env retained for Claude Code CLI."); else if (!env.ok) error(`⚠️ Claude Desktop first-party env cleanup skipped: ${env.reason} (${env.path}).`); diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 380c9c8123b..b3df52a5513 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1062,7 +1062,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } - const { resolveClaudeDesktopApplyMode, observeClaudeDesktopMode, applyDesktopFirstParty, withFirstPartySecretsSettled, captureDesktopFirstPartyRollback, removeDesktopFirstParty } = await import("../../claude/desktop-first-party"); + const { resolveClaudeDesktopApplyMode, observeClaudeDesktopMode, applyDesktopFirstParty, withFirstPartySecretsSettled, captureDesktopFirstPartyRollback, removeDesktopFirstPartySettled } = await import("../../claude/desktop-first-party"); const requested = (parsed as { mode?: unknown } | null)?.mode; let desktopMode: "first-party" | "gateway" = resolveClaudeDesktopApplyMode(config, observeClaudeDesktopMode(config)); if (requested !== undefined) { @@ -1221,7 +1221,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const committed = persistCommittedDesktopGateway(config, state.profile, result.fingerprint); const modeWarning = committed.ok ? undefined : `Gateway applied, but its mode/profile state was not saved (${committed.reason}).`; // The durable marker describes the committed gateway even if old-mode cleanup fails. - const firstPartyRemoved = removeDesktopFirstParty(loadConfig()); + const firstPartyRemoved = await removeDesktopFirstPartySettled(loadConfig); if (!firstPartyRemoved.ok) { return { response: jsonResponse({ error: `Claude Code settings could not be parsed (${firstPartyRemoved.path}); first-party cleanup remains incomplete after gateway apply.`, diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 957b1ac8d70..88c21812f6f 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -35,7 +35,7 @@ import { inspectDesktopFirstParty, observeClaudeDesktopMode, recordClaudeDesktopMode, - removeDesktopFirstParty, + removeDesktopFirstParty, removeDesktopFirstPartySettled, resolveClaudeDesktopApplyMode, type ClaudeDesktopMode, type InterceptSecretDiagnostic, @@ -720,7 +720,7 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { const pickerOff = await ops.disableLocked({ persist: false }); - const firstPartyRemoved = removeDesktopFirstParty(loadConfig()); + const firstPartyRemoved = await removeDesktopFirstPartySettled(loadConfig); if (!firstPartyRemoved.ok) { return postCommitRefusal(409, "claude-desktop", "write_failed", `Claude Code settings could not be read (${firstPartyRemoved.path}); the first-party proxy env was left in place.`, { desiredEnabled }); @@ -833,7 +833,7 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { + const overlapping: string[] = []; + let held = false; + setIcaclsRunnerForTests(args => { + if (held && args[0]?.startsWith(join(root, "claude") + sep)) overlapping.push(args[1] ?? ""); + return success; + }); + const entered = deferred(), release = deferred(); + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + expect(firstParty.applyDesktopFirstParty({ port: 10100 })).toMatchObject({ ok: true }); + const work = startClaudeIntercept({ config: { port: 10100, claudeCode: { cliFirstParty: true } } as OcxConfig, + configDir: root, publicPort: 10100, dispatch: async () => new Response("ok"), pickerPlatform: "linux", + startProxy: async (_port, options) => { + const proxy = await startConnectProxy(0, options); + held = true; hardenConfigDir(); await entered.promise; + return proxy; + }, + }); + try { + await entered.promise; + await macrotask(); + expect(overlapping).toEqual([]); + } finally { + held = false; release.resolve(); + await (await work)?.stop(); + } +}); + +test("settled first-party removal waits for a parent flight over a nested Claude home", async () => { + expect(firstParty.applyDesktopFirstParty({ port: 10100 })).toMatchObject({ ok: true }); + const entered = deferred(), release = deferred(); + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + const flight = hardenSecretDirAsync(root, { required: false }); + await entered.promise; + let removed = false; + const work = firstParty.removeDesktopFirstPartySettled(() => ({}), {}, (config, options) => { + removed = true; + return firstParty.removeDesktopFirstParty(config, options); + }); + try { await macrotask(); expect(removed).toBe(false); } + finally { release.resolve(); await flight; } + expect(await work).toMatchObject({ ok: true, changed: true }); + expect(removed).toBe(true); +}); From 4859387c18aad2acd9f540b85d43efdcc8504b91 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:45:23 +0900 Subject: [PATCH 5/5] fix(claude): admit model-default rewrites of a nested Claude home Review r3 F4: POST /api/claude-code/model-defaults rewrites Claude settings, its staged file and recovery backups synchronously after the body await; gate the whole operation through withConfigDirHardeningSettled(claudeConfigDir()) inside the existing failure mapping. N1: the gateway-apply cleanup refusal no longer claims a JSON parse failure. Also asserts that the startup migration actually rewrites the stale proxy URL (CodeRabbit). Regression: model-defaults POST with a held parent flight, red without the barrier. --- .../management/agent-settings-routes.ts | 7 +++- .../claude-intercept-acl-barrier.test.ts | 40 ++++++++++++++++++- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index b3df52a5513..89cb75d2d6a 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1224,7 +1224,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const firstPartyRemoved = await removeDesktopFirstPartySettled(loadConfig); if (!firstPartyRemoved.ok) { return { response: jsonResponse({ - error: `Claude Code settings could not be parsed (${firstPartyRemoved.path}); first-party cleanup remains incomplete after gateway apply.`, + error: `Claude Code first-party settings cleanup did not complete (${firstPartyRemoved.path}); the gateway was applied.`, code: "claude_desktop_first_party_refused", reason: firstPartyRemoved.reason, applied: true, saved: committed.ok, mode: "gateway", path: result.path, ...(modeWarning ? { warning: modeWarning } : {}), @@ -1409,7 +1409,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise !("fingerprint" in body) || typeof body.fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(body.fingerprint)) { return jsonResponse({ error: "fingerprint is required" }, 400); } - return jsonResponse(applyClaudeModelDefaults(config.claudeCode?.tierModels, body.fingerprint)); + // Fresh admission after the body await: a nested Claude home shares the config-root ACL flight. + const fingerprint = body.fingerprint; + const { withConfigDirHardeningSettled } = await import("../../config/paths"), { claudeConfigDir } = await import("../../claude/auth-detect"); + return jsonResponse(await withConfigDirHardeningSettled(claudeConfigDir(), () => applyClaudeModelDefaults(config.claudeCode?.tierModels, fingerprint))); } catch (error) { rethrowManagementBodyTooLarge(error); if (error instanceof ModelDefaultsRecoveryError) return jsonResponse({ error: error.message, code: error.message, backupPath: error.backupPath }, 500); diff --git a/tests/claude-integration/claude-intercept-acl-barrier.test.ts b/tests/claude-integration/claude-intercept-acl-barrier.test.ts index 24229ed4534..fb48d5d27ca 100644 --- a/tests/claude-integration/claude-intercept-acl-barrier.test.ts +++ b/tests/claude-integration/claude-intercept-acl-barrier.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, sep } from "node:path"; import * as firstParty from "../../src/claude/desktop-first-party"; @@ -23,6 +23,7 @@ import { readClaudeInterceptProxyToken } from "../../src/claude/intercept/proxy- import { hardenSecretDirAsync, resetHardenedStateForTests, setAsyncIcaclsBeltSchedulerForTests, setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests, setNowForTests, setPlatformForTests, windowsSecretAclReapPendingForPath, type IcaclsResult } from "../../src/lib/windows-secret-acl"; import { setSyntheticWindowsPrincipalForTests } from "../../src/lib/windows-user-principal"; import { handleManagementAPI } from "../../src/server/management-api"; +import { previewClaudeModelDefaults } from "../../src/claude/model-default-settings"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -487,14 +488,21 @@ test("startup settings migration after the proxy bind waits for a parent flight return proxy; }, }); + let handle: Awaited | undefined; try { await entered.promise; await macrotask(); expect(overlapping).toEqual([]); } finally { held = false; release.resolve(); - await (await work)?.stop(); + handle = await work; } + try { + // The ordering check alone would also pass if startup skipped the migration entirely. + const env = (JSON.parse(readFileSync(join(root, "claude", "settings.json"), "utf8")) as { env?: Record }).env; + expect(handle?.proxyPort).toBeGreaterThan(0); + expect(env?.HTTPS_PROXY).toContain(`:${handle?.proxyPort}`); + } finally { await handle?.stop(); } }); test("settled first-party removal waits for a parent flight over a nested Claude home", async () => { @@ -513,3 +521,31 @@ test("settled first-party removal waits for a parent flight over a nested Claude expect(await work).toMatchObject({ ok: true, changed: true }); expect(removed).toBe(true); }); + +// #7777 review r3 F4: the model-defaults rewrite also runs after a body await on the Claude home. +test("model-defaults POST waits for a parent flight over a nested Claude home", async () => { + const home = join(root, "claude"); + mkdirSync(home, { recursive: true }); + const path = join(home, "settings.json"); + writeFileSync(path, JSON.stringify({ env: { HTTPS_PROXY: "http://opencodex:fixture-token@127.0.0.1:10200" } })); + const models = { sonnet: "claude-sonnet-4-6" }; + const fingerprint = previewClaudeModelDefaults(models).fingerprint; + const overlapping: string[] = []; + let held = true; + setIcaclsRunnerForTests(args => { if (held && args[0]?.startsWith(home + sep)) overlapping.push(args[1] ?? ""); return success; }); + const entered = deferred(), release = deferred(); + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + hardenConfigDir(); + await entered.promise; + const url = new URL("http://127.0.0.1:10100/api/claude-code/model-defaults"); + const work = handleManagementAPI(new Request(url, { method: "POST", headers: { Host: url.host, "Content-Type": "application/json" }, + body: JSON.stringify({ fingerprint }) }), url, { port: 10100, providers: {}, claudeCode: { tierModels: models } } as OcxConfig, + {}, "admin-token", undefined, { trustedLoopback: true }); + try { + await macrotask(); + expect(overlapping).toEqual([]); + expect(JSON.parse(readFileSync(path, "utf8")).env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBeUndefined(); + } finally { held = false; release.resolve(); } + expect((await work)?.status).toBe(200); + expect(JSON.parse(readFileSync(path, "utf8")).env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe(models.sonnet); +});