headroom/tests/test_cli/test_wrap_opencode.py

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

1273 lines
50 KiB
Python
Raw Normal View History

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 18:07:12 +02:00
"""Tests for `headroom wrap opencode` and `headroom unwrap opencode`."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from headroom.cli import wrap as wrap_mod
from headroom.cli.main import main
feat(opencode): support Copilot subscription backend for headroom models (#2441) (#2445) ## Description `headroom wrap opencode` currently routes `headroom/*` models only to ordinary Anthropic or OpenAI backends. A user with a GitHub Copilot subscription cannot point those `headroom/*` requests at the Copilot seat while keeping Headroom compression and stats, even though Headroom already has the validated subscription resolver, the proxy seed path, and the OpenCode provider route needed to do it. This PR adds `--copilot-subscription` to the OpenCode wrap command. It reuses the existing Copilot subscription token resolver, passes the validated endpoint and token seed into the existing proxy startup path, rejects unsupported runtime modes, and treats any non-empty Copilot API token as a private session seed so token-only sessions do not reuse a shared proxy. The generated OpenCode provider still targets the local proxy, and subscription secrets stay out of OpenCode config, environment, and terminal output. Closes #2441 ## 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 - Add `headroom wrap opencode --copilot-subscription` in `headroom/cli/wrap.py`. - Reuse the existing validated Copilot subscription resolver through one small required-resolution helper shared with the dedicated Copilot wrapper. - Pass the resolved endpoint and token seed into `_ensure_proxy()` as `openai_api_url`, `copilot_api_token`, `copilot_refresh_oauth_token`, and `copilot_api_token_expires_at`. - Reject `--copilot-subscription` with `--no-proxy`, `--prepare-only`, and translated backends before proxy or OpenCode launch. - Validate subscription mode before snapshotting OpenCode config, so rejected invocations don't create stale backups. - Scrub inherited Copilot proxy seed variables from the OpenCode child environment. - Treat any non-empty Copilot API token as a private session seed so token-only sessions do not reuse shared or persistent proxies. - Add focused OpenCode and persistent-proxy coverage for seed handoff, guard failures, direct-token isolation, secret non-disclosure, and unchanged non-subscription behavior. - Leave `CHANGELOG.md` untouched because Headroom generates changelog entries from conventional commits. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q`, `106 passed in 136.65s`) - [x] Linting passes (`uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text Targeted subscription tests pass: OpenCode `7 passed, 37 deselected in 0.36s`, persistent proxy `2 passed, 29 deselected in 0.34s`, and dedicated Copilot `11 passed, 20 deselected in 0.39s`. Coverage includes inherited resolver-input env scrubbing, HEADROOM_BACKEND rejection, no-backup-on-rejection, OpenCode-only scrub scoping, and private-proxy teardown on config-injection failure. The full focused command `uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q` passed with `106 passed in 136.65s`. Ruff check and format check pass. ``` ## Real Behavior Proof - Environment: Windows, `uv` development environment, local CLI tests, no live Copilot seat on this host - Exact command / steps: run the focused OpenCode and persistent-proxy tests with a mocked `CopilotSubscriptionTokenResolution`, then capture the proof rows for seed handoff, direct-token isolation, guards, and secret non-disclosure - Observed result: Targeted subscription and proxy-seed tests pass, including OpenCode-only resolver-input env scrubbing and private-proxy teardown on config-injection failure; the full focused command passed with `106 passed in 136.65s`; Ruff check and format check pass. - Not tested: live Copilot subscription seat run on this host ## 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 - [ ] 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 feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - Feature approval comes from the open `enhancement` label on https://github.com/headroomlabs-ai/headroom/issues/2441. - Keep the final live-backend claim behind manual owner proof. Local CLI tests can prove config, guard, secret, and proxy-seed behavior, but they cannot prove a real Copilot seat on this host. - `CHANGELOG.md` remains untouched because Headroom's release pipeline generates changelog entries from conventional commits.
2026-07-20 14:02:14 -04:00
from headroom.copilot_auth import CopilotSubscriptionTokenResolution
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 18:07:12 +02:00
fix(wrap): make RTK opt-in (off by default) across wrap subcommands (#2344) ## Description RTK CLI-command filtering was set up **by default** across ~16 `wrap` subcommands (copilot, codex, aider, cursor, cline, continue, goose, openhands, opencode, grok, omp, openclaude, vibe, …) via `if not no_rtk:` — so users got rtk hooks / instruction injection without opting in. `wrap claude` was the lone exception (already gated on `--context-tool`). This makes RTK **opt-in (off by default)** everywhere, so Headroom's own savings are what's measured unless a user explicitly wants rtk. Closes # ## Type of Change - [x] Bug fix (behavior change: default flip) ## Changes Made - **Central gate** `_rtk_opt_in()` — RTK runs only when explicitly enabled via `--rtk` or `HEADROOM_RTK=1`. Guards the 3 RTK entry points (`_setup_rtk`, `_ensure_rtk_binary`, `_inject_rtk_instructions`) so they no-op by default — one small change instead of editing ~30 call sites. - **`--rtk` opt-in flag** on all 18 tool subcommands via a shared eager-callback option (`expose_value=False`, sets `HEADROOM_RTK=1`; no subcommand signature changes). - `wrap claude`'s legacy `--context-tool` still opts in (mirrored into the gate). - **`--no-rtk` kept** as an accepted, now-redundant no-op (back-compat). - lean-ctx and all non-RTK behavior untouched. ## Testing ```text pytest tests/test_wrap_rtk_opt_in.py -> 4 passed ruff check / format -> clean mypy headroom -> Success: no issues found in 504 source files ``` Verified `--rtk` appears in `wrap {claude,codex,copilot} --help`; `_rtk_opt_in()` is False by default, True with `HEADROOM_RTK=1`; entry points no-op + write nothing when off. ## Real Behavior Proof - Env: local `.venv`, click CliRunner. - Steps: import wrap; assert gate default-off / env-on; assert `_setup_rtk`/`_ensure_rtk_binary` return None and `_inject_rtk_instructions` returns False + writes no file when not opted in; assert `--rtk` in subcommand help. - Observed: all pass. Not tested: a live end-to-end wrap launch (proxy spawn). ## Notes Part 2 of 3 (RTK opt-in). Separate PRs cover the code-graph MCP engine and the proxy-option cleanup. No `CHANGELOG.md` edit — release-please generates it from the PR title (per the changelog guard). --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-17 16:47:19 -07:00
@pytest.fixture(autouse=True)
def _enable_rtk(monkeypatch: pytest.MonkeyPatch) -> None:
# RTK is opt-in (off by default); these tests exercise the RTK-on injection path.
monkeypatch.setenv("HEADROOM_RTK", "1")
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 18:07:12 +02:00
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
def _set_test_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
home = str(tmp_path)
monkeypatch.setenv("HOME", home)
monkeypatch.setenv("USERPROFILE", home)
monkeypatch.delenv("OPENCODE_HOME", raising=False)
monkeypatch.delenv("OPENCODE_CONFIG", raising=False)
fix(copilot): normalize subscription API routing (#2441) (#2455) ## Description PR https://github.com/headroomlabs-ai/headroom/pull/2445 added the missing OpenCode subscription path, but the shared Copilot subscription resolver still lets Business and Enterprise payload hosts route through segmented `*.githubcopilot.com` domains and still drops an explicit `GITHUB_COPILOT_API_URL` pin on two resolution paths. This follow-up moves the final hosted-route decision back into the shared resolver, normalizes `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to the generic host by default, and makes the explicit pin win on token exchange, explicit API token, and Copilot-token candidate resolution. Both `headroom wrap copilot --subscription` and `headroom wrap opencode --copilot-subscription` inherit the same fix because they already consume the same `CopilotSubscriptionTokenResolution.api_url`. Refs #2441. Attribution: https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 reported and narrowed the Business or Enterprise regression, and https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026863498 scoped the shared-resolver follow-up that this change implements. ## 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 - Centralize subscription hosted-route selection so explicit `GITHUB_COPILOT_API_URL` pins win on token exchange, explicit API token, and Copilot-token candidate resolution. - Normalize `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to `https://api.githubcopilot.com` by default, extending the existing individual-seat normalization. - Extend focused auth and wrapper tests so both subscription wrappers prove the corrected shared resolver output and the private-proxy isolation contract stays intact. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_copilot_auth.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_copilot.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_persistent.py -q`) - [x] Linting passes (`uv run ruff check .`) - [x] Formatting check passes (`uv run ruff format . --check`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_copilot_auth.py -q -> 84 passed uv run pytest tests/test_cli/test_wrap_copilot.py -q -> 31 passed uv run pytest tests/test_cli/test_wrap_opencode.py -q -> 44 passed in 143.46s uv run pytest tests/test_cli/test_wrap_persistent.py -q -> 31 passed uv run ruff check . -> All checks passed! uv run ruff format . --check -> 1331 files already formatted ``` ## Real Behavior Proof - Environment: Windows - Exact command / steps: Run the focused auth, Copilot wrapper, OpenCode wrapper, and persistent-proxy pytest files after implementing the shared resolver change, then ask lucasp1337 to rerun the Business or Enterprise `--copilot-subscription` scenario from PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 on a real seat. - Observed result: Focused auth and wrapper pytest runs passed locally, including the enterprise-host exchange reproduction row, explicit-pin precedence on all three producer paths, both subscription wrapper routes, and the private-proxy isolation regression. Live Business or Enterprise success stays behind reporter retest. - Not tested: live Business or Enterprise tenant run ## 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 - [ ] 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 have updated the CHANGELOG.md if applicable ## Additional Notes No `CHANGELOG.md` edit is needed because Headroom generates release notes from conventional commits. Risk for maintainers: PR https://github.com/headroomlabs-ai/headroom/pull/641 manually validated a Business seat against the GitHub-returned hosted domain in June on `gpt-5.4`, so generic-by-default could affect tenants that genuinely require a dedicated host. This follow-up keeps the documented escape hatch intact by making `GITHUB_COPILOT_API_URL` win on every path. Live-seat proof boundary: lucasp1337 offered to retest on a Business or Enterprise seat in PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395. Keep any live success claim behind that rerun.
2026-07-20 20:15:57 -04:00
def _clear_copilot_route_config(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_COPILOT_API_URL", raising=False)
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_URL", raising=False)
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", raising=False)
feat(opencode): support Copilot subscription backend for headroom models (#2441) (#2445) ## Description `headroom wrap opencode` currently routes `headroom/*` models only to ordinary Anthropic or OpenAI backends. A user with a GitHub Copilot subscription cannot point those `headroom/*` requests at the Copilot seat while keeping Headroom compression and stats, even though Headroom already has the validated subscription resolver, the proxy seed path, and the OpenCode provider route needed to do it. This PR adds `--copilot-subscription` to the OpenCode wrap command. It reuses the existing Copilot subscription token resolver, passes the validated endpoint and token seed into the existing proxy startup path, rejects unsupported runtime modes, and treats any non-empty Copilot API token as a private session seed so token-only sessions do not reuse a shared proxy. The generated OpenCode provider still targets the local proxy, and subscription secrets stay out of OpenCode config, environment, and terminal output. Closes #2441 ## 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 - Add `headroom wrap opencode --copilot-subscription` in `headroom/cli/wrap.py`. - Reuse the existing validated Copilot subscription resolver through one small required-resolution helper shared with the dedicated Copilot wrapper. - Pass the resolved endpoint and token seed into `_ensure_proxy()` as `openai_api_url`, `copilot_api_token`, `copilot_refresh_oauth_token`, and `copilot_api_token_expires_at`. - Reject `--copilot-subscription` with `--no-proxy`, `--prepare-only`, and translated backends before proxy or OpenCode launch. - Validate subscription mode before snapshotting OpenCode config, so rejected invocations don't create stale backups. - Scrub inherited Copilot proxy seed variables from the OpenCode child environment. - Treat any non-empty Copilot API token as a private session seed so token-only sessions do not reuse shared or persistent proxies. - Add focused OpenCode and persistent-proxy coverage for seed handoff, guard failures, direct-token isolation, secret non-disclosure, and unchanged non-subscription behavior. - Leave `CHANGELOG.md` untouched because Headroom generates changelog entries from conventional commits. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q`, `106 passed in 136.65s`) - [x] Linting passes (`uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text Targeted subscription tests pass: OpenCode `7 passed, 37 deselected in 0.36s`, persistent proxy `2 passed, 29 deselected in 0.34s`, and dedicated Copilot `11 passed, 20 deselected in 0.39s`. Coverage includes inherited resolver-input env scrubbing, HEADROOM_BACKEND rejection, no-backup-on-rejection, OpenCode-only scrub scoping, and private-proxy teardown on config-injection failure. The full focused command `uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q` passed with `106 passed in 136.65s`. Ruff check and format check pass. ``` ## Real Behavior Proof - Environment: Windows, `uv` development environment, local CLI tests, no live Copilot seat on this host - Exact command / steps: run the focused OpenCode and persistent-proxy tests with a mocked `CopilotSubscriptionTokenResolution`, then capture the proof rows for seed handoff, direct-token isolation, guards, and secret non-disclosure - Observed result: Targeted subscription and proxy-seed tests pass, including OpenCode-only resolver-input env scrubbing and private-proxy teardown on config-injection failure; the full focused command passed with `106 passed in 136.65s`; Ruff check and format check pass. - Not tested: live Copilot subscription seat run on this host ## 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 - [ ] 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 feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - Feature approval comes from the open `enhancement` label on https://github.com/headroomlabs-ai/headroom/issues/2441. - Keep the final live-backend claim behind manual owner proof. Local CLI tests can prove config, guard, secret, and proxy-seed behavior, but they cannot prove a real Copilot seat on this host. - `CHANGELOG.md` remains untouched because Headroom's release pipeline generates changelog entries from conventional commits.
2026-07-20 14:02:14 -04:00
def _subscription_resolution() -> CopilotSubscriptionTokenResolution:
return CopilotSubscriptionTokenResolution(
token="copilot-api-secret",
source="test",
confidence="test",
api_url="https://api.githubcopilot.com",
token_fingerprint="sha256:test",
refresh_oauth_token="copilot-refresh-secret",
api_token_expires_at=123.5,
)
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 18:07:12 +02:00
# ---------------------------------------------------------------------------
# Wrap opencode
# ---------------------------------------------------------------------------
fix(copilot): normalize subscription API routing (#2441) (#2455) ## Description PR https://github.com/headroomlabs-ai/headroom/pull/2445 added the missing OpenCode subscription path, but the shared Copilot subscription resolver still lets Business and Enterprise payload hosts route through segmented `*.githubcopilot.com` domains and still drops an explicit `GITHUB_COPILOT_API_URL` pin on two resolution paths. This follow-up moves the final hosted-route decision back into the shared resolver, normalizes `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to the generic host by default, and makes the explicit pin win on token exchange, explicit API token, and Copilot-token candidate resolution. Both `headroom wrap copilot --subscription` and `headroom wrap opencode --copilot-subscription` inherit the same fix because they already consume the same `CopilotSubscriptionTokenResolution.api_url`. Refs #2441. Attribution: https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 reported and narrowed the Business or Enterprise regression, and https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026863498 scoped the shared-resolver follow-up that this change implements. ## 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 - Centralize subscription hosted-route selection so explicit `GITHUB_COPILOT_API_URL` pins win on token exchange, explicit API token, and Copilot-token candidate resolution. - Normalize `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to `https://api.githubcopilot.com` by default, extending the existing individual-seat normalization. - Extend focused auth and wrapper tests so both subscription wrappers prove the corrected shared resolver output and the private-proxy isolation contract stays intact. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_copilot_auth.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_copilot.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_persistent.py -q`) - [x] Linting passes (`uv run ruff check .`) - [x] Formatting check passes (`uv run ruff format . --check`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_copilot_auth.py -q -> 84 passed uv run pytest tests/test_cli/test_wrap_copilot.py -q -> 31 passed uv run pytest tests/test_cli/test_wrap_opencode.py -q -> 44 passed in 143.46s uv run pytest tests/test_cli/test_wrap_persistent.py -q -> 31 passed uv run ruff check . -> All checks passed! uv run ruff format . --check -> 1331 files already formatted ``` ## Real Behavior Proof - Environment: Windows - Exact command / steps: Run the focused auth, Copilot wrapper, OpenCode wrapper, and persistent-proxy pytest files after implementing the shared resolver change, then ask lucasp1337 to rerun the Business or Enterprise `--copilot-subscription` scenario from PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 on a real seat. - Observed result: Focused auth and wrapper pytest runs passed locally, including the enterprise-host exchange reproduction row, explicit-pin precedence on all three producer paths, both subscription wrapper routes, and the private-proxy isolation regression. Live Business or Enterprise success stays behind reporter retest. - Not tested: live Business or Enterprise tenant run ## 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 - [ ] 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 have updated the CHANGELOG.md if applicable ## Additional Notes No `CHANGELOG.md` edit is needed because Headroom generates release notes from conventional commits. Risk for maintainers: PR https://github.com/headroomlabs-ai/headroom/pull/641 manually validated a Business seat against the GitHub-returned hosted domain in June on `gpt-5.4`, so generic-by-default could affect tenants that genuinely require a dedicated host. This follow-up keeps the documented escape hatch intact by making `GITHUB_COPILOT_API_URL` win on every path. Live-seat proof boundary: lucasp1337 offered to retest on a Business or Enterprise seat in PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395. Keep any live success claim behind that rerun.
2026-07-20 20:15:57 -04:00
def test_wrap_opencode_copilot_subscription_normalizes_enterprise_host_and_handoffs_seed_after_actual_port(
feat(opencode): support Copilot subscription backend for headroom models (#2441) (#2445) ## Description `headroom wrap opencode` currently routes `headroom/*` models only to ordinary Anthropic or OpenAI backends. A user with a GitHub Copilot subscription cannot point those `headroom/*` requests at the Copilot seat while keeping Headroom compression and stats, even though Headroom already has the validated subscription resolver, the proxy seed path, and the OpenCode provider route needed to do it. This PR adds `--copilot-subscription` to the OpenCode wrap command. It reuses the existing Copilot subscription token resolver, passes the validated endpoint and token seed into the existing proxy startup path, rejects unsupported runtime modes, and treats any non-empty Copilot API token as a private session seed so token-only sessions do not reuse a shared proxy. The generated OpenCode provider still targets the local proxy, and subscription secrets stay out of OpenCode config, environment, and terminal output. Closes #2441 ## 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 - Add `headroom wrap opencode --copilot-subscription` in `headroom/cli/wrap.py`. - Reuse the existing validated Copilot subscription resolver through one small required-resolution helper shared with the dedicated Copilot wrapper. - Pass the resolved endpoint and token seed into `_ensure_proxy()` as `openai_api_url`, `copilot_api_token`, `copilot_refresh_oauth_token`, and `copilot_api_token_expires_at`. - Reject `--copilot-subscription` with `--no-proxy`, `--prepare-only`, and translated backends before proxy or OpenCode launch. - Validate subscription mode before snapshotting OpenCode config, so rejected invocations don't create stale backups. - Scrub inherited Copilot proxy seed variables from the OpenCode child environment. - Treat any non-empty Copilot API token as a private session seed so token-only sessions do not reuse shared or persistent proxies. - Add focused OpenCode and persistent-proxy coverage for seed handoff, guard failures, direct-token isolation, secret non-disclosure, and unchanged non-subscription behavior. - Leave `CHANGELOG.md` untouched because Headroom generates changelog entries from conventional commits. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q`, `106 passed in 136.65s`) - [x] Linting passes (`uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text Targeted subscription tests pass: OpenCode `7 passed, 37 deselected in 0.36s`, persistent proxy `2 passed, 29 deselected in 0.34s`, and dedicated Copilot `11 passed, 20 deselected in 0.39s`. Coverage includes inherited resolver-input env scrubbing, HEADROOM_BACKEND rejection, no-backup-on-rejection, OpenCode-only scrub scoping, and private-proxy teardown on config-injection failure. The full focused command `uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q` passed with `106 passed in 136.65s`. Ruff check and format check pass. ``` ## Real Behavior Proof - Environment: Windows, `uv` development environment, local CLI tests, no live Copilot seat on this host - Exact command / steps: run the focused OpenCode and persistent-proxy tests with a mocked `CopilotSubscriptionTokenResolution`, then capture the proof rows for seed handoff, direct-token isolation, guards, and secret non-disclosure - Observed result: Targeted subscription and proxy-seed tests pass, including OpenCode-only resolver-input env scrubbing and private-proxy teardown on config-injection failure; the full focused command passed with `106 passed in 136.65s`; Ruff check and format check pass. - Not tested: live Copilot subscription seat run on this host ## 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 - [ ] 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 feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - Feature approval comes from the open `enhancement` label on https://github.com/headroomlabs-ai/headroom/issues/2441. - Keep the final live-backend claim behind manual owner proof. Local CLI tests can prove config, guard, secret, and proxy-seed behavior, but they cannot prove a real Copilot seat on this host. - `CHANGELOG.md` remains untouched because Headroom's release pipeline generates changelog entries from conventional commits.
2026-07-20 14:02:14 -04:00
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
_set_test_home(monkeypatch, tmp_path)
fix(copilot): normalize subscription API routing (#2441) (#2455) ## Description PR https://github.com/headroomlabs-ai/headroom/pull/2445 added the missing OpenCode subscription path, but the shared Copilot subscription resolver still lets Business and Enterprise payload hosts route through segmented `*.githubcopilot.com` domains and still drops an explicit `GITHUB_COPILOT_API_URL` pin on two resolution paths. This follow-up moves the final hosted-route decision back into the shared resolver, normalizes `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to the generic host by default, and makes the explicit pin win on token exchange, explicit API token, and Copilot-token candidate resolution. Both `headroom wrap copilot --subscription` and `headroom wrap opencode --copilot-subscription` inherit the same fix because they already consume the same `CopilotSubscriptionTokenResolution.api_url`. Refs #2441. Attribution: https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 reported and narrowed the Business or Enterprise regression, and https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026863498 scoped the shared-resolver follow-up that this change implements. ## 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 - Centralize subscription hosted-route selection so explicit `GITHUB_COPILOT_API_URL` pins win on token exchange, explicit API token, and Copilot-token candidate resolution. - Normalize `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to `https://api.githubcopilot.com` by default, extending the existing individual-seat normalization. - Extend focused auth and wrapper tests so both subscription wrappers prove the corrected shared resolver output and the private-proxy isolation contract stays intact. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_copilot_auth.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_copilot.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_persistent.py -q`) - [x] Linting passes (`uv run ruff check .`) - [x] Formatting check passes (`uv run ruff format . --check`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_copilot_auth.py -q -> 84 passed uv run pytest tests/test_cli/test_wrap_copilot.py -q -> 31 passed uv run pytest tests/test_cli/test_wrap_opencode.py -q -> 44 passed in 143.46s uv run pytest tests/test_cli/test_wrap_persistent.py -q -> 31 passed uv run ruff check . -> All checks passed! uv run ruff format . --check -> 1331 files already formatted ``` ## Real Behavior Proof - Environment: Windows - Exact command / steps: Run the focused auth, Copilot wrapper, OpenCode wrapper, and persistent-proxy pytest files after implementing the shared resolver change, then ask lucasp1337 to rerun the Business or Enterprise `--copilot-subscription` scenario from PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 on a real seat. - Observed result: Focused auth and wrapper pytest runs passed locally, including the enterprise-host exchange reproduction row, explicit-pin precedence on all three producer paths, both subscription wrapper routes, and the private-proxy isolation regression. Live Business or Enterprise success stays behind reporter retest. - Not tested: live Business or Enterprise tenant run ## 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 - [ ] 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 have updated the CHANGELOG.md if applicable ## Additional Notes No `CHANGELOG.md` edit is needed because Headroom generates release notes from conventional commits. Risk for maintainers: PR https://github.com/headroomlabs-ai/headroom/pull/641 manually validated a Business seat against the GitHub-returned hosted domain in June on `gpt-5.4`, so generic-by-default could affect tenants that genuinely require a dedicated host. This follow-up keeps the documented escape hatch intact by making `GITHUB_COPILOT_API_URL` win on every path. Live-seat proof boundary: lucasp1337 offered to retest on a Business or Enterprise seat in PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395. Keep any live success claim behind that rerun.
2026-07-20 20:15:57 -04:00
_clear_copilot_route_config(monkeypatch)
feat(opencode): support Copilot subscription backend for headroom models (#2441) (#2445) ## Description `headroom wrap opencode` currently routes `headroom/*` models only to ordinary Anthropic or OpenAI backends. A user with a GitHub Copilot subscription cannot point those `headroom/*` requests at the Copilot seat while keeping Headroom compression and stats, even though Headroom already has the validated subscription resolver, the proxy seed path, and the OpenCode provider route needed to do it. This PR adds `--copilot-subscription` to the OpenCode wrap command. It reuses the existing Copilot subscription token resolver, passes the validated endpoint and token seed into the existing proxy startup path, rejects unsupported runtime modes, and treats any non-empty Copilot API token as a private session seed so token-only sessions do not reuse a shared proxy. The generated OpenCode provider still targets the local proxy, and subscription secrets stay out of OpenCode config, environment, and terminal output. Closes #2441 ## 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 - Add `headroom wrap opencode --copilot-subscription` in `headroom/cli/wrap.py`. - Reuse the existing validated Copilot subscription resolver through one small required-resolution helper shared with the dedicated Copilot wrapper. - Pass the resolved endpoint and token seed into `_ensure_proxy()` as `openai_api_url`, `copilot_api_token`, `copilot_refresh_oauth_token`, and `copilot_api_token_expires_at`. - Reject `--copilot-subscription` with `--no-proxy`, `--prepare-only`, and translated backends before proxy or OpenCode launch. - Validate subscription mode before snapshotting OpenCode config, so rejected invocations don't create stale backups. - Scrub inherited Copilot proxy seed variables from the OpenCode child environment. - Treat any non-empty Copilot API token as a private session seed so token-only sessions do not reuse shared or persistent proxies. - Add focused OpenCode and persistent-proxy coverage for seed handoff, guard failures, direct-token isolation, secret non-disclosure, and unchanged non-subscription behavior. - Leave `CHANGELOG.md` untouched because Headroom generates changelog entries from conventional commits. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q`, `106 passed in 136.65s`) - [x] Linting passes (`uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text Targeted subscription tests pass: OpenCode `7 passed, 37 deselected in 0.36s`, persistent proxy `2 passed, 29 deselected in 0.34s`, and dedicated Copilot `11 passed, 20 deselected in 0.39s`. Coverage includes inherited resolver-input env scrubbing, HEADROOM_BACKEND rejection, no-backup-on-rejection, OpenCode-only scrub scoping, and private-proxy teardown on config-injection failure. The full focused command `uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q` passed with `106 passed in 136.65s`. Ruff check and format check pass. ``` ## Real Behavior Proof - Environment: Windows, `uv` development environment, local CLI tests, no live Copilot seat on this host - Exact command / steps: run the focused OpenCode and persistent-proxy tests with a mocked `CopilotSubscriptionTokenResolution`, then capture the proof rows for seed handoff, direct-token isolation, guards, and secret non-disclosure - Observed result: Targeted subscription and proxy-seed tests pass, including OpenCode-only resolver-input env scrubbing and private-proxy teardown on config-injection failure; the full focused command passed with `106 passed in 136.65s`; Ruff check and format check pass. - Not tested: live Copilot subscription seat run on this host ## 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 - [ ] 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 feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - Feature approval comes from the open `enhancement` label on https://github.com/headroomlabs-ai/headroom/issues/2441. - Keep the final live-backend claim behind manual owner proof. Local CLI tests can prove config, guard, secret, and proxy-seed behavior, but they cannot prove a real Copilot seat on this host. - `CHANGELOG.md` remains untouched because Headroom's release pipeline generates changelog entries from conventional commits.
2026-07-20 14:02:14 -04:00
monkeypatch.setenv("GITHUB_COPILOT_API_TOKEN", "inherited-api-secret")
monkeypatch.setenv("GITHUB_COPILOT_REFRESH_OAUTH_TOKEN", "inherited-refresh-secret")
monkeypatch.setenv("GITHUB_COPILOT_API_TOKEN_EXPIRES_AT", "999.0")
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "inherited-seat-token")
monkeypatch.setenv("GITHUB_COPILOT_GITHUB_TOKEN", "inherited-github-token")
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "inherited-alt-github-token")
monkeypatch.setenv("COPILOT_PROVIDER_BEARER_TOKEN", "inherited-provider-bearer")
monkeypatch.setenv("GH_TOKEN", "inherited-gh-token")
monkeypatch.setenv("GITHUB_TOKEN", "inherited-github-pat")
captured: dict[str, object] = {}
def fake_ensure_proxy(*args, **kwargs): # noqa: ANN002, ANN003
captured["ensure"] = kwargs
return None, 9010
def fake_launch_tool(**kwargs): # noqa: ANN003
captured["launch"] = kwargs
with (
patch.object(wrap_mod.shutil, "which", return_value="opencode"),
fix(copilot): normalize subscription API routing (#2441) (#2455) ## Description PR https://github.com/headroomlabs-ai/headroom/pull/2445 added the missing OpenCode subscription path, but the shared Copilot subscription resolver still lets Business and Enterprise payload hosts route through segmented `*.githubcopilot.com` domains and still drops an explicit `GITHUB_COPILOT_API_URL` pin on two resolution paths. This follow-up moves the final hosted-route decision back into the shared resolver, normalizes `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to the generic host by default, and makes the explicit pin win on token exchange, explicit API token, and Copilot-token candidate resolution. Both `headroom wrap copilot --subscription` and `headroom wrap opencode --copilot-subscription` inherit the same fix because they already consume the same `CopilotSubscriptionTokenResolution.api_url`. Refs #2441. Attribution: https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 reported and narrowed the Business or Enterprise regression, and https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026863498 scoped the shared-resolver follow-up that this change implements. ## 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 - Centralize subscription hosted-route selection so explicit `GITHUB_COPILOT_API_URL` pins win on token exchange, explicit API token, and Copilot-token candidate resolution. - Normalize `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to `https://api.githubcopilot.com` by default, extending the existing individual-seat normalization. - Extend focused auth and wrapper tests so both subscription wrappers prove the corrected shared resolver output and the private-proxy isolation contract stays intact. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_copilot_auth.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_copilot.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_persistent.py -q`) - [x] Linting passes (`uv run ruff check .`) - [x] Formatting check passes (`uv run ruff format . --check`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_copilot_auth.py -q -> 84 passed uv run pytest tests/test_cli/test_wrap_copilot.py -q -> 31 passed uv run pytest tests/test_cli/test_wrap_opencode.py -q -> 44 passed in 143.46s uv run pytest tests/test_cli/test_wrap_persistent.py -q -> 31 passed uv run ruff check . -> All checks passed! uv run ruff format . --check -> 1331 files already formatted ``` ## Real Behavior Proof - Environment: Windows - Exact command / steps: Run the focused auth, Copilot wrapper, OpenCode wrapper, and persistent-proxy pytest files after implementing the shared resolver change, then ask lucasp1337 to rerun the Business or Enterprise `--copilot-subscription` scenario from PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 on a real seat. - Observed result: Focused auth and wrapper pytest runs passed locally, including the enterprise-host exchange reproduction row, explicit-pin precedence on all three producer paths, both subscription wrapper routes, and the private-proxy isolation regression. Live Business or Enterprise success stays behind reporter retest. - Not tested: live Business or Enterprise tenant run ## 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 - [ ] 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 have updated the CHANGELOG.md if applicable ## Additional Notes No `CHANGELOG.md` edit is needed because Headroom generates release notes from conventional commits. Risk for maintainers: PR https://github.com/headroomlabs-ai/headroom/pull/641 manually validated a Business seat against the GitHub-returned hosted domain in June on `gpt-5.4`, so generic-by-default could affect tenants that genuinely require a dedicated host. This follow-up keeps the documented escape hatch intact by making `GITHUB_COPILOT_API_URL` win on every path. Live-seat proof boundary: lucasp1337 offered to retest on a Business or Enterprise seat in PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395. Keep any live success claim behind that rerun.
2026-07-20 20:15:57 -04:00
patch(
"headroom.copilot_auth.iter_oauth_token_candidates",
return_value=[
type(
"_Candidate",
(),
{
"token": "gho-oauth",
"source": "headroom-copilot-auth:/tmp/copilot_auth.json",
"confidence": "copilot-oauth",
"validate_for_subscription": True,
},
)()
],
),
patch(
"headroom.copilot_auth.CopilotTokenProvider._exchange_token_sync",
staticmethod(
lambda _headers: {
"token": "copilot-api-secret",
"expires_at": 123.5,
"refresh_token": "copilot-refresh-secret",
"endpoints": {"api": "https://api.enterprise.githubcopilot.com"},
}
),
feat(opencode): support Copilot subscription backend for headroom models (#2441) (#2445) ## Description `headroom wrap opencode` currently routes `headroom/*` models only to ordinary Anthropic or OpenAI backends. A user with a GitHub Copilot subscription cannot point those `headroom/*` requests at the Copilot seat while keeping Headroom compression and stats, even though Headroom already has the validated subscription resolver, the proxy seed path, and the OpenCode provider route needed to do it. This PR adds `--copilot-subscription` to the OpenCode wrap command. It reuses the existing Copilot subscription token resolver, passes the validated endpoint and token seed into the existing proxy startup path, rejects unsupported runtime modes, and treats any non-empty Copilot API token as a private session seed so token-only sessions do not reuse a shared proxy. The generated OpenCode provider still targets the local proxy, and subscription secrets stay out of OpenCode config, environment, and terminal output. Closes #2441 ## 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 - Add `headroom wrap opencode --copilot-subscription` in `headroom/cli/wrap.py`. - Reuse the existing validated Copilot subscription resolver through one small required-resolution helper shared with the dedicated Copilot wrapper. - Pass the resolved endpoint and token seed into `_ensure_proxy()` as `openai_api_url`, `copilot_api_token`, `copilot_refresh_oauth_token`, and `copilot_api_token_expires_at`. - Reject `--copilot-subscription` with `--no-proxy`, `--prepare-only`, and translated backends before proxy or OpenCode launch. - Validate subscription mode before snapshotting OpenCode config, so rejected invocations don't create stale backups. - Scrub inherited Copilot proxy seed variables from the OpenCode child environment. - Treat any non-empty Copilot API token as a private session seed so token-only sessions do not reuse shared or persistent proxies. - Add focused OpenCode and persistent-proxy coverage for seed handoff, guard failures, direct-token isolation, secret non-disclosure, and unchanged non-subscription behavior. - Leave `CHANGELOG.md` untouched because Headroom generates changelog entries from conventional commits. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q`, `106 passed in 136.65s`) - [x] Linting passes (`uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text Targeted subscription tests pass: OpenCode `7 passed, 37 deselected in 0.36s`, persistent proxy `2 passed, 29 deselected in 0.34s`, and dedicated Copilot `11 passed, 20 deselected in 0.39s`. Coverage includes inherited resolver-input env scrubbing, HEADROOM_BACKEND rejection, no-backup-on-rejection, OpenCode-only scrub scoping, and private-proxy teardown on config-injection failure. The full focused command `uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q` passed with `106 passed in 136.65s`. Ruff check and format check pass. ``` ## Real Behavior Proof - Environment: Windows, `uv` development environment, local CLI tests, no live Copilot seat on this host - Exact command / steps: run the focused OpenCode and persistent-proxy tests with a mocked `CopilotSubscriptionTokenResolution`, then capture the proof rows for seed handoff, direct-token isolation, guards, and secret non-disclosure - Observed result: Targeted subscription and proxy-seed tests pass, including OpenCode-only resolver-input env scrubbing and private-proxy teardown on config-injection failure; the full focused command passed with `106 passed in 136.65s`; Ruff check and format check pass. - Not tested: live Copilot subscription seat run on this host ## 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 - [ ] 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 feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - Feature approval comes from the open `enhancement` label on https://github.com/headroomlabs-ai/headroom/issues/2441. - Keep the final live-backend claim behind manual owner proof. Local CLI tests can prove config, guard, secret, and proxy-seed behavior, but they cannot prove a real Copilot seat on this host. - `CHANGELOG.md` remains untouched because Headroom's release pipeline generates changelog entries from conventional commits.
2026-07-20 14:02:14 -04:00
),
fix(copilot): normalize subscription API routing (#2441) (#2455) ## Description PR https://github.com/headroomlabs-ai/headroom/pull/2445 added the missing OpenCode subscription path, but the shared Copilot subscription resolver still lets Business and Enterprise payload hosts route through segmented `*.githubcopilot.com` domains and still drops an explicit `GITHUB_COPILOT_API_URL` pin on two resolution paths. This follow-up moves the final hosted-route decision back into the shared resolver, normalizes `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to the generic host by default, and makes the explicit pin win on token exchange, explicit API token, and Copilot-token candidate resolution. Both `headroom wrap copilot --subscription` and `headroom wrap opencode --copilot-subscription` inherit the same fix because they already consume the same `CopilotSubscriptionTokenResolution.api_url`. Refs #2441. Attribution: https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 reported and narrowed the Business or Enterprise regression, and https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026863498 scoped the shared-resolver follow-up that this change implements. ## 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 - Centralize subscription hosted-route selection so explicit `GITHUB_COPILOT_API_URL` pins win on token exchange, explicit API token, and Copilot-token candidate resolution. - Normalize `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to `https://api.githubcopilot.com` by default, extending the existing individual-seat normalization. - Extend focused auth and wrapper tests so both subscription wrappers prove the corrected shared resolver output and the private-proxy isolation contract stays intact. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_copilot_auth.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_copilot.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_persistent.py -q`) - [x] Linting passes (`uv run ruff check .`) - [x] Formatting check passes (`uv run ruff format . --check`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_copilot_auth.py -q -> 84 passed uv run pytest tests/test_cli/test_wrap_copilot.py -q -> 31 passed uv run pytest tests/test_cli/test_wrap_opencode.py -q -> 44 passed in 143.46s uv run pytest tests/test_cli/test_wrap_persistent.py -q -> 31 passed uv run ruff check . -> All checks passed! uv run ruff format . --check -> 1331 files already formatted ``` ## Real Behavior Proof - Environment: Windows - Exact command / steps: Run the focused auth, Copilot wrapper, OpenCode wrapper, and persistent-proxy pytest files after implementing the shared resolver change, then ask lucasp1337 to rerun the Business or Enterprise `--copilot-subscription` scenario from PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 on a real seat. - Observed result: Focused auth and wrapper pytest runs passed locally, including the enterprise-host exchange reproduction row, explicit-pin precedence on all three producer paths, both subscription wrapper routes, and the private-proxy isolation regression. Live Business or Enterprise success stays behind reporter retest. - Not tested: live Business or Enterprise tenant run ## 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 - [ ] 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 have updated the CHANGELOG.md if applicable ## Additional Notes No `CHANGELOG.md` edit is needed because Headroom generates release notes from conventional commits. Risk for maintainers: PR https://github.com/headroomlabs-ai/headroom/pull/641 manually validated a Business seat against the GitHub-returned hosted domain in June on `gpt-5.4`, so generic-by-default could affect tenants that genuinely require a dedicated host. This follow-up keeps the documented escape hatch intact by making `GITHUB_COPILOT_API_URL` win on every path. Live-seat proof boundary: lucasp1337 offered to retest on a Business or Enterprise seat in PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395. Keep any live success claim behind that rerun.
2026-07-20 20:15:57 -04:00
patch("headroom.copilot_auth._fetch_copilot_user_info", return_value=None),
feat(opencode): support Copilot subscription backend for headroom models (#2441) (#2445) ## Description `headroom wrap opencode` currently routes `headroom/*` models only to ordinary Anthropic or OpenAI backends. A user with a GitHub Copilot subscription cannot point those `headroom/*` requests at the Copilot seat while keeping Headroom compression and stats, even though Headroom already has the validated subscription resolver, the proxy seed path, and the OpenCode provider route needed to do it. This PR adds `--copilot-subscription` to the OpenCode wrap command. It reuses the existing Copilot subscription token resolver, passes the validated endpoint and token seed into the existing proxy startup path, rejects unsupported runtime modes, and treats any non-empty Copilot API token as a private session seed so token-only sessions do not reuse a shared proxy. The generated OpenCode provider still targets the local proxy, and subscription secrets stay out of OpenCode config, environment, and terminal output. Closes #2441 ## 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 - Add `headroom wrap opencode --copilot-subscription` in `headroom/cli/wrap.py`. - Reuse the existing validated Copilot subscription resolver through one small required-resolution helper shared with the dedicated Copilot wrapper. - Pass the resolved endpoint and token seed into `_ensure_proxy()` as `openai_api_url`, `copilot_api_token`, `copilot_refresh_oauth_token`, and `copilot_api_token_expires_at`. - Reject `--copilot-subscription` with `--no-proxy`, `--prepare-only`, and translated backends before proxy or OpenCode launch. - Validate subscription mode before snapshotting OpenCode config, so rejected invocations don't create stale backups. - Scrub inherited Copilot proxy seed variables from the OpenCode child environment. - Treat any non-empty Copilot API token as a private session seed so token-only sessions do not reuse shared or persistent proxies. - Add focused OpenCode and persistent-proxy coverage for seed handoff, guard failures, direct-token isolation, secret non-disclosure, and unchanged non-subscription behavior. - Leave `CHANGELOG.md` untouched because Headroom generates changelog entries from conventional commits. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q`, `106 passed in 136.65s`) - [x] Linting passes (`uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text Targeted subscription tests pass: OpenCode `7 passed, 37 deselected in 0.36s`, persistent proxy `2 passed, 29 deselected in 0.34s`, and dedicated Copilot `11 passed, 20 deselected in 0.39s`. Coverage includes inherited resolver-input env scrubbing, HEADROOM_BACKEND rejection, no-backup-on-rejection, OpenCode-only scrub scoping, and private-proxy teardown on config-injection failure. The full focused command `uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q` passed with `106 passed in 136.65s`. Ruff check and format check pass. ``` ## Real Behavior Proof - Environment: Windows, `uv` development environment, local CLI tests, no live Copilot seat on this host - Exact command / steps: run the focused OpenCode and persistent-proxy tests with a mocked `CopilotSubscriptionTokenResolution`, then capture the proof rows for seed handoff, direct-token isolation, guards, and secret non-disclosure - Observed result: Targeted subscription and proxy-seed tests pass, including OpenCode-only resolver-input env scrubbing and private-proxy teardown on config-injection failure; the full focused command passed with `106 passed in 136.65s`; Ruff check and format check pass. - Not tested: live Copilot subscription seat run on this host ## 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 - [ ] 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 feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - Feature approval comes from the open `enhancement` label on https://github.com/headroomlabs-ai/headroom/issues/2441. - Keep the final live-backend claim behind manual owner proof. Local CLI tests can prove config, guard, secret, and proxy-seed behavior, but they cannot prove a real Copilot seat on this host. - `CHANGELOG.md` remains untouched because Headroom's release pipeline generates changelog entries from conventional commits.
2026-07-20 14:02:14 -04:00
patch.object(wrap_mod, "_ensure_proxy", side_effect=fake_ensure_proxy),
patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool),
):
result = runner.invoke(
main,
[
"wrap",
"opencode",
"--copilot-subscription",
"--no-rtk",
"--no-mcp",
"--no-serena",
],
)
assert result.exit_code == 0, result.output
ensure = captured["ensure"]
assert ensure["openai_api_url"] == "https://api.githubcopilot.com"
assert ensure["copilot_api_token"] == "copilot-api-secret"
fix(copilot): normalize subscription API routing (#2441) (#2455) ## Description PR https://github.com/headroomlabs-ai/headroom/pull/2445 added the missing OpenCode subscription path, but the shared Copilot subscription resolver still lets Business and Enterprise payload hosts route through segmented `*.githubcopilot.com` domains and still drops an explicit `GITHUB_COPILOT_API_URL` pin on two resolution paths. This follow-up moves the final hosted-route decision back into the shared resolver, normalizes `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to the generic host by default, and makes the explicit pin win on token exchange, explicit API token, and Copilot-token candidate resolution. Both `headroom wrap copilot --subscription` and `headroom wrap opencode --copilot-subscription` inherit the same fix because they already consume the same `CopilotSubscriptionTokenResolution.api_url`. Refs #2441. Attribution: https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 reported and narrowed the Business or Enterprise regression, and https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026863498 scoped the shared-resolver follow-up that this change implements. ## 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 - Centralize subscription hosted-route selection so explicit `GITHUB_COPILOT_API_URL` pins win on token exchange, explicit API token, and Copilot-token candidate resolution. - Normalize `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to `https://api.githubcopilot.com` by default, extending the existing individual-seat normalization. - Extend focused auth and wrapper tests so both subscription wrappers prove the corrected shared resolver output and the private-proxy isolation contract stays intact. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_copilot_auth.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_copilot.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_persistent.py -q`) - [x] Linting passes (`uv run ruff check .`) - [x] Formatting check passes (`uv run ruff format . --check`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_copilot_auth.py -q -> 84 passed uv run pytest tests/test_cli/test_wrap_copilot.py -q -> 31 passed uv run pytest tests/test_cli/test_wrap_opencode.py -q -> 44 passed in 143.46s uv run pytest tests/test_cli/test_wrap_persistent.py -q -> 31 passed uv run ruff check . -> All checks passed! uv run ruff format . --check -> 1331 files already formatted ``` ## Real Behavior Proof - Environment: Windows - Exact command / steps: Run the focused auth, Copilot wrapper, OpenCode wrapper, and persistent-proxy pytest files after implementing the shared resolver change, then ask lucasp1337 to rerun the Business or Enterprise `--copilot-subscription` scenario from PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 on a real seat. - Observed result: Focused auth and wrapper pytest runs passed locally, including the enterprise-host exchange reproduction row, explicit-pin precedence on all three producer paths, both subscription wrapper routes, and the private-proxy isolation regression. Live Business or Enterprise success stays behind reporter retest. - Not tested: live Business or Enterprise tenant run ## 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 - [ ] 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 have updated the CHANGELOG.md if applicable ## Additional Notes No `CHANGELOG.md` edit is needed because Headroom generates release notes from conventional commits. Risk for maintainers: PR https://github.com/headroomlabs-ai/headroom/pull/641 manually validated a Business seat against the GitHub-returned hosted domain in June on `gpt-5.4`, so generic-by-default could affect tenants that genuinely require a dedicated host. This follow-up keeps the documented escape hatch intact by making `GITHUB_COPILOT_API_URL` win on every path. Live-seat proof boundary: lucasp1337 offered to retest on a Business or Enterprise seat in PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395. Keep any live success claim behind that rerun.
2026-07-20 20:15:57 -04:00
assert ensure["copilot_refresh_oauth_token"] == "gho-oauth"
feat(opencode): support Copilot subscription backend for headroom models (#2441) (#2445) ## Description `headroom wrap opencode` currently routes `headroom/*` models only to ordinary Anthropic or OpenAI backends. A user with a GitHub Copilot subscription cannot point those `headroom/*` requests at the Copilot seat while keeping Headroom compression and stats, even though Headroom already has the validated subscription resolver, the proxy seed path, and the OpenCode provider route needed to do it. This PR adds `--copilot-subscription` to the OpenCode wrap command. It reuses the existing Copilot subscription token resolver, passes the validated endpoint and token seed into the existing proxy startup path, rejects unsupported runtime modes, and treats any non-empty Copilot API token as a private session seed so token-only sessions do not reuse a shared proxy. The generated OpenCode provider still targets the local proxy, and subscription secrets stay out of OpenCode config, environment, and terminal output. Closes #2441 ## 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 - Add `headroom wrap opencode --copilot-subscription` in `headroom/cli/wrap.py`. - Reuse the existing validated Copilot subscription resolver through one small required-resolution helper shared with the dedicated Copilot wrapper. - Pass the resolved endpoint and token seed into `_ensure_proxy()` as `openai_api_url`, `copilot_api_token`, `copilot_refresh_oauth_token`, and `copilot_api_token_expires_at`. - Reject `--copilot-subscription` with `--no-proxy`, `--prepare-only`, and translated backends before proxy or OpenCode launch. - Validate subscription mode before snapshotting OpenCode config, so rejected invocations don't create stale backups. - Scrub inherited Copilot proxy seed variables from the OpenCode child environment. - Treat any non-empty Copilot API token as a private session seed so token-only sessions do not reuse shared or persistent proxies. - Add focused OpenCode and persistent-proxy coverage for seed handoff, guard failures, direct-token isolation, secret non-disclosure, and unchanged non-subscription behavior. - Leave `CHANGELOG.md` untouched because Headroom generates changelog entries from conventional commits. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q`, `106 passed in 136.65s`) - [x] Linting passes (`uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text Targeted subscription tests pass: OpenCode `7 passed, 37 deselected in 0.36s`, persistent proxy `2 passed, 29 deselected in 0.34s`, and dedicated Copilot `11 passed, 20 deselected in 0.39s`. Coverage includes inherited resolver-input env scrubbing, HEADROOM_BACKEND rejection, no-backup-on-rejection, OpenCode-only scrub scoping, and private-proxy teardown on config-injection failure. The full focused command `uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q` passed with `106 passed in 136.65s`. Ruff check and format check pass. ``` ## Real Behavior Proof - Environment: Windows, `uv` development environment, local CLI tests, no live Copilot seat on this host - Exact command / steps: run the focused OpenCode and persistent-proxy tests with a mocked `CopilotSubscriptionTokenResolution`, then capture the proof rows for seed handoff, direct-token isolation, guards, and secret non-disclosure - Observed result: Targeted subscription and proxy-seed tests pass, including OpenCode-only resolver-input env scrubbing and private-proxy teardown on config-injection failure; the full focused command passed with `106 passed in 136.65s`; Ruff check and format check pass. - Not tested: live Copilot subscription seat run on this host ## 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 - [ ] 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 feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - Feature approval comes from the open `enhancement` label on https://github.com/headroomlabs-ai/headroom/issues/2441. - Keep the final live-backend claim behind manual owner proof. Local CLI tests can prove config, guard, secret, and proxy-seed behavior, but they cannot prove a real Copilot seat on this host. - `CHANGELOG.md` remains untouched because Headroom's release pipeline generates changelog entries from conventional commits.
2026-07-20 14:02:14 -04:00
assert ensure["copilot_api_token_expires_at"] == 123.5
launch = captured["launch"]
assert launch["port"] == 9010
assert "copilot-api-secret" not in result.output
assert "copilot-api-secret" not in str(launch["env"])
assert "copilot-refresh-secret" not in str(launch["env"])
assert "copilot-api-secret" not in launch["env"]["OPENCODE_CONFIG_CONTENT"]
assert "GITHUB_COPILOT_API_TOKEN" not in launch["env"]
assert "GITHUB_COPILOT_REFRESH_OAUTH_TOKEN" not in launch["env"]
assert "GITHUB_COPILOT_API_TOKEN_EXPIRES_AT" not in launch["env"]
assert "GITHUB_COPILOT_TOKEN" not in launch["env"]
assert "GITHUB_COPILOT_GITHUB_TOKEN" not in launch["env"]
assert "COPILOT_GITHUB_TOKEN" not in launch["env"]
assert "COPILOT_PROVIDER_BEARER_TOKEN" not in launch["env"]
assert "GH_TOKEN" not in launch["env"]
assert "GITHUB_TOKEN" not in launch["env"]
@pytest.mark.parametrize(
"extra_args, message",
[
(["--no-proxy"], "--no-proxy"),
(["--prepare-only"], "--prepare-only"),
(["--backend", "anyllm"], "translated backends"),
],
)
def test_wrap_opencode_copilot_subscription_rejects_incompatible_modes(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
extra_args: list[str],
message: str,
) -> None:
monkeypatch.chdir(tmp_path)
_set_test_home(monkeypatch, tmp_path)
fix(copilot): normalize subscription API routing (#2441) (#2455) ## Description PR https://github.com/headroomlabs-ai/headroom/pull/2445 added the missing OpenCode subscription path, but the shared Copilot subscription resolver still lets Business and Enterprise payload hosts route through segmented `*.githubcopilot.com` domains and still drops an explicit `GITHUB_COPILOT_API_URL` pin on two resolution paths. This follow-up moves the final hosted-route decision back into the shared resolver, normalizes `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to the generic host by default, and makes the explicit pin win on token exchange, explicit API token, and Copilot-token candidate resolution. Both `headroom wrap copilot --subscription` and `headroom wrap opencode --copilot-subscription` inherit the same fix because they already consume the same `CopilotSubscriptionTokenResolution.api_url`. Refs #2441. Attribution: https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 reported and narrowed the Business or Enterprise regression, and https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026863498 scoped the shared-resolver follow-up that this change implements. ## 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 - Centralize subscription hosted-route selection so explicit `GITHUB_COPILOT_API_URL` pins win on token exchange, explicit API token, and Copilot-token candidate resolution. - Normalize `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to `https://api.githubcopilot.com` by default, extending the existing individual-seat normalization. - Extend focused auth and wrapper tests so both subscription wrappers prove the corrected shared resolver output and the private-proxy isolation contract stays intact. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_copilot_auth.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_copilot.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_persistent.py -q`) - [x] Linting passes (`uv run ruff check .`) - [x] Formatting check passes (`uv run ruff format . --check`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_copilot_auth.py -q -> 84 passed uv run pytest tests/test_cli/test_wrap_copilot.py -q -> 31 passed uv run pytest tests/test_cli/test_wrap_opencode.py -q -> 44 passed in 143.46s uv run pytest tests/test_cli/test_wrap_persistent.py -q -> 31 passed uv run ruff check . -> All checks passed! uv run ruff format . --check -> 1331 files already formatted ``` ## Real Behavior Proof - Environment: Windows - Exact command / steps: Run the focused auth, Copilot wrapper, OpenCode wrapper, and persistent-proxy pytest files after implementing the shared resolver change, then ask lucasp1337 to rerun the Business or Enterprise `--copilot-subscription` scenario from PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 on a real seat. - Observed result: Focused auth and wrapper pytest runs passed locally, including the enterprise-host exchange reproduction row, explicit-pin precedence on all three producer paths, both subscription wrapper routes, and the private-proxy isolation regression. Live Business or Enterprise success stays behind reporter retest. - Not tested: live Business or Enterprise tenant run ## 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 - [ ] 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 have updated the CHANGELOG.md if applicable ## Additional Notes No `CHANGELOG.md` edit is needed because Headroom generates release notes from conventional commits. Risk for maintainers: PR https://github.com/headroomlabs-ai/headroom/pull/641 manually validated a Business seat against the GitHub-returned hosted domain in June on `gpt-5.4`, so generic-by-default could affect tenants that genuinely require a dedicated host. This follow-up keeps the documented escape hatch intact by making `GITHUB_COPILOT_API_URL` win on every path. Live-seat proof boundary: lucasp1337 offered to retest on a Business or Enterprise seat in PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395. Keep any live success claim behind that rerun.
2026-07-20 20:15:57 -04:00
_clear_copilot_route_config(monkeypatch)
feat(opencode): support Copilot subscription backend for headroom models (#2441) (#2445) ## Description `headroom wrap opencode` currently routes `headroom/*` models only to ordinary Anthropic or OpenAI backends. A user with a GitHub Copilot subscription cannot point those `headroom/*` requests at the Copilot seat while keeping Headroom compression and stats, even though Headroom already has the validated subscription resolver, the proxy seed path, and the OpenCode provider route needed to do it. This PR adds `--copilot-subscription` to the OpenCode wrap command. It reuses the existing Copilot subscription token resolver, passes the validated endpoint and token seed into the existing proxy startup path, rejects unsupported runtime modes, and treats any non-empty Copilot API token as a private session seed so token-only sessions do not reuse a shared proxy. The generated OpenCode provider still targets the local proxy, and subscription secrets stay out of OpenCode config, environment, and terminal output. Closes #2441 ## 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 - Add `headroom wrap opencode --copilot-subscription` in `headroom/cli/wrap.py`. - Reuse the existing validated Copilot subscription resolver through one small required-resolution helper shared with the dedicated Copilot wrapper. - Pass the resolved endpoint and token seed into `_ensure_proxy()` as `openai_api_url`, `copilot_api_token`, `copilot_refresh_oauth_token`, and `copilot_api_token_expires_at`. - Reject `--copilot-subscription` with `--no-proxy`, `--prepare-only`, and translated backends before proxy or OpenCode launch. - Validate subscription mode before snapshotting OpenCode config, so rejected invocations don't create stale backups. - Scrub inherited Copilot proxy seed variables from the OpenCode child environment. - Treat any non-empty Copilot API token as a private session seed so token-only sessions do not reuse shared or persistent proxies. - Add focused OpenCode and persistent-proxy coverage for seed handoff, guard failures, direct-token isolation, secret non-disclosure, and unchanged non-subscription behavior. - Leave `CHANGELOG.md` untouched because Headroom generates changelog entries from conventional commits. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q`, `106 passed in 136.65s`) - [x] Linting passes (`uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text Targeted subscription tests pass: OpenCode `7 passed, 37 deselected in 0.36s`, persistent proxy `2 passed, 29 deselected in 0.34s`, and dedicated Copilot `11 passed, 20 deselected in 0.39s`. Coverage includes inherited resolver-input env scrubbing, HEADROOM_BACKEND rejection, no-backup-on-rejection, OpenCode-only scrub scoping, and private-proxy teardown on config-injection failure. The full focused command `uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q` passed with `106 passed in 136.65s`. Ruff check and format check pass. ``` ## Real Behavior Proof - Environment: Windows, `uv` development environment, local CLI tests, no live Copilot seat on this host - Exact command / steps: run the focused OpenCode and persistent-proxy tests with a mocked `CopilotSubscriptionTokenResolution`, then capture the proof rows for seed handoff, direct-token isolation, guards, and secret non-disclosure - Observed result: Targeted subscription and proxy-seed tests pass, including OpenCode-only resolver-input env scrubbing and private-proxy teardown on config-injection failure; the full focused command passed with `106 passed in 136.65s`; Ruff check and format check pass. - Not tested: live Copilot subscription seat run on this host ## 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 - [ ] 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 feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - Feature approval comes from the open `enhancement` label on https://github.com/headroomlabs-ai/headroom/issues/2441. - Keep the final live-backend claim behind manual owner proof. Local CLI tests can prove config, guard, secret, and proxy-seed behavior, but they cannot prove a real Copilot seat on this host. - `CHANGELOG.md` remains untouched because Headroom's release pipeline generates changelog entries from conventional commits.
2026-07-20 14:02:14 -04:00
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
config_file.parent.mkdir(parents=True)
config_file.write_text("{}", encoding="utf-8")
with patch.object(wrap_mod, "_ensure_proxy", side_effect=AssertionError("proxy launched")):
result = runner.invoke(
main,
["wrap", "opencode", "--copilot-subscription", "--no-rtk", "--no-mcp", *extra_args],
)
assert result.exit_code == 1
assert message in result.output
assert not config_file.with_name("opencode.json.headroom-backup").exists()
def test_wrap_opencode_copilot_subscription_rejects_headroom_backend_env(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
_set_test_home(monkeypatch, tmp_path)
fix(copilot): normalize subscription API routing (#2441) (#2455) ## Description PR https://github.com/headroomlabs-ai/headroom/pull/2445 added the missing OpenCode subscription path, but the shared Copilot subscription resolver still lets Business and Enterprise payload hosts route through segmented `*.githubcopilot.com` domains and still drops an explicit `GITHUB_COPILOT_API_URL` pin on two resolution paths. This follow-up moves the final hosted-route decision back into the shared resolver, normalizes `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to the generic host by default, and makes the explicit pin win on token exchange, explicit API token, and Copilot-token candidate resolution. Both `headroom wrap copilot --subscription` and `headroom wrap opencode --copilot-subscription` inherit the same fix because they already consume the same `CopilotSubscriptionTokenResolution.api_url`. Refs #2441. Attribution: https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 reported and narrowed the Business or Enterprise regression, and https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026863498 scoped the shared-resolver follow-up that this change implements. ## 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 - Centralize subscription hosted-route selection so explicit `GITHUB_COPILOT_API_URL` pins win on token exchange, explicit API token, and Copilot-token candidate resolution. - Normalize `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to `https://api.githubcopilot.com` by default, extending the existing individual-seat normalization. - Extend focused auth and wrapper tests so both subscription wrappers prove the corrected shared resolver output and the private-proxy isolation contract stays intact. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_copilot_auth.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_copilot.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_persistent.py -q`) - [x] Linting passes (`uv run ruff check .`) - [x] Formatting check passes (`uv run ruff format . --check`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_copilot_auth.py -q -> 84 passed uv run pytest tests/test_cli/test_wrap_copilot.py -q -> 31 passed uv run pytest tests/test_cli/test_wrap_opencode.py -q -> 44 passed in 143.46s uv run pytest tests/test_cli/test_wrap_persistent.py -q -> 31 passed uv run ruff check . -> All checks passed! uv run ruff format . --check -> 1331 files already formatted ``` ## Real Behavior Proof - Environment: Windows - Exact command / steps: Run the focused auth, Copilot wrapper, OpenCode wrapper, and persistent-proxy pytest files after implementing the shared resolver change, then ask lucasp1337 to rerun the Business or Enterprise `--copilot-subscription` scenario from PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 on a real seat. - Observed result: Focused auth and wrapper pytest runs passed locally, including the enterprise-host exchange reproduction row, explicit-pin precedence on all three producer paths, both subscription wrapper routes, and the private-proxy isolation regression. Live Business or Enterprise success stays behind reporter retest. - Not tested: live Business or Enterprise tenant run ## 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 - [ ] 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 have updated the CHANGELOG.md if applicable ## Additional Notes No `CHANGELOG.md` edit is needed because Headroom generates release notes from conventional commits. Risk for maintainers: PR https://github.com/headroomlabs-ai/headroom/pull/641 manually validated a Business seat against the GitHub-returned hosted domain in June on `gpt-5.4`, so generic-by-default could affect tenants that genuinely require a dedicated host. This follow-up keeps the documented escape hatch intact by making `GITHUB_COPILOT_API_URL` win on every path. Live-seat proof boundary: lucasp1337 offered to retest on a Business or Enterprise seat in PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395. Keep any live success claim behind that rerun.
2026-07-20 20:15:57 -04:00
_clear_copilot_route_config(monkeypatch)
feat(opencode): support Copilot subscription backend for headroom models (#2441) (#2445) ## Description `headroom wrap opencode` currently routes `headroom/*` models only to ordinary Anthropic or OpenAI backends. A user with a GitHub Copilot subscription cannot point those `headroom/*` requests at the Copilot seat while keeping Headroom compression and stats, even though Headroom already has the validated subscription resolver, the proxy seed path, and the OpenCode provider route needed to do it. This PR adds `--copilot-subscription` to the OpenCode wrap command. It reuses the existing Copilot subscription token resolver, passes the validated endpoint and token seed into the existing proxy startup path, rejects unsupported runtime modes, and treats any non-empty Copilot API token as a private session seed so token-only sessions do not reuse a shared proxy. The generated OpenCode provider still targets the local proxy, and subscription secrets stay out of OpenCode config, environment, and terminal output. Closes #2441 ## 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 - Add `headroom wrap opencode --copilot-subscription` in `headroom/cli/wrap.py`. - Reuse the existing validated Copilot subscription resolver through one small required-resolution helper shared with the dedicated Copilot wrapper. - Pass the resolved endpoint and token seed into `_ensure_proxy()` as `openai_api_url`, `copilot_api_token`, `copilot_refresh_oauth_token`, and `copilot_api_token_expires_at`. - Reject `--copilot-subscription` with `--no-proxy`, `--prepare-only`, and translated backends before proxy or OpenCode launch. - Validate subscription mode before snapshotting OpenCode config, so rejected invocations don't create stale backups. - Scrub inherited Copilot proxy seed variables from the OpenCode child environment. - Treat any non-empty Copilot API token as a private session seed so token-only sessions do not reuse shared or persistent proxies. - Add focused OpenCode and persistent-proxy coverage for seed handoff, guard failures, direct-token isolation, secret non-disclosure, and unchanged non-subscription behavior. - Leave `CHANGELOG.md` untouched because Headroom generates changelog entries from conventional commits. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q`, `106 passed in 136.65s`) - [x] Linting passes (`uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text Targeted subscription tests pass: OpenCode `7 passed, 37 deselected in 0.36s`, persistent proxy `2 passed, 29 deselected in 0.34s`, and dedicated Copilot `11 passed, 20 deselected in 0.39s`. Coverage includes inherited resolver-input env scrubbing, HEADROOM_BACKEND rejection, no-backup-on-rejection, OpenCode-only scrub scoping, and private-proxy teardown on config-injection failure. The full focused command `uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q` passed with `106 passed in 136.65s`. Ruff check and format check pass. ``` ## Real Behavior Proof - Environment: Windows, `uv` development environment, local CLI tests, no live Copilot seat on this host - Exact command / steps: run the focused OpenCode and persistent-proxy tests with a mocked `CopilotSubscriptionTokenResolution`, then capture the proof rows for seed handoff, direct-token isolation, guards, and secret non-disclosure - Observed result: Targeted subscription and proxy-seed tests pass, including OpenCode-only resolver-input env scrubbing and private-proxy teardown on config-injection failure; the full focused command passed with `106 passed in 136.65s`; Ruff check and format check pass. - Not tested: live Copilot subscription seat run on this host ## 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 - [ ] 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 feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - Feature approval comes from the open `enhancement` label on https://github.com/headroomlabs-ai/headroom/issues/2441. - Keep the final live-backend claim behind manual owner proof. Local CLI tests can prove config, guard, secret, and proxy-seed behavior, but they cannot prove a real Copilot seat on this host. - `CHANGELOG.md` remains untouched because Headroom's release pipeline generates changelog entries from conventional commits.
2026-07-20 14:02:14 -04:00
monkeypatch.setenv("HEADROOM_BACKEND", "anyllm")
with patch.object(wrap_mod, "_ensure_proxy", side_effect=AssertionError("proxy launched")):
result = runner.invoke(
main,
["wrap", "opencode", "--copilot-subscription", "--no-rtk", "--no-mcp"],
)
assert result.exit_code == 1
assert "translated backends" in result.output
def test_wrap_opencode_copilot_subscription_requires_login_before_launch(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
_set_test_home(monkeypatch, tmp_path)
fix(copilot): normalize subscription API routing (#2441) (#2455) ## Description PR https://github.com/headroomlabs-ai/headroom/pull/2445 added the missing OpenCode subscription path, but the shared Copilot subscription resolver still lets Business and Enterprise payload hosts route through segmented `*.githubcopilot.com` domains and still drops an explicit `GITHUB_COPILOT_API_URL` pin on two resolution paths. This follow-up moves the final hosted-route decision back into the shared resolver, normalizes `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to the generic host by default, and makes the explicit pin win on token exchange, explicit API token, and Copilot-token candidate resolution. Both `headroom wrap copilot --subscription` and `headroom wrap opencode --copilot-subscription` inherit the same fix because they already consume the same `CopilotSubscriptionTokenResolution.api_url`. Refs #2441. Attribution: https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 reported and narrowed the Business or Enterprise regression, and https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026863498 scoped the shared-resolver follow-up that this change implements. ## 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 - Centralize subscription hosted-route selection so explicit `GITHUB_COPILOT_API_URL` pins win on token exchange, explicit API token, and Copilot-token candidate resolution. - Normalize `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to `https://api.githubcopilot.com` by default, extending the existing individual-seat normalization. - Extend focused auth and wrapper tests so both subscription wrappers prove the corrected shared resolver output and the private-proxy isolation contract stays intact. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_copilot_auth.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_copilot.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_persistent.py -q`) - [x] Linting passes (`uv run ruff check .`) - [x] Formatting check passes (`uv run ruff format . --check`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_copilot_auth.py -q -> 84 passed uv run pytest tests/test_cli/test_wrap_copilot.py -q -> 31 passed uv run pytest tests/test_cli/test_wrap_opencode.py -q -> 44 passed in 143.46s uv run pytest tests/test_cli/test_wrap_persistent.py -q -> 31 passed uv run ruff check . -> All checks passed! uv run ruff format . --check -> 1331 files already formatted ``` ## Real Behavior Proof - Environment: Windows - Exact command / steps: Run the focused auth, Copilot wrapper, OpenCode wrapper, and persistent-proxy pytest files after implementing the shared resolver change, then ask lucasp1337 to rerun the Business or Enterprise `--copilot-subscription` scenario from PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 on a real seat. - Observed result: Focused auth and wrapper pytest runs passed locally, including the enterprise-host exchange reproduction row, explicit-pin precedence on all three producer paths, both subscription wrapper routes, and the private-proxy isolation regression. Live Business or Enterprise success stays behind reporter retest. - Not tested: live Business or Enterprise tenant run ## 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 - [ ] 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 have updated the CHANGELOG.md if applicable ## Additional Notes No `CHANGELOG.md` edit is needed because Headroom generates release notes from conventional commits. Risk for maintainers: PR https://github.com/headroomlabs-ai/headroom/pull/641 manually validated a Business seat against the GitHub-returned hosted domain in June on `gpt-5.4`, so generic-by-default could affect tenants that genuinely require a dedicated host. This follow-up keeps the documented escape hatch intact by making `GITHUB_COPILOT_API_URL` win on every path. Live-seat proof boundary: lucasp1337 offered to retest on a Business or Enterprise seat in PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395. Keep any live success claim behind that rerun.
2026-07-20 20:15:57 -04:00
_clear_copilot_route_config(monkeypatch)
feat(opencode): support Copilot subscription backend for headroom models (#2441) (#2445) ## Description `headroom wrap opencode` currently routes `headroom/*` models only to ordinary Anthropic or OpenAI backends. A user with a GitHub Copilot subscription cannot point those `headroom/*` requests at the Copilot seat while keeping Headroom compression and stats, even though Headroom already has the validated subscription resolver, the proxy seed path, and the OpenCode provider route needed to do it. This PR adds `--copilot-subscription` to the OpenCode wrap command. It reuses the existing Copilot subscription token resolver, passes the validated endpoint and token seed into the existing proxy startup path, rejects unsupported runtime modes, and treats any non-empty Copilot API token as a private session seed so token-only sessions do not reuse a shared proxy. The generated OpenCode provider still targets the local proxy, and subscription secrets stay out of OpenCode config, environment, and terminal output. Closes #2441 ## 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 - Add `headroom wrap opencode --copilot-subscription` in `headroom/cli/wrap.py`. - Reuse the existing validated Copilot subscription resolver through one small required-resolution helper shared with the dedicated Copilot wrapper. - Pass the resolved endpoint and token seed into `_ensure_proxy()` as `openai_api_url`, `copilot_api_token`, `copilot_refresh_oauth_token`, and `copilot_api_token_expires_at`. - Reject `--copilot-subscription` with `--no-proxy`, `--prepare-only`, and translated backends before proxy or OpenCode launch. - Validate subscription mode before snapshotting OpenCode config, so rejected invocations don't create stale backups. - Scrub inherited Copilot proxy seed variables from the OpenCode child environment. - Treat any non-empty Copilot API token as a private session seed so token-only sessions do not reuse shared or persistent proxies. - Add focused OpenCode and persistent-proxy coverage for seed handoff, guard failures, direct-token isolation, secret non-disclosure, and unchanged non-subscription behavior. - Leave `CHANGELOG.md` untouched because Headroom generates changelog entries from conventional commits. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q`, `106 passed in 136.65s`) - [x] Linting passes (`uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text Targeted subscription tests pass: OpenCode `7 passed, 37 deselected in 0.36s`, persistent proxy `2 passed, 29 deselected in 0.34s`, and dedicated Copilot `11 passed, 20 deselected in 0.39s`. Coverage includes inherited resolver-input env scrubbing, HEADROOM_BACKEND rejection, no-backup-on-rejection, OpenCode-only scrub scoping, and private-proxy teardown on config-injection failure. The full focused command `uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q` passed with `106 passed in 136.65s`. Ruff check and format check pass. ``` ## Real Behavior Proof - Environment: Windows, `uv` development environment, local CLI tests, no live Copilot seat on this host - Exact command / steps: run the focused OpenCode and persistent-proxy tests with a mocked `CopilotSubscriptionTokenResolution`, then capture the proof rows for seed handoff, direct-token isolation, guards, and secret non-disclosure - Observed result: Targeted subscription and proxy-seed tests pass, including OpenCode-only resolver-input env scrubbing and private-proxy teardown on config-injection failure; the full focused command passed with `106 passed in 136.65s`; Ruff check and format check pass. - Not tested: live Copilot subscription seat run on this host ## 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 - [ ] 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 feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - Feature approval comes from the open `enhancement` label on https://github.com/headroomlabs-ai/headroom/issues/2441. - Keep the final live-backend claim behind manual owner proof. Local CLI tests can prove config, guard, secret, and proxy-seed behavior, but they cannot prove a real Copilot seat on this host. - `CHANGELOG.md` remains untouched because Headroom's release pipeline generates changelog entries from conventional commits.
2026-07-20 14:02:14 -04:00
with (
patch.object(
wrap_mod,
"resolve_subscription_bearer_token_details",
return_value=None,
),
patch.object(wrap_mod, "_ensure_proxy", side_effect=AssertionError("proxy launched")),
):
result = runner.invoke(
main,
["wrap", "opencode", "--copilot-subscription", "--no-rtk", "--no-mcp"],
)
assert result.exit_code == 1
assert "headroom copilot-auth login" in result.output
def test_wrap_opencode_copilot_subscription_cleans_up_proxy_on_config_failure(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
_set_test_home(monkeypatch, tmp_path)
fix(copilot): normalize subscription API routing (#2441) (#2455) ## Description PR https://github.com/headroomlabs-ai/headroom/pull/2445 added the missing OpenCode subscription path, but the shared Copilot subscription resolver still lets Business and Enterprise payload hosts route through segmented `*.githubcopilot.com` domains and still drops an explicit `GITHUB_COPILOT_API_URL` pin on two resolution paths. This follow-up moves the final hosted-route decision back into the shared resolver, normalizes `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to the generic host by default, and makes the explicit pin win on token exchange, explicit API token, and Copilot-token candidate resolution. Both `headroom wrap copilot --subscription` and `headroom wrap opencode --copilot-subscription` inherit the same fix because they already consume the same `CopilotSubscriptionTokenResolution.api_url`. Refs #2441. Attribution: https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 reported and narrowed the Business or Enterprise regression, and https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026863498 scoped the shared-resolver follow-up that this change implements. ## 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 - Centralize subscription hosted-route selection so explicit `GITHUB_COPILOT_API_URL` pins win on token exchange, explicit API token, and Copilot-token candidate resolution. - Normalize `api.business.githubcopilot.com` and `api.enterprise.githubcopilot.com` to `https://api.githubcopilot.com` by default, extending the existing individual-seat normalization. - Extend focused auth and wrapper tests so both subscription wrappers prove the corrected shared resolver output and the private-proxy isolation contract stays intact. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_copilot_auth.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_copilot.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_persistent.py -q`) - [x] Linting passes (`uv run ruff check .`) - [x] Formatting check passes (`uv run ruff format . --check`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_copilot_auth.py -q -> 84 passed uv run pytest tests/test_cli/test_wrap_copilot.py -q -> 31 passed uv run pytest tests/test_cli/test_wrap_opencode.py -q -> 44 passed in 143.46s uv run pytest tests/test_cli/test_wrap_persistent.py -q -> 31 passed uv run ruff check . -> All checks passed! uv run ruff format . --check -> 1331 files already formatted ``` ## Real Behavior Proof - Environment: Windows - Exact command / steps: Run the focused auth, Copilot wrapper, OpenCode wrapper, and persistent-proxy pytest files after implementing the shared resolver change, then ask lucasp1337 to rerun the Business or Enterprise `--copilot-subscription` scenario from PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395 on a real seat. - Observed result: Focused auth and wrapper pytest runs passed locally, including the enterprise-host exchange reproduction row, explicit-pin precedence on all three producer paths, both subscription wrapper routes, and the private-proxy isolation regression. Live Business or Enterprise success stays behind reporter retest. - Not tested: live Business or Enterprise tenant run ## 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 - [ ] 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 have updated the CHANGELOG.md if applicable ## Additional Notes No `CHANGELOG.md` edit is needed because Headroom generates release notes from conventional commits. Risk for maintainers: PR https://github.com/headroomlabs-ai/headroom/pull/641 manually validated a Business seat against the GitHub-returned hosted domain in June on `gpt-5.4`, so generic-by-default could affect tenants that genuinely require a dedicated host. This follow-up keeps the documented escape hatch intact by making `GITHUB_COPILOT_API_URL` win on every path. Live-seat proof boundary: lucasp1337 offered to retest on a Business or Enterprise seat in PR https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395. Keep any live success claim behind that rerun.
2026-07-20 20:15:57 -04:00
_clear_copilot_route_config(monkeypatch)
feat(opencode): support Copilot subscription backend for headroom models (#2441) (#2445) ## Description `headroom wrap opencode` currently routes `headroom/*` models only to ordinary Anthropic or OpenAI backends. A user with a GitHub Copilot subscription cannot point those `headroom/*` requests at the Copilot seat while keeping Headroom compression and stats, even though Headroom already has the validated subscription resolver, the proxy seed path, and the OpenCode provider route needed to do it. This PR adds `--copilot-subscription` to the OpenCode wrap command. It reuses the existing Copilot subscription token resolver, passes the validated endpoint and token seed into the existing proxy startup path, rejects unsupported runtime modes, and treats any non-empty Copilot API token as a private session seed so token-only sessions do not reuse a shared proxy. The generated OpenCode provider still targets the local proxy, and subscription secrets stay out of OpenCode config, environment, and terminal output. Closes #2441 ## 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 - Add `headroom wrap opencode --copilot-subscription` in `headroom/cli/wrap.py`. - Reuse the existing validated Copilot subscription resolver through one small required-resolution helper shared with the dedicated Copilot wrapper. - Pass the resolved endpoint and token seed into `_ensure_proxy()` as `openai_api_url`, `copilot_api_token`, `copilot_refresh_oauth_token`, and `copilot_api_token_expires_at`. - Reject `--copilot-subscription` with `--no-proxy`, `--prepare-only`, and translated backends before proxy or OpenCode launch. - Validate subscription mode before snapshotting OpenCode config, so rejected invocations don't create stale backups. - Scrub inherited Copilot proxy seed variables from the OpenCode child environment. - Treat any non-empty Copilot API token as a private session seed so token-only sessions do not reuse shared or persistent proxies. - Add focused OpenCode and persistent-proxy coverage for seed handoff, guard failures, direct-token isolation, secret non-disclosure, and unchanged non-subscription behavior. - Leave `CHANGELOG.md` untouched because Headroom generates changelog entries from conventional commits. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q`, `106 passed in 136.65s`) - [x] Linting passes (`uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text Targeted subscription tests pass: OpenCode `7 passed, 37 deselected in 0.36s`, persistent proxy `2 passed, 29 deselected in 0.34s`, and dedicated Copilot `11 passed, 20 deselected in 0.39s`. Coverage includes inherited resolver-input env scrubbing, HEADROOM_BACKEND rejection, no-backup-on-rejection, OpenCode-only scrub scoping, and private-proxy teardown on config-injection failure. The full focused command `uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q` passed with `106 passed in 136.65s`. Ruff check and format check pass. ``` ## Real Behavior Proof - Environment: Windows, `uv` development environment, local CLI tests, no live Copilot seat on this host - Exact command / steps: run the focused OpenCode and persistent-proxy tests with a mocked `CopilotSubscriptionTokenResolution`, then capture the proof rows for seed handoff, direct-token isolation, guards, and secret non-disclosure - Observed result: Targeted subscription and proxy-seed tests pass, including OpenCode-only resolver-input env scrubbing and private-proxy teardown on config-injection failure; the full focused command passed with `106 passed in 136.65s`; Ruff check and format check pass. - Not tested: live Copilot subscription seat run on this host ## 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 - [ ] 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 feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - Feature approval comes from the open `enhancement` label on https://github.com/headroomlabs-ai/headroom/issues/2441. - Keep the final live-backend claim behind manual owner proof. Local CLI tests can prove config, guard, secret, and proxy-seed behavior, but they cannot prove a real Copilot seat on this host. - `CHANGELOG.md` remains untouched because Headroom's release pipeline generates changelog entries from conventional commits.
2026-07-20 14:02:14 -04:00
class _FakeProxy:
def __init__(self) -> None:
self.terminated = False
self.wait_timeout: float | None = None
def poll(self) -> None:
return None
def terminate(self) -> None:
self.terminated = True
def wait(self, timeout: float | None = None) -> int:
self.wait_timeout = timeout
return 0
proxy = _FakeProxy()
with (
patch.object(wrap_mod.shutil, "which", return_value="opencode"),
patch.object(
wrap_mod,
"_require_copilot_subscription_resolution",
return_value=_subscription_resolution(),
),
patch.object(wrap_mod, "_ensure_proxy", return_value=(proxy, 9010)),
patch.object(wrap_mod, "_register_proxy_client"),
patch.object(wrap_mod, "_unregister_proxy_client"),
patch.object(wrap_mod, "_live_proxy_clients", return_value=[]),
patch.object(
wrap_mod,
"inject_opencode_provider_config",
side_effect=RuntimeError("config write failed"),
),
patch.object(wrap_mod, "_launch_tool", side_effect=AssertionError("launch should not run")),
):
result = runner.invoke(
main,
[
"wrap",
"opencode",
"--copilot-subscription",
"--no-rtk",
"--no-mcp",
"--no-serena",
],
)
assert result.exit_code == 1
assert isinstance(result.exception, RuntimeError)
assert str(result.exception) == "config write failed"
assert proxy.terminated is True
assert proxy.wait_timeout == 5
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 18:07:12 +02:00
def test_wrap_opencode_sets_config_content_env(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""OPENCODE_CONFIG_CONTENT env var is set with the headroom provider."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
monkeypatch.setenv("OPENAI_BASE_URL", "https://deepseek.example/v1")
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://anthropic.example")
captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003
captured.update(kwargs)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(
ci: restore green lint (reformat for ruff 0.15.17, fix mypy no-any-return, pin linters) (#1295) ## Description The CI lint job (`ruff check .` → `ruff format --check .` → `mypy headroom`) was red on `main` and therefore on every open PR, for two unrelated reasons that the early ruff failure was masking: 1. **ruff**: the lint job installs `ruff` unpinned, and ruff 0.15.17 began enforcing import-block sorting (`I001`) and formatting that older ruff accepted → `ruff check .` / `ruff format --check .` fail on files nobody touched. 2. **mypy**: `headroom/providers/opencode/config.py` had two `return json.loads(...)` statements in a function declared `-> dict[str, Any]`; `json.loads` is typed `Any`, so `mypy headroom` fails with `no-any-return` (reproduced on mypy 1.20.2 — not a version-specific quirk). This restores a green lint baseline and pins both linters so a future release can't silently break CI again. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `.github/workflows/ci.yml`: pin `ruff==0.15.17` and `mypy==1.20.2` in the lint job. - Applied `ruff check --fix .` (6 `I001` import-sort fixes) and `ruff format .` (10 files) across the repo — import ordering and whitespace only, no behavior change. - `headroom/providers/opencode/config.py`: narrow both `_parse_json_loose` return sites with an `isinstance(parsed, dict)` guard, so the `dict[str, Any]` annotation is true at runtime (non-dict JSON falls back to `{}`) and mypy's `no-any-return` is resolved. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`, `ruff format --check .`, `mypy headroom --ignore-missing-imports`) ### Test Output ```text $ python -m ruff check . All checks passed! $ python -m ruff format --check . 913 files already formatted $ python -m mypy headroom/providers/opencode/config.py --ignore-missing-imports Success: no issues found in 1 source file $ python -m pytest tests/test_providers_opencode_config.py -q 37 passed ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13, ruff 0.15.17, mypy 1.20.2, branch ci/fix-ruff-lint off headroomlabs-ai/main - Exact command / steps: reproduced the red lint (latest ruff: 6 `I001` + 10 unformatted files; the mypy failure was read from the #1295 CI lint log — `config.py:125,133 no-any-return`, and re-confirmed locally on mypy 1.20.2). Applied the ruff auto-fix/format, added the dict guard, pinned both linters, and re-ran each lint step. - Observed result: `ruff check .` → "All checks passed!"; `ruff format --check .` → "913 files already formatted"; `mypy` on the fixed file → "Success: no issues found"; full `mypy headroom` reports only Unix `fcntl` attributes that don't exist on this Windows box (present on the Linux CI runner, where the prior run showed exactly the two now-fixed errors). 37 opencode-config tests pass. - Not tested: did not run the full OS/Python test matrix — the change is formatting + two CI dependency pins + a two-line type-narrowing guard, with no runtime behavior change for dict JSON. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:14:40 +02:00
main,
["wrap", "opencode", "--port", "9000", "--no-mcp", "--", "--model", "gpt-4o"],
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 18:07:12 +02:00
)
assert result.exit_code == 0, result.output
env = captured["env"]
assert isinstance(env, dict)
assert "OPENCODE_CONFIG_CONTENT" in env
config = json.loads(env["OPENCODE_CONFIG_CONTENT"])
assert config["provider"]["headroom"]["npm"] == "@ai-sdk/openai-compatible"
assert config["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:9000/v1"
assert "model" not in config # headroom provider is a transparent pass-through
assert captured["tool_label"] == "OPENCODE"
assert captured["agent_type"] == "opencode"
assert captured["args"] == ("--model", "gpt-4o")
def test_wrap_opencode_does_not_add_base_url_env_vars(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""OPENAI_BASE_URL and ANTHROPIC_BASE_URL are left to OpenCode providers."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
monkeypatch.setenv("OPENAI_BASE_URL", "https://deepseek.example/v1")
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://anthropic.example")
captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003
captured.update(kwargs)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
assert result.exit_code == 0, result.output
env = captured["env"]
assert isinstance(env, dict)
assert env["OPENAI_BASE_URL"] == "https://deepseek.example/v1"
assert env["ANTHROPIC_BASE_URL"] == "https://anthropic.example"
def test_wrap_opencode_missing_binary_errors_clearly(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If the opencode binary is missing the command must fail with a clear error."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
with patch.object(wrap_mod.shutil, "which", return_value=None):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "opencode"])
assert result.exit_code == 1
assert "'opencode' not found in PATH" in result.output
def test_wrap_opencode_prepare_only_injects_config(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`wrap opencode --prepare-only` writes the provider config to opencode.json."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--prepare-only"])
assert result.exit_code == 0, result.output
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
assert config_file.exists()
fix(cli): add explicit UTF-8 encoding to file I/O in wrap commands (#1126) (#1164) ## Description On Windows, `Path.read_text()` and `open()` default to the system locale encoding (cp1252, GBK, etc.) instead of UTF-8. This causes `UnicodeDecodeError` when reading or writing instruction files that contain multi-byte UTF-8 characters such as smart quotes or em dashes. The RTK instructions block itself contains an em dash (U+2014, `—`), so `_inject_rtk_instructions` crashes on any non-UTF-8 Windows locale when writing to `.copilot_instructions`, `.clinerules`, `AGENTS.md`, or similar hint files. Closes #1126 ## 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 - Add `encoding="utf-8"` to all bare `read_text()`, `write_text()`, and `open()` calls in `headroom/cli/wrap.py` that handle instruction or config files (18 call sites) - Update test assertions in `test_wrap_hintfile_agents.py`, `test_wrap_copilot.py`, and `test_wrap_bridge.py` to read with `encoding="utf-8"` - Add `test_inject_rtk_handles_utf8_content` verifying that existing hint files with smart quotes and em dashes survive RTK injection without crashing ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cli/test_wrap_hintfile_agents.py tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_bridge.py -v 47 passed in 1.28s ``` ## Real Behavior Proof - Environment: Windows 11 China (GBK locale), Python 3.11, headroom main (f03e77b) - Exact command / steps: python -m pytest tests/test_cli/test_wrap_hintfile_agents.py -v (on GBK Windows) - Observed result: Before fix, test_prepare_only_injects_rtk_into_hintfile fails with UnicodeDecodeError 'gbk' at position 283 (the em dash in RTK block). After fix, all 12 hintfile tests pass including new UTF-8 round-trip test. - Not tested: no manual `headroom wrap copilot` run against a real Copilot installation ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes This is the same class of bug reported in #733 (GBK config.toml corruption). This PR fixes the `wrap.py` call sites; other modules (`learn/analyzer.py`, `install/providers.py`) have the same pattern and could benefit from the same treatment in a follow-up. --------- Signed-off-by: Yiming Zeng <yzeng424@gmail.com> Signed-off-by: RTCartist <wangshengb@buaa.edu.cn> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 01:07:03 +08:00
config = json.loads(config_file.read_text(encoding="utf-8"))
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 18:07:12 +02:00
assert config["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:9000/v1"
fix(opencode): use local MCP config (#1383) ## Description Fixes OpenCode Headroom MCP configuration across wrap, MCP install/status/uninstall, and persistent install docs/CLI. OpenCode was being configured to use a remote HTTP MCP endpoint at `/mcp`, but the Headroom proxy does not expose MCP there. The correct OpenCode configuration is a local stdio MCP server that runs `headroom mcp serve`. Closes #1380 ## Type of Change - [x] Bug fix - [ ] New feature - [ ] Breaking change - [x] Documentation update - [x] Tests ## Changes Made - Changed OpenCode MCP registration to emit `type: "local"` with `command: ["headroom", "mcp", "serve"]`. - Changed OpenCode MCP environment serialization from `env` to OpenCode's `environment` key, while still reading legacy `env` entries. - Removed generated remote `/mcp` entries from OpenCode wrap/runtime config. - Made `wrap opencode --no-mcp` skip persistent `mcp.headroom` injection. - Kept provider-only OpenCode config injection from writing MCP; MCP persistence is owned by the registrar path. - Made `headroom mcp status` and `headroom mcp uninstall` use the registrar lifecycle so OpenCode is covered. - Added `opencode` to persistent install `--target` choices. - Clarified OpenCode persistent install docs to use `--scope provider` for direct `opencode.json` edits. - Added regression coverage for registrar serialization, wrap behavior, runtime config, provider-scope install, MCP CLI lifecycle, and install target parsing. ## Testing - [x] `rtk .venv/bin/python -m pytest tests/test_mcp_registry tests/test_cli/test_mcp.py tests/test_cli/test_wrap_opencode.py tests/test_providers_opencode_config.py tests/test_providers_opencode_install.py tests/test_install -q` - [x] Result after absorbing #1381 overlap: `263 passed, 1 skipped` - [x] Targeted Ruff check passed for the changed Python/test files. - [x] Targeted Ruff format check passed for the changed Python/test files. - [x] Isolated HOME smoke tests with real `opencode mcp list --pure`. ## Real Behavior Proof - `headroom mcp install --agent opencode --proxy-url http://127.0.0.1:9000 --force` against an isolated HOME wrote a valid local OpenCode MCP entry with `environment.HEADROOM_PROXY_URL`. - `opencode mcp list --pure` against that isolated HOME connected to `headroom mcp serve`. - `headroom wrap opencode --prepare-only --no-rtk --no-serena --port 9001` wrote local MCP plus provider config. - `headroom wrap opencode --prepare-only --no-rtk --no-serena --no-mcp --port 9002` wrote provider config without `mcp.headroom`. - Generated runtime `OPENCODE_CONFIG_CONTENT` was accepted by `opencode mcp list --pure`; `include_mcp=False` reported no MCP servers. - `headroom mcp status` detected the isolated OpenCode config and read the custom proxy URL. - `headroom mcp uninstall` removed `mcp.headroom` from the isolated OpenCode config while leaving provider config intact. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review
2026-07-06 15:22:15 +02:00
def test_wrap_opencode_prepare_only_registers_serena_with_agent_context(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "opencode", "--prepare-only"])
assert result.exit_code == 0, result.output
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
config = json.loads(config_file.read_text())
serena_command = config["mcp"]["serena"]["command"]
assert serena_command[serena_command.index("--context") + 1] == "agent"
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 18:07:12 +02:00
def test_wrap_opencode_no_mcp_skips_mcp_injection(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`--no-mcp` skips MCP server injection."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003
captured.update(kwargs)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
assert result.exit_code == 0, result.output
env = captured["env"]
config = json.loads(env["OPENCODE_CONFIG_CONTENT"])
assert "mcp" not in config
fix(opencode): write local MCP config (#1381) ## Description Fixes the OpenCode config corruption reported in #1380 for wrap, MCP registration, and provider-scope install paths. OpenCode MCP entries are local stdio servers, not remote HTTP endpoints. This changes Headroom's OpenCode MCP serialization to write `type: "local"` with `command: ["headroom", "mcp", "serve"]`, uses OpenCode's `environment` field for MCP env vars, and still reads the older `env` key for compatibility. This also stops provider-only OpenCode config injection from creating a fake `http://127.0.0.1:<port>/mcp` entry, so `headroom wrap opencode --no-mcp` no longer leaves `mcp.headroom` behind. Finally, the install CLI/docs now accept and document `--target opencode` with provider scope. This does not change the broader `headroom mcp status/uninstall` behavior from #1380; that looks like a separate follow-up. ## 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) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Write OpenCode MCP entries as local stdio config instead of remote `/mcp` config. - Use `environment` for OpenCode MCP env vars while continuing to read legacy `env` entries. - Stop OpenCode provider injection/persistent provider install from adding MCP config. - Keep `--no-mcp` from writing `mcp.headroom` while preserving other MCP entries such as Serena. - Allow `headroom install apply --target opencode` at the CLI layer. - Update OpenCode docs and changelog. ## Testing - [x] Focused unit tests pass - [x] Linting passes (`ruff check .`) - [x] Formatting passes (`ruff format --check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for the fixed behavior - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_mcp_registry_opencode.py tests/test_cli/test_wrap_opencode.py tests/test_providers_opencode_config.py tests/test_providers_opencode_install.py tests/test_cli/test_install_cli.py tests/test_install/test_providers.py Pytest: 164 passed $ uvx ruff check . All checks passed! $ uvx ruff format --check . 986 files already formatted $ uvx mypy --config-file pyproject.toml headroom Success: no issues found in 398 source files ``` ## Real Behavior Proof - Environment: macOS local worktree at `/Users/vinaygupta/Desktop/git/headroom-fix-opencode-mcp-config`; branch `fix-opencode-mcp-config`; commit `aea96208`. - Exact command / steps: ran the focused OpenCode/installer regression suite plus Ruff lint/format checks and mypy commands shown above. - Observed result: the focused tests pass and cover OpenCode MCP serialization as `type: "local"`, `command: ["headroom", "mcp", "serve"]`, `environment` env vars, `--no-mcp` not writing `mcp.headroom`, provider-scope install not adding MCP config, and `install apply --target opencode` being accepted. - Not tested: full `pytest` locally, because collection requires the native `headroom._core` extension in this worktree. Attempting the project runner hit a local native build failure first: `esaxx-rs` failed compiling `src/esaxx.cpp` with `fatal error: 'cstdint' file not found`. The broader generic `headroom mcp status/uninstall` behavior from #1380 is intentionally left for a follow-up. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review Scope note: generic `mcp status/uninstall` support from #1380 is intentionally left as a separate follow-up PR.
2026-06-26 12:23:54 -05:00
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
persisted_config = json.loads(config_file.read_text())
assert "headroom" not in persisted_config.get("mcp", {})
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 18:07:12 +02:00
def test_wrap_opencode_injects_mcp_by_default(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""MCP is included in OPENCODE_CONFIG_CONTENT by default."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003
captured.update(kwargs)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000"])
assert result.exit_code == 0, result.output
env = captured["env"]
config = json.loads(env["OPENCODE_CONFIG_CONTENT"])
assert "mcp" in config
fix(opencode): write local MCP config (#1381) ## Description Fixes the OpenCode config corruption reported in #1380 for wrap, MCP registration, and provider-scope install paths. OpenCode MCP entries are local stdio servers, not remote HTTP endpoints. This changes Headroom's OpenCode MCP serialization to write `type: "local"` with `command: ["headroom", "mcp", "serve"]`, uses OpenCode's `environment` field for MCP env vars, and still reads the older `env` key for compatibility. This also stops provider-only OpenCode config injection from creating a fake `http://127.0.0.1:<port>/mcp` entry, so `headroom wrap opencode --no-mcp` no longer leaves `mcp.headroom` behind. Finally, the install CLI/docs now accept and document `--target opencode` with provider scope. This does not change the broader `headroom mcp status/uninstall` behavior from #1380; that looks like a separate follow-up. ## 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) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Write OpenCode MCP entries as local stdio config instead of remote `/mcp` config. - Use `environment` for OpenCode MCP env vars while continuing to read legacy `env` entries. - Stop OpenCode provider injection/persistent provider install from adding MCP config. - Keep `--no-mcp` from writing `mcp.headroom` while preserving other MCP entries such as Serena. - Allow `headroom install apply --target opencode` at the CLI layer. - Update OpenCode docs and changelog. ## Testing - [x] Focused unit tests pass - [x] Linting passes (`ruff check .`) - [x] Formatting passes (`ruff format --check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for the fixed behavior - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_mcp_registry_opencode.py tests/test_cli/test_wrap_opencode.py tests/test_providers_opencode_config.py tests/test_providers_opencode_install.py tests/test_cli/test_install_cli.py tests/test_install/test_providers.py Pytest: 164 passed $ uvx ruff check . All checks passed! $ uvx ruff format --check . 986 files already formatted $ uvx mypy --config-file pyproject.toml headroom Success: no issues found in 398 source files ``` ## Real Behavior Proof - Environment: macOS local worktree at `/Users/vinaygupta/Desktop/git/headroom-fix-opencode-mcp-config`; branch `fix-opencode-mcp-config`; commit `aea96208`. - Exact command / steps: ran the focused OpenCode/installer regression suite plus Ruff lint/format checks and mypy commands shown above. - Observed result: the focused tests pass and cover OpenCode MCP serialization as `type: "local"`, `command: ["headroom", "mcp", "serve"]`, `environment` env vars, `--no-mcp` not writing `mcp.headroom`, provider-scope install not adding MCP config, and `install apply --target opencode` being accepted. - Not tested: full `pytest` locally, because collection requires the native `headroom._core` extension in this worktree. Attempting the project runner hit a local native build failure first: `esaxx-rs` failed compiling `src/esaxx.cpp` with `fatal error: 'cstdint' file not found`. The broader generic `headroom mcp status/uninstall` behavior from #1380 is intentionally left for a follow-up. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review Scope note: generic `mcp status/uninstall` support from #1380 is intentionally left as a separate follow-up PR.
2026-06-26 12:23:54 -05:00
assert config["mcp"]["headroom"] == {
"type": "local",
"command": ["headroom", "mcp", "serve"],
"enabled": True,
"environment": {"HEADROOM_PROXY_URL": "http://127.0.0.1:9000"},
}
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 18:07:12 +02:00
def test_wrap_opencode_injects_rtk_into_agents_md(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""RTK instructions are injected into global and project AGENTS.md."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
assert result.exit_code == 0, result.output
global_agents = tmp_path / ".config" / "opencode" / "AGENTS.md"
project_agents = tmp_path / "AGENTS.md"
assert global_agents.exists(), "Global AGENTS.md should be created"
assert project_agents.exists(), "Project AGENTS.md should be created"
fix(cli): add explicit UTF-8 encoding to file I/O in wrap commands (#1126) (#1164) ## Description On Windows, `Path.read_text()` and `open()` default to the system locale encoding (cp1252, GBK, etc.) instead of UTF-8. This causes `UnicodeDecodeError` when reading or writing instruction files that contain multi-byte UTF-8 characters such as smart quotes or em dashes. The RTK instructions block itself contains an em dash (U+2014, `—`), so `_inject_rtk_instructions` crashes on any non-UTF-8 Windows locale when writing to `.copilot_instructions`, `.clinerules`, `AGENTS.md`, or similar hint files. Closes #1126 ## 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 - Add `encoding="utf-8"` to all bare `read_text()`, `write_text()`, and `open()` calls in `headroom/cli/wrap.py` that handle instruction or config files (18 call sites) - Update test assertions in `test_wrap_hintfile_agents.py`, `test_wrap_copilot.py`, and `test_wrap_bridge.py` to read with `encoding="utf-8"` - Add `test_inject_rtk_handles_utf8_content` verifying that existing hint files with smart quotes and em dashes survive RTK injection without crashing ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cli/test_wrap_hintfile_agents.py tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_bridge.py -v 47 passed in 1.28s ``` ## Real Behavior Proof - Environment: Windows 11 China (GBK locale), Python 3.11, headroom main (f03e77b) - Exact command / steps: python -m pytest tests/test_cli/test_wrap_hintfile_agents.py -v (on GBK Windows) - Observed result: Before fix, test_prepare_only_injects_rtk_into_hintfile fails with UnicodeDecodeError 'gbk' at position 283 (the em dash in RTK block). After fix, all 12 hintfile tests pass including new UTF-8 round-trip test. - Not tested: no manual `headroom wrap copilot` run against a real Copilot installation ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes This is the same class of bug reported in #733 (GBK config.toml corruption). This PR fixes the `wrap.py` call sites; other modules (`learn/analyzer.py`, `install/providers.py`) have the same pattern and could benefit from the same treatment in a follow-up. --------- Signed-off-by: Yiming Zeng <yzeng424@gmail.com> Signed-off-by: RTCartist <wangshengb@buaa.edu.cn> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 01:07:03 +08:00
assert wrap_mod._RTK_MARKER in global_agents.read_text(encoding="utf-8")
assert wrap_mod._RTK_MARKER in project_agents.read_text(encoding="utf-8")
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 18:07:12 +02:00
fix(wrap/opencode): unwrap removes the rtk block from AGENTS.md (#2025) ## Description `headroom wrap opencode` injects the marker-fenced rtk guidance block — "prefix shell commands with `rtk`" — into **both** instruction files (`headroom/cli/wrap.py`): ```python # wrap opencode project_agents = Path.cwd() / "AGENTS.md" _inject_rtk_instructions(project_agents, verbose=verbose) global_agents = _opencode_home_dir() / "AGENTS.md" _inject_rtk_instructions(global_agents, verbose=verbose) ``` But `unwrap_opencode` only restores the OpenCode config and cleans up MCP servers — it never removes that rtk block. So after `unwrap opencode`, both `AGENTS.md` files still contain the marker-fenced instruction, and a plain `opencode` launch keeps following "prefix shell commands with `rtk`" and fails once the managed rtk binary is off PATH. `unwrap_codex` (#1421) and `unwrap_copilot` both already do this cleanup via `_remove_rtk_instructions`; opencode was simply never given the equivalent — a wrap/unwrap asymmetry. Closes: no issue filed — found while auditing wrap/unwrap symmetry across agents. ## Fix In `unwrap_opencode`, after the MCP cleanup, strip the rtk block from both files it was injected into, mirroring `unwrap_codex`: ```python for _agents_md in (Path.cwd() / "AGENTS.md", _opencode_home_dir() / "AGENTS.md"): if _remove_rtk_instructions(_agents_md): click.echo(f" Removed Headroom rtk instructions from {_agents_md}.") ``` Best-effort and unconditional, matching the existing MCP cleanup and the codex/copilot unwrap paths. `_remove_rtk_instructions` already no-ops when the file or marker is absent. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `headroom/cli/wrap.py`: `unwrap_opencode` removes the rtk block from the project and global `AGENTS.md`. - `tests/test_cli/test_wrap_opencode.py`: add `test_unwrap_opencode_removes_rtk_from_agents_md` (wrap injects into both, unwrap removes from both). ## Testing - [x] New regression test added (`tests/test_cli/test_wrap_opencode.py`) - [x] Linting/formatting clean — run with the CI-pinned `ruff==0.15.17` - [ ] Full `pytest` deferred to CI (local-OOM reason below). ```text $ uvx ruff@0.15.17 check headroom/cli/wrap.py tests/test_cli/test_wrap_opencode.py All checks passed! ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.10, headroom from this branch. Importing `headroom` pulls in the torch/transformers stack and a full `pytest` gets OOM-killed on this box, so I verified the wrap→unwrap round-trip through the new Click-runner test (which drives the real command) and reasoned through the marker logic; the full pytest runs on CI. - Exact command / steps: the added test runs `wrap opencode --no-mcp` (asserts `_RTK_MARKER` present in both the project and global `AGENTS.md`), then `unwrap opencode`, and asserts the marker is gone from both. - Observed result (the assertions the test enforces): before the fix, `unwrap opencode` left `_RTK_MARKER` in both files; after the fix both are clean: ```text after wrap: _RTK_MARKER in project AGENTS.md ✓ _RTK_MARKER in global AGENTS.md ✓ after unwrap: _RTK_MARKER absent (project) ✓ _RTK_MARKER absent (global) ✓ ``` - Not tested: launching a real `opencode` binary (mocked in the test, as the existing wrap tests do). Full local `pytest` deferred to CI (OOM, per above). ## 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 - [ ] New and existing unit tests pass locally with my changes — ran lint + the new Click-runner test path; full pytest deferred to CI (local OOM, disclosed above) - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes - Directly parallels the merged `unwrap codex` rtk cleanup (#1421); no new dependencies. - @JerrettDavis tagging you — same class as the codex rtk fix, just the opencode side that was missed. Thanks! --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-13 19:23:45 +05:30
def test_unwrap_opencode_removes_rtk_from_agents_md(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""unwrap opencode removes the rtk block that wrap opencode injected into both
the project and global AGENTS.md mirroring unwrap_codex / unwrap_copilot."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
global_agents = tmp_path / ".config" / "opencode" / "AGENTS.md"
project_agents = tmp_path / "AGENTS.md"
assert wrap_mod._RTK_MARKER in global_agents.read_text(encoding="utf-8")
assert wrap_mod._RTK_MARKER in project_agents.read_text(encoding="utf-8")
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
result = runner.invoke(main, ["unwrap", "opencode"])
assert result.exit_code == 0, result.output
# Both rtk blocks are gone after unwrap (previously left behind). A file that
# held only the rtk block is removed entirely by _remove_rtk_instructions, so
# treat a missing file as "block gone".
def _rtk_absent(path: Path) -> bool:
return not path.exists() or wrap_mod._RTK_MARKER not in path.read_text(encoding="utf-8")
assert _rtk_absent(global_agents)
assert _rtk_absent(project_agents)
feat(wrap): allow project RTK instruction opt-out (#2078) ## Description Add an opt-out for project-level RTK guidance when wrapping OpenCode, so teams can preserve an existing repository `AGENTS.md` while still installing RTK and its global OpenCode instructions. Closes #1980 ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring ## Changes Made - add `--no-project-rtk` to `headroom wrap opencode` - leave the repository `AGENTS.md` untouched when requested - continue installing RTK and its global OpenCode guidance - cover preservation of existing team instructions ## Testing - [x] Focused unit test passes (`pytest`) - [x] Touched-file linting passes (`ruff check`) - [ ] Type checking passes (`mypy headroom`) — not run - [x] New test added for new functionality - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_cli/test_wrap_opencode.py::test_wrap_opencode_no_project_rtk_only_skips_project_agents_md -q 1 passed uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_opencode.py All checks passed! uv run ruff format --check headroom/cli/wrap.py tests/test_cli/test_wrap_opencode.py 2 files already formatted ``` ## Real Behavior Proof - Environment: local Python test environment - Exact command / steps: invoke the focused OpenCode wrapper test with an existing project `AGENTS.md` and project RTK guidance disabled - Observed result: the existing project instructions remain byte-for-byte untouched while the global RTK guidance path still runs - Not tested: manual OpenCode launch or the full repository test suite ## 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] My changes generate no new warnings in the scoped checks - [x] I have added a test that proves the feature works - [x] Relevant existing and new unit coverage passes locally - [ ] Documentation and changelog updates — not applicable for this self-documenting CLI option ## Additional Notes The opt-out is deliberately narrow: it suppresses only the project `AGENTS.md` mutation, not RTK installation or global OpenCode configuration. Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-13 09:37:17 -04:00
def test_wrap_opencode_no_project_rtk_only_skips_project_agents_md(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
project_agents = tmp_path / "AGENTS.md"
project_agents.write_text("# Team instructions\n", encoding="utf-8")
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(
main,
[
"wrap",
"opencode",
"--no-project-rtk",
"--no-proxy",
"--port",
"9000",
"--no-mcp",
],
)
assert result.exit_code == 0, result.output
assert project_agents.read_text(encoding="utf-8") == "# Team instructions\n"
global_agents = tmp_path / ".config" / "opencode" / "AGENTS.md"
assert wrap_mod._RTK_MARKER in global_agents.read_text(encoding="utf-8")
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 18:07:12 +02:00
def test_wrap_opencode_idempotent_no_duplicate_block(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Running wrap twice must not duplicate the RTK block in AGENTS.md."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
project_agents = tmp_path / "AGENTS.md"
fix(cli): add explicit UTF-8 encoding to file I/O in wrap commands (#1126) (#1164) ## Description On Windows, `Path.read_text()` and `open()` default to the system locale encoding (cp1252, GBK, etc.) instead of UTF-8. This causes `UnicodeDecodeError` when reading or writing instruction files that contain multi-byte UTF-8 characters such as smart quotes or em dashes. The RTK instructions block itself contains an em dash (U+2014, `—`), so `_inject_rtk_instructions` crashes on any non-UTF-8 Windows locale when writing to `.copilot_instructions`, `.clinerules`, `AGENTS.md`, or similar hint files. Closes #1126 ## 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 - Add `encoding="utf-8"` to all bare `read_text()`, `write_text()`, and `open()` calls in `headroom/cli/wrap.py` that handle instruction or config files (18 call sites) - Update test assertions in `test_wrap_hintfile_agents.py`, `test_wrap_copilot.py`, and `test_wrap_bridge.py` to read with `encoding="utf-8"` - Add `test_inject_rtk_handles_utf8_content` verifying that existing hint files with smart quotes and em dashes survive RTK injection without crashing ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cli/test_wrap_hintfile_agents.py tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_bridge.py -v 47 passed in 1.28s ``` ## Real Behavior Proof - Environment: Windows 11 China (GBK locale), Python 3.11, headroom main (f03e77b) - Exact command / steps: python -m pytest tests/test_cli/test_wrap_hintfile_agents.py -v (on GBK Windows) - Observed result: Before fix, test_prepare_only_injects_rtk_into_hintfile fails with UnicodeDecodeError 'gbk' at position 283 (the em dash in RTK block). After fix, all 12 hintfile tests pass including new UTF-8 round-trip test. - Not tested: no manual `headroom wrap copilot` run against a real Copilot installation ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes This is the same class of bug reported in #733 (GBK config.toml corruption). This PR fixes the `wrap.py` call sites; other modules (`learn/analyzer.py`, `install/providers.py`) have the same pattern and could benefit from the same treatment in a follow-up. --------- Signed-off-by: Yiming Zeng <yzeng424@gmail.com> Signed-off-by: RTCartist <wangshengb@buaa.edu.cn> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 01:07:03 +08:00
content = project_agents.read_text(encoding="utf-8")
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 18:07:12 +02:00
assert content.count(wrap_mod._RTK_MARKER) == 1
# ---------------------------------------------------------------------------
# Unwrap opencode
# ---------------------------------------------------------------------------
def test_unwrap_opencode_restores_from_backup(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Unwrap restores the pre-wrap backup and removes it."""
monkeypatch.chdir(tmp_path)
_set_test_home(monkeypatch, tmp_path)
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
fix(opencode): Use opencode.jsonc when present (#1590) ## Description Fix OpenCode proxy injection so it respects user configurations that use the `.jsonc` extension, preventing Headroom from creating a duplicate `.json` file that overrides it. Closes #1588 ## 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 - Updated `opencode_config_path` in `paths.py` to check for `.jsonc` - Updated backup creation in `config.py` to preserve the original extension ## Testing <!-- Check what you actually ran, then paste the real command output below. --> - [ ] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [ ] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text N/A ``` ## Real Behavior Proof - Environment: local headroom dev - Exact command / steps: creating a dummy `.config/opencode/opencode.jsonc` and running `headroom wrap opencode`. - Observed result: Headroom successfully injects into `.jsonc` and creates a backup named `opencode.jsonc.headroom-backup`. - Not tested: N/A ## 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 - [ ] 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 - [ ] 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 ## Screenshots (if applicable) ## Additional Notes
2026-07-17 02:21:21 +05:30
backup_file = config_file.with_name("opencode.json.headroom-backup")
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 18:07:12 +02:00
config_file.parent.mkdir(parents=True, exist_ok=True)
original = '{"model": "openai/gpt-4o"}'
config_file.write_text(original)
backup_file.write_text(original)
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
result = runner.invoke(main, ["unwrap", "opencode"])
assert result.exit_code == 0, result.output
assert "Restored prior" in result.output
assert not backup_file.exists()
fix(cli): add explicit UTF-8 encoding to file I/O in wrap commands (#1126) (#1164) ## Description On Windows, `Path.read_text()` and `open()` default to the system locale encoding (cp1252, GBK, etc.) instead of UTF-8. This causes `UnicodeDecodeError` when reading or writing instruction files that contain multi-byte UTF-8 characters such as smart quotes or em dashes. The RTK instructions block itself contains an em dash (U+2014, `—`), so `_inject_rtk_instructions` crashes on any non-UTF-8 Windows locale when writing to `.copilot_instructions`, `.clinerules`, `AGENTS.md`, or similar hint files. Closes #1126 ## 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 - Add `encoding="utf-8"` to all bare `read_text()`, `write_text()`, and `open()` calls in `headroom/cli/wrap.py` that handle instruction or config files (18 call sites) - Update test assertions in `test_wrap_hintfile_agents.py`, `test_wrap_copilot.py`, and `test_wrap_bridge.py` to read with `encoding="utf-8"` - Add `test_inject_rtk_handles_utf8_content` verifying that existing hint files with smart quotes and em dashes survive RTK injection without crashing ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cli/test_wrap_hintfile_agents.py tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_bridge.py -v 47 passed in 1.28s ``` ## Real Behavior Proof - Environment: Windows 11 China (GBK locale), Python 3.11, headroom main (f03e77b) - Exact command / steps: python -m pytest tests/test_cli/test_wrap_hintfile_agents.py -v (on GBK Windows) - Observed result: Before fix, test_prepare_only_injects_rtk_into_hintfile fails with UnicodeDecodeError 'gbk' at position 283 (the em dash in RTK block). After fix, all 12 hintfile tests pass including new UTF-8 round-trip test. - Not tested: no manual `headroom wrap copilot` run against a real Copilot installation ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes This is the same class of bug reported in #733 (GBK config.toml corruption). This PR fixes the `wrap.py` call sites; other modules (`learn/analyzer.py`, `install/providers.py`) have the same pattern and could benefit from the same treatment in a follow-up. --------- Signed-off-by: Yiming Zeng <yzeng424@gmail.com> Signed-off-by: RTCartist <wangshengb@buaa.edu.cn> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 01:07:03 +08:00
assert config_file.read_text(encoding="utf-8") == original
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 18:07:12 +02:00
fix(opencode): Use opencode.jsonc when present (#1590) ## Description Fix OpenCode proxy injection so it respects user configurations that use the `.jsonc` extension, preventing Headroom from creating a duplicate `.json` file that overrides it. Closes #1588 ## 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 - Updated `opencode_config_path` in `paths.py` to check for `.jsonc` - Updated backup creation in `config.py` to preserve the original extension ## Testing <!-- Check what you actually ran, then paste the real command output below. --> - [ ] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [ ] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text N/A ``` ## Real Behavior Proof - Environment: local headroom dev - Exact command / steps: creating a dummy `.config/opencode/opencode.jsonc` and running `headroom wrap opencode`. - Observed result: Headroom successfully injects into `.jsonc` and creates a backup named `opencode.jsonc.headroom-backup`. - Not tested: N/A ## 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 - [ ] 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 - [ ] 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 ## Screenshots (if applicable) ## Additional Notes
2026-07-17 02:21:21 +05:30
def test_unwrap_opencode_restores_from_backup_jsonc(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Unwrap restores the pre-wrap backup and removes it for jsonc files."""
monkeypatch.chdir(tmp_path)
_set_test_home(monkeypatch, tmp_path)
config_file = tmp_path / ".config" / "opencode" / "opencode.jsonc"
backup_file = config_file.with_name("opencode.jsonc.headroom-backup")
config_file.parent.mkdir(parents=True, exist_ok=True)
original = '{\n // User comment\n "model": "openai/gpt-4o"\n}'
config_file.write_text(original)
backup_file.write_text(original)
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
result = runner.invoke(main, ["unwrap", "opencode"])
assert result.exit_code == 0, result.output
assert "Restored prior" in result.output
assert not backup_file.exists()
assert config_file.read_text(encoding="utf-8") == original
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 18:07:12 +02:00
def test_unwrap_opencode_strips_blocks_when_no_backup(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Unwrap strips Headroom blocks when no backup exists."""
monkeypatch.chdir(tmp_path)
_set_test_home(monkeypatch, tmp_path)
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
config_file.parent.mkdir(parents=True, exist_ok=True)
user_content = '{"model": "openai/gpt-4o"}'
wrapped_content = (
wrap_mod._PROVIDER_MARKER_START
+ '\n"provider": {},\n'
+ wrap_mod._PROVIDER_MARKER_END
+ "\n"
+ user_content
)
config_file.write_text(wrapped_content)
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
result = runner.invoke(main, ["unwrap", "opencode"])
assert result.exit_code == 0, result.output
assert "Removed Headroom block" in result.output
fix(cli): add explicit UTF-8 encoding to file I/O in wrap commands (#1126) (#1164) ## Description On Windows, `Path.read_text()` and `open()` default to the system locale encoding (cp1252, GBK, etc.) instead of UTF-8. This causes `UnicodeDecodeError` when reading or writing instruction files that contain multi-byte UTF-8 characters such as smart quotes or em dashes. The RTK instructions block itself contains an em dash (U+2014, `—`), so `_inject_rtk_instructions` crashes on any non-UTF-8 Windows locale when writing to `.copilot_instructions`, `.clinerules`, `AGENTS.md`, or similar hint files. Closes #1126 ## 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 - Add `encoding="utf-8"` to all bare `read_text()`, `write_text()`, and `open()` calls in `headroom/cli/wrap.py` that handle instruction or config files (18 call sites) - Update test assertions in `test_wrap_hintfile_agents.py`, `test_wrap_copilot.py`, and `test_wrap_bridge.py` to read with `encoding="utf-8"` - Add `test_inject_rtk_handles_utf8_content` verifying that existing hint files with smart quotes and em dashes survive RTK injection without crashing ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cli/test_wrap_hintfile_agents.py tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_bridge.py -v 47 passed in 1.28s ``` ## Real Behavior Proof - Environment: Windows 11 China (GBK locale), Python 3.11, headroom main (f03e77b) - Exact command / steps: python -m pytest tests/test_cli/test_wrap_hintfile_agents.py -v (on GBK Windows) - Observed result: Before fix, test_prepare_only_injects_rtk_into_hintfile fails with UnicodeDecodeError 'gbk' at position 283 (the em dash in RTK block). After fix, all 12 hintfile tests pass including new UTF-8 round-trip test. - Not tested: no manual `headroom wrap copilot` run against a real Copilot installation ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes This is the same class of bug reported in #733 (GBK config.toml corruption). This PR fixes the `wrap.py` call sites; other modules (`learn/analyzer.py`, `install/providers.py`) have the same pattern and could benefit from the same treatment in a follow-up. --------- Signed-off-by: Yiming Zeng <yzeng424@gmail.com> Signed-off-by: RTCartist <wangshengb@buaa.edu.cn> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 01:07:03 +08:00
assert user_content in config_file.read_text(encoding="utf-8")
assert wrap_mod._PROVIDER_MARKER_START not in config_file.read_text(encoding="utf-8")
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 18:07:12 +02:00
# ---------------------------------------------------------------------------
# Edge cases — wrap
# ---------------------------------------------------------------------------
def test_wrap_opencode_preserves_existing_user_providers(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Wrap merges headroom provider without disturbing user's existing providers."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
config_file.parent.mkdir(parents=True, exist_ok=True)
config_file.write_text('{"provider": {"openai": {"models": {"gpt-4o": {"name": "GPT-4o"}}}}}')
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
assert result.exit_code == 0, result.output
fix(cli): add explicit UTF-8 encoding to file I/O in wrap commands (#1126) (#1164) ## Description On Windows, `Path.read_text()` and `open()` default to the system locale encoding (cp1252, GBK, etc.) instead of UTF-8. This causes `UnicodeDecodeError` when reading or writing instruction files that contain multi-byte UTF-8 characters such as smart quotes or em dashes. The RTK instructions block itself contains an em dash (U+2014, `—`), so `_inject_rtk_instructions` crashes on any non-UTF-8 Windows locale when writing to `.copilot_instructions`, `.clinerules`, `AGENTS.md`, or similar hint files. Closes #1126 ## 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 - Add `encoding="utf-8"` to all bare `read_text()`, `write_text()`, and `open()` calls in `headroom/cli/wrap.py` that handle instruction or config files (18 call sites) - Update test assertions in `test_wrap_hintfile_agents.py`, `test_wrap_copilot.py`, and `test_wrap_bridge.py` to read with `encoding="utf-8"` - Add `test_inject_rtk_handles_utf8_content` verifying that existing hint files with smart quotes and em dashes survive RTK injection without crashing ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cli/test_wrap_hintfile_agents.py tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_bridge.py -v 47 passed in 1.28s ``` ## Real Behavior Proof - Environment: Windows 11 China (GBK locale), Python 3.11, headroom main (f03e77b) - Exact command / steps: python -m pytest tests/test_cli/test_wrap_hintfile_agents.py -v (on GBK Windows) - Observed result: Before fix, test_prepare_only_injects_rtk_into_hintfile fails with UnicodeDecodeError 'gbk' at position 283 (the em dash in RTK block). After fix, all 12 hintfile tests pass including new UTF-8 round-trip test. - Not tested: no manual `headroom wrap copilot` run against a real Copilot installation ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes This is the same class of bug reported in #733 (GBK config.toml corruption). This PR fixes the `wrap.py` call sites; other modules (`learn/analyzer.py`, `install/providers.py`) have the same pattern and could benefit from the same treatment in a follow-up. --------- Signed-off-by: Yiming Zeng <yzeng424@gmail.com> Signed-off-by: RTCartist <wangshengb@buaa.edu.cn> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 01:07:03 +08:00
config = json.loads(config_file.read_text(encoding="utf-8"))
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 18:07:12 +02:00
assert "headroom" in config["provider"], "headroom provider not injected"
assert "openai" in config["provider"], "user's openai provider was removed"
def test_wrap_opencode_port_change_updates_existing_config(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Wrapping with a different port updates the baseURL in opencode.json."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
runner.invoke(main, ["wrap", "opencode", "--port", "9001", "--no-mcp"])
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
fix(cli): add explicit UTF-8 encoding to file I/O in wrap commands (#1126) (#1164) ## Description On Windows, `Path.read_text()` and `open()` default to the system locale encoding (cp1252, GBK, etc.) instead of UTF-8. This causes `UnicodeDecodeError` when reading or writing instruction files that contain multi-byte UTF-8 characters such as smart quotes or em dashes. The RTK instructions block itself contains an em dash (U+2014, `—`), so `_inject_rtk_instructions` crashes on any non-UTF-8 Windows locale when writing to `.copilot_instructions`, `.clinerules`, `AGENTS.md`, or similar hint files. Closes #1126 ## 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 - Add `encoding="utf-8"` to all bare `read_text()`, `write_text()`, and `open()` calls in `headroom/cli/wrap.py` that handle instruction or config files (18 call sites) - Update test assertions in `test_wrap_hintfile_agents.py`, `test_wrap_copilot.py`, and `test_wrap_bridge.py` to read with `encoding="utf-8"` - Add `test_inject_rtk_handles_utf8_content` verifying that existing hint files with smart quotes and em dashes survive RTK injection without crashing ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cli/test_wrap_hintfile_agents.py tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_bridge.py -v 47 passed in 1.28s ``` ## Real Behavior Proof - Environment: Windows 11 China (GBK locale), Python 3.11, headroom main (f03e77b) - Exact command / steps: python -m pytest tests/test_cli/test_wrap_hintfile_agents.py -v (on GBK Windows) - Observed result: Before fix, test_prepare_only_injects_rtk_into_hintfile fails with UnicodeDecodeError 'gbk' at position 283 (the em dash in RTK block). After fix, all 12 hintfile tests pass including new UTF-8 round-trip test. - Not tested: no manual `headroom wrap copilot` run against a real Copilot installation ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes This is the same class of bug reported in #733 (GBK config.toml corruption). This PR fixes the `wrap.py` call sites; other modules (`learn/analyzer.py`, `install/providers.py`) have the same pattern and could benefit from the same treatment in a follow-up. --------- Signed-off-by: Yiming Zeng <yzeng424@gmail.com> Signed-off-by: RTCartist <wangshengb@buaa.edu.cn> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 01:07:03 +08:00
config = json.loads(config_file.read_text(encoding="utf-8"))
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 18:07:12 +02:00
assert config["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:9001/v1"
def test_wrap_opencode_handles_malformed_config_file(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Wrap handles a malformed opencode.json by backing it up before overwriting."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
config_file.parent.mkdir(parents=True, exist_ok=True)
malformed = '{"model": "gpt-4o",}' # trailing comma
config_file.write_text(malformed)
backup_file = config_file.with_suffix(".json.headroom-backup")
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
assert result.exit_code == 0, result.output
assert backup_file.exists(), "backup must be created before overwriting"
fix(cli): add explicit UTF-8 encoding to file I/O in wrap commands (#1126) (#1164) ## Description On Windows, `Path.read_text()` and `open()` default to the system locale encoding (cp1252, GBK, etc.) instead of UTF-8. This causes `UnicodeDecodeError` when reading or writing instruction files that contain multi-byte UTF-8 characters such as smart quotes or em dashes. The RTK instructions block itself contains an em dash (U+2014, `—`), so `_inject_rtk_instructions` crashes on any non-UTF-8 Windows locale when writing to `.copilot_instructions`, `.clinerules`, `AGENTS.md`, or similar hint files. Closes #1126 ## 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 - Add `encoding="utf-8"` to all bare `read_text()`, `write_text()`, and `open()` calls in `headroom/cli/wrap.py` that handle instruction or config files (18 call sites) - Update test assertions in `test_wrap_hintfile_agents.py`, `test_wrap_copilot.py`, and `test_wrap_bridge.py` to read with `encoding="utf-8"` - Add `test_inject_rtk_handles_utf8_content` verifying that existing hint files with smart quotes and em dashes survive RTK injection without crashing ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cli/test_wrap_hintfile_agents.py tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_bridge.py -v 47 passed in 1.28s ``` ## Real Behavior Proof - Environment: Windows 11 China (GBK locale), Python 3.11, headroom main (f03e77b) - Exact command / steps: python -m pytest tests/test_cli/test_wrap_hintfile_agents.py -v (on GBK Windows) - Observed result: Before fix, test_prepare_only_injects_rtk_into_hintfile fails with UnicodeDecodeError 'gbk' at position 283 (the em dash in RTK block). After fix, all 12 hintfile tests pass including new UTF-8 round-trip test. - Not tested: no manual `headroom wrap copilot` run against a real Copilot installation ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes This is the same class of bug reported in #733 (GBK config.toml corruption). This PR fixes the `wrap.py` call sites; other modules (`learn/analyzer.py`, `install/providers.py`) have the same pattern and could benefit from the same treatment in a follow-up. --------- Signed-off-by: Yiming Zeng <yzeng424@gmail.com> Signed-off-by: RTCartist <wangshengb@buaa.edu.cn> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 01:07:03 +08:00
assert backup_file.read_text(encoding="utf-8") == malformed, (
"backup must preserve original byte-for-byte"
)
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 18:07:12 +02:00
# The config file is now valid JSON with headroom provider.
fix(cli): add explicit UTF-8 encoding to file I/O in wrap commands (#1126) (#1164) ## Description On Windows, `Path.read_text()` and `open()` default to the system locale encoding (cp1252, GBK, etc.) instead of UTF-8. This causes `UnicodeDecodeError` when reading or writing instruction files that contain multi-byte UTF-8 characters such as smart quotes or em dashes. The RTK instructions block itself contains an em dash (U+2014, `—`), so `_inject_rtk_instructions` crashes on any non-UTF-8 Windows locale when writing to `.copilot_instructions`, `.clinerules`, `AGENTS.md`, or similar hint files. Closes #1126 ## 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 - Add `encoding="utf-8"` to all bare `read_text()`, `write_text()`, and `open()` calls in `headroom/cli/wrap.py` that handle instruction or config files (18 call sites) - Update test assertions in `test_wrap_hintfile_agents.py`, `test_wrap_copilot.py`, and `test_wrap_bridge.py` to read with `encoding="utf-8"` - Add `test_inject_rtk_handles_utf8_content` verifying that existing hint files with smart quotes and em dashes survive RTK injection without crashing ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cli/test_wrap_hintfile_agents.py tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_bridge.py -v 47 passed in 1.28s ``` ## Real Behavior Proof - Environment: Windows 11 China (GBK locale), Python 3.11, headroom main (f03e77b) - Exact command / steps: python -m pytest tests/test_cli/test_wrap_hintfile_agents.py -v (on GBK Windows) - Observed result: Before fix, test_prepare_only_injects_rtk_into_hintfile fails with UnicodeDecodeError 'gbk' at position 283 (the em dash in RTK block). After fix, all 12 hintfile tests pass including new UTF-8 round-trip test. - Not tested: no manual `headroom wrap copilot` run against a real Copilot installation ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes This is the same class of bug reported in #733 (GBK config.toml corruption). This PR fixes the `wrap.py` call sites; other modules (`learn/analyzer.py`, `install/providers.py`) have the same pattern and could benefit from the same treatment in a follow-up. --------- Signed-off-by: Yiming Zeng <yzeng424@gmail.com> Signed-off-by: RTCartist <wangshengb@buaa.edu.cn> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 01:07:03 +08:00
config = json.loads(config_file.read_text(encoding="utf-8"))
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 18:07:12 +02:00
assert "headroom" in config.get("provider", {})
def test_wrap_opencode_handles_empty_config_file(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Wrap handles an empty opencode.json file gracefully."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
config_file.parent.mkdir(parents=True, exist_ok=True)
config_file.write_text("")
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
assert result.exit_code == 0, result.output
fix(cli): add explicit UTF-8 encoding to file I/O in wrap commands (#1126) (#1164) ## Description On Windows, `Path.read_text()` and `open()` default to the system locale encoding (cp1252, GBK, etc.) instead of UTF-8. This causes `UnicodeDecodeError` when reading or writing instruction files that contain multi-byte UTF-8 characters such as smart quotes or em dashes. The RTK instructions block itself contains an em dash (U+2014, `—`), so `_inject_rtk_instructions` crashes on any non-UTF-8 Windows locale when writing to `.copilot_instructions`, `.clinerules`, `AGENTS.md`, or similar hint files. Closes #1126 ## 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 - Add `encoding="utf-8"` to all bare `read_text()`, `write_text()`, and `open()` calls in `headroom/cli/wrap.py` that handle instruction or config files (18 call sites) - Update test assertions in `test_wrap_hintfile_agents.py`, `test_wrap_copilot.py`, and `test_wrap_bridge.py` to read with `encoding="utf-8"` - Add `test_inject_rtk_handles_utf8_content` verifying that existing hint files with smart quotes and em dashes survive RTK injection without crashing ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cli/test_wrap_hintfile_agents.py tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_bridge.py -v 47 passed in 1.28s ``` ## Real Behavior Proof - Environment: Windows 11 China (GBK locale), Python 3.11, headroom main (f03e77b) - Exact command / steps: python -m pytest tests/test_cli/test_wrap_hintfile_agents.py -v (on GBK Windows) - Observed result: Before fix, test_prepare_only_injects_rtk_into_hintfile fails with UnicodeDecodeError 'gbk' at position 283 (the em dash in RTK block). After fix, all 12 hintfile tests pass including new UTF-8 round-trip test. - Not tested: no manual `headroom wrap copilot` run against a real Copilot installation ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes This is the same class of bug reported in #733 (GBK config.toml corruption). This PR fixes the `wrap.py` call sites; other modules (`learn/analyzer.py`, `install/providers.py`) have the same pattern and could benefit from the same treatment in a follow-up. --------- Signed-off-by: Yiming Zeng <yzeng424@gmail.com> Signed-off-by: RTCartist <wangshengb@buaa.edu.cn> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 01:07:03 +08:00
config = json.loads(config_file.read_text(encoding="utf-8"))
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 18:07:12 +02:00
assert config["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:9000/v1"
def test_wrap_opencode_handles_config_dir_missing(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Wrap creates the config directory when it doesn't exist."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
config_dir = tmp_path / ".config" / "opencode"
assert not config_dir.exists()
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
assert result.exit_code == 0, result.output
assert config_dir.exists()
assert (config_dir / "opencode.json").exists()
def test_wrap_opencode_rtk_preserves_existing_agents_md(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""RTK injection appends to AGENTS.md without removing existing content."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
existing_content = "# My custom rules\nUse spaces, not tabs."
(tmp_path / "AGENTS.md").write_text(existing_content)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
assert result.exit_code == 0, result.output
fix(cli): add explicit UTF-8 encoding to file I/O in wrap commands (#1126) (#1164) ## Description On Windows, `Path.read_text()` and `open()` default to the system locale encoding (cp1252, GBK, etc.) instead of UTF-8. This causes `UnicodeDecodeError` when reading or writing instruction files that contain multi-byte UTF-8 characters such as smart quotes or em dashes. The RTK instructions block itself contains an em dash (U+2014, `—`), so `_inject_rtk_instructions` crashes on any non-UTF-8 Windows locale when writing to `.copilot_instructions`, `.clinerules`, `AGENTS.md`, or similar hint files. Closes #1126 ## 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 - Add `encoding="utf-8"` to all bare `read_text()`, `write_text()`, and `open()` calls in `headroom/cli/wrap.py` that handle instruction or config files (18 call sites) - Update test assertions in `test_wrap_hintfile_agents.py`, `test_wrap_copilot.py`, and `test_wrap_bridge.py` to read with `encoding="utf-8"` - Add `test_inject_rtk_handles_utf8_content` verifying that existing hint files with smart quotes and em dashes survive RTK injection without crashing ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cli/test_wrap_hintfile_agents.py tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_bridge.py -v 47 passed in 1.28s ``` ## Real Behavior Proof - Environment: Windows 11 China (GBK locale), Python 3.11, headroom main (f03e77b) - Exact command / steps: python -m pytest tests/test_cli/test_wrap_hintfile_agents.py -v (on GBK Windows) - Observed result: Before fix, test_prepare_only_injects_rtk_into_hintfile fails with UnicodeDecodeError 'gbk' at position 283 (the em dash in RTK block). After fix, all 12 hintfile tests pass including new UTF-8 round-trip test. - Not tested: no manual `headroom wrap copilot` run against a real Copilot installation ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes This is the same class of bug reported in #733 (GBK config.toml corruption). This PR fixes the `wrap.py` call sites; other modules (`learn/analyzer.py`, `install/providers.py`) have the same pattern and could benefit from the same treatment in a follow-up. --------- Signed-off-by: Yiming Zeng <yzeng424@gmail.com> Signed-off-by: RTCartist <wangshengb@buaa.edu.cn> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 01:07:03 +08:00
content = (tmp_path / "AGENTS.md").read_text(encoding="utf-8")
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 18:07:12 +02:00
assert existing_content in content
assert wrap_mod._RTK_MARKER in content
def test_wrap_opencode_no_rtk_leaves_agents_md_untouched(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`--no-rtk` flag leaves existing AGENTS.md untouched."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
existing_content = "# My custom rules\nUse spaces, not tabs."
(tmp_path / "AGENTS.md").write_text(existing_content)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(
main, ["wrap", "opencode", "--port", "9000", "--no-rtk", "--no-mcp"]
)
assert result.exit_code == 0, result.output
fix(cli): add explicit UTF-8 encoding to file I/O in wrap commands (#1126) (#1164) ## Description On Windows, `Path.read_text()` and `open()` default to the system locale encoding (cp1252, GBK, etc.) instead of UTF-8. This causes `UnicodeDecodeError` when reading or writing instruction files that contain multi-byte UTF-8 characters such as smart quotes or em dashes. The RTK instructions block itself contains an em dash (U+2014, `—`), so `_inject_rtk_instructions` crashes on any non-UTF-8 Windows locale when writing to `.copilot_instructions`, `.clinerules`, `AGENTS.md`, or similar hint files. Closes #1126 ## 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 - Add `encoding="utf-8"` to all bare `read_text()`, `write_text()`, and `open()` calls in `headroom/cli/wrap.py` that handle instruction or config files (18 call sites) - Update test assertions in `test_wrap_hintfile_agents.py`, `test_wrap_copilot.py`, and `test_wrap_bridge.py` to read with `encoding="utf-8"` - Add `test_inject_rtk_handles_utf8_content` verifying that existing hint files with smart quotes and em dashes survive RTK injection without crashing ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cli/test_wrap_hintfile_agents.py tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_bridge.py -v 47 passed in 1.28s ``` ## Real Behavior Proof - Environment: Windows 11 China (GBK locale), Python 3.11, headroom main (f03e77b) - Exact command / steps: python -m pytest tests/test_cli/test_wrap_hintfile_agents.py -v (on GBK Windows) - Observed result: Before fix, test_prepare_only_injects_rtk_into_hintfile fails with UnicodeDecodeError 'gbk' at position 283 (the em dash in RTK block). After fix, all 12 hintfile tests pass including new UTF-8 round-trip test. - Not tested: no manual `headroom wrap copilot` run against a real Copilot installation ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes This is the same class of bug reported in #733 (GBK config.toml corruption). This PR fixes the `wrap.py` call sites; other modules (`learn/analyzer.py`, `install/providers.py`) have the same pattern and could benefit from the same treatment in a follow-up. --------- Signed-off-by: Yiming Zeng <yzeng424@gmail.com> Signed-off-by: RTCartist <wangshengb@buaa.edu.cn> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 01:07:03 +08:00
content = (tmp_path / "AGENTS.md").read_text(encoding="utf-8")
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 18:07:12 +02:00
assert content == existing_content, "--no-rtk modified AGENTS.md"
assert wrap_mod._RTK_MARKER not in content
def test_wrap_opencode_respects_opencode_config_env(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""OPENCODE_CONFIG env var overrides the default config path."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
custom_config = tmp_path / "custom" / "config.json"
monkeypatch.setenv("OPENCODE_CONFIG", str(custom_config))
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
assert result.exit_code == 0, result.output
assert custom_config.exists()
default_config = tmp_path / ".config" / "opencode" / "opencode.json"
ci: restore green lint (reformat for ruff 0.15.17, fix mypy no-any-return, pin linters) (#1295) ## Description The CI lint job (`ruff check .` → `ruff format --check .` → `mypy headroom`) was red on `main` and therefore on every open PR, for two unrelated reasons that the early ruff failure was masking: 1. **ruff**: the lint job installs `ruff` unpinned, and ruff 0.15.17 began enforcing import-block sorting (`I001`) and formatting that older ruff accepted → `ruff check .` / `ruff format --check .` fail on files nobody touched. 2. **mypy**: `headroom/providers/opencode/config.py` had two `return json.loads(...)` statements in a function declared `-> dict[str, Any]`; `json.loads` is typed `Any`, so `mypy headroom` fails with `no-any-return` (reproduced on mypy 1.20.2 — not a version-specific quirk). This restores a green lint baseline and pins both linters so a future release can't silently break CI again. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `.github/workflows/ci.yml`: pin `ruff==0.15.17` and `mypy==1.20.2` in the lint job. - Applied `ruff check --fix .` (6 `I001` import-sort fixes) and `ruff format .` (10 files) across the repo — import ordering and whitespace only, no behavior change. - `headroom/providers/opencode/config.py`: narrow both `_parse_json_loose` return sites with an `isinstance(parsed, dict)` guard, so the `dict[str, Any]` annotation is true at runtime (non-dict JSON falls back to `{}`) and mypy's `no-any-return` is resolved. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`, `ruff format --check .`, `mypy headroom --ignore-missing-imports`) ### Test Output ```text $ python -m ruff check . All checks passed! $ python -m ruff format --check . 913 files already formatted $ python -m mypy headroom/providers/opencode/config.py --ignore-missing-imports Success: no issues found in 1 source file $ python -m pytest tests/test_providers_opencode_config.py -q 37 passed ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13, ruff 0.15.17, mypy 1.20.2, branch ci/fix-ruff-lint off headroomlabs-ai/main - Exact command / steps: reproduced the red lint (latest ruff: 6 `I001` + 10 unformatted files; the mypy failure was read from the #1295 CI lint log — `config.py:125,133 no-any-return`, and re-confirmed locally on mypy 1.20.2). Applied the ruff auto-fix/format, added the dict guard, pinned both linters, and re-ran each lint step. - Observed result: `ruff check .` → "All checks passed!"; `ruff format --check .` → "913 files already formatted"; `mypy` on the fixed file → "Success: no issues found"; full `mypy headroom` reports only Unix `fcntl` attributes that don't exist on this Windows box (present on the Linux CI runner, where the prior run showed exactly the two now-fixed errors). 37 opencode-config tests pass. - Not tested: did not run the full OS/Python test matrix — the change is formatting + two CI dependency pins + a two-line type-narrowing guard, with no runtime behavior change for dict JSON. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:14:40 +02:00
assert not default_config.exists(), (
"default config should not be created when OPENCODE_CONFIG is set"
)
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 18:07:12 +02:00
def test_wrap_opencode_headroom_project_from_cwd(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""HEADROOM_PROJECT is set based on the current working directory name."""
project_dir = tmp_path / "my-project"
project_dir.mkdir()
monkeypatch.chdir(project_dir)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
monkeypatch.delenv("HEADROOM_PROJECT", raising=False)
_set_test_home(monkeypatch, tmp_path)
captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003
captured.update(kwargs)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
assert result.exit_code == 0, result.output
env = captured["env"]
assert env.get("HEADROOM_PROJECT") == "my-project"
def test_wrap_opencode_respects_existing_headroom_project(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""User-set HEADROOM_PROJECT env var is preserved, not overridden."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
monkeypatch.setenv("HEADROOM_PROJECT", "user-set-value")
captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003
captured.update(kwargs)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
assert result.exit_code == 0, result.output
env = captured["env"]
assert env["HEADROOM_PROJECT"] == "user-set-value"
def test_wrap_opencode_config_merges_existing_model(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Wrap preserves the user's existing model selection."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
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"}')
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
assert result.exit_code == 0, result.output
fix(cli): add explicit UTF-8 encoding to file I/O in wrap commands (#1126) (#1164) ## Description On Windows, `Path.read_text()` and `open()` default to the system locale encoding (cp1252, GBK, etc.) instead of UTF-8. This causes `UnicodeDecodeError` when reading or writing instruction files that contain multi-byte UTF-8 characters such as smart quotes or em dashes. The RTK instructions block itself contains an em dash (U+2014, `—`), so `_inject_rtk_instructions` crashes on any non-UTF-8 Windows locale when writing to `.copilot_instructions`, `.clinerules`, `AGENTS.md`, or similar hint files. Closes #1126 ## 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 - Add `encoding="utf-8"` to all bare `read_text()`, `write_text()`, and `open()` calls in `headroom/cli/wrap.py` that handle instruction or config files (18 call sites) - Update test assertions in `test_wrap_hintfile_agents.py`, `test_wrap_copilot.py`, and `test_wrap_bridge.py` to read with `encoding="utf-8"` - Add `test_inject_rtk_handles_utf8_content` verifying that existing hint files with smart quotes and em dashes survive RTK injection without crashing ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cli/test_wrap_hintfile_agents.py tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_bridge.py -v 47 passed in 1.28s ``` ## Real Behavior Proof - Environment: Windows 11 China (GBK locale), Python 3.11, headroom main (f03e77b) - Exact command / steps: python -m pytest tests/test_cli/test_wrap_hintfile_agents.py -v (on GBK Windows) - Observed result: Before fix, test_prepare_only_injects_rtk_into_hintfile fails with UnicodeDecodeError 'gbk' at position 283 (the em dash in RTK block). After fix, all 12 hintfile tests pass including new UTF-8 round-trip test. - Not tested: no manual `headroom wrap copilot` run against a real Copilot installation ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes This is the same class of bug reported in #733 (GBK config.toml corruption). This PR fixes the `wrap.py` call sites; other modules (`learn/analyzer.py`, `install/providers.py`) have the same pattern and could benefit from the same treatment in a follow-up. --------- Signed-off-by: Yiming Zeng <yzeng424@gmail.com> Signed-off-by: RTCartist <wangshengb@buaa.edu.cn> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 01:07:03 +08:00
config = json.loads(config_file.read_text(encoding="utf-8"))
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 18:07:12 +02:00
assert config["model"] == "openai/gpt-4o"
assert config["provider"]["headroom"]["npm"] == "@ai-sdk/openai-compatible"
# ---------------------------------------------------------------------------
# Edge cases — unwrap
# ---------------------------------------------------------------------------
def test_unwrap_opencode_removes_config_when_only_headroom_content(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Unwrap removes the config file entirely when it contained only Headroom content."""
monkeypatch.chdir(tmp_path)
_set_test_home(monkeypatch, tmp_path)
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
config_file.parent.mkdir(parents=True, exist_ok=True)
wrapped_content = (
ci: restore green lint (reformat for ruff 0.15.17, fix mypy no-any-return, pin linters) (#1295) ## Description The CI lint job (`ruff check .` → `ruff format --check .` → `mypy headroom`) was red on `main` and therefore on every open PR, for two unrelated reasons that the early ruff failure was masking: 1. **ruff**: the lint job installs `ruff` unpinned, and ruff 0.15.17 began enforcing import-block sorting (`I001`) and formatting that older ruff accepted → `ruff check .` / `ruff format --check .` fail on files nobody touched. 2. **mypy**: `headroom/providers/opencode/config.py` had two `return json.loads(...)` statements in a function declared `-> dict[str, Any]`; `json.loads` is typed `Any`, so `mypy headroom` fails with `no-any-return` (reproduced on mypy 1.20.2 — not a version-specific quirk). This restores a green lint baseline and pins both linters so a future release can't silently break CI again. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `.github/workflows/ci.yml`: pin `ruff==0.15.17` and `mypy==1.20.2` in the lint job. - Applied `ruff check --fix .` (6 `I001` import-sort fixes) and `ruff format .` (10 files) across the repo — import ordering and whitespace only, no behavior change. - `headroom/providers/opencode/config.py`: narrow both `_parse_json_loose` return sites with an `isinstance(parsed, dict)` guard, so the `dict[str, Any]` annotation is true at runtime (non-dict JSON falls back to `{}`) and mypy's `no-any-return` is resolved. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`, `ruff format --check .`, `mypy headroom --ignore-missing-imports`) ### Test Output ```text $ python -m ruff check . All checks passed! $ python -m ruff format --check . 913 files already formatted $ python -m mypy headroom/providers/opencode/config.py --ignore-missing-imports Success: no issues found in 1 source file $ python -m pytest tests/test_providers_opencode_config.py -q 37 passed ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13, ruff 0.15.17, mypy 1.20.2, branch ci/fix-ruff-lint off headroomlabs-ai/main - Exact command / steps: reproduced the red lint (latest ruff: 6 `I001` + 10 unformatted files; the mypy failure was read from the #1295 CI lint log — `config.py:125,133 no-any-return`, and re-confirmed locally on mypy 1.20.2). Applied the ruff auto-fix/format, added the dict guard, pinned both linters, and re-ran each lint step. - Observed result: `ruff check .` → "All checks passed!"; `ruff format --check .` → "913 files already formatted"; `mypy` on the fixed file → "Success: no issues found"; full `mypy headroom` reports only Unix `fcntl` attributes that don't exist on this Windows box (present on the Linux CI runner, where the prior run showed exactly the two now-fixed errors). 37 opencode-config tests pass. - Not tested: did not run the full OS/Python test matrix — the change is formatting + two CI dependency pins + a two-line type-narrowing guard, with no runtime behavior change for dict JSON. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:14:40 +02:00
wrap_mod._PROVIDER_MARKER_START + '\n"provider": {},\n' + wrap_mod._PROVIDER_MARKER_END
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 18:07:12 +02:00
)
config_file.write_text(wrapped_content)
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
result = runner.invoke(main, ["unwrap", "opencode"])
assert result.exit_code == 0, result.output
assert "Removed" in result.output
assert not config_file.exists()
def test_unwrap_opencode_noop_when_config_missing(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Unwrap is a safe no-op when the config file doesn't exist."""
monkeypatch.chdir(tmp_path)
_set_test_home(monkeypatch, tmp_path)
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
result = runner.invoke(main, ["unwrap", "opencode"])
assert result.exit_code == 0, result.output
assert "does not exist" in result.output
def test_unwrap_opencode_noop_when_no_headroom_markers(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Unwrap is a safe no-op when the config has no Headroom markers."""
monkeypatch.chdir(tmp_path)
_set_test_home(monkeypatch, tmp_path)
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"}')
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
result = runner.invoke(main, ["unwrap", "opencode"])
assert result.exit_code == 0, result.output
assert "no Headroom wrap markers" in result.output
fix(cli): add explicit UTF-8 encoding to file I/O in wrap commands (#1126) (#1164) ## Description On Windows, `Path.read_text()` and `open()` default to the system locale encoding (cp1252, GBK, etc.) instead of UTF-8. This causes `UnicodeDecodeError` when reading or writing instruction files that contain multi-byte UTF-8 characters such as smart quotes or em dashes. The RTK instructions block itself contains an em dash (U+2014, `—`), so `_inject_rtk_instructions` crashes on any non-UTF-8 Windows locale when writing to `.copilot_instructions`, `.clinerules`, `AGENTS.md`, or similar hint files. Closes #1126 ## 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 - Add `encoding="utf-8"` to all bare `read_text()`, `write_text()`, and `open()` calls in `headroom/cli/wrap.py` that handle instruction or config files (18 call sites) - Update test assertions in `test_wrap_hintfile_agents.py`, `test_wrap_copilot.py`, and `test_wrap_bridge.py` to read with `encoding="utf-8"` - Add `test_inject_rtk_handles_utf8_content` verifying that existing hint files with smart quotes and em dashes survive RTK injection without crashing ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cli/test_wrap_hintfile_agents.py tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_bridge.py -v 47 passed in 1.28s ``` ## Real Behavior Proof - Environment: Windows 11 China (GBK locale), Python 3.11, headroom main (f03e77b) - Exact command / steps: python -m pytest tests/test_cli/test_wrap_hintfile_agents.py -v (on GBK Windows) - Observed result: Before fix, test_prepare_only_injects_rtk_into_hintfile fails with UnicodeDecodeError 'gbk' at position 283 (the em dash in RTK block). After fix, all 12 hintfile tests pass including new UTF-8 round-trip test. - Not tested: no manual `headroom wrap copilot` run against a real Copilot installation ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes This is the same class of bug reported in #733 (GBK config.toml corruption). This PR fixes the `wrap.py` call sites; other modules (`learn/analyzer.py`, `install/providers.py`) have the same pattern and could benefit from the same treatment in a follow-up. --------- Signed-off-by: Yiming Zeng <yzeng424@gmail.com> Signed-off-by: RTCartist <wangshengb@buaa.edu.cn> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 01:07:03 +08:00
assert config_file.read_text(encoding="utf-8").strip() == '{"model": "openai/gpt-4o"}'
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 18:07:12 +02:00
def test_wrap_unwrap_rewrap_is_idempotent(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Full wrap-unwrap-rewrap cycle produces consistent results."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
config_file.parent.mkdir(parents=True, exist_ok=True)
user_config = '{"model": "openai/gpt-4o", "provider": {"openai": {}}}'
config_file.write_text(user_config)
# First wrap
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
# Unwrap
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
runner.invoke(main, ["unwrap", "opencode"])
# After unwrap, file should match original
fix(cli): add explicit UTF-8 encoding to file I/O in wrap commands (#1126) (#1164) ## Description On Windows, `Path.read_text()` and `open()` default to the system locale encoding (cp1252, GBK, etc.) instead of UTF-8. This causes `UnicodeDecodeError` when reading or writing instruction files that contain multi-byte UTF-8 characters such as smart quotes or em dashes. The RTK instructions block itself contains an em dash (U+2014, `—`), so `_inject_rtk_instructions` crashes on any non-UTF-8 Windows locale when writing to `.copilot_instructions`, `.clinerules`, `AGENTS.md`, or similar hint files. Closes #1126 ## 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 - Add `encoding="utf-8"` to all bare `read_text()`, `write_text()`, and `open()` calls in `headroom/cli/wrap.py` that handle instruction or config files (18 call sites) - Update test assertions in `test_wrap_hintfile_agents.py`, `test_wrap_copilot.py`, and `test_wrap_bridge.py` to read with `encoding="utf-8"` - Add `test_inject_rtk_handles_utf8_content` verifying that existing hint files with smart quotes and em dashes survive RTK injection without crashing ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cli/test_wrap_hintfile_agents.py tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_bridge.py -v 47 passed in 1.28s ``` ## Real Behavior Proof - Environment: Windows 11 China (GBK locale), Python 3.11, headroom main (f03e77b) - Exact command / steps: python -m pytest tests/test_cli/test_wrap_hintfile_agents.py -v (on GBK Windows) - Observed result: Before fix, test_prepare_only_injects_rtk_into_hintfile fails with UnicodeDecodeError 'gbk' at position 283 (the em dash in RTK block). After fix, all 12 hintfile tests pass including new UTF-8 round-trip test. - Not tested: no manual `headroom wrap copilot` run against a real Copilot installation ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes This is the same class of bug reported in #733 (GBK config.toml corruption). This PR fixes the `wrap.py` call sites; other modules (`learn/analyzer.py`, `install/providers.py`) have the same pattern and could benefit from the same treatment in a follow-up. --------- Signed-off-by: Yiming Zeng <yzeng424@gmail.com> Signed-off-by: RTCartist <wangshengb@buaa.edu.cn> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 01:07:03 +08:00
after_unwrap = json.loads(config_file.read_text(encoding="utf-8"))
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 18:07:12 +02:00
assert after_unwrap["model"] == "openai/gpt-4o"
assert "headroom" not in after_unwrap.get("provider", {})
# Re-wrap
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
runner.invoke(main, ["wrap", "opencode", "--port", "9001", "--no-mcp"])
# After re-wrap, headroom should be back, model unchanged
fix(cli): add explicit UTF-8 encoding to file I/O in wrap commands (#1126) (#1164) ## Description On Windows, `Path.read_text()` and `open()` default to the system locale encoding (cp1252, GBK, etc.) instead of UTF-8. This causes `UnicodeDecodeError` when reading or writing instruction files that contain multi-byte UTF-8 characters such as smart quotes or em dashes. The RTK instructions block itself contains an em dash (U+2014, `—`), so `_inject_rtk_instructions` crashes on any non-UTF-8 Windows locale when writing to `.copilot_instructions`, `.clinerules`, `AGENTS.md`, or similar hint files. Closes #1126 ## 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 - Add `encoding="utf-8"` to all bare `read_text()`, `write_text()`, and `open()` calls in `headroom/cli/wrap.py` that handle instruction or config files (18 call sites) - Update test assertions in `test_wrap_hintfile_agents.py`, `test_wrap_copilot.py`, and `test_wrap_bridge.py` to read with `encoding="utf-8"` - Add `test_inject_rtk_handles_utf8_content` verifying that existing hint files with smart quotes and em dashes survive RTK injection without crashing ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cli/test_wrap_hintfile_agents.py tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_bridge.py -v 47 passed in 1.28s ``` ## Real Behavior Proof - Environment: Windows 11 China (GBK locale), Python 3.11, headroom main (f03e77b) - Exact command / steps: python -m pytest tests/test_cli/test_wrap_hintfile_agents.py -v (on GBK Windows) - Observed result: Before fix, test_prepare_only_injects_rtk_into_hintfile fails with UnicodeDecodeError 'gbk' at position 283 (the em dash in RTK block). After fix, all 12 hintfile tests pass including new UTF-8 round-trip test. - Not tested: no manual `headroom wrap copilot` run against a real Copilot installation ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes This is the same class of bug reported in #733 (GBK config.toml corruption). This PR fixes the `wrap.py` call sites; other modules (`learn/analyzer.py`, `install/providers.py`) have the same pattern and could benefit from the same treatment in a follow-up. --------- Signed-off-by: Yiming Zeng <yzeng424@gmail.com> Signed-off-by: RTCartist <wangshengb@buaa.edu.cn> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 01:07:03 +08:00
after_rewrap = json.loads(config_file.read_text(encoding="utf-8"))
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 18:07:12 +02:00
assert after_rewrap["model"] == "openai/gpt-4o"
assert "headroom" in after_rewrap.get("provider", {})
def test_unwrap_opencode_restores_backup_and_removes_it(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Unwrap removes the backup file after successful restore."""
monkeypatch.chdir(tmp_path)
_set_test_home(monkeypatch, tmp_path)
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
backup_file = config_file.with_suffix(".json.headroom-backup")
config_file.parent.mkdir(parents=True, exist_ok=True)
original = '{"model": "openai/gpt-4o"}'
config_file.write_text(original)
backup_file.write_text(original)
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
result = runner.invoke(main, ["unwrap", "opencode"])
assert result.exit_code == 0, result.output
assert "Restored prior" in result.output
assert not backup_file.exists(), "backup file was not cleaned up after restore"
def test_wrap_opencode_no_arguments_is_valid(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`headroom wrap opencode` with no additional arguments is a valid command."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003
captured.update(kwargs)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "opencode", "--no-mcp"])
assert result.exit_code == 0, result.output
assert captured["tool_label"] == "OPENCODE"
assert captured["args"] == ()
def test_wrap_opencode_with_memory_flag(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""--memory flag is accepted and does not crash."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(
main, ["wrap", "opencode", "--port", "9000", "--memory", "--no-mcp"]
)
assert result.exit_code == 0, result.output
def test_wrap_opencode_with_backend_and_anyllm_provider(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""--backend and --anyllm-provider flags are accepted."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(
ci: restore green lint (reformat for ruff 0.15.17, fix mypy no-any-return, pin linters) (#1295) ## Description The CI lint job (`ruff check .` → `ruff format --check .` → `mypy headroom`) was red on `main` and therefore on every open PR, for two unrelated reasons that the early ruff failure was masking: 1. **ruff**: the lint job installs `ruff` unpinned, and ruff 0.15.17 began enforcing import-block sorting (`I001`) and formatting that older ruff accepted → `ruff check .` / `ruff format --check .` fail on files nobody touched. 2. **mypy**: `headroom/providers/opencode/config.py` had two `return json.loads(...)` statements in a function declared `-> dict[str, Any]`; `json.loads` is typed `Any`, so `mypy headroom` fails with `no-any-return` (reproduced on mypy 1.20.2 — not a version-specific quirk). This restores a green lint baseline and pins both linters so a future release can't silently break CI again. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `.github/workflows/ci.yml`: pin `ruff==0.15.17` and `mypy==1.20.2` in the lint job. - Applied `ruff check --fix .` (6 `I001` import-sort fixes) and `ruff format .` (10 files) across the repo — import ordering and whitespace only, no behavior change. - `headroom/providers/opencode/config.py`: narrow both `_parse_json_loose` return sites with an `isinstance(parsed, dict)` guard, so the `dict[str, Any]` annotation is true at runtime (non-dict JSON falls back to `{}`) and mypy's `no-any-return` is resolved. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`, `ruff format --check .`, `mypy headroom --ignore-missing-imports`) ### Test Output ```text $ python -m ruff check . All checks passed! $ python -m ruff format --check . 913 files already formatted $ python -m mypy headroom/providers/opencode/config.py --ignore-missing-imports Success: no issues found in 1 source file $ python -m pytest tests/test_providers_opencode_config.py -q 37 passed ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13, ruff 0.15.17, mypy 1.20.2, branch ci/fix-ruff-lint off headroomlabs-ai/main - Exact command / steps: reproduced the red lint (latest ruff: 6 `I001` + 10 unformatted files; the mypy failure was read from the #1295 CI lint log — `config.py:125,133 no-any-return`, and re-confirmed locally on mypy 1.20.2). Applied the ruff auto-fix/format, added the dict guard, pinned both linters, and re-ran each lint step. - Observed result: `ruff check .` → "All checks passed!"; `ruff format --check .` → "913 files already formatted"; `mypy` on the fixed file → "Success: no issues found"; full `mypy headroom` reports only Unix `fcntl` attributes that don't exist on this Windows box (present on the Linux CI runner, where the prior run showed exactly the two now-fixed errors). 37 opencode-config tests pass. - Not tested: did not run the full OS/Python test matrix — the change is formatting + two CI dependency pins + a two-line type-narrowing guard, with no runtime behavior change for dict JSON. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:14:40 +02:00
main,
[
"wrap",
"opencode",
"--port",
"9000",
"--backend",
"anyllm",
"--anyllm-provider",
"groq",
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 18:07:12 +02:00
"--no-mcp",
ci: restore green lint (reformat for ruff 0.15.17, fix mypy no-any-return, pin linters) (#1295) ## Description The CI lint job (`ruff check .` → `ruff format --check .` → `mypy headroom`) was red on `main` and therefore on every open PR, for two unrelated reasons that the early ruff failure was masking: 1. **ruff**: the lint job installs `ruff` unpinned, and ruff 0.15.17 began enforcing import-block sorting (`I001`) and formatting that older ruff accepted → `ruff check .` / `ruff format --check .` fail on files nobody touched. 2. **mypy**: `headroom/providers/opencode/config.py` had two `return json.loads(...)` statements in a function declared `-> dict[str, Any]`; `json.loads` is typed `Any`, so `mypy headroom` fails with `no-any-return` (reproduced on mypy 1.20.2 — not a version-specific quirk). This restores a green lint baseline and pins both linters so a future release can't silently break CI again. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `.github/workflows/ci.yml`: pin `ruff==0.15.17` and `mypy==1.20.2` in the lint job. - Applied `ruff check --fix .` (6 `I001` import-sort fixes) and `ruff format .` (10 files) across the repo — import ordering and whitespace only, no behavior change. - `headroom/providers/opencode/config.py`: narrow both `_parse_json_loose` return sites with an `isinstance(parsed, dict)` guard, so the `dict[str, Any]` annotation is true at runtime (non-dict JSON falls back to `{}`) and mypy's `no-any-return` is resolved. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`, `ruff format --check .`, `mypy headroom --ignore-missing-imports`) ### Test Output ```text $ python -m ruff check . All checks passed! $ python -m ruff format --check . 913 files already formatted $ python -m mypy headroom/providers/opencode/config.py --ignore-missing-imports Success: no issues found in 1 source file $ python -m pytest tests/test_providers_opencode_config.py -q 37 passed ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13, ruff 0.15.17, mypy 1.20.2, branch ci/fix-ruff-lint off headroomlabs-ai/main - Exact command / steps: reproduced the red lint (latest ruff: 6 `I001` + 10 unformatted files; the mypy failure was read from the #1295 CI lint log — `config.py:125,133 no-any-return`, and re-confirmed locally on mypy 1.20.2). Applied the ruff auto-fix/format, added the dict guard, pinned both linters, and re-ran each lint step. - Observed result: `ruff check .` → "All checks passed!"; `ruff format --check .` → "913 files already formatted"; `mypy` on the fixed file → "Success: no issues found"; full `mypy headroom` reports only Unix `fcntl` attributes that don't exist on this Windows box (present on the Linux CI runner, where the prior run showed exactly the two now-fixed errors). 37 opencode-config tests pass. - Not tested: did not run the full OS/Python test matrix — the change is formatting + two CI dependency pins + a two-line type-narrowing guard, with no runtime behavior change for dict JSON. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:14:40 +02:00
],
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 18:07:12 +02:00
)
assert result.exit_code == 0, result.output
def test_wrap_opencode_with_no_proxy(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""--no-proxy flag skips proxy startup but still configures the tool."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(
main, ["wrap", "opencode", "--port", "9000", "--no-proxy", "--no-mcp"]
)
assert result.exit_code == 0, result.output
def test_wrap_opencode_with_verbose_flag(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""--verbose flag does not crash."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(
main, ["wrap", "opencode", "--port", "9000", "--verbose", "--no-mcp"]
)
assert result.exit_code == 0, result.output
def test_wrap_opencode_respects_opencode_home_env(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""OPENCODE_HOME env var controls where AGENTS.md is written."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
custom_home = str(tmp_path / "custom-opencode-home")
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("OPENCODE_HOME", custom_home)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
ci: restore green lint (reformat for ruff 0.15.17, fix mypy no-any-return, pin linters) (#1295) ## Description The CI lint job (`ruff check .` → `ruff format --check .` → `mypy headroom`) was red on `main` and therefore on every open PR, for two unrelated reasons that the early ruff failure was masking: 1. **ruff**: the lint job installs `ruff` unpinned, and ruff 0.15.17 began enforcing import-block sorting (`I001`) and formatting that older ruff accepted → `ruff check .` / `ruff format --check .` fail on files nobody touched. 2. **mypy**: `headroom/providers/opencode/config.py` had two `return json.loads(...)` statements in a function declared `-> dict[str, Any]`; `json.loads` is typed `Any`, so `mypy headroom` fails with `no-any-return` (reproduced on mypy 1.20.2 — not a version-specific quirk). This restores a green lint baseline and pins both linters so a future release can't silently break CI again. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `.github/workflows/ci.yml`: pin `ruff==0.15.17` and `mypy==1.20.2` in the lint job. - Applied `ruff check --fix .` (6 `I001` import-sort fixes) and `ruff format .` (10 files) across the repo — import ordering and whitespace only, no behavior change. - `headroom/providers/opencode/config.py`: narrow both `_parse_json_loose` return sites with an `isinstance(parsed, dict)` guard, so the `dict[str, Any]` annotation is true at runtime (non-dict JSON falls back to `{}`) and mypy's `no-any-return` is resolved. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`, `ruff format --check .`, `mypy headroom --ignore-missing-imports`) ### Test Output ```text $ python -m ruff check . All checks passed! $ python -m ruff format --check . 913 files already formatted $ python -m mypy headroom/providers/opencode/config.py --ignore-missing-imports Success: no issues found in 1 source file $ python -m pytest tests/test_providers_opencode_config.py -q 37 passed ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13, ruff 0.15.17, mypy 1.20.2, branch ci/fix-ruff-lint off headroomlabs-ai/main - Exact command / steps: reproduced the red lint (latest ruff: 6 `I001` + 10 unformatted files; the mypy failure was read from the #1295 CI lint log — `config.py:125,133 no-any-return`, and re-confirmed locally on mypy 1.20.2). Applied the ruff auto-fix/format, added the dict guard, pinned both linters, and re-ran each lint step. - Observed result: `ruff check .` → "All checks passed!"; `ruff format --check .` → "913 files already formatted"; `mypy` on the fixed file → "Success: no issues found"; full `mypy headroom` reports only Unix `fcntl` attributes that don't exist on this Windows box (present on the Linux CI runner, where the prior run showed exactly the two now-fixed errors). 37 opencode-config tests pass. - Not tested: did not run the full OS/Python test matrix — the change is formatting + two CI dependency pins + a two-line type-narrowing guard, with no runtime behavior change for dict JSON. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:14:40 +02:00
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
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 18:07:12 +02:00
assert result.exit_code == 0, result.output
agents_md = Path(custom_home) / "AGENTS.md"
assert agents_md.exists()
fix(cli): add explicit UTF-8 encoding to file I/O in wrap commands (#1126) (#1164) ## Description On Windows, `Path.read_text()` and `open()` default to the system locale encoding (cp1252, GBK, etc.) instead of UTF-8. This causes `UnicodeDecodeError` when reading or writing instruction files that contain multi-byte UTF-8 characters such as smart quotes or em dashes. The RTK instructions block itself contains an em dash (U+2014, `—`), so `_inject_rtk_instructions` crashes on any non-UTF-8 Windows locale when writing to `.copilot_instructions`, `.clinerules`, `AGENTS.md`, or similar hint files. Closes #1126 ## 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 - Add `encoding="utf-8"` to all bare `read_text()`, `write_text()`, and `open()` calls in `headroom/cli/wrap.py` that handle instruction or config files (18 call sites) - Update test assertions in `test_wrap_hintfile_agents.py`, `test_wrap_copilot.py`, and `test_wrap_bridge.py` to read with `encoding="utf-8"` - Add `test_inject_rtk_handles_utf8_content` verifying that existing hint files with smart quotes and em dashes survive RTK injection without crashing ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cli/test_wrap_hintfile_agents.py tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_bridge.py -v 47 passed in 1.28s ``` ## Real Behavior Proof - Environment: Windows 11 China (GBK locale), Python 3.11, headroom main (f03e77b) - Exact command / steps: python -m pytest tests/test_cli/test_wrap_hintfile_agents.py -v (on GBK Windows) - Observed result: Before fix, test_prepare_only_injects_rtk_into_hintfile fails with UnicodeDecodeError 'gbk' at position 283 (the em dash in RTK block). After fix, all 12 hintfile tests pass including new UTF-8 round-trip test. - Not tested: no manual `headroom wrap copilot` run against a real Copilot installation ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes This is the same class of bug reported in #733 (GBK config.toml corruption). This PR fixes the `wrap.py` call sites; other modules (`learn/analyzer.py`, `install/providers.py`) have the same pattern and could benefit from the same treatment in a follow-up. --------- Signed-off-by: Yiming Zeng <yzeng424@gmail.com> Signed-off-by: RTCartist <wangshengb@buaa.edu.cn> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 01:07:03 +08:00
# ---------------------------------------------------------------------------
# Regression: unwrap must preserve non-ASCII UTF-8 user content (#1126)
# ---------------------------------------------------------------------------
def test_unwrap_opencode_preserves_utf8_user_content(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Unwrap strips Headroom blocks but preserves non-ASCII UTF-8 user content (#1126)."""
monkeypatch.chdir(tmp_path)
_set_test_home(monkeypatch, tmp_path)
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
config_file.parent.mkdir(parents=True, exist_ok=True)
# User content with smart quotes and em dashes (non-ASCII UTF-8)
user_config = {
"model": "openai/gpt-4o",
"description": "“smart quotes” and an em dash — here",
}
user_json = json.dumps(user_config, ensure_ascii=False)
wrapped_content = (
wrap_mod._PROVIDER_MARKER_START
+ '\n"provider": {},\n'
+ wrap_mod._PROVIDER_MARKER_END
+ "\n"
+ user_json
)
config_file.write_text(wrapped_content, encoding="utf-8")
# Mock out OpencodeRegistrar to avoid its own bare-open encoding issue
# (pre-existing; outside this PR's scope).
fake_registrar = type("FakeRegistrar", (), {"detect": lambda self: False})()
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
with patch("headroom.mcp_registry.OpencodeRegistrar", return_value=fake_registrar):
result = runner.invoke(main, ["unwrap", "opencode"])
assert result.exit_code == 0, result.output
assert "Removed Headroom block" in result.output
content = config_file.read_text(encoding="utf-8")
assert "“smart quotes”" in content
assert "" in content
assert wrap_mod._PROVIDER_MARKER_START not in content