From b3eff06cde6bd8cd5c7bf9049e4395762a25bf37 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 01:55:21 +0000 Subject: [PATCH 1/7] fix(cli): attest Codex listener before token injection --- src/cli/codex-launcher.ts | 11 +++++++++-- structure/cli-management.md | 2 +- .../codex-launcher-admission.test.ts | 2 +- .../codex-launcher-diagnostics.test.ts | 2 +- tests/codex-integration/codex-launcher-env.test.ts | 4 ++-- .../codex-launcher-security.test.ts | 10 +++++----- tests/codex-integration/codex-launcher.test.ts | 13 ++++++++++++- 7 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts index 98fc9f103ea..7cb269a7936 100644 --- a/src/cli/codex-launcher.ts +++ b/src/cli/codex-launcher.ts @@ -33,7 +33,7 @@ import { selfLaunchArgv } from "../lib/self-launch-argv"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; import { commandInvocation } from "../lib/win-exec"; import { findExecutableOnPath } from "../remote-control/workspace-executable"; -import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness"; +import { findLiveProxy, proveLiveProxyOwnedByHome, type LiveProxy } from "../server/proxy-liveness"; import { codexCredentialDestinationIsTrusted, resolveCodexLaunchHome } from "./codex-launch-auth"; import { codexCommandCanBypass, CODEX_UNVERIFIED_SHIM } from "./codex-launch-command"; import { codexTokenArgsAreSafe, codexTokenEnvironmentIsSafe } from "./codex-launch-policy"; @@ -159,6 +159,8 @@ export type CodexLaunchEnvDeps = { /** Test seam; production reads the hardened service token file. */ serviceToken?: (base: CodexLaunchEnv) => string | null; credentialDestination?: (home: string, report?: CodexTokenRefusalReporter) => boolean; + /** True only after the ensured listener proved this home's runtime-record secret. */ + runtimeAttested?: boolean; }; /** Read the client admission credential from this installation's service-token file. */ @@ -186,6 +188,7 @@ export function buildCodexLaunchEnv( }; if (!codexTokenArgsAreSafe(deps.args ?? [])) return withhold("args"); if (!codexTokenEnvironmentIsSafe(env)) return withhold("environment"); + if (deps.routingKind === "opencodex-local" && deps.runtimeAttested !== true) return withhold("destination"); // NO_PROXY parsing and precedence vary across native HTTP/WebSocket transports. // Without proving bypass, preserve the user's transport but never load a secret. const home = resolveCodexLaunchHome(env); @@ -306,5 +309,9 @@ export async function cmdCodex(args: string[]): Promise { // what the session does next; `ocx doctor` names the autostart failure. console.error(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC); } - return spawnCodex(args, buildCodexLaunchEnv(process.env, { routingKind, args })); + // A public health marker proves liveness, not ownership of the listener. Never read the + // persisted service token until the exact runtime-record endpoint answers a fresh challenge. + const runtimeAttested = plan.kind === "routed" && live !== null + && await proveLiveProxyOwnedByHome(live) === "proven"; + return spawnCodex(args, buildCodexLaunchEnv(process.env, { routingKind, args, runtimeAttested })); } diff --git a/structure/cli-management.md b/structure/cli-management.md index c3aab111345..5bdbca762eb 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -32,7 +32,7 @@ Same-file project aliases additionally require ownership/ACL inspection of the l For fresh credentials, the launcher resolves a native literal `CODEX_HOME` once and passes the canonical absolute directory both to the destination verifier and to the child. Missing, unreadable or non-directory homes and ambiguous Windows home casing withhold credentials. Any nonempty inherited HTTP(S)/ALL proxy variable, matched case-insensitively, suppresses token reads irrespective of NO_PROXY; denied and inherited-token launches preserve their environment. Successful injection adds both allowed loopback IPs to identical child NO_PROXY/no_proxy values while preserving prior bypass entries, covering default OS proxy discovery. Configured `features.respect_system_proxy` and feature-changing argv suppress loading because system-selected routes can ignore bypasses. -`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. +`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. Before loading a fresh service token, the ensured listener must prove possession of this home's runtime-record attestation secret for its exact pid and port; a public health marker or failed ensure never authorizes credential loading. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. Single-word prompts require `--` so they cannot select an unknown native subcommand. Missing system files also require a protected namespace: the closest existing directory must prevent another user from creating the missing layer, and its ancestors must prevent replacement. A normal Windows ProgramData directory that grants Users directory creation cannot authorize an absent OpenAI/Codex namespace; the launcher withholds credentials without creating or changing that directory. macOS opens each validated entry without following its final link (O_SYMLINK for the link entry itself), keeps dev/ino as bigint and requires descriptor dev/ino/type to match its original lstat, and reads its ACL with acl_get_fd_np. Only NULL with immediately captured ENOENT on this descriptor query means no extended ACL; open/stat/identity/close failures and every other NULL/error refuse. Every opened descriptor closes in finally. For a present ACL it validates and enumerates every entry through the documented -1/EINVAL terminator. Deny-only and read-only allow entries are accepted; any allow granting write/add-file, append/add-subdirectory, delete, delete-child, attribute, extended-attribute, security or ownership mutation refuses regardless of earlier denies. Unknown tags and FFI/enumeration failures refuse. Forced managed-preference checks remain mandatory. Pure policy and Darwin-gated native ACL coverage lives in `tests/codex-integration/codex-launcher-darwin-acl.test.ts`; modeled IO regressions for all three walks live in `tests/codex-integration/codex-launcher-darwin-identity.test.ts`. Tests model a protected empty machine namespace inside their private fixture; they do not modify real system configuration. Windows fixtures probe ancestor ownership before positive assertions; an unavailable protected location requires a proved ownership refusal and an explicit skip reason. Negative mutations and no-token/zero-read assertions execute even with unsafe ancestors; credential-file negatives bypass destination verification independently. Native checks use production defaults. diff --git a/tests/codex-integration/codex-launcher-admission.test.ts b/tests/codex-integration/codex-launcher-admission.test.ts index 364ad3ef864..0bbcd481f8c 100644 --- a/tests/codex-integration/codex-launcher-admission.test.ts +++ b/tests/codex-integration/codex-launcher-admission.test.ts @@ -36,7 +36,7 @@ function launch(home: string, args: string[] = [], extra: Record console.error = (...values: unknown[]) => { notes.push(values.join(" ")); }; try { const env = buildCodexLaunchEnv({ CODEX_HOME: home, ...extra }, { - routingKind: "opencodex-local", args, serviceToken: () => { reads++; return token; }, + routingKind: "opencodex-local", runtimeAttested: true, args, serviceToken: () => { reads++; return token; }, }); return { env, reads, notes }; } finally { console.error = original; } diff --git a/tests/codex-integration/codex-launcher-diagnostics.test.ts b/tests/codex-integration/codex-launcher-diagnostics.test.ts index d0532e055ba..bfad3f3a492 100644 --- a/tests/codex-integration/codex-launcher-diagnostics.test.ts +++ b/tests/codex-integration/codex-launcher-diagnostics.test.ts @@ -41,7 +41,7 @@ describe("Codex credential withholding diagnostics", () => { [{ CODEX_HOME: "" }, {}, "Codex home cannot be resolved unambiguously"], ] as const) test(reason, () => { let reads = 0; - const result = capture(base, { routingKind: "opencodex-local", ...deps, serviceToken: () => { reads++; return secret; } }); + const result = capture(base, { routingKind: "opencodex-local", runtimeAttested: true, ...deps, serviceToken: () => { reads++; return secret; } }); diagnostic(result.notes, reason); expect(reads).toBe(0); expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); diff --git a/tests/codex-integration/codex-launcher-env.test.ts b/tests/codex-integration/codex-launcher-env.test.ts index 505ce6ffc3e..2e1e4450d45 100644 --- a/tests/codex-integration/codex-launcher-env.test.ts +++ b/tests/codex-integration/codex-launcher-env.test.ts @@ -46,7 +46,7 @@ async function fixture(run: (root: string, home: string) => void | Promise function launch(base = { ...process.env }) { let reads = 0, reason: string | undefined; const env = buildCodexLaunchEnv(base, { - routingKind: "opencodex-local", serviceToken: () => { reads++; return token; }, + routingKind: "opencodex-local", runtimeAttested: true, serviceToken: () => { reads++; return token; }, credentialDestination: (home, report) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), r => { reason = r; report?.(r); }), }); return { env, reads, reason }; @@ -234,7 +234,7 @@ describe("Codex launcher native home and proxy environment", () => { test("feature argv cannot enable a bypass-ignoring system proxy", () => fixture(() => { for (const args of [["--enable", "respect_system_proxy"], ["--enable=respect_system_proxy"]]) { let reads = 0; - buildCodexLaunchEnv(process.env, { routingKind: "opencodex-local", args, serviceToken: () => { reads++; return token; } }); + buildCodexLaunchEnv(process.env, { routingKind: "opencodex-local", runtimeAttested: true, args, serviceToken: () => { reads++; return token; } }); expect(reads).toBe(0); } })); diff --git a/tests/codex-integration/codex-launcher-security.test.ts b/tests/codex-integration/codex-launcher-security.test.ts index 0837aad73dc..50b270fad5d 100644 --- a/tests/codex-integration/codex-launcher-security.test.ts +++ b/tests/codex-integration/codex-launcher-security.test.ts @@ -44,7 +44,7 @@ describe("codex launcher credential destination", () => { codexLaunchOwnershipTest("the managed starting configuration reads the service token exactly once", () => fixture((_home, codexHome) => { writeFileSync(join(codexHome, "config.toml"), local); let reads = 0; - const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; } }); + const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", runtimeAttested: true, serviceToken: () => { reads++; return fixtureToken; } }); expect(reads).toBe(1); expect(env.OPENCODEX_API_AUTH_TOKEN).toBe(fixtureToken); })); @@ -71,14 +71,14 @@ describe("codex launcher credential destination", () => { const originalDirectory = process.cwd(); try { process.chdir(parse(home).root); - expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local" }).OPENCODEX_API_AUTH_TOKEN).toBe(fixtureToken); + expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", runtimeAttested: true }).OPENCODEX_API_AUTH_TOKEN).toBe(fixtureToken); } finally { process.chdir(originalDirectory); } })); test("directory and remote overrides never read a service token", () => fixture((_home, codexHome) => { writeFileSync(join(codexHome, "config.toml"), local); for (const args of [["-C", "other"], ["-Cother"], ["--cd=other"], ["--cd", "other"], ["--remote", "remote"], ["--remote=remote"]]) { let reads = 0; - expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", args, serviceToken: () => { reads++; return fixtureToken; } })).toEqual({ CODEX_HOME: codexHome }); + expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", runtimeAttested: true, args, serviceToken: () => { reads++; return fixtureToken; } })).toEqual({ CODEX_HOME: codexHome }); expect(reads).toBe(0); } })); @@ -93,7 +93,7 @@ describe("codex launcher credential destination", () => { try { process.chdir(project); let reads = 0; - expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; } })).toEqual({ CODEX_HOME: codexHome }); + expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", runtimeAttested: true, serviceToken: () => { reads++; return fixtureToken; } })).toEqual({ CODEX_HOME: codexHome }); expect(reads).toBe(0); } finally { process.chdir(originalDirectory); } })); @@ -111,7 +111,7 @@ describe("codex launcher credential destination", () => { process.chdir(cwd); let reads = 0, reason: string | undefined; const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { - routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; }, + routingKind: "opencodex-local", runtimeAttested: true, serviceToken: () => { reads++; return fixtureToken; }, credentialDestination: (home, report) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), r => { reason = r; report?.(r); }), }); expect(reads).toBe(0); diff --git a/tests/codex-integration/codex-launcher.test.ts b/tests/codex-integration/codex-launcher.test.ts index 96f888fef4d..8d34f70c895 100644 --- a/tests/codex-integration/codex-launcher.test.ts +++ b/tests/codex-integration/codex-launcher.test.ts @@ -39,7 +39,7 @@ import { repoRoot } from "../helpers/repo-root"; // These env-assembly tests supply destination authority explicitly; real config authority // (including the positive managed block) is covered in codex-launcher-security.test.ts. const buildCodexLaunchEnv: typeof buildEnv = (base, deps) => buildEnv({ CODEX_HOME: repoRoot(), ...base }, { - credentialDestination: () => true, ...deps, + credentialDestination: () => true, runtimeAttested: true, ...deps, }); function withTokenFiles(run: (home: string) => void): void { @@ -143,6 +143,17 @@ describe("codex launcher plan", () => { }); describe("codex launch env assembly", () => { + test("managed routing withholds the persisted token until the listener is attested", () => { + let consulted = false; + const env = buildCodexLaunchEnv({ PATH: "/usr/bin" }, { + routingKind: "opencodex-local", + runtimeAttested: false, + serviceToken: () => { consulted = true; return "must-not-be-read"; }, + }); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(consulted).toBe(false); + }); + test("forwarded config and profile overrides never authorize a newly loaded token", () => { for (const args of [ ["-c", 'model_providers.opencodex.base_url="https://foreign.example/v1"', "hello"], From 92021041d98f38456c9e692d6fc9be2d48d63d1f Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 18:58:59 +0900 Subject: [PATCH 2/7] fix(cli): bind listener proof to the Codex destination Carry the actual challenged origin through credential admission and compare it with the final standalone or direct-client destination before loading a token. Preserve existing launch guards and cover port, address-family and proof snapshot behavior with the real attestation verifier and synthetic responses. --- .../src/content/docs/reference/cli/agents.md | 2 + scripts/test-layout/layout-clients.json | 1 + src/cli/codex-launch-auth.ts | 24 ++- src/cli/codex-launcher.ts | 22 +-- structure/cli-management.md | 2 +- .../codex-launcher-admission.test.ts | 2 +- .../codex-launcher-diagnostics.test.ts | 8 +- .../codex-launcher-endpoint.test.ts | 165 ++++++++++++++++++ .../codex-launcher-env.test.ts | 6 +- .../codex-launcher-security.test.ts | 12 +- .../codex-integration/codex-launcher.test.ts | 4 +- tests/fixtures/test-layout-expected.json | 1 + tests/helpers/codex-launch-private-fixture.ts | 6 +- 13 files changed, 224 insertions(+), 31 deletions(-) create mode 100644 tests/codex-integration/codex-launcher-endpoint.test.ts diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index fa0798d1e3d..54b3c948fc4 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -973,6 +973,8 @@ Alternatively, supply the key yourself, for example by exporting `OPENCODEX_API_ Use `--` before a single-word prompt so it cannot select an unknown Codex subcommand. +Automatic credentials also require a fresh ownership proof from the exact HTTP origin in the managed Codex provider configuration. A running proxy on a different port or loopback address does not authorize that destination. Direct clients use their saved server URL for this comparison. If the destinations differ, the launcher withholds the automatic token and leaves routing unchanged. + The machine configuration namespace must also prevent other users from creating missing system files. An unprotected Windows ProgramData/OpenAI/Codex namespace withholds automatic credentials; the launcher does not create it. macOS accepts deny-only and read-only allow ACLs, but refuses any allow entry granting write, append, delete, attribute/security changes or ownership changes, even after a deny entry. Unknown tags and inspection errors refuse. A fresh service token is loaded only when argv is within the allowlist below, the environment has no nonempty HTTP(S)/ALL proxy or `CODEX_*`/`OPENAI_*`/`AZURE_OPENAI_*` overrides (canonical `CODEX_HOME` is allowed), and the home/config/system namespaces pass ownership checks. The configuration must contain only audited keys and the exact managed loopback provider, with no additional credential consumers or configuration layers. Every unsupported argument, including `-c`/`--config`, `-p`/`--profile`, unknown flags and `resume`/`fork`, withholds the token with a value-free diagnostic; explicit user credentials and original arguments are preserved. diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index 309ea779c10..7ea8d97649f 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -1,4 +1,5 @@ { + "codex-launcher-endpoint.test.ts": "codex-integration", "claude-translated-tool-restrictions.test.ts": "claude-integration", "windows-config-root-async-writers.test.ts": "windows", "t3code-client-lifecycle.test.ts": "clients", diff --git a/src/cli/codex-launch-auth.ts b/src/cli/codex-launch-auth.ts index 9d825b81454..6a66b19ad85 100644 --- a/src/cli/codex-launch-auth.ts +++ b/src/cli/codex-launch-auth.ts @@ -7,10 +7,31 @@ import { readBoundedCodexConfig } from "../codex/inject/bounded-config-reader"; import { buildProviderTableBlockForTarget } from "../codex/inject/config-toml"; import { standaloneCodexRoutingTarget, validateCodexRoutingTarget } from "../codex/inject/routing-target"; import { readClientConnectionState } from "../client/state"; +import { probeHostname, proveLiveProxyOwnedByHome, type LiveProxy } from "../server/proxy-liveness"; import { codexHasOtherTokenConsumers, codexTokenConfigShapeIsSafe } from "./codex-launch-policy"; import { codexLaunchHomeIsOwned, codexLaunchProjectAliasIsOwned, codexLaunchSystemNamespaceIsSafe } from "./codex-launch-ownership"; import type { CodexTokenRefusal, CodexTokenRefusalReporter } from "./codex-launch-diagnostics"; +export type CodexRuntimeAttestation = Readonly<{ origin: string }>; + +/** Retain the exact dial endpoint that answered the ownership challenge, not just its verdict. */ +export async function attestCodexRuntime( + live: LiveProxy, + prove: typeof proveLiveProxyOwnedByHome = proveLiveProxyOwnedByHome, +): Promise { + const snapshot = Object.freeze({ ...live }); + try { + const origin = new URL(`http://${probeHostname(snapshot.probeHostname ?? snapshot.hostname)}:${snapshot.port}`).origin; + return await prove(snapshot) === "proven" ? Object.freeze({ origin }) : null; + } catch { return null; } +} + +/** IPv4 and IPv6 listeners are distinct recipients, even when their ports are equal. */ +export function codexRuntimeAttestationMatchesTarget(baseUrl: string, proof: CodexRuntimeAttestation | null | undefined): boolean { + try { return proof !== null && proof !== undefined && new URL(baseUrl).origin === proof.origin; } + catch { return false; } +} + /** Resolve once; the credential verifier and child must use this exact directory. */ export function resolveCodexLaunchHome(env: Record): string | null { try { @@ -28,7 +49,7 @@ export function resolveCodexLaunchHome(env: Record): } /** Config classification is a startup hint; credentials need an independently bound destination. */ -export function codexCredentialDestinationIsTrusted(home: string, systemNamespaceDirectory?: string, report?: CodexTokenRefusalReporter): boolean { +export function codexCredentialDestinationIsTrusted(home: string, systemNamespaceDirectory?: string, report?: CodexTokenRefusalReporter, runtimeAttestation?: CodexRuntimeAttestation | null): boolean { const refuse = (reason: CodexTokenRefusal): false => { report?.(reason); return false; }; try { if (!codexLaunchHomeIsOwned(home)) return refuse("ownership"); @@ -52,6 +73,7 @@ export function codexCredentialDestinationIsTrusted(home: string, systemNamespac const url = new URL(target.baseUrl); if (!target.requiresAdmissionToken) return refuse("admission"); if (url.protocol !== "http:" || !["127.0.0.1", "[::1]"].includes(url.hostname)) return refuse("loopback"); + if (!codexRuntimeAttestationMatchesTarget(target.baseUrl, runtimeAttestation)) return refuse("destination"); const content = readBoundedCodexConfig(join(home, "config.toml")); if (content === null) return refuse("configRead"); const config = Bun.TOML.parse(content) as Record; diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts index 7cb269a7936..70ec52c1dd4 100644 --- a/src/cli/codex-launcher.ts +++ b/src/cli/codex-launcher.ts @@ -33,8 +33,8 @@ import { selfLaunchArgv } from "../lib/self-launch-argv"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; import { commandInvocation } from "../lib/win-exec"; import { findExecutableOnPath } from "../remote-control/workspace-executable"; -import { findLiveProxy, proveLiveProxyOwnedByHome, type LiveProxy } from "../server/proxy-liveness"; -import { codexCredentialDestinationIsTrusted, resolveCodexLaunchHome } from "./codex-launch-auth"; +import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness"; +import { attestCodexRuntime, codexCredentialDestinationIsTrusted, resolveCodexLaunchHome, type CodexRuntimeAttestation } from "./codex-launch-auth"; import { codexCommandCanBypass, CODEX_UNVERIFIED_SHIM } from "./codex-launch-command"; import { codexTokenArgsAreSafe, codexTokenEnvironmentIsSafe } from "./codex-launch-policy"; import { codexTokenWithheldDiagnostic, type CodexTokenRefusal, type CodexTokenRefusalReporter } from "./codex-launch-diagnostics"; @@ -158,9 +158,9 @@ export type CodexLaunchEnvDeps = { args?: readonly string[]; /** Test seam; production reads the hardened service token file. */ serviceToken?: (base: CodexLaunchEnv) => string | null; - credentialDestination?: (home: string, report?: CodexTokenRefusalReporter) => boolean; - /** True only after the ensured listener proved this home's runtime-record secret. */ - runtimeAttested?: boolean; + credentialDestination?: (home: string, report?: CodexTokenRefusalReporter, runtimeAttestation?: CodexRuntimeAttestation | null) => boolean; + /** Exact origin that proved this home's runtime-record secret. */ + runtimeAttestation?: CodexRuntimeAttestation | null; }; /** Read the client admission credential from this installation's service-token file. */ @@ -188,7 +188,7 @@ export function buildCodexLaunchEnv( }; if (!codexTokenArgsAreSafe(deps.args ?? [])) return withhold("args"); if (!codexTokenEnvironmentIsSafe(env)) return withhold("environment"); - if (deps.routingKind === "opencodex-local" && deps.runtimeAttested !== true) return withhold("destination"); + if (!deps.runtimeAttestation) return withhold("destination"); // NO_PROXY parsing and precedence vary across native HTTP/WebSocket transports. // Without proving bypass, preserve the user's transport but never load a secret. const home = resolveCodexLaunchHome(env); @@ -196,8 +196,8 @@ export function buildCodexLaunchEnv( let refusal: CodexTokenRefusal = "destination"; const report: CodexTokenRefusalReporter = reason => { refusal = reason; }; const trusted = deps.credentialDestination - ? deps.credentialDestination(home, report) - : codexCredentialDestinationIsTrusted(home, undefined, report); + ? deps.credentialDestination(home, report, deps.runtimeAttestation) + : codexCredentialDestinationIsTrusted(home, undefined, report, deps.runtimeAttestation); if (!trusted) return withhold(refusal); const token = (deps.serviceToken ?? defaultServiceToken)(env); if (!token) return withhold("credential"); @@ -311,7 +311,7 @@ export async function cmdCodex(args: string[]): Promise { } // A public health marker proves liveness, not ownership of the listener. Never read the // persisted service token until the exact runtime-record endpoint answers a fresh challenge. - const runtimeAttested = plan.kind === "routed" && live !== null - && await proveLiveProxyOwnedByHome(live) === "proven"; - return spawnCodex(args, buildCodexLaunchEnv(process.env, { routingKind, args, runtimeAttested })); + const runtimeAttestation = plan.kind === "routed" && live !== null + ? await attestCodexRuntime(live) : null; + return spawnCodex(args, buildCodexLaunchEnv(process.env, { routingKind, args, runtimeAttestation })); } diff --git a/structure/cli-management.md b/structure/cli-management.md index 5bdbca762eb..78444211891 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -32,7 +32,7 @@ Same-file project aliases additionally require ownership/ACL inspection of the l For fresh credentials, the launcher resolves a native literal `CODEX_HOME` once and passes the canonical absolute directory both to the destination verifier and to the child. Missing, unreadable or non-directory homes and ambiguous Windows home casing withhold credentials. Any nonempty inherited HTTP(S)/ALL proxy variable, matched case-insensitively, suppresses token reads irrespective of NO_PROXY; denied and inherited-token launches preserve their environment. Successful injection adds both allowed loopback IPs to identical child NO_PROXY/no_proxy values while preserving prior bypass entries, covering default OS proxy discovery. Configured `features.respect_system_proxy` and feature-changing argv suppress loading because system-selected routes can ignore bypasses. -`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. Before loading a fresh service token, the ensured listener must prove possession of this home's runtime-record attestation secret for its exact pid and port; a public health marker or failed ensure never authorizes credential loading. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. +`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. Before loading a fresh service token, the ensured listener must prove possession of this home's runtime-record attestation secret for its exact pid and port; a public health marker or failed ensure never authorizes credential loading. The successful proof retains an immutable origin snapshot using the actual probe address (including recovery probe overrides). That origin must match the final provider destination after resolving standalone configuration or persisted direct-client state; IPv4 and IPv6 recipients remain distinct. A mismatched or missing proof withholds the token before the key file is read, without rewriting routing. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. Single-word prompts require `--` so they cannot select an unknown native subcommand. Missing system files also require a protected namespace: the closest existing directory must prevent another user from creating the missing layer, and its ancestors must prevent replacement. A normal Windows ProgramData directory that grants Users directory creation cannot authorize an absent OpenAI/Codex namespace; the launcher withholds credentials without creating or changing that directory. macOS opens each validated entry without following its final link (O_SYMLINK for the link entry itself), keeps dev/ino as bigint and requires descriptor dev/ino/type to match its original lstat, and reads its ACL with acl_get_fd_np. Only NULL with immediately captured ENOENT on this descriptor query means no extended ACL; open/stat/identity/close failures and every other NULL/error refuse. Every opened descriptor closes in finally. For a present ACL it validates and enumerates every entry through the documented -1/EINVAL terminator. Deny-only and read-only allow entries are accepted; any allow granting write/add-file, append/add-subdirectory, delete, delete-child, attribute, extended-attribute, security or ownership mutation refuses regardless of earlier denies. Unknown tags and FFI/enumeration failures refuse. Forced managed-preference checks remain mandatory. Pure policy and Darwin-gated native ACL coverage lives in `tests/codex-integration/codex-launcher-darwin-acl.test.ts`; modeled IO regressions for all three walks live in `tests/codex-integration/codex-launcher-darwin-identity.test.ts`. Tests model a protected empty machine namespace inside their private fixture; they do not modify real system configuration. Windows fixtures probe ancestor ownership before positive assertions; an unavailable protected location requires a proved ownership refusal and an explicit skip reason. Negative mutations and no-token/zero-read assertions execute even with unsafe ancestors; credential-file negatives bypass destination verification independently. Native checks use production defaults. diff --git a/tests/codex-integration/codex-launcher-admission.test.ts b/tests/codex-integration/codex-launcher-admission.test.ts index 0bbcd481f8c..205fe9ce3f4 100644 --- a/tests/codex-integration/codex-launcher-admission.test.ts +++ b/tests/codex-integration/codex-launcher-admission.test.ts @@ -36,7 +36,7 @@ function launch(home: string, args: string[] = [], extra: Record console.error = (...values: unknown[]) => { notes.push(values.join(" ")); }; try { const env = buildCodexLaunchEnv({ CODEX_HOME: home, ...extra }, { - routingKind: "opencodex-local", runtimeAttested: true, args, serviceToken: () => { reads++; return token; }, + routingKind: "opencodex-local", runtimeAttestation: { origin: "http://127.0.0.1:12199" }, args, serviceToken: () => { reads++; return token; }, }); return { env, reads, notes }; } finally { console.error = original; } diff --git a/tests/codex-integration/codex-launcher-diagnostics.test.ts b/tests/codex-integration/codex-launcher-diagnostics.test.ts index bfad3f3a492..a369014d122 100644 --- a/tests/codex-integration/codex-launcher-diagnostics.test.ts +++ b/tests/codex-integration/codex-launcher-diagnostics.test.ts @@ -16,7 +16,7 @@ const secret = "ocx_fixture_diagnostic_secret", contents = "fixture-key-file-con function capture(base: Record, deps: CodexLaunchEnvDeps) { const notes: string[] = [], original = console.error; console.error = (...values: unknown[]) => { notes.push(values.join(" ")); }; - try { return { env: buildCodexLaunchEnv(base, deps), notes }; } + try { return { env: buildCodexLaunchEnv(base, { runtimeAttestation: { origin: "http://127.0.0.1:12199" }, ...deps }), notes }; } finally { console.error = original; } } @@ -41,7 +41,7 @@ describe("Codex credential withholding diagnostics", () => { [{ CODEX_HOME: "" }, {}, "Codex home cannot be resolved unambiguously"], ] as const) test(reason, () => { let reads = 0; - const result = capture(base, { routingKind: "opencodex-local", runtimeAttested: true, ...deps, serviceToken: () => { reads++; return secret; } }); + const result = capture(base, { routingKind: "opencodex-local", runtimeAttestation: { origin: "http://127.0.0.1:12199" }, ...deps, serviceToken: () => { reads++; return secret; } }); diagnostic(result.notes, reason); expect(reads).toBe(0); expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); @@ -89,7 +89,7 @@ $acl.AddAccessRule($rule) [System.IO.Directory]::SetAccessControl($p,$acl)`; let reads = 0; const launch = () => capture({ CODEX_HOME: home }, { - credentialDestination: (path, report) => codexCredentialDestinationIsTrusted(path, system, report), + credentialDestination: (path, report, proof) => codexCredentialDestinationIsTrusted(path, system, report, proof), serviceToken: () => { reads++; return secret; }, }); try { @@ -185,7 +185,7 @@ $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new([Sy } let reads = 0; const result = capture({ CODEX_HOME: home }, { - credentialDestination: (path, report) => codexCredentialDestinationIsTrusted(path, system, report), + credentialDestination: (path, report, proof) => codexCredentialDestinationIsTrusted(path, system, report, proof), serviceToken: () => { reads++; return secret; }, }); // Diagnostic reachability needs protected ancestors; the mutation and diff --git a/tests/codex-integration/codex-launcher-endpoint.test.ts b/tests/codex-integration/codex-launcher-endpoint.test.ts new file mode 100644 index 00000000000..c7925d491ea --- /dev/null +++ b/tests/codex-integration/codex-launcher-endpoint.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { attestCodexRuntime, codexRuntimeAttestationMatchesTarget, type CodexRuntimeAttestation } from "../../src/cli/codex-launch-auth"; +import { buildCodexLaunchEnv } from "../../src/cli/codex-launcher"; +import { proveLiveProxyOwnedByHome, type LiveProxy } from "../../src/server/proxy-liveness"; +import { createLocalAttestationProof, LOCAL_ATTESTATION_PROOF_HEADER } from "../../src/lib/local-management-attestation"; +import { buildProviderTableBlockForTarget } from "../../src/codex/inject/config-toml"; +import { standaloneCodexRoutingTarget, validateCodexRoutingTarget } from "../../src/codex/inject/routing-target"; +import { createCodexLaunchFixtureRoot, codexLaunchOwnershipTest, buildCodexPrivateFixtureLaunchEnv } from "../helpers/codex-launch-private-fixture"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const token = "ocx_fixture_endpoint_bound", origin = "http://127.0.0.1:12199"; +const live: LiveProxy = { pid: 4242, port: 12199, hostname: "0.0.0.0", source: "runtime" }; + +/** Exercise the launch boundary independently of the host's filesystem ACL policy. */ +function launch(proof: CodexRuntimeAttestation | null | undefined, extra: Record = {}, args: string[] = []) { + let reads = 0, checks = 0; + const env = buildCodexLaunchEnv({ CODEX_HOME: process.cwd(), ...extra }, { + routingKind: "opencodex-local", runtimeAttestation: proof, args, + credentialDestination: (_home, _report, received) => { + checks++; + expect(received).toBe(proof); + return codexRuntimeAttestationMatchesTarget(`${origin}/v1`, received); + }, + serviceToken: () => { reads++; return token; }, + }); + return { env, reads, checks }; +} + +describe("Codex runtime endpoint binding", () => { + test("a real challenge proof is bound to the address actually dialed", async () => { + const secret = "s".repeat(43), challenge = "c".repeat(43); + for (const port of [12199, 12200]) { + let requested = ""; + const endpoint = { ...live, port, hostname: "::1", probeHostname: "127.0.0.1" }; + const proof = await attestCodexRuntime(endpoint, snapshot => proveLiveProxyOwnedByHome(snapshot, { + readRuntimeFn: () => ({ pid: 4242, port, attestationSecret: secret }), + createChallengeFn: () => challenge, + fetchFn: async input => { + requested = String(input); + return Response.json({ service: "opencodex", pid: 4242 }, { + headers: { [LOCAL_ATTESTATION_PROOF_HEADER]: createLocalAttestationProof(secret, challenge, 4242, port)! }, + }); + }, + })); + expect(requested).toBe(`http://127.0.0.1:${port}/healthz`); + expect(proof).toEqual({ origin: `http://127.0.0.1:${port}` }); + expect(launch(proof).reads).toBe(port === 12199 ? 1 : 0); + } + }); + + test("direct-client destination matching is independent of the local machine port", () => { + const local = standaloneCodexRoutingTarget(12200, { hostname: "0.0.0.0" }); + const direct = validateCodexRoutingTarget({ baseUrl: `${origin}/v1`, requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" }); + expect(codexRuntimeAttestationMatchesTarget(direct.baseUrl, { origin })).toBe(true); + expect(codexRuntimeAttestationMatchesTarget(local.baseUrl, { origin })).toBe(false); + expect(codexRuntimeAttestationMatchesTarget(direct.baseUrl, { origin: "http://127.0.0.1:12200" })).toBe(false); + }); + + test("a different attested runtime port cannot authorize the configured destination", async () => { + const result = launch(await attestCodexRuntime({ ...live, port: 12200 }, async () => "proven")); + expect(result.checks).toBe(1); + expect(result.reads).toBe(0); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + }); + test("the same attested endpoint reads exactly one fresh token", async () => { + const result = launch(await attestCodexRuntime(live, async () => "proven")); + expect(result.reads).toBe(1); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBe(token); + }); + for (const verdict of ["refuted", "indeterminate"] as const) test(`${verdict} ownership never authorizes loading`, async () => { + const proof = await attestCodexRuntime(live, async () => verdict); + expect(proof).toBeNull(); + expect(launch(proof).reads).toBe(0); + }); + test("missing proof also refuses helper calls without a routing classification", () => { + let reads = 0; + const env = buildCodexLaunchEnv({ CODEX_HOME: process.cwd() }, { + credentialDestination: () => true, serviceToken: () => { reads++; return token; }, + }); + expect(reads).toBe(0); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(launch(undefined).checks).toBe(0); + }); + test("a failed proof operation degrades without a fresh credential", async () => { + expect(await attestCodexRuntime(live, async () => { throw new Error("unavailable"); })).toBeNull(); + }); + for (const hostname of ["0.0.0.0", "::", "[::]", "0:0:0:0:0:0:0:0", "0.0.0.0.", "*"]) { + test(`wildcard proof uses established loopback normalization: ${hostname}`, async () => { + expect(await attestCodexRuntime({ ...live, hostname }, async () => "proven")).toEqual({ origin }); + }); + } + test("the successful probe address takes precedence and IPv6 does not bless IPv4", async () => { + const proof = await attestCodexRuntime({ ...live, probeHostname: "::1" }, async () => "proven"); + expect(proof).toEqual({ origin: "http://[::1]:12199" }); + expect(launch(proof).reads).toBe(0); + expect(codexRuntimeAttestationMatchesTarget("http://[0:0:0:0:0:0:0:1]:12199/v1", proof)).toBe(true); + expect(codexRuntimeAttestationMatchesTarget("https://[::1]:12199/v1", proof)).toBe(false); + expect(codexRuntimeAttestationMatchesTarget("invalid", proof)).toBe(false); + }); + test("proof snapshots the endpoint before awaiting ownership", async () => { + const mutable = { ...live }; + const proof = await attestCodexRuntime(mutable, async snapshot => { + mutable.port = 12200; mutable.hostname = "::1"; + expect(snapshot.port).toBe(12199); + expect(snapshot.hostname).toBe("0.0.0.0"); + return "proven"; + }); + expect(proof).toEqual({ origin }); + expect(Object.isFrozen(proof)).toBe(true); + }); + test("valid proof retains argv/environment guards and explicit user credentials win", async () => { + const proof = await attestCodexRuntime(live, async () => "proven"); + for (const args of [["-c", "model_provider=foreign"], ["--profile=foreign"], ["resume"]]) { + expect(launch(proof, {}, args).reads).toBe(0); + } + expect(launch(proof, { OPENAI_BASE_URL: "https://foreign.invalid/v1" }).reads).toBe(0); + const inherited = launch({ origin: "http://127.0.0.1:12200" }, { OPENCODEX_API_AUTH_TOKEN: "fixture-explicit" }, ["--profile=foreign"]); + expect(inherited.reads).toBe(0); + expect(inherited.checks).toBe(0); + expect(inherited.env.OPENCODEX_API_AUTH_TOKEN).toBe("fixture-explicit"); + }); +}); + +function configurationFixture(client: boolean, run: (home: string) => void) { + const root = createCodexLaunchFixtureRoot("ocx-bound-endpoint-"), home = join(root, "codex"), ocx = join(root, "ocx"); + mkdirSync(home); mkdirSync(ocx); + const saved = process.env.OPENCODEX_HOME, cwd = process.cwd(); + try { + process.env.OPENCODEX_HOME = ocx; process.chdir(root); + const config = { port: client ? 12200 : 12199, hostname: "0.0.0.0", ...(client ? { + runtimeRole: "client", client: { serverUrl: origin, managementUrl: origin, + managementTransport: "direct", selectedClients: ["codex"], tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + apiKeyId: "fixture", tokenFingerprint: "a".repeat(64), protocolVersion: 1, connectedAt: "2026-01-01T00:00:00.000Z" }, + } : {}) }; + const target = client ? validateCodexRoutingTarget({ baseUrl: `${origin}/v1`, requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" }) + : standaloneCodexRoutingTarget(config.port, config); + writeFileSync(join(ocx, "config.json"), JSON.stringify(config)); + writeFileSync(join(home, "config.toml"), 'model_provider="opencodex"\n' + buildProviderTableBlockForTarget(target)); + run(home); + } finally { + process.chdir(cwd); + if (saved === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = saved; + removeTreeWithRetry(root); + } +} + +for (const client of [false, true]) codexLaunchOwnershipTest(`${client ? "direct client" : "standalone"} binds proof to the final provider destination`, () => { + configurationFixture(client, home => { + for (const port of [12199, 12200]) { + let reads = 0; + const result = buildCodexPrivateFixtureLaunchEnv({ CODEX_HOME: home }, { + routingKind: "opencodex-local", runtimeAttestation: { origin: `http://127.0.0.1:${port}` }, + serviceToken: () => { reads++; return token; }, + }); + expect(reads).toBe(port === 12199 ? 1 : 0); + expect(result.OPENCODEX_API_AUTH_TOKEN).toBe(port === 12199 ? token : undefined); + } + writeFileSync(join(home, "managed_config.toml"), ""); + let reads = 0; + buildCodexPrivateFixtureLaunchEnv({ CODEX_HOME: home }, { runtimeAttestation: { origin }, serviceToken: () => { reads++; return token; } }); + expect(reads).toBe(0); + }); +}); diff --git a/tests/codex-integration/codex-launcher-env.test.ts b/tests/codex-integration/codex-launcher-env.test.ts index 2e1e4450d45..4bdae7df7a6 100644 --- a/tests/codex-integration/codex-launcher-env.test.ts +++ b/tests/codex-integration/codex-launcher-env.test.ts @@ -46,8 +46,8 @@ async function fixture(run: (root: string, home: string) => void | Promise function launch(base = { ...process.env }) { let reads = 0, reason: string | undefined; const env = buildCodexLaunchEnv(base, { - routingKind: "opencodex-local", runtimeAttested: true, serviceToken: () => { reads++; return token; }, - credentialDestination: (home, report) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), r => { reason = r; report?.(r); }), + routingKind: "opencodex-local", runtimeAttestation: { origin: "http://127.0.0.1:12199" }, serviceToken: () => { reads++; return token; }, + credentialDestination: (home, report, proof) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), r => { reason = r; report?.(r); }, proof), }); return { env, reads, reason }; } @@ -234,7 +234,7 @@ describe("Codex launcher native home and proxy environment", () => { test("feature argv cannot enable a bypass-ignoring system proxy", () => fixture(() => { for (const args of [["--enable", "respect_system_proxy"], ["--enable=respect_system_proxy"]]) { let reads = 0; - buildCodexLaunchEnv(process.env, { routingKind: "opencodex-local", runtimeAttested: true, args, serviceToken: () => { reads++; return token; } }); + buildCodexLaunchEnv(process.env, { routingKind: "opencodex-local", runtimeAttestation: { origin: "http://127.0.0.1:12199" }, args, serviceToken: () => { reads++; return token; } }); expect(reads).toBe(0); } })); diff --git a/tests/codex-integration/codex-launcher-security.test.ts b/tests/codex-integration/codex-launcher-security.test.ts index 50b270fad5d..a829fdcd8e7 100644 --- a/tests/codex-integration/codex-launcher-security.test.ts +++ b/tests/codex-integration/codex-launcher-security.test.ts @@ -44,7 +44,7 @@ describe("codex launcher credential destination", () => { codexLaunchOwnershipTest("the managed starting configuration reads the service token exactly once", () => fixture((_home, codexHome) => { writeFileSync(join(codexHome, "config.toml"), local); let reads = 0; - const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", runtimeAttested: true, serviceToken: () => { reads++; return fixtureToken; } }); + const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", runtimeAttestation: { origin: "http://127.0.0.1:12199" }, serviceToken: () => { reads++; return fixtureToken; } }); expect(reads).toBe(1); expect(env.OPENCODEX_API_AUTH_TOKEN).toBe(fixtureToken); })); @@ -71,14 +71,14 @@ describe("codex launcher credential destination", () => { const originalDirectory = process.cwd(); try { process.chdir(parse(home).root); - expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", runtimeAttested: true }).OPENCODEX_API_AUTH_TOKEN).toBe(fixtureToken); + expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", runtimeAttestation: { origin: "http://127.0.0.1:12199" } }).OPENCODEX_API_AUTH_TOKEN).toBe(fixtureToken); } finally { process.chdir(originalDirectory); } })); test("directory and remote overrides never read a service token", () => fixture((_home, codexHome) => { writeFileSync(join(codexHome, "config.toml"), local); for (const args of [["-C", "other"], ["-Cother"], ["--cd=other"], ["--cd", "other"], ["--remote", "remote"], ["--remote=remote"]]) { let reads = 0; - expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", runtimeAttested: true, args, serviceToken: () => { reads++; return fixtureToken; } })).toEqual({ CODEX_HOME: codexHome }); + expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", runtimeAttestation: { origin: "http://127.0.0.1:12199" }, args, serviceToken: () => { reads++; return fixtureToken; } })).toEqual({ CODEX_HOME: codexHome }); expect(reads).toBe(0); } })); @@ -93,7 +93,7 @@ describe("codex launcher credential destination", () => { try { process.chdir(project); let reads = 0; - expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", runtimeAttested: true, serviceToken: () => { reads++; return fixtureToken; } })).toEqual({ CODEX_HOME: codexHome }); + expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", runtimeAttestation: { origin: "http://127.0.0.1:12199" }, serviceToken: () => { reads++; return fixtureToken; } })).toEqual({ CODEX_HOME: codexHome }); expect(reads).toBe(0); } finally { process.chdir(originalDirectory); } })); @@ -111,8 +111,8 @@ describe("codex launcher credential destination", () => { process.chdir(cwd); let reads = 0, reason: string | undefined; const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { - routingKind: "opencodex-local", runtimeAttested: true, serviceToken: () => { reads++; return fixtureToken; }, - credentialDestination: (home, report) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), r => { reason = r; report?.(r); }), + routingKind: "opencodex-local", runtimeAttestation: { origin: "http://127.0.0.1:12199" }, serviceToken: () => { reads++; return fixtureToken; }, + credentialDestination: (home, report, proof) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), r => { reason = r; report?.(r); }, proof), }); expect(reads).toBe(0); expect(reason).toBe(expectedReason); diff --git a/tests/codex-integration/codex-launcher.test.ts b/tests/codex-integration/codex-launcher.test.ts index 8d34f70c895..fa5b0bdb806 100644 --- a/tests/codex-integration/codex-launcher.test.ts +++ b/tests/codex-integration/codex-launcher.test.ts @@ -39,7 +39,7 @@ import { repoRoot } from "../helpers/repo-root"; // These env-assembly tests supply destination authority explicitly; real config authority // (including the positive managed block) is covered in codex-launcher-security.test.ts. const buildCodexLaunchEnv: typeof buildEnv = (base, deps) => buildEnv({ CODEX_HOME: repoRoot(), ...base }, { - credentialDestination: () => true, runtimeAttested: true, ...deps, + credentialDestination: () => true, runtimeAttestation: { origin: "http://127.0.0.1:12199" }, ...deps, }); function withTokenFiles(run: (home: string) => void): void { @@ -147,7 +147,7 @@ describe("codex launch env assembly", () => { let consulted = false; const env = buildCodexLaunchEnv({ PATH: "/usr/bin" }, { routingKind: "opencodex-local", - runtimeAttested: false, + runtimeAttestation: null, serviceToken: () => { consulted = true; return "must-not-be-read"; }, }); expect(env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0c9e9e966e0..24e9358ac61 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -2,6 +2,7 @@ "windows-config-root-async-writers.test.ts": "windows", "windows-atomic-write-acl-rearm.test.ts": "windows", "advisor-web-search.test.ts": "advisor", + "codex-launcher-endpoint.test.ts": "codex-integration", "t3code-client.test.ts": "clients", "t3code-client-lifecycle.test.ts": "clients", "fix-jsonc-cr-comments.test.ts": "lib", diff --git a/tests/helpers/codex-launch-private-fixture.ts b/tests/helpers/codex-launch-private-fixture.ts index ad959f83e2d..e9693c1089d 100644 --- a/tests/helpers/codex-launch-private-fixture.ts +++ b/tests/helpers/codex-launch-private-fixture.ts @@ -75,7 +75,8 @@ export function codexLaunchOwnershipTest(name: string, body: () => void | Promis expect(codexLaunchHomeIsOwned(root)).toBe(false); let reason: string | undefined, reads = 0; const env = buildCodexLaunchEnv({ CODEX_HOME: root }, { - credentialDestination: (home, report) => codexCredentialDestinationIsTrusted(home, join(home, "system"), value => { reason = value; report?.(value); }), + runtimeAttestation: { origin: "http://127.0.0.1:12199" }, + credentialDestination: (home, report, proof) => codexCredentialDestinationIsTrusted(home, join(home, "system"), value => { reason = value; report?.(value); }, proof), serviceToken: () => { reads++; return "ocx_fixture_must_not_read"; }, }); expect(reason).toBe("ownership"); @@ -96,7 +97,8 @@ export function codexLaunchProjectRefusalTest(name: string, body: (reason: "proj // Model a protected, empty machine configuration namespace inside this fixture. // Every other authority check uses real config/ownership IO. Native probes use production defaults. export const buildCodexPrivateFixtureLaunchEnv: typeof buildCodexLaunchEnv = (base, deps) => buildCodexLaunchEnv(base, { - credentialDestination: (home, report) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), report), ...deps, + runtimeAttestation: { origin: "http://127.0.0.1:12199" }, + credentialDestination: (home, report, proof) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), report, proof), ...deps, }); /** Windows Temp grants Users create rights to descendants; model a private Codex home. */ From 111ac60655acc7a4677b0d44783f86cbf1e7dcf3 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 3 Nov 2026 15:16:10 +0900 Subject: [PATCH 3/7] fix(cli): keep direct-client launches on their own listener proof A direct client's ownership proof comes from its machine listener, while Codex dials the hub URL, so comparing the two always withheld the client credential. Bind the proof origin to the destination only for standalone installations; direct clients still require a proven local runtime and keep their connection-state and loopback checks. --- docs-site/src/content/docs/reference/cli/agents.md | 2 +- src/cli/codex-launch-auth.ts | 6 +++++- structure/cli-management.md | 2 +- .../codex-launcher-endpoint.test.ts | 14 +++++++++++--- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 37632bb2b52..2b76789ae75 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -990,7 +990,7 @@ Alternatively, supply the key yourself, for example by exporting `OPENCODEX_API_ Use `--` before a single-word prompt so it cannot select an unknown Codex subcommand. -Automatic credentials also require a fresh ownership proof from the exact HTTP origin in the managed Codex provider configuration. A running proxy on a different port or loopback address does not authorize that destination. Direct clients use their saved server URL for this comparison. If the destinations differ, the launcher withholds the automatic token and leaves routing unchanged. +Automatic credentials also require a fresh ownership proof from this installation's running listener. For a standalone proxy, the proof must come from the exact HTTP origin in the managed Codex provider configuration; a running proxy on a different port or loopback address does not authorize that destination. A direct client proves its own machine listener and keeps its saved server URL checks, because the server it dials is a different installation. Without a matching proof, the launcher withholds the automatic token and leaves routing unchanged. The machine configuration namespace must also prevent other users from creating missing system files. An unprotected Windows ProgramData/OpenAI/Codex namespace withholds automatic credentials; the launcher does not create it. macOS accepts deny-only and read-only allow ACLs, but refuses any allow entry granting write, append, delete, attribute/security changes or ownership changes, even after a deny entry. Unknown tags and inspection errors refuse. diff --git a/src/cli/codex-launch-auth.ts b/src/cli/codex-launch-auth.ts index 6a66b19ad85..1bf3641f96a 100644 --- a/src/cli/codex-launch-auth.ts +++ b/src/cli/codex-launch-auth.ts @@ -64,16 +64,20 @@ export function codexCredentialDestinationIsTrusted(home: string, systemNamespac if (diagnostics.error || diagnostics.source === "fallback") return refuse("installationConfig"); const cfg = diagnostics.config; let target = standaloneCodexRoutingTarget(cfg.port, cfg); + // A direct client's proof comes from its own machine listener, never from the hub it + // dials, so only a standalone destination can be compared with the proven origin. + let destinationIsProvenListener = true; if (cfg.runtimeRole === "client") { // Direct clients bind to persisted connection state; link clients use an authless relay. const connection = readClientConnectionState(); if (connection.kind !== "connected" || connection.value.transport === "link") return refuse("connection"); target = validateCodexRoutingTarget({ baseUrl: `${connection.value.serverUrl}/v1`, requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" }); + destinationIsProvenListener = false; } const url = new URL(target.baseUrl); if (!target.requiresAdmissionToken) return refuse("admission"); if (url.protocol !== "http:" || !["127.0.0.1", "[::1]"].includes(url.hostname)) return refuse("loopback"); - if (!codexRuntimeAttestationMatchesTarget(target.baseUrl, runtimeAttestation)) return refuse("destination"); + if (destinationIsProvenListener && !codexRuntimeAttestationMatchesTarget(target.baseUrl, runtimeAttestation)) return refuse("destination"); const content = readBoundedCodexConfig(join(home, "config.toml")); if (content === null) return refuse("configRead"); const config = Bun.TOML.parse(content) as Record; diff --git a/structure/cli-management.md b/structure/cli-management.md index 28080a773aa..8ee18fa1317 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -34,7 +34,7 @@ Same-file project aliases additionally require ownership/ACL inspection of the l For fresh credentials, the launcher resolves a native literal `CODEX_HOME` once and passes the canonical absolute directory both to the destination verifier and to the child. Missing, unreadable or non-directory homes and ambiguous Windows home casing withhold credentials. Any nonempty inherited HTTP(S)/ALL proxy variable, matched case-insensitively, suppresses token reads irrespective of NO_PROXY; denied and inherited-token launches preserve their environment. Successful injection adds both allowed loopback IPs to identical child NO_PROXY/no_proxy values while preserving prior bypass entries, covering default OS proxy discovery. Configured `features.respect_system_proxy` and feature-changing argv suppress loading because system-selected routes can ignore bypasses. -`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. Before loading a fresh service token, the ensured listener must prove possession of this home's runtime-record attestation secret for its exact pid and port; a public health marker or failed ensure never authorizes credential loading. The successful proof retains an immutable origin snapshot using the actual probe address (including recovery probe overrides). That origin must match the final provider destination after resolving standalone configuration or persisted direct-client state; IPv4 and IPv6 recipients remain distinct. A mismatched or missing proof withholds the token before the key file is read, without rewriting routing. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. +`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. Before loading a fresh service token, the ensured listener must prove possession of this home's runtime-record attestation secret for its exact pid and port; a public health marker or failed ensure never authorizes credential loading. The successful proof retains an immutable origin snapshot using the actual probe address (including recovery probe overrides). For a standalone installation that origin must match the final provider destination; IPv4 and IPv6 recipients remain distinct. A direct client's proof comes from its machine listener, so its persisted hub URL is not compared with the proven origin and keeps the connection-state and loopback checks instead. A mismatched or missing proof withholds the token before the key file is read, without rewriting routing. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. Every handoff, including help/version and passthrough, awaits the [execution trust contract](clients/chatgpt-desktop.md#codex-app-execution-trust) for the selected backing and spawns the returned invocation. On macOS, CLI handoffs verify any enclosing `.app` at the canonical target location, including renamed bundles and symlink targets; nested apps retain the outer bundle root. Fast paths bypass startup and credential loading, not executable validation. The hidden `internal codex-exec-target` helper returns a single verified path for the generated app-server launcher; its strict `--bundle-root` mode retains the discovered root independent of bundle name and refuses malformed roots without falling back to generic classification. It skips root shim auto-repair even on malformed arguments, and stays outside public capability discovery. Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. Single-word prompts require `--` so they cannot select an unknown native subcommand. Missing system files also require a protected namespace: the closest existing directory must prevent another user from creating the missing layer, and its ancestors must prevent replacement. A normal Windows ProgramData directory that grants Users directory creation cannot authorize an absent OpenAI/Codex namespace; the launcher withholds credentials without creating or changing that directory. macOS opens each validated entry without following its final link (O_SYMLINK for the link entry itself), keeps dev/ino as bigint and requires descriptor dev/ino/type to match its original lstat, and reads its ACL with acl_get_fd_np. Only NULL with immediately captured ENOENT on this descriptor query means no extended ACL; open/stat/identity/close failures and every other NULL/error refuse. Every opened descriptor closes in finally. For a present ACL it validates and enumerates every entry through the documented -1/EINVAL terminator. Deny-only and read-only allow entries are accepted; any allow granting write/add-file, append/add-subdirectory, delete, delete-child, attribute, extended-attribute, security or ownership mutation refuses regardless of earlier denies. Unknown tags and FFI/enumeration failures refuse. Forced managed-preference checks remain mandatory. Pure policy and Darwin-gated native ACL coverage lives in `tests/codex-integration/codex-launcher-darwin-acl.test.ts`; modeled IO regressions for all three walks live in `tests/codex-integration/codex-launcher-darwin-identity.test.ts`. Tests model a protected empty machine namespace inside their private fixture; they do not modify real system configuration. Windows fixtures probe ancestor ownership before positive assertions; an unavailable protected location requires a proved ownership refusal and an explicit skip reason. Negative mutations and no-token/zero-read assertions execute even with unsafe ancestors; credential-file negatives bypass destination verification independently. Native checks use production defaults. diff --git a/tests/codex-integration/codex-launcher-endpoint.test.ts b/tests/codex-integration/codex-launcher-endpoint.test.ts index c7925d491ea..63b0cf06d80 100644 --- a/tests/codex-integration/codex-launcher-endpoint.test.ts +++ b/tests/codex-integration/codex-launcher-endpoint.test.ts @@ -146,7 +146,8 @@ function configurationFixture(client: boolean, run: (home: string) => void) { } } -for (const client of [false, true]) codexLaunchOwnershipTest(`${client ? "direct client" : "standalone"} binds proof to the final provider destination`, () => { +// A direct client's proof comes from its machine listener (12200), never from the hub it dials (12199). +for (const client of [false, true]) codexLaunchOwnershipTest(`${client ? "direct client requires its own listener proof" : "standalone binds proof to the final provider destination"}`, () => { configurationFixture(client, home => { for (const port of [12199, 12200]) { let reads = 0; @@ -154,8 +155,15 @@ for (const client of [false, true]) codexLaunchOwnershipTest(`${client ? "direct routingKind: "opencodex-local", runtimeAttestation: { origin: `http://127.0.0.1:${port}` }, serviceToken: () => { reads++; return token; }, }); - expect(reads).toBe(port === 12199 ? 1 : 0); - expect(result.OPENCODEX_API_AUTH_TOKEN).toBe(port === 12199 ? token : undefined); + const injected = client || port === 12199; + expect(reads).toBe(injected ? 1 : 0); + expect(result.OPENCODEX_API_AUTH_TOKEN).toBe(injected ? token : undefined); + } + for (const runtimeAttestation of [null, undefined]) { + let reads = 0; + const result = buildCodexPrivateFixtureLaunchEnv({ CODEX_HOME: home }, { routingKind: "opencodex-local", runtimeAttestation, serviceToken: () => { reads++; return token; } }); + expect(reads).toBe(0); + expect(result.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); } writeFileSync(join(home, "managed_config.toml"), ""); let reads = 0; From db996696724bdeb1aa10f1f9b85bb34c3dd9d102 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 3 Nov 2026 16:15:35 +0900 Subject: [PATCH 4/7] fix(cli): require the client listener role for direct-client proofs A direct client's credential could be authorized by any listener of the same home that answered the ownership challenge. Carry the proven listener's client role into the proof and refuse direct-client injection without it. --- docs-site/src/content/docs/reference/cli/agents.md | 2 +- src/cli/codex-launch-auth.ts | 9 +++++++-- structure/cli-management.md | 2 +- .../codex-launcher-endpoint.test.ts | 12 ++++++++++-- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 2b76789ae75..484333ae75a 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -990,7 +990,7 @@ Alternatively, supply the key yourself, for example by exporting `OPENCODEX_API_ Use `--` before a single-word prompt so it cannot select an unknown Codex subcommand. -Automatic credentials also require a fresh ownership proof from this installation's running listener. For a standalone proxy, the proof must come from the exact HTTP origin in the managed Codex provider configuration; a running proxy on a different port or loopback address does not authorize that destination. A direct client proves its own machine listener and keeps its saved server URL checks, because the server it dials is a different installation. Without a matching proof, the launcher withholds the automatic token and leaves routing unchanged. +Automatic credentials also require a fresh ownership proof from this installation's running listener. For a standalone proxy, the proof must come from the exact HTTP origin in the managed Codex provider configuration; a running proxy on a different port or loopback address does not authorize that destination. A direct client must prove its own client machine listener (another listener of the same installation does not count) and keeps its saved server URL checks, because the server it dials is a different installation. Without a matching proof, the launcher withholds the automatic token and leaves routing unchanged. The machine configuration namespace must also prevent other users from creating missing system files. An unprotected Windows ProgramData/OpenAI/Codex namespace withholds automatic credentials; the launcher does not create it. macOS accepts deny-only and read-only allow ACLs, but refuses any allow entry granting write, append, delete, attribute/security changes or ownership changes, even after a deny entry. Unknown tags and inspection errors refuse. diff --git a/src/cli/codex-launch-auth.ts b/src/cli/codex-launch-auth.ts index 1bf3641f96a..642dc347cf8 100644 --- a/src/cli/codex-launch-auth.ts +++ b/src/cli/codex-launch-auth.ts @@ -12,7 +12,8 @@ import { codexHasOtherTokenConsumers, codexTokenConfigShapeIsSafe } from "./code import { codexLaunchHomeIsOwned, codexLaunchProjectAliasIsOwned, codexLaunchSystemNamespaceIsSafe } from "./codex-launch-ownership"; import type { CodexTokenRefusal, CodexTokenRefusalReporter } from "./codex-launch-diagnostics"; -export type CodexRuntimeAttestation = Readonly<{ origin: string }>; +/** role is the listener's self-reported role for the same pid/port the challenge proved. */ +export type CodexRuntimeAttestation = Readonly<{ origin: string; role?: "client" }>; /** Retain the exact dial endpoint that answered the ownership challenge, not just its verdict. */ export async function attestCodexRuntime( @@ -22,7 +23,8 @@ export async function attestCodexRuntime( const snapshot = Object.freeze({ ...live }); try { const origin = new URL(`http://${probeHostname(snapshot.probeHostname ?? snapshot.hostname)}:${snapshot.port}`).origin; - return await prove(snapshot) === "proven" ? Object.freeze({ origin }) : null; + if (await prove(snapshot) !== "proven") return null; + return Object.freeze(snapshot.role === "client" ? { origin, role: "client" as const } : { origin }); } catch { return null; } } @@ -72,6 +74,9 @@ export function codexCredentialDestinationIsTrusted(home: string, systemNamespac const connection = readClientConnectionState(); if (connection.kind !== "connected" || connection.value.transport === "link") return refuse("connection"); target = validateCodexRoutingTarget({ baseUrl: `${connection.value.serverUrl}/v1`, requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" }); + // Only this home's client machine listener may vouch for a direct client; a leftover + // standalone or hub listener from the same home proves the home, not the client. + if (runtimeAttestation?.role !== "client") return refuse("destination"); destinationIsProvenListener = false; } const url = new URL(target.baseUrl); diff --git a/structure/cli-management.md b/structure/cli-management.md index 8ee18fa1317..2b1679b4883 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -34,7 +34,7 @@ Same-file project aliases additionally require ownership/ACL inspection of the l For fresh credentials, the launcher resolves a native literal `CODEX_HOME` once and passes the canonical absolute directory both to the destination verifier and to the child. Missing, unreadable or non-directory homes and ambiguous Windows home casing withhold credentials. Any nonempty inherited HTTP(S)/ALL proxy variable, matched case-insensitively, suppresses token reads irrespective of NO_PROXY; denied and inherited-token launches preserve their environment. Successful injection adds both allowed loopback IPs to identical child NO_PROXY/no_proxy values while preserving prior bypass entries, covering default OS proxy discovery. Configured `features.respect_system_proxy` and feature-changing argv suppress loading because system-selected routes can ignore bypasses. -`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. Before loading a fresh service token, the ensured listener must prove possession of this home's runtime-record attestation secret for its exact pid and port; a public health marker or failed ensure never authorizes credential loading. The successful proof retains an immutable origin snapshot using the actual probe address (including recovery probe overrides). For a standalone installation that origin must match the final provider destination; IPv4 and IPv6 recipients remain distinct. A direct client's proof comes from its machine listener, so its persisted hub URL is not compared with the proven origin and keeps the connection-state and loopback checks instead. A mismatched or missing proof withholds the token before the key file is read, without rewriting routing. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. +`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. Before loading a fresh service token, the ensured listener must prove possession of this home's runtime-record attestation secret for its exact pid and port; a public health marker or failed ensure never authorizes credential loading. The successful proof retains an immutable origin snapshot using the actual probe address (including recovery probe overrides). For a standalone installation that origin must match the final provider destination; IPv4 and IPv6 recipients remain distinct. A direct client's proof comes from its machine listener, so its persisted hub URL is not compared with the proven origin and keeps the connection-state and loopback checks instead; the proof must come from a listener that reports the client role, so a leftover standalone or hub listener of the same home does not qualify. A mismatched or missing proof withholds the token before the key file is read, without rewriting routing. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. Every handoff, including help/version and passthrough, awaits the [execution trust contract](clients/chatgpt-desktop.md#codex-app-execution-trust) for the selected backing and spawns the returned invocation. On macOS, CLI handoffs verify any enclosing `.app` at the canonical target location, including renamed bundles and symlink targets; nested apps retain the outer bundle root. Fast paths bypass startup and credential loading, not executable validation. The hidden `internal codex-exec-target` helper returns a single verified path for the generated app-server launcher; its strict `--bundle-root` mode retains the discovered root independent of bundle name and refuses malformed roots without falling back to generic classification. It skips root shim auto-repair even on malformed arguments, and stays outside public capability discovery. Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. Single-word prompts require `--` so they cannot select an unknown native subcommand. Missing system files also require a protected namespace: the closest existing directory must prevent another user from creating the missing layer, and its ancestors must prevent replacement. A normal Windows ProgramData directory that grants Users directory creation cannot authorize an absent OpenAI/Codex namespace; the launcher withholds credentials without creating or changing that directory. macOS opens each validated entry without following its final link (O_SYMLINK for the link entry itself), keeps dev/ino as bigint and requires descriptor dev/ino/type to match its original lstat, and reads its ACL with acl_get_fd_np. Only NULL with immediately captured ENOENT on this descriptor query means no extended ACL; open/stat/identity/close failures and every other NULL/error refuse. Every opened descriptor closes in finally. For a present ACL it validates and enumerates every entry through the documented -1/EINVAL terminator. Deny-only and read-only allow entries are accepted; any allow granting write/add-file, append/add-subdirectory, delete, delete-child, attribute, extended-attribute, security or ownership mutation refuses regardless of earlier denies. Unknown tags and FFI/enumeration failures refuse. Forced managed-preference checks remain mandatory. Pure policy and Darwin-gated native ACL coverage lives in `tests/codex-integration/codex-launcher-darwin-acl.test.ts`; modeled IO regressions for all three walks live in `tests/codex-integration/codex-launcher-darwin-identity.test.ts`. Tests model a protected empty machine namespace inside their private fixture; they do not modify real system configuration. Windows fixtures probe ancestor ownership before positive assertions; an unavailable protected location requires a proved ownership refusal and an explicit skip reason. Negative mutations and no-token/zero-read assertions execute even with unsafe ancestors; credential-file negatives bypass destination verification independently. Native checks use production defaults. diff --git a/tests/codex-integration/codex-launcher-endpoint.test.ts b/tests/codex-integration/codex-launcher-endpoint.test.ts index 63b0cf06d80..e9072ff52bc 100644 --- a/tests/codex-integration/codex-launcher-endpoint.test.ts +++ b/tests/codex-integration/codex-launcher-endpoint.test.ts @@ -50,6 +50,13 @@ describe("Codex runtime endpoint binding", () => { } }); + test("a proof keeps the client role only when the proven listener reported it", async () => { + expect(await attestCodexRuntime({ ...live, role: "client" }, async () => "proven")).toEqual({ origin, role: "client" }); + expect(await attestCodexRuntime({ ...live, role: "hub" }, async () => "proven")).toEqual({ origin }); + expect(await attestCodexRuntime(live, async () => "proven")).toEqual({ origin }); + expect(await attestCodexRuntime({ ...live, role: "client" }, async () => "unproven")).toBeNull(); + }); + test("direct-client destination matching is independent of the local machine port", () => { const local = standaloneCodexRoutingTarget(12200, { hostname: "0.0.0.0" }); const direct = validateCodexRoutingTarget({ baseUrl: `${origin}/v1`, requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" }); @@ -152,14 +159,15 @@ for (const client of [false, true]) codexLaunchOwnershipTest(`${client ? "direct for (const port of [12199, 12200]) { let reads = 0; const result = buildCodexPrivateFixtureLaunchEnv({ CODEX_HOME: home }, { - routingKind: "opencodex-local", runtimeAttestation: { origin: `http://127.0.0.1:${port}` }, + routingKind: "opencodex-local", runtimeAttestation: { origin: `http://127.0.0.1:${port}`, ...(client ? { role: "client" as const } : {}) }, serviceToken: () => { reads++; return token; }, }); const injected = client || port === 12199; expect(reads).toBe(injected ? 1 : 0); expect(result.OPENCODEX_API_AUTH_TOKEN).toBe(injected ? token : undefined); } - for (const runtimeAttestation of [null, undefined]) { + // A same-home listener that is not the client machine listener cannot vouch for a direct client. + for (const runtimeAttestation of [null, undefined, ...(client ? [{ origin: "http://127.0.0.1:12200" }] : [])]) { let reads = 0; const result = buildCodexPrivateFixtureLaunchEnv({ CODEX_HOME: home }, { routingKind: "opencodex-local", runtimeAttestation, serviceToken: () => { reads++; return token; } }); expect(reads).toBe(0); From c4779395976b534a8753314ce9f957176559a1d7 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 3 Nov 2026 16:46:43 +0900 Subject: [PATCH 5/7] Revert the client-role requirement for direct-client proofs The role a listener reports is not covered by the ownership proof, so requiring it added no authenticated guarantee. Binding the role would need a change to the attestation format shared with the desktop app. Direct clients keep the home-ownership proof, which dev does not require at all. --- docs-site/src/content/docs/reference/cli/agents.md | 2 +- src/cli/codex-launch-auth.ts | 9 ++------- structure/cli-management.md | 2 +- .../codex-launcher-endpoint.test.ts | 12 ++---------- 4 files changed, 6 insertions(+), 19 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 484333ae75a..2b76789ae75 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -990,7 +990,7 @@ Alternatively, supply the key yourself, for example by exporting `OPENCODEX_API_ Use `--` before a single-word prompt so it cannot select an unknown Codex subcommand. -Automatic credentials also require a fresh ownership proof from this installation's running listener. For a standalone proxy, the proof must come from the exact HTTP origin in the managed Codex provider configuration; a running proxy on a different port or loopback address does not authorize that destination. A direct client must prove its own client machine listener (another listener of the same installation does not count) and keeps its saved server URL checks, because the server it dials is a different installation. Without a matching proof, the launcher withholds the automatic token and leaves routing unchanged. +Automatic credentials also require a fresh ownership proof from this installation's running listener. For a standalone proxy, the proof must come from the exact HTTP origin in the managed Codex provider configuration; a running proxy on a different port or loopback address does not authorize that destination. A direct client proves its own machine listener and keeps its saved server URL checks, because the server it dials is a different installation. Without a matching proof, the launcher withholds the automatic token and leaves routing unchanged. The machine configuration namespace must also prevent other users from creating missing system files. An unprotected Windows ProgramData/OpenAI/Codex namespace withholds automatic credentials; the launcher does not create it. macOS accepts deny-only and read-only allow ACLs, but refuses any allow entry granting write, append, delete, attribute/security changes or ownership changes, even after a deny entry. Unknown tags and inspection errors refuse. diff --git a/src/cli/codex-launch-auth.ts b/src/cli/codex-launch-auth.ts index 642dc347cf8..1bf3641f96a 100644 --- a/src/cli/codex-launch-auth.ts +++ b/src/cli/codex-launch-auth.ts @@ -12,8 +12,7 @@ import { codexHasOtherTokenConsumers, codexTokenConfigShapeIsSafe } from "./code import { codexLaunchHomeIsOwned, codexLaunchProjectAliasIsOwned, codexLaunchSystemNamespaceIsSafe } from "./codex-launch-ownership"; import type { CodexTokenRefusal, CodexTokenRefusalReporter } from "./codex-launch-diagnostics"; -/** role is the listener's self-reported role for the same pid/port the challenge proved. */ -export type CodexRuntimeAttestation = Readonly<{ origin: string; role?: "client" }>; +export type CodexRuntimeAttestation = Readonly<{ origin: string }>; /** Retain the exact dial endpoint that answered the ownership challenge, not just its verdict. */ export async function attestCodexRuntime( @@ -23,8 +22,7 @@ export async function attestCodexRuntime( const snapshot = Object.freeze({ ...live }); try { const origin = new URL(`http://${probeHostname(snapshot.probeHostname ?? snapshot.hostname)}:${snapshot.port}`).origin; - if (await prove(snapshot) !== "proven") return null; - return Object.freeze(snapshot.role === "client" ? { origin, role: "client" as const } : { origin }); + return await prove(snapshot) === "proven" ? Object.freeze({ origin }) : null; } catch { return null; } } @@ -74,9 +72,6 @@ export function codexCredentialDestinationIsTrusted(home: string, systemNamespac const connection = readClientConnectionState(); if (connection.kind !== "connected" || connection.value.transport === "link") return refuse("connection"); target = validateCodexRoutingTarget({ baseUrl: `${connection.value.serverUrl}/v1`, requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" }); - // Only this home's client machine listener may vouch for a direct client; a leftover - // standalone or hub listener from the same home proves the home, not the client. - if (runtimeAttestation?.role !== "client") return refuse("destination"); destinationIsProvenListener = false; } const url = new URL(target.baseUrl); diff --git a/structure/cli-management.md b/structure/cli-management.md index 2b1679b4883..8ee18fa1317 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -34,7 +34,7 @@ Same-file project aliases additionally require ownership/ACL inspection of the l For fresh credentials, the launcher resolves a native literal `CODEX_HOME` once and passes the canonical absolute directory both to the destination verifier and to the child. Missing, unreadable or non-directory homes and ambiguous Windows home casing withhold credentials. Any nonempty inherited HTTP(S)/ALL proxy variable, matched case-insensitively, suppresses token reads irrespective of NO_PROXY; denied and inherited-token launches preserve their environment. Successful injection adds both allowed loopback IPs to identical child NO_PROXY/no_proxy values while preserving prior bypass entries, covering default OS proxy discovery. Configured `features.respect_system_proxy` and feature-changing argv suppress loading because system-selected routes can ignore bypasses. -`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. Before loading a fresh service token, the ensured listener must prove possession of this home's runtime-record attestation secret for its exact pid and port; a public health marker or failed ensure never authorizes credential loading. The successful proof retains an immutable origin snapshot using the actual probe address (including recovery probe overrides). For a standalone installation that origin must match the final provider destination; IPv4 and IPv6 recipients remain distinct. A direct client's proof comes from its machine listener, so its persisted hub URL is not compared with the proven origin and keeps the connection-state and loopback checks instead; the proof must come from a listener that reports the client role, so a leftover standalone or hub listener of the same home does not qualify. A mismatched or missing proof withholds the token before the key file is read, without rewriting routing. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. +`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. Before loading a fresh service token, the ensured listener must prove possession of this home's runtime-record attestation secret for its exact pid and port; a public health marker or failed ensure never authorizes credential loading. The successful proof retains an immutable origin snapshot using the actual probe address (including recovery probe overrides). For a standalone installation that origin must match the final provider destination; IPv4 and IPv6 recipients remain distinct. A direct client's proof comes from its machine listener, so its persisted hub URL is not compared with the proven origin and keeps the connection-state and loopback checks instead. A mismatched or missing proof withholds the token before the key file is read, without rewriting routing. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. Every handoff, including help/version and passthrough, awaits the [execution trust contract](clients/chatgpt-desktop.md#codex-app-execution-trust) for the selected backing and spawns the returned invocation. On macOS, CLI handoffs verify any enclosing `.app` at the canonical target location, including renamed bundles and symlink targets; nested apps retain the outer bundle root. Fast paths bypass startup and credential loading, not executable validation. The hidden `internal codex-exec-target` helper returns a single verified path for the generated app-server launcher; its strict `--bundle-root` mode retains the discovered root independent of bundle name and refuses malformed roots without falling back to generic classification. It skips root shim auto-repair even on malformed arguments, and stays outside public capability discovery. Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. Single-word prompts require `--` so they cannot select an unknown native subcommand. Missing system files also require a protected namespace: the closest existing directory must prevent another user from creating the missing layer, and its ancestors must prevent replacement. A normal Windows ProgramData directory that grants Users directory creation cannot authorize an absent OpenAI/Codex namespace; the launcher withholds credentials without creating or changing that directory. macOS opens each validated entry without following its final link (O_SYMLINK for the link entry itself), keeps dev/ino as bigint and requires descriptor dev/ino/type to match its original lstat, and reads its ACL with acl_get_fd_np. Only NULL with immediately captured ENOENT on this descriptor query means no extended ACL; open/stat/identity/close failures and every other NULL/error refuse. Every opened descriptor closes in finally. For a present ACL it validates and enumerates every entry through the documented -1/EINVAL terminator. Deny-only and read-only allow entries are accepted; any allow granting write/add-file, append/add-subdirectory, delete, delete-child, attribute, extended-attribute, security or ownership mutation refuses regardless of earlier denies. Unknown tags and FFI/enumeration failures refuse. Forced managed-preference checks remain mandatory. Pure policy and Darwin-gated native ACL coverage lives in `tests/codex-integration/codex-launcher-darwin-acl.test.ts`; modeled IO regressions for all three walks live in `tests/codex-integration/codex-launcher-darwin-identity.test.ts`. Tests model a protected empty machine namespace inside their private fixture; they do not modify real system configuration. Windows fixtures probe ancestor ownership before positive assertions; an unavailable protected location requires a proved ownership refusal and an explicit skip reason. Negative mutations and no-token/zero-read assertions execute even with unsafe ancestors; credential-file negatives bypass destination verification independently. Native checks use production defaults. diff --git a/tests/codex-integration/codex-launcher-endpoint.test.ts b/tests/codex-integration/codex-launcher-endpoint.test.ts index e9072ff52bc..63b0cf06d80 100644 --- a/tests/codex-integration/codex-launcher-endpoint.test.ts +++ b/tests/codex-integration/codex-launcher-endpoint.test.ts @@ -50,13 +50,6 @@ describe("Codex runtime endpoint binding", () => { } }); - test("a proof keeps the client role only when the proven listener reported it", async () => { - expect(await attestCodexRuntime({ ...live, role: "client" }, async () => "proven")).toEqual({ origin, role: "client" }); - expect(await attestCodexRuntime({ ...live, role: "hub" }, async () => "proven")).toEqual({ origin }); - expect(await attestCodexRuntime(live, async () => "proven")).toEqual({ origin }); - expect(await attestCodexRuntime({ ...live, role: "client" }, async () => "unproven")).toBeNull(); - }); - test("direct-client destination matching is independent of the local machine port", () => { const local = standaloneCodexRoutingTarget(12200, { hostname: "0.0.0.0" }); const direct = validateCodexRoutingTarget({ baseUrl: `${origin}/v1`, requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" }); @@ -159,15 +152,14 @@ for (const client of [false, true]) codexLaunchOwnershipTest(`${client ? "direct for (const port of [12199, 12200]) { let reads = 0; const result = buildCodexPrivateFixtureLaunchEnv({ CODEX_HOME: home }, { - routingKind: "opencodex-local", runtimeAttestation: { origin: `http://127.0.0.1:${port}`, ...(client ? { role: "client" as const } : {}) }, + routingKind: "opencodex-local", runtimeAttestation: { origin: `http://127.0.0.1:${port}` }, serviceToken: () => { reads++; return token; }, }); const injected = client || port === 12199; expect(reads).toBe(injected ? 1 : 0); expect(result.OPENCODEX_API_AUTH_TOKEN).toBe(injected ? token : undefined); } - // A same-home listener that is not the client machine listener cannot vouch for a direct client. - for (const runtimeAttestation of [null, undefined, ...(client ? [{ origin: "http://127.0.0.1:12200" }] : [])]) { + for (const runtimeAttestation of [null, undefined]) { let reads = 0; const result = buildCodexPrivateFixtureLaunchEnv({ CODEX_HOME: home }, { routingKind: "opencodex-local", runtimeAttestation, serviceToken: () => { reads++; return token; } }); expect(reads).toBe(0); From 4771d63c2f9896f3e26c0f5771b65c0f1529273a Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 3 Nov 2026 16:46:52 +0900 Subject: [PATCH 6/7] docs(cli): state what the direct-client listener proof covers --- docs-site/src/content/docs/reference/cli/agents.md | 2 +- structure/cli-management.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 2b76789ae75..8d84eb48a54 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -990,7 +990,7 @@ Alternatively, supply the key yourself, for example by exporting `OPENCODEX_API_ Use `--` before a single-word prompt so it cannot select an unknown Codex subcommand. -Automatic credentials also require a fresh ownership proof from this installation's running listener. For a standalone proxy, the proof must come from the exact HTTP origin in the managed Codex provider configuration; a running proxy on a different port or loopback address does not authorize that destination. A direct client proves its own machine listener and keeps its saved server URL checks, because the server it dials is a different installation. Without a matching proof, the launcher withholds the automatic token and leaves routing unchanged. +Automatic credentials also require a fresh ownership proof from this installation's running listener. For a standalone proxy, the proof must come from the exact HTTP origin in the managed Codex provider configuration; a running proxy on a different port or loopback address does not authorize that destination. A direct client proves its own machine listener and keeps its saved server URL checks, because the server it dials is a different installation. For a direct client the proof shows that the answering listener belongs to this installation; it does not prove which kind of listener it is. Without a matching proof, the launcher withholds the automatic token and leaves routing unchanged. The machine configuration namespace must also prevent other users from creating missing system files. An unprotected Windows ProgramData/OpenAI/Codex namespace withholds automatic credentials; the launcher does not create it. macOS accepts deny-only and read-only allow ACLs, but refuses any allow entry granting write, append, delete, attribute/security changes or ownership changes, even after a deny entry. Unknown tags and inspection errors refuse. diff --git a/structure/cli-management.md b/structure/cli-management.md index 8ee18fa1317..7fc8d55c98b 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -34,7 +34,7 @@ Same-file project aliases additionally require ownership/ACL inspection of the l For fresh credentials, the launcher resolves a native literal `CODEX_HOME` once and passes the canonical absolute directory both to the destination verifier and to the child. Missing, unreadable or non-directory homes and ambiguous Windows home casing withhold credentials. Any nonempty inherited HTTP(S)/ALL proxy variable, matched case-insensitively, suppresses token reads irrespective of NO_PROXY; denied and inherited-token launches preserve their environment. Successful injection adds both allowed loopback IPs to identical child NO_PROXY/no_proxy values while preserving prior bypass entries, covering default OS proxy discovery. Configured `features.respect_system_proxy` and feature-changing argv suppress loading because system-selected routes can ignore bypasses. -`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. Before loading a fresh service token, the ensured listener must prove possession of this home's runtime-record attestation secret for its exact pid and port; a public health marker or failed ensure never authorizes credential loading. The successful proof retains an immutable origin snapshot using the actual probe address (including recovery probe overrides). For a standalone installation that origin must match the final provider destination; IPv4 and IPv6 recipients remain distinct. A direct client's proof comes from its machine listener, so its persisted hub URL is not compared with the proven origin and keeps the connection-state and loopback checks instead. A mismatched or missing proof withholds the token before the key file is read, without rewriting routing. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. +`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. Before loading a fresh service token, the ensured listener must prove possession of this home's runtime-record attestation secret for its exact pid and port; a public health marker or failed ensure never authorizes credential loading. The successful proof retains an immutable origin snapshot using the actual probe address (including recovery probe overrides). For a standalone installation that origin must match the final provider destination; IPv4 and IPv6 recipients remain distinct. A direct client's proof comes from its machine listener, so its persisted hub URL is not compared with the proven origin and keeps the connection-state and loopback checks instead. The proof authenticates home ownership, pid and port only; a listener role is self-reported and is not bound by it. A mismatched or missing proof withholds the token before the key file is read, without rewriting routing. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. Every handoff, including help/version and passthrough, awaits the [execution trust contract](clients/chatgpt-desktop.md#codex-app-execution-trust) for the selected backing and spawns the returned invocation. On macOS, CLI handoffs verify any enclosing `.app` at the canonical target location, including renamed bundles and symlink targets; nested apps retain the outer bundle root. Fast paths bypass startup and credential loading, not executable validation. The hidden `internal codex-exec-target` helper returns a single verified path for the generated app-server launcher; its strict `--bundle-root` mode retains the discovered root independent of bundle name and refuses malformed roots without falling back to generic classification. It skips root shim auto-repair even on malformed arguments, and stays outside public capability discovery. Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. Single-word prompts require `--` so they cannot select an unknown native subcommand. Missing system files also require a protected namespace: the closest existing directory must prevent another user from creating the missing layer, and its ancestors must prevent replacement. A normal Windows ProgramData directory that grants Users directory creation cannot authorize an absent OpenAI/Codex namespace; the launcher withholds credentials without creating or changing that directory. macOS opens each validated entry without following its final link (O_SYMLINK for the link entry itself), keeps dev/ino as bigint and requires descriptor dev/ino/type to match its original lstat, and reads its ACL with acl_get_fd_np. Only NULL with immediately captured ENOENT on this descriptor query means no extended ACL; open/stat/identity/close failures and every other NULL/error refuse. Every opened descriptor closes in finally. For a present ACL it validates and enumerates every entry through the documented -1/EINVAL terminator. Deny-only and read-only allow entries are accepted; any allow granting write/add-file, append/add-subdirectory, delete, delete-child, attribute, extended-attribute, security or ownership mutation refuses regardless of earlier denies. Unknown tags and FFI/enumeration failures refuse. Forced managed-preference checks remain mandatory. Pure policy and Darwin-gated native ACL coverage lives in `tests/codex-integration/codex-launcher-darwin-acl.test.ts`; modeled IO regressions for all three walks live in `tests/codex-integration/codex-launcher-darwin-identity.test.ts`. Tests model a protected empty machine namespace inside their private fixture; they do not modify real system configuration. Windows fixtures probe ancestor ownership before positive assertions; an unavailable protected location requires a proved ownership refusal and an explicit skip reason. Negative mutations and no-token/zero-read assertions execute even with unsafe ancestors; credential-file negatives bypass destination verification independently. Native checks use production defaults. From a1f5fd87dbfad0c02be2a7767cd9e52fd54d00c3 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 3 Nov 2026 17:16:54 +0900 Subject: [PATCH 7/7] fix(cli): prove a localhost listener at the address it binds A configured localhost binds 127.0.0.1, which is also what the standalone Codex target dials; probing the raw name produced a different origin and withheld the automatic token. --- src/cli/codex-launch-auth.ts | 7 +++++-- tests/codex-integration/codex-launcher-endpoint.test.ts | 8 ++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/cli/codex-launch-auth.ts b/src/cli/codex-launch-auth.ts index 1bf3641f96a..3d0dde1e568 100644 --- a/src/cli/codex-launch-auth.ts +++ b/src/cli/codex-launch-auth.ts @@ -7,6 +7,7 @@ import { readBoundedCodexConfig } from "../codex/inject/bounded-config-reader"; import { buildProviderTableBlockForTarget } from "../codex/inject/config-toml"; import { standaloneCodexRoutingTarget, validateCodexRoutingTarget } from "../codex/inject/routing-target"; import { readClientConnectionState } from "../client/state"; +import { normalizeBindHostname } from "../server/bind-host"; import { probeHostname, proveLiveProxyOwnedByHome, type LiveProxy } from "../server/proxy-liveness"; import { codexHasOtherTokenConsumers, codexTokenConfigShapeIsSafe } from "./codex-launch-policy"; import { codexLaunchHomeIsOwned, codexLaunchProjectAliasIsOwned, codexLaunchSystemNamespaceIsSafe } from "./codex-launch-ownership"; @@ -19,9 +20,11 @@ export async function attestCodexRuntime( live: LiveProxy, prove: typeof proveLiveProxyOwnedByHome = proveLiveProxyOwnedByHome, ): Promise { - const snapshot = Object.freeze({ ...live }); + // Probe the address the listener actually binds: a configured localhost binds 127.0.0.1, + // which is also what the standalone Codex target dials. + const snapshot = Object.freeze({ ...live, probeHostname: normalizeBindHostname(live.probeHostname ?? live.hostname) }); try { - const origin = new URL(`http://${probeHostname(snapshot.probeHostname ?? snapshot.hostname)}:${snapshot.port}`).origin; + const origin = new URL(`http://${probeHostname(snapshot.probeHostname)}:${snapshot.port}`).origin; return await prove(snapshot) === "proven" ? Object.freeze({ origin }) : null; } catch { return null; } } diff --git a/tests/codex-integration/codex-launcher-endpoint.test.ts b/tests/codex-integration/codex-launcher-endpoint.test.ts index 63b0cf06d80..94505bc8782 100644 --- a/tests/codex-integration/codex-launcher-endpoint.test.ts +++ b/tests/codex-integration/codex-launcher-endpoint.test.ts @@ -50,6 +50,14 @@ describe("Codex runtime endpoint binding", () => { } }); + test("a configured localhost listener is proven at the 127.0.0.1 origin it binds", async () => { + let probed: LiveProxy | undefined; + const proof = await attestCodexRuntime({ ...live, hostname: "localhost" }, async snapshot => { probed = snapshot; return "proven"; }); + expect(probed?.probeHostname).toBe("127.0.0.1"); + expect(proof).toEqual({ origin }); + expect(codexRuntimeAttestationMatchesTarget(standaloneCodexRoutingTarget(12199, { hostname: "localhost" }).baseUrl, proof)).toBe(true); + }); + test("direct-client destination matching is independent of the local machine port", () => { const local = standaloneCodexRoutingTarget(12200, { hostname: "0.0.0.0" }); const direct = validateCodexRoutingTarget({ baseUrl: `${origin}/v1`, requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" });