fix(opencode): don't preload a missing transport shim into child processes

The wrap transport plugin appended
`NODE_OPTIONS=--import=<plugin dir>/../hook-shim/handler.js` to its own env
and to every child it spawns. That path only resolves in a repo checkout
(`plugins/opencode/dist/` has a `hook-shim/` sibling). Wheel installs load
the standalone bundle from `headroom/providers/opencode/_dist/`, where no
shim exists — `hook-shim/` lives under `plugins/` and maturin only ships
files under `headroom/`.

Every Node child then aborted with ERR_MODULE_NOT_FOUND before running,
including OpenCode's stdio MCP servers, which surfaced as
`<server> MCP error -32000: Connection closed` for third-party servers
(codegraph, firecrawl) while Headroom's own Python MCP server stayed up.

Resolve the shim only when it is present on disk, and skip the NODE_OPTIONS
mutation otherwise: children go direct instead of dying. Checkout builds keep
child-process transport hooking unchanged.
This commit is contained in:
Tejas Chopra 2026-08-05 11:42:17 -07:00
parent 64e203931b
commit 01f86665e5
3 changed files with 59 additions and 7 deletions

View file

@ -12484,6 +12484,7 @@ var http = nodeRequire("node:http");
var https = nodeRequire("node:https");
var http2 = nodeRequire("node:http2");
var childProcess = nodeRequire("node:child_process");
var fs = nodeRequire("node:fs");
var BASE_URL_HEADER = "x-headroom-base-url";
var ORIGINAL_PATH_HEADER = "x-headroom-original-path";
var PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL";
@ -12495,7 +12496,8 @@ function setState(state) {
globalThis[STATE_KEY] = state;
}
function shimImportSpecifier() {
return new URL("../hook-shim/handler.js", import.meta.url).href;
const shim = new URL("../hook-shim/handler.js", import.meta.url);
return fs.existsSync(shim) ? shim.href : void 0;
}
function withNodeImportOption(existing, shim) {
const parts = existing?.trim() ? existing.trim().split(/\s+/) : [];
@ -12510,12 +12512,18 @@ function withNodeImportOption(existing, shim) {
function withShimEnv(env, proxyUrl) {
const nextEnv = { ...env ?? process.env };
nextEnv[PROXY_ENV] = proxyUrl;
nextEnv.NODE_OPTIONS = withNodeImportOption(nextEnv.NODE_OPTIONS, shimImportSpecifier());
const shim = shimImportSpecifier();
if (shim) {
nextEnv.NODE_OPTIONS = withNodeImportOption(nextEnv.NODE_OPTIONS, shim);
}
return nextEnv;
}
function installProcessEnv(proxyUrl) {
process.env[PROXY_ENV] = proxyUrl;
process.env.NODE_OPTIONS = withNodeImportOption(process.env.NODE_OPTIONS, shimImportSpecifier());
const shim = shimImportSpecifier();
if (shim) {
process.env.NODE_OPTIONS = withNodeImportOption(process.env.NODE_OPTIONS, shim);
}
}
function isOptions(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value) && !(value instanceof URL);

View file

@ -1,4 +1,5 @@
import childProcess from "node:child_process";
import fs from "node:fs";
import http from "node:http";
import http2 from "node:http2";
import https from "node:https";
@ -328,6 +329,35 @@ describe("Headroom OpenCode transport", () => {
}
});
it("skips the shim preload when the bundle ships without it (#2798)", () => {
const originalNodeOptions = process.env.NODE_OPTIONS;
const originalSpawn = childProcess.spawn;
const spawnMock = vi.fn(() => ({ on: vi.fn(), kill: vi.fn(), pid: 123 }));
childProcess.spawn = spawnMock as unknown as typeof childProcess.spawn;
vi.spyOn(fs, "existsSync").mockReturnValue(false);
try {
process.env.NODE_OPTIONS = "--trace-warnings";
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
// A missing --import target aborts the child before it speaks JSON-RPC,
// which OpenCode reports as `MCP error -32000: Connection closed`.
expect(process.env.NODE_OPTIONS).toBe("--trace-warnings");
childProcess.spawn("npx", ["-y", "firecrawl-mcp"]);
const options = (spawnMock.mock.calls[0] as unknown[])[2] as { env: NodeJS.ProcessEnv };
expect(options.env.NODE_OPTIONS).not.toContain("--import");
} finally {
if (originalNodeOptions === undefined) {
delete process.env.NODE_OPTIONS;
} else {
process.env.NODE_OPTIONS = originalNodeOptions;
}
childProcess.spawn = originalSpawn;
uninstallHeadroomTransport();
}
});
it("injects the Headroom shim into child processes with custom env", () => {
const originalSpawn = childProcess.spawn;
const spawnMock = vi.fn(() => ({

View file

@ -5,6 +5,7 @@ const http = nodeRequire("node:http") as typeof import("node:http");
const https = nodeRequire("node:https") as typeof import("node:https");
const http2 = nodeRequire("node:http2") as typeof import("node:http2");
const childProcess = nodeRequire("node:child_process") as typeof import("node:child_process");
const fs = nodeRequire("node:fs") as typeof import("node:fs");
const BASE_URL_HEADER = "x-headroom-base-url";
const ORIGINAL_PATH_HEADER = "x-headroom-original-path";
@ -61,8 +62,15 @@ function setState(state: TransportState | undefined): void {
(globalThis as GlobalWithHeadroomTransport)[STATE_KEY] = state;
}
function shimImportSpecifier(): string {
return new URL("../hook-shim/handler.js", import.meta.url).href;
// ponytail: the shim only exists next to the checkout build
// (plugins/opencode/dist/). The wheel ships entry.opencode.js alone, so
// `--import=<missing file>` killed every Node child at startup — including
// OpenCode's stdio MCP servers (issue #2798). No shim on disk, no injection:
// children go direct instead of dying. Upgrade path is bundling the shim into
// _dist/ so wheel installs get child-process routing back.
function shimImportSpecifier(): string | undefined {
const shim = new URL("../hook-shim/handler.js", import.meta.url);
return fs.existsSync(shim) ? shim.href : undefined;
}
function withNodeImportOption(existing: string | undefined, shim: string): string {
@ -79,13 +87,19 @@ function withNodeImportOption(existing: string | undefined, shim: string): strin
function withShimEnv(env: NodeJS.ProcessEnv | Record<string, unknown> | undefined, proxyUrl: string): NodeJS.ProcessEnv {
const nextEnv = { ...(env ?? process.env) } as NodeJS.ProcessEnv;
nextEnv[PROXY_ENV] = proxyUrl;
nextEnv.NODE_OPTIONS = withNodeImportOption(nextEnv.NODE_OPTIONS, shimImportSpecifier());
const shim = shimImportSpecifier();
if (shim) {
nextEnv.NODE_OPTIONS = withNodeImportOption(nextEnv.NODE_OPTIONS, shim);
}
return nextEnv;
}
function installProcessEnv(proxyUrl: string): void {
process.env[PROXY_ENV] = proxyUrl;
process.env.NODE_OPTIONS = withNodeImportOption(process.env.NODE_OPTIONS, shimImportSpecifier());
const shim = shimImportSpecifier();
if (shim) {
process.env.NODE_OPTIONS = withNodeImportOption(process.env.NODE_OPTIONS, shim);
}
}
function isOptions(value: unknown): value is Record<string, unknown> {