From a5d38ca1a662eaff9ec4590a5d3e41c497a45b0c Mon Sep 17 00:00:00 2001 From: Soyeon Park Date: Fri, 11 Sep 2026 15:38:33 -0700 Subject: [PATCH] feat(build): support host native plugin builds --- plugins/codex-security/mcp-app/TESTING.md | 16 ++ plugins/codex-security/mcp-app/package.json | 1 + .../mcp-app/scripts/build_mcp_app.mjs | 41 +++-- .../mcp-app/scripts/build_native.mjs | 27 +++ .../mcp-app/tests/test_build_mcp_app.mjs | 164 ++++++++++++++++++ .../mcp-app/tsconfig.native.json | 12 ++ 6 files changed, 250 insertions(+), 11 deletions(-) create mode 100644 plugins/codex-security/mcp-app/scripts/build_native.mjs create mode 100644 plugins/codex-security/mcp-app/tests/test_build_mcp_app.mjs create mode 100644 plugins/codex-security/mcp-app/tsconfig.native.json diff --git a/plugins/codex-security/mcp-app/TESTING.md b/plugins/codex-security/mcp-app/TESTING.md index a79b05197..e0cc692dd 100644 --- a/plugins/codex-security/mcp-app/TESTING.md +++ b/plugins/codex-security/mcp-app/TESTING.md @@ -11,3 +11,19 @@ node --test --test-concurrency=1 "tests/test_*.mjs" ``` Use the existing injected clocks for retry policy tests. Keep real timers, subprocesses, SQLite files, and isolated temporary directories in the lifecycle, locking, permissions, and stdio tests. Do not share writable fixtures between test files or rebuild the bundled plugin while another test is reading it. + +## Build for the local host + +For a local or CI plugin that only runs on the build host, install the MCP app dependencies and the toolchain declared in `../native/rust-toolchain.toml`. Windows also requires the MSVC C++ build tools. From this directory: + +```sh +pnpm install --frozen-lockfile +pnpm run build:native +node scripts/build_mcp_app.mjs --output .preview/mcp --native host +``` + +`build:native` compiles the native TypeScript tools with this package's existing dependencies, runs the locked Cargo build, and prepares dependency notices. It does not require the SDK source tree. Native outputs stay in ignored `../native/dist` and `../native/target` directories; `CARGO_TARGET_DIR` can select another Cargo cache. + +`--native host` copies the current OS, CPU, and Linux libc target from `native/dist`, together with all shared notices. It requires a fresh `build:native` run after native source changes and fails if the host binary is missing. Build again on each execution platform; a host build is not a portable release artifact. + +The default, `--native universal`, still requires the complete verified `native/prebuilt` payload. Package and release validation keep checking every supported platform. For distributing GNU Linux binaries, retain the glibc 2.28 build environment and the compatibility checks described in the [native README](../native/README.md). diff --git a/plugins/codex-security/mcp-app/package.json b/plugins/codex-security/mcp-app/package.json index 121a343d8..8b36b217c 100644 --- a/plugins/codex-security/mcp-app/package.json +++ b/plugins/codex-security/mcp-app/package.json @@ -5,6 +5,7 @@ "private": true, "scripts": { "build": "tsc --noEmit", + "build:native": "node scripts/build_native.mjs", "build:mcp": "node scripts/build_mcp_app.mjs --output .preview/mcp", "test:mcp": "node --test --test-concurrency=2 --test-reporter=./scripts/test_reporter.mjs \"tests/test_*.mjs\"", "typecheck": "tsc --noEmit" 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 94022da4f..6dfdae774 100644 --- a/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs +++ b/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs @@ -4,13 +4,20 @@ 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"; +import { parseArgs } from "node:util"; import { build } from "esbuild"; const root = resolve(import.meta.dirname, ".."); const maxChunkBytes = 140_000; -export async function buildMcpApp({ output }) { +export async function buildMcpApp({ output, native = "universal" }) { + if (native !== "universal" && native !== "host") { + throw new Error("Native packaging must be universal or host."); + } const mcpDir = resolve(output); + const nativeTarget = native === "host" + ? (await import("../../native/platform.mjs")).nativeTarget + : undefined; execFileSync(process.execPath, ["--run", "build"], { cwd: root, @@ -21,11 +28,18 @@ export async function buildMcpApp({ output }) { await writeRuntime("server", "main.ts"); const contract = JSON.parse(await readFile(join(root, "../plugin-files.json"), "utf8")); - for (const file of contract.shippedExact.filter((path) => path.startsWith("mcp/native/"))) { + const nativeFiles = contract.shippedExact.filter((path) => path.startsWith("mcp/native/")); + if (nativeTarget !== undefined && !nativeFiles.some((path) => path.startsWith(`mcp/native/${nativeTarget}/`))) { + throw new Error(`Unsupported native target: ${nativeTarget}`); + } + for (const file of nativeFiles) { + if (nativeTarget !== undefined && file.endsWith(".node") && !file.startsWith(`mcp/native/${nativeTarget}/`)) { + continue; + } const path = file.slice("mcp/native/".length); const destination = join(mcpDir, "native", path); await mkdir(dirname(destination), { recursive: true }); - await copyFile(join(root, "../native/prebuilt", path), destination); + await copyFile(join(root, "../native", native === "host" ? "dist" : "prebuilt", path), destination); } await writeRuntime("helpers", "helpers-main.ts"); @@ -71,15 +85,20 @@ if ( invokedPath !== undefined && pathToFileURL(resolve(invokedPath)).href === import.meta.url ) { - const args = process.argv.slice(2); - if (args.length !== 2 || args[0] !== "--output") { - console.error("Usage: node scripts/build_mcp_app.mjs --output "); - process.exitCode = 1; - } else { - buildMcpApp({ output: args[1] }).catch((error) => { - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; + try { + const { values } = parseArgs({ + options: { + output: { type: "string" }, + native: { type: "string", default: "universal" } + } }); + if (!values.output) { + throw new Error("Usage: node scripts/build_mcp_app.mjs --output [--native universal|host]"); + } + await buildMcpApp(values); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; } } diff --git a/plugins/codex-security/mcp-app/scripts/build_native.mjs b/plugins/codex-security/mcp-app/scripts/build_native.mjs new file mode 100644 index 000000000..4fef6b221 --- /dev/null +++ b/plugins/codex-security/mcp-app/scripts/build_native.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { join, resolve } from "node:path"; + +const app = resolve(import.meta.dirname, ".."); +const native = join(app, "../native"); + +execFileSync( + process.execPath, + [ + join(app, "node_modules/typescript/bin/tsc"), + "--project", + join(app, "tsconfig.native.json"), + ], + { cwd: app, stdio: "inherit" }, +); + +execFileSync(process.execPath, ["build.mjs"], { + cwd: native, + stdio: "inherit", +}); +// Notices include every locked dependency, including those for other targets. +execFileSync("cargo", ["fetch", "--locked"], { cwd: native, stdio: "inherit" }); +execFileSync(process.execPath, ["notices.mjs"], { + cwd: native, + stdio: "inherit", +}); diff --git a/plugins/codex-security/mcp-app/tests/test_build_mcp_app.mjs b/plugins/codex-security/mcp-app/tests/test_build_mcp_app.mjs new file mode 100644 index 000000000..d9b865670 --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/test_build_mcp_app.mjs @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + copyFile, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { transform } from "esbuild"; + +const app = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const contract = JSON.parse( + await readFile(join(app, "../plugin-files.json"), "utf8"), +); +const nativeFiles = contract.shippedExact + .filter((path) => path.startsWith("mcp/native/")) + .map((path) => path.slice("mcp/native/".length)); +const platform = await transform( + await readFile(join(app, "../native/platform.mts"), "utf8"), + { + loader: "ts", + format: "esm", + }, +); +const { nativeTarget } = await import( + `data:text/javascript,${encodeURIComponent(platform.code)}` +); +const hostBinary = nativeFiles.find((path) => + path.startsWith(`${nativeTarget}/`), +); +assert.ok(hostBinary); +const foreignBinary = nativeFiles.find( + (path) => path.endsWith(".node") && path !== hostBinary, +); +const notices = nativeFiles.filter((path) => !path.endsWith(".node")); + +async function fixture(t) { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-package-test-")), + ); + t.after(() => rm(root, { recursive: true, force: true })); + const mcp = join(root, "mcp-app"); + await mkdir(join(mcp, "scripts"), { recursive: true }); + await symlink( + join(app, "node_modules"), + join(mcp, "node_modules"), + "junction", + ); + await copyFile( + join(app, "scripts/build_mcp_app.mjs"), + join(mcp, "scripts/build_mcp_app.mjs"), + ); + await writeFile( + join(mcp, "package.json"), + JSON.stringify({ + type: "module", + scripts: { build: "tsc --noEmit" }, + }), + ); + await writeFile( + join(mcp, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { skipLibCheck: true }, + include: ["*.ts"], + }), + ); + await writeFile(join(mcp, "main.ts"), 'console.log("server fixture");\n'); + await writeFile( + join(mcp, "helpers-main.ts"), + 'console.log("helper fixture");\n', + ); + await writeFile(join(root, "plugin-files.json"), JSON.stringify(contract)); + for (const [directory, files] of [ + ["prebuilt", nativeFiles], + ["dist", [...notices, hostBinary]], + ]) { + for (const path of files) { + const destination = join(root, "native", directory, path); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, `${directory}:${path}`); + } + } + await writeFile(join(root, "native/platform.mjs"), platform.code); + const output = join(root, "output"); + return { + root, + output, + build: (...args) => + spawnSync( + process.execPath, + [join(mcp, "scripts/build_mcp_app.mjs"), "--output", output, ...args], + { encoding: "utf8" }, + ), + }; +} + +test("host packaging uses only the source-built target and shared notices", async (t) => { + const { root, output, build } = await fixture(t); + await rm(join(root, "native/prebuilt"), { recursive: true }); + const result = build("--native", "host"); + assert.equal(result.status, 0, result.stderr); + const files = ( + await readdir(join(output, "native"), { + recursive: true, + withFileTypes: true, + }) + ).filter((entry) => entry.isFile()); + assert.equal(files.length, notices.length + 1); + for (const path of [...notices, hostBinary]) { + assert.equal( + await readFile(join(output, "native", path), "utf8"), + `dist:${path}`, + ); + } + assert.equal( + spawnSync(process.execPath, [join(output, "server.mjs")], { + encoding: "utf8", + }).stdout, + "server fixture\n", + ); + assert.equal( + spawnSync(process.execPath, [join(output, "helpers.mjs")], { + encoding: "utf8", + }).stdout, + "helper fixture\n", + ); +}); + +test("default packaging retains every verified prebuilt target", async (t) => { + const { output, build } = await fixture(t); + const result = build(); + assert.equal(result.status, 0, result.stderr); + for (const path of nativeFiles) { + assert.equal( + await readFile(join(output, "native", path), "utf8"), + `prebuilt:${path}`, + ); + } +}); + +test("host packaging rejects a missing host build even when prebuilt artifacts exist", async (t) => { + const { root, build } = await fixture(t); + await rm(join(root, "native/dist", hostBinary)); + const result = build("--native", "host"); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /ENOENT/); +}); + +test("universal packaging still rejects a missing foreign target", async (t) => { + const { root, build } = await fixture(t); + await rm(join(root, "native/prebuilt", foreignBinary)); + const result = build(); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /ENOENT/); +}); diff --git a/plugins/codex-security/mcp-app/tsconfig.native.json b/plugins/codex-security/mcp-app/tsconfig.native.json new file mode 100644 index 000000000..4ac9001ac --- /dev/null +++ b/plugins/codex-security/mcp-app/tsconfig.native.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": false, + "noEmitOnError": true, + "rootDir": "../native", + "typeRoots": ["./node_modules/@types"] + }, + "include": ["../native/*.mts"] +}