mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
64e203931b
commit
01f86665e5
3 changed files with 59 additions and 7 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(() => ({
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue