diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index f75ab0511..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 @@ -172,7 +170,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 +181,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 @@ -191,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 @@ -202,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: @@ -212,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,8 +281,42 @@ jobs: with: node-version: ${{ matrix.node }} + - 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 - run: npm install --global pnpm@11.9.0 --no-audit --no-fund + id: pnpm + shell: bash + env: + PNPM_PACKAGE: ${{ steps.pnpm-version.outputs.package }} + PNPM_CACHE_HIT: ${{ steps.pnpm-tool.outputs.cache-hit }} + run: | + 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" + + - name: Cache pnpm store + continue-on-error: true + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + 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 @@ -262,7 +339,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 +352,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 @@ -299,8 +387,42 @@ 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 - run: npm install --global pnpm@11.9.0 --no-audit --no-fund + id: pnpm + shell: bash + env: + PNPM_PACKAGE: ${{ steps.pnpm-version.outputs.package }} + PNPM_CACHE_HIT: ${{ steps.pnpm-tool.outputs.cache-hit }} + run: | + 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" + + - name: Cache pnpm store + continue-on-error: true + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + 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 @@ -310,9 +432,20 @@ 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: ${{ steps.windows-temp.outputs.path }} + TMP: ${{ steps.windows-temp.outputs.path }} + 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 3fb473480..9fe20318c 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -22,12 +22,18 @@ 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. +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 @@ -60,10 +66,72 @@ 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 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 +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 +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 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 +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. + +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: + +```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 @@ -80,7 +148,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..e9ce8a7f1 --- /dev/null +++ b/sdk/typescript/scripts/run-ci-tests.mjs @@ -0,0 +1,74 @@ +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. 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]; +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."); +} +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( + workers.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/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); diff --git a/sdk/typescript/scripts/test-shards.mjs b/sdk/typescript/scripts/test-shards.mjs new file mode 100644 index 000000000..4aa34c76c --- /dev/null +++ b/sdk/typescript/scripts/test-shards.mjs @@ -0,0 +1,40 @@ +// 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": [2, 48], + "api.test.ts": [19, 77], + "api-attribution-concurrency.test.ts": [1, 14], + "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": [18, 87], + "runtime.test.ts": [16, 60], + "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) { + 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/api-credentials.test.ts b/sdk/typescript/tests-ts/api-credentials.test.ts index e8935a636..9695e6eba 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"), ); @@ -358,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", diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 74836e58d..ea2424e13 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", @@ -6485,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( @@ -6518,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( @@ -6529,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 () => { @@ -6615,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); @@ -6655,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(); } 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-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(""); 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/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/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) { 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/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") 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(); 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"'); diff --git a/sdk/typescript/tests-ts/patch-tui.test.ts b/sdk/typescript/tests-ts/patch-tui.test.ts index 58dd67f79..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,13 +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 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"]), @@ -112,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() ?? ""); } @@ -189,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!], @@ -200,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"); @@ -225,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"]), @@ -234,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"] }, ]); @@ -253,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"]), @@ -262,36 +277,38 @@ describe("interactive patch finding browser", () => { }), ); - app.stdin.write("i"); - await settle(); + 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 settle(); + 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 settle(); + 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 settle(); - app.stdin.write("i"); - await settle(); - app.stdin.write("Keep the existing middleware."); - await settle(); - app.stdin.write("\r"); - await settle(); + 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 settle(); - app.stdin.write("\r"); - await settle(); + 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([ { @@ -306,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"]), @@ -318,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([ { @@ -337,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"]), @@ -346,25 +361,28 @@ describe("interactive patch finding browser", () => { }), ); - app.stdin.write("i"); - await settle(); - app.stdin.write("Discard this guidance."); - await settle(); - app.stdin.write("\u001B"); - await settle(); + 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 settle(); - app.stdin.write("x"); - await settle(); - app.stdin.write("\u007F"); - await settle(); - app.stdin.write("\r"); - await settle(); - app.stdin.write("\r"); - await settle(); + await reactAct(() => app.stdin.write("i")); + expect(app.lastFrame()).toContain("Enter save"); + const emptyDraftFrame = app.lastFrame(); + 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"] }]); }); @@ -372,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"]), @@ -381,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!], @@ -414,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], { diff --git a/sdk/typescript/tests-ts/publication-integration.test.ts b/sdk/typescript/tests-ts/publication-integration.test.ts index 8f8f7f415..58c25c9fb 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); @@ -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"); 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/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index d73f58b64..2627c0b83 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"; @@ -3879,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, @@ -3895,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]", ); @@ -3911,6 +3994,7 @@ describe("GitHub release workflow safeguards", () => { { name: string; if?: string; + needs?: string | string[]; "timeout-minutes"?: number; strategy?: { matrix: Record }; steps: Array<{ @@ -3937,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 ?? []; @@ -3967,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')", @@ -3975,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], @@ -3982,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, @@ -4029,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", @@ -4507,7 +4624,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 +4662,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/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..51f4d65c5 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 }); @@ -1538,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")( @@ -2133,1741 +2103,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( diff --git a/sdk/typescript/tests-ts/skeleton.test.ts b/sdk/typescript/tests-ts/skeleton.test.ts index 1289b2a23..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"], @@ -145,10 +153,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", }), ); }); @@ -171,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", @@ -255,7 +265,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..758c4e840 100644 --- a/sdk/typescript/tests-ts/test-reports.test.ts +++ b/sdk/typescript/tests-ts/test-reports.test.ts @@ -1,5 +1,12 @@ -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"; @@ -41,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", @@ -67,6 +67,142 @@ 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( + 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[]) => + runCommand([node, runner, ...args], { + cwd: root, + timeout: 30_000, + }); + + const failed = await 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(" + 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(" { test("runs every workflow comparison before reporting a mismatch", async () => { const fixture = await fixtures(); @@ -96,12 +232,10 @@ describe("JUnit inventory comparison", () => { 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-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 0a74c093a..28b66141a 100644 --- a/sdk/typescript/tests-ts/workbench-scan-root-alias.test.ts +++ b/sdk/typescript/tests-ts/workbench-scan-root-alias.test.ts @@ -1,15 +1,20 @@ -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"; 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-")), @@ -34,13 +39,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: 30_000, windowsHide: true }, ); - expect(result.status, result.stderr).toBe(0); expect(result.stderr).toBe(""); expect(JSON.parse(result.stdout)).toMatchObject({ scans: [{ scanId: "scan" }],