2026-04-10 23:27:24 -05:00
$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'
2026-04-11 15:56:18 -05:00
$InstallImage = if ( $env:HEADROOM_DOCKER_IMAGE ) { $env:HEADROOM_DOCKER_IMAGE } else { $ImageDefault }
2026-04-10 23:27:24 -05:00
$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 )
2026-04-10 23:27:24 -05:00
$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 )
2026-04-10 23:27:24 -05:00
}
}
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
}
2026-04-10 23:27:24 -05:00
$markerStart = '# >>> headroom docker-native >>>'
$markerEnd = '# <<< headroom docker-native <<<'
2026-04-11 15:56:18 -05:00
$escapedPathEntry = $PathEntry . Replace ( " ' " , " '' " )
2026-04-10 23:27:24 -05:00
$block = @"
$markerStart
2026-04-11 15:56:18 -05:00
if ( -not ( ( `$ env : Path -split ';' ) -contains '$escapedPathEntry' ) ) {
`$ env : Path = '$escapedPathEntry;' + `$ env : Path
2026-04-10 23:27:24 -05:00
}
$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'
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
$resolvedInstallImage = $InstallImage . Replace ( " ' " , " '' " )
2026-04-10 23:27:24 -05:00
$wrapper = @ '
$ErrorActionPreference = 'Stop'
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
$HeadroomImage = if ( $env:HEADROOM_DOCKER_IMAGE ) { $env:HEADROOM_DOCKER_IMAGE } else { '__HEADROOM_INSTALL_IMAGE__' }
2026-04-10 23:27:24 -05:00
$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' )
2026-04-16 19:19:25 -05:00
# 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 " )
2026-04-10 23:27:24 -05:00
$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 ( )
}
2026-04-11 15:56:18 -05:00
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' )
}
}
2026-04-10 23:27:24 -05:00
function Invoke-HeadroomDocker {
param ( [ string[] ] $Arguments )
$dockerArgs = New-Object System . Collections . Generic . List [ string ]
2026-04-11 15:56:18 -05:00
$dockerArgs . AddRange ( [ string[] ] @ ( 'run' , '--rm' ) )
Add-TtyArgs -ArgsList $dockerArgs
2026-04-10 23:27:24 -05:00
$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 ]
2026-04-11 00:40:16 -05:00
$dockerArgs . AddRange ( [ string[] ] @ ( 'run' , '-d' , '--rm' , '--name' , $containerName , '-p' , " $Port ` : $Port " ) )
2026-04-10 23:27:24 -05:00
$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
}
}
2026-04-11 15:56:18 -05:00
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 "
}
}
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
function Write-Utf8NoBomFile {
param (
[ string ] $Path ,
[ string ] $Content
)
[ System.IO.File ] :: WriteAllText ( $Path , $Content , [ System.Text.UTF8Encoding ] :: new ( $false ) )
}
2026-04-11 15:56:18 -05:00
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' )
2026-04-16 19:19:25 -05:00
# 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 " )
2026-04-11 15:56:18 -05:00
$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 ( )
}
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'
}
}
2026-04-11 15:56:18 -05:00
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 "
}
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
Write-Utf8NoBomFile -Path ( Get-PersistentStatePath -Profile $Profile ) -Content ( $state | ConvertTo-Json -Depth 4 )
2026-04-11 15:56:18 -05:00
}
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 = @ ( )
}
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
Write-Utf8NoBomFile -Path ( Get-PersistentManifestPath -Profile $Profile ) -Content ( $manifest | ConvertTo-Json -Depth 8 )
2026-04-11 15:56:18 -05:00
}
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 ]
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 " ) )
2026-04-11 15:56:18 -05:00
$dockerArgs . AddRange ( ( Get-PersistentDockerArgs ) )
2026-08-12 00:25:29 +03:00
Add-DashboardGatewayEnv -ArgsList $dockerArgs
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
$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'
) )
2026-04-11 15:56:18 -05:00
$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]' ,
2026-04-11 15:56:18 -05:00
' -?, --help Show this message and exit.'
)
Write-Host ( $lines -join [ Environment ] :: NewLine )
}
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
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 )
}
2026-04-11 15:56:18 -05:00
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
}
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
'^(--help|-\?)$' {
2026-04-11 15:56:18 -05:00
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
}
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
'^(--help|-\?)$' {
2026-04-11 15:56:18 -05:00
Show-InstallHelp
exit 0
}
default {
Fail " Unsupported option for 'headroom install': $arg "
}
}
}
return $profile
}
2026-04-10 23:27:24 -05:00
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' )
}
}
}
2026-04-11 00:04:15 -05:00
function Test-HelpFlag {
param ( [ string[] ] $Arguments )
foreach ( $arg in $Arguments ) {
2026-04-11 00:40:16 -05:00
if ( $arg -eq '--' ) {
break
}
2026-04-11 00:04:15 -05:00
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$' {
2026-04-11 15:56:18 -05:00
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
2026-04-11 00:04:15 -05:00
$pluginPath = $Arguments [ $i + 1 ]
$i + = 2
continue
}
'^--plugin-path=' {
$pluginPath = $arg -replace '^--plugin-path=' , ''
$i + = 1
continue
}
'^--plugin-spec$' {
2026-04-11 15:56:18 -05:00
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
2026-04-11 00:04:15 -05:00
$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$' {
2026-04-11 15:56:18 -05:00
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$proxyPort = Parse-PortValue -Value $Arguments [ $i + 1 ]
2026-04-11 00:04:15 -05:00
$i + = 2
continue
}
'^--proxy-port=' {
2026-04-11 15:56:18 -05:00
$proxyPort = Parse-PortValue -Value ( $arg -replace '^--proxy-port=' , '' )
2026-04-11 00:04:15 -05:00
$i + = 1
continue
}
'^--startup-timeout-ms$' {
2026-04-11 15:56:18 -05:00
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$startupTimeoutMs = Parse-PositiveIntegerValue -Value $Arguments [ $i + 1 ]
2026-04-11 00:04:15 -05:00
$i + = 2
continue
}
'^--startup-timeout-ms=' {
2026-04-11 15:56:18 -05:00
$startupTimeoutMs = Parse-PositiveIntegerValue -Value ( $arg -replace '^--startup-timeout-ms=' , '' )
2026-04-11 00:04:15 -05:00
$i + = 1
continue
}
'^--gateway-provider-id$' {
2026-04-11 15:56:18 -05:00
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
2026-04-11 00:04:15 -05:00
$gatewayProviderIds . Add ( $Arguments [ $i + 1 ] )
$i + = 2
continue
}
'^--gateway-provider-id=' {
$gatewayProviderIds . Add ( $arg -replace '^--gateway-provider-id=' , '' )
$i + = 1
continue
}
'^--python-path$' {
2026-04-11 15:56:18 -05:00
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
2026-04-11 00:04:15 -05:00
$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 ]
2026-04-11 00:40:16 -05:00
$dockerArgs . AddRange ( [ string[] ] @ ( 'run' , '--rm' ) )
2026-04-11 00:04:15 -05:00
$dockerArgs . AddRange ( ( Get-SharedDockerArgs ) )
$dockerArgs . Add ( '--entrypoint' )
$dockerArgs . Add ( 'headroom' )
$dockerArgs . Add ( $HeadroomImage )
2026-04-11 00:40:16 -05:00
$dockerArgs . AddRange ( [ string[] ] @ ( 'wrap' , 'openclaw' , '--prepare-only' , '--proxy-port' , " $( $Parsed . ProxyPort ) " , '--startup-timeout-ms' , " $( $Parsed . StartupTimeoutMs ) " ) )
2026-04-11 00:04:15 -05:00
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 ]
2026-04-11 00:40:16 -05:00
$dockerArgs . AddRange ( [ string[] ] @ ( 'run' , '--rm' ) )
2026-04-11 00:04:15 -05:00
$dockerArgs . AddRange ( ( Get-SharedDockerArgs ) )
$dockerArgs . Add ( '--entrypoint' )
$dockerArgs . Add ( 'headroom' )
$dockerArgs . Add ( $HeadroomImage )
2026-04-11 00:40:16 -05:00
$dockerArgs . AddRange ( [ string[] ] @ ( 'unwrap' , 'openclaw' , '--prepare-only' ) )
2026-04-11 00:04:15 -05:00
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 " "
}
2026-04-10 23:27:24 -05:00
function Parse-WrapArgs {
param ( [ string[] ] $Arguments )
$known = New-Object System . Collections . Generic . List [ string ]
2026-04-11 15:56:18 -05:00
$hostArgs = New-Object System . Collections . Generic . List [ string ]
2026-04-10 23:27:24 -05:00
$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 + + ) {
2026-04-11 15:56:18 -05:00
$hostArgs . Add ( $Arguments [ $j ] )
2026-04-10 23:27:24 -05:00
}
$i = $Arguments . Count
continue
}
'^--port$|^-p$' {
2026-04-11 15:56:18 -05:00
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$port = Parse-PortValue -Value $Arguments [ $i + 1 ]
2026-04-10 23:27:24 -05:00
$known . Add ( $arg )
$known . Add ( $Arguments [ $i + 1 ] )
$i + = 2
continue
}
'^--port=' {
2026-04-11 15:56:18 -05:00
$port = Parse-PortValue -Value ( $arg -replace '^--port=' , '' )
2026-04-10 23:27:24 -05:00
$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$' {
2026-04-11 15:56:18 -05:00
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
2026-04-10 23:27:24 -05:00
$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$' {
2026-04-11 15:56:18 -05:00
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
2026-04-10 23:27:24 -05:00
$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$' {
2026-04-11 15:56:18 -05:00
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
2026-04-10 23:27:24 -05:00
$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. "
}
2026-04-10 23:27:24 -05:00
default {
for ( $j = $i ; $j -lt $Arguments . Count ; $j + + ) {
2026-04-11 15:56:18 -05:00
$hostArgs . Add ( $Arguments [ $j ] )
2026-04-10 23:27:24 -05:00
}
$i = $Arguments . Count
}
}
}
[ pscustomobject ] @ {
KnownArgs = $known . ToArray ( )
2026-04-11 15:56:18 -05:00
HostArgs = $hostArgs . ToArray ( )
2026-04-10 23:27:24 -05:00
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 ]
2026-04-11 15:56:18 -05:00
$dockerArgs . AddRange ( [ string[] ] @ ( 'run' , '--rm' ) )
Add-TtyArgs -ArgsList $dockerArgs
2026-04-10 23:27:24 -05:00
$dockerArgs . AddRange ( ( Get-SharedDockerArgs ) )
$dockerArgs . Add ( '--entrypoint' )
$dockerArgs . Add ( 'headroom' )
$dockerArgs . Add ( $HeadroomImage )
2026-04-11 00:40:16 -05:00
$dockerArgs . AddRange ( [ string[] ] @ ( 'wrap' , $Tool , '--prepare-only' ) )
2026-04-10 23:27:24 -05:00
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 ] ) {
2026-04-11 15:56:18 -05:00
'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 "
}
}
}
2026-04-10 23:27:24 -05:00
'wrap' {
2026-04-11 00:40:16 -05:00
if ( $args . Count -eq 1 -or $args [ 1 ] -eq '--help' -or $args [ 1 ] -eq '-?' ) {
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
Show-WrapHelp
2026-04-11 00:40:16 -05:00
exit 0
}
2026-04-10 23:27:24 -05:00
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> [...]'
2026-04-10 23:27:24 -05:00
}
$tool = $args [ 1 ]
$wrapArgs = if ( $args . Count -gt 2 ) { $args [ 2 . . ( $args . Count - 1 ) ] } else { @ ( ) }
2026-04-11 00:04:15 -05:00
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
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' { }
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
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 "
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
}
}
2026-04-11 00:04:15 -05:00
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
}
2026-04-11 00:40:16 -05:00
if ( Test-HelpFlag -Arguments $wrapArgs ) {
$helpArgs = @ ( 'wrap' , $tool ) + $wrapArgs
Invoke-HeadroomDocker -Arguments $helpArgs
exit 0
}
2026-04-10 23:27:24 -05:00
$parsed = Parse-WrapArgs -Arguments $wrapArgs
$proxyArgs = New-Object System . Collections . Generic . List [ string ]
if ( $parsed . Learn ) { $proxyArgs . Add ( '--learn' ) }
2026-04-11 00:40:16 -05:00
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 ) ) }
2026-04-10 23:27:24 -05:00
$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' {
2026-04-11 00:40:16 -05:00
if ( $args . Count -eq 1 -or $args [ 1 ] -eq '--help' -or $args [ 1 ] -eq '-?' ) {
Invoke-HeadroomDocker -Arguments @ ( 'unwrap' , '--help' )
exit 0
}
2026-04-10 23:27:24 -05:00
if ( $args . Count -ge 2 -and $args [ 1 ] -eq 'openclaw' ) {
2026-04-11 00:04:15 -05:00
$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
2026-04-10 23:27:24 -05:00
}
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' ) {
2026-04-11 15:56:18 -05:00
Require-OptionValue -Arguments $args -Index $i -Option $args [ $i ]
$port = Parse-PortValue -Value $args [ $i + 1 ]
2026-04-10 23:27:24 -05:00
break
}
if ( $args [ $i ] -match '^--port=' ) {
2026-04-11 15:56:18 -05:00
$port = Parse-PortValue -Value ( $args [ $i ] -replace '^--port=' , '' )
2026-04-10 23:27:24 -05:00
break
}
}
$dockerArgs = New-Object System . Collections . Generic . List [ string ]
2026-04-11 15:56:18 -05:00
$dockerArgs . AddRange ( [ string[] ] @ ( 'run' , '--rm' ) )
Add-TtyArgs -ArgsList $dockerArgs
$dockerArgs . AddRange ( [ string[] ] @ ( '-p' , " $port ` : $port " ) )
2026-04-10 23:27:24 -05:00
$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
}
}
' @
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
$wrapper = $wrapper . Replace ( '__HEADROOM_INSTALL_IMAGE__' , $resolvedInstallImage )
2026-04-11 15:56:18 -05:00
$cmdWrapper = ( [ string][char ] 64 ) + " echo off `r `n powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File "" %~dp0headroom.ps1 "" %* `r `n "
2026-04-10 23:27:24 -05:00
2026-04-11 15:56:18 -05:00
Set-Content -Path $wrapperPath -Value $wrapper -Encoding utf8
Set-Content -Path $cmdPath -Value $cmdWrapper -Encoding ascii
2026-04-10 23:27:24 -05:00
}
Require-Command docker
docker version | Out-Null
2026-04-11 15:56:18 -05:00
if ( $LASTEXITCODE -ne 0 ) {
throw 'Docker is installed but not available to the current user'
}
2026-04-10 23:27:24 -05:00
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
Write-Wrapper -TargetDir $InstallDir
Ensure-PathEntry -PathEntry $InstallDir
Ensure-ProfileBlock -PathEntry $InstallDir
2026-04-11 15:56:18 -05:00
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
}
2026-04-10 23:27:24 -05:00
2026-04-11 15:56:18 -05:00
Write-Host " "
Write-Host " Headroom Docker-native install complete. "
Write-Host " "
2026-04-10 23:27:24 -05:00
Write-Host " Installed wrappers: "
Write-Host " $InstallDir \headroom.ps1 "
Write-Host " $InstallDir \headroom.cmd "
2026-04-11 15:56:18 -05:00
Write-Host " "
Write-Host " Next steps: "
2026-04-10 23:27:24 -05:00
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 "