Skip to content

feat(opencode): HEADROOM_OPENCODE_BIN for forks + config/rtk fixes - #2021

Closed
lennney wants to merge 18 commits into
headroomlabs-ai:mainfrom
lennney:fix/opencode-custom-bin
Closed

feat(opencode): HEADROOM_OPENCODE_BIN for forks + config/rtk fixes#2021
lennney wants to merge 18 commits into
headroomlabs-ai:mainfrom
lennney:fix/opencode-custom-bin

Conversation

@lennney

@lennney lennney commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Description

Allow HEADROOM_OPENCODE_BIN env var to support OpenCode forks with custom binary names (e.g. Rolandcode). Also fixes 10+ long-standing and newly-discovered bugs in the OpenCode integration discovered through adversarial testing: config file preference, backup naming, duplicate rtk injection, MCP block stripping, lock file permissions, Windows fcntl compatibility, JSONC parsing edge cases, and more.

Closes #1927, closes #1588, closes #1980

Type of Change

  • New feature (non-breaking change which adds functionality)
  • Bug fix (non-breaking change which fixes an issue)
  • Documentation (docs update)

Changes Made

Feature

  • Add HEADROOM_OPENCODE_BIN env var to customise binary name resolution
  • Deduplicate _opencode_home_dir() logic spread across 4 files into single _shared.py
  • Make MCP registrar config writes atomic (temp-file + rename, guarded by fcntl.flock)
  • Add _locked_config() context manager with advisory locking to prevent concurrent write corruption

Bug fixes (design review)

Bug fixes (adversarial testing — 8 workers × concurrent stress + malicious inputs + crash recovery)

  • MCP block stripped on reinstall: apply_provider_scope called strip_opencode_headroom_blocks with default remove_mcp=True, nuking MCP entries during install→wrap→reinstall cycle
  • Windows import fcntl crashes: Module-level import fcntl caused ModuleNotFoundError on Windows (docstring claimed no-op but code didn't protect it). Now guarded with try/except.
  • _locked_config mypy 1.20.2 type error: CI uses mypy 1.x which requires explicit -> Generator[None, None, None] on @contextmanager functions (mypy 2.x relaxed this)
  • JSONC config destroyed by registrar: _read_json used strict json.load which fails on // comments, returning {} and causing _write_json to overwrite entire config (model, provider, user data all lost). Now strips comments as fallback.
  • Trailing JSONC comments without \n not stripped: Regex ^\s*//[^\n]*\n missed EOF-terminating comments. Fixed to handle EOF boundary.
  • Empty command: [] causes IndexError: _entry_to_spec indexing command_value[0] without emptiness guard. Now falls through to safe branch.

Test updates

  • Added test_opencode_config_paths_prefers_jsonc
  • Added 42 tests across E2E (30) and concurrency (11) suites
  • Updated E2E wrap test to no longer assert project AGENTS.md creation
  • Adversarial testing: 8-thread concurrent hammer (160 ops, 0 errors), 8-thread mixed register/unregister/force (160 ops, 0 leaks), 15 malicious input variants, crash recovery (kill-9 mid-write), 200 register/unregister cycles, 10-thread force overwrite race, 6 JSONC comment variants, injection attacks, 100-entry × 10-args scale test, real headroom wrap E2E with custom binary

Testing

  • Unit tests (pytest)
  • Adversarial testing (concurrent stress + malicious inputs + crash recovery)
  • E2E tests (real fork+exec of headroom wrap)
  • Concurrency safety tests (lock file prevents corruption)
  • CI lint pre-check (ruff check + ruff format --check + mypy)
$ uv run pytest tests/test_providers_opencode_config.py tests/test_cli/test_wrap_opencode.py tests/test_mcp_registry_opencode.py tests/test_install/ tests/test_providers_opencode_install.py -q
209 passed, 1 skipped in 13.15s

# Adversarial: 8 workers × 20 concurrent registrations
=== CONCURRENT HAMMER: 8 workers × 20 ops = 160 total ===
Results: 160 | Errors: 0
Config entries: 160 | Missing: 0 | Extra: 0
get_server failures: 0/160

# Adversarial: 8 workers mixed reg/unreg/force
=== CONCURRENT MIXED: 8 workers, 10 reg + 5 unreg + 5 force each ===
Total ops: 160 | Errors: 0
Config entries: 40 (expected ~40)
Unregistered entries still present: 0

# Adversarial: 15 malicious inputs
empty file ✅ | whitespace ✅ | no mcp key ✅ | mcp is string ✅ | mcp is list ✅
long server name ✅ | empty name ✅ | big env dict ✅ | nul in name ✅
shell metacharacters ✅ | unicode ✅ | json injection ✅ | empty mcp cleanup ✅

# Adversarial: crash recovery
Config exists after crash: True | Original data intact: ✅
Stale temp files: harmlessly present | Operations unblocked: ✅

# E2E: real headroom wrap with custom binary
Custom binary detected ✅ | config.json created ✅ | backup created ✅
lock file permissions 600 ✅ | unwrap removes provider ✅

Real Behavior Proof

  • Environment: Python 3.12, headroom dev install (uv sync), Ubuntu 24.04
  • Exact command / steps: (1) HEADROOM_OPENCODE_BIN=my-opencode headroom wrap opencode --port 9999 -- --help → custom binary invoked; (2) headroom wrap opencode --port 9999 -- --help → default opencode binary; (3) headroom unwrap opencode --port 9999 → provider removed, config restored; (4) uv run pytest ... → 209 passed; (5) adversarial tests above → 0 errors across all categories; (6) uv run ruff check . && uv run ruff format --check . && uv run mypy headroom --ignore-missing-imports → 0 errors
  • Observed result: Custom binary resolution via HEADROOM_OPENCODE_BIN works. Config writes are atomic (crash-kill mid-write leaves original intact). Lock file 0o600. Backup naming correct for .jsonc (.jsonc.headroom-backup). Concurrent 8-thread registration/unregistration produces 0 errors, 0 leaks, 0 missing entries. JSONC files with comments are NOT destroyed by the registrar (non-MCP data preserved). MCP blocks survive install→wrap→reinstall cycle. Windows import fcntl guarded by try/except. All lint/format/mypy clean.
  • Not tested: OpenCode fork with entirely different config path structure; Windows CI runner (fcntl guard is code-level, tested on Linux only); macOS CI runner (rust-based OpenCode)

Review Readiness

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

lennney added 2 commits July 11, 2026 16:07
Add HEADROOM_OPENCODE_BIN env var to customize the opencode binary name
that headroom resolves via shutil.which. Deduplicate the
_opencode_home_dir() logic spread across 4 files into a single _shared.py.

Closes headroomlabs-ai#1927

Design: HEADROOM_OPENCODE_BIN controls binary discovery only — config
path stays at ~/.config/opencode/ (OpenCode hardcoded location).
Use OPENCODE_HOME or OPENCODE_CONFIG to point at a fork custom config.

Also: make MCP registrar config writes atomic (temp-file + rename)
and add fcntl.flock advisory locking on .lock file to prevent data
corruption from concurrent headroom processes. Remove redundant
unregister_server call in register_server(force=True) that doubled
the race window.

- New _shared.py: source of truth for all config path resolution
- wrap.py, config.py, mcp_registry/opencode.py: import from _shared
- mcp_registry/opencode.py: atomic writes + flock + single RMW
- config.py: remove dead imports
- Tests: +42 new tests (E2E 30 + concurrency 11)
- paths.py: remove stale HEADROOM_OPENCODE_BIN basename reference from
  opencode_config_path() docstring (config dir is always ~/.config/opencode,
  not derived from binary name)
- install.py: strip prior Headroom-managed blocks before re-injecting in
  apply_provider_scope(), mirroring inject_opencode_provider_config().
  Fixes revert failure when transitioning from wrapped to persistent
  install state.
@lennney
lennney force-pushed the fix/opencode-custom-bin branch from fff8a03 to 0ccf48e Compare July 11, 2026 08:08
@lennney
lennney marked this pull request as ready for review July 11, 2026 08:08
lennney added 3 commits July 11, 2026 16:08
- _shared.py: _opencode_config_path() checks for opencode.jsonc first,
  falls back to opencode.json. OPENCODE_CONFIG env var still takes
  precedence over both.
- config.py: opencode_config_paths() uses .with_name() instead of
  .with_suffix() for backup files, so .jsonc backup is correctly
  named opencode.jsonc.headroom-backup (not opencode.headroom-backup).
- install.py: same backup naming fix in apply_provider_scope() and
  revert_provider_scope().
- Tests: added test_opencode_config_paths_prefers_jsonc.

Closes headroomlabs-ai#1588
Align with Codex wrapper behaviour — only inject rtk instructions into
the global OpenCode AGENTS.md (~/.config/opencode/AGENTS.md), not the
project-level AGENTS.md. The project file is a tracked team artefact
and should not be modified by a wrapper tool.

Other agents (Claude, Codex) already follow this pattern. Only
OpenCode was double-injecting into both global and project.

Closes headroomlabs-ai#1980
Comment thread headroom/mcp_registry/opencode.py Fixed
lennney added 2 commits July 11, 2026 17:15
CodeQL flagged os.open with 0o644 mask sets the lock file to
world-readable. Change to 0o600 (owner-only read/write) since
lock files don't need to be readable by other users.
358d7bb stopped injecting rtk into the project-level AGENTS.md
for OpenCode wrap (intentional — aligns with Codex behaviour).
Update the E2E test to no longer assert that the project file
exists or contains the RTK marker.
@lennney lennney changed the title feat(opencode): support HEADROOM_OPENCODE_BIN for custom binary names feat(opencode): HEADROOM_OPENCODE_BIN for forks + config/rtk fixes Jul 11, 2026
@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

PR governance

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

@github-actions github-actions Bot added status: needs author action Pull request body or readiness checklist still needs author updates status: ci failing Required or reported CI checks are failing status: ready for review Pull request body is complete and the author marked it ready for human review and removed status: needs author action Pull request body or readiness checklist still needs author updates status: ready for review Pull request body is complete and the author marked it ready for human review labels Jul 11, 2026
@github-actions github-actions Bot added status: ready for review Pull request body is complete and the author marked it ready for human review and removed status: ci failing Required or reported CI checks are failing labels Jul 11, 2026
lennney and others added 7 commits July 11, 2026 18:12
- Split semicolons and colon-one-liners into separate statements
- Add noqa: E402 for intentional post-sys.path imports
mypy 1.20.2 (CI) requires return type on @contextmanager generators;
local mypy 2.2.0 was lenient but CI failed with [no-untyped-def].
import fcntl at module level would crash on Windows where the
module doesn't exist. Wrap in try/except and make _locked_config
a true no-op when fcntl is unavailable.
apply_provider_scope called strip_opencode_headroom_blocks
with default remove_mcp=True, which nuked MCP server entries
on reinstall after wrap. Use remove_mcp=False to only strip
the provider block.
MCP entries with "command": [] (empty list) would raise IndexError
on command_value[0] in the isinstance(list) branch. Add truthiness
check to fall through to the safe else branch instead.
…P data

_read_json used strict json.load which fails on // comments,
returning {} and causing _write_json to nuke the entire config
(model, provider, user comments). Add comment-stripping fallback
mirroring _parse_json_loose from config.py.
Regex r'^\s*//[^\n]*\n' only matched comment-only lines ending
with \n, missing a trailing comment on the last line of a file
(EOF with no newline). Drop the \n requirement to also strip
terminating comments. Applied to both _read_json and
_parse_json_loose.

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The implementation is close, and CI is green on the hosted matrix, but I hit a Windows portability failure in the focused OpenCode test suite:

uv run pytest tests/test_providers_opencode_config.py -q
FAILED tests/test_providers_opencode_config.py::test_build_launch_env_with_project
assert 'C:\\...\\entry.opencode.js' in '{"plugin":["C:\\\\...\\\\entry.opencode.js"]}'

The actual launch env is JSON-encoding the Windows path, so backslashes are escaped in OPENCODE_CONFIG_CONTENT. The assertion should parse the JSON and compare config["plugin"] (or compare against json.dumps(str(plugin))), rather than searching for the raw platform path inside the serialized JSON string.

This is small, but worth fixing before merge because this PR is explicitly touching OpenCode config/wrap behavior and the repository supports Windows workflows. The MCP registry tests passed locally (31 passed), and CI is otherwise green.

@github-actions github-actions Bot removed the status: ready for review Pull request body is complete and the author marked it ready for human review label Jul 11, 2026
lennney added 2 commits July 12, 2026 11:04
Replace raw string  check on OPENCODE_CONFIG_CONTENT with
json.loads() + structured comparison. On Windows the JSON-serialized
path backslashes are escaped (C:\\...\\entry.opencode.js), so the
raw str(plugin) never matches the JSON string.

The parsed comparison works identically on all platforms.
…-bin

# Conflicts:
#	headroom/mcp_registry/opencode.py
@lennney
lennney force-pushed the fix/opencode-custom-bin branch from f8ffe38 to bab4f1c Compare July 12, 2026 03:10
@lennney

lennney commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on upstream/main. Changes:

@github-actions github-actions Bot added status: ready for review Pull request body is complete and the author marked it ready for human review and removed status: ready for review Pull request body is complete and the author marked it ready for human review labels Jul 12, 2026

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the rebase and follow-up. The Windows-specific blocker from my last review is fixed: test_build_launch_env_with_project now parses OPENCODE_CONFIG_CONTENT as JSON and compares the plugin array instead of searching for the raw platform path.

Verified locally on Windows:

python -m pytest tests/test_providers_opencode_config.py -q
40 passed

I also rechecked the PR state: current head bab4f1ca is clean with no failed or pending GitHub checks. The broader tests/test_cli/test_wrap_opencode.py file stalled in my freshly cleaned local worktree before pytest emitted the session header, so I am not treating that as a PR failure; the hosted checks are green and the previous concrete blocker is resolved.

@lennney lennney closed this Jul 13, 2026
@lennney
lennney deleted the fix/opencode-custom-bin branch July 13, 2026 05:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants