2026-04-10 23:27:24 -05:00
#!/usr/bin/env bash
set -euo pipefail
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_DEFAULT = "ghcr.io/headroomlabs-ai/headroom:latest"
2026-04-11 15:56:18 -05:00
INSTALL_IMAGE = " ${ HEADROOM_DOCKER_IMAGE :- ${ IMAGE_DEFAULT } } "
2026-04-10 23:27:24 -05:00
INSTALL_DIR = " ${ HOME } /.local/bin "
if [ [ ! -d " ${ HOME } /.local " ] ] ; then
INSTALL_DIR = " ${ HOME } /bin "
fi
2026-04-11 15:56:18 -05:00
BASH_PATH = " ${ BASH :- $( command -v bash) } "
if ( ( BASH_VERSINFO[ 0] < 4 || ( BASH_VERSINFO[ 0] = = 4 && BASH_VERSINFO[ 1] < 3) ) ) ; then
printf 'ERROR: Headroom Docker-native install requires bash >= 4.3\n' >& 2
exit 1
fi
2026-04-10 23:27:24 -05:00
info( ) {
printf '==> %s\n' " $* "
}
warn( ) {
printf 'WARN: %s\n' " $* " >& 2
}
die( ) {
printf 'ERROR: %s\n' " $* " >& 2
exit 1
}
require_cmd( ) {
command -v " $1 " >/dev/null 2>& 1 || die " Missing required command: $1 "
}
append_path_block( ) {
local target_file = " $1 "
local marker_start = "# >>> headroom docker-native >>>"
local marker_end = "# <<< headroom docker-native <<<"
local block = " ${ marker_start }
export PATH = \" ${ INSTALL_DIR } :\$ PATH\"
${ marker_end } "
touch " ${ target_file } "
if grep -Fq " ${ marker_start } " " ${ target_file } " ; then
return
fi
{
printf '\n%s\n' " ${ block } "
} >>" ${ target_file } "
}
write_wrapper( ) {
local wrapper_path = " ${ INSTALL_DIR } /headroom "
2026-04-11 15:56:18 -05:00
{
printf '#!%s\n\n' " ${ BASH_PATH } "
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
printf 'HEADROOM_IMAGE_DEFAULT=%q\n' " ${ INSTALL_IMAGE } "
2026-04-11 15:56:18 -05:00
cat <<'WRAPPER'
2026-04-10 23:27:24 -05:00
set -euo pipefail
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
HEADROOM_IMAGE = " ${ HEADROOM_DOCKER_IMAGE :- ${ HEADROOM_IMAGE_DEFAULT } } "
2026-04-10 23:27:24 -05:00
HEADROOM_CONTAINER_HOME = " ${ HEADROOM_CONTAINER_HOME :- /tmp/headroom-home } "
HEADROOM_HOST_HOME = " ${ HOME : ? } "
2026-04-11 15:56:18 -05:00
if ( ( BASH_VERSINFO[ 0] < 4 || ( BASH_VERSINFO[ 0] = = 4 && BASH_VERSINFO[ 1] < 3) ) ) ; then
printf 'ERROR: Headroom Docker-native wrapper requires bash >= 4.3\n' >& 2
exit 1
fi
2026-04-10 23:27:24 -05:00
warn( ) {
printf 'WARN: %s\n' " $* " >& 2
}
die( ) {
printf 'ERROR: %s\n' " $* " >& 2
exit 1
}
require_cmd( ) {
command -v " $1 " >/dev/null 2>& 1 || die " Missing required command: $1 "
}
ensure_host_dirs( ) {
mkdir -p \
" ${ HEADROOM_HOST_HOME } /.headroom " \
" ${ HEADROOM_HOST_HOME } /.claude " \
" ${ HEADROOM_HOST_HOME } /.codex " \
" ${ HEADROOM_HOST_HOME } /.gemini "
}
append_passthrough_envs( ) {
local -n ref = $1
local name
for name in $( compgen -e) ; do
case " ${ name } " in
HEADROOM_*| ANTHROPIC_*| OPENAI_*| GEMINI_*| AWS_*| AZURE_*| VERTEX_*| GOOGLE_*| GOOGLE_CLOUD_*| MISTRAL_*| GROQ_*| OPENROUTER_*| XAI_*| TOGETHER_*| COHERE_*| OLLAMA_*| LITELLM_*| OTEL_*| SUPABASE_*| QDRANT_*| NEO4J_*| LANGSMITH_*)
ref += ( --env " ${ name } " )
; ;
esac
done
}
append_common_container_args( ) {
local -n ref = $1
ensure_host_dirs
ref += ( -w /workspace)
ref += ( --env " HOME= ${ HEADROOM_CONTAINER_HOME } " )
ref += ( --env "PYTHONUNBUFFERED=1" )
2026-04-16 19:19:25 -05:00
# Canonical Headroom filesystem contract (issue #175) — forward into the
# container so the proxy resolves state/config to the bind-mounted path.
ref += ( --env " HEADROOM_WORKSPACE_DIR= ${ HEADROOM_CONTAINER_HOME } /.headroom " )
ref += ( --env " HEADROOM_CONFIG_DIR= ${ HEADROOM_CONTAINER_HOME } /.headroom/config " )
2026-04-10 23:27:24 -05:00
ref += ( -v " ${ PWD } :/workspace " )
ref += ( -v " ${ HEADROOM_HOST_HOME } /.headroom: ${ HEADROOM_CONTAINER_HOME } /.headroom " )
ref += ( -v " ${ HEADROOM_HOST_HOME } /.claude: ${ HEADROOM_CONTAINER_HOME } /.claude " )
ref += ( -v " ${ HEADROOM_HOST_HOME } /.codex: ${ HEADROOM_CONTAINER_HOME } /.codex " )
ref += ( -v " ${ HEADROOM_HOST_HOME } /.gemini: ${ HEADROOM_CONTAINER_HOME } /.gemini " )
if command -v id >/dev/null 2>& 1; then
ref += ( --user " $( id -u) : $( id -g) " )
fi
2026-04-11 00:40:16 -05:00
append_passthrough_envs " $1 "
2026-04-10 23:27:24 -05:00
}
append_tty_args( ) {
local -n ref = $1
if [ [ -t 0 && -t 1 ] ] ; then
ref += ( -it)
elif [ [ -t 0 ] ] ; then
ref += ( -i)
elif [ [ -t 1 ] ] ; then
ref += ( -t)
fi
}
run_headroom( ) {
local args = ( )
args = ( docker run --rm)
append_tty_args args
append_common_container_args args
args += ( --entrypoint headroom " ${ HEADROOM_IMAGE } " " $@ " )
" ${ args [@] } "
}
docker_container_exists( ) {
local name = " $1 "
docker ps --format '{{.Names}}' | grep -Fxq " ${ name } "
}
wait_for_proxy( ) {
local container_name = " $1 "
local port = " $2 "
local attempt
for attempt in $( seq 1 45) ; do
2026-04-11 15:56:18 -05:00
if command -v curl >/dev/null 2>& 1; then
if curl --fail --silent " http://127.0.0.1: ${ port } /readyz " >/dev/null; then
return 0
fi
elif ( echo >/dev/tcp/127.0.0.1/" ${ port } " ) >/dev/null 2>& 1; then
2026-04-10 23:27:24 -05:00
return 0
fi
if ! docker_container_exists " ${ container_name } " ; then
break
fi
sleep 1
done
docker logs " ${ container_name } " >& 2 || true
return 1
}
start_proxy_container( ) {
local port = " $1 "
shift
local container_name = " headroom-proxy- ${ port } - $$ "
local args = ( )
args = ( docker run -d --rm --name " ${ container_name } " -p " ${ port } : ${ port } " )
append_common_container_args args
args += ( " ${ HEADROOM_IMAGE } " --host 0.0.0.0 --port " ${ port } " " $@ " )
" ${ args [@] } " >/dev/null
if ! wait_for_proxy " ${ container_name } " " ${ port } " ; then
docker stop " ${ container_name } " >/dev/null 2>& 1 || true
die " Headroom proxy failed to start on port ${ port } "
fi
printf '%s\n' " ${ container_name } "
}
stop_proxy_container( ) {
local container_name = " ${ 1 :- } "
if [ [ -n " ${ container_name } " ] ] ; then
docker stop " ${ container_name } " >/dev/null 2>& 1 || true
fi
}
2026-04-11 15:56:18 -05:00
persistent_profile_root( ) {
local profile = " $1 "
validate_profile_name " ${ profile } "
printf '%s/.headroom/deploy/%s\n' " ${ HEADROOM_HOST_HOME } " " ${ profile } "
}
persistent_state_path( ) {
local profile = " $1 "
printf '%s/docker-native.env\n' " $( persistent_profile_root " ${ profile } " ) "
}
persistent_manifest_path( ) {
local profile = " $1 "
printf '%s/manifest.json\n' " $( persistent_profile_root " ${ profile } " ) "
}
persistent_container_name( ) {
local profile = " $1 "
validate_profile_name " ${ profile } "
printf 'headroom-%s\n' " ${ profile } "
}
validate_profile_name( ) {
local profile = " $1 "
[ [ " ${ profile } " = ~ ^[ A-Za-z0-9._-] +$ ] ] || die " Invalid profile name ' ${ profile } ' "
[ [ " ${ profile } " != "." && " ${ profile } " != ".." ] ] || die " Invalid profile name ' ${ profile } ' "
}
validate_port( ) {
local port = " $1 "
[ [ " ${ port } " = ~ ^[ 0-9] +$ ] ] || die " Invalid port ' ${ port } ' "
( ( 10#${ port } >= 1 && 10#${ port } <= 65535) ) || die " Invalid port ' ${ port } ' "
}
validate_positive_integer( ) {
local value = " $1 "
[ [ " ${ value } " = ~ ^[ 0-9] +$ ] ] || die " Invalid value ' ${ value } ' "
( ( 10#${ value } >= 1) ) || die " Invalid value ' ${ value } ' "
}
require_option_value( ) {
( ( $# >= 2) ) || die " Option $1 requires a value "
}
json_escape( ) {
local value = " $1 "
value = " ${ value // \\ / \\ \\ } "
value = " ${ value // \" / \\ \" } "
value = " ${ value // $'\n' / \\ n } "
printf '%s' " ${ value } "
}
json_array_from_args( ) {
local first = 1
local arg
printf '['
for arg in " $@ " ; do
if [ [ " ${ first } " -eq 0 ] ] ; then
printf ','
fi
first = 0
printf '"%s"' " $( json_escape " ${ arg } " ) "
done
printf ']'
}
append_persistent_container_args( ) {
local -n ref = $1
ensure_host_dirs
ref += ( --workdir " ${ HEADROOM_CONTAINER_HOME } " )
ref += ( --env " HOME= ${ HEADROOM_CONTAINER_HOME } " )
ref += ( --env "PYTHONUNBUFFERED=1" )
2026-04-16 19:19:25 -05:00
# Canonical Headroom filesystem contract (issue #175).
ref += ( --env " HEADROOM_WORKSPACE_DIR= ${ HEADROOM_CONTAINER_HOME } /.headroom " )
ref += ( --env " HEADROOM_CONFIG_DIR= ${ HEADROOM_CONTAINER_HOME } /.headroom/config " )
2026-04-11 15:56:18 -05:00
ref += ( -v " ${ HEADROOM_HOST_HOME } /.headroom: ${ HEADROOM_CONTAINER_HOME } /.headroom " )
ref += ( -v " ${ HEADROOM_HOST_HOME } /.claude: ${ HEADROOM_CONTAINER_HOME } /.claude " )
ref += ( -v " ${ HEADROOM_HOST_HOME } /.codex: ${ HEADROOM_CONTAINER_HOME } /.codex " )
ref += ( -v " ${ HEADROOM_HOST_HOME } /.gemini: ${ HEADROOM_CONTAINER_HOME } /.gemini " )
if command -v id >/dev/null 2>& 1; then
ref += ( --user " $( id -u) : $( id -g) " )
fi
append_passthrough_envs " $1 "
}
2026-08-12 00:25:29 +03:00
append_dashboard_gateway_env( ) {
local -n ref = $1
# 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 [ [ -n " ${ HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS +x } " ] ] ; then
return
fi
local gateway
gateway = " $( docker network inspect bridge --format '{{(index .IPAM.Config 0).Gateway}}' 2>/dev/null || true ) "
if [ [ -n " ${ gateway } " ] ] ; then
ref += ( --env " HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS= ${ gateway } /32 " )
else
warn "Could not determine Docker bridge gateway; dashboard metadata remains restricted"
fi
}
2026-04-11 15:56:18 -05:00
build_manifest_proxy_args( ) {
local -n out_args = $1
local port = " $2 "
local proxy_mode = " $3 "
local backend = " $4 "
local anyllm = " $5 "
local region = " $6 "
local memory_enabled = " $7 "
local telemetry_enabled = " $8 "
out_args = ( --host 127.0.0.1 --port " ${ port } " --mode " ${ proxy_mode } " --backend " ${ backend } " )
if [ [ " ${ telemetry_enabled } " -eq 0 ] ] ; then
out_args += ( --no-telemetry)
fi
if [ [ " ${ memory_enabled } " -eq 1 ] ] ; then
out_args += ( --memory --memory-db-path " ${ HEADROOM_CONTAINER_HOME } /.headroom/memory.db " )
fi
if [ [ -n " ${ anyllm } " ] ] ; then
out_args += ( --anyllm-provider " ${ anyllm } " )
fi
if [ [ -n " ${ region } " ] ] ; then
out_args += ( --region " ${ region } " )
fi
}
write_persistent_state( ) {
local profile = " $1 "
local image = " $2 "
local port = " $3 "
local backend = " $4 "
local anyllm = " $5 "
local region = " $6 "
local proxy_mode = " $7 "
local memory_enabled = " $8 "
local telemetry_enabled = " $9 "
local root
root = " $( persistent_profile_root " ${ profile } " ) "
mkdir -p " ${ root } "
{
printf 'PROFILE=%s\n' " ${ profile } "
printf 'IMAGE=%s\n' " ${ image } "
printf 'PORT=%s\n' " ${ port } "
printf 'BACKEND=%s\n' " ${ backend } "
printf 'ANYLLM_PROVIDER=%s\n' " ${ anyllm } "
printf 'REGION=%s\n' " ${ region } "
printf 'PROXY_MODE=%s\n' " ${ proxy_mode } "
printf 'MEMORY_ENABLED=%s\n' " ${ memory_enabled } "
printf 'TELEMETRY_ENABLED=%s\n' " ${ telemetry_enabled } "
printf 'CONTAINER_NAME=%s\n' " $( persistent_container_name " ${ profile } " ) "
printf 'HEALTH_URL=%s\n' " http://127.0.0.1: ${ port } /readyz "
} >" $( persistent_state_path " ${ profile } " ) "
}
write_persistent_manifest( ) {
local profile = " $1 "
local image = " $2 "
local port = " $3 "
local backend = " $4 "
local anyllm = " $5 "
local region = " $6 "
local proxy_mode = " $7 "
local memory_enabled = " $8 "
local telemetry_enabled = " $9 "
local -n proxy_args_ref = ${ 10 }
local root
local manifest_path
local anyllm_json = "null"
local region_json = "null"
local memory_json = "false"
local telemetry_json = "true"
root = " $( persistent_profile_root " ${ profile } " ) "
manifest_path = " $( persistent_manifest_path " ${ profile } " ) "
mkdir -p " ${ root } "
if [ [ -n " ${ anyllm } " ] ] ; then
anyllm_json = " \" $( json_escape " ${ anyllm } " ) \" "
fi
if [ [ -n " ${ region } " ] ] ; then
region_json = " \" $( json_escape " ${ region } " ) \" "
fi
if [ [ " ${ memory_enabled } " -eq 1 ] ] ; then
memory_json = "true"
fi
if [ [ " ${ telemetry_enabled } " -eq 0 ] ] ; then
telemetry_json = "false"
fi
cat >" ${ manifest_path } " <<EOF
{
"profile" : " $( json_escape " ${ profile } " ) " ,
"preset" : "persistent-docker" ,
"runtime_kind" : "docker" ,
"supervisor_kind" : "none" ,
"scope" : "user" ,
"provider_mode" : "manual" ,
"targets" : [ ] ,
"port" : ${ port } ,
"host" : "127.0.0.1" ,
"backend" : " $( json_escape " ${ backend } " ) " ,
"anyllm_provider" : ${ anyllm_json } ,
"region" : ${ region_json } ,
"proxy_mode" : " $( json_escape " ${ proxy_mode } " ) " ,
"memory_enabled" : ${ memory_json } ,
"memory_db_path" : " $( json_escape " ${ HEADROOM_CONTAINER_HOME } /.headroom/memory.db " ) " ,
"telemetry_enabled" : ${ telemetry_json } ,
"image" : " $( json_escape " ${ image } " ) " ,
"service_name" : " headroom- $( json_escape " ${ profile } " ) " ,
"container_name" : " $( json_escape " $( persistent_container_name " ${ profile } " ) " ) " ,
"health_url" : " http://127.0.0.1: ${ port } /readyz " ,
"base_env" : {
"HEADROOM_PORT" : " ${ port } " ,
"HEADROOM_HOST" : "127.0.0.1" ,
"HEADROOM_MODE" : " $( json_escape " ${ proxy_mode } " ) " ,
"HEADROOM_BACKEND" : " $( json_escape " ${ backend } " ) "
} ,
"tool_envs" : { } ,
"proxy_args" : $( json_array_from_args " ${ proxy_args_ref [@] } " ) ,
"mutations" : [ ] ,
"artifacts" : [ ]
}
EOF
}
load_persistent_state( ) {
local profile = " $1 "
local state_path
validate_profile_name " ${ profile } "
state_path = " $( persistent_state_path " ${ profile } " ) "
[ [ -f " ${ state_path } " ] ] || die " No docker-native persistent deployment profile named ' ${ profile } ' "
PROFILE = ""
IMAGE = ""
PORT = ""
BACKEND = ""
ANYLLM_PROVIDER = ""
REGION = ""
PROXY_MODE = ""
MEMORY_ENABLED = ""
TELEMETRY_ENABLED = ""
CONTAINER_NAME = ""
HEALTH_URL = ""
while IFS = '=' read -r key value; do
case " ${ key } " in
PROFILE| IMAGE| PORT| BACKEND| ANYLLM_PROVIDER| REGION| PROXY_MODE| MEMORY_ENABLED| TELEMETRY_ENABLED| CONTAINER_NAME| HEALTH_URL)
printf -v " ${ key } " '%s' " ${ value } "
; ;
esac
done <" ${ state_path } "
}
start_persistent_docker_install( ) {
local profile = " $1 "
local image = " $2 "
local port = " $3 "
local backend = " $4 "
local anyllm = " $5 "
local region = " $6 "
local proxy_mode = " $7 "
local memory_enabled = " $8 "
local telemetry_enabled = " $9 "
local container_name
local proxy_args = ( )
local args = ( )
validate_profile_name " ${ profile } "
container_name = " $( persistent_container_name " ${ profile } " ) "
build_manifest_proxy_args proxy_args " ${ port } " " ${ proxy_mode } " " ${ backend } " " ${ anyllm } " " ${ region } " " ${ memory_enabled } " " ${ telemetry_enabled } "
docker rm -f " ${ container_name } " >/dev/null 2>& 1 || true
2026-08-12 00:25:29 +03:00
args = ( docker run -d --restart unless-stopped --name " ${ container_name } " -p " 127.0.0.1: ${ port } : ${ port } " )
2026-04-11 15:56:18 -05:00
append_persistent_container_args args
2026-08-12 00:25:29 +03:00
append_dashboard_gateway_env 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
args += (
--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
args += ( " ${ image } " --host 0.0.0.0 " ${ proxy_args [@] : 2 } " )
" ${ args [@] } " >/dev/null
if ! wait_for_proxy " ${ container_name } " " ${ port } " ; then
docker rm -f " ${ container_name } " >/dev/null 2>& 1 || true
die " Headroom persistent Docker deployment failed to start on port ${ port } "
fi
write_persistent_state " ${ profile } " " ${ image } " " ${ port } " " ${ backend } " " ${ anyllm } " " ${ region } " " ${ proxy_mode } " " ${ memory_enabled } " " ${ telemetry_enabled } "
write_persistent_manifest " ${ profile } " " ${ image } " " ${ port } " " ${ backend } " " ${ anyllm } " " ${ region } " " ${ proxy_mode } " " ${ memory_enabled } " " ${ telemetry_enabled } " proxy_args
}
stop_persistent_docker_install( ) {
local profile = " $1 "
local container_name
load_persistent_state " ${ profile } "
container_name = " ${ CONTAINER_NAME } "
docker stop " ${ container_name } " >/dev/null 2>& 1 || true
docker rm -f " ${ container_name } " >/dev/null 2>& 1 || true
}
status_persistent_docker_install( ) {
local profile = " $1 "
local status = "stopped"
local ready = "no"
load_persistent_state " ${ profile } "
if docker_container_exists " ${ CONTAINER_NAME } " ; then
status = "running"
if command -v curl >/dev/null 2>& 1; then
if curl --fail --silent " ${ HEALTH_URL } " >/dev/null; then
ready = "yes"
fi
elif ( echo >/dev/tcp/127.0.0.1/" ${ PORT } " ) >/dev/null 2>& 1; then
ready = "yes"
fi
fi
printf 'Profile: %s\n' " ${ PROFILE } "
printf 'Preset: persistent-docker\n'
printf 'Runtime: docker\n'
printf 'Supervisor: none\n'
printf 'Port: %s\n' " ${ PORT } "
printf 'Status: %s\n' " ${ status } "
printf 'Ready: %s\n' " ${ ready } "
printf 'Health URL: %s\n' " ${ HEALTH_URL } "
}
remove_persistent_docker_install( ) {
local profile = " $1 "
local root
load_persistent_state " ${ profile } "
docker stop " ${ CONTAINER_NAME } " >/dev/null 2>& 1 || true
docker rm -f " ${ CONTAINER_NAME } " >/dev/null 2>& 1 || true
root = " $( persistent_profile_root " ${ profile } " ) "
rm -rf " ${ root } "
}
print_install_help( ) {
cat <<'EOF'
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.
EOF
}
print_install_apply_help( ) {
cat <<'EOF'
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.
EOF
}
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
print_wrap_help( ) {
cat <<'EOF'
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.
EOF
}
2026-04-11 15:56:18 -05:00
parse_install_apply_args( ) {
local -n out_profile = $1
local -n out_port = $2
local -n out_backend = $3
local -n out_anyllm = $4
local -n out_region = $5
local -n out_mode = $6
local -n out_memory = $7
local -n out_telemetry = $8
local -n out_image = $9
shift 9
out_profile = "default"
out_port = 8787
out_backend = "anthropic"
out_anyllm = ""
out_region = ""
out_mode = "token"
out_memory = 0
out_telemetry = 1
out_image = " ${ HEADROOM_IMAGE } "
while ( ( $# ) ) ; do
case " $1 " in
--preset)
require_option_value " $@ "
[ [ " $2 " = = "persistent-docker" ] ] || die "Docker-native wrapper supports only --preset persistent-docker"
shift 2
; ;
--preset= *)
[ [ " ${ 1 #*= } " = = "persistent-docker" ] ] || die "Docker-native wrapper supports only --preset persistent-docker"
shift
; ;
--runtime)
require_option_value " $@ "
[ [ " $2 " = = "docker" ] ] || die "Docker-native wrapper supports only --runtime docker"
shift 2
; ;
--runtime= *)
[ [ " ${ 1 #*= } " = = "docker" ] ] || die "Docker-native wrapper supports only --runtime docker"
shift
; ;
--scope| --providers| --target)
die "Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows"
; ;
--scope= *| --providers= *| --target= *)
die "Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows"
; ;
--profile)
require_option_value " $@ "
out_profile = " $2 "
shift 2
; ;
--profile= *)
out_profile = " ${ 1 #*= } "
shift
; ;
--port| -p)
require_option_value " $@ "
out_port = " $2 "
shift 2
; ;
--port= *| -p= *)
out_port = " ${ 1 #*= } "
shift
; ;
--backend)
require_option_value " $@ "
out_backend = " $2 "
shift 2
; ;
--backend= *)
out_backend = " ${ 1 #*= } "
shift
; ;
--anyllm-provider)
require_option_value " $@ "
out_anyllm = " $2 "
shift 2
; ;
--anyllm-provider= *)
out_anyllm = " ${ 1 #*= } "
shift
; ;
--region)
require_option_value " $@ "
out_region = " $2 "
shift 2
; ;
--region= *)
out_region = " ${ 1 #*= } "
shift
; ;
--mode)
require_option_value " $@ "
out_mode = " $2 "
shift 2
; ;
--mode= *)
out_mode = " ${ 1 #*= } "
shift
; ;
--memory)
out_memory = 1
shift
; ;
--no-telemetry)
out_telemetry = 0
shift
; ;
--image)
require_option_value " $@ "
out_image = " $2 "
shift 2
; ;
--image= *)
out_image = " ${ 1 #*= } "
shift
; ;
--help| -?)
print_install_apply_help
exit 0
; ;
*)
die " Unsupported option for 'headroom install apply': $1 "
; ;
esac
done
validate_port " ${ out_port } "
}
parse_install_profile_arg( ) {
local -n out_profile = $1
shift
out_profile = "default"
while ( ( $# ) ) ; do
case " $1 " in
--profile)
require_option_value " $@ "
out_profile = " $2 "
shift 2
; ;
--profile= *)
out_profile = " ${ 1 #*= } "
shift
; ;
--help| -?)
print_install_help
exit 0
; ;
*)
die " Unsupported option for 'headroom install': $1 "
; ;
esac
done
}
2026-04-10 23:27:24 -05:00
parse_wrap_args( ) {
local -n out_known = $1
local -n out_host = $2
local -n out_port = $3
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
local -n out_no_proxy = $4
local -n out_learn = $5
local -n out_backend = $6
local -n out_anyllm = $7
local -n out_region = $8
shift 8
2026-04-10 23:27:24 -05:00
out_known = ( )
out_host = ( )
out_port = 8787
out_no_proxy = 0
out_learn = 0
out_backend = ""
out_anyllm = ""
out_region = ""
while ( ( $# ) ) ; do
case " $1 " in
--)
shift
out_host += ( " $@ " )
break
; ;
--port| -p)
2026-04-11 15:56:18 -05:00
require_option_value " $@ "
2026-04-10 23:27:24 -05:00
out_port = " $2 "
2026-04-11 15:56:18 -05:00
validate_port " ${ out_port } "
2026-04-10 23:27:24 -05:00
out_known += ( " $1 " " $2 " )
shift 2
; ;
--port= *)
out_port = " ${ 1 #*= } "
2026-04-11 15:56:18 -05:00
validate_port " ${ out_port } "
2026-04-10 23:27:24 -05:00
out_known += ( " $1 " )
shift
; ;
--no-proxy)
out_no_proxy = 1
out_known += ( " $1 " )
shift
; ;
--learn)
out_learn = 1
out_known += ( " $1 " )
shift
; ;
--verbose| -v)
out_known += ( " $1 " )
shift
; ;
--backend)
2026-04-11 15:56:18 -05:00
require_option_value " $@ "
2026-04-10 23:27:24 -05:00
out_backend = " $2 "
out_known += ( " $1 " " $2 " )
shift 2
; ;
--backend= *)
out_backend = " ${ 1 #*= } "
out_known += ( " $1 " )
shift
; ;
--anyllm-provider)
2026-04-11 15:56:18 -05:00
require_option_value " $@ "
2026-04-10 23:27:24 -05:00
out_anyllm = " $2 "
out_known += ( " $1 " " $2 " )
shift 2
; ;
--anyllm-provider= *)
out_anyllm = " ${ 1 #*= } "
out_known += ( " $1 " )
shift
; ;
--region)
2026-04-11 15:56:18 -05:00
require_option_value " $@ "
2026-04-10 23:27:24 -05:00
out_region = " $2 "
out_known += ( " $1 " " $2 " )
shift 2
; ;
--region= *)
out_region = " ${ 1 #*= } "
out_known += ( " $1 " )
shift
; ;
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| --no-context-tool| --context-tool= *)
# Retired CLI context tools (rtk, lean-ctx). Reject explicitly: the
# catch-all 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 by the wrapped CLI.
die " CLI context tools (rtk, lean-ctx) have been removed from Headroom. Drop $1 and unset HEADROOM_CONTEXT_TOOL; 'headroom wrap' uninstalls what they left behind on first run. "
; ;
2026-04-10 23:27:24 -05:00
*)
out_host += ( " $@ " )
break
; ;
esac
done
}
run_prepare_only( ) {
local tool = " $1 "
shift
local args = ( )
args = ( docker run --rm)
append_tty_args args
append_common_container_args args
args += ( --entrypoint headroom " ${ HEADROOM_IMAGE } " wrap " ${ tool } " --prepare-only " $@ " )
" ${ args [@] } "
}
run_host_tool( ) {
local binary = " $1 "
shift
command -v " ${ binary } " >/dev/null 2>& 1 || die " ' ${ binary } ' not found in PATH "
" ${ binary } " " $@ "
}
2026-04-11 00:04:15 -05:00
contains_help_flag( ) {
local arg
for arg in " $@ " ; do
2026-04-11 00:40:16 -05:00
if [ [ " ${ arg } " = = "--" ] ] ; then
break
fi
2026-04-11 00:04:15 -05:00
if [ [ " ${ arg } " = = "--help" || " ${ arg } " = = "-?" ] ] ; then
return 0
fi
done
return 1
}
parse_openclaw_wrap_args( ) {
local -n out_plugin_path = $1
local -n out_plugin_spec = $2
local -n out_skip_build = $3
local -n out_copy = $4
local -n out_proxy_port = $5
local -n out_startup_timeout_ms = $6
local -n out_gateway_provider_ids = $7
local -n out_python_path = $8
local -n out_no_auto_start = $9
local -n out_no_restart = ${ 10 }
local -n out_verbose = ${ 11 }
shift 11
out_plugin_path = ""
out_plugin_spec = "headroom-ai/openclaw"
out_skip_build = 0
out_copy = 0
out_proxy_port = 8787
out_startup_timeout_ms = 20000
out_gateway_provider_ids = ( )
out_python_path = ""
out_no_auto_start = 0
out_no_restart = 0
out_verbose = 0
while ( ( $# ) ) ; do
case " $1 " in
--plugin-path)
2026-04-11 15:56:18 -05:00
require_option_value " $@ "
2026-04-11 00:04:15 -05:00
out_plugin_path = " $2 "
shift 2
; ;
--plugin-path= *)
out_plugin_path = " ${ 1 #*= } "
shift
; ;
--plugin-spec)
2026-04-11 15:56:18 -05:00
require_option_value " $@ "
2026-04-11 00:04:15 -05:00
out_plugin_spec = " $2 "
shift 2
; ;
--plugin-spec= *)
out_plugin_spec = " ${ 1 #*= } "
shift
; ;
--skip-build)
out_skip_build = 1
shift
; ;
--copy)
out_copy = 1
shift
; ;
--proxy-port)
2026-04-11 15:56:18 -05:00
require_option_value " $@ "
2026-04-11 00:04:15 -05:00
out_proxy_port = " $2 "
2026-04-11 15:56:18 -05:00
validate_port " ${ out_proxy_port } "
2026-04-11 00:04:15 -05:00
shift 2
; ;
--proxy-port= *)
out_proxy_port = " ${ 1 #*= } "
2026-04-11 15:56:18 -05:00
validate_port " ${ out_proxy_port } "
2026-04-11 00:04:15 -05:00
shift
; ;
--startup-timeout-ms)
2026-04-11 15:56:18 -05:00
require_option_value " $@ "
2026-04-11 00:04:15 -05:00
out_startup_timeout_ms = " $2 "
2026-04-11 15:56:18 -05:00
validate_positive_integer " ${ out_startup_timeout_ms } "
2026-04-11 00:04:15 -05:00
shift 2
; ;
--startup-timeout-ms= *)
out_startup_timeout_ms = " ${ 1 #*= } "
2026-04-11 15:56:18 -05:00
validate_positive_integer " ${ out_startup_timeout_ms } "
2026-04-11 00:04:15 -05:00
shift
; ;
--gateway-provider-id)
2026-04-11 15:56:18 -05:00
require_option_value " $@ "
2026-04-11 00:04:15 -05:00
out_gateway_provider_ids += ( " $2 " )
shift 2
; ;
--gateway-provider-id= *)
out_gateway_provider_ids += ( " ${ 1 #*= } " )
shift
; ;
--python-path)
2026-04-11 15:56:18 -05:00
require_option_value " $@ "
2026-04-11 00:04:15 -05:00
out_python_path = " $2 "
shift 2
; ;
--python-path= *)
out_python_path = " ${ 1 #*= } "
shift
; ;
--no-auto-start)
out_no_auto_start = 1
shift
; ;
--no-restart)
out_no_restart = 1
shift
; ;
--verbose| -v)
out_verbose = 1
shift
; ;
*)
die " Unsupported option for 'headroom wrap openclaw': $1 "
; ;
esac
done
}
parse_openclaw_unwrap_args( ) {
local -n out_no_restart = $1
local -n out_verbose = $2
shift 2
out_no_restart = 0
out_verbose = 0
while ( ( $# ) ) ; do
case " $1 " in
--no-restart)
out_no_restart = 1
shift
; ;
--verbose| -v)
out_verbose = 1
shift
; ;
*)
die " Unsupported option for 'headroom unwrap openclaw': $1 "
; ;
esac
done
}
get_openclaw_existing_entry_json( ) {
local output = ""
if output = " $( openclaw config get plugins.entries.headroom 2>/dev/null) " ; then
printf '%s' " ${ output } "
fi
}
prepare_openclaw_entry_json( ) {
local existing_entry_json = " $1 "
local proxy_port = " $2 "
local startup_timeout_ms = " $3 "
local python_path = " $4 "
local no_auto_start = " $5 "
shift 5
local gateway_provider_ids = ( " $@ " )
local args = ( )
args = ( docker run --rm)
append_common_container_args args
args += ( --entrypoint headroom " ${ HEADROOM_IMAGE } " wrap openclaw --prepare-only)
args += ( --proxy-port " ${ proxy_port } " --startup-timeout-ms " ${ startup_timeout_ms } " )
if [ [ -n " ${ existing_entry_json } " ] ] ; then
args += ( --existing-entry-json " ${ existing_entry_json } " )
fi
if [ [ -n " ${ python_path } " ] ] ; then
args += ( --python-path " ${ python_path } " )
fi
if [ [ " ${ no_auto_start } " -eq 1 ] ] ; then
args += ( --no-auto-start)
fi
local provider_id
for provider_id in " ${ gateway_provider_ids [@] } " ; do
args += ( --gateway-provider-id " ${ provider_id } " )
done
" ${ args [@] } "
}
prepare_openclaw_unwrap_entry_json( ) {
local existing_entry_json = " $1 "
local args = ( )
args = ( docker run --rm)
append_common_container_args args
args += ( --entrypoint headroom " ${ HEADROOM_IMAGE } " unwrap openclaw --prepare-only)
if [ [ -n " ${ existing_entry_json } " ] ] ; then
args += ( --existing-entry-json " ${ existing_entry_json } " )
fi
" ${ args [@] } "
}
run_openclaw_checked( ) {
local action = " $1 "
shift
local output = ""
if ! output = " $( " $@ " 2>& 1) " ; then
output = " ${ output // $'\r' / } "
die " ${ action } failed: ${ output :- unknown error } "
fi
printf '%s' " ${ output // $'\r' / } "
}
run_openclaw_checked_in_dir( ) {
local action = " $1 "
local cwd = " $2 "
shift 2
local output = ""
if ! output = " $( cd " ${ cwd } " && " $@ " 2>& 1) " ; then
output = " ${ output // $'\r' / } "
die " ${ action } failed: ${ output :- unknown error } "
fi
printf '%s' " ${ output // $'\r' / } "
}
resolve_openclaw_extensions_dir( ) {
local config_output
config_output = " $( run_openclaw_checked "openclaw config file" openclaw config file) "
local config_path
config_path = " $( printf '%s\n' " ${ config_output } " | tail -n 1) "
[ [ -n " ${ config_path } " ] ] || die "Unable to resolve OpenClaw config path."
printf '%s\n' " $( dirname " ${ config_path } " ) /extensions "
}
copy_openclaw_plugin_into_extensions( ) {
local plugin_dir = " $1 "
local dist_dir = " ${ plugin_dir } /dist "
local hook_shim_dir = " ${ plugin_dir } /hook-shim "
[ [ -d " ${ dist_dir } " ] ] || die " Plugin dist folder missing at ${ dist_dir } . Build the plugin first. "
[ [ -d " ${ hook_shim_dir } " ] ] || die " Plugin hook-shim folder missing at ${ hook_shim_dir } . Build the plugin first. "
local extensions_dir
extensions_dir = " $( resolve_openclaw_extensions_dir) "
local target_dir = " ${ extensions_dir } /headroom "
mkdir -p " ${ target_dir } "
rm -rf " ${ target_dir } /dist " " ${ target_dir } /hook-shim "
cp -R " ${ dist_dir } " " ${ target_dir } /dist "
cp -R " ${ hook_shim_dir } " " ${ target_dir } /hook-shim "
local filename
for filename in openclaw.plugin.json package.json README.md; do
if [ [ -f " ${ plugin_dir } / ${ filename } " ] ] ; then
cp " ${ plugin_dir } / ${ filename } " " ${ target_dir } / ${ filename } "
fi
done
printf '%s\n' " ${ target_dir } "
}
install_openclaw_plugin( ) {
local plugin_path = " $1 "
local plugin_spec = " $2 "
local skip_build = " $3 "
local copy_mode = " $4 "
local verbose = " $5 "
local local_source_mode = 0
if [ [ -n " ${ plugin_path } " ] ] ; then
local_source_mode = 1
[ [ -d " ${ plugin_path } " ] ] || die " Plugin path not found: ${ plugin_path } . "
[ [ -f " ${ plugin_path } /package.json " ] ] || die " Invalid plugin path (missing package.json): ${ plugin_path } "
[ [ -f " ${ plugin_path } /openclaw.plugin.json " ] ] || die " Invalid plugin path (missing openclaw.plugin.json): ${ plugin_path } "
fi
if [ [ " ${ local_source_mode } " -eq 1 && " ${ skip_build } " -eq 0 ] ] ; then
require_cmd npm
info "Building OpenClaw plugin (npm install + npm run build)..."
run_openclaw_checked_in_dir "npm install" " ${ plugin_path } " npm install >/dev/null
run_openclaw_checked_in_dir "npm run build" " ${ plugin_path } " npm run build >/dev/null
fi
local install_output = ""
local install_status = 0
set +e
if [ [ " ${ local_source_mode } " -eq 1 ] ] ; then
if [ [ " ${ copy_mode } " -eq 1 ] ] ; then
install_output = " $( openclaw plugins install --dangerously-force-unsafe-install " ${ plugin_path } " 2>& 1) "
install_status = $?
else
install_output = " $( cd " ${ plugin_path } " && openclaw plugins install --dangerously-force-unsafe-install --link . 2>& 1) "
install_status = $?
fi
else
install_output = " $( openclaw plugins install --dangerously-force-unsafe-install " ${ plugin_spec } " 2>& 1) "
install_status = $?
fi
set -e
install_output = " ${ install_output // $'\r' / } "
if [ [ " ${ install_status } " -eq 0 ] ] ; then
if [ [ " ${ verbose } " -eq 1 && -n " ${ install_output } " ] ] ; then
printf '%s\n' " ${ install_output } "
fi
return
fi
local lower_output = " ${ install_output ,, } "
if [ [ " ${ lower_output } " = = *"plugin already exists" * ] ] ; then
info "Plugin already installed; continuing with configuration/update steps."
return
fi
if [ [ " ${ lower_output } " = = *"also not a valid hook pack" * && " ${ local_source_mode } " -eq 1 && " ${ copy_mode } " -eq 0 ] ] ; then
info "OpenClaw linked-path install bug detected; applying extension-path fallback..."
local target_dir
target_dir = " $( copy_openclaw_plugin_into_extensions " ${ plugin_path } " ) "
info " Fallback plugin copy completed: ${ target_dir } "
return
fi
die " openclaw plugins install failed: ${ install_output :- exit code ${ install_status } } "
}
restart_or_start_openclaw_gateway( ) {
local output = ""
if output = " $( openclaw gateway restart 2>& 1) " ; then
OPENCLAW_GATEWAY_ACTION = "restarted"
OPENCLAW_GATEWAY_OUTPUT = " ${ output // $'\r' / } "
return
fi
OPENCLAW_GATEWAY_OUTPUT = " $( run_openclaw_checked "openclaw gateway start" openclaw gateway start) "
OPENCLAW_GATEWAY_ACTION = "started"
}
wrap_openclaw_host( ) {
local plugin_path plugin_spec skip_build copy_mode proxy_port startup_timeout_ms python_path
local no_auto_start no_restart verbose
local gateway_provider_ids = ( )
parse_openclaw_wrap_args \
plugin_path \
plugin_spec \
skip_build \
copy_mode \
proxy_port \
startup_timeout_ms \
gateway_provider_ids \
python_path \
no_auto_start \
no_restart \
verbose \
" $@ "
require_cmd openclaw
local existing_entry_json = ""
existing_entry_json = " $( get_openclaw_existing_entry_json) "
local entry_json
entry_json = " $( prepare_openclaw_entry_json " ${ existing_entry_json } " " ${ proxy_port } " " ${ startup_timeout_ms } " " ${ python_path } " " ${ no_auto_start } " " ${ gateway_provider_ids [@] } " ) "
printf '\n ╔═══════════════════════════════════════════════╗\n'
printf ' ║ HEADROOM WRAP: OPENCLAW ║\n'
printf ' ╚═══════════════════════════════════════════════╝\n\n'
if [ [ -n " ${ plugin_path } " ] ] ; then
printf ' Plugin source: local (%s)\n' " ${ plugin_path } "
else
printf ' Plugin source: npm (%s)\n' " ${ plugin_spec } "
fi
printf ' Writing plugin configuration...\n'
run_openclaw_checked \
"openclaw config set plugins.entries.headroom" \
openclaw config set plugins.entries.headroom " ${ entry_json } " --strict-json >/dev/null
printf ' Installing OpenClaw plugin with required unsafe-install flag...\n'
install_openclaw_plugin " ${ plugin_path } " " ${ plugin_spec } " " ${ skip_build } " " ${ copy_mode } " " ${ verbose } "
run_openclaw_checked \
"openclaw config set plugins.slots.contextEngine" \
openclaw config set plugins.slots.contextEngine '"headroom"' --strict-json >/dev/null
run_openclaw_checked "openclaw config validate" openclaw config validate >/dev/null
if [ [ " ${ no_restart } " -eq 1 ] ] ; then
printf ' Skipping gateway restart (--no-restart).\n'
printf ' Run `openclaw gateway restart` (or `openclaw gateway start`) to apply plugin changes.\n'
else
printf ' Applying plugin changes to OpenClaw gateway...\n'
restart_or_start_openclaw_gateway
printf ' Gateway %s.\n' " ${ OPENCLAW_GATEWAY_ACTION } "
if [ [ " ${ verbose } " -eq 1 && -n " ${ OPENCLAW_GATEWAY_OUTPUT } " ] ] ; then
printf '%s\n' " ${ OPENCLAW_GATEWAY_OUTPUT } "
fi
fi
local inspect_output = ""
inspect_output = " $( run_openclaw_checked "openclaw plugins inspect headroom" openclaw plugins inspect headroom) "
if [ [ " ${ verbose } " -eq 1 && -n " ${ inspect_output } " ] ] ; then
printf '%s\n' " ${ inspect_output } "
fi
printf '\n✓ OpenClaw is configured to use Headroom context compression.\n'
printf ' Plugin: headroom\n'
printf ' Slot: plugins.slots.contextEngine = headroom\n\n'
}
unwrap_openclaw_host( ) {
local no_restart verbose
parse_openclaw_unwrap_args no_restart verbose " $@ "
require_cmd openclaw
local existing_entry_json = ""
existing_entry_json = " $( get_openclaw_existing_entry_json) "
local entry_json
entry_json = " $( prepare_openclaw_unwrap_entry_json " ${ existing_entry_json } " ) "
printf '\n ╔═══════════════════════════════════════════════╗\n'
printf ' ║ HEADROOM UNWRAP: OPENCLAW ║\n'
printf ' ╚═══════════════════════════════════════════════╝\n\n'
printf ' Disabling Headroom plugin and removing engine mapping...\n'
run_openclaw_checked \
"openclaw config set plugins.entries.headroom" \
openclaw config set plugins.entries.headroom " ${ entry_json } " --strict-json >/dev/null
run_openclaw_checked \
"openclaw config set plugins.slots.contextEngine" \
openclaw config set plugins.slots.contextEngine '"legacy"' --strict-json >/dev/null
run_openclaw_checked "openclaw config validate" openclaw config validate >/dev/null
if [ [ " ${ no_restart } " -eq 1 ] ] ; then
printf ' Skipping gateway restart (--no-restart).\n'
printf ' Run `openclaw gateway restart` (or `openclaw gateway start`) to apply unwrap changes.\n'
else
printf ' Applying unwrap changes to OpenClaw gateway...\n'
restart_or_start_openclaw_gateway
printf ' Gateway %s.\n' " ${ OPENCLAW_GATEWAY_ACTION } "
if [ [ " ${ verbose } " -eq 1 && -n " ${ OPENCLAW_GATEWAY_OUTPUT } " ] ] ; then
printf '%s\n' " ${ OPENCLAW_GATEWAY_OUTPUT } "
fi
fi
if [ [ " ${ verbose } " -eq 1 ] ] ; then
local inspect_output = ""
inspect_output = " $( run_openclaw_checked "openclaw plugins inspect headroom" openclaw plugins inspect headroom) "
if [ [ -n " ${ inspect_output } " ] ] ; then
printf '%s\n' " ${ inspect_output } "
fi
fi
printf '\n✓ OpenClaw Headroom wrap removed.\n'
printf ' Plugin: headroom (installed, disabled)\n'
printf ' Slot: plugins.slots.contextEngine = legacy\n\n'
}
2026-04-10 23:27:24 -05:00
main( ) {
require_cmd docker
if ( ( $# = = 0) ) ; then
run_headroom --help
return
fi
case " $1 " in
2026-04-11 15:56:18 -05:00
install)
if ( ( $# = = 1) ) || [ [ " $2 " = = "--help" || " $2 " = = "-?" ] ] ; then
print_install_help
return
fi
local install_command = " $2 "
shift 2
case " ${ install_command } " in
apply)
local profile port backend anyllm region proxy_mode memory_enabled telemetry_enabled image
parse_install_apply_args profile port backend anyllm region proxy_mode memory_enabled telemetry_enabled image " $@ "
start_persistent_docker_install " ${ profile } " " ${ image } " " ${ port } " " ${ backend } " " ${ anyllm } " " ${ region } " " ${ proxy_mode } " " ${ memory_enabled } " " ${ telemetry_enabled } "
printf "Installed docker-native persistent deployment '%s' on port %s.\n" " ${ profile } " " ${ port } "
; ;
status)
local profile
parse_install_profile_arg profile " $@ "
status_persistent_docker_install " ${ profile } "
; ;
start)
local profile
parse_install_profile_arg profile " $@ "
load_persistent_state " ${ profile } "
start_persistent_docker_install " ${ PROFILE } " " ${ IMAGE } " " ${ PORT } " " ${ BACKEND } " " ${ ANYLLM_PROVIDER } " " ${ REGION } " " ${ PROXY_MODE } " " ${ MEMORY_ENABLED } " " ${ TELEMETRY_ENABLED } "
printf "Started docker-native persistent deployment '%s'.\n" " ${ profile } "
; ;
stop)
local profile
parse_install_profile_arg profile " $@ "
stop_persistent_docker_install " ${ profile } "
printf "Stopped docker-native persistent deployment '%s'.\n" " ${ profile } "
; ;
restart)
local profile
parse_install_profile_arg profile " $@ "
load_persistent_state " ${ profile } "
start_persistent_docker_install " ${ PROFILE } " " ${ IMAGE } " " ${ PORT } " " ${ BACKEND } " " ${ ANYLLM_PROVIDER } " " ${ REGION } " " ${ PROXY_MODE } " " ${ MEMORY_ENABLED } " " ${ TELEMETRY_ENABLED } "
printf "Restarted docker-native persistent deployment '%s'.\n" " ${ profile } "
; ;
remove)
local profile
parse_install_profile_arg profile " $@ "
remove_persistent_docker_install " ${ profile } "
printf "Removed docker-native persistent deployment '%s'.\n" " ${ profile } "
; ;
*)
die " Unsupported install target: ${ install_command } "
; ;
esac
; ;
2026-04-10 23:27:24 -05:00
wrap)
2026-04-11 00:40:16 -05:00
if ( ( $# = = 1) ) || [ [ " $2 " = = "--help" || " $2 " = = "-?" ] ] ; then
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
print_wrap_help
2026-04-11 00:40:16 -05:00
return
fi
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
( ( $# >= 2) ) || die "Usage: headroom wrap <claude|codex|aider|cursor|openclaw|opencode> [...]"
2026-04-10 23:27:24 -05:00
local tool = " $2 "
shift 2
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
case " ${ tool } " in
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
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
; ;
*)
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
die " 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
; ;
esac
2026-04-11 00:04:15 -05:00
if [ [ " ${ tool } " = = "openclaw" ] ] ; then
if contains_help_flag " $@ " ; then
run_headroom wrap openclaw " $@ "
return
fi
wrap_openclaw_host " $@ "
return
fi
2026-04-11 00:40:16 -05:00
if contains_help_flag " $@ " ; then
run_headroom wrap " ${ tool } " " $@ "
return
fi
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
local known_args host_args port no_proxy learn backend anyllm region
parse_wrap_args known_args host_args port no_proxy learn backend anyllm region " $@ "
2026-04-10 23:27:24 -05:00
local proxy_args = ( )
if [ [ " ${ learn } " -eq 1 ] ] ; then
proxy_args += ( --learn)
fi
if [ [ -n " ${ backend } " ] ] ; then
proxy_args += ( --backend " ${ backend } " )
fi
if [ [ -n " ${ anyllm } " ] ] ; then
proxy_args += ( --anyllm-provider " ${ anyllm } " )
fi
if [ [ -n " ${ region } " ] ] ; then
proxy_args += ( --region " ${ region } " )
fi
local container_name = ""
if [ [ " ${ no_proxy } " -eq 0 ] ] ; then
container_name = " $( start_proxy_container " ${ port } " " ${ proxy_args [@] } " ) "
fi
trap 'stop_proxy_container "${container_name}"' EXIT INT TERM
local prep_args = ( " ${ known_args [@] } " )
if [ [ " ${ no_proxy } " -eq 0 ] ] ; then
prep_args += ( --no-proxy)
fi
run_prepare_only " ${ tool } " " ${ prep_args [@] } "
case " ${ tool } " in
claude)
ANTHROPIC_BASE_URL = " http://127.0.0.1: ${ port } " run_host_tool claude " ${ host_args [@] } "
; ;
codex)
OPENAI_BASE_URL = " http://127.0.0.1: ${ port } /v1 " run_host_tool codex " ${ host_args [@] } "
; ;
aider)
OPENAI_API_BASE = " http://127.0.0.1: ${ port } /v1 " \
ANTHROPIC_BASE_URL = " http://127.0.0.1: ${ port } " \
run_host_tool aider " ${ host_args [@] } "
; ;
cursor)
cat <<EOF
Headroom proxy is running for Cursor.
OpenAI base URL: http://127.0.0.1:${ port } /v1
Anthropic base URL: http://127.0.0.1:${ port }
Press Ctrl+C to stop the proxy.
EOF
while true; do
sleep 1
done
; ;
esac
; ;
unwrap)
2026-04-11 00:40:16 -05:00
if ( ( $# = = 1) ) || [ [ " $2 " = = "--help" || " $2 " = = "-?" ] ] ; then
run_headroom unwrap --help
return
fi
2026-04-10 23:27:24 -05:00
if ( ( $# >= 2) ) && [ [ " $2 " = = "openclaw" ] ] ; then
2026-04-11 00:04:15 -05:00
shift 2
if contains_help_flag " $@ " ; then
run_headroom unwrap openclaw " $@ "
return
fi
unwrap_openclaw_host " $@ "
return
2026-04-10 23:27:24 -05:00
fi
run_headroom " $@ "
; ;
proxy)
shift
local port = 8787
local args = ( )
args = ( proxy)
while ( ( $# ) ) ; do
case " $1 " in
--port| -p)
2026-04-11 15:56:18 -05:00
require_option_value " $@ "
2026-04-10 23:27:24 -05:00
port = " $2 "
2026-04-11 15:56:18 -05:00
validate_port " ${ port } "
2026-04-10 23:27:24 -05:00
args += ( " $1 " " $2 " )
shift 2
; ;
--port= *)
port = " ${ 1 #*= } "
2026-04-11 15:56:18 -05:00
validate_port " ${ port } "
2026-04-10 23:27:24 -05:00
args += ( " $1 " )
shift
; ;
*)
args += ( " $1 " )
shift
; ;
esac
done
local run_args = ( )
run_args = ( docker run --rm)
append_tty_args run_args
append_common_container_args run_args
run_args += ( -p " ${ port } : ${ port } " )
run_args += ( --entrypoint headroom " ${ HEADROOM_IMAGE } " " ${ args [@] } " )
" ${ run_args [@] } "
; ;
*)
run_headroom " $@ "
; ;
esac
}
main " $@ "
WRAPPER
2026-04-11 15:56:18 -05:00
} >" ${ wrapper_path } "
2026-04-10 23:27:24 -05:00
chmod +x " ${ wrapper_path } "
}
main( ) {
require_cmd docker
docker version >/dev/null 2>& 1 || die "Docker is installed but not available to the current user"
mkdir -p " ${ INSTALL_DIR } "
write_wrapper
append_path_block " ${ HOME } /.bashrc "
append_path_block " ${ HOME } /.zshrc "
append_path_block " ${ HOME } /.profile "
2026-04-11 15:56:18 -05:00
if [ [ -n " ${ HEADROOM_DOCKER_IMAGE :- } " ] ] ; then
if docker image inspect " ${ INSTALL_IMAGE } " >/dev/null 2>& 1; then
info " Using existing HEADROOM_DOCKER_IMAGE= ${ INSTALL_IMAGE } "
else
info " Pulling ${ INSTALL_IMAGE } "
docker pull " ${ INSTALL_IMAGE } " >/dev/null
fi
else
info " Pulling ${ IMAGE_DEFAULT } "
docker pull " ${ IMAGE_DEFAULT } " >/dev/null
fi
2026-04-10 23:27:24 -05:00
cat <<EOF
Headroom Docker-native install complete.
Installed wrapper:
${ INSTALL_DIR } /headroom
Next steps:
1. Restart your shell or run: export PATH = " ${ INSTALL_DIR } :\$PATH "
2. Try: headroom proxy
3. Docs: https://github.com/chopratejas/headroom/blob/main/docs/docker-install.md
EOF
}
main " $@ "