diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 0d235f59556..6f3d29a138b 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -59,7 +59,10 @@ 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, + codexCliInspectionLookupSeeds, isCodexCliUpdateInspectionArgv, } from "../src/update/codex-cli-update-launch-policy.mjs"; import { @@ -1192,14 +1195,25 @@ function createNodeLaunchContext() { : configuredCodexCliPath; const preBunPath = typeof process.env.PATH === "string" ? process.env.PATH : null; const preBunPathExt = typeof process.env.PATHEXT === "string" ? process.env.PATHEXT : null; + // HOME and NODE_PATH seed the candidate Node module lookup the update + // inspection models, so they are proof-bound here for the same reason as + // PATH: the live environment could have been rewritten after selection. They + // stay in the inherited environment regardless — the child may need them for + // its own work; only the snapshot copy is optional. + const preBunHome = typeof process.env.HOME === "string" ? process.env.HOME : null; + const preBunNodePath = typeof process.env.NODE_PATH === "string" ? process.env.NODE_PATH : null; const preBunCodexCliManagerRoots = Object.fromEntries( CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS.flatMap(name => { const value = process.env[name]; return typeof value === "string" && value !== "" ? [[name, value]] : []; }), ); + // Proxy/CA settings are captured once, here, before Bun can add project dotenv + // values. They are part of the required selection snapshot, so they count + // against the context budget below before any optional lookup seed does. + const preBunTransportEnv = captureCodexCliTransportEnv(process.env); const launchProof = randomBytes(32).toString("base64url"); - const launchContext = JSON.stringify({ + const serializeContext = lookupSeeds => JSON.stringify({ version: 1, proof: launchProof, anthropicEnvSlots: preBunAnthropicSlots, @@ -1207,11 +1221,30 @@ function createNodeLaunchContext() { codexCliPath: preBunCodexCliPath, path: preBunPath, pathExt: preBunPathExt, + home: lookupSeeds.home, + nodePath: lookupSeeds.nodePath, managerRoots: preBunCodexCliManagerRoots, + transportEnv: preBunTransportEnv, + // The published Codex entrypoint selects its native backing by the arch of + // the Node that runs it; a Bun override or PATH fallback can run another + // arch under Rosetta, so this Node launcher's arch is the bound value. + arch: process.arch, configDir: configDir(), } : null, }); - // The inspection snapshot above already carries PATH, PATHEXT, and the manager-root slots as + let launchContext = serializeContext({ home: preBunHome, nodePath: preBunNodePath }); + // The lookup seeds are optional: a Windows inspection never models the global + // Node lookup, and an oversized NODE_PATH must not push the serialized + // context past the reader's limit — that would discard the whole selection + // snapshot, while dropped seeds merely leave the fallback lane unverifiable + // and fail only the content observation closed. + const lookupSeeds = codexCliInspectionLookupSeeds( + process.platform, preBunHome, preBunNodePath, launchContext.length, + ); + if (lookupSeeds.home !== preBunHome || lookupSeeds.nodePath !== preBunNodePath) { + launchContext = serializeContext(lookupSeeds); + } + // The inspection snapshot above already 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 +1257,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..11bb7b95f46 100644 --- a/src/cli/codex-cli-update.ts +++ b/src/cli/codex-cli-update.ts @@ -221,11 +221,19 @@ 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, + // The lookup seeds the inspection models against the launch-time + // capture; ambient values are never consulted. + HOME: trusted.home ?? undefined, + NODE_PATH: trusted.nodePath ?? undefined, }, configDir: trusted.configDir, + // Content observation picks the platform backing by the Node launcher's + // arch, which can differ from this Bun process's under Rosetta. + arch: trusted.arch ?? undefined, // The snapshot is proof-bound to the launcher, but the report may only attest // the environment candidate when the resolver picks the same canonical path — // managed requires both. diff --git a/src/cli/launcher-context.ts b/src/cli/launcher-context.ts index 5541ba7472d..b0220682b79 100644 --- a/src/cli/launcher-context.ts +++ b/src/cli/launcher-context.ts @@ -24,7 +24,11 @@ export type TrustedNodeLaunchContext = { codexCliPath: string | null; path: string | null; pathExt: string | null; + home: string | null; + nodePath: string | null; managerRoots: CodexCliVersionManagerRoots | null; + transportEnv: Readonly>; + arch: NodeJS.Architecture | null; configDir: string; }> | null; }; @@ -50,6 +54,27 @@ 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)); +} + +const NODE_ARCHITECTURES: ReadonlySet = new Set([ + "arm", "arm64", "ia32", "loong64", "mips", "mipsel", "ppc64", "riscv64", "s390x", "x64", +]); + +function parseLauncherArch(value: unknown): NodeJS.Architecture | null | undefined { + // Older snapshots predate the field; absent stays null (Bun's own arch is the + // pre-existing fallback). Any other non-architecture value is untrusted. + if (value === undefined || value === null) return null; + return typeof value === "string" && NODE_ARCHITECTURES.has(value) ? value as NodeJS.Architecture : undefined; +} + /** Consume the internal proof before normal CLI argument parsing. */ export function initializeNodeLauncherContext( argv: string[] = process.argv, @@ -70,7 +95,7 @@ export function initializeNodeLauncherContext( delete env.OCX_PRE_BUN_ANTHROPIC_ENV; trustedContext = null; - if (proofArgs.length !== 1 || !raw || raw.length > 64 * 1024) return null; + if (proofArgs.length !== 1 || !raw || raw.length > NODE_LAUNCH_CONTEXT_MAX_CHARS) return null; const proof = proofArgs[0]!; if (!isLaunchProof(proof)) return null; @@ -95,6 +120,12 @@ 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 arch = inspection && typeof inspection === "object" && !Array.isArray(inspection) + ? parseLauncherArch((inspection as Record).arch) + : undefined; const codexCliInspectionEnv = inspection === null || inspection === undefined ? null : inspection && typeof inspection === "object" && !Array.isArray(inspection) @@ -102,15 +133,27 @@ export function initializeNodeLauncherContext( const value = (inspection as Record)[key]; return value === null || typeof value === "string"; }) + // Older launchers predate the lookup-seed slots; absent stays null so + // the fallback lane simply remains unverifiable rather than untrusted. + && ["home", "nodePath"].every(key => { + const value = (inspection as Record)[key]; + return value === undefined || value === null || typeof value === "string"; + }) && typeof (inspection as Record).configDir === "string" && (inspection as Record).configDir.length > 0 && (inspection as Record).configDir.length <= 32 * 1024 && managerRoots !== undefined + && transportEnv !== undefined + && arch !== undefined ? Object.freeze({ codexCliPath: (inspection as Record).codexCliPath ?? null, path: (inspection as Record).path ?? null, pathExt: (inspection as Record).pathExt ?? null, + home: (inspection as Record).home ?? null, + nodePath: (inspection as Record).nodePath ?? null, managerRoots, + transportEnv, + arch, configDir: (inspection as Record).configDir, }) : undefined; @@ -125,4 +168,8 @@ 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, + NODE_LAUNCH_CONTEXT_MAX_CHARS, +} from "../update/codex-cli-update-launch-policy.mjs"; diff --git a/src/codex/cli-install-provenance.ts b/src/codex/cli-install-provenance.ts index 1fb16156453..81822401ec3 100644 --- a/src/codex/cli-install-provenance.ts +++ b/src/codex/cli-install-provenance.ts @@ -9,6 +9,7 @@ import { readSync, realpathSync, statSync, + type Stats, } from "node:fs"; import { posix, win32 } from "node:path"; import { getConfigDir } from "../config"; @@ -33,6 +34,18 @@ const CODEX_PACKAGE = "@openai/codex"; const MAX_MANIFEST_BYTES = 256 * 1024; const MAX_RUNTIME_STATE_BYTES = 256 * 1024; const MAX_MANIFEST_ANCESTORS = 12; +/** The published package entrypoint is a few KB; 1 MiB leaves wide headroom. */ +const MAX_PACKAGE_ENTRYPOINT_BYTES = 1024 * 1024; +/** + * The native companion binary is streamed in HELD_READ_CHUNK_BYTES chunks and + * never buffered whole, so this bound limits read time, not memory. Published + * backings outgrew the former 256 MiB: `@openai/codex@0.163.0-linux-x64` ships a + * 296,865,480-byte binary, in platform packages of 341-430 MB unpacked. 1 GiB + * leaves more than 3x headroom over that binary while staying finite; larger + * files are refused by fstat before any read. + */ +export const MAX_NATIVE_BACKING_BYTES = 1024 * 1024 * 1024; +const HELD_READ_CHUNK_BYTES = 1024 * 1024; export type CodexCliInstallKind = | "npm-global" @@ -64,6 +77,7 @@ export type CodexCliInstallEvidence = | "version_manager_path" | "package_manifest" | "package_manifest_digest" + | "install_content_digest" | "shim_backing" | "global_npm_layout"; @@ -92,6 +106,20 @@ export interface CodexCliInstallReport { * identity binds this value so two different install roots cannot share a plan. */ readonly installDigest: string | null; + /** + * sha256 digest over the bounded content observation of the versioned file + * set the supported npm-global layout defines: the package manifest, its + * declared bin/codex.js entrypoint, and the platform backing package manifest + * plus vendored binary at the locations the layout's modeled Node lookup + * produces. This binds the bytes that were actually read under a + * held-identity window; it is not an execution guarantee. It does not prove + * which bytes a launcher run would execute, that the files are authentic + * upstream artifacts, or that the rest of the install tree is unchanged — a + * later apply phase must re-observe under its own held handles rather than + * treating this plan-time digest as durable proof. Null when the caller did + * not request the observation or when the required set could not be bound. + */ + readonly installContentDigest: string | null; readonly packageVersion: string | null; readonly shim: Readonly<{ status: "not-tracked" | "matched" | "unknown"; @@ -110,6 +138,21 @@ export interface CodexCliInstallProvenanceDeps { readonly readFile?: (path: string) => Buffer; readonly boundedFileReadMode?: "native-hardened" | "injected-test"; readonly realpath?: (path: string) => string; + /** + * The architecture the launcher would report for its own interpreter. The + * published entrypoint picks its native backing by (platform, arch); the + * value is recorded inside the content digest so a different launcher + * architecture can never silently share a plan. + */ + readonly arch?: NodeJS.Architecture; + /** + * Opt-in switch for the bounded install-content observation. Reading the + * required file set can stream a vendored binary of up to + * MAX_NATIVE_BACKING_BYTES, so a caller that only needs the ownership + * classification leaves this unset and receives a null installContentDigest; + * the update-plan path sets it because its planId binds the digest. + */ + readonly observeInstallContent?: boolean; readonly inspectShim?: ( command: string, platform: NodeJS.Platform, @@ -183,6 +226,7 @@ function unknownReport( reason, location: extra.location ?? null, installDigest: null, + installContentDigest: null, packageVersion: null, shim: Object.freeze({ status: "not-tracked", backingKind: null }), evidence: Object.freeze([]), @@ -636,6 +680,359 @@ function shimReport(shim: CodexShimBackingForCommand): CodexCliInstallReport["sh return Object.freeze({ status: "not-tracked" as const, backingKind: null }); } +/** + * The published @openai/codex bin/codex.js selection table, mirrored as layout + * knowledge: the supported npm-global layout places the platform backing + * package where Node's module lookup from the package's bin directory finds + * "/package.json", then "/vendor//bin/codex" + * (codex.exe on Windows), with an in-package "vendor/" fallback when + * that resolve would throw. This table locates files for observation; it does + * not prove which file a launcher run would select. A launcher following a + * different rule simply produces different observed entrypoint bytes. + */ +const CODEX_PLATFORM_BACKING: Readonly>> = { + "linux:x64": { triple: "x86_64-unknown-linux-musl", package: "@openai/codex-linux-x64" }, + "linux:arm64": { triple: "aarch64-unknown-linux-musl", package: "@openai/codex-linux-arm64" }, + "android:x64": { triple: "x86_64-unknown-linux-musl", package: "@openai/codex-linux-x64" }, + "android:arm64": { triple: "aarch64-unknown-linux-musl", package: "@openai/codex-linux-arm64" }, + "darwin:x64": { triple: "x86_64-apple-darwin", package: "@openai/codex-darwin-x64" }, + "darwin:arm64": { triple: "aarch64-apple-darwin", package: "@openai/codex-darwin-arm64" }, + "win32:x64": { triple: "x86_64-pc-windows-msvc", package: "@openai/codex-win32-x64" }, + "win32:arm64": { triple: "aarch64-pc-windows-msvc", package: "@openai/codex-win32-arm64" }, +}; + +/** Node's Module._nodeModulePaths: every ancestor's node_modules child, closest first. */ +function nodeModuleDirs(fromDir: string, platform: NodeJS.Platform): string[] { + const tools = pathTools(platform); + const dirs: string[] = []; + let cursor = fromDir; + for (;;) { + // A directory literally named "node_modules" contributes no nested + // node_modules entry of its own in Node's lookup. + if (tools.basename(cursor) !== "node_modules") dirs.push(tools.join(cursor, "node_modules")); + const parent = tools.dirname(cursor); + if (parent === cursor) break; + cursor = parent; + } + return dirs; +} + +/** + * Node's global lookup list for the launcher process: NODE_PATH entries first, + * then $HOME/.node_modules and $HOME/.node_libraries, then the lib/node directory + * beside the install prefix of the node binary the captured PATH resolves. + * 'complete' is false when any input cannot be modeled from the bound + * environment — in that case 'require.resolve threw' cannot be proven and the + * in-package vendor fallback lane stays unverifiable. + */ +function globalModuleDirs( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, + deps: CodexCliInstallProvenanceDeps, +): { dirs: string[]; complete: boolean } { + const tools = pathTools(platform); + const dirs: string[] = []; + let complete = true; + const nodePath = platform === "win32" ? caseInsensitiveEnv(env, "NODE_PATH") : env.NODE_PATH; + if (nodePath) { + for (const entry of nodePath.split(tools.delimiter)) { + if (!entry) continue; + // Node resolves a relative NODE_PATH entry against the launcher process's + // cwd, which this read-only inspection cannot know. + if (isSafeLocalInspectionPath(entry, deps)) dirs.push(tools.resolve(entry)); + else complete = false; + } + } + const home = platform === "win32" + ? caseInsensitiveEnv(env, "USERPROFILE") ?? caseInsensitiveEnv(env, "HOME") + : env.HOME; + if (home && isSafeLocalInspectionPath(home, deps)) { + dirs.push(tools.join(home, ".node_modules"), tools.join(home, ".node_libraries")); + } else { + complete = false; + } + const nodeCommand = resolveCandidateCommandPath("node", deps); + const canonicalNode = nodeCommand ? canonicalize(nodeCommand, deps) : null; + if (canonicalNode) { + // POSIX node lives at /bin/node, so the lookup prefix is two levels + // up; a Windows prefix sits beside the executable. The win32 arm is + // unreachable today — inspection returns before any candidate I/O there. + const prefix = platform === "win32" + ? tools.dirname(canonicalNode) + : tools.dirname(tools.dirname(canonicalNode)); + dirs.push(tools.join(prefix, "lib", "node")); + } else { + complete = false; + } + return { dirs, complete }; +} + +interface InstallContentFile { + readonly role: "manifest" | "entrypoint" | "backing-manifest" | "backing-binary"; + readonly path: string; + readonly maxBytes: number; +} + +interface ObservedContentFile { + readonly digest: string; + /** Retained only for small role files; the streamed binary keeps none. */ + readonly bytes: Buffer | null; +} + +function fileContentDomain(role: InstallContentFile["role"]): string { + return role === "manifest" ? "codex-cli-package-manifest-v1" : "codex-cli-observed-file-v1"; +} + +/** + * Hash every requested file under one observation window. Native POSIX mode + * opens all files first (O_NOFOLLOW | O_NONBLOCK), streams each digest, then + * re-verifies every descriptor's identity and each canonical path's linkage + * before returning. Injected-test mode performs the equivalent stat -> read -> + * stat sequence per file plus a final identity pass over the whole set. The + * optional finalize hook runs its own checks while the held set is still open, + * so linkage and selector re-verification completes inside the window rather + * than after the descriptors close. Any read, size, identity or finalize + * failure inside the window fails the set closed. + */ +function observeHeldFileSet( + requests: readonly InstallContentFile[], + deps: CodexCliInstallProvenanceDeps, + finalize?: () => boolean, +): Map | null { + const keepBytes = (role: InstallContentFile["role"]): boolean => role !== "backing-binary"; + if (deps.boundedFileReadMode === "injected-test") { + const stat = deps.stat; + const read = deps.readFile; + if (!stat || !read) return null; + const results = new Map(); + const identities: { before: Stats; req: InstallContentFile }[] = []; + try { + for (const req of requests) { + const before = stat(req.path); + if (!before.isFile() || before.size > req.maxBytes) return null; + const bytes = read(req.path); + const after = stat(req.path); + if (bytes.byteLength !== before.size + || before.dev !== after.dev || before.ino !== after.ino + || before.size !== after.size || before.mtimeMs !== after.mtimeMs) return null; + identities.push({ before, req }); + results.set(req.role, { + digest: sha256(fileContentDomain(req.role), bytes), + bytes: keepBytes(req.role) ? bytes : null, + }); + } + // A replacement that slipped in after a file's own read still failed. + for (const { before, req } of identities) { + const now = stat(req.path); + if (before.dev !== now.dev || before.ino !== now.ino + || before.size !== now.size || before.mtimeMs !== now.mtimeMs) return null; + } + if (finalize && !finalize()) return null; + return results; + } catch { + return null; + } + } + const held: { fd: number; req: InstallContentFile; before: Stats }[] = []; + const openedFds: number[] = []; + try { + const flags = process.platform === "win32" + ? fsConstants.O_RDONLY + : fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK; + for (const req of requests) { + if (!isSafeLocalInspectionPath(req.path, deps)) return null; + const fd = openSync(req.path, flags); + // The descriptor joins the close set before any check that can bail, so + // an fstat failure or a refused type/size cannot leak it. + openedFds.push(fd); + const before = fstatSync(fd); + // O_NONBLOCK keeps a FIFO open nonblocking, so the type check must come + // first: anything that is not a regular file is refused before a read. + if (!before.isFile() || before.size > req.maxBytes) return null; + held.push({ fd, req, before }); + } + const results = new Map(); + for (const { fd, req, before } of held) { + const hasher = createHash("sha256") + .update(fileContentDomain(req.role), "utf8") + .update("\0", "utf8"); + const bytes = keepBytes(req.role) ? Buffer.allocUnsafe(before.size) : null; + const chunk = Buffer.allocUnsafe(Math.max(1, Math.min(HELD_READ_CHUNK_BYTES, before.size))); + let offset = 0; + while (offset < before.size) { + const count = readSync(fd, chunk, 0, Math.min(chunk.length, before.size - offset), offset); + if (count <= 0) return null; + hasher.update(chunk.subarray(0, count)); + if (bytes) chunk.copy(bytes, offset, 0, count); + offset += count; + } + // A file that grew inside the window is a different file than was hashed. + if (readSync(fd, chunk, 0, 1, offset) !== 0) return null; + results.set(req.role, { digest: hasher.digest("hex"), bytes }); + } + for (const { fd, req, before } of held) { + const after = fstatSync(fd); + const lexical = lstatSync(req.path); + const followed = statSync(req.path); + if (before.dev !== after.dev || before.ino !== after.ino + || before.size !== after.size || before.mtimeMs !== after.mtimeMs + || before.ctimeMs !== after.ctimeMs + || lexical.isSymbolicLink() || !lexical.isFile() + || lexical.dev !== before.dev || lexical.ino !== before.ino + || followed.dev !== before.dev || followed.ino !== before.ino) return null; + } + if (finalize && !finalize()) return null; + return results; + } catch { + return null; + } finally { + for (const fd of openedFds) { + try { closeSync(fd); } catch { /* the observation already failed */ } + } + } +} + +/** + * Bind the versioned file set the supported npm-global layout defines: the + * manifest, its declared bin/codex.js entrypoint, and the platform backing + * (manifest plus vendored binary) at the locations the layout's modeled Node + * lookup produces. The bound set is deliberately small and versioned; this is + * not an execution proof, an artifact-authenticity claim, or a + * whole-tree-integrity claim. + */ +function observeInstallContent( + manifest: PackageManifestEvidence, + npmPrefix: string, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, + deps: CodexCliInstallProvenanceDeps, + selection: { readonly lexical: string; readonly canonical: string }, +): string | null { + const arch = deps.arch ?? process.arch; + const backing = CODEX_PLATFORM_BACKING[platform + ":" + arch]; + if (!backing) return null; + const tools = pathTools(platform); + const normalizedPrefix = normalizePath(npmPrefix, platform); + // A filesystem-root prefix cannot bound anything; treat it as unsupported. + if (!normalizedPrefix || normalizedPrefix === "/") return null; + const withinPrefix = (path: string): boolean => { + const normalized = normalizePath(path, platform); + return normalized === normalizedPrefix || normalized.startsWith(normalizedPrefix + "/"); + }; + // Every lexical path that produced a bound canonical target — the selected + // launcher included — is re-resolved at the window's final observed boundary. + // Held descriptors do not prevent link mutation; this check observes whether + // the selection still reaches the bound files at that instant. + const links: { readonly lexical: string; readonly canonical: string }[] = [ + { lexical: selection.lexical, canonical: selection.canonical }, + ]; + const canonicalBound = (path: string): string | null => { + const canonical = canonicalize(path, deps); + if (!canonical || !withinPrefix(canonical)) return null; + links.push({ lexical: path, canonical }); + return canonical; + }; + const entrypoint = canonicalBound(tools.join(manifest.root, ...manifest.binPath.split("/"))); + if (!entrypoint) return null; + + const globals = globalModuleDirs(env, platform, deps); + const lstat = deps.lstat ?? lstatSync; + // Probe one modeled lookup lane in order; the first hit wins. + const probeDirs = ( + dirs: readonly string[], + ): { kind: "none" } | { kind: "hit"; path: string; dir: string } | { kind: "refuse" } => { + for (const dir of dirs) { + const probe = tools.join(dir, ...backing.package.split("/"), "package.json"); + let entry; + try { + entry = lstat(probe); + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code; + if (code === "ENOENT" || code === "ENOTDIR") continue; + return { kind: "refuse" }; + } + if (!entry.isFile() && !entry.isSymbolicLink()) continue; + // First hit wins, matching the lookup order the layout models. + const canonical = canonicalize(probe, deps); + if (!canonical || !withinPrefix(canonical)) return { kind: "refuse" }; + links.push({ lexical: probe, canonical }); + return { kind: "hit", path: canonical, dir: tools.dirname(canonical) }; + } + return { kind: "none" }; + }; + const probeBacking = (): ReturnType => { + const local = probeDirs(nodeModuleDirs(tools.dirname(entrypoint), platform)); + if (local.kind !== "none") return local; + // Missing lookup seeds leave unknown earlier global lanes. Even an + // observed in-prefix global hit cannot prove which package wins there. + if (!globals.complete) return { kind: "refuse" }; + return probeDirs(globals.dirs); + }; + const selectedBacking = probeBacking(); + if (selectedBacking.kind === "refuse") return null; + const backingManifestPath = selectedBacking.kind === "hit" ? selectedBacking.path : null; + const backingPackageDir = selectedBacking.kind === "hit" ? selectedBacking.dir : null; + + const binName = platform === "win32" ? "codex.exe" : "codex"; + const backingBinary = canonicalBound(backingPackageDir + ? tools.join(backingPackageDir, "vendor", backing.triple, "bin", binName) + : tools.join(manifest.root, "vendor", backing.triple, "bin", binName)); + // The launcher cannot run without the vendored binary; a missing one makes + // the required set incomplete, not merely shorter. + if (!backingBinary) return null; + + const requests: InstallContentFile[] = [ + { role: "manifest", path: manifest.path, maxBytes: MAX_MANIFEST_BYTES }, + { role: "entrypoint", path: entrypoint, maxBytes: MAX_PACKAGE_ENTRYPOINT_BYTES }, + ...(backingManifestPath + ? [{ role: "backing-manifest" as const, path: backingManifestPath, maxBytes: MAX_MANIFEST_BYTES }] + : []), + { role: "backing-binary", path: backingBinary, maxBytes: MAX_NATIVE_BACKING_BYTES }, + ]; + const observed = observeHeldFileSet(requests, deps, () => { + // Re-run the selector after the final file identity pass, while native + // descriptors remain open. An earlier lane gaining a package or the + // selected one disappearing makes the bound set ambiguous. + const selectedAgain = probeBacking(); + if (selectedAgain.kind !== selectedBacking.kind + || (selectedAgain.kind === "hit" && selectedBacking.kind === "hit" + && selectedAgain.path !== selectedBacking.path)) return false; + // A canonical-path lstat cannot see a retargeted lexical selection link. + for (const link of links) { + const resolved = canonicalize(link.lexical, deps); + if (!resolved || !samePath(resolved, link.canonical, platform)) return false; + } + return true; + }); + if (!observed) return null; + // The manifest was already hashed once while provenance was being decided. + // If the bytes read now differ, ownership answered a different file than the + // content claim would describe. + if (observed.get("manifest")?.digest !== manifest.digest) return null; + const backingManifest = observed.get("backing-manifest"); + if (backingManifest?.bytes) { + // A backing manifest carrying an exports map can make the real + // require.resolve throw instead of returning this file; without modeling + // export conditions the resolution lane would be ambiguous, so it is refused. + try { + const value: unknown = JSON.parse(backingManifest.bytes.toString("utf8")); + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + if ("exports" in value) return null; + } catch { + return null; + } + } + const record = { + layout: "npm-global" as const, + platform, + arch, + triple: backing.triple, + backingKind: backingManifestPath ? "optional-package" as const : "package-vendor" as const, + files: requests.map(req => ({ role: req.role, digest: observed.get(req.role)?.digest })), + }; + return sha256("codex-cli-install-content-v1", JSON.stringify(record)); +} + /** Inspect ownership of one configured Codex CLI candidate, without mutation. */ export async function inspectCodexCliInstall( deps: CodexCliInstallProvenanceDeps = {}, @@ -687,6 +1084,7 @@ export async function inspectCodexCliInstall( reason: appBundle ? "app_bundle" : "version_manager_owned", location: publicExecutableLocation(lexicalCandidatePath, platform), installDigest: null, + installContentDigest: null, packageVersion: null, shim: Object.freeze({ status: "unknown", backingKind: null }), evidence: Object.freeze([appBundle ? "app_bundle_path" : "version_manager_path"]), @@ -770,6 +1168,7 @@ export async function inspectCodexCliInstall( reason: "app_bundle", location, installDigest: null, + installContentDigest: null, packageVersion: null, shim: shimReport(shim), evidence: Object.freeze(["canonical_path", "app_bundle_path"]), @@ -792,6 +1191,7 @@ export async function inspectCodexCliInstall( reason: "version_manager_owned", location, installDigest: null, + installContentDigest: null, packageVersion: null, shim: shimReport(shim), evidence: Object.freeze(["canonical_path", "version_manager_path"]), @@ -821,6 +1221,21 @@ export async function inspectCodexCliInstall( deps, ); const manifestOwned = global && linked; + // The npm-global prefix bounds every content path this observation may + // follow: it is the manifest root minus the fixed package suffix. An + // install that cannot prove the layout keeps a null digest. + const npmPrefix = manifestOwned + ? manifest.root.slice(0, manifest.root.length - "/lib/node_modules/@openai/codex".length) + : ""; + const installContentDigest = manifestOwned && deps.observeInstallContent === true + // The selection's own lexical->canonical link is part of the claim: + // verified again at the end of the observation so a retargeted launcher + // cannot leave the digest bound to bytes it no longer selects. + ? observeInstallContent(manifest, npmPrefix, platform, env, deps, { + lexical: ownershipPath, + canonical: canonicalOwnershipPath, + }) + : null; const versionMatches = candidate.version === null || candidate.version === manifest.version; const reason: CodexCliInstallReason = !global || !linked ? "npm_global_unverified" @@ -848,11 +1263,13 @@ export async function inspectCodexCliInstall( // package roots that resolve to the same "/codex" string must still // produce different plans. installDigest: sha256("codex-cli-install-root-v1", normalizePath(manifest.root, platform)), + installContentDigest, packageVersion: manifest.version, shim: shimReport(shim), evidence: Object.freeze([ "canonical_path", "package_manifest", "package_manifest_digest", ...(manifestOwned ? ["global_npm_layout" as const] : []), + ...(installContentDigest ? ["install_content_digest" as const] : []), ]), }); } @@ -882,6 +1299,7 @@ export async function inspectCodexCliInstall( reason: "unverified_standalone", location, installDigest: null, + installContentDigest: null, packageVersion: null, shim: shimReport(shim), evidence: Object.freeze(["canonical_path"]), diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index f87c2f5de6b..f5a6d5d770b 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { compareStrictSemver, parseStrictSemver } from "../lib/strict-semver"; import { npmInvocation } from "../update/npm-invocation.mjs"; +import { CODEX_CLI_TRANSPORT_ENV_KEYS } from "../update/codex-cli-update-launch-policy.mjs"; import { inspectCodexCliInstall, type CodexCliInstallKind, @@ -61,6 +62,7 @@ export type CodexCliUpdateRefusal = | "windows_inspection_deferred" | "not_managed" | "installed_version_unverified" + | "install_content_unverified" | "target_unresolved" | "already_current" | "target_not_newer" @@ -100,6 +102,13 @@ export interface CodexCliUpdatePlan { readonly targetVersion: string | null; readonly targetIntegrity: string | null; readonly session: CodexCliUpdateSession; + /** + * The bounded content observation the installation report produced: digest of + * the manifest, declared entrypoint and resolved platform backing the plan's + * identity is pinned to. Not an execution guarantee — an apply phase must + * re-observe before it can rely on the same bytes. + */ + readonly installContentDigest: string | null; /** Indicative install argv for the operator to read; the apply phase this plan precedes verifies a packed tarball against the bound sha512 rather than trusting a moving spec. */ readonly command: readonly string[] | null; } @@ -157,6 +166,7 @@ export function codexCliUpdatePlanId(bound: { readonly provenance: CodexCliInstallKind; readonly installedVersion: string; readonly installDigest: string | null; + readonly installContentDigest: string; readonly channel: CodexCliUpdateChannel; readonly targetVersion: string; readonly targetIntegrity: string; @@ -170,6 +180,7 @@ export function codexCliUpdatePlanId(bound: { ["provenance", bound.provenance], ["installedVersion", bound.installedVersion], ["installDigest", bound.installDigest ?? ""], + ["installContentDigest", bound.installContentDigest], ["channel", bound.channel], ["targetVersion", bound.targetVersion], ["targetIntegrity", bound.targetIntegrity], @@ -220,8 +231,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 +266,24 @@ 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; + // Derived from the launcher's allowlist so capture, parsing and the npm child cannot drift. + const boundKeys = new Set(["path", "pathext", ...CODEX_CLI_TRANSPORT_ENV_KEYS]); 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 +315,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 +368,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 @@ -453,6 +465,7 @@ function refusedPlan( targetVersion: target?.kind === "resolved" ? target.version : null, targetIntegrity: target?.kind === "resolved" ? target.integrity : null, session, + installContentDigest: report.installContentDigest, command: null, }); } @@ -473,7 +486,9 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} const channel: CodexCliUpdateChannel = deps.channel ?? "latest"; const platform = deps.platform ?? process.platform; const inspect = deps.inspect ?? inspectCodexCliInstall; - const report = await inspect(deps.inspectionDeps ?? {}); + // The planId binds the observed file set, so the plan is the one caller that + // pays the bounded content read; a bare ownership check leaves it unobserved. + const report = await inspect({ ...(deps.inspectionDeps ?? {}), observeInstallContent: true }); // Phase 1 performs no candidate filesystem I/O at all on Windows, so there is no // ownership evidence to build a plan on. This stays dormant until the handle-bound @@ -494,9 +509,15 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} return refusedPlan("installed_version_unverified", report, channel, null, NOT_EVALUATED); } + // A managed install whose observed content could not be bound produces no + // usable plan id — spending a registry query on it can only widen the claim. + if (!report.installContentDigest) { + return refusedPlan("install_content_unverified", report, channel, null, NOT_EVALUATED); + } + // 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 @@ -543,6 +564,7 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} provenance: report.provenance, installedVersion, installDigest: report.installDigest, + installContentDigest: report.installContentDigest, channel, targetVersion: target.version, targetIntegrity: target.integrity, @@ -555,6 +577,7 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} targetVersion: target.version, targetIntegrity: target.integrity, session: Object.freeze({ state: "none" as const, matches: 0 }), + installContentDigest: report.installContentDigest, command: codexCliUpdateCommand(target.version), }); } diff --git a/src/update/codex-cli-update-launch-policy.d.mts b/src/update/codex-cli-update-launch-policy.d.mts index 5e4221031d0..9373bba3a12 100644 --- a/src/update/codex-cli-update-launch-policy.d.mts +++ b/src/update/codex-cli-update-launch-policy.d.mts @@ -16,3 +16,12 @@ export const CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS: readonly [ "VOLTA_HOME", ]; export function isCodexCliUpdateInspectionArgv(argv: readonly string[]): boolean; +export const NODE_LAUNCH_CONTEXT_MAX_CHARS: number; +export function codexCliInspectionLookupSeeds( + platform: string, + home: string | null, + nodePath: string | null, + serializedContextChars: number, +): { home: string | null; nodePath: string | null }; +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..8a1759d66d4 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 @@ -28,3 +38,23 @@ export function isCodexCliUpdateInspectionArgv(argv) { const args = argv.slice(2).filter(value => !value.startsWith("--ocx-internal-launch-proof=")); return args[0] === "system" && args[1] === "codex-cli-update"; } + +/** Serialized OCX_NODE_LAUNCH_CONTEXT ceiling; src/cli/launcher-context.ts refuses above it. */ +export const NODE_LAUNCH_CONTEXT_MAX_CHARS = 64 * 1024; + +/** + * The HOME/NODE_PATH seeds the update inspection's modeled Node lookup needs, + * bounded against the launch-context budget. They are POSIX-only: a Windows + * inspection never models the global lookup, so the seeds are not sent at all + * (and their env copies stay inherited for the child's own use). If carrying + * them would push the serialized context past the reader's limit they are + * dropped instead of taking the whole snapshot down with them — absent seeds + * leave only the global lane unverifiable, which fails the content + * observation closed while the selection context survives. + */ +export function codexCliInspectionLookupSeeds(platform, home, nodePath, serializedContextChars) { + if (platform === "win32" || serializedContextChars > NODE_LAUNCH_CONTEXT_MAX_CHARS) { + return { home: null, nodePath: null }; + } + return { home: home ?? null, nodePath: nodePath ?? null }; +} diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index bae97d671a3..5fa77a9be65 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -8,7 +8,40 @@ 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. +Plan identity also binds a bounded content observation (installContentDigest): a versioned file set +fixed by the supported npm-global layout — the package manifest, its declared bin/codex.js +entrypoint, and the platform backing manifest plus vendored binary at the layout's modeled +locations. The vendored binary is streamed in chunks under a finite 1 GiB bound sized for the +published backings (about 297 MB for linux-x64 at 0.163.0). The actually selected launcher stays bound through the existing canonical-path and +manifest-linkage provenance; the observation covers those files' bytes only and claims nothing about +which file a launcher run would execute, artifact authenticity, or the rest of the install tree. The +modeled locations follow Node's module lookup evaluated against the bound inspection environment +— the captured PATH/NODE_PATH/HOME and the node binary that PATH resolves — never the +inspecting process's own runtime globals. The backing is selected by the Node launcher's +`process.arch`, carried in the same snapshot, because a Bun override or PATH fallback may run a +different architecture under Rosetta; a snapshot without it falls back to the inspecting process's +arch, and an unknown value invalidates the context. The optional HOME/NODE_PATH seeds are withheld on +Windows, where the modeled lookup never runs, and dropped when they would push the serialized +launch context past its cap, so the selection snapshot survives: a local optional-package lane can +still bind, while the global and vendor-fallback lanes stay unmodelable and fail closed. Missing +required files, an out-of-prefix resolution, an unreadable or mid-observation-changed file, a +retargeted selection link, or an unmodelable global lookup all fail closed. The selector and lexical +links are checked after the final file-identity pass while native descriptors remain open; this +observes linkage at that boundary and does not prevent later symlink mutation. A managed install with +no bound content is refused install_content_unverified before any registry query. The digest is +observation only — it carries no execution guarantee — so a future apply phase must re-observe +rather than rely on it. The observation is opt-in: the plan path requests it because the planId +binds it, while a bare check leaves the digest null rather than paying the bounded read. 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, the `LD_*`/`DYLD_*` loader families and the OpenSSL startup configuration and diff --git a/structure/runtime.md b/structure/runtime.md index 5103c022457..adcbdb833a6 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -312,6 +312,7 @@ Codex CLI update inspection is split from mutation. `system codex-cli-update che package-registry request and reads bounded provenance evidence for the configured launcher candidate, npm ownership layout, package metadata, and shim binding. The proof-bound launcher snapshot does not attest successful Codex execution; POSIX selection is attested when the runtime resolver selects the same canonical environment candidate without executing it. A linked npm-global manifest can then establish managed ownership; persisted candidates stay unattested. +A manifest-owned POSIX npm-global install can also report an opt-in bounded content digest over the manifest, declared entrypoint, and the platform backing files at the layout's modeled locations; it is an observation claim, not execution proof. On Windows this first slice performs no candidate/configuration filesystem I/O: it preserves only proof-captured absolute environment candidates for lexical app-bundle/version-manager reporting and otherwise fails closed. That fail-closed result records which observation was missing: a run with no proof-captured environment candidate reports diff --git a/tests/cli/cli-codex-cli-update-boundary.test.ts b/tests/cli/cli-codex-cli-update-boundary.test.ts index ad6e47c786f..c0c82b7bb5f 100644 --- a/tests/cli/cli-codex-cli-update-boundary.test.ts +++ b/tests/cli/cli-codex-cli-update-boundary.test.ts @@ -1,11 +1,19 @@ 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, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, 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 { + captureCodexCliTransportEnv, + codexCliInspectionLookupSeeds, + NODE_LAUNCH_CONTEXT_MAX_CHARS, +} from "../../src/update/codex-cli-update-launch-policy.mjs"; import { inspectCodexCliInstall } from "../../src/codex/cli-install-provenance"; const roots: string[] = []; @@ -33,6 +41,60 @@ describe("Codex CLI selection inspection boundary", () => { expect(resolutions).toBe(0); }); + test("a near-cap context with dropped lookup seeds still attests selection", () => { + const proof = "W".repeat(43); + // Pad mandatory selection fields exactly to the cap. The optional lookup + // seeds alone overflow it; dropping them must preserve this whole snapshot. + let path = "/"; + const serialize = (seeds: { home: string | null; nodePath: string | null }) => JSON.stringify({ + version: 1, + proof, + anthropicEnvSlots: [], + codexCliInspectionEnv: { + codexCliPath: "/x/bin/codex", + path, + pathExt: null, + ...seeds, + managerRoots: {}, + configDir: "/cfg", + }, + }); + const emptySeeds = { home: null, nodePath: null }; + path += "d".repeat(NODE_LAUNCH_CONTEXT_MAX_CHARS - serialize(emptySeeds).length); + const withSeeds = serialize({ home: "/home/u", nodePath: "/a:/b" }); + expect(withSeeds.length).toBeGreaterThan(NODE_LAUNCH_CONTEXT_MAX_CHARS); + const raw = serialize(codexCliInspectionLookupSeeds("linux", "/home/u", "/a:/b", withSeeds.length)); + expect(raw.length).toBe(NODE_LAUNCH_CONTEXT_MAX_CHARS); + const trusted = initializeNodeLauncherContext( + ["bun", "cli", `${NODE_LAUNCH_PROOF_PREFIX}${proof}`], + { [NODE_LAUNCH_CONTEXT_ENV]: raw }, + ); + expect(trusted?.codexCliInspectionEnv?.path).toBe(path); + expect(trusted?.codexCliInspectionEnv?.home).toBeNull(); + expect(trusted?.codexCliInspectionEnv?.nodePath).toBeNull(); + }); + + test.skipIf(process.platform !== "win32")("published check preserves a large mixed-case HOME/NODE_PATH environment", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-context-seeds-")); + roots.push(root); + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (["home", "node_path"].includes(key.toLowerCase())) delete env[key]; + } + const result = spawnSync("node", [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", "check", "--json"], { + cwd: repoRoot(), encoding: "utf8", timeout: 15_000, windowsHide: true, + env: { ...env, Home: "C:\\" + "h".repeat(8 * 1024), Node_Path: "C:\\" + "n".repeat(8 * 1024), + OPENCODEX_HOME: root, OPENCODEX_BUN_PATH: process.execPath, + CODEX_CLI_PATH: "C:\\fixture\\codex.exe", OCX_NO_DESKTOP_HANDOFF: "1" }, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.candidateSource).toBe("environment"); + expect(report.reason).toBe("windows_inspection_deferred"); + expect(report.installContentDigest).toBeNull(); + }); + (process.platform === "win32" ? test.skip : test).each(["codex-runtime.json", "codex-shim.json"])( "published check cannot block on a FIFO %s before bounded provenance inspection", state => { const root = mkdtempSync(join(tmpdir(), "ocx-selection-boundary-")); @@ -52,4 +114,396 @@ describe("Codex CLI selection inspection boundary", () => { expect(JSON.parse(result.stdout).managed).toBe(false); }, ); + + // The published Node launcher snapshot -> provenance inspection -> dry-run plan + // chain, end to end against a real npm install -g of a synthetic fixture + // package into an owned temporary prefix. Only the registry answer is stubbed + // (a fake npm first on the bound PATH); the install itself is real npm's work. + test.skipIf(process.platform === "win32")("plan identity binds the bytes a real npm-global install placed", () => { + const node = Bun.which("node"); + const npm = Bun.which("npm"); + // The suite runs under Bun, so its own executable is always a usable fallback. + const bun = Bun.which("bun") ?? process.execPath; + expect(node).not.toBeNull(); + expect(npm).not.toBeNull(); + expect(typeof bun).toBe("string"); + + const BACKING: Record = { + "linux:x64": { triple: "x86_64-unknown-linux-musl", pkg: "@openai/codex-linux-x64" }, + "linux:arm64": { triple: "aarch64-unknown-linux-musl", pkg: "@openai/codex-linux-arm64" }, + "darwin:x64": { triple: "x86_64-apple-darwin", pkg: "@openai/codex-darwin-x64" }, + "darwin:arm64": { triple: "aarch64-apple-darwin", pkg: "@openai/codex-darwin-arm64" }, + }; + const selected = BACKING[process.platform + ":" + process.arch]; + expect(selected).toBeDefined(); + + const root = mkdtempSync(join(tmpdir(), "ocx-cli-content-e2e-")); + roots.push(root); + const fixture = join(root, "fixture"); + const prefix = join(root, "npm-prefix"); + const home = join(root, "home"); + const fakeBin = join(root, "sbin"); + mkdirSync(home, { recursive: true }); + mkdirSync(fakeBin, { recursive: true }); + + // Synthetic package set, explicitly marked: the main package declares the + // published entrypoint contract (bin/codex.js) and the platform backing + // package carries a vendor//bin/codex binary. npm installs them as + // two real global packages; the launcher's require.resolve finds the + // backing as a sibling under the npm root. + const codexDir = join(fixture, "codex"); + const backingDir = join(fixture, "backing"); + mkdirSync(join(codexDir, "bin"), { recursive: true }); + mkdirSync(join(backingDir, "vendor", selected.triple, "bin"), { recursive: true }); + writeFileSync(join(codexDir, "package.json"), JSON.stringify({ + name: "@openai/codex", + version: "1.2.3", + description: "synthetic test fixture package - not a published artifact", + type: "module", + bin: { codex: "bin/codex.js" }, + }, null, 2) + "\n"); + writeFileSync(join(codexDir, "bin", "codex.js"), [ + "#!/usr/bin/env node", + "// synthetic fixture entrypoint implementing the published launcher selection rule", + 'import { createRequire } from "node:module";', + 'import { existsSync } from "node:fs";', + 'import path from "node:path";', + 'import { fileURLToPath } from "node:url";', + "const require = createRequire(import.meta.url);", + "const dir = path.dirname(fileURLToPath(import.meta.url));", + "const pkg = " + JSON.stringify(selected.pkg) + ";", + "const triple = " + JSON.stringify(selected.triple) + ";", + "let vendorRoot;", + 'try { vendorRoot = path.join(path.dirname(require.resolve(pkg + "/package.json")), "vendor"); }', + 'catch { vendorRoot = path.join(dir, "..", "vendor"); }', + 'const exe = path.join(vendorRoot, triple, "bin", process.platform === "win32" ? "codex.exe" : "codex");', + 'if (!existsSync(exe)) throw new Error("missing optional dependency " + pkg);', + "process.exit(0);", + "", + ].join("\n")); + writeFileSync(join(backingDir, "package.json"), JSON.stringify({ + name: selected.pkg, + version: "1.2.3", + description: "synthetic test fixture backing package - not a published artifact", + os: [process.platform === "linux" ? "linux" : "darwin"], + cpu: [process.arch], + files: ["vendor"], + }, null, 2) + "\n"); + const backingExe = join(backingDir, "vendor", selected.triple, "bin", "codex"); + writeFileSync(backingExe, "#!/bin/sh\n# synthetic native-backing fixture\nexit 0\n"); + chmodSync(backingExe, 0o755); + + // Every real npm call runs against owned cache/config/state paths, so an + // ambient NPM_CONFIG_*, user npmrc, or Node startup variable can neither + // steer the fixture install nor write outside this root; lifecycle scripts + // stay disabled end to end, and the pinned dead registry makes any + // accidental network resolution a visible failure. + mkdirSync(join(root, "tmp"), { recursive: true }); + writeFileSync(join(root, "user-npmrc"), ""); + writeFileSync(join(root, "global-npmrc"), ""); + const npmEnv = { + PATH: [dirname(node!), "/usr/bin", "/bin"].join(delimiter), + HOME: home, + TMPDIR: join(root, "tmp"), + npm_config_offline: "true", + }; + const npmConfigArgs = [ + "--cache", join(root, "npm-cache"), + "--userconfig", join(root, "user-npmrc"), + "--globalconfig", join(root, "global-npmrc"), + ]; + const pack = (dir: string) => { + const packed = spawnSync(npm!, [ + "pack", "--json", "--pack-destination", dir, "--ignore-scripts", ...npmConfigArgs, + ], { + cwd: dir, encoding: "utf8", timeout: 30_000, env: npmEnv, + }); + expect(packed.status).toBe(0); + const meta = JSON.parse(packed.stdout) as { filename?: string }[]; + const filename = meta[0]?.filename ?? ""; + // The tarball name comes from npm's own report, never a guess at how the + // scoped package name is flattened. + expect(filename).toMatch(/^[^/\\]+[.]tgz$/); + const tarball = resolve(dir, filename); + expect(dirname(tarball)).toBe(dir); + return tarball; + }; + const npmInstall = (tarball: string) => { + const installed = spawnSync(npm!, [ + "install", "-g", "--prefix", prefix, tarball, + "--ignore-scripts", "--no-audit", "--no-fund", + "--registry=http://127.0.0.1:9/", + ...npmConfigArgs, + ], { + cwd: root, encoding: "utf8", timeout: 60_000, env: npmEnv, + }); + expect(installed.status).toBe(0); + }; + npmInstall(pack(backingDir)); + npmInstall(pack(codexDir)); + const launcher = join(prefix, "bin", "codex"); + + // The bound snapshot PATH: the fake npm first, then the real npm-global bin + // and node directories. Everything the spawned npm view needs is answered + // by the script; nothing else on the PATH can shadow it. + const integrity = "sha512-" + Buffer.alloc(64, 7).toString("base64"); + writeFileSync(join(fakeBin, "npm"), [ + "#!/bin/sh", + 'case "$*" in', + ' *dist.integrity*) printf "%s\n" ' + JSON.stringify(integrity) + " ;;", + ' *dist.tarball*) printf "%s\n" "https://registry.npmjs.org/@openai/codex/-/codex-9.9.9.tgz" ;;', + ' *) printf "%s\n" "9.9.9" ;;', + "esac", + "", + ].join("\n")); + chmodSync(join(fakeBin, "npm"), 0o755); + const boundPath = [fakeBin, join(prefix, "bin"), join(node!, ".."), "/usr/bin", "/bin"].join(":"); + + const planEnv = { + HOME: home, + PATH: boundPath, + TMPDIR: join(root, "tmp"), + OPENCODEX_HOME: join(home, "ocx"), + CODEX_CLI_PATH: launcher, + OCX_NO_DESKTOP_HANDOFF: "1", + OPENCODEX_BUN_PATH: bun!, + }; + const runPlan = (env: NodeJS.ProcessEnv = planEnv) => spawnSync(node!, [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", "plan", "--json"], { + cwd: repoRoot(), encoding: "utf8", timeout: 30_000, + env, + }); + + // The real process scan sees every Codex session on this host, and the plan + // rightly refuses while one runs. That refusal still reports the content + // digest, so the digest is the host-independent binding; plan ids are + // compared as well whenever both runs found an idle host (as on CI). + const bound = (result: ReturnType) => { + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + const plan = JSON.parse(result.stdout); + expect(plan.managed).toBe(true); + expect(plan.installContentDigest).toMatch(/^[0-9a-f]{64}$/); + if (!plan.applicable) { + expect(plan.refusal).toBe("blocked_active_session"); + expect(plan.session.state).toBe("active"); + } + return { digest: plan.installContentDigest as string, planId: plan.applicable ? plan.planId as string : null }; + }; + const expectSame = (a: ReturnType, b: ReturnType) => { + expect(b.digest).toBe(a.digest); + if (a.planId !== null && b.planId !== null) expect(b.planId).toBe(a.planId); + }; + const expectChanged = (a: ReturnType, b: ReturnType) => { + expect(b.digest).not.toBe(a.digest); + if (a.planId !== null && b.planId !== null) expect(b.planId).not.toBe(a.planId); + }; + const first = bound(runPlan()); + + // Re-running on untouched bytes reproduces the same plan. + expectSame(first, bound(runPlan())); + + // An oversized NODE_PATH cannot overflow the serialized launch context: + // the launcher drops the optional lookup seed instead, so the snapshot + // still parses, the nested-lane binding (which needs no global lookup) is + // unaffected, and the same plan is produced — only lanes that would need + // the dropped seed become unverifiable. 80KiB stays under Linux's + // per-string MAX_ARG_STRLEN so the spawn itself is not the refusal. + expectSame(first, bound(runPlan({ ...planEnv, NODE_PATH: "/d".repeat(40 * 1024) }))); + + // A same-length byte change to the entrypoint is a different install. The + // mtime is restored too, so only content can drive the plan-id change. + const entrypoint = join(prefix, "lib", "node_modules", "@openai", "codex", "bin", "codex.js"); + const mutate = (path: string, from: string, to: string) => { + const before = readFileSync(path, "utf8"); + const stat = statSync(path); + expect(before.includes(from)).toBe(true); + const replaced = before.replace(from, to); + expect(replaced.length).toBe(before.length); + writeFileSync(path, replaced); + utimesSync(path, stat.atime, stat.mtime); + }; + mutate(entrypoint, "synthetic fixture", "synthetic FIXTURE"); + const entryPlan = bound(runPlan()); + expectChanged(first, entryPlan); + + // A same-length change to the vendored native backing is a different plan too. + const backingPkgName = selected.pkg.split("/")[1]; + const installedBackingExe = join( + prefix, "lib", "node_modules", "@openai", backingPkgName, + "vendor", selected.triple, "bin", "codex", + ); + mutate(installedBackingExe, "native-backing", "native-BACKING"); + expectChanged(entryPlan, bound(runPlan())); + + // Removing the required backing makes the plan refuse, never silently rebind. + // This refusal precedes the session scan, so it holds on every host. + rmSync(join(prefix, "lib", "node_modules", "@openai", backingPkgName), { recursive: true, force: true }); + const removed = runPlan(); + expect(removed.status).toBe(0); + const removedPlan = JSON.parse(removed.stdout); + expect(removedPlan.applicable).toBe(false); + expect(removedPlan.refusal).toBe("install_content_unverified"); + }); +}); + +function inspectionContext(transportEnv?: unknown, extra: Record = {}) { + 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, ...extra }, + }), + }); +} + +describe("Codex CLI launcher architecture snapshot", () => { + // An arch this Bun process is not running, so the forwarded value is + // distinguishable from the process.arch fallback on every host. + const foreign = process.arch === "x64" ? "arm64" : "x64"; + + test("a known launcher arch is kept and an absent one stays null", () => { + expect(inspectionContext(undefined, { arch: foreign })?.codexCliInspectionEnv?.arch).toBe(foreign); + expect(inspectionContext(undefined, {})?.codexCliInspectionEnv?.arch).toBeNull(); + }); + + test.each(["x86_64", "", 64, ["x64"]].map(value => ({ value })))("an unknown arch fails the context closed %#", row => { + expect(inspectionContext(undefined, { arch: row.value })).toBeNull(); + }); + + test("inspection selects the backing by the Node launcher's arch, not Bun's", async () => { + inspectionContext(undefined, { arch: foreign }); + let inspectedArch: string | undefined; + expect(await handleCodexCliUpdateCommand(["check", "--json"], { + resolveSelectedRuntime: () => ({ runtime: null, failures: [] }), + inspectInstall: async deps => { + inspectedArch = deps.arch; + return inspectCodexCliInstall(deps); + }, + })).toBe(0); + expect(inspectedArch).toBe(foreign); + }); +}); + +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), + installContentDigest: "b".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/cli-codex-cli-update.test.ts b/tests/cli/cli-codex-cli-update.test.ts index 96048839975..df948dd06c6 100644 --- a/tests/cli/cli-codex-cli-update.test.ts +++ b/tests/cli/cli-codex-cli-update.test.ts @@ -17,6 +17,7 @@ const report: CodexCliInstallReport = { versionEvidence: { kind: "unavailable" }, provenance: "unknown", managed: false, reason: "candidate_unavailable", location: null, installDigest: null, + installContentDigest: null, packageVersion: null, shim: { status: "not-tracked", backingKind: null }, evidence: [], }; @@ -238,10 +239,14 @@ describe("Codex CLI update CLI", () => { test("check inspects exactly once", async () => { let inspectedCalls = 0; + let received: CodexCliInstallProvenanceDeps | null = null; expect(await handleCodexCliUpdateCommand(["check", "--json"], { - inspectInstall: async () => { inspectedCalls += 1; return report; }, + inspectInstall: async deps => { inspectedCalls += 1; received = deps; return report; }, })).toBe(0); expect(inspectedCalls).toBe(1); + // A bare check answers ownership only; the bounded content read is reserved + // for the plan path whose planId binds the digest. + expect(received?.observeInstallContent).not.toBe(true); }); test("passes only proof-bound manager roots into production provenance inspection", async () => { @@ -256,6 +261,8 @@ describe("Codex CLI update CLI", () => { codexCliPath: "C:\\managed\\codex.cmd", path: "C:\\managed", pathExt: ".CMD", + home: "C:\\managed-home", + nodePath: "C:\\managed-modules", managerRoots: { FNM_DIR: "C:\\custom-manager" }, configDir, }, @@ -281,6 +288,8 @@ describe("Codex CLI update CLI", () => { CODEX_CLI_PATH: "C:\\managed\\codex.cmd", PATH: "C:\\managed", PATHEXT: ".CMD", + HOME: "C:\\managed-home", + NODE_PATH: "C:\\managed-modules", }); expect(received?.configDir).toBe(configDir); expect(received?.selectedCommand).toBe(process.platform === "win32" ? null : "C:\\managed\\codex.cmd"); diff --git a/tests/cli/ocx-launcher-source.test.ts b/tests/cli/ocx-launcher-source.test.ts index 1dce864c01c..c6eb5840d42 100644 --- a/tests/cli/ocx-launcher-source.test.ts +++ b/tests/cli/ocx-launcher-source.test.ts @@ -143,7 +143,11 @@ 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("const preBunTransportEnv = captureCodexCliTransportEnv(process.env);"); + expect(source).toContain("transportEnv: preBunTransportEnv,"); + // The backing arch is the Node launcher's, bound before Bun (possibly another arch) starts. + expect(source).toContain("arch: process.arch,"); // 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-install-provenance.test.ts b/tests/codex-integration/codex-cli-install-provenance.test.ts index c6f810d1238..9d282870d1c 100644 --- a/tests/codex-integration/codex-cli-install-provenance.test.ts +++ b/tests/codex-integration/codex-cli-install-provenance.test.ts @@ -1,15 +1,46 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { chmodSync, linkSync, mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; +import { + chmodSync, + closeSync, + ftruncateSync, + linkSync, + mkdirSync, + mkdtempSync, + openSync, + readdirSync, + readlinkSync, + realpathSync, + rmSync, + symlinkSync, + truncateSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { inspectCodexCliInstall, isAppBundledCodexPath, + MAX_NATIVE_BACKING_BYTES, isCodexCliUpdateVersionManagerPath, type CodexCliInstallProvenanceDeps, } from "../../src/codex/cli-install-provenance"; import { buildUnixCodexShim } from "../../src/codex/shim"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { + VIRTUAL_BACKING_BINARY, + VIRTUAL_BACKING_MANIFEST, + VIRTUAL_ENTRYPOINT, + VIRTUAL_LAUNCHER, + VIRTUAL_MANIFEST, + VIRTUAL_PACKAGE_ROOT, + VIRTUAL_VENDOR_BINARY, + virtualInspectionDeps, + virtualManagedNpmGlobal, + virtualPosixFs, + VIRTUAL_MANIFEST_TEXT, + VIRTUAL_ENTRYPOINT_TEXT, + VIRTUAL_BACKING_TEXT, +} from "../helpers/codex-cli-content-fs"; const roots: string[] = []; @@ -663,3 +694,468 @@ describe("Codex CLI install provenance", () => { } }); }); + +describe("install content binding", () => { + test("an ownership-only read never opens the backing bytes", async () => { + const fs = virtualManagedNpmGlobal(); + const probed: string[] = []; + const deps = virtualInspectionDeps(fs, { + lstat: (path: string) => { + if (path.includes("@openai/codex-")) probed.push(path); + return fs.lstat(path); + }, + readFile: (path: string) => { + if (path === VIRTUAL_BACKING_BINARY) { + throw new Error("must not read the native backing on a plain inspection"); + } + return fs.readFile(path); + }, + }); + const report = await inspectCodexCliInstall(deps); + expect(report.managed).toBe(true); + expect(report.installContentDigest).toBeNull(); + expect(report.evidence).not.toContain("install_content_digest"); + expect(probed).toEqual([]); + }); + + test("a managed npm-global report binds the observed manifest, entrypoint and backing bytes", async () => { + const fs = virtualManagedNpmGlobal(); + const report = await inspectCodexCliInstall(virtualInspectionDeps(fs, { observeInstallContent: true })); + expect(report.managed).toBe(true); + expect(report.reason).toBe("managed_npm_global"); + expect(report.installContentDigest).toMatch(/^[0-9a-f]{64}$/); + expect(report.evidence).toContain("install_content_digest"); + }); + + test("the same bytes observed again produce the same digest", async () => { + const first = await inspectCodexCliInstall(virtualInspectionDeps(virtualManagedNpmGlobal(), { observeInstallContent: true })); + const second = await inspectCodexCliInstall(virtualInspectionDeps(virtualManagedNpmGlobal(), { observeInstallContent: true })); + expect(first.installContentDigest).toMatch(/^[0-9a-f]{64}$/); + expect(second.installContentDigest).toBe(first.installContentDigest); + expect(second.installDigest).toBe(first.installDigest); + }); + + test.each([ + ["manifest", VIRTUAL_MANIFEST, VIRTUAL_MANIFEST_TEXT.replace("1.2.3", "1.2.4")], + ["entrypoint", VIRTUAL_ENTRYPOINT, "#!/usr/bin/env node\n// synthetic fixture altered!!!\n"], + ["backing manifest", VIRTUAL_BACKING_MANIFEST, JSON.stringify({ name: "@openai/codex-linux-x64", version: "9.9.9" }) + "\n"], + ["backing binary", VIRTUAL_BACKING_BINARY, "synthetic-native-backing-v2\n"], + ])("a same-length change to the %s changes the content digest but keeps the location digest", async (_role, path, replacement) => { + const original = virtualManagedNpmGlobal(); + const before = await inspectCodexCliInstall(virtualInspectionDeps(original, { observeInstallContent: true })); + expect(before.installContentDigest).not.toBeNull(); + const mutated = virtualManagedNpmGlobal(); + const prior = (mutated.readFile(path)).length; + mutated.setBytes(path, replacement); + expect(mutated.readFile(path).length).toBe(prior); + const after = await inspectCodexCliInstall(virtualInspectionDeps(mutated, { observeInstallContent: true })); + expect(after.installContentDigest).not.toBeNull(); + expect(after.installContentDigest).not.toBe(before.installContentDigest); + expect(after.installDigest).toBe(before.installDigest); + }); + + test("a missing platform backing package is refused, not bound as absent", async () => { + const fs = virtualManagedNpmGlobal(); + fs.remove(VIRTUAL_BACKING_MANIFEST); + fs.remove(VIRTUAL_BACKING_BINARY); + // With no resolve hit and no modelable fallback globals, the lane is ambiguous. + const report = await inspectCodexCliInstall(virtualInspectionDeps(fs, { observeInstallContent: true })); + expect(report.managed).toBe(true); + expect(report.installContentDigest).toBeNull(); + }); + + test("a backing manifest resolving outside the npm prefix is refused", async () => { + const fs = virtualManagedNpmGlobal({ + "/outside/codex-linux-x64/package.json": { + data: JSON.stringify({ name: "@openai/codex-linux-x64", version: "1.2.3" }) + "\n", + }, + }); + fs.redirect(VIRTUAL_BACKING_MANIFEST, "/outside/codex-linux-x64/package.json"); + const report = await inspectCodexCliInstall(virtualInspectionDeps(fs, { observeInstallContent: true })); + expect(report.managed).toBe(true); + expect(report.installContentDigest).toBeNull(); + }); + + test("a backing read failure is never treated as absent backing", async () => { + const fs = virtualManagedNpmGlobal(); + const deps = virtualInspectionDeps(fs, { + observeInstallContent: true, + readFile: (path: string) => { + if (path === VIRTUAL_BACKING_MANIFEST) throw new Error("synthetic read denial"); + return fs.readFile(path); + }, + }); + const report = await inspectCodexCliInstall(deps); + expect(report.installContentDigest).toBeNull(); + }); + + test("an identity change between observations refuses", async () => { + const fs = virtualManagedNpmGlobal(); + const backingStats = { count: 0 }; + const stat = fs.stat; + const deps = virtualInspectionDeps(fs, { + observeInstallContent: true, + stat: (path: string) => { + const result = stat(path); + if (path === VIRTUAL_BACKING_BINARY) { + backingStats.count += 1; + if (backingStats.count === 1) fs.bumpIdentity(path); + } + return result; + }, + }); + const report = await inspectCodexCliInstall(deps); + expect(report.installContentDigest).toBeNull(); + }); + + test("the package-vendor fallback binds when no resolve hit exists and globals are fully probed", async () => { + const fs = virtualPosixFs({ + [VIRTUAL_LAUNCHER]: { link: VIRTUAL_ENTRYPOINT }, + [VIRTUAL_MANIFEST]: { data: VIRTUAL_MANIFEST_TEXT }, + [VIRTUAL_ENTRYPOINT]: { data: VIRTUAL_ENTRYPOINT_TEXT, mode: 0o755 }, + [VIRTUAL_VENDOR_BINARY]: { data: VIRTUAL_BACKING_TEXT, mode: 0o755 }, + "/virtual-npm/bin/node": { data: "synthetic-node\n", mode: 0o755 }, + "/virtual-home/.keep": { data: "" }, + }); + const deps = virtualInspectionDeps(fs, { + observeInstallContent: true, + env: { CODEX_CLI_PATH: VIRTUAL_LAUNCHER, PATH: "/virtual-npm/bin", HOME: "/virtual-home" }, + }); + const report = await inspectCodexCliInstall(deps); + expect(report.managed).toBe(true); + expect(report.installContentDigest).toMatch(/^[0-9a-f]{64}$/); + }); + + test("the vendor fallback refuses when the Node global lookup cannot be modeled", async () => { + const fs = virtualPosixFs({ + [VIRTUAL_LAUNCHER]: { link: VIRTUAL_ENTRYPOINT }, + [VIRTUAL_MANIFEST]: { data: VIRTUAL_MANIFEST_TEXT }, + [VIRTUAL_ENTRYPOINT]: { data: VIRTUAL_ENTRYPOINT_TEXT, mode: 0o755 }, + [VIRTUAL_VENDOR_BINARY]: { data: VIRTUAL_BACKING_TEXT, mode: 0o755 }, + }); + // PATH is empty: node cannot be resolved, so /lib/node cannot be + // probed and the require.resolve failure the fallback needs is unproven. + const report = await inspectCodexCliInstall(virtualInspectionDeps(fs, { observeInstallContent: true })); + // Selection stays intact; only the content observation is refused. + expect(report.managed).toBe(true); + expect(report.installContentDigest).toBeNull(); + }); + + test("missing lookup seeds refuse a global backing hit while preserving a local hit", async () => { + const fs = virtualManagedNpmGlobal({ + "/virtual-npm/bin/node": { data: "synthetic-node\n", mode: 0o755 }, + }); + const env = { CODEX_CLI_PATH: VIRTUAL_LAUNCHER, PATH: "/virtual-npm/bin" }; + const deps = virtualInspectionDeps(fs, { observeInstallContent: true, env }); + // Missing HOME/NODE_PATH does not affect a local optional-package hit. + expect((await inspectCodexCliInstall(deps)).installContentDigest).toMatch(/^[0-9a-f]{64}$/); + + fs.remove(VIRTUAL_BACKING_MANIFEST); + fs.remove(VIRTUAL_BACKING_BINARY); + const globalRoot = "/virtual-npm/lib/node/@openai/codex-linux-x64"; + fs.create(globalRoot + "/package.json", { + data: JSON.stringify({ name: "@openai/codex-linux-x64", version: "1.2.3" }) + "\n", + }); + fs.create(globalRoot + "/vendor/x86_64-unknown-linux-musl/bin/codex", { + data: VIRTUAL_BACKING_TEXT, mode: 0o755, + }); + // This same in-prefix Node-global backing binds with complete lookup seeds. + expect((await inspectCodexCliInstall({ ...deps, env: { ...env, HOME: "/virtual-home" } })) + .installContentDigest).toMatch(/^[0-9a-f]{64}$/); + // Without them, unknown earlier global lanes can shadow the observed hit. + const report = await inspectCodexCliInstall(deps); + expect(report.managed).toBe(true); + expect(report.installContentDigest).toBeNull(); + }); + + test("a backing package appearing in an earlier lookup lane mid-observation refuses", async () => { + const fs = virtualManagedNpmGlobal(); + const earlierManifest = VIRTUAL_PACKAGE_ROOT + "/bin/node_modules/@openai/codex-linux-x64/package.json"; + const deps = virtualInspectionDeps(fs, { + observeInstallContent: true, + readFile: (path: string) => { + const bytes = fs.readFile(path); + if (path === VIRTUAL_BACKING_BINARY) { + // The modeled selector already resolved the nested optional package; + // a package.json materializing in an earlier lane changes the answer. + fs.create(earlierManifest, { + data: JSON.stringify({ name: "@openai/codex-linux-x64", version: "1.2.3" }) + "\n", + }); + } + return bytes; + }, + }); + const report = await inspectCodexCliInstall(deps); + expect(report.installContentDigest).toBeNull(); + }); + + test("a backing package disappearing after selection refuses", async () => { + const fs = virtualManagedNpmGlobal(); + const deps = virtualInspectionDeps(fs, { + observeInstallContent: true, + readFile: (path: string) => { + const bytes = fs.readFile(path); + if (path === VIRTUAL_BACKING_MANIFEST) fs.remove(VIRTUAL_BACKING_MANIFEST); + return bytes; + }, + }); + const report = await inspectCodexCliInstall(deps); + expect(report.installContentDigest).toBeNull(); + }); + + test("a selected launcher retargeted while the backing is read refuses", async () => { + const fs = virtualManagedNpmGlobal({ + "/elsewhere/codex.js": { data: "// swapped-in entrypoint\n", mode: 0o755 }, + }); + expect((await inspectCodexCliInstall(virtualInspectionDeps(fs, { observeInstallContent: true }))) + .installContentDigest).toMatch(/^[0-9a-f]{64}$/); + const deps = virtualInspectionDeps(fs, { + observeInstallContent: true, + readFile: (path: string) => { + const bytes = fs.readFile(path); + // Canonical-path checks cannot see this: the entrypoint file itself is + // untouched; only the selected launcher's lexical->canonical link moved. + if (path === VIRTUAL_BACKING_BINARY) fs.redirect(VIRTUAL_LAUNCHER, "/elsewhere/codex.js"); + return bytes; + }, + }); + const report = await inspectCodexCliInstall(deps); + expect(report.installContentDigest).toBeNull(); + }); + + test("a backing package symlink retargeted while its binary is read refuses", async () => { + const fs = virtualManagedNpmGlobal({ + "/elsewhere/codex-linux-x64/package.json": { + data: JSON.stringify({ name: "@openai/codex-linux-x64", version: "9.9.9" }) + "\n", + }, + }); + expect((await inspectCodexCliInstall(virtualInspectionDeps(fs, { observeInstallContent: true }))) + .installContentDigest).toMatch(/^[0-9a-f]{64}$/); + const deps = virtualInspectionDeps(fs, { + observeInstallContent: true, + readFile: (path: string) => { + const bytes = fs.readFile(path); + if (path === VIRTUAL_BACKING_BINARY) { + fs.redirect(VIRTUAL_BACKING_MANIFEST, "/elsewhere/codex-linux-x64/package.json"); + } + return bytes; + }, + }); + const report = await inspectCodexCliInstall(deps); + expect(report.installContentDigest).toBeNull(); + }); + + test("content observation is independent of the caller's selection attestation", async () => { + const fs = virtualManagedNpmGlobal(); + const report = await inspectCodexCliInstall(virtualInspectionDeps(fs, { selectionAttested: false, observeInstallContent: true })); + expect(report.managed).toBe(false); + expect(report.reason).toBe("selection_unattested"); + // The digest binds the bytes that were read; ownership and selection stay separate. + expect(report.installContentDigest).toMatch(/^[0-9a-f]{64}$/); + }); + + // Linux only: descriptor counting uses /proc/self/fd, and the held-read seam + // is the native O_NOFOLLOW/O_NONBLOCK path. A refused file once leaked the + // descriptor it had just opened, so each refusal is repeated enough times to + // make a surviving leak impossible to miss. + test.skipIf(process.platform !== "linux")("a refused content file never leaks its descriptor", async () => { + const triples: Record = { + "linux:x64": "x86_64-unknown-linux-musl", + "linux:arm64": "aarch64-unknown-linux-musl", + }; + const packages: Record = { + "linux:x64": "@openai/codex-linux-x64", + "linux:arm64": "@openai/codex-linux-arm64", + }; + const triple = triples[process.platform + ":" + process.arch]; + const backingName = packages[process.platform + ":" + process.arch]; + expect(triple).toBeDefined(); + expect(backingName).toBeDefined(); + + const root = tempRoot("ocx-cli-content-fd-"); + const prefix = join(root, "npm-prefix"); + const pkgRoot = join(prefix, "lib", "node_modules", "@openai", "codex"); + const backingPkg = join(pkgRoot, "node_modules", "@openai", backingName!.split("/")[1]); + const backingBin = join(backingPkg, "vendor", triple!, "bin", "codex"); + mkdirSync(join(prefix, "bin"), { recursive: true }); + mkdirSync(join(pkgRoot, "bin"), { recursive: true }); + mkdirSync(join(backingPkg, "vendor", triple!, "bin"), { recursive: true }); + writeFileSync(join(pkgRoot, "package.json"), JSON.stringify({ + name: "@openai/codex", + version: "1.2.3", + description: "synthetic fixture package - not a published artifact", + bin: { codex: "bin/codex.js" }, + }, null, 2) + "\n"); + writeFileSync(join(pkgRoot, "bin", "codex.js"), "#!/usr/bin/env node\n// synthetic fixture\n"); + chmodSync(join(pkgRoot, "bin", "codex.js"), 0o755); + writeFileSync(join(backingPkg, "package.json"), JSON.stringify({ + name: backingName, + version: "1.2.3", + }) + "\n"); + const launcher = join(prefix, "bin", "codex"); + symlinkSync(join("..", "lib", "node_modules", "@openai", "codex", "bin", "codex.js"), launcher); + + const deps: CodexCliInstallProvenanceDeps = { + platform: "linux", + arch: process.arch as NodeJS.Architecture, + configDir: join(root, "config"), + env: { CODEX_CLI_PATH: launcher, PATH: "", HOME: join(root, "home") }, + selectionAttested: true, + selectedCommand: launcher, + observeInstallContent: true, + inspectShim: () => ({ status: "not-tracked" }), + }; + const fdCount = () => readdirSync("/proc/self/fd").length; + + // A healthy install must bind before the refusal checks: without this the + // loops below could pass while never reaching the descriptor path at all. + writeFileSync(backingBin, "#!/bin/sh\n# synthetic fixture backing\n"); + const healthy = await inspectCodexCliInstall(deps); + expect(healthy.installContentDigest).toMatch(/^[0-9a-f]{64}$/); + + // A directory at the backing-binary slot opens fine under O_RDONLY and is + // then refused by the type check — the path that used to leak the fd. + rmSync(backingBin); + mkdirSync(backingBin); + const beforeNonregular = fdCount(); + for (let i = 0; i < 25; i += 1) { + const report = await inspectCodexCliInstall(deps); + expect(report.installContentDigest).toBeNull(); + } + expect(fdCount()).toBe(beforeNonregular); + + // A sparse oversize regular file takes the size check instead; same rule. + rmSync(backingBin, { recursive: true, force: true }); + const fd = openSync(backingBin, "w"); + try { + ftruncateSync(fd, MAX_NATIVE_BACKING_BYTES + 1); + } finally { + closeSync(fd); + } + const beforeOversize = fdCount(); + for (let i = 0; i < 25; i += 1) { + const report = await inspectCodexCliInstall(deps); + expect(report.installContentDigest).toBeNull(); + } + expect(fdCount()).toBe(beforeOversize); + }); + + // POSIX only: exercises the native held-fd path and the end-of-observation + // lexical->canonical re-resolution. The launcher swap happens at its final + // resolution; Linux additionally verifies the backing descriptor is still + // open at that boundary. No held descriptor prevents the symlink mutation. + test.skipIf(process.platform === "win32")("a launcher retargeted during the native observation refuses", async () => { + const backing: Record = { + "linux:x64": { triple: "x86_64-unknown-linux-musl", pkg: "@openai/codex-linux-x64" }, + "linux:arm64": { triple: "aarch64-unknown-linux-musl", pkg: "@openai/codex-linux-arm64" }, + "darwin:x64": { triple: "x86_64-apple-darwin", pkg: "@openai/codex-darwin-x64" }, + "darwin:arm64": { triple: "aarch64-apple-darwin", pkg: "@openai/codex-darwin-arm64" }, + }; + const selected = backing[process.platform + ":" + process.arch]; + expect(selected).toBeDefined(); + + const root = tempRoot("ocx-cli-content-retarget-"); + const prefix = join(root, "npm-prefix"); + const { launcher, packageRoot } = createPosixNpmGlobal(prefix); + const backingPkg = join(packageRoot, "node_modules", "@openai", selected!.pkg.split("/")[1]); + const backingBin = join(backingPkg, "vendor", selected!.triple, "bin", "codex"); + mkdirSync(join(backingPkg, "vendor", selected!.triple, "bin"), { recursive: true }); + writeFileSync(join(backingPkg, "package.json"), JSON.stringify({ + name: selected!.pkg, + version: "1.2.3", + }) + "\n"); + writeFileSync(backingBin, "#!/bin/sh\n# synthetic fixture backing\n"); + // realpath reports canonical paths; macOS temp roots sit behind /var -> /private/var. + const canonicalBackingBin = realpathSync.native(backingBin); + const swapped = join(root, "elsewhere", "codex.js"); + mkdirSync(dirname(swapped), { recursive: true }); + writeFileSync(swapped, "// swapped-in entrypoint\n"); + + const baseDeps: CodexCliInstallProvenanceDeps = { + platform: process.platform, + arch: process.arch, + configDir: join(root, "config"), + env: { CODEX_CLI_PATH: launcher, PATH: "", HOME: join(root, "home") }, + selectionAttested: true, + selectedCommand: launcher, + observeInstallContent: true, + inspectShim: () => ({ status: "not-tracked" }), + }; + // Positive control first: without the seam the same fixture must bind, or + // the refusal below proves nothing about the retarget path. + const healthy = await inspectCodexCliInstall(baseDeps); + expect(healthy.installContentDigest).toMatch(/^[0-9a-f]{64}$/); + + let backingSeen = false; + let flipped = false; + const report = await inspectCodexCliInstall({ + ...baseDeps, + realpath: (path: string) => { + if (backingSeen && !flipped && path === launcher) { + if (process.platform === "linux") { + const backingHeld = readdirSync("/proc/self/fd").some(fd => { + try { return readlinkSync("/proc/self/fd/" + fd) === canonicalBackingBin; } + catch { return false; } + }); + expect(backingHeld).toBe(true); + } + flipped = true; + rmSync(launcher); + symlinkSync(swapped, launcher); + } + const resolved = realpathSync.native(path); + if (resolved === canonicalBackingBin) backingSeen = true; + return resolved; + }, + }); + expect(flipped).toBe(true); + // Selection still classified; only the content observation refused. + expect(report.managed).toBe(true); + expect(report.installContentDigest).toBeNull(); + }); + + // POSIX only (the native held-descriptor reader). Sparse files keep this + // cheap: the accepted size streams holes, and the over-limit size is refused + // by fstat before any read. + test.skipIf(process.platform === "win32")("a published-size native backing binds and one past the finite bound refuses", async () => { + const backing: Record = { + "linux:x64": { triple: "x86_64-unknown-linux-musl", pkg: "@openai/codex-linux-x64" }, + "linux:arm64": { triple: "aarch64-unknown-linux-musl", pkg: "@openai/codex-linux-arm64" }, + "darwin:x64": { triple: "x86_64-apple-darwin", pkg: "@openai/codex-darwin-x64" }, + "darwin:arm64": { triple: "aarch64-apple-darwin", pkg: "@openai/codex-darwin-arm64" }, + }; + const selected = backing[process.platform + ":" + process.arch]; + expect(selected).toBeDefined(); + + const root = tempRoot("ocx-cli-content-large-"); + const { launcher, packageRoot } = createPosixNpmGlobal(join(root, "npm-prefix")); + const backingPkg = join(packageRoot, "node_modules", "@openai", selected!.pkg.split("/")[1]); + const backingBin = join(backingPkg, "vendor", selected!.triple, "bin", "codex"); + mkdirSync(dirname(backingBin), { recursive: true }); + writeFileSync(join(backingPkg, "package.json"), JSON.stringify({ name: selected!.pkg, version: "1.2.3" }) + "\n"); + const deps: CodexCliInstallProvenanceDeps = { + platform: process.platform, + arch: process.arch, + configDir: join(root, "config"), + env: { CODEX_CLI_PATH: launcher, PATH: "", HOME: join(root, "home") }, + selectionAttested: true, + selectedCommand: launcher, + observeInstallContent: true, + inspectShim: () => ({ status: "not-tracked" }), + }; + + // The size of @openai/codex@0.163.0-linux-x64's binary, above the former 256 MiB bound. + const publishedSize = 296_865_480; + expect(publishedSize).toBeGreaterThan(256 * 1024 * 1024); + writeFileSync(backingBin, ""); + truncateSync(backingBin, publishedSize); + const published = await inspectCodexCliInstall(deps); + expect(published.managed).toBe(true); + expect(published.installContentDigest).toMatch(/^[0-9a-f]{64}$/); + + truncateSync(backingBin, MAX_NATIVE_BACKING_BYTES + 1); + const oversized = await inspectCodexCliInstall(deps); + expect(oversized.managed).toBe(true); + expect(oversized.installContentDigest).toBeNull(); + }); +}); diff --git a/tests/codex-integration/codex-cli-update-launcher-policy.test.ts b/tests/codex-integration/codex-cli-update-launcher-policy.test.ts index dd16c762cc2..aa833c55625 100644 --- a/tests/codex-integration/codex-cli-update-launcher-policy.test.ts +++ b/tests/codex-integration/codex-cli-update-launcher-policy.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { isCodexCliUpdateInspectionArgv } from "../../src/update/codex-cli-update-launch-policy.mjs"; +import { + codexCliInspectionLookupSeeds, + isCodexCliUpdateInspectionArgv, + NODE_LAUNCH_CONTEXT_MAX_CHARS, +} from "../../src/update/codex-cli-update-launch-policy.mjs"; import { repoPath } from "../helpers/repo-root"; describe("Codex CLI updater launcher policy", () => { @@ -34,4 +38,38 @@ describe("Codex CLI updater launcher policy", () => { 'if (allowInstall && installJs && existsSync(installJs)) {\n const r = spawnSync(process.execPath, [installJs], { stdio: "inherit" });', ); }); + + test("lookup seeds are withheld on Windows and dropped — not fatal — on context overflow", () => { + // Windows never models the POSIX global-module lookup, so the seeds are not + // sent at all no matter how the ambient environment spells or sizes them; + // the child's own env copies stay inherited for its general use. + expect(codexCliInspectionLookupSeeds("win32", "C:\\Users\\u", "C:\\lib;D:\\lib", 100)) + .toEqual({ home: null, nodePath: null }); + // POSIX keeps both seeds whenever the serialized context still fits, + // including exactly at the reader's limit. + expect(codexCliInspectionLookupSeeds("linux", "/home/u", "/a:/b", 100)) + .toEqual({ home: "/home/u", nodePath: "/a:/b" }); + expect(codexCliInspectionLookupSeeds("linux", "/h", "/n", NODE_LAUNCH_CONTEXT_MAX_CHARS)) + .toEqual({ home: "/h", nodePath: "/n" }); + // One char over: the seeds are dropped so the selection snapshot survives; + // the inspection then models no global lane and fails only the content + // observation closed. A context that overflows even without the seeds is a + // different, pre-existing refusal — mandatory fields are not dropped here. + expect(codexCliInspectionLookupSeeds("linux", "/h", "/n", NODE_LAUNCH_CONTEXT_MAX_CHARS + 1)) + .toEqual({ home: null, nodePath: null }); + }); + + test("the published launcher measures the serialized context and reserializes dropped seeds", () => { + const source = readFileSync(repoPath("bin", "ocx.mjs"), "utf8"); + const capture = source.slice(source.indexOf("function createNodeLaunchContext()"), source.indexOf("const child = spawn(bun,")); + expect(capture).toContain("let launchContext = serializeContext({ home: preBunHome, nodePath: preBunNodePath });"); + expect(capture).toContain("process.platform, preBunHome, preBunNodePath, launchContext.length,"); + expect(capture).toContain("launchContext = serializeContext(lookupSeeds);"); + // HOME/NODE_PATH remain inherited for the child; only the optional proof + // copy is removed. Every case spelling of the mandatory duplicate fields + // is removed by the existing lowercase match before spawning. + expect(capture).toContain('["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS,'); + expect(capture).toContain("...CODEX_CLI_TRANSPORT_ENV_KEYS].map(name => name.toLowerCase())"); + expect(capture).toContain("if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name];"); + }); }); diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index 6d3b86fccc7..e30fe6865d2 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -8,7 +8,11 @@ import { delimiter, dirname, join } from "node:path"; import { npmInvocation } from "../../src/update/npm-invocation.mjs"; import { scanCodexAppServerProcesses } from "../../src/codex/app-server-processes"; -import type { CodexCliInstallReport } from "../../src/codex/cli-install-provenance"; +import { + inspectCodexCliInstall, + type CodexCliInstallProvenanceDeps, + type CodexCliInstallReport, +} from "../../src/codex/cli-install-provenance"; import { CODEX_CLI_REGISTRY, codexCliUpdatePlanId, @@ -18,6 +22,15 @@ import { type CodexCliUpdatePlanDeps, type CodexCliUpdateTarget, } from "../../src/codex/cli-update-plan"; +import { + VIRTUAL_BACKING_BINARY, + VIRTUAL_BACKING_MANIFEST, + VIRTUAL_ENTRYPOINT, + VIRTUAL_MANIFEST, + VIRTUAL_MANIFEST_TEXT, + virtualInspectionDeps, + virtualManagedNpmGlobal, +} from "../helpers/codex-cli-content-fs"; const REGISTRY_INTEGRITY = `sha512-${Buffer.alloc(64, 7).toString("base64")}`; @@ -41,6 +54,7 @@ const MANAGED: CodexCliInstallReport = Object.freeze({ reason: "managed_npm_global", location: "/@openai/codex", installDigest: "a".repeat(64), + installContentDigest: "c".repeat(64), packageVersion: "1.0.0", shim: { status: "not-tracked", backingKind: null }, evidence: ["package_manifest", "global_npm_layout"], @@ -157,6 +171,63 @@ describe("Codex CLI update dry-run plan", () => { expect(plan.planId).toBeNull(); }); + test("an install whose observed content could not be bound is refused before the registry query", async () => { + // The plan id promises the observed file set; a managed install whose + // manifest, entrypoint or backing could not be read under held identity + // cannot produce one, and no registry request should be spent finding out. + let queries = 0; + const plan = await createCodexCliUpdatePlan(planDeps({ + inspect: async () => report({ installContentDigest: null }), + resolveTarget: () => { queries += 1; return RESOLVED; }, + })); + expect(plan.applicable).toBe(false); + expect(plan.refusal).toBe("install_content_unverified"); + expect(plan.planId).toBeNull(); + expect(queries).toBe(0); + expect(plan.session.state).toBe("not-evaluated"); + }); + + test("a real inspection reports the content digest the plan binds", async () => { + // The real provenance inspection runs against a virtual POSIX npm-global + // install; only the registry target and the process scan are stubbed. + const target: CodexCliUpdateTarget = { kind: "resolved", version: "2.0.0", integrity: "sha512-AAAA" }; + const baselineFs = virtualManagedNpmGlobal(); + let requested: CodexCliInstallProvenanceDeps | null = null; + const baseline = await applicablePlan({ + resolveTarget: () => target, + inspect: (d) => { requested = d; return inspectCodexCliInstall({ ...d, ...virtualInspectionDeps(baselineFs) }); }, + }); + // The plan is the one caller that pays the bounded content read. + expect(requested?.observeInstallContent).toBe(true); + for (const [path, replacement] of [ + // Same-length byte changes only: the installed version stays "1.2.3" so + // every mutation leaves the plan applicable and only the bound bytes differ. + [VIRTUAL_MANIFEST, VIRTUAL_MANIFEST_TEXT.replace("synthetic", "synthetiC")], + [VIRTUAL_ENTRYPOINT, "#!/usr/bin/env node\n// synthetic fixture altered!!!\n"], + [VIRTUAL_BACKING_BINARY, "synthetic-native-backing-v2\n"], + ] as const) { + const mutated = virtualManagedNpmGlobal(); + const sizeBefore = mutated.readFile(path).length; + mutated.setBytes(path, replacement); + expect(mutated.readFile(path).length).toBe(sizeBefore); + const plan = await createCodexCliUpdatePlan(planDeps({ + resolveTarget: () => target, + inspect: (d) => inspectCodexCliInstall({ ...d, ...virtualInspectionDeps(mutated) }), + })); + expect(plan.applicable).toBe(true); + expect(plan.planId).not.toBe(baseline.planId); + } + // A missing backing must never produce the same plan: it refuses outright. + const broken = virtualManagedNpmGlobal(); + broken.remove(VIRTUAL_BACKING_MANIFEST); + broken.remove(VIRTUAL_BACKING_BINARY); + const refused = await createCodexCliUpdatePlan(planDeps({ + resolveTarget: () => target, + inspect: (d) => inspectCodexCliInstall({ ...d, ...virtualInspectionDeps(broken) }), + })); + expect(refused.refusal).toBe("install_content_unverified"); + }); + test("an unreadable process table defers instead of reading as no live session", async () => { const plan = await createCodexCliUpdatePlan(planDeps({ scanProcesses: () => ({ kind: "unavailable" }) })); expect(plan.refusal).toBe("blocked_process_state_unknown"); @@ -199,6 +270,8 @@ describe("Codex CLI update plan identity", () => { // Two install roots sharing the redacted "/codex" display location // must still produce different plans. { inspect: async () => report({ installDigest: "b".repeat(64) }) }, + // Same install root and version, different observed content. + { inspect: async () => report({ installContentDigest: "d".repeat(64) }) }, ]; for (const variant of variants) { const plan = await applicablePlan(variant); @@ -209,7 +282,7 @@ describe("Codex CLI update plan identity", () => { test("an applicable plan binds only the declared evidence, excluding session state", async () => { // Independently computed SHA-256 vector for MANAGED + RESOLVED, schema 1. // Binding session state (or any undeclared field) changes this fixed oracle. - expect((await applicablePlan()).planId).toBe("65ce26ec550cbcd82dda68f0c86a0b4c"); + expect((await applicablePlan()).planId).toBe("b569dd23df14dd7ef69b11ab95692d98"); }); test("the id contains only the explicitly bound evidence", () => { @@ -218,11 +291,12 @@ describe("Codex CLI update plan identity", () => { provenance: "npm-global" as const, installedVersion: "1.0.0", installDigest: "a".repeat(64), + installContentDigest: "c".repeat(64), channel: "latest" as const, targetVersion: "1.1.0", targetIntegrity: "sha512-AAAA", }; - const expected = "65ce26ec550cbcd82dda68f0c86a0b4c"; + const expected = "b569dd23df14dd7ef69b11ab95692d98"; expect(codexCliUpdatePlanId(bound)).toBe(expected); const withSession = { ...bound, session: { state: "active", matches: 1 } }; expect(codexCliUpdatePlanId(withSession)).toBe(expected); @@ -474,15 +548,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 +689,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 { diff --git a/tests/codex-integration/codex-proc-scan-errors.test.ts b/tests/codex-integration/codex-proc-scan-errors.test.ts index d4916991d4e..861cefdd43a 100644 --- a/tests/codex-integration/codex-proc-scan-errors.test.ts +++ b/tests/codex-integration/codex-proc-scan-errors.test.ts @@ -98,6 +98,7 @@ describe("procfs process read failures", () => { selectionAttested: true, versionEvidence: { kind: "package-manifest" }, provenance: "npm-global", managed: true, reason: "managed_npm_global", location: "/@openai/codex", installDigest: "a".repeat(64), packageVersion: "1.0.0", shim: { status: "not-tracked", backingKind: null }, + installContentDigest: "c".repeat(64), evidence: ["package_manifest", "global_npm_layout"], }), resolveTarget: () => ({ kind: "resolved", version: "1.1.0", integrity: "sha512-AAAA" }), diff --git a/tests/helpers/codex-cli-content-fs.ts b/tests/helpers/codex-cli-content-fs.ts new file mode 100644 index 00000000000..bef0c687720 --- /dev/null +++ b/tests/helpers/codex-cli-content-fs.ts @@ -0,0 +1,212 @@ +import { posix } from "node:path"; + +import type { CodexCliInstallProvenanceDeps } from "../../src/codex/cli-install-provenance"; + +/** + * Minimal POSIX virtual filesystem for npm-global Codex install fixtures. + * + * Every path is absolute and lexical; only regular files and final-component + * symlinks exist (directories are implied by path prefixes, which is all the + * provenance inspection ever stats). stat follows the whole link chain, lstat + * reports the final component as written. Inode and mtime fields are plain + * numbers so a test can model a mid-observation identity change by mutating + * them between calls. + */ +export interface VirtualFileSpec { + readonly data?: string | Buffer; + readonly link?: string; + readonly mode?: number; + readonly ino?: number; + readonly mtimeMs?: number; +} + +interface VirtualFileEntry { + kind: "file"; + bytes: Buffer; + mode: number; + ino: number; + mtimeMs: number; + ctimeMs: number; +} + +interface VirtualSymlinkEntry { + kind: "symlink"; + target: string; +} + +type VirtualEntry = VirtualFileEntry | VirtualSymlinkEntry; + +const enoent = (): never => { + throw Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }); +}; + +export interface VirtualPosixFs { + readonly exists: (path: string) => boolean; + readonly lstat: (path: string) => unknown; + readonly stat: (path: string) => unknown; + readonly readFile: (path: string) => Buffer; + readonly realpath: (path: string) => string; + /** Replace a regular file's bytes in place, keeping inode and timestamps. */ + readonly setBytes: (path: string, data: string | Buffer) => void; + /** Change a file's recorded inode: simulates replacement between two stats. */ + readonly bumpIdentity: (path: string) => void; + /** Remove an entry entirely (next lookup is ENOENT). */ + readonly remove: (path: string) => void; + /** Repoint a path at another path: simulates an out-of-prefix redirect. */ + readonly redirect: (path: string, target: string) => void; + /** Materialize a new entry: simulates a package appearing mid-observation. */ + readonly create: (path: string, spec: VirtualFileSpec) => void; +} + +export function virtualPosixFs(initial: Record): VirtualPosixFs { + const entries = new Map(); + let nextIno = 1; + const norm = (path: string): string => posix.normalize(path); + const put = (path: string, spec: VirtualFileSpec) => { + entries.set(norm(path), spec.link !== undefined + ? { kind: "symlink", target: spec.link } + : { + kind: "file", + bytes: Buffer.from(spec.data ?? ""), + mode: spec.mode ?? 0o644, + ino: spec.ino ?? nextIno++, + mtimeMs: spec.mtimeMs ?? 1_700_000_000_000, + ctimeMs: spec.mtimeMs ?? 1_700_000_000_000, + }); + }; + for (const [path, spec] of Object.entries(initial)) put(path, spec); + + const resolvePath = (path: string): string => { + let current = norm(path); + const seen = new Set(); + for (;;) { + const entry = entries.get(current); + if (entry?.kind !== "symlink") return current; + if (seen.has(current)) { + throw Object.assign(new Error("ELOOP: too many levels of symbolic links"), { code: "ELOOP" }); + } + seen.add(current); + current = norm(posix.isAbsolute(entry.target) + ? entry.target + : posix.join(posix.dirname(current), entry.target)); + } + }; + + const statLike = (entry: VirtualEntry) => ({ + isFile: () => entry.kind === "file", + isSymbolicLink: () => entry.kind === "symlink", + isDirectory: () => false, + mode: entry.kind === "file" ? entry.mode : 0o777, + size: entry.kind === "file" ? entry.bytes.length : entry.target.length, + dev: 42, + ino: entry.kind === "file" ? entry.ino : 0, + mtimeMs: entry.kind === "file" ? entry.mtimeMs : 0, + ctimeMs: entry.kind === "file" ? entry.ctimeMs : 0, + }); + + const fileAt = (path: string): VirtualFileEntry => { + const resolved = entries.get(resolvePath(path)); + if (!resolved || resolved.kind !== "file") enoent(); + return resolved as VirtualFileEntry; + }; + + return { + exists: path => entries.get(resolvePath(path))?.kind === "file", + lstat: path => { + const entry = entries.get(norm(path)); + if (!entry) enoent(); + return statLike(entry); + }, + stat: path => statLike(fileAt(path)), + readFile: path => fileAt(path).bytes, + realpath: path => { + const resolved = resolvePath(path); + // Directories are implied by path prefixes rather than stored entries, so + // an existing path is either an entry or an ancestor of one. + if (!entries.has(resolved) && ![...entries.keys()].some(key => key.startsWith(resolved + "/"))) { + enoent(); + } + return resolved; + }, + setBytes: (path, data) => { + const entry = entries.get(norm(path)); + if (!entry || entry.kind !== "file") enoent(); + entry.bytes = Buffer.from(data); + }, + bumpIdentity: path => { + const entry = entries.get(norm(path)); + if (!entry || entry.kind !== "file") enoent(); + entry.ino = nextIno++; + }, + remove: path => { + entries.delete(norm(path)); + }, + redirect: (path, target) => { + entries.set(norm(path), { kind: "symlink", target }); + }, + create: (path, spec) => { + put(path, spec); + }, + }; +} + +export const VIRTUAL_NPM_PREFIX = "/virtual-npm"; +export const VIRTUAL_PACKAGE_ROOT = VIRTUAL_NPM_PREFIX + "/lib/node_modules/@openai/codex"; +export const VIRTUAL_LAUNCHER = VIRTUAL_NPM_PREFIX + "/bin/codex"; +export const VIRTUAL_ENTRYPOINT = VIRTUAL_PACKAGE_ROOT + "/bin/codex.js"; +export const VIRTUAL_MANIFEST = VIRTUAL_PACKAGE_ROOT + "/package.json"; +const VIRTUAL_BACKING_PACKAGE = VIRTUAL_PACKAGE_ROOT + "/node_modules/@openai/codex-linux-x64"; +export const VIRTUAL_BACKING_MANIFEST = VIRTUAL_BACKING_PACKAGE + "/package.json"; +export const VIRTUAL_BACKING_BINARY = VIRTUAL_BACKING_PACKAGE + "/vendor/x86_64-unknown-linux-musl/bin/codex"; +export const VIRTUAL_VENDOR_BINARY = VIRTUAL_PACKAGE_ROOT + "/vendor/x86_64-unknown-linux-musl/bin/codex"; + +export const VIRTUAL_MANIFEST_TEXT = JSON.stringify({ + name: "@openai/codex", + version: "1.2.3", + description: "synthetic fixture for content-binding tests", + bin: { codex: "bin/codex.js" }, +}, null, 2) + "\n"; +export const VIRTUAL_ENTRYPOINT_TEXT = "#!/usr/bin/env node\n// synthetic fixture entrypoint\n"; +export const VIRTUAL_BACKING_TEXT = "synthetic-native-backing-v1\n"; + +/** + * A managed-layout virtual install: launcher symlink to the package entrypoint, + * with the nested optional-package backing exactly where npm install -g puts it. + */ +export function virtualManagedNpmGlobal( + overrides: Record = {}, +): VirtualPosixFs { + return virtualPosixFs({ + [VIRTUAL_LAUNCHER]: { link: VIRTUAL_ENTRYPOINT }, + [VIRTUAL_MANIFEST]: { data: VIRTUAL_MANIFEST_TEXT }, + [VIRTUAL_ENTRYPOINT]: { data: VIRTUAL_ENTRYPOINT_TEXT, mode: 0o755 }, + [VIRTUAL_BACKING_MANIFEST]: { + data: JSON.stringify({ name: "@openai/codex-linux-x64", version: "1.2.3" }) + "\n", + }, + [VIRTUAL_BACKING_BINARY]: { data: VIRTUAL_BACKING_TEXT, mode: 0o755 }, + ...overrides, + }); +} + +/** Deps for the injected-test bounded-read seam against a virtual install. */ +export function virtualInspectionDeps( + fs: VirtualPosixFs, + overrides: Partial = {}, +): CodexCliInstallProvenanceDeps { + return { + platform: "linux", + arch: "x64", + configDir: "/virtual-opencodex", + env: { CODEX_CLI_PATH: VIRTUAL_LAUNCHER, PATH: "" }, + exists: fs.exists, + lstat: fs.lstat as CodexCliInstallProvenanceDeps["lstat"], + stat: fs.stat as CodexCliInstallProvenanceDeps["stat"], + readFile: fs.readFile, + realpath: fs.realpath, + boundedFileReadMode: "injected-test", + selectionAttested: true, + selectedCommand: VIRTUAL_LAUNCHER, + inspectShim: () => ({ status: "not-tracked" }), + ...overrides, + }; +}