diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index c7a1e08e..60892c1e 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -20,11 +20,9 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-latest] node: ["22.13.0"] include: - - os: windows-latest - node: "24" - os: ubuntu-latest node: "24.0.0" - os: ubuntu-latest @@ -121,3 +119,144 @@ jobs: ' node bin/codex-security.mjs --version node bin/codex-security.mjs --help + + windows-test: + name: windows-latest / node-${{ matrix.node == '22.13.0' && '22' || matrix.node }} / tests-${{ matrix.shard }} + runs-on: windows-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + node: ["22.13.0", "24"] + shard: [1, 2, 3, 4, 5, 6, 7] + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: ${{ matrix.node }} + + - name: Set up pnpm + run: npm install --global pnpm@11.9.0 --no-audit --no-fund + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.3.14" + + - name: Install dependencies + working-directory: sdk/typescript + run: pnpm install --frozen-lockfile + + - name: Prepare private Windows test root + id: windows-temp + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $path = Join-Path $env:USERPROFILE '.codex-security-ci-temp' + New-Item -ItemType Directory -Path $path -Force | Out-Null + $sid = (whoami /user /fo csv /nh | ConvertFrom-Csv -Header Name, Sid).Sid + & icacls $path /inheritance:r /grant:r "*${sid}:(OI)(CI)F" '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Could not secure the Windows test root' } + "path=$path" >> $env:GITHUB_OUTPUT + + - name: Test shard ${{ matrix.shard }} + timeout-minutes: 10 + env: + TEMP: ${{ steps.windows-temp.outputs.path }} + TMP: ${{ steps.windows-temp.outputs.path }} + TMPDIR: ${{ steps.windows-temp.outputs.path }} + CODEX_SECURITY_ALLOW_MACHINE_POLICY_TEST: ${{ runner.environment == 'github-hosted' && 'true' || 'false' }} + run: node sdk/typescript/scripts/run-windows-ci-tests.mjs ${{ matrix.shard }} + + - name: Typecheck + if: matrix.shard == 7 + working-directory: sdk/typescript + run: pnpm run types + + - name: Check formatting + if: matrix.shard == 7 + working-directory: sdk/typescript + run: pnpm run format + + windows-verify: + name: windows-latest / node-${{ matrix.node == '22.13.0' && '22' || matrix.node }} / verify + runs-on: windows-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + node: ["22.13.0", "24"] + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - 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: Set up pnpm + run: npm install --global pnpm@11.9.0 --no-audit --no-fund + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.3.14" + + - name: Install dependencies + working-directory: sdk/typescript + run: pnpm install --frozen-lockfile + + - name: Build + working-directory: sdk/typescript + run: pnpm run build + + - name: Pack + working-directory: sdk/typescript + run: pnpm pack --pack-destination ../../dist + + - name: Inspect package + working-directory: sdk/typescript + shell: bash + run: pnpm run check:package ../../dist/*.tgz + + - name: Smoke-test Node.js runtime + working-directory: sdk/typescript + shell: bash + run: | + set -euo pipefail + node --input-type=module --eval ' + import { CodexSecurity } from "@openai/codex-security"; + + if (typeof CodexSecurity !== "function") { + throw new Error("The SDK does not export CodexSecurity."); + } + ' + node bin/codex-security.mjs --version + node bin/codex-security.mjs --help + + windows: + name: windows-latest / node-${{ matrix.node == '22.13.0' && '22' || matrix.node }} + runs-on: ubuntu-latest + if: always() + needs: [windows-test, windows-verify] + strategy: + fail-fast: false + matrix: + node: ["22.13.0", "24"] + + steps: + - name: Require every Windows coverage job + if: needs.windows-test.result != 'success' || needs.windows-verify.result != 'success' + run: exit 1 diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index c4a04a57..731a289f 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -44,6 +44,7 @@ function tar(args, encoding = "buffer") { } let offset = 0; +const archiveFiles = new Map(); for (; offset + 512 <= archiveBytes.byteLength; ) { const header = archiveBytes.subarray(offset, offset + 512); if (header.every((byte) => byte === 0)) { @@ -65,12 +66,31 @@ for (; offset + 512 <= archiveBytes.byteLength; ) { throw new Error("npm tarball contains an invalid tar entry."); } const size = Number.parseInt(sizeField || "0", 8); - offset += 512 + Math.ceil(size / 512) * 512; + const contentsStart = offset + 512; + const nextOffset = contentsStart + Math.ceil(size / 512) * 512; + if (nextOffset > archiveBytes.byteLength) { + throw new Error("npm tarball contains an invalid tar entry."); + } + if (header[156] === 0 || header[156] === 0x30) { + archiveFiles.set( + path, + archiveBytes.subarray(contentsStart, contentsStart + size), + ); + } + offset = nextOffset; } if (archiveBytes.subarray(offset).some((byte) => byte !== 0)) { throw new Error("npm tarball contains trailing tar data."); } +function archiveFile(path) { + const contents = archiveFiles.get(path); + if (contents === undefined) { + throw new Error("npm tarball contains an invalid tar entry: " + path + "."); + } + return contents; +} + const entries = tar(["-tzf", archive], "utf8").split(/\r?\n/u).filter(Boolean); const files = new Set(entries); if (files.size !== entries.length) { @@ -211,7 +231,7 @@ if ([3, 6, 9].some((index) => launcherPermissions[index] !== "x")) { throw new Error("npm package CLI launcher is not executable."); } const packageJson = JSON.parse( - tar(["-xOf", archive, "package/package.json"]).toString("utf8"), + archiveFile("package/package.json").toString("utf8"), ); if ( packageJson.name !== "@openai/codex-security" || @@ -251,22 +271,16 @@ function brotliPayload(bytes, file) { } for (const file of compressedFiles) { - payloads.push( - brotliPayload(tar(["-xOf", archive, file]), file).toString("utf8"), - ); + payloads.push(brotliPayload(archiveFile(file), file).toString("utf8")); } for (const parts of compressedParts.values()) { parts.sort((left, right) => left.part - right.part); - const bytes = Buffer.concat( - parts.map(({ file }) => tar(["-xOf", archive, file])), - ); + const bytes = Buffer.concat(parts.map(({ file }) => archiveFile(file))); payloads.push(brotliPayload(bytes, parts[0].file).toString("utf8")); } for (const file of files) { if (/\.png$/iu.test(file)) { - const digest = createHash("sha256") - .update(tar(["-xOf", archive, file])) - .digest("hex"); + const digest = createHash("sha256").update(archiveFile(file)).digest("hex"); if (digest !== PUBLIC_LOGO_SHA256) { throw new Error(`npm tarball contains an unexpected PNG asset: ${file}.`); } diff --git a/sdk/typescript/scripts/run-windows-ci-tests.mjs b/sdk/typescript/scripts/run-windows-ci-tests.mjs new file mode 100644 index 00000000..f0fa31ad --- /dev/null +++ b/sdk/typescript/scripts/run-windows-ci-tests.mjs @@ -0,0 +1,116 @@ +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")) + .sort(); +const slowApiTests = [ + "keeps a private preflight snapshot isolated from persistent credentials", + "reuses keyring-compatible credentials across separate scan clients", + "serializes parallel scans sharing a managed credential home", + "recreates isolated and managed runtimes when scan authentication changes", + "does not reimport ambient credentials after an explicit logout", +].join("|"); +const shardSeeds = [ + { + files: ["api.test.ts"], + testNamePattern: slowApiTests, + }, + { + files: ["api.test.ts"], + testNamePattern: `^(?!.*(?:${slowApiTests})).*$`, + }, + { files: ["runtime.test.ts"] }, + { files: ["cli-authentication.test.ts"] }, + { files: ["scan-recovery.test.ts"] }, + { files: [] }, + { files: [] }, +]; +const assigned = new Set(shardSeeds.flatMap(({ files }) => files)); +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)].files.push( + file, + ); +} + +const assignments = new Map(); +for (const { files } of shardSeeds) { + for (const file of files) { + assignments.set(file, (assignments.get(file) ?? 0) + 1); + } +} +for (const file of tests) { + const expectedAssignments = file === "api.test.ts" ? 2 : 1; + if (assignments.get(file) !== expectedAssignments) { + throw new Error("Windows CI test shards must run every test file."); + } +} + +const requestedShard = + process.argv[2] === undefined + ? undefined + : Number.parseInt(process.argv[2], 10); +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((shard, index) => ({ ...shard, index })) + : [{ ...shardSeeds[requestedShard - 1], index: requestedShard - 1 }]; + +const results = await Promise.all( + selectedShards.map( + ({ files, index, testNamePattern }) => + new Promise((resolve, reject) => { + const paths = files.map((file) => "./tests-ts/" + file); + console.log( + "Windows CI test shard " + + (index + 1) + + "/" + + shardSeeds.length + + ": " + + paths.join(" ") + + (testNamePattern === undefined + ? "" + : " --test-name-pattern " + testNamePattern), + ); + const args = ["test", "--timeout", "30000"]; + if (testNamePattern !== undefined) { + args.push("--test-name-pattern", testNamePattern); + } + args.push(...paths); + const child = spawn("bun", args, { + 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 0cd6b116..3ccb900a 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -314,6 +314,7 @@ try { "--prefer-offline", "--include=optional", "--ignore-scripts", + "--package-lock=false", "--no-audit", "--no-fund", archive, diff --git a/sdk/typescript/tests-ts/skeleton.test.ts b/sdk/typescript/tests-ts/skeleton.test.ts index 128ec67b..1226a2b0 100644 --- a/sdk/typescript/tests-ts/skeleton.test.ts +++ b/sdk/typescript/tests-ts/skeleton.test.ts @@ -82,7 +82,13 @@ describe("TypeScript package skeleton", () => { expect(packageJson.scripts.test).toBe( "bun test --timeout 30000 ./tests-ts", ); - expect(ciWorkflow).toContain("run: pnpm --dir sdk/typescript run test\n"); + expect(ciWorkflow).toContain( + "run: node sdk/typescript/scripts/run-windows-ci-tests.mjs ${{ matrix.shard }}", + ); + expect(ciWorkflow).toContain( + "name: windows-latest / node-${{ matrix.node == '22.13.0' && '22' || matrix.node }}", + ); + expect(ciWorkflow).toContain("run: pnpm --dir sdk/typescript run test"); expect(ciWorkflow).not.toContain("--timeout 60000"); });