From b4571cc346f6bba29e600fa82bbf5cf302e8ea27 Mon Sep 17 00:00:00 2001 From: Rudimar Ronsoni Date: Mon, 22 Jun 2026 18:07:12 +0200 Subject: [PATCH] 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> --- .serena/.gitignore | 2 + .serena/project.yml | 133 ++++ AGENTS.md | 42 ++ Makefile | 17 + docs/content/docs/meta.json | 1 + docs/content/docs/opencode.mdx | 120 ++++ docs/content/docs/persistent-installs.mdx | 1 + e2e/wrap/Dockerfile | 11 +- e2e/wrap/run.py | 60 +- headroom/binaries.py | 28 + headroom/cli/wrap.py | 261 ++++++- headroom/install/models.py | 1 + headroom/install/paths.py | 14 + headroom/install/planner.py | 4 +- headroom/install/runtime.py | 4 +- headroom/mcp_registry/__init__.py | 2 + headroom/mcp_registry/install.py | 3 +- headroom/mcp_registry/opencode.py | 182 +++++ headroom/memory/traffic_learner.py | 2 +- headroom/observability/metrics.py | 13 +- headroom/observability/tracing.py | 28 +- headroom/providers/install_registry.py | 9 + headroom/providers/opencode/__init__.py | 31 + headroom/providers/opencode/config.py | 229 ++++++ headroom/providers/opencode/install.py | 88 +++ headroom/providers/opencode/runtime.py | 81 +++ headroom/release_version.py | 36 + headroom/telemetry/context.py | 2 +- plugins/opencode/.gitignore | 3 + plugins/opencode/README.md | 127 ++++ plugins/opencode/hook-shim/handler.js | 8 + plugins/opencode/package.json | 46 ++ plugins/opencode/src/index.ts | 23 + plugins/opencode/src/plugin.test.ts | 68 ++ plugins/opencode/src/plugin.ts | 69 ++ plugins/opencode/src/provider.ts | 92 +++ plugins/opencode/src/retrieve.ts | 94 +++ plugins/opencode/src/transport.test.ts | 215 ++++++ plugins/opencode/src/transport.ts | 438 +++++++++++ plugins/opencode/tsconfig.json | 19 + plugins/opencode/tsup.config.ts | 10 + plugins/opencode/vitest.config.ts | 8 + scripts/install.ps1 | 5 +- scripts/install.sh | 6 +- tests/conftest.py | 11 +- tests/test_cli/test_install_cli.py | 2 +- tests/test_cli/test_wrap_opencode.py | 840 ++++++++++++++++++++++ tests/test_cli/test_wrap_persistent.py | 1 + tests/test_install/test_paths.py | 1 + tests/test_install/test_providers.py | 188 +++++ tests/test_learn/test_scanner.py | 4 +- tests/test_mcp_registry_opencode.py | 392 ++++++++++ tests/test_memory/test_traffic_learner.py | 18 +- tests/test_ml_model_registry_lifecycle.py | 9 + tests/test_providers_opencode_config.py | 469 ++++++++++++ tests/test_providers_opencode_install.py | 112 +++ uv.lock | 34 +- 57 files changed, 4655 insertions(+), 62 deletions(-) create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml create mode 100644 AGENTS.md create mode 100644 docs/content/docs/opencode.mdx create mode 100644 headroom/mcp_registry/opencode.py create mode 100644 headroom/providers/opencode/__init__.py create mode 100644 headroom/providers/opencode/config.py create mode 100644 headroom/providers/opencode/install.py create mode 100644 headroom/providers/opencode/runtime.py create mode 100644 plugins/opencode/.gitignore create mode 100644 plugins/opencode/README.md create mode 100644 plugins/opencode/hook-shim/handler.js create mode 100644 plugins/opencode/package.json create mode 100644 plugins/opencode/src/index.ts create mode 100644 plugins/opencode/src/plugin.test.ts create mode 100644 plugins/opencode/src/plugin.ts create mode 100644 plugins/opencode/src/provider.ts create mode 100644 plugins/opencode/src/retrieve.ts create mode 100644 plugins/opencode/src/transport.test.ts create mode 100644 plugins/opencode/src/transport.ts create mode 100644 plugins/opencode/tsconfig.json create mode 100644 plugins/opencode/tsup.config.ts create mode 100644 plugins/opencode/vitest.config.ts create mode 100644 tests/test_cli/test_wrap_opencode.py create mode 100644 tests/test_mcp_registry_opencode.py create mode 100644 tests/test_providers_opencode_config.py create mode 100644 tests/test_providers_opencode_install.py diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 000000000..2e510aff5 --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 000000000..0e9ac3ead --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,133 @@ +# the name by which the project can be referenced within Serena +project_name: "feature-opencode-wrap" + + +# list of languages for which language servers are started; choose from: +# al angular ansible bash clojure +# cpp cpp_ccls crystal csharp csharp_omnisharp +# dart elixir elm erlang fortran +# fsharp go groovy haskell haxe +# hlsl html java json julia +# kotlin lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor powershell python +# python_jedi python_ty r rego ruby +# ruby_solargraph rust scala scss solidity +# svelte swift systemverilog terraform toml +# typescript typescript_vts vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- typescript + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries. +# Currently supported for: TypeScript. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +additional_workspace_folders: [] + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +fixed_tools: [] + +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. +# This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..6b2291ab0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ + +# RTK (Rust Token Killer) - Token-Optimized Commands + +When running shell commands, **always prefix with `rtk`**. This reduces context +usage by 60-90% with zero behavior change. If rtk has no filter for a command, +it passes through unchanged — so it is always safe to use. + +## Key Commands +```bash +# Git (59-80% savings) +rtk git status rtk git diff rtk git log + +# Files & Search (60-75% savings) +rtk ls rtk read rtk grep +rtk find rtk diff + +# Test (90-99% savings) — shows failures only +rtk pytest tests/ rtk cargo test rtk test + +# Build & Lint (80-90% savings) — shows errors only +rtk tsc rtk lint rtk cargo build +rtk prettier --check rtk mypy rtk ruff check + +# Analysis (70-90% savings) +rtk err rtk log rtk json +rtk summary rtk deps rtk env + +# GitHub (26-87% savings) +rtk gh pr view rtk gh run list rtk gh issue list + +# Infrastructure (85% savings) +rtk docker ps rtk kubectl get rtk docker logs + +# Package managers (70-90% savings) +rtk pip list rtk pnpm install rtk npm run