From 5cd35b04b189494b08c110dee015f1f505cfe064 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Mon, 31 Aug 2026 21:31:59 +0000 Subject: [PATCH 01/12] refactor(ci): port source and test report checks to JavaScript --- .../check_plugin_source_compatibility.mjs | 163 ++++++++++++ .../check_plugin_source_compatibility.py | 152 ----------- ...test_check_plugin_source_compatibility.mjs | 171 +++++++++++++ .../test_check_plugin_source_compatibility.py | 159 ------------ .github/workflows/node-ci.yml | 28 +- .github/workflows/test-quality.yml | 14 +- AGENTS.md | 7 +- sdk/typescript/package.json | 2 + sdk/typescript/pnpm-lock.yaml | 19 ++ .../scripts/compare-test-reports.mjs | 239 ++++++++++++++++++ .../scripts/compare-test-reports.py | 84 ------ .../tests-ts/release-automation.test.ts | 6 +- sdk/typescript/tests-ts/test-reports.test.ts | 148 ++++++++++- 13 files changed, 768 insertions(+), 424 deletions(-) create mode 100644 .github/scripts/check_plugin_source_compatibility.mjs delete mode 100644 .github/scripts/check_plugin_source_compatibility.py create mode 100644 .github/scripts/test_check_plugin_source_compatibility.mjs delete mode 100644 .github/scripts/test_check_plugin_source_compatibility.py create mode 100644 sdk/typescript/scripts/compare-test-reports.mjs delete mode 100644 sdk/typescript/scripts/compare-test-reports.py diff --git a/.github/scripts/check_plugin_source_compatibility.mjs b/.github/scripts/check_plugin_source_compatibility.mjs new file mode 100644 index 000000000..0b7b9cefe --- /dev/null +++ b/.github/scripts/check_plugin_source_compatibility.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node +// Check that tracked plugin source stays portable across repository imports. + +import { spawnSync } from "node:child_process"; +import { lstatSync, readFileSync } from "node:fs"; +import { basename, extname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +const MAX_SOURCE_FILE_BYTES = 150_000; +const MAX_DEPENDENCY_LOCK_BYTES = 2_000_000; +const DEPENDENCY_LOCK_NAMES = new Set([ + "Cargo.lock", + "package-lock.json", + "pnpm-lock.yaml", + "requirements.txt", + "uv.lock", + "yarn.lock", +]); +const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/u; +const HTML_BLOCK = /^\s*<\/?[A-Za-z][^>]*>\s*$/u; +const NATURAL_LINE_ENDINGS = new Set("\\.?!:;。!?:;)]}'\"`>"); +const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + +function trackedFiles(pluginRoot) { + const result = spawnSync( + "git", + ["-C", pluginRoot, "ls-files", "-z", "--", "."], + { + maxBuffer: Infinity, + }, + ); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(result.stderr.toString().trim() || "git ls-files failed"); + } + return utf8.decode(result.stdout).split("\0").filter(Boolean); +} + +function isMarkdownStructure(line) { + const stripped = line.trim(); + return ( + !stripped || + ["#", ">", "|", "\n::directive\n
\n code\n\tmore code\n", + "A list\n- first\n1. second\n2) third\n", + "Complete.\nQuestion?\nBang!\nColon:\nSemicolon;\n。\n!\n?\n:\n;\nParen)\nBracket]\nBrace}\nQuote'\nDouble\"\nCode`\nAngle>\nBackslash\\\nBreak \nhttps://example.invalid/url\nlast\n", + ].map((content, index) => [`example-${index}.md`, content]), + ), + ); + const result = runChecker("--plugin-root", root); + assert.equal(result.status, 0, result.stderr); +}); + +test("rejects hard wraps after closed front matter and fences, including CRLF", () => { + const root = fixture({ + "README.MD": + "---\r\ntitle: Example\r\n---\r\n```\r\ncode\r\n```\r\nThis continues\r\non another line.\r\n", + }); + const result = runChecker("--plugin-root", root); + assert.equal(result.status, 1); + assert.match(result.stderr, /README\.MD:7: prose is hard-wrapped/u); +}); + +test("rejects dependency lock files above two megabytes", () => { + const root = fixture({ "pnpm-lock.yaml": "x".repeat(2_000_001) }); + const result = runChecker("--plugin-root", root); + assert.equal(result.status, 1); + assert.equal( + result.stderr, + "pnpm-lock.yaml: file is 2000001 bytes; maximum is 2000000 bytes\n", + ); +}); + +test( + "does not follow tracked symlinks outside the plugin", + { skip: process.platform === "win32" }, + () => { + const outside = fixture({ + "outside.md": "This outside prose continues\nonto another source line.\n", + }); + const root = fixture(); + symlinkSync(join(outside, "outside.md"), join(root, "linked.md")); + git(root, "add", "--", "linked.md"); + const result = runChecker("--plugin-root", root); + assert.equal(result.status, 0, result.stderr); + }, +); + +test("reports Git, missing tracked files, and invalid UTF-8 as check failures", () => { + const root = fixture({ "README.md": Buffer.from([0xff]) }); + for (const action of [ + () => {}, + () => unlinkSync(join(root, "README.md")), + () => rmSync(join(root, ".git"), { recursive: true }), + ]) { + action(); + const result = runChecker("--plugin-root", root); + assert.equal(result.status, 2); + assert.match(result.stderr, /^source compatibility check failed:/u); + } +}); + +test("help describes the source contract and invalid arguments fail", () => { + const result = runChecker("--help"); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /tracked plugin source/u); + assert.equal(runChecker("--plugin-root").status, 2); + assert.equal(runChecker("--unknown").status, 2); +}); diff --git a/.github/scripts/test_check_plugin_source_compatibility.py b/.github/scripts/test_check_plugin_source_compatibility.py deleted file mode 100644 index f49a80420..000000000 --- a/.github/scripts/test_check_plugin_source_compatibility.py +++ /dev/null @@ -1,159 +0,0 @@ -from __future__ import annotations - -import os -import subprocess -import sys -from pathlib import Path - -import pytest - -CHECKER = Path(__file__).with_name("check_plugin_source_compatibility.py") - - -def initialize_repository(root: Path) -> None: - subprocess.run(["git", "init", "--quiet", str(root)], check=True) - - -def track(root: Path, *paths: str) -> None: - subprocess.run(["git", "-C", str(root), "add", "--", *paths], check=True) - - -def run_checker(root: Path, *args: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [sys.executable, str(CHECKER), "--plugin-root", str(root), *args], - check=False, - capture_output=True, - text=True, - ) - - -def test_reports_tracked_source_violations_in_stable_order(tmp_path: Path) -> None: - (tmp_path / "notes.md").write_text( - "This prose continues in the middle of a sentence\nonto another source line.\n", - encoding="utf-8", - ) - (tmp_path / "oversized.py").write_bytes(b"x" * 150_001) - initialize_repository(tmp_path) - track(tmp_path, "oversized.py", "notes.md") - - result = run_checker(tmp_path) - - assert result.returncode == 1 - assert result.stdout == "" - assert result.stderr.splitlines() == [ - "notes.md:1: prose is hard-wrapped mid-sentence; use a natural Markdown line", - "oversized.py: file is 150001 bytes; maximum is 150000 bytes", - ] - - -def test_accepts_valid_source_and_ignores_untracked_files(tmp_path: Path) -> None: - (tmp_path / "README.md").write_text("A complete sentence.\n", encoding="utf-8") - (tmp_path / "package-lock.json").write_bytes(b"x" * 150_001) - (tmp_path / "untracked.md").write_text( - "This untracked prose continues\nonto another source line.\n", - encoding="utf-8", - ) - initialize_repository(tmp_path) - track(tmp_path, "README.md", "package-lock.json") - - result = run_checker(tmp_path) - - assert result.returncode == 0, result.stderr - assert result.stdout == "Plugin source compatibility checks passed.\n" - assert result.stderr == "" - - -def test_python_checkout_preserves_source_size_with_autocrlf(tmp_path: Path) -> None: - attributes = CHECKER.parents[2] / ".gitattributes" - (tmp_path / ".gitattributes").write_bytes(attributes.read_bytes()) - source = tmp_path / "module.py" - content = b"pass\n" * 30_000 - source.write_bytes(content) - initialize_repository(tmp_path) - subprocess.run( - ["git", "-C", str(tmp_path), "config", "--local", "core.autocrlf", "true"], - check=True, - ) - track(tmp_path, ".gitattributes", "module.py") - source.unlink() - subprocess.run( - ["git", "-C", str(tmp_path), "checkout-index", "--", "module.py"], - check=True, - ) - - result = run_checker(tmp_path) - - assert result.returncode == 0, result.stderr - assert source.read_bytes() == content - - -def test_accepts_prose_after_an_opening_thematic_break(tmp_path: Path) -> None: - (tmp_path / "README.md").write_text( - """--- -This prose continues -onto another source line. -""", - encoding="utf-8", - ) - initialize_repository(tmp_path) - track(tmp_path, "README.md") - - result = run_checker(tmp_path) - - assert result.returncode == 0, result.stderr - - -@pytest.mark.parametrize( - "content", - [ - "First clause,\ncontinues here.\n", - "First clause\n**continues** here.\n", - ], -) -def test_accepts_wraps_adjacent_to_inline_markup(tmp_path: Path, content: str) -> None: - (tmp_path / "README.md").write_text(content, encoding="utf-8") - initialize_repository(tmp_path) - track(tmp_path, "README.md") - - result = run_checker(tmp_path) - - assert result.returncode == 0, result.stderr - - -def test_rejects_dependency_lock_files_above_two_megabytes(tmp_path: Path) -> None: - (tmp_path / "pnpm-lock.yaml").write_bytes(b"x" * 2_000_001) - initialize_repository(tmp_path) - track(tmp_path, "pnpm-lock.yaml") - - result = run_checker(tmp_path) - - assert result.returncode == 1 - assert result.stderr == ("pnpm-lock.yaml: file is 2000001 bytes; maximum is 2000000 bytes\n") - - -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks requires elevated Windows access") -def test_does_not_follow_tracked_symlinks_outside_the_plugin(tmp_path: Path) -> None: - outside = tmp_path.parent / f"{tmp_path.name}-outside.md" - outside.write_text( - "This outside prose continues\nonto another source line.\n", - encoding="utf-8", - ) - (tmp_path / "linked.md").symlink_to(outside) - initialize_repository(tmp_path) - track(tmp_path, "linked.md") - - result = run_checker(tmp_path) - - assert result.returncode == 0, result.stderr - - -def test_help_describes_the_source_contract() -> None: - result = subprocess.run( - [sys.executable, str(CHECKER), "--help"], - check=False, - capture_output=True, - text=True, - ) - - assert result.returncode == 0, result.stderr - assert "tracked plugin source" in result.stdout diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 024dc5a15..b065f9563 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -82,11 +82,6 @@ jobs: exit 1 fi - - name: Check plugin source compatibility - if: steps.scope.outputs.ci-mode == 'markdown' - run: | - python .github/scripts/check_plugin_source_compatibility.py - - name: Set up pnpm if: steps.scope.outputs.check-markdown == 'true' uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 @@ -101,6 +96,11 @@ jobs: with: node-version: "22.13.0" + - name: Check plugin source compatibility + if: steps.scope.outputs.ci-mode == 'markdown' + run: | + node .github/scripts/check_plugin_source_compatibility.mjs + - name: Install dependencies if: steps.scope.outputs.check-markdown == 'true' run: pnpm --dir sdk/typescript install --frozen-lockfile @@ -362,6 +362,10 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: "22.13.0" - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: @@ -376,11 +380,13 @@ jobs: sudo apt-get install --yes ripgrep - name: Check plugin source compatibility run: | - python -m ruff check --config plugins/codex-security/pyproject.toml .github/scripts/check_plugin_source_compatibility.py .github/scripts/test_check_plugin_source_compatibility.py plugins/codex-security - python -m ruff format --check --config plugins/codex-security/pyproject.toml .github/scripts/check_plugin_source_compatibility.py .github/scripts/test_check_plugin_source_compatibility.py plugins/codex-security - python .github/scripts/check_plugin_source_compatibility.py + python -m ruff check --config plugins/codex-security/pyproject.toml plugins/codex-security + python -m ruff format --check --config plugins/codex-security/pyproject.toml plugins/codex-security + node .github/scripts/check_plugin_source_compatibility.mjs + - name: Test source compatibility checker + run: node --test .github/scripts/test_check_plugin_source_compatibility.mjs - name: Test Python source contracts - run: python -m pytest .github/scripts/test_check_plugin_source_compatibility.py plugins/codex-security/tests -q -n 4 --dist worksteal --max-worker-restart 0 --durations=30 --junitxml=reports/python.xml + run: python -m pytest plugins/codex-security/tests -q -n 4 --dist worksteal --max-worker-restart 0 --durations=30 --junitxml=reports/python.xml - name: Upload Python test reports if: always() continue-on-error: true @@ -391,10 +397,6 @@ jobs: path: reports/python.xml if-no-files-found: warn retention-days: 14 - - name: Set up Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - with: - node-version: "22.13.0" - name: Test deterministic triage eval contracts working-directory: plugins/codex-security/skills/triage-finding/evals run: node --run test:deterministic diff --git a/.github/workflows/test-quality.yml b/.github/workflows/test-quality.yml index 1400b02bc..0520fc1ec 100644 --- a/.github/workflows/test-quality.yml +++ b/.github/workflows/test-quality.yml @@ -113,6 +113,16 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: "22.13.0" + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: package.json + cache: true + cache_dependency_path: sdk/typescript/pnpm-lock.yaml + - name: Install comparison dependencies + run: pnpm --dir sdk/typescript install --frozen-lockfile - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: runner-* @@ -124,10 +134,10 @@ jobs: comparison_status=0 for os in ubuntu-latest windows-latest; do for mode in isolated parallel; do - python3 sdk/typescript/scripts/compare-test-reports.py "reports/runner-$os-baseline.xml" "reports/runner-$os-$mode.xml" >> "$GITHUB_STEP_SUMMARY" || comparison_status=1 + node sdk/typescript/scripts/compare-test-reports.mjs "reports/runner-$os-baseline.xml" "reports/runner-$os-$mode.xml" >> "$GITHUB_STEP_SUMMARY" || comparison_status=1 done done - python3 sdk/typescript/scripts/compare-test-reports.py reports/runner-windows-latest-baseline.xml 'reports/runner-windows-latest-shard-*.xml' >> "$GITHUB_STEP_SUMMARY" || comparison_status=1 + node sdk/typescript/scripts/compare-test-reports.mjs reports/runner-windows-latest-baseline.xml 'reports/runner-windows-latest-shard-*.xml' >> "$GITHUB_STEP_SUMMARY" || comparison_status=1 exit "$comparison_status" mutation: diff --git a/AGENTS.md b/AGENTS.md index a395c4c4a..0f3f83636 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,9 +18,10 @@ When changing `plugins/codex-security`, run its portable source checks before submitting the change: ```bash -python -m ruff check --config plugins/codex-security/pyproject.toml .github/scripts/check_plugin_source_compatibility.py .github/scripts/test_check_plugin_source_compatibility.py plugins/codex-security -python -m ruff format --check --config plugins/codex-security/pyproject.toml .github/scripts/check_plugin_source_compatibility.py .github/scripts/test_check_plugin_source_compatibility.py plugins/codex-security -python .github/scripts/check_plugin_source_compatibility.py +python -m ruff check --config plugins/codex-security/pyproject.toml plugins/codex-security +python -m ruff format --check --config plugins/codex-security/pyproject.toml plugins/codex-security +node .github/scripts/check_plugin_source_compatibility.mjs +node --test .github/scripts/test_check_plugin_source_compatibility.mjs ``` ## Avoid speculative defenses diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 302355d39..ff37bea81 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -98,9 +98,11 @@ "fast-check": "4.9.0", "ink-testing-library": "4.0.0", "json-schema-to-typescript": "15.0.4", + "minimatch": "10.2.6", "postcss": "8.5.23", "prettier": "3.2.5", "react-dom": "19.2.4", + "saxes": "6.0.0", "tailwindcss": "4.3.3", "typescript": "5.7.3" } diff --git a/sdk/typescript/pnpm-lock.yaml b/sdk/typescript/pnpm-lock.yaml index 438eb4021..7e904b328 100644 --- a/sdk/typescript/pnpm-lock.yaml +++ b/sdk/typescript/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: json-schema-to-typescript: specifier: 15.0.4 version: 15.0.4 + minimatch: + specifier: 10.2.6 + version: 10.2.6 postcss: specifier: 8.5.23 version: 8.5.23 @@ -108,6 +111,9 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) + saxes: + specifier: 6.0.0 + version: 6.0.0 tailwindcss: specifier: 4.3.3 version: 4.3.3 @@ -2682,6 +2688,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2946,6 +2956,9 @@ packages: utf-8-validate: optional: true + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -5897,6 +5910,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -6145,6 +6162,8 @@ snapshots: ws@8.21.3: {} + xmlchars@2.2.0: {} + yallist@3.1.1: {} yaml@2.9.0: {} diff --git a/sdk/typescript/scripts/compare-test-reports.mjs b/sdk/typescript/scripts/compare-test-reports.mjs new file mode 100644 index 000000000..faa02ef4a --- /dev/null +++ b/sdk/typescript/scripts/compare-test-reports.mjs @@ -0,0 +1,239 @@ +// Compare Bun JUnit inventories before changing the required CI runner. +import { lstatSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { basename, dirname, join, sep } from "node:path"; +import { parseArgs } from "node:util"; +import { minimatch } from "minimatch"; +import { SaxesParser } from "saxes"; + +function matchingReports(pattern) { + // Python glob treats ** as one component and does not expand braces/extglobs. + const directoriesOnly = + pattern.endsWith(sep) || + (process.platform === "win32" && pattern.endsWith("/")); + if (!/[*?[]/u.test(pattern)) { + try { + const stat = directoriesOnly ? statSync(pattern) : lstatSync(pattern); + return !directoriesOnly || stat.isDirectory() ? [pattern] : []; + } catch { + return []; + } + } + const parent = dirname(pattern); + const namePattern = basename(pattern); + const directories = /[*?[]/u.test(parent) + ? matchingReports(parent) + : [parent]; + return directories.flatMap((directory) => { + let names; + try { + names = readdirSync(directory); + } catch { + return []; + } + return names + .filter( + (name) => + (!name.startsWith(".") || namePattern.startsWith(".")) && + minimatch( + name, + namePattern.replaceAll("\\", "\\\\").replaceAll("[^", "[\\^"), + { + dot: true, + nobrace: true, + noext: true, + noglobstar: true, + nonegate: true, + nocomment: true, + nocase: process.platform === "win32", + }, + ), + ) + .map((name) => join(directory, name)) + .filter((path) => { + if (!directoriesOnly) return true; + try { + return statSync(path).isDirectory(); + } catch { + return false; + } + }); + }); +} + +function integer(value) { + if (!/^[+-]?\d(?:_?\d)*$/u.test(value.trim())) { + throw new Error(`invalid integer: ${value}`); + } + return BigInt(value.replaceAll("_", "").trim()); +} + +function seconds(value) { + const number = value.trim().replaceAll(/(?<=\d)_(?=\d)/gu, ""); + if ( + !/^[+-]?(?:(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?|inf(?:inity)?|nan)$/iu.test( + number, + ) + ) { + throw new Error(`invalid duration: ${value}`); + } + return Number(number.replace(/inf(?:inity)?/iu, "Infinity")); +} + +function readReport(path) { + const parser = new SaxesParser({ xmlns: true, fileName: path }); + let root; + const stack = []; + const records = []; + const suites = []; + parser.on("opentag", (node) => { + root ??= node; + const name = node.uri ? `{${node.uri}}${node.local}` : node.local; + const parent = stack.at(-1); + if (parent?.record) { + if (name === "failure" || name === "error") + parent.record.status = "failed"; + else if (name === "skipped" && parent.record.status === "passed") + parent.record.status = "skipped"; + } + let record; + if (name === "testcase") { + record = { attributes: node.attributes, status: "passed" }; + records.push(record); + } + if (name === "testsuite" || name === "testsuites") suites.push(node); + stack.push({ record }); + }); + parser.on("closetag", () => stack.pop()); + const content = new TextDecoder("utf-8", { fatal: true }).decode( + readFileSync(path), + ); + parser.write(content).close(); + + const cases = new Map(); + for (const { attributes, status } of records) { + const identity = [ + (attributes.file?.value ?? "") + .replaceAll("\\", "/") + .replace(/^\.\//u, ""), + attributes.classname?.value ?? "", + attributes.name?.value ?? "", + ]; + const key = JSON.stringify(identity); + if (cases.has(key)) + throw new Error( + `${path}: duplicate test identity: ${identity.join(" > ")}`, + ); + cases.set(key, status); + } + if (!cases.size) throw new Error(`${path}: no test cases`); + if ( + integer(root.attributes.tests?.value ?? String(cases.size)) !== + BigInt(cases.size) + ) { + throw new Error(`${path}: reported test count does not match test cases`); + } + const failed = + [...cases.values()].includes("failed") || + suites.some((node) => + ["failures", "errors"].some( + (field) => integer(node.attributes[field]?.value ?? "0") !== 0n, + ), + ); + if (failed) console.error(`${path}: test run failed`); + const duration = seconds(root.attributes.time?.value ?? "0"); + const skipped = [...cases.values()].filter( + (status) => status === "skipped", + ).length; + console.log( + `| ${basename(path)} | ${cases.size} | ${skipped} | ${duration.toFixed(2)} |`, + ); + return { + cases: new Map( + [...cases].map(([identity, status]) => [ + JSON.stringify([...JSON.parse(identity), status]), + 1, + ]), + ), + duration, + failed, + }; +} + +function main() { + let args; + try { + args = parseArgs({ + options: { help: { type: "boolean", short: "h" } }, + allowPositionals: true, + }); + if (!args.values.help && args.positionals.length < 2) + throw new Error( + "a baseline and at least one candidate report are required", + ); + } catch (error) { + console.error(error.message); + return 2; + } + if (args.values.help) { + console.log( + "Compare Bun JUnit inventories before changing the required CI runner.\n\nUsage: node compare-test-reports.mjs baseline candidates [candidates ...]\nCandidates are JUnit files or glob patterns.", + ); + return 0; + } + console.log("| Report | Cases | Skipped | Seconds |"); + console.log("| --- | ---: | ---: | ---: |"); + const baseline = readReport(args.positionals[0]); + let failed = baseline.failed; + const candidates = new Map(); + const durations = []; + for (const pattern of args.positionals.slice(1)) { + const paths = matchingReports(pattern).sort((a, b) => + Buffer.compare(Buffer.from(a), Buffer.from(b)), + ); + if (!paths.length) throw new Error(`No reports match ${pattern}`); + for (const path of paths) { + const report = readReport(path); + failed ||= report.failed; + for (const [identity, count] of report.cases) + candidates.set(identity, (candidates.get(identity) ?? 0) + count); + durations.push(report.duration); + } + } + for (const [label, left, right] of [ + ["Missing", baseline.cases, candidates], + ["Extra", candidates, baseline.cases], + ]) { + const differences = [...left] + .map(([identity, count]) => [ + JSON.parse(identity), + count - (right.get(identity) ?? 0), + ]) + .filter(([, count]) => count > 0) + .sort(([a], [b]) => { + for (let index = 0; index < a.length; index++) { + const order = Buffer.compare( + Buffer.from(a[index]), + Buffer.from(b[index]), + ); + if (order) return order; + } + return 0; + }); + for (const [identity, count] of differences) { + failed = true; + console.error(`${label} (${count}): ${identity.join(" > ")}`); + } + } + if (failed) return 1; + console.log( + `\nIdentical test inventory and outcomes. Slowest candidate: ${Math.max(...durations).toFixed(2)}s; combined test time: ${durations.reduce((sum, duration) => sum + duration, 0).toFixed(2)}s.\n`, + ); + return 0; +} + +try { + process.exitCode = main(); +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} diff --git a/sdk/typescript/scripts/compare-test-reports.py b/sdk/typescript/scripts/compare-test-reports.py deleted file mode 100644 index 6f735eed4..000000000 --- a/sdk/typescript/scripts/compare-test-reports.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Compare Bun JUnit inventories before changing the required CI runner.""" - -import argparse -from collections import Counter -from glob import glob -from pathlib import Path -import sys -import xml.etree.ElementTree as ET - - -def read_report(path: Path) -> tuple[Counter, float, bool]: - root = ET.parse(path).getroot() - cases = {} - for case in root.iter("testcase"): - status = "passed" - if case.find("skipped") is not None: - status = "skipped" - if case.find("failure") is not None or case.find("error") is not None: - status = "failed" - identity = ( - case.get("file", "").replace("\\", "/").removeprefix("./"), - case.get("classname", ""), - case.get("name", ""), - ) - if identity in cases: - raise ValueError(f"{path}: duplicate test identity: {' > '.join(identity)}") - cases[identity] = status - if not cases: - raise ValueError(f"{path}: no test cases") - if int(root.get("tests", str(len(cases)))) != len(cases): - raise ValueError(f"{path}: reported test count does not match test cases") - failed = "failed" in cases.values() or any( - int(node.get(field, "0")) - for node in root.iter() - if node.tag in ("testsuite", "testsuites") - for field in ("failures", "errors") - ) - if failed: - print(f"{path}: test run failed", file=sys.stderr) - seconds = float(root.get("time", "0")) - skipped = sum(status == "skipped" for status in cases.values()) - print(f"| {path.name} | {len(cases)} | {skipped} | {seconds:.2f} |") - return ( - Counter((*identity, status) for identity, status in cases.items()), - seconds, - failed, - ) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("baseline", type=Path) - parser.add_argument("candidates", nargs="+", help="JUnit files or glob patterns") - args = parser.parse_args() - print("| Report | Cases | Skipped | Seconds |") - print("| --- | ---: | ---: | ---: |") - baseline, _, failed = read_report(args.baseline) - candidates = Counter() - durations = [] - for pattern in args.candidates: - paths = sorted(glob(pattern)) - if not paths: - raise ValueError(f"No reports match {pattern}") - for path in paths: - cases, seconds, report_failed = read_report(Path(path)) - failed = failed or report_failed - candidates.update(cases) - durations.append(seconds) - missing, extra = baseline - candidates, candidates - baseline - if failed or missing or extra: - for label, difference in (("Missing", missing), ("Extra", extra)): - for identity, count in sorted(difference.items()): - print(f"{label} ({count}): {' > '.join(identity)}", file=sys.stderr) - return 1 - print(f"\nIdentical test inventory and outcomes. Slowest candidate: {max(durations):.2f}s; combined test time: {sum(durations):.2f}s.\n") - return 0 - - -if __name__ == "__main__": - try: - sys.exit(main()) - except (OSError, ValueError, ET.ParseError) as error: - print(error, file=sys.stderr) - sys.exit(1) diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index 2b6dde7ab..691076aeb 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -4173,10 +4173,10 @@ describe("GitHub release workflow safeguards", () => { mkdirSync(scripts, { recursive: true }); mkdirSync(pluginRoot, { recursive: true }); writeFileSync( - join(scripts, "check_plugin_source_compatibility.py"), + join(scripts, "check_plugin_source_compatibility.mjs"), readFileSync( new URL( - "../../../.github/scripts/check_plugin_source_compatibility.py", + "../../../.github/scripts/check_plugin_source_compatibility.mjs", import.meta.url, ), ), @@ -4188,7 +4188,7 @@ describe("GitHub release workflow safeguards", () => { workspace, "add", "--", - ".github/scripts/check_plugin_source_compatibility.py", + ".github/scripts/check_plugin_source_compatibility.mjs", "plugins/codex-security/README.md", ]); try { diff --git a/sdk/typescript/tests-ts/test-reports.test.ts b/sdk/typescript/tests-ts/test-reports.test.ts index d85fba7bf..746bf26e5 100644 --- a/sdk/typescript/tests-ts/test-reports.test.ts +++ b/sdk/typescript/tests-ts/test-reports.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -41,15 +41,11 @@ function testcase(name: string, status = "") { } async function compare(baseline: string, ...candidates: string[]) { - const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); - if (python === null) throw new Error("A Python interpreter is required."); const child = Bun.spawn({ cmd: [ - python, - "-I", - "-B", + "node", fileURLToPath( - new URL("../scripts/compare-test-reports.py", import.meta.url), + new URL("../scripts/compare-test-reports.mjs", import.meta.url), ), baseline, ...candidates, @@ -88,7 +84,7 @@ describe("JUnit inventory comparison", () => { ), "reports/runner-windows-latest-shard-*.xml", ]; - const mock = `python3() { + const mock = `node() { printf '%s\\n' "$3" [[ "$3" != "$CODEX_SECURITY_TEST_FAIL_REPORT" ]] }`; @@ -128,9 +124,145 @@ describe("JUnit inventory comparison", () => { await fixture.report("shard-2.xml", [passed]); const result = await compare(baseline, join(fixture.root, "shard-*.xml")); expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toBe(""); expect(result.stdout).toContain("combined test time: 2.50s"); }); + test("parses XML entities while ignoring comments and CDATA markup", async () => { + const fixture = await fixtures(); + const baseline = await fixture.report("baseline.xml", [ + testcase("a & " ' < >"), + ]); + const candidate = join(fixture.root, "candidate.xml"); + await writeFile( + candidate, + ` + + +]]> +${testcase("a & " ' < >")} +`, + ); + const result = await compare(baseline, candidate); + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + }); + + test("normalizes report paths and rejects duplicates across candidate files", async () => { + const fixture = await fixtures(); + const original = testcase("portable"); + const baseline = await fixture.report("baseline.xml", [original]); + const candidate = await fixture.report("candidate.xml", [ + original.replace( + "tests-ts/example.test.ts", + "./tests-ts\\example.test.ts", + ), + ]); + expect((await compare(baseline, candidate)).status).toBe(0); + const result = await compare(baseline, candidate, candidate); + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "Extra (1): tests-ts/example.test.ts > example > portable > passed", + ); + }); + + test("preserves file, character-class, hidden-file, and nonrecursive glob matching", async () => { + const fixture = await fixtures(); + const cases = [testcase("portable")]; + const baseline = await fixture.report("baseline.xml", cases); + const directory = join(fixture.root, "reports with spaces"); + await mkdir(directory); + await fixture.report("reports with spaces/shard-a.xml", cases); + await fixture.report("reports with spaces/.hidden.xml", [ + testcase("hidden"), + ]); + await mkdir(join(directory, "nested")); + await fixture.report("reports with spaces/nested/shard-b.xml", [ + testcase("nested"), + ]); + for (const pattern of [ + "reports with spaces/shard-?.xml", + "reports with spaces/shard-[ab].xml", + "reports with spaces/shard-[!b].xml", + "reports with spaces/*.xml", + "**/shard-*.xml", + ]) { + const result = await compare(baseline, join(fixture.root, pattern)); + expect(result.status, `${pattern}: ${result.stderr}`).toBe(0); + } + const hidden = await fixture.report("hidden-baseline.xml", [ + testcase("hidden"), + ]); + expect((await compare(hidden, join(directory, ".*.xml"))).status).toBe(0); + for (const name of ["{a,b}.xml", "!report.xml", "@(shard).xml"]) { + const path = await fixture.report(name, cases); + expect((await compare(baseline, path)).status, name).toBe(0); + } + }); + + test("detects error status and nested summary failures with failure overriding skipped", async () => { + const fixture = await fixtures(); + const baseline = await fixture.report("baseline.xml", [ + testcase("example"), + ]); + for (const status of ["", ""]) { + const candidate = await fixture.report("error.xml", [ + testcase("example", status), + ]); + const result = await compare(baseline, candidate); + expect(result.status).toBe(1); + expect(result.stderr).toContain("test run failed"); + expect(result.stderr).toContain("example > example > failed"); + expect(result.stderr).not.toContain("example > example > skipped"); + } + const nested = join(fixture.root, "nested.xml"); + await writeFile( + nested, + `${testcase("example")}`, + ); + const result = await compare(baseline, nested); + expect(result.status).toBe(1); + expect(result.stderr).toContain("test run failed"); + }); + + test("rejects malformed XML and invalid report numbers", async () => { + const fixture = await fixtures(); + const baseline = await fixture.report("baseline.xml", [ + testcase("example"), + ]); + for (const xml of [ + "", + "", + '', + '', + "", + '', + '', + '', + ]) { + const candidate = join(fixture.root, "invalid.xml"); + await writeFile(candidate, xml); + const result = await compare(baseline, candidate); + expect(result.status, xml).toBe(1); + expect(result.stdout).not.toContain("Identical test inventory"); + } + }); + + test("uses count and timing defaults and only direct unqualified status children", async () => { + const fixture = await fixtures(); + const baseline = await fixture.report("baseline.xml", [ + testcase("example"), + ]); + const candidate = join(fixture.root, "defaults.xml"); + await writeFile( + candidate, + `${testcase("example", "")}`, + ); + const result = await compare(baseline, candidate); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("| defaults.xml | 1 | 0 | 0.00 |"); + }); + test("rejects ambiguous test identities even when totals match", async () => { const fixture = await fixtures(); const first = testcase("same parameterized name"); From 0be200a7c5afadaf18dc8386a58fa0cb01d84007 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Mon, 31 Aug 2026 23:16:47 +0000 Subject: [PATCH 02/12] refactor(ci): use TypeScript for migrated checks --- .gitattributes | 1 + ... => check_plugin_source_compatibility.mts} | 34 ++++---- ...est_check_plugin_source_compatibility.mts} | 46 +++++++---- .github/workflows/node-ci.yml | 6 +- .github/workflows/test-quality.yml | 4 +- AGENTS.md | 4 +- sdk/typescript/package.json | 2 +- ...t-reports.mjs => compare-test-reports.mts} | 77 +++++++++++-------- .../tests-ts/release-automation.test.ts | 6 +- sdk/typescript/tests-ts/test-reports.test.ts | 17 +++- sdk/typescript/tsconfig.json | 2 + 11 files changed, 120 insertions(+), 79 deletions(-) rename .github/scripts/{check_plugin_source_compatibility.mjs => check_plugin_source_compatibility.mts} (82%) rename .github/scripts/{test_check_plugin_source_compatibility.mjs => test_check_plugin_source_compatibility.mts} (83%) rename sdk/typescript/scripts/{compare-test-reports.mjs => compare-test-reports.mts} (76%) diff --git a/.gitattributes b/.gitattributes index a411474ea..e9a5ecc5b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ *.json text eol=lf *.md text eol=lf *.mjs text eol=lf +*.mts text eol=lf *.py text eol=lf *.sh text eol=lf *.ts text eol=lf diff --git a/.github/scripts/check_plugin_source_compatibility.mjs b/.github/scripts/check_plugin_source_compatibility.mts similarity index 82% rename from .github/scripts/check_plugin_source_compatibility.mjs rename to .github/scripts/check_plugin_source_compatibility.mts index 0b7b9cefe..73f2a9fb7 100644 --- a/.github/scripts/check_plugin_source_compatibility.mjs +++ b/.github/scripts/check_plugin_source_compatibility.mts @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning // Check that tracked plugin source stays portable across repository imports. import { spawnSync } from "node:child_process"; @@ -22,7 +22,7 @@ const HTML_BLOCK = /^\s*<\/?[A-Za-z][^>]*>\s*$/u; const NATURAL_LINE_ENDINGS = new Set("\\.?!:;。!?:;)]}'\"`>"); const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); -function trackedFiles(pluginRoot) { +function trackedFiles(pluginRoot: string): string[] { const result = spawnSync( "git", ["-C", pluginRoot, "ls-files", "-z", "--", "."], @@ -37,7 +37,7 @@ function trackedFiles(pluginRoot) { return utf8.decode(result.stdout).split("\0").filter(Boolean); } -function isMarkdownStructure(line) { +function isMarkdownStructure(line: string): boolean { const stripped = line.trim(); return ( !stripped || @@ -51,24 +51,24 @@ function isMarkdownStructure(line) { ); } -function lineEndsNaturally(line) { +function lineEndsNaturally(line: string): boolean { const stripped = line.trimEnd(); return ( line.endsWith(" ") || - NATURAL_LINE_ENDINGS.has(stripped.at(-1)) || + NATURAL_LINE_ENDINGS.has(stripped.slice(-1)) || /https?:\/\/\S+$/u.test(stripped) ); } -function hardWrappedLines(content) { +function hardWrappedLines(content: string): number[] { const lines = content.split( /\r\n|[\n\r\v\f\u001c-\u001e\u0085\u2028\u2029]/u, ); - const offenders = []; + const offenders: number[] = []; let inFence = false; let inFrontmatter = content.startsWith("---\n"); for (let index = 0; index < lines.length - 1; index++) { - const line = lines[index]; + const line = lines[index]!; const stripped = line.trim(); if (stripped.startsWith("```") || stripped.startsWith("~~~")) { inFence = !inFence; @@ -79,7 +79,7 @@ function hardWrappedLines(content) { continue; } if (inFence || inFrontmatter) continue; - const followingLine = lines[index + 1]; + const followingLine = lines[index + 1]!; if (isMarkdownStructure(line) || isMarkdownStructure(followingLine)) continue; if (LIST_ITEM.test(followingLine) || lineEndsNaturally(line)) continue; @@ -90,8 +90,8 @@ function hardWrappedLines(content) { return offenders; } -function sourceCompatibilityErrors(pluginRoot) { - const errors = []; +function sourceCompatibilityErrors(pluginRoot: string): string[] { + const errors: string[] = []; for (const relativePath of trackedFiles(pluginRoot)) { const path = join(pluginRoot, relativePath); const stat = lstatSync(path); @@ -116,8 +116,8 @@ function sourceCompatibilityErrors(pluginRoot) { return errors.sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b))); } -function main() { - let values; +function main(): number { + let values: { "plugin-root": string; help?: boolean }; try { ({ values } = parseArgs({ options: { @@ -131,7 +131,7 @@ function main() { }, })); } catch (error) { - console.error(error.message); + console.error((error as Error).message); return 2; } if (values.help) { @@ -139,7 +139,7 @@ function main() { "Check tracked plugin source for deterministic import compatibility.\n", ); console.log( - "Usage: node check_plugin_source_compatibility.mjs [--plugin-root PATH]", + "Usage: node --experimental-strip-types --disable-warning=ExperimentalWarning check_plugin_source_compatibility.mts [--plugin-root PATH]", ); console.log( "\n--plugin-root PATH plugin source root (default: plugins/codex-security in this repository)", @@ -153,7 +153,9 @@ function main() { return 1; } } catch (error) { - console.error(`source compatibility check failed: ${error.message}`); + console.error( + `source compatibility check failed: ${(error as Error).message}`, + ); return 2; } console.log("Plugin source compatibility checks passed."); diff --git a/.github/scripts/test_check_plugin_source_compatibility.mjs b/.github/scripts/test_check_plugin_source_compatibility.mts similarity index 83% rename from .github/scripts/test_check_plugin_source_compatibility.mjs rename to .github/scripts/test_check_plugin_source_compatibility.mts index 09c6c0e9a..7ce1f3315 100644 --- a/.github/scripts/test_check_plugin_source_compatibility.mjs +++ b/.github/scripts/test_check_plugin_source_compatibility.mts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; import { copyFileSync, mkdtempSync, @@ -15,20 +15,20 @@ import { afterEach, test } from "node:test"; import { fileURLToPath } from "node:url"; const checker = fileURLToPath( - new URL("./check_plugin_source_compatibility.mjs", import.meta.url), + new URL("./check_plugin_source_compatibility.mts", import.meta.url), ); -const directories = []; +const directories: string[] = []; afterEach(() => { for (const root of directories.splice(0)) rmSync(root, { recursive: true, force: true }); }); -function git(root, ...args) { +function git(root: string, ...args: string[]): void { const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" }); assert.equal(result.status, 0, result.stderr); } -function fixture(files = {}) { +function fixture(files: Record = {}): string { const root = mkdtempSync(join(tmpdir(), "plugin-source-check-")); directories.push(root); git(root, "init", "--quiet"); @@ -38,8 +38,17 @@ function fixture(files = {}) { return root; } -function runChecker(...args) { - return spawnSync(process.execPath, [checker, ...args], { encoding: "utf8" }); +function runChecker(...args: string[]): SpawnSyncReturns { + return spawnSync( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + checker, + ...args, + ], + { encoding: "utf8" }, + ); } test("reports tracked source violations in stable order", () => { @@ -81,16 +90,21 @@ test("checkout preserves source size with autocrlf", () => { new URL("../../.gitattributes", import.meta.url), join(root, ".gitattributes"), ); - const source = join(root, "module.py"); - const content = Buffer.from("pass\n".repeat(30_000)); - writeFileSync(source, content); git(root, "config", "--local", "core.autocrlf", "true"); - git(root, "add", "--", ".gitattributes", "module.py"); - unlinkSync(source); - git(root, "checkout-index", "--", "module.py"); - const result = runChecker("--plugin-root", root); - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(readFileSync(source), content); + for (const [name, line] of [ + ["module.py", "pass\n"], + ["module.mts", "null\n"], + ] as const) { + const source = join(root, name); + const content = Buffer.from(line.repeat(30_000)); + writeFileSync(source, content); + git(root, "add", "--", ".gitattributes", name); + unlinkSync(source); + git(root, "checkout-index", "--", name); + const result = runChecker("--plugin-root", root); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(readFileSync(source), content); + } }); test("accepts Markdown structures, natural line endings, and inline markup", () => { diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index b065f9563..bef7283bf 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -99,7 +99,7 @@ jobs: - name: Check plugin source compatibility if: steps.scope.outputs.ci-mode == 'markdown' run: | - node .github/scripts/check_plugin_source_compatibility.mjs + node --experimental-strip-types --disable-warning=ExperimentalWarning .github/scripts/check_plugin_source_compatibility.mts - name: Install dependencies if: steps.scope.outputs.check-markdown == 'true' @@ -382,9 +382,9 @@ jobs: run: | python -m ruff check --config plugins/codex-security/pyproject.toml plugins/codex-security python -m ruff format --check --config plugins/codex-security/pyproject.toml plugins/codex-security - node .github/scripts/check_plugin_source_compatibility.mjs + node --experimental-strip-types --disable-warning=ExperimentalWarning .github/scripts/check_plugin_source_compatibility.mts - name: Test source compatibility checker - run: node --test .github/scripts/test_check_plugin_source_compatibility.mjs + run: node --experimental-strip-types --disable-warning=ExperimentalWarning --test .github/scripts/test_check_plugin_source_compatibility.mts - name: Test Python source contracts run: python -m pytest plugins/codex-security/tests -q -n 4 --dist worksteal --max-worker-restart 0 --durations=30 --junitxml=reports/python.xml - name: Upload Python test reports diff --git a/.github/workflows/test-quality.yml b/.github/workflows/test-quality.yml index 0520fc1ec..390e4672b 100644 --- a/.github/workflows/test-quality.yml +++ b/.github/workflows/test-quality.yml @@ -134,10 +134,10 @@ jobs: comparison_status=0 for os in ubuntu-latest windows-latest; do for mode in isolated parallel; do - node sdk/typescript/scripts/compare-test-reports.mjs "reports/runner-$os-baseline.xml" "reports/runner-$os-$mode.xml" >> "$GITHUB_STEP_SUMMARY" || comparison_status=1 + node --experimental-strip-types --disable-warning=ExperimentalWarning sdk/typescript/scripts/compare-test-reports.mts "reports/runner-$os-baseline.xml" "reports/runner-$os-$mode.xml" >> "$GITHUB_STEP_SUMMARY" || comparison_status=1 done done - node sdk/typescript/scripts/compare-test-reports.mjs reports/runner-windows-latest-baseline.xml 'reports/runner-windows-latest-shard-*.xml' >> "$GITHUB_STEP_SUMMARY" || comparison_status=1 + node --experimental-strip-types --disable-warning=ExperimentalWarning sdk/typescript/scripts/compare-test-reports.mts reports/runner-windows-latest-baseline.xml 'reports/runner-windows-latest-shard-*.xml' >> "$GITHUB_STEP_SUMMARY" || comparison_status=1 exit "$comparison_status" mutation: diff --git a/AGENTS.md b/AGENTS.md index 0f3f83636..8b1697919 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,8 @@ submitting the change: ```bash python -m ruff check --config plugins/codex-security/pyproject.toml plugins/codex-security python -m ruff format --check --config plugins/codex-security/pyproject.toml plugins/codex-security -node .github/scripts/check_plugin_source_compatibility.mjs -node --test .github/scripts/test_check_plugin_source_compatibility.mjs +node --experimental-strip-types --disable-warning=ExperimentalWarning .github/scripts/check_plugin_source_compatibility.mts +node --experimental-strip-types --disable-warning=ExperimentalWarning --test .github/scripts/test_check_plugin_source_compatibility.mts ``` ## Avoid speculative defenses diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index ff37bea81..18b78f0a3 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -52,7 +52,7 @@ "build:plugin": "node scripts/build-plugin.mjs", "check:plugin-source": "node scripts/check-plugin-source.mjs", "check:package": "node scripts/check-package.mjs", - "format": "prettier --check --ignore-path .gitignore --ignore-path .prettierignore \"**/*.{cjs,mjs,js,ts,tsx,json,md}\"", + "format": "prettier --check --ignore-path .gitignore --ignore-path .prettierignore \"**/*.{cjs,mjs,js,ts,mts,tsx,json,md}\" \"../../.github/scripts/*.mts\"", "generate:models": "node scripts/generate-models.cjs", "generate:models:check": "node scripts/generate-models.cjs --check", "lint": "tsc --noEmit", diff --git a/sdk/typescript/scripts/compare-test-reports.mjs b/sdk/typescript/scripts/compare-test-reports.mts similarity index 76% rename from sdk/typescript/scripts/compare-test-reports.mjs rename to sdk/typescript/scripts/compare-test-reports.mts index faa02ef4a..e5eb505d5 100644 --- a/sdk/typescript/scripts/compare-test-reports.mjs +++ b/sdk/typescript/scripts/compare-test-reports.mts @@ -3,9 +3,22 @@ import { lstatSync, readFileSync, readdirSync, statSync } from "node:fs"; import { basename, dirname, join, sep } from "node:path"; import { parseArgs } from "node:util"; import { minimatch } from "minimatch"; -import { SaxesParser } from "saxes"; +import { SaxesParser, type SaxesTagNS } from "saxes"; -function matchingReports(pattern) { +type TestStatus = "passed" | "skipped" | "failed"; +type TestIdentity = [file: string, classname: string, name: string]; +type TestOutcome = [...TestIdentity, status: TestStatus]; +type TestRecord = { + attributes: SaxesTagNS["attributes"]; + status: TestStatus; +}; +type TestReport = { + cases: Map; + duration: number; + failed: boolean; +}; + +function matchingReports(pattern: string): string[] { // Python glob treats ** as one component and does not expand braces/extglobs. const directoriesOnly = pattern.endsWith(sep) || @@ -24,7 +37,7 @@ function matchingReports(pattern) { ? matchingReports(parent) : [parent]; return directories.flatMap((directory) => { - let names; + let names: string[]; try { names = readdirSync(directory); } catch { @@ -60,14 +73,14 @@ function matchingReports(pattern) { }); } -function integer(value) { +function integer(value: string): bigint { if (!/^[+-]?\d(?:_?\d)*$/u.test(value.trim())) { throw new Error(`invalid integer: ${value}`); } return BigInt(value.replaceAll("_", "").trim()); } -function seconds(value) { +function seconds(value: string): number { const number = value.trim().replaceAll(/(?<=\d)_(?=\d)/gu, ""); if ( !/^[+-]?(?:(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?|inf(?:inity)?|nan)$/iu.test( @@ -79,12 +92,12 @@ function seconds(value) { return Number(number.replace(/inf(?:inity)?/iu, "Infinity")); } -function readReport(path) { +function readReport(path: string): TestReport { const parser = new SaxesParser({ xmlns: true, fileName: path }); - let root; - const stack = []; - const records = []; - const suites = []; + let root: SaxesTagNS | undefined; + const stack: Array<{ record: TestRecord | undefined }> = []; + const records: TestRecord[] = []; + const suites: SaxesTagNS[] = []; parser.on("opentag", (node) => { root ??= node; const name = node.uri ? `{${node.uri}}${node.local}` : node.local; @@ -95,7 +108,7 @@ function readReport(path) { else if (name === "skipped" && parent.record.status === "passed") parent.record.status = "skipped"; } - let record; + let record: TestRecord | undefined; if (name === "testcase") { record = { attributes: node.attributes, status: "passed" }; records.push(record); @@ -109,14 +122,14 @@ function readReport(path) { ); parser.write(content).close(); - const cases = new Map(); + const cases = new Map(); for (const { attributes, status } of records) { - const identity = [ - (attributes.file?.value ?? "") + const identity: TestIdentity = [ + (attributes["file"]?.value ?? "") .replaceAll("\\", "/") .replace(/^\.\//u, ""), - attributes.classname?.value ?? "", - attributes.name?.value ?? "", + attributes["classname"]?.value ?? "", + attributes["name"]?.value ?? "", ]; const key = JSON.stringify(identity); if (cases.has(key)) @@ -127,7 +140,7 @@ function readReport(path) { } if (!cases.size) throw new Error(`${path}: no test cases`); if ( - integer(root.attributes.tests?.value ?? String(cases.size)) !== + integer(root!.attributes["tests"]?.value ?? String(cases.size)) !== BigInt(cases.size) ) { throw new Error(`${path}: reported test count does not match test cases`); @@ -140,7 +153,7 @@ function readReport(path) { ), ); if (failed) console.error(`${path}: test run failed`); - const duration = seconds(root.attributes.time?.value ?? "0"); + const duration = seconds(root!.attributes["time"]?.value ?? "0"); const skipped = [...cases.values()].filter( (status) => status === "skipped", ).length; @@ -150,7 +163,7 @@ function readReport(path) { return { cases: new Map( [...cases].map(([identity, status]) => [ - JSON.stringify([...JSON.parse(identity), status]), + JSON.stringify([...(JSON.parse(identity) as TestIdentity), status]), 1, ]), ), @@ -159,8 +172,8 @@ function readReport(path) { }; } -function main() { - let args; +function main(): number { + let args: { values: { help?: boolean }; positionals: string[] }; try { args = parseArgs({ options: { help: { type: "boolean", short: "h" } }, @@ -171,21 +184,21 @@ function main() { "a baseline and at least one candidate report are required", ); } catch (error) { - console.error(error.message); + console.error((error as Error).message); return 2; } if (args.values.help) { console.log( - "Compare Bun JUnit inventories before changing the required CI runner.\n\nUsage: node compare-test-reports.mjs baseline candidates [candidates ...]\nCandidates are JUnit files or glob patterns.", + "Compare Bun JUnit inventories before changing the required CI runner.\n\nUsage: node --experimental-strip-types --disable-warning=ExperimentalWarning compare-test-reports.mts baseline candidates [candidates ...]\nCandidates are JUnit files or glob patterns.", ); return 0; } console.log("| Report | Cases | Skipped | Seconds |"); console.log("| --- | ---: | ---: | ---: |"); - const baseline = readReport(args.positionals[0]); + const baseline = readReport(args.positionals[0]!); let failed = baseline.failed; - const candidates = new Map(); - const durations = []; + const candidates = new Map(); + const durations: number[] = []; for (const pattern of args.positionals.slice(1)) { const paths = matchingReports(pattern).sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)), @@ -202,18 +215,18 @@ function main() { for (const [label, left, right] of [ ["Missing", baseline.cases, candidates], ["Extra", candidates, baseline.cases], - ]) { + ] as const) { const differences = [...left] - .map(([identity, count]) => [ - JSON.parse(identity), + .map(([identity, count]): [TestOutcome, number] => [ + JSON.parse(identity) as TestOutcome, count - (right.get(identity) ?? 0), ]) .filter(([, count]) => count > 0) .sort(([a], [b]) => { for (let index = 0; index < a.length; index++) { const order = Buffer.compare( - Buffer.from(a[index]), - Buffer.from(b[index]), + Buffer.from(a[index]!), + Buffer.from(b[index]!), ); if (order) return order; } @@ -234,6 +247,6 @@ function main() { try { process.exitCode = main(); } catch (error) { - console.error(error.message); + console.error((error as Error).message); process.exitCode = 1; } diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index 691076aeb..528c414f3 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -4173,10 +4173,10 @@ describe("GitHub release workflow safeguards", () => { mkdirSync(scripts, { recursive: true }); mkdirSync(pluginRoot, { recursive: true }); writeFileSync( - join(scripts, "check_plugin_source_compatibility.mjs"), + join(scripts, "check_plugin_source_compatibility.mts"), readFileSync( new URL( - "../../../.github/scripts/check_plugin_source_compatibility.mjs", + "../../../.github/scripts/check_plugin_source_compatibility.mts", import.meta.url, ), ), @@ -4188,7 +4188,7 @@ describe("GitHub release workflow safeguards", () => { workspace, "add", "--", - ".github/scripts/check_plugin_source_compatibility.mjs", + ".github/scripts/check_plugin_source_compatibility.mts", "plugins/codex-security/README.md", ]); try { diff --git a/sdk/typescript/tests-ts/test-reports.test.ts b/sdk/typescript/tests-ts/test-reports.test.ts index 746bf26e5..01e1adb59 100644 --- a/sdk/typescript/tests-ts/test-reports.test.ts +++ b/sdk/typescript/tests-ts/test-reports.test.ts @@ -44,8 +44,10 @@ async function compare(baseline: string, ...candidates: string[]) { const child = Bun.spawn({ cmd: [ "node", + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", fileURLToPath( - new URL("../scripts/compare-test-reports.mjs", import.meta.url), + new URL("../scripts/compare-test-reports.mts", import.meta.url), ), baseline, ...candidates, @@ -85,8 +87,8 @@ describe("JUnit inventory comparison", () => { "reports/runner-windows-latest-shard-*.xml", ]; const mock = `node() { - printf '%s\\n' "$3" - [[ "$3" != "$CODEX_SECURITY_TEST_FAIL_REPORT" ]] + printf '%s\\n' "$5" + [[ "$5" != "$CODEX_SECURITY_TEST_FAIL_REPORT" ]] }`; const summary = join(fixture.root, "summary.md"); for (const failedReport of ["", expected[0]!]) { @@ -125,7 +127,14 @@ describe("JUnit inventory comparison", () => { const result = await compare(baseline, join(fixture.root, "shard-*.xml")); expect(result.status, result.stderr).toBe(0); expect(result.stderr).toBe(""); - expect(result.stdout).toContain("combined test time: 2.50s"); + expect(result.stdout).toBe( + "| Report | Cases | Skipped | Seconds |\n" + + "| --- | ---: | ---: | ---: |\n" + + "| baseline.xml | 2 | 1 | 1.25 |\n" + + "| shard-1.xml | 1 | 1 | 1.25 |\n" + + "| shard-2.xml | 1 | 0 | 1.25 |\n" + + "\nIdentical test inventory and outcomes. Slowest candidate: 1.25s; combined test time: 2.50s.\n\n", + ); }); test("parses XML entities while ignoring comments and CDATA markup", async () => { diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json index 18454c78e..6738b4dcf 100644 --- a/sdk/typescript/tsconfig.json +++ b/sdk/typescript/tsconfig.json @@ -5,6 +5,8 @@ "dashboard/**/*.ts", "dashboard/**/*.tsx", "tests-ts/**/*.ts", + "../../.github/scripts/*.mts", + "scripts/compare-test-reports.mts", "scripts/smoke-findings-service.ts", "scripts/fixtures/findings-service-sqlite.ts", "scripts/fixtures/prepare-runner-scan.ts" From afd9033000cde66ca117467f5abe896f794d310a Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 2 Sep 2026 17:35:44 +0000 Subject: [PATCH 03/12] fix(ci): compile TypeScript checks before execution --- .../check_plugin_source_compatibility.mts | 4 +- ...test_check_plugin_source_compatibility.mts | 13 +--- .github/workflows/node-ci.yml | 28 ++++++--- .github/workflows/test-quality.yml | 6 +- .gitignore | 4 ++ AGENTS.md | 5 +- sdk/typescript/.gitignore | 1 + sdk/typescript/package.json | 1 + .../scripts/compare-test-reports.mts | 2 +- .../tests-ts/release-automation.test.ts | 27 +++++---- sdk/typescript/tests-ts/test-reports.test.ts | 59 ++++++++++++++++--- sdk/typescript/tsconfig.ci.json | 17 ++++++ 12 files changed, 120 insertions(+), 47 deletions(-) create mode 100644 sdk/typescript/tsconfig.ci.json diff --git a/.github/scripts/check_plugin_source_compatibility.mts b/.github/scripts/check_plugin_source_compatibility.mts index 73f2a9fb7..5fcd6b53f 100644 --- a/.github/scripts/check_plugin_source_compatibility.mts +++ b/.github/scripts/check_plugin_source_compatibility.mts @@ -1,4 +1,4 @@ -#!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning +#!/usr/bin/env node // Check that tracked plugin source stays portable across repository imports. import { spawnSync } from "node:child_process"; @@ -139,7 +139,7 @@ function main(): number { "Check tracked plugin source for deterministic import compatibility.\n", ); console.log( - "Usage: node --experimental-strip-types --disable-warning=ExperimentalWarning check_plugin_source_compatibility.mts [--plugin-root PATH]", + "Usage: node check_plugin_source_compatibility.mjs [--plugin-root PATH]", ); console.log( "\n--plugin-root PATH plugin source root (default: plugins/codex-security in this repository)", diff --git a/.github/scripts/test_check_plugin_source_compatibility.mts b/.github/scripts/test_check_plugin_source_compatibility.mts index 7ce1f3315..a99528dd3 100644 --- a/.github/scripts/test_check_plugin_source_compatibility.mts +++ b/.github/scripts/test_check_plugin_source_compatibility.mts @@ -15,7 +15,7 @@ import { afterEach, test } from "node:test"; import { fileURLToPath } from "node:url"; const checker = fileURLToPath( - new URL("./check_plugin_source_compatibility.mts", import.meta.url), + new URL("./check_plugin_source_compatibility.mjs", import.meta.url), ); const directories: string[] = []; afterEach(() => { @@ -39,16 +39,7 @@ function fixture(files: Record = {}): string { } function runChecker(...args: string[]): SpawnSyncReturns { - return spawnSync( - process.execPath, - [ - "--experimental-strip-types", - "--disable-warning=ExperimentalWarning", - checker, - ...args, - ], - { encoding: "utf8" }, - ); + return spawnSync(process.execPath, [checker, ...args], { encoding: "utf8" }); } test("reports tracked source violations in stable order", () => { diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index bef7283bf..9a6855264 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -96,15 +96,19 @@ jobs: with: node-version: "22.13.0" - - name: Check plugin source compatibility - if: steps.scope.outputs.ci-mode == 'markdown' - run: | - node --experimental-strip-types --disable-warning=ExperimentalWarning .github/scripts/check_plugin_source_compatibility.mts - - name: Install dependencies if: steps.scope.outputs.check-markdown == 'true' run: pnpm --dir sdk/typescript install --frozen-lockfile + - name: Compile CI scripts + if: steps.scope.outputs.ci-mode == 'markdown' + run: pnpm --dir sdk/typescript run build:ci + + - name: Check plugin source compatibility + if: steps.scope.outputs.ci-mode == 'markdown' + run: | + node .github/scripts/check_plugin_source_compatibility.mjs + - name: Check Markdown formatting if: steps.scope.outputs.check-markdown == 'true' shell: bash @@ -366,6 +370,16 @@ jobs: uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: "22.13.0" + - name: Set up pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: package.json + cache: true + cache_dependency_path: sdk/typescript/pnpm-lock.yaml + - name: Install dependencies + run: pnpm --dir sdk/typescript install --frozen-lockfile + - name: Compile CI scripts + run: pnpm --dir sdk/typescript run build:ci - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: @@ -382,9 +396,9 @@ jobs: run: | python -m ruff check --config plugins/codex-security/pyproject.toml plugins/codex-security python -m ruff format --check --config plugins/codex-security/pyproject.toml plugins/codex-security - node --experimental-strip-types --disable-warning=ExperimentalWarning .github/scripts/check_plugin_source_compatibility.mts + node .github/scripts/check_plugin_source_compatibility.mjs - name: Test source compatibility checker - run: node --experimental-strip-types --disable-warning=ExperimentalWarning --test .github/scripts/test_check_plugin_source_compatibility.mts + run: node --test .github/scripts/test_check_plugin_source_compatibility.mjs - name: Test Python source contracts run: python -m pytest plugins/codex-security/tests -q -n 4 --dist worksteal --max-worker-restart 0 --durations=30 --junitxml=reports/python.xml - name: Upload Python test reports diff --git a/.github/workflows/test-quality.yml b/.github/workflows/test-quality.yml index 390e4672b..859a25c20 100644 --- a/.github/workflows/test-quality.yml +++ b/.github/workflows/test-quality.yml @@ -123,6 +123,8 @@ jobs: cache_dependency_path: sdk/typescript/pnpm-lock.yaml - name: Install comparison dependencies run: pnpm --dir sdk/typescript install --frozen-lockfile + - name: Compile CI scripts + run: pnpm --dir sdk/typescript run build:ci - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: runner-* @@ -134,10 +136,10 @@ jobs: comparison_status=0 for os in ubuntu-latest windows-latest; do for mode in isolated parallel; do - node --experimental-strip-types --disable-warning=ExperimentalWarning sdk/typescript/scripts/compare-test-reports.mts "reports/runner-$os-baseline.xml" "reports/runner-$os-$mode.xml" >> "$GITHUB_STEP_SUMMARY" || comparison_status=1 + node sdk/typescript/scripts/compare-test-reports.mjs "reports/runner-$os-baseline.xml" "reports/runner-$os-$mode.xml" >> "$GITHUB_STEP_SUMMARY" || comparison_status=1 done done - node --experimental-strip-types --disable-warning=ExperimentalWarning sdk/typescript/scripts/compare-test-reports.mts reports/runner-windows-latest-baseline.xml 'reports/runner-windows-latest-shard-*.xml' >> "$GITHUB_STEP_SUMMARY" || comparison_status=1 + node sdk/typescript/scripts/compare-test-reports.mjs reports/runner-windows-latest-baseline.xml 'reports/runner-windows-latest-shard-*.xml' >> "$GITHUB_STEP_SUMMARY" || comparison_status=1 exit "$comparison_status" mutation: diff --git a/.gitignore b/.gitignore index d6b8b94b5..3c4dba3eb 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,7 @@ __pycache__/ # Never publish internal-only plugin data. .internal/ + +# Emitted by sdk/typescript's build:ci script. +/.github/scripts/check_plugin_source_compatibility.mjs +/.github/scripts/test_check_plugin_source_compatibility.mjs diff --git a/AGENTS.md b/AGENTS.md index 8b1697919..a8a5b1fe6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,9 @@ submitting the change: ```bash python -m ruff check --config plugins/codex-security/pyproject.toml plugins/codex-security python -m ruff format --check --config plugins/codex-security/pyproject.toml plugins/codex-security -node --experimental-strip-types --disable-warning=ExperimentalWarning .github/scripts/check_plugin_source_compatibility.mts -node --experimental-strip-types --disable-warning=ExperimentalWarning --test .github/scripts/test_check_plugin_source_compatibility.mts +pnpm --dir sdk/typescript run build:ci +node .github/scripts/check_plugin_source_compatibility.mjs +node --test .github/scripts/test_check_plugin_source_compatibility.mjs ``` ## Avoid speculative defenses diff --git a/sdk/typescript/.gitignore b/sdk/typescript/.gitignore index 973a09c34..3dd160171 100644 --- a/sdk/typescript/.gitignore +++ b/sdk/typescript/.gitignore @@ -6,3 +6,4 @@ /.stryker-tmp/ /private_release/dist/ /*.tsbuildinfo +/scripts/compare-test-reports.mjs diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 18b78f0a3..90b825883 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -49,6 +49,7 @@ "audit:prod": "pnpm audit --prod --audit-level high", "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", "build": "node --run clean && tsc -p tsconfig.build.json && node scripts/build-dashboard.mjs", + "build:ci": "tsc -p tsconfig.ci.json", "build:plugin": "node scripts/build-plugin.mjs", "check:plugin-source": "node scripts/check-plugin-source.mjs", "check:package": "node scripts/check-package.mjs", diff --git a/sdk/typescript/scripts/compare-test-reports.mts b/sdk/typescript/scripts/compare-test-reports.mts index e5eb505d5..e78b6103c 100644 --- a/sdk/typescript/scripts/compare-test-reports.mts +++ b/sdk/typescript/scripts/compare-test-reports.mts @@ -189,7 +189,7 @@ function main(): number { } if (args.values.help) { console.log( - "Compare Bun JUnit inventories before changing the required CI runner.\n\nUsage: node --experimental-strip-types --disable-warning=ExperimentalWarning compare-test-reports.mts baseline candidates [candidates ...]\nCandidates are JUnit files or glob patterns.", + "Compare Bun JUnit inventories before changing the required CI runner.\n\nUsage: node compare-test-reports.mjs baseline candidates [candidates ...]\nCandidates are JUnit files or glob patterns.", ); return 0; } diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index 528c414f3..efa925bbb 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -4164,23 +4164,12 @@ describe("GitHub release workflow safeguards", () => { test.skipIf(process.platform === "win32")( "rejects oversized plugin Markdown in reduced CI", - () => { + async () => { const workspace = mkdtempSync( join(tmpdir(), "release-ci-plugin-source-"), ); const pluginRoot = join(workspace, "plugins", "codex-security"); - const scripts = join(workspace, ".github", "scripts"); - mkdirSync(scripts, { recursive: true }); mkdirSync(pluginRoot, { recursive: true }); - writeFileSync( - join(scripts, "check_plugin_source_compatibility.mts"), - readFileSync( - new URL( - "../../../.github/scripts/check_plugin_source_compatibility.mts", - import.meta.url, - ), - ), - ); writeFileSync(join(pluginRoot, "README.md"), "x".repeat(150_001)); spawnSync("git", ["init", "--quiet", workspace]); spawnSync("git", [ @@ -4188,10 +4177,22 @@ describe("GitHub release workflow safeguards", () => { workspace, "add", "--", - ".github/scripts/check_plugin_source_compatibility.mts", "plugins/codex-security/README.md", ]); try { + const packageRoot = fileURLToPath(new URL("..", import.meta.url)); + const build = await runCommand( + "node", + [ + join(packageRoot, "node_modules", "typescript", "bin", "tsc"), + "--project", + join(packageRoot, "tsconfig.ci.json"), + "--outDir", + workspace, + ], + { timeout: 30_000 }, + ); + expect(build.status, build.stdout + build.stderr).toBe(0); const result = spawnSync( bash, [ diff --git a/sdk/typescript/tests-ts/test-reports.test.ts b/sdk/typescript/tests-ts/test-reports.test.ts index 01e1adb59..8fb83daee 100644 --- a/sdk/typescript/tests-ts/test-reports.test.ts +++ b/sdk/typescript/tests-ts/test-reports.test.ts @@ -1,11 +1,50 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { afterEach, describe, expect, test } from "bun:test"; -import { bashCommand } from "./support/shell.js"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + test, +} from "bun:test"; +import { bashCommand, runCommand } from "./support/shell.js"; const bash = bashCommand(); +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +let buildRoot: string; +beforeAll(async () => { + buildRoot = await mkdtemp(join(tmpdir(), "codex-security-ci-build-")); + const result = await runCommand( + "node", + [ + join(packageRoot, "node_modules", "typescript", "bin", "tsc"), + "--project", + join(packageRoot, "tsconfig.ci.json"), + "--outDir", + buildRoot, + ], + { timeout: 30_000 }, + ); + expect(result.status, result.stdout + result.stderr).toBe(0); + await symlink( + join(packageRoot, "node_modules"), + join(buildRoot, "sdk", "typescript", "node_modules"), + process.platform === "win32" ? "junction" : "dir", + ); +}, 30_000); +afterAll(async () => { + if (buildRoot) await rm(buildRoot, { recursive: true, force: true }); +}); const directories: string[] = []; afterEach(async () => { await Promise.all( @@ -44,10 +83,12 @@ async function compare(baseline: string, ...candidates: string[]) { const child = Bun.spawn({ cmd: [ "node", - "--experimental-strip-types", - "--disable-warning=ExperimentalWarning", - fileURLToPath( - new URL("../scripts/compare-test-reports.mts", import.meta.url), + join( + buildRoot, + "sdk", + "typescript", + "scripts", + "compare-test-reports.mjs", ), baseline, ...candidates, @@ -87,8 +128,8 @@ describe("JUnit inventory comparison", () => { "reports/runner-windows-latest-shard-*.xml", ]; const mock = `node() { - printf '%s\\n' "$5" - [[ "$5" != "$CODEX_SECURITY_TEST_FAIL_REPORT" ]] + printf '%s\\n' "$3" + [[ "$3" != "$CODEX_SECURITY_TEST_FAIL_REPORT" ]] }`; const summary = join(fixture.root, "summary.md"); for (const failedReport of ["", expected[0]!]) { diff --git a/sdk/typescript/tsconfig.ci.json b/sdk/typescript/tsconfig.ci.json new file mode 100644 index 000000000..527df088e --- /dev/null +++ b/sdk/typescript/tsconfig.ci.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "../../.github/scripts/*.mts", + "scripts/compare-test-reports.mts" + ], + "compilerOptions": { + "rootDir": "../..", + "noEmit": false, + "noEmitOnError": true, + "incremental": false, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "types": ["node"] + } +} From 29fe83912f6625992dc5a4f36df0379e587ea4be Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Thu, 3 Sep 2026 18:07:23 +0000 Subject: [PATCH 04/12] fix(test): give parameterized CI cases unique names --- sdk/typescript/tests-ts/cli.test.ts | 2 +- sdk/typescript/tests-ts/cost.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index 897ca5041..58fe4df3c 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -4796,7 +4796,7 @@ describe("CLI", () => { [[], {}, false, true, false], [[], {}, true, false, false], ] as const)( - "gates budget interaction for flags %j, environment %j, input TTY %s, output TTY %s", + "gates budget interaction for flags %j, environment %j, input TTY %p, output TTY %p", async (flags, environment, inputTty, outputTty, expected) => { const input = Object.assign(new PassThrough(), { isTTY: inputTty }); let budgetCallback: ScanOptions["onBudgetApproaching"]; diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 81628dc68..579dc84d4 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -1983,7 +1983,7 @@ describe("live scan cost tracking", () => { }); test.each([undefined, 100, 1_000, 1_500])( - "reconciles the parent receipt with worker usage when logged parent tokens are %s", + "reconciles the parent receipt with worker usage when logged parent tokens are %p", async (parentTokens) => { const home = await codexHome(); if (parentTokens !== undefined) { From 00c4719b16e4d4f9792a67ffb79acbd158d58bb6 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Thu, 3 Sep 2026 18:50:35 +0000 Subject: [PATCH 05/12] fix(test): give release test cases unique names --- sdk/typescript/tests-ts/release-pr.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/tests-ts/release-pr.test.ts b/sdk/typescript/tests-ts/release-pr.test.ts index 6549d9f10..5f86826bb 100644 --- a/sdk/typescript/tests-ts/release-pr.test.ts +++ b/sdk/typescript/tests-ts/release-pr.test.ts @@ -711,7 +711,7 @@ describe("pre-1.0 release policy", () => { change("fix: correct behavior", 1, { labels: ["breaking-change", "skip-release-notes"], }), - ])("uses a minor for a breaking change: $title", (breaking) => { + ])("uses a minor for a breaking change (%#): $title", (breaking) => { expect( nextReleaseVersion("0.1.23", [change("fix: first fix"), breaking]), ).toBe("0.2.0"); @@ -1248,7 +1248,7 @@ describe("rolling release reconciliation", () => { test.each([ { "sdk/typescript/src/example.ts": "Human implementation changes.\n" }, { [packagePath]: packageText("0.1.24", { example: "1.0.0" }) }, - ])("pauses instead of losing unrelated human edits", async (files) => { + ])("pauses instead of losing unrelated human edits (%#)", async (files) => { const fixture = new Fixture(); fixture.merge("feat: initial feature"); const first = await fixture.run(); From 53491e7c984d17153e0e289f6c148d6f0b025219 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 2 Sep 2026 22:27:43 +0000 Subject: [PATCH 06/12] refactor(examples): port custom validation to TypeScript --- .gitignore | 4 + examples/custom-validation/README.md | 19 +++-- examples/custom-validation/app.mts | 58 ++++++++++++++ examples/custom-validation/app.py | 48 ----------- examples/custom-validation/run.mjs | 25 +++++- examples/custom-validation/scan.md | 4 +- examples/custom-validation/validate.mts | 79 +++++++++++++++++++ examples/custom-validation/validate.py | 60 -------------- examples/custom-validation/validation.md | 4 +- sdk/typescript/package.json | 3 +- .../custom-validation-example.test.ts | 55 +++++++++++++ sdk/typescript/tsconfig.examples.json | 7 ++ sdk/typescript/tsconfig.json | 1 + 13 files changed, 247 insertions(+), 120 deletions(-) create mode 100644 examples/custom-validation/app.mts delete mode 100644 examples/custom-validation/app.py create mode 100644 examples/custom-validation/validate.mts delete mode 100644 examples/custom-validation/validate.py create mode 100644 sdk/typescript/tests-ts/custom-validation-example.test.ts create mode 100644 sdk/typescript/tsconfig.examples.json diff --git a/.gitignore b/.gitignore index 3c4dba3eb..8e82c49f2 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ __pycache__/ # Emitted by sdk/typescript's build:ci script. /.github/scripts/check_plugin_source_compatibility.mjs /.github/scripts/test_check_plugin_source_compatibility.mjs + +# Emitted by sdk/typescript's build:examples script. +/examples/custom-validation/app.mjs +/examples/custom-validation/validate.mjs diff --git a/examples/custom-validation/README.md b/examples/custom-validation/README.md index 16aa0464a..571de2d87 100644 --- a/examples/custom-validation/README.md +++ b/examples/custom-validation/README.md @@ -2,8 +2,9 @@ This deliberately vulnerable invoice API contains only synthetic data. Do not deploy it. The validation script starts a real loopback HTTP server, tests -cross-account access, saves the evidence, and stops the server. It needs Python -3.10 or later and no extra packages or Docker. +cross-account access, saves the evidence, and stops the server. It uses the +SDK's supported Node.js version (including 22.13) and TypeScript compiler, with no +extra packages or Docker. From the repository root, build the CLI and run the demo: @@ -14,8 +15,16 @@ node examples/custom-validation/run.mjs ``` The runner uses your existing Codex Security sign-in or API key. It copies the -fixture to a temporary directory and prints the scan output path. Extra CLI -options can be appended, for example `--model gpt-5.6-terra --effort high`. +TypeScript fixture to a temporary directory, compiles it to JavaScript, and +prints the scan output path. Extra CLI options can be appended, for example +`--model gpt-5.6-terra --effort high`. + +To run just the HTTP proof without a scan: + +```bash +pnpm --dir sdk/typescript run build:examples +node examples/custom-validation/validate.mjs --output reports/http-proof.json +``` Look for these files in the printed scan directory: @@ -31,4 +40,4 @@ cannot complete; it does not fall back to the default validation workflow. Adapt [validation.md](validation.md) for your own setup, tests, and cleanup. For a Docker-based project, the same prompt can run your existing compose or -test script instead of `validate.py`. +test script instead of `validate.mjs`. diff --git a/examples/custom-validation/app.mts b/examples/custom-validation/app.mts new file mode 100644 index 000000000..478d252a6 --- /dev/null +++ b/examples/custom-validation/app.mts @@ -0,0 +1,58 @@ +// Deliberately vulnerable local fixture. Do not deploy this application. +import { once } from "node:events"; +import { createServer as createHttpServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { pathToFileURL } from "node:url"; + +// These identities, tokens, and records are synthetic demo data. +const tokens = new Map([ + ["demo-alice", "alice"], + ["demo-bob", "bob"], +]); +type Invoice = { id: string; owner: string; amount: number }; +const invoices = new Map([ + ["1001", { id: "1001", owner: "alice", amount: 25 }], + ["1002", { id: "1002", owner: "bob", amount: 80 }], +]); + +export async function createServer(): Promise { + const server = createHttpServer((request, response) => { + function reply(status: number, body: Invoice | { error: string }): void { + const encoded = JSON.stringify(body); + response.writeHead(status, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(encoded), + }); + response.end(encoded); + } + + if (request.method !== "GET") { + response.writeHead(501).end(); + return; + } + const authorization = request.headers.authorization ?? ""; + const token = authorization.startsWith("Bearer ") + ? authorization.slice("Bearer ".length) + : authorization; + const user = tokens.get(token); + if (user === undefined) return reply(401, { error: "unauthorized" }); + const path = request.url ?? ""; + if (!path.startsWith("/invoices/")) + return reply(404, { error: "not found" }); + const invoice = invoices.get(path.slice("/invoices/".length)); + if (invoice === undefined) return reply(404, { error: "not found" }); + // BUG: authentication does not establish ownership of this invoice. + reply(200, invoice); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return server; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const server = await createServer(); + console.log(`http://127.0.0.1:${(server.address() as AddressInfo).port}`); +} diff --git a/examples/custom-validation/app.py b/examples/custom-validation/app.py deleted file mode 100644 index b9267bdc5..000000000 --- a/examples/custom-validation/app.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Deliberately vulnerable local fixture. Do not deploy this application.""" - -import json -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - - -# These identities, tokens, and records are synthetic demo data. -TOKENS = {"demo-alice": "alice", "demo-bob": "bob"} -INVOICES = { - "1001": {"id": "1001", "owner": "alice", "amount": 25}, - "1002": {"id": "1002", "owner": "bob", "amount": 80}, -} - - -class InvoiceHandler(BaseHTTPRequestHandler): - def reply(self, status, body): - encoded = json.dumps(body).encode() - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(encoded))) - self.end_headers() - self.wfile.write(encoded) - - def do_GET(self): - token = self.headers.get("Authorization", "").removeprefix("Bearer ") - user = TOKENS.get(token) - if user is None: - return self.reply(401, {"error": "unauthorized"}) - if not self.path.startswith("/invoices/"): - return self.reply(404, {"error": "not found"}) - invoice = INVOICES.get(self.path.removeprefix("/invoices/")) - if invoice is None: - return self.reply(404, {"error": "not found"}) - # BUG: authentication does not establish ownership of this invoice. - return self.reply(200, invoice) - - def log_message(self, format, *args): - pass - - -def create_server(): - return ThreadingHTTPServer(("127.0.0.1", 0), InvoiceHandler) - - -if __name__ == "__main__": - with create_server() as server: - print(f"http://127.0.0.1:{server.server_port}", flush=True) - server.serve_forever() diff --git a/examples/custom-validation/run.mjs b/examples/custom-validation/run.mjs index fa790e04a..2eb2b5a35 100644 --- a/examples/custom-validation/run.mjs +++ b/examples/custom-validation/run.mjs @@ -8,10 +8,31 @@ const root = await mkdtemp(join(tmpdir(), "codex-security-validation-demo-")); const target = join(root, "target"); const output = join(root, "scan"); await mkdir(target); -for (const name of ["app.py", "validate.py"]) { +for (const name of ["app.mts", "validate.mts"]) { await copyFile(new URL(name, import.meta.url), join(target, name)); } +const build = spawnSync( + process.execPath, + [ + fileURLToPath( + new URL( + "../../sdk/typescript/node_modules/typescript/bin/tsc", + import.meta.url, + ), + ), + "--project", + fileURLToPath( + new URL("../../sdk/typescript/tsconfig.examples.json", import.meta.url), + ), + "--outDir", + target, + ], + { stdio: "inherit" }, +); +if (build.error) throw build.error; +if (build.status !== 0) process.exit(build.status ?? 1); + console.log(`Demo target: ${target}\nScan output: ${output}`); const child = spawnSync( process.execPath, @@ -22,7 +43,7 @@ const child = spawnSync( "scan", target, "--path", - "app.py", + "app.mts", "--scan-prompt-file", fileURLToPath(new URL("scan.md", import.meta.url)), "--validation-prompt-file", diff --git a/examples/custom-validation/scan.md b/examples/custom-validation/scan.md index 0f6e4ff6c..eb7accbf7 100644 --- a/examples/custom-validation/scan.md +++ b/examples/custom-validation/scan.md @@ -1,4 +1,4 @@ -Review invoice ownership checks in `app.py`. An authenticated account must not +Review invoice ownership checks in `app.mts`. An authenticated account must not read another account's invoice. The fixed tokens and records are synthetic test -data, not production credentials. `validate.py` is a test harness, not an +data, not production credentials. `validate.mts` is a test harness, not an application endpoint. Keep discovery source-only. diff --git a/examples/custom-validation/validate.mts b/examples/custom-validation/validate.mts new file mode 100644 index 000000000..68bfc378d --- /dev/null +++ b/examples/custom-validation/validate.mts @@ -0,0 +1,79 @@ +// Exercise the fixture over real HTTP and save the observed evidence. +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import type { AddressInfo } from "node:net"; +import { dirname } from "node:path"; +import { parseArgs } from "node:util"; +import { createServer } from "./app.mjs"; + +type HttpResult = { + status: number; + body: { id?: string; owner?: string; amount?: number; error?: string }; +}; + +async function main(): Promise { + let output: string; + try { + const { values } = parseArgs({ + options: { + output: { type: "string" }, + help: { type: "boolean", short: "h" }, + }, + }); + if (values.help) { + console.log("Usage: node validate.mjs --output PATH"); + return 0; + } + if (values.output === undefined) throw new Error("--output is required"); + output = values.output; + } catch (error) { + console.error((error as Error).message); + return 2; + } + + const server = await createServer(); + const baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + async function get(invoice: string, token?: string): Promise { + const response = await fetch(`${baseUrl}/invoices/${invoice}`, { + headers: token === undefined ? {} : { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(5_000), + }); + return { + status: response.status, + body: (await response.json()) as HttpResult["body"], + }; + } + + let evidence: { + anonymous: HttpResult; + own_invoice: HttpResult; + other_invoice: HttpResult; + cross_account_read: boolean; + }; + try { + const anonymous = await get("1002"); + const own_invoice = await get("1001", "demo-alice"); + const other_invoice = await get("1002", "demo-alice"); + assert.equal(anonymous.status, 401, "Authentication control failed"); + assert.equal(own_invoice.status, 200, "Own-account control failed"); + evidence = { + anonymous, + own_invoice, + other_invoice, + cross_account_read: + other_invoice.status === 200 && other_invoice.body.owner === "bob", + }; + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + + const proof = { ...evidence, server_stopped: true }; + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, `${JSON.stringify(proof, null, 2)}\n`, "utf8"); + console.log(JSON.stringify(proof)); + return 0; +} + +process.exitCode = await main(); diff --git a/examples/custom-validation/validate.py b/examples/custom-validation/validate.py deleted file mode 100644 index c7430a689..000000000 --- a/examples/custom-validation/validate.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Exercise the fixture over real HTTP and save the observed evidence.""" - -import argparse -import json -import runpy -from http.client import HTTPConnection -from pathlib import Path -from threading import Thread - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--output", required=True, type=Path) - args = parser.parse_args() - - app = runpy.run_path(str(Path(__file__).with_name("app.py"))) - server = app["create_server"]() - worker = Thread(target=server.serve_forever, daemon=True) - worker.start() - - def get(invoice, token=None): - connection = HTTPConnection("127.0.0.1", server.server_port, timeout=5) - try: - headers = ( - {} if token is None else {"Authorization": f"Bearer {token}"} - ) - connection.request("GET", f"/invoices/{invoice}", headers=headers) - response = connection.getresponse() - return {"status": response.status, "body": json.loads(response.read())} - finally: - connection.close() - - try: - anonymous = get("1002") - own_invoice = get("1001", "demo-alice") - other_invoice = get("1002", "demo-alice") - assert anonymous["status"] == 401, "Authentication control failed" - assert own_invoice["status"] == 200, "Own-account control failed" - evidence = { - "anonymous": anonymous, - "own_invoice": own_invoice, - "other_invoice": other_invoice, - "cross_account_read": ( - other_invoice["status"] == 200 - and other_invoice["body"].get("owner") == "bob" - ), - } - finally: - server.shutdown() - server.server_close() - worker.join() - - evidence["server_stopped"] = True - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(json.dumps(evidence, indent=2) + "\n", encoding="utf-8") - print(json.dumps(evidence)) - - -if __name__ == "__main__": - main() diff --git a/examples/custom-validation/validation.md b/examples/custom-validation/validation.md index 0aec3c4a4..ba2aec678 100644 --- a/examples/custom-validation/validation.md +++ b/examples/custom-validation/validation.md @@ -1,9 +1,9 @@ Validate the invoice-ownership finding against this local fixture. -1. Use the configured Python interpreter to run `validate.py` from the supplied +1. Run `node validate.mjs` from the supplied repository root. Pass `--output` with the absolute path to `artifacts/custom-validation/http-proof.json` inside this scan's directory. - Set `PYTHONDONTWRITEBYTECODE=1` so the target remains unchanged. + The runner has already compiled the TypeScript fixture to JavaScript. 2. The script starts a server on an ephemeral `127.0.0.1` port, makes three HTTP requests using synthetic identities, and shuts the server down. This local server is the only authorized test target. Do not install packages or contact diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 90b825883..6df215058 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -50,10 +50,11 @@ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", "build": "node --run clean && tsc -p tsconfig.build.json && node scripts/build-dashboard.mjs", "build:ci": "tsc -p tsconfig.ci.json", + "build:examples": "tsc -p tsconfig.examples.json", "build:plugin": "node scripts/build-plugin.mjs", "check:plugin-source": "node scripts/check-plugin-source.mjs", "check:package": "node scripts/check-package.mjs", - "format": "prettier --check --ignore-path .gitignore --ignore-path .prettierignore \"**/*.{cjs,mjs,js,ts,mts,tsx,json,md}\" \"../../.github/scripts/*.mts\"", + "format": "prettier --check --ignore-path .gitignore --ignore-path .prettierignore \"**/*.{cjs,mjs,js,ts,mts,tsx,json,md}\" \"../../.github/scripts/*.mts\" \"../../examples/custom-validation/*.{mts,md}\"", "generate:models": "node scripts/generate-models.cjs", "generate:models:check": "node scripts/generate-models.cjs --check", "lint": "tsc --noEmit", diff --git a/sdk/typescript/tests-ts/custom-validation-example.test.ts b/sdk/typescript/tests-ts/custom-validation-example.test.ts new file mode 100644 index 000000000..d1dbfd575 --- /dev/null +++ b/sdk/typescript/tests-ts/custom-validation-example.test.ts @@ -0,0 +1,55 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "bun:test"; +import { runCommand } from "./support/shell.js"; + +test("compiled validation example saves HTTP proof and exits after server cleanup", async () => { + const source = await mkdtemp(join(tmpdir(), "custom-validation-example-")); + const output = await mkdtemp(join(tmpdir(), "custom-validation-proof-")); + const packageRoot = fileURLToPath(new URL("..", import.meta.url)); + try { + const build = await runCommand( + "node", + [ + join(packageRoot, "node_modules", "typescript", "bin", "tsc"), + "--project", + join(packageRoot, "tsconfig.examples.json"), + "--outDir", + source, + ], + { timeout: 30_000 }, + ); + expect(build.status, build.stdout + build.stderr).toBe(0); + const proofPath = join(output, "artifacts", "http-proof.json"); + const result = await runCommand( + "node", + [join(source, "validate.mjs"), "--output", proofPath], + { timeout: 30_000 }, + ); + // A server left listening would prevent this process from exiting normally. + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + const proof = { + anonymous: { status: 401, body: { error: "unauthorized" } }, + own_invoice: { + status: 200, + body: { id: "1001", owner: "alice", amount: 25 }, + }, + other_invoice: { + status: 200, + body: { id: "1002", owner: "bob", amount: 80 }, + }, + cross_account_read: true, + server_stopped: true, + }; + expect(JSON.parse(result.stdout)).toEqual(proof); + expect(JSON.parse(await readFile(proofPath, "utf8"))).toEqual(proof); + } finally { + await Promise.all([ + rm(source, { recursive: true, force: true }), + rm(output, { recursive: true, force: true }), + ]); + } +}); diff --git a/sdk/typescript/tsconfig.examples.json b/sdk/typescript/tsconfig.examples.json new file mode 100644 index 000000000..eabb66409 --- /dev/null +++ b/sdk/typescript/tsconfig.examples.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.ci.json", + "include": ["../../examples/custom-validation/*.mts"], + "compilerOptions": { + "rootDir": "../../examples/custom-validation" + } +} diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json index 6738b4dcf..1b08f5158 100644 --- a/sdk/typescript/tsconfig.json +++ b/sdk/typescript/tsconfig.json @@ -6,6 +6,7 @@ "dashboard/**/*.tsx", "tests-ts/**/*.ts", "../../.github/scripts/*.mts", + "../../examples/custom-validation/*.mts", "scripts/compare-test-reports.mts", "scripts/smoke-findings-service.ts", "scripts/fixtures/findings-service-sqlite.ts", From a44464ed015bf4ad201da50edc167ef063d1b502 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Wed, 2 Sep 2026 23:44:54 +0000 Subject: [PATCH 07/12] refactor(plugin): add Python-free Unix filesystem primitives --- .github/workflows/native-unix.yml | 127 +++++ plugins/codex-security/native/.gitignore | 3 + plugins/codex-security/native/Cargo.lock | 296 +++++++++++ plugins/codex-security/native/Cargo.toml | 23 + plugins/codex-security/native/README.md | 34 ++ plugins/codex-security/native/binding.mts | 67 +++ plugins/codex-security/native/build.mts | 47 ++ plugins/codex-security/native/build.rs | 3 + plugins/codex-security/native/check.mts | 89 ++++ plugins/codex-security/native/proof.mts | 476 ++++++++++++++++++ .../codex-security/native/rust-toolchain.toml | 4 + plugins/codex-security/native/src/lib.rs | 170 +++++++ sdk/typescript/package.json | 2 +- sdk/typescript/tsconfig.ci.json | 1 + sdk/typescript/tsconfig.json | 1 + 15 files changed, 1342 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/native-unix.yml create mode 100644 plugins/codex-security/native/.gitignore create mode 100644 plugins/codex-security/native/Cargo.lock create mode 100644 plugins/codex-security/native/Cargo.toml create mode 100644 plugins/codex-security/native/README.md create mode 100644 plugins/codex-security/native/binding.mts create mode 100644 plugins/codex-security/native/build.mts create mode 100644 plugins/codex-security/native/build.rs create mode 100644 plugins/codex-security/native/check.mts create mode 100644 plugins/codex-security/native/proof.mts create mode 100644 plugins/codex-security/native/rust-toolchain.toml create mode 100644 plugins/codex-security/native/src/lib.rs diff --git a/.github/workflows/native-unix.yml b/.github/workflows/native-unix.yml new file mode 100644 index 000000000..f75495b0e --- /dev/null +++ b/.github/workflows/native-unix.yml @@ -0,0 +1,127 @@ +name: native-unix + +on: + push: + branches: [main] + pull_request: + types: [opened, reopened, synchronize] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + primitives: + name: primitives / ${{ matrix.platform }}-${{ matrix.arch }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + platform: linux + arch: x64 + image: quay.io/pypa/manylinux_2_28_x86_64@sha256:0536c364004fa2a3c5041120b6fe35d84fc5bfe31f04c6a6304f13eac4a67b63 + - runner: ubuntu-24.04-arm + platform: linux + arch: arm64 + image: quay.io/pypa/manylinux_2_28_aarch64@sha256:1676a4f178dc6cf8a1d1e3b7e1d71fca891a466ebde72a33880ccda8af3383d8 + - runner: macos-15-intel + platform: darwin + arch: x64 + - runner: macos-15 + platform: darwin + arch: arm64 + defaults: + run: + shell: bash + working-directory: plugins/codex-security/native + env: + NATIVE_IMAGE: ${{ matrix.image }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Set up pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: package.json + cache: true + cache_dependency_path: sdk/typescript/pnpm-lock.yaml + - name: Set up Node.js 22 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: "22.13.0" + - name: Compile TypeScript tools + working-directory: . + run: | + pnpm --dir sdk/typescript install --frozen-lockfile + pnpm --dir sdk/typescript run build:ci + - name: Set up Rust + run: rustup toolchain install 1.97.1 --profile minimal --component rustfmt --component clippy + - name: Check Rust source and fetch locked dependencies + run: | + cargo fmt --check + cargo clippy --locked -- -D warnings + cargo fetch --locked + - name: Build and verify Linux on glibc 2.28 with Node.js 22 + if: matrix.platform == 'linux' + run: | + mkdir -p "$RUNNER_TEMP/native-cargo" "$RUNNER_TEMP/native-no-python" + printf '#!/bin/sh\nexit 99\n' > "$RUNNER_TEMP/native-no-python/python" + cp "$RUNNER_TEMP/native-no-python/python" "$RUNNER_TEMP/native-no-python/python3" + chmod +x "$RUNNER_TEMP/native-no-python/"* + docker run --rm \ + --volume "$PWD:/source" \ + --volume "$RUNNER_TEMP/native-cargo:/cargo" \ + --volume "${CARGO_HOME:-$HOME/.cargo}/registry:/cargo/registry:ro" \ + --volume "$(rustc --print sysroot):/toolchain:ro" \ + --volume "$(dirname "$(dirname "$(command -v node)")"):/node:ro" \ + --volume "$RUNNER_TEMP/native-no-python:/no-python:ro" \ + --env CARGO_HOME=/cargo \ + --env CARGO_NET_OFFLINE=true \ + --env PATH=/no-python:/toolchain/bin:/opt/rh/gcc-toolset-14/root/usr/bin:/usr/local/bin:/usr/bin:/bin \ + --workdir /source "$NATIVE_IMAGE" /bin/bash -euc ' + /node/bin/node build.mjs + /node/bin/node check.mjs + PATH= /node/bin/node proof.mjs + ' + - name: Build and verify macOS with Node.js 22 + if: matrix.platform == 'darwin' + run: | + node build.mjs + node check.mjs + native_node="$(command -v node)" + PATH= "$native_node" proof.mjs + - name: Set up Node.js 20 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: "20.0.0" + - name: Verify the same Linux artifact with Node.js 20 + if: matrix.platform == 'linux' + run: | + docker run --rm \ + --volume "$PWD:/source:ro" \ + --volume "$(dirname "$(dirname "$(command -v node)")"):/node:ro" \ + --workdir /source "$NATIVE_IMAGE" /bin/bash -euc ' + /node/bin/node check.mjs + PATH= /node/bin/node proof.mjs + ' + - name: Verify the same macOS artifact with Node.js 20 + if: matrix.platform == 'darwin' + run: | + node check.mjs + native_node="$(command -v node)" + PATH= "$native_node" proof.mjs + - name: Upload verified native artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-${{ matrix.platform }}-${{ matrix.arch }}-${{ github.sha }} + path: plugins/codex-security/native/dist/${{ matrix.platform }}-${{ matrix.arch }}/unix.node + if-no-files-found: error + retention-days: 7 diff --git a/plugins/codex-security/native/.gitignore b/plugins/codex-security/native/.gitignore new file mode 100644 index 000000000..5fb9d30c0 --- /dev/null +++ b/plugins/codex-security/native/.gitignore @@ -0,0 +1,3 @@ +/target/ +/dist/ +/*.mjs diff --git a/plugins/codex-security/native/Cargo.lock b/plugins/codex-security/native/Cargo.lock new file mode 100644 index 000000000..346cf3300 --- /dev/null +++ b/plugins/codex-security/native/Cargo.lock @@ -0,0 +1,296 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "codex-security-native-unix" +version = "0.0.0" +dependencies = [ + "libc", + "napi", + "napi-build", + "napi-derive", +] + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "napi" +version = "3.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c5f4d5375213fdb7be2655e152386e82f026f9a5ba36a75556e11359aafe09" +dependencies = [ + "bitflags", + "ctor", + "futures", + "libc", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", +] + +[[package]] +name = "napi-build" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60fdf9b392c50e7c4170fa633bd909490ed7835cea4c046776d1a4dd8d2ae0ab" + +[[package]] +name = "napi-derive" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa55ea69990c90b888e9e77044410e304ce7f35de599dc6d0b5c1923d2e59af" +dependencies = [ + "convert_case", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "napi-derive-backend" +version = "6.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df4056ac7c18e4438ccf0edaed4340ca0d269278c8ec19284f7b23cb039fd0ae" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "semver", + "syn 2.0.119", +] + +[[package]] +name = "napi-sys" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85fbf1fa9f1babfe396d74bbbf52b3643770243e8f5b0b46715d4caf7f0dfc9a" +dependencies = [ + "libloading", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" diff --git a/plugins/codex-security/native/Cargo.toml b/plugins/codex-security/native/Cargo.toml new file mode 100644 index 000000000..dbc6d2334 --- /dev/null +++ b/plugins/codex-security/native/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "codex-security-native-unix" +version = "0.0.0" +edition = "2021" +rust-version = "1.97" +license = "Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +napi = { version = "=3.12.2", default-features = false, features = ["napi8"] } +napi-derive = "=3.6.3" +libc = "=0.2.189" + +[build-dependencies] +napi-build = "=2.4.1" + +[profile.release] +strip = true +lto = true +codegen-units = 1 diff --git a/plugins/codex-security/native/README.md b/plugins/codex-security/native/README.md new file mode 100644 index 000000000..2bfcd8569 --- /dev/null +++ b/plugins/codex-security/native/README.md @@ -0,0 +1,34 @@ +# Unix native primitives + +This foundation supplies the descriptor operations that Node does not expose. The SDK and CLI continue to use their existing helpers while Windows primitives and universal package assembly are completed. + +The eight Node-API 8 functions are typed in `binding.mts`. Paths remain byte buffers. `statAt` never follows the final symlink; device and inode numbers are decimal strings so JavaScript does not round them. `openAt` and `duplicate` create descriptors with close-on-exec set. Node owns subsequent reads, writes, `fstat`, `fsync`, and close calls. + +`openAt` and `fileLock` retry EINTR, matching the current Python helpers. Other operations return their native errno. `readDescriptor` retries one interrupted Node read without losing earlier chunks. Blocking locks must run outside the main JavaScript event loop; a process that holds a lock releases it on close or exit. A Python signal handler can raise during a blocked call, so later routing must preserve cancellation through the worker lifecycle. + +Install the pinned Rust toolchain and the existing TypeScript dependencies, then run from the repository root: + +```sh +pnpm --dir sdk/typescript install --frozen-lockfile +pnpm --dir sdk/typescript run build:ci +node plugins/codex-security/native/build.mjs +node plugins/codex-security/native/proof.mjs +cargo +1.97.1 fmt --check --manifest-path plugins/codex-security/native/Cargo.toml +cargo +1.97.1 clippy --locked --manifest-path plugins/codex-security/native/Cargo.toml -- -D warnings +``` + +The proof runs without Python. It checks directory replacement, byte paths, unreadable-file metadata, long raw symlinks, descriptor duplication, Node descriptor I/O, contention, unlock, and process-death release. CI invokes it with an empty `PATH`. During migration, the same protocol can compare the existing Python lock helper: + +```sh +node plugins/codex-security/native/proof.mjs python3 plugins/codex-security/scripts +``` + +Build outputs stay under ignored `target` and `dist` directories. Source, Cargo registry, and compiler paths are remapped before compilation; actual payload bytes are checked for private paths. Before an artifact is uploaded, run: + +```sh +node plugins/codex-security/native/check.mjs +``` + +Linux artifacts must import no glibc version newer than 2.28. macOS artifacts must declare a deployment target of 11.0 or earlier. A build from a newer Linux workstation can pass the behavioral proof and still fail this distribution check. + +The `native-unix` workflow builds Linux artifacts in digest-pinned manylinux 2.28 images. It mounts the pinned Rust toolchain and fetched Cargo registry, builds offline, and blocks Python commands during compilation. macOS builds set `MACOSX_DEPLOYMENT_TARGET=11.0`. CI verifies separate x64 and arm64 artifacts on both platforms using Node 20.0.0 and 22.13.0. These artifacts are inputs to the later universal-package gate. diff --git a/plugins/codex-security/native/binding.mts b/plugins/codex-security/native/binding.mts new file mode 100644 index 000000000..11f2b5987 --- /dev/null +++ b/plugins/codex-security/native/binding.mts @@ -0,0 +1,67 @@ +import { createRequire } from "node:module"; +import { readSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const root = dirname(fileURLToPath(import.meta.url)); +export const output = join(root, "dist", `${process.platform}-${process.arch}`); +export const binaryPath = join(output, "unix.node"); + +export interface SyscallResult { + value: number; + errno: number; +} + +export interface MetadataResult { + errno: number; + mode: number; + device: string; + inode: string; +} + +/** Paths are uninterpreted POSIX bytes. Only openAt and fileLock retry EINTR. */ +export interface UnixBinding { + openAt( + directory: number, + name: Buffer, + flags: number, + mode: number, + ): SyscallResult; + duplicate(descriptor: number): SyscallResult; + makeDirectoryAt(directory: number, name: Buffer, mode: number): SyscallResult; + renameAt( + oldDirectory: number, + oldName: Buffer, + newDirectory: number, + newName: Buffer, + ): SyscallResult; + unlinkAt(directory: number, name: Buffer): SyscallResult; + statAt(directory: number, name: Buffer): MetadataResult; + readLinkAt(directory: number, name: Buffer): { errno: number; value: Buffer }; + fileLock( + descriptor: number, + unlock: boolean, + nonblocking: boolean, + ): SyscallResult; +} + +export function loadBinding(): UnixBinding { + return createRequire(import.meta.url)(binaryPath) as UnixBinding; +} + +/** Retry the interrupted read, preserving the caller's previously read bytes. */ +export function readDescriptor( + fd: number, + buffer: Buffer, + offset: number, + length: number, + position: number | null, +): number { + while (true) { + try { + return readSync(fd, buffer, offset, length, position); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EINTR") throw error; + } + } +} diff --git a/plugins/codex-security/native/build.mts b/plugins/codex-security/native/build.mts new file mode 100644 index 000000000..50e675640 --- /dev/null +++ b/plugins/codex-security/native/build.mts @@ -0,0 +1,47 @@ +import { execFileSync } from "node:child_process"; +import { copyFileSync, mkdirSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { binaryPath, output, root } from "./binding.mjs"; +import { checkPrivatePaths } from "./check.mjs"; + +const extension = process.platform === "darwin" ? "dylib" : "so"; +const cargoHome = resolve( + root, + process.env["CARGO_HOME"] ?? join(homedir(), ".cargo"), +); +const sysroot = execFileSync("rustc", ["--print", "sysroot"], { + cwd: root, + encoding: "utf8", +}).trim(); +const inheritedFlags = + process.env["CARGO_ENCODED_RUSTFLAGS"]?.split("\u001f") ?? + process.env["RUSTFLAGS"]?.split(/\s+/u).filter(Boolean) ?? + []; +const flags = [ + ...inheritedFlags, + `--remap-path-prefix=${root}=codex-security-native`, + `--remap-path-prefix=${cargoHome}=cargo`, + `--remap-path-prefix=${sysroot}=rust-toolchain`, +]; +const target = resolve(root, process.env["CARGO_TARGET_DIR"] ?? "target"); +execFileSync("cargo", ["build", "--release", "--locked"], { + cwd: root, + stdio: "inherit", + env: { + ...process.env, + CARGO_ENCODED_RUSTFLAGS: flags.join("\u001f"), + ...(process.platform === "darwin" + ? { MACOSX_DEPLOYMENT_TARGET: "11.0" } + : {}), + }, +}); +const library = join( + target, + "release", + `libcodex_security_native_unix.${extension}`, +); +checkPrivatePaths(readFileSync(library), [root, cargoHome, sysroot]); +mkdirSync(output, { recursive: true }); +copyFileSync(library, binaryPath); +console.log(`Built ${process.platform}-${process.arch} Node-API 8 primitives.`); diff --git a/plugins/codex-security/native/build.rs b/plugins/codex-security/native/build.rs new file mode 100644 index 000000000..0f1b01002 --- /dev/null +++ b/plugins/codex-security/native/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/plugins/codex-security/native/check.mts b/plugins/codex-security/native/check.mts new file mode 100644 index 000000000..41362fe85 --- /dev/null +++ b/plugins/codex-security/native/check.mts @@ -0,0 +1,89 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { binaryPath } from "./binding.mjs"; + +export function checkPrivatePaths( + bytes: Buffer, + buildPaths: string[] = [], +): void { + for (const marker of [ + "/Users/", + "/home/dev-user", + "/tmp/codex-security-python-", + ...buildPaths, + ]) { + if (bytes.includes(Buffer.from(marker))) { + throw new Error("Native payload contains a private build path."); + } + } +} + +function versionAfter(value: string, floor: string): boolean { + const actual = value.split(".").map(Number); + const maximum = floor.split(".").map(Number); + for ( + let index = 0; + index < Math.max(actual.length, maximum.length); + index++ + ) { + const difference = (actual[index] ?? 0) - (maximum[index] ?? 0); + if (difference !== 0) return difference > 0; + } + return false; +} + +if ( + process.argv[1] !== undefined && + pathToFileURL(resolve(process.argv[1])).href === import.meta.url +) { + const bytes = readFileSync(binaryPath); + checkPrivatePaths(bytes); + let floor: string; + if (process.platform === "linux") { + const versions = execFileSync("readelf", ["--version-info", binaryPath], { + encoding: "utf8", + }); + const required = [...versions.matchAll(/\bGLIBC_(\d+(?:\.\d+)*)/gu)].map( + (match) => match[1]!, + ); + if ( + required.length === 0 || + required.some((version) => versionAfter(version, "2.28")) + ) { + throw new Error( + "Native payload requires glibc newer than 2.28, or has no inspectable glibc requirements.", + ); + } + floor = "glibc 2.28"; + } else if (process.platform === "darwin") { + const commands = execFileSync("otool", ["-l", binaryPath], { + encoding: "utf8", + }); + const minimum = + /cmd LC_BUILD_VERSION\s+[\s\S]*?\bminos ([\d.]+)/u.exec(commands)?.[1] ?? + /cmd LC_VERSION_MIN_MACOSX\s+[\s\S]*?\bversion ([\d.]+)/u.exec( + commands, + )?.[1]; + if (minimum === undefined || versionAfter(minimum, "11.0")) { + throw new Error( + "Native payload does not declare a compatible macOS 11.0 deployment target.", + ); + } + floor = "macOS 11.0"; + } else { + throw new Error("This foundation verifies Linux and macOS artifacts only."); + } + console.log( + JSON.stringify({ + platform: process.platform, + arch: process.arch, + nodeApi: 8, + floor, + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }), + ); +} diff --git a/plugins/codex-security/native/proof.mts b/plugins/codex-security/native/proof.mts new file mode 100644 index 000000000..b2a62f8ba --- /dev/null +++ b/plugins/codex-security/native/proof.mts @@ -0,0 +1,476 @@ +import assert from "node:assert/strict"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { + closeSync, + constants, + existsSync, + fstatSync, + fsyncSync, + mkdirSync, + mkdtempSync, + openSync, + lstatSync, + readFileSync, + renameSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; +import { loadBinding, readDescriptor } from "./binding.mjs"; +import { constants as osConstants } from "node:os"; + +const native = loadBinding(); +const errno = osConstants.errno; +class NativeError extends Error { + constructor(readonly errno: number) { + super(`Native operation failed: errno ${errno}`); + } +} +function checked(result: T): T { + if (result.errno !== 0) throw new NativeError(result.errno); + return result; +} +const bytes = (path: string | Buffer) => + typeof path === "string" ? Buffer.from(path) : path; +const openAt = (fd: number, path: string | Buffer, flags: number, mode = 0) => + checked(native.openAt(fd, bytes(path), flags, mode)).value; +const mkdirAt = (fd: number, path: string | Buffer, mode: number) => { + checked(native.makeDirectoryAt(fd, bytes(path), mode)); +}; +const renameAt = ( + oldFd: number, + oldPath: string | Buffer, + newFd: number, + newPath: string | Buffer, +) => { + checked(native.renameAt(oldFd, bytes(oldPath), newFd, bytes(newPath))); +}; +const unlinkAt = (fd: number, path: string | Buffer) => { + checked(native.unlinkAt(fd, bytes(path))); +}; +const duplicate = (fd: number) => checked(native.duplicate(fd)).value; +const fileLock = (fd: number, unlock = false, nonblocking = false) => { + checked(native.fileLock(fd, unlock, nonblocking)); +}; +const statAt = (fd: number, path: string | Buffer) => + checked(native.statAt(fd, bytes(path))); +const readLinkAt = (fd: number, path: string | Buffer) => + checked(native.readLinkAt(fd, bytes(path))).value; +const rawPath = (parent: string, name: Buffer) => + Buffer.concat([Buffer.from(parent + "/"), name]); + +const directoryFlags = + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW; +const ownFileFlags = + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW; +const readFlags = + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; +const self = fileURLToPath(import.meta.url); + +function expectedError(action: () => void, ...codes: number[]): number { + try { + action(); + } catch (error) { + assert(error instanceof NativeError); + assert(codes.includes(error.errno), error.message); + return error.errno; + } + throw new Error("Expected native operation to fail"); +} + +function descriptorProof(root: string) { + const scan = join(root, "scan"); + const movedScan = join(root, "held-scan"); + const outside = join(root, "outside"); + mkdirSync(scan); + mkdirSync(outside); + writeFileSync(join(outside, "result.json"), "outside sentinel"); + const held = new Set(); + const keep = (fd: number) => { + held.add(fd); + return fd; + }; + const close = (fd: number) => { + closeSync(fd); + held.delete(fd); + }; + try { + const rootFd = keep(openSync(scan, directoryFlags)); + const identity = fstatSync(rootFd, { bigint: true }); + mkdirAt(rootFd, "artifacts", 0o700); + const originalParent = keep(openAt(rootFd, "artifacts", directoryFlags)); + const parentFd = keep(duplicate(originalParent)); + close(originalParent); + const unreadable = Buffer.from([0x75, 0xff]); + const rawLink = Buffer.from([0x6c, 0xfe]); + const rawTarget = Buffer.concat([ + Buffer.from("component/".repeat(80)), + Buffer.from([0xff]), + ]); + writeFileSync(rawPath(join(scan, "artifacts"), unreadable), "unreadable", { + mode: 0, + }); + symlinkSync(rawTarget, rawPath(join(scan, "artifacts"), rawLink)); + const unreadableMetadata = statAt(parentFd, unreadable); + const unreadableExpected = lstatSync( + rawPath(join(scan, "artifacts"), unreadable), + { bigint: true }, + ); + assert.equal(unreadableMetadata.mode, Number(unreadableExpected.mode)); + assert.equal(unreadableMetadata.device, unreadableExpected.dev.toString()); + assert.equal(unreadableMetadata.inode, unreadableExpected.ino.toString()); + assert.equal(unreadableMetadata.mode & 0o777, 0); + if (process.geteuid?.() !== 0) + expectedError( + () => openAt(parentFd, unreadable, readFlags), + errno.EACCES!, + ); + const linkMetadata = statAt(parentFd, rawLink); + assert.equal(linkMetadata.mode & constants.S_IFMT, constants.S_IFLNK); + assert.deepEqual(readLinkAt(parentFd, rawLink), rawTarget); + + // Replace both visible ancestor paths while keeping their validated FDs. + renameSync(scan, movedScan); + symlinkSync(outside, scan, "dir"); + renameSync(join(movedScan, "artifacts"), join(movedScan, "held-artifacts")); + symlinkSync(outside, join(movedScan, "artifacts"), "dir"); + assert.equal(fstatSync(rootFd, { bigint: true }).ino, identity.ino); + assert.equal(statSync(movedScan, { bigint: true }).dev, identity.dev); + assert.deepEqual(statAt(parentFd, unreadable), unreadableMetadata); + assert.deepEqual(statAt(parentFd, rawLink), linkMetadata); + assert.deepEqual(readLinkAt(parentFd, rawLink), rawTarget); + assert.equal( + statAt(rootFd, "artifacts").mode & constants.S_IFMT, + constants.S_IFLNK, + ); + assert.equal(readLinkAt(rootFd, "artifacts").toString(), outside); + mkdirAt(parentFd, "nested", 0o700); + + const rawDirectory = Buffer.from([0x64, 0xfd]); + const rawSource = Buffer.from([0x73, 0xfc]); + const rawDestination = Buffer.from([0x74, 0xfb]); + mkdirAt(parentFd, rawDirectory, 0o700); + assert.equal(statAt(parentFd, rawDirectory).mode & 0o777, 0o700); + const rawDirectoryFd = keep(openAt(parentFd, rawDirectory, directoryFlags)); + const rawFd = keep(openAt(parentFd, rawSource, ownFileFlags, 0o600)); + writeFileSync(rawFd, "raw path contents"); + close(rawFd); + renameAt(parentFd, rawSource, rawDirectoryFd, rawDestination); + const rawRead = keep(openAt(rawDirectoryFd, rawDestination, readFlags)); + assert.equal(readFileSync(rawRead, "utf8"), "raw path contents"); + close(rawRead); + unlinkAt(rawDirectoryFd, rawDestination); + close(rawDirectoryFd); + rmSync(rawPath(join(movedScan, "held-artifacts"), rawDirectory), { + recursive: true, + }); + expectedError(() => statAt(parentFd, rawDirectory), errno.ENOENT!); + + const fd = keep(openAt(parentFd, ".result.tmp", ownFileFlags, 0o600)); + const payload = Buffer.from('{"proof":"anchored 🔐"}\n'); + writeFileSync(fd, payload); + fsyncSync(fd); + assert(fstatSync(fd).isFile()); + assert.equal(fstatSync(fd).mode & 0o777, 0o600); + assert.equal(fstatSync(fd).size, payload.length); + close(fd); + renameAt(parentFd, ".result.tmp", parentFd, "result.json"); + + const input = keep(openAt(parentFd, "result.json", readFlags)); + const chunks: Buffer[] = []; + while (true) { + const chunk = Buffer.alloc(5); + const count = readDescriptor(input, chunk, 0, chunk.length, null); + if (count === 0) break; + chunks.push(chunk.subarray(0, count)); + } + assert.deepEqual(Buffer.concat(chunks), payload); + close(input); + const anchoredDirectory = join(movedScan, "held-artifacts"); + assert.deepEqual( + readFileSync(join(anchoredDirectory, "result.json")), + payload, + ); + assert.equal( + readFileSync(join(outside, "result.json"), "utf8"), + "outside sentinel", + ); + assert(!existsSync(join(outside, "nested"))); + + symlinkSync(join(outside, "result.json"), join(anchoredDirectory, "link")); + const noFollowErrno = expectedError( + () => openAt(parentFd, "link", readFlags), + errno.ELOOP!, + ); + unlinkAt(parentFd, "link"); + unlinkAt(parentFd, "result.json"); + assert(!existsSync(join(anchoredDirectory, "result.json"))); + assert.equal( + readFileSync(join(outside, "result.json"), "utf8"), + "outside sentinel", + ); + expectedError( + () => openAt(rootFd, "artifacts", directoryFlags), + errno.ELOOP!, + errno.ENOTDIR!, + ); + expectedError(() => mkdirAt(parentFd, "nested", 0o700), errno.EEXIST!); + expectedError( + () => renameAt(parentFd, "missing", parentFd, "other"), + errno.ENOENT!, + ); + expectedError(() => unlinkAt(parentFd, "missing"), errno.ENOENT!); + expectedError(() => statAt(-1, "missing"), errno.EBADF!); + expectedError(() => readLinkAt(-1, "missing"), errno.EBADF!); + const badFdErrno = expectedError( + () => openAt(-1, "missing", readFlags), + errno.EBADF!, + ); + return { + anchoredMkdirWriteRenameReadDelete: true, + outsideSentinelPreserved: true, + nodeFdWriteReadFstatFsyncClose: true, + mode: "0600", + duplicateSurvivesOriginalClose: true, + rawMkdirOpenRenameUnlink: true, + unreadableMetadataMatches: true, + noFollowStatAndLongRawReadlinkSurviveReplacement: true, + rawLinkTargetBytes: rawTarget.length, + noFollowErrno, + badFdErrno, + }; + } finally { + for (const fd of held) closeSync(fd); + } +} + +async function nativeLockWorker(path: string): Promise { + const fd = openSync(path, constants.O_RDWR | constants.O_CREAT, 0o600); + try { + if (process.argv[4] === "try") { + const result = native.fileLock(fd, false, true); + if (result.errno === 0) fileLock(fd, true); + console.log( + JSON.stringify({ acquired: result.errno === 0, errno: result.errno }), + ); + return; + } + const commands = createInterface({ input: process.stdin })[ + Symbol.asyncIterator + ](); + console.log("waiting"); + fileLock(fd); + console.log("acquired"); + await commands.next(); + fileLock(fd, true); + console.log("released"); + } finally { + closeSync(fd); + } +} + +// Temporary interoperability oracle: import the actual current Python functions. +// This protocol scaffold is never a migrated implementation or shipped artifact. +const pythonOracle = String.raw` +import json, os, sys +sys.path.insert(0, sys.argv[1]) +from workbench_db import acquire_completion_file_lock, release_completion_file_lock, posix_file_lock +fd = os.open(sys.argv[2], os.O_RDWR | os.O_CREAT, 0o600) +try: + if sys.argv[3] == "try": + try: + posix_file_lock.flock(fd, posix_file_lock.LOCK_EX | posix_file_lock.LOCK_NB) + except OSError as error: + print(json.dumps({"acquired": False, "errno": error.errno}), flush=True) + else: + release_completion_file_lock(fd) + print(json.dumps({"acquired": True}), flush=True) + else: + print("waiting", flush=True) + acquire_completion_file_lock(fd) + print("acquired", flush=True) + sys.stdin.buffer.readline() + release_completion_file_lock(fd) + print("released", flush=True) +finally: + os.close(fd) +`; + +type Worker = { + child: ChildProcessWithoutNullStreams; + lines: AsyncIterableIterator; + exit: Promise<{ code: number | null; signal: NodeJS.Signals | null }>; + stderr: () => string; +}; +const workers: Worker[] = []; + +function worker(command: string, args: string[]): Worker { + const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] }); + let stderr = ""; + child.stderr.setEncoding("utf8").on("data", (chunk: string) => { + stderr += chunk; + }); + const result: Worker = { + child, + lines: createInterface({ input: child.stdout })[Symbol.asyncIterator](), + exit: new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`worker exit timeout: ${stderr}`)); + }, 30_000); + child.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.once("exit", (code, signal) => { + clearTimeout(timeout); + resolve({ code, signal }); + }); + }), + stderr: () => stderr, + }; + workers.push(result); + return result; +} + +async function line(child: Worker, expected?: string): Promise { + let timeout: ReturnType; + const value = await Promise.race([ + child.lines.next(), + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error(`worker protocol timeout: ${child.stderr()}`)), + 10_000, + ); + }), + ]).finally(() => clearTimeout(timeout)); + assert(!value.done, `worker ended early: ${child.stderr()}`); + if (expected !== undefined) assert.equal(value.value, expected); + return value.value; +} + +async function release(child: Worker): Promise { + child.child.stdin.end("release\n"); + await line(child, "released"); + const ended = await child.exit; + assert.equal(ended.code, 0, child.stderr()); +} + +async function kill(child: Worker): Promise { + child.child.kill("SIGKILL"); + assert.equal((await child.exit).signal, "SIGKILL"); +} + +async function lockProof(root: string, python?: string, scripts?: string) { + const path = join(root, "completion.lock"); + const fd = openSync(path, constants.O_RDWR | constants.O_CREAT, 0o600); + const peerWorker = (mode = "hold") => + python !== undefined && scripts !== undefined + ? worker(python, ["-c", pythonOracle, scripts, path, mode]) + : worker(process.execPath, [self, "lock-worker", path, mode]); + const nativeWorker = () => + worker(process.execPath, [self, "lock-worker", path]); + try { + fileLock(fd); + const probe = peerWorker("try"); + const contention = JSON.parse(await line(probe)) as { + acquired: boolean; + errno: number; + }; + assert.equal(contention.acquired, false); + assert([errno.EAGAIN, errno.EWOULDBLOCK].includes(contention.errno)); + assert.equal((await probe.exit).code, 0, probe.stderr()); + const waitingPeer = peerWorker(); + await line(waitingPeer, "waiting"); + fileLock(fd, true); + await line(waitingPeer, "acquired"); + const nativeContention = expectedError( + () => fileLock(fd, false, true), + errno.EAGAIN!, + errno.EWOULDBLOCK!, + ); + await release(waitingPeer); + + const heldPeer = peerWorker(); + await line(heldPeer, "waiting"); + await line(heldPeer, "acquired"); + const waitingNative = nativeWorker(); + await line(waitingNative, "waiting"); + await kill(heldPeer); + await line(waitingNative, "acquired"); + await release(waitingNative); + + const heldNative = nativeWorker(); + await line(heldNative, "waiting"); + await line(heldNative, "acquired"); + const peerAfterDeath = peerWorker(); + await line(peerAfterDeath, "waiting"); + await kill(heldNative); + await line(peerAfterDeath, "acquired"); + await release(peerAfterDeath); + fileLock(fd, false, true); + fileLock(fd, true); + return { + peerRuntime: python === undefined ? "node" : "python", + peerContentionErrno: contention.errno, + nativeContentionErrno: nativeContention, + bidirectionalBlockingHandoff: true, + unlockHandoff: true, + peerDeathReleasesLock: true, + nativeDeathReleasesLock: true, + }; + } finally { + closeSync(fd); + } +} + +if (process.argv[2] === "lock-worker") { + await nativeLockWorker(process.argv[3]!); +} else { + const python = process.argv[2]; + const scripts = process.argv[3]; + assert.equal( + Boolean(python), + Boolean(scripts), + "Pass both the optional Python interpreter and legacy scripts directory", + ); + const root = mkdtempSync(join(tmpdir(), "codex-security-native-")); + try { + const descriptors = descriptorProof(root); + const locks = await lockProof(root); + const pythonCompatibility = + python && scripts ? await lockProof(root, python, scripts) : undefined; + console.log( + JSON.stringify( + { + node: process.version, + platform: process.platform, + architecture: process.arch, + nodeApi: 8, + descriptors, + locks, + pythonCompatibility, + fixture: basename(root), + }, + null, + 2, + ), + ); + } finally { + for (const child of workers) { + if (child.child.exitCode === null && child.child.signalCode === null) + child.child.kill("SIGKILL"); + } + await Promise.allSettled(workers.map((child) => child.exit)); + rmSync(root, { recursive: true, force: true }); + assert(!existsSync(root)); + } +} diff --git a/plugins/codex-security/native/rust-toolchain.toml b/plugins/codex-security/native/rust-toolchain.toml new file mode 100644 index 000000000..010f002cf --- /dev/null +++ b/plugins/codex-security/native/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.97.1" +profile = "minimal" +components = ["rustfmt", "clippy"] diff --git a/plugins/codex-security/native/src/lib.rs b/plugins/codex-security/native/src/lib.rs new file mode 100644 index 000000000..a4b353dfa --- /dev/null +++ b/plugins/codex-security/native/src/lib.rs @@ -0,0 +1,170 @@ +use napi::bindgen_prelude::Buffer; +use napi_derive::napi; +use std::{ffi::CString, io}; + +#[napi(object)] +pub struct SyscallResult { + pub value: i32, + pub errno: i32, +} + +fn result(value: i32) -> SyscallResult { + SyscallResult { + value, + errno: if value < 0 { + io::Error::last_os_error().raw_os_error().unwrap() + } else { + 0 + }, + } +} + +fn retry_eintr(mut operation: impl FnMut() -> i32) -> SyscallResult { + loop { + let value = result(operation()); + if value.errno != libc::EINTR { + return value; + } + } +} + +fn path(value: Buffer) -> napi::Result { + CString::new(value.as_ref()).map_err(|_| napi::Error::from_reason("Path contains a NUL byte")) +} + +#[napi] +pub fn open_at(directory: i32, name: Buffer, flags: i32, mode: u32) -> napi::Result { + let name = path(name)?; + let mode = mode as libc::mode_t; + // macOS mode_t is u16 and needs C integer promotion in this variadic call. + #[cfg(target_os = "macos")] + let mode = libc::c_int::from(mode); + Ok(retry_eintr(|| unsafe { + libc::openat(directory, name.as_ptr(), flags | libc::O_CLOEXEC, mode) + })) +} + +#[napi] +pub fn make_directory_at(directory: i32, name: Buffer, mode: u32) -> napi::Result { + let name = path(name)?; + Ok(result(unsafe { + libc::mkdirat(directory, name.as_ptr(), mode as libc::mode_t) + })) +} + +#[napi] +pub fn rename_at( + old_directory: i32, + old_name: Buffer, + new_directory: i32, + new_name: Buffer, +) -> napi::Result { + let old_name = path(old_name)?; + let new_name = path(new_name)?; + Ok(result(unsafe { + libc::renameat( + old_directory, + old_name.as_ptr(), + new_directory, + new_name.as_ptr(), + ) + })) +} + +#[napi] +pub fn unlink_at(directory: i32, name: Buffer) -> napi::Result { + let name = path(name)?; + Ok(result(unsafe { + libc::unlinkat(directory, name.as_ptr(), 0) + })) +} + +#[napi] +pub fn duplicate(descriptor: i32) -> SyscallResult { + result(unsafe { libc::fcntl(descriptor, libc::F_DUPFD_CLOEXEC, 0) }) +} + +#[napi] +pub fn file_lock(descriptor: i32, unlock: bool, nonblocking: bool) -> SyscallResult { + let flags = if unlock { + libc::LOCK_UN + } else { + libc::LOCK_EX | if nonblocking { libc::LOCK_NB } else { 0 } + }; + retry_eintr(|| unsafe { libc::flock(descriptor, flags) }) +} + +#[napi(object)] +pub struct MetadataResult { + pub errno: i32, + pub mode: u32, + pub device: String, + pub inode: String, +} + +#[napi] +// libc stat field widths differ between Linux and macOS. +#[allow(clippy::unnecessary_cast)] +pub fn stat_at(directory: i32, name: Buffer) -> napi::Result { + let name = path(name)?; + let mut stat = std::mem::MaybeUninit::::uninit(); + let code = unsafe { + libc::fstatat( + directory, + name.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if code < 0 { + return Ok(MetadataResult { + errno: io::Error::last_os_error().raw_os_error().unwrap(), + mode: 0, + device: String::new(), + inode: String::new(), + }); + } + let stat = unsafe { stat.assume_init() }; + Ok(MetadataResult { + errno: 0, + mode: stat.st_mode as u32, + device: (stat.st_dev as u64).to_string(), + inode: stat.st_ino.to_string(), + }) +} + +#[napi(object)] +pub struct ReadLinkResult { + pub errno: i32, + pub value: Buffer, +} + +#[napi] +pub fn read_link_at(directory: i32, name: Buffer) -> napi::Result { + let name = path(name)?; + let mut buffer = vec![0_u8; 256]; + loop { + let length = unsafe { + libc::readlinkat( + directory, + name.as_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + ) + }; + if length < 0 { + return Ok(ReadLinkResult { + errno: io::Error::last_os_error().raw_os_error().unwrap(), + value: Vec::new().into(), + }); + } + if (length as usize) < buffer.len() { + buffer.truncate(length as usize); + return Ok(ReadLinkResult { + errno: 0, + value: buffer.into(), + }); + } + buffer.resize(buffer.len() * 2, 0); + } +} diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 6df215058..da4287319 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -54,7 +54,7 @@ "build:plugin": "node scripts/build-plugin.mjs", "check:plugin-source": "node scripts/check-plugin-source.mjs", "check:package": "node scripts/check-package.mjs", - "format": "prettier --check --ignore-path .gitignore --ignore-path .prettierignore \"**/*.{cjs,mjs,js,ts,mts,tsx,json,md}\" \"../../.github/scripts/*.mts\" \"../../examples/custom-validation/*.{mts,md}\"", + "format": "prettier --check --ignore-path .gitignore --ignore-path .prettierignore \"**/*.{cjs,mjs,js,ts,mts,tsx,json,md}\" \"../../.github/scripts/*.mts\" \"../../examples/custom-validation/*.{mts,md}\" \"../../plugins/codex-security/native/*.{mts,md}\"", "generate:models": "node scripts/generate-models.cjs", "generate:models:check": "node scripts/generate-models.cjs --check", "lint": "tsc --noEmit", diff --git a/sdk/typescript/tsconfig.ci.json b/sdk/typescript/tsconfig.ci.json index 527df088e..3bd8a2c5c 100644 --- a/sdk/typescript/tsconfig.ci.json +++ b/sdk/typescript/tsconfig.ci.json @@ -2,6 +2,7 @@ "extends": "./tsconfig.json", "include": [ "../../.github/scripts/*.mts", + "../../plugins/codex-security/native/*.mts", "scripts/compare-test-reports.mts" ], "compilerOptions": { diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json index 1b08f5158..9631fdd3c 100644 --- a/sdk/typescript/tsconfig.json +++ b/sdk/typescript/tsconfig.json @@ -6,6 +6,7 @@ "dashboard/**/*.tsx", "tests-ts/**/*.ts", "../../.github/scripts/*.mts", + "../../plugins/codex-security/native/*.mts", "../../examples/custom-validation/*.mts", "scripts/compare-test-reports.mts", "scripts/smoke-findings-service.ts", From 7c524ad5238cde651c67692198fc1ff5731a706e Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Thu, 3 Sep 2026 00:03:58 +0000 Subject: [PATCH 08/12] fix(plugin): add native account lookup and portable proof fixtures --- plugins/codex-security/native/README.md | 4 +- plugins/codex-security/native/binding.mts | 1 + plugins/codex-security/native/proof.mts | 50 ++++++++++++++++++++--- plugins/codex-security/native/src/lib.rs | 45 +++++++++++++++++++- 4 files changed, 91 insertions(+), 9 deletions(-) diff --git a/plugins/codex-security/native/README.md b/plugins/codex-security/native/README.md index 2bfcd8569..16f23b0b2 100644 --- a/plugins/codex-security/native/README.md +++ b/plugins/codex-security/native/README.md @@ -2,7 +2,7 @@ This foundation supplies the descriptor operations that Node does not expose. The SDK and CLI continue to use their existing helpers while Windows primitives and universal package assembly are completed. -The eight Node-API 8 functions are typed in `binding.mts`. Paths remain byte buffers. `statAt` never follows the final symlink; device and inode numbers are decimal strings so JavaScript does not round them. `openAt` and `duplicate` create descriptors with close-on-exec set. Node owns subsequent reads, writes, `fstat`, `fsync`, and close calls. +The nine Node-API 8 functions are typed in `binding.mts`. Paths remain byte buffers. `statAt` never follows the final symlink; device and inode numbers are decimal strings so JavaScript does not round them. `openAt` and `duplicate` create descriptors with close-on-exec set. Node owns subsequent reads, writes, `fstat`, `fsync`, and close calls. `userHome` looks up raw username bytes through the operating system and returns raw home-directory bytes or a missing result, without Git. `openAt` and `fileLock` retry EINTR, matching the current Python helpers. Other operations return their native errno. `readDescriptor` retries one interrupted Node read without losing earlier chunks. Blocking locks must run outside the main JavaScript event loop; a process that holds a lock releases it on close or exit. A Python signal handler can raise during a blocked call, so later routing must preserve cancellation through the worker lifecycle. @@ -17,7 +17,7 @@ cargo +1.97.1 fmt --check --manifest-path plugins/codex-security/native/Cargo.to cargo +1.97.1 clippy --locked --manifest-path plugins/codex-security/native/Cargo.toml -- -D warnings ``` -The proof runs without Python. It checks directory replacement, byte paths, unreadable-file metadata, long raw symlinks, descriptor duplication, Node descriptor I/O, contention, unlock, and process-death release. CI invokes it with an empty `PATH`. During migration, the same protocol can compare the existing Python lock helper: +The proof runs without Python. It checks directory replacement, byte paths, unreadable-file metadata, long raw symlinks, descriptor duplication, Node descriptor I/O, account lookup, contention, unlock, and process-death release. Linux exercises undecodable filename bytes; macOS uses valid UTF-8 filenames required by APFS. CI invokes it with an empty `PATH`. During migration, the same protocol can compare the existing Python lock helper: ```sh node plugins/codex-security/native/proof.mjs python3 plugins/codex-security/scripts diff --git a/plugins/codex-security/native/binding.mts b/plugins/codex-security/native/binding.mts index 11f2b5987..eab03d364 100644 --- a/plugins/codex-security/native/binding.mts +++ b/plugins/codex-security/native/binding.mts @@ -43,6 +43,7 @@ export interface UnixBinding { unlock: boolean, nonblocking: boolean, ): SyscallResult; + userHome(username: Buffer): { errno: number; value: Buffer | null }; } export function loadBinding(): UnixBinding { diff --git a/plugins/codex-security/native/proof.mts b/plugins/codex-security/native/proof.mts index b2a62f8ba..981e9129c 100644 --- a/plugins/codex-security/native/proof.mts +++ b/plugins/codex-security/native/proof.mts @@ -17,7 +17,8 @@ import { symlinkSync, writeFileSync, } from "node:fs"; -import { tmpdir } from "node:os"; +import { tmpdir, userInfo } from "node:os"; +import { randomUUID } from "node:crypto"; import { basename, join } from "node:path"; import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; @@ -63,6 +64,41 @@ const readLinkAt = (fd: number, path: string | Buffer) => checked(native.readLinkAt(fd, bytes(path))).value; const rawPath = (parent: string, name: Buffer) => Buffer.concat([Buffer.from(parent + "/"), name]); +// APFS requires valid UTF-8 names; Linux also exercises undecodable bytes. +const fixtureName = (prefix: string, byte: number) => + process.platform === "darwin" + ? Buffer.from(`${prefix}-é`) + : Buffer.from([prefix.charCodeAt(0), byte]); + +function accountProof() { + let currentHomeMatches: boolean | null = null; + try { + const current = userInfo({ encoding: "buffer" }); + assert.deepEqual( + checked(native.userHome(current.username)).value, + current.homedir, + ); + currentHomeMatches = true; + } catch (error) { + // A container can run a numeric UID with no account database entry. + const system = error as { code?: string; info?: { code?: string } }; + assert.equal(system.code, "ERR_SYSTEM_ERROR"); + assert.equal(system.info?.code, "ENOENT"); + } + const other = checked(native.userHome(Buffer.from("root"))).value; + assert(other !== null && other[0] === 0x2f); + assert.equal( + checked(native.userHome(Buffer.from(`codex-${randomUUID().slice(0, 8)}`))) + .value, + null, + ); + assert.throws(() => native.userHome(Buffer.from([0]))); + return { + currentHomeMatches, + namedHomeWithoutGit: true, + missingAccount: true, + }; +} const directoryFlags = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW; @@ -109,8 +145,8 @@ function descriptorProof(root: string) { const originalParent = keep(openAt(rootFd, "artifacts", directoryFlags)); const parentFd = keep(duplicate(originalParent)); close(originalParent); - const unreadable = Buffer.from([0x75, 0xff]); - const rawLink = Buffer.from([0x6c, 0xfe]); + const unreadable = fixtureName("u", 0xff); + const rawLink = fixtureName("l", 0xfe); const rawTarget = Buffer.concat([ Buffer.from("component/".repeat(80)), Buffer.from([0xff]), @@ -154,9 +190,9 @@ function descriptorProof(root: string) { assert.equal(readLinkAt(rootFd, "artifacts").toString(), outside); mkdirAt(parentFd, "nested", 0o700); - const rawDirectory = Buffer.from([0x64, 0xfd]); - const rawSource = Buffer.from([0x73, 0xfc]); - const rawDestination = Buffer.from([0x74, 0xfb]); + const rawDirectory = fixtureName("d", 0xfd); + const rawSource = fixtureName("s", 0xfc); + const rawDestination = fixtureName("t", 0xfb); mkdirAt(parentFd, rawDirectory, 0o700); assert.equal(statAt(parentFd, rawDirectory).mode & 0o777, 0o700); const rawDirectoryFd = keep(openAt(parentFd, rawDirectory, directoryFlags)); @@ -445,6 +481,7 @@ if (process.argv[2] === "lock-worker") { const root = mkdtempSync(join(tmpdir(), "codex-security-native-")); try { const descriptors = descriptorProof(root); + const accounts = accountProof(); const locks = await lockProof(root); const pythonCompatibility = python && scripts ? await lockProof(root, python, scripts) : undefined; @@ -456,6 +493,7 @@ if (process.argv[2] === "lock-worker") { architecture: process.arch, nodeApi: 8, descriptors, + accounts, locks, pythonCompatibility, fixture: basename(root), diff --git a/plugins/codex-security/native/src/lib.rs b/plugins/codex-security/native/src/lib.rs index a4b353dfa..713b39abd 100644 --- a/plugins/codex-security/native/src/lib.rs +++ b/plugins/codex-security/native/src/lib.rs @@ -1,6 +1,9 @@ use napi::bindgen_prelude::Buffer; use napi_derive::napi; -use std::{ffi::CString, io}; +use std::{ + ffi::{CStr, CString}, + io, +}; #[napi(object)] pub struct SyscallResult { @@ -168,3 +171,43 @@ pub fn read_link_at(directory: i32, name: Buffer) -> napi::Result, +} + +#[napi] +pub fn user_home(username: Buffer) -> napi::Result { + let username = path(username)?; + let mut buffer = vec![0_u8; 1024]; + loop { + let mut entry = std::mem::MaybeUninit::::uninit(); + let mut found = std::ptr::null_mut(); + let code = unsafe { + libc::getpwnam_r( + username.as_ptr(), + entry.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut found, + ) + }; + if code == libc::ERANGE { + buffer.resize(buffer.len() * 2, 0); + continue; + } + let value = if code == 0 && !found.is_null() { + Some( + unsafe { CStr::from_ptr((*found).pw_dir) } + .to_bytes() + .to_vec() + .into(), + ) + } else { + None + }; + return Ok(UserHomeResult { errno: code, value }); + } +} From 2b6e4323eb4d46f01be157326cda29770e4b30a8 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Thu, 3 Sep 2026 00:19:14 +0000 Subject: [PATCH 09/12] fix(native): avoid absolute macOS library install names --- plugins/codex-security/native/build.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/codex-security/native/build.rs b/plugins/codex-security/native/build.rs index 0f1b01002..02a624e82 100644 --- a/plugins/codex-security/native/build.rs +++ b/plugins/codex-security/native/build.rs @@ -1,3 +1,7 @@ fn main() { napi_build::setup(); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { + // The linker otherwise embeds its absolute output path as the install name. + println!("cargo:rustc-link-arg-cdylib=-Wl,-install_name,@rpath/unix.node"); + } } From 1973e835b950ea45b0bd79364e03c4e127342f5c Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Thu, 3 Sep 2026 00:11:55 +0000 Subject: [PATCH 10/12] refactor(plugin): add Python-free Windows OS primitives --- .github/workflows/native-windows.yml | 89 +++ plugins/codex-security/native/Cargo.lock | 12 +- plugins/codex-security/native/Cargo.toml | 7 +- plugins/codex-security/native/README.md | 18 +- plugins/codex-security/native/binding.mts | 5 +- plugins/codex-security/native/build.mts | 25 +- plugins/codex-security/native/check.mts | 22 +- .../codex-security/native/proof-windows.mts | 579 ++++++++++++++++++ plugins/codex-security/native/src/lib.rs | 217 +------ plugins/codex-security/native/src/unix.rs | 213 +++++++ plugins/codex-security/native/src/windows.rs | 401 ++++++++++++ .../codex-security/native/windows-binding.mts | 70 +++ 12 files changed, 1434 insertions(+), 224 deletions(-) create mode 100644 .github/workflows/native-windows.yml create mode 100644 plugins/codex-security/native/proof-windows.mts create mode 100644 plugins/codex-security/native/src/unix.rs create mode 100644 plugins/codex-security/native/src/windows.rs create mode 100644 plugins/codex-security/native/windows-binding.mts diff --git a/.github/workflows/native-windows.yml b/.github/workflows/native-windows.yml new file mode 100644 index 000000000..5dc1cbb1a --- /dev/null +++ b/.github/workflows/native-windows.yml @@ -0,0 +1,89 @@ +name: native-windows + +on: + push: + branches: [main] + pull_request: + types: [opened, reopened, synchronize] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + primitives: + name: primitives / win32-${{ matrix.arch }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - runner: windows-2022 + arch: x64 + target: x86_64-pc-windows-msvc + - runner: windows-11-arm + arch: arm64 + target: aarch64-pc-windows-msvc + defaults: + run: + shell: pwsh + working-directory: plugins/codex-security/native + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Set up pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: package.json + - name: Set up Node.js 22 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: "22.13.0" + architecture: ${{ matrix.arch }} + - name: Compile TypeScript tools + working-directory: . + run: | + pnpm --dir sdk/typescript install --frozen-lockfile + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + pnpm --dir sdk/typescript run build:ci + - name: Set up Rust + run: rustup toolchain install 1.97.1 --profile minimal --component rustfmt --component clippy --target ${{ matrix.target }} + - name: Check Rust source + run: | + cargo fmt --check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo clippy --locked --target ${{ matrix.target }} -- -D warnings + - name: Build and verify with Node.js 22 + run: | + node build.mjs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node check.mjs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $nativeNode = (Get-Command node).Source + $env:PATH = "" + & $nativeNode --expose-gc proof-windows.mjs + - name: Set up Node.js 20 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: "20.0.0" + architecture: ${{ matrix.arch }} + - name: Verify the same artifact with Node.js 20 + run: | + node check.mjs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $nativeNode = (Get-Command node).Source + $env:PATH = "" + & $nativeNode --expose-gc proof-windows.mjs + - name: Upload verified native artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-win32-${{ matrix.arch }}-${{ github.sha }} + path: plugins/codex-security/native/dist/win32-${{ matrix.arch }}/windows.node + if-no-files-found: error + retention-days: 7 diff --git a/plugins/codex-security/native/Cargo.lock b/plugins/codex-security/native/Cargo.lock index 346cf3300..b1e1532f2 100644 --- a/plugins/codex-security/native/Cargo.lock +++ b/plugins/codex-security/native/Cargo.lock @@ -15,13 +15,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "codex-security-native-unix" +name = "codex-security-native" version = "0.0.0" dependencies = [ "libc", "napi", "napi-build", "napi-derive", + "windows-sys", ] [[package]] @@ -294,3 +295,12 @@ name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/plugins/codex-security/native/Cargo.toml b/plugins/codex-security/native/Cargo.toml index dbc6d2334..50a3bb3ad 100644 --- a/plugins/codex-security/native/Cargo.toml +++ b/plugins/codex-security/native/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "codex-security-native-unix" +name = "codex-security-native" version = "0.0.0" edition = "2021" rust-version = "1.97" @@ -12,8 +12,13 @@ crate-type = ["cdylib"] [dependencies] napi = { version = "=3.12.2", default-features = false, features = ["napi8"] } napi-derive = "=3.6.3" + +[target.'cfg(unix)'.dependencies] libc = "=0.2.189" +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_IO"] } + [build-dependencies] napi-build = "=2.4.1" diff --git a/plugins/codex-security/native/README.md b/plugins/codex-security/native/README.md index 16f23b0b2..216e3c3df 100644 --- a/plugins/codex-security/native/README.md +++ b/plugins/codex-security/native/README.md @@ -1,6 +1,6 @@ -# Unix native primitives +# Native OS primitives -This foundation supplies the descriptor operations that Node does not expose. The SDK and CLI continue to use their existing helpers while Windows primitives and universal package assembly are completed. +This foundation supplies the OS operations that Node does not expose. The SDK and CLI continue to use their existing helpers while universal package assembly and migration proofs are completed. The nine Node-API 8 functions are typed in `binding.mts`. Paths remain byte buffers. `statAt` never follows the final symlink; device and inode numbers are decimal strings so JavaScript does not round them. `openAt` and `duplicate` create descriptors with close-on-exec set. Node owns subsequent reads, writes, `fstat`, `fsync`, and close calls. `userHome` looks up raw username bytes through the operating system and returns raw home-directory bytes or a missing result, without Git. @@ -32,3 +32,17 @@ node plugins/codex-security/native/check.mjs Linux artifacts must import no glibc version newer than 2.28. macOS artifacts must declare a deployment target of 11.0 or earlier. A build from a newer Linux workstation can pass the behavioral proof and still fail this distribution check. The `native-unix` workflow builds Linux artifacts in digest-pinned manylinux 2.28 images. It mounts the pinned Rust toolchain and fetched Cargo registry, builds offline, and blocks Python commands during compilation. macOS builds set `MACOSX_DEPLOYMENT_TARGET=11.0`. CI verifies separate x64 and arm64 artifacts on both platforms using Node 20.0.0 and 22.13.0. These artifacts are inputs to the later universal-package gate. + +Windows uses `windows-binding.mts` and the same Rust crate. `WindowsHandle` owns a non-inheritable Win32 handle; explicit `close()` and garbage collection release it. Handles never cross into Node's CRT descriptor table. Paths and returned names are UTF-16LE buffers without a NUL terminator, preserving lone surrogates. Volume identities and file positions are decimal strings; file IDs retain all 128 bits in a buffer. + +The binding exposes synchronous file and directory creation, attributes and reparse tags, identity and final/opened names, read/write/seek/size/EOF/flush, exact-handle rename and deletion, and byte-range locking. Calls return numeric Windows errors. Buffer ranges, path encoding, and 64-bit arguments are checked before FFI calls. Overlapped handles are unsupported because pending operations could retain native buffers beyond the call. Path authorization, ancestor traversal, and reparse-point policy remain the caller's responsibility. + +Build on Windows after compiling the TypeScript tools, then run: + +```sh +node plugins/codex-security/native/build.mjs +node plugins/codex-security/native/check.mjs +node --expose-gc plugins/codex-security/native/proof-windows.mjs +``` + +The `native-windows` workflow builds x64 and arm64 with MSVC and a static CRT. It checks PE architecture and private paths, then runs the same artifact on Node 22.13.0 and 20.0.0 with an empty `PATH`. The proof covers handle lifetime and garbage collection, ancestor replacement, junctions, exact-handle operations, raw UTF-16 and long paths, numeric errors, and cross-process byte-zero locking and release. Blocking locks run in child processes. Comparison with the existing Python `msvcrt` lock remains a separate migration gate before production routing. diff --git a/plugins/codex-security/native/binding.mts b/plugins/codex-security/native/binding.mts index eab03d364..4dac94153 100644 --- a/plugins/codex-security/native/binding.mts +++ b/plugins/codex-security/native/binding.mts @@ -5,7 +5,10 @@ import { fileURLToPath } from "node:url"; export const root = dirname(fileURLToPath(import.meta.url)); export const output = join(root, "dist", `${process.platform}-${process.arch}`); -export const binaryPath = join(output, "unix.node"); +export const binaryPath = join( + output, + process.platform === "win32" ? "windows.node" : "unix.node", +); export interface SyscallResult { value: number; diff --git a/plugins/codex-security/native/build.mts b/plugins/codex-security/native/build.mts index 50e675640..ba14ef327 100644 --- a/plugins/codex-security/native/build.mts +++ b/plugins/codex-security/native/build.mts @@ -5,7 +5,18 @@ import { join, resolve } from "node:path"; import { binaryPath, output, root } from "./binding.mjs"; import { checkPrivatePaths } from "./check.mjs"; -const extension = process.platform === "darwin" ? "dylib" : "so"; +let windowsTarget: string | undefined; +if (process.platform === "win32") { + const architecture = + process.arch === "arm64" + ? "aarch64" + : process.arch === "x64" + ? "x86_64" + : undefined; + if (architecture === undefined) + throw new Error("Windows native builds support x64 and arm64."); + windowsTarget = `${architecture}-pc-windows-msvc`; +} const cargoHome = resolve( root, process.env["CARGO_HOME"] ?? join(homedir(), ".cargo"), @@ -20,12 +31,15 @@ const inheritedFlags = []; const flags = [ ...inheritedFlags, + ...(windowsTarget === undefined ? [] : ["-C", "target-feature=+crt-static"]), `--remap-path-prefix=${root}=codex-security-native`, `--remap-path-prefix=${cargoHome}=cargo`, `--remap-path-prefix=${sysroot}=rust-toolchain`, ]; const target = resolve(root, process.env["CARGO_TARGET_DIR"] ?? "target"); -execFileSync("cargo", ["build", "--release", "--locked"], { +const args = ["build", "--release", "--locked"]; +if (windowsTarget !== undefined) args.push("--target", windowsTarget); +execFileSync("cargo", args, { cwd: root, stdio: "inherit", env: { @@ -38,10 +52,13 @@ execFileSync("cargo", ["build", "--release", "--locked"], { }); const library = join( target, + ...(windowsTarget === undefined ? [] : [windowsTarget]), "release", - `libcodex_security_native_unix.${extension}`, + windowsTarget === undefined + ? `libcodex_security_native.${process.platform === "darwin" ? "dylib" : "so"}` + : "codex_security_native.dll", ); -checkPrivatePaths(readFileSync(library), [root, cargoHome, sysroot]); +checkPrivatePaths(readFileSync(library), [root, cargoHome, sysroot, target]); mkdirSync(output, { recursive: true }); copyFileSync(library, binaryPath); console.log(`Built ${process.platform}-${process.arch} Node-API 8 primitives.`); diff --git a/plugins/codex-security/native/check.mts b/plugins/codex-security/native/check.mts index 41362fe85..acc949121 100644 --- a/plugins/codex-security/native/check.mts +++ b/plugins/codex-security/native/check.mts @@ -15,7 +15,10 @@ export function checkPrivatePaths( "/tmp/codex-security-python-", ...buildPaths, ]) { - if (bytes.includes(Buffer.from(marker))) { + if ( + bytes.includes(Buffer.from(marker)) || + bytes.includes(Buffer.from(marker, "utf16le")) + ) { throw new Error("Native payload contains a private build path."); } } @@ -73,8 +76,23 @@ if ( ); } floor = "macOS 11.0"; + } else if (process.platform === "win32") { + const header = bytes.readUInt32LE(0x3c); + const machine = process.arch === "arm64" ? 0xaa64 : 0x8664; + if ( + bytes.toString("ascii", 0, 2) !== "MZ" || + bytes.toString("ascii", header, header + 4) !== "PE\0\0" || + bytes.readUInt16LE(header + 4) !== machine + ) { + throw new Error( + "Native payload is not a PE image for this architecture.", + ); + } + floor = "Windows MSVC; Node 20 and 22 load proofs required"; } else { - throw new Error("This foundation verifies Linux and macOS artifacts only."); + throw new Error( + "This foundation verifies Linux, macOS, and Windows artifacts only.", + ); } console.log( JSON.stringify({ diff --git a/plugins/codex-security/native/proof-windows.mts b/plugins/codex-security/native/proof-windows.mts new file mode 100644 index 000000000..678a49b83 --- /dev/null +++ b/plugins/codex-security/native/proof-windows.mts @@ -0,0 +1,579 @@ +import assert from "node:assert/strict"; +import { fork, type ChildProcess } from "node:child_process"; +import { + existsSync, + linkSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join, win32 } from "node:path"; +import { fileURLToPath } from "node:url"; +import { setImmediate } from "node:timers/promises"; +import { + loadWindowsBinding, + windowsFlags as flags, + type WindowsHandle, +} from "./windows-binding.mjs"; + +assert.equal(process.platform, "win32", "Windows proof requires Windows"); +const native = loadWindowsBinding(); +const shareAll = + flags.FILE_SHARE_READ | flags.FILE_SHARE_WRITE | flags.FILE_SHARE_DELETE; +const directoryFlags = + flags.FILE_FLAG_BACKUP_SEMANTICS | flags.FILE_FLAG_OPEN_REPARSE_POINT; +const readWrite = flags.GENERIC_READ | flags.GENERIC_WRITE; +const self = fileURLToPath(import.meta.url); +const pathBytes = (path: string) => + Buffer.from(win32.toNamespacedPath(path), "utf16le"); +const checked = (result: T): T => { + assert.equal(result.error, 0, `Win32 error ${result.error}`); + return result; +}; +const success = (error: number) => checked({ error }); + +function open( + path: string | Buffer, + access = readWrite | flags.DELETE | flags.FILE_READ_ATTRIBUTES, + share = shareAll, + disposition: number = flags.OPEN_EXISTING, + attributes: number = flags.FILE_ATTRIBUTE_NORMAL, +): WindowsHandle { + const result = checked( + native.openWindowsFile( + typeof path === "string" ? pathBytes(path) : path, + access, + share, + disposition, + attributes, + ), + ); + assert(result.handle); + return result.handle; +} + +function samePath(actual: Buffer, expected: string): void { + assert.equal( + win32.normalize(actual.toString("utf16le")).toLowerCase(), + win32.toNamespacedPath(expected).toLowerCase(), + ); +} + +function remove(path: string): void { + const result = native.openWindowsFile( + pathBytes(path), + flags.DELETE, + shareAll, + flags.OPEN_EXISTING, + directoryFlags, + ); + if (result.error === 2 || result.error === 3) return; + const handle = checked(result).handle!; + try { + success(handle.setDisposition(true)); + } finally { + success(handle.close()); + } +} + +function handleProof(root: string) { + const held = new Set(); + const rawPaths: string[] = []; + const keep = (handle: WindowsHandle) => { + held.add(handle); + return handle; + }; + const close = (handle: WindowsHandle) => { + success(handle.close()); + held.delete(handle); + }; + try { + const path = join(root, "data"); + const file = keep(open(path, undefined, undefined, flags.CREATE_NEW)); + const payload = Buffer.from("handle I/O 🔐\n"); + const input = Buffer.concat([ + Buffer.from("ignore"), + payload, + Buffer.from("tail"), + ]); + assert.equal( + checked(file.write(input, 6, payload.length)).value, + payload.length, + ); + success(file.flush()); + assert.equal(checked(file.size()).value, String(payload.length)); + assert.equal(checked(file.fileType()).value, 1); + assert.equal( + checked(file.attributes()).attributes & flags.FILE_ATTRIBUTE_DIRECTORY, + 0, + ); + assert.equal(checked(file.seek(0n, flags.FILE_BEGIN)).value, "0"); + const buffer = Buffer.alloc(payload.length + 6, 0x7e); + assert.equal( + checked(file.read(buffer, 3, payload.length)).value, + payload.length, + ); + assert.deepEqual(buffer.subarray(3, -3), payload); + assert.deepEqual(buffer.subarray(0, 3), Buffer.from("~~~")); + assert.deepEqual(buffer.subarray(-3), Buffer.from("~~~")); + assert.equal(checked(file.read(buffer, 0, 1)).value, 0); + const far = (1n << 53n) + 5n; + assert.equal( + checked(file.seek(far, flags.FILE_BEGIN)).value, + far.toString(), + ); + assert.equal( + checked(file.seek(-2n, flags.FILE_END)).value, + String(payload.length - 2), + ); + success(file.setEndOfFile()); + assert.equal(checked(file.size()).value, String(payload.length - 2)); + samePath(checked(file.finalPath(0)).path, path); + samePath(checked(file.finalPath(flags.FILE_NAME_OPENED)).path, path); + + const identity = checked(file.identity()); + assert.match(identity.volume, /^\d+$/u); + assert.equal(identity.fileId.length, 16); + const link = join(root, "hard-link"); + linkSync(path, link); + const second = keep(open(link)); + assert.deepEqual(checked(second.identity()), identity); + close(second); + assert.equal( + native.openWindowsFile( + pathBytes(path), + flags.GENERIC_READ, + 0, + flags.OPEN_EXISTING, + 0, + ).error, + 32, + ); + assert.equal( + native.openWindowsFile( + pathBytes(join(root, "missing")), + flags.GENERIC_READ, + shareAll, + flags.OPEN_EXISTING, + 0, + ).error, + 2, + ); + for (const [offset, length] of [ + [-1, 1], + [0, -1], + [0.5, 1], + [0, NaN], + [0, Infinity], + [0, 2 ** 32], + [buffer.length, 1], + ]) { + assert.throws(() => file.read(buffer, offset!, length!)); + assert.throws(() => file.write(buffer, offset!, length!)); + } + for (const invalid of [ + Buffer.from([0x41]), + Buffer.from("bad\0path", "utf16le"), + ]) { + assert.throws(() => + native.openWindowsFile(invalid, 0, 0, flags.OPEN_EXISTING, 0), + ); + } + assert.throws(() => + native.openWindowsFile( + pathBytes(path), + readWrite, + shareAll, + flags.OPEN_EXISTING, + flags.FILE_FLAG_OVERLAPPED, + ), + ); + assert.throws(() => file.seek(1n << 63n, flags.FILE_BEGIN)); + assert.throws(() => file.lock(-1n, 1n, true, true)); + assert.throws(() => file.lock(0n, 1n << 64n, true, true)); + close(file); + success(file.close()); + assert.equal(file.size().error, 6); + + const ancestor = join(root, "ancestor"); + const scan = join(ancestor, "scan"); + const child = join(scan, "child"); + for (const directory of [ancestor, scan, child]) { + success(native.createWindowsDirectory(pathBytes(directory))); + } + const directories = [ancestor, scan, child].map((directory) => + keep( + open( + directory, + flags.FILE_READ_ATTRIBUTES, + flags.FILE_SHARE_READ | flags.FILE_SHARE_WRITE, + flags.OPEN_EXISTING, + directoryFlags, + ), + ), + ); + for (const directory of [ancestor, scan, child]) { + assert.equal( + native.openWindowsFile( + pathBytes(directory), + flags.DELETE, + shareAll, + flags.OPEN_EXISTING, + directoryFlags, + ).error, + 32, + ); + assert.throws(() => renameSync(directory, directory + "-moved")); + } + for (const handle of directories.reverse()) close(handle); + renameSync(ancestor, ancestor + "-moved"); + const target = join(root, "target"); + mkdirSync(target); + writeFileSync(join(target, "sentinel"), "target unchanged"); + symlinkSync(target, ancestor, "junction"); + const junction = keep( + open( + ancestor, + flags.FILE_READ_ATTRIBUTES, + shareAll, + flags.OPEN_EXISTING, + directoryFlags, + ), + ); + const attributes = checked(junction.attributes()); + assert(attributes.attributes & flags.FILE_ATTRIBUTE_REPARSE_POINT); + assert(attributes.attributes & flags.FILE_ATTRIBUTE_DIRECTORY); + assert.equal(attributes.reparseTag, 0xa0000003); + samePath( + checked(junction.finalPath(flags.FILE_NAME_OPENED)).path, + ancestor, + ); + const followed = keep( + open( + ancestor, + flags.FILE_READ_ATTRIBUTES, + shareAll, + flags.OPEN_EXISTING, + flags.FILE_FLAG_BACKUP_SEMANTICS, + ), + ); + samePath(checked(followed.finalPath(0)).path, target); + assert( + !( + checked(followed.attributes()).attributes & + flags.FILE_ATTRIBUTE_REPARSE_POINT + ), + ); + close(followed); + close(junction); + remove(ancestor); + assert.equal( + readFileSync(join(target, "sentinel"), "utf8"), + "target unchanged", + ); + + const source = join(root, "source"); + const moved = join(root, "moved-source"); + const destination = join(root, "destination"); + const exact = keep(open(source, undefined, undefined, flags.CREATE_NEW)); + const exactPayload = Buffer.from("exact handle"); + assert.equal( + checked(exact.write(exactPayload, 0, exactPayload.length)).value, + exactPayload.length, + ); + success(exact.flush()); + const exactIdentity = checked(exact.identity()); + renameSync(source, moved); + writeFileSync(source, "replacement source"); + writeFileSync(destination, "old destination"); + assert([80, 183].includes(exact.rename(pathBytes(destination), false))); + success(exact.rename(pathBytes(destination), true)); + assert.equal(readFileSync(destination, "utf8"), "exact handle"); + assert.equal(readFileSync(source, "utf8"), "replacement source"); + assert(!existsSync(moved)); + assert.deepEqual(checked(exact.identity()), exactIdentity); + samePath(checked(exact.finalPath(0)).path, destination); + renameSync(destination, moved); + writeFileSync(destination, "replacement destination"); + success(exact.setDisposition(true)); + close(exact); + assert(!existsSync(moved)); + assert.equal(readFileSync(destination, "utf8"), "replacement destination"); + + let longDirectory = root; + for (let index = 0; index < 5; index++) { + longDirectory = join(longDirectory, `part-${index}-${"x".repeat(55)}`); + success(native.createWindowsDirectory(pathBytes(longDirectory))); + } + const rawDirectory = join(longDirectory, "directory-\udfff"); + success(native.createWindowsDirectory(pathBytes(rawDirectory))); + rawPaths.push(rawDirectory); + const rawPath = join(rawDirectory, "file-\ud800"); + // A lossy UTF-8 round trip would collide with this different filename. + const replacement = join(longDirectory, "directory-\ufffd"); + mkdirSync(replacement); + writeFileSync(join(replacement, "file-\ufffd"), "replacement sentinel"); + const raw = keep(open(rawPath, undefined, undefined, flags.CREATE_NEW)); + rawPaths.push(rawPath); + assert.equal( + checked(raw.write(exactPayload, 0, exactPayload.length)).value, + exactPayload.length, + ); + success(raw.flush()); + const finalName = checked(raw.finalPath(flags.FILE_NAME_OPENED)).path; + assert(finalName.length / 2 > 260); + assert( + finalName.includes( + Buffer.from("directory-\udfff\\file-\ud800", "utf16le"), + ), + ); + const reopened = keep(open(finalName)); + assert.deepEqual(checked(reopened.identity()), checked(raw.identity())); + const rawContents = Buffer.alloc(exactPayload.length); + assert.equal( + checked(reopened.read(rawContents, 0, rawContents.length)).value, + rawContents.length, + ); + assert.deepEqual(rawContents, exactPayload); + close(reopened); + close(raw); + assert.equal( + readFileSync(join(replacement, "file-\ufffd"), "utf8"), + "replacement sentinel", + ); + return { + handleReadWriteFlushSeekSizeAndEof: true, + exact64BitPositionAnd128BitIdentity: true, + numericMissingSharingAndClosedErrors: true, + ancestorReplacementBlockedUntilClose: true, + junctionMetadataAndFinalNames: true, + exactHandleRenameAndDeleteAfterNameReplacement: true, + rawUtf16AndLongPaths: true, + invalidFfiRepresentationsRejected: true, + }; + } finally { + for (const handle of held) handle.close(); + for (const path of rawPaths.reverse()) remove(path); + } +} + +async function ownershipProof(root: string): Promise { + assert(global.gc, "Run the Windows proof with --expose-gc"); + const path = join(root, "garbage-collected-handle"); + open(path, readWrite, 0, flags.CREATE_NEW); + await setImmediate(); + global.gc(); + await setImmediate(); + const reopened = open(path, readWrite, 0); + success(reopened.close()); + return true; +} + +interface Message { + type: string; + error?: number; +} +const send = (message: Message) => + new Promise((resolve, reject) => { + process.send!(message, (error) => (error ? reject(error) : resolve())); + }); + +async function worker(path: string): Promise { + const handle = open(path, readWrite, shareAll, flags.OPEN_ALWAYS); + process.on("disconnect", () => { + handle.close(); + process.exit(0); + }); + process.on("message", async (command: string) => { + if (command === "probe") { + const error = handle.lock(0n, 1n, true, true); + if (error === 0) success(handle.unlock(0n, 1n)); + await send({ type: "probe", error }); + } else if (command === "lock") { + await send({ type: "attempting" }); + success(handle.lock(0n, 1n, true, false)); + await send({ type: "acquired" }); + } else if (command === "unlock") { + success(handle.unlock(0n, 1n)); + await send({ type: "released" }); + } else if (command === "close") { + success(handle.close()); + await send({ type: "closed" }); + } else if (command === "exit") { + success(handle.close()); + process.disconnect!(); + } else throw new Error(`Unknown worker command: ${command}`); + }); + await send({ type: "ready" }); +} + +const peers = new Set(); +class Peer { + readonly child: ChildProcess; + readonly exited: Promise; + private messages: Message[] = []; + private pending?: (message: Message) => void; + private stderr = ""; + constructor(path: string) { + this.child = fork(self, ["worker", path], { + execPath: process.execPath, + execArgv: [], + stdio: ["ignore", "pipe", "pipe", "ipc"], + }); + peers.add(this); + this.child.stderr!.on("data", (chunk: Buffer) => { + this.stderr += chunk.toString(); + }); + this.child.on("message", (message: Message) => { + if (this.pending) { + const receive = this.pending; + this.pending = undefined; + receive(message); + } else this.messages.push(message); + }); + this.exited = new Promise((resolve) => + this.child.once("exit", () => resolve()), + ); + } + send(command: string): void { + this.child.send(command); + } + async next(type: string): Promise { + let timer: ReturnType | undefined; + try { + const message = await Promise.race([ + this.messages.length + ? Promise.resolve(this.messages.shift()!) + : new Promise((resolve) => { + this.pending = resolve; + }), + this.exited.then(() => { + throw new Error(`Worker exited before ${type}: ${this.stderr}`); + }), + new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error(`Worker timed out before ${type}: ${this.stderr}`), + ), + 30_000, + ); + }), + ]); + assert.equal(message.type, type); + return message; + } finally { + clearTimeout(timer); + } + } + async stop(kill = false): Promise { + if (kill) this.child.kill("SIGKILL"); + else this.send("exit"); + let timer: ReturnType | undefined; + try { + await Promise.race([ + this.exited, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Worker did not exit: ${this.stderr}`)), + 30_000, + ); + }), + ]); + } finally { + clearTimeout(timer); + } + peers.delete(this); + if (!kill) { + assert.equal(this.child.exitCode, 0, this.stderr); + assert.equal(this.stderr, ""); + } + } +} + +async function lockProof(root: string) { + const path = join(root, "byte-lock"); + const parent = open(path, readWrite, shareAll, flags.OPEN_ALWAYS); + const other = open(path, readWrite, shareAll); + try { + const high = (1n << 32n) + 5n; + success(parent.lock(high, 2n, true, true)); + assert.equal(other.lock(high, 1n, true, true), 33); + success(other.lock(5n, 1n, true, true)); + success(other.unlock(5n, 1n)); + success(parent.unlock(high, 2n)); + success(parent.lock(0n, 1n, true, true)); + const first = new Peer(path); + await first.next("ready"); + first.send("probe"); + assert.equal((await first.next("probe")).error, 33); + first.send("lock"); + await first.next("attempting"); + success(parent.unlock(0n, 1n)); + await first.next("acquired"); + assert.equal(parent.lock(0n, 1n, true, true), 33); + first.send("unlock"); + await first.next("released"); + success(parent.lock(0n, 1n, true, true)); + first.send("lock"); + await first.next("attempting"); + success(parent.close()); + await first.next("acquired"); + const second = new Peer(path); + await second.next("ready"); + second.send("lock"); + await second.next("attempting"); + await first.stop(true); + await second.next("acquired"); + assert.equal(other.lock(0n, 1n, true, true), 33); + second.send("close"); + await second.next("closed"); + success(other.lock(0n, 1n, true, true)); + success(other.unlock(0n, 1n)); + await second.stop(); + return { + byteZeroContention: true, + high64BitRanges: true, + blockingHandoff: true, + unlockCloseAndProcessDeathRelease: true, + }; + } finally { + parent.close(); + other.close(); + for (const peer of peers) await peer.stop(true); + } +} + +if (process.argv[2] === "worker") { + await worker(process.argv[3]!); +} else { + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), "codex-security-windows-")), + ); + try { + console.log( + JSON.stringify( + { + node: process.version, + platform: process.platform, + architecture: process.arch, + nodeApi: 8, + handles: handleProof(root), + garbageCollectionClosesHandle: await ownershipProof(root), + locks: await lockProof(root), + fixture: basename(root), + }, + null, + 2, + ), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} diff --git a/plugins/codex-security/native/src/lib.rs b/plugins/codex-security/native/src/lib.rs index 713b39abd..5ec2a12ac 100644 --- a/plugins/codex-security/native/src/lib.rs +++ b/plugins/codex-security/native/src/lib.rs @@ -1,213 +1,4 @@ -use napi::bindgen_prelude::Buffer; -use napi_derive::napi; -use std::{ - ffi::{CStr, CString}, - io, -}; - -#[napi(object)] -pub struct SyscallResult { - pub value: i32, - pub errno: i32, -} - -fn result(value: i32) -> SyscallResult { - SyscallResult { - value, - errno: if value < 0 { - io::Error::last_os_error().raw_os_error().unwrap() - } else { - 0 - }, - } -} - -fn retry_eintr(mut operation: impl FnMut() -> i32) -> SyscallResult { - loop { - let value = result(operation()); - if value.errno != libc::EINTR { - return value; - } - } -} - -fn path(value: Buffer) -> napi::Result { - CString::new(value.as_ref()).map_err(|_| napi::Error::from_reason("Path contains a NUL byte")) -} - -#[napi] -pub fn open_at(directory: i32, name: Buffer, flags: i32, mode: u32) -> napi::Result { - let name = path(name)?; - let mode = mode as libc::mode_t; - // macOS mode_t is u16 and needs C integer promotion in this variadic call. - #[cfg(target_os = "macos")] - let mode = libc::c_int::from(mode); - Ok(retry_eintr(|| unsafe { - libc::openat(directory, name.as_ptr(), flags | libc::O_CLOEXEC, mode) - })) -} - -#[napi] -pub fn make_directory_at(directory: i32, name: Buffer, mode: u32) -> napi::Result { - let name = path(name)?; - Ok(result(unsafe { - libc::mkdirat(directory, name.as_ptr(), mode as libc::mode_t) - })) -} - -#[napi] -pub fn rename_at( - old_directory: i32, - old_name: Buffer, - new_directory: i32, - new_name: Buffer, -) -> napi::Result { - let old_name = path(old_name)?; - let new_name = path(new_name)?; - Ok(result(unsafe { - libc::renameat( - old_directory, - old_name.as_ptr(), - new_directory, - new_name.as_ptr(), - ) - })) -} - -#[napi] -pub fn unlink_at(directory: i32, name: Buffer) -> napi::Result { - let name = path(name)?; - Ok(result(unsafe { - libc::unlinkat(directory, name.as_ptr(), 0) - })) -} - -#[napi] -pub fn duplicate(descriptor: i32) -> SyscallResult { - result(unsafe { libc::fcntl(descriptor, libc::F_DUPFD_CLOEXEC, 0) }) -} - -#[napi] -pub fn file_lock(descriptor: i32, unlock: bool, nonblocking: bool) -> SyscallResult { - let flags = if unlock { - libc::LOCK_UN - } else { - libc::LOCK_EX | if nonblocking { libc::LOCK_NB } else { 0 } - }; - retry_eintr(|| unsafe { libc::flock(descriptor, flags) }) -} - -#[napi(object)] -pub struct MetadataResult { - pub errno: i32, - pub mode: u32, - pub device: String, - pub inode: String, -} - -#[napi] -// libc stat field widths differ between Linux and macOS. -#[allow(clippy::unnecessary_cast)] -pub fn stat_at(directory: i32, name: Buffer) -> napi::Result { - let name = path(name)?; - let mut stat = std::mem::MaybeUninit::::uninit(); - let code = unsafe { - libc::fstatat( - directory, - name.as_ptr(), - stat.as_mut_ptr(), - libc::AT_SYMLINK_NOFOLLOW, - ) - }; - if code < 0 { - return Ok(MetadataResult { - errno: io::Error::last_os_error().raw_os_error().unwrap(), - mode: 0, - device: String::new(), - inode: String::new(), - }); - } - let stat = unsafe { stat.assume_init() }; - Ok(MetadataResult { - errno: 0, - mode: stat.st_mode as u32, - device: (stat.st_dev as u64).to_string(), - inode: stat.st_ino.to_string(), - }) -} - -#[napi(object)] -pub struct ReadLinkResult { - pub errno: i32, - pub value: Buffer, -} - -#[napi] -pub fn read_link_at(directory: i32, name: Buffer) -> napi::Result { - let name = path(name)?; - let mut buffer = vec![0_u8; 256]; - loop { - let length = unsafe { - libc::readlinkat( - directory, - name.as_ptr(), - buffer.as_mut_ptr().cast(), - buffer.len(), - ) - }; - if length < 0 { - return Ok(ReadLinkResult { - errno: io::Error::last_os_error().raw_os_error().unwrap(), - value: Vec::new().into(), - }); - } - if (length as usize) < buffer.len() { - buffer.truncate(length as usize); - return Ok(ReadLinkResult { - errno: 0, - value: buffer.into(), - }); - } - buffer.resize(buffer.len() * 2, 0); - } -} - -#[napi(object, use_nullable = true)] -pub struct UserHomeResult { - pub errno: i32, - pub value: Option, -} - -#[napi] -pub fn user_home(username: Buffer) -> napi::Result { - let username = path(username)?; - let mut buffer = vec![0_u8; 1024]; - loop { - let mut entry = std::mem::MaybeUninit::::uninit(); - let mut found = std::ptr::null_mut(); - let code = unsafe { - libc::getpwnam_r( - username.as_ptr(), - entry.as_mut_ptr(), - buffer.as_mut_ptr().cast(), - buffer.len(), - &mut found, - ) - }; - if code == libc::ERANGE { - buffer.resize(buffer.len() * 2, 0); - continue; - } - let value = if code == 0 && !found.is_null() { - Some( - unsafe { CStr::from_ptr((*found).pw_dir) } - .to_bytes() - .to_vec() - .into(), - ) - } else { - None - }; - return Ok(UserHomeResult { errno: code, value }); - } -} +#[cfg(unix)] +mod unix; +#[cfg(windows)] +mod windows; diff --git a/plugins/codex-security/native/src/unix.rs b/plugins/codex-security/native/src/unix.rs new file mode 100644 index 000000000..713b39abd --- /dev/null +++ b/plugins/codex-security/native/src/unix.rs @@ -0,0 +1,213 @@ +use napi::bindgen_prelude::Buffer; +use napi_derive::napi; +use std::{ + ffi::{CStr, CString}, + io, +}; + +#[napi(object)] +pub struct SyscallResult { + pub value: i32, + pub errno: i32, +} + +fn result(value: i32) -> SyscallResult { + SyscallResult { + value, + errno: if value < 0 { + io::Error::last_os_error().raw_os_error().unwrap() + } else { + 0 + }, + } +} + +fn retry_eintr(mut operation: impl FnMut() -> i32) -> SyscallResult { + loop { + let value = result(operation()); + if value.errno != libc::EINTR { + return value; + } + } +} + +fn path(value: Buffer) -> napi::Result { + CString::new(value.as_ref()).map_err(|_| napi::Error::from_reason("Path contains a NUL byte")) +} + +#[napi] +pub fn open_at(directory: i32, name: Buffer, flags: i32, mode: u32) -> napi::Result { + let name = path(name)?; + let mode = mode as libc::mode_t; + // macOS mode_t is u16 and needs C integer promotion in this variadic call. + #[cfg(target_os = "macos")] + let mode = libc::c_int::from(mode); + Ok(retry_eintr(|| unsafe { + libc::openat(directory, name.as_ptr(), flags | libc::O_CLOEXEC, mode) + })) +} + +#[napi] +pub fn make_directory_at(directory: i32, name: Buffer, mode: u32) -> napi::Result { + let name = path(name)?; + Ok(result(unsafe { + libc::mkdirat(directory, name.as_ptr(), mode as libc::mode_t) + })) +} + +#[napi] +pub fn rename_at( + old_directory: i32, + old_name: Buffer, + new_directory: i32, + new_name: Buffer, +) -> napi::Result { + let old_name = path(old_name)?; + let new_name = path(new_name)?; + Ok(result(unsafe { + libc::renameat( + old_directory, + old_name.as_ptr(), + new_directory, + new_name.as_ptr(), + ) + })) +} + +#[napi] +pub fn unlink_at(directory: i32, name: Buffer) -> napi::Result { + let name = path(name)?; + Ok(result(unsafe { + libc::unlinkat(directory, name.as_ptr(), 0) + })) +} + +#[napi] +pub fn duplicate(descriptor: i32) -> SyscallResult { + result(unsafe { libc::fcntl(descriptor, libc::F_DUPFD_CLOEXEC, 0) }) +} + +#[napi] +pub fn file_lock(descriptor: i32, unlock: bool, nonblocking: bool) -> SyscallResult { + let flags = if unlock { + libc::LOCK_UN + } else { + libc::LOCK_EX | if nonblocking { libc::LOCK_NB } else { 0 } + }; + retry_eintr(|| unsafe { libc::flock(descriptor, flags) }) +} + +#[napi(object)] +pub struct MetadataResult { + pub errno: i32, + pub mode: u32, + pub device: String, + pub inode: String, +} + +#[napi] +// libc stat field widths differ between Linux and macOS. +#[allow(clippy::unnecessary_cast)] +pub fn stat_at(directory: i32, name: Buffer) -> napi::Result { + let name = path(name)?; + let mut stat = std::mem::MaybeUninit::::uninit(); + let code = unsafe { + libc::fstatat( + directory, + name.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if code < 0 { + return Ok(MetadataResult { + errno: io::Error::last_os_error().raw_os_error().unwrap(), + mode: 0, + device: String::new(), + inode: String::new(), + }); + } + let stat = unsafe { stat.assume_init() }; + Ok(MetadataResult { + errno: 0, + mode: stat.st_mode as u32, + device: (stat.st_dev as u64).to_string(), + inode: stat.st_ino.to_string(), + }) +} + +#[napi(object)] +pub struct ReadLinkResult { + pub errno: i32, + pub value: Buffer, +} + +#[napi] +pub fn read_link_at(directory: i32, name: Buffer) -> napi::Result { + let name = path(name)?; + let mut buffer = vec![0_u8; 256]; + loop { + let length = unsafe { + libc::readlinkat( + directory, + name.as_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + ) + }; + if length < 0 { + return Ok(ReadLinkResult { + errno: io::Error::last_os_error().raw_os_error().unwrap(), + value: Vec::new().into(), + }); + } + if (length as usize) < buffer.len() { + buffer.truncate(length as usize); + return Ok(ReadLinkResult { + errno: 0, + value: buffer.into(), + }); + } + buffer.resize(buffer.len() * 2, 0); + } +} + +#[napi(object, use_nullable = true)] +pub struct UserHomeResult { + pub errno: i32, + pub value: Option, +} + +#[napi] +pub fn user_home(username: Buffer) -> napi::Result { + let username = path(username)?; + let mut buffer = vec![0_u8; 1024]; + loop { + let mut entry = std::mem::MaybeUninit::::uninit(); + let mut found = std::ptr::null_mut(); + let code = unsafe { + libc::getpwnam_r( + username.as_ptr(), + entry.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut found, + ) + }; + if code == libc::ERANGE { + buffer.resize(buffer.len() * 2, 0); + continue; + } + let value = if code == 0 && !found.is_null() { + Some( + unsafe { CStr::from_ptr((*found).pw_dir) } + .to_bytes() + .to_vec() + .into(), + ) + } else { + None + }; + return Ok(UserHomeResult { errno: code, value }); + } +} diff --git a/plugins/codex-security/native/src/windows.rs b/plugins/codex-security/native/src/windows.rs new file mode 100644 index 000000000..07ee9dd1d --- /dev/null +++ b/plugins/codex-security/native/src/windows.rs @@ -0,0 +1,401 @@ +use napi::bindgen_prelude::{BigInt, Buffer}; +use napi_derive::napi; +use std::{ + mem::{offset_of, size_of, MaybeUninit}, + os::windows::io::{AsRawHandle, FromRawHandle, IntoRawHandle, OwnedHandle}, + ptr::{copy_nonoverlapping, null, null_mut}, +}; +use windows_sys::Win32::{ + Foundation::{CloseHandle, GetLastError, SetLastError, HANDLE, INVALID_HANDLE_VALUE}, + Storage::FileSystem::*, + System::IO::OVERLAPPED, +}; + +fn invalid(message: &str) -> napi::Error { + napi::Error::new(napi::Status::InvalidArg, message) +} + +fn status(success: i32) -> u32 { + if success == 0 { + unsafe { GetLastError() } + } else { + 0 + } +} + +fn wide_path(bytes: Buffer) -> napi::Result> { + if !bytes.len().is_multiple_of(2) { + return Err(invalid("Path must contain whole UTF-16LE code units")); + } + let mut path = bytes + .chunks_exact(2) + .map(|part| u16::from_le_bytes([part[0], part[1]])) + .collect::>(); + if path.contains(&0) { + return Err(invalid("Path contains a NUL code unit")); + } + path.push(0); + Ok(path) +} + +fn io_range(buffer: &Buffer, offset: f64, length: f64) -> napi::Result<(usize, u32)> { + if !offset.is_finite() + || !length.is_finite() + || offset.fract() != 0.0 + || length.fract() != 0.0 + || offset < 0.0 + || length < 0.0 + || length > u32::MAX as f64 + || offset + length > buffer.len() as f64 + { + return Err(invalid("I/O range must fit the buffer and a Win32 DWORD")); + } + Ok((offset as usize, length as u32)) +} + +fn unsigned(value: BigInt) -> napi::Result { + let (_, value, lossless) = value.get_u64(); + if !lossless { + return Err(invalid("Byte range must fit an unsigned 64-bit integer")); + } + Ok(value) +} + +fn overlapped(offset: u64) -> OVERLAPPED { + let mut value = OVERLAPPED::default(); + value.Anonymous.Anonymous.Offset = offset as u32; + value.Anonymous.Anonymous.OffsetHigh = (offset >> 32) as u32; + value +} + +#[napi] +pub struct WindowsHandle { + handle: Option, +} + +impl WindowsHandle { + fn raw(&self) -> HANDLE { + self.handle + .as_ref() + .map_or(INVALID_HANDLE_VALUE, AsRawHandle::as_raw_handle) + } +} + +#[napi(object, object_from_js = false)] +pub struct OpenResult { + pub error: u32, + pub handle: Option, +} + +#[napi(object)] +pub struct WindowsResult { + pub error: u32, + pub value: u32, +} + +#[napi(object)] +pub struct AttributesResult { + pub error: u32, + pub attributes: u32, + pub reparse_tag: u32, +} + +#[napi(object)] +pub struct IdentityResult { + pub error: u32, + pub volume: String, + pub file_id: Buffer, +} + +#[napi(object)] +pub struct PositionResult { + pub error: u32, + pub value: String, +} + +#[napi(object)] +pub struct PathResult { + pub error: u32, + pub path: Buffer, +} + +#[napi] +pub fn open_windows_file( + path: Buffer, + access: u32, + share: u32, + disposition: u32, + flags: u32, +) -> napi::Result { + // Pending overlapped I/O could retain pointers after these synchronous calls return. + if flags & FILE_FLAG_OVERLAPPED != 0 { + return Err(invalid("Overlapped handles are not supported")); + } + let path = wide_path(path)?; + let handle = unsafe { + CreateFileW( + path.as_ptr(), + access, + share, + null(), + disposition, + flags, + null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Ok(OpenResult { + error: unsafe { GetLastError() }, + handle: None, + }); + } + Ok(OpenResult { + error: 0, + handle: Some(WindowsHandle { + handle: Some(unsafe { OwnedHandle::from_raw_handle(handle) }), + }), + }) +} + +#[napi] +pub fn create_windows_directory(path: Buffer) -> napi::Result { + let path = wide_path(path)?; + Ok(status(unsafe { CreateDirectoryW(path.as_ptr(), null()) })) +} + +#[napi] +impl WindowsHandle { + pub fn close(&mut self) -> u32 { + self.handle.take().map_or(0, |handle| { + status(unsafe { CloseHandle(handle.into_raw_handle()) }) + }) + } + + pub fn attributes(&self) -> AttributesResult { + let mut info = FILE_ATTRIBUTE_TAG_INFO::default(); + let error = status(unsafe { + GetFileInformationByHandleEx( + self.raw(), + FileAttributeTagInfo, + (&mut info as *mut FILE_ATTRIBUTE_TAG_INFO).cast(), + size_of::() as u32, + ) + }); + AttributesResult { + error, + attributes: info.FileAttributes, + reparse_tag: info.ReparseTag, + } + } + + pub fn identity(&self) -> IdentityResult { + let mut info = FILE_ID_INFO::default(); + let error = status(unsafe { + GetFileInformationByHandleEx( + self.raw(), + FileIdInfo, + (&mut info as *mut FILE_ID_INFO).cast(), + size_of::() as u32, + ) + }); + IdentityResult { + error, + volume: info.VolumeSerialNumber.to_string(), + file_id: info.FileId.Identifier.to_vec().into(), + } + } + + pub fn file_type(&self) -> WindowsResult { + unsafe { SetLastError(0) }; + let value = unsafe { GetFileType(self.raw()) }; + WindowsResult { + error: if value == FILE_TYPE_UNKNOWN { + unsafe { GetLastError() } + } else { + 0 + }, + value, + } + } + + pub fn final_path(&self, flags: u32) -> napi::Result { + let mut path = vec![0_u16; 256]; + loop { + let capacity = u32::try_from(path.len()) + .map_err(|_| invalid("Final path exceeds the Win32 buffer size"))?; + let length = unsafe { + GetFinalPathNameByHandleW(self.raw(), path.as_mut_ptr(), capacity, flags) + }; + if length == 0 { + return Ok(PathResult { + error: unsafe { GetLastError() }, + path: Vec::new().into(), + }); + } + if length < capacity { + return Ok(PathResult { + error: 0, + path: path[..length as usize] + .iter() + .flat_map(|unit| unit.to_le_bytes()) + .collect::>() + .into(), + }); + } + path.resize(length as usize + 1, 0); + } + } + + pub fn read( + &self, + mut buffer: Buffer, + offset: f64, + length: f64, + ) -> napi::Result { + let (offset, length) = io_range(&buffer, offset, length)?; + let mut value = 0; + let error = status(unsafe { + ReadFile( + self.raw(), + buffer.as_mut_ptr().add(offset), + length, + &mut value, + null_mut(), + ) + }); + Ok(WindowsResult { error, value }) + } + + pub fn write(&self, buffer: Buffer, offset: f64, length: f64) -> napi::Result { + let (offset, length) = io_range(&buffer, offset, length)?; + let mut value = 0; + let error = status(unsafe { + WriteFile( + self.raw(), + buffer.as_ptr().add(offset), + length, + &mut value, + null_mut(), + ) + }); + Ok(WindowsResult { error, value }) + } + + pub fn seek(&self, distance: BigInt, origin: u32) -> napi::Result { + let (distance, lossless) = distance.get_i64(); + if !lossless { + return Err(invalid("Seek offset must fit a signed 64-bit integer")); + } + let mut value = 0; + let error = status(unsafe { SetFilePointerEx(self.raw(), distance, &mut value, origin) }); + Ok(PositionResult { + error, + value: value.to_string(), + }) + } + + pub fn size(&self) -> PositionResult { + let mut value = 0; + let error = status(unsafe { GetFileSizeEx(self.raw(), &mut value) }); + PositionResult { + error, + value: value.to_string(), + } + } + + pub fn set_end_of_file(&self) -> u32 { + status(unsafe { SetEndOfFile(self.raw()) }) + } + + pub fn flush(&self) -> u32 { + status(unsafe { FlushFileBuffers(self.raw()) }) + } + + pub fn rename(&self, destination: Buffer, replace: bool) -> napi::Result { + let path = wide_path(destination)?; + let name_bytes = (path.len() - 1) * size_of::(); + let size = offset_of!(FILE_RENAME_INFO, FileName) + .checked_add(name_bytes + size_of::()) + .ok_or_else(|| invalid("Rename path exceeds the Win32 buffer size"))? + .max(size_of::()); + let size_u32 = u32::try_from(size) + .map_err(|_| invalid("Rename path exceeds the Win32 buffer size"))?; + // Allocate with the generated structure's alignment, including its variable tail. + let mut storage = vec![ + MaybeUninit::::zeroed(); + size.div_ceil(size_of::()) + ]; + let info = storage.as_mut_ptr().cast::(); + unsafe { + (*info).Anonymous.ReplaceIfExists = replace; + (*info).RootDirectory = null_mut(); + (*info).FileNameLength = name_bytes as u32; + copy_nonoverlapping( + path.as_ptr(), + info.cast::() + .add(offset_of!(FILE_RENAME_INFO, FileName)) + .cast::(), + path.len(), + ); + } + Ok(status(unsafe { + SetFileInformationByHandle(self.raw(), FileRenameInfo, info.cast(), size_u32) + })) + } + + pub fn set_disposition(&self, delete: bool) -> u32 { + let info = FILE_DISPOSITION_INFO { DeleteFile: delete }; + status(unsafe { + SetFileInformationByHandle( + self.raw(), + FileDispositionInfo, + (&info as *const FILE_DISPOSITION_INFO).cast(), + size_of::() as u32, + ) + }) + } + + pub fn lock( + &self, + offset: BigInt, + length: BigInt, + exclusive: bool, + nonblocking: bool, + ) -> napi::Result { + let mut position = overlapped(unsigned(offset)?); + let length = unsigned(length)?; + let flags = (if exclusive { + LOCKFILE_EXCLUSIVE_LOCK + } else { + 0 + }) | (if nonblocking { + LOCKFILE_FAIL_IMMEDIATELY + } else { + 0 + }); + Ok(status(unsafe { + LockFileEx( + self.raw(), + flags, + 0, + length as u32, + (length >> 32) as u32, + &mut position, + ) + })) + } + + pub fn unlock(&self, offset: BigInt, length: BigInt) -> napi::Result { + let mut position = overlapped(unsigned(offset)?); + let length = unsigned(length)?; + Ok(status(unsafe { + UnlockFileEx( + self.raw(), + 0, + length as u32, + (length >> 32) as u32, + &mut position, + ) + })) + } +} diff --git a/plugins/codex-security/native/windows-binding.mts b/plugins/codex-security/native/windows-binding.mts new file mode 100644 index 000000000..478fb4ba8 --- /dev/null +++ b/plugins/codex-security/native/windows-binding.mts @@ -0,0 +1,70 @@ +import { createRequire } from "node:module"; +import { binaryPath } from "./binding.mjs"; + +export interface WindowsResult { + error: number; + value: T; +} + +/** Owns a synchronous Win32 handle. close() is idempotent; GC also closes it. */ +export interface WindowsHandle { + close(): number; + attributes(): { error: number; attributes: number; reparseTag: number }; + identity(): { error: number; volume: string; fileId: Buffer }; + fileType(): WindowsResult; + finalPath(flags: number): { error: number; path: Buffer }; + read(buffer: Buffer, offset: number, length: number): WindowsResult; + write(buffer: Buffer, offset: number, length: number): WindowsResult; + seek(distance: bigint, origin: number): WindowsResult; + size(): WindowsResult; + setEndOfFile(): number; + flush(): number; + rename(destination: Buffer, replace: boolean): number; + setDisposition(deleteFile: boolean): number; + lock( + offset: bigint, + length: bigint, + exclusive: boolean, + nonblocking: boolean, + ): number; + unlock(offset: bigint, length: bigint): number; +} + +/** Paths are UTF-16LE code units without a terminator, including lone surrogates. */ +export interface WindowsBinding { + openWindowsFile( + path: Buffer, + access: number, + share: number, + disposition: number, + flags: number, + ): { error: number; handle?: WindowsHandle | null }; + createWindowsDirectory(path: Buffer): number; +} + +export const windowsFlags = { + DELETE: 0x00010000, + FILE_READ_ATTRIBUTES: 0x00000080, + GENERIC_READ: 0x80000000, + GENERIC_WRITE: 0x40000000, + FILE_SHARE_READ: 1, + FILE_SHARE_WRITE: 2, + FILE_SHARE_DELETE: 4, + CREATE_NEW: 1, + OPEN_EXISTING: 3, + OPEN_ALWAYS: 4, + FILE_ATTRIBUTE_DIRECTORY: 0x00000010, + FILE_ATTRIBUTE_NORMAL: 0x00000080, + FILE_ATTRIBUTE_REPARSE_POINT: 0x00000400, + FILE_FLAG_BACKUP_SEMANTICS: 0x02000000, + FILE_FLAG_OPEN_REPARSE_POINT: 0x00200000, + FILE_FLAG_OVERLAPPED: 0x40000000, + FILE_NAME_OPENED: 8, + FILE_BEGIN: 0, + FILE_CURRENT: 1, + FILE_END: 2, +} as const; + +export function loadWindowsBinding(): WindowsBinding { + return createRequire(import.meta.url)(binaryPath) as WindowsBinding; +} From f84f70c211849a60bc2e2026cb8cf22dc83a2299 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Thu, 3 Sep 2026 00:23:24 +0000 Subject: [PATCH 11/12] fix(native): exercise Windows directory delete sharing --- plugins/codex-security/native/proof-windows.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/codex-security/native/proof-windows.mts b/plugins/codex-security/native/proof-windows.mts index 678a49b83..b310f0c58 100644 --- a/plugins/codex-security/native/proof-windows.mts +++ b/plugins/codex-security/native/proof-windows.mts @@ -211,7 +211,7 @@ function handleProof(root: string) { keep( open( directory, - flags.FILE_READ_ATTRIBUTES, + flags.GENERIC_READ, flags.FILE_SHARE_READ | flags.FILE_SHARE_WRITE, flags.OPEN_EXISTING, directoryFlags, From 50b7aa2b4a7c74a63c38ae79c88ed1f8cc1fa83b Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Thu, 3 Sep 2026 00:39:12 +0000 Subject: [PATCH 12/12] fix(native): export Windows handle methods through Node-API --- plugins/codex-security/native/src/windows.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/plugins/codex-security/native/src/windows.rs b/plugins/codex-security/native/src/windows.rs index 07ee9dd1d..e77872408 100644 --- a/plugins/codex-security/native/src/windows.rs +++ b/plugins/codex-security/native/src/windows.rs @@ -165,12 +165,14 @@ pub fn create_windows_directory(path: Buffer) -> napi::Result { #[napi] impl WindowsHandle { + #[napi] pub fn close(&mut self) -> u32 { self.handle.take().map_or(0, |handle| { status(unsafe { CloseHandle(handle.into_raw_handle()) }) }) } + #[napi] pub fn attributes(&self) -> AttributesResult { let mut info = FILE_ATTRIBUTE_TAG_INFO::default(); let error = status(unsafe { @@ -188,6 +190,7 @@ impl WindowsHandle { } } + #[napi] pub fn identity(&self) -> IdentityResult { let mut info = FILE_ID_INFO::default(); let error = status(unsafe { @@ -205,6 +208,7 @@ impl WindowsHandle { } } + #[napi] pub fn file_type(&self) -> WindowsResult { unsafe { SetLastError(0) }; let value = unsafe { GetFileType(self.raw()) }; @@ -218,6 +222,7 @@ impl WindowsHandle { } } + #[napi] pub fn final_path(&self, flags: u32) -> napi::Result { let mut path = vec![0_u16; 256]; loop { @@ -246,6 +251,7 @@ impl WindowsHandle { } } + #[napi] pub fn read( &self, mut buffer: Buffer, @@ -266,6 +272,7 @@ impl WindowsHandle { Ok(WindowsResult { error, value }) } + #[napi] pub fn write(&self, buffer: Buffer, offset: f64, length: f64) -> napi::Result { let (offset, length) = io_range(&buffer, offset, length)?; let mut value = 0; @@ -281,6 +288,7 @@ impl WindowsHandle { Ok(WindowsResult { error, value }) } + #[napi] pub fn seek(&self, distance: BigInt, origin: u32) -> napi::Result { let (distance, lossless) = distance.get_i64(); if !lossless { @@ -294,6 +302,7 @@ impl WindowsHandle { }) } + #[napi] pub fn size(&self) -> PositionResult { let mut value = 0; let error = status(unsafe { GetFileSizeEx(self.raw(), &mut value) }); @@ -303,14 +312,17 @@ impl WindowsHandle { } } + #[napi] pub fn set_end_of_file(&self) -> u32 { status(unsafe { SetEndOfFile(self.raw()) }) } + #[napi] pub fn flush(&self) -> u32 { status(unsafe { FlushFileBuffers(self.raw()) }) } + #[napi] pub fn rename(&self, destination: Buffer, replace: bool) -> napi::Result { let path = wide_path(destination)?; let name_bytes = (path.len() - 1) * size_of::(); @@ -343,6 +355,7 @@ impl WindowsHandle { })) } + #[napi] pub fn set_disposition(&self, delete: bool) -> u32 { let info = FILE_DISPOSITION_INFO { DeleteFile: delete }; status(unsafe { @@ -355,6 +368,7 @@ impl WindowsHandle { }) } + #[napi] pub fn lock( &self, offset: BigInt, @@ -385,6 +399,7 @@ impl WindowsHandle { })) } + #[napi] pub fn unlock(&self, offset: BigInt, length: BigInt) -> napi::Result { let mut position = overlapped(unsigned(offset)?); let length = unsigned(length)?;