Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/test-layout/layout-clients.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
58 changes: 54 additions & 4 deletions src/claude/desktop-first-party.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<T>(configDir: string, prepare: () => T):
Promise<T | Extract<DesktopFirstPartyApplyResult, { ok: false }>> {
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
Expand All @@ -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 };
Expand All @@ -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<OcxConfig, "claudeCode" | "runtimeRole">,
options: DesktopFirstPartyOptions = {},
remove: typeof removeDesktopFirstParty = removeDesktopFirstParty,
): Promise<ReturnType<typeof removeDesktopFirstParty>> {
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") };
}
}
19 changes: 11 additions & 8 deletions src/claude/desktop-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion src/claude/first-party-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<DesktopFirstPartyApplyResult, { ok: false }>;

// Synchronous leaf: async callers wrap reconciliation in withConfigDirHardeningSettled.
export function reconcileClaudeFirstPartySettings(
config: Pick<OcxConfig, "claudeCode" | "port" | "runtimeRole" | "clientIntegrations">,
desired: ClaudeFirstPartyDesired,
Expand Down
3 changes: 2 additions & 1 deletion src/claude/intercept/local-ca.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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<LocalInterceptCa> {
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;
Expand Down
6 changes: 4 additions & 2 deletions src/claude/intercept/picker-ca-cleanup.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -16,15 +17,16 @@ export async function drainPendingPickerCaUntrust(
): Promise<boolean> {
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 {
const publicCopy = join(privateDir, "ca.pem");
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 });
}
Expand Down
4 changes: 3 additions & 1 deletion src/claude/intercept/picker-ca-startup.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
}
9 changes: 6 additions & 3 deletions src/claude/intercept/picker-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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. */ }
Expand Down
5 changes: 3 additions & 2 deletions src/claude/intercept/picker-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 };
Expand Down
9 changes: 6 additions & 3 deletions src/claude/intercept/runtime.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -149,7 +150,7 @@ export async function startClaudeIntercept<T>(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<T>({
leaf,
Expand Down Expand Up @@ -179,7 +180,9 @@ export async function startClaudeIntercept<T>(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)}`);
Expand Down
Loading
Loading