From 7a33203073936ac3cc20e755bc6f38e19496168a Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 03:39:17 +0000 Subject: [PATCH 01/21] ci: balance test shards and cache Windows dependencies --- .github/workflows/node-ci.yml | 32 +++++-- sdk/typescript/TESTING.md | 38 +++++++- sdk/typescript/package.json | 2 +- sdk/typescript/scripts/run-ci-tests.mjs | 67 +++++++++++++ .../scripts/run-windows-ci-tests.mjs | 91 ------------------ sdk/typescript/scripts/test-shards.mjs | 38 ++++++++ sdk/typescript/tests-ts/auth.test.ts | 28 +++--- .../tests-ts/cli-cloud-publish.test.ts | 2 +- sdk/typescript/tests-ts/cli-publish.test.ts | 2 +- sdk/typescript/tests-ts/cloud-publish.test.ts | 2 +- .../tests-ts/component-scan.test.ts | 4 +- .../tests-ts/publication-integration.test.ts | 2 +- sdk/typescript/tests-ts/publish.test.ts | 2 +- sdk/typescript/tests-ts/skeleton.test.ts | 7 +- sdk/typescript/tests-ts/test-reports.test.ts | 94 ++++++++++++++++++- 15 files changed, 286 insertions(+), 125 deletions(-) create mode 100644 sdk/typescript/scripts/run-ci-tests.mjs delete mode 100644 sdk/typescript/scripts/run-windows-ci-tests.mjs create mode 100644 sdk/typescript/scripts/test-shards.mjs diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index f75ab0511..7c4879336 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -172,7 +172,8 @@ jobs: TMP: ${{ runner.temp }} TMPDIR: ${{ runner.temp }} CODEX_SECURITY_ALLOW_MACHINE_POLICY_TEST: "false" - run: pnpm --dir sdk/typescript run ${{ matrix.os == 'ubuntu-latest' && matrix.node == '22.13.0' && 'test:ci' || 'test' }} + working-directory: sdk/typescript + run: ${{ matrix.os == 'ubuntu-latest' && matrix.node == '22.13.0' && 'pnpm run test:ci' || 'node scripts/run-ci-tests.mjs' }} - name: Upload test reports if: always() && matrix.os == 'ubuntu-latest' && matrix.node == '22.13.0' @@ -182,8 +183,8 @@ jobs: name: node-22-test-reports overwrite: true path: | - sdk/typescript/reports/junit.xml - sdk/typescript/coverage/lcov.info + sdk/typescript/reports/junit-*.xml + sdk/typescript/coverage/shard-*/lcov.info if-no-files-found: warn retention-days: 14 @@ -239,7 +240,11 @@ jobs: node-version: ${{ matrix.node }} - name: Set up pnpm - run: npm install --global pnpm@11.9.0 --no-audit --no-fund + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: sdk/typescript/package.json + cache: true + cache_dependency_path: sdk/typescript/pnpm-lock.yaml - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 @@ -262,7 +267,7 @@ jobs: TMP: ${{ steps.windows-temp.outputs.path }} TMPDIR: ${{ steps.windows-temp.outputs.path }} CODEX_SECURITY_ALLOW_MACHINE_POLICY_TEST: "false" - run: node sdk/typescript/scripts/run-windows-ci-tests.mjs ${{ matrix.shard }} + run: node sdk/typescript/scripts/run-ci-tests.mjs ${{ matrix.shard }}/7 - name: Test machine-wide PowerShell policy if: matrix.shard == 3 && runner.environment == 'github-hosted' @@ -275,6 +280,17 @@ jobs: CODEX_SECURITY_ALLOW_MACHINE_POLICY_TEST: "true" run: bun test --timeout 120000 ./tests-ts/windows-machine-policy.test.ts + - name: Upload Windows test report + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-test-report-node-${{ matrix.node }}-${{ matrix.shard }} + overwrite: true + path: sdk/typescript/reports/junit-*.xml + if-no-files-found: warn + retention-days: 14 + windows-verify: name: windows-latest / node-${{ matrix.node == '22.13.0' && '22' || matrix.node }} / verify needs: validate-title @@ -300,7 +316,11 @@ jobs: cache-dependency-path: sdk/typescript/pnpm-lock.yaml - name: Set up pnpm - run: npm install --global pnpm@11.9.0 --no-audit --no-fund + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: sdk/typescript/package.json + cache: true + cache_dependency_path: sdk/typescript/pnpm-lock.yaml - name: Install dependencies working-directory: sdk/typescript diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index 3fb473480..ecfc1c19e 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -22,8 +22,9 @@ Bun's summary to `pnpm run test --seed 12345`. The local test commands pass a 30-second per-test timeout explicitly. Windows CI and the Windows runner experiment allow 120 seconds for slower native -credential and document checks. `test:ci` writes `reports/junit.xml` and -`coverage/lcov.info`. Coverage measures loaded +credential and document checks. `test:ci` runs four independent Bun processes +and writes `reports/junit-*.xml` and `coverage/shard-*/lcov.info`. These are +per-shard reports, not four measurements of the full suite. Coverage measures loaded JavaScript and TypeScript, not the Python helpers or child processes. It is diagnostic for now. Use several successful CI runs to establish a baseline before proposing a coverage floor. @@ -60,11 +61,38 @@ default to 100 cases; filesystem contract properties default to 20. `node-ci` retains the required `ubuntu-latest / node-22`, `macos-latest / node-22`, and `windows-latest / node-22` checks. Its Ubuntu -Node 22 job runs static checks and uploads JUnit and LCOV. All supported runtime -lanes still test and inspect an installed package. Package inspection includes +Node 22 job runs static checks and uploads JUnit and LCOV. Every existing OS and +Node runtime lane still runs the full suite: tests also launch Node subprocesses, +so changing Node can affect more than the Bun test runtime. All supported runtime +lanes still inspect an installed package. Package inspection includes a strict NodeNext TypeScript consumer and the actual installed CLI. Failed tests block CI; a failed diagnostic upload does not. +`scripts/run-ci-tests.mjs` discovers the test files and balances them using +rounded timings in `scripts/test-shards.mjs`. Unix jobs run four processes; +Windows runs seven separate jobs with `node scripts/run-ci-tests.mjs 1/7` +(substitute the shard number). Each file runs once. New files are included +automatically; stale timing estimates can affect balance, but not coverage. +The machine-wide Windows policy test still runs separately and serially. +Windows dependency installation uses the same pinned pnpm action and store +cache as Unix. + +To compare the serial and CI runners on the same machine, commit, and seed: + +```sh +mkdir -p reports +pnpm run test --seed 12345 --reporter=junit --reporter-outfile=reports/baseline.xml +node scripts/run-ci-tests.mjs --seed 12345 +python3 scripts/compare-test-reports.py reports/baseline.xml 'reports/junit-*.xml' +``` + +Use a clean reports directory and compare wall time as well as the test +identities and outcomes. Run both commands with the same coverage options +when measuring coverage overhead. The Windows serial baseline includes a +skipped machine-policy case that the shard runner excludes; compare that +case separately. Update the timing estimates only when reports show a +meaningful imbalance, not on every timing fluctuation. + The separate `test-quality` workflow runs weekly, can be dispatched manually, and runs on pull requests that change its workflow file. It compares Bun's default runner, `--isolate`, `--parallel=2`, and seven-way Windows sharding. @@ -80,7 +108,7 @@ that breaks the Ink UI tests under isolation. Keep the trial pin until a newer release passes the full SDK suite in every mode. Required CI and the mutation trial remain on Bun 1.3.14. -Keep the current file-balanced Windows runner until the native runner has +Keep the file-balanced CI runner until the native runner has matching inventories and acceptable Windows timings. Before promotion, compare native and file-balanced shards using the same commit and Bun version. Keep the machine-policy test serial. Do not replace the full required suite diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 26ba4cc99..e7c752126 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -49,7 +49,7 @@ "lint": "tsc --noEmit", "prepack": "node --run build", "test": "bun test --timeout 30000 ./tests-ts", - "test:ci": "node -e \"require('node:fs').mkdirSync('reports',{recursive:true})\" && pnpm run test --coverage --coverage-reporter=text --coverage-reporter=lcov --reporter=junit --reporter-outfile=reports/junit.xml", + "test:ci": "node scripts/run-ci-tests.mjs --coverage --coverage-reporter=text --coverage-reporter=lcov", "test:mutation": "stryker run", "test:package": "node scripts/smoke-package.mjs", "types": "pnpm run generate:models:check && tsc --noEmit" diff --git a/sdk/typescript/scripts/run-ci-tests.mjs b/sdk/typescript/scripts/run-ci-tests.mjs new file mode 100644 index 000000000..38ae3b35e --- /dev/null +++ b/sdk/typescript/scripts/run-ci-tests.mjs @@ -0,0 +1,67 @@ +import { spawn } from "node:child_process"; +import { mkdir, readdir } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { partitionTestFiles } from "./test-shards.mjs"; + +const packageDirectory = fileURLToPath(new URL("../", import.meta.url)); +const args = process.argv.slice(2); +// Unix jobs run four independent Bun processes. Windows jobs each run one of +// seven shards, so native credential checks do not compete on the same host. +const selection = + args[0] !== undefined && !args[0].startsWith("-") ? args.shift() : undefined; +const [requestedShard, count] = selection?.split("/").map(Number) ?? [0, 4]; +if ( + (selection !== undefined && !/^\d+\/\d+$/u.test(selection)) || + !Number.isSafeInteger(count) || + count < 1 || + !Number.isSafeInteger(requestedShard) || + requestedShard < (selection === undefined ? 0 : 1) || + requestedShard > count +) { + throw new Error( + "Usage: node scripts/run-ci-tests.mjs [shard/count] [Bun test options]", + ); +} +const files = ( + await readdir(new URL("../tests-ts/", import.meta.url), { recursive: true }) +).filter( + (file) => + file.endsWith(".test.ts") && + (process.platform !== "win32" || file !== "windows-machine-policy.test.ts"), +); +const shards = partitionTestFiles(files, count); +const selectedShards = shards + .map((files, index) => ({ files, shard: index + 1 })) + .filter(({ shard }) => requestedShard === 0 || requestedShard === shard); +if (selectedShards.some(({ files }) => files.length === 0)) { + throw new Error("CI test shards must not be empty."); +} +await mkdir(new URL("../reports/", import.meta.url), { recursive: true }); + +const results = await Promise.all( + selectedShards.map(async ({ files, shard }) => { + console.log(`CI test shard ${shard}/${count}: ${files.join(" ")}`); + return await new Promise((resolve) => { + const child = spawn( + "bun", + [ + "test", + "--timeout", + process.platform === "win32" ? "120000" : "30000", + ...files.map((file) => "./tests-ts/" + file), + ...args, + "--reporter=junit", + `--reporter-outfile=reports/junit-${shard}.xml`, + `--coverage-dir=coverage/shard-${shard}`, + ], + { cwd: packageDirectory, stdio: "inherit", windowsHide: true }, + ); + child.once("error", (error) => { + console.error(error); + resolve(1); + }); + child.once("close", (code) => resolve(code ?? 1)); + }); + }), +); +if (results.some((code) => code !== 0)) process.exitCode = 1; diff --git a/sdk/typescript/scripts/run-windows-ci-tests.mjs b/sdk/typescript/scripts/run-windows-ci-tests.mjs deleted file mode 100644 index 83b792e81..000000000 --- a/sdk/typescript/scripts/run-windows-ci-tests.mjs +++ /dev/null @@ -1,91 +0,0 @@ -import { spawn } from "node:child_process"; -import { readdir } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; - -const testsDirectory = new URL("../tests-ts/", import.meta.url); -const packageDirectory = fileURLToPath(new URL("../", import.meta.url)); -const tests = (await readdir(testsDirectory)) - .filter( - (file) => - file.endsWith(".test.ts") && file !== "windows-machine-policy.test.ts", - ) - .sort(); -const shardSeeds = [ - ["api-credentials.test.ts"], - ["api.test.ts"], - ["runtime.test.ts"], - ["cli-authentication.test.ts"], - ["scan-recovery.test.ts"], - [], - [], -]; -const assigned = new Set(shardSeeds.flat()); -for (const file of assigned) { - if (!tests.includes(file)) { - throw new Error("Windows CI test shard references a missing file: " + file); - } -} -const unassigned = tests.filter((file) => !assigned.has(file)); -const slowRemainderFiles = new Set([ - "deep-scan-workbench.test.ts", - "release-automation.test.ts", - "scan-comparison.test.ts", -]); -for (const [index, file] of unassigned.entries()) { - shardSeeds[slowRemainderFiles.has(file) ? 6 : 5 + (index % 2)].push(file); -} - -const assignments = shardSeeds.flat(); -if ( - assignments.length !== tests.length || - new Set(assignments).size !== tests.length -) { - throw new Error("Windows CI test shards must run every test file once."); -} - -const requestedShard = - process.argv[2] === undefined ? undefined : Number(process.argv[2]); -if ( - requestedShard !== undefined && - (!Number.isSafeInteger(requestedShard) || - requestedShard < 1 || - requestedShard > shardSeeds.length) -) { - throw new Error("Usage: node scripts/run-windows-ci-tests.mjs [1-7]"); -} -const selectedShards = - requestedShard === undefined - ? shardSeeds.map((files, index) => ({ files, index })) - : [{ files: shardSeeds[requestedShard - 1], index: requestedShard - 1 }]; - -const results = await Promise.all( - selectedShards.map( - ({ files, index }) => - new Promise((resolve, reject) => { - const paths = files.map((file) => "./tests-ts/" + file); - console.log( - "Windows CI test shard " + - (index + 1) + - "/" + - shardSeeds.length + - ": " + - paths.join(" "), - ); - // Native Windows credential and document checks can exceed 30 seconds. - // The workflow still bounds each complete shard to ten minutes. - const child = spawn("bun", ["test", "--timeout", "120000", ...paths], { - cwd: packageDirectory, - stdio: "inherit", - windowsHide: true, - }); - child.once("error", reject); - child.once("close", (code) => { - resolve(code ?? 1); - }); - }), - ), -); - -if (results.some((code) => code !== 0)) { - process.exitCode = 1; -} diff --git a/sdk/typescript/scripts/test-shards.mjs b/sdk/typescript/scripts/test-shards.mjs new file mode 100644 index 000000000..bf63b06d2 --- /dev/null +++ b/sdk/typescript/scripts/test-shards.mjs @@ -0,0 +1,38 @@ +// Rounded file timings [Unix, Windows] in seconds from node-ci on 2026-08-25. +// These affect only scheduling: new files automatically get a small default. +const timings = { + "api-credentials.test.ts": [1, 48], + "api.test.ts": [9, 77], + "api-attribution-concurrency.test.ts": [1, 14], + "cli-authentication.test.ts": [5, 50], + "cli-launcher.test.ts": [8, 10], + "compact-diff-scan.test.ts": [13, 33], + "contract.test.ts": [8, 10], + "custom-validation.test.ts": [8, 19], + "deep-scan-workbench.test.ts": [14, 41], + "multiscan.test.ts": [3, 30], + "patch-tui.test.ts": [5, 6], + "publication-integration.test.ts": [9, 19], + "publication-store.test.ts": [17, 30], + "release-automation.test.ts": [7, 50], + "runtime.test.ts": [20, 121], + "scan-comparison.test.ts": [1, 11], + "scan-recovery.test.ts": [24, 83], + "stopped-scan-results.test.ts": [7, 17], +}; + +export function partitionTestFiles(files, count, platform = process.platform) { + const timingIndex = platform === "win32" ? 1 : 0; + const estimate = (file) => timings[file]?.[timingIndex] ?? 2; + const shards = Array.from({ length: count }, () => []); + const totals = Array(count).fill(0); + // Place slow files first, then give each remaining file to the lightest shard. + for (const file of [...files] + .sort() + .sort((a, b) => estimate(b) - estimate(a))) { + const index = totals.indexOf(Math.min(...totals)); + shards[index].push(file); + totals[index] += estimate(file); + } + return shards; +} diff --git a/sdk/typescript/tests-ts/auth.test.ts b/sdk/typescript/tests-ts/auth.test.ts index 1aca9a3fb..1378ee660 100644 --- a/sdk/typescript/tests-ts/auth.test.ts +++ b/sdk/typescript/tests-ts/auth.test.ts @@ -314,9 +314,9 @@ grandchild.once("error", (error) => { await writeFile( script, ` +process.on("SIGTERM", () => console.error("ignored SIGTERM")); console.error("Open https://auth.example.test/device"); console.error("User code: ABCD-EFGH"); -process.on("SIGTERM", () => {}); setInterval(() => {}, 1000); `, ); @@ -331,14 +331,18 @@ setInterval(() => {}, 1000); ); await handle.waitForInstructions({ deviceCode: true }); handle.cancel(); - await expect( - Promise.race([ - handle.wait(), - delay(5_000).then(() => { - throw new Error("Login cancellation did not settle."); - }), - ]), - ).resolves.toMatchObject({ success: false }); + const result = await Promise.race([ + handle.wait(), + delay(5_000).then(() => { + throw new Error("Login cancellation did not settle."); + }), + ]); + expect(result.success).toBe(false); + // Windows terminates the process directly; Unix must reach the handler + // before escalating, rather than passing because setup raced cancellation. + if (process.platform !== "win32") { + expect(result.stderr).toContain("ignored SIGTERM"); + } expect(succeeded).toBe(false); }); @@ -349,9 +353,9 @@ setInterval(() => {}, 1000); await writeFile( script, ` +process.on("SIGTERM", () => process.exit(0)); console.error("Open https://auth.example.test/device"); console.error("User code: ABCD-EFGH"); -process.on("SIGTERM", () => process.exit(0)); setInterval(() => {}, 1000); `, ); @@ -366,7 +370,9 @@ setInterval(() => {}, 1000); ); await handle.waitForInstructions({ deviceCode: true }); handle.cancel(); - await expect(handle.wait()).resolves.toMatchObject({ success: false }); + const result = await handle.wait(); + expect(result.success).toBe(false); + if (process.platform !== "win32") expect(result.exitCode).toBe(0); expect(succeeded).toBe(false); }); }); diff --git a/sdk/typescript/tests-ts/cli-cloud-publish.test.ts b/sdk/typescript/tests-ts/cli-cloud-publish.test.ts index 803c5ad93..0a1354f21 100644 --- a/sdk/typescript/tests-ts/cli-cloud-publish.test.ts +++ b/sdk/typescript/tests-ts/cli-cloud-publish.test.ts @@ -559,7 +559,7 @@ describe("publish scan to Cloud", () => { }); test.each([false, true])( - "preserves batch receipts on cancellation with a confirmed response: %s", + "preserves batch receipts on cancellation with a confirmed response: %p", async (confirmed) => { for (const [signal, code] of [ ["SIGINT", 130], diff --git a/sdk/typescript/tests-ts/cli-publish.test.ts b/sdk/typescript/tests-ts/cli-publish.test.ts index c1cc4fb6e..af271fb18 100644 --- a/sdk/typescript/tests-ts/cli-publish.test.ts +++ b/sdk/typescript/tests-ts/cli-publish.test.ts @@ -298,7 +298,7 @@ describe("publish scan", () => { }); test.each([false, true])( - "resolves a saved scan ID for Linear publication with skipExisting=%s", + "resolves a saved scan ID for Linear publication with skipExisting=%p", async (skipExisting) => { const scanDir = await publicationDirectory(); const deps = dependencies({ diff --git a/sdk/typescript/tests-ts/cloud-publish.test.ts b/sdk/typescript/tests-ts/cloud-publish.test.ts index 200b97613..5f937f604 100644 --- a/sdk/typescript/tests-ts/cloud-publish.test.ts +++ b/sdk/typescript/tests-ts/cloud-publish.test.ts @@ -343,7 +343,7 @@ describe("Cloud publication", () => { }); test.each([false, true])( - "rejects artifacts from another scan before upload or preview (dryRun=%s)", + "rejects artifacts from another scan before upload or preview (dryRun=%p)", async (dryRun) => { const { scan, environment } = await fixture(); let requests = 0; diff --git a/sdk/typescript/tests-ts/component-scan.test.ts b/sdk/typescript/tests-ts/component-scan.test.ts index 7d7da29b0..0fa9b4af5 100644 --- a/sdk/typescript/tests-ts/component-scan.test.ts +++ b/sdk/typescript/tests-ts/component-scan.test.ts @@ -601,7 +601,7 @@ test("retains earlier confirmed matches if later matching fails", async () => { }); test.each([0, 1])( - "skips matching with %s populated components", + "skips matching with %p populated components", async (populated) => { const paths = await fixture(); let calls = 0; @@ -909,7 +909,7 @@ test("CLI forwards scan settings and returns incomplete coverage", async () => { }); test.each([false, true])( - "CLI reports matching completion (failure: %s)", + "CLI reports matching completion (failure: %p)", async (failMatching) => { const paths = await fixture(); let calls = 0; diff --git a/sdk/typescript/tests-ts/publication-integration.test.ts b/sdk/typescript/tests-ts/publication-integration.test.ts index 8f8f7f415..5b6293cc2 100644 --- a/sdk/typescript/tests-ts/publication-integration.test.ts +++ b/sdk/typescript/tests-ts/publication-integration.test.ts @@ -908,7 +908,7 @@ describe("database-backed Linear publication integration", () => { }); test.each([false, true])( - "keeps conflicting connector identities out of CLI history and retains recovery evidence with skipExisting=%s", + "keeps conflicting connector identities out of CLI history and retains recovery evidence with skipExisting=%p", async (skipExisting) => { const completed = await fixture(1 + Number(skipExisting)); const sealed = await artifactDigests(completed.scanDirectory); diff --git a/sdk/typescript/tests-ts/publish.test.ts b/sdk/typescript/tests-ts/publish.test.ts index 62e653ab1..5be0cd417 100644 --- a/sdk/typescript/tests-ts/publish.test.ts +++ b/sdk/typescript/tests-ts/publish.test.ts @@ -972,7 +972,7 @@ describe("direct Linear API publication", () => { }); test.each([false, true])( - "lets active direct mutations settle after external cancellation with skipExisting=%s", + "lets active direct mutations settle after external cancellation with skipExisting=%p", async (skipExisting) => { const publication = preparedPublication(23); const recorded = { diff --git a/sdk/typescript/tests-ts/skeleton.test.ts b/sdk/typescript/tests-ts/skeleton.test.ts index 1289b2a23..8fdf4352a 100644 --- a/sdk/typescript/tests-ts/skeleton.test.ts +++ b/sdk/typescript/tests-ts/skeleton.test.ts @@ -145,10 +145,12 @@ describe("TypeScript package skeleton", () => { "bun test --timeout 30000 ./tests-ts", ); expect(bunConfig).toMatchObject({ test: { randomize: true } }); - expect(packageJson.scripts["test:ci"]).toContain("pnpm run test "); + expect(packageJson.scripts["test:ci"]).toContain( + "node scripts/run-ci-tests.mjs --coverage", + ); expect(jobs["windows-test"]?.steps).toContainEqual( expect.objectContaining({ - run: "node sdk/typescript/scripts/run-windows-ci-tests.mjs ${{ matrix.shard }}", + run: "node sdk/typescript/scripts/run-ci-tests.mjs ${{ matrix.shard }}/7", }), ); }); @@ -255,7 +257,6 @@ describe("TypeScript package skeleton", () => { const uploads = [...Object.values(ci.jobs), ...Object.values(quality.jobs)] .flatMap((job) => job.steps) .filter((step) => step.uses?.startsWith("actions/upload-artifact@")); - expect(uploads).toHaveLength(3); for (const upload of uploads) { expect(upload.with?.["overwrite"]).toBe(true); } diff --git a/sdk/typescript/tests-ts/test-reports.test.ts b/sdk/typescript/tests-ts/test-reports.test.ts index 27eda4015..ca99055a2 100644 --- a/sdk/typescript/tests-ts/test-reports.test.ts +++ b/sdk/typescript/tests-ts/test-reports.test.ts @@ -1,5 +1,13 @@ import { spawnSync } from "node:child_process"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { + copyFile, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -67,6 +75,90 @@ async function compare(baseline: string, ...candidates: string[]) { return { status, stdout, stderr }; } +describe("CI file shards", () => { + test("balances slow files without dropping new files or depending on discovery order", async () => { + const { partitionTestFiles } = (await import( + new URL("../scripts/test-shards.mjs", import.meta.url).href + )) as { + partitionTestFiles: ( + files: string[], + count: number, + platform: string, + ) => string[][]; + }; + const files = [ + "runtime.test.ts", + "scan-recovery.test.ts", + "api.test.ts", + "new.test.ts", + ]; + for (const platform of ["linux", "darwin", "win32"]) { + const shards = partitionTestFiles(files, 3, platform); + expect(shards.flat().sort()).toEqual([...files].sort()); + expect(shards).toEqual( + partitionTestFiles([...files].reverse(), 3, platform), + ); + expect( + shards.every( + (shard) => + shard.filter((file) => file !== "new.test.ts").length === 1, + ), + ).toBe(true); + } + }); + + test("runs every shard, forwards Bun options, and preserves failures and separate reports", async () => { + const { root } = await fixtures(); + await mkdir(join(root, "scripts")); + await mkdir(join(root, "tests-ts", "nested"), { recursive: true }); + for (const file of ["run-ci-tests.mjs", "test-shards.mjs"]) { + await copyFile( + new URL(`../scripts/${file}`, import.meta.url), + join(root, "scripts", file), + ); + } + for (const name of ["a", "b", "c", "nested/d"]) { + await writeFile( + join(root, "tests-ts", `${name}.test.ts`), + `import { test, expect } from "bun:test"; test("${name}", () => expect(${name !== "a"}).toBe(true));\n`, + ); + } + const runner = join(root, "scripts", "run-ci-tests.mjs"); + const node = Bun.which("node"); + if (node === null) throw new Error("Node is required for the CI runner."); + const run = (...args: string[]) => + spawnSync(node, [runner, ...args], { + cwd: root, + encoding: "utf8", + timeout: 30_000, + }); + + const failed = run("--seed", "12345"); + expect(failed.status, failed.stderr).toBe(1); + expect((await readdir(join(root, "reports"))).sort()).toEqual([ + "junit-1.xml", + "junit-2.xml", + "junit-3.xml", + "junit-4.xml", + ]); + expect( + await readFile(join(root, "reports", "junit-1.xml"), "utf8"), + ).toContain(" { test("runs every workflow comparison before reporting a mismatch", async () => { const fixture = await fixtures(); From 6f1959dd2623220b9a142e86043790b7a4a06731 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 03:50:35 +0000 Subject: [PATCH 02/21] test: stabilize CI subprocess and instruction editor checks --- sdk/typescript/tests-ts/api.test.ts | 7 +- sdk/typescript/tests-ts/patch-tui.test.ts | 68 +++++++++++++------ .../tests-ts/release-automation.test.ts | 26 ++++--- sdk/typescript/tests-ts/test-reports.test.ts | 52 +++++++------- .../workbench-scan-root-alias.test.ts | 8 +-- 5 files changed, 102 insertions(+), 59 deletions(-) diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 74836e58d..43cb92112 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -4842,7 +4842,12 @@ describe("CodexSecurity orchestration", () => { clients.map((client) => client.run(repository).finally(releaseScans)), ); for (const result of results) { - expect(result).toMatchObject({ + expect( + result, + result.status === "rejected" && result.reason instanceof Error + ? result.reason.stack + : undefined, + ).toMatchObject({ status: "rejected", reason: expect.objectContaining({ message: "parallel API-key scan reached", diff --git a/sdk/typescript/tests-ts/patch-tui.test.ts b/sdk/typescript/tests-ts/patch-tui.test.ts index 58dd67f79..641d15b34 100644 --- a/sdk/typescript/tests-ts/patch-tui.test.ts +++ b/sdk/typescript/tests-ts/patch-tui.test.ts @@ -84,6 +84,19 @@ async function settle(): Promise { await new Promise((resolve) => setTimeout(resolve, 60)); } +async function waitFor(assertion: () => void): Promise { + const deadline = Date.now() + 5_000; + for (;;) { + await settle(); + try { + assertion(); + return; + } catch (error) { + if (Date.now() >= deadline) throw error; + } + } +} + describe("interactive patch finding browser", () => { test("shows complete finding details and scrolls through source evidence", async () => { const app = render( @@ -263,35 +276,43 @@ describe("interactive patch finding browser", () => { ); app.stdin.write("i"); - await settle(); - expect(app.lastFrame()).toContain("Enter save"); + await waitFor(() => expect(app.lastFrame()).toContain("Enter save")); app.stdin.write("Use the shared 2FA helper, not a new dependency."); - await settle(); - expect(app.lastFrame()).toContain("Use the shared 2FA helper"); + await waitFor(() => + expect(app.lastFrame()).toContain("Use the shared 2FA helper"), + ); expect(app.lastFrame()).toContain("2/2 selected"); app.stdin.write("\r"); - await settle(); + await waitFor(() => { + expect(app.lastFrame()).toContain("i edit"); + expect(app.lastFrame()).not.toContain("Enter save"); + }); expect(app.lastFrame()).toContain("PATCH INSTRUCTIONS"); expect(app.lastFrame()).toContain("Use the shared 2FA helper"); expect(app.lastFrame()).toContain("✎"); expect(app.lastFrame()?.match(/PATCH INSTRUCTIONS/gu)).toHaveLength(1); app.stdin.write("\u001B[B"); - await settle(); + await waitFor(() => expect(app.lastFrame()).toContain("› [✓] MEDIUM")); app.stdin.write("i"); - await settle(); + await waitFor(() => expect(app.lastFrame()).toContain("Enter save")); app.stdin.write("Keep the existing middleware."); - await settle(); + await waitFor(() => + expect(app.lastFrame()).toContain("Keep the existing middleware."), + ); app.stdin.write("\r"); - await settle(); + await waitFor(() => { + expect(app.lastFrame()).toContain("i edit"); + expect(app.lastFrame()).not.toContain("Enter save"); + }); expect(app.lastFrame()).toContain("Keep the existing middleware."); app.stdin.write(" "); - await settle(); + await waitFor(() => expect(app.lastFrame()).toContain("1/2 selected")); app.stdin.write("\r"); - await settle(); + await waitFor(() => expect(selected).toHaveLength(1)); expect(selected).toEqual([ { @@ -347,24 +368,33 @@ describe("interactive patch finding browser", () => { ); app.stdin.write("i"); - await settle(); + await waitFor(() => expect(app.lastFrame()).toContain("Enter save")); app.stdin.write("Discard this guidance."); - await settle(); + await waitFor(() => + expect(app.lastFrame()).toContain("Discard this guidance."), + ); app.stdin.write("\u001B"); - await settle(); + await waitFor(() => { + expect(app.lastFrame()).toContain("i edit"); + expect(app.lastFrame()).not.toContain("Enter save"); + }); expect(selected).toEqual([]); expect(app.lastFrame()).not.toContain("Discard this guidance."); app.stdin.write("i"); - await settle(); + await waitFor(() => expect(app.lastFrame()).toContain("Enter save")); + const emptyDraftFrame = app.lastFrame(); app.stdin.write("x"); - await settle(); + await waitFor(() => expect(app.lastFrame()).not.toBe(emptyDraftFrame)); app.stdin.write("\u007F"); - await settle(); + await waitFor(() => expect(app.lastFrame()).toBe(emptyDraftFrame)); app.stdin.write("\r"); - await settle(); + await waitFor(() => { + expect(app.lastFrame()).toContain("i edit"); + expect(app.lastFrame()).not.toContain("Enter save"); + }); app.stdin.write("\r"); - await settle(); + await waitFor(() => expect(selected).toHaveLength(1)); expect(selected).toEqual([{ severity: "high", occurrenceIds: ["occ_1"] }]); }); diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index d73f58b64..16681aa7c 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { execFile, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdirSync, @@ -11,6 +11,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { describe, expect, test } from "bun:test"; import { bashCommand } from "./support/shell.js"; @@ -4507,7 +4508,7 @@ describe("GitHub release workflow safeguards", () => { expect(result.stdout).toContain(`labels[]=${label}`); }); - test("executes and recovers from concurrent skip-label creation", () => { + test("executes and recovers from concurrent skip-label creation", async () => { const script = workflowStepShell( releaseLabelsWorkflow, "Categorize pull request without checking out its code", @@ -4545,17 +4546,20 @@ describe("GitHub release workflow safeguards", () => { " esac", "}", ].join("\n"); - const result = spawnSync(bash, ["-c", `${mock}\n${script}`], { - encoding: "utf8", - env: { - ...process.env, - GITHUB_REPOSITORY: "test/codex-security", - PR_NUMBER: "17", + const result = await promisify(execFile)( + bash, + ["-c", `${mock}\n${script}`], + { + encoding: "utf8", + env: { + ...process.env, + GITHUB_REPOSITORY: "test/codex-security", + PR_NUMBER: "17", + }, + timeout: 10_000, }, - timeout: 10_000, - }); + ); - expect(result.status).toBe(0); expect(result.stdout).toContain("applied skip-release-notes"); }); diff --git a/sdk/typescript/tests-ts/test-reports.test.ts b/sdk/typescript/tests-ts/test-reports.test.ts index ca99055a2..90fc66739 100644 --- a/sdk/typescript/tests-ts/test-reports.test.ts +++ b/sdk/typescript/tests-ts/test-reports.test.ts @@ -1,4 +1,3 @@ -import { spawnSync } from "node:child_process"; import { copyFile, mkdir, @@ -49,20 +48,13 @@ function testcase(name: string, status = "") { return `${status}`; } -async function compare(baseline: string, ...candidates: string[]) { - const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); - if (python === null) throw new Error("A Python interpreter is required."); +async function runCommand( + cmd: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv; timeout?: number } = {}, +) { const child = Bun.spawn({ - cmd: [ - python, - "-I", - "-B", - fileURLToPath( - new URL("../scripts/compare-test-reports.py", import.meta.url), - ), - baseline, - ...candidates, - ], + ...options, + cmd, stdin: "ignore", stdout: "pipe", stderr: "pipe", @@ -75,6 +67,21 @@ async function compare(baseline: string, ...candidates: string[]) { return { status, stdout, stderr }; } +async function compare(baseline: string, ...candidates: string[]) { + const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + if (python === null) throw new Error("A Python interpreter is required."); + return await runCommand([ + python, + "-I", + "-B", + fileURLToPath( + new URL("../scripts/compare-test-reports.py", import.meta.url), + ), + baseline, + ...candidates, + ]); +} + describe("CI file shards", () => { test("balances slow files without dropping new files or depending on discovery order", async () => { const { partitionTestFiles } = (await import( @@ -127,13 +134,12 @@ describe("CI file shards", () => { const node = Bun.which("node"); if (node === null) throw new Error("Node is required for the CI runner."); const run = (...args: string[]) => - spawnSync(node, [runner, ...args], { + runCommand([node, runner, ...args], { cwd: root, - encoding: "utf8", timeout: 30_000, }); - const failed = run("--seed", "12345"); + const failed = await run("--seed", "12345"); expect(failed.status, failed.stderr).toBe(1); expect((await readdir(join(root, "reports"))).sort()).toEqual([ "junit-1.xml", @@ -149,13 +155,13 @@ describe("CI file shards", () => { ).toContain('name="nested/d"'); // The filter is forwarded to Bun, so the failing test is no longer selected. - const selected = run("1/2", "--test-name-pattern", "^c$"); + const selected = await run("1/2", "--test-name-pattern", "^c$"); expect(selected.status, selected.stderr).toBe(0); const report = await readFile(join(root, "reports", "junit-1.xml"), "utf8"); expect(report).toContain('name="c"'); expect(report).not.toContain(" { const summary = join(fixture.root, "summary.md"); for (const failedReport of ["", expected[0]!]) { await writeFile(summary, ""); - const result = spawnSync( - bash, - ["-e", "-o", "pipefail", "-c", `${mock}\n${script}`], + const result = await runCommand( + [bash, "-e", "-o", "pipefail", "-c", `${mock}\n${script}`], { cwd: fixture.root, - encoding: "utf8", env: { ...process.env, GITHUB_STEP_SUMMARY: "summary.md", diff --git a/sdk/typescript/tests-ts/workbench-scan-root-alias.test.ts b/sdk/typescript/tests-ts/workbench-scan-root-alias.test.ts index 0a74c093a..91cc240d7 100644 --- a/sdk/typescript/tests-ts/workbench-scan-root-alias.test.ts +++ b/sdk/typescript/tests-ts/workbench-scan-root-alias.test.ts @@ -1,7 +1,8 @@ -import { spawnSync } from "node:child_process"; +import { execFile } from "node:child_process"; import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { promisify } from "node:util"; import { expect, test } from "bun:test"; import { resolvePluginPython } from "../src/runtime.js"; import { PLUGIN_ROOT } from "./plugin-root.js"; @@ -34,13 +35,12 @@ test.skipIf(process.platform !== "win32")( "args = argparse.Namespace(repository=None, scan_root=sys.argv[2].upper(), target_id=None, mode=None, status=None, query=None, limit=None, offset=0)", "print(json.dumps(history.list_scans(connection, args)))", ].join("\n"); - const result = spawnSync( + const result = await promisify(execFile)( python, ["-I", "-B", "-c", probe, join(PLUGIN_ROOT, "scripts"), scanRoot], - { encoding: "utf8", timeout: 10_000 }, + { encoding: "utf8", timeout: 10_000, windowsHide: true }, ); - expect(result.status, result.stderr).toBe(0); expect(result.stderr).toBe(""); expect(JSON.parse(result.stdout)).toMatchObject({ scans: [{ scanId: "scan" }], From 70e0f118669d84c3eb521849aaaf3a9a0bbabe51 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 04:03:55 +0000 Subject: [PATCH 03/21] test: flush React effects between patch UI interactions --- sdk/typescript/tests-ts/patch-tui.test.ts | 188 ++++++++++------------ 1 file changed, 87 insertions(+), 101 deletions(-) diff --git a/sdk/typescript/tests-ts/patch-tui.test.ts b/sdk/typescript/tests-ts/patch-tui.test.ts index 641d15b34..5026a7aa4 100644 --- a/sdk/typescript/tests-ts/patch-tui.test.ts +++ b/sdk/typescript/tests-ts/patch-tui.test.ts @@ -4,12 +4,12 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { cleanup, render } from "ink-testing-library"; -import { createElement } from "react"; +import { act, createElement } from "react"; import type { Finding, SeverityLevel } from "../src/index.js"; import { PatchTui, type PatchSelection } from "../src/patch-tui.js"; import { fakeResult } from "./cli-fixtures.js"; -afterEach(() => cleanup()); +afterEach(() => reactAct(cleanup)); function findings(severities: readonly SeverityLevel[]): Finding[] { const result = fakeResult(severities); @@ -80,26 +80,34 @@ function findings(severities: readonly SeverityLevel[]): Finding[] { return result.findings.findings; } -async function settle(): Promise { - await new Promise((resolve) => setTimeout(resolve, 60)); +// A rendered frame can precede Ink's useInput effect. Flush both before the next key. +async function reactAct(action: () => T): Promise { + const environment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + const previous = environment.IS_REACT_ACT_ENVIRONMENT; + environment.IS_REACT_ACT_ENVIRONMENT = true; + try { + let result!: T; + await act(async () => { + result = action(); + // Ink defers a bare Escape key with setImmediate. + await new Promise((resolve) => setImmediate(resolve)); + }); + return result; + } finally { + if (previous === undefined) delete environment.IS_REACT_ACT_ENVIRONMENT; + else environment.IS_REACT_ACT_ENVIRONMENT = previous; + } } -async function waitFor(assertion: () => void): Promise { - const deadline = Date.now() + 5_000; - for (;;) { - await settle(); - try { - assertion(); - return; - } catch (error) { - if (Date.now() >= deadline) throw error; - } - } +async function renderTui(element: Parameters[0]) { + return await reactAct(() => render(element)); } describe("interactive patch finding browser", () => { test("shows complete finding details and scrolls through source evidence", async () => { - const app = render( + const app = await renderTui( createElement(PatchTui, { repository: "/work/example", findings: findings(["high", "medium", "low"]), @@ -125,11 +133,9 @@ describe("interactive patch finding browser", () => { expect(app.lastFrame()).not.toContain('"rationale"'); const frames = [app.lastFrame() ?? ""]; - app.stdin.write("\t"); - await settle(); + await reactAct(() => app.stdin.write("\t")); for (let page = 0; page < 12; page += 1) { - app.stdin.write("\u001B[6~"); - await settle(); + await reactAct(() => app.stdin.write("\u001B[6~")); frames.push(app.lastFrame() ?? ""); } @@ -202,7 +208,7 @@ describe("interactive patch finding browser", () => { { path: "../outside.ts", startLine: 1 }, { path: "src/outside-link.ts", startLine: 1 }, ]; - const app = render( + const app = await renderTui( createElement(PatchTui, { repository, findings: [finding!], @@ -213,8 +219,7 @@ describe("interactive patch finding browser", () => { const frames = [app.lastFrame() ?? ""]; for (let page = 0; page < 12; page += 1) { - app.stdin.write("\u001B[6~"); - await settle(); + await reactAct(() => app.stdin.write("\u001B[6~")); frames.push(app.lastFrame() ?? ""); } const reviewed = frames.join("\n"); @@ -238,7 +243,7 @@ describe("interactive patch finding browser", () => { test("combines severity presets with individual finding selection", async () => { const selected: (PatchSelection | null)[] = []; - const app = render( + const app = await renderTui( createElement(PatchTui, { repository: "/work/example", findings: findings(["high", "medium", "low"]), @@ -247,18 +252,15 @@ describe("interactive patch finding browser", () => { }), ); - app.stdin.write("2"); - await settle(); + await reactAct(() => app.stdin.write("2")); expect(app.lastFrame()).toContain("1/3 selected"); expect(app.lastFrame()).toContain("high and above"); - app.stdin.write("\u001B[B "); - await settle(); + await reactAct(() => app.stdin.write("\u001B[B ")); expect(app.lastFrame()).toContain("2/3 selected"); expect(app.lastFrame()).toContain("custom"); - app.stdin.write("\r"); - await settle(); + await reactAct(() => app.stdin.write("\r")); expect(selected).toEqual([ { severity: "medium", occurrenceIds: ["occ_1", "occ_2"] }, ]); @@ -266,7 +268,7 @@ describe("interactive patch finding browser", () => { test("edits instructions per finding and only returns selected guidance", async () => { const selected: (PatchSelection | null)[] = []; - const app = render( + const app = await renderTui( createElement(PatchTui, { repository: "/work/example", findings: findings(["high", "medium"]), @@ -275,44 +277,38 @@ describe("interactive patch finding browser", () => { }), ); - app.stdin.write("i"); - await waitFor(() => expect(app.lastFrame()).toContain("Enter save")); + await reactAct(() => app.stdin.write("i")); + expect(app.lastFrame()).toContain("Enter save"); - app.stdin.write("Use the shared 2FA helper, not a new dependency."); - await waitFor(() => - expect(app.lastFrame()).toContain("Use the shared 2FA helper"), + await reactAct(() => + app.stdin.write("Use the shared 2FA helper, not a new dependency."), ); + expect(app.lastFrame()).toContain("Use the shared 2FA helper"); expect(app.lastFrame()).toContain("2/2 selected"); - app.stdin.write("\r"); - await waitFor(() => { - expect(app.lastFrame()).toContain("i edit"); - expect(app.lastFrame()).not.toContain("Enter save"); - }); + await reactAct(() => app.stdin.write("\r")); + expect(app.lastFrame()).toContain("i edit"); + expect(app.lastFrame()).not.toContain("Enter save"); expect(app.lastFrame()).toContain("PATCH INSTRUCTIONS"); expect(app.lastFrame()).toContain("Use the shared 2FA helper"); expect(app.lastFrame()).toContain("✎"); expect(app.lastFrame()?.match(/PATCH INSTRUCTIONS/gu)).toHaveLength(1); - app.stdin.write("\u001B[B"); - await waitFor(() => expect(app.lastFrame()).toContain("› [✓] MEDIUM")); - app.stdin.write("i"); - await waitFor(() => expect(app.lastFrame()).toContain("Enter save")); - app.stdin.write("Keep the existing middleware."); - await waitFor(() => - expect(app.lastFrame()).toContain("Keep the existing middleware."), - ); - app.stdin.write("\r"); - await waitFor(() => { - expect(app.lastFrame()).toContain("i edit"); - expect(app.lastFrame()).not.toContain("Enter save"); - }); + await reactAct(() => app.stdin.write("\u001B[B")); + expect(app.lastFrame()).toContain("› [✓] MEDIUM"); + await reactAct(() => app.stdin.write("i")); + expect(app.lastFrame()).toContain("Enter save"); + await reactAct(() => app.stdin.write("Keep the existing middleware.")); + expect(app.lastFrame()).toContain("Keep the existing middleware."); + await reactAct(() => app.stdin.write("\r")); + expect(app.lastFrame()).toContain("i edit"); + expect(app.lastFrame()).not.toContain("Enter save"); expect(app.lastFrame()).toContain("Keep the existing middleware."); - app.stdin.write(" "); - await waitFor(() => expect(app.lastFrame()).toContain("1/2 selected")); - app.stdin.write("\r"); - await waitFor(() => expect(selected).toHaveLength(1)); + await reactAct(() => app.stdin.write(" ")); + expect(app.lastFrame()).toContain("1/2 selected"); + await reactAct(() => app.stdin.write("\r")); + expect(selected).toHaveLength(1); expect(selected).toEqual([ { @@ -327,7 +323,7 @@ describe("interactive patch finding browser", () => { test("optionally creates a draft pull request after selected patches", async () => { const selected: (PatchSelection | null)[] = []; - const app = render( + const app = await renderTui( createElement(PatchTui, { repository: "/work/example", findings: findings(["high"]), @@ -339,13 +335,11 @@ describe("interactive patch finding browser", () => { expect(app.lastFrame()).toContain( "[ ] Create draft GitHub pull request after patching", ); - app.stdin.write("r"); - await settle(); + await reactAct(() => app.stdin.write("r")); expect(app.lastFrame()).toContain( "[✓] Create draft GitHub pull request after patching", ); - app.stdin.write("\r"); - await settle(); + await reactAct(() => app.stdin.write("\r")); expect(selected).toEqual([ { @@ -358,7 +352,7 @@ describe("interactive patch finding browser", () => { test("cancels and clears finding instructions without leaving the browser", async () => { const selected: (PatchSelection | null)[] = []; - const app = render( + const app = await renderTui( createElement(PatchTui, { repository: "/work/example", findings: findings(["high"]), @@ -367,34 +361,28 @@ describe("interactive patch finding browser", () => { }), ); - app.stdin.write("i"); - await waitFor(() => expect(app.lastFrame()).toContain("Enter save")); - app.stdin.write("Discard this guidance."); - await waitFor(() => - expect(app.lastFrame()).toContain("Discard this guidance."), - ); - app.stdin.write("\u001B"); - await waitFor(() => { - expect(app.lastFrame()).toContain("i edit"); - expect(app.lastFrame()).not.toContain("Enter save"); - }); + await reactAct(() => app.stdin.write("i")); + expect(app.lastFrame()).toContain("Enter save"); + await reactAct(() => app.stdin.write("Discard this guidance.")); + expect(app.lastFrame()).toContain("Discard this guidance."); + await reactAct(() => app.stdin.write("\u001B")); + expect(app.lastFrame()).toContain("i edit"); + expect(app.lastFrame()).not.toContain("Enter save"); expect(selected).toEqual([]); expect(app.lastFrame()).not.toContain("Discard this guidance."); - app.stdin.write("i"); - await waitFor(() => expect(app.lastFrame()).toContain("Enter save")); + await reactAct(() => app.stdin.write("i")); + expect(app.lastFrame()).toContain("Enter save"); const emptyDraftFrame = app.lastFrame(); - app.stdin.write("x"); - await waitFor(() => expect(app.lastFrame()).not.toBe(emptyDraftFrame)); - app.stdin.write("\u007F"); - await waitFor(() => expect(app.lastFrame()).toBe(emptyDraftFrame)); - app.stdin.write("\r"); - await waitFor(() => { - expect(app.lastFrame()).toContain("i edit"); - expect(app.lastFrame()).not.toContain("Enter save"); - }); - app.stdin.write("\r"); - await waitFor(() => expect(selected).toHaveLength(1)); + await reactAct(() => app.stdin.write("x")); + expect(app.lastFrame()).not.toBe(emptyDraftFrame); + await reactAct(() => app.stdin.write("\u007F")); + expect(app.lastFrame()).toBe(emptyDraftFrame); + await reactAct(() => app.stdin.write("\r")); + expect(app.lastFrame()).toContain("i edit"); + expect(app.lastFrame()).not.toContain("Enter save"); + await reactAct(() => app.stdin.write("\r")); + expect(selected).toHaveLength(1); expect(selected).toEqual([{ severity: "high", occurrenceIds: ["occ_1"] }]); }); @@ -402,7 +390,7 @@ describe("interactive patch finding browser", () => { test("allows selecting none and canceling without patching", async () => { for (const input of ["q", "\r"]) { const selected: (PatchSelection | null)[] = []; - const app = render( + const app = await renderTui( createElement(PatchTui, { repository: "/work/example", findings: findings(["high"]), @@ -411,22 +399,20 @@ describe("interactive patch finding browser", () => { }), ); if (input === "\r") { - app.stdin.write("n"); - await settle(); + await reactAct(() => app.stdin.write("n")); expect(app.lastFrame()).toContain("0/1 selected"); } - app.stdin.write(input); - await settle(); + await reactAct(() => app.stdin.write(input)); expect(selected).toEqual([null]); - app.unmount(); + await reactAct(app.unmount); } }); - test("sanitizes terminal escapes and credential-bearing finding details", () => { + test("sanitizes terminal escapes and credential-bearing finding details", async () => { const [finding] = findings(["high"]); finding!.title = "\u001B[31mUnsafe title\u001B[0m\nforged line"; finding!.summary = "sk-proj-SYNTHETIC_KEY_123"; - const app = render( + const app = await renderTui( createElement(PatchTui, { repository: "/work/example", findings: [finding!], @@ -444,13 +430,13 @@ describe("interactive patch finding browser", () => { test("keeps finding details restrained while honoring NO_COLOR", () => { const source = [ 'import {render} from "ink-testing-library";', - 'import {createElement} from "react";', + 'import {act,createElement} from "react";globalThis.IS_REACT_ACT_ENVIRONMENT=true;', `import {PatchTui} from ${JSON.stringify(new URL("../src/patch-tui.tsx", import.meta.url).href)};`, `const findings=${JSON.stringify(findings(["critical", "high", "medium", "low"]))};`, - 'const app=render(createElement(PatchTui,{repository:"/work/example",findings,onComplete(){}}));', - 'const frames=[app.lastFrame() ?? ""];app.stdin.write("\\t");', - 'for(let page=0;page<12;page+=1){app.stdin.write("\\u001B[6~");await new Promise(resolve=>setTimeout(resolve,30));frames.push(app.lastFrame()??"");}', - 'process.stdout.write(frames.join("\\n"));app.unmount();', + 'let app;await act(async()=>{app=render(createElement(PatchTui,{repository:"/work/example",findings,onComplete(){}}));});', + 'const frames=[app.lastFrame() ?? ""];await act(async()=>{app.stdin.write("\\t");await new Promise(resolve=>setImmediate(resolve));});', + 'for(let page=0;page<12;page+=1){await act(async()=>{app.stdin.write("\\u001B[6~");await new Promise(resolve=>setImmediate(resolve));});frames.push(app.lastFrame()??"");}', + 'process.stdout.write(frames.join("\\n"));await act(async()=>{app.unmount();});', ].join(""); const run = (color: boolean) => spawnSync(process.execPath, ["--eval", source], { From 87acc35c73935ddcbce569c8324ee46971dfd754 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 04:14:35 +0000 Subject: [PATCH 04/21] ci: streamline Windows setup and synchronize signal fixtures --- .github/workflows/node-ci.yml | 38 +++++++++++++++++----- sdk/typescript/TESTING.md | 6 ++-- sdk/typescript/tests-ts/cli-skills.test.ts | 25 ++++++++++---- 3 files changed, 52 insertions(+), 17 deletions(-) diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 7c4879336..357ca04a3 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -238,13 +238,25 @@ jobs: uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: ${{ matrix.node }} + cache: npm + cache-dependency-path: sdk/typescript/pnpm-lock.yaml - name: Set up pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + id: pnpm + shell: bash + run: | + pnpm_package=$(node -p 'require("./sdk/typescript/package.json").packageManager.split("+")[0]') + npm install --global "$pnpm_package" --prefer-offline --no-audit --no-fund + pnpm_store=$(pnpm store path --silent) + pnpm_arch=$(node -p 'process.arch') + printf 'store-path=%s\narch=%s\n' "$pnpm_store" "$pnpm_arch" >> "$GITHUB_OUTPUT" + + - name: Cache pnpm store + continue-on-error: true + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - package_json_file: sdk/typescript/package.json - cache: true - cache_dependency_path: sdk/typescript/pnpm-lock.yaml + path: ${{ steps.pnpm.outputs.store-path }} + key: pnpm-cache-${{ runner.os }}-${{ steps.pnpm.outputs.arch }}-${{ hashFiles('sdk/typescript/pnpm-lock.yaml') }} - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 @@ -316,11 +328,21 @@ jobs: cache-dependency-path: sdk/typescript/pnpm-lock.yaml - name: Set up pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + id: pnpm + shell: bash + run: | + pnpm_package=$(node -p 'require("./sdk/typescript/package.json").packageManager.split("+")[0]') + npm install --global "$pnpm_package" --prefer-offline --no-audit --no-fund + pnpm_store=$(pnpm store path --silent) + pnpm_arch=$(node -p 'process.arch') + printf 'store-path=%s\narch=%s\n' "$pnpm_store" "$pnpm_arch" >> "$GITHUB_OUTPUT" + + - name: Cache pnpm store + continue-on-error: true + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - package_json_file: sdk/typescript/package.json - cache: true - cache_dependency_path: sdk/typescript/pnpm-lock.yaml + path: ${{ steps.pnpm.outputs.store-path }} + key: pnpm-cache-${{ runner.os }}-${{ steps.pnpm.outputs.arch }}-${{ hashFiles('sdk/typescript/pnpm-lock.yaml') }} - name: Install dependencies working-directory: sdk/typescript diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index ecfc1c19e..a7847b4f3 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -74,8 +74,10 @@ Windows runs seven separate jobs with `node scripts/run-ci-tests.mjs 1/7` (substitute the shard number). Each file runs once. New files are included automatically; stale timing estimates can affect balance, but not coverage. The machine-wide Windows policy test still runs separately and serially. -Windows dependency installation uses the same pinned pnpm action and store -cache as Unix. +Windows installs the pnpm version from `packageManager` directly, reuses the +npm download cache, and caches the resolved pnpm store separately. Cache +failures do not suppress installation failures. Unix keeps its pinned pnpm +setup action. To compare the serial and CI runners on the same machine, commit, and seed: diff --git a/sdk/typescript/tests-ts/cli-skills.test.ts b/sdk/typescript/tests-ts/cli-skills.test.ts index d50d151ae..04234e287 100644 --- a/sdk/typescript/tests-ts/cli-skills.test.ts +++ b/sdk/typescript/tests-ts/cli-skills.test.ts @@ -1420,6 +1420,8 @@ lines.on("line", (line) => { join(tmpdir(), "codex-security-skill-signal-"), ); const ready = join(directory, "ready"); + const wrapperReady = join(directory, "wrapper-ready"); + const ignoredSignal = join(directory, "ignored-signal"); const child = join(directory, "child.mjs"); const wrapper = join(directory, "wrapper.mjs"); await writeFile( @@ -1427,6 +1429,7 @@ lines.on("line", (line) => { ` import { spawn } from "node:child_process"; import { writeFileSync } from "node:fs"; +process.on("SIGTERM", () => writeFileSync(${JSON.stringify(ignoredSignal)}, "ignored")); const descendant = spawn( process.execPath, ["-e", "setInterval(() => {}, 1000)"], @@ -1436,20 +1439,21 @@ writeFileSync(${JSON.stringify(ready)}, JSON.stringify({ child: process.pid, descendant: descendant.pid, })); -process.on("SIGTERM", () => {}); setInterval(() => {}, 1000); `, ); await writeFile( wrapper, ` +import { writeFileSync } from "node:fs"; import { runCodexSkillCommand } from ${JSON.stringify(new URL("../src/cli.ts", import.meta.url).href)}; -const status = await runCodexSkillCommand( +const completion = runCodexSkillCommand( [${JSON.stringify(child)}], { command: "validate", stdout: process.stdout, stderr: process.stderr }, { command: process.execPath }, ); -process.exit(status); +writeFileSync(${JSON.stringify(wrapperReady)}, "ready"); +process.exit(await completion); `, ); @@ -1467,6 +1471,7 @@ process.exit(status); descendant: number; }; childPids = [marker.child, marker.descendant]; + await Bun.file(wrapperReady).text(); break; } catch (error) { if (Date.now() >= deadline) throw error; @@ -1474,16 +1479,22 @@ process.exit(status); } } invocation.kill("SIGTERM"); - const status = await Promise.race([ - new Promise((resolve, reject) => { + const result = await Promise.race([ + new Promise<{ + code: number | null; + signal: NodeJS.Signals | null; + }>((resolve, reject) => { invocation.once("error", reject); - invocation.once("close", resolve); + invocation.once("close", (code, signal) => + resolve({ code, signal }), + ); }), delay(5_000).then(() => { throw new Error("CLI skill cancellation did not settle."); }), ]); - expect(status).toBe(143); + expect(result).toEqual({ code: 143, signal: null }); + expect(await Bun.file(ignoredSignal).text()).toBe("ignored"); } finally { invocation.kill("SIGKILL"); for (const childPid of childPids) { From 4def5fb4b6997ea1a4c069336cf93dad28b51e1f Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 04:30:25 +0000 Subject: [PATCH 05/21] ci: run two workers per selected Windows shard --- .github/workflows/node-ci.yml | 2 + sdk/typescript/TESTING.md | 12 ++++-- sdk/typescript/scripts/run-ci-tests.mjs | 13 ++++-- sdk/typescript/tests-ts/test-reports.test.ts | 44 ++++++++++++++++++-- 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 357ca04a3..756aca2b1 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -355,6 +355,8 @@ jobs: - name: Inspect package working-directory: sdk/typescript shell: bash + env: + npm_config_timing: "true" run: pnpm run check:package ../../dist/*.tgz windows: diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index a7847b4f3..f37ecc8d4 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -71,13 +71,19 @@ tests block CI; a failed diagnostic upload does not. `scripts/run-ci-tests.mjs` discovers the test files and balances them using rounded timings in `scripts/test-shards.mjs`. Unix jobs run four processes; Windows runs seven separate jobs with `node scripts/run-ci-tests.mjs 1/7` -(substitute the shard number). Each file runs once. New files are included -automatically; stale timing estimates can affect balance, but not coverage. +(substitute the shard number), each using up to two Bun processes with separate +reports such as `junit-1-1.xml` and `junit-1-2.xml`. Each file runs once. New files +are included automatically; stale timing estimates can affect balance, but not coverage. The machine-wide Windows policy test still runs separately and serially. Windows installs the pnpm version from `packageManager` directly, reuses the npm download cache, and caches the resolved pnpm store separately. Cache failures do not suppress installation failures. Unix keeps its pinned pnpm -setup action. +setup action. Windows package inspection enables npm's native phase timings to +diagnose installation delays without changing its failure or timeout behavior. + +When forwarding `--test-name-pattern` to a sharded run, also pass Bun's +`--pass-with-no-tests` if some workers may have no matching tests. Normal CI +does not enable that option, and empty file partitions are never launched. To compare the serial and CI runners on the same machine, commit, and seed: diff --git a/sdk/typescript/scripts/run-ci-tests.mjs b/sdk/typescript/scripts/run-ci-tests.mjs index 38ae3b35e..e9ce8a7f1 100644 --- a/sdk/typescript/scripts/run-ci-tests.mjs +++ b/sdk/typescript/scripts/run-ci-tests.mjs @@ -5,8 +5,8 @@ import { partitionTestFiles } from "./test-shards.mjs"; const packageDirectory = fileURLToPath(new URL("../", import.meta.url)); const args = process.argv.slice(2); -// Unix jobs run four independent Bun processes. Windows jobs each run one of -// seven shards, so native credential checks do not compete on the same host. +// Unix jobs run four independent Bun processes. Each selected Windows shard +// uses up to two processes; the machine-wide policy test runs separately. const selection = args[0] !== undefined && !args[0].startsWith("-") ? args.shift() : undefined; const [requestedShard, count] = selection?.split("/").map(Number) ?? [0, 4]; @@ -36,10 +36,17 @@ const selectedShards = shards if (selectedShards.some(({ files }) => files.length === 0)) { throw new Error("CI test shards must not be empty."); } +const workers = selectedShards.flatMap(({ files, shard }) => + process.platform === "win32" && selection !== undefined + ? partitionTestFiles(files, 2) + .filter((files) => files.length > 0) + .map((files, index) => ({ files, shard: `${shard}-${index + 1}` })) + : [{ files, shard: String(shard) }], +); await mkdir(new URL("../reports/", import.meta.url), { recursive: true }); const results = await Promise.all( - selectedShards.map(async ({ files, shard }) => { + workers.map(async ({ files, shard }) => { console.log(`CI test shard ${shard}/${count}: ${files.join(" ")}`); return await new Promise((resolve) => { const child = spawn( diff --git a/sdk/typescript/tests-ts/test-reports.test.ts b/sdk/typescript/tests-ts/test-reports.test.ts index 90fc66739..758c4e840 100644 --- a/sdk/typescript/tests-ts/test-reports.test.ts +++ b/sdk/typescript/tests-ts/test-reports.test.ts @@ -154,12 +154,50 @@ describe("CI file shards", () => { await readFile(join(root, "reports", "junit-4.xml"), "utf8"), ).toContain('name="nested/d"'); - // The filter is forwarded to Bun, so the failing test is no longer selected. - const selected = await run("1/2", "--test-name-pattern", "^c$"); + await rm(join(root, "reports"), { recursive: true }); + const selectedFailure = await run("1/2"); + expect(selectedFailure.status, selectedFailure.stderr).toBe(1); + const selectedReports = (await readdir(join(root, "reports"))).sort(); + expect(selectedReports).toEqual( + process.platform === "win32" + ? ["junit-1-1.xml", "junit-1-2.xml"] + : ["junit-1.xml"], + ); + const selectedContents = await Promise.all( + selectedReports.map((file) => + readFile(join(root, "reports", file), "utf8"), + ), + ); + expect(selectedContents.join("\n")).toContain('name="a"'); + expect(selectedContents.join("\n")).toContain('name="c"'); + expect(selectedContents.join("\n")).toContain(" Date: Wed, 26 Aug 2026 04:42:43 +0000 Subject: [PATCH 06/21] ci: measure Windows package extraction with a larger worker pool --- .github/workflows/node-ci.yml | 2 ++ sdk/typescript/TESTING.md | 3 +++ 2 files changed, 5 insertions(+) diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 756aca2b1..26e5d1551 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -357,6 +357,8 @@ jobs: shell: bash env: npm_config_timing: "true" + npm_config_loglevel: http + UV_THREADPOOL_SIZE: "16" run: pnpm run check:package ../../dist/*.tgz windows: diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index f37ecc8d4..d27cf23bd 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -80,6 +80,9 @@ npm download cache, and caches the resolved pnpm store separately. Cache failures do not suppress installation failures. Unix keeps its pinned pnpm setup action. Windows package inspection enables npm's native phase timings to diagnose installation delays without changing its failure or timeout behavior. +It also logs npm cache/fetch activity and trials a 16-thread libuv pool for the +observed unpacking delays. This is a scheduling experiment, not a proven speedup; +compare hosted install phases and whole-workflow timings before retaining it. When forwarding `--test-name-pattern` to a sharded run, also pass Bun's `--pass-with-no-tests` if some workers may have no matching tests. Normal CI From 6072c788f140cbacb09bf121f29e022ae61c6de7 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 04:57:09 +0000 Subject: [PATCH 07/21] test: separate credential-home checks from runtime integration tests --- sdk/typescript/TESTING.md | 9 +- sdk/typescript/scripts/test-shards.mjs | 5 +- .../tests-ts/runtime-credentials.test.ts | 1831 +++++++++++++++++ sdk/typescript/tests-ts/runtime.test.ts | 1787 ---------------- 4 files changed, 1840 insertions(+), 1792 deletions(-) create mode 100644 sdk/typescript/tests-ts/runtime-credentials.test.ts diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index d27cf23bd..11f4c1791 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -74,15 +74,18 @@ Windows runs seven separate jobs with `node scripts/run-ci-tests.mjs 1/7` (substitute the shard number), each using up to two Bun processes with separate reports such as `junit-1-1.xml` and `junit-1-2.xml`. Each file runs once. New files are included automatically; stale timing estimates can affect balance, but not coverage. +Credential-home locking and ACL checks live in `runtime-credentials.test.ts` so +they can run independently of the plugin, output-directory, and Python checks +in `runtime.test.ts`. The machine-wide Windows policy test still runs separately and serially. Windows installs the pnpm version from `packageManager` directly, reuses the npm download cache, and caches the resolved pnpm store separately. Cache failures do not suppress installation failures. Unix keeps its pinned pnpm setup action. Windows package inspection enables npm's native phase timings to diagnose installation delays without changing its failure or timeout behavior. -It also logs npm cache/fetch activity and trials a 16-thread libuv pool for the -observed unpacking delays. This is a scheduling experiment, not a proven speedup; -compare hosted install phases and whole-workflow timings before retaining it. +It also logs npm cache/fetch activity and uses a 16-thread libuv pool for +filesystem-heavy package extraction. These settings apply only to Windows +package inspection; the fresh consumer install and its assertions are unchanged. When forwarding `--test-name-pattern` to a sharded run, also pass Bun's `--pass-with-no-tests` if some workers may have no matching tests. Normal CI diff --git a/sdk/typescript/scripts/test-shards.mjs b/sdk/typescript/scripts/test-shards.mjs index bf63b06d2..09500110a 100644 --- a/sdk/typescript/scripts/test-shards.mjs +++ b/sdk/typescript/scripts/test-shards.mjs @@ -1,4 +1,4 @@ -// Rounded file timings [Unix, Windows] in seconds from node-ci on 2026-08-25. +// Rounded file timings [Unix, Windows] in seconds from CI and local runs. // These affect only scheduling: new files automatically get a small default. const timings = { "api-credentials.test.ts": [1, 48], @@ -15,7 +15,8 @@ const timings = { "publication-integration.test.ts": [9, 19], "publication-store.test.ts": [17, 30], "release-automation.test.ts": [7, 50], - "runtime.test.ts": [20, 121], + "runtime-credentials.test.ts": [12, 87], + "runtime.test.ts": [18, 60], "scan-comparison.test.ts": [1, 11], "scan-recovery.test.ts": [24, 83], "stopped-scan-results.test.ts": [7, 17], diff --git a/sdk/typescript/tests-ts/runtime-credentials.test.ts b/sdk/typescript/tests-ts/runtime-credentials.test.ts new file mode 100644 index 000000000..4806c6f2c --- /dev/null +++ b/sdk/typescript/tests-ts/runtime-credentials.test.ts @@ -0,0 +1,1831 @@ +import { execFile, spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { + chmod, + link, + lstat, + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + stat, + symlink, + utimes, + writeFile, +} from "node:fs/promises"; +import * as fsPromises from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { + acquireCodexSecurityCredentialHomeLock, + codexSecurityCredentialAllowsAmbientImport, + codexSecurityCredentialHome, + codexSecurityHasStoredFileCredentials, + inspectWindowsCredentialAcl, + inspectWindowsCredentialAclSnapshot, + prepareCodexSecurityCredentialHome, + requirePrivateCredentialHome, + requirePrivateCredentialFile, + requireSecureCredentialHome, + requireSecureOutputAncestry, + setCodexSecurityCredentialLogout, + streamWindowsCredentialAclDescriptors, + verifyStableWindowsCredentialDescendants, +} from "../src/runtime.js"; +import { runTestInSubprocess } from "./support/test-subprocess.js"; + +const temporaryDirectories: string[] = []; +const testPosix = process.platform === "win32" ? test.skip : test; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +async function temporaryDirectory( + prefix = "codex-security-runtime-", +): Promise { + const path = await realpath(await mkdtemp(join(tmpdir(), prefix))); + temporaryDirectories.push(path); + return path; +} + +async function plantCredentialHomeLock( + home: string, + pid: number, + modifiedAt?: Date, +): Promise { + const lock = join(home, ".codex-security-scan.lock"); + await mkdir(lock, { mode: 0o700 }); + await writeFile( + join(lock, "owner.json"), + `${JSON.stringify({ pid, token: "planted-owner" })}\n`, + { mode: 0o600 }, + ); + if (modifiedAt !== undefined) await utimes(lock, modifiedAt, modifiedAt); + return lock; +} + +async function acquireCredentialHomeLockWithTimeout( + home: string, +): Promise<() => Promise> { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(new DOMException("timed out", "AbortError")), + 10_000, + ); + timeout.unref(); + try { + return await acquireCodexSecurityCredentialHomeLock( + home, + controller.signal, + ); + } finally { + clearTimeout(timeout); + } +} + +describe("runtime directories and plugin Python boundary", () => { + test("prepares one private, reusable managed-credential home", async () => { + const root = await temporaryDirectory(); + const environment = { CODEX_SECURITY_STATE_DIR: join(root, "state") }; + const expectedHome = join(root, "state", "codex-home"); + + expect(codexSecurityCredentialHome(environment)).toBe(expectedHome); + expect(await prepareCodexSecurityCredentialHome(environment)).toBe( + expectedHome, + ); + await writeFile(join(expectedHome, "existing-state"), "preserved\n"); + expect(await prepareCodexSecurityCredentialHome(environment)).toBe( + expectedHome, + ); + expect(await readFile(join(expectedHome, "existing-state"), "utf8")).toBe( + "preserved\n", + ); + if (process.platform !== "win32") { + expect((await stat(expectedHome)).mode & 0o777).toBe(0o700); + } + }); + + testPosix("rejects unsafe persistent credential homes", async () => { + const root = await temporaryDirectory(); + const stateDirectory = join(root, "state"); + const environment = { CODEX_SECURITY_STATE_DIR: stateDirectory }; + const credentialHome = + await prepareCodexSecurityCredentialHome(environment); + await chmod(credentialHome, 0o755); + await expect( + prepareCodexSecurityCredentialHome(environment), + ).rejects.toThrow("must not be accessible to other users"); + await chmod(credentialHome, 0o700); + await rm(credentialHome, { recursive: true, force: true }); + + const redirectedHome = join(root, "redirected-home"); + await mkdir(redirectedHome, { mode: 0o700 }); + await symlink(redirectedHome, credentialHome); + await expect( + prepareCodexSecurityCredentialHome(environment), + ).rejects.toThrow("credential home is not a directory"); + }); + + testPosix( + "rejects credential homes under a non-sticky shared parent directory", + async () => { + const root = await temporaryDirectory(); + const shared = join(root, "shared"); + await mkdir(shared, { mode: 0o777 }); + await chmod(shared, 0o777); + expect((await lstat(shared)).mode & 0o1000).toBe(0); + const environment = { CODEX_SECURITY_STATE_DIR: join(shared, "state") }; + + await expect( + prepareCodexSecurityCredentialHome(environment), + ).rejects.toThrow("sticky bit"); + await expect( + requireSecureOutputAncestry(join(shared, "state")), + ).rejects.toThrow("sticky bit"); + }, + ); + + testPosix( + "accepts credential homes under a sticky shared parent directory", + async () => { + const root = await temporaryDirectory(); + // Some filesystems (notably user dirs on macOS APFS) ignore sticky on + // chmod; fall back to the process temp root when it is already sticky. + let stickyParent = join(root, "shared"); + await mkdir(stickyParent, { mode: 0o1777 }); + await chmod(stickyParent, 0o1777); + if (((await lstat(stickyParent)).mode & 0o1000) === 0) { + stickyParent = await realpath(tmpdir()); + if (((await lstat(stickyParent)).mode & 0o1000) === 0) { + return; + } + } + const stateDirectory = join( + stickyParent, + `codex-security-sticky-${process.pid}-${Date.now()}`, + ); + temporaryDirectories.push(stateDirectory); + await mkdir(stateDirectory, { recursive: true, mode: 0o700 }); + const home = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: stateDirectory, + }); + await expect(requireSecureCredentialHome(home)).resolves.toBeDefined(); + await expect(requireSecureOutputAncestry(home)).resolves.toBeUndefined(); + }, + ); + + testPosix( + "rejects a credential home that is no longer private to the current user", + async () => { + const root = await temporaryDirectory(); + const home = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }); + await chmod(home, 0o755); + await expect(requireSecureCredentialHome(home)).rejects.toThrow( + "must not be accessible to other users", + ); + await expect( + acquireCodexSecurityCredentialHomeLock(home), + ).rejects.toThrow("must not be accessible to other users"); + }, + ); + + testPosix( + "pins credential-home identity for the duration of a lock session", + async () => { + const root = await temporaryDirectory(); + const home = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }); + const release = await acquireCodexSecurityCredentialHomeLock(home); + const stolen = join(root, "stolen-home"); + await rename(home, stolen); + await mkdir(home, { recursive: true, mode: 0o700 }); + await chmod(home, 0o700); + await expect(release()).rejects.toThrow("credential home was replaced"); + const releaseRecovered = + await acquireCredentialHomeLockWithTimeout(stolen); + await releaseRecovered(); + }, + ); + + testPosix( + "rejects stale credential-home metadata after canonical target replacement", + async () => { + const root = await temporaryDirectory(); + const home = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }); + const stale = await lstat(home, { bigint: true }); + await rename(home, join(root, "original-home")); + await mkdir(home, { mode: 0o700 }); + + await expect( + requireSecureCredentialHome(home, { metadata: stale }), + ).rejects.toThrow("credential home was replaced"); + }, + ); + + testPosix( + "rejects world-writable or symlink stored authentication files", + async () => { + const root = await temporaryDirectory(); + const home = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }); + const authPath = join(home, "auth.json"); + await writeFile(authPath, '{"token":"test"}\n', { mode: 0o600 }); + expect(await codexSecurityHasStoredFileCredentials(home)).toBe(true); + + await chmod(authPath, 0o644); + await expect(codexSecurityHasStoredFileCredentials(home)).rejects.toThrow( + "must not be accessible to other users", + ); + await rm(authPath); + + const target = join(home, "auth-target.json"); + await writeFile(target, '{"token":"test"}\n', { mode: 0o600 }); + await symlink(target, authPath); + await expect(codexSecurityHasStoredFileCredentials(home)).rejects.toThrow( + "not a regular file", + ); + + expect(() => + requirePrivateCredentialFile( + { mode: 0o100644, uid: 1000 }, + authPath, + 1000, + ), + ).toThrow("must not be accessible to other users"); + }, + ); + + test("identifies a credential home that already exists as a regular file", async () => { + const root = await temporaryDirectory(); + const stateDirectory = join(root, "state"); + await mkdir(stateDirectory); + await writeFile(join(stateDirectory, "codex-home"), "not a directory\n"); + + await expect( + prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: stateDirectory, + }), + ).rejects.toThrow("credential home is not a directory"); + }); + + test("serializes and releases persistent credential-home locks", async () => { + const root = await temporaryDirectory(); + const home = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }); + const releaseFirst = await acquireCodexSecurityCredentialHomeLock(home); + const database = join(home, ".codex-security-scan.sqlite3"); + const original = await stat(database); + let secondAcquired = false; + const second = acquireCodexSecurityCredentialHomeLock(home).then( + (release) => { + secondAcquired = true; + return release; + }, + ); + + await new Promise((resolve) => setTimeout(resolve, 40)); + expect(secondAcquired).toBe(false); + await releaseFirst(); + const releaseSecond = await second; + expect(secondAcquired).toBe(true); + await releaseSecond(); + expect(existsSync(join(home, ".codex-security-scan.lock"))).toBe(false); + // Removing this file would let waiters lock different inodes. + expect((await stat(database)).ino).toBe(original.ino); + }); + + testPosix( + "initializes the credential-lock database without a descriptor or permission race", + async () => { + if ( + runTestInSubprocess( + import.meta.path, + "initializes the credential-lock database without a descriptor or permission race", + ) + ) { + return; + } + const root = await temporaryDirectory(); + const home = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }); + const database = join(home, ".codex-security-scan.sqlite3"); + const originalLstat = fsPromises.lstat; + const originalWriteFile = fsPromises.writeFile; + let separateDatabaseCreates = 0; + let observedMode: number | undefined; + let paused = false; + let reportPaused!: () => void; + let resumeCreator!: () => void; + const creatorPaused = new Promise((resolve) => { + reportPaused = resolve; + }); + const creatorResumed = new Promise((resolve) => { + resumeCreator = resolve; + }); + mock.module("node:fs/promises", () => ({ + ...fsPromises, + lstat: async (...args: Parameters) => { + const metadata = await originalLstat(...args); + if (args[0] === database && !paused) { + paused = true; + observedMode = Number(metadata.mode) & 0o777; + reportPaused(); + await creatorResumed; + } + return metadata; + }, + writeFile: async (...args: Parameters) => { + if (args[0] === database) separateDatabaseCreates += 1; + return await originalWriteFile(...args); + }, + })); + + let first: Promise<() => Promise> | undefined; + let releaseSecond: (() => Promise) | undefined; + try { + first = acquireCredentialHomeLockWithTimeout(home); + await Promise.race([ + creatorPaused, + first.then(() => { + throw new Error("Credential-lock initialization did not pause"); + }), + ]); + + // Pause the creator at its first post-open inspection. A contender must + // see the final mode and acquire normally instead of rejecting it. + releaseSecond = await acquireCredentialHomeLockWithTimeout(home); + await releaseSecond(); + releaseSecond = undefined; + resumeCreator(); + const releaseFirst = await first; + await releaseFirst(); + first = undefined; + } finally { + resumeCreator(); + await releaseSecond?.(); + const pendingRelease = await first?.catch(() => undefined); + await pendingRelease?.(); + mock.module("node:fs/promises", () => ({ + ...fsPromises, + lstat: originalLstat, + writeFile: originalWriteFile, + })); + } + + // Closing another descriptor for this inode can release SQLite's + // process-owned POSIX lock, so SQLite must create its own guard file. + expect(separateDatabaseCreates).toBe(0); + expect(observedMode).toBe(0o600); + expect((await stat(database)).mode & 0o777).toBe(0o600); + }, + ); + + test("keeps a fresh live credential-home lock and cancels the waiter", async () => { + const root = await temporaryDirectory(); + const home = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }); + const release = await acquireCodexSecurityCredentialHomeLock(home); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 100); + + try { + const waiting = acquireCodexSecurityCredentialHomeLock( + home, + controller.signal, + ); + await expect(waiting).rejects.toMatchObject({ name: "AbortError" }); + expect(existsSync(join(home, ".codex-security-scan.lock"))).toBe(true); + } finally { + clearTimeout(timeout); + await release(); + } + expect(existsSync(join(home, ".codex-security-scan.lock"))).toBe(false); + const releaseAgain = await acquireCredentialHomeLockWithTimeout(home); + await releaseAgain(); + }); + + test("releases the native credential lock when legacy-lock inspection fails", async () => { + const root = await temporaryDirectory(); + const home = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }); + const lock = join(home, ".codex-security-scan.lock"); + await writeFile(lock, "not a directory", { mode: 0o600 }); + await expect(acquireCodexSecurityCredentialHomeLock(home)).rejects.toThrow( + "not a directory", + ); + await rm(lock); + const release = await acquireCredentialHomeLockWithTimeout(home); + await release(); + }); + + test("protects active and stalled credential-lock owners and recovers after a crash with a reused PID", async () => { + const root = await temporaryDirectory(); + await promisify(execFile)( + process.execPath, + [ + fileURLToPath( + new URL("../scripts/fixtures/credential-lock.mjs", import.meta.url), + ), + new URL("../src/runtime.ts", import.meta.url).href, + join(root, "state"), + ], + { timeout: 25_000, windowsHide: true }, + ); + }); + + test("does not rewrite Windows credential ACLs while polling a held lock", async () => { + const root = await temporaryDirectory(); + const home = join(root, "credential-home"); + await mkdir(home, { mode: 0o700 }); + const validations: string[] = []; + const securityOptions = { + platform: "win32" as const, + secureWindowsHome: async (path: string) => { + const lock = join(path, ".codex-security-scan.lock"); + expect(existsSync(lock) && !existsSync(join(lock, "owner.json"))).toBe( + false, + ); + validations.push(path); + }, + }; + const release = await acquireCodexSecurityCredentialHomeLock( + home, + undefined, + securityOptions, + ); + const controller = new AbortController(); + const waiting = acquireCodexSecurityCredentialHomeLock( + home, + controller.signal, + securityOptions, + ); + + try { + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(validations).toHaveLength(3); + controller.abort(new DOMException("canceled", "AbortError")); + await expect(waiting).rejects.toMatchObject({ name: "AbortError" }); + } finally { + await release(); + } + }); + + test("recovers credential-home locks left by exited processes", async () => { + const root = await temporaryDirectory(); + const home = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }); + const exited = spawnSync(process.execPath, ["--eval", ""], { + encoding: "utf8", + windowsHide: true, + }); + expect(exited.status).toBe(0); + expect(typeof exited.pid).toBe("number"); + const lock = join(home, ".codex-security-scan.lock"); + await mkdir(lock, { mode: 0o700 }); + await writeFile( + join(lock, "owner.json"), + `${JSON.stringify({ pid: exited.pid, token: "exited-process" })}\n`, + { mode: 0o600 }, + ); + + const release = await acquireCodexSecurityCredentialHomeLock(home); + expect(existsSync(lock)).toBe(true); + await release(); + expect(existsSync(lock)).toBe(false); + }); + + test("preserves a legacy live owner beyond the stale-heartbeat grace period", async () => { + const root = await temporaryDirectory(); + const home = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }); + const stale = new Date(Date.now() - 10 * 60_000); + const lock = await plantCredentialHomeLock(home, process.pid, stale); + const owner = await readFile(join(lock, "owner.json"), "utf8"); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 6_000); + let release: (() => Promise) | undefined; + + try { + await expect( + (async () => { + release = await acquireCodexSecurityCredentialHomeLock( + home, + controller.signal, + ); + })(), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(await readFile(join(lock, "owner.json"), "utf8")).toBe(owner); + } finally { + clearTimeout(timeout); + if (release !== undefined) await release(); + } + }); + + test("preserves a legacy owner when PID inspection is denied", async () => { + const root = await temporaryDirectory(); + const home = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }); + const stale = new Date(Date.now() - 10 * 60_000); + const lock = await plantCredentialHomeLock(home, process.pid, stale); + const controller = new AbortController(); + const inspectOwner = spyOn(process, "kill").mockImplementation((() => { + controller.abort(); + const error = new Error( + "operation not permitted", + ) as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + }) as typeof process.kill); + + try { + await expect( + acquireCodexSecurityCredentialHomeLock(home, controller.signal), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(inspectOwner).toHaveBeenCalledWith(process.pid, 0); + expect(existsSync(lock)).toBe(true); + } finally { + inspectOwner.mockRestore(); + } + }); + + testPosix( + "rejects linked and repairs non-private credential-lock database files", + async () => { + const root = await temporaryDirectory(); + const home = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }); + const database = join(home, ".codex-security-scan.sqlite3"); + const target = join(home, "target"); + await writeFile(target, "unchanged", { mode: 0o600 }); + for (const createLink of [symlink, link]) { + await createLink(target, database); + await expect( + acquireCodexSecurityCredentialHomeLock(home), + ).rejects.toThrow("regular file"); + expect(await readFile(target, "utf8")).toBe("unchanged"); + await rm(database); + } + // Recover the state left if a creator exits after SQLite opens the guard + // but before it tightens the default mode. + await writeFile(database, "", { mode: 0o600 }); + await chmod(database, 0o644); + const release = await acquireCodexSecurityCredentialHomeLock(home); + await release(); + expect((await stat(database)).mode & 0o777).toBe(0o600); + }, + ); + + test("recovers credential-home locks whose owner names no process", async () => { + const root = await temporaryDirectory(); + const home = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }); + const lock = join(home, ".codex-security-scan.lock"); + for (const pid of [0, -1, 0.5, 2 ** 31, 2 ** 53]) { + await mkdir(lock, { mode: 0o700 }); + await writeFile( + join(lock, "owner.json"), + `${JSON.stringify({ pid, token: "unidentifiable-owner" })}\n`, + { mode: 0o600 }, + ); + const aged = new Date(Date.now() - 10 * 60_000); + await utimes(lock, aged, aged); + + const release = await acquireCredentialHomeLockWithTimeout(home); + expect(existsSync(lock)).toBe(true); + await release(); + expect(existsSync(lock)).toBe(false); + } + }); + + test("prevents ambient credential imports after an explicit logout", async () => { + const root = await temporaryDirectory(); + const home = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }); + + expect(await codexSecurityCredentialAllowsAmbientImport(home)).toBe(true); + await setCodexSecurityCredentialLogout(home, true); + expect(await codexSecurityCredentialAllowsAmbientImport(home)).toBe(false); + if (process.platform !== "win32") { + expect( + (await stat(join(home, ".codex-security-logged-out"))).mode & 0o777, + ).toBe(0o600); + } + await setCodexSecurityCredentialLogout(home, false); + expect(await codexSecurityCredentialAllowsAmbientImport(home)).toBe(true); + }); + + test("requires a real private-ACL operation for Windows credential homes", async () => { + const root = await temporaryDirectory(); + const home = join(root, "home"); + await mkdir(home); + const metadata = await lstat(home); + const secured: string[] = []; + + await requirePrivateCredentialHome(metadata, home, { + platform: "win32", + secureWindowsHome: async (path) => { + secured.push(path); + }, + }); + + expect(secured).toEqual([home]); + await expect( + requirePrivateCredentialHome(metadata, home, { + platform: "win32", + secureWindowsHome: async () => { + throw new Error("ACL could not be secured"); + }, + }), + ).rejects.toThrow("private Windows credential home"); + }); + + test("retries Windows credential descendant verification after concurrent changes", async () => { + const root = await temporaryDirectory(); + const home = join(root, "home"); + const temporary = join(home, ".auth-temporary"); + await mkdir(home); + await writeFile(join(home, "auth.json"), "credential\n"); + await writeFile(temporary, "temporary credential\n"); + let attempts = 0; + + await verifyStableWindowsCredentialDescendants(home, async () => { + attempts += 1; + if (attempts === 1) await rm(temporary); + return 1; + }); + + expect(attempts).toBe(2); + }); + + test("retries Windows credential verification when a descendant disappears", async () => { + if ( + runTestInSubprocess( + import.meta.path, + "retries Windows credential verification when a descendant disappears", + ) + ) { + return; + } + const root = await temporaryDirectory(); + const home = join(root, "home"); + const temporary = join(home, ".auth-temporary"); + await mkdir(home); + await writeFile(join(home, "auth.json"), "credential\n"); + await writeFile(temporary, "temporary credential\n"); + const originalLstat = fsPromises.lstat; + let removed = false; + let inspections = 0; + mock.module("node:fs/promises", () => ({ + ...fsPromises, + lstat: async (path: Parameters[0]) => { + if (path === temporary && !removed) { + removed = true; + await rm(temporary); + } + return originalLstat(path); + }, + })); + + try { + await verifyStableWindowsCredentialDescendants(home, async () => { + inspections += 1; + return 1; + }); + } finally { + mock.module("node:fs/promises", () => ({ + ...fsPromises, + lstat: originalLstat, + })); + } + + expect(removed).toBe(true); + expect(inspections).toBe(1); + }); + + test("rejects Windows credential descendants that repeatedly disappear", async () => { + if ( + runTestInSubprocess( + import.meta.path, + "rejects Windows credential descendants that repeatedly disappear", + ) + ) { + return; + } + const root = await temporaryDirectory(); + const home = join(root, "home"); + const credential = join(home, "auth.json"); + await mkdir(home); + await writeFile(credential, "credential\n"); + const originalLstat = fsPromises.lstat; + let attempts = 0; + mock.module("node:fs/promises", () => ({ + ...fsPromises, + lstat: async (path: Parameters[0]) => { + if (path === credential) { + attempts += 1; + throw Object.assign(new Error("credential disappeared"), { + code: "ENOENT", + path, + }); + } + return originalLstat(path); + }, + })); + + try { + await expect( + verifyStableWindowsCredentialDescendants(home, async () => 1), + ).rejects.toThrow("Windows credential descendants could not be verified"); + } finally { + mock.module("node:fs/promises", () => ({ + ...fsPromises, + lstat: originalLstat, + })); + } + + expect(attempts).toBe(3); + }); + + test("does not retry a missing Windows credential home", async () => { + const root = await temporaryDirectory(); + const home = join(root, "missing-home"); + let inspections = 0; + + await expect( + verifyStableWindowsCredentialDescendants(home, async () => { + inspections += 1; + return 0; + }), + ).rejects.toMatchObject({ code: "ENOENT", path: home }); + + expect(inspections).toBe(0); + }); + + test("rejects Windows credential descendants that never stabilize", async () => { + const root = await temporaryDirectory(); + const home = join(root, "home"); + await mkdir(home); + await writeFile(join(home, "auth.json"), "credential\n"); + let attempts = 0; + + await expect( + verifyStableWindowsCredentialDescendants(home, async () => { + attempts += 1; + return 0; + }), + ).rejects.toThrow("Windows credential descendants could not be verified"); + expect(attempts).toBe(3); + }); + + test("inspects Windows credential ancestry, home, and descendants in one subprocess", async () => { + const root = await temporaryDirectory(); + const home = join(root, "home"); + const inspectionCount = join(root, "inspection-count"); + await mkdir(home); + await writeFile(join(home, "auth.json"), "credential\n"); + const sid = "S-1-5-21-111-222-333-1001"; + const directory = `O:${sid}G:SYD:P(A;OICI;FA;;;${sid})`; + const file = `O:${sid}G:SYD:P(A;;FA;;;${sid})`; + const ancestors: string[] = []; + for (let ancestor = dirname(home); ; ancestor = dirname(ancestor)) { + ancestors.push(directory); + if (ancestor === dirname(ancestor)) break; + } + const descriptors = [...ancestors, directory, file]; + const script = [ + `require("node:fs").appendFileSync(${JSON.stringify(inspectionCount)}, "inspection\\n")`, + `process.stdout.write(${JSON.stringify(`${descriptors.join("\n")}\n`)})`, + ].join("; "); + + const snapshot = await inspectWindowsCredentialAclSnapshot(home, sid, { + command: process.execPath, + args: ["--eval", script], + }); + + expect(snapshot.home).toMatchObject({ + owner: sid, + protected: true, + grantsCurrentUserAccess: true, + untrustedPrincipals: [], + }); + expect(snapshot.descendantsArePrivate).toBe(true); + expect(await readFile(inspectionCount, "utf8")).toBe("inspection\n"); + }); + + test("inspects Windows credential ancestry and the home even without descendants", async () => { + const root = await temporaryDirectory(); + const home = join(root, "home"); + await mkdir(home); + const sid = "S-1-5-21-111-222-333-1001"; + const directory = `O:${sid}G:SYD:P(A;OICI;FA;;;${sid})`; + const ancestors: string[] = []; + for (let ancestor = dirname(home); ; ancestor = dirname(ancestor)) { + ancestors.push(directory); + if (ancestor === dirname(ancestor)) break; + } + const descriptors = [...ancestors, directory]; + + await expect( + inspectWindowsCredentialAclSnapshot(home, sid, { + command: process.execPath, + args: [ + "--eval", + `process.stdout.write(${JSON.stringify(`${descriptors.join("\n")}\n`)})`, + ], + }), + ).resolves.toMatchObject({ + home: { owner: sid, protected: true }, + descendantsArePrivate: true, + }); + }); + + test("rejects unsafe Windows credential ancestry during combined ACL inspection", async () => { + const root = await temporaryDirectory(); + const home = join(root, "home"); + await mkdir(home); + const sid = "S-1-5-21-111-222-333-1001"; + const unsafe = `O:${sid}G:SYD:P(A;OICI;FA;;;${sid})(A;OICI;FA;;;WD)`; + + await expect( + inspectWindowsCredentialAclSnapshot(home, sid, { + command: process.execPath, + args: [ + "--eval", + `process.stdout.write(${JSON.stringify(`${unsafe}\n`)})`, + ], + }), + ).rejects.toThrow( + "Windows credential-home ancestor allows another identity to replace the directory", + ); + }); + + test("rejects incomplete combined Windows credential ACL inspections", async () => { + const root = await temporaryDirectory(); + const home = join(root, "home"); + await mkdir(home); + const sid = "S-1-5-21-111-222-333-1001"; + const directory = `O:${sid}G:SYD:P(A;OICI;FA;;;${sid})`; + + await expect( + inspectWindowsCredentialAclSnapshot(home, sid, { + command: process.execPath, + args: [ + "--eval", + `process.stdout.write(${JSON.stringify(`${directory}\n`)})`, + ], + }), + ).rejects.toThrow("Windows credential-home ancestry could not be verified"); + }); + + test("detects unsafe descendants during combined Windows credential ACL inspections", async () => { + const root = await temporaryDirectory(); + const home = join(root, "home"); + await mkdir(home); + await writeFile(join(home, "auth.json"), "credential\n"); + const sid = "S-1-5-21-111-222-333-1001"; + const directory = `O:${sid}G:SYD:P(A;OICI;FA;;;${sid})`; + const unsafeFile = `O:${sid}G:SYD:P(A;;FA;;;${sid})(A;;FR;;;WD)`; + const ancestors: string[] = []; + for (let ancestor = dirname(home); ; ancestor = dirname(ancestor)) { + ancestors.push(directory); + if (ancestor === dirname(ancestor)) break; + } + const descriptors = [...ancestors, directory, unsafeFile]; + + await expect( + inspectWindowsCredentialAclSnapshot(home, sid, { + command: process.execPath, + args: [ + "--eval", + `process.stdout.write(${JSON.stringify(`${descriptors.join("\n")}\n`)})`, + ], + }), + ).resolves.toMatchObject({ descendantsArePrivate: false }); + }); + + test("streams Windows credential ACL output larger than the subprocess buffer", async () => { + const descriptor = + "O:S-1-5-21-111-222-333-1001G:SYD:P(A;;FA;;;S-1-5-21-111-222-333-1001)"; + const expected = Math.ceil((1024 * 1024) / (descriptor.length + 1)) + 1; + let observed = 0; + + const count = await streamWindowsCredentialAclDescriptors( + process.execPath, + [ + "--eval", + `process.stdout.write(${JSON.stringify(`${descriptor}\n`)}.repeat(${expected}))`, + ], + async (received) => { + if (observed === 0 || observed === expected - 1) { + expect(received).toBe(descriptor); + } + observed += 1; + }, + ); + + expect(count).toBe(expected); + expect(observed).toBe(expected); + }); + + test("preserves Windows credential ACL subprocess failures while streaming", async () => { + await expect( + streamWindowsCredentialAclDescriptors( + process.execPath, + [ + "--eval", + 'process.stderr.write("synthetic ACL inspection failure"); process.exitCode = 1', + ], + async () => {}, + ), + ).rejects.toMatchObject({ stderr: "synthetic ACL inspection failure" }); + }); + + test("accepts managed Windows ACLs with trusted system principals", () => { + const user = "S-1-5-21-111-222-333-1001"; + const descriptor = + `O:${user}G:${user}D:AI` + + `(A;OICIID;FA;;;${user})` + + "(A;OICIID;FA;;;SY)" + + "(A;OICIID;FA;;;BA)"; + + expect(inspectWindowsCredentialAcl(descriptor, user)).toEqual({ + owner: user, + protected: false, + grantsCurrentUserAccess: true, + untrustedPrincipals: [], + deniedPrincipals: [], + }); + expect( + inspectWindowsCredentialAcl( + `O:BAG:SYD:P(A;OICI;FA;;;${user})(A;OICI;FA;;;SY)`, + user, + ), + ).toMatchObject({ + owner: "S-1-5-32-544", + protected: true, + untrustedPrincipals: [], + }); + }); + + test("identifies Windows ancestor grants that can replace credential homes", () => { + const user = "S-1-5-21-111-222-333-1001"; + for (const rights of [ + "FA", + "GA", + "FW", + "GW", + "GAGX", + "GXGA", + "GWGX", + "GXGW", + "FAGX", + "FWGX", + "SD", + "WD", + "WO", + "DC", + "0x40", + "0x10000", + "0x40000", + "0x80000", + "0x1301bf", + ]) { + expect( + inspectWindowsCredentialAcl( + `O:${user}G:SYD:(A;OICI;FA;;;${user})(A;;${rights};;;WD)`, + user, + { scope: "ancestor" }, + ).untrustedPrincipals, + ).toEqual(["S-1-1-0"]); + } + + for (const [flags, rights] of [ + ["", "FR"], + ["", "FRGX"], + ["", "GRGX"], + ["", "0x1200a9"], + ["IO", "FA"], + ] as const) { + expect( + inspectWindowsCredentialAcl( + `O:${user}G:SYD:(A;OICI;FA;;;${user})(A;${flags};${rights};;;WD)`, + user, + { scope: "ancestor" }, + ).untrustedPrincipals, + ).toEqual([]); + } + + const service = "S-1-5-80-111-222-333-444-555"; + for (const [principal, expected] of [ + ["LS", "S-1-5-19"], + ["NS", "S-1-5-20"], + [service, service], + ] as const) { + expect( + inspectWindowsCredentialAcl( + `O:${user}G:SYD:(A;OICI;FA;;;${user})(A;;DC;;;${principal})`, + user, + { scope: "ancestor" }, + ).untrustedPrincipals, + ).toEqual([expected]); + } + expect(() => + inspectWindowsCredentialAcl( + `O:${service}G:SYD:(A;OICI;FA;;;${user})`, + user, + { scope: "ancestor" }, + ), + ).toThrow("owner is not a trusted principal"); + + const installer = + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"; + expect( + inspectWindowsCredentialAcl( + `O:${installer}G:SYD:(A;OICI;FA;;;${installer})(A;OICI;FA;;;${user})`, + user, + { scope: "ancestor" }, + ).untrustedPrincipals, + ).toEqual([]); + expect(() => + inspectWindowsCredentialAcl( + `O:${installer}G:SYD:(A;OICI;FA;;;${user})`, + user, + ), + ).toThrow("owner is not a trusted principal"); + }); + + test("accepts private credential-file ACLs without inheritance flags", () => { + const user = "S-1-5-21-111-222-333-1001"; + const descriptor = `O:${user}G:SYD:P(A;;FA;;;${user})(A;;FA;;;SY)`; + + expect(inspectWindowsCredentialAcl(descriptor, user)).toMatchObject({ + grantsCurrentUserAccess: false, + }); + expect( + inspectWindowsCredentialAcl(descriptor, user, { scope: "file" }), + ).toMatchObject({ + grantsCurrentUserAccess: true, + untrustedPrincipals: [], + }); + }); + + test("identifies broad, foreign, and inherited Windows ACL grants", () => { + const user = "S-1-5-21-111-222-333-1001"; + const stranger = "S-1-5-21-111-222-333-1002"; + for (const [principal, expected] of [ + ["WD", "S-1-1-0"], + ["BU", "S-1-5-32-545"], + ["AU", "S-1-5-11"], + ["CO", "S-1-3-0"], + ["CG", "S-1-3-1"], + ["OW", "S-1-3-4"], + ["AC", "S-1-15-2-1"], + ["AN", "S-1-5-7"], + ["IU", "S-1-5-4"], + ["SU", "S-1-5-6"], + ["RD", "S-1-5-32-555"], + ["DA", "S-1-5-21-111-222-333-512"], + ["DU", "S-1-5-21-111-222-333-513"], + [stranger, stranger], + ] as const) { + expect( + inspectWindowsCredentialAcl( + `O:${user}G:${user}D:AI(A;OICIID;FA;;;${user})(A;OICIID;FR;;;${principal})`, + user, + { + resolvedAliases: { + DA: "S-1-5-21-111-222-333-512", + DU: "S-1-5-21-111-222-333-513", + }, + }, + ).untrustedPrincipals, + ).toEqual([expected]); + } + expect( + inspectWindowsCredentialAcl( + `O:${user}G:${user}D:P(D;OICI;FR;;;WD)(A;OICI;FA;;;${user})`, + user, + ), + ).toMatchObject({ + grantsCurrentUserAccess: false, + untrustedPrincipals: [], + deniedPrincipals: ["S-1-1-0"], + }); + }); + + test("requires effective, inheritable Windows credential access", () => { + const user = "S-1-5-21-111-222-333-1001"; + for (const [flags, rights] of [ + ["OICI", "FR"], + ["OICI", "FW"], + ["", "FA"], + ["OI", "FA"], + ["CI", "FA"], + ["OICIIO", "FA"], + ["OICINP", "FA"], + ["OICINPID", "FA"], + ["CIIOID", "FA"], + ] as const) { + expect( + inspectWindowsCredentialAcl( + `O:${user}G:${user}D:P(A;${flags};${rights};;;${user})`, + user, + ).grantsCurrentUserAccess, + ).toBe(false); + } + + for (const rights of ["FA", "GA", "0x1f01ff", "0x10000000"]) { + expect( + inspectWindowsCredentialAcl( + `O:${user}G:${user}D:P(A;OICI;${rights};;;${user})`, + user, + ).grantsCurrentUserAccess, + ).toBe(true); + } + + expect( + inspectWindowsCredentialAcl( + `O:${user}G:${user}D:P(A;;FA;;;${user})(A;OICIIO;FA;;;${user})`, + user, + ).grantsCurrentUserAccess, + ).toBe(true); + expect( + inspectWindowsCredentialAcl( + `O:${user}G:${user}D:P(A;CIOI;FA;;;${user})`, + user, + ).grantsCurrentUserAccess, + ).toBe(true); + expect( + inspectWindowsCredentialAcl( + `O:${user}G:${user}D:P(A;;FA;;;${user})(A;OINP;FA;;;${user})(A;CI;FA;;;${user})`, + user, + ).grantsCurrentUserAccess, + ).toBe(false); + }); + + test("normalizes built-in Windows user and service SID aliases", () => { + for (const [alias, user] of [ + ["SY", "S-1-5-18"], + ["LS", "S-1-5-19"], + ["NS", "S-1-5-20"], + ["LA", "S-1-5-21-111-222-333-500"], + ["LG", "S-1-5-21-111-222-333-501"], + ] as const) { + expect( + inspectWindowsCredentialAcl( + `O:${alias}G:SYD:P(A;OICI;FA;;;${alias})(A;OICI;FA;;;BA)`, + user, + { + resolvedAliases: + alias === "LA" || alias === "LG" ? { [alias]: user } : {}, + }, + ), + ).toMatchObject({ + owner: user, + protected: true, + grantsCurrentUserAccess: true, + untrustedPrincipals: [], + deniedPrincipals: [], + }); + } + }); + + test("does not confuse domain accounts with local Administrator or Guest", () => { + const administrator = "S-1-5-21-111-222-333-500"; + const guest = "S-1-5-21-111-222-333-501"; + const localAdministrator = "S-1-5-21-444-555-666-500"; + const localGuest = "S-1-5-21-444-555-666-501"; + + expect( + inspectWindowsCredentialAcl( + `O:LAG:SYD:P(A;OICI;FA;;;LA)(A;OICI;FA;;;BA)`, + administrator, + { resolvedAliases: { LA: localAdministrator } }, + ), + ).toMatchObject({ + owner: localAdministrator, + grantsCurrentUserAccess: false, + }); + expect( + inspectWindowsCredentialAcl( + `O:${guest}G:SYD:P(A;OICI;FA;;;${guest})(A;OICI;FA;;;LG)`, + guest, + { resolvedAliases: { LG: localGuest } }, + ).untrustedPrincipals, + ).toEqual([localGuest]); + expect(() => + inspectWindowsCredentialAcl( + `O:LGG:SYD:P(A;OICI;FA;;;${guest})(A;OICI;FA;;;LG)`, + guest, + { resolvedAliases: { LG: localGuest } }, + ), + ).toThrow("owner is not a trusted principal"); + }); + + test("resolves domain and forest aliases against their actual SID domain", () => { + const currentUser = "S-1-5-21-111-222-333-1001"; + const joinedDomainAdmins = "S-1-5-21-444-555-666-512"; + const forestRootAdmins = "S-1-5-21-777-888-999-519"; + const domainRasServers = "S-1-5-21-444-555-666-553"; + + expect( + inspectWindowsCredentialAcl( + `O:${currentUser}G:SYD:P(A;OICI;FA;;;${currentUser})(A;OICI;FR;;;DA)(A;OICI;FR;;;EA)(A;OICI;FR;;;RS)`, + currentUser, + { + resolvedAliases: { + DA: joinedDomainAdmins, + EA: forestRootAdmins, + RS: domainRasServers, + }, + }, + ).untrustedPrincipals, + ).toEqual([joinedDomainAdmins, forestRootAdmins, domainRasServers]); + }); + + test("classifies conditional Windows access rules without trusting callbacks", () => { + const user = "S-1-5-21-111-222-333-1001"; + const condition = '(@User.department == "(Managed;QA)")'; + + expect( + inspectWindowsCredentialAcl( + `O:${user}G:SYD:P(A;OICI;FA;;;${user})(XA;OICI;FR;;;WD;${condition})`, + user, + ), + ).toMatchObject({ + grantsCurrentUserAccess: true, + untrustedPrincipals: ["S-1-1-0"], + }); + expect( + inspectWindowsCredentialAcl( + `O:${user}G:SYD:P(XA;OICI;FA;;;${user};${condition})`, + user, + ).grantsCurrentUserAccess, + ).toBe(false); + expect( + inspectWindowsCredentialAcl( + `O:${user}G:SYD:P(A;OICI;FA;;;${user})(ZA;OICI;FR;;;WD;${condition})`, + user, + ), + ).toMatchObject({ + grantsCurrentUserAccess: true, + untrustedPrincipals: ["S-1-1-0"], + }); + expect( + inspectWindowsCredentialAcl( + `O:${user}G:SYD:P(ZA;OICI;FA;;;${user};${condition})`, + user, + ).grantsCurrentUserAccess, + ).toBe(false); + expect( + inspectWindowsCredentialAcl( + `O:${user}G:SYD:P(A;OICI;FA;;;${user})(XD;OICI;FR;;;WD;${condition})`, + user, + ), + ).toMatchObject({ + grantsCurrentUserAccess: false, + deniedPrincipals: ["S-1-1-0"], + }); + }); + + test("classifies object-specific Windows ACLs without treating them as unrestricted", () => { + const user = "S-1-5-21-111-222-333-1001"; + const guid = "bf967aba-0de6-11d0-a285-00aa003049e2"; + + expect( + inspectWindowsCredentialAcl( + `O:${user}G:SYD:P(A;OICI;FA;;;${user})(OA;OICI;FR;${guid};;WD)`, + user, + ), + ).toMatchObject({ + grantsCurrentUserAccess: true, + untrustedPrincipals: ["S-1-1-0"], + }); + expect( + inspectWindowsCredentialAcl( + `O:${user}G:SYD:P(OA;OICI;FA;${guid};;${user})`, + user, + ).grantsCurrentUserAccess, + ).toBe(false); + expect( + inspectWindowsCredentialAcl( + `O:${user}G:SYD:P(A;OICI;FA;;;${user})(OD;OICI;FR;;${guid};WD)`, + user, + ), + ).toMatchObject({ + grantsCurrentUserAccess: false, + deniedPrincipals: ["S-1-1-0"], + }); + }); + + test("rejects incomplete, unowned, and unsupported Windows ACLs", () => { + const user = "S-1-5-21-111-222-333-1001"; + const stranger = "S-1-5-21-111-222-333-1002"; + for (const descriptor of [ + `G:${user}D:P(A;OICI;FA;;;${user})`, + `O:${user}G:${user}`, + `O:${user}G:${user}D:NO_ACCESS_CONTROL`, + `O:${user}G:${user}D:P`, + `O:${stranger}G:${user}D:P(A;OICI;FA;;;${user})`, + `O:${user}G:${user}D:P(XA;OICI;FA;;;${user})`, + `O:${user}G:${user}D:P(A;OIN;FA;;;${user})`, + `O:${user}G:${user}D:P(A;ZZ;FA;;;${user})`, + `O:${user}G:${user}D:P(OA;OICI;FA;not-a-guid;;${user})`, + `O:${user}G:${user}D:P(A;OICI;FA;bf967aba-0de6-11d0-a285-00aa003049e2;;${user})`, + `O:${user}G:${user}D:P(A;OICI;FA;;;${user};(@User.Department == \"QA\"))`, + ]) { + expect(() => inspectWindowsCredentialAcl(descriptor, user)).toThrow(); + } + expect(() => + inspectWindowsCredentialAcl( + `O:${user}G:${user}D:P(A;OICI;FA;;;${user})`, + "not-a-sid", + ), + ).toThrow("current Windows user SID"); + expect( + inspectWindowsCredentialAcl( + `O:${user}G:${user}D:P(A;OICIIO;FA;;;${user})`, + user, + ).grantsCurrentUserAccess, + ).toBe(false); + }); + + test("preserves Windows ACL subprocess failures", async () => { + const root = await temporaryDirectory(); + const home = join(root, "home"); + await mkdir(home); + const metadata = await lstat(home); + const underlying = Object.assign(new Error("PowerShell failed"), { + stderr: + "Method invocation is supported only on core types in this language mode. " + + "token=sk-proj-SYNTHETIC_WINDOWS_ACL_SECRET_123", + }); + + try { + await requirePrivateCredentialHome(metadata, home, { + platform: "win32", + secureWindowsHome: async () => { + throw underlying; + }, + }); + throw new Error("expected the Windows ACL operation to fail"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("core types"); + expect((error as Error).message).toContain( + "token=sk-proj-SYNTHETIC_WINDOWS_ACL_SECRET_123", + ); + expect((error as Error).cause).toBe(underlying); + } + }); + + test("rejects replacement credential homes when numeric identities collide", async () => { + const root = await temporaryDirectory(); + const home = join(root, "home"); + await mkdir(home); + const canonicalHome = await realpath(home); + const originalLstat = fsPromises.lstat; + const firstExactIdentity = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + let homeInspections = 0; + const inspectHome = spyOn(fsPromises, "lstat").mockImplementation( + async (path, options) => { + const stats = await originalLstat(path, options as never); + if (String(path) !== home && String(path) !== canonicalHome) { + return stats as never; + } + const exactIdentity = + firstExactIdentity + (homeInspections++ === 0 ? 0n : 1n); + return Object.assign( + Object.create(Object.getPrototypeOf(stats)), + stats, + { + ino: + typeof stats.ino === "bigint" + ? exactIdentity + : Number(exactIdentity), + }, + ) as never; + }, + ); + + try { + await expect( + requireSecureCredentialHome(home, { + platform: "win32", + secureWindowsHome: async () => {}, + }), + ).rejects.toThrow("credential home was replaced"); + } finally { + inspectHome.mockRestore(); + } + }); + + test("revalidates the Windows credential ACL every time the home is used", async () => { + const root = await temporaryDirectory(); + const home = join(root, "home"); + await mkdir(home); + const validations: string[] = []; + + await requireSecureCredentialHome(home, { + platform: "win32", + secureWindowsHome: async (path) => { + validations.push(path); + }, + }); + + expect(validations).toEqual([home]); + await expect( + requireSecureCredentialHome(home, { + platform: "win32", + secureWindowsHome: async () => { + throw new Error("ACL changed after preparation"); + }, + }), + ).rejects.toThrow("private Windows credential home"); + }); + + test.skipIf(process.platform !== "win32")( + "creates credential homes with a verified managed-compatible Windows ACL", + async () => { + const root = await temporaryDirectory(); + const home = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }); + const powershell = join( + process.env["SystemRoot"] ?? "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + const command = [ + "$ErrorActionPreference = 'Stop'", + "$path = [Environment]::GetEnvironmentVariable('CODEX_SECURITY_TEST_ACL_PATH', 'Process')", + "$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value", + "$acl = [System.IO.Directory]::GetAccessControl($path)", + "$trusted = @($identity, 'S-1-5-18', 'S-1-5-32-544')", + "$unexpected = @($acl.Access | Where-Object { $_.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Allow -and $trusted -notcontains $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value })", + "[pscustomobject]@{ unexpected = $unexpected.Count } | ConvertTo-Json -Compress", + ].join("; "); + const result = await promisify(execFile)( + powershell, + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], + { + encoding: "utf8", + env: { ...process.env, CODEX_SECURITY_TEST_ACL_PATH: home }, + timeout: 20_000, + windowsHide: true, + }, + ); + + expect(JSON.parse(result.stdout)).toEqual({ unexpected: 0 }); + }, + ); + + test.skipIf(process.platform !== "win32")( + "preserves SYSTEM and Administrators when protecting inherited access", + async () => { + const root = await temporaryDirectory(); + const state = join(root, "state"); + await mkdir(state); + const systemDirectory = join( + process.env["SystemRoot"] ?? "C:\\Windows", + "System32", + ); + const user = spawnSync( + join(systemDirectory, "whoami.exe"), + ["/user", "/fo", "csv", "/nh"], + { encoding: "utf8", windowsHide: true }, + ); + expect(user.status).toBe(0); + const sid = /"(S-1-(?:\d+-)*\d+)"\s*$/u.exec(user.stdout)?.[1]; + expect(sid).toBeDefined(); + const configured = spawnSync( + join(systemDirectory, "icacls.exe"), + [ + state, + "/inheritance:r", + "/grant:r", + `*${sid}:(OI)(CI)F`, + "*S-1-5-18:(OI)(CI)F", + "*S-1-5-32-544:(OI)(CI)F", + ], + { encoding: "utf8", windowsHide: true }, + ); + expect(configured.status).toBe(0); + + const home = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: state, + }); + const descriptor = spawnSync( + join(systemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"), + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + [ + "$acl = [System.IO.Directory]::GetAccessControl($env:CODEX_SECURITY_TEST_ACL_PATH)", + "$allowed = @($acl.Access | Where-Object { $_.AccessControlType -eq 'Allow' })", + "$denied = @($acl.Access | Where-Object { $_.AccessControlType -eq 'Deny' })", + "$owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value", + "$principals = @($allowed | ForEach-Object { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value })", + "$deniedPrincipals = @($denied | ForEach-Object { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value })", + "$fullControl = @($allowed | Where-Object { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq $env:CODEX_SECURITY_TEST_USER_SID -and ($_.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl -and ($_.InheritanceFlags -band [System.Security.AccessControl.InheritanceFlags]::ContainerInherit) -ne 0 -and ($_.InheritanceFlags -band [System.Security.AccessControl.InheritanceFlags]::ObjectInherit) -ne 0 -and $_.PropagationFlags -eq [System.Security.AccessControl.PropagationFlags]::None })", + "[pscustomobject]@{ owner = $owner; protected = $acl.AreAccessRulesProtected; principals = $principals; deniedPrincipals = $deniedPrincipals; grantsCurrentUserAccess = ($fullControl.Count -gt 0 -and $denied.Count -eq 0) } | ConvertTo-Json -Compress", + ].join("; "), + ], + { + encoding: "utf8", + env: { + ...process.env, + CODEX_SECURITY_TEST_ACL_PATH: home, + CODEX_SECURITY_TEST_USER_SID: sid!, + }, + windowsHide: true, + }, + ); + expect(descriptor.status).toBe(0); + const access = JSON.parse(descriptor.stdout) as { + owner: string; + protected: boolean; + principals: string[]; + deniedPrincipals: string[]; + grantsCurrentUserAccess: boolean; + }; + expect(access).toMatchObject({ + protected: true, + deniedPrincipals: [], + grantsCurrentUserAccess: true, + }); + expect(access.principals).toEqual( + expect.arrayContaining([sid!, "S-1-5-18", "S-1-5-32-544"]), + ); + expect([sid!, "S-1-5-18", "S-1-5-32-544"]).toContain(access.owner); + expect(new Set(access.principals)).toEqual( + new Set([sid!, "S-1-5-18", "S-1-5-32-544"]), + ); + }, + ); + + test.skipIf(process.platform !== "win32")( + "removes unsafe inherited Windows credential-home permissions", + async () => { + const root = await temporaryDirectory(); + const state = join(root, "state"); + await mkdir(state); + const systemDirectory = join( + process.env["SystemRoot"] ?? "C:\\Windows", + "System32", + ); + const shared = spawnSync( + join(systemDirectory, "icacls.exe"), + [state, "/grant", "*S-1-1-0:(OI)(CI)R"], + { encoding: "utf8", windowsHide: true }, + ); + expect(shared.status).toBe(0); + + const home = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: state, + }); + const result = spawnSync( + join(systemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"), + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + [ + "$acl = [System.IO.Directory]::GetAccessControl($env:CODEX_SECURITY_TEST_ACL_PATH)", + "$everyone = @($acl.Access | Where-Object { $_.AccessControlType -eq 'Allow' -and $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq 'S-1-1-0' })", + "[pscustomobject]@{ protected = $acl.AreAccessRulesProtected; everyone = $everyone.Count } | ConvertTo-Json -Compress", + ].join("; "), + ], + { + encoding: "utf8", + env: { ...process.env, CODEX_SECURITY_TEST_ACL_PATH: home }, + windowsHide: true, + }, + ); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + protected: true, + everyone: 0, + }); + }, + ); + + test.skipIf(process.platform !== "win32")( + "removes explicit foreign Windows credential-home grants", + async () => { + const root = await temporaryDirectory(); + const state = join(root, "state"); + const home = join(state, "codex-home"); + await mkdir(home, { recursive: true }); + const systemDirectory = join( + process.env["SystemRoot"] ?? "C:\\Windows", + "System32", + ); + const configured = spawnSync( + join(systemDirectory, "icacls.exe"), + [home, "/grant", "*S-1-1-0:(OI)(CI)R"], + { encoding: "utf8", windowsHide: true }, + ); + expect(configured.status).toBe(0); + + expect( + await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: state, + }), + ).toBe(await realpath(home)); + const result = spawnSync( + join(systemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"), + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + [ + "$acl = [System.IO.Directory]::GetAccessControl($env:CODEX_SECURITY_TEST_ACL_PATH)", + "$everyone = @($acl.Access | Where-Object { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq 'S-1-1-0' })", + "[pscustomobject]@{ protected = $acl.AreAccessRulesProtected; everyone = $everyone.Count } | ConvertTo-Json -Compress", + ].join("; "), + ], + { + encoding: "utf8", + env: { ...process.env, CODEX_SECURITY_TEST_ACL_PATH: home }, + windowsHide: true, + }, + ); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + protected: true, + everyone: 0, + }); + }, + ); + + test.skipIf(process.platform !== "win32")( + "rejects attacker-writable Windows credential-home ancestry without changing it", + async () => { + const root = await temporaryDirectory(); + const state = join(root, "state"); + await mkdir(state); + const systemDirectory = join( + process.env["SystemRoot"] ?? "C:\\Windows", + "System32", + ); + const identity = spawnSync( + join(systemDirectory, "whoami.exe"), + ["/user", "/fo", "csv", "/nh"], + { encoding: "utf8", windowsHide: true }, + ); + expect(identity.status).toBe(0); + const sid = /"(S-1-(?:\d+-)*\d+)"\s*$/u.exec(identity.stdout)?.[1]; + expect(sid).toBeDefined(); + for (const ancestor of [root, state]) { + const owned = spawnSync( + join(systemDirectory, "icacls.exe"), + [ancestor, "/setowner", `*${sid}`], + { encoding: "utf8", windowsHide: true }, + ); + expect(owned.status).toBe(0); + const writable = spawnSync( + join(systemDirectory, "icacls.exe"), + [ancestor, "/grant", "*S-1-1-0:(OI)(CI)M"], + { encoding: "utf8", windowsHide: true }, + ); + expect(writable.status).toBe(0); + } + + await expect( + prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: state, + }), + ).rejects.toThrow( + "Windows credential-home ancestor allows another identity to replace the directory", + ); + + for (const ancestor of [root, state]) { + const inspection = spawnSync( + join(systemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"), + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + [ + "$acl = [System.IO.Directory]::GetAccessControl($env:CODEX_SECURITY_TEST_ACL_PATH)", + "$everyone = @($acl.Access | Where-Object { $_.AccessControlType -eq 'Allow' -and $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq 'S-1-1-0' })", + "[pscustomobject]@{ protected = $acl.AreAccessRulesProtected; everyone = $everyone.Count } | ConvertTo-Json -Compress", + ].join("; "), + ], + { + encoding: "utf8", + env: { ...process.env, CODEX_SECURITY_TEST_ACL_PATH: ancestor }, + windowsHide: true, + }, + ); + expect(inspection.status).toBe(0); + expect(JSON.parse(inspection.stdout).everyone).toBeGreaterThan(0); + } + }, + ); + + test.skipIf(process.platform !== "win32")( + "repairs unsafe ACLs on existing nested Windows credential files", + async () => { + const root = await temporaryDirectory(); + const state = join(root, "state"); + const home = join(state, "codex-home"); + const nested = join(home, "sessions"); + await mkdir(nested, { recursive: true }); + const auth = join(home, "auth.json"); + const nestedAuth = join(nested, "credentials.json"); + await writeFile(auth, '{"token":"synthetic-root"}\n'); + await writeFile(nestedAuth, '{"token":"synthetic-nested"}\n'); + + const systemDirectory = join( + process.env["SystemRoot"] ?? "C:\\Windows", + "System32", + ); + const identity = spawnSync( + join(systemDirectory, "whoami.exe"), + ["/user", "/fo", "csv", "/nh"], + { encoding: "utf8", windowsHide: true }, + ); + expect(identity.status).toBe(0); + const sid = /"(S-1-(?:\d+-)*\d+)"\s*$/u.exec(identity.stdout)?.[1]; + expect(sid).toBeDefined(); + + for (const credential of [auth, nestedAuth]) { + const unsafe = spawnSync( + join(systemDirectory, "icacls.exe"), + [credential, "/inheritance:r", "/grant:r", `*${sid}:F`, "*S-1-1-0:R"], + { encoding: "utf8", windowsHide: true }, + ); + expect(unsafe.status).toBe(0); + } + + expect( + await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: state, + }), + ).toBe(await realpath(home)); + + const inspection = spawnSync( + join(systemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"), + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + [ + "$paths = @($env:CODEX_SECURITY_TEST_AUTH_PATH, $env:CODEX_SECURITY_TEST_NESTED_AUTH_PATH)", + "$unexpected = @($paths | ForEach-Object { $acl = Get-Acl -LiteralPath $_; $acl.Access | Where-Object { $_.AccessControlType -eq 'Allow' -and $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq 'S-1-1-0' } })", + "[pscustomobject]@{ unexpected = $unexpected.Count } | ConvertTo-Json -Compress", + ].join("; "), + ], + { + encoding: "utf8", + env: { + ...process.env, + CODEX_SECURITY_TEST_AUTH_PATH: auth, + CODEX_SECURITY_TEST_NESTED_AUTH_PATH: nestedAuth, + }, + windowsHide: true, + }, + ); + expect(inspection.status).toBe(0); + expect(JSON.parse(inspection.stdout)).toEqual({ unexpected: 0 }); + expect(await readFile(auth, "utf8")).toContain("synthetic-root"); + expect(await readFile(nestedAuth, "utf8")).toContain("synthetic-nested"); + }, + ); +}); diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 776adf60c..de9cdcb18 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -3,7 +3,6 @@ import { existsSync, renameSync, symlinkSync } from "node:fs"; import { chmod, copyFile, - link, lstat, mkdir, mkdtemp, @@ -11,11 +10,9 @@ import { readlink, realpath, readdir, - rename, rm, stat, symlink, - utimes, writeFile, } from "node:fs/promises"; import * as fsPromises from "node:fs/promises"; @@ -30,7 +27,6 @@ import { sep, } from "node:path"; import { promisify } from "node:util"; -import { fileURLToPath } from "node:url"; import { brotliDecompressSync } from "node:zlib"; import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { strToU8, zipSync } from "fflate"; @@ -52,30 +48,17 @@ import { validateOutputDir, } from "../src/index.js"; import { - acquireCodexSecurityCredentialHomeLock, bundledPluginCandidates, canonicalizeModelSafePath, - codexSecurityCredentialAllowsAmbientImport, - codexSecurityCredentialHome, - codexSecurityHasStoredFileCredentials, codexSecurityStateDirectory, - inspectWindowsCredentialAcl, - inspectWindowsCredentialAclSnapshot, isPythonPathCandidate, planOutputArchive, - prepareCodexSecurityCredentialHome, preparePersistentOutputRoot, preserveCodexSecurityPluginRegistration, - requirePrivateCredentialHome, - requirePrivateCredentialFile, requirePrivateOutputDirectory, - requireSecureCredentialHome, requireSecureOutputAncestry, requireTrustedOutputAncestor, runWorkbench, - setCodexSecurityCredentialLogout, - streamWindowsCredentialAclDescriptors, - verifyStableWindowsCredentialDescendants, } from "../src/runtime.js"; import { loadBundledRuntime, PLUGIN_ROOT } from "./plugin-root.js"; import { runTestInSubprocess } from "./support/test-subprocess.js"; @@ -99,41 +82,6 @@ async function temporaryDirectory( return path; } -async function plantCredentialHomeLock( - home: string, - pid: number, - modifiedAt?: Date, -): Promise { - const lock = join(home, ".codex-security-scan.lock"); - await mkdir(lock, { mode: 0o700 }); - await writeFile( - join(lock, "owner.json"), - `${JSON.stringify({ pid, token: "planted-owner" })}\n`, - { mode: 0o600 }, - ); - if (modifiedAt !== undefined) await utimes(lock, modifiedAt, modifiedAt); - return lock; -} - -async function acquireCredentialHomeLockWithTimeout( - home: string, -): Promise<() => Promise> { - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(new DOMException("timed out", "AbortError")), - 10_000, - ); - timeout.unref(); - try { - return await acquireCodexSecurityCredentialHomeLock( - home, - controller.signal, - ); - } finally { - clearTimeout(timeout); - } -} - async function plugin(root: string, version = "1.2.3"): Promise { const path = join(root, "plugin"); await mkdir(join(path, ".codex-plugin"), { recursive: true }); @@ -2133,1741 +2081,6 @@ describe("plugin runtime preparation", () => { }); describe("runtime directories and plugin Python boundary", () => { - test("prepares one private, reusable managed-credential home", async () => { - const root = await temporaryDirectory(); - const environment = { CODEX_SECURITY_STATE_DIR: join(root, "state") }; - const expectedHome = join(root, "state", "codex-home"); - - expect(codexSecurityCredentialHome(environment)).toBe(expectedHome); - expect(await prepareCodexSecurityCredentialHome(environment)).toBe( - expectedHome, - ); - await writeFile(join(expectedHome, "existing-state"), "preserved\n"); - expect(await prepareCodexSecurityCredentialHome(environment)).toBe( - expectedHome, - ); - expect(await readFile(join(expectedHome, "existing-state"), "utf8")).toBe( - "preserved\n", - ); - if (process.platform !== "win32") { - expect((await stat(expectedHome)).mode & 0o777).toBe(0o700); - } - }); - - testPosix("rejects unsafe persistent credential homes", async () => { - const root = await temporaryDirectory(); - const stateDirectory = join(root, "state"); - const environment = { CODEX_SECURITY_STATE_DIR: stateDirectory }; - const credentialHome = - await prepareCodexSecurityCredentialHome(environment); - await chmod(credentialHome, 0o755); - await expect( - prepareCodexSecurityCredentialHome(environment), - ).rejects.toThrow("must not be accessible to other users"); - await chmod(credentialHome, 0o700); - await rm(credentialHome, { recursive: true, force: true }); - - const redirectedHome = join(root, "redirected-home"); - await mkdir(redirectedHome, { mode: 0o700 }); - await symlink(redirectedHome, credentialHome); - await expect( - prepareCodexSecurityCredentialHome(environment), - ).rejects.toThrow("credential home is not a directory"); - }); - - testPosix( - "rejects credential homes under a non-sticky shared parent directory", - async () => { - const root = await temporaryDirectory(); - const shared = join(root, "shared"); - await mkdir(shared, { mode: 0o777 }); - await chmod(shared, 0o777); - expect((await lstat(shared)).mode & 0o1000).toBe(0); - const environment = { CODEX_SECURITY_STATE_DIR: join(shared, "state") }; - - await expect( - prepareCodexSecurityCredentialHome(environment), - ).rejects.toThrow("sticky bit"); - await expect( - requireSecureOutputAncestry(join(shared, "state")), - ).rejects.toThrow("sticky bit"); - }, - ); - - testPosix( - "accepts credential homes under a sticky shared parent directory", - async () => { - const root = await temporaryDirectory(); - // Some filesystems (notably user dirs on macOS APFS) ignore sticky on - // chmod; fall back to the process temp root when it is already sticky. - let stickyParent = join(root, "shared"); - await mkdir(stickyParent, { mode: 0o1777 }); - await chmod(stickyParent, 0o1777); - if (((await lstat(stickyParent)).mode & 0o1000) === 0) { - stickyParent = await realpath(tmpdir()); - if (((await lstat(stickyParent)).mode & 0o1000) === 0) { - return; - } - } - const stateDirectory = join( - stickyParent, - `codex-security-sticky-${process.pid}-${Date.now()}`, - ); - temporaryDirectories.push(stateDirectory); - await mkdir(stateDirectory, { recursive: true, mode: 0o700 }); - const home = await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: stateDirectory, - }); - await expect(requireSecureCredentialHome(home)).resolves.toBeDefined(); - await expect(requireSecureOutputAncestry(home)).resolves.toBeUndefined(); - }, - ); - - testPosix( - "rejects a credential home that is no longer private to the current user", - async () => { - const root = await temporaryDirectory(); - const home = await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: join(root, "state"), - }); - await chmod(home, 0o755); - await expect(requireSecureCredentialHome(home)).rejects.toThrow( - "must not be accessible to other users", - ); - await expect( - acquireCodexSecurityCredentialHomeLock(home), - ).rejects.toThrow("must not be accessible to other users"); - }, - ); - - testPosix( - "pins credential-home identity for the duration of a lock session", - async () => { - const root = await temporaryDirectory(); - const home = await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: join(root, "state"), - }); - const release = await acquireCodexSecurityCredentialHomeLock(home); - const stolen = join(root, "stolen-home"); - await rename(home, stolen); - await mkdir(home, { recursive: true, mode: 0o700 }); - await chmod(home, 0o700); - await expect(release()).rejects.toThrow("credential home was replaced"); - const releaseRecovered = - await acquireCredentialHomeLockWithTimeout(stolen); - await releaseRecovered(); - }, - ); - - testPosix( - "rejects stale credential-home metadata after canonical target replacement", - async () => { - const root = await temporaryDirectory(); - const home = await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: join(root, "state"), - }); - const stale = await lstat(home, { bigint: true }); - await rename(home, join(root, "original-home")); - await mkdir(home, { mode: 0o700 }); - - await expect( - requireSecureCredentialHome(home, { metadata: stale }), - ).rejects.toThrow("credential home was replaced"); - }, - ); - - testPosix( - "rejects world-writable or symlink stored authentication files", - async () => { - const root = await temporaryDirectory(); - const home = await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: join(root, "state"), - }); - const authPath = join(home, "auth.json"); - await writeFile(authPath, '{"token":"test"}\n', { mode: 0o600 }); - expect(await codexSecurityHasStoredFileCredentials(home)).toBe(true); - - await chmod(authPath, 0o644); - await expect(codexSecurityHasStoredFileCredentials(home)).rejects.toThrow( - "must not be accessible to other users", - ); - await rm(authPath); - - const target = join(home, "auth-target.json"); - await writeFile(target, '{"token":"test"}\n', { mode: 0o600 }); - await symlink(target, authPath); - await expect(codexSecurityHasStoredFileCredentials(home)).rejects.toThrow( - "not a regular file", - ); - - expect(() => - requirePrivateCredentialFile( - { mode: 0o100644, uid: 1000 }, - authPath, - 1000, - ), - ).toThrow("must not be accessible to other users"); - }, - ); - - test("identifies a credential home that already exists as a regular file", async () => { - const root = await temporaryDirectory(); - const stateDirectory = join(root, "state"); - await mkdir(stateDirectory); - await writeFile(join(stateDirectory, "codex-home"), "not a directory\n"); - - await expect( - prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: stateDirectory, - }), - ).rejects.toThrow("credential home is not a directory"); - }); - - test("serializes and releases persistent credential-home locks", async () => { - const root = await temporaryDirectory(); - const home = await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: join(root, "state"), - }); - const releaseFirst = await acquireCodexSecurityCredentialHomeLock(home); - const database = join(home, ".codex-security-scan.sqlite3"); - const original = await stat(database); - let secondAcquired = false; - const second = acquireCodexSecurityCredentialHomeLock(home).then( - (release) => { - secondAcquired = true; - return release; - }, - ); - - await new Promise((resolve) => setTimeout(resolve, 40)); - expect(secondAcquired).toBe(false); - await releaseFirst(); - const releaseSecond = await second; - expect(secondAcquired).toBe(true); - await releaseSecond(); - expect(existsSync(join(home, ".codex-security-scan.lock"))).toBe(false); - // Removing this file would let waiters lock different inodes. - expect((await stat(database)).ino).toBe(original.ino); - }); - - testPosix( - "initializes the credential-lock database without a descriptor or permission race", - async () => { - if ( - runTestInSubprocess( - import.meta.path, - "initializes the credential-lock database without a descriptor or permission race", - ) - ) { - return; - } - const root = await temporaryDirectory(); - const home = await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: join(root, "state"), - }); - const database = join(home, ".codex-security-scan.sqlite3"); - const originalLstat = fsPromises.lstat; - const originalWriteFile = fsPromises.writeFile; - let separateDatabaseCreates = 0; - let observedMode: number | undefined; - let paused = false; - let reportPaused!: () => void; - let resumeCreator!: () => void; - const creatorPaused = new Promise((resolve) => { - reportPaused = resolve; - }); - const creatorResumed = new Promise((resolve) => { - resumeCreator = resolve; - }); - mock.module("node:fs/promises", () => ({ - ...fsPromises, - lstat: async (...args: Parameters) => { - const metadata = await originalLstat(...args); - if (args[0] === database && !paused) { - paused = true; - observedMode = Number(metadata.mode) & 0o777; - reportPaused(); - await creatorResumed; - } - return metadata; - }, - writeFile: async (...args: Parameters) => { - if (args[0] === database) separateDatabaseCreates += 1; - return await originalWriteFile(...args); - }, - })); - - let first: Promise<() => Promise> | undefined; - let releaseSecond: (() => Promise) | undefined; - try { - first = acquireCredentialHomeLockWithTimeout(home); - await Promise.race([ - creatorPaused, - first.then(() => { - throw new Error("Credential-lock initialization did not pause"); - }), - ]); - - // Pause the creator at its first post-open inspection. A contender must - // see the final mode and acquire normally instead of rejecting it. - releaseSecond = await acquireCredentialHomeLockWithTimeout(home); - await releaseSecond(); - releaseSecond = undefined; - resumeCreator(); - const releaseFirst = await first; - await releaseFirst(); - first = undefined; - } finally { - resumeCreator(); - await releaseSecond?.(); - const pendingRelease = await first?.catch(() => undefined); - await pendingRelease?.(); - mock.module("node:fs/promises", () => ({ - ...fsPromises, - lstat: originalLstat, - writeFile: originalWriteFile, - })); - } - - // Closing another descriptor for this inode can release SQLite's - // process-owned POSIX lock, so SQLite must create its own guard file. - expect(separateDatabaseCreates).toBe(0); - expect(observedMode).toBe(0o600); - expect((await stat(database)).mode & 0o777).toBe(0o600); - }, - ); - - test("keeps a fresh live credential-home lock and cancels the waiter", async () => { - const root = await temporaryDirectory(); - const home = await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: join(root, "state"), - }); - const release = await acquireCodexSecurityCredentialHomeLock(home); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 100); - - try { - const waiting = acquireCodexSecurityCredentialHomeLock( - home, - controller.signal, - ); - await expect(waiting).rejects.toMatchObject({ name: "AbortError" }); - expect(existsSync(join(home, ".codex-security-scan.lock"))).toBe(true); - } finally { - clearTimeout(timeout); - await release(); - } - expect(existsSync(join(home, ".codex-security-scan.lock"))).toBe(false); - const releaseAgain = await acquireCredentialHomeLockWithTimeout(home); - await releaseAgain(); - }); - - test("releases the native credential lock when legacy-lock inspection fails", async () => { - const root = await temporaryDirectory(); - const home = await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: join(root, "state"), - }); - const lock = join(home, ".codex-security-scan.lock"); - await writeFile(lock, "not a directory", { mode: 0o600 }); - await expect(acquireCodexSecurityCredentialHomeLock(home)).rejects.toThrow( - "not a directory", - ); - await rm(lock); - const release = await acquireCredentialHomeLockWithTimeout(home); - await release(); - }); - - test("protects active and stalled credential-lock owners and recovers after a crash with a reused PID", async () => { - const root = await temporaryDirectory(); - await promisify(execFile)( - process.execPath, - [ - fileURLToPath( - new URL("../scripts/fixtures/credential-lock.mjs", import.meta.url), - ), - new URL("../src/runtime.ts", import.meta.url).href, - join(root, "state"), - ], - { timeout: 25_000, windowsHide: true }, - ); - }); - - test("does not rewrite Windows credential ACLs while polling a held lock", async () => { - const root = await temporaryDirectory(); - const home = join(root, "credential-home"); - await mkdir(home, { mode: 0o700 }); - const validations: string[] = []; - const securityOptions = { - platform: "win32" as const, - secureWindowsHome: async (path: string) => { - const lock = join(path, ".codex-security-scan.lock"); - expect(existsSync(lock) && !existsSync(join(lock, "owner.json"))).toBe( - false, - ); - validations.push(path); - }, - }; - const release = await acquireCodexSecurityCredentialHomeLock( - home, - undefined, - securityOptions, - ); - const controller = new AbortController(); - const waiting = acquireCodexSecurityCredentialHomeLock( - home, - controller.signal, - securityOptions, - ); - - try { - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(validations).toHaveLength(3); - controller.abort(new DOMException("canceled", "AbortError")); - await expect(waiting).rejects.toMatchObject({ name: "AbortError" }); - } finally { - await release(); - } - }); - - test("recovers credential-home locks left by exited processes", async () => { - const root = await temporaryDirectory(); - const home = await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: join(root, "state"), - }); - const exited = spawnSync(process.execPath, ["--eval", ""], { - encoding: "utf8", - windowsHide: true, - }); - expect(exited.status).toBe(0); - expect(typeof exited.pid).toBe("number"); - const lock = join(home, ".codex-security-scan.lock"); - await mkdir(lock, { mode: 0o700 }); - await writeFile( - join(lock, "owner.json"), - `${JSON.stringify({ pid: exited.pid, token: "exited-process" })}\n`, - { mode: 0o600 }, - ); - - const release = await acquireCodexSecurityCredentialHomeLock(home); - expect(existsSync(lock)).toBe(true); - await release(); - expect(existsSync(lock)).toBe(false); - }); - - test("preserves a legacy live owner beyond the stale-heartbeat grace period", async () => { - const root = await temporaryDirectory(); - const home = await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: join(root, "state"), - }); - const stale = new Date(Date.now() - 10 * 60_000); - const lock = await plantCredentialHomeLock(home, process.pid, stale); - const owner = await readFile(join(lock, "owner.json"), "utf8"); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 6_000); - let release: (() => Promise) | undefined; - - try { - await expect( - (async () => { - release = await acquireCodexSecurityCredentialHomeLock( - home, - controller.signal, - ); - })(), - ).rejects.toMatchObject({ name: "AbortError" }); - expect(await readFile(join(lock, "owner.json"), "utf8")).toBe(owner); - } finally { - clearTimeout(timeout); - if (release !== undefined) await release(); - } - }); - - test("preserves a legacy owner when PID inspection is denied", async () => { - const root = await temporaryDirectory(); - const home = await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: join(root, "state"), - }); - const stale = new Date(Date.now() - 10 * 60_000); - const lock = await plantCredentialHomeLock(home, process.pid, stale); - const controller = new AbortController(); - const inspectOwner = spyOn(process, "kill").mockImplementation((() => { - controller.abort(); - const error = new Error( - "operation not permitted", - ) as NodeJS.ErrnoException; - error.code = "EPERM"; - throw error; - }) as typeof process.kill); - - try { - await expect( - acquireCodexSecurityCredentialHomeLock(home, controller.signal), - ).rejects.toMatchObject({ name: "AbortError" }); - expect(inspectOwner).toHaveBeenCalledWith(process.pid, 0); - expect(existsSync(lock)).toBe(true); - } finally { - inspectOwner.mockRestore(); - } - }); - - testPosix( - "rejects linked and repairs non-private credential-lock database files", - async () => { - const root = await temporaryDirectory(); - const home = await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: join(root, "state"), - }); - const database = join(home, ".codex-security-scan.sqlite3"); - const target = join(home, "target"); - await writeFile(target, "unchanged", { mode: 0o600 }); - for (const createLink of [symlink, link]) { - await createLink(target, database); - await expect( - acquireCodexSecurityCredentialHomeLock(home), - ).rejects.toThrow("regular file"); - expect(await readFile(target, "utf8")).toBe("unchanged"); - await rm(database); - } - // Recover the state left if a creator exits after SQLite opens the guard - // but before it tightens the default mode. - await writeFile(database, "", { mode: 0o600 }); - await chmod(database, 0o644); - const release = await acquireCodexSecurityCredentialHomeLock(home); - await release(); - expect((await stat(database)).mode & 0o777).toBe(0o600); - }, - ); - - test("recovers credential-home locks whose owner names no process", async () => { - const root = await temporaryDirectory(); - const home = await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: join(root, "state"), - }); - const lock = join(home, ".codex-security-scan.lock"); - for (const pid of [0, -1, 0.5, 2 ** 31, 2 ** 53]) { - await mkdir(lock, { mode: 0o700 }); - await writeFile( - join(lock, "owner.json"), - `${JSON.stringify({ pid, token: "unidentifiable-owner" })}\n`, - { mode: 0o600 }, - ); - const aged = new Date(Date.now() - 10 * 60_000); - await utimes(lock, aged, aged); - - const release = await acquireCredentialHomeLockWithTimeout(home); - expect(existsSync(lock)).toBe(true); - await release(); - expect(existsSync(lock)).toBe(false); - } - }); - - test("prevents ambient credential imports after an explicit logout", async () => { - const root = await temporaryDirectory(); - const home = await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: join(root, "state"), - }); - - expect(await codexSecurityCredentialAllowsAmbientImport(home)).toBe(true); - await setCodexSecurityCredentialLogout(home, true); - expect(await codexSecurityCredentialAllowsAmbientImport(home)).toBe(false); - if (process.platform !== "win32") { - expect( - (await stat(join(home, ".codex-security-logged-out"))).mode & 0o777, - ).toBe(0o600); - } - await setCodexSecurityCredentialLogout(home, false); - expect(await codexSecurityCredentialAllowsAmbientImport(home)).toBe(true); - }); - - test("requires a real private-ACL operation for Windows credential homes", async () => { - const root = await temporaryDirectory(); - const home = join(root, "home"); - await mkdir(home); - const metadata = await lstat(home); - const secured: string[] = []; - - await requirePrivateCredentialHome(metadata, home, { - platform: "win32", - secureWindowsHome: async (path) => { - secured.push(path); - }, - }); - - expect(secured).toEqual([home]); - await expect( - requirePrivateCredentialHome(metadata, home, { - platform: "win32", - secureWindowsHome: async () => { - throw new Error("ACL could not be secured"); - }, - }), - ).rejects.toThrow("private Windows credential home"); - }); - - test("retries Windows credential descendant verification after concurrent changes", async () => { - const root = await temporaryDirectory(); - const home = join(root, "home"); - const temporary = join(home, ".auth-temporary"); - await mkdir(home); - await writeFile(join(home, "auth.json"), "credential\n"); - await writeFile(temporary, "temporary credential\n"); - let attempts = 0; - - await verifyStableWindowsCredentialDescendants(home, async () => { - attempts += 1; - if (attempts === 1) await rm(temporary); - return 1; - }); - - expect(attempts).toBe(2); - }); - - test("retries Windows credential verification when a descendant disappears", async () => { - if ( - runTestInSubprocess( - import.meta.path, - "retries Windows credential verification when a descendant disappears", - ) - ) { - return; - } - const root = await temporaryDirectory(); - const home = join(root, "home"); - const temporary = join(home, ".auth-temporary"); - await mkdir(home); - await writeFile(join(home, "auth.json"), "credential\n"); - await writeFile(temporary, "temporary credential\n"); - const originalLstat = fsPromises.lstat; - let removed = false; - let inspections = 0; - mock.module("node:fs/promises", () => ({ - ...fsPromises, - lstat: async (path: Parameters[0]) => { - if (path === temporary && !removed) { - removed = true; - await rm(temporary); - } - return originalLstat(path); - }, - })); - - try { - await verifyStableWindowsCredentialDescendants(home, async () => { - inspections += 1; - return 1; - }); - } finally { - mock.module("node:fs/promises", () => ({ - ...fsPromises, - lstat: originalLstat, - })); - } - - expect(removed).toBe(true); - expect(inspections).toBe(1); - }); - - test("rejects Windows credential descendants that repeatedly disappear", async () => { - if ( - runTestInSubprocess( - import.meta.path, - "rejects Windows credential descendants that repeatedly disappear", - ) - ) { - return; - } - const root = await temporaryDirectory(); - const home = join(root, "home"); - const credential = join(home, "auth.json"); - await mkdir(home); - await writeFile(credential, "credential\n"); - const originalLstat = fsPromises.lstat; - let attempts = 0; - mock.module("node:fs/promises", () => ({ - ...fsPromises, - lstat: async (path: Parameters[0]) => { - if (path === credential) { - attempts += 1; - throw Object.assign(new Error("credential disappeared"), { - code: "ENOENT", - path, - }); - } - return originalLstat(path); - }, - })); - - try { - await expect( - verifyStableWindowsCredentialDescendants(home, async () => 1), - ).rejects.toThrow("Windows credential descendants could not be verified"); - } finally { - mock.module("node:fs/promises", () => ({ - ...fsPromises, - lstat: originalLstat, - })); - } - - expect(attempts).toBe(3); - }); - - test("does not retry a missing Windows credential home", async () => { - const root = await temporaryDirectory(); - const home = join(root, "missing-home"); - let inspections = 0; - - await expect( - verifyStableWindowsCredentialDescendants(home, async () => { - inspections += 1; - return 0; - }), - ).rejects.toMatchObject({ code: "ENOENT", path: home }); - - expect(inspections).toBe(0); - }); - - test("rejects Windows credential descendants that never stabilize", async () => { - const root = await temporaryDirectory(); - const home = join(root, "home"); - await mkdir(home); - await writeFile(join(home, "auth.json"), "credential\n"); - let attempts = 0; - - await expect( - verifyStableWindowsCredentialDescendants(home, async () => { - attempts += 1; - return 0; - }), - ).rejects.toThrow("Windows credential descendants could not be verified"); - expect(attempts).toBe(3); - }); - - test("inspects Windows credential ancestry, home, and descendants in one subprocess", async () => { - const root = await temporaryDirectory(); - const home = join(root, "home"); - const inspectionCount = join(root, "inspection-count"); - await mkdir(home); - await writeFile(join(home, "auth.json"), "credential\n"); - const sid = "S-1-5-21-111-222-333-1001"; - const directory = `O:${sid}G:SYD:P(A;OICI;FA;;;${sid})`; - const file = `O:${sid}G:SYD:P(A;;FA;;;${sid})`; - const ancestors: string[] = []; - for (let ancestor = dirname(home); ; ancestor = dirname(ancestor)) { - ancestors.push(directory); - if (ancestor === dirname(ancestor)) break; - } - const descriptors = [...ancestors, directory, file]; - const script = [ - `require("node:fs").appendFileSync(${JSON.stringify(inspectionCount)}, "inspection\\n")`, - `process.stdout.write(${JSON.stringify(`${descriptors.join("\n")}\n`)})`, - ].join("; "); - - const snapshot = await inspectWindowsCredentialAclSnapshot(home, sid, { - command: process.execPath, - args: ["--eval", script], - }); - - expect(snapshot.home).toMatchObject({ - owner: sid, - protected: true, - grantsCurrentUserAccess: true, - untrustedPrincipals: [], - }); - expect(snapshot.descendantsArePrivate).toBe(true); - expect(await readFile(inspectionCount, "utf8")).toBe("inspection\n"); - }); - - test("inspects Windows credential ancestry and the home even without descendants", async () => { - const root = await temporaryDirectory(); - const home = join(root, "home"); - await mkdir(home); - const sid = "S-1-5-21-111-222-333-1001"; - const directory = `O:${sid}G:SYD:P(A;OICI;FA;;;${sid})`; - const ancestors: string[] = []; - for (let ancestor = dirname(home); ; ancestor = dirname(ancestor)) { - ancestors.push(directory); - if (ancestor === dirname(ancestor)) break; - } - const descriptors = [...ancestors, directory]; - - await expect( - inspectWindowsCredentialAclSnapshot(home, sid, { - command: process.execPath, - args: [ - "--eval", - `process.stdout.write(${JSON.stringify(`${descriptors.join("\n")}\n`)})`, - ], - }), - ).resolves.toMatchObject({ - home: { owner: sid, protected: true }, - descendantsArePrivate: true, - }); - }); - - test("rejects unsafe Windows credential ancestry during combined ACL inspection", async () => { - const root = await temporaryDirectory(); - const home = join(root, "home"); - await mkdir(home); - const sid = "S-1-5-21-111-222-333-1001"; - const unsafe = `O:${sid}G:SYD:P(A;OICI;FA;;;${sid})(A;OICI;FA;;;WD)`; - - await expect( - inspectWindowsCredentialAclSnapshot(home, sid, { - command: process.execPath, - args: [ - "--eval", - `process.stdout.write(${JSON.stringify(`${unsafe}\n`)})`, - ], - }), - ).rejects.toThrow( - "Windows credential-home ancestor allows another identity to replace the directory", - ); - }); - - test("rejects incomplete combined Windows credential ACL inspections", async () => { - const root = await temporaryDirectory(); - const home = join(root, "home"); - await mkdir(home); - const sid = "S-1-5-21-111-222-333-1001"; - const directory = `O:${sid}G:SYD:P(A;OICI;FA;;;${sid})`; - - await expect( - inspectWindowsCredentialAclSnapshot(home, sid, { - command: process.execPath, - args: [ - "--eval", - `process.stdout.write(${JSON.stringify(`${directory}\n`)})`, - ], - }), - ).rejects.toThrow("Windows credential-home ancestry could not be verified"); - }); - - test("detects unsafe descendants during combined Windows credential ACL inspections", async () => { - const root = await temporaryDirectory(); - const home = join(root, "home"); - await mkdir(home); - await writeFile(join(home, "auth.json"), "credential\n"); - const sid = "S-1-5-21-111-222-333-1001"; - const directory = `O:${sid}G:SYD:P(A;OICI;FA;;;${sid})`; - const unsafeFile = `O:${sid}G:SYD:P(A;;FA;;;${sid})(A;;FR;;;WD)`; - const ancestors: string[] = []; - for (let ancestor = dirname(home); ; ancestor = dirname(ancestor)) { - ancestors.push(directory); - if (ancestor === dirname(ancestor)) break; - } - const descriptors = [...ancestors, directory, unsafeFile]; - - await expect( - inspectWindowsCredentialAclSnapshot(home, sid, { - command: process.execPath, - args: [ - "--eval", - `process.stdout.write(${JSON.stringify(`${descriptors.join("\n")}\n`)})`, - ], - }), - ).resolves.toMatchObject({ descendantsArePrivate: false }); - }); - - test("streams Windows credential ACL output larger than the subprocess buffer", async () => { - const descriptor = - "O:S-1-5-21-111-222-333-1001G:SYD:P(A;;FA;;;S-1-5-21-111-222-333-1001)"; - const expected = Math.ceil((1024 * 1024) / (descriptor.length + 1)) + 1; - let observed = 0; - - const count = await streamWindowsCredentialAclDescriptors( - process.execPath, - [ - "--eval", - `process.stdout.write(${JSON.stringify(`${descriptor}\n`)}.repeat(${expected}))`, - ], - async (received) => { - if (observed === 0 || observed === expected - 1) { - expect(received).toBe(descriptor); - } - observed += 1; - }, - ); - - expect(count).toBe(expected); - expect(observed).toBe(expected); - }); - - test("preserves Windows credential ACL subprocess failures while streaming", async () => { - await expect( - streamWindowsCredentialAclDescriptors( - process.execPath, - [ - "--eval", - 'process.stderr.write("synthetic ACL inspection failure"); process.exitCode = 1', - ], - async () => {}, - ), - ).rejects.toMatchObject({ stderr: "synthetic ACL inspection failure" }); - }); - - test("accepts managed Windows ACLs with trusted system principals", () => { - const user = "S-1-5-21-111-222-333-1001"; - const descriptor = - `O:${user}G:${user}D:AI` + - `(A;OICIID;FA;;;${user})` + - "(A;OICIID;FA;;;SY)" + - "(A;OICIID;FA;;;BA)"; - - expect(inspectWindowsCredentialAcl(descriptor, user)).toEqual({ - owner: user, - protected: false, - grantsCurrentUserAccess: true, - untrustedPrincipals: [], - deniedPrincipals: [], - }); - expect( - inspectWindowsCredentialAcl( - `O:BAG:SYD:P(A;OICI;FA;;;${user})(A;OICI;FA;;;SY)`, - user, - ), - ).toMatchObject({ - owner: "S-1-5-32-544", - protected: true, - untrustedPrincipals: [], - }); - }); - - test("identifies Windows ancestor grants that can replace credential homes", () => { - const user = "S-1-5-21-111-222-333-1001"; - for (const rights of [ - "FA", - "GA", - "FW", - "GW", - "GAGX", - "GXGA", - "GWGX", - "GXGW", - "FAGX", - "FWGX", - "SD", - "WD", - "WO", - "DC", - "0x40", - "0x10000", - "0x40000", - "0x80000", - "0x1301bf", - ]) { - expect( - inspectWindowsCredentialAcl( - `O:${user}G:SYD:(A;OICI;FA;;;${user})(A;;${rights};;;WD)`, - user, - { scope: "ancestor" }, - ).untrustedPrincipals, - ).toEqual(["S-1-1-0"]); - } - - for (const [flags, rights] of [ - ["", "FR"], - ["", "FRGX"], - ["", "GRGX"], - ["", "0x1200a9"], - ["IO", "FA"], - ] as const) { - expect( - inspectWindowsCredentialAcl( - `O:${user}G:SYD:(A;OICI;FA;;;${user})(A;${flags};${rights};;;WD)`, - user, - { scope: "ancestor" }, - ).untrustedPrincipals, - ).toEqual([]); - } - - const service = "S-1-5-80-111-222-333-444-555"; - for (const [principal, expected] of [ - ["LS", "S-1-5-19"], - ["NS", "S-1-5-20"], - [service, service], - ] as const) { - expect( - inspectWindowsCredentialAcl( - `O:${user}G:SYD:(A;OICI;FA;;;${user})(A;;DC;;;${principal})`, - user, - { scope: "ancestor" }, - ).untrustedPrincipals, - ).toEqual([expected]); - } - expect(() => - inspectWindowsCredentialAcl( - `O:${service}G:SYD:(A;OICI;FA;;;${user})`, - user, - { scope: "ancestor" }, - ), - ).toThrow("owner is not a trusted principal"); - - const installer = - "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"; - expect( - inspectWindowsCredentialAcl( - `O:${installer}G:SYD:(A;OICI;FA;;;${installer})(A;OICI;FA;;;${user})`, - user, - { scope: "ancestor" }, - ).untrustedPrincipals, - ).toEqual([]); - expect(() => - inspectWindowsCredentialAcl( - `O:${installer}G:SYD:(A;OICI;FA;;;${user})`, - user, - ), - ).toThrow("owner is not a trusted principal"); - }); - - test("accepts private credential-file ACLs without inheritance flags", () => { - const user = "S-1-5-21-111-222-333-1001"; - const descriptor = `O:${user}G:SYD:P(A;;FA;;;${user})(A;;FA;;;SY)`; - - expect(inspectWindowsCredentialAcl(descriptor, user)).toMatchObject({ - grantsCurrentUserAccess: false, - }); - expect( - inspectWindowsCredentialAcl(descriptor, user, { scope: "file" }), - ).toMatchObject({ - grantsCurrentUserAccess: true, - untrustedPrincipals: [], - }); - }); - - test("identifies broad, foreign, and inherited Windows ACL grants", () => { - const user = "S-1-5-21-111-222-333-1001"; - const stranger = "S-1-5-21-111-222-333-1002"; - for (const [principal, expected] of [ - ["WD", "S-1-1-0"], - ["BU", "S-1-5-32-545"], - ["AU", "S-1-5-11"], - ["CO", "S-1-3-0"], - ["CG", "S-1-3-1"], - ["OW", "S-1-3-4"], - ["AC", "S-1-15-2-1"], - ["AN", "S-1-5-7"], - ["IU", "S-1-5-4"], - ["SU", "S-1-5-6"], - ["RD", "S-1-5-32-555"], - ["DA", "S-1-5-21-111-222-333-512"], - ["DU", "S-1-5-21-111-222-333-513"], - [stranger, stranger], - ] as const) { - expect( - inspectWindowsCredentialAcl( - `O:${user}G:${user}D:AI(A;OICIID;FA;;;${user})(A;OICIID;FR;;;${principal})`, - user, - { - resolvedAliases: { - DA: "S-1-5-21-111-222-333-512", - DU: "S-1-5-21-111-222-333-513", - }, - }, - ).untrustedPrincipals, - ).toEqual([expected]); - } - expect( - inspectWindowsCredentialAcl( - `O:${user}G:${user}D:P(D;OICI;FR;;;WD)(A;OICI;FA;;;${user})`, - user, - ), - ).toMatchObject({ - grantsCurrentUserAccess: false, - untrustedPrincipals: [], - deniedPrincipals: ["S-1-1-0"], - }); - }); - - test("requires effective, inheritable Windows credential access", () => { - const user = "S-1-5-21-111-222-333-1001"; - for (const [flags, rights] of [ - ["OICI", "FR"], - ["OICI", "FW"], - ["", "FA"], - ["OI", "FA"], - ["CI", "FA"], - ["OICIIO", "FA"], - ["OICINP", "FA"], - ["OICINPID", "FA"], - ["CIIOID", "FA"], - ] as const) { - expect( - inspectWindowsCredentialAcl( - `O:${user}G:${user}D:P(A;${flags};${rights};;;${user})`, - user, - ).grantsCurrentUserAccess, - ).toBe(false); - } - - for (const rights of ["FA", "GA", "0x1f01ff", "0x10000000"]) { - expect( - inspectWindowsCredentialAcl( - `O:${user}G:${user}D:P(A;OICI;${rights};;;${user})`, - user, - ).grantsCurrentUserAccess, - ).toBe(true); - } - - expect( - inspectWindowsCredentialAcl( - `O:${user}G:${user}D:P(A;;FA;;;${user})(A;OICIIO;FA;;;${user})`, - user, - ).grantsCurrentUserAccess, - ).toBe(true); - expect( - inspectWindowsCredentialAcl( - `O:${user}G:${user}D:P(A;CIOI;FA;;;${user})`, - user, - ).grantsCurrentUserAccess, - ).toBe(true); - expect( - inspectWindowsCredentialAcl( - `O:${user}G:${user}D:P(A;;FA;;;${user})(A;OINP;FA;;;${user})(A;CI;FA;;;${user})`, - user, - ).grantsCurrentUserAccess, - ).toBe(false); - }); - - test("normalizes built-in Windows user and service SID aliases", () => { - for (const [alias, user] of [ - ["SY", "S-1-5-18"], - ["LS", "S-1-5-19"], - ["NS", "S-1-5-20"], - ["LA", "S-1-5-21-111-222-333-500"], - ["LG", "S-1-5-21-111-222-333-501"], - ] as const) { - expect( - inspectWindowsCredentialAcl( - `O:${alias}G:SYD:P(A;OICI;FA;;;${alias})(A;OICI;FA;;;BA)`, - user, - { - resolvedAliases: - alias === "LA" || alias === "LG" ? { [alias]: user } : {}, - }, - ), - ).toMatchObject({ - owner: user, - protected: true, - grantsCurrentUserAccess: true, - untrustedPrincipals: [], - deniedPrincipals: [], - }); - } - }); - - test("does not confuse domain accounts with local Administrator or Guest", () => { - const administrator = "S-1-5-21-111-222-333-500"; - const guest = "S-1-5-21-111-222-333-501"; - const localAdministrator = "S-1-5-21-444-555-666-500"; - const localGuest = "S-1-5-21-444-555-666-501"; - - expect( - inspectWindowsCredentialAcl( - `O:LAG:SYD:P(A;OICI;FA;;;LA)(A;OICI;FA;;;BA)`, - administrator, - { resolvedAliases: { LA: localAdministrator } }, - ), - ).toMatchObject({ - owner: localAdministrator, - grantsCurrentUserAccess: false, - }); - expect( - inspectWindowsCredentialAcl( - `O:${guest}G:SYD:P(A;OICI;FA;;;${guest})(A;OICI;FA;;;LG)`, - guest, - { resolvedAliases: { LG: localGuest } }, - ).untrustedPrincipals, - ).toEqual([localGuest]); - expect(() => - inspectWindowsCredentialAcl( - `O:LGG:SYD:P(A;OICI;FA;;;${guest})(A;OICI;FA;;;LG)`, - guest, - { resolvedAliases: { LG: localGuest } }, - ), - ).toThrow("owner is not a trusted principal"); - }); - - test("resolves domain and forest aliases against their actual SID domain", () => { - const currentUser = "S-1-5-21-111-222-333-1001"; - const joinedDomainAdmins = "S-1-5-21-444-555-666-512"; - const forestRootAdmins = "S-1-5-21-777-888-999-519"; - const domainRasServers = "S-1-5-21-444-555-666-553"; - - expect( - inspectWindowsCredentialAcl( - `O:${currentUser}G:SYD:P(A;OICI;FA;;;${currentUser})(A;OICI;FR;;;DA)(A;OICI;FR;;;EA)(A;OICI;FR;;;RS)`, - currentUser, - { - resolvedAliases: { - DA: joinedDomainAdmins, - EA: forestRootAdmins, - RS: domainRasServers, - }, - }, - ).untrustedPrincipals, - ).toEqual([joinedDomainAdmins, forestRootAdmins, domainRasServers]); - }); - - test("classifies conditional Windows access rules without trusting callbacks", () => { - const user = "S-1-5-21-111-222-333-1001"; - const condition = '(@User.department == "(Managed;QA)")'; - - expect( - inspectWindowsCredentialAcl( - `O:${user}G:SYD:P(A;OICI;FA;;;${user})(XA;OICI;FR;;;WD;${condition})`, - user, - ), - ).toMatchObject({ - grantsCurrentUserAccess: true, - untrustedPrincipals: ["S-1-1-0"], - }); - expect( - inspectWindowsCredentialAcl( - `O:${user}G:SYD:P(XA;OICI;FA;;;${user};${condition})`, - user, - ).grantsCurrentUserAccess, - ).toBe(false); - expect( - inspectWindowsCredentialAcl( - `O:${user}G:SYD:P(A;OICI;FA;;;${user})(ZA;OICI;FR;;;WD;${condition})`, - user, - ), - ).toMatchObject({ - grantsCurrentUserAccess: true, - untrustedPrincipals: ["S-1-1-0"], - }); - expect( - inspectWindowsCredentialAcl( - `O:${user}G:SYD:P(ZA;OICI;FA;;;${user};${condition})`, - user, - ).grantsCurrentUserAccess, - ).toBe(false); - expect( - inspectWindowsCredentialAcl( - `O:${user}G:SYD:P(A;OICI;FA;;;${user})(XD;OICI;FR;;;WD;${condition})`, - user, - ), - ).toMatchObject({ - grantsCurrentUserAccess: false, - deniedPrincipals: ["S-1-1-0"], - }); - }); - - test("classifies object-specific Windows ACLs without treating them as unrestricted", () => { - const user = "S-1-5-21-111-222-333-1001"; - const guid = "bf967aba-0de6-11d0-a285-00aa003049e2"; - - expect( - inspectWindowsCredentialAcl( - `O:${user}G:SYD:P(A;OICI;FA;;;${user})(OA;OICI;FR;${guid};;WD)`, - user, - ), - ).toMatchObject({ - grantsCurrentUserAccess: true, - untrustedPrincipals: ["S-1-1-0"], - }); - expect( - inspectWindowsCredentialAcl( - `O:${user}G:SYD:P(OA;OICI;FA;${guid};;${user})`, - user, - ).grantsCurrentUserAccess, - ).toBe(false); - expect( - inspectWindowsCredentialAcl( - `O:${user}G:SYD:P(A;OICI;FA;;;${user})(OD;OICI;FR;;${guid};WD)`, - user, - ), - ).toMatchObject({ - grantsCurrentUserAccess: false, - deniedPrincipals: ["S-1-1-0"], - }); - }); - - test("rejects incomplete, unowned, and unsupported Windows ACLs", () => { - const user = "S-1-5-21-111-222-333-1001"; - const stranger = "S-1-5-21-111-222-333-1002"; - for (const descriptor of [ - `G:${user}D:P(A;OICI;FA;;;${user})`, - `O:${user}G:${user}`, - `O:${user}G:${user}D:NO_ACCESS_CONTROL`, - `O:${user}G:${user}D:P`, - `O:${stranger}G:${user}D:P(A;OICI;FA;;;${user})`, - `O:${user}G:${user}D:P(XA;OICI;FA;;;${user})`, - `O:${user}G:${user}D:P(A;OIN;FA;;;${user})`, - `O:${user}G:${user}D:P(A;ZZ;FA;;;${user})`, - `O:${user}G:${user}D:P(OA;OICI;FA;not-a-guid;;${user})`, - `O:${user}G:${user}D:P(A;OICI;FA;bf967aba-0de6-11d0-a285-00aa003049e2;;${user})`, - `O:${user}G:${user}D:P(A;OICI;FA;;;${user};(@User.Department == \"QA\"))`, - ]) { - expect(() => inspectWindowsCredentialAcl(descriptor, user)).toThrow(); - } - expect(() => - inspectWindowsCredentialAcl( - `O:${user}G:${user}D:P(A;OICI;FA;;;${user})`, - "not-a-sid", - ), - ).toThrow("current Windows user SID"); - expect( - inspectWindowsCredentialAcl( - `O:${user}G:${user}D:P(A;OICIIO;FA;;;${user})`, - user, - ).grantsCurrentUserAccess, - ).toBe(false); - }); - - test("preserves Windows ACL subprocess failures", async () => { - const root = await temporaryDirectory(); - const home = join(root, "home"); - await mkdir(home); - const metadata = await lstat(home); - const underlying = Object.assign(new Error("PowerShell failed"), { - stderr: - "Method invocation is supported only on core types in this language mode. " + - "token=sk-proj-SYNTHETIC_WINDOWS_ACL_SECRET_123", - }); - - try { - await requirePrivateCredentialHome(metadata, home, { - platform: "win32", - secureWindowsHome: async () => { - throw underlying; - }, - }); - throw new Error("expected the Windows ACL operation to fail"); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain("core types"); - expect((error as Error).message).toContain( - "token=sk-proj-SYNTHETIC_WINDOWS_ACL_SECRET_123", - ); - expect((error as Error).cause).toBe(underlying); - } - }); - - test("rejects replacement credential homes when numeric identities collide", async () => { - const root = await temporaryDirectory(); - const home = join(root, "home"); - await mkdir(home); - const canonicalHome = await realpath(home); - const originalLstat = fsPromises.lstat; - const firstExactIdentity = BigInt(Number.MAX_SAFE_INTEGER) + 1n; - let homeInspections = 0; - const inspectHome = spyOn(fsPromises, "lstat").mockImplementation( - async (path, options) => { - const stats = await originalLstat(path, options as never); - if (String(path) !== home && String(path) !== canonicalHome) { - return stats as never; - } - const exactIdentity = - firstExactIdentity + (homeInspections++ === 0 ? 0n : 1n); - return Object.assign( - Object.create(Object.getPrototypeOf(stats)), - stats, - { - ino: - typeof stats.ino === "bigint" - ? exactIdentity - : Number(exactIdentity), - }, - ) as never; - }, - ); - - try { - await expect( - requireSecureCredentialHome(home, { - platform: "win32", - secureWindowsHome: async () => {}, - }), - ).rejects.toThrow("credential home was replaced"); - } finally { - inspectHome.mockRestore(); - } - }); - - test("revalidates the Windows credential ACL every time the home is used", async () => { - const root = await temporaryDirectory(); - const home = join(root, "home"); - await mkdir(home); - const validations: string[] = []; - - await requireSecureCredentialHome(home, { - platform: "win32", - secureWindowsHome: async (path) => { - validations.push(path); - }, - }); - - expect(validations).toEqual([home]); - await expect( - requireSecureCredentialHome(home, { - platform: "win32", - secureWindowsHome: async () => { - throw new Error("ACL changed after preparation"); - }, - }), - ).rejects.toThrow("private Windows credential home"); - }); - - test.skipIf(process.platform !== "win32")( - "creates credential homes with a verified managed-compatible Windows ACL", - async () => { - const root = await temporaryDirectory(); - const home = await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: join(root, "state"), - }); - const powershell = join( - process.env["SystemRoot"] ?? "C:\\Windows", - "System32", - "WindowsPowerShell", - "v1.0", - "powershell.exe", - ); - const command = [ - "$ErrorActionPreference = 'Stop'", - "$path = [Environment]::GetEnvironmentVariable('CODEX_SECURITY_TEST_ACL_PATH', 'Process')", - "$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value", - "$acl = [System.IO.Directory]::GetAccessControl($path)", - "$trusted = @($identity, 'S-1-5-18', 'S-1-5-32-544')", - "$unexpected = @($acl.Access | Where-Object { $_.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Allow -and $trusted -notcontains $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value })", - "[pscustomobject]@{ unexpected = $unexpected.Count } | ConvertTo-Json -Compress", - ].join("; "); - const result = await promisify(execFile)( - powershell, - ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], - { - encoding: "utf8", - env: { ...process.env, CODEX_SECURITY_TEST_ACL_PATH: home }, - timeout: 20_000, - windowsHide: true, - }, - ); - - expect(JSON.parse(result.stdout)).toEqual({ unexpected: 0 }); - }, - ); - - test.skipIf(process.platform !== "win32")( - "preserves SYSTEM and Administrators when protecting inherited access", - async () => { - const root = await temporaryDirectory(); - const state = join(root, "state"); - await mkdir(state); - const systemDirectory = join( - process.env["SystemRoot"] ?? "C:\\Windows", - "System32", - ); - const user = spawnSync( - join(systemDirectory, "whoami.exe"), - ["/user", "/fo", "csv", "/nh"], - { encoding: "utf8", windowsHide: true }, - ); - expect(user.status).toBe(0); - const sid = /"(S-1-(?:\d+-)*\d+)"\s*$/u.exec(user.stdout)?.[1]; - expect(sid).toBeDefined(); - const configured = spawnSync( - join(systemDirectory, "icacls.exe"), - [ - state, - "/inheritance:r", - "/grant:r", - `*${sid}:(OI)(CI)F`, - "*S-1-5-18:(OI)(CI)F", - "*S-1-5-32-544:(OI)(CI)F", - ], - { encoding: "utf8", windowsHide: true }, - ); - expect(configured.status).toBe(0); - - const home = await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: state, - }); - const descriptor = spawnSync( - join(systemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"), - [ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-Command", - [ - "$acl = [System.IO.Directory]::GetAccessControl($env:CODEX_SECURITY_TEST_ACL_PATH)", - "$allowed = @($acl.Access | Where-Object { $_.AccessControlType -eq 'Allow' })", - "$denied = @($acl.Access | Where-Object { $_.AccessControlType -eq 'Deny' })", - "$owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value", - "$principals = @($allowed | ForEach-Object { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value })", - "$deniedPrincipals = @($denied | ForEach-Object { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value })", - "$fullControl = @($allowed | Where-Object { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq $env:CODEX_SECURITY_TEST_USER_SID -and ($_.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl -and ($_.InheritanceFlags -band [System.Security.AccessControl.InheritanceFlags]::ContainerInherit) -ne 0 -and ($_.InheritanceFlags -band [System.Security.AccessControl.InheritanceFlags]::ObjectInherit) -ne 0 -and $_.PropagationFlags -eq [System.Security.AccessControl.PropagationFlags]::None })", - "[pscustomobject]@{ owner = $owner; protected = $acl.AreAccessRulesProtected; principals = $principals; deniedPrincipals = $deniedPrincipals; grantsCurrentUserAccess = ($fullControl.Count -gt 0 -and $denied.Count -eq 0) } | ConvertTo-Json -Compress", - ].join("; "), - ], - { - encoding: "utf8", - env: { - ...process.env, - CODEX_SECURITY_TEST_ACL_PATH: home, - CODEX_SECURITY_TEST_USER_SID: sid!, - }, - windowsHide: true, - }, - ); - expect(descriptor.status).toBe(0); - const access = JSON.parse(descriptor.stdout) as { - owner: string; - protected: boolean; - principals: string[]; - deniedPrincipals: string[]; - grantsCurrentUserAccess: boolean; - }; - expect(access).toMatchObject({ - protected: true, - deniedPrincipals: [], - grantsCurrentUserAccess: true, - }); - expect(access.principals).toEqual( - expect.arrayContaining([sid!, "S-1-5-18", "S-1-5-32-544"]), - ); - expect([sid!, "S-1-5-18", "S-1-5-32-544"]).toContain(access.owner); - expect(new Set(access.principals)).toEqual( - new Set([sid!, "S-1-5-18", "S-1-5-32-544"]), - ); - }, - ); - - test.skipIf(process.platform !== "win32")( - "removes unsafe inherited Windows credential-home permissions", - async () => { - const root = await temporaryDirectory(); - const state = join(root, "state"); - await mkdir(state); - const systemDirectory = join( - process.env["SystemRoot"] ?? "C:\\Windows", - "System32", - ); - const shared = spawnSync( - join(systemDirectory, "icacls.exe"), - [state, "/grant", "*S-1-1-0:(OI)(CI)R"], - { encoding: "utf8", windowsHide: true }, - ); - expect(shared.status).toBe(0); - - const home = await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: state, - }); - const result = spawnSync( - join(systemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"), - [ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-Command", - [ - "$acl = [System.IO.Directory]::GetAccessControl($env:CODEX_SECURITY_TEST_ACL_PATH)", - "$everyone = @($acl.Access | Where-Object { $_.AccessControlType -eq 'Allow' -and $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq 'S-1-1-0' })", - "[pscustomobject]@{ protected = $acl.AreAccessRulesProtected; everyone = $everyone.Count } | ConvertTo-Json -Compress", - ].join("; "), - ], - { - encoding: "utf8", - env: { ...process.env, CODEX_SECURITY_TEST_ACL_PATH: home }, - windowsHide: true, - }, - ); - expect(result.status).toBe(0); - expect(JSON.parse(result.stdout)).toEqual({ - protected: true, - everyone: 0, - }); - }, - ); - - test.skipIf(process.platform !== "win32")( - "removes explicit foreign Windows credential-home grants", - async () => { - const root = await temporaryDirectory(); - const state = join(root, "state"); - const home = join(state, "codex-home"); - await mkdir(home, { recursive: true }); - const systemDirectory = join( - process.env["SystemRoot"] ?? "C:\\Windows", - "System32", - ); - const configured = spawnSync( - join(systemDirectory, "icacls.exe"), - [home, "/grant", "*S-1-1-0:(OI)(CI)R"], - { encoding: "utf8", windowsHide: true }, - ); - expect(configured.status).toBe(0); - - expect( - await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: state, - }), - ).toBe(await realpath(home)); - const result = spawnSync( - join(systemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"), - [ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-Command", - [ - "$acl = [System.IO.Directory]::GetAccessControl($env:CODEX_SECURITY_TEST_ACL_PATH)", - "$everyone = @($acl.Access | Where-Object { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq 'S-1-1-0' })", - "[pscustomobject]@{ protected = $acl.AreAccessRulesProtected; everyone = $everyone.Count } | ConvertTo-Json -Compress", - ].join("; "), - ], - { - encoding: "utf8", - env: { ...process.env, CODEX_SECURITY_TEST_ACL_PATH: home }, - windowsHide: true, - }, - ); - expect(result.status).toBe(0); - expect(JSON.parse(result.stdout)).toEqual({ - protected: true, - everyone: 0, - }); - }, - ); - - test.skipIf(process.platform !== "win32")( - "rejects attacker-writable Windows credential-home ancestry without changing it", - async () => { - const root = await temporaryDirectory(); - const state = join(root, "state"); - await mkdir(state); - const systemDirectory = join( - process.env["SystemRoot"] ?? "C:\\Windows", - "System32", - ); - const identity = spawnSync( - join(systemDirectory, "whoami.exe"), - ["/user", "/fo", "csv", "/nh"], - { encoding: "utf8", windowsHide: true }, - ); - expect(identity.status).toBe(0); - const sid = /"(S-1-(?:\d+-)*\d+)"\s*$/u.exec(identity.stdout)?.[1]; - expect(sid).toBeDefined(); - for (const ancestor of [root, state]) { - const owned = spawnSync( - join(systemDirectory, "icacls.exe"), - [ancestor, "/setowner", `*${sid}`], - { encoding: "utf8", windowsHide: true }, - ); - expect(owned.status).toBe(0); - const writable = spawnSync( - join(systemDirectory, "icacls.exe"), - [ancestor, "/grant", "*S-1-1-0:(OI)(CI)M"], - { encoding: "utf8", windowsHide: true }, - ); - expect(writable.status).toBe(0); - } - - await expect( - prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: state, - }), - ).rejects.toThrow( - "Windows credential-home ancestor allows another identity to replace the directory", - ); - - for (const ancestor of [root, state]) { - const inspection = spawnSync( - join(systemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"), - [ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-Command", - [ - "$acl = [System.IO.Directory]::GetAccessControl($env:CODEX_SECURITY_TEST_ACL_PATH)", - "$everyone = @($acl.Access | Where-Object { $_.AccessControlType -eq 'Allow' -and $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq 'S-1-1-0' })", - "[pscustomobject]@{ protected = $acl.AreAccessRulesProtected; everyone = $everyone.Count } | ConvertTo-Json -Compress", - ].join("; "), - ], - { - encoding: "utf8", - env: { ...process.env, CODEX_SECURITY_TEST_ACL_PATH: ancestor }, - windowsHide: true, - }, - ); - expect(inspection.status).toBe(0); - expect(JSON.parse(inspection.stdout).everyone).toBeGreaterThan(0); - } - }, - ); - - test.skipIf(process.platform !== "win32")( - "repairs unsafe ACLs on existing nested Windows credential files", - async () => { - const root = await temporaryDirectory(); - const state = join(root, "state"); - const home = join(state, "codex-home"); - const nested = join(home, "sessions"); - await mkdir(nested, { recursive: true }); - const auth = join(home, "auth.json"); - const nestedAuth = join(nested, "credentials.json"); - await writeFile(auth, '{"token":"synthetic-root"}\n'); - await writeFile(nestedAuth, '{"token":"synthetic-nested"}\n'); - - const systemDirectory = join( - process.env["SystemRoot"] ?? "C:\\Windows", - "System32", - ); - const identity = spawnSync( - join(systemDirectory, "whoami.exe"), - ["/user", "/fo", "csv", "/nh"], - { encoding: "utf8", windowsHide: true }, - ); - expect(identity.status).toBe(0); - const sid = /"(S-1-(?:\d+-)*\d+)"\s*$/u.exec(identity.stdout)?.[1]; - expect(sid).toBeDefined(); - - for (const credential of [auth, nestedAuth]) { - const unsafe = spawnSync( - join(systemDirectory, "icacls.exe"), - [credential, "/inheritance:r", "/grant:r", `*${sid}:F`, "*S-1-1-0:R"], - { encoding: "utf8", windowsHide: true }, - ); - expect(unsafe.status).toBe(0); - } - - expect( - await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: state, - }), - ).toBe(await realpath(home)); - - const inspection = spawnSync( - join(systemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"), - [ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-Command", - [ - "$paths = @($env:CODEX_SECURITY_TEST_AUTH_PATH, $env:CODEX_SECURITY_TEST_NESTED_AUTH_PATH)", - "$unexpected = @($paths | ForEach-Object { $acl = Get-Acl -LiteralPath $_; $acl.Access | Where-Object { $_.AccessControlType -eq 'Allow' -and $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq 'S-1-1-0' } })", - "[pscustomobject]@{ unexpected = $unexpected.Count } | ConvertTo-Json -Compress", - ].join("; "), - ], - { - encoding: "utf8", - env: { - ...process.env, - CODEX_SECURITY_TEST_AUTH_PATH: auth, - CODEX_SECURITY_TEST_NESTED_AUTH_PATH: nestedAuth, - }, - windowsHide: true, - }, - ); - expect(inspection.status).toBe(0); - expect(JSON.parse(inspection.stdout)).toEqual({ unexpected: 0 }); - expect(await readFile(auth, "utf8")).toContain("synthetic-root"); - expect(await readFile(nestedAuth, "utf8")).toContain("synthetic-nested"); - }, - ); - test("derives persistent state from the ambient home or explicit override", async () => { const root = await temporaryDirectory(); expect(codexSecurityStateDirectory({ CODEX_HOME: root })).toBe( From 9e4ef02db1e341d549b85149ea67556d7ffec9cf Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 05:14:14 +0000 Subject: [PATCH 08/21] test: keep API login fixtures active until cancellation --- sdk/typescript/tests-ts/api.test.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 43cb92112..ea2424e13 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -6490,7 +6490,13 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s await mkdir(codexHome, { mode: 0o700 }); await writeFile( fakeCodex, - 'console.error("Open https://auth.example.test/device");\nconsole.error("User code: ABCD-EFGH");\nprocess.on("SIGTERM", () => {});\nsetInterval(() => {}, 1000);\n', + [ + 'process.on("SIGTERM", () => console.error("ignored SIGTERM"));', + 'console.error("Open https://auth.example.test/device");', + 'console.error("User code: ABCD-EFGH");', + "setInterval(() => {}, 1000);", + "await new Promise(() => {});", + ].join("\n"), ); const fakeCommand = nodeCodex(fakeCodex); const client = new TestClient( @@ -6523,8 +6529,8 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s expect(login.verificationUrl).toBe("https://auth.example.test/device"); expect(login.userCode).toBe("ABCD-EFGH"); const timeout = AbortSignal.timeout(5_000); - await expect( - Promise.race([ + expect( + await Promise.race([ client.close(), new Promise((_, reject) => { timeout.addEventListener( @@ -6534,8 +6540,12 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s ); }), ]), - ).resolves.toBeUndefined(); - await expect(login.wait()).resolves.toMatchObject({ success: false }); + ).toBeUndefined(); + const result = await login.wait(); + expect(result).toMatchObject({ success: false }); + if (process.platform !== "win32") { + expect(result.stderr).toContain("ignored SIGTERM"); + } }); test("keeps ambient credentials available to scans", async () => { @@ -6620,6 +6630,7 @@ process.on("SIGTERM", () => { writeFileSync(${JSON.stringify(ready)}, "ready"); for await (const _chunk of process.stdin) {} setInterval(() => {}, 1000); +await new Promise(() => {}); `, ); const fakeCommand = nodeCodex(fakeCodex); @@ -6660,6 +6671,11 @@ setInterval(() => {}, 1000); await client.close(); await expect(login).rejects.toThrow(); await expect(stat(codexHome)).resolves.toBeDefined(); + if (process.platform !== "win32") { + expect(await readFile(join(codexHome, "auth.json"), "utf8")).toBe( + "late write", + ); + } } finally { await client.close(); } From bf372ef0b8c40aa3b8fe834af711bc67702b3350 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 05:37:16 +0000 Subject: [PATCH 09/21] ci: use runner temp for package smoke and rebalance Unix shards --- .github/workflows/node-ci.yml | 2 ++ sdk/typescript/TESTING.md | 9 ++++--- sdk/typescript/scripts/test-shards.mjs | 34 +++++++++++++------------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 26e5d1551..5ab943e64 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -356,6 +356,8 @@ jobs: working-directory: sdk/typescript shell: bash env: + TEMP: ${{ runner.temp }} + TMP: ${{ runner.temp }} npm_config_timing: "true" npm_config_loglevel: http UV_THREADPOOL_SIZE: "16" diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index 11f4c1791..9c46f6940 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -69,7 +69,8 @@ a strict NodeNext TypeScript consumer and the actual installed CLI. Failed tests block CI; a failed diagnostic upload does not. `scripts/run-ci-tests.mjs` discovers the test files and balances them using -rounded timings in `scripts/test-shards.mjs`. Unix jobs run four processes; +rounded timings in `scripts/test-shards.mjs`. Unix estimates average measured +Linux and macOS file timings. Unix jobs run four processes; Windows runs seven separate jobs with `node scripts/run-ci-tests.mjs 1/7` (substitute the shard number), each using up to two Bun processes with separate reports such as `junit-1-1.xml` and `junit-1-2.xml`. Each file runs once. New files @@ -84,8 +85,10 @@ failures do not suppress installation failures. Unix keeps its pinned pnpm setup action. Windows package inspection enables npm's native phase timings to diagnose installation delays without changing its failure or timeout behavior. It also logs npm cache/fetch activity and uses a 16-thread libuv pool for -filesystem-heavy package extraction. These settings apply only to Windows -package inspection; the fresh consumer install and its assertions are unchanged. +filesystem-heavy package extraction. Its `TEMP` and `TMP` point to the runner's +job temporary directory, keeping the fresh npm consumer outside the checkout. +These settings apply only to Windows package inspection; the install arguments, +assertions, and timeouts are unchanged. When forwarding `--test-name-pattern` to a sharded run, also pass Bun's `--pass-with-no-tests` if some workers may have no matching tests. Normal CI diff --git a/sdk/typescript/scripts/test-shards.mjs b/sdk/typescript/scripts/test-shards.mjs index 09500110a..ea5238b76 100644 --- a/sdk/typescript/scripts/test-shards.mjs +++ b/sdk/typescript/scripts/test-shards.mjs @@ -1,25 +1,25 @@ // Rounded file timings [Unix, Windows] in seconds from CI and local runs. // These affect only scheduling: new files automatically get a small default. const timings = { - "api-credentials.test.ts": [1, 48], - "api.test.ts": [9, 77], + "api-credentials.test.ts": [2, 48], + "api.test.ts": [19, 77], "api-attribution-concurrency.test.ts": [1, 14], - "cli-authentication.test.ts": [5, 50], - "cli-launcher.test.ts": [8, 10], - "compact-diff-scan.test.ts": [13, 33], - "contract.test.ts": [8, 10], - "custom-validation.test.ts": [8, 19], - "deep-scan-workbench.test.ts": [14, 41], - "multiscan.test.ts": [3, 30], - "patch-tui.test.ts": [5, 6], - "publication-integration.test.ts": [9, 19], - "publication-store.test.ts": [17, 30], - "release-automation.test.ts": [7, 50], - "runtime-credentials.test.ts": [12, 87], - "runtime.test.ts": [18, 60], + "cli-authentication.test.ts": [11, 50], + "cli-launcher.test.ts": [15, 10], + "compact-diff-scan.test.ts": [24, 33], + "contract.test.ts": [15, 10], + "custom-validation.test.ts": [19, 19], + "deep-scan-workbench.test.ts": [27, 41], + "multiscan.test.ts": [10, 30], + "patch-tui.test.ts": [4, 6], + "publication-integration.test.ts": [17, 19], + "publication-store.test.ts": [31, 30], + "release-automation.test.ts": [18, 50], + "runtime-credentials.test.ts": [11, 87], + "runtime.test.ts": [16, 60], "scan-comparison.test.ts": [1, 11], - "scan-recovery.test.ts": [24, 83], - "stopped-scan-results.test.ts": [7, 17], + "scan-recovery.test.ts": [55, 83], + "stopped-scan-results.test.ts": [15, 17], }; export function partitionTestFiles(files, count, platform = process.platform) { From 6ec7fde7fc5806e4e870c16cc47f6f0786e17a30 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 05:57:00 +0000 Subject: [PATCH 10/21] test: wait for parallel scan startup before checking lock release --- sdk/typescript/tests-ts/api-credentials.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/sdk/typescript/tests-ts/api-credentials.test.ts b/sdk/typescript/tests-ts/api-credentials.test.ts index e8935a636..9cab2b133 100644 --- a/sdk/typescript/tests-ts/api-credentials.test.ts +++ b/sdk/typescript/tests-ts/api-credentials.test.ts @@ -313,11 +313,6 @@ describe("CodexSecurity orchestration", () => { startThread: () => ({ id: null, async runStreamed() { - expect( - existsSync( - join(credentialHome, ".codex-security-scan.lock"), - ), - ).toBe(false); if (++scansStarted === 2) releaseScans(); const credentialConfig = parseToml( await readFile( @@ -332,7 +327,13 @@ describe("CodexSecurity orchestration", () => { expect(before["deep_scan"]).toMatchObject({ workers: index + 2, }); + // The peer may hold the startup lock until it reaches execution. await concurrentScans; + expect( + existsSync( + join(credentialHome, ".codex-security-scan.lock"), + ), + ).toBe(false); const after = parseToml( await readFile(deepScanConfigPath!, "utf8"), ); From c1567a2e20066e96d664178fc588abf7df07d156 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 06:06:17 +0000 Subject: [PATCH 11/21] test: include parallel credential failure stacks --- sdk/typescript/tests-ts/api-credentials.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/tests-ts/api-credentials.test.ts b/sdk/typescript/tests-ts/api-credentials.test.ts index 9cab2b133..9695e6eba 100644 --- a/sdk/typescript/tests-ts/api-credentials.test.ts +++ b/sdk/typescript/tests-ts/api-credentials.test.ts @@ -359,7 +359,12 @@ describe("CodexSecurity orchestration", () => { ), ); for (const result of results) { - expect(result).toMatchObject({ + expect( + result, + result.status === "rejected" && result.reason instanceof Error + ? result.reason.stack + : undefined, + ).toMatchObject({ status: "rejected", reason: expect.objectContaining({ message: "parallel managed scan reached", From a2ef096d75c87df5be48579cd6de45746298fbe5 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 07:51:56 +0000 Subject: [PATCH 12/21] test: await Git output in affected CI fixtures --- sdk/typescript/tests-ts/cli-patch.test.ts | 47 +++++++++++++---------- sdk/typescript/tests-ts/multiscan.test.ts | 26 +++++++------ 2 files changed, 41 insertions(+), 32 deletions(-) diff --git a/sdk/typescript/tests-ts/cli-patch.test.ts b/sdk/typescript/tests-ts/cli-patch.test.ts index 36f759f8f..520fcb4d5 100644 --- a/sdk/typescript/tests-ts/cli-patch.test.ts +++ b/sdk/typescript/tests-ts/cli-patch.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { promisify } from "node:util"; import type { Finding, JsonObject, SeverityLevel } from "../src/index.js"; import { main } from "../src/cli.js"; import { capture, dependencies, fakeResult } from "./cli-fixtures.js"; @@ -382,25 +383,27 @@ describe("scan and patch workflow", () => { let failOnce = true; let publishedUrl = ""; await mkdir(join(repository, "src"), { recursive: true }); - const git = (...args: string[]) => - execFileSync("git", args, { + const executeFile = promisify(execFile); + const git = async (...args: string[]) => { + const { stdout } = await executeFile("git", args, { cwd: repository, encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }).trim(); + }); + return stdout.trim(); + }; try { - git("init", "--initial-branch=main"); - git("config", "user.name", "Synthetic User"); - git("config", "user.email", "synthetic@example.test"); - git("config", "commit.gpgsign", "false"); + await git("init", "--initial-branch=main"); + await git("config", "user.name", "Synthetic User"); + await git("config", "user.email", "synthetic@example.test"); + await git("config", "commit.gpgsign", "false"); await writeFile(join(repository, "src", "finding-1.ts"), "unsafe\n"); await writeFile(join(repository, "unrelated.ts"), "original\n"); - git("add", "."); - git("commit", "-m", "Initial synthetic checkout"); - git("init", "--bare", remote); - git("remote", "add", "origin", remote); - git("push", "--set-upstream", "origin", "main"); + await git("add", "."); + await git("commit", "-m", "Initial synthetic checkout"); + await git("init", "--bare", remote); + await git("remote", "add", "origin", remote); + await git("push", "--set-upstream", "origin", "main"); const fixtures: Parameters[0] = { currentDirectory: repository, @@ -446,12 +449,16 @@ describe("scan and patch workflow", () => { ); expect(first.exitCode).toBe(2); expect(first.stderr).toContain(`patch --resume-pr ${branch}`); - const commit = git("rev-parse", "HEAD"); + const commit = await git("rev-parse", "HEAD"); expect( - git("config", "--get", `branch.${branch}.codexSecurityPatchCommit`), + await git( + "config", + "--get", + `branch.${branch}.codexSecurityPatchCommit`, + ), ).toBe(commit); if (failure === "create") { - expect(git("rev-parse", `origin/${branch}`)).toBe(commit); + expect(await git("rev-parse", `origin/${branch}`)).toBe(commit); } await writeFile(join(repository, "unrelated.ts"), "later local work\n"); @@ -465,9 +472,9 @@ describe("scan and patch workflow", () => { }); expect(modelCalls).toBe(1); expect(created).toBe(1); - expect(git("rev-parse", "HEAD")).toBe(commit); - expect(git("rev-parse", `origin/${branch}`)).toBe(commit); - expect(git("diff", "--name-only")).toBe("unrelated.ts"); + expect(await git("rev-parse", "HEAD")).toBe(commit); + expect(await git("rev-parse", `origin/${branch}`)).toBe(commit); + expect(await git("diff", "--name-only")).toBe("unrelated.ts"); const pushes = pushCalls; const repeated = await runWorkflow( diff --git a/sdk/typescript/tests-ts/multiscan.test.ts b/sdk/typescript/tests-ts/multiscan.test.ts index 8fd8ac9ba..daf05536e 100644 --- a/sdk/typescript/tests-ts/multiscan.test.ts +++ b/sdk/typescript/tests-ts/multiscan.test.ts @@ -1,4 +1,4 @@ -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import { access, appendFile, @@ -19,6 +19,7 @@ import * as filesystem from "node:fs/promises"; import { hostname, tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { main } from "../src/cli.js"; import { ScanCostLimitExceededError } from "../src/errors.js"; @@ -33,6 +34,7 @@ type SecurityClient = ReturnType; const temporaryDirectories: string[] = []; const testPosix = process.platform === "win32" ? test.skip : test; +const executeFile = promisify(execFile); afterEach(async () => { await Promise.all( @@ -58,11 +60,11 @@ async function fixture(): Promise<{ }; } -function git(repository: string, ...args: string[]): string { - return execFileSync("git", ["-C", repository, ...args], { +async function git(repository: string, ...args: string[]): Promise { + const { stdout } = await executeFile("git", ["-C", repository, ...args], { encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }).trim(); + }); + return stdout.trim(); } async function repository( @@ -75,9 +77,9 @@ async function repository( join(path, "src", "app.ts"), `export const name = "${name}";\n`, ); - git(path, "init", "-q"); - git(path, "add", "."); - git( + await git(path, "init", "-q"); + await git(path, "add", "."); + await git( path, "-c", "user.name=Multiscan Test", @@ -87,7 +89,7 @@ async function repository( "-qm", "initial", ); - return { path, revision: git(path, "rev-parse", "HEAD") }; + return { path, revision: await git(path, "rev-parse", "HEAD") }; } async function completedScan( @@ -867,8 +869,8 @@ describe("multiscan", () => { join(source.path, "src", "app.ts"), "export const changed = true;\n", ); - git(source.path, "add", "."); - git( + await git(source.path, "add", "."); + await git( source.path, "-c", "user.name=Multiscan Test", @@ -891,7 +893,7 @@ describe("multiscan", () => { client( async (path, scanOptions = {}) => { checkout = path; - expect(git(path, "rev-parse", "HEAD")).toBe(source.revision); + expect(await git(path, "rev-parse", "HEAD")).toBe(source.revision); expect( await readFile(join(path, "src", "app.ts"), "utf8"), ).toContain('name = "payments"'); From 399041a60ee5af66ede4019ab227892d81bc171b Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 08:48:30 +0000 Subject: [PATCH 13/21] test: allow valid stdout export chunk coalescing --- sdk/typescript/tests-ts/cli-export.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/sdk/typescript/tests-ts/cli-export.test.ts b/sdk/typescript/tests-ts/cli-export.test.ts index e8e58c8a4..ab0a7d975 100644 --- a/sdk/typescript/tests-ts/cli-export.test.ts +++ b/sdk/typescript/tests-ts/cli-export.test.ts @@ -151,7 +151,6 @@ describe("CLI", () => { { mode: 0o700 }, ); let bytes = 0; - let writes = 0; let drains = 0; let emptyWrites = 0; const stdout = new Writable({ @@ -159,7 +158,6 @@ describe("CLI", () => { write(chunk, _encoding, callback) { if (chunk.length === 0) emptyWrites += 1; bytes += chunk.length; - writes += 1; setTimeout(callback, 1); }, }); @@ -186,7 +184,6 @@ describe("CLI", () => { ), ).toBe(0); expect(bytes).toBe(expectedBytes); - expect(writes).toBeGreaterThan(1); expect(drains).toBeGreaterThan(0); expect(emptyWrites).toBe(0); expect(stderr.text()).toBe(""); From ad7c47cce307db694fa5325a29a945523c0f7cb9 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 09:29:29 +0000 Subject: [PATCH 14/21] test: rebalance credential checks after lock updates --- sdk/typescript/scripts/test-shards.mjs | 2 +- sdk/typescript/tests-ts/runtime.test.ts | 46 ++++++++++++++++++------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/sdk/typescript/scripts/test-shards.mjs b/sdk/typescript/scripts/test-shards.mjs index ea5238b76..640d12500 100644 --- a/sdk/typescript/scripts/test-shards.mjs +++ b/sdk/typescript/scripts/test-shards.mjs @@ -15,7 +15,7 @@ const timings = { "publication-integration.test.ts": [17, 19], "publication-store.test.ts": [31, 30], "release-automation.test.ts": [18, 50], - "runtime-credentials.test.ts": [11, 87], + "runtime-credentials.test.ts": [18, 87], "runtime.test.ts": [16, 60], "scan-comparison.test.ts": [1, 11], "scan-recovery.test.ts": [55, 83], diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index de9cdcb18..51f4d65c5 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -1486,19 +1486,41 @@ describe("plugin runtime preparation", () => { await mkdir(ambient); await writeFile(join(ambient, "auth.json"), '{"token":"ambient"}\n'); - const imports = await Promise.all( - Array.from({ length: 8 }, async () => - importAmbientAuth(ambient, isolated), - ), - ); - - expect(imports).toEqual(Array.from({ length: 8 }, () => true)); - expect(await readFile(join(isolated, "auth.json"), "utf8")).toBe( - '{"token":"ambient"}\n', + const temporaryPaths: string[] = []; + let releaseCopies!: () => void; + const copiesReady = new Promise((resolve) => { + releaseCopies = resolve; + }); + const originalCopyFile = fsPromises.copyFile; + const copyCredentials = spyOn(fsPromises, "copyFile").mockImplementation( + async (source, destination, flags) => { + if (String(source) === join(ambient, "auth.json")) { + // Both imports must pass credential checks before either publishes auth. + temporaryPaths.push(String(destination)); + if (temporaryPaths.length === 2) releaseCopies(); + await copiesReady; + } + await originalCopyFile(source, destination, flags); + }, ); - expect( - (await readdir(isolated)).filter((path) => path.startsWith(".auth-")), - ).toEqual([]); + const imports = [ + importAmbientAuth(ambient, isolated), + importAmbientAuth(ambient, isolated), + ]; + try { + expect(await Promise.all(imports)).toEqual([true, true]); + expect(new Set(temporaryPaths).size).toBe(2); + expect(await readFile(join(isolated, "auth.json"), "utf8")).toBe( + '{"token":"ambient"}\n', + ); + expect( + (await readdir(isolated)).filter((path) => path.startsWith(".auth-")), + ).toEqual([]); + } finally { + releaseCopies(); + await Promise.allSettled(imports); + copyCredentials.mockRestore(); + } }); test.skipIf(process.platform === "win32")( From 752113281255d4f9489cfdf99a0f9e9eb3ad05ca Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 09:43:16 +0000 Subject: [PATCH 15/21] test: stabilize package and Git fixtures --- .github/workflows/node-ci.yml | 9 ++- sdk/typescript/TESTING.md | 5 +- .../tests-ts/diff-rank-input.test.ts | 55 ++++++++++--------- 3 files changed, 39 insertions(+), 30 deletions(-) diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 5ab943e64..578e44ea6 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -352,12 +352,17 @@ jobs: working-directory: sdk/typescript run: pnpm pack --pack-destination ../../dist + - name: Prepare private Windows test root + id: windows-temp + shell: pwsh + run: ./sdk/typescript/scripts/prepare-windows-test-root.ps1 + - name: Inspect package working-directory: sdk/typescript shell: bash env: - TEMP: ${{ runner.temp }} - TMP: ${{ runner.temp }} + TEMP: ${{ steps.windows-temp.outputs.path }} + TMP: ${{ steps.windows-temp.outputs.path }} npm_config_timing: "true" npm_config_loglevel: http UV_THREADPOOL_SIZE: "16" diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index 9c46f6940..e1b84b200 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -85,8 +85,9 @@ failures do not suppress installation failures. Unix keeps its pinned pnpm setup action. Windows package inspection enables npm's native phase timings to diagnose installation delays without changing its failure or timeout behavior. It also logs npm cache/fetch activity and uses a 16-thread libuv pool for -filesystem-heavy package extraction. Its `TEMP` and `TMP` point to the runner's -job temporary directory, keeping the fresh npm consumer outside the checkout. +filesystem-heavy package extraction. Its `TEMP` and `TMP` use the same private +Windows root as the tests, keeping the fresh npm consumer outside the checkout +while satisfying the installed credential fixture's ancestor ACL checks. These settings apply only to Windows package inspection; the install arguments, assertions, and timeouts are unchanged. diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 19e120300..cb883e823 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -1,4 +1,4 @@ -import { execFileSync, spawnSync } from "node:child_process"; +import { execFile, spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, @@ -10,10 +10,12 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; +import { promisify } from "node:util"; import { afterEach, expect, test } from "bun:test"; import { PLUGIN_ROOT } from "./plugin-root.js"; const temporaryRoots: string[] = []; +const executeFile = promisify(execFile); function pythonExecutable(): string | null { return ( @@ -30,8 +32,8 @@ afterEach(() => { } }); -function git(repository: string, ...args: string[]): string { - return execFileSync( +async function git(repository: string, ...args: string[]): Promise { + const { stdout } = await executeFile( "git", [ "-c", @@ -41,10 +43,11 @@ function git(repository: string, ...args: string[]): string { ...args, ], { cwd: repository, encoding: "utf8" }, - ).trim(); + ); + return stdout.trim(); } -test("diff previews stay inside the selected repository", () => { +test("diff previews stay inside the selected repository", async () => { const root = realpathSync( mkdtempSync(join(tmpdir(), "codex-security-diff-rank-")), ); @@ -52,36 +55,36 @@ test("diff previews stay inside the selected repository", () => { const repository = join(root, "repository"); const nested = join(repository, "src", "nested"); mkdirSync(nested, { recursive: true }); - git(repository, "init", "-q"); + await git(repository, "init", "-q"); writeFileSync(join(repository, "src", "handler.py"), "value = 1\n"); writeFileSync(join(repository, "src", "deleted.py"), "removed = True\n"); writeFileSync(join(repository, "src", "entry.py"), "handler.py"); writeFileSync(join(nested, "linked.py"), "value = 1\n"); - git(repository, "add", "."); - const originalLink = git(repository, "hash-object", "src/entry.py"); - git( + await git(repository, "add", "."); + const originalLink = await git(repository, "hash-object", "src/entry.py"); + await git( repository, "update-index", "--cacheinfo", `120000,${originalLink},src/entry.py`, ); - git(repository, "commit", "-qm", "base"); - const base = git(repository, "rev-parse", "HEAD"); + await git(repository, "commit", "-qm", "base"); + const base = await git(repository, "rev-parse", "HEAD"); writeFileSync(join(repository, "src", "handler.py"), "value = 2\n"); writeFileSync(join(repository, "src", "entry.py"), "nested/linked.py"); writeFileSync(join(nested, "linked.py"), "value = 2\n"); rmSync(join(repository, "src", "deleted.py")); - git(repository, "add", "."); - const updatedLink = git(repository, "hash-object", "src/entry.py"); - git( + await git(repository, "add", "."); + const updatedLink = await git(repository, "hash-object", "src/entry.py"); + await git( repository, "update-index", "--cacheinfo", `120000,${updatedLink},src/entry.py`, ); - git(repository, "commit", "-qm", "selected changes"); - const head = git(repository, "rev-parse", "HEAD"); + await git(repository, "commit", "-qm", "selected changes"); + const head = await git(repository, "rev-parse", "HEAD"); const externalFixture = join(root, "synthetic-fixture"); mkdirSync(externalFixture); @@ -131,7 +134,7 @@ test("diff previews stay inside the selected repository", () => { ); }); -test("preserves Unicode Git paths and legacy-encoded commit metadata", () => { +test("preserves Unicode Git paths and legacy-encoded commit metadata", async () => { const root = realpathSync( mkdtempSync(join(tmpdir(), "codex-security-diff-rank-unicode-")), ); @@ -139,18 +142,18 @@ test("preserves Unicode Git paths and legacy-encoded commit metadata", () => { const repository = join(root, "repository-漢字"); const source = join(repository, "src", "変更.py"); mkdirSync(join(repository, "src"), { recursive: true }); - git(repository, "init", "-q"); + await git(repository, "init", "-q"); writeFileSync(source, "value = 1\n"); - git(repository, "add", "."); - git(repository, "commit", "-qm", "base"); - const base = git(repository, "rev-parse", "HEAD"); + await git(repository, "add", "."); + await git(repository, "commit", "-qm", "base"); + const base = await git(repository, "rev-parse", "HEAD"); writeFileSync(source, "value = 2\n"); - git(repository, "add", "."); - git(repository, "commit", "-qm", "変更"); - const head = git(repository, "rev-parse", "HEAD"); + await git(repository, "add", "."); + await git(repository, "commit", "-qm", "変更"); + const head = await git(repository, "rev-parse", "HEAD"); const legacyMessage = join(root, "legacy-message"); writeFileSync(legacyMessage, Buffer.from("café\n", "latin1")); - git( + await git( repository, "-c", "i18n.commitEncoding=ISO-8859-1", @@ -160,7 +163,7 @@ test("preserves Unicode Git paths and legacy-encoded commit metadata", () => { "-F", legacyMessage, ); - const legacyHead = git(repository, "rev-parse", "HEAD"); + const legacyHead = await git(repository, "rev-parse", "HEAD"); const python = pythonExecutable(); expect(python).not.toBeNull(); From f7fa7c4468749b1b5ea9c5cf94118b395c2df419 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 10:00:12 +0000 Subject: [PATCH 16/21] test: separate package and credential fixtures --- sdk/typescript/TESTING.md | 9 ++++-- sdk/typescript/scripts/smoke-package.mjs | 37 ++++++++++++++++++------ 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index e1b84b200..340a4a776 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -85,9 +85,12 @@ failures do not suppress installation failures. Unix keeps its pinned pnpm setup action. Windows package inspection enables npm's native phase timings to diagnose installation delays without changing its failure or timeout behavior. It also logs npm cache/fetch activity and uses a 16-thread libuv pool for -filesystem-heavy package extraction. Its `TEMP` and `TMP` use the same private -Windows root as the tests, keeping the fresh npm consumer outside the checkout -while satisfying the installed credential fixture's ancestor ACL checks. +filesystem-heavy package extraction. The fresh Windows npm consumer uses +GitHub's `RUNNER_TEMP` when available, otherwise the normal temporary directory. +Its `TEMP` and `TMP` use the same private Windows root as the tests; the +installed credential fixture gets a separate temporary state directory there +so its ancestor ACL checks remain enabled. Both temporary directories are +removed after use. These settings apply only to Windows package inspection; the install arguments, assertions, and timeouts are unchanged. diff --git a/sdk/typescript/scripts/smoke-package.mjs b/sdk/typescript/scripts/smoke-package.mjs index 87a85fa26..919deb67f 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -297,7 +297,14 @@ assert.equal( "Plugin contract must not contain duplicate installed paths.", ); -const consumer = await mkdtemp(join(tmpdir(), "codex-security-package-")); +const consumer = await mkdtemp( + join( + process.platform === "win32" + ? process.env.RUNNER_TEMP ?? tmpdir() + : tmpdir(), + "codex-security-package-", + ), +); try { await writeFile( join(consumer, "package.json"), @@ -556,15 +563,27 @@ try { /lin_api_|security@example\.test/u, ); - run( - process.execPath, - [ - join(packageRoot, "scripts", "fixtures", "credential-lock.mjs"), - pathToFileURL(join(installedRoot, "dist", "runtime.js")).href, - join(consumer, "credential-lock-state"), - ], - { cwd: consumer }, + const credentialState = await mkdtemp( + join(tmpdir(), "codex-security-package-credentials-"), ); + try { + run( + process.execPath, + [ + join(packageRoot, "scripts", "fixtures", "credential-lock.mjs"), + pathToFileURL(join(installedRoot, "dist", "runtime.js")).href, + credentialState, + ], + { cwd: consumer }, + ); + } finally { + await rm(credentialState, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 100, + }); + } await smokeNestedDeepScanWorker(installedRoot, consumer); From 25ff9db877e1a89547db1ecb2cb54553cf5eb4b2 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 10:41:25 +0000 Subject: [PATCH 17/21] ci: parallelize package checks and cache pnpm --- .github/workflows/node-ci.yml | 100 +++++++++++++-- sdk/typescript/TESTING.md | 24 +++- .../tests-ts/release-automation.test.ts | 120 +++++++++++++++++- sdk/typescript/tests-ts/skeleton.test.ts | 10 +- 4 files changed, 236 insertions(+), 18 deletions(-) diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 578e44ea6..3708e382a 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -145,8 +145,6 @@ jobs: uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: ${{ matrix.node }} - cache: npm - cache-dependency-path: sdk/typescript/pnpm-lock.yaml - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 @@ -192,6 +190,50 @@ jobs: if: matrix.os == 'ubuntu-latest' && matrix.node == '22.13.0' run: pnpm --dir sdk/typescript run format + unix-verify: + name: ${{ matrix.os }} / node-${{ matrix.node == '22.13.0' && '22' || matrix.node }} / verify + needs: validate-title + if: needs.validate-title.outputs.ci-mode == 'full' + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + node: ["22.13.0"] + include: + - os: ubuntu-latest + node: "24.0.0" + - os: ubuntu-latest + node: "24" + - os: ubuntu-latest + node: "26.0.0" + - os: ubuntu-latest + node: "26" + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: sdk/typescript/package.json + cache: true + cache_dependency_path: sdk/typescript/pnpm-lock.yaml + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: ${{ matrix.node }} + cache: npm + cache-dependency-path: sdk/typescript/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm --dir sdk/typescript install --frozen-lockfile + - name: Pack working-directory: sdk/typescript run: pnpm pack --pack-destination ../../dist @@ -203,7 +245,7 @@ jobs: required-test: name: ${{ matrix.os }} / node-22 - needs: [validate-title, test] + needs: [validate-title, test, unix-verify] if: always() runs-on: ubuntu-latest strategy: @@ -213,7 +255,7 @@ jobs: steps: - name: Require every Unix coverage job - if: needs.validate-title.result != 'success' || (needs.validate-title.outputs.ci-mode == 'full' && needs.test.result != 'success') || (needs.validate-title.outputs.ci-mode != 'full' && needs.validate-title.outputs.ci-mode != 'markdown') + if: needs.validate-title.result != 'success' || (needs.validate-title.outputs.ci-mode == 'full' && (needs.test.result != 'success' || needs.unix-verify.result != 'success')) || (needs.validate-title.outputs.ci-mode != 'full' && needs.validate-title.outputs.ci-mode != 'markdown') run: exit 1 windows-test: @@ -238,15 +280,33 @@ jobs: uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: ${{ matrix.node }} - cache: npm - cache-dependency-path: sdk/typescript/pnpm-lock.yaml + + - name: Select pnpm version + id: pnpm-version + shell: bash + run: | + pnpm_pin=$(node -p 'require("./sdk/typescript/package.json").packageManager') + printf 'pin=%s\npackage=%s\n' "$pnpm_pin" "${pnpm_pin%%+*}" >> "$GITHUB_OUTPUT" + printf '%s/pnpm\n' "$RUNNER_TEMP" >> "$GITHUB_PATH" + + - name: Cache pnpm executable + id: pnpm-tool + continue-on-error: true + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/pnpm + key: pnpm-tool-${{ runner.os }}-${{ runner.arch }}-${{ steps.pnpm-version.outputs.pin }} - name: Set up pnpm id: pnpm shell: bash + env: + PNPM_PACKAGE: ${{ steps.pnpm-version.outputs.package }} + PNPM_CACHE_HIT: ${{ steps.pnpm-tool.outputs.cache-hit }} run: | - pnpm_package=$(node -p 'require("./sdk/typescript/package.json").packageManager.split("+")[0]') - npm install --global "$pnpm_package" --prefer-offline --no-audit --no-fund + if [[ "$PNPM_CACHE_HIT" != 'true' ]]; then + npm install --global --prefix "$RUNNER_TEMP/pnpm" "$PNPM_PACKAGE" --prefer-offline --no-audit --no-fund + fi pnpm_store=$(pnpm store path --silent) pnpm_arch=$(node -p 'process.arch') printf 'store-path=%s\narch=%s\n' "$pnpm_store" "$pnpm_arch" >> "$GITHUB_OUTPUT" @@ -327,12 +387,32 @@ jobs: cache: npm cache-dependency-path: sdk/typescript/pnpm-lock.yaml + - name: Select pnpm version + id: pnpm-version + shell: bash + run: | + pnpm_pin=$(node -p 'require("./sdk/typescript/package.json").packageManager') + printf 'pin=%s\npackage=%s\n' "$pnpm_pin" "${pnpm_pin%%+*}" >> "$GITHUB_OUTPUT" + printf '%s/pnpm\n' "$RUNNER_TEMP" >> "$GITHUB_PATH" + + - name: Cache pnpm executable + id: pnpm-tool + continue-on-error: true + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/pnpm + key: pnpm-tool-${{ runner.os }}-${{ runner.arch }}-${{ steps.pnpm-version.outputs.pin }} + - name: Set up pnpm id: pnpm shell: bash + env: + PNPM_PACKAGE: ${{ steps.pnpm-version.outputs.package }} + PNPM_CACHE_HIT: ${{ steps.pnpm-tool.outputs.cache-hit }} run: | - pnpm_package=$(node -p 'require("./sdk/typescript/package.json").packageManager.split("+")[0]') - npm install --global "$pnpm_package" --prefer-offline --no-audit --no-fund + if [[ "$PNPM_CACHE_HIT" != 'true' ]]; then + npm install --global --prefix "$RUNNER_TEMP/pnpm" "$PNPM_PACKAGE" --prefer-offline --no-audit --no-fund + fi pnpm_store=$(pnpm store path --silent) pnpm_arch=$(node -p 'process.arch') printf 'store-path=%s\narch=%s\n' "$pnpm_store" "$pnpm_arch" >> "$GITHUB_OUTPUT" diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index 340a4a776..183954c09 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -62,11 +62,22 @@ default to 100 cases; filesystem contract properties default to 20. `node-ci` retains the required `ubuntu-latest / node-22`, `macos-latest / node-22`, and `windows-latest / node-22` checks. Its Ubuntu Node 22 job runs static checks and uploads JUnit and LCOV. Every existing OS and -Node runtime lane still runs the full suite: tests also launch Node subprocesses, +Node runtime lane still runs the full suite in full CI: tests also launch Node subprocesses, so changing Node can affect more than the Bun test runtime. All supported runtime lanes still inspect an installed package. Package inspection includes a strict NodeNext TypeScript consumer and the actual installed CLI. Failed -tests block CI; a failed diagnostic upload does not. +tests block CI; a failed diagnostic upload does not. Unix package verification +runs in six separate jobs alongside the six test lanes, with the same OS and +Node versions. Required checks depend on both matrices. This raises full CI +from 27 to 33 jobs and duplicates checkout and dependency setup, trading more +runner resources for less sequential work; it does not duplicate the test suite. + +Pull-request validation classifies changes before starting test and package +jobs. Markdown-only diffs run changed-file formatting instead; pushes, base +retargets, empty diffs, and changes to other file types run full CI. Required +check names remain stable and reject failed validation or an unknown CI mode. +Every pull-request edit is reclassified and shares the same cancellation group, +so editing the body of a code pull request can restart its full CI run. `scripts/run-ci-tests.mjs` discovers the test files and balances them using rounded timings in `scripts/test-shards.mjs`. Unix estimates average measured @@ -79,9 +90,12 @@ Credential-home locking and ACL checks live in `runtime-credentials.test.ts` so they can run independently of the plugin, output-directory, and Python checks in `runtime.test.ts`. The machine-wide Windows policy test still runs separately and serially. -Windows installs the pnpm version from `packageManager` directly, reuses the -npm download cache, and caches the resolved pnpm store separately. Cache -failures do not suppress installation failures. Unix keeps its pinned pnpm +Windows caches the exact pnpm version from `packageManager` in a dedicated +runner-temp prefix. A cache miss or cache error installs it with npm; only an +exact hit skips installation. The resolved pnpm store is cached separately. +In full CI, only package-verification jobs restore the npm download cache for +the fresh consumer; test jobs on either platform do not restore it. Cache failures do not suppress +installation failures. Unix keeps its pinned pnpm setup action. Windows package inspection enables npm's native phase timings to diagnose installation delays without changing its failure or timeout behavior. It also logs npm cache/fetch activity and uses a 16-thread libuv pool for diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index 16681aa7c..2627c0b83 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -3880,6 +3880,85 @@ describe("GitHub release workflow safeguards", () => { expect(releaseLabelsWorkflow).toContain("return 0"); }); + test.each([ + { name: "exact cache hit", cacheHit: "true", installExit: 0 }, + { name: "cache miss", cacheHit: "false", installExit: 0 }, + { name: "cache error", cacheHit: "", installExit: 0 }, + { name: "installation failure", cacheHit: "false", installExit: 23 }, + ])("sets up Windows pnpm after $name", async ({ cacheHit, installExit }) => { + const workflow = Bun.YAML.parse(nodeCiWorkflow) as { + jobs: Record }>; + }; + const directory = mkdtempSync(join(tmpdir(), "pnpm workflow-")); + const output = join(directory, "output"); + const mock = [ + 'npm() { printf "npm:%s\\n" "$@"; return "$INSTALL_EXIT"; }', + 'pnpm() { printf "%s/pnpm-store\\n" "$RUNNER_TEMP"; }', + 'node() { printf "x64\\n"; }', + ].join("\n"); + try { + for (const job of ["windows-test", "windows-verify"]) { + writeFileSync(output, ""); + const script = workflow.jobs[job]?.steps.find( + (step) => step.name === "Set up pnpm", + )?.run; + expect(script).toBeDefined(); + const child = Bun.spawn( + [ + bash, + "--noprofile", + "--norc", + "-eo", + "pipefail", + "-c", + `${mock}\n${script}`, + ], + { + env: { + ...process.env, + GITHUB_OUTPUT: output, + RUNNER_TEMP: directory, + PNPM_PACKAGE: "pnpm@1.2.3", + PNPM_CACHE_HIT: cacheHit, + INSTALL_EXIT: String(installExit), + }, + stdout: "pipe", + stderr: "pipe", + timeout: 10_000, + }, + ); + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(code, stderr).toBe(installExit); + if (cacheHit === "true") { + expect(stdout).not.toContain("npm:"); + } else { + expect(stdout.trimEnd().split("\n")).toEqual([ + "npm:install", + "npm:--global", + "npm:--prefix", + `npm:${directory}/pnpm`, + "npm:pnpm@1.2.3", + "npm:--prefer-offline", + "npm:--no-audit", + "npm:--no-fund", + ]); + } + const result = readFileSync(output, "utf8"); + if (installExit === 0) { + expect(result).toBe(`store-path=${directory}/pnpm-store\narch=x64\n`); + } else { + expect(result).toBe(""); + } + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + test("enforces the same Conventional Commit title in required CI", () => { const releasePattern = /conventional_title='([^']+)'/u.exec( releaseLabelsWorkflow, @@ -3896,6 +3975,9 @@ describe("GitHub release workflow safeguards", () => { "types: [opened, edited, reopened, synchronize]", ); expect(nodeCiWorkflow).toContain("needs: validate-title"); + expect(nodeCiWorkflow).toContain( + "needs: [validate-title, test, unix-verify]", + ); expect(nodeCiWorkflow).toContain( "needs: [validate-title, windows-test, windows-verify]", ); @@ -3912,6 +3994,7 @@ describe("GitHub release workflow safeguards", () => { { name: string; if?: string; + needs?: string | string[]; "timeout-minutes"?: number; strategy?: { matrix: Record }; steps: Array<{ @@ -3938,8 +4021,14 @@ describe("GitHub release workflow safeguards", () => { ).toContain("github.event.changes.base == null"); const fullCiCondition = "needs.validate-title.outputs.ci-mode == 'full'"; - for (const job of ["test", "windows-test", "windows-verify"]) { + for (const job of [ + "test", + "unix-verify", + "windows-test", + "windows-verify", + ]) { expect(workflow.jobs[job]?.if).toBe(fullCiCondition); + expect(workflow.jobs[job]?.needs).toBe("validate-title"); } expect(workflow.jobs["markdown-checks"]).toBeUndefined(); const validationSteps = workflow.jobs["validate-title"]?.steps ?? []; @@ -3968,7 +4057,7 @@ describe("GitHub release workflow safeguards", () => { expect(workflow.jobs["required-test"]?.if).toBe(requiredJobCondition); expect(workflow.jobs["windows"]?.if).toBe(requiredJobCondition); expect(workflow.jobs["required-test"]?.steps[0]?.if).toBe( - "needs.validate-title.result != 'success' || (needs.validate-title.outputs.ci-mode == 'full' && needs.test.result != 'success') || (needs.validate-title.outputs.ci-mode != 'full' && needs.validate-title.outputs.ci-mode != 'markdown')", + "needs.validate-title.result != 'success' || (needs.validate-title.outputs.ci-mode == 'full' && (needs.test.result != 'success' || needs.unix-verify.result != 'success')) || (needs.validate-title.outputs.ci-mode != 'full' && needs.validate-title.outputs.ci-mode != 'markdown')", ); expect(workflow.jobs["windows"]?.steps[0]?.if).toBe( "needs.validate-title.result != 'success' || (needs.validate-title.outputs.ci-mode == 'full' && (needs.windows-test.result != 'success' || needs.windows-verify.result != 'success')) || (needs.validate-title.outputs.ci-mode != 'full' && needs.validate-title.outputs.ci-mode != 'markdown')", @@ -3976,6 +4065,7 @@ describe("GitHub release workflow safeguards", () => { for (const [ciMode, validation, upstream, gateFailure] of [ ["full", "success", "success", false], ["full", "success", "skipped", true], + ["full", "failure", "skipped", true], ["markdown", "success", "skipped", false], ["markdown", "failure", "skipped", true], ["skip", "success", "skipped", true], @@ -3983,6 +4073,7 @@ describe("GitHub release workflow safeguards", () => { ] as const) { const values = { "needs.test.result": upstream, + "needs.unix-verify.result": upstream, "needs.validate-title.outputs.ci-mode": ciMode, "needs.validate-title.result": validation, "needs.windows-test.result": upstream, @@ -4030,6 +4121,31 @@ describe("GitHub release workflow safeguards", () => { ).toEqual([...requiredContexts].sort()); }); + test.each(["failure", "skipped"])( + "keeps required contexts truthful for full CI with package verification %s", + (verification) => { + const workflow = Bun.YAML.parse(nodeCiWorkflow) as { + jobs: Record }>; + }; + const values = { + "needs.validate-title.outputs.ci-mode": "full", + "needs.validate-title.result": "success", + "needs.test.result": "success", + "needs.unix-verify.result": verification, + "needs.windows-test.result": "success", + "needs.windows-verify.result": verification, + }; + for (const job of ["required-test", "windows"]) { + expect( + evaluateWorkflowCondition( + workflow.jobs[job]?.steps[0]?.if ?? "", + values, + ), + ).toBe(true); + } + }, + ); + test.each([ [ "Markdown-only PR", diff --git a/sdk/typescript/tests-ts/skeleton.test.ts b/sdk/typescript/tests-ts/skeleton.test.ts index 8fdf4352a..655d2930a 100644 --- a/sdk/typescript/tests-ts/skeleton.test.ts +++ b/sdk/typescript/tests-ts/skeleton.test.ts @@ -114,6 +114,14 @@ describe("TypeScript package skeleton", () => { node, })), }); + expect(jobs["unix-verify"]?.strategy?.matrix).toEqual( + jobs["test"]?.strategy?.matrix, + ); + expect(jobs["required-test"]?.needs).toEqual([ + "validate-title", + "test", + "unix-verify", + ]); expect(jobs["required-test"]?.name).toContain("${{ matrix.os }} / node-22"); expect(jobs["required-test"]?.strategy?.matrix).toEqual({ os: ["ubuntu-latest", "macos-latest"], @@ -173,7 +181,7 @@ describe("TypeScript package skeleton", () => { ).toMatchObject({ "continue-on-error": true, }); - for (const name of ["test", "windows-verify"]) { + for (const name of ["unix-verify", "windows-verify"]) { expect(jobs[name]?.steps).toContainEqual( expect.objectContaining({ run: "pnpm run check:package ../../dist/*.tgz", From d3e1514b1afdbf0cf24c5461bc8a75177c5bf83c Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 11:27:14 +0000 Subject: [PATCH 18/21] test: simplify Python history fixture setup --- sdk/typescript/TESTING.md | 5 +++++ .../tests-ts/workbench-scan-history.test.ts | 20 ++++++++++++++----- .../workbench-scan-root-alias.test.ts | 10 +++++++--- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index 183954c09..9fe20318c 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -29,6 +29,11 @@ JavaScript and TypeScript, not the Python helpers or child processes. It is diagnostic for now. Use several successful CI runs to establish a baseline before proposing a coverage floor. +The scan-history Python fixtures use direct interpreter lookup and a 30-second +child deadline, raised from 10 seconds after repeated hosted Windows timeouts. +They still run real Python processes and fail if Python is unavailable. +Production interpreter discovery and its deadlines are unchanged. + ## Writing tests - Test observable results, failures, cancellation, and cleanup. Prefer a diff --git a/sdk/typescript/tests-ts/workbench-scan-history.test.ts b/sdk/typescript/tests-ts/workbench-scan-history.test.ts index fb2ca8a74..d4f387f9b 100644 --- a/sdk/typescript/tests-ts/workbench-scan-history.test.ts +++ b/sdk/typescript/tests-ts/workbench-scan-history.test.ts @@ -1,11 +1,16 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "bun:test"; -import { resolvePluginPython, runCodexCommand } from "../src/runtime.js"; +import { runCodexCommand } from "../src/runtime.js"; import { PLUGIN_ROOT } from "./plugin-root.js"; test("loads each scan's matching findings once across historical batches", async () => { - const python = await resolvePluginPython(); + const python = + process.env["PYTHON"] ?? + Bun.which("python3") ?? + Bun.which("python") ?? + Bun.which("py"); + if (python === null) throw new Error("A Python interpreter is required."); const probe = [ "import argparse, json, sqlite3, sys", @@ -43,7 +48,7 @@ test("loads each scan's matching findings once across historical batches", async ], process.env, probe, - AbortSignal.timeout(10_000), + AbortSignal.timeout(30_000), ); expect(result.exitCode, result.stderr).toBe(0); @@ -65,7 +70,12 @@ test("loads each scan's matching findings once across historical batches", async }); test("loads oversized comparison matches from stdin", async () => { - const python = await resolvePluginPython(); + const python = + process.env["PYTHON"] ?? + Bun.which("python3") ?? + Bun.which("python") ?? + Bun.which("py"); + if (python === null) throw new Error("A Python interpreter is required."); const probe = [ "import argparse, io, json, sqlite3, sys", @@ -102,7 +112,7 @@ test("loads oversized comparison matches from stdin", async () => { ["-I", "-B", "-c", probe, join(PLUGIN_ROOT, "scripts")], process.env, payload, - AbortSignal.timeout(10_000), + AbortSignal.timeout(30_000), ); expect(result.exitCode, result.stderr).toBe(0); diff --git a/sdk/typescript/tests-ts/workbench-scan-root-alias.test.ts b/sdk/typescript/tests-ts/workbench-scan-root-alias.test.ts index 91cc240d7..28b66141a 100644 --- a/sdk/typescript/tests-ts/workbench-scan-root-alias.test.ts +++ b/sdk/typescript/tests-ts/workbench-scan-root-alias.test.ts @@ -4,13 +4,17 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { expect, test } from "bun:test"; -import { resolvePluginPython } from "../src/runtime.js"; import { PLUGIN_ROOT } from "./plugin-root.js"; test.skipIf(process.platform !== "win32")( "matches scan-root filters across Windows path aliases", async () => { - const python = await resolvePluginPython(); + const python = + process.env["PYTHON"] ?? + Bun.which("python3") ?? + Bun.which("python") ?? + Bun.which("py"); + if (python === null) throw new Error("A Python interpreter is required."); const root = realpathSync( mkdtempSync(join(tmpdir(), "codex-security-scan-root-case-")), @@ -38,7 +42,7 @@ test.skipIf(process.platform !== "win32")( const result = await promisify(execFile)( python, ["-I", "-B", "-c", probe, join(PLUGIN_ROOT, "scripts"), scanRoot], - { encoding: "utf8", timeout: 10_000, windowsHide: true }, + { encoding: "utf8", timeout: 30_000, windowsHide: true }, ); expect(result.stderr).toBe(""); From d201738ae5ff27768270454a20f52892454a3d4a Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 12:37:36 +0000 Subject: [PATCH 19/21] test: publish complete descendant PID before signaling --- sdk/typescript/tests-ts/publication-integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/typescript/tests-ts/publication-integration.test.ts b/sdk/typescript/tests-ts/publication-integration.test.ts index 5b6293cc2..58c25c9fb 100644 --- a/sdk/typescript/tests-ts/publication-integration.test.ts +++ b/sdk/typescript/tests-ts/publication-integration.test.ts @@ -1133,7 +1133,7 @@ describe("database-backed Linear publication integration", () => { const publisherPidFile = join(root, "publisher.pid"); const descendantPidFile = join(root, "descendant.pid"); const descendant = - 'const fs = require("node:fs");process.on("SIGINT", () => {});process.on("SIGTERM", () => {});fs.writeFileSync(process.env.CODEX_PUBLICATION_DESCENDANT_PID, String(process.pid));setInterval(() => {}, 1000);'; + 'const fs = require("node:fs");process.on("SIGINT", () => {});process.on("SIGTERM", () => {});const pidFile = process.env.CODEX_PUBLICATION_DESCENDANT_PID;fs.writeFileSync(pidFile + ".tmp", String(process.pid));fs.renameSync(pidFile + ".tmp", pidFile);setInterval(() => {}, 1000);'; await writeFile( preload, `const fs = require("node:fs"); const { spawn } = require("node:child_process"); From 5f004f643395c065ef8cbebc592e23ae1fa13953 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 13:22:03 +0000 Subject: [PATCH 20/21] test: balance large tracked-output coverage in CI --- sdk/typescript/scripts/test-shards.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/typescript/scripts/test-shards.mjs b/sdk/typescript/scripts/test-shards.mjs index 640d12500..4aa34c76c 100644 --- a/sdk/typescript/scripts/test-shards.mjs +++ b/sdk/typescript/scripts/test-shards.mjs @@ -20,6 +20,7 @@ const timings = { "scan-comparison.test.ts": [1, 11], "scan-recovery.test.ts": [55, 83], "stopped-scan-results.test.ts": [15, 17], + "targets-large-output.test.ts": [2, 11], }; export function partitionTestFiles(files, count, platform = process.platform) { From ec9c3e3a1d4f1bdeaf70617481f69d86607807ec Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 26 Aug 2026 13:42:00 +0000 Subject: [PATCH 21/21] test: await Deep tool discovery subprocesses --- .../tests-ts/deep-scan-reducer-recovery.test.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/sdk/typescript/tests-ts/deep-scan-reducer-recovery.test.ts b/sdk/typescript/tests-ts/deep-scan-reducer-recovery.test.ts index c33104c52..110720068 100644 --- a/sdk/typescript/tests-ts/deep-scan-reducer-recovery.test.ts +++ b/sdk/typescript/tests-ts/deep-scan-reducer-recovery.test.ts @@ -1,8 +1,8 @@ -import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "bun:test"; +import { runCodexCommand } from "../src/runtime.js"; import { loadBundledRuntime, PLUGIN_ROOT } from "./plugin-root.js"; function bundledFunction(runtime: string, name: string): string { @@ -66,18 +66,19 @@ test("keeps every advertised Deep worker tool within Codex's name limit", async ); expect(Object.keys(servers)).toEqual(["cs_artifacts"]); const server = servers["cs_artifacts"]!; - const result = spawnSync(node!, server.args, { - encoding: "utf8", - env: { ...process.env, ...server.env }, - input: [ + const result = await runCodexCommand( + { command: node! }, + server.args, + { ...process.env, ...server.env }, + [ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"codex-security-test","version":"1.0.0"}}}', '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}', '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}', "", ].join("\n"), - timeout: 30_000, - }); - expect(result.status, result.stderr).toBe(0); + AbortSignal.timeout(30_000), + ); + expect(result.exitCode, result.stderr).toBe(0); const response = result.stdout .trim() .split("\n")