Skip to content

Commit 303e052

Browse files
authored
fix(opencode): don't preload a missing transport shim into child processes (#2806)
## Description `headroom wrap opencode` broke third-party MCP servers in pip/wheel installs. The wrap transport plugin appended `NODE_OPTIONS=--import=<plugin dir>/../hook-shim/handler.js` to its own env (and injected it into every child it spawns), but that path only resolves in a repo checkout. Wheel installs load the standalone bundle from `headroom/providers/opencode/_dist/`, which has no `hook-shim/` sibling — the shim lives under `plugins/` and maturin only ships files under `headroom/` (pyproject.toml `python-source`/package-dir behavior). Every Node child then aborted with `ERR_MODULE_NOT_FOUND` before executing a line, including OpenCode's stdio MCP servers. OpenCode reports that as `<server> MCP error -32000: Connection closed`. Headroom's own MCP server is a Python process, so it stayed connected — which is why the breakage looked selective, and why nothing appeared in the proxy logs (the failure is entirely inside OpenCode's child process). Docker and `--no-proxy` are incidental: the plugin installs the transport on load in every wrap mode. Fix: resolve the shim only when it exists on disk, and skip the `NODE_OPTIONS` mutation otherwise. Children go direct instead of dying. Checkout builds still get child-process transport hooking, unchanged. Closes #2798 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `plugins/opencode/src/transport.ts`: `shimImportSpecifier()` returns `string | undefined`, gated on `fs.existsSync`; `installProcessEnv()` and `withShimEnv()` leave `NODE_OPTIONS` untouched when the shim is absent. - `plugins/opencode/src/transport.test.ts`: new regression test — with the shim missing, the parent's `NODE_OPTIONS` is unmodified and a spawned `npx -y firecrawl-mcp` receives no `--import`. - `headroom/providers/opencode/_dist/entry.opencode.js`: regenerated via `npm run build:standalone` (the bundle that wheel installs actually load). ## Testing - [ ] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed No Python source changed, so `pytest` / `ruff` / `mypy` are N/A here; the TypeScript equivalents were run instead. ### Test Output ```text $ npm run typecheck > tsc --noEmit (no output) $ npm test RUN v4.1.9 /private/tmp/hr-pr-2798/plugins/opencode Test Files 2 passed (2) Tests 14 passed (14) Duration 416ms # The new test is not vacuous — reverting the guard to `return shim.href` reddens it: $ npx vitest run -t "#2798" Test Files 1 failed | 1 skipped (2) Tests 1 failed | 13 skipped (14) ``` ## Real Behavior Proof - Environment: macOS (darwin 25.4.0), Node v24, Bun present; both bundles loaded directly from disk. - Exact command / steps: load each built bundle, invoke the default plugin export, print `process.env.NODE_OPTIONS`, then `spawnSync(process.execPath, ["-e", "console.log('mcp server handshake ok')"])` — the same way OpenCode launches a stdio MCP server. ```text ### BEFORE (wheel layout, shim missing) ### NODE_OPTIONS: "--import=file:///…/headroom/providers/opencode/hook-shim/handler.js" child: Error [ERR_MODULE_NOT_FOUND]: Cannot find module '…/headroom/providers/opencode/hook-shim/handler.js' <-- becomes MCP -32000 ### AFTER — wheel layout (headroom/providers/opencode/_dist/) ### NODE_OPTIONS after plugin load: undefined child status: 0 | stdout: mcp server handshake ok ### AFTER — checkout layout (plugins/opencode/dist/, shim present) ### NODE_OPTIONS after plugin load: "--import=file:///…/plugins/opencode/hook-shim/handler.js" child status: 0 | stdout: mcp server handshake ok ``` - Observed result: wheel installs no longer poison child env, so Node MCP servers start; checkout builds keep the preload and still start children cleanly. - Not tested: no reproduction against a live `opencode` + codegraph/firecrawl session on Ubuntu (no OpenCode install on this machine); the child-process failure was reproduced directly instead, which is the exact mechanism behind the reported `-32000`. Docker proxy path not re-tested — it is unrelated to the fix. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` ## Additional Notes Docs unchanged: this is an internal packaging/runtime bug with no documented behavior attached. Follow-up (deliberately not in this PR): wheel installs now lose child-process transport hooking rather than crashing — the same coverage they effectively had, since the preload never once loaded from a wheel. Restoring it means a standalone shim build emitted into `_dist/` plus exporting `installHeadroomTransport` from that bundle; `hook-shim/handler.js` also imports `../dist/index.js`, which does not exist in the wheel layout, so copying the file alone would not be enough. Worth doing only if something needs a subprocess's LLM traffic proxied.
1 parent 6ec3e34 commit 303e052

3 files changed

Lines changed: 59 additions & 7 deletions

File tree

headroom/providers/opencode/_dist/entry.opencode.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12484,6 +12484,7 @@ var http = nodeRequire("node:http");
1248412484
var https = nodeRequire("node:https");
1248512485
var http2 = nodeRequire("node:http2");
1248612486
var childProcess = nodeRequire("node:child_process");
12487+
var fs = nodeRequire("node:fs");
1248712488
var BASE_URL_HEADER = "x-headroom-base-url";
1248812489
var ORIGINAL_PATH_HEADER = "x-headroom-original-path";
1248912490
var PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL";
@@ -12495,7 +12496,8 @@ function setState(state) {
1249512496
globalThis[STATE_KEY] = state;
1249612497
}
1249712498
function shimImportSpecifier() {
12498-
return new URL("../hook-shim/handler.js", import.meta.url).href;
12499+
const shim = new URL("../hook-shim/handler.js", import.meta.url);
12500+
return fs.existsSync(shim) ? shim.href : void 0;
1249912501
}
1250012502
function withNodeImportOption(existing, shim) {
1250112503
const parts = existing?.trim() ? existing.trim().split(/\s+/) : [];
@@ -12510,12 +12512,18 @@ function withNodeImportOption(existing, shim) {
1251012512
function withShimEnv(env, proxyUrl) {
1251112513
const nextEnv = { ...env ?? process.env };
1251212514
nextEnv[PROXY_ENV] = proxyUrl;
12513-
nextEnv.NODE_OPTIONS = withNodeImportOption(nextEnv.NODE_OPTIONS, shimImportSpecifier());
12515+
const shim = shimImportSpecifier();
12516+
if (shim) {
12517+
nextEnv.NODE_OPTIONS = withNodeImportOption(nextEnv.NODE_OPTIONS, shim);
12518+
}
1251412519
return nextEnv;
1251512520
}
1251612521
function installProcessEnv(proxyUrl) {
1251712522
process.env[PROXY_ENV] = proxyUrl;
12518-
process.env.NODE_OPTIONS = withNodeImportOption(process.env.NODE_OPTIONS, shimImportSpecifier());
12523+
const shim = shimImportSpecifier();
12524+
if (shim) {
12525+
process.env.NODE_OPTIONS = withNodeImportOption(process.env.NODE_OPTIONS, shim);
12526+
}
1251912527
}
1252012528
function isOptions(value) {
1252112529
return Boolean(value) && typeof value === "object" && !Array.isArray(value) && !(value instanceof URL);

plugins/opencode/src/transport.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import childProcess from "node:child_process";
2+
import fs from "node:fs";
23
import http from "node:http";
34
import http2 from "node:http2";
45
import https from "node:https";
@@ -328,6 +329,35 @@ describe("Headroom OpenCode transport", () => {
328329
}
329330
});
330331

332+
it("skips the shim preload when the bundle ships without it (#2798)", () => {
333+
const originalNodeOptions = process.env.NODE_OPTIONS;
334+
const originalSpawn = childProcess.spawn;
335+
const spawnMock = vi.fn(() => ({ on: vi.fn(), kill: vi.fn(), pid: 123 }));
336+
childProcess.spawn = spawnMock as unknown as typeof childProcess.spawn;
337+
vi.spyOn(fs, "existsSync").mockReturnValue(false);
338+
339+
try {
340+
process.env.NODE_OPTIONS = "--trace-warnings";
341+
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
342+
343+
// A missing --import target aborts the child before it speaks JSON-RPC,
344+
// which OpenCode reports as `MCP error -32000: Connection closed`.
345+
expect(process.env.NODE_OPTIONS).toBe("--trace-warnings");
346+
347+
childProcess.spawn("npx", ["-y", "firecrawl-mcp"]);
348+
const options = (spawnMock.mock.calls[0] as unknown[])[2] as { env: NodeJS.ProcessEnv };
349+
expect(options.env.NODE_OPTIONS).not.toContain("--import");
350+
} finally {
351+
if (originalNodeOptions === undefined) {
352+
delete process.env.NODE_OPTIONS;
353+
} else {
354+
process.env.NODE_OPTIONS = originalNodeOptions;
355+
}
356+
childProcess.spawn = originalSpawn;
357+
uninstallHeadroomTransport();
358+
}
359+
});
360+
331361
it("injects the Headroom shim into child processes with custom env", () => {
332362
const originalSpawn = childProcess.spawn;
333363
const spawnMock = vi.fn(() => ({

plugins/opencode/src/transport.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const http = nodeRequire("node:http") as typeof import("node:http");
55
const https = nodeRequire("node:https") as typeof import("node:https");
66
const http2 = nodeRequire("node:http2") as typeof import("node:http2");
77
const childProcess = nodeRequire("node:child_process") as typeof import("node:child_process");
8+
const fs = nodeRequire("node:fs") as typeof import("node:fs");
89

910
const BASE_URL_HEADER = "x-headroom-base-url";
1011
const ORIGINAL_PATH_HEADER = "x-headroom-original-path";
@@ -61,8 +62,15 @@ function setState(state: TransportState | undefined): void {
6162
(globalThis as GlobalWithHeadroomTransport)[STATE_KEY] = state;
6263
}
6364

64-
function shimImportSpecifier(): string {
65-
return new URL("../hook-shim/handler.js", import.meta.url).href;
65+
// ponytail: the shim only exists next to the checkout build
66+
// (plugins/opencode/dist/). The wheel ships entry.opencode.js alone, so
67+
// `--import=<missing file>` killed every Node child at startup — including
68+
// OpenCode's stdio MCP servers (issue #2798). No shim on disk, no injection:
69+
// children go direct instead of dying. Upgrade path is bundling the shim into
70+
// _dist/ so wheel installs get child-process routing back.
71+
function shimImportSpecifier(): string | undefined {
72+
const shim = new URL("../hook-shim/handler.js", import.meta.url);
73+
return fs.existsSync(shim) ? shim.href : undefined;
6674
}
6775

6876
function withNodeImportOption(existing: string | undefined, shim: string): string {
@@ -79,13 +87,19 @@ function withNodeImportOption(existing: string | undefined, shim: string): strin
7987
function withShimEnv(env: NodeJS.ProcessEnv | Record<string, unknown> | undefined, proxyUrl: string): NodeJS.ProcessEnv {
8088
const nextEnv = { ...(env ?? process.env) } as NodeJS.ProcessEnv;
8189
nextEnv[PROXY_ENV] = proxyUrl;
82-
nextEnv.NODE_OPTIONS = withNodeImportOption(nextEnv.NODE_OPTIONS, shimImportSpecifier());
90+
const shim = shimImportSpecifier();
91+
if (shim) {
92+
nextEnv.NODE_OPTIONS = withNodeImportOption(nextEnv.NODE_OPTIONS, shim);
93+
}
8394
return nextEnv;
8495
}
8596

8697
function installProcessEnv(proxyUrl: string): void {
8798
process.env[PROXY_ENV] = proxyUrl;
88-
process.env.NODE_OPTIONS = withNodeImportOption(process.env.NODE_OPTIONS, shimImportSpecifier());
99+
const shim = shimImportSpecifier();
100+
if (shim) {
101+
process.env.NODE_OPTIONS = withNodeImportOption(process.env.NODE_OPTIONS, shim);
102+
}
89103
}
90104

91105
function isOptions(value: unknown): value is Record<string, unknown> {

0 commit comments

Comments
 (0)