Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 142 additions & 3 deletions .github/workflows/node-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
36 changes: 25 additions & 11 deletions sdk/typescript/scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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" ||
Expand Down Expand Up @@ -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}.`);
}
Expand Down
116 changes: 116 additions & 0 deletions sdk/typescript/scripts/run-windows-ci-tests.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions sdk/typescript/scripts/smoke-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ try {
"--prefer-offline",
"--include=optional",
"--ignore-scripts",
"--package-lock=false",
"--no-audit",
"--no-fund",
archive,
Expand Down
8 changes: 7 additions & 1 deletion sdk/typescript/tests-ts/skeleton.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand Down
Loading