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..c1796ab5718 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"; @@ -211,9 +212,35 @@ 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 }; + +/** 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 @@ -226,11 +253,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 }; @@ -248,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/desktop-picker.ts b/src/claude/desktop-picker.ts index 6c00058c423..e804938bb01 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"; @@ -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); @@ -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); @@ -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/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-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-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-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/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..6c5001e41b8 100644 --- a/src/claude/intercept/runtime.ts +++ b/src/claude/intercept/runtime.ts @@ -1,7 +1,8 @@ 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 { claudeConfigDir } from "../auth-detect"; import type { DesktopPickerController } from "../desktop-picker"; import type { ClaudeFirstPartyDesired } from "../first-party-settings"; import { classifyInterceptClient, interceptRouteFor } from "./client-class"; @@ -149,7 +150,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, @@ -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 f10c6008125..be66a45239c 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"; @@ -19,12 +19,12 @@ import { } from "../claude/desktop-profile"; import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; import { - applyDesktopFirstParty, + applyDesktopFirstParty, withFirstPartySecretsSettled, captureDesktopFirstPartyRollback, isClaudeDesktopMode, observeClaudeDesktopMode, recordClaudeDesktopMode, - removeDesktopFirstParty, + removeDesktopFirstParty, removeDesktopFirstPartySettled, resolveClaudeDesktopMode, resolveClaudeDesktopApplyMode, type ClaudeDesktopModeObservation, @@ -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 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 }); 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("; "); @@ -462,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 9cff9cf11f5..3299983fa73 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 } from "../config/paths"; import { claudeInterceptEnabled } from "../claude/intercept/runtime"; import { removeDesktopPickerArtifacts } from "../claude/desktop-picker"; import { findLiveProxy } from "../server/proxy-liveness"; @@ -17,10 +18,10 @@ 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, + removeDesktopFirstParty, removeDesktopFirstPartySettled, resolveClaudeDesktopMode, } from "../claude/desktop-first-party"; import { @@ -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 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})`); 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 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; @@ -196,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/config/paths.ts b/src/config/paths.ts index d20166733d8..9f470b9ebd4 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,21 @@ 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 + * 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 { + // 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); +} + /** 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..5841a656fb7 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,43 @@ 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)))); +} + +/** 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); + 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. */ @@ -550,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 8ea546b1177..89cb75d2d6a 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, 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) { @@ -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 withFirstPartySecretsSettled(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("; "); @@ -1219,10 +1221,10 @@ 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.`, + 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 } : {}), @@ -1407,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); @@ -1552,7 +1557,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)(); @@ -1622,13 +1627,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 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 = 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 => { @@ -1650,6 +1657,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..88c21812f6f 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -30,14 +30,15 @@ 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, recordClaudeDesktopMode, - removeDesktopFirstParty, + removeDesktopFirstParty, removeDesktopFirstPartySettled, 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", @@ -717,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 }); @@ -755,17 +758,17 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { const rollback = captureDesktopFirstPartyRollback(current); - const applied = 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, - 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 }); } @@ -830,7 +833,7 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise/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..fb48d5d27ca --- /dev/null +++ b/tests/claude-integration/claude-intercept-acl-barrier.test.ts @@ -0,0 +1,551 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +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"; +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"; +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, 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"; + +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; }); + return { promise, resolve }; +} +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; + +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; + process.env.OPENCODEX_ACL_TIMEOUT_MS = "1000"; + 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 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); +}); + +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 macrotask(); 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.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(() => { + 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 macrotask(); + expect(readClaudeInterceptProxyToken(root)).toBeNull(); + } finally { + release.resolve(); + try { await (await work)?.stop(); } finally { startupCa.mockRestore(); } + } + expect(readClaudeInterceptProxyToken(root)).not.toBeNull(); +}); + +// #7777 review r2 F3: a CLAUDE_CONFIG_DIR nested in the opencodex home shares the config-root +// flight, so its credential-bearing settings rewrites need the same fresh admission. +test("startup settings migration after the proxy bind waits for a parent flight over a nested Claude home", async () => { + 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; + }, + }); + let handle: Awaited | undefined; + try { + await entered.promise; + await macrotask(); + expect(overlapping).toEqual([]); + } finally { + held = false; release.resolve(); + 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 () => { + 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); +}); + +// #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); +}); 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",