diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index 5a585c60f0..957c7c760c 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -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", diff --git a/src/codex/cli-update-artifacts.ts b/src/codex/cli-update-artifacts.ts index fcd2ddeace..f40d83d045 100644 --- a/src/codex/cli-update-artifacts.ts +++ b/src/codex/cli-update-artifacts.ts @@ -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 { @@ -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; /** 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 { +export async function verifyCodexCliArchive(source: Buffer | string, target: CodexCliArtifact, + options: { windowsX64?: boolean } = {}): Promise { if (!isSha512Integrity(target.integrity)) throw new Error("artifact_integrity_mismatch"); let fd: number | undefined; let before: ReturnType | undefined; @@ -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(); + const windowsName = options.windowsX64 ? windowsArchivePathValidator() : undefined; let current: { name: string; size: number; mode: number; remaining: number; keep: boolean; - chunks: Buffer[]; hash: ReturnType } | undefined; + chunks: Buffer[]; prefix?: Buffer; hash: ReturnType } | undefined; let tracker: Transform | undefined, gunzip: ReturnType | undefined, pumping: Promise | undefined; let verified = false; try { @@ -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; @@ -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; diff --git a/src/codex/cli-update-windows-layout.ts b/src/codex/cli-update-windows-layout.ts new file mode 100644 index 0000000000..9e2186d61a --- /dev/null +++ b/src/codex/cli-update-windows-layout.ts @@ -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 => !!value && typeof value === "object" && !Array.isArray(value); +function manifest(files: CodexArchiveFiles): Record { + 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 { + 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; + }); +} diff --git a/src/codex/cli-update-windows-paths.ts b/src/codex/cli-update-windows-paths.ts new file mode 100644 index 0000000000..51f3db5454 --- /dev/null +++ b/src/codex/cli-update-windows-paths.ts @@ -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(); + 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 }); + } + }; +} diff --git a/structure/INDEX.md b/structure/INDEX.md index b8dc61f11b..b3b1690b2d 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -225,6 +225,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/quota.ts` | [`quota-snapshots.md`](quota-snapshots.md) | diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 2ada58b5fe..988c0d15b5 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -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. diff --git a/structure/manifest.json b/structure/manifest.json index 7005ed3784..8be5605de0 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -193,6 +193,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", diff --git a/tests/codex-integration/codex-cli-update-windows-layout.test.ts b/tests/codex-integration/codex-cli-update-windows-layout.test.ts new file mode 100644 index 0000000000..a65062ebf3 --- /dev/null +++ b/tests/codex-integration/codex-cli-update-windows-layout.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { gunzipSync, gzipSync } from "node:zlib"; +import { fixturePath } from "../helpers/repo-root"; +import { fixtureArtifact, fixtureArtifactSet, fixtureTar } from "../helpers/codex-cli-apply-artifact"; +import { verifiedCodexCliContent, verifyCodexCliArchive, type CodexCliArtifactSet } from "../../src/codex/cli-update-artifacts"; +import { matchesWindowsCodexCliLayout, verifyWindowsCodexCliLayout } from "../../src/codex/cli-update-windows-layout"; + +const MAIN = "node_modules/@openai/codex", BACKING = "node_modules/@openai/codex-win32-x64"; +const NATIVE = "vendor/x86_64-pc-windows-msvc/bin/codex.exe", PACKAGE = "@openai/codex-win32-x64"; +const fileDigest = (bytes: string | Buffer) => createHash("sha256").update("codex-cli-observed-file-v1\0").update(bytes).digest("hex"); +function pe(machine = 0x8664, size = 65539): Buffer { + const bytes = Buffer.alloc(size, 0x61); + bytes.write("MZ"); bytes.writeUInt32LE(128, 60); bytes.write("PE\0\0", 128); + bytes.writeUInt16LE(machine, 132); bytes.writeUInt16LE(1, 134); + bytes.writeUInt16LE(240, 148); bytes.writeUInt16LE(2, 150); bytes.writeUInt16LE(0x20b, 152); + return bytes; +} +function fixture(alias = true) { + const version = "1.2.3", backingVersion = alias ? `${version}-win32-x64` : version; + const backingName = alias ? "@openai/codex" : PACKAGE; + const mainFiles: Record = { + "package.json": JSON.stringify({ name: "@openai/codex", version, bin: { codex: "bin/codex.js" }, + optionalDependencies: { [PACKAGE]: alias ? `npm:@openai/codex@${backingVersion}` : version } }), + "bin/codex.js": "#!/usr/bin/env node\n// Synthetic entrypoint; never executed.\n", + "README.md": "main auxiliary\n", + }; + const backingFiles: Record = { + "package.json": JSON.stringify({ name: backingName, version: backingVersion, os: ["win32"], cpu: ["x64"] }), + [NATIVE]: pe(), "vendor/auxiliary.txt": "backing auxiliary\n", + }; + const build = () => { + const main = fixtureTar(mainFiles), backing = fixtureTar(backingFiles); + const set: CodexCliArtifactSet = { platform: "win32", arch: "x64", main: fixtureArtifact(main, version), + backing: fixtureArtifact(backing, backingVersion, PACKAGE, backingName) }; + return { main, backing, set }; + }; + return { mainFiles, backingFiles, build }; +} +const verify = (f: ReturnType) => { + const input = f.build(); return verifyWindowsCodexCliLayout(input.set, input.main, input.backing); +}; +function editManifest(files: Record, change: (manifest: Record) => void) { + const value = JSON.parse(String(files["package.json"])); change(value); files["package.json"] = JSON.stringify(value); +} +function rewriteTar(bytes: Buffer, change: (header: Buffer, name: string) => void): Buffer { + const tar = gunzipSync(bytes); + for (let offset = 0; offset < tar.length && tar[offset];) { + const header = tar.subarray(offset, offset + 512); + const name = header.subarray(0, 100).toString().replace(/\0.*$/s, ""); + const size = parseInt(header.subarray(124, 136).toString(), 8); + change(header, name); + header.fill(32, 148, 156); + header.write(header.reduce((sum, byte) => sum + byte, 0).toString(8).padStart(7, "0") + "\0", 148, 8); + offset += 512 + Math.ceil(size / 512) * 512; + } + return gzipSync(tar); +} + +describe("independent Windows x64 artifact layout", () => { + test.each([true, false])("verified %s alias backing yields only relative whole-file expectations", async alias => { + const f = fixture(alias), layout = await verify(f); + expect(layout).toMatchObject({ applicable: false, mutationAuthority: false, platform: "win32", arch: "x64", + mainRoot: MAIN, backingRoot: BACKING, nativePath: `${BACKING}/${NATIVE}`, nativeHeader: "PE32+-AMD64" }); + expect(layout.files).toHaveLength(9); + expect(layout.files.find(file => file.relativePath === `${BACKING}/${NATIVE}`)).toMatchObject({ + size: 65539, digest: fileDigest(f.backingFiles[NATIVE]!), + }); + expect(Object.isFrozen(layout)).toBe(true); expect(Object.isFrozen(layout.files)).toBe(true); + expect(layout.files.every(file => Object.isFrozen(file) && !file.relativePath.includes(":"))).toBe(true); + expect(matchesWindowsCodexCliLayout(layout, [...layout.files].reverse())).toBe(true); + }); + test("three exact npm shim expectations match independently captured cmd-shim 8 output", async () => { + // Offline byte-contract regression only. The fixture records the separately verified + // registry artifact, raw source hashes and actual exported-function generation receipt. + const capture = JSON.parse(readFileSync(fixturePath("codex-windows-npm-shims.json"), "utf8")); + const layout = await verify(fixture()); + expect(capture.source).toContain("cmd-shim@8.0.0"); + for (const name of ["codex", "codex.cmd", "codex.ps1"]) { + const bytes = Buffer.from(capture.files[name]); + expect(layout.files.find(file => file.relativePath === name)).toEqual({ relativePath: name, size: bytes.length, digest: fileDigest(bytes) }); + const wrong = layout.files.map(file => file.relativePath === name ? { ...file, + size: bytes.length + 1, digest: fileDigest(Buffer.concat([bytes, Buffer.from("x")])) } : file); + expect(matchesWindowsCodexCliLayout(layout, wrong)).toBe(false); + } + }); + test("the full verified stream binds native hash and retains at most 4 KiB, with no second file read", async () => { + const f = fixture(), input = f.build(); + const files = await verifyCodexCliArchive(input.backing, input.set.backing, { windowsX64: true }); + expect(files.get(NATIVE)!.bytes).toBeNull(); + expect(files.get(NATIVE)!.nativePrefix).toEqual((f.backingFiles[NATIVE] as Buffer).subarray(0, 4096)); + expect(files.get(NATIVE)!.digest).toBe(fileDigest(f.backingFiles[NATIVE]!)); + expect([...files].filter(([, file]) => file.nativePrefix !== undefined).map(([name]) => name)).toEqual([NATIVE]); + const plain = await verifyCodexCliArchive(input.backing, input.set.backing); + expect(plain.get(NATIVE)!.nativePrefix).toBeUndefined(); + }); + test.each(["arm64", "x86"])("other requested architecture %s is not admitted", async arch => { + const f = fixture().build(); + await expect(verifyWindowsCodexCliLayout({ ...f.set, arch }, f.main, f.backing)).rejects.toThrow("artifact_platform_unverified"); + }); + test.each(["missing", "arm64", "x86", "not-pe", "header-outside-prefix", "dll", "pe32", "truncated"])("native %s refuses", async kind => { + const f = fixture(); + if (kind === "missing") { delete f.backingFiles[NATIVE]; f.backingFiles[NATIVE.replace(/\.exe$/, "")] = pe(); } + else { + const bytes = pe(kind === "arm64" ? 0xaa64 : kind === "x86" ? 0x14c : 0x8664); + if (kind === "not-pe") bytes.write("??"); + if (kind === "header-outside-prefix") bytes.writeUInt32LE(4096, 60); + if (kind === "dll") bytes.writeUInt16LE(0x2002, 150); + if (kind === "pe32") bytes.writeUInt16LE(0x10b, 152); + f.backingFiles[NATIVE] = kind === "truncated" ? bytes.subarray(0, 150) : bytes; + } + await expect(verify(f)).rejects.toThrow("artifact_windows_native_unverified"); + }); + test.each(["wrong-bin", "extra-bin", "missing-js", "wrong-shebang", "wrong-link", "nested-package", "cpu", "exports"])("linkage %s refuses", async kind => { + const f = fixture(); + if (kind === "wrong-bin") editManifest(f.mainFiles, pkg => pkg.bin.codex = "evil.js"); + if (kind === "extra-bin") editManifest(f.mainFiles, pkg => pkg.bin.extra = "evil.js"); + if (kind === "missing-js") delete f.mainFiles["bin/codex.js"]; + if (kind === "wrong-shebang") f.mainFiles["bin/codex.js"] = "#!/usr/bin/env node --eval evil\n"; + if (kind === "wrong-link") editManifest(f.mainFiles, pkg => pkg.optionalDependencies[PACKAGE] = "latest"); + if (kind === "nested-package") f.mainFiles[`bin/node_modules/${PACKAGE}/package.json`] = "{}"; + if (kind === "cpu") editManifest(f.backingFiles, pkg => pkg.cpu = ["arm64"]); + if (kind === "exports") editManifest(f.backingFiles, pkg => pkg.exports = {}); + await expect(verify(f)).rejects.toThrow(); + }); + test.each(["README.MD", "BIN/extra", "bin", "name:stream", "NUL.txt", "COM1.log", "AUX/file", "CONIN$", "dir./file", + "trailing /file", "name?", "non-ascii-한글", "../escape", "bad\\path"])("Windows ambiguous or unsafe member %s refuses", async name => { + const f = fixture(); f.mainFiles[name] = "x"; + await expect(verify(f)).rejects.toThrow(); + }); + test("empty directory records receive the same Windows path validation", async () => { + const f = fixture(); f.mainFiles["NUL"] = ""; + const input = f.build(), main = rewriteTar(input.main, (header, name) => { if (name === "package/NUL") header.write("5", 156); }); + await expect(verifyWindowsCodexCliLayout({ ...input.set, main: fixtureArtifact(main, "1.2.3") }, main, input.backing)) + .rejects.toThrow("artifact_windows_path_unverified"); + }); + test("Windows ignores POSIX executable bits without changing the POSIX content validator", async () => { + const f = fixture().build(), clearMode = (header: Buffer) => { header.write("0000644\0", 100, 8); }; + const main = rewriteTar(f.main, clearMode), backing = rewriteTar(f.backing, clearMode); + const set = { ...f.set, main: fixtureArtifact(main, "1.2.3"), backing: fixtureArtifact(backing, "1.2.3-win32-x64", PACKAGE) }; + expect((await verifyWindowsCodexCliLayout(set, main, backing)).nativeHeader).toBe("PE32+-AMD64"); + const posix = fixtureArtifactSet(); + const posixMain = await verifyCodexCliArchive(posix.main, posix.artifacts.main); + expect(verifiedCodexCliContent(posix.artifacts, posixMain, + await verifyCodexCliArchive(posix.backing, posix.artifacts.backing))).toMatch(/^[a-f0-9]{64}$/); + const notExecutable = rewriteTar(posix.backing, clearMode); + const files = await verifyCodexCliArchive(notExecutable, fixtureArtifact(notExecutable, posix.artifacts.backing.version, posix.artifacts.backing.installName)); + expect(() => verifiedCodexCliContent(posix.artifacts, posixMain, files)).toThrow(); + }); + test("auxiliary content, missing/extra files and duplicate readback paths cannot share a full match", async () => { + const f = fixture(), before = await verify(f); + f.backingFiles["vendor/auxiliary.txt"] = "changed auxiliary\n"; + const after = await verify(f); + expect(before.contentDigest).not.toBe(after.contentDigest); + expect(matchesWindowsCodexCliLayout(before, after.files)).toBe(false); + expect(matchesWindowsCodexCliLayout(before, before.files.slice(1))).toBe(false); + expect(matchesWindowsCodexCliLayout(before, [...before.files, before.files[0]!])).toBe(false); + expect(matchesWindowsCodexCliLayout(before, [before.files[0]!, ...before.files.slice(0, -1)])).toBe(false); + }); + test("archive corruption and non-registry metadata refuse before any layout is returned", async () => { + const input = fixture().build(), corrupted = Buffer.from(input.backing); corrupted[corrupted.length - 1] ^= 1; + await expect(verifyWindowsCodexCliLayout(input.set, input.main, corrupted)).rejects.toThrow(); + const foreign = { ...input.set, main: { ...input.set.main, tarball: "https://example.invalid/archive.tgz" } }; + await expect(verifyWindowsCodexCliLayout(foreign, input.main, input.backing)).rejects.toThrow("artifact_metadata_unverified"); + }); +}); diff --git a/tests/fixtures/codex-windows-npm-shims.json b/tests/fixtures/codex-windows-npm-shims.json new file mode 100644 index 0000000000..47b335cd29 --- /dev/null +++ b/tests/fixtures/codex-windows-npm-shims.json @@ -0,0 +1,36 @@ +{ + "source": "npm/cmd-shim@8.0.0 (ISC); generated by the verified official package's exported cmdShim function", + "sourceSha256": "ec89ec47d22e7012336aa41cea1216b497aecd36f7802b30eebf3b8e6a298b12", + "files": { + "codex.ps1": "#!/usr/bin/env pwsh\n$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent\n\n$exe=\"\"\nif ($PSVersionTable.PSVersion -lt \"6.0\" -or $IsWindows) {\n # Fix case when both the Windows and Linux builds of Node\n # are installed in the same directory\n $exe=\".exe\"\n}\n$ret=0\nif (Test-Path \"$basedir/node$exe\") {\n # Support pipeline input\n if ($MyInvocation.ExpectingInput) {\n $input | & \"$basedir/node$exe\" \"$basedir/node_modules/@openai/codex/bin/codex.js\" $args\n } else {\n & \"$basedir/node$exe\" \"$basedir/node_modules/@openai/codex/bin/codex.js\" $args\n }\n $ret=$LASTEXITCODE\n} else {\n # Support pipeline input\n if ($MyInvocation.ExpectingInput) {\n $input | & \"node$exe\" \"$basedir/node_modules/@openai/codex/bin/codex.js\" $args\n } else {\n & \"node$exe\" \"$basedir/node_modules/@openai/codex/bin/codex.js\" $args\n }\n $ret=$LASTEXITCODE\n}\nexit $ret\n", + "codex.cmd": "@ECHO off\r\nGOTO start\r\n:find_dp0\r\nSET dp0=%~dp0\r\nEXIT /b\r\n:start\r\nSETLOCAL\r\nCALL :find_dp0\r\n\r\nIF EXIST \"%dp0%\\node.exe\" (\r\n SET \"_prog=%dp0%\\node.exe\"\r\n) ELSE (\r\n SET \"_prog=node\"\r\n SET PATHEXT=%PATHEXT:;.JS;=;%\r\n)\r\n\r\nendLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & \"%_prog%\" \"%dp0%\\node_modules\\@openai\\codex\\bin\\codex.js\" %*\r\n", + "codex": "#!/bin/sh\nbasedir=$(dirname \"$(echo \"$0\" | sed -e 's,\\\\,/,g')\")\n\ncase `uname` in\n *CYGWIN*|*MINGW*|*MSYS*)\n if command -v cygpath > /dev/null 2>&1; then\n basedir=`cygpath -w \"$basedir\"`\n fi\n ;;\nesac\n\nif [ -x \"$basedir/node\" ]; then\n exec \"$basedir/node\" \"$basedir/node_modules/@openai/codex/bin/codex.js\" \"$@\"\nelse \n exec node \"$basedir/node_modules/@openai/codex/bin/codex.js\" \"$@\"\nfi\n" + }, + "provenance": { + "metadataUrl": "https://registry.npmjs.org/cmd-shim/8.0.0", + "tarballUrl": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz", + "tarballIntegrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==", + "tarballBytes": 4589, + "tarballSha256": "4ce9f5ab6f0b5d360af270e0483063ec92329a1bf9970efc45a2c02f49d16f70", + "registryGitHead": "ea0f7bd72dfb64d203032073cefdbb9324adc261", + "sourceSha256Target": "package/lib/index.js raw archive member bytes (7706 bytes), without newline normalization", + "helperSource": { + "path": "package/lib/to-batch-syntax.js", + "bytes": 1558, + "sha256": "e39a03dac6e5e31c6c4bb58fab2c23e8aeeaacd53e0b8c63e742fe7f4ef476ec" + }, + "generation": { + "executed": true, + "runtime": "Bun 1.4.3", + "pathImplementation": "node:path.win32", + "from": "C:\\fixture\\node_modules\\@openai\\codex\\bin\\codex.js", + "to": "C:\\fixture\\codex", + "entrypointBytes": "#!/usr/bin/env node\n", + "method": "Loaded the two integrity-verified CommonJS sources; called the exported cmdShim function. fs/promises calls were intercepted and writeFile strings captured in memory.", + "comparison": "All three generated byte strings exactly matched these retained files and the production size/domain-hash expectations.", + "installationPerformed": false, + "lifecycleScriptsRun": false + }, + "verificationBoundary": "The offline fixture test checks retained output bytes; it does not fetch the package or re-establish registry provenance on each run." + } +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e479c218af..0312b85f09 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,6 @@ { "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "service-proxy-env.test.ts": "service", + "codex-cli-update-windows-layout.test.ts": "codex-integration", "codex-quota-persistence.test.ts": "codex-integration", "bridge-terminal-refusal.test.ts": "adapters", "codex-cli-update-artifacts.test.ts": "codex-integration",