From 01f86665e5ad8fa2828dfd005b0310f2a932600f Mon Sep 17 00:00:00 2001 From: Tejas Chopra Date: Wed, 5 Aug 2026 11:42:17 -0700 Subject: [PATCH] fix(opencode): don't preload a missing transport shim into child processes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrap transport plugin appended `NODE_OPTIONS=--import=/../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 ` 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. --- .../opencode/_dist/entry.opencode.js | 14 +++++++-- plugins/opencode/src/transport.test.ts | 30 +++++++++++++++++++ plugins/opencode/src/transport.ts | 22 +++++++++++--- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/headroom/providers/opencode/_dist/entry.opencode.js b/headroom/providers/opencode/_dist/entry.opencode.js index 56f9873e8..f2fb1fcbb 100644 --- a/headroom/providers/opencode/_dist/entry.opencode.js +++ b/headroom/providers/opencode/_dist/entry.opencode.js @@ -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); diff --git a/plugins/opencode/src/transport.test.ts b/plugins/opencode/src/transport.test.ts index 2e97e1fe5..39edd98de 100644 --- a/plugins/opencode/src/transport.test.ts +++ b/plugins/opencode/src/transport.test.ts @@ -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(() => ({ diff --git a/plugins/opencode/src/transport.ts b/plugins/opencode/src/transport.ts index b7ced7912..b15d5f67d 100644 --- a/plugins/opencode/src/transport.ts +++ b/plugins/opencode/src/transport.ts @@ -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=` 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 | 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 {