Skip to content
Draft
1 change: 1 addition & 0 deletions scripts/test-layout/layout-codex-collision.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"codex-cli-update-windows-layout.test.ts": "codex-integration",
"codex-cli-windows-plan.test.ts": "codex-integration",
"codex-cli-update-artifacts.test.ts": "codex-integration",
"codex-cli-update-apply.test.ts": "codex-integration",
Expand Down
20 changes: 16 additions & 4 deletions src/codex/cli-update-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { parseStrictSemver } from "../lib/strict-semver";
import { codexCliContentFileDigest, codexCliContentSetDigest, codexCliPlatformBacking } from "./cli-install-provenance";
import { windowsArchivePathValidator, WINDOWS_CODEX_HEADER_BYTES, WINDOWS_CODEX_NATIVE_MEMBER } from "./cli-update-windows-paths";

export const CODEX_CLI_REGISTRY = "https://registry.npmjs.org";
export interface CodexCliArtifact {
Expand Down Expand Up @@ -90,14 +91,18 @@ export function codexCliArtifactSetDigest(set: CodexCliArtifactSet): string {
export const MAX_CODEX_ARCHIVE_BYTES = 256 * 1024 * 1024;
export const MAX_CODEX_UNPACKED_BYTES = 512 * 1024 * 1024;
export const MAX_CODEX_MEMBER_BYTES = 384 * 1024 * 1024;
export interface CodexArchiveFile { readonly size: number; readonly mode: number; readonly digest: string; readonly bytes: Buffer | null }
export interface CodexArchiveFile { readonly size: number; readonly mode: number; readonly digest: string; readonly bytes: Buffer | null;
/** Optional bounded prefix from the same integrity-verified member stream, never a second path read. */
readonly nativePrefix?: Buffer }
export type CodexArchiveFiles = Map<string, CodexArchiveFile>;
/** Stream gzip and tar. Only package.json (256 KiB) and the JS entrypoint (1 MiB)
* are retained; native binaries and other files contribute a streaming digest.
* Windows x64 opt-in additionally retains at most 4 KiB of its native member.
* Official 0.163.0 linux-x64: 162,055,867 compressed, 429,938,138 unpacked,
* largest member 296,865,480 bytes. Limits are independent, not a heap budget.
*/
export async function verifyCodexCliArchive(source: Buffer | string, target: CodexCliArtifact): Promise<CodexArchiveFiles> {
export async function verifyCodexCliArchive(source: Buffer | string, target: CodexCliArtifact,
options: { windowsX64?: boolean } = {}): Promise<CodexArchiveFiles> {
if (!isSha512Integrity(target.integrity)) throw new Error("artifact_integrity_mismatch");
let fd: number | undefined;
let before: ReturnType<typeof fstatSync> | undefined;
Expand All @@ -122,8 +127,9 @@ export async function verifyCodexCliArchive(source: Buffer | string, target: Cod
const header = Buffer.alloc(512);
const files: CodexArchiveFiles = new Map();
const names = new Set<string>();
const windowsName = options.windowsX64 ? windowsArchivePathValidator() : undefined;
let current: { name: string; size: number; mode: number; remaining: number; keep: boolean;
chunks: Buffer[]; hash: ReturnType<typeof createHash> } | undefined;
chunks: Buffer[]; prefix?: Buffer; hash: ReturnType<typeof createHash> } | undefined;
let tracker: Transform | undefined, gunzip: ReturnType<typeof createGunzip> | undefined, pumping: Promise<void> | undefined;
let verified = false;
try {
Expand Down Expand Up @@ -152,10 +158,13 @@ export async function verifyCodexCliArchive(source: Buffer | string, target: Cod
const bytes = chunk.subarray(position, position + take);
current.hash.update(bytes);
if (current.keep) current.chunks.push(Buffer.from(bytes));
const offset = current.size - current.remaining;
if (current.prefix && offset < current.prefix.length) bytes.copy(current.prefix, offset, 0, current.prefix.length - offset);
current.remaining -= take; position += take;
if (!current.remaining) {
files.set(current.name, { size: current.size, mode: current.mode, digest: current.hash.digest("hex"),
bytes: current.keep ? Buffer.concat(current.chunks) : null });
bytes: current.keep ? Buffer.concat(current.chunks) : null,
...(current.prefix ? { nativePrefix: current.prefix } : {}) });
padding = (512 - current.size % 512) % 512; current = undefined;
}
continue;
Expand All @@ -181,11 +190,14 @@ export async function verifyCodexCliArchive(source: Buffer | string, target: Cod
|| !["", "0", "5"].includes(type) || (type === "5" && size !== 0)
|| (type !== "5" && name.endsWith("/")) || size > MAX_CODEX_MEMBER_BYTES) throw new Error("artifact_tar_invalid");
names.add(name);
windowsName?.(name, type === "5");
if (type === "5") continue;
const relative = name.slice(8);
const keep = relative === "package.json" || relative === "bin/codex.js";
if (keep && size > (relative === "package.json" ? 256 * 1024 : 1024 * 1024)) throw new Error("artifact_metadata_limit");
current = { name: relative, size, mode, remaining: size, keep, chunks: [],
...(options.windowsX64 && relative === WINDOWS_CODEX_NATIVE_MEMBER
? { prefix: Buffer.alloc(Math.min(size, WINDOWS_CODEX_HEADER_BYTES)) } : {}),
hash: createHash("sha256").update("codex-cli-observed-file-v1\0") };
if (!size) {
files.set(relative, { size, mode, digest: current.hash.digest("hex"), bytes: keep ? Buffer.alloc(0) : null }); current = undefined;
Expand Down
110 changes: 110 additions & 0 deletions src/codex/cli-update-windows-layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { createHash } from "node:crypto";
import { backingReference, codexCliArtifactSet, codexCliArtifactSetDigest, verifyCodexCliArchive,
type CodexArchiveFiles, type CodexCliArtifactSet } from "./cli-update-artifacts";
import { WINDOWS_CODEX_HEADER_BYTES, WINDOWS_CODEX_NATIVE_MEMBER } from "./cli-update-windows-paths";

export interface WindowsCodexExpectedFile { readonly relativePath: string; readonly size: number; readonly digest: string }
export interface WindowsCodexArchiveLayout {
readonly schemaVersion: 1;
readonly applicable: false;
readonly mutationAuthority: false;
readonly platform: "win32";
readonly arch: "x64";
readonly nativeHeader: "PE32+-AMD64";
readonly shimContract: "npm-cmd-shim-8-node";
readonly mainRoot: "node_modules/@openai/codex";
readonly backingRoot: "node_modules/@openai/codex-win32-x64";
readonly nativePath: string;
readonly artifactsDigest: string;
readonly contentDigest: string;
readonly files: readonly WindowsCodexExpectedFile[];
}
const MAIN = "node_modules/@openai/codex", BACKING = "node_modules/@openai/codex-win32-x64";
const PACKAGE = "@openai/codex-win32-x64";
// Exact cmd-shim@8.0.0 (ISC) output for the supported node shebang/global-prefix linkage.
// Independent captured outputs live in tests/fixtures/codex-windows-npm-shims.json.
// This is a readback contract, not a shim writer or a claim about every npm version.
const SHIMS: readonly WindowsCodexExpectedFile[] = [
{ relativePath: "codex", size: 421, digest: "e4cfdce20603991a0cc30e79b7080277f934100a317f9c8b2779c0a2f8213ab2" },
{ relativePath: "codex.cmd", size: 341, digest: "0ea5e45d176e31ed39d5b9aad97b58afd6b41f40023508ae5362e151335db803" },
{ relativePath: "codex.ps1", size: 869, digest: "1f82c9b7d42cb3293d459d7304e67f9da76297d1cc33230a3f786c297ee0cc6a" },
];
const object = (value: unknown): value is Record<string, any> => !!value && typeof value === "object" && !Array.isArray(value);
function manifest(files: CodexArchiveFiles): Record<string, any> {
const bytes = files.get("package.json")?.bytes;
const value: unknown = bytes && JSON.parse(bytes.toString("utf8"));
if (!object(value)) throw new Error("artifact_manifest_unverified");
return value;
}
function validateNative(files: CodexArchiveFiles): void {
const native = files.get(WINDOWS_CODEX_NATIVE_MEMBER), prefix = native?.nativePrefix;
if (!native || !prefix || prefix.length !== Math.min(native.size, WINDOWS_CODEX_HEADER_BYTES)
|| prefix.length < 64 || prefix.toString("ascii", 0, 2) !== "MZ") throw new Error("artifact_windows_native_unverified");
const pe = prefix.readUInt32LE(60);
if (pe < 64 || pe > prefix.length - 26 || prefix.toString("ascii", pe, pe + 4) !== "PE\0\0"
|| prefix.readUInt16LE(pe + 4) !== 0x8664 || !prefix.readUInt16LE(pe + 6)
|| prefix.readUInt16LE(pe + 20) < 2 || pe + 24 + prefix.readUInt16LE(pe + 20) > native.size
|| !(prefix.readUInt16LE(pe + 22) & 2) || (prefix.readUInt16LE(pe + 22) & 0x2000)
|| prefix.readUInt16LE(pe + 24) !== 0x20b) throw new Error("artifact_windows_native_unverified");
}

/** Read-only artifact verification. Relative paths never select a live prefix or grant write authority.
* Uses the existing Windows package/Node lookup contract's sibling optional-package lane only.
* PE header evidence is not a signature, complete image validation or execution proof.
*/
export async function verifyWindowsCodexCliLayout(set: CodexCliArtifactSet, mainSource: Buffer | string,
backingSource: Buffer | string): Promise<WindowsCodexArchiveLayout> {
if (set.platform !== "win32" || set.arch !== "x64" || set.main.name !== "@openai/codex"
|| set.main.installName !== "@openai/codex" || set.backing.installName !== PACKAGE) throw new Error("artifact_platform_unverified");
const main = await verifyCodexCliArchive(mainSource, set.main, { windowsX64: true });
const backing = await verifyCodexCliArchive(backingSource, set.backing, { windowsX64: true });
const pkg = manifest(main), platform = manifest(backing);
// Reuse metadata origin/version and exact direct/alias linkage checks, rather than trusting a structural DTO.
codexCliArtifactSet({ ...pkg, dist: { integrity: set.main.integrity, tarball: set.main.tarball } },
() => ({ ...platform, dist: { integrity: set.backing.integrity, tarball: set.backing.tarball } }), "win32", "x64");
for (const name of Object.keys(pkg.optionalDependencies ?? {})) {
if (!/^@openai\/codex-(linux|darwin|win32)-(x64|arm64)$/.test(name)) throw new Error("artifact_dependency_unverified");
backingReference(pkg, name);
}
const bin = typeof pkg.bin === "string" ? pkg.bin
: object(pkg.bin) && Object.keys(pkg.bin).length === 1 ? pkg.bin.codex : null;
const entry = main.get("bin/codex.js")?.bytes;
if (typeof bin !== "string" || bin.replace(/^\.\//, "") !== "bin/codex.js" || !entry
|| entry.toString("utf8").split(/\r?\n/, 1)[0] !== "#!/usr/bin/env node"
|| platform.exports !== undefined || platform.bin !== undefined || Object.keys(platform.optionalDependencies ?? {}).length
|| (platform.os !== undefined && (!Array.isArray(platform.os) || platform.os.length !== 1 || platform.os[0] !== "win32"))
|| (platform.cpu !== undefined && (!Array.isArray(platform.cpu) || platform.cpu.length !== 1 || platform.cpu[0] !== "x64"))) {
throw new Error("artifact_backing_unverified");
}
validateNative(backing);
const files: WindowsCodexExpectedFile[] = SHIMS.map(file => ({ ...file }));
for (const [root, archive] of [[MAIN, main], [BACKING, backing]] as const) {
for (const [relative, proof] of archive) {
// npm aliases are installed as explicit sibling packages. No embedded package may shadow that lookup.
if (relative.split("/").some(part => part.toLowerCase() === "node_modules")) throw new Error("artifact_backing_unverified");
files.push({ relativePath: `${root}/${relative}`, size: proof.size, digest: proof.digest });
}
}
files.sort((a, b) => a.relativePath < b.relativePath ? -1 : a.relativePath > b.relativePath ? 1 : 0);
const artifactsDigest = codexCliArtifactSetDigest(set);
const contentDigest = createHash("sha256").update("codex-cli-windows-layout-v1\0")
.update(JSON.stringify([artifactsDigest, files])).digest("hex");
return Object.freeze({ schemaVersion: 1, applicable: false, mutationAuthority: false, platform: "win32", arch: "x64",
nativeHeader: "PE32+-AMD64", shimContract: "npm-cmd-shim-8-node", mainRoot: MAIN, backingRoot: BACKING,
nativePath: `${BACKING}/${WINDOWS_CODEX_NATIVE_MEMBER}`, artifactsDigest, contentDigest,
files: Object.freeze(files.map(file => Object.freeze(file))) });
}

/** Compare the two package roots and three shim paths, not unrelated files in the global prefix.
* No filesystem reads, mode guesses or partial matches.
* The caller still owes held-file/linkage/selection proof; equal content is not mutation authority.
*/
export function matchesWindowsCodexCliLayout(expected: WindowsCodexArchiveLayout,
observed: readonly WindowsCodexExpectedFile[]): boolean {
if (expected.files.length !== observed.length) return false;
const actual = new Map(observed.map(file => [file.relativePath, file]));
return actual.size === observed.length && expected.files.every(file => {
const other = actual.get(file.relativePath);
return other?.size === file.size && other.digest === file.digest;
});
}
26 changes: 26 additions & 0 deletions src/codex/cli-update-windows-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/** Archive names only, never filesystem resolution or installation authority. */
export const WINDOWS_CODEX_NATIVE_MEMBER = "vendor/x86_64-pc-windows-msvc/bin/codex.exe";
export const WINDOWS_CODEX_HEADER_BYTES = 4096;

/** ASCII is the supported archive namespace. Refuse unknown Windows case folding. */
export function windowsArchivePathValidator(): (name: string, directory: boolean) => void {
const seen = new Map<string, { spelling: string; directory: boolean; explicit: boolean }>();
return (name, directory) => {
const relative = directory && name.endsWith("/") ? name.slice(0, -1) : name;
const parts = relative.split("/");
if (relative.length > 8192 || parts.length > 64 || parts.some(part => !part || part === "." || part === ".."
|| /[^\x20-\x7e]|[<>:"\\|?*]/.test(part) || /[. ]$/.test(part)
|| /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)|^(?:conin\$|conout\$)$/i.test(part))) {
throw new Error("artifact_windows_path_unverified");
}
for (let i = 0; i < parts.length; i++) {
const spelling = parts.slice(0, i + 1).join("/"), key = spelling.toLowerCase();
const isDirectory = directory || i < parts.length - 1, explicit = i === parts.length - 1;
const prior = seen.get(key);
if (prior && (prior.spelling !== spelling || prior.directory !== isDirectory || prior.explicit && explicit)) {
throw new Error("artifact_windows_path_collision");
}
seen.set(key, { spelling, directory: isDirectory, explicit: explicit || !!prior?.explicit });
}
};
}
2 changes: 2 additions & 0 deletions structure/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@ A source area can be described by more than one doc, because these docs are orga
| `src/codex/cli-update-lease.ts` | [`codex-cli-update.md`](codex-cli-update.md) |
| `src/codex/cli-update-plan.ts` | [`codex-cli-update.md`](codex-cli-update.md) |
| `src/codex/cli-update-selection-gate.ts` | [`codex-cli-update.md`](codex-cli-update.md) |
| `src/codex/cli-update-windows-layout.ts` | [`codex-cli-update.md`](codex-cli-update.md) |
| `src/codex/cli-update-windows-paths.ts` | [`codex-cli-update.md`](codex-cli-update.md) |
| `src/codex/cli-update-worker.ts` | [`codex-cli-update.md`](codex-cli-update.md) |
| `src/codex/provider-routing.ts` | [`codex-provider-routing.md`](codex-provider-routing.md) |
| `src/codex/runtime.ts` | [`catalog-effort-clamp.md`](catalog-effort-clamp.md) |
Expand Down
25 changes: 25 additions & 0 deletions structure/codex-cli-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,31 @@ A completed Windows read-only plan has a deterministic ID and `applicable: false
`refusal: windows_apply_disabled`; active/unknown sessions still refuse earlier.
The independent Windows apply entrypoint remains refused before inspection or lease creation.

## Independent Windows archive layout

The independent `src/codex/cli-update-windows-layout.ts` verifies Windows x64 archives and
returns only prefix-relative expected files, sizes and content digests; `applicable` and
`mutationAuthority` remain false. No plan, selection, installer, lease or apply consumer uses it.
The main root is `node_modules/@openai/codex`; the explicit sibling backing is
`node_modules/@openai/codex-win32-x64`, with `vendor/x86_64-pc-windows-msvc/bin/codex.exe`.
Exact direct-version and npm-alias references retain the archive verifier's metadata/linkage rules.
An opt-in Windows archive mode uses `src/codex/cli-update-windows-paths.ts` to reject ASCII case
and file/directory collisions, ADS, device names, trailing dots/spaces and unknown non-ASCII paths,
including directory records. Embedded node_modules files cannot shadow the modeled backing.
The full native member still streams into its digest; at most 4 KiB from that same stream is retained
for the AMD64 PE32+ header check. This does not prove a complete valid image, signature or execution.
Native members remain unbuffered and Windows does not use POSIX executable-bit admission.
The exact codex/codex.cmd/codex.ps1 size/hash expectations model npm cmd-shim 8.0.0's node-shebang
output; tests/fixtures/codex-windows-npm-shims.json records the official npm tarball URL/integrity,
raw archive source-file hashes and actual exported-function generation with intercepted filesystem
calls. All three generated strings matched the retained bytes. No npm installation or lifecycle
script ran; the offline fixture test checks byte consistency, not registry provenance afresh.
Other shim grammars remain unsupported. All archive auxiliary files enter the expected set and digest, and
readback comparison requires the complete set. A future consumer must separately prove live held
file identities, package lookup, selected Node/shim and write authority; a matching DTO grants none.
Coverage: `tests/codex-integration/codex-cli-update-windows-layout.test.ts` uses only synthetic
archives and captured shim bytes, with no npm installation or native executable invocation.

## Explicit POSIX apply

`src/cli/codex-cli-update.ts` accepts `apply --plan-id <32 lowercase hex> --yes` with optional JSON output.
Expand Down
2 changes: 2 additions & 0 deletions structure/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@
"src/codex/cli-update-inspection-context.ts",
"bin/ocx.mjs",
"src/codex/cli-update-artifacts.ts",
"src/codex/cli-update-windows-layout.ts",
"src/codex/cli-update-windows-paths.ts",
"src/codex/cli-update-files.ts",
"src/codex/cli-update-lease.ts",
"src/codex/app-server-processes.ts",
Expand Down
Loading
Loading