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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/reference/cli/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,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 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.

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.
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout-clients.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"omo-responses-loop.test.ts": "clients",
"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",
Expand Down
31 changes: 30 additions & 1 deletion src/cli/codex-launch-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,34 @@ 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";
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<CodexRuntimeAttestation | null> {
// 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.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, string | undefined>): string | null {
try {
Expand All @@ -28,7 +52,7 @@ export function resolveCodexLaunchHome(env: Record<string, string | undefined>):
}

/** 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");
Expand All @@ -43,15 +67,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 (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<string, unknown>;
Expand Down
17 changes: 12 additions & 5 deletions src/cli/codex-launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { selfLaunchArgv } from "../lib/self-launch-argv";
import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets";
import { findExecutableOnPath } from "../remote-control/workspace-executable";
import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness";
import { codexCredentialDestinationIsTrusted, resolveCodexLaunchHome } from "./codex-launch-auth";
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";
Expand Down Expand Up @@ -158,7 +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;
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. */
Expand Down Expand Up @@ -186,15 +188,16 @@ export function buildCodexLaunchEnv(
};
if (!codexTokenArgsAreSafe(deps.args ?? [])) return withhold("args");
if (!codexTokenEnvironmentIsSafe(env)) return withhold("environment");
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);
if (!home) return withhold("home");
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");
Expand Down Expand Up @@ -318,5 +321,9 @@ export async function cmdCodex(args: string[]): Promise<number> {
// 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 runtimeAttestation = plan.kind === "routed" && live !== null
? await attestCodexRuntime(live) : null;
return spawnCodex(args, buildCodexLaunchEnv(process.env, { routingKind, args, runtimeAttestation }));
}
2 changes: 1 addition & 1 deletion structure/cli-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. `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.
Expand Down
2 changes: 1 addition & 1 deletion tests/codex-integration/codex-launcher-admission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function launch(home: string, args: string[] = [], extra: Record<string, string>
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", runtimeAttestation: { origin: "http://127.0.0.1:12199" }, args, serviceToken: () => { reads++; return token; },
});
return { env, reads, notes };
} finally { console.error = original; }
Expand Down
Loading
Loading