headroom/scripts/install.ps1

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

1807 lines
62 KiB
PowerShell
Raw Permalink Normal View History

$ErrorActionPreference = 'Stop'
fix(install): default docker image to headroomlabs-ai GHCR registry (#1867) (#2039) ## Description The GitHub repository was transferred from `chopratejas/headroom` to `headroomlabs-ai/headroom`. GitHub 301-redirects transferred repos for web and git operations, but **GitHub Container Registry (GHCR) does not** — the old package `ghcr.io/chopratejas/headroom` is now orphaned and frozen (its `latest` tag stopped advancing at `0.27.0`), while CI publishes new images to `ghcr.io/headroomlabs-ai/headroom` (the workflow derives the path from `${{ github.repository }}`). Headroom's install tooling still defaulted to the dead path, so `headroom install apply --preset persistent-docker`, `headroom init`, and the standalone install scripts all pulled a stale `0.27.0` image instead of the current release. This changes every docker-image **default** to `ghcr.io/headroomlabs-ai/headroom:latest`. Closes #1867 ## 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 - `headroom/install/models.py` — `InstallManifest.image` default. - `headroom/cli/install.py` — `--image` Click option default. - `headroom/cli/init.py` — two `InstallManifest(...)` image args. - `scripts/install.sh` / `scripts/install.ps1` — `IMAGE_DEFAULT` / `$ImageDefault` plus the `--image` help-text default. - `docker/docker-compose.native.yml` — image default in both services. - `tests/test_install/test_planner.py` (4) and `tests/test_install/test_runtime.py` (5) — updated the assertions that pinned the old image (including `assert "<image>" in command`), so they now verify the corrected registry threads through the planner and docker runtime command. Scope note: `github.com/chopratejas/...` links and the plugin marketplace slug are intentionally **not** changed — GitHub redirects those, so they still work. Only the genuinely-dead GHCR image references are touched. No new dependency, no new abstraction. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest tests/test_install/ -q 99 passed, 1 skipped in 48.34s $ uv run ruff check headroom/install/models.py headroom/cli/install.py headroom/cli/init.py All checks passed! $ grep -rn "ghcr.io/chopratejas/headroom" headroom/ scripts/ docker/ tests/ # (no source matches — every default now points at headroomlabs-ai) ``` The install-test assertions pinned the old image, so they fail against the old defaults and pass after the fix — they are the regression guard. ## Real Behavior Proof - **Environment:** Windows 11, Python 3.13.5, headroom installed from this branch (editable, via `uv`). - **Exact command / steps:** The dead-registry claim is verifiable at the registry level, independent of a release: ```text docker pull ghcr.io/chopratejas/headroom:latest # old default → 0.27.0 (frozen / orphaned) docker pull ghcr.io/headroomlabs-ai/headroom:latest # new default → current release ``` And the install pipeline now emits the correct image (covered by `tests/test_install/test_runtime.py`, which asserts the resolved docker command contains `ghcr.io/headroomlabs-ai/headroom:latest`). - **Observed result:** `grep` confirms no `ghcr.io/chopratejas/headroom` default remains in code, scripts, compose, or tests; `tests/test_install/` is green (99 passed) with the corrected image asserted end-to-end through planner → runtime command. - **Not tested:** A live `docker pull` of both tags on this specific machine (no local Docker daemon guaranteed) — the registry difference is reproducible by anyone running the two `docker pull` commands above; and an end-to-end `headroom install apply --preset persistent-docker` against a real Docker 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 - [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 - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes - Documentation checklist item is N/A — no user-facing docs surface beyond the CHANGELOG entry. - `mypy` left unchecked — not run as part of this verification; the change is a string-default swap with no type-level surface. - The `--image` help text and `docker-compose.native.yml` were included so every user-facing default is consistent; only the dead GHCR image string was changed. Co-authored-by: JerrettDavis <mxjerrett@gmail.com> Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-13 23:31:28 +05:30
$ImageDefault = 'ghcr.io/headroomlabs-ai/headroom:latest'
$InstallImage = if ($env:HEADROOM_DOCKER_IMAGE) { $env:HEADROOM_DOCKER_IMAGE } else { $ImageDefault }
$InstallDir = Join-Path $HOME '.local\bin'
if (-not (Test-Path (Join-Path $HOME '.local'))) {
$InstallDir = Join-Path $HOME 'bin'
}
function Write-Info {
param([string]$Message)
Write-Host "==> $Message"
}
function Require-Command {
param([string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "Missing required command: $Name"
}
}
function Ensure-PathEntry {
param([string]$PathEntry)
fix(install): stop the PowerShell installer leaking temp dirs into the real user PATH (#2985) ## Description `scripts/install.ps1` persists the install directory to the user's PATH through `Ensure-PathEntry`, which calls `[Environment]::SetEnvironmentVariable('Path', ..., 'User')`. That value lives in the `HKCU\Environment` registry key, so it is **not** scoped by a `HOME` / `USERPROFILE` override. `tests/test_install/test_native_installers.py::test_powershell_native_installer_supports_persistent_docker_lifecycle` runs that real installer against a `tmp_path` fake home. Every run therefore prepended the test's throwaway shim directory to the developer's actual, persistent user PATH -- and it stayed there after the test finished. The entries accumulate one per run, ahead of the real install dir; and since the installer also drops `headroom.ps1`/`headroom.cmd` into that dir, `headroom` in a fresh shell could then resolve to a leftover wrapper from a deleted temp directory (#2970). ## Fix Make the persistence scope configurable via `HEADROOM_INSTALL_PATH_SCOPE`, defaulting to `'User'` so production behavior is unchanged: ```powershell $scope = if ($env:HEADROOM_INSTALL_PATH_SCOPE) { $env:HEADROOM_INSTALL_PATH_SCOPE } else { 'User' } $currentPath = [Environment]::GetEnvironmentVariable('Path', $scope) ... [Environment]::SetEnvironmentVariable('Path', ($newPath -join ';'), $scope) ``` The installer tests (`_build_env`) set `HEADROOM_INSTALL_PATH_SCOPE=Process`, so the PATH update stays in the spawned PowerShell process (discarded when it exits) instead of writing to the registry. Fixes #2970 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature - [ ] Breaking change - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `scripts/install.ps1` (`Ensure-PathEntry`): read/write the PATH via `$env:HEADROOM_INSTALL_PATH_SCOPE` (default `'User'`). - `tests/test_install/test_native_installers.py`: `_build_env` sets `HEADROOM_INSTALL_PATH_SCOPE=Process` for every installer invocation; add a Windows-only `test_powershell_installer_does_not_leak_into_user_path` asserting the real User PATH entry count is unchanged across an installer run. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] New test added ### Test Output ```text tests/test_install/test_native_installers.py -k does_not_leak_into_user_path 1 passed # uvx ruff@0.15.22 check tests/test_install/test_native_installers.py -> All checks passed! ``` ## Real Behavior Proof - Environment: Windows 11, Windows PowerShell 5.1, Python 3.12.11, project venv, pytest 9.1.1, ruff 0.15.22 via uvx. - Exact command / steps: recorded the real user PATH entry count (`([Environment]::GetEnvironmentVariable('Path','User') -split ';').Count` = 27), ran the PowerShell installer test with the fix, then re-read the count: still 27 -- no leak. The new `test_powershell_installer_does_not_leak_into_user_path` formalizes this (before == after). - Observed result: running the installer test suite no longer mutates the developer's persistent user PATH; production installs still persist to `'User'` as before. - Not tested: the sibling `test_powershell_native_installer_supports_persistent_docker_lifecycle` fails on my Windows host on an unrelated `trusted_cidrs` dashboard-gateway assertion (it fails identically on `main` without this change, and the whole PowerShell suite is skipped on the Linux CI runners). This PR does not touch that path. ## Runtime Rollout Safety - Rollout-managed feature(s): none. This is the native PowerShell installer script, not a rollout-channel-gated runtime feature. - Minimum rollout channel: N/A (no rollout-managed behavior). - Stable/default behavior changed: no. Production installs still persist PATH to the `User` scope exactly as before; the new `HEADROOM_INSTALL_PATH_SCOPE` override defaults to `User` and is used only by the test suite to avoid mutating the developer's persistent PATH. - Kill switch / disable path: leave `HEADROOM_INSTALL_PATH_SCOPE` unset (the default) for the normal `User` behavior. - Unsafe override required: no. - Qualification impact: none. Installer-only; no proxy runtime path is touched. - Rollback path: revert this PR; the installer returns to writing the `User` PATH unconditionally. ## 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 - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md`: it is generated by release-please from my Conventional Commit PR title ## Additional Notes The scope override defaults to `'User'`, so nothing changes for real installs. It doubles as an escape hatch for any environment (CI images, ephemeral containers) that must not touch the persistent user PATH. --------- Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-08-17 03:35:04 +05:30
# Persist to the User PATH by default. The 'User' scope lives in
# HKCU\Environment and is NOT redirected by a HOME/USERPROFILE override, so a
# caller that must not mutate the real persistent PATH (the installer test
# suite, which runs this against a throwaway fake home) sets
# HEADROOM_INSTALL_PATH_SCOPE=Process to keep the update ephemeral instead of
# leaking the temp shim dir into the developer's actual user PATH (#2970).
#
# Only those two persistence modes are supported. The value is handed to
# .NET's EnvironmentVariableTarget, whose 'Machine' member would rewrite the
# SYSTEM-wide PATH if this variable were inherited by an elevated installer,
# and a typo would otherwise fail late with an opaque enum-conversion error.
# Normalize case-insensitively and allow-list 'User'/'Process', failing early
# and clearly for 'Machine' or anything else.
$scope = 'User'
if ($env:HEADROOM_INSTALL_PATH_SCOPE) {
switch ($env:HEADROOM_INSTALL_PATH_SCOPE.Trim().ToLowerInvariant()) {
'user' { $scope = 'User' }
'process' { $scope = 'Process' }
default {
throw "HEADROOM_INSTALL_PATH_SCOPE must be 'User' or 'Process' (got '$($env:HEADROOM_INSTALL_PATH_SCOPE)'); 'Machine' and other targets are not supported."
}
}
}
$currentPath = [Environment]::GetEnvironmentVariable('Path', $scope)
$parts = @()
if ($currentPath) {
$parts = $currentPath -split ';' | Where-Object { $_ }
}
if ($parts -notcontains $PathEntry) {
$newPath = @($PathEntry) + $parts
fix(install): stop the PowerShell installer leaking temp dirs into the real user PATH (#2985) ## Description `scripts/install.ps1` persists the install directory to the user's PATH through `Ensure-PathEntry`, which calls `[Environment]::SetEnvironmentVariable('Path', ..., 'User')`. That value lives in the `HKCU\Environment` registry key, so it is **not** scoped by a `HOME` / `USERPROFILE` override. `tests/test_install/test_native_installers.py::test_powershell_native_installer_supports_persistent_docker_lifecycle` runs that real installer against a `tmp_path` fake home. Every run therefore prepended the test's throwaway shim directory to the developer's actual, persistent user PATH -- and it stayed there after the test finished. The entries accumulate one per run, ahead of the real install dir; and since the installer also drops `headroom.ps1`/`headroom.cmd` into that dir, `headroom` in a fresh shell could then resolve to a leftover wrapper from a deleted temp directory (#2970). ## Fix Make the persistence scope configurable via `HEADROOM_INSTALL_PATH_SCOPE`, defaulting to `'User'` so production behavior is unchanged: ```powershell $scope = if ($env:HEADROOM_INSTALL_PATH_SCOPE) { $env:HEADROOM_INSTALL_PATH_SCOPE } else { 'User' } $currentPath = [Environment]::GetEnvironmentVariable('Path', $scope) ... [Environment]::SetEnvironmentVariable('Path', ($newPath -join ';'), $scope) ``` The installer tests (`_build_env`) set `HEADROOM_INSTALL_PATH_SCOPE=Process`, so the PATH update stays in the spawned PowerShell process (discarded when it exits) instead of writing to the registry. Fixes #2970 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature - [ ] Breaking change - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `scripts/install.ps1` (`Ensure-PathEntry`): read/write the PATH via `$env:HEADROOM_INSTALL_PATH_SCOPE` (default `'User'`). - `tests/test_install/test_native_installers.py`: `_build_env` sets `HEADROOM_INSTALL_PATH_SCOPE=Process` for every installer invocation; add a Windows-only `test_powershell_installer_does_not_leak_into_user_path` asserting the real User PATH entry count is unchanged across an installer run. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] New test added ### Test Output ```text tests/test_install/test_native_installers.py -k does_not_leak_into_user_path 1 passed # uvx ruff@0.15.22 check tests/test_install/test_native_installers.py -> All checks passed! ``` ## Real Behavior Proof - Environment: Windows 11, Windows PowerShell 5.1, Python 3.12.11, project venv, pytest 9.1.1, ruff 0.15.22 via uvx. - Exact command / steps: recorded the real user PATH entry count (`([Environment]::GetEnvironmentVariable('Path','User') -split ';').Count` = 27), ran the PowerShell installer test with the fix, then re-read the count: still 27 -- no leak. The new `test_powershell_installer_does_not_leak_into_user_path` formalizes this (before == after). - Observed result: running the installer test suite no longer mutates the developer's persistent user PATH; production installs still persist to `'User'` as before. - Not tested: the sibling `test_powershell_native_installer_supports_persistent_docker_lifecycle` fails on my Windows host on an unrelated `trusted_cidrs` dashboard-gateway assertion (it fails identically on `main` without this change, and the whole PowerShell suite is skipped on the Linux CI runners). This PR does not touch that path. ## Runtime Rollout Safety - Rollout-managed feature(s): none. This is the native PowerShell installer script, not a rollout-channel-gated runtime feature. - Minimum rollout channel: N/A (no rollout-managed behavior). - Stable/default behavior changed: no. Production installs still persist PATH to the `User` scope exactly as before; the new `HEADROOM_INSTALL_PATH_SCOPE` override defaults to `User` and is used only by the test suite to avoid mutating the developer's persistent PATH. - Kill switch / disable path: leave `HEADROOM_INSTALL_PATH_SCOPE` unset (the default) for the normal `User` behavior. - Unsafe override required: no. - Qualification impact: none. Installer-only; no proxy runtime path is touched. - Rollback path: revert this PR; the installer returns to writing the `User` PATH unconditionally. ## 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 - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md`: it is generated by release-please from my Conventional Commit PR title ## Additional Notes The scope override defaults to `'User'`, so nothing changes for real installs. It doubles as an escape hatch for any environment (CI images, ephemeral containers) that must not touch the persistent user PATH. --------- Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-08-17 03:35:04 +05:30
[Environment]::SetEnvironmentVariable('Path', ($newPath -join ';'), $scope)
}
}
function Ensure-ProfileBlock {
param([string]$PathEntry)
fix(install): don't crash the PowerShell installer when $PROFILE is unset (#2469) ## Description The PowerShell installer (`scripts/install.ps1`) crashes at the very end on any machine where PowerShell cannot resolve the current user's profile path. `Ensure-ProfileBlock` locates the profile with: ```powershell $profileDir = Split-Path -Parent $PROFILE ``` `$PROFILE` is an empty string when PowerShell cannot compute the profile path for the current user, which happens for a fresh account with no Documents folder yet, a service or CI context, or a redirected profile. `Split-Path -Parent ''` then throws: ``` Split-Path : Cannot bind argument to parameter 'Path' because it is an empty string. ``` Because the script runs under `$ErrorActionPreference = 'Stop'`, that terminates the whole installer with a non-zero exit, even though it happens after the `headroom` wrapper and the persistent User PATH entry were already written. The user sees a scary Split-Path error and assumes the install failed. ## Fix Skip the profile convenience block when `$PROFILE` is empty and log why. `Ensure-PathEntry` already persists the User PATH for new sessions, so the only thing skipped is auto-refreshing PATH inside the current profile file, which does not exist in that environment anyway. Well-behaved environments with a real `$PROFILE` are unchanged. ## 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 - `scripts/install.ps1`: early-return from `Ensure-ProfileBlock` with an informational message when `$PROFILE` is null or empty, before the `Split-Path` call. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [ ] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_install/test_native_installers.py -q 1 passed, 1 skipped # The PowerShell lifecycle test was failing on main before this change and now passes: $ python -m pytest "tests/test_install/test_native_installers.py::test_powershell_native_installer_supports_persistent_docker_lifecycle" -q 1 passed ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12, Windows PowerShell 5.1, project venv (`uv sync --extra proxy`), pytest in the venv. - Exact command / steps: ran `install.ps1` under a temp `USERPROFILE` with no Documents folder (the same setup the installer test uses). Confirmed `$PROFILE` resolves to an empty string in that context and that `Split-Path -Parent $PROFILE` throws there, then re-ran the installer test with the fix. - Observed result: before the fix the installer aborted with `Split-Path : Cannot bind argument to parameter 'Path' because it is an empty string` and exit code 1 (and the test failed); after the fix the installer completes, writes the wrapper and PATH entry, logs that it skipped the profile update, and the test passes. Ran against the actual script. - Not tested: a real end-user account whose Documents folder is redirected to a network share. ## 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 - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable
2026-08-12 10:42:30 +05:30
# $PROFILE is empty when PowerShell cannot resolve the profile path for the
# current user (a fresh account with no Documents folder, a service/CI
# context, a redirected profile). Split-Path below would then throw
# "Cannot bind argument to parameter 'Path' because it is an empty string",
# and with $ErrorActionPreference = 'Stop' that aborts the whole installer
# AFTER the wrapper and persistent User PATH were already written. Skip the
# profile convenience block instead: Ensure-PathEntry has already persisted
# the PATH for new sessions.
if ([string]::IsNullOrEmpty($PROFILE)) {
Write-Info 'Skipping PowerShell profile update: $PROFILE is not set in this environment (PATH was still updated for new sessions).'
return
}
$markerStart = '# >>> headroom docker-native >>>'
$markerEnd = '# <<< headroom docker-native <<<'
$escapedPathEntry = $PathEntry.Replace("'", "''")
$block = @"
$markerStart
if (-not ((`$env:Path -split ';') -contains '$escapedPathEntry')) {
`$env:Path = '$escapedPathEntry;' + `$env:Path
}
$markerEnd
"@
$profileDir = Split-Path -Parent $PROFILE
if (-not (Test-Path $profileDir)) {
New-Item -ItemType Directory -Force -Path $profileDir | Out-Null
}
if (-not (Test-Path $PROFILE)) {
New-Item -ItemType File -Force -Path $PROFILE | Out-Null
}
$existing = Get-Content -Raw -Path $PROFILE
if ($existing -notmatch [regex]::Escape($markerStart)) {
Add-Content -Path $PROFILE -Value "`n$block"
}
}
function Write-Wrapper {
param([string]$TargetDir)
$wrapperPath = Join-Path $TargetDir 'headroom.ps1'
$cmdPath = Join-Path $TargetDir 'headroom.cmd'
$resolvedInstallImage = $InstallImage.Replace("'", "''")
$wrapper = @'
$ErrorActionPreference = 'Stop'
$HeadroomImage = if ($env:HEADROOM_DOCKER_IMAGE) { $env:HEADROOM_DOCKER_IMAGE } else { '__HEADROOM_INSTALL_IMAGE__' }
$ContainerHome = if ($env:HEADROOM_CONTAINER_HOME) { $env:HEADROOM_CONTAINER_HOME } else { '/tmp/headroom-home' }
$HostHome = $HOME
function Fail {
param([string]$Message)
throw $Message
}
function Require-Command {
param([string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
Fail "Missing required command: $Name"
}
}
function Ensure-HostDirs {
foreach ($dir in @(
(Join-Path $HostHome '.headroom'),
(Join-Path $HostHome '.claude'),
(Join-Path $HostHome '.codex'),
(Join-Path $HostHome '.gemini')
)) {
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Force -Path $dir | Out-Null
}
}
}
function Get-PassthroughEnvArgs {
$args = New-Object System.Collections.Generic.List[string]
$prefixes = @(
'HEADROOM_','ANTHROPIC_','OPENAI_','GEMINI_','AWS_','AZURE_','VERTEX_',
'GOOGLE_','GOOGLE_CLOUD_','MISTRAL_','GROQ_','OPENROUTER_','XAI_',
'TOGETHER_','COHERE_','OLLAMA_','LITELLM_','OTEL_','SUPABASE_',
'QDRANT_','NEO4J_','LANGSMITH_'
)
foreach ($item in Get-ChildItem Env:) {
foreach ($prefix in $prefixes) {
if ($item.Name.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
$args.Add('--env')
$args.Add($item.Name)
break
}
}
}
return ,$args.ToArray()
}
function Get-SharedDockerArgs {
Ensure-HostDirs
$args = New-Object System.Collections.Generic.List[string]
$args.Add('--workdir')
$args.Add('/workspace')
$args.Add('--env')
$args.Add("HOME=$ContainerHome")
$args.Add('--env')
$args.Add('PYTHONUNBUFFERED=1')
# Canonical Headroom filesystem contract (issue #175).
$args.Add('--env')
$args.Add("HEADROOM_WORKSPACE_DIR=$ContainerHome/.headroom")
$args.Add('--env')
$args.Add("HEADROOM_CONFIG_DIR=$ContainerHome/.headroom/config")
$args.Add('--volume')
$args.Add("${PWD}:/workspace")
$args.Add('--volume')
$args.Add((Join-Path $HostHome '.headroom') + ":$ContainerHome/.headroom")
$args.Add('--volume')
$args.Add((Join-Path $HostHome '.claude') + ":$ContainerHome/.claude")
$args.Add('--volume')
$args.Add((Join-Path $HostHome '.codex') + ":$ContainerHome/.codex")
$args.Add('--volume')
$args.Add((Join-Path $HostHome '.gemini') + ":$ContainerHome/.gemini")
foreach ($entry in (Get-PassthroughEnvArgs)) {
$args.Add($entry)
}
return ,$args.ToArray()
}
function Add-TtyArgs {
param($ArgsList)
if (-not [Console]::IsInputRedirected -and -not [Console]::IsOutputRedirected) {
$ArgsList.Add('-it')
return
}
if (-not [Console]::IsInputRedirected) {
$ArgsList.Add('-i')
}
if (-not [Console]::IsOutputRedirected) {
$ArgsList.Add('-t')
}
}
function Invoke-HeadroomDocker {
param([string[]]$Arguments)
$dockerArgs = New-Object System.Collections.Generic.List[string]
$dockerArgs.AddRange([string[]]@('run','--rm'))
Add-TtyArgs -ArgsList $dockerArgs
$dockerArgs.AddRange((Get-SharedDockerArgs))
$dockerArgs.Add('--entrypoint')
$dockerArgs.Add('headroom')
$dockerArgs.Add($HeadroomImage)
foreach ($arg in $Arguments) {
$dockerArgs.Add($arg)
}
& docker @dockerArgs
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
}
function Wait-Proxy {
param(
[string]$ContainerName,
[int]$Port
)
for ($attempt = 0; $attempt -lt 45; $attempt++) {
try {
Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:$Port/readyz" | Out-Null
return
} catch {
$running = docker ps --format '{{.Names}}'
if ($running -notcontains $ContainerName) {
break
}
Start-Sleep -Seconds 1
}
}
docker logs $ContainerName | Write-Error
throw "Headroom proxy failed to start on port $Port"
}
function Start-ProxyContainer {
param(
[int]$Port,
[string[]]$ProxyArgs
)
$containerName = "headroom-proxy-$Port-$PID"
$dockerArgs = New-Object System.Collections.Generic.List[string]
$dockerArgs.AddRange([string[]]@('run','-d','--rm','--name',$containerName,'-p',"$Port`:$Port"))
$dockerArgs.AddRange((Get-SharedDockerArgs))
$dockerArgs.Add($HeadroomImage)
$dockerArgs.Add('--host')
$dockerArgs.Add('0.0.0.0')
$dockerArgs.Add('--port')
$dockerArgs.Add("$Port")
foreach ($arg in $ProxyArgs) {
$dockerArgs.Add($arg)
}
& docker @dockerArgs | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Failed to start Headroom proxy container"
}
Wait-Proxy -ContainerName $containerName -Port $Port
return $containerName
}
function Stop-ProxyContainer {
param([string]$ContainerName)
if ($ContainerName) {
docker stop $ContainerName | Out-Null
}
}
function Get-PersistentProfileRoot {
param([string]$Profile)
Assert-ValidProfileName -Profile $Profile
return Join-Path (Join-Path $HostHome '.headroom\deploy') $Profile
}
function Get-PersistentStatePath {
param([string]$Profile)
return Join-Path (Get-PersistentProfileRoot -Profile $Profile) 'docker-native.json'
}
function Get-PersistentManifestPath {
param([string]$Profile)
return Join-Path (Get-PersistentProfileRoot -Profile $Profile) 'manifest.json'
}
function Get-PersistentContainerName {
param([string]$Profile)
return "headroom-$Profile"
}
function Assert-ValidProfileName {
param([string]$Profile)
if ($Profile -notmatch '^[A-Za-z0-9._-]+$' -or $Profile -in @('.', '..')) {
Fail "Invalid profile name '$Profile'"
}
}
function Parse-PortValue {
param([string]$Value)
$parsed = 0
if (-not [int]::TryParse($Value, [ref]$parsed) -or $parsed -lt 1 -or $parsed -gt 65535) {
Fail "Invalid port '$Value'"
}
return $parsed
}
function Parse-PositiveIntegerValue {
param([string]$Value)
$parsed = 0
if (-not [int]::TryParse($Value, [ref]$parsed) -or $parsed -lt 1) {
Fail "Invalid value '$Value'"
}
return $parsed
}
function Require-OptionValue {
param(
[string[]]$Arguments,
[int]$Index,
[string]$Option
)
if ($Index + 1 -ge $Arguments.Count) {
Fail "Option $Option requires a value"
}
}
function Write-Utf8NoBomFile {
param(
[string]$Path,
[string]$Content
)
[System.IO.File]::WriteAllText($Path, $Content, [System.Text.UTF8Encoding]::new($false))
}
function Get-PersistentDockerArgs {
Ensure-HostDirs
$args = New-Object System.Collections.Generic.List[string]
$args.Add('--workdir')
$args.Add($ContainerHome)
$args.Add('--env')
$args.Add("HOME=$ContainerHome")
$args.Add('--env')
$args.Add('PYTHONUNBUFFERED=1')
# Canonical Headroom filesystem contract (issue #175).
$args.Add('--env')
$args.Add("HEADROOM_WORKSPACE_DIR=$ContainerHome/.headroom")
$args.Add('--env')
$args.Add("HEADROOM_CONFIG_DIR=$ContainerHome/.headroom/config")
$args.Add('--volume')
$args.Add((Join-Path $HostHome '.headroom') + ":$ContainerHome/.headroom")
$args.Add('--volume')
$args.Add((Join-Path $HostHome '.claude') + ":$ContainerHome/.claude")
$args.Add('--volume')
$args.Add((Join-Path $HostHome '.codex') + ":$ContainerHome/.codex")
$args.Add('--volume')
$args.Add((Join-Path $HostHome '.gemini') + ":$ContainerHome/.gemini")
foreach ($entry in (Get-PassthroughEnvArgs)) {
$args.Add($entry)
}
return ,$args.ToArray()
}
fix(install): trust Docker bridge for dashboard metadata ## Summary Closes #2909. The `persistent-docker` installer now discovers Docker's default bridge gateway and passes the exact `/32` gateway CIDR to the proxy's dashboard metadata allowlist when no explicit `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS` value is configured. This keeps the existing metadata gate intact while allowing the first-party loopback-published container to see its own Recent Requests and Per-Project Savings data. Explicit user configuration continues to take precedence. Both native wrappers (POSIX and PowerShell) use the same behavior, and installer integration coverage verifies the generated Docker command. ## Validation - `python -m pytest tests/test_install/test_native_installers.py -q -k bash` (1 skipped on Windows because Bash is unavailable) - PowerShell wrapper smoke test with the repository fake Docker shim: verified `docker network inspect bridge` is called and `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=172.17.0.1/32` is passed to `docker run` - Explicit allowlist smoke test: verified an existing `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS` is preserved without adding a discovered default - `git diff --check` ## Real behavior proof Setup tested: Windows 11 host, PowerShell wrapper, repository fake Docker shim (Docker CLI is not installed in this environment). Exact command: `headroom.ps1 install apply --profile smoke --port 18999 --image fake/headroom:test`. Observed result: the generated Docker invocation included `docker network inspect bridge --format ...` and `--env HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=172.17.0.1/32`, and the installer completed successfully. Not tested: a live Docker daemon/dashboard request on this host. --------- Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
2026-08-12 00:25:29 +03:00
function Add-DashboardGatewayEnv {
param([System.Collections.Generic.List[string]]$ArgsList)
# This default is safe only because the published dashboard port is bound
# to the host loopback interface below. A host request published through
# Docker's default bridge reaches the
# container from the bridge gateway (for example, 172.17.0.1), not from
# 127.0.0.1. Trust only that exact gateway by default so the dashboard's
# metadata gate works for the first-party persistent Docker preset while
# preserving an explicitly configured allowlist.
if (Test-Path Env:HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS) {
return
}
$gateway = (& docker network inspect bridge --format '{{(index .IPAM.Config 0).Gateway}}' 2>$null | Out-String).Trim()
if ($LASTEXITCODE -eq 0 -and $gateway) {
$ArgsList.Add('--env')
$ArgsList.Add("HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=$gateway/32")
} else {
Write-Warning 'Could not determine Docker bridge gateway; dashboard metadata remains restricted'
}
}
function Get-ManifestProxyArgs {
param(
[int]$Port,
[string]$Backend,
[string]$AnyllmProvider,
[string]$Region,
[string]$Mode,
[bool]$Memory,
[bool]$TelemetryEnabled
)
$args = New-Object System.Collections.Generic.List[string]
$args.AddRange([string[]]@('--host','127.0.0.1','--port',"$Port",'--mode',$Mode,'--backend',$Backend))
if (-not $TelemetryEnabled) {
$args.Add('--no-telemetry')
}
if ($Memory) {
$args.AddRange([string[]]@('--memory','--memory-db-path',"$ContainerHome/.headroom/memory.db"))
}
if ($AnyllmProvider) {
$args.AddRange([string[]]@('--anyllm-provider', $AnyllmProvider))
}
if ($Region) {
$args.AddRange([string[]]@('--region', $Region))
}
return ,$args.ToArray()
}
function Write-PersistentState {
param(
[string]$Profile,
[string]$Image,
[int]$Port,
[string]$Backend,
[string]$AnyllmProvider,
[string]$Region,
[string]$Mode,
[bool]$Memory,
[bool]$TelemetryEnabled
)
$root = Get-PersistentProfileRoot -Profile $Profile
New-Item -ItemType Directory -Force -Path $root | Out-Null
$state = [ordered]@{
profile = $Profile
image = $Image
port = $Port
backend = $Backend
anyllm_provider = $AnyllmProvider
region = $Region
proxy_mode = $Mode
memory_enabled = $Memory
telemetry_enabled = $TelemetryEnabled
container_name = Get-PersistentContainerName -Profile $Profile
health_url = "http://127.0.0.1:$Port/readyz"
}
Write-Utf8NoBomFile -Path (Get-PersistentStatePath -Profile $Profile) -Content ($state | ConvertTo-Json -Depth 4)
}
function Write-PersistentManifest {
param(
[string]$Profile,
[string]$Image,
[int]$Port,
[string]$Backend,
[string]$AnyllmProvider,
[string]$Region,
[string]$Mode,
[bool]$Memory,
[bool]$TelemetryEnabled,
[string[]]$ProxyArgs
)
$root = Get-PersistentProfileRoot -Profile $Profile
New-Item -ItemType Directory -Force -Path $root | Out-Null
$baseEnv = [ordered]@{
HEADROOM_PORT = "$Port"
HEADROOM_HOST = '127.0.0.1'
HEADROOM_MODE = $Mode
HEADROOM_BACKEND = $Backend
}
$manifest = [ordered]@{
profile = $Profile
preset = 'persistent-docker'
runtime_kind = 'docker'
supervisor_kind = 'none'
scope = 'user'
provider_mode = 'manual'
targets = @()
port = $Port
host = '127.0.0.1'
backend = $Backend
anyllm_provider = if ($AnyllmProvider) { $AnyllmProvider } else { $null }
region = if ($Region) { $Region } else { $null }
proxy_mode = $Mode
memory_enabled = $Memory
memory_db_path = "$ContainerHome/.headroom/memory.db"
telemetry_enabled = $TelemetryEnabled
image = $Image
service_name = "headroom-$Profile"
container_name = Get-PersistentContainerName -Profile $Profile
health_url = "http://127.0.0.1:$Port/readyz"
base_env = $baseEnv
tool_envs = @{}
proxy_args = $ProxyArgs
mutations = @()
artifacts = @()
}
Write-Utf8NoBomFile -Path (Get-PersistentManifestPath -Profile $Profile) -Content ($manifest | ConvertTo-Json -Depth 8)
}
function Read-PersistentState {
param([string]$Profile)
Assert-ValidProfileName -Profile $Profile
$statePath = Get-PersistentStatePath -Profile $Profile
if (-not (Test-Path $statePath)) {
Fail "No docker-native persistent deployment profile named '$Profile'"
}
return Get-Content -Raw -Path $statePath | ConvertFrom-Json
}
function Start-PersistentDockerInstall {
param(
[string]$Profile,
[string]$Image,
[int]$Port,
[string]$Backend,
[string]$AnyllmProvider,
[string]$Region,
[string]$Mode,
[bool]$Memory,
[bool]$TelemetryEnabled
)
Assert-ValidProfileName -Profile $Profile
$containerName = Get-PersistentContainerName -Profile $Profile
$proxyArgs = Get-ManifestProxyArgs -Port $Port -Backend $Backend -AnyllmProvider $AnyllmProvider -Region $Region -Mode $Mode -Memory $Memory -TelemetryEnabled $TelemetryEnabled
docker rm -f $containerName | Out-Null 2>$null
$dockerArgs = New-Object System.Collections.Generic.List[string]
fix(install): trust Docker bridge for dashboard metadata ## Summary Closes #2909. The `persistent-docker` installer now discovers Docker's default bridge gateway and passes the exact `/32` gateway CIDR to the proxy's dashboard metadata allowlist when no explicit `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS` value is configured. This keeps the existing metadata gate intact while allowing the first-party loopback-published container to see its own Recent Requests and Per-Project Savings data. Explicit user configuration continues to take precedence. Both native wrappers (POSIX and PowerShell) use the same behavior, and installer integration coverage verifies the generated Docker command. ## Validation - `python -m pytest tests/test_install/test_native_installers.py -q -k bash` (1 skipped on Windows because Bash is unavailable) - PowerShell wrapper smoke test with the repository fake Docker shim: verified `docker network inspect bridge` is called and `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=172.17.0.1/32` is passed to `docker run` - Explicit allowlist smoke test: verified an existing `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS` is preserved without adding a discovered default - `git diff --check` ## Real behavior proof Setup tested: Windows 11 host, PowerShell wrapper, repository fake Docker shim (Docker CLI is not installed in this environment). Exact command: `headroom.ps1 install apply --profile smoke --port 18999 --image fake/headroom:test`. Observed result: the generated Docker invocation included `docker network inspect bridge --format ...` and `--env HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=172.17.0.1/32`, and the installer completed successfully. Not tested: a live Docker daemon/dashboard request on this host. --------- Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
2026-08-12 00:25:29 +03:00
$dockerArgs.AddRange([string[]]@('run','-d','--restart','unless-stopped','--name',$containerName,'-p',"127.0.0.1`:$Port`:$Port"))
$dockerArgs.AddRange((Get-PersistentDockerArgs))
fix(install): trust Docker bridge for dashboard metadata ## Summary Closes #2909. The `persistent-docker` installer now discovers Docker's default bridge gateway and passes the exact `/32` gateway CIDR to the proxy's dashboard metadata allowlist when no explicit `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS` value is configured. This keeps the existing metadata gate intact while allowing the first-party loopback-published container to see its own Recent Requests and Per-Project Savings data. Explicit user configuration continues to take precedence. Both native wrappers (POSIX and PowerShell) use the same behavior, and installer integration coverage verifies the generated Docker command. ## Validation - `python -m pytest tests/test_install/test_native_installers.py -q -k bash` (1 skipped on Windows because Bash is unavailable) - PowerShell wrapper smoke test with the repository fake Docker shim: verified `docker network inspect bridge` is called and `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=172.17.0.1/32` is passed to `docker run` - Explicit allowlist smoke test: verified an existing `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS` is preserved without adding a discovered default - `git diff --check` ## Real behavior proof Setup tested: Windows 11 host, PowerShell wrapper, repository fake Docker shim (Docker CLI is not installed in this environment). Exact command: `headroom.ps1 install apply --profile smoke --port 18999 --image fake/headroom:test`. Observed result: the generated Docker invocation included `docker network inspect bridge --format ...` and `--env HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=172.17.0.1/32`, and the installer completed successfully. Not tested: a live Docker daemon/dashboard request on this host. --------- Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
2026-08-12 00:25:29 +03:00
Add-DashboardGatewayEnv -ArgsList $dockerArgs
$dockerArgs.AddRange([string[]]@(
'--env',"HEADROOM_DEPLOYMENT_PROFILE=$Profile",
'--env','HEADROOM_DEPLOYMENT_PRESET=persistent-docker',
'--env','HEADROOM_DEPLOYMENT_RUNTIME=docker',
'--env','HEADROOM_DEPLOYMENT_SUPERVISOR=none',
'--env','HEADROOM_DEPLOYMENT_SCOPE=user'
))
$dockerArgs.Add($Image)
$dockerArgs.Add('--host')
$dockerArgs.Add('0.0.0.0')
for ($i = 2; $i -lt $proxyArgs.Count; $i++) {
$dockerArgs.Add($proxyArgs[$i])
}
& docker @dockerArgs | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Failed to start docker-native persistent deployment"
}
try {
Wait-Proxy -ContainerName $containerName -Port $Port
} catch {
docker rm -f $containerName | Out-Null 2>$null
throw
}
Write-PersistentState -Profile $Profile -Image $Image -Port $Port -Backend $Backend -AnyllmProvider $AnyllmProvider -Region $Region -Mode $Mode -Memory $Memory -TelemetryEnabled $TelemetryEnabled
Write-PersistentManifest -Profile $Profile -Image $Image -Port $Port -Backend $Backend -AnyllmProvider $AnyllmProvider -Region $Region -Mode $Mode -Memory $Memory -TelemetryEnabled $TelemetryEnabled -ProxyArgs $proxyArgs
}
function Stop-PersistentDockerInstall {
param([string]$Profile)
$state = Read-PersistentState -Profile $Profile
docker stop $state.container_name | Out-Null 2>$null
docker rm -f $state.container_name | Out-Null 2>$null
}
function Remove-PersistentDockerInstall {
param([string]$Profile)
$state = Read-PersistentState -Profile $Profile
docker stop $state.container_name | Out-Null 2>$null
docker rm -f $state.container_name | Out-Null 2>$null
$root = Get-PersistentProfileRoot -Profile $Profile
if (Test-Path $root) {
Remove-Item -Recurse -Force -Path $root
}
}
function Show-PersistentDockerInstallStatus {
param([string]$Profile)
$state = Read-PersistentState -Profile $Profile
$status = 'stopped'
$ready = 'no'
$running = docker ps --format '{{.Names}}'
if ($running -contains $state.container_name) {
$status = 'running'
try {
Invoke-WebRequest -UseBasicParsing -Uri $state.health_url | Out-Null
$ready = 'yes'
} catch {
$ready = 'no'
}
}
Write-Host "Profile: $($state.profile)"
Write-Host 'Preset: persistent-docker'
Write-Host 'Runtime: docker'
Write-Host 'Supervisor: none'
Write-Host "Port: $($state.port)"
Write-Host "Status: $status"
Write-Host "Ready: $ready"
Write-Host "Health URL: $($state.health_url)"
}
function Show-InstallHelp {
$lines = @(
'Usage: headroom install [OPTIONS] COMMAND [ARGS]...',
'',
' Manage persistent Docker-native Headroom deployments.',
'',
' The Docker-native wrapper currently supports the persistent-docker preset only.',
' Use the Python-native `headroom install` command for persistent-service and',
' persistent-task installs, or when you need provider/user/system config mutation.',
'',
'Options:',
' -?, --help Show this message and exit.',
'',
'Commands:',
' apply Install a persistent Docker deployment.',
' remove Remove a persistent Docker deployment.',
' restart Restart a persistent Docker deployment.',
' start Start a persistent Docker deployment.',
' status Show persistent Docker deployment status.',
' stop Stop a persistent Docker deployment.'
)
Write-Host ($lines -join [Environment]::NewLine)
}
function Show-InstallApplyHelp {
$lines = @(
'Usage: headroom install apply [OPTIONS]',
'',
' Install a persistent Docker deployment.',
'',
'Options:',
' --preset [persistent-docker] Docker-native wrapper supports persistent-docker only.',
' --runtime [docker] Docker-native wrapper supports runtime=docker only.',
' --profile TEXT Deployment profile name. [default: default]',
' -p, --port INTEGER Persistent proxy port. [default: 8787]',
' --backend TEXT Proxy backend. [default: anthropic]',
' --anyllm-provider TEXT Provider for any-llm backends.',
' --region TEXT Cloud region for Bedrock / Vertex style backends.',
' --mode TEXT Proxy optimization mode. [default: token]',
' --memory Enable persistent memory in the runtime.',
' --no-telemetry Disable anonymous telemetry in the runtime.',
fix(install): default docker image to headroomlabs-ai GHCR registry (#1867) (#2039) ## Description The GitHub repository was transferred from `chopratejas/headroom` to `headroomlabs-ai/headroom`. GitHub 301-redirects transferred repos for web and git operations, but **GitHub Container Registry (GHCR) does not** — the old package `ghcr.io/chopratejas/headroom` is now orphaned and frozen (its `latest` tag stopped advancing at `0.27.0`), while CI publishes new images to `ghcr.io/headroomlabs-ai/headroom` (the workflow derives the path from `${{ github.repository }}`). Headroom's install tooling still defaulted to the dead path, so `headroom install apply --preset persistent-docker`, `headroom init`, and the standalone install scripts all pulled a stale `0.27.0` image instead of the current release. This changes every docker-image **default** to `ghcr.io/headroomlabs-ai/headroom:latest`. Closes #1867 ## 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 - `headroom/install/models.py` — `InstallManifest.image` default. - `headroom/cli/install.py` — `--image` Click option default. - `headroom/cli/init.py` — two `InstallManifest(...)` image args. - `scripts/install.sh` / `scripts/install.ps1` — `IMAGE_DEFAULT` / `$ImageDefault` plus the `--image` help-text default. - `docker/docker-compose.native.yml` — image default in both services. - `tests/test_install/test_planner.py` (4) and `tests/test_install/test_runtime.py` (5) — updated the assertions that pinned the old image (including `assert "<image>" in command`), so they now verify the corrected registry threads through the planner and docker runtime command. Scope note: `github.com/chopratejas/...` links and the plugin marketplace slug are intentionally **not** changed — GitHub redirects those, so they still work. Only the genuinely-dead GHCR image references are touched. No new dependency, no new abstraction. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest tests/test_install/ -q 99 passed, 1 skipped in 48.34s $ uv run ruff check headroom/install/models.py headroom/cli/install.py headroom/cli/init.py All checks passed! $ grep -rn "ghcr.io/chopratejas/headroom" headroom/ scripts/ docker/ tests/ # (no source matches — every default now points at headroomlabs-ai) ``` The install-test assertions pinned the old image, so they fail against the old defaults and pass after the fix — they are the regression guard. ## Real Behavior Proof - **Environment:** Windows 11, Python 3.13.5, headroom installed from this branch (editable, via `uv`). - **Exact command / steps:** The dead-registry claim is verifiable at the registry level, independent of a release: ```text docker pull ghcr.io/chopratejas/headroom:latest # old default → 0.27.0 (frozen / orphaned) docker pull ghcr.io/headroomlabs-ai/headroom:latest # new default → current release ``` And the install pipeline now emits the correct image (covered by `tests/test_install/test_runtime.py`, which asserts the resolved docker command contains `ghcr.io/headroomlabs-ai/headroom:latest`). - **Observed result:** `grep` confirms no `ghcr.io/chopratejas/headroom` default remains in code, scripts, compose, or tests; `tests/test_install/` is green (99 passed) with the corrected image asserted end-to-end through planner → runtime command. - **Not tested:** A live `docker pull` of both tags on this specific machine (no local Docker daemon guaranteed) — the registry difference is reproducible by anyone running the two `docker pull` commands above; and an end-to-end `headroom install apply --preset persistent-docker` against a real Docker 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 - [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 - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes - Documentation checklist item is N/A — no user-facing docs surface beyond the CHANGELOG entry. - `mypy` left unchecked — not run as part of this verification; the change is a string-default swap with no type-level surface. - The `--image` help text and `docker-compose.native.yml` were included so every user-facing default is consistent; only the dead GHCR image string was changed. Co-authored-by: JerrettDavis <mxjerrett@gmail.com> Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-13 23:31:28 +05:30
' --image TEXT Docker image to use. [default: HEADROOM_DOCKER_IMAGE or ghcr.io/headroomlabs-ai/headroom:latest]',
' -?, --help Show this message and exit.'
)
Write-Host ($lines -join [Environment]::NewLine)
}
function Show-WrapHelp {
$lines = @(
'Usage: headroom wrap <COMMAND> [OPTIONS] [-- ARGS...]',
'',
' Launch supported host tools through a Docker-native Headroom proxy.',
'',
'Supported commands:',
' claude',
' codex',
' aider',
' cursor',
' openclaw',
'',
'Notes:',
' - GitHub Copilot CLI wrapping is not supported by the Docker-native wrapper.',
' - Use the Python-native CLI for unsupported wrap targets.'
)
Write-Host ($lines -join [Environment]::NewLine)
}
function Parse-InstallApplyArgs {
param([string[]]$Arguments)
$profile = 'default'
$port = 8787
$backend = 'anthropic'
$anyllmProvider = $null
$region = $null
$mode = 'token'
$memory = $false
$telemetryEnabled = $true
$image = $HeadroomImage
$i = 0
while ($i -lt $Arguments.Count) {
$arg = $Arguments[$i]
switch -Regex ($arg) {
'^--preset$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--preset'
if ($Arguments[$i + 1] -ne 'persistent-docker') { Fail 'Docker-native wrapper supports only --preset persistent-docker' }
$i += 2
continue
}
'^--preset=' {
if (($arg -replace '^--preset=', '') -ne 'persistent-docker') { Fail 'Docker-native wrapper supports only --preset persistent-docker' }
$i += 1
continue
}
'^--runtime$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--runtime'
if ($Arguments[$i + 1] -ne 'docker') { Fail 'Docker-native wrapper supports only --runtime docker' }
$i += 2
continue
}
'^--runtime=' {
if (($arg -replace '^--runtime=', '') -ne 'docker') { Fail 'Docker-native wrapper supports only --runtime docker' }
$i += 1
continue
}
'^(--scope|--providers|--target)$' { Fail 'Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows' }
'^(--scope=|--providers=|--target=)' { Fail 'Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows' }
'^--profile$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--profile'
$profile = $Arguments[$i + 1]
$i += 2
continue
}
'^--profile=' {
$profile = $arg -replace '^--profile=', ''
$i += 1
continue
}
'^(--port|-p)$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$port = Parse-PortValue -Value $Arguments[$i + 1]
$i += 2
continue
}
'^(--port=|-p=)' {
$port = Parse-PortValue -Value ($arg -replace '^(--port=|-p=)', '')
$i += 1
continue
}
'^--backend$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--backend'
$backend = $Arguments[$i + 1]
$i += 2
continue
}
'^--backend=' {
$backend = $arg -replace '^--backend=', ''
$i += 1
continue
}
'^--anyllm-provider$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--anyllm-provider'
$anyllmProvider = $Arguments[$i + 1]
$i += 2
continue
}
'^--anyllm-provider=' {
$anyllmProvider = $arg -replace '^--anyllm-provider=', ''
$i += 1
continue
}
'^--region$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--region'
$region = $Arguments[$i + 1]
$i += 2
continue
}
'^--region=' {
$region = $arg -replace '^--region=', ''
$i += 1
continue
}
'^--mode$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--mode'
$mode = $Arguments[$i + 1]
$i += 2
continue
}
'^--mode=' {
$mode = $arg -replace '^--mode=', ''
$i += 1
continue
}
'^--memory$' {
$memory = $true
$i += 1
continue
}
'^--no-telemetry$' {
$telemetryEnabled = $false
$i += 1
continue
}
'^--image$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--image'
$image = $Arguments[$i + 1]
$i += 2
continue
}
'^--image=' {
$image = $arg -replace '^--image=', ''
$i += 1
continue
}
'^(--help|-\?)$' {
Show-InstallApplyHelp
exit 0
}
default {
Fail "Unsupported option for 'headroom install apply': $arg"
}
}
}
return [pscustomobject]@{
Profile = $profile
Port = $port
Backend = $backend
AnyllmProvider = $anyllmProvider
Region = $region
Mode = $mode
Memory = $memory
TelemetryEnabled = $telemetryEnabled
Image = $image
}
}
function Parse-InstallProfileArgs {
param([string[]]$Arguments)
$profile = 'default'
$i = 0
while ($i -lt $Arguments.Count) {
$arg = $Arguments[$i]
switch -Regex ($arg) {
'^--profile$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--profile'
$profile = $Arguments[$i + 1]
$i += 2
continue
}
'^--profile=' {
$profile = $arg -replace '^--profile=', ''
$i += 1
continue
}
'^(--help|-\?)$' {
Show-InstallHelp
exit 0
}
default {
Fail "Unsupported option for 'headroom install': $arg"
}
}
}
return $profile
}
function Invoke-WithTemporaryEnv {
param(
[hashtable]$Environment,
[string]$Command,
[string[]]$Arguments
)
$previous = @{}
foreach ($pair in $Environment.GetEnumerator()) {
$previous[$pair.Key] = [Environment]::GetEnvironmentVariable($pair.Key, 'Process')
[Environment]::SetEnvironmentVariable($pair.Key, $pair.Value, 'Process')
}
try {
& $Command @Arguments
return $LASTEXITCODE
} finally {
foreach ($pair in $Environment.GetEnumerator()) {
[Environment]::SetEnvironmentVariable($pair.Key, $previous[$pair.Key], 'Process')
}
}
}
function Test-HelpFlag {
param([string[]]$Arguments)
foreach ($arg in $Arguments) {
if ($arg -eq '--') {
break
}
if ($arg -eq '--help' -or $arg -eq '-?') {
return $true
}
}
return $false
}
function Parse-OpenClawWrapArgs {
param([string[]]$Arguments)
$gatewayProviderIds = New-Object System.Collections.Generic.List[string]
$pluginPath = $null
$pluginSpec = 'headroom-ai/openclaw'
$skipBuild = $false
$copy = $false
$proxyPort = 8787
$startupTimeoutMs = 20000
$pythonPath = $null
$noAutoStart = $false
$noRestart = $false
$verbose = $false
$i = 0
while ($i -lt $Arguments.Count) {
$arg = $Arguments[$i]
switch -Regex ($arg) {
'^--plugin-path$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$pluginPath = $Arguments[$i + 1]
$i += 2
continue
}
'^--plugin-path=' {
$pluginPath = $arg -replace '^--plugin-path=', ''
$i += 1
continue
}
'^--plugin-spec$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$pluginSpec = $Arguments[$i + 1]
$i += 2
continue
}
'^--plugin-spec=' {
$pluginSpec = $arg -replace '^--plugin-spec=', ''
$i += 1
continue
}
'^--skip-build$' {
$skipBuild = $true
$i += 1
continue
}
'^--copy$' {
$copy = $true
$i += 1
continue
}
'^--proxy-port$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$proxyPort = Parse-PortValue -Value $Arguments[$i + 1]
$i += 2
continue
}
'^--proxy-port=' {
$proxyPort = Parse-PortValue -Value ($arg -replace '^--proxy-port=', '')
$i += 1
continue
}
'^--startup-timeout-ms$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$startupTimeoutMs = Parse-PositiveIntegerValue -Value $Arguments[$i + 1]
$i += 2
continue
}
'^--startup-timeout-ms=' {
$startupTimeoutMs = Parse-PositiveIntegerValue -Value ($arg -replace '^--startup-timeout-ms=', '')
$i += 1
continue
}
'^--gateway-provider-id$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$gatewayProviderIds.Add($Arguments[$i + 1])
$i += 2
continue
}
'^--gateway-provider-id=' {
$gatewayProviderIds.Add($arg -replace '^--gateway-provider-id=', '')
$i += 1
continue
}
'^--python-path$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$pythonPath = $Arguments[$i + 1]
$i += 2
continue
}
'^--python-path=' {
$pythonPath = $arg -replace '^--python-path=', ''
$i += 1
continue
}
'^--no-auto-start$' {
$noAutoStart = $true
$i += 1
continue
}
'^--no-restart$' {
$noRestart = $true
$i += 1
continue
}
'^--verbose$|^-v$' {
$verbose = $true
$i += 1
continue
}
default {
Fail "Unsupported option for 'headroom wrap openclaw': $arg"
}
}
}
[pscustomobject]@{
PluginPath = $pluginPath
PluginSpec = $pluginSpec
SkipBuild = $skipBuild
Copy = $copy
ProxyPort = $proxyPort
StartupTimeoutMs = $startupTimeoutMs
GatewayProviderIds = $gatewayProviderIds.ToArray()
PythonPath = $pythonPath
NoAutoStart = $noAutoStart
NoRestart = $noRestart
Verbose = $verbose
}
}
function Parse-OpenClawUnwrapArgs {
param([string[]]$Arguments)
$noRestart = $false
$verbose = $false
$i = 0
while ($i -lt $Arguments.Count) {
$arg = $Arguments[$i]
switch -Regex ($arg) {
'^--no-restart$' {
$noRestart = $true
$i += 1
continue
}
'^--verbose$|^-v$' {
$verbose = $true
$i += 1
continue
}
default {
Fail "Unsupported option for 'headroom unwrap openclaw': $arg"
}
}
}
[pscustomobject]@{
NoRestart = $noRestart
Verbose = $verbose
}
}
function Invoke-CapturedCommand {
param(
[string]$Action,
[string]$Command,
[string[]]$Arguments,
[string]$WorkingDirectory
)
$previous = $null
try {
if ($WorkingDirectory) {
$previous = Get-Location
Set-Location $WorkingDirectory
}
$output = (& $Command @Arguments 2>&1 | Out-String).Trim()
$exitCode = $LASTEXITCODE
} finally {
if ($previous) {
Set-Location $previous
}
}
if ($exitCode -ne 0) {
if (-not $output) {
$output = "exit code $exitCode"
}
Fail "$Action failed: $output"
}
return $output
}
function Get-OpenClawExistingEntryJson {
$output = (& openclaw config get plugins.entries.headroom 2>$null | Out-String).Trim()
if ($LASTEXITCODE -ne 0) {
return $null
}
return $output
}
function Invoke-OpenClawPrepareEntryJson {
param(
[string]$ExistingEntryJson,
[pscustomobject]$Parsed
)
$dockerArgs = New-Object System.Collections.Generic.List[string]
$dockerArgs.AddRange([string[]]@('run','--rm'))
$dockerArgs.AddRange((Get-SharedDockerArgs))
$dockerArgs.Add('--entrypoint')
$dockerArgs.Add('headroom')
$dockerArgs.Add($HeadroomImage)
$dockerArgs.AddRange([string[]]@('wrap','openclaw','--prepare-only','--proxy-port',"$($Parsed.ProxyPort)",'--startup-timeout-ms',"$($Parsed.StartupTimeoutMs)"))
if ($ExistingEntryJson) {
$dockerArgs.Add('--existing-entry-json')
$dockerArgs.Add($ExistingEntryJson)
}
if ($Parsed.PythonPath) {
$dockerArgs.Add('--python-path')
$dockerArgs.Add($Parsed.PythonPath)
}
if ($Parsed.NoAutoStart) {
$dockerArgs.Add('--no-auto-start')
}
foreach ($providerId in $Parsed.GatewayProviderIds) {
$dockerArgs.Add('--gateway-provider-id')
$dockerArgs.Add($providerId)
}
$output = (& docker @dockerArgs 2>&1 | Out-String).Trim()
if ($LASTEXITCODE -ne 0) {
Fail "Failed to prepare docker-native OpenClaw config: $output"
}
return $output
}
function Invoke-OpenClawPrepareUnwrapEntryJson {
param([string]$ExistingEntryJson)
$dockerArgs = New-Object System.Collections.Generic.List[string]
$dockerArgs.AddRange([string[]]@('run','--rm'))
$dockerArgs.AddRange((Get-SharedDockerArgs))
$dockerArgs.Add('--entrypoint')
$dockerArgs.Add('headroom')
$dockerArgs.Add($HeadroomImage)
$dockerArgs.AddRange([string[]]@('unwrap','openclaw','--prepare-only'))
if ($ExistingEntryJson) {
$dockerArgs.Add('--existing-entry-json')
$dockerArgs.Add($ExistingEntryJson)
}
$output = (& docker @dockerArgs 2>&1 | Out-String).Trim()
if ($LASTEXITCODE -ne 0) {
Fail "Failed to prepare docker-native OpenClaw unwrap config: $output"
}
return $output
}
function Resolve-OpenClawExtensionsDir {
$configOutput = Invoke-CapturedCommand -Action 'openclaw config file' -Command 'openclaw' -Arguments @('config','file')
$configPath = ($configOutput -split "`r?`n")[-1].Trim()
if (-not $configPath) {
Fail 'Unable to resolve OpenClaw config path.'
}
return (Join-Path (Split-Path -Parent $configPath) 'extensions')
}
function Copy-OpenClawPluginIntoExtensions {
param([string]$PluginPath)
$distDir = Join-Path $PluginPath 'dist'
$hookShimDir = Join-Path $PluginPath 'hook-shim'
if (-not (Test-Path $distDir)) {
Fail "Plugin dist folder missing at $distDir. Build the plugin first."
}
if (-not (Test-Path $hookShimDir)) {
Fail "Plugin hook-shim folder missing at $hookShimDir. Build the plugin first."
}
$extensionsDir = Resolve-OpenClawExtensionsDir
$targetDir = Join-Path $extensionsDir 'headroom'
$targetDist = Join-Path $targetDir 'dist'
$targetHookShim = Join-Path $targetDir 'hook-shim'
New-Item -ItemType Directory -Force -Path $targetDir | Out-Null
if (Test-Path $targetDist) { Remove-Item -Recurse -Force $targetDist }
if (Test-Path $targetHookShim) { Remove-Item -Recurse -Force $targetHookShim }
Copy-Item -Recurse -Force $distDir $targetDist
Copy-Item -Recurse -Force $hookShimDir $targetHookShim
foreach ($fileName in @('openclaw.plugin.json','package.json','README.md')) {
$source = Join-Path $PluginPath $fileName
if (Test-Path $source) {
Copy-Item -Force $source (Join-Path $targetDir $fileName)
}
}
return $targetDir
}
function Install-OpenClawPlugin {
param([pscustomobject]$Parsed)
if ($Parsed.PluginPath) {
if (-not (Test-Path $Parsed.PluginPath)) {
Fail "Plugin path not found: $($Parsed.PluginPath)."
}
if (-not (Test-Path (Join-Path $Parsed.PluginPath 'package.json'))) {
Fail "Invalid plugin path (missing package.json): $($Parsed.PluginPath)"
}
if (-not (Test-Path (Join-Path $Parsed.PluginPath 'openclaw.plugin.json'))) {
Fail "Invalid plugin path (missing openclaw.plugin.json): $($Parsed.PluginPath)"
}
}
if ($Parsed.PluginPath -and -not $Parsed.SkipBuild) {
Require-Command npm
Write-Host ' Building OpenClaw plugin (npm install + npm run build)...'
[void](Invoke-CapturedCommand -Action 'npm install' -Command 'npm' -Arguments @('install') -WorkingDirectory $Parsed.PluginPath)
[void](Invoke-CapturedCommand -Action 'npm run build' -Command 'npm' -Arguments @('run','build') -WorkingDirectory $Parsed.PluginPath)
}
if ($Parsed.PluginPath) {
if ($Parsed.Copy) {
$arguments = @('plugins','install','--dangerously-force-unsafe-install',$Parsed.PluginPath)
$workingDirectory = $null
} else {
$arguments = @('plugins','install','--dangerously-force-unsafe-install','--link','.')
$workingDirectory = $Parsed.PluginPath
}
} else {
$arguments = @('plugins','install','--dangerously-force-unsafe-install',$Parsed.PluginSpec)
$workingDirectory = $null
}
$previous = $null
try {
if ($workingDirectory) {
$previous = Get-Location
Set-Location $workingDirectory
}
$installOutput = (& openclaw @arguments 2>&1 | Out-String).Trim()
$installExitCode = $LASTEXITCODE
} finally {
if ($previous) {
Set-Location $previous
}
}
if ($installExitCode -eq 0) {
if ($Parsed.Verbose -and $installOutput) {
Write-Host $installOutput
}
return
}
$lowerOutput = $installOutput.ToLowerInvariant()
if ($lowerOutput.Contains('plugin already exists')) {
Write-Host ' Plugin already installed; continuing with configuration/update steps.'
return
}
if ($Parsed.PluginPath -and -not $Parsed.Copy -and $lowerOutput.Contains('also not a valid hook pack')) {
Write-Host ' OpenClaw linked-path install bug detected; applying extension-path fallback...'
$targetDir = Copy-OpenClawPluginIntoExtensions -PluginPath $Parsed.PluginPath
Write-Host " Fallback plugin copy completed: $targetDir"
return
}
if (-not $installOutput) {
$installOutput = "exit code $installExitCode"
}
Fail "openclaw plugins install failed: $installOutput"
}
function Restart-OrStartOpenClawGateway {
$restartOutput = (& openclaw gateway restart 2>&1 | Out-String).Trim()
if ($LASTEXITCODE -eq 0) {
return [pscustomobject]@{ Action = 'restarted'; Output = $restartOutput }
}
$startOutput = Invoke-CapturedCommand -Action 'openclaw gateway start' -Command 'openclaw' -Arguments @('gateway','start')
return [pscustomobject]@{ Action = 'started'; Output = $startOutput }
}
function Invoke-OpenClawWrap {
param([string[]]$Arguments)
Require-Command openclaw
$parsed = Parse-OpenClawWrapArgs -Arguments $Arguments
$existingEntryJson = Get-OpenClawExistingEntryJson
$entryJson = Invoke-OpenClawPrepareEntryJson -ExistingEntryJson $existingEntryJson -Parsed $parsed
Write-Host ""
Write-Host " ╔═══════════════════════════════════════════════╗"
Write-Host " ║ HEADROOM WRAP: OPENCLAW ║"
Write-Host " ╚═══════════════════════════════════════════════╝"
Write-Host ""
if ($parsed.PluginPath) {
Write-Host " Plugin source: local ($($parsed.PluginPath))"
} else {
Write-Host " Plugin source: npm ($($parsed.PluginSpec))"
}
Write-Host ' Writing plugin configuration...'
[void](Invoke-CapturedCommand -Action 'openclaw config set plugins.entries.headroom' -Command 'openclaw' -Arguments @('config','set','plugins.entries.headroom',$entryJson,'--strict-json'))
Write-Host ' Installing OpenClaw plugin with required unsafe-install flag...'
Install-OpenClawPlugin -Parsed $parsed
[void](Invoke-CapturedCommand -Action 'openclaw config set plugins.slots.contextEngine' -Command 'openclaw' -Arguments @('config','set','plugins.slots.contextEngine','"headroom"','--strict-json'))
[void](Invoke-CapturedCommand -Action 'openclaw config validate' -Command 'openclaw' -Arguments @('config','validate'))
if ($parsed.NoRestart) {
Write-Host ' Skipping gateway restart (--no-restart).'
Write-Host ' Run `openclaw gateway restart` (or `openclaw gateway start`) to apply plugin changes.'
} else {
Write-Host ' Applying plugin changes to OpenClaw gateway...'
$gatewayResult = Restart-OrStartOpenClawGateway
Write-Host " Gateway $($gatewayResult.Action)."
if ($parsed.Verbose -and $gatewayResult.Output) {
Write-Host $gatewayResult.Output
}
}
$inspectOutput = Invoke-CapturedCommand -Action 'openclaw plugins inspect headroom' -Command 'openclaw' -Arguments @('plugins','inspect','headroom')
if ($parsed.Verbose -and $inspectOutput) {
Write-Host $inspectOutput
}
Write-Host ""
Write-Host "✓ OpenClaw is configured to use Headroom context compression."
Write-Host " Plugin: headroom"
Write-Host " Slot: plugins.slots.contextEngine = headroom"
Write-Host ""
}
function Invoke-OpenClawUnwrap {
param([string[]]$Arguments)
Require-Command openclaw
$parsed = Parse-OpenClawUnwrapArgs -Arguments $Arguments
$existingEntryJson = Get-OpenClawExistingEntryJson
$entryJson = Invoke-OpenClawPrepareUnwrapEntryJson -ExistingEntryJson $existingEntryJson
Write-Host ""
Write-Host " ╔═══════════════════════════════════════════════╗"
Write-Host " ║ HEADROOM UNWRAP: OPENCLAW ║"
Write-Host " ╚═══════════════════════════════════════════════╝"
Write-Host ""
Write-Host ' Disabling Headroom plugin and removing engine mapping...'
[void](Invoke-CapturedCommand -Action 'openclaw config set plugins.entries.headroom' -Command 'openclaw' -Arguments @('config','set','plugins.entries.headroom',$entryJson,'--strict-json'))
[void](Invoke-CapturedCommand -Action 'openclaw config set plugins.slots.contextEngine' -Command 'openclaw' -Arguments @('config','set','plugins.slots.contextEngine','"legacy"','--strict-json'))
[void](Invoke-CapturedCommand -Action 'openclaw config validate' -Command 'openclaw' -Arguments @('config','validate'))
if ($parsed.NoRestart) {
Write-Host ' Skipping gateway restart (--no-restart).'
Write-Host ' Run `openclaw gateway restart` (or `openclaw gateway start`) to apply unwrap changes.'
} else {
Write-Host ' Applying unwrap changes to OpenClaw gateway...'
$gatewayResult = Restart-OrStartOpenClawGateway
Write-Host " Gateway $($gatewayResult.Action)."
if ($parsed.Verbose -and $gatewayResult.Output) {
Write-Host $gatewayResult.Output
}
}
if ($parsed.Verbose) {
$inspectOutput = Invoke-CapturedCommand -Action 'openclaw plugins inspect headroom' -Command 'openclaw' -Arguments @('plugins','inspect','headroom')
if ($inspectOutput) {
Write-Host $inspectOutput
}
}
Write-Host ""
Write-Host "✓ OpenClaw Headroom wrap removed."
Write-Host " Plugin: headroom (installed, disabled)"
Write-Host " Slot: plugins.slots.contextEngine = legacy"
Write-Host ""
}
function Parse-WrapArgs {
param([string[]]$Arguments)
$known = New-Object System.Collections.Generic.List[string]
$hostArgs = New-Object System.Collections.Generic.List[string]
$port = 8787
$noProxy = $false
$learn = $false
$backend = $null
$anyllm = $null
$region = $null
$i = 0
while ($i -lt $Arguments.Count) {
$arg = $Arguments[$i]
switch -Regex ($arg) {
'^--$' {
for ($j = $i + 1; $j -lt $Arguments.Count; $j++) {
$hostArgs.Add($Arguments[$j])
}
$i = $Arguments.Count
continue
}
'^--port$|^-p$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$port = Parse-PortValue -Value $Arguments[$i + 1]
$known.Add($arg)
$known.Add($Arguments[$i + 1])
$i += 2
continue
}
'^--port=' {
$port = Parse-PortValue -Value ($arg -replace '^--port=', '')
$known.Add($arg)
$i += 1
continue
}
'^--no-proxy$' {
$noProxy = $true
$known.Add($arg)
$i += 1
continue
}
'^--learn$' {
$learn = $true
$known.Add($arg)
$i += 1
continue
}
'^--verbose$|^-v$' {
$known.Add($arg)
$i += 1
continue
}
'^--backend$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$backend = $Arguments[$i + 1]
$known.Add($arg)
$known.Add($Arguments[$i + 1])
$i += 2
continue
}
'^--backend=' {
$backend = $arg -replace '^--backend=', ''
$known.Add($arg)
$i += 1
continue
}
'^--anyllm-provider$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$anyllm = $Arguments[$i + 1]
$known.Add($arg)
$known.Add($Arguments[$i + 1])
$i += 2
continue
}
'^--anyllm-provider=' {
$anyllm = $arg -replace '^--anyllm-provider=', ''
$known.Add($arg)
$i += 1
continue
}
'^--region$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$region = $Arguments[$i + 1]
$known.Add($arg)
$known.Add($Arguments[$i + 1])
$i += 2
continue
}
'^--region=' {
$region = $arg -replace '^--region=', ''
$known.Add($arg)
$i += 1
continue
}
fix: remove rtk and lean-ctx CLI context tools (#2677) ## Description Removes both third-party CLI context tools — **rtk** and **lean-ctx** — and with them the context-tool selector itself. Headroom no longer downloads, installs or configures either one, and there is no replacement. The previous pass (#2344) gated only three entry points inside `headroom/cli/wrap.py`. That left the feature reachable in practice: | Gap | Effect | |---|---| | `scripts/install.sh:1544`, `install.ps1:1681` | Ran `rtk init --global --auto-patch` from bash/PowerShell, **bypassing the Python gate entirely** — `curl \| sh` still wrote a Claude Code `PreToolUse` hook regardless of `HEADROOM_RTK` | | `wrap.py` `_setup_context_tool_for_agent` | **`wrap openhands` was broken by default**: `rtk_required=True` met a gate returning `None` → `SystemExit(1)`. Invisible because all 8 openhands tests patched `_ensure_rtk_binary` to a fake path | | `proxy/helpers.py`, `subscription/tracker.py` | Proxy shelled out to `rtk gain` from `/stats`, the dashboard and `headroom perf`; the tracker polled it per contribution (`_RTK_WIRING_DEFAULT = "enabled"`) | | No cleanup path | Nothing removed artifacts an earlier default had installed, so a machine that once ran the old default kept rtk in the loop forever (#1669, #1955) | Also worth noting: the rtk binary download had **no SHA or signature verification** — only `rtk --version` as a smoke test. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [x] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made **Removed** — `headroom/rtk/` and `headroom/lean_ctx/` packages, `headroom/cli/wrap_rtk_metrics.py`, `_selected_context_tool` / `_setup_context_tool_for_agent` / `_VALID_CONTEXT_TOOLS`, the `--rtk` / `--no-rtk` / `--no-project-rtk` / `--keep-rtk` flags across all 18 wrap subcommands, `HEADROOM_RTK*`, the proxy-side `rtk gain` polling, the dashboard CLI-filtering panel (rows + all 8 `cliFiltering*` Alpine getters), `paths.rtk_path()` / `lean_ctx_path()`, the SDK path helpers, `benchmarks/rtk_loop_learn_eval.py`, and the `headroom/rtk/**` CI path filters. **Fails loudly, not silently** — `--context-tool` / `--no-context-tool` / `HEADROOM_CONTEXT_TOOL` are kept solely to error out. They live in shell profiles, aliases and CI jobs, and accepting them as a no-op would read as Headroom having quietly stopped working. The installers reject them too, which matters more than it looks: their arg parsers forward the first unknown flag **and everything after it** to the wrapped tool, so a leftover `--no-rtk` would have silently swallowed a following `--port` and then been ignored downstream. **New `headroom/context_tool_cleanup.py`** — deleting the code cannot help a machine that already ran the old default, since the hooks, binaries and injected guidance are durable on disk. `purge_context_tool_artifacts()` runs once per `wrap`/`unwrap` and removes the registered hook entries, the generated hook scripts, the Headroom-managed `~/.local/bin` symlinks, the vendored `~/.headroom/bin/{rtk,lean-ctx}` binaries, the `lean-ctx` MCP server entry and the marker-fenced instruction blocks. Deliberately conservative: idempotent, **skips** a malformed config rather than overwriting it, and only unlinks a symlink resolving inside Headroom's own bin dir so a user's own build is untouched. It reports on **stderr**, because `wrap/unwrap openclaw --prepare-only` emit machine-readable JSON on stdout as their entire contract. Skipped for `wrap selfheal` (runs from a SessionStart hook; must not race Claude Code's writer for `~/.claude.json`) and for `--help`, which must stay read-only. **Client-config hardening** (discovered while investigating a "corrupted Serena settings file" report) — `wrap.py` reset a settings file to `{}` when an existing file would not parse, then wrote that back. One hand-edited typo or a transient `EACCES`/`EINTR` on a valid file destroyed the user's `permissions`, `env` and `hooks`, on **every `headroom wrap claude`**. It now refuses to write. Separately, `fsutil.write_text` is now atomic (temp file + `fsync` + `os.replace`), fixing all 14 non-atomic client-config writes at once; it follows symlinks rather than replacing them (dotfile managers) and preserves an existing file's mode. **Deliberately kept** — `rtk` stays in the wrapper-peel list in `transforms/content_router.py`. It sits beside `sudo`/`env`/`timeout` as shell-command grammar, so `rtk cat f` is still classified as a file read for anyone running their own rtk install, which the purge intentionally leaves alone. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ ruff check headroom/ tests/ e2e/ --exclude headroom/dashboard/templates All checks passed! $ ruff format --check headroom/ tests/ e2e/ --exclude headroom/dashboard/templates 1255 files already formatted $ mypy headroom/ Success: no issues found in 508 source files $ pytest tests/test_context_tool_cleanup.py -q 11 passed $ pytest tests/test_fsutil.py -q 12 passed $ pytest tests/test_cli/test_wrap_codex.py -q # 89 tests 89 passed in 431.68s $ pytest tests/test_cli/test_wrap_opencode.py -q 39 passed in 257.46s $ pytest tests/test_cli/test_wrap_helpers.py -q 45 passed $ pytest tests/test_paths.py -q 75 passed $ pytest tests/test_cli/test_unwrap_claude.py -q 14 passed $ pytest tests/test_proxy_savings_history.py -q 39 passed $ pytest tests/test_cli/test_wrap_copilot.py -q 27 passed $ pytest tests/test_cli/test_wrap_zcode.py -q 20 passed $ pytest tests/test_subscription_tracker.py -q 9 passed $ pytest tests/test_proxy_dashboard_stats_cache.py -q 5 passed, 1 skipped ``` Repo-wide grep for 14 removed symbols (`headroom.rtk`, `headroom.lean_ctx`, `_ensure_rtk_binary`, `_selected_context_tool`, `_get_context_tool_stats`, `rtk_path`, `lean_ctx_path`, `wrap_rtk_metrics`, `HEADROOM_RTK`, `cli_tokens_avoided`, `tokens_saved_rtk`, …) across `*.py`, `*.ts`, `*.sh`, `*.ps1`, `*.yml`, `*.html`: **zero hits**. Notable test changes: `test_wrap_openhands.py` no longer patches `_ensure_rtk_binary` and asserts `wrap openhands --prepare-only` exits 0 unpatched — the regression that was previously masked. `test_wrap_continue.py` and `test_wrap_hintfile_agents.py` were removed (every test drove RTK instruction injection). A new `test_subscription_tracker.py::test_load_state_written_before_cli_context_tools_were_removed` proves a pre-removal `subscription_state.json` still loads. ## Real Behavior Proof - **Environment:** macOS 15.4 (darwin 25.4.0), Python 3.12.6, Headroom @ this branch, real `~/.headroom` and `~/.claude` on the dev machine. - **Exact command / steps and observed result:** ```text # 1. Retired flag fails loudly instead of silently no-op'ing $ headroom wrap codex --prepare-only --context-tool rtk Error: CLI context tools (rtk, lean-ctx) have been removed from Headroom: they rewrote shell commands through a third-party binary Headroom no longer manages. Drop --context-tool / --no-context-tool and unset HEADROOM_CONTEXT_TOOL; `headroom wrap` uninstalls what they left behind on first run. $ HEADROOM_CONTEXT_TOOL=lean-ctx headroom wrap codex --prepare-only Error: CLI context tools (rtk, lean-ctx) have been removed from Headroom: ... # 2. install.sh rejects the retired flags (extracted parse_wrap_args harness) ['--no-rtk', '--port', '9999'] rc=1 ERROR: CLI context tools ... Drop --no-rtk ['--context-tool=rtk'] rc=1 ERROR: CLI context tools ... Drop --context-tool $ bash -n scripts/install.sh # syntax OK # 3. Purge ran against the real machine, which had all the orphaned artifacts $ python -c "from headroom.context_tool_cleanup import purge_context_tool_artifacts; ..." removed ~/.headroom/bin/lean-ctx (51 MB) removed ~/.headroom/bin/rtk (7.7 MB) removed ~/.local/bin/rtk (symlink into ~/.headroom/bin) removed ~/.claude/hooks/rtk-rewrite.sh removed 8 lean-ctx-* hook scripts # ~/.claude.json afterwards: 90 top-level keys, 19 projects, mcpServers unchanged # → ~59 MB reclaimed, no unrelated key touched # 4. stdout stays machine-readable while the purge reports (planted a fake artifact) $ headroom wrap openclaw --prepare-only --gateway-provider-id codex >out 2>err $ cat out {"enabled":true,"config":{"proxyPort":8787,...}} # parses as JSON $ cat err Retired CLI context tool cleanup: removed /Users/tcms/.headroom/bin/rtk # 5. --help is inert (planted artifact survives), a real run purges $ headroom wrap codex --help → artifact survived: CORRECT $ headroom wrap openclaw --prepare-only → purged: CORRECT # 6. MCP purge dry-run against a copy of the real 82 KB ~/.claude.json top-level keys 90 -> 90; projects 19 -> 19; LOST keys: none all content outside mcpServers byte-identical: True ``` Dashboard rendered via the Playwright test after the panel removal: "Token Savings" shows only `Proxy 0 (0.0%)` / `Of total wire: 36.86%`, and "Token Usage" reads Before Compression → Proxy Removed → After Compression with no "Filtered (this session)" row. Nothing below the removed panel broke. - **Not tested:** Windows and Linux (macOS only) — `install.ps1` is verified by brace-balance and inspection, not executed, since no `pwsh` is available locally. The wrap e2e suite (`e2e/wrap/run.py`) was updated but not run; it needs the Docker e2e image. `serena project index` interaction is exercised in the stacked base PR. ## 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 - [x] 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 did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Additional Notes **Stacked on #2676** (`tejas/serena-config-bootstrap`) — please merge that first; this PR's base should then be retargeted to `main`, or it will read as containing that fix too. **Breaking-change migration for users:** - Drop `--rtk`, `--no-rtk`, `--no-project-rtk`, `--keep-rtk`, `--context-tool`, `--no-context-tool` from any alias, script or CI job, and unset `HEADROOM_RTK*` / `HEADROOM_CONTEXT_TOOL`. They now error rather than being ignored, so the failure is immediate and self-explaining. - Previously-installed artifacts are purged automatically on the next `wrap`/`unwrap`; no manual cleanup needed. - `headroom perf --json` no longer carries a `cli_filtering` key, and `/stats` no longer returns a `context_tool` section. **Docs:** `docs/rtk-architecture.md` deleted; RTK/lean-ctx removed from `README.md`, `docs/content/docs/{configuration,opencode,grok-build,docker-install,filesystem-contract}.mdx`, `docs/observability.md` and the matching `wiki/` pages. `REALIGNMENT/09-phase-G-rtk-observability.md` is marked SUPERSEDED rather than deleted, to keep the planning record. **Follow-ups not in scope:** `_emit_wrap_interrupted` was deleted as dead code — its only caller was the `except KeyboardInterrupt` guarding the binary download, so with no download there is nothing slow left to interrupt.
2026-07-30 22:59:41 -07:00
'^--rtk$|^--no-rtk$|^--no-project-rtk$|^--keep-rtk$|^--context-tool$|^--context-tool=|^--no-context-tool$' {
# Retired CLI context tools (rtk, lean-ctx). Reject explicitly: the
# default branch below forwards the first unknown flag AND everything
# after it to the wrapped tool, so a leftover --no-rtk in a script
# would silently swallow a following --port and be ignored downstream.
Fail "CLI context tools (rtk, lean-ctx) have been removed from Headroom. Drop $arg and unset HEADROOM_CONTEXT_TOOL; 'headroom wrap' uninstalls what they left behind on first run."
}
default {
for ($j = $i; $j -lt $Arguments.Count; $j++) {
$hostArgs.Add($Arguments[$j])
}
$i = $Arguments.Count
}
}
}
[pscustomobject]@{
KnownArgs = $known.ToArray()
HostArgs = $hostArgs.ToArray()
Port = $port
NoProxy = $noProxy
Learn = $learn
Backend = $backend
Anyllm = $anyllm
Region = $region
}
}
function Invoke-PrepareOnly {
param(
[string]$Tool,
[string[]]$KnownArgs
)
$dockerArgs = New-Object System.Collections.Generic.List[string]
$dockerArgs.AddRange([string[]]@('run','--rm'))
Add-TtyArgs -ArgsList $dockerArgs
$dockerArgs.AddRange((Get-SharedDockerArgs))
$dockerArgs.Add('--entrypoint')
$dockerArgs.Add('headroom')
$dockerArgs.Add($HeadroomImage)
$dockerArgs.AddRange([string[]]@('wrap',$Tool,'--prepare-only'))
foreach ($arg in $KnownArgs) {
$dockerArgs.Add($arg)
}
& docker @dockerArgs
if ($LASTEXITCODE -ne 0) {
throw "Failed to prepare docker-native wrap for $Tool"
}
}
Require-Command docker
if ($args.Count -eq 0) {
Invoke-HeadroomDocker -Arguments @('--help')
exit 0
}
switch ($args[0]) {
'install' {
if ($args.Count -eq 1 -or $args[1] -eq '--help' -or $args[1] -eq '-?') {
Show-InstallHelp
exit 0
}
$installCommand = $args[1]
$installArgs = if ($args.Count -gt 2) { $args[2..($args.Count - 1)] } else { @() }
switch ($installCommand) {
'apply' {
$parsed = Parse-InstallApplyArgs -Arguments $installArgs
Start-PersistentDockerInstall -Profile $parsed.Profile -Image $parsed.Image -Port $parsed.Port -Backend $parsed.Backend -AnyllmProvider $parsed.AnyllmProvider -Region $parsed.Region -Mode $parsed.Mode -Memory $parsed.Memory -TelemetryEnabled $parsed.TelemetryEnabled
Write-Host "Installed docker-native persistent deployment '$($parsed.Profile)' on port $($parsed.Port)."
exit 0
}
'status' {
$profile = Parse-InstallProfileArgs -Arguments $installArgs
Show-PersistentDockerInstallStatus -Profile $profile
exit 0
}
'start' {
$profile = Parse-InstallProfileArgs -Arguments $installArgs
$state = Read-PersistentState -Profile $profile
Start-PersistentDockerInstall -Profile $state.profile -Image $state.image -Port $state.port -Backend $state.backend -AnyllmProvider $state.anyllm_provider -Region $state.region -Mode $state.proxy_mode -Memory ([bool]$state.memory_enabled) -TelemetryEnabled ([bool]$state.telemetry_enabled)
Write-Host "Started docker-native persistent deployment '$profile'."
exit 0
}
'stop' {
$profile = Parse-InstallProfileArgs -Arguments $installArgs
Stop-PersistentDockerInstall -Profile $profile
Write-Host "Stopped docker-native persistent deployment '$profile'."
exit 0
}
'restart' {
$profile = Parse-InstallProfileArgs -Arguments $installArgs
$state = Read-PersistentState -Profile $profile
Start-PersistentDockerInstall -Profile $state.profile -Image $state.image -Port $state.port -Backend $state.backend -AnyllmProvider $state.anyllm_provider -Region $state.region -Mode $state.proxy_mode -Memory ([bool]$state.memory_enabled) -TelemetryEnabled ([bool]$state.telemetry_enabled)
Write-Host "Restarted docker-native persistent deployment '$profile'."
exit 0
}
'remove' {
$profile = Parse-InstallProfileArgs -Arguments $installArgs
Remove-PersistentDockerInstall -Profile $profile
Write-Host "Removed docker-native persistent deployment '$profile'."
exit 0
}
default {
Fail "Unsupported install target: $installCommand"
}
}
}
'wrap' {
if ($args.Count -eq 1 -or $args[1] -eq '--help' -or $args[1] -eq '-?') {
Show-WrapHelp
exit 0
}
if ($args.Count -lt 2) {
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
Fail 'Usage: headroom wrap <claude|codex|aider|cursor|openclaw|opencode> [...]'
}
$tool = $args[1]
$wrapArgs = if ($args.Count -gt 2) { $args[2..($args.Count - 1)] } else { @() }
switch ($tool) {
'claude' { }
'codex' { }
'aider' { }
'cursor' { }
'openclaw' { }
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
'opencode' { }
default {
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
Fail "Docker-native wrapper does not support 'wrap $tool'. Supported targets: claude, codex, aider, cursor, openclaw, opencode"
}
}
if ($tool -eq 'openclaw') {
if (Test-HelpFlag -Arguments $wrapArgs) {
$helpArgs = @('wrap','openclaw') + $wrapArgs
Invoke-HeadroomDocker -Arguments $helpArgs
exit 0
}
Invoke-OpenClawWrap -Arguments $wrapArgs
exit 0
}
if (Test-HelpFlag -Arguments $wrapArgs) {
$helpArgs = @('wrap', $tool) + $wrapArgs
Invoke-HeadroomDocker -Arguments $helpArgs
exit 0
}
$parsed = Parse-WrapArgs -Arguments $wrapArgs
$proxyArgs = New-Object System.Collections.Generic.List[string]
if ($parsed.Learn) { $proxyArgs.Add('--learn') }
if ($parsed.Backend) { $proxyArgs.AddRange([string[]]@('--backend', $parsed.Backend)) }
if ($parsed.Anyllm) { $proxyArgs.AddRange([string[]]@('--anyllm-provider', $parsed.Anyllm)) }
if ($parsed.Region) { $proxyArgs.AddRange([string[]]@('--region', $parsed.Region)) }
$containerName = $null
try {
if (-not $parsed.NoProxy) {
$containerName = Start-ProxyContainer -Port $parsed.Port -ProxyArgs $proxyArgs.ToArray()
}
$prepareArgs = New-Object System.Collections.Generic.List[string]
foreach ($arg in $parsed.KnownArgs) {
$prepareArgs.Add($arg)
}
if (-not $parsed.NoProxy) {
$prepareArgs.Add('--no-proxy')
}
Invoke-PrepareOnly -Tool $tool -KnownArgs $prepareArgs.ToArray()
switch ($tool) {
'claude' {
$exitCode = Invoke-WithTemporaryEnv -Environment @{ ANTHROPIC_BASE_URL = "http://127.0.0.1:$($parsed.Port)" } -Command 'claude' -Arguments $parsed.HostArgs
exit $exitCode
}
'codex' {
$exitCode = Invoke-WithTemporaryEnv -Environment @{ OPENAI_BASE_URL = "http://127.0.0.1:$($parsed.Port)/v1" } -Command 'codex' -Arguments $parsed.HostArgs
exit $exitCode
}
'aider' {
$exitCode = Invoke-WithTemporaryEnv -Environment @{
OPENAI_API_BASE = "http://127.0.0.1:$($parsed.Port)/v1"
ANTHROPIC_BASE_URL = "http://127.0.0.1:$($parsed.Port)"
} -Command 'aider' -Arguments $parsed.HostArgs
exit $exitCode
}
'cursor' {
Write-Host "Headroom proxy is running for Cursor."
Write-Host ""
Write-Host "OpenAI base URL: http://127.0.0.1:$($parsed.Port)/v1"
Write-Host "Anthropic base URL: http://127.0.0.1:$($parsed.Port)"
Write-Host ""
Write-Host "Press Ctrl+C to stop the proxy."
while ($true) { Start-Sleep -Seconds 1 }
}
}
} finally {
Stop-ProxyContainer -ContainerName $containerName
}
}
'unwrap' {
if ($args.Count -eq 1 -or $args[1] -eq '--help' -or $args[1] -eq '-?') {
Invoke-HeadroomDocker -Arguments @('unwrap','--help')
exit 0
}
if ($args.Count -ge 2 -and $args[1] -eq 'openclaw') {
$unwrapArgs = if ($args.Count -gt 2) { $args[2..($args.Count - 1)] } else { @() }
if (Test-HelpFlag -Arguments $unwrapArgs) {
$helpArgs = @('unwrap','openclaw') + $unwrapArgs
Invoke-HeadroomDocker -Arguments $helpArgs
exit 0
}
Invoke-OpenClawUnwrap -Arguments $unwrapArgs
exit 0
}
Invoke-HeadroomDocker -Arguments $args
}
'proxy' {
$port = 8787
$forwardArgs = New-Object System.Collections.Generic.List[string]
foreach ($arg in $args) { $forwardArgs.Add($arg) }
for ($i = 1; $i -lt $args.Count; $i++) {
if ($args[$i] -eq '--port' -or $args[$i] -eq '-p') {
Require-OptionValue -Arguments $args -Index $i -Option $args[$i]
$port = Parse-PortValue -Value $args[$i + 1]
break
}
if ($args[$i] -match '^--port=') {
$port = Parse-PortValue -Value ($args[$i] -replace '^--port=', '')
break
}
}
$dockerArgs = New-Object System.Collections.Generic.List[string]
$dockerArgs.AddRange([string[]]@('run','--rm'))
Add-TtyArgs -ArgsList $dockerArgs
$dockerArgs.AddRange([string[]]@('-p',"$port`:$port"))
$dockerArgs.AddRange((Get-SharedDockerArgs))
$dockerArgs.Add('--entrypoint')
$dockerArgs.Add('headroom')
$dockerArgs.Add($HeadroomImage)
foreach ($arg in $forwardArgs) {
$dockerArgs.Add($arg)
}
& docker @dockerArgs
exit $LASTEXITCODE
}
default {
Invoke-HeadroomDocker -Arguments $args
}
}
'@
$wrapper = $wrapper.Replace('__HEADROOM_INSTALL_IMAGE__', $resolvedInstallImage)
$cmdWrapper = ([string][char]64) + "echo off`r`npowershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File ""%~dp0headroom.ps1"" %*`r`n"
Set-Content -Path $wrapperPath -Value $wrapper -Encoding utf8
Set-Content -Path $cmdPath -Value $cmdWrapper -Encoding ascii
}
Require-Command docker
docker version | Out-Null
if ($LASTEXITCODE -ne 0) {
throw 'Docker is installed but not available to the current user'
}
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
Write-Wrapper -TargetDir $InstallDir
Ensure-PathEntry -PathEntry $InstallDir
Ensure-ProfileBlock -PathEntry $InstallDir
if ($env:HEADROOM_DOCKER_IMAGE) {
$null = docker image inspect $InstallImage 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Info "Using existing HEADROOM_DOCKER_IMAGE=$InstallImage"
} else {
Write-Info "Pulling $InstallImage"
docker pull $InstallImage | Out-Null
}
} else {
Write-Info "Pulling $ImageDefault"
docker pull $ImageDefault | Out-Null
}
Write-Host ""
Write-Host "Headroom Docker-native install complete."
Write-Host ""
Write-Host "Installed wrappers:"
Write-Host " $InstallDir\headroom.ps1"
Write-Host " $InstallDir\headroom.cmd"
Write-Host ""
Write-Host "Next steps:"
Write-Host " 1. Restart PowerShell"
Write-Host " 2. Try: headroom proxy"
Write-Host " 3. Docs: https://github.com/chopratejas/headroom/blob/main/docs/docker-install.md"