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:
@@ -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 });
|
||||
@@ -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,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")
|
||||
|
||||
Reference in New Issue
Block a user