diff --git a/.github/actions/download-native/action.yml b/.github/actions/download-native/action.yml new file mode 100644 index 000000000..7fd2e7d75 --- /dev/null +++ b/.github/actions/download-native/action.yml @@ -0,0 +1,9 @@ +name: Download native runtime +description: Prepare the verified universal native payload for package builds. +runs: + using: composite + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: native-universal-${{ github.sha }} + path: plugins/codex-security/native/prebuilt diff --git a/.github/workflows/container-ci.yml b/.github/workflows/container-ci.yml index d55e90871..cd5499b6c 100644 --- a/.github/workflows/container-ci.yml +++ b/.github/workflows/container-ci.yml @@ -5,6 +5,8 @@ on: branches: [main] paths: - .dockerignore + - .github/workflows/native-*.yml + - .github/actions/download-native/** - .github/workflows/container-ci.yml - Dockerfile - Dockerfile.dockerignore @@ -18,6 +20,8 @@ on: pull_request: paths: - .dockerignore + - .github/workflows/native-*.yml + - .github/actions/download-native/** - .github/workflows/container-ci.yml - Dockerfile - Dockerfile.dockerignore @@ -38,7 +42,11 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + native: + uses: ./.github/workflows/native-artifacts.yml + container: + needs: native name: linux-amd64 runs-on: ubuntu-latest timeout-minutes: 30 @@ -48,6 +56,8 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - name: Prepare native runtime + uses: ./.github/actions/download-native - name: Verify customer build excludes secrets and scan data shell: bash diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml index 61ee8d4a9..8d4adcd91 100644 --- a/.github/workflows/container-release.yml +++ b/.github/workflows/container-release.yml @@ -4,6 +4,8 @@ on: pull_request: paths: - .dockerignore + - .github/workflows/native-*.yml + - .github/actions/download-native/** - .github/workflows/container-release.yml - Dockerfile - Dockerfile.dockerignore @@ -27,7 +29,12 @@ permissions: contents: read jobs: + native: + if: github.repository == 'openai/codex-security' + uses: ./.github/workflows/native-artifacts.yml + validate: + needs: native if: github.repository == 'openai/codex-security' name: validate-linux-${{ matrix.architecture }} runs-on: ${{ matrix.runner }} @@ -46,6 +53,8 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - name: Prepare native runtime + uses: ./.github/actions/download-native - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 @@ -353,6 +362,8 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - name: Prepare native runtime + uses: ./.github/actions/download-native - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 diff --git a/.github/workflows/native-artifacts.yml b/.github/workflows/native-artifacts.yml new file mode 100644 index 000000000..a59b2aeaf --- /dev/null +++ b/.github/workflows/native-artifacts.yml @@ -0,0 +1,34 @@ +name: native-artifacts + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + unix: + uses: ./.github/workflows/native-unix.yml + windows: + uses: ./.github/workflows/native-windows.yml + musl: + uses: ./.github/workflows/native-musl.yml + bundle: + needs: [unix, windows, musl] + runs-on: ubuntu-24.04 + steps: + - name: Download verified platform payloads + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: native-platform-*-${{ github.sha }} + merge-multiple: true + path: native + - name: Upload universal native payload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-universal-${{ github.sha }} + path: native/ + if-no-files-found: error + overwrite: true + retention-days: 7 diff --git a/.github/workflows/native-musl.yml b/.github/workflows/native-musl.yml index 98d241d4a..eef25fd43 100644 --- a/.github/workflows/native-musl.yml +++ b/.github/workflows/native-musl.yml @@ -1,18 +1,11 @@ name: native-musl on: - push: - branches: [main] - pull_request: - types: [opened, reopened, synchronize] + workflow_call: 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 / linux-${{ matrix.arch }}-musl @@ -106,7 +99,8 @@ jobs: - name: Upload verified native artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: native-linux-${{ matrix.arch }}-musl-${{ github.sha }} - path: plugins/codex-security/native/dist/linux-${{ matrix.arch }}-musl/unix.node + name: native-platform-linux-${{ matrix.arch }}-musl-${{ github.sha }} + path: plugins/codex-security/native/dist/*/*.node if-no-files-found: error + overwrite: true retention-days: 7 diff --git a/.github/workflows/native-unix.yml b/.github/workflows/native-unix.yml index fef17c62b..01e15ff84 100644 --- a/.github/workflows/native-unix.yml +++ b/.github/workflows/native-unix.yml @@ -1,18 +1,11 @@ name: native-unix on: - push: - branches: [main] - pull_request: - types: [opened, reopened, synchronize] + workflow_call: 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.artifact }} @@ -73,6 +66,9 @@ jobs: cargo fmt --check cargo clippy --locked -- -D warnings cargo fetch --locked + - name: Prepare shared native notices + if: matrix.platform == 'linux' && matrix.arch == 'x64' + run: node notices.mjs - name: Build and verify Linux on glibc 2.28 with Node.js 22 if: matrix.platform == 'linux' run: | @@ -125,7 +121,12 @@ jobs: - name: Upload verified native artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: native-${{ matrix.artifact }}-${{ github.sha }} - path: plugins/codex-security/native/dist/${{ matrix.artifact }}/unix.node + name: native-platform-${{ matrix.artifact }}-${{ github.sha }} + path: | + plugins/codex-security/native/dist/*/*.node + plugins/codex-security/native/dist/THIRD_PARTY_NOTICES.txt + plugins/codex-security/native/dist/COPYRIGHT-library.html + plugins/codex-security/native/dist/licenses/*.txt if-no-files-found: error + overwrite: true retention-days: 7 diff --git a/.github/workflows/native-windows.yml b/.github/workflows/native-windows.yml index 5dc1cbb1a..061e5e050 100644 --- a/.github/workflows/native-windows.yml +++ b/.github/workflows/native-windows.yml @@ -1,18 +1,11 @@ name: native-windows on: - push: - branches: [main] - pull_request: - types: [opened, reopened, synchronize] + workflow_call: 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 }} @@ -83,7 +76,8 @@ jobs: - 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 + name: native-platform-win32-${{ matrix.arch }}-${{ github.sha }} + path: plugins/codex-security/native/dist/*/*.node if-no-files-found: error + overwrite: true retention-days: 7 diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 9a6855264..e1d6987dc 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -156,9 +156,14 @@ jobs: - name: Check formatting run: pnpm --dir sdk/typescript run format + native: + needs: validate-title + if: needs.validate-title.outputs.ci-mode == 'full' + uses: ./.github/workflows/native-artifacts.yml + package: name: build and check package - needs: validate-title + needs: [validate-title, native] if: needs.validate-title.outputs.ci-mode == 'full' runs-on: ubuntu-latest timeout-minutes: 20 @@ -169,6 +174,8 @@ jobs: persist-credentials: false - name: Check plugin source boundary run: node sdk/typescript/scripts/check-plugin-source.mjs + - name: Prepare native runtime + uses: ./.github/actions/download-native - name: Set up pnpm uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: @@ -219,6 +226,8 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - name: Prepare native runtime + uses: ./.github/actions/download-native - name: Set up pnpm uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: @@ -319,6 +328,8 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - name: Prepare native runtime + uses: ./.github/actions/download-native - name: Set up pnpm uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: @@ -457,6 +468,8 @@ jobs: uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.12" + - name: Prepare native runtime + uses: ./.github/actions/download-native - name: Set up Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml index 10c2975ee..de8f8988d 100644 --- a/.github/workflows/node-release.yml +++ b/.github/workflows/node-release.yml @@ -11,7 +11,12 @@ concurrency: queue: max jobs: + native: + if: github.repository == 'openai/codex-security' + uses: ./.github/workflows/native-artifacts.yml + verify: + needs: native if: github.repository == 'openai/codex-security' name: verify runs-on: ubuntu-latest @@ -33,6 +38,8 @@ jobs: with: fetch-depth: 0 persist-credentials: false + - name: Prepare native runtime + uses: ./.github/actions/download-native - name: Set up Socket Firewall uses: SocketDev/action@937f824ec476dfd164d4a4d9995751427b0be143 # v1 diff --git a/.github/workflows/test-quality.yml b/.github/workflows/test-quality.yml index 859a25c20..0b21f15ba 100644 --- a/.github/workflows/test-quality.yml +++ b/.github/workflows/test-quality.yml @@ -20,7 +20,11 @@ env: CODEX_SECURITY_ALLOW_MACHINE_POLICY_TEST: "false" jobs: + native: + uses: ./.github/workflows/native-artifacts.yml + runner: + needs: native name: ${{ matrix.os }} / ${{ matrix.mode }} runs-on: ${{ matrix.os }} timeout-minutes: 30 @@ -63,6 +67,8 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - name: Prepare native runtime + uses: ./.github/actions/download-native - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: "22.13.0" diff --git a/docker/README.md b/docker/README.md index 50d9ce468..4517b6f06 100644 --- a/docker/README.md +++ b/docker/README.md @@ -55,6 +55,8 @@ than build arguments. Before the first release, an administrator must prepare the package: +Prepare the [universal native payload](../plugins/codex-security/native/README.md#package-inputs) before building an image from source. + 1. Allow organization package creation and, if the package is missing, bootstrap it with a reviewed image and a non-release tag: @@ -135,7 +137,7 @@ Git authentication uses the existing `GH_TOKEN`/`GITHUB_TOKEN` and optional the findings service's embedding credentials are configured separately. Use a version or digest in `CODEX_SECURITY_IMAGE` for repeatable deployments. To test an unreleased checkout, build the same scanner target locally instead -of pulling: +of pulling. First prepare the [universal native payload](../plugins/codex-security/native/README.md#package-inputs): ```bash docker build --target scanner -t codex-security:local . diff --git a/plugins/codex-security/mcp-app/TESTING.md b/plugins/codex-security/mcp-app/TESTING.md index 4b1787d79..a79b05197 100644 --- a/plugins/codex-security/mcp-app/TESTING.md +++ b/plugins/codex-security/mcp-app/TESTING.md @@ -1,6 +1,6 @@ # MCP tests -From `sdk/typescript`, install both packages' dependencies and run `pnpm run test:mcp`. That command builds the bundled plugin before testing it. To rerun only the tests after a build, run `pnpm run test:mcp` from this directory. +From `sdk/typescript`, install both packages' dependencies, prepare the [universal native payload](../native/README.md#package-inputs), and run `pnpm run test:mcp`. That command builds the bundled plugin before testing it. To rerun only the tests after a build, run `pnpm run test:mcp` from this directory. Node's test runner discovers `tests/test_*.mjs` and runs at most two files at once, each in a separate process. Assertions within a file remain sequential. Keep shared helpers outside that filename pattern. A failing script fails the command. The reporter streams Node's TAP output and then tries to write Node's JUnit report to `reports/junit.xml`; an unavailable report path warns without changing the test result. CI uploads that file with the Node 22 reports. diff --git a/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs b/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs index 855062673..100724c61 100644 --- a/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs +++ b/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { brotliCompressSync, constants as zlibConstants } from "node:zlib"; import { execFileSync } from "node:child_process"; @@ -20,6 +20,33 @@ export async function buildMcpApp({ output }) { await mkdir(mcpDir, { recursive: true }); await writeRuntime("server", "main.ts"); + for (const target of [ + "darwin-arm64", + "darwin-x64", + "linux-arm64-gnu", + "linux-arm64-musl", + "linux-x64-gnu", + "linux-x64-musl", + "win32-arm64", + "win32-x64" + ]) { + const name = target.startsWith("win32-") ? "windows.node" : "unix.node"; + const destination = join(mcpDir, "native", target); + await mkdir(destination, { recursive: true }); + await copyFile( + join(root, "../native/prebuilt", target, name), + join(destination, name) + ); + } + for (const path of [ + "THIRD_PARTY_NOTICES.txt", "COPYRIGHT-library.html", + "licenses/MIT.txt", "licenses/Apache-2.0.txt", + "licenses/Unicode-3.0.txt", "licenses/BSD-2-Clause.txt" + ]) { + const destination = join(mcpDir, "native", path); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(join(root, "../native/prebuilt", path), destination); + } async function writeRuntime(name, entryPoint) { const bundle = join(mcpDir, name + ".bundle.cjs"); diff --git a/plugins/codex-security/native/.gitignore b/plugins/codex-security/native/.gitignore index 5fb9d30c0..2e665804b 100644 --- a/plugins/codex-security/native/.gitignore +++ b/plugins/codex-security/native/.gitignore @@ -1,3 +1,4 @@ /target/ /dist/ +/prebuilt/ /*.mjs diff --git a/plugins/codex-security/native/README.md b/plugins/codex-security/native/README.md index 50b5a01e9..d55fc63fd 100644 --- a/plugins/codex-security/native/README.md +++ b/plugins/codex-security/native/README.md @@ -31,7 +31,7 @@ node plugins/codex-security/native/check.mjs GNU Linux artifacts must import no glibc version newer than 2.28. Musl artifacts must be ELF images for the current architecture, depend on that architecture's musl library, and have no version requirements from glibc. GCC's own `GLIBC_2.0` compatibility exports are attributed to `libgcc_s.so.1`, not the C library. Musl has no glibc-style symbol version floor, so its runtime compatibility also requires the load proofs below. macOS artifacts must declare a deployment target of 11.0 or earlier. A build from a newer GNU 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. +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. The `native-musl` workflow uses native x64 and arm64 Ubuntu workers with digest-pinned Rust 1.97.1 Alpine compiler images. Musl builds disable static CRT linkage so Node can load the shared library. After the ELF and private-path checks, each unchanged artifact runs the full proof in pinned Node 20.0.0 Alpine 3.17 and Node 22.13.0 Alpine 3.21 images, with musl 1.2.3 and 1.2.5 respectively. Compilation uses the locked registry offline; runtime containers mount only the source and artifact read-only. Python is absent, and proof processes receive an empty `PATH`. @@ -48,3 +48,17 @@ 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. + +## Package inputs + +The `native-artifacts` workflow calls all three platform workflows and combines their eight verified payloads into `native-universal-`. Package, release, container, and test workflows prepare this artifact before building the plugin. The standalone MCP builder and npm package include the same complete `mcp/native` tree; neither compiles nor downloads code at runtime. + +The GNU x64 job also runs `notices.mjs` against the locked Cargo metadata. It collects crate licenses and the pinned Rust standard-library notices for both package surfaces. The NAPI crates omit license files from their registry archives, so `licenses/napi.txt` preserves their [pinned upstream license](https://github.com/napi-rs/napi-rs/blob/956e4525fea6a676ea3680b711382f167b899af9/LICENSE). Review that override when upgrading those dependencies. + +Before local plugin builds, tests, or Docker builds, select a successful run for the checkout's native sources. You can run `native-artifacts` manually on a pushed branch. Use the artifact name shown by that run; pull-request artifacts use the tested merge commit. From the repository root: + +```sh +gh run download --name native-universal- --dir plugins/codex-security/native/prebuilt +``` + +The ignored `prebuilt` directory must contain all eight platform directories and the shared notices. Refresh it after changing the native source or build toolchain. Missing payloads fail the build, including on hosts that only load one of them. Installed-package checks load the matching artifact with an empty `PATH`. diff --git a/plugins/codex-security/native/licenses/napi.txt b/plugins/codex-security/native/licenses/napi.txt new file mode 100644 index 000000000..7fe7e35ef --- /dev/null +++ b/plugins/codex-security/native/licenses/napi.txt @@ -0,0 +1,43 @@ +MIT License + +Copyright (c) 2020-present LongYinan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +MIT License + +Copyright (c) 2018 GitHub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/codex-security/native/notices.mts b/plugins/codex-security/native/notices.mts new file mode 100644 index 000000000..e26f4f58b --- /dev/null +++ b/plugins/codex-security/native/notices.mts @@ -0,0 +1,89 @@ +import { execFileSync } from "node:child_process"; +import { + copyFile, + mkdir, + readFile, + readdir, + writeFile, +} from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { root } from "./binding.mjs"; + +interface Package { + name: string; + version: string; + source: string | null; + manifest_path: string; +} + +const metadata = JSON.parse( + execFileSync( + "cargo", + ["metadata", "--locked", "--offline", "--format-version", "1"], + { + cwd: root, + encoding: "utf8", + }, + ), +) as { packages: Package[] }; +const packages = metadata.packages + .filter((entry) => entry.source?.startsWith("registry+")) + .sort((left, right) => { + const leftName = `${left.name}@${left.version}`; + const rightName = `${right.name}@${right.version}`; + return leftName < rightName ? -1 : leftName > rightName ? 1 : 0; + }); +// These archives omit LICENSE; their pinned upstream revisions share this text. +const napiLicense = new Set([ + "napi@3.12.2", + "napi-build@2.4.1", + "napi-derive@3.6.3", + "napi-derive-backend@6.1.2", + "napi-sys@3.3.0", +]); +const notices: string[] = []; +for (const entry of packages) { + const directory = dirname(entry.manifest_path); + const files = (await readdir(directory, { withFileTypes: true })) + .filter( + (file) => + file.isFile() && + /^(?:licen[sc]e|copying|copyright)(?:$|[._-])/iu.test(file.name), + ) + .map((file) => join(directory, file.name)) + .sort(); + const name = `${entry.name}@${entry.version}`; + if (files.length === 0 && napiLicense.has(name)) { + files.push(join(root, "licenses", "napi.txt")); + } + if (files.length === 0) + throw new Error(`Missing native dependency license: ${name}`); + notices.push( + `${name}\n\n${(await Promise.all(files.map((file) => readFile(file, "utf8")))).join("\n\n")}`, + ); +} + +const destination = join(root, "dist"); +await mkdir(join(destination, "licenses"), { recursive: true }); +await writeFile( + join(destination, "THIRD_PARTY_NOTICES.txt"), + notices.join("\n\n---\n\n"), +); +const sysroot = execFileSync("rustc", ["--print", "sysroot"], { + cwd: root, + encoding: "utf8", +}).trim(); +const rustNotices = join(sysroot, "share", "doc", "rust"); +await copyFile( + join(rustNotices, "COPYRIGHT-library.html"), + join(destination, "COPYRIGHT-library.html"), +); +for (const license of ["MIT", "Apache-2.0", "Unicode-3.0", "BSD-2-Clause"]) { + await copyFile( + join(rustNotices, "licenses", `${license}.txt`), + join(destination, "licenses", `${license}.txt`), + ); +} +console.log( + `Prepared native notices for ${packages.length} registry packages and the Rust standard library.`, +); diff --git a/plugins/codex-security/plugin-files.json b/plugins/codex-security/plugin-files.json index 2d0004e21..6eb6bbbd1 100644 --- a/plugins/codex-security/plugin-files.json +++ b/plugins/codex-security/plugin-files.json @@ -11,6 +11,20 @@ "examples/completed-scan/findings.json", "examples/completed-scan/report.md", "examples/completed-scan/scan-manifest.json", + "mcp/native/COPYRIGHT-library.html", + "mcp/native/THIRD_PARTY_NOTICES.txt", + "mcp/native/darwin-arm64/unix.node", + "mcp/native/darwin-x64/unix.node", + "mcp/native/linux-arm64-gnu/unix.node", + "mcp/native/linux-arm64-musl/unix.node", + "mcp/native/linux-x64-gnu/unix.node", + "mcp/native/linux-x64-musl/unix.node", + "mcp/native/licenses/Apache-2.0.txt", + "mcp/native/licenses/BSD-2-Clause.txt", + "mcp/native/licenses/MIT.txt", + "mcp/native/licenses/Unicode-3.0.txt", + "mcp/native/win32-arm64/windows.node", + "mcp/native/win32-x64/windows.node", "mcp/server.mjs", "mcp/server.mjs.br.part-000", "mcp/server.mjs.br.part-001", diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index 11a7c1074..b538f948b 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -2,6 +2,8 @@ Use the pnpm version in `package.json` and Bun 1.3.14, matching required CI. Install both the SDK and MCP app dependencies before building or testing. +Prepare the [universal native payload](../../plugins/codex-security/native/README.md#package-inputs) +before running a command that builds the bundled plugin. Run these commands from `sdk/typescript`: ```sh diff --git a/sdk/typescript/scripts/smoke-package.mjs b/sdk/typescript/scripts/smoke-package.mjs index bf25dd861..2d63f30de 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -394,6 +394,39 @@ try { "Installed npm package does not match the complete bundled-plugin contract.", ); + const libc = + process.platform === "linux" + ? process.report.getReport().header.glibcVersionRuntime === undefined + ? "-musl" + : "-gnu" + : ""; + const nativeLibrary = join( + installedRoot, + "_bundled_plugin", + "mcp", + "native", + `${process.platform}-${process.arch}${libc}`, + process.platform === "win32" ? "windows.node" : "unix.node", + ); + run( + process.execPath, + [ + "--input-type=commonjs", + "--eval", + `const assert = require("node:assert/strict"); +const native = require(process.argv[1]); +if (process.platform === "win32") { + const result = native.openWindowsFile(Buffer.from(process.argv[2], "utf16le"), 0, 7, 3, 0); + assert.equal(result.error, 2); +} else { + assert.deepEqual(native.duplicate(-1), { value: -1, errno: 9 }); +}`, + nativeLibrary, + join(consumer, "missing-native-file"), + ], + { cwd: consumer, env: { ...process.env, PATH: "" } }, + ); + run( process.execPath, [ diff --git a/sdk/typescript/tests-ts/build-plugin.test.ts b/sdk/typescript/tests-ts/build-plugin.test.ts index b1097d920..edbe95657 100644 --- a/sdk/typescript/tests-ts/build-plugin.test.ts +++ b/sdk/typescript/tests-ts/build-plugin.test.ts @@ -101,7 +101,21 @@ describe("bundled plugin build", () => { }, ); - expect(await files(destination)).toContain("server.mjs"); + const contract = JSON.parse( + await readFile( + new URL( + "../../../plugins/codex-security/plugin-files.json", + import.meta.url, + ), + "utf8", + ), + ) as { shippedExact: string[] }; + expect(await files(destination)).toEqual( + contract.shippedExact + .filter((path) => path.startsWith("mcp/")) + .map((path) => path.slice(4)) + .sort(), + ); }); test("builds from a source snapshot without Git metadata", async () => { diff --git a/sdk/typescript/tests-ts/deep-scan-reducer-recovery.test.ts b/sdk/typescript/tests-ts/deep-scan-reducer-recovery.test.ts index c33104c52..fc6ba7405 100644 --- a/sdk/typescript/tests-ts/deep-scan-reducer-recovery.test.ts +++ b/sdk/typescript/tests-ts/deep-scan-reducer-recovery.test.ts @@ -1,4 +1,3 @@ -import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -66,19 +65,28 @@ test("keeps every advertised Deep worker tool within Codex's name limit", async ); expect(Object.keys(servers)).toEqual(["cs_artifacts"]); const server = servers["cs_artifacts"]!; - const result = spawnSync(node!, server.args, { - encoding: "utf8", + const child = Bun.spawn({ + cmd: [node!, ...server.args], env: { ...process.env, ...server.env }, - input: [ - '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"codex-security-test","version":"1.0.0"}}}', - '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}', - '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}', - "", - ].join("\n"), + stdin: Buffer.from( + [ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"codex-security-test","version":"1.0.0"}}}', + '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}', + '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}', + "", + ].join("\n"), + ), + stdout: "pipe", + stderr: "pipe", timeout: 30_000, }); - expect(result.status, result.stderr).toBe(0); - const response = result.stdout + const [status, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(status, stderr).toBe(0); + const response = stdout .trim() .split("\n") .map((line) => JSON.parse(line) as { id?: number; result?: unknown }) diff --git a/sdk/typescript/tests-ts/skeleton.test.ts b/sdk/typescript/tests-ts/skeleton.test.ts index 5636217c8..fc0c8d65b 100644 --- a/sdk/typescript/tests-ts/skeleton.test.ts +++ b/sdk/typescript/tests-ts/skeleton.test.ts @@ -25,7 +25,7 @@ interface WorkflowJob { needs?: string | string[]; env?: Record; strategy?: { matrix: Record }; - steps: WorkflowStep[]; + steps?: WorkflowStep[]; } async function workflow(name: string) { @@ -141,7 +141,7 @@ describe("TypeScript package skeleton", () => { run: "node scripts/check-package.mjs ../../dist/*.tgz", }), ); - expect(jobs[name]?.steps.some(({ name }) => name === "Set up Bun")).toBe( + expect(jobs[name]?.steps!.some(({ name }) => name === "Set up Bun")).toBe( false, ); } @@ -169,7 +169,7 @@ describe("TypeScript package skeleton", () => { test("checks one archive and restores its plugin before every test shard", async () => { const { jobs } = await workflow("node-ci.yml"); - const uploads = jobs["package"]!.steps; + const uploads = jobs["package"]!.steps!; const inspection = uploads.findIndex( ({ name }) => name === "Inspect archive contents", ); @@ -193,13 +193,13 @@ describe("TypeScript package skeleton", () => { const job = jobs[name]!; expect(job.needs).toContain("package"); expect( - job.steps.find( + job.steps!.find( ({ name }) => name === "Download package for this commit", )?.with, ).toEqual({ name: "package-${{ github.sha }}", path: "dist" }); } for (const name of ["test", "windows-test", "mcp"]) { - const steps = jobs[name]!.steps; + const steps = jobs[name]!.steps!; const restore = steps.findIndex( ({ name }) => name === "Restore bundled plugin", ); @@ -218,28 +218,28 @@ describe("TypeScript package skeleton", () => { test("installs ripgrep before the independent MCP job", async () => { const { jobs } = await workflow("node-ci.yml"); - const steps = jobs["mcp"]!.steps; + const steps = jobs["mcp"]!.steps!; const ripgrep = steps.findIndex(({ name }) => name === "Install ripgrep"); const tests = steps.findIndex(({ name }) => name === "Test MCP app"); expect(steps[ripgrep]?.run).toContain("apt-get install --yes ripgrep"); expect(ripgrep).toBeLessThan(tests); expect( - jobs["test"]!.steps.some(({ name }) => name === "Test MCP app"), + jobs["test"]!.steps!.some(({ name }) => name === "Test MCP app"), ).toBe(false); }); test("runs static checks independently and keeps diagnostic uploads non-blocking", async () => { const { jobs } = await workflow("node-ci.yml"); - const steps = Object.values(jobs).flatMap((job) => job.steps); + const steps = Object.values(jobs).flatMap((job) => job.steps ?? []); expect(jobs["static-checks"]?.needs).toBe("validate-title"); - expect(jobs["package"]?.needs).toBe("validate-title"); + expect(jobs["package"]?.needs).toEqual(["validate-title", "native"]); for (const [name, job] of [ ["Check plugin source boundary", "package"], ["Typecheck", "static-checks"], ["Check formatting", "static-checks"], ] as const) { expect(steps.filter((step) => step.name === name)).toHaveLength(1); - expect(jobs[job]!.steps.some((step) => step.name === name)).toBe(true); + expect(jobs[job]!.steps!.some((step) => step.name === name)).toBe(true); } for (const name of [ "Upload test reports", @@ -253,7 +253,7 @@ describe("TypeScript package skeleton", () => { }); } expect( - jobs["plugin-source"]!.steps.find( + jobs["plugin-source"]!.steps!.find( ({ name }) => name === "Test Python source contracts", )?.run, ).toContain( @@ -263,7 +263,7 @@ describe("TypeScript package skeleton", () => { test("keeps machine-wide policy changes out of parallel and experimental runs", async () => { const ci = await workflow("node-ci.yml"); - const windows = ci.jobs["windows-test"]!.steps; + const windows = ci.jobs["windows-test"]!.steps!; expect( windows.find((step) => step.name === "Test shard ${{ matrix.shard }}") ?.env?.["CODEX_SECURITY_ALLOW_MACHINE_POLICY_TEST"], @@ -324,7 +324,7 @@ describe("TypeScript package skeleton", () => { args, }); } - const command = runner.steps.find( + const command = runner.steps!.find( (step) => step.name === "Test runner mode", )?.run; expect(command).toContain( @@ -334,7 +334,7 @@ describe("TypeScript package skeleton", () => { expect(command).toContain("--seed=${{ env.CODEX_SECURITY_PROPERTY_SEED }}"); const uploads = [...Object.values(ci.jobs), ...Object.values(quality.jobs)] - .flatMap((job) => job.steps) + .flatMap((job) => job.steps ?? []) .filter((step) => step.uses?.startsWith("actions/upload-artifact@")); for (const upload of uploads) { expect(upload.with?.["overwrite"]).toBe(true); @@ -346,7 +346,7 @@ describe("TypeScript package skeleton", () => { uploads.find((step) => step.name === "Upload runner report"), ).not.toHaveProperty("continue-on-error"); expect( - quality.jobs["mutation"]?.steps.find( + quality.jobs["mutation"]?.steps!.find( (step) => step.name === "Run mutation trial", ), ).not.toHaveProperty("continue-on-error"); @@ -379,7 +379,7 @@ describe("TypeScript package skeleton", () => { for (const workflowName of ["node-ci.yml", "node-release.yml"]) { const { jobs } = await workflow(workflowName); const audits = Object.values(jobs) - .flatMap((job) => job.steps) + .flatMap((job) => job.steps ?? []) .filter((step) => step.name === "Audit production dependencies"); expect(audits.length).toBeGreaterThan(0); for (const audit of audits) { diff --git a/sdk/typescript/tests-ts/test-shards.test.ts b/sdk/typescript/tests-ts/test-shards.test.ts index a416db464..6dcf02d42 100644 --- a/sdk/typescript/tests-ts/test-shards.test.ts +++ b/sdk/typescript/tests-ts/test-shards.test.ts @@ -1,4 +1,3 @@ -import { spawnSync } from "node:child_process"; import { copyFile, mkdir, @@ -188,18 +187,21 @@ test("isolated timeout", async () => { ); try { - const result = spawnSync( - process.execPath, - ["test", "--timeout", "30000", fixture], - { - encoding: "utf8", - env: { ...process.env, CODEX_SECURITY_TEST_TIMEOUT_MS: "100" }, - timeout: 30_000, - windowsHide: true, - }, - ); - expect(result.status, result.stderr || result.error?.message).toBe(1); - expect(result.stderr).toContain("this test timed out after 100ms"); + const child = Bun.spawn({ + cmd: [process.execPath, "test", "--timeout", "30000", fixture], + env: { ...process.env, CODEX_SECURITY_TEST_TIMEOUT_MS: "100" }, + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + timeout: 30_000, + windowsHide: true, + }); + const [status, stderr] = await Promise.all([ + child.exited, + new Response(child.stderr).text(), + ]); + expect(status, stderr).toBe(1); + expect(stderr).toContain("this test timed out after 100ms"); } finally { await rm(directory, { recursive: true, force: true }); }