From 55016cf64982d28af6ead42beb1b4694bc537f0d Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:18:46 +0800 Subject: [PATCH 1/2] fix: make Codex worktree setup Windows-safe --- AGENTS.md | 16 ++ README.md | 6 + docs/codex-cloud.md | 4 + docs/scripts-index.md | 2 +- package.json | 1 + scripts/setup-codex-worktree.mjs | 243 +++++++++++++++++++++++++++++ tests/setup-codex-worktree.test.ts | 97 ++++++++++++ 7 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 scripts/setup-codex-worktree.mjs create mode 100644 tests/setup-codex-worktree.test.ts diff --git a/AGENTS.md b/AGENTS.md index de8b57d632..721a3c8371 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,6 +161,22 @@ Babysit / Run PR ledger policy: do not push a tip whose sole delta is a babysit + + +# Codex Desktop worktree setup + +- The Windows Codex Desktop environment setup command is `node scripts/setup-codex-worktree.mjs`. + It must work before `node_modules` exists, validate Node 24/npm 11, reuse only a complete + byte-identical local installation, and otherwise run the locked npm install. +- Never configure Windows Desktop worktrees to run `bash scripts/setup-codex-cloud.sh`. That script + is Linux/Cloud-only; Windows launches it through WSL outside the worktree and cannot provision the + repository. +- `.codex/environments/environment.toml` is autogenerated and ignored. Change the Database + environment through Codex settings, then verify the effective command with the generated file and + `node scripts/setup-codex-worktree.mjs --dry-run`. + + + # Process hardening phases diff --git a/README.md b/README.md index bc4ea406ed..fc5098657f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ This is the clean-checkout and validation install contract. Use `npm install` only when intentionally changing dependencies and regenerating `package-lock.json`. +Codex Desktop worktrees use `npm run setup:codex-worktree`. The local bootstrap +reuses dependencies only from a complete worktree with a byte-identical lockfile, +then validates the installed metadata. It falls back to the locked install above +when no safe local donor exists. Do not configure Windows Desktop worktrees to run +the Cloud-only Bash setup script. + For Codex Cloud, use the tracked environment setup and acceptance contract in [`docs/codex-cloud.md`](docs/codex-cloud.md). It installs the complete repository toolchain and distinguishes safe offline tasks from explicitly connected provider tasks. diff --git a/docs/codex-cloud.md b/docs/codex-cloud.md index 54bdcc308e..1642b361e5 100644 --- a/docs/codex-cloud.md +++ b/docs/codex-cloud.md @@ -1,5 +1,9 @@ # Codex Cloud environment +> This Bash setup is for Linux-based Codex Cloud environments. Codex Desktop on +> Windows uses `npm run setup:codex-worktree`; pointing Desktop at this Cloud +> script starts WSL outside the Windows worktree and cannot provision it. + This repository supports reproducible Codex Cloud work with Node 24, npm 11, locked development dependencies, Deno 2, Python/OCR tooling, and the Chromium, Firefox, and WebKit Playwright browser matrix. The repository setup can prepare and validate the diff --git a/docs/scripts-index.md b/docs/scripts-index.md index e849f39432..cc985ef45f 100644 --- a/docs/scripts-index.md +++ b/docs/scripts-index.md @@ -1,6 +1,6 @@ # Scripts index -Curated map of `scripts/` (199 files) and the `package.json` script surface (212 entries), +Curated map of `scripts/` (200 files) and the `package.json` script surface (213 entries), grouped by purpose. This is orientation, not an exhaustive per-file listing — the authoritative command list is `package.json`, and `npm run docs:check-scripts` verifies every `npm run ` referenced in docs resolves to a real script. `npm run docs:update` refreshes the exact counts above. diff --git a/package.json b/package.json index 26f6d51b8e..55c6670f97 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "preinstall": "node scripts/check-node-engine.cjs", "postinstall": "node scripts/install-git-hooks.mjs", "hooks:install": "node scripts/install-git-hooks.mjs", + "setup:codex-worktree": "node scripts/setup-codex-worktree.mjs", "guard:push": "node scripts/guard-push.mjs", "guard:push:self-test": "node scripts/guard-push.mjs --self-test", "check:base-freshness": "node scripts/check-base-freshness.mjs", diff --git a/scripts/setup-codex-worktree.mjs b/scripts/setup-codex-worktree.mjs new file mode 100644 index 0000000000..498c3ffda9 --- /dev/null +++ b/scripts/setup-codex-worktree.mjs @@ -0,0 +1,243 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { cpSync, existsSync, readFileSync, rmSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +import { installedLockParity } from "./check-installed-lock-parity.mjs"; + +const logPrefix = "[codex-worktree:setup]"; + +function log(message) { + console.log(`${logPrefix} ${message}`); +} + +function fail(message) { + console.error(`${logPrefix} ERROR: ${message}`); + process.exit(1); +} + +export function parseWorktreeList(output) { + return output + .split(/\r?\n/u) + .filter((line) => line.startsWith("worktree ")) + .map((line) => path.resolve(line.slice("worktree ".length).trim())); +} + +export function lockDigest(projectRoot) { + return createHash("sha256") + .update(readFileSync(path.join(projectRoot, "package-lock.json"))) + .digest("hex"); +} + +function packageEntries(lock) { + return Object.entries(lock.packages ?? {}).filter( + ([packagePath, entry]) => packagePath.startsWith("node_modules/") && entry?.version, + ); +} + +export function installedMetadataMatches(projectRoot) { + const installedLockPath = path.join(projectRoot, "node_modules", ".package-lock.json"); + if (!existsSync(installedLockPath)) return false; + + try { + const expected = JSON.parse(readFileSync(path.join(projectRoot, "package-lock.json"), "utf8")); + const installed = JSON.parse(readFileSync(installedLockPath, "utf8")); + const expectedPackages = new Map(packageEntries(expected).map(([packagePath, entry]) => [packagePath, entry])); + const installedPackages = new Map(packageEntries(installed).map(([packagePath, entry]) => [packagePath, entry])); + return ( + [...installedPackages].every( + ([packagePath, entry]) => expectedPackages.get(packagePath)?.version === entry.version, + ) && + [...expectedPackages].every( + ([packagePath, entry]) => entry.optional || installedPackages.get(packagePath)?.version === entry.version, + ) + ); + } catch { + return false; + } +} + +export function installationIsComplete(projectRoot, packageNames) { + try { + return ( + installedMetadataMatches(projectRoot) && installedLockParity(projectRoot, packageNames).every((entry) => entry.ok) + ); + } catch { + return false; + } +} + +function samePath(left, right) { + const normalizedLeft = path.resolve(left); + const normalizedRight = path.resolve(right); + return process.platform === "win32" + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; +} + +export function findDependencyDonor(worktrees, currentRoot, packageNames) { + const expectedDigest = lockDigest(currentRoot); + for (const candidate of worktrees) { + if (samePath(candidate, currentRoot)) continue; + if (!existsSync(path.join(candidate, "package-lock.json"))) continue; + if (!existsSync(path.join(candidate, "node_modules"))) continue; + try { + if (lockDigest(candidate) !== expectedDigest) continue; + if (installationIsComplete(candidate, packageNames)) return candidate; + } catch { + // A concurrently removed or incomplete worktree is not a donor. + } + } + return null; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + encoding: "utf8", + shell: false, + stdio: options.capture ? "pipe" : "inherit", + }); + if (result.error) fail(`${command} could not start: ${result.error.message}`); + return result; +} + +export function resolveNpmCli(environment = process.env) { + const candidates = [ + environment.APPDATA && path.join(environment.APPDATA, "npm", "node_modules", "npm", "bin", "npm-cli.js"), + path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"), + ].filter(Boolean); + return candidates.find((candidate) => existsSync(candidate)) ?? null; +} + +function runNpm(args, options = {}) { + const npmCli = resolveNpmCli(); + if (npmCli) return run(process.execPath, [npmCli, ...args], options); + if (process.platform === "win32") fail("Could not locate npm-cli.js for the active Windows Node runtime."); + return run("npm", args, options); +} + +function assertRuntime(projectRoot) { + const expectedNodeMajor = readFileSync(path.join(projectRoot, ".node-version"), "utf8").trim(); + const packageJson = JSON.parse(readFileSync(path.join(projectRoot, "package.json"), "utf8")); + const expectedNpm = String(packageJson.packageManager ?? "").replace(/^npm@/u, ""); + const actualNodeMajor = process.versions.node.split(".")[0]; + const npmResult = runNpm(["--version"], { cwd: projectRoot, capture: true }); + const actualNpm = npmResult.stdout?.trim(); + + if (actualNodeMajor !== expectedNodeMajor) { + fail(`Node ${expectedNodeMajor}.x is required; detected ${process.versions.node}.`); + } + if (npmResult.status !== 0 || actualNpm !== expectedNpm) { + fail(`npm ${expectedNpm} is required; detected ${actualNpm || "unavailable"}.`); + } +} + +function removePartialInstall(projectRoot) { + const target = path.resolve(projectRoot, "node_modules"); + const expected = path.join(path.resolve(projectRoot), "node_modules"); + if (!samePath(target, expected)) fail(`Refusing to remove unexpected dependency path ${target}.`); + rmSync(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 500 }); +} + +function copyDependencies(donor, projectRoot) { + const source = path.join(donor, "node_modules"); + const destination = path.join(projectRoot, "node_modules"); + removePartialInstall(projectRoot); + + if (process.platform === "win32") { + const excluded = [".cache", ".vite", ".tmp"].map((name) => path.join(source, name)); + const result = run( + "robocopy.exe", + [ + source, + destination, + "/E", + "/COPY:DAT", + "/DCOPY:DAT", + "/R:2", + "/W:1", + "/MT:32", + "/NFL", + "/NDL", + "/NP", + "/XD", + ...excluded, + ], + { cwd: projectRoot, capture: true }, + ); + if (result.status === null || result.status > 7) { + fail(`robocopy failed with exit ${result.status ?? "unknown"}.\n${result.stderr || result.stdout || ""}`); + } + return; + } + + cpSync(source, destination, { + recursive: true, + filter: (sourcePath) => ![".cache", ".vite", ".tmp"].includes(path.basename(sourcePath)), + }); +} + +function installHooks(projectRoot) { + const result = run(process.execPath, ["scripts/install-git-hooks.mjs"], { cwd: projectRoot }); + if (result.status !== 0) fail(`Git hook setup failed with exit ${result.status ?? "unknown"}.`); +} + +export function main(projectRoot = process.cwd(), options = {}) { + const root = path.resolve(projectRoot); + if (!existsSync(path.join(root, ".git")) || !existsSync(path.join(root, "package-lock.json"))) { + fail("Run this script from a Database worktree."); + } + assertRuntime(root); + + const currentInstallationComplete = installationIsComplete(root); + if (options.dryRun && currentInstallationComplete) { + log("DRY RUN: existing dependencies match package-lock.json."); + return; + } + if (currentInstallationComplete) { + installHooks(root); + log("PASS: existing dependencies match package-lock.json."); + return; + } + + const worktreeResult = run("git", ["worktree", "list", "--porcelain"], { cwd: root, capture: true }); + if (worktreeResult.status !== 0) fail("Could not enumerate local Git worktrees."); + const donor = findDependencyDonor(parseWorktreeList(worktreeResult.stdout), root); + + if (options.dryRun) { + log( + donor + ? `DRY RUN: would reuse byte-identical dependencies from ${donor}.` + : "DRY RUN: no complete byte-identical donor; would run locked npm installation.", + ); + return; + } + + if (donor) { + log(`Reusing byte-identical dependencies from ${donor}.`); + copyDependencies(donor, root); + } else { + log("No complete byte-identical local install found; running locked npm installation."); + removePartialInstall(root); + const installResult = runNpm(["ci", "--include=dev", "--prefer-offline", "--no-audit", "--no-fund"], { + cwd: root, + }); + if (installResult.status !== 0) fail(`npm ci failed with exit ${installResult.status ?? "unknown"}.`); + } + + if (!installationIsComplete(root)) fail("Installed dependencies do not match package-lock.json."); + installHooks(root); + log("PASS: worktree dependencies match package-lock.json."); +} + +const isDirectExecution = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isDirectExecution) { + const args = process.argv.slice(2); + if (args.some((arg) => arg !== "--dry-run")) fail(`Unknown option: ${args.find((arg) => arg !== "--dry-run")}`); + main(process.cwd(), { dryRun: args.includes("--dry-run") }); +} diff --git a/tests/setup-codex-worktree.test.ts b/tests/setup-codex-worktree.test.ts new file mode 100644 index 0000000000..27de13f637 --- /dev/null +++ b/tests/setup-codex-worktree.test.ts @@ -0,0 +1,97 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + findDependencyDonor, + installationIsComplete, + installedMetadataMatches, + lockDigest, + parseWorktreeList, + resolveNpmCli, +} from "../scripts/setup-codex-worktree.mjs"; + +const temporaryRoots: string[] = []; + +function fixture(version = "1.0.0", complete = false) { + const root = mkdtempSync(path.join(os.tmpdir(), "codex-worktree-setup-")); + temporaryRoots.push(root); + const packages = { + "": { name: "fixture" }, + "node_modules/next": { version }, + "node_modules/react": { version: "19.2.8" }, + }; + writeFileSync(path.join(root, "package-lock.json"), JSON.stringify({ lockfileVersion: 3, packages })); + if (complete) { + mkdirSync(path.join(root, "node_modules", "next"), { recursive: true }); + mkdirSync(path.join(root, "node_modules", "react"), { recursive: true }); + writeFileSync(path.join(root, "node_modules", "next", "package.json"), JSON.stringify({ version })); + writeFileSync(path.join(root, "node_modules", "react", "package.json"), JSON.stringify({ version: "19.2.8" })); + writeFileSync( + path.join(root, "node_modules", ".package-lock.json"), + JSON.stringify({ + lockfileVersion: 3, + packages: { + "node_modules/next": { version }, + "node_modules/react": { version: "19.2.8" }, + }, + }), + ); + } + return root; +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("Codex Desktop worktree setup", () => { + it("parses worktree paths without treating metadata as paths", () => { + expect(parseWorktreeList("worktree C:/repo/main\nHEAD abc\n\nworktree C:/repo/feature\ndetached\n")).toEqual([ + path.resolve("C:/repo/main"), + path.resolve("C:/repo/feature"), + ]); + }); + + it("requires installed metadata and critical packages", () => { + const incomplete = fixture("16.2.12"); + const complete = fixture("16.2.12", true); + mkdirSync(path.join(incomplete, "node_modules"), { recursive: true }); + writeFileSync( + path.join(incomplete, "node_modules", ".package-lock.json"), + JSON.stringify({ lockfileVersion: 3, packages: { "node_modules/next": { version: "16.2.12" } } }), + ); + expect(installedMetadataMatches(incomplete)).toBe(false); + expect(installationIsComplete(incomplete)).toBe(false); + expect(installedMetadataMatches(complete)).toBe(true); + expect(installationIsComplete(complete, ["next"])).toBe(true); + }); + + it("uses only a complete donor with a byte-identical lockfile", () => { + const current = fixture("16.2.12"); + const wrongLock = fixture("16.2.11", true); + const exactLock = fixture("16.2.12", true); + + expect(lockDigest(wrongLock)).not.toBe(lockDigest(current)); + expect(lockDigest(exactLock)).toBe(lockDigest(current)); + expect(findDependencyDonor([wrongLock, exactLock], current, ["next"])).toBe(exactLock); + }); + + it("resolves npm's JavaScript entrypoint without executing a Windows command shim", () => { + expect(resolveNpmCli()).toMatch(/npm-cli\.js$/u); + }); + + it("keeps the Windows Desktop bootstrap distinct from the Cloud Bash setup", () => { + const packageJson = JSON.parse(readFileSync(path.resolve("package.json"), "utf8")) as { + scripts: Record; + }; + const agentInstructions = readFileSync(path.resolve("AGENTS.md"), "utf8"); + const cloudDocumentation = readFileSync(path.resolve("docs/codex-cloud.md"), "utf8"); + + expect(packageJson.scripts["setup:codex-worktree"]).toBe("node scripts/setup-codex-worktree.mjs"); + expect(agentInstructions).toContain("Never configure Windows Desktop worktrees"); + expect(agentInstructions).toContain("node scripts/setup-codex-worktree.mjs --dry-run"); + expect(cloudDocumentation).toContain("This Bash setup is for Linux-based Codex Cloud environments"); + }); +}); From 3200613979a5e9f84f1a1b6b2ca9b322ae569448 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:19:07 +0800 Subject: [PATCH 2/2] Update tests/setup-codex-worktree.test.ts Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/setup-codex-worktree.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/setup-codex-worktree.test.ts b/tests/setup-codex-worktree.test.ts index 27de13f637..cbd3ad2d24 100644 --- a/tests/setup-codex-worktree.test.ts +++ b/tests/setup-codex-worktree.test.ts @@ -79,7 +79,8 @@ describe("Codex Desktop worktree setup", () => { }); it("resolves npm's JavaScript entrypoint without executing a Windows command shim", () => { - expect(resolveNpmCli()).toMatch(/npm-cli\.js$/u); + const resolved = resolveNpmCli(); + expect(resolved === null || /npm-cli\.js$/u.test(resolved)).toBe(true); }); it("keeps the Windows Desktop bootstrap distinct from the Cloud Bash setup", () => {