Skip to content
Closed
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
8 changes: 6 additions & 2 deletions bin/ocx.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "../src/updat
import { bootRestoreProbe, launcherUsableAfterNpmUpdate, transactionalNpmUpdate } from "../src/update/transactional-install.mjs";
import { manualUpdateFailureGuidance, npmUpdateFailureGuidance } from "../src/update/update-failure-guidance.mjs";
import {
captureCodexCliTransportEnv,
CODEX_CLI_TRANSPORT_ENV_KEYS,
CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS,
isCodexCliUpdateInspectionArgv,
} from "../src/update/codex-cli-update-launch-policy.mjs";
Expand Down Expand Up @@ -1208,10 +1210,11 @@ function createNodeLaunchContext() {
path: preBunPath,
pathExt: preBunPathExt,
managerRoots: preBunCodexCliManagerRoots,
transportEnv: captureCodexCliTransportEnv(process.env),
configDir: configDir(),
} : null,
});
// The inspection snapshot above already carries PATH, PATHEXT, and the manager-root slots as
// The inspection snapshot carries PATH, PATHEXT, manager roots and proxy/CA settings as
// proof-bound values, and `inspectCodexCliInstall` reads them from that snapshot rather than
// from the live environment. Inheriting them again would spend the 32,767-character Windows
// environment block twice, so a large-but-valid shell environment could stop the Bun child
Expand All @@ -1224,7 +1227,8 @@ function createNodeLaunchContext() {
const inheritedEnv = { ...process.env };
if (codexCliUpdateInspection) {
const snapshotted = new Set(
["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS].map(name => name.toLowerCase()),
["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS,
...CODEX_CLI_TRANSPORT_ENV_KEYS].map(name => name.toLowerCase()),
);
for (const name of Object.keys(inheritedEnv)) {
if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name];
Expand Down
1 change: 1 addition & 0 deletions src/cli/codex-cli-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ function inspectionDeps(commandDeps: CodexCliUpdateCommandDeps): CodexCliInstall
return {
env: {
...trusted.managerRoots,
...trusted.transportEnv,
CODEX_CLI_PATH: trusted.codexCliPath ?? undefined,
PATH: trusted.path ?? undefined,
PATHEXT: trusted.pathExt ?? undefined,
Expand Down
18 changes: 17 additions & 1 deletion src/cli/launcher-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type TrustedNodeLaunchContext = {
path: string | null;
pathExt: string | null;
managerRoots: CodexCliVersionManagerRoots | null;
transportEnv: Readonly<Record<string, string>>;
configDir: string;
}> | null;
};
Expand All @@ -50,6 +51,16 @@ function parseVersionManagerRoots(value: unknown): CodexCliVersionManagerRoots |
return Object.freeze(Object.fromEntries(entries)) as CodexCliVersionManagerRoots;
}

function parseTransportEnv(value: unknown): Readonly<Record<string, string>> | undefined {
// Older snapshots convey no transport settings. Never fill them from Bun's ambient env.
if (value === undefined) return Object.freeze({});
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const entries = Object.entries(value);
if (entries.some(([name, setting]) => !CODEX_CLI_TRANSPORT_ENV_KEYS.includes(name.toLowerCase())
|| typeof setting !== "string" || setting.length > 32 * 1024)) return undefined;
return Object.freeze(Object.fromEntries(entries));
}

/** Consume the internal proof before normal CLI argument parsing. */
export function initializeNodeLauncherContext(
argv: string[] = process.argv,
Expand Down Expand Up @@ -95,6 +106,9 @@ export function initializeNodeLauncherContext(
const managerRoots = inspection && typeof inspection === "object" && !Array.isArray(inspection)
? parseVersionManagerRoots((inspection as Record<string, unknown>).managerRoots)
: null;
const transportEnv = inspection && typeof inspection === "object" && !Array.isArray(inspection)
? parseTransportEnv((inspection as Record<string, unknown>).transportEnv)
: undefined;
const codexCliInspectionEnv = inspection === null || inspection === undefined
? null
: inspection && typeof inspection === "object" && !Array.isArray(inspection)
Expand All @@ -106,11 +120,13 @@ export function initializeNodeLauncherContext(
&& (inspection as Record<string, string>).configDir.length > 0
&& (inspection as Record<string, string>).configDir.length <= 32 * 1024
&& managerRoots !== undefined
&& transportEnv !== undefined
? Object.freeze({
codexCliPath: (inspection as Record<string, string | null>).codexCliPath ?? null,
path: (inspection as Record<string, string | null>).path ?? null,
pathExt: (inspection as Record<string, string | null>).pathExt ?? null,
managerRoots,
transportEnv,
configDir: (inspection as Record<string, string>).configDir,
})
: undefined;
Expand All @@ -125,4 +141,4 @@ export function initializeNodeLauncherContext(
export function trustedNodeLauncherContext(): TrustedNodeLaunchContext | null {
return trustedContext;
}
import { CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS } from "../update/codex-cli-update-launch-policy.mjs";
import { CODEX_CLI_TRANSPORT_ENV_KEYS, CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS } from "../update/codex-cli-update-launch-policy.mjs";
28 changes: 15 additions & 13 deletions src/codex/cli-update-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,8 @@ function npmTarget(args: readonly string[], env?: NodeJS.ProcessEnv): NpmTarget
* shared modules from. None of these is input the updater child needs, so
* ambient values stay out of the spawned npm. This is a denylist for
* process-startup/output channels, not a claim that every ambient variable is
* allowlisted. Ordinary OS environment, proxy settings and the supported
* NODE_EXTRA_CA_CERTS extension remain available.
* allowlisted. When a proof-bound launcher snapshot is available, proxy and
* CA settings are replaced from that snapshot below.
*/
const NPM_ENV_DROP_KEYS: ReadonlySet<string> = new Set([
"node_options",
Expand Down Expand Up @@ -255,23 +255,25 @@ function codexCliUpdateNpmEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.Pro
}

/**
* Ambient environment with the executable-selection channels replaced by the proof-bound
* launcher snapshot. HOME, proxy variables and NODE_EXTRA_CA_CERTS stay ambient because npm
* itself needs them; PATH/PATHEXT are the channels a project dotenv could use to supply a
* fake npm, so they come only from the captured snapshot. A snapshot without PATH means no
* trusted PATH at all — an empty one fails the spawn closed rather than falling back.
* Ambient environment with executable-selection and transport-trust channels replaced by
* the proof-bound launcher snapshot. Bun may add project dotenv values after that snapshot,
* so PATH/PATHEXT, proxies and NODE_EXTRA_CA_CERTS must all come from the captured context.
* A snapshot without PATH means no trusted PATH at all — an empty one fails the spawn closed.
*/
function codexCliUpdateBoundEnv(bound: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
const merged = codexCliUpdateNpmEnv();
if (bound === undefined) return merged;
const boundKeys = new Set([
"path", "pathext", "http_proxy", "https_proxy", "all_proxy", "no_proxy", "node_extra_ca_certs",
]);
for (const key of Object.keys(merged)) {
const k = key.toLowerCase();
if (k === "path" || k === "pathext") delete merged[key];
if (boundKeys.has(k)) delete merged[key];
}
let hasTrustedPath = false;
for (const [key, value] of Object.entries(bound)) {
const k = key.toLowerCase();
if ((k === "path" || k === "pathext") && value !== undefined) {
if (boundKeys.has(k) && value !== undefined) {
merged[key] = value;
if (k === "path") hasTrustedPath = true;
}
Expand Down Expand Up @@ -303,7 +305,7 @@ interface NpmConfigIsolation {
* the operator's real ancestors. The sentinel package.json anchors the walk here,
* where no .npmrc exists. --userconfig/--globalconfig replace the two file configs,
* and the env filter removes the env-var channel. What remains is exactly the pinned
* --registry flag plus deliberately supported proxy/CA env.
* --registry flag plus proxy/CA values captured before project dotenv loading.
*/
function createNpmConfigIsolation(dir?: string, boundEnv?: NodeJS.ProcessEnv): NpmConfigIsolation {
const ownsRoot = dir === undefined;
Expand Down Expand Up @@ -356,7 +358,7 @@ function isolatedNpmTarget(args: readonly string[], isolation: NpmConfigIsolatio
export function resolveCodexCliUpdateTarget(
channel: CodexCliUpdateChannel,
spawn: typeof spawnSync = spawnSync,
/** Proof-bound launcher snapshot env; executable resolution reads only its PATH/PATHEXT. */
/** Proof-bound pre-dotenv PATH/PATHEXT and proxy/CA env; absent settings stay absent. */
boundEnv?: NodeJS.ProcessEnv,
): CodexCliUpdateTarget {
// The pinned registry and the isolation directory together are the boundary: the
Expand Down Expand Up @@ -495,8 +497,8 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {}
}

// The npm evidence channels are bound to the same environment snapshot the
// ownership inspection ran against: PATH/PATHEXT from the proof-bound launcher
// context, never the ambient ones a project dotenv could point at a fake npm.
// ownership inspection ran against: PATH/PATHEXT and proxy/CA from the proof-bound
// pre-dotenv launcher context, never ambient executable or transport overrides.
// When the caller had no trusted snapshot, inspectionDeps.env is the fail-closed
// { PATH: "" } form, so this resolution cannot silently fall back to ambient.
const resolveTarget = deps.resolveTarget
Expand Down
2 changes: 2 additions & 0 deletions src/update/codex-cli-update-launch-policy.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ export const CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS: readonly [
"VOLTA_HOME",
];
export function isCodexCliUpdateInspectionArgv(argv: readonly string[]): boolean;
export const CODEX_CLI_TRANSPORT_ENV_KEYS: readonly string[];
export function captureCodexCliTransportEnv(env: Readonly<NodeJS.ProcessEnv>): Record<string, string>;
10 changes: 10 additions & 0 deletions src/update/codex-cli-update-launch-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ export const CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS = Object.freeze([
"VOLTA_HOME",
]);

export const CODEX_CLI_TRANSPORT_ENV_KEYS = Object.freeze([
"http_proxy", "https_proxy", "all_proxy", "no_proxy", "node_extra_ca_certs",
]);

/** Preserve spelling, aliases and empty values before Bun loads project dotenv. */
export function captureCodexCliTransportEnv(env) {
return Object.fromEntries(Object.entries(env).filter(([name, value]) =>
CODEX_CLI_TRANSPORT_ENV_KEYS.includes(name.toLowerCase()) && typeof value === "string"));
}

/**
* Detect the read-only Codex CLI updater inspection namespace before Bun loads.
* Keep this exact and argument-position based: malformed actions still inherit
Expand Down
9 changes: 9 additions & 0 deletions structure/codex-cli-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ and explicit Windows observation follows its [separate read-only contract](runti
Explicit Codex CLI installation observation does not discover a Codex home or read/write its state. See the [read-only observation contract](runtime.md#explicit-codex-cli-installation-observation).

The read-only CLI update plan binds npm executable selection to the inspected launcher PATH.
`bin/ocx.mjs` captures proxy variables (HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, NO_PROXY,
in any case) and NODE_EXTRA_CA_CERTS before Bun loads project dotenv. The proof-bound
snapshot preserves each key's spelling and value, including aliases and empty strings;
`src/cli/launcher-context.ts` validates and freezes that allowlisted transport map.
`src/cli/codex-cli-update.ts` forwards it with the inspected PATH/PATHEXT to the plan's
npm resolver. The resolver replaces all ambient aliases with the captured entries:
project dotenv cannot supply missing settings or change the captured precedence.
An older snapshot without a transport map conveys no transport settings, with no
ambient fallback. Snapshot values are internal inputs, never report or diagnostic fields.
Registry integrity requires a canonical base64 SHA-512 digest of exactly 64 bytes; malformed or truncated tokens refuse the target.
Selection observation uses the runtime resolver with provenance's bounded no-follow, nonblocking state reader and local executable-path policy. Windows returns before invoking the resolver; its deferred inspection performs no candidate or configuration I/O.
Its child environment drops npm configuration overrides, named Node startup/output controls,
Expand Down
Loading
Loading