From 702dbc5902ff184a7c20178958a811beb9c78fa3 Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Tue, 11 Aug 2026 21:40:38 +0530 Subject: [PATCH] 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. --- .github/workflows/opencode-plugin.yml | 5 + .../providers/opencode/hook-shim/handler.js | 390 ++++++++++++++++++ plugins/opencode/src/hook-shim.ts | 26 ++ plugins/opencode/tsup.standalone.config.ts | 7 +- tests/test_providers_opencode_plugin_path.py | 17 + 5 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 headroom/providers/opencode/hook-shim/handler.js create mode 100644 plugins/opencode/src/hook-shim.ts diff --git a/.github/workflows/opencode-plugin.yml b/.github/workflows/opencode-plugin.yml index 4fd8103ab..4e81e6597 100644 --- a/.github/workflows/opencode-plugin.yml +++ b/.github/workflows/opencode-plugin.yml @@ -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; } diff --git a/headroom/providers/opencode/hook-shim/handler.js b/headroom/providers/opencode/hook-shim/handler.js new file mode 100644 index 000000000..1dbfba992 --- /dev/null +++ b/headroom/providers/opencode/hook-shim/handler.js @@ -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 }); diff --git a/plugins/opencode/src/hook-shim.ts b/plugins/opencode/src/hook-shim.ts new file mode 100644 index 000000000..de15cd01d --- /dev/null +++ b/plugins/opencode/src/hook-shim.ts @@ -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 }); diff --git a/plugins/opencode/tsup.standalone.config.ts b/plugins/opencode/tsup.standalone.config.ts index 3045a98c3..1391e4024 100644 --- a/plugins/opencode/tsup.standalone.config.ts +++ b/plugins/opencode/tsup.standalone.config.ts @@ -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, diff --git a/tests/test_providers_opencode_plugin_path.py b/tests/test_providers_opencode_plugin_path.py index 98e8dd7eb..c6739bbe1 100644 --- a/tests/test_providers_opencode_plugin_path.py +++ b/tests/test_providers_opencode_plugin_path.py @@ -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")