diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 0d235f59556..fc013a3382f 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -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"; @@ -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 @@ -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]; diff --git a/src/cli/codex-cli-update.ts b/src/cli/codex-cli-update.ts index 2a786258630..3454eeb3270 100644 --- a/src/cli/codex-cli-update.ts +++ b/src/cli/codex-cli-update.ts @@ -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, diff --git a/src/cli/launcher-context.ts b/src/cli/launcher-context.ts index 5541ba7472d..75412172c46 100644 --- a/src/cli/launcher-context.ts +++ b/src/cli/launcher-context.ts @@ -25,6 +25,7 @@ export type TrustedNodeLaunchContext = { path: string | null; pathExt: string | null; managerRoots: CodexCliVersionManagerRoots | null; + transportEnv: Readonly>; configDir: string; }> | null; }; @@ -50,6 +51,16 @@ function parseVersionManagerRoots(value: unknown): CodexCliVersionManagerRoots | return Object.freeze(Object.fromEntries(entries)) as CodexCliVersionManagerRoots; } +function parseTransportEnv(value: unknown): Readonly> | 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, @@ -95,6 +106,9 @@ export function initializeNodeLauncherContext( const managerRoots = inspection && typeof inspection === "object" && !Array.isArray(inspection) ? parseVersionManagerRoots((inspection as Record).managerRoots) : null; + const transportEnv = inspection && typeof inspection === "object" && !Array.isArray(inspection) + ? parseTransportEnv((inspection as Record).transportEnv) + : undefined; const codexCliInspectionEnv = inspection === null || inspection === undefined ? null : inspection && typeof inspection === "object" && !Array.isArray(inspection) @@ -106,11 +120,13 @@ export function initializeNodeLauncherContext( && (inspection as Record).configDir.length > 0 && (inspection as Record).configDir.length <= 32 * 1024 && managerRoots !== undefined + && transportEnv !== undefined ? Object.freeze({ codexCliPath: (inspection as Record).codexCliPath ?? null, path: (inspection as Record).path ?? null, pathExt: (inspection as Record).pathExt ?? null, managerRoots, + transportEnv, configDir: (inspection as Record).configDir, }) : undefined; @@ -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"; diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index f87c2f5de6b..dbaee88b5b6 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -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 = new Set([ "node_options", @@ -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; } @@ -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; @@ -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 @@ -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 diff --git a/src/update/codex-cli-update-launch-policy.d.mts b/src/update/codex-cli-update-launch-policy.d.mts index 5e4221031d0..bed9169d29a 100644 --- a/src/update/codex-cli-update-launch-policy.d.mts +++ b/src/update/codex-cli-update-launch-policy.d.mts @@ -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): Record; diff --git a/src/update/codex-cli-update-launch-policy.mjs b/src/update/codex-cli-update-launch-policy.mjs index 0d12c40338c..167186bf126 100644 --- a/src/update/codex-cli-update-launch-policy.mjs +++ b/src/update/codex-cli-update-launch-policy.mjs @@ -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 diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index bae97d671a3..b2d99adebed 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -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, diff --git a/tests/cli/cli-codex-cli-update-boundary.test.ts b/tests/cli/cli-codex-cli-update-boundary.test.ts index ad6e47c786f..c4694d8dba3 100644 --- a/tests/cli/cli-codex-cli-update-boundary.test.ts +++ b/tests/cli/cli-codex-cli-update-boundary.test.ts @@ -1,12 +1,14 @@ import { afterEach, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join, relative, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { repoPath, repoRoot } from "../helpers/repo-root"; import { handleCodexCliUpdateCommand } from "../../src/cli/codex-cli-update"; import { initializeNodeLauncherContext, NODE_LAUNCH_CONTEXT_ENV, NODE_LAUNCH_PROOF_PREFIX } from "../../src/cli/launcher-context"; import { inspectCodexCliInstall } from "../../src/codex/cli-install-provenance"; +import { captureCodexCliTransportEnv } from "../../src/update/codex-cli-update-launch-policy.mjs"; const roots: string[] = []; afterEach(() => { @@ -53,3 +55,136 @@ describe("Codex CLI selection inspection boundary", () => { }, ); }); + +function inspectionContext(transportEnv?: unknown) { + const proof = "T".repeat(43); + return initializeNodeLauncherContext(["bun", "cli", `${NODE_LAUNCH_PROOF_PREFIX}${proof}`], { + [NODE_LAUNCH_CONTEXT_ENV]: JSON.stringify({ version: 1, proof, anthropicEnvSlots: [], + codexCliInspectionEnv: { codexCliPath: null, path: "", pathExt: null, managerRoots: {}, + configDir: "/fixture", transportEnv }, + }), + }); +} + +describe("Codex CLI transport snapshot validation", () => { + test("capture and parser preserve alias precedence, empty values and absence", () => { + const expected = { HTTPS_PROXY: "http://upper.invalid", https_proxy: "http://lower.invalid", + NO_PROXY: "", nOdE_ExTrA_cA_cErTs: "/fixture/ca.pem" }; + const captured = captureCodexCliTransportEnv({ ...expected, HTTP_PROXY: undefined, NODE_OPTIONS: "untrusted" }); + expect(captured).toEqual(expected); + const snapshot = inspectionContext(captured)?.codexCliInspectionEnv?.transportEnv; + expect(snapshot).toEqual(expected); + expect(Object.isFrozen(snapshot)).toBe(true); + }); + + test.each([null, [], { NODE_OPTIONS: "untrusted" }, { HTTPS_PROXY: false }, + { HTTPS_PROXY: "x".repeat(32 * 1024 + 1) }].map(value => ({ value })))("invalid transport map fails closed %#", row => { + expect(inspectionContext(row.value)).toBeNull(); + }); + + test("an older snapshot supplies no ambient transport values", () => { + expect(inspectionContext()?.codexCliInspectionEnv?.transportEnv).toEqual({}); + }); +}); + +/** Copy the launcher's actual relative module graph, without dependencies or a source-tree clone. */ +function copyLauncherModule(root: string, source: string, copied = new Set()) { + if (copied.has(source)) return; + copied.add(source); + const target = join(root, relative(repoRoot(), source)); + mkdirSync(dirname(target), { recursive: true }); + copyFileSync(source, target); + for (const match of readFileSync(source, "utf8").matchAll(/(?:from\s*|import\s*\()\s*["'](\.[^"']+\.mjs)["']/g)) { + copyLauncherModule(root, resolve(dirname(source), match[1]!), copied); + } +} + +describe("published launcher to npm transport binding", () => { + test.each(["uppercase", "lowercase", "empty", "absent"])( + "pre-dotenv %s settings reach every npm evidence query without dotenv aliases", mode => { + const root = mkdtempSync(join(tmpdir(), "ocx-transport-boundary-")); + roots.push(root); + copyLauncherModule(root, repoPath("bin", "ocx.mjs")); + writeFileSync(join(root, "package.json"), '{"type":"module","dependencies":{"bun":"1.4.2"}}'); + const project = join(root, "project"); + const tools = join(root, "tools"); + mkdirSync(project); + mkdirSync(tools); + const npm = join(tools, process.platform === "win32" ? "npm.exe" : "npm"); + writeFileSync(npm, "fixture: never executed"); + chmodSync(npm, 0o700); + const ca = join(root, "early-ca.pem"); + writeFileSync(ca, ""); + const proxy = new URL("http://early-proxy.invalid:8080"); + proxy.username = "snapshot-user"; + proxy.password = "fixture-password"; + const upper = { HTTP_PROXY: proxy.href, + HTTPS_PROXY: "http://early-https.invalid", ALL_PROXY: "http://early-all.invalid", + NO_PROXY: "localhost,.early.invalid", NODE_EXTRA_CA_CERTS: ca }; + const expected = mode === "absent" ? {} : Object.fromEntries(Object.entries(upper).map(([key, value]) => + [mode === "lowercase" ? key.toLowerCase() : key, mode === "empty" ? "" : value])); + const dotenv = Object.keys(upper).flatMap(key => [key, key.toLowerCase()]) + .map(key => `${key}=dotenv-${key}`).join("\n"); + writeFileSync(join(project, ".env"), dotenv + "\n"); + const moduleUrl = (path: string) => JSON.stringify(pathToFileURL(repoPath("src", path)).href); + const probe = ` +import { initializeNodeLauncherContext } from ${moduleUrl("cli/launcher-context.ts")}; +import { handleCodexCliUpdateCommand } from ${moduleUrl("cli/codex-cli-update.ts")}; +import { createCodexCliUpdatePlan } from ${moduleUrl("codex/cli-update-plan.ts")}; +import { captureCodexCliTransportEnv } from ${moduleUrl("update/codex-cli-update-launch-policy.mjs")}; +try { +const expected = ${JSON.stringify(expected)}; +const equal = value => JSON.stringify(Object.entries(value).sort()) === JSON.stringify(Object.entries(expected).sort()); +const requireEvidence = (ok, label) => { if (!ok) throw new Error(label); }; +const context = initializeNodeLauncherContext(); +requireEvidence(equal(context?.codexCliInspectionEnv?.transportEnv ?? null), "launcher snapshot mismatch"); +requireEvidence(Object.values(captureCodexCliTransportEnv(process.env)).some(v => v.startsWith("dotenv-")), "dotenv did not load"); +let queries = 0; +const result = await handleCodexCliUpdateCommand(process.argv.slice(4), { + resolveSelectedRuntime: () => ({ runtime: null, failures: [] }), + createPlan: deps => createCodexCliUpdatePlan({ ...deps, platform: "linux", + inspect: async () => ({ managed: true, provenance: "npm-global", packageVersion: "1.0.0", + versionEvidence: { kind: "package-manifest" }, installDigest: "a".repeat(64) }), + scanProcesses: () => ({ kind: "observed", processes: [] }), + spawnProcess: (_command, args, options) => { + requireEvidence(equal(captureCodexCliTransportEnv(options.env)), "npm transport mismatch"); + requireEvidence(options.env.PATH === ${JSON.stringify(tools)}, "npm PATH mismatch"); + queries++; + return { status: 0, stdout: args.includes("version") ? "1.1.0" + : args.includes("dist.integrity") ? "sha512-" + Buffer.alloc(64, 7).toString("base64") + : "https://registry.npmjs.org/@openai/codex/-/codex-1.1.0.tgz", stderr: "" }; + }, + }), +}); +requireEvidence(result === 0 && queries === 3, "npm evidence queries incomplete"); +console.log(JSON.stringify({ verifiedQueries: queries })); +} catch { + console.error("launcher transport fixture verification failed"); + process.exitCode = 1; +} +`; + mkdirSync(join(root, "src", "cli"), { recursive: true }); + writeFileSync(join(root, "src", "cli", "index.ts"), probe); + const env: NodeJS.ProcessEnv = { ...process.env }; + for (const key of Object.keys(env)) { + if (["path", "pathext", ...Object.keys(upper).map(k => k.toLowerCase())].includes(key.toLowerCase())) delete env[key]; + } + Object.assign(env, expected, { PATH: tools, PATHEXT: ".EXE;.CMD", OPENCODEX_HOME: join(root, "home"), + OPENCODEX_BUN_PATH: process.execPath, OCX_NO_DESKTOP_HANDOFF: "1" }); + const node = spawnSync("node", ["-p", "process.execPath"], { encoding: "utf8", windowsHide: true }); + expect(node.status).toBe(0); + const result = spawnSync(node.stdout.trim(), [join(root, "bin", "ocx.mjs"), "system", "codex-cli-update", "plan", "--json"], { + cwd: project, env, encoding: "utf8", timeout: 25_000, windowsHide: true, + }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + const marker = result.stdout.lastIndexOf('{"verifiedQueries":'); + expect(marker).toBeGreaterThan(0); + expect(JSON.parse(result.stdout.slice(0, marker))).toMatchObject({ applicable: true, targetVersion: "1.1.0" }); + expect(JSON.parse(result.stdout.slice(marker))).toEqual({ verifiedQueries: 3 }); + for (const secret of ["snapshot-user", "fixture-password", "early-proxy.invalid", ca]) { + expect(result.stdout + result.stderr).not.toContain(secret); + } + }, 30_000, + ); +}); diff --git a/tests/cli/ocx-launcher-source.test.ts b/tests/cli/ocx-launcher-source.test.ts index 1dce864c01c..6a7c562856d 100644 --- a/tests/cli/ocx-launcher-source.test.ts +++ b/tests/cli/ocx-launcher-source.test.ts @@ -143,7 +143,8 @@ describe("ocx.mjs package launcher (source invariants)", () => { // Windows spells the variable `Path` in practice, so an upper-case-only delete would // leave the duplicate behind. The match must be on the lowercase form of every key. expect(source).toContain("if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name];"); - expect(source).toContain('["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS].map(name => name.toLowerCase())'); + expect(source).toContain('...CODEX_CLI_TRANSPORT_ENV_KEYS].map(name => name.toLowerCase())'); + expect(source).toContain('transportEnv: captureCodexCliTransportEnv(process.env)'); // The de-duplication is scoped to the one-shot inspection launch; every other launch // must still inherit PATH, or the long-running proxy child loses its tooling lookup. diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index 6d3b86fccc7..3039e8146f4 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -474,15 +474,29 @@ describe("registry configuration isolation", () => { }; } - test("the bound launcher snapshot supplies npm's PATH and PATHEXT, not the ambient ones", () => { + test("the bound launcher snapshot supplies executable and transport trust inputs", () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - const boundEnv = markerBoundEnv(); - const target = resolveCodexCliUpdateTarget("latest", spawn, boundEnv); - expect(target.kind).toBe("resolved"); - for (const call of calls) { - const env = call.options.env as NodeJS.ProcessEnv; - expect(env.PATH).toBe(boundEnv.PATH); - expect(env.PATHEXT).toBe(boundEnv.PATHEXT); + const boundEnv = { ...markerBoundEnv(), HTTPS_PROXY: "http://trusted-proxy", NODE_EXTRA_CA_CERTS: "/trusted/ca.pem" }; + const previous = { HTTPS_PROXY: process.env.HTTPS_PROXY, ALL_PROXY: process.env.ALL_PROXY, + NODE_EXTRA_CA_CERTS: process.env.NODE_EXTRA_CA_CERTS }; + try { + process.env.HTTPS_PROXY = "http://project-dotenv-proxy"; + process.env.ALL_PROXY = "http://project-dotenv-fallback"; + process.env.NODE_EXTRA_CA_CERTS = "/project/dotenv-ca.pem"; + expect(resolveCodexCliUpdateTarget("latest", spawn, boundEnv).kind).toBe("resolved"); + for (const call of calls) { + const env = call.options.env as NodeJS.ProcessEnv; + expect(env.PATH).toBe(boundEnv.PATH); + expect(env.PATHEXT).toBe(boundEnv.PATHEXT); + expect(env.HTTPS_PROXY).toBe(boundEnv.HTTPS_PROXY); + expect(env.ALL_PROXY).toBeUndefined(); + expect(env.NODE_EXTRA_CA_CERTS).toBe(boundEnv.NODE_EXTRA_CA_CERTS); + } + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } } }); @@ -601,8 +615,8 @@ describe("registry configuration isolation", () => { for (const [key, value] of Object.entries(sentinels)) { // Match case-insensitively: the child env is a plain object, so a // Windows-normalized casing must not make a supported channel look absent. - const entry = Object.entries(env).find(([name]) => name.toLowerCase() === key.toLowerCase()); - expect(entry?.[1]).toBe(value); + const entry = env[key] ?? Object.entries(env).find(([name]) => name.toLowerCase() === key.toLowerCase())?.[1]; + expect(entry).toBe(value); } } } finally {