From dda1db99fdf1bb656974090176f6a0a35f83d494 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 17 Aug 2026 12:10:39 -0700 Subject: [PATCH 1/6] test: isolate SDK fixtures and simplify Windows shards --- .github/workflows/node-ci.yml | 22 +- .../scripts/prepare-windows-test-root.ps1 | 7 + .../scripts/run-windows-ci-tests.mjs | 89 +- .../tests-ts/api-credentials.test.ts | 486 +++++++++++ sdk/typescript/tests-ts/api-post-scan.test.ts | 28 +- sdk/typescript/tests-ts/api.test.ts | 792 ++++-------------- sdk/typescript/tests-ts/multiscan.test.ts | 9 + sdk/typescript/tests-ts/runtime.test.ts | 200 +---- sdk/typescript/tests-ts/support/api-client.ts | 73 ++ sdk/typescript/tests-ts/support/api-events.ts | 12 +- .../tests-ts/support/isolated-mock.ts | 18 - .../tests-ts/support/test-subprocess.ts | 22 + .../tests-ts/windows-machine-policy.test.ts | 206 +++++ 13 files changed, 1010 insertions(+), 954 deletions(-) create mode 100644 sdk/typescript/scripts/prepare-windows-test-root.ps1 create mode 100644 sdk/typescript/tests-ts/api-credentials.test.ts create mode 100644 sdk/typescript/tests-ts/support/api-client.ts delete mode 100644 sdk/typescript/tests-ts/support/isolated-mock.ts create mode 100644 sdk/typescript/tests-ts/support/test-subprocess.ts create mode 100644 sdk/typescript/tests-ts/windows-machine-policy.test.ts diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index f04481a9f..ae0998420 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -140,14 +140,7 @@ jobs: - 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 + run: ./sdk/typescript/scripts/prepare-windows-test-root.ps1 - name: Test shard ${{ matrix.shard }} timeout-minutes: 10 @@ -155,9 +148,20 @@ jobs: 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' }} + CODEX_SECURITY_ALLOW_MACHINE_POLICY_TEST: "false" run: node sdk/typescript/scripts/run-windows-ci-tests.mjs ${{ matrix.shard }} + - name: Test machine-wide PowerShell policy + if: matrix.shard == 3 && runner.environment == 'github-hosted' + timeout-minutes: 5 + working-directory: sdk/typescript + 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: "true" + run: bun test --timeout 30000 ./tests-ts/windows-machine-policy.test.ts + - name: Typecheck if: matrix.shard == 7 working-directory: sdk/typescript diff --git a/sdk/typescript/scripts/prepare-windows-test-root.ps1 b/sdk/typescript/scripts/prepare-windows-test-root.ps1 new file mode 100644 index 000000000..e6c9ed9af --- /dev/null +++ b/sdk/typescript/scripts/prepare-windows-test-root.ps1 @@ -0,0 +1,7 @@ +$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 diff --git a/sdk/typescript/scripts/run-windows-ci-tests.mjs b/sdk/typescript/scripts/run-windows-ci-tests.mjs index 534f9fac4..16854e769 100644 --- a/sdk/typescript/scripts/run-windows-ci-tests.mjs +++ b/sdk/typescript/scripts/run-windows-ci-tests.mjs @@ -1,45 +1,25 @@ import { spawn } from "node:child_process"; -import { readFile, readdir } from "node:fs/promises"; +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")) + .filter( + (file) => + file.endsWith(".test.ts") && file !== "windows-machine-policy.test.ts", + ) .sort(); -const slowApiTestNames = [ - "keeps a private preflight snapshot isolated from persistent credentials", - "reuses keyring-compatible credentials across separate scan clients", - "runs parallel ChatGPT scans with isolated mutable configuration", - "reuses the managed runtime when scan authentication changes", - "does not reimport ambient credentials after an explicit logout", -]; -const apiTestSource = await readFile( - new URL("../tests-ts/api.test.ts", import.meta.url), - "utf8", -); -for (const testName of slowApiTestNames) { - if (!apiTestSource.includes(`test("${testName}"`)) { - throw new Error("Windows CI slow API shard references a missing test."); - } -} -const slowApiTests = slowApiTestNames.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: [] }, + ["api-credentials.test.ts"], + ["api.test.ts"], + ["runtime.test.ts"], + ["cli-authentication.test.ts"], + ["scan-recovery.test.ts"], + [], + [], ]; -const assigned = new Set(shardSeeds.flatMap(({ files }) => files)); +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); @@ -52,28 +32,19 @@ const slowRemainderFiles = new Set([ "scan-comparison.test.ts", ]); for (const [index, file] of unassigned.entries()) { - shardSeeds[slowRemainderFiles.has(file) ? 6 : 5 + (index % 2)].files.push( - file, - ); + shardSeeds[slowRemainderFiles.has(file) ? 6 : 5 + (index % 2)].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 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.parseInt(process.argv[2], 10); + process.argv[2] === undefined ? undefined : Number(process.argv[2]); if ( requestedShard !== undefined && (!Number.isSafeInteger(requestedShard) || @@ -84,12 +55,12 @@ if ( } const selectedShards = requestedShard === undefined - ? shardSeeds.map((shard, index) => ({ ...shard, index })) - : [{ ...shardSeeds[requestedShard - 1], index: requestedShard - 1 }]; + ? shardSeeds.map((files, index) => ({ files, index })) + : [{ files: shardSeeds[requestedShard - 1], index: requestedShard - 1 }]; const results = await Promise.all( selectedShards.map( - ({ files, index, testNamePattern }) => + ({ files, index }) => new Promise((resolve, reject) => { const paths = files.map((file) => "./tests-ts/" + file); console.log( @@ -98,17 +69,9 @@ const results = await Promise.all( "/" + shardSeeds.length + ": " + - paths.join(" ") + - (testNamePattern === undefined - ? "" - : " --test-name-pattern " + testNamePattern), + paths.join(" "), ); - const args = ["test", "--timeout", "30000"]; - if (testNamePattern !== undefined) { - args.push("--test-name-pattern", testNamePattern); - } - args.push(...paths); - const child = spawn("bun", args, { + const child = spawn("bun", ["test", "--timeout", "30000", ...paths], { cwd: packageDirectory, stdio: "inherit", windowsHide: true, diff --git a/sdk/typescript/tests-ts/api-credentials.test.ts b/sdk/typescript/tests-ts/api-credentials.test.ts new file mode 100644 index 000000000..c72eb54fb --- /dev/null +++ b/sdk/typescript/tests-ts/api-credentials.test.ts @@ -0,0 +1,486 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { CodexOptions } from "@openai/codex-sdk"; +import { afterEach, describe, expect, test } from "bun:test"; +import { parse as parseToml } from "smol-toml"; +import { initialCredentialsAvailable } from "../src/api.js"; +import { setCodexSecurityCredentialLogout } from "../src/runtime.js"; +import { PLUGIN_ROOT } from "./plugin-root.js"; +import { shellEnvironmentReference, TestClient } from "./support/api-client.js"; +import { + completedEvents, + createApiTestFixtures, +} from "./support/api-events.js"; + +const { cleanup, copyCompletedScan, temporaryDirectory } = + createApiTestFixtures(); +afterEach(cleanup); + +describe("CodexSecurity orchestration", () => { + test("keeps a private preflight snapshot isolated from persistent credentials", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const ambientHome = join(root, "ambient-codex-home"); + const scanDir = join(root, "scan"); + await mkdir(repository); + await mkdir(ambientHome); + await mkdir(scanDir, { mode: 0o700 }); + await writeFile(join(ambientHome, "auth.json"), "{}\n"); + const interpreter = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + expect(interpreter).not.toBeNull(); + let capturedConfigPath: string | undefined; + let capturedCodexHome: string | undefined; + const unrelatedProjects = Object.fromEntries( + Array.from({ length: 256 }, (_, index) => [ + join(root, `unrelated-project-${index}`), + { trust_level: "untrusted" }, + ]), + ); + const client = new TestClient( + { + pluginPath: PLUGIN_ROOT, + codexOverrides: { + approval_policy: "never", + features: { goals: true }, + projects: { + ...unrelatedProjects, + [repository]: { trust_level: "trusted" }, + }, + mcp_servers: { + private: { + command: "echo", + env: { PRIVATE_TOKEN: "RUNTIME_MCP_SECRET" }, + }, + }, + shell_environment_policy: { + set: { PRIVATE_TOKEN: "RUNTIME_SHELL_SECRET" }, + }, + responses_api_metadata: { + request_trace: "preserve-configured-metadata", + }, + }, + }, + { + environment: { CODEX_HOME: ambientHome }, + resolvePluginPython: async () => interpreter!, + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + createCodex: (options: CodexOptions) => ({ + startThread: () => ({ + id: null, + async runStreamed(input: string) { + const configPath = options.env?.["CODEX_SECURITY_CONFIG_PATH"]; + const codexHome = options.env?.["CODEX_HOME"]; + expect(typeof configPath).toBe("string"); + expect(typeof codexHome).toBe("string"); + capturedConfigPath = configPath; + capturedCodexHome = codexHome; + expect(configPath!.startsWith(`${codexHome!}/`)).toBe(false); + expect( + parseToml( + await readFile(join(codexHome!, "config.toml"), "utf8"), + ), + ).toMatchObject({ + approval_policy: "never", + permissions: { + codex_security_scan: { + filesystem: { + ":root": "read", + ":workspace_roots": "write", + [join(ambientHome, "state", "plugins", "codex-security")]: + "write", + [join( + ambientHome, + "state", + "plugins", + "codex-security", + "codex-home", + )]: "read", + }, + }, + }, + }); + const codexConfig = await readFile( + join(codexHome!, "config.toml"), + "utf8", + ); + expect(options.env?.["CODEX_SECURITY_SURFACE"]).toBe("sdk"); + expect(codexConfig).not.toContain("model_reasoning_summary"); + expect(codexConfig).not.toContain("show_raw_agent_reasoning"); + expect(options.config).not.toHaveProperty("projects"); + expect(options.config).not.toHaveProperty("permissions"); + expect(options.config).toMatchObject({ + default_permissions: "codex_security_scan", + allow_login_shell: false, + model_reasoning_summary: "detailed", + show_raw_agent_reasoning: true, + windows: { sandbox: "unelevated" }, + mcp_servers: { + private: { + command: "echo", + env: { PRIVATE_TOKEN: "RUNTIME_MCP_SECRET" }, + }, + }, + shell_environment_policy: { + set: { PRIVATE_TOKEN: "RUNTIME_SHELL_SECRET" }, + }, + responses_api_metadata: { + request_trace: "preserve-configured-metadata", + codex_security_surface: "sdk", + }, + }); + if (process.platform !== "win32") { + expect((await stat(configPath!)).mode & 0o777).toBe(0o600); + } + const serialized = await readFile(configPath!, "utf8"); + expect(serialized).not.toContain("RUNTIME_MCP_SECRET"); + expect(serialized).not.toContain("RUNTIME_SHELL_SECRET"); + expect(serialized).not.toContain("mcp_servers"); + expect(serialized).not.toContain("shell_environment_policy"); + expect(parseToml(serialized)).toMatchObject({ + projects: { + [repository]: { trust_level: "trusted" }, + }, + }); + expect(input).toContain( + `--config ${shellEnvironmentReference("CODEX_SECURITY_CONFIG_PATH")}`, + ); + expect(input).toContain("--effective-config"); + const shellEnvironment = options.env as Record; + const helper = execFileSync( + interpreter!, + [ + join(PLUGIN_ROOT, "scripts", "config_preflight.py"), + "--skill", + "security-scan", + "--config", + shellEnvironment["CODEX_SECURITY_CONFIG_PATH"]!, + "--cwd", + repository, + "--multi-agent-runtime-owner", + "native", + "--multi-agent-runtime-version", + "v2", + "--multi-agent-session-cap", + "12", + "--multi-agent-runtime-provenance", + "tool-surface", + "--runtime-check", + "delegation_available=true", + "--runtime-check", + "goal_tools_available=true", + "--effective-config", + "features.goals=true", + ], + { + env: { + PATH: process.env["PATH"], + CODEX_HOME: join(root, "denied"), + }, + encoding: "utf8", + }, + ); + const preflight = JSON.parse(helper) as Record; + expect(preflight["status"]).toBe("ready"); + expect(preflight["config_resolution"]).toBe("manual-layers"); + expect(preflight["config_paths"]).toEqual([configPath]); + await copyCompletedScan(root); + const manifestPath = join(scanDir, "scan-manifest.json"); + const manifest = JSON.parse( + await readFile(manifestPath, "utf8"), + ) as { scan: { producer: { version: string } } }; + const pluginManifest = JSON.parse( + await readFile( + join(PLUGIN_ROOT, ".codex-plugin", "plugin.json"), + "utf8", + ), + ) as { version: string }; + manifest.scan.producer.version = pluginManifest.version; + await writeFile(manifestPath, JSON.stringify(manifest)); + return { events: completedEvents() }; + }, + }), + }), + }, + ); + + try { + await client.run(repository); + expect(capturedConfigPath).toBeDefined(); + expect(capturedCodexHome).toBeDefined(); + } finally { + await client.close(); + } + expect(existsSync(capturedConfigPath!)).toBe(false); + expect(capturedCodexHome).toBe( + join(ambientHome, "state", "plugins", "codex-security", "codex-home"), + ); + expect(existsSync(capturedCodexHome!)).toBe(true); + }); + + test("reuses keyring-compatible credentials across separate scan clients", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const ambientHome = join(root, "ambient-codex-home"); + const stateDirectory = join(root, "state"); + const credentialHome = join(stateDirectory, "codex-home"); + const runtimeHomes: string[] = []; + await mkdir(repository); + await mkdir(ambientHome); + await writeFile(join(ambientHome, "auth.json"), "{}\n"); + + for (const index of [0, 1]) { + const scanDir = join(root, `scan-${index}`); + await mkdir(scanDir, { mode: 0o700 }); + const client = new TestClient( + { pluginPath: PLUGIN_ROOT }, + { + environment: { + CODEX_HOME: ambientHome, + CODEX_SECURITY_STATE_DIR: stateDirectory, + }, + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + createCodex: (options: CodexOptions) => { + runtimeHomes.push(options.env?.["CODEX_HOME"] ?? ""); + throw new Error("persistent credential scan reached"); + }, + }, + ); + + try { + await expect(client.run(repository)).rejects.toThrow( + "persistent credential scan reached", + ); + } finally { + await client.close(); + } + expect(existsSync(credentialHome)).toBe(true); + } + + expect(runtimeHomes).toEqual([credentialHome, credentialHome]); + }); + + test("runs parallel ChatGPT scans with isolated mutable configuration", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const ambientHome = join(root, "ambient-codex-home"); + const stateDirectory = join(root, "state"); + const credentialHome = join(stateDirectory, "codex-home"); + await mkdir(repository); + await mkdir(ambientHome); + await writeFile(join(ambientHome, "auth.json"), "{}\n"); + let activeScans = 0; + let maximumActiveScans = 0; + const deepScanConfigPaths = new Set(); + let releaseScans!: () => void; + const concurrentScans = new Promise((resolve) => { + releaseScans = resolve; + }); + + const clients = await Promise.all( + [0, 1].map(async (index) => { + const scanDir = join(root, `parallel-scan-${index}`); + await mkdir(scanDir, { mode: 0o700 }); + return new TestClient( + { + pluginPath: PLUGIN_ROOT, + codexOverrides: { + model: index === 0 ? "gpt-5.6-sol" : "gpt-5.6-terra", + }, + }, + { + environment: { + CODEX_HOME: ambientHome, + CODEX_SECURITY_STATE_DIR: stateDirectory, + }, + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + createCodex: (options: CodexOptions) => { + expect(options.env?.["CODEX_HOME"]).toBe(credentialHome); + const expectedModel = + index === 0 ? "gpt-5.6-sol" : "gpt-5.6-terra"; + expect(options.config?.["model"]).toBe(expectedModel); + const deepScanConfigPath = + options.env?.["CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH"]; + expect(typeof deepScanConfigPath).toBe("string"); + deepScanConfigPaths.add(deepScanConfigPath!); + return { + startThread: () => ({ + id: null, + async runStreamed() { + expect( + existsSync( + join(credentialHome, ".codex-security-scan.lock"), + ), + ).toBe(false); + activeScans += 1; + maximumActiveScans = Math.max( + maximumActiveScans, + activeScans, + ); + if (activeScans === 2) releaseScans(); + try { + const credentialConfig = parseToml( + await readFile( + join(credentialHome, "config.toml"), + "utf8", + ), + ); + expect(credentialConfig["model"]).toBeUndefined(); + const before = parseToml( + await readFile(deepScanConfigPath!, "utf8"), + ); + expect(before["deep_scan"]).toMatchObject({ + workers: index + 2, + }); + await concurrentScans; + const after = parseToml( + await readFile(deepScanConfigPath!, "utf8"), + ); + expect(after["deep_scan"]).toMatchObject({ + workers: index + 2, + }); + throw new Error("parallel managed scan reached"); + } finally { + activeScans -= 1; + } + }, + }), + }; + }, + }, + ); + }), + ); + + try { + const results = await Promise.allSettled( + clients.map((client, index) => + client + .run(repository, { mode: "deep", workers: index + 2 }) + .finally(releaseScans), + ), + ); + for (const result of results) { + expect(result).toMatchObject({ + status: "rejected", + reason: expect.objectContaining({ + message: "parallel managed scan reached", + }), + }); + } + expect(existsSync(credentialHome)).toBe(true); + expect(maximumActiveScans).toBe(2); + expect(deepScanConfigPaths.size).toBe(2); + const pluginConfiguration = JSON.parse( + await readFile(join(PLUGIN_ROOT, ".mcp.json"), "utf8"), + ) as { mcpServers: Record }; + expect( + pluginConfiguration.mcpServers["codex-security"]?.env_vars.includes( + "CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH", + ), + ).toBe(true); + } finally { + releaseScans(); + await Promise.all(clients.map(async (client) => await client.close())); + } + }); + + test("reuses the managed runtime when scan authentication changes", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const ambientHome = join(root, "ambient-codex-home"); + const stateDirectory = join(root, "state"); + const dedicatedHome = join(stateDirectory, "codex-home"); + const scanDir = join(root, "scan"); + const ambientAuthentication = '{"auth_mode":"chatgpt"}\n'; + await mkdir(repository); + await mkdir(ambientHome); + await mkdir(scanDir, { mode: 0o700 }); + await writeFile(join(ambientHome, "auth.json"), ambientAuthentication); + const runs: Array<{ home: string; apiKey?: string }> = []; + const client = new TestClient( + { pluginPath: PLUGIN_ROOT }, + { + environment: { + CODEX_HOME: ambientHome, + CODEX_SECURITY_STATE_DIR: stateDirectory, + OPENAI_API_KEY: "synthetic-transient-key", + }, + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + createCodex: (options: CodexOptions) => { + runs.push({ + home: options.env?.["CODEX_HOME"] ?? "", + ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }), + }); + throw new Error("authentication-selected scan reached"); + }, + }, + ); + + try { + await expect(client.run(repository, { auth: "api-key" })).rejects.toThrow( + "authentication-selected scan reached", + ); + expect(runs[0]?.home).toBe(dedicatedHome); + expect(runs[0]?.apiKey).toBe("synthetic-transient-key"); + expect(existsSync(join(dedicatedHome, "auth.json"))).toBe(false); + + await expect(client.run(repository, { auth: "chatgpt" })).rejects.toThrow( + "authentication-selected scan reached", + ); + expect(runs[1]).toEqual({ home: dedicatedHome }); + expect(await readFile(join(dedicatedHome, "auth.json"), "utf8")).toBe( + ambientAuthentication, + ); + await expect(client.run(repository, { auth: "api-key" })).rejects.toThrow( + "authentication-selected scan reached", + ); + expect(runs[2]?.home).toBe(dedicatedHome); + expect(runs[2]?.apiKey).toBe("synthetic-transient-key"); + expect(await readFile(join(dedicatedHome, "auth.json"), "utf8")).toBe( + ambientAuthentication, + ); + } finally { + await client.close(); + } + expect(existsSync(dedicatedHome)).toBe(true); + }); + + test("does not reimport ambient credentials after an explicit logout", async () => { + const root = await temporaryDirectory(); + const ambientHome = join(root, "ambient-home"); + const credentialHome = join(root, "credential-home"); + await mkdir(ambientHome); + await mkdir(credentialHome, { mode: 0o700 }); + await writeFile(join(ambientHome, "auth.json"), '{"token":"ambient"}\n'); + await setCodexSecurityCredentialLogout(credentialHome, true); + let imported = false; + + await expect( + initialCredentialsAvailable({}, ambientHome, credentialHome, async () => { + imported = true; + return true; + }), + ).resolves.toBe(false); + expect(imported).toBe(false); + + await setCodexSecurityCredentialLogout(credentialHome, false); + await expect( + initialCredentialsAvailable( + {}, + ambientHome, + credentialHome, + async () => true, + ), + ).resolves.toBe(true); + }); +}); diff --git a/sdk/typescript/tests-ts/api-post-scan.test.ts b/sdk/typescript/tests-ts/api-post-scan.test.ts index c60563558..1c17fe13b 100644 --- a/sdk/typescript/tests-ts/api-post-scan.test.ts +++ b/sdk/typescript/tests-ts/api-post-scan.test.ts @@ -3,7 +3,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import type { ThreadEvent } from "@openai/codex-sdk"; import { afterEach, describe, expect, test } from "bun:test"; -import { CodexSecurity } from "../src/index.js"; +import { TestClient } from "./support/api-client.js"; import { completedEvents, createApiTestFixtures, @@ -15,31 +15,6 @@ const { cleanup, copyCompletedScan, temporaryDirectory } = afterEach(cleanup); -const TestClient = CodexSecurity as unknown as new ( - config: Record, - dependencies: Record, -) => CodexSecurity; - -function runWorkbench(_options: unknown, args: readonly string[]) { - if (args[0] === "register-cli-scan") { - return Promise.resolve({ - scanId: "scan_example_001", - targetId: "target_sha256_example", - targetRevision: "deadbeef", - scanDir: args[args.indexOf("--scan-dir") + 1], - contract: { target: { allowedKinds: ["git_revision"] } }, - }); - } - if (args[0] === "get-scan-feedback") { - return Promise.resolve({ - scanId: "scan_example_001", - targetId: "target_sha256_example", - falsePositives: [], - }); - } - return Promise.resolve({}); -} - describe("completed scan follow-up instructions", () => { test.each([ ["missing report", "report.md", undefined], @@ -66,7 +41,6 @@ describe("completed scan follow-up instructions", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench, createCodex: () => ({ startThread: () => ({ id: "thread-1", diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 37896482a..4d4f58d76 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -3,7 +3,6 @@ import { copyFile, cp, mkdir, - mkdtemp, readFile, readdir, realpath, @@ -16,7 +15,6 @@ import * as fsPromises from "node:fs/promises"; import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; -import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { Codex, type CodexOptions, type ThreadEvent } from "@openai/codex-sdk"; @@ -45,16 +43,24 @@ import { type JsonObject, } from "../src/config.js"; import { estimateScanCost, type ScanCost } from "../src/cost.js"; -import { - resolveCodexCommand, - runWorkbench, - setCodexSecurityCredentialLogout, -} from "../src/runtime.js"; +import { resolveCodexCommand, runWorkbench } from "../src/runtime.js"; import { normalizeTarget } from "../src/targets.js"; import { SYNTHETIC_CREDENTIALS } from "./cli-fixtures.js"; import { INTEGRATION_TARGET, PLUGIN_ROOT } from "./plugin-root.js"; -import { preparedRuntime } from "./support/api-events.js"; -import { runMockInSubprocess } from "./support/isolated-mock.js"; +import { + mockScanRegistration, + mockWorkbench, + shellEnvironmentReference, + SHELL_ENVIRONMENT_PREFIX, + TestClient, + TEST_SNAPSHOT_DIGEST, +} from "./support/api-client.js"; +import { + completedEvents, + createApiTestFixtures, + preparedRuntime, +} from "./support/api-events.js"; +import { runTestInSubprocess } from "./support/test-subprocess.js"; type ScanObserverName = Parameters< NonNullable @@ -62,13 +68,9 @@ type ScanObserverName = Parameters< const REPOSITORY_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); const EXAMPLE = join(PLUGIN_ROOT, "examples", "completed-scan"); -const temporaryDirectories: string[] = []; -const TEST_SNAPSHOT_DIGEST = `codex-security-snapshot/v1:sha256:${"a".repeat(64)}`; -const SHELL_ENVIRONMENT_PREFIX = process.platform === "win32" ? "$env:" : "$"; - -function shellEnvironmentReference(name: string, suffix = ""): string { - return `"${SHELL_ENVIRONMENT_PREFIX}${name}${suffix}"`; -} +const { cleanup, copyCompletedScan, temporaryDirectory } = + createApiTestFixtures(); +afterEach(cleanup); const EXTERNAL_PROVIDER_CASES = [ [ @@ -130,79 +132,6 @@ const BEDROCK_AUTHENTICATION_CASES = [ ], ["default AWS credential chain", {}, "default_credential_chain"], ] as const; -const TestClientBase = CodexSecurity as unknown as new ( - config: Record, - dependencies: Record, -) => CodexSecurity; - -function mockScanRegistration(args: readonly string[]) { - const recipe = JSON.parse(args[args.indexOf("--recipe-json") + 1]!) as { - repositoryRevision?: string; - target: { kind: string }; - }; - const kind = - recipe.target.kind === "refs" || recipe.target.kind === "working_tree" - ? "git_diff" - : recipe.repositoryRevision === undefined - ? "directory_snapshot" - : "git_revision"; - - return { - scanId: "scan_example_001", - targetId: "target_sha256_example", - targetRevision: recipe.repositoryRevision ?? "unversioned", - scanDir: args[args.indexOf("--scan-dir") + 1], - contract: { - target: { - allowedKinds: [kind], - ...(kind === "directory_snapshot" - ? { requiredSnapshotDigest: TEST_SNAPSHOT_DIGEST } - : {}), - }, - }, - }; -} - -class TestClient extends TestClientBase { - public constructor( - config: Record, - dependencies: Record, - ) { - super(config, { - runWorkbench: async (_options: unknown, args: readonly string[]) => - mockWorkbench(args), - ...dependencies, - }); - } -} - -function mockWorkbench(args: readonly string[]) { - if (args[0] === "register-cli-scan") return mockScanRegistration(args); - if (args[0] === "get-scan-feedback") { - return { - scanId: "scan_example_001", - targetId: "target_sha256_example", - falsePositives: [], - }; - } - return {}; -} - -afterEach(async () => { - await Promise.all( - temporaryDirectories - .splice(0) - .map((path) => rm(path, { recursive: true, force: true })), - ); -}); - -async function temporaryDirectory(): Promise { - const path = await realpath( - await mkdtemp(join(tmpdir(), "codex-security-api-")), - ); - temporaryDirectories.push(path); - return path; -} function nodeCodex(script: string): { command: { command: string }; @@ -218,13 +147,6 @@ function nodeCodex(script: string): { }; } -async function copyCompletedScan(root: string): Promise { - const scanDir = join(root, "scan"); - await cp(EXAMPLE, scanDir, { recursive: true }); - await writeFile(join(scanDir, "report.md"), "# Scan report\n"); - return scanDir; -} - async function writeUsageSession( codexHome: string, threadId: string, @@ -257,25 +179,6 @@ async function writeUsageSession( ); } -async function* completedEvents(): AsyncGenerator { - yield { type: "thread.started", thread_id: "thread-1" }; - yield { type: "turn.started" }; - yield { - type: "item.completed", - item: { id: "message-1", type: "agent_message", text: "scan complete" }, - }; - yield { - type: "turn.completed", - usage: { - input_tokens: 10, - cached_input_tokens: 2, - cache_write_input_tokens: 0, - output_tokens: 3, - reasoning_output_tokens: 1, - }, - }; -} - describe("CodexSecurity orchestration", () => { test("distinguishes local workbench and database errors from model transport failures", () => { for (const message of [ @@ -322,7 +225,10 @@ describe("CodexSecurity orchestration", () => { prepareRuntime: async () => preparedRuntime(codexHome), resolvePluginPython: async () => "/managed/python", repositoryRevision: async () => null, - runWorkbench: async (_options: unknown, args: readonly string[]) => { + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => { if (args[0] === "register-cli-scan") { recipe = JSON.parse(args[args.indexOf("--recipe-json") + 1]!); } @@ -672,12 +578,13 @@ describe("CodexSecurity orchestration", () => { const repository = join(root, "repository"); await mkdir(repository); - for (const codexOverrides of [ + const invalidSettings: JsonObject[] = [ { model: "" }, { model: 42 }, { model_reasoning_effort: "" }, { model_reasoning_effort: false }, - ]) { + ]; + for (const codexOverrides of invalidSettings) { const client = new TestClient({ codexOverrides }, { environment: {} }); await expect(client.preflight(repository)).rejects.toThrow( /model|reasoning effort/u, @@ -822,10 +729,8 @@ describe("CodexSecurity orchestration", () => { codex_api_key: "synthetic-forwarded-codex-key", }, }), - resolvePluginPython: async (options: { - environment?: Record; - }) => { - pythonEnvironment = options.environment; + resolvePluginPython: async (options) => { + pythonEnvironment = options?.environment; return "/managed/python"; }, prepareOutputDir: async () => scanDir, @@ -1139,7 +1044,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => { + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => { if (args[0] === "register-cli-scan") { savedRecipe = JSON.parse(args[args.indexOf("--recipe-json") + 1]!); } @@ -1426,7 +1334,10 @@ describe("CodexSecurity orchestration", () => { prepareRuntime: async () => preparedRuntime(codexHome), resolvePluginPython: async () => "/managed/python", repositoryRevision: async () => null, - runWorkbench: async (_options: unknown, args: readonly string[]) => { + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => { if (args[0] === "get-scan-feedback") { return { scanId: "scan_example_001", @@ -1784,6 +1695,13 @@ describe("CodexSecurity orchestration", () => { }); test("keeps a relative repository stable if runtime initialization changes cwd", async () => { + if ( + runTestInSubprocess( + fileURLToPath(import.meta.url), + "keeps a relative repository stable if runtime initialization changes cwd", + ) + ) + return; const root = await temporaryDirectory(); const initial = join(root, "initial"); const elsewhere = join(root, "elsewhere"); @@ -1869,7 +1787,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => { + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => { commands.push(args); if (args[0] === "register-cli-scan") { return mockScanRegistration(args); @@ -2077,7 +1998,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => { + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => { if (args[0] === "register-cli-scan") { return { ...mockScanRegistration(args), @@ -2157,7 +2081,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => { + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => { if (args[0] !== "register-cli-scan") { return { scanId: "scan_example_001", @@ -2353,7 +2280,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => args[0] === "register-cli-scan" ? { ...mockScanRegistration(args), @@ -2390,7 +2320,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => { + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => { commands.push(args[0]!); if (args[0] === "register-cli-scan") { return mockScanRegistration(args); @@ -2454,7 +2387,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => { + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => { commands.push(args[0]!); return mockWorkbench(args); }, @@ -2506,7 +2442,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => args[0] === "register-cli-scan" ? { ...mockScanRegistration(args), scopeFileCount: 4_207 } : mockWorkbench(args), @@ -2582,7 +2521,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => args[0] === "register-cli-scan" ? { ...mockScanRegistration(args), scopeFileCount: 1_258 } : mockWorkbench(args), @@ -2704,7 +2646,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => { + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => { commands.push(args); if (args[0] === "register-cli-scan") { return mockScanRegistration(args); @@ -2803,7 +2748,7 @@ describe("CodexSecurity orchestration", () => { prepareRuntime: async () => ({ ...runtime, plugin: { - ...(runtime["plugin"] as Record), + ...runtime.plugin, pluginRoot, marketplaceRoot: pluginRoot, installedRoot: pluginRoot, @@ -2812,7 +2757,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => { + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => { commands.push(args[0]!); return args[0] === "register-cli-scan" ? mockScanRegistration(args) @@ -2853,7 +2801,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => { + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => { if (args[0] === "register-cli-scan") { return { ...mockScanRegistration(args), scanId }; } @@ -2949,7 +2900,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => { + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => { commands.push(args); if (args[0] === "get-scan-feedback") { return { @@ -3078,7 +3032,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => { + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => { if (args[0] === "register-cli-scan") { return mockScanRegistration(args); } @@ -3139,7 +3096,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => { + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => { commands.push(args); if (args[0] === "register-cli-scan") { return mockScanRegistration(args); @@ -3205,7 +3165,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => { + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => { commands.push(args); return mockWorkbench(args); }, @@ -3372,7 +3335,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => { + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => { commands.push(args); if (args[0] === "register-cli-scan") { return mockScanRegistration(args); @@ -3501,7 +3467,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => { + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => { commands.push(args); if (args[0] !== "complete-budget-exhausted-scan") { return mockWorkbench(args); @@ -3533,7 +3502,7 @@ describe("CodexSecurity orchestration", () => { .digest("hex"); await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); return { - scan: { warnings: [args[args.indexOf("--message") + 1]] }, + scan: { warnings: [args[args.indexOf("--message") + 1]!] }, }; }, createCodex: () => ({ @@ -3633,7 +3602,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => { + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => { commands.push(args); if (args[0] === "register-cli-scan") { return mockScanRegistration(args); @@ -3717,7 +3689,10 @@ describe("CodexSecurity orchestration", () => { resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", - runWorkbench: async (_options: unknown, args: readonly string[]) => { + runWorkbench: async ( + _options: unknown, + args: readonly string[], + ): Promise => { if (args[0] === "get-scan-feedback") { return { scanId: "scan_example_001", @@ -3822,7 +3797,7 @@ describe("CodexSecurity orchestration", () => { const python = Bun.which("python3") ?? Bun.which("python"); expect(python).not.toBeNull(); const environment = { - PATH: process.env["PATH"], + PATH: process.env["PATH"] ?? "", CODEX_SECURITY_STATE_DIR: stateDirectory, }; const commands: Array = []; @@ -3840,7 +3815,7 @@ describe("CodexSecurity orchestration", () => { runWorkbench: async ( options: Parameters[0], args: readonly string[], - ) => { + ): Promise => { commands.push(args); const result = await runWorkbench(options, args); if (args[0] === "fail-scan") { @@ -3895,7 +3870,7 @@ describe("CodexSecurity orchestration", () => { const python = Bun.which("python3") ?? Bun.which("python"); expect(python).not.toBeNull(); const environment = { - PATH: process.env["PATH"], + PATH: process.env["PATH"] ?? "", CODEX_SECURITY_STATE_DIR: stateDirectory, }; const commands: Array = []; @@ -3918,7 +3893,7 @@ describe("CodexSecurity orchestration", () => { runWorkbench: async ( options: Parameters[0], args: readonly string[], - ) => { + ): Promise => { commands.push(args); return await runWorkbench(options, args); }, @@ -4068,252 +4043,6 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); - test("keeps a private preflight snapshot isolated from persistent credentials", async () => { - const root = await temporaryDirectory(); - const repository = join(root, "repository"); - const ambientHome = join(root, "ambient-codex-home"); - const scanDir = join(root, "scan"); - await mkdir(repository); - await mkdir(ambientHome); - await mkdir(scanDir, { mode: 0o700 }); - await writeFile(join(ambientHome, "auth.json"), "{}\n"); - const interpreter = - Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); - expect(interpreter).not.toBeNull(); - let capturedConfigPath: string | undefined; - let capturedCodexHome: string | undefined; - const unrelatedProjects = Object.fromEntries( - Array.from({ length: 256 }, (_, index) => [ - join(root, `unrelated-project-${index}`), - { trust_level: "untrusted" }, - ]), - ); - const client = new TestClient( - { - pluginPath: PLUGIN_ROOT, - codexOverrides: { - approval_policy: "never", - features: { goals: true }, - projects: { - ...unrelatedProjects, - [repository]: { trust_level: "trusted" }, - }, - mcp_servers: { - private: { - command: "echo", - env: { PRIVATE_TOKEN: "RUNTIME_MCP_SECRET" }, - }, - }, - shell_environment_policy: { - set: { PRIVATE_TOKEN: "RUNTIME_SHELL_SECRET" }, - }, - responses_api_metadata: { - request_trace: "preserve-configured-metadata", - }, - }, - }, - { - environment: { CODEX_HOME: ambientHome }, - resolvePluginPython: async () => interpreter!, - prepareOutputDir: async () => scanDir, - repositoryRevision: async () => "deadbeef", - createCodex: (options: CodexOptions) => ({ - startThread: () => ({ - id: null, - async runStreamed(input: string) { - const configPath = options.env?.["CODEX_SECURITY_CONFIG_PATH"]; - const codexHome = options.env?.["CODEX_HOME"]; - expect(typeof configPath).toBe("string"); - expect(typeof codexHome).toBe("string"); - capturedConfigPath = configPath; - capturedCodexHome = codexHome; - expect(configPath!.startsWith(`${codexHome!}/`)).toBe(false); - expect( - parseToml( - await readFile(join(codexHome!, "config.toml"), "utf8"), - ), - ).toMatchObject({ - approval_policy: "never", - permissions: { - codex_security_scan: { - filesystem: { - ":root": "read", - ":workspace_roots": "write", - [join(ambientHome, "state", "plugins", "codex-security")]: - "write", - [join( - ambientHome, - "state", - "plugins", - "codex-security", - "codex-home", - )]: "read", - }, - }, - }, - }); - const codexConfig = await readFile( - join(codexHome!, "config.toml"), - "utf8", - ); - expect(options.env?.["CODEX_SECURITY_SURFACE"]).toBe("sdk"); - expect(codexConfig).not.toContain("model_reasoning_summary"); - expect(codexConfig).not.toContain("show_raw_agent_reasoning"); - expect(options.config).not.toHaveProperty("projects"); - expect(options.config).not.toHaveProperty("permissions"); - expect(options.config).toMatchObject({ - default_permissions: "codex_security_scan", - allow_login_shell: false, - model_reasoning_summary: "detailed", - show_raw_agent_reasoning: true, - windows: { sandbox: "unelevated" }, - mcp_servers: { - private: { - command: "echo", - env: { PRIVATE_TOKEN: "RUNTIME_MCP_SECRET" }, - }, - }, - shell_environment_policy: { - set: { PRIVATE_TOKEN: "RUNTIME_SHELL_SECRET" }, - }, - responses_api_metadata: { - request_trace: "preserve-configured-metadata", - codex_security_surface: "sdk", - }, - }); - if (process.platform !== "win32") { - expect((await stat(configPath!)).mode & 0o777).toBe(0o600); - } - const serialized = await readFile(configPath!, "utf8"); - expect(serialized).not.toContain("RUNTIME_MCP_SECRET"); - expect(serialized).not.toContain("RUNTIME_SHELL_SECRET"); - expect(serialized).not.toContain("mcp_servers"); - expect(serialized).not.toContain("shell_environment_policy"); - expect(parseToml(serialized)).toMatchObject({ - projects: { - [repository]: { trust_level: "trusted" }, - }, - }); - expect(input).toContain( - `--config ${shellEnvironmentReference("CODEX_SECURITY_CONFIG_PATH")}`, - ); - expect(input).toContain("--effective-config"); - const shellEnvironment = options.env as Record; - const helper = execFileSync( - interpreter!, - [ - join(PLUGIN_ROOT, "scripts", "config_preflight.py"), - "--skill", - "security-scan", - "--config", - shellEnvironment["CODEX_SECURITY_CONFIG_PATH"]!, - "--cwd", - repository, - "--multi-agent-runtime-owner", - "native", - "--multi-agent-runtime-version", - "v2", - "--multi-agent-session-cap", - "12", - "--multi-agent-runtime-provenance", - "tool-surface", - "--runtime-check", - "delegation_available=true", - "--runtime-check", - "goal_tools_available=true", - "--effective-config", - "features.goals=true", - ], - { - env: { - PATH: process.env["PATH"], - CODEX_HOME: join(root, "denied"), - }, - encoding: "utf8", - }, - ); - const preflight = JSON.parse(helper) as Record; - expect(preflight["status"]).toBe("ready"); - expect(preflight["config_resolution"]).toBe("manual-layers"); - expect(preflight["config_paths"]).toEqual([configPath]); - await copyCompletedScan(root); - const manifestPath = join(scanDir, "scan-manifest.json"); - const manifest = JSON.parse( - await readFile(manifestPath, "utf8"), - ) as { scan: { producer: { version: string } } }; - const pluginManifest = JSON.parse( - await readFile( - join(PLUGIN_ROOT, ".codex-plugin", "plugin.json"), - "utf8", - ), - ) as { version: string }; - manifest.scan.producer.version = pluginManifest.version; - await writeFile(manifestPath, JSON.stringify(manifest)); - return { events: completedEvents() }; - }, - }), - }), - }, - ); - - try { - await client.run(repository); - expect(capturedConfigPath).toBeDefined(); - expect(capturedCodexHome).toBeDefined(); - } finally { - await client.close(); - } - expect(existsSync(capturedConfigPath!)).toBe(false); - expect(capturedCodexHome).toBe( - join(ambientHome, "state", "plugins", "codex-security", "codex-home"), - ); - expect(existsSync(capturedCodexHome!)).toBe(true); - }); - - test("reuses keyring-compatible credentials across separate scan clients", async () => { - const root = await temporaryDirectory(); - const repository = join(root, "repository"); - const ambientHome = join(root, "ambient-codex-home"); - const stateDirectory = join(root, "state"); - const credentialHome = join(stateDirectory, "codex-home"); - const runtimeHomes: string[] = []; - await mkdir(repository); - await mkdir(ambientHome); - await writeFile(join(ambientHome, "auth.json"), "{}\n"); - - for (const index of [0, 1]) { - const scanDir = join(root, `scan-${index}`); - await mkdir(scanDir, { mode: 0o700 }); - const client = new TestClient( - { pluginPath: PLUGIN_ROOT }, - { - environment: { - CODEX_HOME: ambientHome, - CODEX_SECURITY_STATE_DIR: stateDirectory, - }, - resolvePluginPython: async () => "/managed/python", - prepareOutputDir: async () => scanDir, - repositoryRevision: async () => "deadbeef", - createCodex: (options: CodexOptions) => { - runtimeHomes.push(options.env?.["CODEX_HOME"] ?? ""); - throw new Error("persistent credential scan reached"); - }, - }, - ); - - try { - await expect(client.run(repository)).rejects.toThrow( - "persistent credential scan reached", - ); - } finally { - await client.close(); - } - expect(existsSync(credentialHome)).toBe(true); - } - - expect(runtimeHomes).toEqual([credentialHome, credentialHome]); - }); - test.each([ ["OpenAI", undefined, "OPENAI_API_KEY", "gpt-5.6-sol", undefined], ...EXTERNAL_PROVIDER_CASES, @@ -4346,7 +4075,7 @@ describe("CodexSecurity orchestration", () => { ? {} : { model_provider: provider, - model_providers: { [provider]: providerConfig }, + model_providers: { [provider]: providerConfig! }, }), }, }, @@ -4428,10 +4157,12 @@ describe("CodexSecurity orchestration", () => { }); const clients = await Promise.all( - [ - ["OPENAI_API_KEY", "gpt-5.6-sol", undefined], - ["OPENROUTER_API_KEY", "anthropic/claude-sonnet-4.5", "openrouter"], - ].map(async ([apiKey, model, provider], index) => { + ( + [ + ["OPENAI_API_KEY", "gpt-5.6-sol", undefined], + ["OPENROUTER_API_KEY", "anthropic/claude-sonnet-4.5", "openrouter"], + ] as const + ).map(async ([apiKey, model, provider], index) => { const scanDir = join(root, `parallel-api-key-scan-${index}`); await mkdir(scanDir, { mode: 0o700 }); return new TestClient( @@ -4504,133 +4235,6 @@ describe("CodexSecurity orchestration", () => { } }); - test("runs parallel ChatGPT scans with isolated mutable configuration", async () => { - const root = await temporaryDirectory(); - const repository = join(root, "repository"); - const ambientHome = join(root, "ambient-codex-home"); - const stateDirectory = join(root, "state"); - const credentialHome = join(stateDirectory, "codex-home"); - await mkdir(repository); - await mkdir(ambientHome); - await writeFile(join(ambientHome, "auth.json"), "{}\n"); - let activeScans = 0; - let maximumActiveScans = 0; - const deepScanConfigPaths = new Set(); - let releaseScans!: () => void; - const concurrentScans = new Promise((resolve) => { - releaseScans = resolve; - }); - - const clients = await Promise.all( - [0, 1].map(async (index) => { - const scanDir = join(root, `parallel-scan-${index}`); - await mkdir(scanDir, { mode: 0o700 }); - return new TestClient( - { - pluginPath: PLUGIN_ROOT, - codexOverrides: { - model: index === 0 ? "gpt-5.6-sol" : "gpt-5.6-terra", - }, - }, - { - environment: { - CODEX_HOME: ambientHome, - CODEX_SECURITY_STATE_DIR: stateDirectory, - }, - resolvePluginPython: async () => "/managed/python", - prepareOutputDir: async () => scanDir, - repositoryRevision: async () => "deadbeef", - createCodex: (options: CodexOptions) => { - expect(options.env?.["CODEX_HOME"]).toBe(credentialHome); - const expectedModel = - index === 0 ? "gpt-5.6-sol" : "gpt-5.6-terra"; - expect(options.config?.["model"]).toBe(expectedModel); - const deepScanConfigPath = - options.env?.["CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH"]; - expect(typeof deepScanConfigPath).toBe("string"); - deepScanConfigPaths.add(deepScanConfigPath!); - return { - startThread: () => ({ - id: null, - async runStreamed() { - expect( - existsSync( - join(credentialHome, ".codex-security-scan.lock"), - ), - ).toBe(false); - activeScans += 1; - maximumActiveScans = Math.max( - maximumActiveScans, - activeScans, - ); - if (activeScans === 2) releaseScans(); - try { - const credentialConfig = parseToml( - await readFile( - join(credentialHome, "config.toml"), - "utf8", - ), - ); - expect(credentialConfig["model"]).toBeUndefined(); - const before = parseToml( - await readFile(deepScanConfigPath!, "utf8"), - ); - expect(before["deep_scan"]).toMatchObject({ - workers: index + 2, - }); - await Promise.race([ - concurrentScans, - new Promise((resolve) => setTimeout(resolve, 5_000)), - ]); - const after = parseToml( - await readFile(deepScanConfigPath!, "utf8"), - ); - expect(after["deep_scan"]).toMatchObject({ - workers: index + 2, - }); - throw new Error("parallel managed scan reached"); - } finally { - activeScans -= 1; - } - }, - }), - }; - }, - }, - ); - }), - ); - - try { - const results = await Promise.allSettled( - clients.map((client, index) => - client.run(repository, { mode: "deep", workers: index + 2 }), - ), - ); - for (const result of results) { - expect(result).toMatchObject({ - status: "rejected", - reason: expect.objectContaining({ - message: "parallel managed scan reached", - }), - }); - } - expect(existsSync(credentialHome)).toBe(true); - expect(maximumActiveScans).toBe(2); - expect(deepScanConfigPaths.size).toBe(2); - const pluginConfiguration = JSON.parse( - await readFile(join(PLUGIN_ROOT, ".mcp.json"), "utf8"), - ) as { mcpServers: Record }; - expect( - pluginConfiguration.mcpServers["codex-security"]?.env_vars.includes( - "CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH", - ), - ).toBe(true); - } finally { - await Promise.all(clients.map(async (client) => await client.close())); - } - }); - test("keeps legacy custom-plugin Deep Scan settings under the credential lock", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -4715,10 +4319,7 @@ describe("CodexSecurity orchestration", () => { prepareRuntime: async () => ({ ...preparedRuntime(codexHome), plugin: { - ...(preparedRuntime(codexHome)["plugin"] as Record< - string, - unknown - >), + ...preparedRuntime(codexHome).plugin, pluginRoot, installedRoot: pluginRoot, }, @@ -4831,7 +4432,7 @@ describe("CodexSecurity orchestration", () => { return { ...runtime, plugin: { - ...(runtime["plugin"] as Record), + ...runtime.plugin, installedRoot: join( codexHome, "plugins", @@ -4955,7 +4556,7 @@ describe("CodexSecurity orchestration", () => { { cwd: root, env: { - PATH: process.env["PATH"], + PATH: process.env["PATH"] ?? "", HOME: process.env["HOME"], ...environment, CODEX_SECURITY_TARGET_PATHS_FILE: capturedTargetPathsFile, @@ -5539,12 +5140,9 @@ process.exit(2); credentialsAvailable: false, }), resolveCodexCommand: () => fakeCommand.command, - resolvePluginPython: async (options: { - environment?: Record; - protectedRoot?: string; - }) => { - pythonEnvironment = options.environment; - pythonProtectedRoot = options.protectedRoot; + resolvePluginPython: async (options) => { + pythonEnvironment = options?.environment; + pythonProtectedRoot = options?.protectedRoot; return "/managed/python"; }, prepareOutputDir: async () => scanDir, @@ -5649,69 +5247,6 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s } }); - test("reuses the managed runtime when scan authentication changes", async () => { - const root = await temporaryDirectory(); - const repository = join(root, "repository"); - const ambientHome = join(root, "ambient-codex-home"); - const stateDirectory = join(root, "state"); - const dedicatedHome = join(stateDirectory, "codex-home"); - const scanDir = join(root, "scan"); - const ambientAuthentication = '{"auth_mode":"chatgpt"}\n'; - await mkdir(repository); - await mkdir(ambientHome); - await mkdir(scanDir, { mode: 0o700 }); - await writeFile(join(ambientHome, "auth.json"), ambientAuthentication); - const runs: Array<{ home: string; apiKey?: string }> = []; - const client = new TestClient( - { pluginPath: PLUGIN_ROOT }, - { - environment: { - CODEX_HOME: ambientHome, - CODEX_SECURITY_STATE_DIR: stateDirectory, - OPENAI_API_KEY: "synthetic-transient-key", - }, - resolvePluginPython: async () => "/managed/python", - prepareOutputDir: async () => scanDir, - repositoryRevision: async () => "deadbeef", - createCodex: (options: CodexOptions) => { - runs.push({ - home: options.env?.["CODEX_HOME"] ?? "", - ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }), - }); - throw new Error("authentication-selected scan reached"); - }, - }, - ); - - try { - await expect(client.run(repository, { auth: "api-key" })).rejects.toThrow( - "authentication-selected scan reached", - ); - expect(runs[0]?.home).toBe(dedicatedHome); - expect(runs[0]?.apiKey).toBe("synthetic-transient-key"); - expect(existsSync(join(dedicatedHome, "auth.json"))).toBe(false); - - await expect(client.run(repository, { auth: "chatgpt" })).rejects.toThrow( - "authentication-selected scan reached", - ); - expect(runs[1]).toEqual({ home: dedicatedHome }); - expect(await readFile(join(dedicatedHome, "auth.json"), "utf8")).toBe( - ambientAuthentication, - ); - await expect(client.run(repository, { auth: "api-key" })).rejects.toThrow( - "authentication-selected scan reached", - ); - expect(runs[2]?.home).toBe(dedicatedHome); - expect(runs[2]?.apiKey).toBe("synthetic-transient-key"); - expect(await readFile(join(dedicatedHome, "auth.json"), "utf8")).toBe( - ambientAuthentication, - ); - } finally { - await client.close(); - } - expect(existsSync(dedicatedHome)).toBe(true); - }); - test("does not cache an environment key as reusable file authentication", async () => { let imported = false; await expect( @@ -5766,35 +5301,6 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s ); }); - test("does not reimport ambient credentials after an explicit logout", async () => { - const root = await temporaryDirectory(); - const ambientHome = join(root, "ambient-home"); - const credentialHome = join(root, "credential-home"); - await mkdir(ambientHome); - await mkdir(credentialHome, { mode: 0o700 }); - await writeFile(join(ambientHome, "auth.json"), '{"token":"ambient"}\n'); - await setCodexSecurityCredentialLogout(credentialHome, true); - let imported = false; - - await expect( - initialCredentialsAvailable({}, ambientHome, credentialHome, async () => { - imported = true; - return true; - }), - ).resolves.toBe(false); - expect(imported).toBe(false); - - await setCodexSecurityCredentialLogout(credentialHome, false); - await expect( - initialCredentialsAvailable( - {}, - ambientHome, - credentialHome, - async () => true, - ), - ).resolves.toBe(true); - }); - test("restores stored ChatGPT credentials when switching from an API-key scan", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -5956,14 +5462,16 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s const codexHome = join(root, "codex-home"); await mkdir(repository); await mkdir(codexHome); - let releaseRuntime!: (runtime: Record) => void; + let releaseRuntime!: (runtime: ReturnType) => void; let preparationStarted!: () => void; const started = new Promise((resolve) => { preparationStarted = resolve; }); - const prepared = new Promise>((resolve) => { - releaseRuntime = resolve; - }); + const prepared = new Promise>( + (resolve) => { + releaseRuntime = resolve; + }, + ); let createCodexCalled = false; const client = new TestClient( {}, @@ -6008,14 +5516,16 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s await mkdir(repository); await mkdir(codexHome); await mkdir(scanDir, { mode: 0o700 }); - let releaseRuntime!: (runtime: Record) => void; + let releaseRuntime!: (runtime: ReturnType) => void; let preparationStarted!: () => void; const started = new Promise((resolve) => { preparationStarted = resolve; }); - const prepared = new Promise>((resolve) => { - releaseRuntime = resolve; - }); + const prepared = new Promise>( + (resolve) => { + releaseRuntime = resolve; + }, + ); const client = new TestClient( {}, { @@ -6216,7 +5726,7 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s test("cleans the bootstrap workspace when credential-home cleanup fails", async () => { if ( - runMockInSubprocess( + runTestInSubprocess( import.meta.path, "cleans the bootstrap workspace when credential-home cleanup fails", ) @@ -6279,7 +5789,7 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s test("attempts both preparation cleanups and preserves the preparation and cleanup failures", async () => { if ( - runMockInSubprocess( + runTestInSubprocess( import.meta.path, "attempts both preparation cleanups and preserves the preparation and cleanup failures", ) diff --git a/sdk/typescript/tests-ts/multiscan.test.ts b/sdk/typescript/tests-ts/multiscan.test.ts index 33676192a..2c62c0422 100644 --- a/sdk/typescript/tests-ts/multiscan.test.ts +++ b/sdk/typescript/tests-ts/multiscan.test.ts @@ -18,6 +18,7 @@ import { import * as filesystem from "node:fs/promises"; import { hostname, tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { main } from "../src/cli.js"; import { ScanCostLimitExceededError } from "../src/errors.js"; @@ -25,6 +26,7 @@ import type { ScanResult } from "../src/result.js"; import { buildGitHubCredentialArgs, runMultiscan } from "../src/multiscan.js"; import { resolveTrustedExecutable } from "../src/trusted-executable.js"; import { capture, dependencies, fakeResult } from "./cli-fixtures.js"; +import { runTestInSubprocess } from "./support/test-subprocess.js"; type MultiscanOptions = Parameters[0]; type SecurityClient = ReturnType; @@ -1540,6 +1542,13 @@ describe("multiscan", () => { }); test("ignores repository-local Git shims while preserving credential configuration", async () => { + if ( + runTestInSubprocess( + fileURLToPath(import.meta.url), + "ignores repository-local Git shims while preserving credential configuration", + ) + ) + return; const paths = await fixture(); const source = await repository(paths.root, "private"); await writeFile( diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 16280e70e..37d3d8a6e 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -27,7 +27,6 @@ import { relative, sep, } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { brotliDecompressSync } from "node:zlib"; import { afterEach, describe, expect, mock, test } from "bun:test"; @@ -74,7 +73,7 @@ import { verifyStableWindowsCredentialDescendants, } from "../src/runtime.js"; import { loadBundledRuntime, PLUGIN_ROOT } from "./plugin-root.js"; -import { runMockInSubprocess } from "./support/isolated-mock.js"; +import { runTestInSubprocess } from "./support/test-subprocess.js"; const temporaryDirectories: string[] = []; const testPosix = process.platform === "win32" ? test.skip : test; @@ -890,7 +889,7 @@ describe("plugin runtime preparation", () => { test("cancels configured plugin directory discovery", async () => { if ( - runMockInSubprocess( + runTestInSubprocess( import.meta.path, "cancels configured plugin directory discovery", ) @@ -1014,7 +1013,7 @@ describe("plugin runtime preparation", () => { "rejects a queued plugin directory replaced with a symlink", async () => { if ( - runMockInSubprocess( + runTestInSubprocess( import.meta.path, "rejects a queued plugin directory replaced with a symlink", ) @@ -1327,7 +1326,7 @@ describe("plugin runtime preparation", () => { test("imports ambient auth when credential files do not support hard links", async () => { if ( - runMockInSubprocess( + runTestInSubprocess( import.meta.path, "imports ambient auth when credential files do not support hard links", ) @@ -2259,7 +2258,7 @@ describe("runtime directories and plugin Python boundary", () => { test("retries Windows credential verification when a descendant disappears", async () => { if ( - runMockInSubprocess( + runTestInSubprocess( import.meta.path, "retries Windows credential verification when a descendant disappears", ) @@ -2304,7 +2303,7 @@ describe("runtime directories and plugin Python boundary", () => { test("rejects Windows credential descendants that repeatedly disappear", async () => { if ( - runMockInSubprocess( + runTestInSubprocess( import.meta.path, "rejects Windows credential descendants that repeatedly disappear", ) @@ -3362,193 +3361,6 @@ describe("runtime directories and plugin Python boundary", () => { }, ); - test.skipIf( - process.platform !== "win32" || - process.env["GITHUB_ACTIONS"] !== "true" || - process.env["RUNNER_ENVIRONMENT"] !== "github-hosted" || - process.env["CODEX_SECURITY_ALLOW_MACHINE_POLICY_TEST"] !== "true", - )( - "prepares managed credential homes under constrained PowerShell", - async () => { - const root = await temporaryDirectory(); - const powershell = join( - process.env["SystemRoot"] ?? "C:\\Windows", - "System32", - "WindowsPowerShell", - "v1.0", - "powershell.exe", - ); - const constrainedEnvironment = { - ...process.env, - CODEX_SECURITY_STATE_DIR: join(root, "state"), - PSModulePath: join(root, "untrusted-or-incompatible-modules"), - PSMODULEPATH: join(root, "uppercase-untrusted-modules"), - }; - const registry = join(dirname(dirname(dirname(powershell))), "reg.exe"); - const policyKey = - "HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment"; - const policyName = "__PSLockdownPolicy"; - const original = spawnSync( - registry, - ["query", policyKey, "/v", policyName], - { encoding: "utf8", timeout: 15_000, windowsHide: true }, - ); - expect(original.status === 0 || original.status === 1).toBe(true); - const originalEntry = - original.status === 0 - ? /^\s*__PSLockdownPolicy\s+(REG_[A-Z_]+)\s*(.*?)\s*$/mu.exec( - original.stdout, - ) - : null; - if (original.status === 0) expect(originalEntry).not.toBeNull(); - const originalPolicy = - originalEntry === null - ? null - : { type: originalEntry[1]!, value: originalEntry[2]! }; - const enabled = spawnSync( - registry, - ["add", policyKey, "/v", policyName, "/t", "REG_SZ", "/d", "4", "/f"], - { encoding: "utf8", timeout: 15_000, windowsHide: true }, - ); - expect(enabled.status).toBe(0); - - try { - const mode = spawnSync( - powershell, - [ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-Command", - "$ExecutionContext.SessionState.LanguageMode", - ], - { - encoding: "utf8", - env: constrainedEnvironment, - timeout: 15_000, - windowsHide: true, - }, - ); - expect(mode.status).toBe(0); - expect(mode.stdout.trim()).toBe("ConstrainedLanguage"); - - const oldImplementation = spawnSync( - powershell, - [ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-Command", - "$ErrorActionPreference = 'Stop'; New-Object System.Security.AccessControl.DirectorySecurity", - ], - { - encoding: "utf8", - env: constrainedEnvironment, - timeout: 15_000, - windowsHide: true, - }, - ); - expect(oldImplementation.status).not.toBe(0); - - const trustedPowerShellEnvironment = { - ...Object.fromEntries( - Object.entries(process.env).filter( - ([name]) => name.toUpperCase() !== "PSMODULEPATH", - ), - ), - PSModulePath: join(dirname(powershell), "Modules"), - }; - const guest = spawnSync( - powershell, - [ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-Command", - [ - "Microsoft.PowerShell.Utility\\ConvertFrom-SddlString -Sddl 'O:LGG:SYD:(A;;GA;;;SY)'", - "Microsoft.PowerShell.Utility\\Select-Object -ExpandProperty RawDescriptor", - "Microsoft.PowerShell.Utility\\Select-Object -ExpandProperty Owner", - "Microsoft.PowerShell.Utility\\Select-Object -ExpandProperty Value", - ].join(" | "), - ], - { - encoding: "utf8", - env: trustedPowerShellEnvironment, - timeout: 15_000, - windowsHide: true, - }, - ); - expect(guest.status).toBe(0); - expect(guest.stdout.trim()).toMatch(/^S-1-(?:\d+-)*501$/u); - const home = join(root, "state", "codex-home"); - await mkdir(home, { recursive: true }); - const foreignGrant = spawnSync( - join(dirname(dirname(dirname(powershell))), "icacls.exe"), - [home, "/grant", `*${guest.stdout.trim()}:(OI)(CI)R`], - { encoding: "utf8", timeout: 15_000, windowsHide: true }, - ); - expect(foreignGrant.status).toBe(0); - - const fixtureModule = join(root, "runtime-node-fixture.mjs"); - const build = spawnSync( - process.execPath, - [ - "build", - fileURLToPath(new URL("../src/runtime.ts", import.meta.url)), - "--target=node", - "--format=esm", - `--outfile=${fixtureModule}`, - ], - { encoding: "utf8", timeout: 30_000, windowsHide: true }, - ); - expect(build.status).toBe(0); - const selectedNode = spawnSync("node", ["-p", "process.execPath"], { - encoding: "utf8", - timeout: 15_000, - windowsHide: true, - }); - expect(selectedNode.status).toBe(0); - const fixture = spawnSync( - selectedNode.stdout.trim(), - [ - "--input-type=module", - "--eval", - `import { prepareCodexSecurityCredentialHome } from ${JSON.stringify(pathToFileURL(fixtureModule).href)}; await prepareCodexSecurityCredentialHome();`, - ], - { - encoding: "utf8", - env: constrainedEnvironment, - timeout: 30_000, - windowsHide: true, - }, - ); - expect(fixture.stderr).toBe(""); - expect(fixture.status).toBe(0); - expect(existsSync(home)).toBe(true); - } finally { - const restore = spawnSync( - registry, - originalPolicy === null - ? ["delete", policyKey, "/v", policyName, "/f"] - : [ - "add", - policyKey, - "/v", - policyName, - "/t", - originalPolicy.type, - "/d", - originalPolicy.value, - "/f", - ], - { encoding: "utf8", timeout: 15_000, windowsHide: true }, - ); - expect(restore.status).toBe(0); - } - }, - ); - 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/support/api-client.ts b/sdk/typescript/tests-ts/support/api-client.ts new file mode 100644 index 000000000..79e3b595c --- /dev/null +++ b/sdk/typescript/tests-ts/support/api-client.ts @@ -0,0 +1,73 @@ +import { CodexSecurity } from "../../src/api.js"; +import type { JsonObject } from "../../src/config.js"; + +type ClientArguments = ConstructorParameters; + +export const TEST_SNAPSHOT_DIGEST = `codex-security-snapshot/v1:sha256:${"a".repeat(64)}`; + +export function mockScanRegistration(args: readonly string[]) { + const recipe = JSON.parse(args[args.indexOf("--recipe-json") + 1]!) as { + repositoryRevision?: string; + target: { kind: string }; + }; + const kind = + recipe.target.kind === "refs" || recipe.target.kind === "working_tree" + ? "git_diff" + : recipe.repositoryRevision === undefined + ? "directory_snapshot" + : "git_revision"; + + return { + scanId: "scan_example_001", + targetId: "target_sha256_example", + targetRevision: recipe.repositoryRevision ?? "unversioned", + scanDir: args[args.indexOf("--scan-dir") + 1]!, + contract: { + target: { + allowedKinds: [kind], + ...(kind === "directory_snapshot" + ? { requiredSnapshotDigest: TEST_SNAPSHOT_DIGEST } + : {}), + }, + }, + }; +} + +export function mockWorkbench(args: readonly string[]): JsonObject { + if (args[0] === "register-cli-scan") return mockScanRegistration(args); + if (args[0] === "get-scan-feedback") { + return { + scanId: "scan_example_001", + targetId: "target_sha256_example", + falsePositives: [], + }; + } + return {}; +} + +export class TestClient extends CodexSecurity { + public constructor( + config: ClientArguments[0], + dependencies: Partial, + ) { + super( + config, + { + createCodex: () => { + throw new Error("Unexpected Codex invocation in test"); + }, + environment: {}, + runWorkbench: async (_options, args) => mockWorkbench(args), + ...dependencies, + }, + { surface: "sdk" }, + ); + } +} + +export const SHELL_ENVIRONMENT_PREFIX = + process.platform === "win32" ? "$env:" : "$"; + +export function shellEnvironmentReference(name: string, suffix = ""): string { + return `"${SHELL_ENVIRONMENT_PREFIX}${name}${suffix}"`; +} diff --git a/sdk/typescript/tests-ts/support/api-events.ts b/sdk/typescript/tests-ts/support/api-events.ts index a73f8aa0e..2b5d501cd 100644 --- a/sdk/typescript/tests-ts/support/api-events.ts +++ b/sdk/typescript/tests-ts/support/api-events.ts @@ -2,11 +2,19 @@ import { chmod, cp, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ThreadEvent } from "@openai/codex-sdk"; -import { runScanEvents } from "../../src/api.js"; +import { CodexSecurity, runScanEvents } from "../../src/api.js"; import type { ScanOptions } from "../../src/index.js"; import { PLUGIN_ROOT } from "../plugin-root.js"; -export function preparedRuntime(codexHome: string): Record { +type PreparedRuntime = Awaited< + ReturnType< + NonNullable< + ConstructorParameters[1]["prepareRuntime"] + > + > +>; + +export function preparedRuntime(codexHome: string): PreparedRuntime { return { codexHome, plugin: { diff --git a/sdk/typescript/tests-ts/support/isolated-mock.ts b/sdk/typescript/tests-ts/support/isolated-mock.ts deleted file mode 100644 index 4ec8fb599..000000000 --- a/sdk/typescript/tests-ts/support/isolated-mock.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { expect } from "bun:test"; - -export function runMockInSubprocess(file: string, name: string): boolean { - if (process.env["CODEX_SECURITY_ISOLATED_MOCK"] === "1") return false; - - const result = spawnSync( - process.execPath, - ["test", "--timeout", "30000", "--test-name-pattern", name, file], - { - encoding: "utf8", - env: { ...process.env, CODEX_SECURITY_ISOLATED_MOCK: "1" }, - windowsHide: true, - }, - ); - expect(result.status, result.stderr).toBe(0); - return true; -} diff --git a/sdk/typescript/tests-ts/support/test-subprocess.ts b/sdk/typescript/tests-ts/support/test-subprocess.ts new file mode 100644 index 000000000..dfe4588f5 --- /dev/null +++ b/sdk/typescript/tests-ts/support/test-subprocess.ts @@ -0,0 +1,22 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { expect } from "bun:test"; + +export function runTestInSubprocess(file: string, name: string): boolean { + const identity = `${file}::${name}`; + if (process.env["CODEX_SECURITY_ISOLATED_TEST"] === identity) return false; + const pattern = `${name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}$`; + + const result = spawnSync( + process.execPath, + ["test", "--timeout", "30000", "--test-name-pattern", pattern, file], + { + cwd: fileURLToPath(new URL("../../", import.meta.url)), + encoding: "utf8", + env: { ...process.env, CODEX_SECURITY_ISOLATED_TEST: identity }, + windowsHide: true, + }, + ); + expect(result.status, result.stderr || result.error?.message).toBe(0); + return true; +} diff --git a/sdk/typescript/tests-ts/windows-machine-policy.test.ts b/sdk/typescript/tests-ts/windows-machine-policy.test.ts new file mode 100644 index 000000000..13983049a --- /dev/null +++ b/sdk/typescript/tests-ts/windows-machine-policy.test.ts @@ -0,0 +1,206 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { afterEach, describe, expect, test } from "bun:test"; + +let temporaryRoot: string | undefined; +afterEach(async () => { + if (temporaryRoot !== undefined) + await rm(temporaryRoot, { recursive: true, force: true }); + temporaryRoot = undefined; +}); + +describe("runtime directories and plugin Python boundary", () => { + test.skipIf( + process.platform !== "win32" || + process.env["GITHUB_ACTIONS"] !== "true" || + process.env["RUNNER_ENVIRONMENT"] !== "github-hosted" || + process.env["CODEX_SECURITY_ALLOW_MACHINE_POLICY_TEST"] !== "true", + )( + "prepares managed credential homes under constrained PowerShell", + async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-policy-")), + ); + temporaryRoot = root; + const powershell = join( + process.env["SystemRoot"] ?? "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + const constrainedEnvironment = { + ...process.env, + CODEX_SECURITY_STATE_DIR: join(root, "state"), + PSModulePath: join(root, "untrusted-or-incompatible-modules"), + PSMODULEPATH: join(root, "uppercase-untrusted-modules"), + }; + const registry = join(dirname(dirname(dirname(powershell))), "reg.exe"); + const policyKey = + "HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment"; + const policyName = "__PSLockdownPolicy"; + const original = spawnSync( + registry, + ["query", policyKey, "/v", policyName], + { encoding: "utf8", timeout: 15_000, windowsHide: true }, + ); + expect(original.status === 0 || original.status === 1).toBe(true); + const originalEntry = + original.status === 0 + ? /^\s*__PSLockdownPolicy\s+(REG_[A-Z_]+)\s*(.*?)\s*$/mu.exec( + original.stdout, + ) + : null; + if (original.status === 0) expect(originalEntry).not.toBeNull(); + const originalPolicy = + originalEntry === null + ? null + : { type: originalEntry[1]!, value: originalEntry[2]! }; + const enabled = spawnSync( + registry, + ["add", policyKey, "/v", policyName, "/t", "REG_SZ", "/d", "4", "/f"], + { encoding: "utf8", timeout: 15_000, windowsHide: true }, + ); + expect(enabled.status).toBe(0); + + try { + const mode = spawnSync( + powershell, + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "$ExecutionContext.SessionState.LanguageMode", + ], + { + encoding: "utf8", + env: constrainedEnvironment, + timeout: 15_000, + windowsHide: true, + }, + ); + expect(mode.status).toBe(0); + expect(mode.stdout.trim()).toBe("ConstrainedLanguage"); + + const oldImplementation = spawnSync( + powershell, + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "$ErrorActionPreference = 'Stop'; New-Object System.Security.AccessControl.DirectorySecurity", + ], + { + encoding: "utf8", + env: constrainedEnvironment, + timeout: 15_000, + windowsHide: true, + }, + ); + expect(oldImplementation.status).not.toBe(0); + + const trustedPowerShellEnvironment = { + ...Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => name.toUpperCase() !== "PSMODULEPATH", + ), + ), + PSModulePath: join(dirname(powershell), "Modules"), + }; + const guest = spawnSync( + powershell, + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + [ + "Microsoft.PowerShell.Utility\\ConvertFrom-SddlString -Sddl 'O:LGG:SYD:(A;;GA;;;SY)'", + "Microsoft.PowerShell.Utility\\Select-Object -ExpandProperty RawDescriptor", + "Microsoft.PowerShell.Utility\\Select-Object -ExpandProperty Owner", + "Microsoft.PowerShell.Utility\\Select-Object -ExpandProperty Value", + ].join(" | "), + ], + { + encoding: "utf8", + env: trustedPowerShellEnvironment, + timeout: 15_000, + windowsHide: true, + }, + ); + expect(guest.status).toBe(0); + expect(guest.stdout.trim()).toMatch(/^S-1-(?:\d+-)*501$/u); + const home = join(root, "state", "codex-home"); + await mkdir(home, { recursive: true }); + const foreignGrant = spawnSync( + join(dirname(dirname(dirname(powershell))), "icacls.exe"), + [home, "/grant", `*${guest.stdout.trim()}:(OI)(CI)R`], + { encoding: "utf8", timeout: 15_000, windowsHide: true }, + ); + expect(foreignGrant.status).toBe(0); + + const fixtureModule = join(root, "runtime-node-fixture.mjs"); + const build = spawnSync( + process.execPath, + [ + "build", + fileURLToPath(new URL("../src/runtime.ts", import.meta.url)), + "--target=node", + "--format=esm", + `--outfile=${fixtureModule}`, + ], + { encoding: "utf8", timeout: 30_000, windowsHide: true }, + ); + expect(build.status).toBe(0); + const selectedNode = spawnSync("node", ["-p", "process.execPath"], { + encoding: "utf8", + timeout: 15_000, + windowsHide: true, + }); + expect(selectedNode.status).toBe(0); + const fixture = spawnSync( + selectedNode.stdout.trim(), + [ + "--input-type=module", + "--eval", + `import { prepareCodexSecurityCredentialHome } from ${JSON.stringify(pathToFileURL(fixtureModule).href)}; await prepareCodexSecurityCredentialHome();`, + ], + { + encoding: "utf8", + env: constrainedEnvironment, + timeout: 30_000, + windowsHide: true, + }, + ); + expect(fixture.stderr).toBe(""); + expect(fixture.status).toBe(0); + expect(existsSync(home)).toBe(true); + } finally { + const restore = spawnSync( + registry, + originalPolicy === null + ? ["delete", policyKey, "/v", policyName, "/f"] + : [ + "add", + policyKey, + "/v", + policyName, + "/t", + originalPolicy.type, + "/d", + originalPolicy.value, + "/f", + ], + { encoding: "utf8", timeout: 15_000, windowsHide: true }, + ); + expect(restore.status).toBe(0); + } + }, + ); +}); From 87be4b951ee6dcfea5555e1803eb949b453855db Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 17 Aug 2026 12:14:02 -0700 Subject: [PATCH 2/6] test: add property and mutation coverage for core invariants --- sdk/typescript/.gitignore | 2 + sdk/typescript/package.json | 3 + sdk/typescript/pnpm-lock.yaml | 1125 +++++++++++++++++ sdk/typescript/scripts/check-package.mjs | 1 + sdk/typescript/src/cost-model.ts | 122 ++ sdk/typescript/src/cost.ts | 126 +- sdk/typescript/src/errors.ts | 2 +- sdk/typescript/stryker.config.json | 12 + sdk/typescript/tests-ts/contract.test.ts | 81 ++ .../tests-ts/cost-model.property.test.ts | 184 +++ .../tests-ts/errors.property.test.ts | 65 + sdk/typescript/tests-ts/errors.test.ts | 58 +- .../tests-ts/publication-store.test.ts | 66 + .../tests-ts/scan-comparison.property.test.ts | 142 +++ sdk/typescript/tests-ts/support/property.ts | 7 + .../tests-ts/worker-progress.property.test.ts | 144 +++ .../tests-ts/worker-progress.test.ts | 55 + 17 files changed, 2074 insertions(+), 121 deletions(-) create mode 100644 sdk/typescript/src/cost-model.ts create mode 100644 sdk/typescript/stryker.config.json create mode 100644 sdk/typescript/tests-ts/cost-model.property.test.ts create mode 100644 sdk/typescript/tests-ts/errors.property.test.ts create mode 100644 sdk/typescript/tests-ts/scan-comparison.property.test.ts create mode 100644 sdk/typescript/tests-ts/support/property.ts create mode 100644 sdk/typescript/tests-ts/worker-progress.property.test.ts diff --git a/sdk/typescript/.gitignore b/sdk/typescript/.gitignore index 6c62fd432..d39addef6 100644 --- a/sdk/typescript/.gitignore +++ b/sdk/typescript/.gitignore @@ -1,4 +1,6 @@ /dist/ /node_modules +/reports/ +/.stryker-tmp/ /private_release/dist/ /*.tsbuildinfo diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index e29413c30..82c6aa106 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -49,6 +49,7 @@ "lint": "tsc --noEmit", "prepack": "node --run build", "test": "bun test --timeout 30000 ./tests-ts", + "test:mutation": "stryker run", "test:package": "node scripts/smoke-package.mjs", "types": "pnpm run generate:models:check && tsc --noEmit" }, @@ -68,9 +69,11 @@ "smol-toml": "1.6.1" }, "devDependencies": { + "@stryker-mutator/core": "9.6.1", "@types/bun": "1.3.13", "@types/node": "22.19.17", "@types/papaparse": "5.3.15", + "fast-check": "4.9.0", "json-schema-to-typescript": "15.0.4", "prettier": "3.2.5", "typescript": "5.7.3" diff --git a/sdk/typescript/pnpm-lock.yaml b/sdk/typescript/pnpm-lock.yaml index eca8be0e5..4befc5cfb 100644 --- a/sdk/typescript/pnpm-lock.yaml +++ b/sdk/typescript/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: specifier: 1.6.1 version: 1.6.1 devDependencies: + '@stryker-mutator/core': + specifier: 9.6.1 + version: 9.6.1(@types/node@22.19.17) '@types/bun': specifier: 1.3.13 version: 1.3.13 @@ -57,6 +60,9 @@ importers: '@types/papaparse': specifier: 5.3.15 version: 5.3.15 + fast-check: + specifier: 4.9.0 + version: 4.9.0 json-schema-to-typescript: specifier: 15.0.4 version: 15.0.4 @@ -73,6 +79,159 @@ packages: resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} engines: {node: '>= 16'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-proposal-decorators@7.29.7': + resolution: {integrity: sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.29.7': + resolution: {integrity: sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.29.7': + resolution: {integrity: sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-explicit-resource-management@7.29.7': + resolution: {integrity: sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} @@ -215,6 +374,22 @@ packages: '@types/node': optional: true + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jsdevtools/ono@7.1.3': resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} @@ -384,6 +559,29 @@ packages: resolution: {integrity: sha512-WmaxVSfvY5K/TwcG2B2TU1WOe1As1uc2s7myswtP6dBlcjU3hM08SApxv/jmyGaCE8t4gO5BBhmHY4pDUfmr2g==} engines: {node: '>=22'} + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@stryker-mutator/api@9.6.1': + resolution: {integrity: sha512-g8VNoFWQWbx0pdal3Vt8jVCZW+v3sc3gi94iI0GVtVgUGTqphAjJF6EAruPTx0lqvtonsaAxn5TD36hcG1d6Wg==} + engines: {node: '>=20.0.0'} + + '@stryker-mutator/core@9.6.1': + resolution: {integrity: sha512-WMgnvf+Wyh/yiruhNZwc8w8DlzmmjXhPjSn5MR8RhAXzlnWji8TQrUYgBUkHk9bEgSaIlB3KZHm37iiU5Q2cLQ==} + engines: {node: '>=20.0.0'} + hasBin: true + + '@stryker-mutator/instrumenter@9.6.1': + resolution: {integrity: sha512-5K8wH4Pthly25c2uKKik4Dfcoeou7sbJdFS6u3QIYHlulgFVDJwtEMWTZGkZfs7IiUEXIDNa0keRACq5jn5AvA==} + engines: {node: '>=20.0.0'} + + '@stryker-mutator/util@9.6.1': + resolution: {integrity: sha512-Lk/ALVctJjFv1vvwR+CFoKzDCWvsBlq7flDUnmnpuwTrGbm156EdZD1Jjq4o8KdOap0ezUZqQNE9OAI1m2+pUQ==} + '@toon-format/toon@2.3.0': resolution: {integrity: sha512-/Ew9etdRQKVMnm9fDaCG0JjyAOK/O7T0M97oum1aW4W+UR8ZhVVPBanIV7oWgHBiGlnVxV9M55PWQCHofDV07w==} @@ -405,21 +603,61 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + angular-html-parser@10.4.0: + resolution: {integrity: sha512-++nLNyZwRfHqFh7akH5Gw/JYizoFlMRz0KRigfwfsLqV8ZqlcVRb1LkPEWdYvEKDnbktknM2J4BXaYUGrQZPww==} + engines: {node: '>= 14'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.11.13: + resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==} + engines: {node: '>=6.0.0'} + hasBin: true + before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} bun-types@1.3.13: resolution: {integrity: sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA==} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} @@ -427,10 +665,21 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + content-type@2.0.0: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -440,14 +689,54 @@ packages: supports-color: optional: true + des.js@1.1.0: + resolution: {integrity: sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==} + + diff-match-patch@1.0.5: + resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.403: + resolution: {integrity: sha512-MQsYmdaLzvaCX5j+ZZBr5Fm6uCCnPQcRtlvmvRlWqrXy+BH2O4ffXIAScF+JQznQWB9brWp4lSD9Z4yNmaf2BA==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + extract-zip@2.0.1: resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} engines: {node: '>= 10.17.0'} hasBin: true + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -478,14 +767,53 @@ packages: fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-stream@5.2.0: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graphql@17.0.2: resolution: {integrity: sha512-FRWbddMxfkjiB7z+aQDWIR+E34xo9I8c9mtK2RPv8PmMzKRvrdsreHL/Ui/TmwHJfhHChEtsFPyMHKI+xuarQQ==} engines: {node: ^22.0.0 || ^24.0.0 || ^25.0.0 || >=26.0.0} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -495,6 +823,9 @@ packages: engines: {node: '>=22'} hasBin: true + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -503,10 +834,39 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-md4@0.3.2: + resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@4.3.1: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-rpc-2.0@1.7.1: + resolution: {integrity: sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg==} + json-schema-to-typescript@15.0.4: resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==} engines: {node: '>=16.0.0'} @@ -518,25 +878,84 @@ packages: json-with-bigint@3.5.10: resolution: {integrity: sha512-Vcx+JVNEBts/xfcoCS69sKrOhOk/3TVlvlT+XzUOefVKnnrbYSCKpDCm10pohsJFtsJVYnwa/cXRZ4eElzaM6w==} + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mutation-server-protocol@0.4.1: + resolution: {integrity: sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g==} + engines: {node: '>=18'} + + mutation-testing-elements@3.7.3: + resolution: {integrity: sha512-SMeIPxngJpfjfNYctFpYQQtlBlZaVO0aoB3FKdwrI8Ee/2bkyUuCZzAOCLv1U9fnmfA37dPFq0Owduoxs2XgGQ==} + + mutation-testing-metrics@3.7.3: + resolution: {integrity: sha512-B8QrP0ZomErzTPNlhrzKWPNBln+3afwBZPHv0Q7N8wZZTYxMptzb/Gdm3ExXVmioVYrtZAtsDs7W/T/b2AixOQ==} + + mutation-testing-report-schema@3.7.3: + resolution: {integrity: sha512-BHm3MYq+ckO+t5CtlG8zpqxc75rdJCkxVlE+fGuGJM3F7tNCQ/OW2N+TQVHN3BHsYa84+BFc6g3AwDYkUsw2MA==} + mute-stream@3.0.0: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} papaparse@5.5.3: resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + pdfjs-dist@6.2.108: resolution: {integrity: sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==} engines: {node: '>=22.13.0 || >=24'} @@ -544,6 +963,9 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.5: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} @@ -553,16 +975,72 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -571,6 +1049,14 @@ packages: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -578,20 +1064,63 @@ packages: tokenx@1.3.0: resolution: {integrity: sha512-NLdXTEZkKiO0gZuLtMoZKjCXTREXeZZt8nnnNeyoXtNZAfG/GKGSbQtLU5STspc0rMSwcA+UJfWZkbNU01iKmQ==} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + + typed-inject@5.0.0: + resolution: {integrity: sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA==} + engines: {node: '>=18'} + + typed-rest-client@2.3.1: + resolution: {integrity: sha512-k4kX5Up6qA68D0Cby2AK+6+vM5k3qTxe+/3FqhnHRExjY5cfbOnzjQZbP/LXleF8hVoDvDqxlgk9KK83HoBZlQ==} + engines: {node: '>= 16.0.0'} + typescript@5.7.3: resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} engines: {node: '>=14.17'} hasBin: true + underscore@1.13.8: + resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + weapon-regex@1.3.6: + resolution: {integrity: sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -600,6 +1129,10 @@ packages: yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -611,6 +1144,222 @@ snapshots: '@types/json-schema': 7.0.15 js-yaml: 4.3.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.8 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-syntax-decorators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@cfworker/json-schema@4.1.1': {} '@graphql-typed-document-node/core@3.2.0(graphql@17.0.2)': @@ -736,6 +1485,25 @@ snapshots: optionalDependencies: '@types/node': 22.19.17 + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jsdevtools/ono@7.1.3': {} '@linear/sdk@89.0.0(graphql@17.0.2)': @@ -876,6 +1644,68 @@ snapshots: '@scalar/openapi-types@0.8.0': {} + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@stryker-mutator/api@9.6.1': + dependencies: + mutation-testing-metrics: 3.7.3 + mutation-testing-report-schema: 3.7.3 + tslib: 2.8.1 + typed-inject: 5.0.0 + + '@stryker-mutator/core@9.6.1(@types/node@22.19.17)': + dependencies: + '@inquirer/prompts': 8.3.0(@types/node@22.19.17) + '@stryker-mutator/api': 9.6.1 + '@stryker-mutator/instrumenter': 9.6.1 + '@stryker-mutator/util': 9.6.1 + ajv: 8.18.0 + chalk: 5.6.2 + commander: 14.0.3 + diff-match-patch: 1.0.5 + emoji-regex: 10.6.0 + execa: 9.6.1 + json-rpc-2.0: 1.7.1 + lodash.groupby: 4.6.0 + minimatch: 10.2.6 + mutation-server-protocol: 0.4.1 + mutation-testing-elements: 3.7.3 + mutation-testing-metrics: 3.7.3 + mutation-testing-report-schema: 3.7.3 + npm-run-path: 6.0.0 + progress: 2.0.3 + rxjs: 7.8.2 + semver: 7.8.5 + source-map: 0.7.6 + tree-kill: 1.2.2 + tslib: 2.8.1 + typed-inject: 5.0.0 + typed-rest-client: 2.3.1 + transitivePeerDependencies: + - '@types/node' + - supports-color + + '@stryker-mutator/instrumenter@9.6.1': + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.8 + '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) + '@stryker-mutator/api': 9.6.1 + '@stryker-mutator/util': 9.6.1 + angular-html-parser: 10.4.0 + semver: 7.7.4 + tslib: 2.8.1 + weapon-regex: 1.3.6 + transitivePeerDependencies: + - supports-color + + '@stryker-mutator/util@9.6.1': {} + '@toon-format/toon@2.3.0': {} '@types/bun@1.3.13': @@ -899,6 +1729,13 @@ snapshots: '@types/node': 22.19.17 optional: true + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -906,30 +1743,114 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + angular-html-parser@10.4.0: {} + argparse@2.0.1: {} + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.13: {} + before-after-hook@4.0.0: {} + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.13 + caniuse-lite: 1.0.30001809 + electron-to-chromium: 1.5.403 + node-releases: 2.0.53 + update-browserslist-db: 1.3.1(browserslist@4.28.8) + buffer-crc32@0.2.13: {} bun-types@1.3.13: dependencies: '@types/node': 22.19.17 + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001809: {} + + chalk@5.6.2: {} + chardet@2.2.0: {} cli-width@4.1.0: {} + commander@14.0.3: {} + content-type@2.0.0: {} + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + debug@4.4.3: dependencies: ms: 2.1.3 + des.js@1.1.0: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + + diff-match-patch@1.0.5: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.403: {} + + emoji-regex@10.6.0: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escalade@3.2.0: {} + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.2.0 + extract-zip@2.0.1: dependencies: debug: 4.4.3 @@ -940,6 +1861,10 @@ snapshots: transitivePeerDependencies: - supports-color + fast-check@4.9.0: + dependencies: + pure-rand: 8.4.2 + fast-deep-equal@3.1.3: {} fast-string-truncated-width@3.0.3: {} @@ -964,12 +1889,53 @@ snapshots: fflate@0.8.2: {} + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + get-stream@5.2.0: dependencies: pump: 3.0.4 + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + gopd@1.2.0: {} + graphql@17.0.2: {} + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + human-signals@8.0.1: {} + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -984,16 +1950,34 @@ snapshots: yaml: 2.9.0 zod: 4.4.3 + inherits@2.0.4: {} + is-extglob@2.1.1: {} is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-plain-obj@4.1.0: {} + + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + + isexe@2.0.0: {} + + js-md4@0.3.2: {} + + js-tokens@4.0.0: {} + js-yaml@4.3.1: dependencies: argparse: 2.0.1 + jsesc@3.1.0: {} + + json-rpc-2.0@1.7.1: {} + json-schema-to-typescript@15.0.4: dependencies: '@apidevtools/json-schema-ref-parser': 11.9.3 @@ -1010,43 +1994,148 @@ snapshots: json-with-bigint@3.5.10: {} + json5@2.2.3: {} + + lodash.groupby@4.6.0: {} + lodash@4.18.1: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + math-intrinsics@1.1.0: {} + + minimalistic-assert@1.0.1: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + minimist@1.2.8: {} ms@2.1.3: {} + mutation-server-protocol@0.4.1: + dependencies: + zod: 4.4.3 + + mutation-testing-elements@3.7.3: {} + + mutation-testing-metrics@3.7.3: + dependencies: + mutation-testing-report-schema: 3.7.3 + + mutation-testing-report-schema@3.7.3: {} + mute-stream@3.0.0: {} + node-releases@2.0.53: {} + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + object-inspect@1.13.4: {} + once@1.4.0: dependencies: wrappy: 1.0.2 papaparse@5.5.3: {} + parse-ms@4.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + pdfjs-dist@6.2.108: optionalDependencies: '@napi-rs/canvas': 1.0.3 pend@1.2.0: {} + picocolors@1.1.1: {} + picomatch@4.0.5: {} prettier@3.2.5: {} + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + progress@2.0.3: {} + pump@3.0.4: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + pure-rand@8.4.2: {} + + qs@6.15.1: + dependencies: + side-channel: 1.1.1 + require-from-string@2.0.2: {} + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safer-buffer@2.1.2: {} + semver@6.3.1: {} + + semver@7.7.4: {} + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + signal-exit@4.1.0: {} smol-toml@1.6.1: {} + source-map@0.7.6: {} + + strip-final-newline@4.0.0: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) @@ -1054,14 +2143,48 @@ snapshots: tokenx@1.3.0: {} + tree-kill@1.2.2: {} + + tslib@2.8.1: {} + + tunnel@0.0.6: {} + + typed-inject@5.0.0: {} + + typed-rest-client@2.3.1: + dependencies: + des.js: 1.1.0 + js-md4: 0.3.2 + qs: 6.15.1 + tunnel: 0.0.6 + underscore: 1.13.8 + typescript@5.7.3: {} + underscore@1.13.8: {} + undici-types@6.21.0: {} + unicorn-magic@0.3.0: {} + universal-user-agent@7.0.3: {} + update-browserslist-db@1.3.1(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + weapon-regex@1.3.6: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + wrappy@1.0.2: {} + yallist@3.1.1: {} + yaml@2.9.0: {} yauzl@2.10.0: @@ -1069,4 +2192,6 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + yoctocolors@2.2.0: {} + zod@4.4.3: {} diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index 9cd6feb8c..bd0936c9e 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -167,6 +167,7 @@ const distFiles = new Set( "config", "contract", "cost", + "cost-model", "errors", "index", "knowledge-base", diff --git a/sdk/typescript/src/cost-model.ts b/sdk/typescript/src/cost-model.ts new file mode 100644 index 000000000..eb36e4055 --- /dev/null +++ b/sdk/typescript/src/cost-model.ts @@ -0,0 +1,122 @@ +export interface ScanCost { + model: string; + inputTokens: number; + cachedInputTokens: number; + cacheWriteInputTokens: number; + outputTokens: number; + estimatedUsd: number; +} + +type ModelPricing = readonly [ + input: number, + cachedInput: number, + cacheWriteInput: number, + output: number, +]; + +export interface ScanTokenUsage { + input_tokens: number; + cached_input_tokens: number; + cache_write_input_tokens: number; + output_tokens: number; + reasoning_output_tokens: number; + total_tokens: number; +} + +const MODEL_PRICING_NANODOLLARS: Readonly> = { + "gpt-5.6": [5_000, 500, 6_250, 30_000], + "gpt-5.6-sol": [5_000, 500, 6_250, 30_000], + "gpt-5.6-terra": [2_000, 200, 2_500, 12_000], + "gpt-5.6-luna": [200, 20, 250, 1_200], +}; + +export function tokenUsage(value: unknown): ScanTokenUsage | null { + if (!isRecord(value)) return null; + const input = value["input_tokens"]; + const cached = value["cached_input_tokens"] ?? 0; + const canonicalCacheWrite = value["cache_write_input_tokens"]; + const legacyCacheWrite = value["cache_write_tokens"]; + const cacheWrite = + canonicalCacheWrite === 0 && + isTokenCount(input) && + isTokenCount(cached) && + isTokenCount(legacyCacheWrite) && + legacyCacheWrite > 0 && + cached + legacyCacheWrite <= input + ? legacyCacheWrite + : canonicalCacheWrite ?? legacyCacheWrite ?? 0; + const output = value["output_tokens"]; + const reasoning = value["reasoning_output_tokens"] ?? 0; + if ( + !isTokenCount(input) || + !isTokenCount(cached) || + !isTokenCount(cacheWrite) || + !isTokenCount(output) || + !isTokenCount(reasoning) || + cached + cacheWrite > input || + reasoning > output + ) { + return null; + } + return { + input_tokens: input, + cached_input_tokens: cached, + cache_write_input_tokens: cacheWrite, + output_tokens: output, + reasoning_output_tokens: reasoning, + total_tokens: input + output, + }; +} + +export function estimateScanCost( + model: string | undefined, + usage: unknown, +): ScanCost | null { + if (model === undefined) return null; + const pricingModel = model.startsWith("openai.") + ? model.slice("openai.".length) + : model; + const pricing = MODEL_PRICING_NANODOLLARS[pricingModel]; + const normalized = tokenUsage(usage); + if (pricing === undefined || normalized === null) return null; + const [inputRate, cachedInputRate, cacheWriteInputRate, outputRate] = pricing; + const { + input_tokens: inputTokens, + cached_input_tokens: cachedInputTokens, + cache_write_input_tokens: cacheWriteInputTokens, + output_tokens: outputTokens, + } = normalized; + + const nanodollars = + (inputTokens - cachedInputTokens - cacheWriteInputTokens) * inputRate + + cachedInputTokens * cachedInputRate + + cacheWriteInputTokens * cacheWriteInputRate + + outputTokens * outputRate; + if (!Number.isSafeInteger(nanodollars)) return null; + + return { + model, + inputTokens, + cachedInputTokens, + cacheWriteInputTokens, + outputTokens, + estimatedUsd: nanodollars / 1_000_000_000, + }; +} + +export function formatUsd(value: number): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 9, + }).format(value); +} + +function isTokenCount(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 5bb5c1061..8f8f56df7 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -1,3 +1,10 @@ +import { + estimateScanCost, + tokenUsage, + type ScanCost, + type ScanTokenUsage, +} from "./cost-model.js"; +export { estimateScanCost, formatUsd, type ScanCost } from "./cost-model.js"; import { open, readdir } from "node:fs/promises"; import { join, relative, sep } from "node:path"; import { @@ -9,31 +16,6 @@ import { type ScanProgress, } from "./worker-progress.js"; -export interface ScanCost { - model: string; - inputTokens: number; - cachedInputTokens: number; - cacheWriteInputTokens: number; - outputTokens: number; - estimatedUsd: number; -} - -type ModelPricing = readonly [ - input: number, - cachedInput: number, - cacheWriteInput: number, - output: number, -]; - -interface ScanTokenUsage { - input_tokens: number; - cached_input_tokens: number; - cache_write_input_tokens: number; - output_tokens: number; - reasoning_output_tokens: number; - total_tokens: number; -} - interface SessionReasoning { id: string; text: string; @@ -81,13 +63,6 @@ interface ScanCostSnapshot { cost: ScanCost | null; } -const MODEL_PRICING_NANODOLLARS: Readonly> = { - "gpt-5.6": [5_000, 500, 6_250, 30_000], - "gpt-5.6-sol": [5_000, 500, 6_250, 30_000], - "gpt-5.6-terra": [2_000, 200, 2_500, 12_000], - "gpt-5.6-luna": [200, 20, 250, 1_200], -}; - const COST_POLL_INTERVAL_MS = 100; const SESSION_READ_SIZE = 64 * 1_024; @@ -751,44 +726,6 @@ function sessionContentText( .join("\n"); } -function tokenUsage(value: unknown): ScanTokenUsage | null { - if (!isRecord(value)) return null; - const input = value["input_tokens"]; - const cached = value["cached_input_tokens"] ?? 0; - const canonicalCacheWrite = value["cache_write_input_tokens"]; - const legacyCacheWrite = value["cache_write_tokens"]; - const cacheWrite = - canonicalCacheWrite === 0 && - isTokenCount(input) && - isTokenCount(cached) && - isTokenCount(legacyCacheWrite) && - legacyCacheWrite > 0 && - cached + legacyCacheWrite <= input - ? legacyCacheWrite - : canonicalCacheWrite ?? legacyCacheWrite ?? 0; - const output = value["output_tokens"]; - const reasoning = value["reasoning_output_tokens"] ?? 0; - if ( - !isTokenCount(input) || - !isTokenCount(cached) || - !isTokenCount(cacheWrite) || - !isTokenCount(output) || - !isTokenCount(reasoning) || - cached + cacheWrite > input || - reasoning > output - ) { - return null; - } - return { - input_tokens: input, - cached_input_tokens: cached, - cache_write_input_tokens: cacheWrite, - output_tokens: output, - reasoning_output_tokens: reasoning, - total_tokens: input + output, - }; -} - function addTokenUsage( previous: ScanTokenUsage | null, next: ScanTokenUsage, @@ -830,52 +767,3 @@ function isRecord(value: unknown): value is Record { function isMissingFile(error: unknown): boolean { return isRecord(error) && error["code"] === "ENOENT"; } - -export function estimateScanCost( - model: string | undefined, - usage: unknown, -): ScanCost | null { - if (model === undefined) return null; - const pricingModel = model.startsWith("openai.") - ? model.slice("openai.".length) - : model; - const pricing = MODEL_PRICING_NANODOLLARS[pricingModel]; - const normalized = tokenUsage(usage); - if (pricing === undefined || normalized === null) return null; - const [inputRate, cachedInputRate, cacheWriteInputRate, outputRate] = pricing; - const { - input_tokens: inputTokens, - cached_input_tokens: cachedInputTokens, - cache_write_input_tokens: cacheWriteInputTokens, - output_tokens: outputTokens, - } = normalized; - - const nanodollars = - (inputTokens - cachedInputTokens - cacheWriteInputTokens) * inputRate + - cachedInputTokens * cachedInputRate + - cacheWriteInputTokens * cacheWriteInputRate + - outputTokens * outputRate; - if (!Number.isSafeInteger(nanodollars)) return null; - - return { - model, - inputTokens, - cachedInputTokens, - cacheWriteInputTokens, - outputTokens, - estimatedUsd: nanodollars / 1_000_000_000, - }; -} - -export function formatUsd(value: number): string { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - minimumFractionDigits: 2, - maximumFractionDigits: 9, - }).format(value); -} - -function isTokenCount(value: unknown): value is number { - return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; -} diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index 53d67c7ff..5262e71ee 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -1,4 +1,4 @@ -import { formatUsd, type ScanCost } from "./cost.js"; +import { formatUsd, type ScanCost } from "./cost-model.js"; /** Returns the original error message without altering its contents. */ export function errorMessage(error: unknown): string { diff --git a/sdk/typescript/stryker.config.json b/sdk/typescript/stryker.config.json new file mode 100644 index 000000000..452fbc763 --- /dev/null +++ b/sdk/typescript/stryker.config.json @@ -0,0 +1,12 @@ +{ + "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json", + "testRunner": "command", + "commandRunner": { + "command": "bun test --timeout 30000 ./tests-ts/cost-model.property.test.ts ./tests-ts/errors.test.ts ./tests-ts/errors.property.test.ts ./tests-ts/worker-progress.test.ts ./tests-ts/worker-progress.property.test.ts" + }, + "mutate": ["src/cost-model.ts", "src/errors.ts", "src/worker-progress.ts"], + "coverageAnalysis": "off", + "reporters": ["clear-text", "progress", "json", "html"], + "concurrency": 2, + "thresholds": { "break": 0 } +} diff --git a/sdk/typescript/tests-ts/contract.test.ts b/sdk/typescript/tests-ts/contract.test.ts index 4fdfcd05f..c52f20bd3 100644 --- a/sdk/typescript/tests-ts/contract.test.ts +++ b/sdk/typescript/tests-ts/contract.test.ts @@ -15,10 +15,12 @@ import { import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; +import fc from "fast-check"; import { ContractValidationError, loadContract } from "../src/index.js"; import { sameCheckedFileDevice } from "../src/contract.js"; import type { NormalizedTarget, ScanExpectation } from "../src/index.js"; import { PLUGIN_ROOT } from "./plugin-root.js"; +import { propertyOptions } from "./support/property.js"; const EXAMPLE = join(PLUGIN_ROOT, "examples", "completed-scan"); const temporaryDirectories: string[] = []; @@ -105,6 +107,85 @@ function expectation( } describe("canonical scan contract", () => { + test("rejects byte changes to any sealed binary artifact", async () => { + const scanDir = await copyExample(); + const artifactPath = join(scanDir, "artifacts", "synthetic.bin"); + const manifestPath = join(scanDir, "scan-manifest.json"); + const manifest = await readJson(manifestPath); + await mkdir(dirname(artifactPath), { recursive: true }); + + await fc.assert( + fc.asyncProperty( + fc.uint8Array({ minLength: 1, maxLength: 256 }), + fc.nat(), + fc.integer({ min: 1, max: 255 }), + async (bytes, offset, difference) => { + await writeFile(artifactPath, bytes); + await writeJson(manifestPath, { + ...manifest, + scan: { + ...manifest["scan"], + artifacts: [ + ...manifest["scan"]["artifacts"], + { + path: "artifacts/synthetic.bin", + sha256: createHash("sha256").update(bytes).digest("hex"), + mediaType: "application/octet-stream", + }, + ], + }, + }); + await loadContract(scanDir, { pluginRoot: PLUGIN_ROOT }); + const changed = Uint8Array.from(bytes); + changed[offset % bytes.length]! ^= difference; + await writeFile(artifactPath, changed); + await expect( + loadContract(scanDir, { pluginRoot: PLUGIN_ROOT }), + ).rejects.toBeInstanceOf(ContractValidationError); + }, + ), + { + ...propertyOptions, + numRuns: Number(process.env["CODEX_SECURITY_PROPERTY_RUNS"] ?? "20"), + }, + ); + }); + + test("rejects non-relative artifact paths before accepting a seal", async () => { + const scanDir = await copyExample(); + const manifestPath = join(scanDir, "scan-manifest.json"); + const manifest = await readJson(manifestPath); + await fc.assert( + fc.asyncProperty( + fc.stringMatching(/^[a-z]{1,16}$/u), + fc.constantFrom("../", "/", "C:/", "artifacts/../", "artifacts\\"), + async (name, prefix) => { + await writeJson(manifestPath, { + ...manifest, + scan: { + ...manifest["scan"], + artifacts: [ + ...manifest["scan"]["artifacts"], + { + path: `${prefix}${name}`, + sha256: "0".repeat(64), + mediaType: "application/octet-stream", + }, + ], + }, + }); + await expect( + loadContract(scanDir, { pluginRoot: PLUGIN_ROOT }), + ).rejects.toBeInstanceOf(ContractValidationError); + }, + ), + { + ...propertyOptions, + numRuns: Number(process.env["CODEX_SECURITY_PROPERTY_RUNS"] ?? "20"), + }, + ); + }); + test("compares exact Windows volume serials without rounding file identity", async () => { const scanDir = await copyExample(); const path = join(scanDir, "scan-manifest.json"); diff --git a/sdk/typescript/tests-ts/cost-model.property.test.ts b/sdk/typescript/tests-ts/cost-model.property.test.ts new file mode 100644 index 000000000..65703a80e --- /dev/null +++ b/sdk/typescript/tests-ts/cost-model.property.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from "bun:test"; +import fc from "fast-check"; +import { estimateScanCost, formatUsd, tokenUsage } from "../src/cost-model.js"; +import { propertyOptions } from "./support/property.js"; + +const rates = [ + ["gpt-5.6", 5000n, 500n, 6250n, 30000n], + ["gpt-5.6-sol", 5000n, 500n, 6250n, 30000n], + ["gpt-5.6-terra", 2000n, 200n, 2500n, 12000n], + ["gpt-5.6-luna", 200n, 20n, 250n, 1200n], +] as const; +const count = fc.integer({ min: 0, max: 1_000_000_000 }); +const usageParts = fc.record({ + uncached: count, + cached: count, + written: count, + output: count, +}); + +function usage(parts: { + uncached: number; + cached: number; + written: number; + output: number; +}) { + return { + input_tokens: parts.uncached + parts.cached + parts.written, + cached_input_tokens: parts.cached, + cache_write_input_tokens: parts.written, + output_tokens: parts.output, + reasoning_output_tokens: parts.output, + }; +} + +describe("cost-model invariants", () => { + test("rejects non-object usage and formats small costs without rounding them away", () => { + for (const value of [undefined, null, [], "usage", 1, true]) { + expect(tokenUsage(value)).toBeNull(); + expect(estimateScanCost("gpt-5.6-sol", value)).toBeNull(); + } + expect(formatUsd(0)).toBe("$0.00"); + expect(formatUsd(0.000000001)).toBe("$0.000000001"); + expect(formatUsd(1234.5)).toBe("$1,234.50"); + }); + + test("matches an integer nanodollar oracle for every priced model", () => { + fc.assert( + fc.property( + usageParts, + fc.constantFrom(...rates), + fc.boolean(), + ( + parts, + [model, inputRate, cachedRate, writeRate, outputRate], + prefixed, + ) => { + const selected = prefixed ? `openai.${model}` : model; + const tokens = usage(parts); + const nanos = + BigInt(parts.uncached) * inputRate + + BigInt(parts.cached) * cachedRate + + BigInt(parts.written) * writeRate + + BigInt(parts.output) * outputRate; + expect(estimateScanCost(selected, tokens)).toEqual({ + model: selected, + inputTokens: tokens.input_tokens, + cachedInputTokens: parts.cached, + cacheWriteInputTokens: parts.written, + outputTokens: parts.output, + estimatedUsd: Number(nanos) / 1_000_000_000, + }); + expect(tokenUsage(tokens)).toEqual({ + ...tokens, + total_tokens: tokens.input_tokens + tokens.output_tokens, + }); + }, + ), + propertyOptions, + ); + }); + + test("normalizes legacy cache writes without double charging", () => { + fc.assert( + fc.property(usageParts, (parts) => { + const canonical = usage(parts); + const { cache_write_input_tokens: written, ...rest } = canonical; + const expected = estimateScanCost("gpt-5.6-sol", canonical); + expect( + estimateScanCost("gpt-5.6-sol", { + ...rest, + cache_write_tokens: written, + }), + ).toEqual(expected); + expect( + estimateScanCost("gpt-5.6-sol", { + ...canonical, + cache_write_tokens: canonical.input_tokens + 1, + }), + ).toEqual(expected); + if (parts.written > 0) { + expect( + estimateScanCost("gpt-5.6-sol", { + ...canonical, + cache_write_tokens: parts.written - 1, + }), + ).toEqual(expected); + } + expect( + estimateScanCost("gpt-5.6-sol", { + ...rest, + cache_write_input_tokens: 0, + cache_write_tokens: written, + }), + ).toEqual(expected); + }), + propertyOptions, + ); + }); + + test("rejects inconsistent or non-integer token accounting", () => { + fc.assert( + fc.property( + usageParts, + fc.constantFrom( + "input_tokens", + "cached_input_tokens", + "cache_write_input_tokens", + "output_tokens", + "reasoning_output_tokens", + ), + fc.constantFrom( + -1, + 0.5, + Number.MAX_SAFE_INTEGER + 1, + NaN, + Infinity, + "1", + ), + (parts, field, invalid) => { + const valid = usage(parts); + expect( + estimateScanCost("gpt-5.6-sol", { ...valid, [field]: invalid }), + ).toBeNull(); + expect( + estimateScanCost("gpt-5.6-sol", { + ...valid, + cached_input_tokens: valid.input_tokens + 1, + }), + ).toBeNull(); + expect( + estimateScanCost("gpt-5.6-sol", { + ...valid, + reasoning_output_tokens: valid.output_tokens + 1, + }), + ).toBeNull(); + expect( + estimateScanCost("unpriced-synthetic-model", valid), + ).toBeNull(); + expect(estimateScanCost(undefined, valid)).toBeNull(); + }, + ), + propertyOptions, + ); + }); + + test("returns no estimate when nanodollar arithmetic would lose precision", () => { + fc.assert( + fc.property( + fc.integer({ min: 0, max: Number.MAX_SAFE_INTEGER }), + (input) => { + const nanos = BigInt(input) * 5000n; + const result = estimateScanCost("gpt-5.6-sol", { + input_tokens: input, + output_tokens: 0, + }); + if (nanos > BigInt(Number.MAX_SAFE_INTEGER)) + expect(result).toBeNull(); + else expect(result?.estimatedUsd).toBe(Number(nanos) / 1_000_000_000); + }, + ), + propertyOptions, + ); + }); +}); diff --git a/sdk/typescript/tests-ts/errors.property.test.ts b/sdk/typescript/tests-ts/errors.property.test.ts new file mode 100644 index 000000000..ced620dc7 --- /dev/null +++ b/sdk/typescript/tests-ts/errors.property.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import fc from "fast-check"; +import { errorMessage, safeErrorMessage } from "../src/errors.js"; +import { propertyOptions } from "./support/property.js"; + +const word = fc.stringMatching(/^[a-zA-Z0-9]{1,40}$/u); +const credential = word.map((value) => `SYNTHETIC_${value}`); + +describe("error-message invariants", () => { + test("omits recognized credentials in plain, JSON, and URL-encoded errors", () => { + fc.assert( + fc.property( + credential, + fc.constantFrom( + "api_key", + "apikey", + "accesskey", + "access-key", + "privatekey", + "private-key", + "sig", + "access_token", + "clientSecret", + "password", + "authorization", + ), + (secret, field) => { + const json = JSON.stringify({ [field]: secret }); + for (const message of [ + `${field}=${secret}`, + json, + JSON.stringify(json), + encodeURIComponent(json), + `Bearer ${secret}`, + `Basic ${secret}`, + `token%20${secret}`, + `sk-${secret}`, + `sk-proj-${secret}`, + `github_pat_${secret}`, + ...["ghp_", "gho_", "ghu_", "ghs_", "ghr_", "npm_"].map( + (prefix) => `${prefix}${secret}`, + ), + `https://synthetic:${secret}@example.test/`, + ]) { + expect(safeErrorMessage(message)).toBe("[redacted]"); + expect(safeErrorMessage(new Error(message))).toBe("[redacted]"); + expect(errorMessage(new Error(message))).toBe(message); + } + }, + ), + propertyOptions, + ); + }); + + test("preserves ordinary diagnostic text exactly", () => { + fc.assert( + fc.property(fc.nat(), word, (index, detail) => { + const message = `operation failed for item ${index} (${detail})`; + expect(safeErrorMessage(message)).toBe(message); + expect(safeErrorMessage(new Error(message))).toBe(message); + }), + propertyOptions, + ); + }); +}); diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index 35be8c961..c710d5223 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -1,7 +1,63 @@ import { describe, expect, test } from "bun:test"; -import { errorMessage, safeErrorMessage } from "../src/errors.js"; +import { + CodexSecurityError, + OutputInsideProtectedRootError, + ScanCostLimitExceededError, + ScanInterruptedError, + errorMessage, + safeErrorMessage, +} from "../src/errors.js"; describe("error messages", () => { + test("preserves public error names, causes, and recovery details", () => { + const cause = new Error("synthetic cause"); + const base = new CodexSecurityError("synthetic failure", { cause }); + expect(base).toMatchObject({ + name: "CodexSecurityError", + message: "synthetic failure", + cause, + }); + const output = new OutputInsideProtectedRootError("/scan", "/repository"); + expect(output).toMatchObject({ + name: "OutputInsideProtectedRootError", + outputDirectory: "/scan", + protectedRoot: "/repository", + pathKind: "output", + }); + expect(output.message).toContain("/scan"); + expect( + new OutputInsideProtectedRootError("/runtime", "/repository", "runtime") + .pathKind, + ).toBe("runtime"); + expect( + new ScanInterruptedError("stopped", "/scan", { cause }), + ).toMatchObject({ + name: "ScanInterruptedError", + message: "stopped", + scanDir: "/scan", + cause, + }); + const cost = { + model: "synthetic", + inputTokens: 1, + cachedInputTokens: 0, + cacheWriteInputTokens: 0, + outputTokens: 1, + estimatedUsd: 2, + }; + const limit = new ScanCostLimitExceededError(1, cost, "/scan"); + expect(limit).toBeInstanceOf(ScanInterruptedError); + expect(limit).toMatchObject({ + name: "ScanCostLimitExceededError", + maxCostUsd: 1, + cost, + scanDir: "/scan", + }); + expect(limit.message).toContain("$2.00"); + expect(limit.message).toContain("$1.00"); + expect(limit.message).toContain("/scan"); + }); + test("preserves error messages exactly", () => { const message = "request failed: token=SYNTHETIC_TOKEN"; expect(errorMessage(new Error(message))).toBe(message); diff --git a/sdk/typescript/tests-ts/publication-store.test.ts b/sdk/typescript/tests-ts/publication-store.test.ts index 6fb9762ca..194230bfb 100644 --- a/sdk/typescript/tests-ts/publication-store.test.ts +++ b/sdk/typescript/tests-ts/publication-store.test.ts @@ -165,6 +165,72 @@ function publishedIssue( } describe("persisted finding publication associations", () => { + test("rolls back failed migrations without losing populated scan history", async () => { + const fixture = await publicationFixture({ count: 1 }); + await recordPublishedIssues( + fixture.publication, + [publishedIssue(fixture.publication, 0)], + fixture.environment, + ); + databaseRows( + fixture, + "INSERT INTO finding_triage (occurrence_id, status, close_reason, note, updated_at) VALUES (?, 'closed', 'false_positive', 'synthetic triage note', '2026-08-01T00:00:00Z')", + [fixture.publication.issues[0]!.occurrenceId], + ); + + const probe = spawnSync( + fixture.python, + [ + "-I", + "-B", + "-c", + `import json, sqlite3, sys +sys.path.insert(0, sys.argv[1]) +import workbench_db as db +import workbench_schema as schema + +connection = sqlite3.connect(sys.argv[2]) +connection.row_factory = sqlite3.Row +connection.execute("PRAGMA foreign_keys = ON") +db.apply_migrations(connection) +before = list(connection.iterdump()) +next_version = max(version for version, _, _ in schema.MIGRATIONS) + 1 +failing = (next_version, "synthetic failing migration", """ +CREATE TABLE synthetic_migration_probe (value TEXT); +INSERT INTO synthetic_migration_probe VALUES ('synthetic'); +DELETE FROM finding_triage; +INSERT INTO synthetic_missing_table VALUES (1); +""") +try: + schema.apply_migrations(connection, (*schema.MIGRATIONS, failing), db.now, db.backfill_security_targets) +except sqlite3.OperationalError as error: + assert "synthetic_missing_table" in str(error), error +else: + raise AssertionError("The injected migration must fail") +assert not connection.in_transaction +assert list(connection.iterdump()) == before, "Failed migration changed committed data or schema" +db.apply_migrations(connection) +db.apply_migrations(connection) +assert list(connection.iterdump()) == before, "Reapplying migrations changed existing history" +assert list(connection.execute("PRAGMA foreign_key_check")) == [] +assert connection.execute("PRAGMA integrity_check").fetchone()[0] == "ok" +print(json.dumps({table: connection.execute("SELECT COUNT(*) FROM " + table).fetchone()[0] for table in ("scans", "finding_occurrences", "finding_triage", "finding_publications")})) +connection.close() +`, + join(PLUGIN_ROOT, "scripts"), + join(fixture.stateDirectory, "workbench.sqlite3"), + ], + { encoding: "utf8", env: fixture.environment }, + ); + expect(probe.status, probe.stderr).toBe(0); + expect(JSON.parse(probe.stdout)).toEqual({ + scans: 1, + finding_occurrences: 1, + finding_triage: 1, + finding_publications: 1, + }); + }); + test("upgrades existing scan history and verifies every completed finding before publication", async () => { const fixture = await publicationFixture(); databaseRows(fixture, "DROP TABLE finding_publications"); diff --git a/sdk/typescript/tests-ts/scan-comparison.property.test.ts b/sdk/typescript/tests-ts/scan-comparison.property.test.ts new file mode 100644 index 000000000..d6b27bd90 --- /dev/null +++ b/sdk/typescript/tests-ts/scan-comparison.property.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test"; +import fc from "fast-check"; +import { + matchScanFindings, + type ScanComparisonInput, + type ScanComparisonOptions, + type ScanComparisonResult, +} from "../src/scan-comparison.js"; +import { propertyOptions } from "./support/property.js"; + +const identities = fc.uniqueArray(fc.uuid(), { minLength: 2, maxLength: 8 }); + +function fixture(ids: string[]) { + const input: ScanComparisonInput = { + before: ids.map((id) => ({ occurrenceId: `before-${id}` })), + after: ids.map((id) => ({ occurrenceId: `after-${id}` })), + }; + const result: ScanComparisonResult = { + matches: ids.slice(1).map((id) => ({ + beforeOccurrenceIds: [`before-${id}`], + afterOccurrenceIds: [`after-${id}`], + confidence: "high", + reason: "same synthetic control", + })), + uncertain: [ + { + beforeOccurrenceId: `before-${ids[0]}`, + afterOccurrenceId: `after-${ids[0]}`, + reason: "needs more evidence", + }, + ], + }; + return { input, result }; +} + +function compare( + input: ScanComparisonInput, + response: unknown, + options: Pick = {}, +) { + return matchScanFindings(input, { + ...options, + codex: { + startThread: () => ({ + run: async () => ({ finalResponse: JSON.stringify(response) }), + }), + }, + }); +} + +describe("finding-comparison invariants", () => { + test("preserves valid identities regardless of finding order", async () => { + await fc.assert( + fc.asyncProperty(identities, async (ids) => { + const { input, result } = fixture(ids); + await expect(compare(input, result)).resolves.toEqual(result); + await expect( + compare( + { + before: [...input.before].reverse(), + after: [...input.after].reverse(), + }, + result, + ), + ).resolves.toEqual(result); + }), + propertyOptions, + ); + }); + + test("rejects invented identities and duplicate confirmed assignments", async () => { + await fc.assert( + fc.asyncProperty(identities, async (ids) => { + const { input, result } = fixture(ids); + const first = result.matches[0]!; + for (const side of [ + "beforeOccurrenceIds", + "afterOccurrenceIds", + ] as const) { + await expect( + compare(input, { + ...result, + matches: [{ ...first, [side]: ["unknown-synthetic-occurrence"] }], + }), + ).rejects.toThrow(/unknown .* occurrence/u); + await expect( + compare(input, { + ...result, + matches: [ + { ...first, [side]: [...first[side], first[side][0]!] }, + ], + }), + ).rejects.toThrow(/more than once/u); + } + await expect( + compare(input, { ...result, matches: [...result.matches, first] }), + ).rejects.toThrow(/more than once/u); + }), + propertyOptions, + ); + }); + + test("keeps uncertain pairs unique and separate from confirmed matches", async () => { + await fc.assert( + fc.asyncProperty(identities, async (ids) => { + const { input, result } = fixture(ids); + const uncertain = result.uncertain[0]!; + await expect( + compare(input, { ...result, uncertain: [uncertain, uncertain] }), + ).rejects.toThrow(/duplicate uncertain pair/u); + const overlapsAfter = { + ...uncertain, + afterOccurrenceId: result.matches[0]!.afterOccurrenceIds[0]!, + }; + const historical = { ...result, uncertain: [overlapsAfter] }; + await expect(compare(input, historical)).rejects.toThrow( + /invalid uncertain pair/u, + ); + await expect( + compare(input, historical, { allowHistoricalUncertainty: true }), + ).resolves.toEqual(historical); + await expect( + compare( + input, + { + ...result, + uncertain: [ + { + ...uncertain, + beforeOccurrenceId: + result.matches[0]!.beforeOccurrenceIds[0]!, + }, + ], + }, + { allowHistoricalUncertainty: true }, + ), + ).rejects.toThrow(/invalid uncertain pair/u); + }), + propertyOptions, + ); + }); +}); diff --git a/sdk/typescript/tests-ts/support/property.ts b/sdk/typescript/tests-ts/support/property.ts new file mode 100644 index 000000000..cc5abbd9f --- /dev/null +++ b/sdk/typescript/tests-ts/support/property.ts @@ -0,0 +1,7 @@ +export const propertyOptions = { + seed: Number(process.env["CODEX_SECURITY_PROPERTY_SEED"] ?? "20260817"), + numRuns: Number(process.env["CODEX_SECURITY_PROPERTY_RUNS"] ?? "100"), + ...(process.env["CODEX_SECURITY_PROPERTY_PATH"] === undefined + ? {} + : { path: process.env["CODEX_SECURITY_PROPERTY_PATH"] }), +}; diff --git a/sdk/typescript/tests-ts/worker-progress.property.test.ts b/sdk/typescript/tests-ts/worker-progress.property.test.ts new file mode 100644 index 000000000..56d4dacfe --- /dev/null +++ b/sdk/typescript/tests-ts/worker-progress.property.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from "bun:test"; +import fc from "fast-check"; +import { + scanProgressUpdatesFromEvent, + workerStatusFromEvent, +} from "../src/worker-progress.js"; +import { propertyOptions } from "./support/property.js"; + +const count = fc.integer({ min: 0, max: Number.MAX_SAFE_INTEGER }); +const countInput = fc.oneof( + count, + fc.constantFrom(-1, 0.5, Number.MAX_SAFE_INTEGER + 1, null, "1"), +); +const phases = [ + "preflight", + "threat_model", + "discovery", + "validation", + "attack_path", + "reporting", +] as const; +const phase = fc.constantFrom(...phases); + +function message(text: string) { + return { + type: "item.completed", + item: { type: "agent_message", text }, + }; +} + +function validCounts(completed: unknown, total: unknown): boolean { + return ( + typeof completed === "number" && + typeof total === "number" && + Number.isSafeInteger(completed) && + Number.isSafeInteger(total) && + completed >= 0 && + completed <= total + ); +} + +describe("progress invariants", () => { + test("accepts exactly the valid progress count pairs", () => { + fc.assert( + fc.property( + phase, + countInput, + countInput, + (phase, filesCompleted, filesTotal) => { + const progress = { phase, filesCompleted, filesTotal }; + const marker = `CODEX_SECURITY_SCAN_PROGRESS ${JSON.stringify(progress)}`; + const expected = validCounts(filesCompleted, filesTotal) + ? [ + { + phase, + filesCompleted: Number(filesCompleted), + filesTotal: Number(filesTotal), + }, + ] + : []; + expect(scanProgressUpdatesFromEvent(message(marker))).toEqual( + expected, + ); + expect( + scanProgressUpdatesFromEvent({ + type: "item.completed", + item: { type: "command_execution", aggregated_output: marker }, + }), + ).toEqual(expected); + expect( + scanProgressUpdatesFromEvent( + message(`\`\`\`json\n${marker}\n\`\`\``), + ), + ).toEqual([]); + }, + ), + propertyOptions, + ); + }); + + test("never reports more started workers than were planned", () => { + fc.assert( + fc.property( + fc.constantFrom("ranking", "file_review", "validation", "attack_path"), + countInput, + countInput, + (phase, started, planned) => { + const payload = { phase, planned, started }; + const marker = `CODEX_SECURITY_WORKER_STATUS ${JSON.stringify(payload)}`; + expect(workerStatusFromEvent(message(marker))).toEqual( + validCounts(started, planned) + ? { + kind: "dispatch", + phase, + started: Number(started), + planned: Number(planned), + } + : null, + ); + expect( + workerStatusFromEvent(message(`${marker}\n${marker}`)), + ).toBeNull(); + }, + ), + propertyOptions, + ); + }); + + test("preserves zero, complete, and maximum-safe counts", () => { + fc.assert( + fc.property(count, (total) => { + for (const completed of [0, total]) { + for (const phase of phases) { + const progress = { + phase, + filesCompleted: completed, + filesTotal: total, + }; + expect( + scanProgressUpdatesFromEvent( + message( + `CODEX_SECURITY_SCAN_PROGRESS ${JSON.stringify(progress)}`, + ), + ), + ).toEqual([progress]); + } + const dispatch = { + phase: "ranking" as const, + planned: total, + started: completed, + }; + expect( + workerStatusFromEvent( + message( + `CODEX_SECURITY_WORKER_STATUS ${JSON.stringify(dispatch)}`, + ), + ), + ).toEqual({ kind: "dispatch", ...dispatch }); + } + }), + propertyOptions, + ); + }); +}); diff --git a/sdk/typescript/tests-ts/worker-progress.test.ts b/sdk/typescript/tests-ts/worker-progress.test.ts index 36b95b86b..a17c9b9fb 100644 --- a/sdk/typescript/tests-ts/worker-progress.test.ts +++ b/sdk/typescript/tests-ts/worker-progress.test.ts @@ -29,6 +29,61 @@ function messageEvent(text: string): Record { } describe("worker progress events", () => { + test("ignores incomplete and malformed event envelopes", () => { + const progress = + 'CODEX_SECURITY_SCAN_PROGRESS {"phase":"reporting","filesCompleted":1,"filesTotal":1}'; + const dispatch = + 'CODEX_SECURITY_WORKER_STATUS {"phase":"ranking","planned":1,"started":1}'; + for (const event of [ + {}, + { type: "item.completed", item: null }, + { type: "item.completed", item: [] }, + { ...messageEvent(progress), type: "item.started" }, + { ...messageEvent(dispatch), type: "item.updated" }, + { + type: "item.completed", + item: { type: "reasoning", text: `${progress}\n${dispatch}` }, + }, + { type: "item.completed", item: { type: "agent_message", text: null } }, + { + type: "item.completed", + item: { + type: "command_execution", + command: null, + aggregated_output: 1, + }, + }, + messageEvent( + "CODEX_SECURITY_SCAN_PROGRESS not-json\nCODEX_SECURITY_WORKER_STATUS null", + ), + ]) { + expect(workerStatusFromEvent(event)).toBeNull(); + expect(scanProgressUpdatesFromEvent(event)).toEqual([]); + } + }); + + test("rejects conflicting preflight capacity and ignores unrelated results", () => { + const delegated = { capability: "delegated_workers", status: "pass" }; + const capacity = { capability: "usable_worker_slots_2", actual: 2 }; + const preflight = (results: unknown[]) => + workerStatusFromEvent( + commandEvent( + "config_preflight.py", + JSON.stringify({ profile: "security_scan", results }), + ), + ); + expect( + preflight([null, {}, { capability: 42 }, delegated, capacity]), + ).toEqual({ + kind: "preflight", + delegation: "available", + configuredSlots: 2, + }); + expect(preflight([delegated, capacity, capacity])).toBeNull(); + expect(preflight([{ ...delegated, status: "invalid" }])).toBeNull(); + expect(preflight([])).toBeNull(); + }); + test("reads configured worker capacity from a completed preflight", () => { const output = JSON.stringify({ profile: "security_scan", From 2a228a2e0b8346f3cf80ba396ae59a43fc971b47 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 17 Aug 2026 12:30:14 -0700 Subject: [PATCH 3/6] test: make boundary properties reject real regressions --- sdk/typescript/tests-ts/contract.test.ts | 48 ++++++++++++------- .../tests-ts/worker-progress.property.test.ts | 5 +- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/sdk/typescript/tests-ts/contract.test.ts b/sdk/typescript/tests-ts/contract.test.ts index c52f20bd3..79c861051 100644 --- a/sdk/typescript/tests-ts/contract.test.ts +++ b/sdk/typescript/tests-ts/contract.test.ts @@ -155,33 +155,45 @@ describe("canonical scan contract", () => { const scanDir = await copyExample(); const manifestPath = join(scanDir, "scan-manifest.json"); const manifest = await readJson(manifestPath); + const prefixes = ["../", "/", "C:/", "artifacts/../", "artifacts\\"]; + await mkdir(join(scanDir, "artifacts"), { recursive: true }); await fc.assert( fc.asyncProperty( fc.stringMatching(/^[a-z]{1,16}$/u), - fc.constantFrom("../", "/", "C:/", "artifacts/../", "artifacts\\"), + fc.constantFrom(...prefixes), async (name, prefix) => { - await writeJson(manifestPath, { - ...manifest, - scan: { - ...manifest["scan"], - artifacts: [ - ...manifest["scan"]["artifacts"], - { - path: `${prefix}${name}`, - sha256: "0".repeat(64), - mediaType: "application/octet-stream", - }, - ], - }, - }); - await expect( - loadContract(scanDir, { pluginRoot: PLUGIN_ROOT }), - ).rejects.toBeInstanceOf(ContractValidationError); + const bytes = Buffer.from(name); + const artifact = { + path: `artifacts/${name}`, + sha256: createHash("sha256").update(bytes).digest("hex"), + mediaType: "application/octet-stream", + }; + const scan = { + ...manifest["scan"], + artifacts: [...manifest["scan"]["artifacts"], artifact], + }; + await writeFile(join(scanDir, "artifacts", name), bytes); + await writeJson(manifestPath, { ...manifest, scan }); + await loadContract(scanDir, { pluginRoot: PLUGIN_ROOT }); + + artifact.path = `${prefix}${name}`; + await writeJson(manifestPath, { ...manifest, scan }); + const rejected = loadContract(scanDir, { pluginRoot: PLUGIN_ROOT }); + await expect(rejected).rejects.toBeInstanceOf( + ContractValidationError, + ); + await expect(rejected).rejects.toThrow( + /safe scan-relative POSIX path|schema validation failed \(pattern/u, + ); }, ), { ...propertyOptions, numRuns: Number(process.env["CODEX_SECURITY_PROPERTY_RUNS"] ?? "20"), + examples: prefixes.map((prefix): [string, string] => [ + "synthetic", + prefix, + ]), }, ); }); diff --git a/sdk/typescript/tests-ts/worker-progress.property.test.ts b/sdk/typescript/tests-ts/worker-progress.property.test.ts index 56d4dacfe..f36f13a2c 100644 --- a/sdk/typescript/tests-ts/worker-progress.property.test.ts +++ b/sdk/typescript/tests-ts/worker-progress.property.test.ts @@ -138,7 +138,10 @@ describe("progress invariants", () => { ).toEqual({ kind: "dispatch", ...dispatch }); } }), - propertyOptions, + { + ...propertyOptions, + examples: [[0], [Number.MAX_SAFE_INTEGER]], + }, ); }); }); From d65ec7d7b4dbc6998fec2328cf4c6f6f85404f30 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 17 Aug 2026 12:41:45 -0700 Subject: [PATCH 4/6] test: keep generated artifact names portable to Windows --- sdk/typescript/tests-ts/contract.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/sdk/typescript/tests-ts/contract.test.ts b/sdk/typescript/tests-ts/contract.test.ts index 79c861051..17f9b7ddf 100644 --- a/sdk/typescript/tests-ts/contract.test.ts +++ b/sdk/typescript/tests-ts/contract.test.ts @@ -162,9 +162,10 @@ describe("canonical scan contract", () => { fc.stringMatching(/^[a-z]{1,16}$/u), fc.constantFrom(...prefixes), async (name, prefix) => { + const filename = `artifact-${name}`; const bytes = Buffer.from(name); const artifact = { - path: `artifacts/${name}`, + path: `artifacts/${filename}`, sha256: createHash("sha256").update(bytes).digest("hex"), mediaType: "application/octet-stream", }; @@ -172,11 +173,11 @@ describe("canonical scan contract", () => { ...manifest["scan"], artifacts: [...manifest["scan"]["artifacts"], artifact], }; - await writeFile(join(scanDir, "artifacts", name), bytes); + await writeFile(join(scanDir, "artifacts", filename), bytes); await writeJson(manifestPath, { ...manifest, scan }); await loadContract(scanDir, { pluginRoot: PLUGIN_ROOT }); - artifact.path = `${prefix}${name}`; + artifact.path = `${prefix}${filename}`; await writeJson(manifestPath, { ...manifest, scan }); const rejected = loadContract(scanDir, { pluginRoot: PLUGIN_ROOT }); await expect(rejected).rejects.toBeInstanceOf( @@ -190,10 +191,10 @@ describe("canonical scan contract", () => { { ...propertyOptions, numRuns: Number(process.env["CODEX_SECURITY_PROPERTY_RUNS"] ?? "20"), - examples: prefixes.map((prefix): [string, string] => [ - "synthetic", - prefix, - ]), + examples: [ + ...prefixes.map((prefix): [string, string] => ["synthetic", prefix]), + ["con", "C:/"], + ], }, ); }); From e038344e35131ad0f6430df7786a18bf62b9aa7d Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 17 Aug 2026 13:00:55 -0700 Subject: [PATCH 5/6] test: cover progress event and Markdown boundary cases --- .../tests-ts/worker-progress.test.ts | 58 +++++++++++++++---- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/sdk/typescript/tests-ts/worker-progress.test.ts b/sdk/typescript/tests-ts/worker-progress.test.ts index a17c9b9fb..557559fa0 100644 --- a/sdk/typescript/tests-ts/worker-progress.test.ts +++ b/sdk/typescript/tests-ts/worker-progress.test.ts @@ -34,6 +34,10 @@ describe("worker progress events", () => { 'CODEX_SECURITY_SCAN_PROGRESS {"phase":"reporting","filesCompleted":1,"filesTotal":1}'; const dispatch = 'CODEX_SECURITY_WORKER_STATUS {"phase":"ranking","planned":1,"started":1}'; + const preflight = JSON.stringify({ + profile: "security_scan", + results: [{ capability: "delegated_workers", status: "pass" }], + }); for (const event of [ {}, { type: "item.completed", item: null }, @@ -44,7 +48,27 @@ describe("worker progress events", () => { type: "item.completed", item: { type: "reasoning", text: `${progress}\n${dispatch}` }, }, + { + type: "item.completed", + item: { type: "reasoning", aggregated_output: progress }, + }, { type: "item.completed", item: { type: "agent_message", text: null } }, + { + type: "item.completed", + item: { + type: "command_execution", + command: ["config_preflight.py"], + aggregated_output: preflight, + }, + }, + { + type: "item.completed", + item: { + type: "command_execution", + command: "config_preflight.py", + aggregated_output: [preflight], + }, + }, { type: "item.completed", item: { @@ -288,19 +312,33 @@ describe("worker progress events", () => { }); test("does not mistake documented examples for real scan progress", () => { + const progress = { + phase: "discovery" as const, + filesCompleted: 3, + filesTotal: 8, + }; + const marker = `CODEX_SECURITY_SCAN_PROGRESS ${JSON.stringify(progress)}`; + for (const indent of ["", " ", " ", " "]) { + expect( + scanProgressUpdatesFromEvent( + commandEvent( + "read the scan workflow", + [ + "Example progress:", + `${indent}\`\`\`text`, + marker, + `${indent}\`\`\``, + marker, + ].join("\n"), + ), + ), + ).toEqual([progress]); + } expect( scanProgressUpdatesFromEvent( - commandEvent( - "read the scan workflow", - [ - "Example progress:", - "```text", - 'CODEX_SECURITY_SCAN_PROGRESS {"phase":"discovery","filesCompleted":3,"filesTotal":8}', - "```", - ].join("\n"), - ), + messageEvent(`Inline \`\`\` is not a fence.\n${marker}`), ), - ).toEqual([]); + ).toEqual([progress]); }); test("rejects malformed or overstated file progress", () => { From dbe0392de8b66feae1f5028932af94bb88804a1c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 17 Aug 2026 16:03:47 -0700 Subject: [PATCH 6/6] ci: preserve the Windows policy test timeout --- .github/workflows/node-ci.yml | 2 +- sdk/typescript/tests-ts/skeleton.test.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index ae0998420..c6398851d 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -160,7 +160,7 @@ jobs: TMP: ${{ steps.windows-temp.outputs.path }} TMPDIR: ${{ steps.windows-temp.outputs.path }} CODEX_SECURITY_ALLOW_MACHINE_POLICY_TEST: "true" - run: bun test --timeout 30000 ./tests-ts/windows-machine-policy.test.ts + run: bun test --timeout 120000 ./tests-ts/windows-machine-policy.test.ts - name: Typecheck if: matrix.shard == 7 diff --git a/sdk/typescript/tests-ts/skeleton.test.ts b/sdk/typescript/tests-ts/skeleton.test.ts index dc72f52fe..25ee45cdf 100644 --- a/sdk/typescript/tests-ts/skeleton.test.ts +++ b/sdk/typescript/tests-ts/skeleton.test.ts @@ -62,7 +62,7 @@ describe("TypeScript package skeleton", () => { } }); - test("uses the default test timeout consistently across CI platforms", async () => { + test("keeps the default and Windows CI test timeouts", async () => { const packageJson = JSON.parse( await readFile(new URL("../package.json", import.meta.url), "utf8"), ); @@ -77,6 +77,9 @@ describe("TypeScript package skeleton", () => { expect(ciWorkflow).toContain( "run: node sdk/typescript/scripts/run-windows-ci-tests.mjs ${{ matrix.shard }}", ); + expect(ciWorkflow).toContain( + "run: bun test --timeout 120000 ./tests-ts/windows-machine-policy.test.ts", + ); expect(ciWorkflow).toContain( "name: windows-latest / node-${{ matrix.node == '22.13.0' && '22' || matrix.node }}", );