Commit graph

5 commits

Author SHA1 Message Date
Peter Lodri
a51bbfb6a5
fix(opencode): use type=local + environment field for MCP config (#1380) (#1388)
## Summary

Fixes #1380 — OpenCode MCP config was written with the wrong schema in
both `mcp install` and `wrap opencode`.

### Root Cause

`_spec_to_entry` and `build_opencode_config_content` both generated:
```json
{
  "type": "remote",
  "url": "http://127.0.0.1:<port>/mcp",
  "env": {...}
}
```

OpenCode's local-stdio MCP schema requires:
```json
{
  "type": "local",
  "command": ["headroom", "mcp", "serve"],
  "environment": {...}
}
```

The proxy does not expose `/mcp`; it returns 404. The `env` field is
also the wrong key — OpenCode expects `environment`.

### Changes

- **`headroom/mcp_registry/opencode.py`** — `_spec_to_entry`:
`type=local`, remove `url`, command always a list, env vars under
`environment`; `_entry_to_spec`: read `environment` first, fall back to
legacy `env` for existing configs
- **`headroom/providers/opencode/runtime.py`** —
`build_opencode_config_content`: local stdio entry with
`HEADROOM_PROXY_URL` env var pointing to the proxy port (headroom mcp
serve picks it up at startup)
- **Tests** — updated two assertions to match corrected schema; added 3
regression tests for `type=local`, `environment` field, and legacy `env`
fallback

## Test Plan

- [x] `pytest tests/test_mcp_registry_opencode.py` — 64 passed
- [x] `pytest tests/test_cli/test_wrap_opencode.py` — 64 passed
- [x] All previously-passing tests remain green

## Remaining items from #1380

- `--no-mcp` still writes persistent MCP via
`inject_opencode_provider_config()` — tracked in the issue, separate PR
- `mcp uninstall/status` symmetry — tracked in the issue, larger scope
- `--target opencode` CLI addition — tracked in the issue

🤖 Generated with [Claude Code](https://claude.com/claude-code)

## Real behavior proof

**Setup:** macOS 14, Python 3.12, headroom-ai 0.27.0-dev, OpenCode 0.1.x

**Steps after patch:**
```bash
headroom mcp install --agent opencode
cat ~/.config/opencode/opencode.json | python3 -m json.tool
```

**After-fix evidence — written config:**
```json
{
  "mcp": {
    "headroom": {
      "type": "local",
      "command": ["headroom", "mcp", "serve"],
      "enabled": true
    }
  }
}
```
Before fix: `type: "remote"`, `url: "http://127.0.0.1:8787/mcp"` (404 on
proxy), `env` key (wrong field name). OpenCode failed to start headroom
MCP server.

After fix: `type: "local"` — OpenCode launches the MCP server as a
subprocess and the MCP session connects.

**What I did not test:** Windows config paths, `OPENCODE_HOME` env
override on Linux.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 20:36:14 +00:00
Abhay Singh
d079614b1f
fix(mcp/opencode): don't clobber an unparseable opencode.json on register (#1661)
## Description

`OpencodeRegistrar._write_entry` does a full-file read-modify-write of
`opencode.json`:

```python
data = _read_json(self._config_path)     # returns {} on JSONDecodeError
mcp = data.setdefault("mcp", {})
mcp[spec.name] = _spec_to_entry(spec)
_write_json(self._config_path, data)     # overwrites the ENTIRE file
```

`_read_json` returns `{}` for a file that exists but doesn't parse.
OpenCode
configs are commonly hand-edited and JSONC-ish (comments, trailing
commas), so a
file that doesn't strictly parse gets silently rewritten as just
`{"mcp": {"headroom": {...}}}` — **destroying the user's `theme`,
`model`,
`provider`, and any other MCP servers**. No backup.

This is the same class of data-loss bug as the Claude registrar
(separate PR);
this one is `headroom/mcp_registry/opencode.py`.

Closes: no issue filed — found while auditing the MCP registry
config-write paths.

## Fix

Keep `_read_json` (returning `{}`) for read-only callers. Add
`_read_json_for_write` for the rewrite path: it returns `{}` only when
the file
is **absent or empty**, and raises `_MalformedConfigError` when the file
is
present but not a JSON object. `_write_entry` catches it and returns
`FAILED`
with an actionable message instead of overwriting.

Absent/empty → registers fresh (unchanged); valid → merges, all keys
preserved
(unchanged); present-but-invalid → left untouched.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `headroom/mcp_registry/opencode.py`: add `_read_json_for_write` +
`_MalformedConfigError`; `_write_entry` uses it and returns `FAILED`
(without writing) when `opencode.json` is present-but-unparseable.
`_read_json` unchanged for read-only callers.
- `tests/test_mcp_registry_opencode.py`: regression tests — register
against malformed configs leaves the bytes untouched and returns
`FAILED`; register against a valid config still merges and preserves
`theme`/`model` plus a pre-existing MCP server.
- `CHANGELOG.md`: Bug Fixes entry under Unreleased.

## Testing

- [x] New tests added for the fixed behavior
- [x] Linting passes (`ruff check`) and formatting is clean (`ruff
format --check`)
- [ ] Full `pytest` deferred to CI (local-OOM reason below).

```text
$ uv run ruff check headroom/mcp_registry/opencode.py tests/test_mcp_registry_opencode.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, headroom built from this
branch. Importing `headroom` loads the torch/transformers stack; a full
`pytest` gets OOM-killed on this box, so I verified the write-path logic
with a dependency-free script and left the full pytest to CI.
- Exact command / steps: the write-path logic here is identical to the
Claude registrar fix, so I verified it with the same standalone script —
replicated `_read_json_for_write` + the read-modify-write flow (only
stdlib, no `headroom` import) against real temp files, exercising
absent, empty, four malformed variants, and a valid config carrying
unrelated keys.
- Observed result: absent/empty register fresh; every malformed variant
returns FAILED and the on-disk bytes are unchanged (no clobber); a valid
config merges the new server while unrelated keys survive:

```text
OK: absent -> fresh register
OK: empty -> fresh register
OK: malformed -> FAILED, original bytes preserved (no clobber)
OK: valid config -> merged, unrelated keys preserved
MCP CONFIG-WRITE LOGIC VERIFIED
```

- Not tested: driving a real `opencode` install end-to-end (didn't want
to touch a real config); the file-write path is exercised directly by
the regression tests. 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 + a standalone logic check; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- Companion to the Claude-registrar fix (same root cause, different
file). No new dependencies. This does not touch OpenCode's
`opencode.jsonc` file-selection (handled elsewhere) — it only hardens
the existing `opencode.json` write against clobbering.
2026-07-11 10:38:43 -05:00
Vinay Gupta
6c83790680
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
Parideboy
487aa71a3c
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 15:14:40 -05:00
Rudimar Ronsoni
b4571cc346
feat: headroom wrap opencode / unwrap opencode CLI (#1105)
## Summary

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

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

## What changed

### Transparent OpenCode wrapping

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

### Runtime transport interception

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

### Live provider additions

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

### Subagent and child-process coverage

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

## Why this goes beyond PR #1089

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

This PR goes further because:

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

## Additional robustness fixes

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

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

## Validation

All implementation validation was run inside Docker.

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

## Notes

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

---------

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