headroom/tests/test_providers_opencode_install.py
Rudimar Ronsoni b4571cc346
feat: headroom wrap opencode / unwrap opencode CLI (#1105)
## Summary

This PR implements transparent `headroom wrap opencode` support without
asking users to edit OpenCode provider URLs, choose an extra CLI flag,
or maintain a static provider list.

The wrapper now lives at the runtime transport boundary: OpenCode keeps
its user/provider config, while Headroom intercepts outbound provider
traffic in-process and routes it through the local Headroom proxy.

## What changed

### Transparent OpenCode wrapping

- `headroom wrap opencode` injects the `headroom-opencode` plugin
through `OPENCODE_CONFIG_CONTENT`.
- Existing OpenCode provider URLs are preserved. We do not rewrite user
config URLs to point at Headroom.
- Existing `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` env vars are
preserved.
- Local OpenCode traffic, localhost traffic, and Headroom proxy traffic
bypass the shim to avoid loops.

### Runtime transport interception

- Added an OpenCode plugin transport shim that wraps:
  - `globalThis.fetch`
  - `http.request` / `http.get`
  - `https.request` / `https.get`
- External provider calls are routed to the local Headroom proxy.
- The original upstream origin is passed through `x-headroom-base-url`,
so the proxy can forward to the real provider without changing OpenCode
config.
- External `http2.connect` is blocked loudly instead of allowing direct
provider traffic to leak outside Headroom.

### Live provider additions

Provider coverage is no longer based on a static config scan. Because
routing happens at outbound request time, providers added mid-session
are routed through Headroom automatically as long as they use the
covered Node transport paths.

### Subagent and child-process coverage

- The parent OpenCode plugin sets a packaged Node preload shim through
`NODE_OPTIONS=--import=.../hook-shim/handler.js`.
- The transport shim patches `child_process.spawn`, `exec`, `execFile`,
and `fork` so child Node processes receive the Headroom preload even
when OpenCode passes a custom `env`.
- The child-process shim fails closed if it loads without
`HEADROOM_OPENCODE_TRANSPORT_PROXY_URL`.
- This closes the subagent leak path where a child Node process could
otherwise start without Headroom transport interception.

## Why this goes beyond PR #1089

PR #1089 improves OpenCode provider registration, but it still focuses
on provider config shape. This PR moves the enforcement boundary to
runtime transport interception.

This PR goes further because:

- No provider URL rewriting is required.
- New providers added mid-session are covered automatically.
- Subagents and child Node processes inherit the Headroom transport
shim.
- Direct external HTTP/2 paths fail loudly instead of leaking.
- The wrap remains transparent to the user's OpenCode provider config.
- The wrapper is fail-closed for unsupported child-process preload
state.

## Additional robustness fixes

While validating the change in Docker, the full Python suite exposed
unrelated Linux/container robustness issues. These are fixed in this PR
so the suite is green:

- Binary cache handling now treats cache paths under a non-writable
existing parent as unavailable, including when tests run as root in
Docker.
- `release_version.py` honors `MANUAL_VER` before git calls so direct
script execution works outside a `.git` checkout.
- Test logger isolation now resets relevant Headroom child loggers so
proxy logging setup cannot poison later `caplog` tests.
- The scanner missing-path test now uses a guaranteed missing `tmp_path`
child instead of relying on `/nonexistent/path`.

## Validation

All implementation validation was run inside Docker.

- Full Python suite from a fresh Docker copy: `6605 passed, 523
skipped`.
- Ruff on changed Python/OpenCode paths: passed.
- OpenCode plugin typecheck: passed.
- OpenCode plugin tests: `9 passed`.
- OpenCode plugin build: passed.
- Hook shim preload smoke test: passed.

## Notes

This PR intentionally does not add a CLI option. `headroom wrap
opencode` means full wrap. Either Headroom wraps OpenCode transparently,
or the path fails loudly instead of silently leaking provider traffic.

---------

Co-authored-by: Rudimar Ronsoni <6081613+rudironsoni@users.noreply.github.com>
2026-06-22 11:07:12 -05:00

112 lines
3.5 KiB
Python

"""Tests for OpenCode install-time helpers."""
from __future__ import annotations
from pathlib import Path
import pytest
from headroom.install.models import ConfigScope, DeploymentManifest
from headroom.providers.opencode.install import (
apply_provider_scope,
build_install_env,
revert_provider_scope,
)
def _manifest(port: int = 8787) -> DeploymentManifest:
return DeploymentManifest(
profile="test",
preset="persistent-task",
runtime_kind="python",
supervisor_kind="none",
scope=ConfigScope.PROVIDER.value,
provider_mode="auto",
targets=[],
port=port,
host="127.0.0.1",
backend="anthropic",
proxy_args=[],
base_env={},
tool_envs={},
)
def test_build_install_env() -> None:
"""build_install_env leaves OpenCode provider env vars untouched."""
env = build_install_env(port=8787, backend="anthropic")
assert env == {}
def test_apply_provider_scope_creates_config(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""apply_provider_scope creates the opencode config with headroom provider."""
home = str(tmp_path)
monkeypatch.setenv("HOME", home)
monkeypatch.setenv("USERPROFILE", home)
monkeypatch.delenv("OPENCODE_HOME", raising=False)
monkeypatch.delenv("OPENCODE_CONFIG", raising=False)
manifest = _manifest(port=8787)
mutation = apply_provider_scope(manifest)
assert mutation is not None
assert mutation.target == "opencode"
assert mutation.kind == "json-block"
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
assert config_file.exists()
import json
config = json.loads(config_file.read_text())
assert config["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:8787/v1"
def test_apply_provider_scope_skips_when_scope_is_not_provider(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""apply_provider_scope returns None when scope is not PROVIDER."""
manifest = _manifest()
manifest.scope = ConfigScope.USER.value
result = apply_provider_scope(manifest)
assert result is None
def test_revert_provider_scope_restores_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""revert_provider_scope strips the Headroom block from the config."""
home = str(tmp_path)
monkeypatch.setenv("HOME", home)
monkeypatch.setenv("USERPROFILE", home)
monkeypatch.delenv("OPENCODE_HOME", raising=False)
monkeypatch.delenv("OPENCODE_CONFIG", raising=False)
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
config_file.parent.mkdir(parents=True, exist_ok=True)
config_file.write_text('{"model": "openai/gpt-4o"}')
from headroom.install.models import ManagedMutation
mutation = ManagedMutation(
target="opencode",
kind="json-block",
path=str(config_file),
)
manifest = _manifest()
revert_provider_scope(mutation, manifest)
assert config_file.exists()
assert config_file.read_text().strip() == '{"model": "openai/gpt-4o"}'
def test_revert_provider_scope_noop_when_file_missing(
tmp_path: Path,
) -> None:
"""revert_provider_scope is a safe no-op when the config file is gone."""
from headroom.install.models import ManagedMutation
mutation = ManagedMutation(
target="opencode",
kind="json-block",
path=str(tmp_path / "nonexistent.json"),
)
manifest = _manifest()
revert_provider_scope(mutation, manifest)
# Should not raise