Skip to content

feat(opencode): native OpenCode support — provider discovery, wrap/unwrap, MCP, install#1089

Closed
hareeshkar wants to merge 5 commits into
headroomlabs-ai:mainfrom
hareeshkar:feat/opencode-native-support
Closed

feat(opencode): native OpenCode support — provider discovery, wrap/unwrap, MCP, install#1089
hareeshkar wants to merge 5 commits into
headroomlabs-ai:mainfrom
hareeshkar:feat/opencode-native-support

Conversation

@hareeshkar

@hareeshkar hareeshkar commented Jun 17, 2026

Copy link
Copy Markdown

Description

Adds native OpenCode support to Headroom: `headroom wrap opencode` and `headroom unwrap opencode` with transparent fetch-level interception, full provider discovery, MCP registration, install infrastructure, and centralized port management.

Why: Headroom has no OpenCode integration today. Users must manually configure `headroom proxy` and set `OPENAI_BASE_URL` / `ANTHROPIC_BASE_URL`. This PR makes OpenCode a first-class target and covers all providers — including those added mid-session via `/connect` — without touching the user's on-disk config.

Closes #1193

Type of Change

  • 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)

Architecture: How It Works

headroom wrap opencode
│
├── build_launch_env() injects three env vars:
│   ├── OPENCODE_CONFIG_CONTENT  — known providers at launch (model picker shows real names)
│   ├── HEADROOM_PROXY_URL       — http://127.0.0.1:{port}
│   └── NODE_OPTIONS             — --import=/path/to/shim.mjs
│
└── OpenCode process starts
    ├── shim.mjs loaded via NODE_OPTIONS (before any userland code — ESM preload)
    │   └── patches globalThis.fetch (the only transport Vercel AI SDK uses)
    │       ├── External AI call? → set x-headroom-base-url: <origin>
    │       │                     → rewrite URL to HEADROOM_PROXY_URL
    │       │                     → retry 3× at 75ms if proxy not ready (cold-start grace)
    │       ├── Loopback / localhost? → pass through (no proxy→proxy loops)
    │       └── Already proxied? → pass through
    │
    └── Headroom proxy receives request
        └── x-headroom-base-url → normalize_upstream_base() → forward to real provider
            (proxy_routes.py lines 428, 482, 585, 884 — no proxy changes needed)

Mid-session /connect provider: User adds a new provider mid-session. The shim already intercepts all outbound HTTPS calls — no config change, no restart required.

Known providers at launch: Still injected via OPENCODE_CONFIG_CONTENT overlay so OpenCode's model picker shows real provider names (deepseek, opencode-go) not a generic wrapper.

Comparison to Transport-Level Interception Alternatives

Metric Our approach Patching http/https/http2
Shim size ~80-line ESM ~400+ lines TypeScript
Transport patches globalThis.fetch only fetch + http + https + http2 + child_process
Breaks git/npm/native http? No Yes (http2 blocked entirely)
Dynamic provider discovery From auth.json (14+ upstreams) Hardcoded model list
Provider names in model picker Real names (deepseek, opencode-go) Generic wrapper
Cold-start robustness 3× retry at 75ms (OpenChamber pattern) No retry
Mid-session /connect providers ✅ Covered ✅ Covered
OpenCode Go (opencode-go)
Port management allocate_ports() bind/release
MCP registrar ✅ JSONC-aware
Install infrastructure ToolTarget.OPENCODE
Test coverage 110 tests

Changes Made

  • Fetch shim (headroom/providers/opencode/shim.mjs, ~80 lines): ESM module loaded via NODE_OPTIONS=--import. Patches globalThis.fetch to route external AI calls through Headroom proxy with x-headroom-base-url header. Cold-start retry (3× at 75ms) mirrors OpenChamber's READINESS_HOLD_POLL_MS pattern.
  • Provider discovery (headroom/providers/opencode/runtime.py): build_provider_upstream_map() discovers providers from 3 sources — opencode.json custom providers, auth.json API entries, and 14-entry known upstreams table. Supports OpenCode Zen (opencode.ai/zen/v1) and OpenCode Go (opencode.ai/zen/go/v1). Filters OAuth providers and localhost URLs.
  • Launch env (headroom/providers/opencode/runtime.py): build_launch_env() injects HEADROOM_PROXY_URL and NODE_OPTIONS=--import=<shim.mjs> in single-proxy mode (default). Uses importlib.resources.files() to locate the bundled shim.
  • Config overlay (headroom/providers/opencode/runtime.py): build_overlay() generates OPENCODE_CONFIG_CONTENT JSON with per-provider routing. In single mode all providers share one port; x-headroom-base-url carries upstream identity.
  • Port management (headroom/port_utils.py): Centralized DEFAULT_PROXY_PORT, allocate_ports() (bind-and-release, no TOCTOU race), is_headroom_proxy() (HTTP /healthz probe), find_opencode_ports() (multi-port discovery for unwrap).
  • Proxy route changes (headroom/providers/proxy_routes.py): 3 route handlers check x-headroom-base-url/v1/messages (Anthropic), /v1/chat/completions (OpenAI), /v1beta/models/{model}:generateContent (Gemini). Catch-all passthrough also normalized.
  • URL normalization (headroom/proxy/helpers.py): normalize_upstream_base() strips /v1 and /v1beta suffixes to prevent path doubling.
  • MCP registrar (headroom/mcp_registry/opencode.py): OpencodeRegistrar(MCPRegistrar) — detect/get/register/unregister with JSONC-aware parsing.
  • Install infrastructure (headroom/install/): ToolTarget.OPENCODE enum, opencode_config_path(), provider scope in SUPPORTED_TARGETS, wired into _ENV_BUILDERS and _PROVIDER_SCOPE_HANDLERS.
  • CLI commands (headroom/cli/wrap.py): wrap opencode and unwrap opencode with all standard flags.
  • Documentation (docs/content/docs/opencode.mdx): Integration guide covering provider discovery, OpenCode Go, routing modes, architecture, CLI options, troubleshooting.
  • Docker e2e (e2e/wrap/): OpenCode binary in Dockerfile, verify_opencode_wrap() with 12 assertions.
  • Telemetry (headroom/telemetry/context.py): opencode added to _KNOWN_WRAP_AGENTS.

Testing

  • Unit tests pass (pytest)
  • Linting passes (ruff check .)
  • Type checking passes (mypy headroom) — pre-existing issues in codebase
  • New tests added for new functionality
  • Manual testing performed

Test Output

$ .venv/bin/python -m pytest tests/test_port_utils.py tests/test_providers/test_opencode.py \
    tests/test_providers/test_opencode_config.py tests/test_mcp_registry/test_opencode_mcp.py \
    tests/test_cli/test_wrap_opencode.py -q

110 passed, 1 warning in 2.84s

$ ruff check headroom/providers/opencode/ headroom/cli/wrap.py
All checks passed!

New shim tests:

test_single_mode_sets_headroom_proxy_url            PASSED
test_single_mode_sets_node_options_with_import_flag PASSED
test_single_mode_preserves_existing_node_options    PASSED
test_multi_mode_does_not_set_shim_env_vars          PASSED
test_shim_file_exists                               PASSED

Real Behavior Proof

  • Environment: macOS Darwin 24.6.0, Python 3.10.20 (.venv), Node.js 22, OpenCode 1.14.41 at ~/.opencode/bin/opencode. Real auth.json with 10 provider accounts (opencode, opencode-go, deepseek, openai, google, minimax, minimax-coding-plan, minimax-cn-coding-plan, nvidia, github-copilot). Custom opencode.json with mimo provider (@ai-sdk/openai-compatible, baseURL: https://token-plan-sgp.xiaomimimo.com/v1, models: mimo-v2.5-pro, mimo-v2.5).

  • Exact command / steps: (1) Verified provider discovery: build_provider_upstream_map()['deepseek', 'google', 'mimo', 'openai', 'opencode', 'opencode-go'] — 6 providers, github-copilot correctly excluded (OAuth). (2) Verified overlay: build_launch_env(8787, routing_mode='single')OPENCODE_CONFIG_CONTENT with all 6 providers pointing to http://127.0.0.1:8787/v1, each with x-headroom-base-url to real upstream. NODE_OPTIONS=--import=.../shim.mjs, HEADROOM_PROXY_URL=http://127.0.0.1:8787. (3) Started headroom proxy --port 58790. (4) Non-streaming request via curl: curl -X POST http://127.0.0.1:58790/v1/chat/completions -H "x-headroom-base-url: https://token-plan-sgp.xiaomimimo.com/v1" -H "Authorization: Bearer <mimo-key>" -d '{"model":"mimo-v2.5-pro","messages":[{"role":"user","content":"Say hello in exactly 3 words."}],"stream":false}'. (5) Streaming request via curl — same endpoint, "stream":true. (6) Shim e2e: NODE_OPTIONS="--import=shim.mjs" HEADROOM_PROXY_URL="http://127.0.0.1:58790" node -e "const res = await fetch('https://token-plan-sgp.xiaomimimo.com/v1/chat/completions', {method:'POST', headers:{...}, body: JSON.stringify({model:'mimo-v2.5-pro', messages:[{role:'user',content:'What is 1+1?'}], stream:false})}); console.log(await res.json())".

  • Observed result: (4) Non-streaming response from mimo-v2.5-pro through Headroom proxy — HTTP 200, {"choices":[{"message":{"content":"Hello, nice day!","role":"assistant"}}],"model":"mimo-v2.5-pro","usage":{"total_tokens":279}}. (5) Streaming — SSE chunks received with reasoning_content (mimo-v2.5-pro is a reasoning model, shows chain-of-thought in reasoning_content). (6) Shim e2e — fetch('https://token-plan-sgp.xiaomimimo.com/...') intercepted by shim, rewritten to http://127.0.0.1:58790/v1/chat/completions with x-headroom-base-url header, proxy forwarded to real mimo API — HTTP 200, total_tokens: 294, reasoning: "The answer is simply 2". The shim correctly skipped loopback calls and avoided proxy→proxy loops. Provider names (deepseek, opencode-go, mimo) visible in OPENCODE_CONFIG_CONTENT overlay.

  • Not tested: Docker e2e (Docker Desktop daemon not running on dev machine; e2e logic verified directly on host with isolated temp directory — 12/12 assertions passed). Type checking (pre-existing mypy issues in codebase unrelated to this PR). Interactive OpenCode TUI session end-to-end (provider routing verified via Node.js shim test which replicates the exact fetch path OpenCode uses).

Review Readiness

  • I have performed a self-review
  • This PR is ready for human review

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have updated the CHANGELOG.md if applicable

Additional Notes

The fetch shim patches only globalThis.fetch — the single transport the Vercel AI SDK uses. Patching http/https/http2/child_process is unnecessary and breaks unrelated tools (git, npm, native Node http calls). The shim is ~80 lines of plain ESM with no npm dependencies, embedded directly in the Python package via importlib.resources.files().

@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

PR governance

This PR follows the template and is marked ready for human review.

@github-actions github-actions Bot added status: needs author action Pull request body or readiness checklist still needs author updates status: ready for review Pull request body is complete and the author marked it ready for human review and removed status: needs author action Pull request body or readiness checklist still needs author updates status: ready for review Pull request body is complete and the author marked it ready for human review labels Jun 17, 2026
@taylor-shift

Copy link
Copy Markdown

+1, please merge this ASAP

@anthonymq

Copy link
Copy Markdown

Highly anticipated !

@leviitta

Copy link
Copy Markdown

+1, please merge this ASAP

@github-actions github-actions Bot added status: needs author action Pull request body or readiness checklist still needs author updates and removed status: ready for review Pull request body is complete and the author marked it ready for human review labels Jun 18, 2026
@github-actions github-actions Bot added status: ready for review Pull request body is complete and the author marked it ready for human review and removed status: needs author action Pull request body or readiness checklist still needs author updates labels Jun 18, 2026
@vuthanhtrung2010

Copy link
Copy Markdown

+1... Cool PR, I would like to see it merged

@FrankCasanova

Copy link
Copy Markdown

Top 1 priority to merge if u ask me. I'd like to see this merged soon

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is not merge-ready in current GitHub state (mergeStateStatus=UNSTABLE). Please update from current main, resolve any conflicts if present, and rerun/clear required CI before this can be approved.

@codecov

codecov Bot commented Jun 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 23.20261% with 235 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
headroom/providers/opencode/runtime.py 16.26% 103 Missing ⚠️
headroom/providers/opencode/install.py 16.45% 66 Missing ⚠️
headroom/cli/wrap.py 32.63% 64 Missing ⚠️
headroom/memory/traffic_learner.py 0.00% 1 Missing ⚠️
headroom/telemetry/context.py 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@hareeshkar hareeshkar requested a review from JerrettDavis June 19, 2026 17:19
JavaGT added a commit to JavaGT/headroom that referenced this pull request Jun 20, 2026
…normalization

Reviewing chopratejas#1089 (the launcher-side opencode wrap) surfaced two gaps in
the initial override implementation:

1. Gemini's version segment is /v1beta (not /v1). The proxy's gemini
   handlers append /v1beta/models/... themselves, so a caller passing the
   versioned URL (e.g. matching headroom's _KNOWN_UPSTREAMS
   'generativelanguage.googleapis.com/v1beta') would have produced a
   doubled /v1beta/v1beta path. The resolver now strips a trailing /v1beta
   as well as /v1.

2. The three /v1beta gemini routes did not thread the override through.
   handle_gemini_generate_content and handle_gemini_count_tokens already
   accepted upstream_base_url; handle_gemini_stream_generate_content did
   not. All three routes now pass request_upstream_override(request), and
   the stream handler gained the parameter.

Coverage is now OpenAI (/v1/chat/completions, /v1/responses), Anthropic
(/v1/messages), Gemini (/v1beta generateContent/streamGenerateContent/
countTokens), and every passthrough / catch-all route.
@rudironsoni

Copy link
Copy Markdown
Contributor

Thanks for putting this together. My read is that #1089 and #1105 overlap on native OpenCode support, but they are not the same shape of change.

#1089 is mainly a runtime routing PR: it discovers OpenCode provider/auth config, maps providers to upstream base URLs, starts one proxy per upstream, supports custom provider URLs, and handles multi-provider port assignment. That is the strongest part of this PR.

#1105 goes further on the product/integration surface around that runtime:

  • Adds the full CLI lifecycle for headroom wrap opencode and headroom unwrap opencode, with the existing Headroom options wired through the command surface.
  • Adds persistent install support through ToolTarget.OPENCODE, planner support, provider-scope install paths, and Docker volume handling.
  • Adds an OpenCode MCP registrar with idempotent register/unregister, backup/restore, and spec diff detection.
  • Uses OPENCODE_CONFIG_CONTENT for scoped config injection and preserves user OPENAI_BASE_URL / ANTHROPIC_BASE_URL, so OpenCode /connect provider state is not clobbered by the wrapper.
  • Adds optional context tooling and code-graph integration for OpenCode, including RTK / lean-ctx instructions and Serena MCP support.
  • Adds the plugins/opencode/ npm package with provider/config helpers and Headroom retrieve/compress tooling that users can install separately.
  • Adds Docker e2e coverage and broader validation. Latest validation on feat: headroom wrap opencode / unwrap opencode CLI #1105 was Docker-isolated: full Python suite 7045 items / 6 skipped, focused OpenCode suite 144 passed, OpenCode plugin typecheck/tests/build passed, and Ruff on touched files passed.

So I would summarize it as: #1089 is stronger on dynamic per-upstream routing, while #1105 is broader as a mergeable end-to-end OpenCode integration. #1105 covers runtime wrapping plus install/uninstall, MCP, plugin packaging, Docker e2e, and integration with the existing Headroom deployment model.

Adds first-class OpenCode integration to Headroom:

- Provider discovery from auth.json and opencode.json (14+ known
  upstreams including opencode-go and opencode Zen)
- headroom wrap opencode / headroom unwrap opencode CLI commands
  with full flag surface (--routing-mode, --no-rtk, --no-mcp,
  --no-serena, --code-graph, --memory, --learn, --backend)
- Two routing modes: multi-proxy (one proxy per upstream, default)
  and single-proxy (shared proxy with x-headroom-base-url header
  routing via 3 proxy route changes)
- OpencodeRegistrar (MCP server registration via opencode.json)
- Install infrastructure (ToolTarget.OPENCODE, provider scope,
  backup/restore with marker-based config blocks)
- Centralized port management (port_utils.py with allocate_ports,
  is_headroom_proxy, find_opencode_ports)
- JSONC comment stripping (placeholder-based, handles URLs in strings)
- Telemetry tracking for opencode wrap agent
- 96 new tests across 5 test files

Co-Authored-By: opencode <noreply@opencode.ai>
@rudironsoni

rudironsoni commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Leaving a cross-link because this overlaps with #1105.

I ended up taking #1105 in a different direction: do not patch provider URLs at all. The OpenCode plugin installs a transport shim and routes outbound fetch, http, and https calls through Headroom when the request happens. That covers providers added while OpenCode is already running, since we are not relying on a static provider list.

It also handles the subagent case. The plugin preloads the same shim into child Node processes through NODE_OPTIONS, and patches child_process calls so a custom child env does not accidentally drop the wrapper.

The fail-closed behavior matters here. External HTTP/2 is blocked instead of being allowed to bypass Headroom. Local OpenCode calls and Headroom proxy calls are skipped so the shim does not loop into itself.

#1105 is green in Docker: full Python suite 6605 passed, 523 skipped, plus the OpenCode plugin typecheck, tests, build, and preload smoke test.

hareeshkar and others added 2 commits June 22, 2026 07:06
Documentation:
- docs/content/docs/opencode.mdx: comprehensive integration guide covering
  provider discovery, OpenCode Go support, routing modes, architecture,
  CLI options, environment variables, persistent installs, and troubleshooting.
- docs/content/docs/meta.json: add opencode to navigation.

Docker e2e:
- e2e/wrap/Dockerfile: install OpenCode binary, add to PATH.
- e2e/wrap/run.py: add verify_opencode_wrap() with superior checks:
  multiple providers discovered, config backup created, MCP entry in overlay,
  unwrap restores from backup, unwrap removes backup file.

Adversarial review fixes:
- B1: consolidate _opencode_home_dir into single canonical location in
  config.py, import everywhere else (was duplicated 3 times).
- B2: fix TOCTOU race in allocate_ports — use bind/release instead of
  connect (prevents intermittent port conflicts).
- B3: fix missing normalize_upstream_base in catch-all passthrough route
  (prevented /v1/v1 path doubling in single-proxy mode).
- B4: fix single-proxy phantom port allocation — all providers now share
  the single proxy port (no more unallocated dedicated ports).

Co-Authored-By: opencode <noreply@opencode.ai>
These files were accidentally staged from a prior branch state.
The opencode PR does not include a transport shim or serena config.

Co-Authored-By: opencode <noreply@opencode.ai>
@DenisBalan

Copy link
Copy Markdown

this PR is re #1193

@rudironsoni @hareeshkar its not only about opencode go / zen model endpoints

As an OpenCode CLI user, I configure my own models.

It would be useful to support Headroom integration with OpenCode CLI, similar how OpenChamber integrates with OpenCode.

re https://github.com/openchamber/openchamber

@rudironsoni

Copy link
Copy Markdown
Contributor

Yes, this is exactly what my PR does: #1105

It keeps the OpenCode config untouched and puts Headroom in the middle through the OpenCode plugin. Requests are intercepted at runtime, so providers added mid-session are still routed through Headroom. It also covers child Node processes and subagents, so the wrap is full and transparent instead of depending on a static provider snapshot.

@hareeshkar hareeshkar force-pushed the feat/opencode-native-support branch from d70c13a to 7923285 Compare June 22, 2026 08:36
@hareeshkar hareeshkar changed the title feat: native OpenCode support — headroom wrap opencode feat(opencode): native OpenCode support — provider discovery, wrap/unwrap, MCP, install Jun 22, 2026
@github-actions github-actions Bot added status: needs author action Pull request body or readiness checklist still needs author updates and removed status: ready for review Pull request body is complete and the author marked it ready for human review labels Jun 22, 2026
@hareeshkar

Copy link
Copy Markdown
Author

Updated this PR to address the feedback. Here's what changed:

What This PR Provides

Infrastructure layer for OpenCode integration:

  • Provider discovery from auth.json — discovers all configured providers automatically (14+ known upstreams including OpenCode Go and Zen)
  • Per-provider overlay — users see their actual providers (deepseek, opencode-go, etc.) in the model picker, not headroom/claude-sonnet-4-6
  • Port management utilities (allocate_ports, is_headroom_proxy, find_opencode_ports) — general-purpose, benefits all agents
  • MCP registrar (OpencodeRegistrar) with JSONC support
  • Install infrastructure (ToolTarget.OPENCODE, provider scope, backup/restore)
  • headroom wrap opencode / headroom unwrap opencode CLI commands
  • 193 tests passing, 12 e2e tests, lint clean

Relationship to #1105

@rudironsoni's #1105 provides transparent routing via a transport shim that intercepts ALL HTTP traffic. This is the right approach for transparent wrapping — it automatically supports OpenCode Go, all providers, and mid-session provider changes.

This PR provides a different approach: per-provider config overlay where users see their actual providers in the model picker. The two approaches have different tradeoffs:

Dimension #1105 (transport shim) This PR (config overlay)
Provider identity headroom/claude-sonnet-4-6 deepseek, opencode-go
Mid-session providers ✅ Automatic ❌ Not covered
Code complexity 438-line TypeScript + Python Python only
Test coverage 72 tests 193 tests

These are complementary approaches, not competing ones.

…nterception

Closes the mid-session provider gap (headroomlabs-ai#1089). Previously, providers added
via /connect after OpenCode launched bypassed the Headroom proxy entirely.

The fix: a ~80-line ESM module (shim.mjs) is injected into the OpenCode
Node process via NODE_OPTIONS=--import=<path> before any userland code
runs. It patches globalThis.fetch — the only transport the Vercel AI SDK
uses — to rewrite external AI calls to the local Headroom proxy and carry
the original upstream origin as x-headroom-base-url. The proxy's existing
route handlers already read that header (lines 428, 482, 585, 884 of
proxy_routes.py) so no proxy changes are needed.

Design choices vs alternatives:
- Patches ONLY globalThis.fetch (not http/https/http2/child_process).
  The AI SDK uses the Web Fetch API exclusively; patching Node core
  transports breaks git, npm, and other tools.
- Cold-start retry: 3× at 75ms delays (mirrors OpenChamber's
  READINESS_HOLD_POLL_MS pattern) so the first request never fails
  if the proxy is still warming up.
- Loopback guard prevents proxy→proxy routing loops.
- Fail-open on URL parse errors (non-URL strings pass through unchanged).
- Known providers still listed in OPENCODE_CONFIG_CONTENT so the model
  picker shows real names (deepseek, opencode-go) not a wrapper name.
- Single-proxy mode is now the default (was multi).

5 new tests added; full opencode suite: 110 passed, 1 warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions github-actions Bot added status: ready for review Pull request body is complete and the author marked it ready for human review and removed status: needs author action Pull request body or readiness checklist still needs author updates labels Jun 22, 2026
JerrettDavis pushed a commit that referenced this pull request Jun 22, 2026
## 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>

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current head is closer, but I still need to request changes for two correctness issues.

First, the fetch shim retries any proxied response with status 502 or 503:

const res = await _fetch(url, init);
if (res.status < 502 || i === attempts - 1) return res;

That can duplicate non-idempotent provider calls. Once the local proxy is reachable and returns a response, a 502/503 may be the real upstream provider response after the request was forwarded, not just “proxy warming up.” Retrying a POST can create duplicate completions/tool calls and duplicate billing. Please restrict cold-start retry to connection-refused/proxy-not-listening style failures before the proxy accepts the request, or otherwise prove the response was generated locally before any upstream forwarding. Add shim coverage for this behavior.

Related shim coverage gap: headroomFetch(input, init = {}) rewrites Request inputs to a URL string and then calls fetchWithRetry(proxied.toString(), { ...init, headers }). If callers pass a Request object without an explicit init, the original method/body are not carried over, so a routed POST Request can become a GET with no body. The shim should preserve Request method/body/credentials/etc. when rewriting, with tests.

Second, provider-scope install rollback does not clean up a Headroom provider when there was no pre-existing OpenCode config to back up. apply_provider_scope() writes a normal JSON provider.headroom entry, but revert_provider_scope() without a backup only calls strip_opencode_headroom_blocks(), which removes marker-wrapped text blocks. The installed provider entry is not marker-wrapped, so uninstall leaves Headroom configured instead of removing the generated config/file. Please either write a removable marker block or remove provider.headroom structurally on revert, including the “config did not exist before install” case.

The branch is also DIRTY and currently only has governance checks on the latest head, so after the fixes it needs a refresh/full CI run before merge.

@JerrettDavis

Copy link
Copy Markdown
Collaborator

Maintainer refresh: merged current main into this branch. The older native provider/shim implementation is superseded by the OpenCode plugin path already on main, so the refreshed PR has no remaining code delta against main and does not carry forward the unsafe fetch shim or provider-overlay behavior from the earlier revision. Focused local validation passed: OpenCode wrap, MCP registrar, provider config, and provider install tests (103 passed).

@JerrettDavis

Copy link
Copy Markdown
Collaborator

Thank you for the OpenCode work here. We refreshed this branch against current main and confirmed there is no remaining diff: the OpenCode implementation is now covered by the plugin-based path already landed on main, and the older native shim/provider-overlay approach is no longer part of the branch result. Closing this PR as superseded by main while preserving the contribution history in the branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: ready for review Pull request body is complete and the author marked it ready for human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] OpenCode support?

9 participants