headroom/scripts/install.sh

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

1615 lines
45 KiB
Bash
Raw Permalink Normal View History

#!/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"
INSTALL_IMAGE="${HEADROOM_DOCKER_IMAGE:-${IMAGE_DEFAULT}}"
INSTALL_DIR="${HOME}/.local/bin"
if [[ ! -d "${HOME}/.local" ]]; then
INSTALL_DIR="${HOME}/bin"
fi
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
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"
{
printf '#!%s\n\n' "${BASH_PATH}"
printf 'HEADROOM_IMAGE_DEFAULT=%q\n' "${INSTALL_IMAGE}"
cat <<'WRAPPER'
set -euo pipefail
HEADROOM_IMAGE="${HEADROOM_DOCKER_IMAGE:-${HEADROOM_IMAGE_DEFAULT}}"
HEADROOM_CONTAINER_HOME="${HEADROOM_CONTAINER_HOME:-/tmp/headroom-home}"
HEADROOM_HOST_HOME="${HOME:?}"
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
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")
# 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")
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
append_passthrough_envs "$1"
}
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
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
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
}
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")
# 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")
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"
}
fix(install): trust Docker bridge for dashboard metadata ## Summary Closes #2909. The `persistent-docker` installer now discovers Docker's default bridge gateway and passes the exact `/32` gateway CIDR to the proxy's dashboard metadata allowlist when no explicit `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS` value is configured. This keeps the existing metadata gate intact while allowing the first-party loopback-published container to see its own Recent Requests and Per-Project Savings data. Explicit user configuration continues to take precedence. Both native wrappers (POSIX and PowerShell) use the same behavior, and installer integration coverage verifies the generated Docker command. ## Validation - `python -m pytest tests/test_install/test_native_installers.py -q -k bash` (1 skipped on Windows because Bash is unavailable) - PowerShell wrapper smoke test with the repository fake Docker shim: verified `docker network inspect bridge` is called and `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=172.17.0.1/32` is passed to `docker run` - Explicit allowlist smoke test: verified an existing `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS` is preserved without adding a discovered default - `git diff --check` ## Real behavior proof Setup tested: Windows 11 host, PowerShell wrapper, repository fake Docker shim (Docker CLI is not installed in this environment). Exact command: `headroom.ps1 install apply --profile smoke --port 18999 --image fake/headroom:test`. Observed result: the generated Docker invocation included `docker network inspect bridge --format ...` and `--env HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=172.17.0.1/32`, and the installer completed successfully. Not tested: a live Docker daemon/dashboard request on this host. --------- Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
2026-08-12 00:25:29 +03:00
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
}
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
fix(install): trust Docker bridge for dashboard metadata ## Summary Closes #2909. The `persistent-docker` installer now discovers Docker's default bridge gateway and passes the exact `/32` gateway CIDR to the proxy's dashboard metadata allowlist when no explicit `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS` value is configured. This keeps the existing metadata gate intact while allowing the first-party loopback-published container to see its own Recent Requests and Per-Project Savings data. Explicit user configuration continues to take precedence. Both native wrappers (POSIX and PowerShell) use the same behavior, and installer integration coverage verifies the generated Docker command. ## Validation - `python -m pytest tests/test_install/test_native_installers.py -q -k bash` (1 skipped on Windows because Bash is unavailable) - PowerShell wrapper smoke test with the repository fake Docker shim: verified `docker network inspect bridge` is called and `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=172.17.0.1/32` is passed to `docker run` - Explicit allowlist smoke test: verified an existing `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS` is preserved without adding a discovered default - `git diff --check` ## Real behavior proof Setup tested: Windows 11 host, PowerShell wrapper, repository fake Docker shim (Docker CLI is not installed in this environment). Exact command: `headroom.ps1 install apply --profile smoke --port 18999 --image fake/headroom:test`. Observed result: the generated Docker invocation included `docker network inspect bridge --format ...` and `--env HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=172.17.0.1/32`, and the installer completed successfully. Not tested: a live Docker daemon/dashboard request on this host. --------- Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
2026-08-12 00:25:29 +03:00
args=(docker run -d --restart unless-stopped --name "${container_name}" -p "127.0.0.1:${port}:${port}")
append_persistent_container_args args
fix(install): trust Docker bridge for dashboard metadata ## Summary Closes #2909. The `persistent-docker` installer now discovers Docker's default bridge gateway and passes the exact `/32` gateway CIDR to the proxy's dashboard metadata allowlist when no explicit `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS` value is configured. This keeps the existing metadata gate intact while allowing the first-party loopback-published container to see its own Recent Requests and Per-Project Savings data. Explicit user configuration continues to take precedence. Both native wrappers (POSIX and PowerShell) use the same behavior, and installer integration coverage verifies the generated Docker command. ## Validation - `python -m pytest tests/test_install/test_native_installers.py -q -k bash` (1 skipped on Windows because Bash is unavailable) - PowerShell wrapper smoke test with the repository fake Docker shim: verified `docker network inspect bridge` is called and `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=172.17.0.1/32` is passed to `docker run` - Explicit allowlist smoke test: verified an existing `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS` is preserved without adding a discovered default - `git diff --check` ## Real behavior proof Setup tested: Windows 11 host, PowerShell wrapper, repository fake Docker shim (Docker CLI is not installed in this environment). Exact command: `headroom.ps1 install apply --profile smoke --port 18999 --image fake/headroom:test`. Observed result: the generated Docker invocation included `docker network inspect bridge --format ...` and `--env HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=172.17.0.1/32`, and the installer completed successfully. Not tested: a live Docker daemon/dashboard request on this host. --------- Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
2026-08-12 00:25:29 +03:00
append_dashboard_gateway_env args
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"
)
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]
-?, --help Show this message and exit.
EOF
}
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
}
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
}
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
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)
require_option_value "$@"
out_port="$2"
validate_port "${out_port}"
out_known+=("$1" "$2")
shift 2
;;
--port=*)
out_port="${1#*=}"
validate_port "${out_port}"
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)
require_option_value "$@"
out_backend="$2"
out_known+=("$1" "$2")
shift 2
;;
--backend=*)
out_backend="${1#*=}"
out_known+=("$1")
shift
;;
--anyllm-provider)
require_option_value "$@"
out_anyllm="$2"
out_known+=("$1" "$2")
shift 2
;;
--anyllm-provider=*)
out_anyllm="${1#*=}"
out_known+=("$1")
shift
;;
--region)
require_option_value "$@"
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."
;;
*)
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}" "$@"
}
contains_help_flag() {
local arg
for arg in "$@"; do
if [[ "${arg}" == "--" ]]; then
break
fi
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)
require_option_value "$@"
out_plugin_path="$2"
shift 2
;;
--plugin-path=*)
out_plugin_path="${1#*=}"
shift
;;
--plugin-spec)
require_option_value "$@"
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)
require_option_value "$@"
out_proxy_port="$2"
validate_port "${out_proxy_port}"
shift 2
;;
--proxy-port=*)
out_proxy_port="${1#*=}"
validate_port "${out_proxy_port}"
shift
;;
--startup-timeout-ms)
require_option_value "$@"
out_startup_timeout_ms="$2"
validate_positive_integer "${out_startup_timeout_ms}"
shift 2
;;
--startup-timeout-ms=*)
out_startup_timeout_ms="${1#*=}"
validate_positive_integer "${out_startup_timeout_ms}"
shift
;;
--gateway-provider-id)
require_option_value "$@"
out_gateway_provider_ids+=("$2")
shift 2
;;
--gateway-provider-id=*)
out_gateway_provider_ids+=("${1#*=}")
shift
;;
--python-path)
require_option_value "$@"
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'
}
main() {
require_cmd docker
if (($# == 0)); then
run_headroom --help
return
fi
case "$1" in
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
;;
wrap)
if (($# == 1)) || [[ "$2" == "--help" || "$2" == "-?" ]]; then
print_wrap_help
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> [...]"
local tool="$2"
shift 2
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)
;;
*)
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"
;;
esac
if [[ "${tool}" == "openclaw" ]]; then
if contains_help_flag "$@"; then
run_headroom wrap openclaw "$@"
return
fi
wrap_openclaw_host "$@"
return
fi
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 "$@"
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)
if (($# == 1)) || [[ "$2" == "--help" || "$2" == "-?" ]]; then
run_headroom unwrap --help
return
fi
if (($# >= 2)) && [[ "$2" == "openclaw" ]]; then
shift 2
if contains_help_flag "$@"; then
run_headroom unwrap openclaw "$@"
return
fi
unwrap_openclaw_host "$@"
return
fi
run_headroom "$@"
;;
proxy)
shift
local port=8787
local args=()
args=(proxy)
while (($#)); do
case "$1" in
--port|-p)
require_option_value "$@"
port="$2"
validate_port "${port}"
args+=("$1" "$2")
shift 2
;;
--port=*)
port="${1#*=}"
validate_port "${port}"
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
} >"${wrapper_path}"
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"
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
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 "$@"