fix(opencode): ship the transport hook-shim so wheel installs route Node child traffic

## Description

The OpenCode transport plugin injects
`NODE_OPTIONS=--import=<...>/hook-shim/handler.js` into every spawned
Node child so its `fetch`/`http` traffic routes through the proxy
(`transport.ts` wraps those globals only in the plugin's own process; a
spawned `npx` MCP server or `tokensave serve` is a fresh process). That
shim was never shipped in the wheel:

- Only `headroom/providers/opencode/_dist/entry.opencode.js` is
committed and packaged.
- The shim source at `plugins/opencode/hook-shim/handler.js` imports the
non-bundled `../dist/index.js`, which a pip install (no `node_modules`)
cannot resolve.

Before #2806, the missing file crashed every Node MCP under `headroom
wrap opencode` with `ERR_MODULE_NOT_FOUND` at the ESM loader, before the
stdio handshake. #2806 added an `existsSync` guard so the loader is not
injected when the shim is absent, which stopped the crash but left
child-process routing silently disabled for all wheel installs (#2850).

This ships the shim. It builds a self-contained variant in the
standalone tsup config (`src/hook-shim.ts`, with the transport bundled
inline like the entry, since site-packages has no `node_modules`), and
commits it to `headroom/providers/opencode/hook-shim/handler.js` -- the
sibling of `_dist/` that `transport.ts`'s `shimImportSpecifier()`
resolves via `../hook-shim/handler.js`. maturin packages every file
under `headroom/`, so the wheel now carries it, and `existsSync` finds
it, so the loader routes spawned Node children again.

Fixes #2850

## 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/hook-shim.ts` (new): self-contained Node
`--import` loader that installs the transport from the inlined
`./transport.js`.
- `plugins/opencode/tsup.standalone.config.ts`: add `hook-shim/handler`
as a second standalone entry.
- `headroom/providers/opencode/hook-shim/handler.js` (new): the
committed self-contained shim (output of `npm run build:standalone`),
shipped by maturin.
- `.github/workflows/opencode-plugin.yml`: byte-compare the committed
shim against a fresh build (mirrors the existing `entry.opencode.js`
guard), and add the shim path to the workflow triggers.
- `tests/test_providers_opencode_plugin_path.py`: added
`test_hook_shim_is_committed_next_to_the_entry_bundle` asserting the
shim ships as a sibling of `_dist/` and is the self-contained build.

## Testing

- [x] Unit tests pass (`pytest` + `vitest`)
- [x] Type checking passes (`tsc --noEmit`)
- [x] New tests added for new functionality
- [x] Committed shim rebuilt and byte-matches the standalone build
- [ ] Manual testing performed

### Test Output

```text
# Fail-before (shim removed from the package):
tests/test_providers_opencode_plugin_path.py::test_hook_shim_is_committed_next_to_the_entry_bundle FAILED

# Pass-after:
tests/test_providers_opencode_plugin_path.py tests/test_providers_opencode_install.py
tests/test_providers_opencode_config.py            49 passed, 1 pre-existing failure
#   the 1 failure (test_build_launch_env_with_project) fails identically on pristine main:
#   a Windows path-escaping quirk in OPENCODE_CONFIG_CONTENT, unrelated to this diff.

# TypeScript: npm run typecheck (clean), npm test -> 14 passed
# Standalone build: entry.opencode.js byte-unchanged vs the committed blob;
#   dist-standalone/hook-shim/handler.js cmp-matches the committed shim.

# Shim runtime sanity (node):
#   with HEADROOM_OPENCODE_TRANSPORT_PROXY_URL set -> loads, exit 0, wraps globalThis.fetch
#   without it -> throws "loaded without HEADROOM_OPENCODE_TRANSPORT_PROXY_URL", exit 1
```

## Real Behavior Proof

- Environment: Windows 11, Node v24.11.0, npm 11.5.2, tsup 8.5.1 /
esbuild 0.28.1 (pinned via `npm ci`), Python 3.12.11, pytest 9.1.1, ruff
0.15.17.
- Exact command / steps: confirmed `transport.ts` resolves
`../hook-shim/handler.js` next to the loaded entry (so the wheel needs
it at `providers/opencode/hook-shim/handler.js`), that the current wheel
ships only `_dist/entry.opencode.js`, and that maturin packages every
file under `headroom/`. Added the standalone shim entry, ran `npm run
typecheck` and `npm test` (clean), `npm run build:standalone`, verified
`entry.opencode.js` is byte-identical to the committed git blob (the
standalone build is reproducible; my working copy was only
autocrlf-inflated), copied the built shim to the wheel path, and
exercised it in Node: it installs the transport (wraps `fetch`) with the
proxy env set and throws without it. Fail-before by removing the shim
(the new Python test fails); pass-after restored.
- Observed result: `headroom/providers/opencode/hook-shim/handler.js`
now ships in the package as a self-contained module, so a pip-installed
`headroom wrap opencode` routes spawned Node children (npx MCPs,
`tokensave serve`) through the proxy instead of leaving them unrouted,
and never crashes them.
- Not tested: a full pip-install-and-spawn on Linux with a live OpenCode
session (no OpenCode client here). The shim is verified to load and wrap
`fetch` under Node, the bundle is reproducible and byte-checked by CI,
and the packaging path is maturin's standard file inclusion under
`headroom/`.

## 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`: it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

The checkout keeps using `plugins/opencode/hook-shim/handler.js` (which
imports `../dist/index.js` from the regular build), so dev behavior is
unchanged; only the wheel gains the self-contained sibling.
`entry.opencode.js` is byte-unchanged, so its existing CI guard still
passes. The committed shim is stored with LF endings so the Linux CI
byte-compare matches.
This commit is contained in:
Abhay Singh
2026-08-11 21:40:38 +05:30
committed by GitHub
parent d7b25ae3bb
commit 702dbc5902
5 changed files with 444 additions and 1 deletions
+5
View File
@@ -11,12 +11,14 @@ on:
paths:
- "plugins/opencode/**"
- "headroom/providers/opencode/_dist/**"
- "headroom/providers/opencode/hook-shim/**"
- ".github/workflows/opencode-plugin.yml"
push:
branches: [main]
paths:
- "plugins/opencode/**"
- "headroom/providers/opencode/_dist/**"
- "headroom/providers/opencode/hook-shim/**"
- ".github/workflows/opencode-plugin.yml"
permissions:
@@ -51,3 +53,6 @@ jobs:
cmp dist-standalone/entry.opencode.js \
../../headroom/providers/opencode/_dist/entry.opencode.js \
|| { echo "::error::headroom/providers/opencode/_dist/entry.opencode.js is stale - run 'npm run build:standalone' in plugins/opencode and commit the result"; exit 1; }
cmp dist-standalone/hook-shim/handler.js \
../../headroom/providers/opencode/hook-shim/handler.js \
|| { echo "::error::headroom/providers/opencode/hook-shim/handler.js is stale - run 'npm run build:standalone' in plugins/opencode and commit the result"; exit 1; }
@@ -0,0 +1,390 @@
// src/transport.ts
import { createRequire, syncBuiltinESMExports } from "module";
var nodeRequire = createRequire(import.meta.url);
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";
var STATE_KEY = /* @__PURE__ */ Symbol.for("headroom.opencode.transport");
function getState() {
return globalThis[STATE_KEY];
}
function setState(state) {
globalThis[STATE_KEY] = state;
}
function shimImportSpecifier() {
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+/) : [];
const alreadyPresent = parts.some((part, index) => {
return part === `--import=${shim}` || part === "--import" && parts[index + 1] === shim;
});
if (!alreadyPresent) {
parts.push(`--import=${shim}`);
}
return parts.join(" ");
}
function withShimEnv(env, proxyUrl2) {
const nextEnv = { ...env ?? process.env };
nextEnv[PROXY_ENV] = proxyUrl2;
const shim = shimImportSpecifier();
if (shim) {
nextEnv.NODE_OPTIONS = withNodeImportOption(nextEnv.NODE_OPTIONS, shim);
}
return nextEnv;
}
function installProcessEnv(proxyUrl2) {
process.env[PROXY_ENV] = proxyUrl2;
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);
}
function injectOptionsEnv(args, optionIndex, proxyUrl2) {
const nextArgs = [...args];
const callback = typeof nextArgs.at(-1) === "function" ? nextArgs.pop() : void 0;
const existing = isOptions(nextArgs[optionIndex]) ? { ...nextArgs[optionIndex] } : {};
existing.env = withShimEnv(existing.env, proxyUrl2);
if (isOptions(nextArgs[optionIndex])) {
nextArgs[optionIndex] = existing;
} else {
nextArgs.splice(optionIndex, 0, existing);
}
if (callback) {
nextArgs.push(callback);
}
return nextArgs;
}
function wrapSpawn(originalSpawn) {
return function headroomSpawn(...args) {
const state = getState();
if (!state) {
return Reflect.apply(originalSpawn, this, args);
}
const optionIndex = Array.isArray(args[1]) ? 2 : 1;
return Reflect.apply(originalSpawn, this, injectOptionsEnv(args, optionIndex, state.proxyUrl));
};
}
function wrapExec(originalExec) {
return function headroomExec(...args) {
const state = getState();
if (!state) {
return Reflect.apply(originalExec, this, args);
}
return Reflect.apply(originalExec, this, injectOptionsEnv(args, 1, state.proxyUrl));
};
}
function wrapExecFile(originalExecFile) {
return function headroomExecFile(...args) {
const state = getState();
if (!state) {
return Reflect.apply(originalExecFile, this, args);
}
const optionIndex = Array.isArray(args[1]) ? 2 : 1;
return Reflect.apply(originalExecFile, this, injectOptionsEnv(args, optionIndex, state.proxyUrl));
};
}
function wrapFork(originalFork) {
return function headroomFork(...args) {
const state = getState();
if (!state) {
return Reflect.apply(originalFork, this, args);
}
const optionIndex = Array.isArray(args[1]) ? 2 : 1;
return Reflect.apply(originalFork, this, injectOptionsEnv(args, optionIndex, state.proxyUrl));
};
}
function normalizeProxyUrl(proxyUrl2) {
return new URL(proxyUrl2);
}
function isLoopback(hostname) {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
}
function shouldRoute(url, proxy) {
if (url.protocol !== "http:" && url.protocol !== "https:") {
return false;
}
if (isLoopback(url.hostname)) {
return false;
}
if (url.origin === proxy.origin) {
return false;
}
return true;
}
function routedUrl(upstream, proxy) {
return new URL(`${upstream.pathname}${upstream.search}`, proxy.origin);
}
function normalizedOpenAiProxyPath(pathname) {
if (pathname.endsWith("/chat/completions")) {
return "/v1/chat/completions";
}
if (pathname.endsWith("/responses")) {
return "/v1/responses";
}
return void 0;
}
function routedUrlForOpenCode(upstream, proxy) {
const normalizedPath = normalizedOpenAiProxyPath(upstream.pathname);
if (!normalizedPath) {
return {
url: routedUrl(upstream, proxy),
originalPath: void 0
};
}
return {
url: new URL(`${normalizedPath}${upstream.search}`, proxy.origin),
originalPath: upstream.pathname
};
}
function requestUrl(input) {
if (input instanceof Request) {
return new URL(input.url);
}
if (input instanceof URL) {
return input;
}
return new URL(String(input));
}
function mergeFetchHeaders(input, init, upstream, originalPath = void 0) {
const headers = new Headers(input instanceof Request ? input.headers : void 0);
if (init?.headers) {
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
}
if (upstream) {
headers.set(BASE_URL_HEADER, upstream.origin);
headers.delete("host");
}
if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath);
}
return headers;
}
function withRoutedFetchInput(input, init, proxy) {
const upstream = requestUrl(input);
if (!shouldRoute(upstream, proxy)) {
return [input, init];
}
const { url: nextUrl, originalPath } = routedUrlForOpenCode(upstream, proxy);
const nextInit = {
...init,
headers: mergeFetchHeaders(input, init, upstream, originalPath)
};
if (input instanceof Request) {
return [new Request(nextUrl, input), nextInit];
}
return [nextUrl, nextInit];
}
function splitNodeArgs(args) {
const callback = typeof args.at(-1) === "function" ? args.at(-1) : void 0;
const withoutCallback = callback ? args.slice(0, -1) : args;
const [first, second] = withoutCallback;
const options = typeof second === "object" && second !== null ? { ...second } : {};
if (first instanceof URL) {
return { url: first, options, callback };
}
if (typeof first === "string") {
try {
return { url: new URL(first), options, callback };
} catch {
return { options, callback };
}
}
if (typeof first === "object" && first !== null) {
const requestOptions = { ...first, ...options };
return { url: urlFromRequestOptions(requestOptions), options: requestOptions, callback };
}
return { options, callback };
}
function urlFromRequestOptions(options) {
const protocol = String(options.protocol ?? "http:");
if (protocol !== "http:" && protocol !== "https:") {
return void 0;
}
const hostValue = options.hostname ?? options.host;
if (!hostValue) {
return void 0;
}
const hostname = String(hostValue).replace(/:\d+$/, "");
const port = options.port ? `:${String(options.port)}` : "";
const path = String(options.path ?? "/");
try {
return new URL(`${protocol}//${hostname}${port}${path}`);
} catch {
return void 0;
}
}
function headersForNodeRequest(options, upstream, originalPath) {
const headers = new Headers(options.headers);
headers.set(BASE_URL_HEADER, upstream.origin);
if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath);
}
headers.delete("host");
const result = {};
headers.forEach((value, key) => {
result[key] = value;
});
return result;
}
function routedNodeOptions(parts, proxy) {
if (!parts.url || !shouldRoute(parts.url, proxy)) {
return void 0;
}
const { url: nextUrl, originalPath } = routedUrlForOpenCode(parts.url, proxy);
const {
agent: _agent,
auth: _auth,
createConnection: _createConnection,
defaultPort: _defaultPort,
family: _family,
headers: _headers,
host: _host,
hostname: _hostname,
href: _href,
lookup: _lookup,
path: _path,
pathname: _pathname,
port: _port,
protocol: _protocol,
search: _search,
servername: _servername,
setHost: _setHost,
...rest
} = parts.options;
return {
...rest,
protocol: nextUrl.protocol,
hostname: nextUrl.hostname,
port: nextUrl.port || void 0,
path: `${nextUrl.pathname}${nextUrl.search}`,
headers: headersForNodeRequest(parts.options, parts.url, originalPath)
};
}
function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest) {
return function headroomRequest(...args) {
const state = getState();
if (!state) {
return Reflect.apply(originalRequest, this, args);
}
const proxy = normalizeProxyUrl(state.proxyUrl);
const parts = splitNodeArgs(args);
const nextOptions = routedNodeOptions(parts, proxy);
if (!nextOptions) {
return Reflect.apply(originalRequest, this, args);
}
const targetRequest = proxy.protocol === "https:" ? originalHttpsRequest : originalHttpRequest;
const nextArgs = parts.callback ? [nextOptions, parts.callback] : [nextOptions];
return Reflect.apply(targetRequest, this, nextArgs);
};
}
function wrapGet(request) {
return function headroomGet(...args) {
const req = Reflect.apply(request, this, args);
req.end();
return req;
};
}
function wrapHttp2Connect(originalConnect) {
return function headroomHttp2Connect(authority, ...args) {
const state = getState();
if (state) {
const proxy = normalizeProxyUrl(state.proxyUrl);
const upstream = authority instanceof URL ? authority : new URL(String(authority));
if (shouldRoute(upstream, proxy)) {
throw new Error(
`Headroom OpenCode wrap blocked direct HTTP/2 connection to ${upstream.origin}. Use fetch, http, or https so traffic can be routed through Headroom.`
);
}
}
return Reflect.apply(originalConnect, this, [authority, ...args]);
};
}
function installHeadroomTransport(options) {
const existing = getState();
if (existing) {
existing.refs += 1;
existing.proxyUrl = options.proxyUrl;
existing.debug = Boolean(options.debug);
installProcessEnv(options.proxyUrl);
return () => uninstallHeadroomTransport();
}
const state = {
refs: 1,
proxyUrl: options.proxyUrl,
debug: Boolean(options.debug),
originalFetch: globalThis.fetch,
originalHttpRequest: http.request,
originalHttpGet: http.get,
originalHttpsRequest: https.request,
originalHttpsGet: https.get,
originalHttp2Connect: http2.connect,
originalChildSpawn: childProcess.spawn,
originalChildExec: childProcess.exec,
originalChildExecFile: childProcess.execFile,
originalChildFork: childProcess.fork
};
setState(state);
installProcessEnv(options.proxyUrl);
globalThis.fetch = async (...args) => {
const current = getState();
if (!current) {
return state.originalFetch(...args);
}
const proxy = normalizeProxyUrl(current.proxyUrl);
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy);
return state.originalFetch(nextInput, nextInit);
};
http.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpRequest);
https.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpsRequest);
http.get = wrapGet(http.request);
https.get = wrapGet(https.request);
http2.connect = wrapHttp2Connect(state.originalHttp2Connect);
childProcess.spawn = wrapSpawn(state.originalChildSpawn);
childProcess.exec = wrapExec(state.originalChildExec);
childProcess.execFile = wrapExecFile(state.originalChildExecFile);
childProcess.fork = wrapFork(state.originalChildFork);
syncBuiltinESMExports();
return () => uninstallHeadroomTransport();
}
function uninstallHeadroomTransport() {
const state = getState();
if (!state) {
return;
}
state.refs -= 1;
if (state.refs > 0) {
return;
}
globalThis.fetch = state.originalFetch;
http.request = state.originalHttpRequest;
http.get = state.originalHttpGet;
https.request = state.originalHttpsRequest;
https.get = state.originalHttpsGet;
http2.connect = state.originalHttp2Connect;
childProcess.spawn = state.originalChildSpawn;
childProcess.exec = state.originalChildExec;
childProcess.execFile = state.originalChildExecFile;
childProcess.fork = state.originalChildFork;
syncBuiltinESMExports();
setState(void 0);
}
// src/hook-shim.ts
var proxyUrl = process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
if (!proxyUrl) {
throw new Error(
"Headroom OpenCode transport shim loaded without HEADROOM_OPENCODE_TRANSPORT_PROXY_URL"
);
}
installHeadroomTransport({ proxyUrl });
+26
View File
@@ -0,0 +1,26 @@
// Self-contained Node `--import` loader for the OpenCode transport, built
// standalone (see tsup.standalone.config.ts) and shipped inside the Python wheel
// at headroom/providers/opencode/hook-shim/handler.js.
//
// transport.ts wraps `fetch`/`http`/`https` in the plugin's own process, but a
// spawned Node child (an `npx` MCP server, `tokensave serve`, ...) is a fresh
// process, so its traffic is only routed if this loader runs at that child's
// startup via NODE_OPTIONS=--import. `shimImportSpecifier()` in transport.ts
// resolves `../hook-shim/handler.js` next to the loaded entry, which is this
// file in a wheel install.
//
// The checkout uses plugins/opencode/hook-shim/handler.js instead, which imports
// the non-bundled `../dist/index.js`; pip installs have no node_modules, so this
// variant inlines the transport. Without it shipped, the loader path did not
// exist, so child-process routing was silently disabled for wheel installs
// (before #2806 it crashed every Node child with ERR_MODULE_NOT_FOUND) (#2850).
import { installHeadroomTransport } from "./transport.js";
const proxyUrl = process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
if (!proxyUrl) {
throw new Error(
"Headroom OpenCode transport shim loaded without HEADROOM_OPENCODE_TRANSPORT_PROXY_URL",
);
}
installHeadroomTransport({ proxyUrl });
+6 -1
View File
@@ -6,7 +6,12 @@ import { defineConfig } from "tsup";
// with node_modules present; pip installs have no node_modules, so this
// variant bundles every dependency into a single loadable file.
export default defineConfig({
entry: { "entry.opencode": "src/entry.opencode.ts" },
// `hook-shim/handler` is the self-contained Node `--import` loader shipped
// alongside the entry so spawned Node children route their traffic too (#2850).
entry: {
"entry.opencode": "src/entry.opencode.ts",
"hook-shim/handler": "src/hook-shim.ts",
},
outDir: "dist-standalone",
format: ["esm"],
splitting: false,
@@ -31,6 +31,23 @@ def test_packaged_bundle_is_committed_and_self_contained() -> None:
assert 'from "@opencode-ai/plugin"' not in text
def test_hook_shim_is_committed_next_to_the_entry_bundle() -> None:
# transport.ts resolves `../hook-shim/handler.js` next to the loaded entry,
# so the shim must ship as a sibling of _dist/. Without it, Node children
# spawned under `headroom wrap opencode` lose fetch/http routing (the
# existsSync guard skips injection), and before that guard they crashed with
# ERR_MODULE_NOT_FOUND on every Node MCP (#2850, #2806).
shim = _PACKAGED.resolve().parent.parent / "hook-shim" / "handler.js"
assert shim.is_file(), "committed wheel hook-shim missing - run npm run build:standalone"
text = shim.read_text(encoding="utf-8")
assert len(text) > 5_000, "hook-shim suspiciously small - not the standalone build?"
# Self-contained standalone build, not the checkout dev shim (which imports
# the non-bundled ../dist/index.js that site-packages has no node_modules for).
assert 'from "../dist/index.js"' not in text
assert "installHeadroomTransport" in text
assert "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL" in text
def test_plugin_path_env_override_wins(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
override = tmp_path / "custom.js"
override.write_text("// plugin")