headroom/tests/test_providers_opencode_plugin_path.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

68 lines
3.1 KiB
Python
Raw Permalink Normal View History

feat(opencode): ship the transport plugin in pip installs (#2601) ## Description The OpenCode transport plugin - the piece that gives `wrap opencode` all-provider routing by tagging each request with `x-headroom-base-url` - only exists in repo checkouts today. `headroom_opencode_plugin_path()` resolves `plugins/opencode/dist/entry.opencode.js`, which pip wheels do not ship, so every pip install silently degrades to the two-provider (anthropic/openai) baseURL fallback. The function's own docstring documents the gap ("a pip-only install that does not ship `plugins/`"). Shipping the existing build output is not enough: the regular tsup build leaves `headroom-ai` and `@opencode-ai/plugin` as bare external imports, which only resolve next to the checkout's `node_modules`. Copied into site-packages, the file fails to load. This PR ships a self-contained bundle inside the wheel instead. Closes # ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] 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/tsup.standalone.config.ts` + `npm run build:standalone`: a second build of the loader entry with `noExternal: [/.*/]` and `splitting: false` - a single self-contained file whose only imports are node builtins. - `headroom/providers/opencode/_dist/entry.opencode.js`: the committed standalone bundle (452 KB). It sits inside the package directory, so maturin's `python-source = "."` packaging picks it up into the wheel with no build-system changes. - `headroom_opencode_plugin_path()`: falls back to the packaged bundle. Precedence otherwise unchanged: `HEADROOM_OPENCODE_PLUGIN_PATH` env override, then a repo-checkout build (fresher during development), then the packaged bundle. - CI (`opencode-plugin.yml`): rebuilds the standalone bundle and fails the run if the committed artifact drifted from source, with a one-line fix instruction; workflow path triggers extended to `headroom/providers/opencode/_dist/**`. - `tests/test_providers_opencode_plugin_path.py`: packaged bundle exists and is self-contained (no bare npm imports), env override wins, fallback resolution order. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run --frozen --extra dev pytest tests/test_providers_opencode_plugin_path.py \ tests/test_providers_opencode_config.py tests/test_providers_opencode_install.py ============================== 49 passed in 0.31s ============================== $ uvx ruff check headroom/providers/opencode/runtime.py tests/test_providers_opencode_plugin_path.py All checks passed! $ uvx ruff format --check headroom/providers/opencode/runtime.py tests/test_providers_opencode_plugin_path.py 2 files already formatted $ uv run --frozen --extra dev mypy headroom/providers/opencode/runtime.py Success: no issues found in 1 source file $ cd plugins/opencode && npm run build:standalone ESM dist-standalone/entry.opencode.js 452.28 KB ESM Build success in 28ms ``` ## Real Behavior Proof - Environment: macOS 15 (arm64), opencode 1.18.5 (Homebrew), node 22 / npm 10, isolated `XDG_*` dirs so no real user config was touched. - Exact command / steps: 1. `npm run build:standalone` in `plugins/opencode`. 2. Started a local header-logging HTTP listener on `127.0.0.1:9977` (stands in for the proxy; logs method, path, headers, returns 401). 3. Registered the standalone bundle by absolute path in a scratch `opencode.json` (`"plugin": ["<abs>/dist-standalone/entry.opencode.js"]`) with a `google` provider entry and a fake API key. Note: the bundle's directory has **no** `node_modules` - this is exactly the site-packages situation. 4. `HEADROOM_PROXY_URL=http://127.0.0.1:9977 opencode run -m google/gemini-2.5-flash "say hi"`. - Observed result: the listener received `POST /v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse` with `User-Agent: opencode/1.18.5 ...` - i.e. the plugin loaded standalone and rerouted a provider that the baseURL fallback cannot cover (native Gemini wire format) to the proxy URL from `HEADROOM_PROXY_URL`. - Not tested: Windows path resolution (pure `pathlib`, no platform branches); wheel-build byte-determinism of the tsup output across OSes (the CI drift check will surface it on the first divergent build). ## 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) ## Screenshots (if applicable) Not applicable - CLI/packaging change. ## Additional Notes - Documentation checklist item: unchecked because the only doc surface I found is the `headroom_opencode_plugin_path()` docstring, which this PR rewrites to describe the three-step resolution order. Happy to add a line to `docs/content/docs/` if there is a preferred page. - A committed build artifact is not free: the CI drift check keeps it honest, and the byte-compare relies on tsup/esbuild determinism under `npm ci` (pinned lockfile). If you'd rather avoid the committed artifact entirely, the alternative is publishing `headroom-opencode` to npm (its `package.json` is publish-ready) and registering the plugin by package name - happy to rework in that direction; the wheel-bundled path has the advantage of version-locking the plugin to the backend it ships with. - Downstream motivation: Headroom Desktop manages a long-lived shared proxy (no `wrap` launcher) and wants to register this plugin from the installed wheel path so OpenCode users get all-provider routing there too. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:40:43 +02:00
"""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
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.
2026-08-11 21:40:38 +05:30
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
feat(opencode): ship the transport plugin in pip installs (#2601) ## Description The OpenCode transport plugin - the piece that gives `wrap opencode` all-provider routing by tagging each request with `x-headroom-base-url` - only exists in repo checkouts today. `headroom_opencode_plugin_path()` resolves `plugins/opencode/dist/entry.opencode.js`, which pip wheels do not ship, so every pip install silently degrades to the two-provider (anthropic/openai) baseURL fallback. The function's own docstring documents the gap ("a pip-only install that does not ship `plugins/`"). Shipping the existing build output is not enough: the regular tsup build leaves `headroom-ai` and `@opencode-ai/plugin` as bare external imports, which only resolve next to the checkout's `node_modules`. Copied into site-packages, the file fails to load. This PR ships a self-contained bundle inside the wheel instead. Closes # ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] 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/tsup.standalone.config.ts` + `npm run build:standalone`: a second build of the loader entry with `noExternal: [/.*/]` and `splitting: false` - a single self-contained file whose only imports are node builtins. - `headroom/providers/opencode/_dist/entry.opencode.js`: the committed standalone bundle (452 KB). It sits inside the package directory, so maturin's `python-source = "."` packaging picks it up into the wheel with no build-system changes. - `headroom_opencode_plugin_path()`: falls back to the packaged bundle. Precedence otherwise unchanged: `HEADROOM_OPENCODE_PLUGIN_PATH` env override, then a repo-checkout build (fresher during development), then the packaged bundle. - CI (`opencode-plugin.yml`): rebuilds the standalone bundle and fails the run if the committed artifact drifted from source, with a one-line fix instruction; workflow path triggers extended to `headroom/providers/opencode/_dist/**`. - `tests/test_providers_opencode_plugin_path.py`: packaged bundle exists and is self-contained (no bare npm imports), env override wins, fallback resolution order. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run --frozen --extra dev pytest tests/test_providers_opencode_plugin_path.py \ tests/test_providers_opencode_config.py tests/test_providers_opencode_install.py ============================== 49 passed in 0.31s ============================== $ uvx ruff check headroom/providers/opencode/runtime.py tests/test_providers_opencode_plugin_path.py All checks passed! $ uvx ruff format --check headroom/providers/opencode/runtime.py tests/test_providers_opencode_plugin_path.py 2 files already formatted $ uv run --frozen --extra dev mypy headroom/providers/opencode/runtime.py Success: no issues found in 1 source file $ cd plugins/opencode && npm run build:standalone ESM dist-standalone/entry.opencode.js 452.28 KB ESM Build success in 28ms ``` ## Real Behavior Proof - Environment: macOS 15 (arm64), opencode 1.18.5 (Homebrew), node 22 / npm 10, isolated `XDG_*` dirs so no real user config was touched. - Exact command / steps: 1. `npm run build:standalone` in `plugins/opencode`. 2. Started a local header-logging HTTP listener on `127.0.0.1:9977` (stands in for the proxy; logs method, path, headers, returns 401). 3. Registered the standalone bundle by absolute path in a scratch `opencode.json` (`"plugin": ["<abs>/dist-standalone/entry.opencode.js"]`) with a `google` provider entry and a fake API key. Note: the bundle's directory has **no** `node_modules` - this is exactly the site-packages situation. 4. `HEADROOM_PROXY_URL=http://127.0.0.1:9977 opencode run -m google/gemini-2.5-flash "say hi"`. - Observed result: the listener received `POST /v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse` with `User-Agent: opencode/1.18.5 ...` - i.e. the plugin loaded standalone and rerouted a provider that the baseURL fallback cannot cover (native Gemini wire format) to the proxy URL from `HEADROOM_PROXY_URL`. - Not tested: Windows path resolution (pure `pathlib`, no platform branches); wheel-build byte-determinism of the tsup output across OSes (the CI drift check will surface it on the first divergent build). ## 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) ## Screenshots (if applicable) Not applicable - CLI/packaging change. ## Additional Notes - Documentation checklist item: unchecked because the only doc surface I found is the `headroom_opencode_plugin_path()` docstring, which this PR rewrites to describe the three-step resolution order. Happy to add a line to `docs/content/docs/` if there is a preferred page. - A committed build artifact is not free: the CI drift check keeps it honest, and the byte-compare relies on tsup/esbuild determinism under `npm ci` (pinned lockfile). If you'd rather avoid the committed artifact entirely, the alternative is publishing `headroom-opencode` to npm (its `package.json` is publish-ready) and registering the plugin by package name - happy to rework in that direction; the wheel-bundled path has the advantage of version-locking the plugin to the backend it ships with. - Downstream motivation: Headroom Desktop manages a long-lived shared proxy (no `wrap` launcher) and wants to register this plugin from the installed wheel path so OpenCode users get all-provider routing there too. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:40:43 +02:00
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)