mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## 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.
67 lines
3.1 KiB
Python
67 lines
3.1 KiB
Python
"""Resolution of the OpenCode transport-plugin path across install layouts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import headroom.providers.opencode.runtime as oc_runtime
|
|
from headroom.providers.opencode.runtime import headroom_opencode_plugin_path
|
|
|
|
_PACKAGED = Path(oc_runtime.__file__).resolve().parent / "_dist" / "entry.opencode.js"
|
|
_REPO_BUILD = (
|
|
Path(oc_runtime.__file__).resolve().parents[3]
|
|
/ "plugins"
|
|
/ "opencode"
|
|
/ "dist"
|
|
/ "entry.opencode.js"
|
|
)
|
|
|
|
|
|
def test_packaged_bundle_is_committed_and_self_contained() -> None:
|
|
# The wheel picks this file up via maturin's python-source packaging; if
|
|
# it goes missing, pip installs silently lose all-provider routing again.
|
|
assert _PACKAGED.is_file(), "committed wheel bundle missing - run npm run build:standalone"
|
|
text = _PACKAGED.read_text(encoding="utf-8")
|
|
assert len(text) > 10_000, "bundle suspiciously small - not the standalone build?"
|
|
# Self-contained: no bare npm imports; node builtins are the only imports
|
|
# allowed (site-packages has no node_modules to resolve anything else).
|
|
assert 'from "headroom-ai"' not in text
|
|
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")
|
|
monkeypatch.setenv("HEADROOM_OPENCODE_PLUGIN_PATH", str(override))
|
|
assert headroom_opencode_plugin_path() == str(override)
|
|
|
|
|
|
def test_plugin_path_falls_back_to_packaged_bundle(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("HEADROOM_OPENCODE_PLUGIN_PATH", raising=False)
|
|
resolved = headroom_opencode_plugin_path()
|
|
assert resolved is not None
|
|
# In a repo checkout with a built plugins/opencode/dist the repo build wins
|
|
# (fresher during development); otherwise the packaged bundle must resolve.
|
|
if _REPO_BUILD.is_file():
|
|
assert resolved == str(_REPO_BUILD)
|
|
else:
|
|
assert resolved == str(_PACKAGED)
|