feat: harden persistent install wrappers

Tighten Docker-native bash and PowerShell wrapper validation for wrap and proxy flows, pin the bash wrapper to the install-time interpreter, clean up failed persistent container starts, and extend docs, CI, e2e, and native installer coverage for persistent Docker installs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
JerrettDavis 2026-04-11 15:56:18 -05:00
parent 21896a095c
commit b325a06aae
9 changed files with 2294 additions and 48 deletions

View file

@ -101,6 +101,66 @@ jobs:
run: |
pytest tests/test_integrations/agno/ -v
docker-native-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Build local Headroom image
run: |
docker build -t headroom-native-e2e:latest .
- name: Run Docker-native installer e2e
env:
HEADROOM_DOCKER_IMAGE: headroom-native-e2e:latest
run: |
bash e2e/docker-native-install.sh
windows-native-wrapper:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install test dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
- name: Run native installer wrapper tests
run: |
pytest tests/test_install/test_native_installers.py -q
macos-native-wrapper:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install bash and test dependencies
run: |
brew install bash
python -m pip install --upgrade pip
pip install pytest
- name: Run native installer wrapper tests
run: |
export PATH="$(brew --prefix bash)/bin:$PATH"
pytest tests/test_install/test_native_installers.py -q
build:
runs-on: ubuntu-latest
steps:

View file

@ -22,7 +22,7 @@ This page is the authoritative reference for the **Python Headroom CLI** exposed
| Command | Purpose | Docker-native parity |
|---|---|---|
| `headroom install ...` | Install and manage persistent deployments | **python-native / compose-managed Docker** |
| `headroom install ...` | Install and manage persistent deployments | **python-native; Docker-native wrapper supports `persistent-docker` lifecycle subset** |
| `headroom proxy` | Run the Headroom proxy server | **native in container** |
| `headroom learn` | Learn from past tool-call failures | **native in container** |
| `headroom perf` | Summarize recent proxy performance | **native in container** |
@ -30,7 +30,7 @@ This page is the authoritative reference for the **Python Headroom CLI** exposed
| `headroom memory ...` | Inspect and manage stored memories | **native in container** |
| `headroom mcp ...` | Install, inspect, remove, or serve MCP integration | **native in container** |
| `headroom wrap claude` | Start proxy and launch Claude Code | **host-bridged** |
| `headroom wrap copilot` | Start proxy and launch GitHub Copilot CLI | **host-bridged** |
| `headroom wrap copilot` | Start proxy and launch GitHub Copilot CLI | **python-native only** |
| `headroom wrap codex` | Start proxy and launch Codex CLI | **host-bridged** |
| `headroom wrap aider` | Start proxy and launch Aider | **host-bridged** |
| `headroom wrap cursor` | Start proxy and print Cursor config guidance | **host-bridged** |
@ -630,6 +630,8 @@ headroom install apply --preset persistent-docker --scope user
`apply` stores a manifest under `~/.headroom/deploy/<profile>/manifest.json`, applies managed tool configuration, starts the chosen runtime, and waits for `readyz`.
Docker-native host wrappers expose a narrower `headroom install` subset for `persistent-docker` only: `apply`, `status`, `start`, `stop`, `restart`, and `remove`. Those wrapper flows preserve the same port and manifest behavior, but they intentionally reject `persistent-service`, `persistent-task`, and provider mutation flags like `--scope`, `--providers`, and `--target`.
### `headroom install status`
```bash
@ -852,9 +854,9 @@ Legend:
| `headroom mcp uninstall` | native | native in container | full |
| `headroom mcp status` | native | native in container | full |
| `headroom mcp serve` | native | native in container | full |
| `headroom install ...` | native | compose-managed persistent Docker path | partial |
| `headroom install apply|status|start|stop|restart|remove` | native | Docker-native wrapper for `persistent-docker`; compose remains an alternative | partial |
| `headroom wrap claude` | native | host-bridged | partial |
| `headroom wrap copilot` | native | host-bridged | partial |
| `headroom wrap copilot` | native | not implemented in Docker-native wrapper | none |
| `headroom wrap codex` | native | host-bridged | partial |
| `headroom wrap aider` | native | host-bridged | partial |
| `headroom wrap cursor` | native | host-bridged | partial |

View file

@ -4,12 +4,20 @@ Run Headroom without installing Python or Node.js on the host. The install scrip
## One-line install
### macOS / Linux
### Linux
```bash
curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.sh | bash
```
### macOS (bash 4.3+)
```bash
curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.sh | "$(brew --prefix bash)/bin/bash"
```
Stock `/bin/bash` on macOS is 3.2, so install a newer bash first (for example via Homebrew) and run the installer with that shell. The installed wrapper pins that same bash interpreter so later invocations stay on the supported runtime.
### Windows PowerShell
```powershell
@ -19,7 +27,7 @@ irm https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.
## What the installer does
1. Verifies Docker is installed and available.
2. Pulls `ghcr.io/chopratejas/headroom:latest`.
2. Pulls `ghcr.io/chopratejas/headroom:latest` by default, or reuses / pulls `HEADROOM_DOCKER_IMAGE` when you set a custom image override.
3. Installs a `headroom` wrapper into `~/.local/bin` or `~/bin`.
4. Updates shell startup files so the wrapper directory is on `PATH`.
@ -81,9 +89,28 @@ OpenClaw remains host-native in Docker-native mode:
- plugin auto-start still launches the installed host `headroom` wrapper from `PATH`, which then runs Headroom in Docker
- local plugin source mode (`--plugin-path`) is also supported, but it may require host `npm` when build steps are needed
## Persistent Docker lifecycle from the native wrapper
The Docker-native `headroom` wrapper now exposes the persistent Docker lifecycle directly:
```bash
headroom install apply --profile default --preset persistent-docker
headroom install status
headroom install restart
headroom install remove
```
In Docker-native mode this surface is intentionally scoped to **persistent-docker**:
- supported: `apply`, `status`, `start`, `stop`, `restart`, `remove`
- supported flags: `--profile`, `--port`, `--backend`, `--anyllm-provider`, `--region`, `--mode`, `--memory`, `--no-telemetry`, `--image`
- not supported: `persistent-service`, `persistent-task`, or provider/user/system mutation flags such as `--scope`, `--providers`, and `--target`
Those broader lifecycle and config-mutation flows still belong to the Python-native `headroom install ...` command.
## Docker Compose support
Use `docker/docker-compose.native.yml` when you want an explicit compose-managed proxy or CLI shell.
Use `docker/docker-compose.native.yml` when you want an explicit compose-managed proxy or CLI shell, or when you prefer compose over the native wrapper's `headroom install ...` surface.
### Persistent Docker runtime
@ -101,7 +128,7 @@ $env:HEADROOM_WORKSPACE = (Get-Location).Path
docker compose -f docker/docker-compose.native.yml up -d proxy
```
This is the recommended persistent-Docker path when you installed Headroom through the Docker-native host wrapper.
This remains a supported persistent-Docker path when you want the proxy managed explicitly through Compose instead of the installed wrapper.
### macOS / Linux
@ -147,3 +174,4 @@ That keeps provider auth and runtime config working without maintaining a separa
- Wrapped tools like Claude Code, Codex CLI, Aider, and Cursor still run on the host when you use `headroom wrap ...`.
- The install scripts are idempotent: rerunning them refreshes the wrapper and image without duplicating shell profile blocks.
- For persistent service and task installs, use the Python-native `headroom install ...` workflow described in [Persistent Installs](persistent-installs.md).
- For Docker-native `headroom install ...`, the wrapper persists its profile manifest under `~/.headroom/deploy/<profile>/`.

View file

@ -2,7 +2,7 @@
Headroom can now be installed as a durable local runtime instead of only being started ad hoc with `headroom proxy` or `headroom wrap ...`.
Use `headroom install` when you want supported tools to keep talking to an always-on proxy at `http://127.0.0.1:8787` and have `wrap` reuse or recover that deployment instead of starting a second ephemeral proxy.
Use the Python-native `headroom install` CLI when you want supported tools to keep talking to an always-on proxy at `http://127.0.0.1:8787` and have `wrap` reuse or recover that deployment instead of starting a second ephemeral proxy.
## Runtime matrix
@ -43,7 +43,7 @@ headroom install apply --preset persistent-docker --scope user --providers auto
This uses Docker's restart policy instead of an OS supervisor.
If you are using the Docker-native host wrapper instead of a Python install, use the compose-managed path documented in [Docker-Native Install](docker-install.md).
If you are using the Docker-native host wrapper instead of a Python install, you can now use `headroom install apply|status|start|stop|restart|remove` for the `persistent-docker` preset directly from the installed wrapper. Service/task installs and provider/user/system mutation flows still belong to the Python-native CLI.
## Command surface
@ -125,14 +125,16 @@ Persistent deployments publish the same `readyz` and `health` endpoints as ad ho
}
```
`headroom wrap ...` checks for a matching persistent deployment on the requested port before it starts a new ephemeral proxy. If an installed deployment exists but is stopped or unhealthy, `wrap` attempts to recover it first.
The Python-native `headroom wrap ...` flow checks for a matching persistent deployment on the requested port before it starts a new ephemeral proxy. If an installed deployment exists but is stopped or unhealthy, it attempts to recover it first.
The Docker-native host wrapper does **not** yet reuse or recover persistent profiles automatically; it still starts a fresh proxy container unless you opt into `--no-proxy`.
## Docker-native relationship
The Docker-native host wrapper and the Python install CLI solve different layers of the runtime story:
- [Docker-Native Install](docker-install.md) -> containerized on-demand CLI and wrapped host-tool flows
- `headroom install ...` -> persistent service, task, and native Docker lifecycle management
- [Docker-Native Install](docker-install.md) -> containerized on-demand CLI, wrapped host-tool flows, and Docker-native `persistent-docker` lifecycle commands
- `headroom install ...` -> full persistent service, task, and Docker lifecycle management, including provider/user/system mutation
For a no-Python persistent Docker workflow, use the compose-managed proxy path from `docker/docker-compose.native.yml`:

81
e2e/docker-native-install.sh Executable file
View file

@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="${HEADROOM_DOCKER_IMAGE:?set HEADROOM_DOCKER_IMAGE to a built test image}"
PROFILE="ci-smoke"
TMP_HOME="$(mktemp -d)"
PORT="$(python3 - <<'PY'
import socket
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
print(sock.getsockname()[1])
PY
)"
cleanup() {
docker rm -f "headroom-${PROFILE}" >/dev/null 2>&1 || true
rm -rf "${TMP_HOME}"
}
trap cleanup EXIT
mkdir -p "${TMP_HOME}/.local"
export HOME="${TMP_HOME}"
export PATH="${HOME}/.local/bin:${PATH}"
bash "${ROOT_DIR}/scripts/install.sh"
WRAPPER="${HOME}/.local/bin/headroom"
[[ -x "${WRAPPER}" ]]
"${WRAPPER}" install -? | grep -Fq "persistent-docker preset only"
"${WRAPPER}" install apply \
--profile "${PROFILE}" \
--port "${PORT}" \
--image "${IMAGE}" \
--no-telemetry
status_output="$("${WRAPPER}" install status --profile "${PROFILE}")"
printf '%s\n' "${status_output}"
grep -Fq "Status: running" <<<"${status_output}"
curl --fail --silent "http://127.0.0.1:${PORT}/readyz" >/dev/null
python3 - <<'PY' "${HOME}" "${PROFILE}" "${PORT}"
import json
import sys
from pathlib import Path
home = Path(sys.argv[1])
profile = sys.argv[2]
port = int(sys.argv[3])
manifest = json.loads((home / ".headroom" / "deploy" / profile / "manifest.json").read_text())
assert manifest["preset"] == "persistent-docker"
assert manifest["port"] == port
assert manifest["telemetry_enabled"] is False
PY
if apply_error="$("${WRAPPER}" install apply --scope user 2>&1)"; then
echo "expected docker-native install apply --scope user to fail" >&2
exit 1
fi
grep -Fq "does not support provider/user/system mutation flags" <<<"${apply_error}"
"${WRAPPER}" install stop --profile "${PROFILE}"
stopped_output="$("${WRAPPER}" install status --profile "${PROFILE}")"
printf '%s\n' "${stopped_output}"
grep -Fq "Status: stopped" <<<"${stopped_output}"
"${WRAPPER}" install start --profile "${PROFILE}"
started_output="$("${WRAPPER}" install status --profile "${PROFILE}")"
printf '%s\n' "${started_output}"
grep -Fq "Status: running" <<<"${started_output}"
curl --fail --silent "http://127.0.0.1:${PORT}/readyz" >/dev/null
"${WRAPPER}" install restart --profile "${PROFILE}"
curl --fail --silent "http://127.0.0.1:${PORT}/readyz" >/dev/null
"${WRAPPER}" install remove --profile "${PROFILE}"
[[ ! -e "${HOME}/.headroom/deploy/${PROFILE}" ]]

View file

@ -1,6 +1,7 @@
$ErrorActionPreference = 'Stop'
$ImageDefault = 'ghcr.io/chopratejas/headroom:latest'
$InstallImage = if ($env:HEADROOM_DOCKER_IMAGE) { $env:HEADROOM_DOCKER_IMAGE } else { $ImageDefault }
$InstallDir = Join-Path $HOME '.local\bin'
if (-not (Test-Path (Join-Path $HOME '.local'))) {
$InstallDir = Join-Path $HOME 'bin'
@ -37,10 +38,11 @@ function Ensure-ProfileBlock {
$markerStart = '# >>> headroom docker-native >>>'
$markerEnd = '# <<< headroom docker-native <<<'
$escapedPathEntry = $PathEntry.Replace("'", "''")
$block = @"
$markerStart
if (-not ((`$env:Path -split ';') -contains '$PathEntry')) {
`$env:Path = '$PathEntry;' + `$env:Path
if (-not ((`$env:Path -split ';') -contains '$escapedPathEntry')) {
`$env:Path = '$escapedPathEntry;' + `$env:Path
}
$markerEnd
"@
@ -151,11 +153,27 @@ function Get-SharedDockerArgs {
return ,$args.ToArray()
}
function Add-TtyArgs {
param($ArgsList)
if (-not [Console]::IsInputRedirected -and -not [Console]::IsOutputRedirected) {
$ArgsList.Add('-it')
return
}
if (-not [Console]::IsInputRedirected) {
$ArgsList.Add('-i')
}
if (-not [Console]::IsOutputRedirected) {
$ArgsList.Add('-t')
}
}
function Invoke-HeadroomDocker {
param([string[]]$Arguments)
$dockerArgs = New-Object System.Collections.Generic.List[string]
$dockerArgs.AddRange([string[]]@('run','--rm','-it'))
$dockerArgs.AddRange([string[]]@('run','--rm'))
Add-TtyArgs -ArgsList $dockerArgs
$dockerArgs.AddRange((Get-SharedDockerArgs))
$dockerArgs.Add('--entrypoint')
$dockerArgs.Add('headroom')
@ -228,6 +246,538 @@ function Stop-ProxyContainer {
}
}
function Get-PersistentProfileRoot {
param([string]$Profile)
Assert-ValidProfileName -Profile $Profile
return Join-Path (Join-Path $HostHome '.headroom\deploy') $Profile
}
function Get-PersistentStatePath {
param([string]$Profile)
return Join-Path (Get-PersistentProfileRoot -Profile $Profile) 'docker-native.json'
}
function Get-PersistentManifestPath {
param([string]$Profile)
return Join-Path (Get-PersistentProfileRoot -Profile $Profile) 'manifest.json'
}
function Get-PersistentContainerName {
param([string]$Profile)
return "headroom-$Profile"
}
function Assert-ValidProfileName {
param([string]$Profile)
if ($Profile -notmatch '^[A-Za-z0-9._-]+$' -or $Profile -in @('.', '..')) {
Fail "Invalid profile name '$Profile'"
}
}
function Parse-PortValue {
param([string]$Value)
$parsed = 0
if (-not [int]::TryParse($Value, [ref]$parsed) -or $parsed -lt 1 -or $parsed -gt 65535) {
Fail "Invalid port '$Value'"
}
return $parsed
}
function Parse-PositiveIntegerValue {
param([string]$Value)
$parsed = 0
if (-not [int]::TryParse($Value, [ref]$parsed) -or $parsed -lt 1) {
Fail "Invalid value '$Value'"
}
return $parsed
}
function Require-OptionValue {
param(
[string[]]$Arguments,
[int]$Index,
[string]$Option
)
if ($Index + 1 -ge $Arguments.Count) {
Fail "Option $Option requires a value"
}
}
function Get-PersistentDockerArgs {
Ensure-HostDirs
$args = New-Object System.Collections.Generic.List[string]
$args.Add('--workdir')
$args.Add($ContainerHome)
$args.Add('--env')
$args.Add("HOME=$ContainerHome")
$args.Add('--env')
$args.Add('PYTHONUNBUFFERED=1')
$args.Add('--volume')
$args.Add((Join-Path $HostHome '.headroom') + ":$ContainerHome/.headroom")
$args.Add('--volume')
$args.Add((Join-Path $HostHome '.claude') + ":$ContainerHome/.claude")
$args.Add('--volume')
$args.Add((Join-Path $HostHome '.codex') + ":$ContainerHome/.codex")
$args.Add('--volume')
$args.Add((Join-Path $HostHome '.gemini') + ":$ContainerHome/.gemini")
foreach ($entry in (Get-PassthroughEnvArgs)) {
$args.Add($entry)
}
return ,$args.ToArray()
}
function Get-ManifestProxyArgs {
param(
[int]$Port,
[string]$Backend,
[string]$AnyllmProvider,
[string]$Region,
[string]$Mode,
[bool]$Memory,
[bool]$TelemetryEnabled
)
$args = New-Object System.Collections.Generic.List[string]
$args.AddRange([string[]]@('--host','127.0.0.1','--port',"$Port",'--mode',$Mode,'--backend',$Backend))
if (-not $TelemetryEnabled) {
$args.Add('--no-telemetry')
}
if ($Memory) {
$args.AddRange([string[]]@('--memory','--memory-db-path',"$ContainerHome/.headroom/memory.db"))
}
if ($AnyllmProvider) {
$args.AddRange([string[]]@('--anyllm-provider', $AnyllmProvider))
}
if ($Region) {
$args.AddRange([string[]]@('--region', $Region))
}
return ,$args.ToArray()
}
function Write-PersistentState {
param(
[string]$Profile,
[string]$Image,
[int]$Port,
[string]$Backend,
[string]$AnyllmProvider,
[string]$Region,
[string]$Mode,
[bool]$Memory,
[bool]$TelemetryEnabled
)
$root = Get-PersistentProfileRoot -Profile $Profile
New-Item -ItemType Directory -Force -Path $root | Out-Null
$state = [ordered]@{
profile = $Profile
image = $Image
port = $Port
backend = $Backend
anyllm_provider = $AnyllmProvider
region = $Region
proxy_mode = $Mode
memory_enabled = $Memory
telemetry_enabled = $TelemetryEnabled
container_name = Get-PersistentContainerName -Profile $Profile
health_url = "http://127.0.0.1:$Port/readyz"
}
$state | ConvertTo-Json -Depth 4 | Set-Content -Path (Get-PersistentStatePath -Profile $Profile) -Encoding utf8
}
function Write-PersistentManifest {
param(
[string]$Profile,
[string]$Image,
[int]$Port,
[string]$Backend,
[string]$AnyllmProvider,
[string]$Region,
[string]$Mode,
[bool]$Memory,
[bool]$TelemetryEnabled,
[string[]]$ProxyArgs
)
$root = Get-PersistentProfileRoot -Profile $Profile
New-Item -ItemType Directory -Force -Path $root | Out-Null
$baseEnv = [ordered]@{
HEADROOM_PORT = "$Port"
HEADROOM_HOST = '127.0.0.1'
HEADROOM_MODE = $Mode
HEADROOM_BACKEND = $Backend
}
$manifest = [ordered]@{
profile = $Profile
preset = 'persistent-docker'
runtime_kind = 'docker'
supervisor_kind = 'none'
scope = 'user'
provider_mode = 'manual'
targets = @()
port = $Port
host = '127.0.0.1'
backend = $Backend
anyllm_provider = if ($AnyllmProvider) { $AnyllmProvider } else { $null }
region = if ($Region) { $Region } else { $null }
proxy_mode = $Mode
memory_enabled = $Memory
memory_db_path = "$ContainerHome/.headroom/memory.db"
telemetry_enabled = $TelemetryEnabled
image = $Image
service_name = "headroom-$Profile"
container_name = Get-PersistentContainerName -Profile $Profile
health_url = "http://127.0.0.1:$Port/readyz"
base_env = $baseEnv
tool_envs = @{}
proxy_args = $ProxyArgs
mutations = @()
artifacts = @()
}
$manifest | ConvertTo-Json -Depth 8 | Set-Content -Path (Get-PersistentManifestPath -Profile $Profile) -Encoding utf8
}
function Read-PersistentState {
param([string]$Profile)
Assert-ValidProfileName -Profile $Profile
$statePath = Get-PersistentStatePath -Profile $Profile
if (-not (Test-Path $statePath)) {
Fail "No docker-native persistent deployment profile named '$Profile'"
}
return Get-Content -Raw -Path $statePath | ConvertFrom-Json
}
function Start-PersistentDockerInstall {
param(
[string]$Profile,
[string]$Image,
[int]$Port,
[string]$Backend,
[string]$AnyllmProvider,
[string]$Region,
[string]$Mode,
[bool]$Memory,
[bool]$TelemetryEnabled
)
Assert-ValidProfileName -Profile $Profile
$containerName = Get-PersistentContainerName -Profile $Profile
$proxyArgs = Get-ManifestProxyArgs -Port $Port -Backend $Backend -AnyllmProvider $AnyllmProvider -Region $Region -Mode $Mode -Memory $Memory -TelemetryEnabled $TelemetryEnabled
docker rm -f $containerName | Out-Null 2>$null
$dockerArgs = New-Object System.Collections.Generic.List[string]
$dockerArgs.AddRange([string[]]@('run','-d','--restart','unless-stopped','--name',$containerName,'-p',"$Port`:$Port"))
$dockerArgs.AddRange((Get-PersistentDockerArgs))
$dockerArgs.Add($Image)
$dockerArgs.Add('--host')
$dockerArgs.Add('0.0.0.0')
for ($i = 2; $i -lt $proxyArgs.Count; $i++) {
$dockerArgs.Add($proxyArgs[$i])
}
& docker @dockerArgs | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Failed to start docker-native persistent deployment"
}
try {
Wait-Proxy -ContainerName $containerName -Port $Port
} catch {
docker rm -f $containerName | Out-Null 2>$null
throw
}
Write-PersistentState -Profile $Profile -Image $Image -Port $Port -Backend $Backend -AnyllmProvider $AnyllmProvider -Region $Region -Mode $Mode -Memory $Memory -TelemetryEnabled $TelemetryEnabled
Write-PersistentManifest -Profile $Profile -Image $Image -Port $Port -Backend $Backend -AnyllmProvider $AnyllmProvider -Region $Region -Mode $Mode -Memory $Memory -TelemetryEnabled $TelemetryEnabled -ProxyArgs $proxyArgs
}
function Stop-PersistentDockerInstall {
param([string]$Profile)
$state = Read-PersistentState -Profile $Profile
docker stop $state.container_name | Out-Null 2>$null
docker rm -f $state.container_name | Out-Null 2>$null
}
function Remove-PersistentDockerInstall {
param([string]$Profile)
$state = Read-PersistentState -Profile $Profile
docker stop $state.container_name | Out-Null 2>$null
docker rm -f $state.container_name | Out-Null 2>$null
$root = Get-PersistentProfileRoot -Profile $Profile
if (Test-Path $root) {
Remove-Item -Recurse -Force -Path $root
}
}
function Show-PersistentDockerInstallStatus {
param([string]$Profile)
$state = Read-PersistentState -Profile $Profile
$status = 'stopped'
$ready = 'no'
$running = docker ps --format '{{.Names}}'
if ($running -contains $state.container_name) {
$status = 'running'
try {
Invoke-WebRequest -UseBasicParsing -Uri $state.health_url | Out-Null
$ready = 'yes'
} catch {
$ready = 'no'
}
}
Write-Host "Profile: $($state.profile)"
Write-Host 'Preset: persistent-docker'
Write-Host 'Runtime: docker'
Write-Host 'Supervisor: none'
Write-Host "Port: $($state.port)"
Write-Host "Status: $status"
Write-Host "Ready: $ready"
Write-Host "Health URL: $($state.health_url)"
}
function Show-InstallHelp {
$lines = @(
'Usage: headroom install [OPTIONS] COMMAND [ARGS]...',
'',
' Manage persistent Docker-native Headroom deployments.',
'',
' The Docker-native wrapper currently supports the persistent-docker preset only.',
' Use the Python-native `headroom install` command for persistent-service and',
' persistent-task installs, or when you need provider/user/system config mutation.',
'',
'Options:',
' -?, --help Show this message and exit.',
'',
'Commands:',
' apply Install a persistent Docker deployment.',
' remove Remove a persistent Docker deployment.',
' restart Restart a persistent Docker deployment.',
' start Start a persistent Docker deployment.',
' status Show persistent Docker deployment status.',
' stop Stop a persistent Docker deployment.'
)
Write-Host ($lines -join [Environment]::NewLine)
}
function Show-InstallApplyHelp {
$lines = @(
'Usage: headroom install apply [OPTIONS]',
'',
' Install a persistent Docker deployment.',
'',
'Options:',
' --preset [persistent-docker] Docker-native wrapper supports persistent-docker only.',
' --runtime [docker] Docker-native wrapper supports runtime=docker only.',
' --profile TEXT Deployment profile name. [default: default]',
' -p, --port INTEGER Persistent proxy port. [default: 8787]',
' --backend TEXT Proxy backend. [default: anthropic]',
' --anyllm-provider TEXT Provider for any-llm backends.',
' --region TEXT Cloud region for Bedrock / Vertex style backends.',
' --mode TEXT Proxy optimization mode. [default: token]',
' --memory Enable persistent memory in the runtime.',
' --no-telemetry Disable anonymous telemetry in the runtime.',
' --image TEXT Docker image to use. [default: HEADROOM_DOCKER_IMAGE or ghcr.io/chopratejas/headroom:latest]',
' -?, --help Show this message and exit.'
)
Write-Host ($lines -join [Environment]::NewLine)
}
function Parse-InstallApplyArgs {
param([string[]]$Arguments)
$profile = 'default'
$port = 8787
$backend = 'anthropic'
$anyllmProvider = $null
$region = $null
$mode = 'token'
$memory = $false
$telemetryEnabled = $true
$image = $HeadroomImage
$i = 0
while ($i -lt $Arguments.Count) {
$arg = $Arguments[$i]
switch -Regex ($arg) {
'^--preset$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--preset'
if ($Arguments[$i + 1] -ne 'persistent-docker') { Fail 'Docker-native wrapper supports only --preset persistent-docker' }
$i += 2
continue
}
'^--preset=' {
if (($arg -replace '^--preset=', '') -ne 'persistent-docker') { Fail 'Docker-native wrapper supports only --preset persistent-docker' }
$i += 1
continue
}
'^--runtime$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--runtime'
if ($Arguments[$i + 1] -ne 'docker') { Fail 'Docker-native wrapper supports only --runtime docker' }
$i += 2
continue
}
'^--runtime=' {
if (($arg -replace '^--runtime=', '') -ne 'docker') { Fail 'Docker-native wrapper supports only --runtime docker' }
$i += 1
continue
}
'^(--scope|--providers|--target)$' { Fail 'Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows' }
'^(--scope=|--providers=|--target=)' { Fail 'Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows' }
'^--profile$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--profile'
$profile = $Arguments[$i + 1]
$i += 2
continue
}
'^--profile=' {
$profile = $arg -replace '^--profile=', ''
$i += 1
continue
}
'^(--port|-p)$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$port = Parse-PortValue -Value $Arguments[$i + 1]
$i += 2
continue
}
'^(--port=|-p=)' {
$port = Parse-PortValue -Value ($arg -replace '^(--port=|-p=)', '')
$i += 1
continue
}
'^--backend$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--backend'
$backend = $Arguments[$i + 1]
$i += 2
continue
}
'^--backend=' {
$backend = $arg -replace '^--backend=', ''
$i += 1
continue
}
'^--anyllm-provider$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--anyllm-provider'
$anyllmProvider = $Arguments[$i + 1]
$i += 2
continue
}
'^--anyllm-provider=' {
$anyllmProvider = $arg -replace '^--anyllm-provider=', ''
$i += 1
continue
}
'^--region$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--region'
$region = $Arguments[$i + 1]
$i += 2
continue
}
'^--region=' {
$region = $arg -replace '^--region=', ''
$i += 1
continue
}
'^--mode$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--mode'
$mode = $Arguments[$i + 1]
$i += 2
continue
}
'^--mode=' {
$mode = $arg -replace '^--mode=', ''
$i += 1
continue
}
'^--memory$' {
$memory = $true
$i += 1
continue
}
'^--no-telemetry$' {
$telemetryEnabled = $false
$i += 1
continue
}
'^--image$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--image'
$image = $Arguments[$i + 1]
$i += 2
continue
}
'^--image=' {
$image = $arg -replace '^--image=', ''
$i += 1
continue
}
'^(--help|-\\?)$' {
Show-InstallApplyHelp
exit 0
}
default {
Fail "Unsupported option for 'headroom install apply': $arg"
}
}
}
return [pscustomobject]@{
Profile = $profile
Port = $port
Backend = $backend
AnyllmProvider = $anyllmProvider
Region = $region
Mode = $mode
Memory = $memory
TelemetryEnabled = $telemetryEnabled
Image = $image
}
}
function Parse-InstallProfileArgs {
param([string[]]$Arguments)
$profile = 'default'
$i = 0
while ($i -lt $Arguments.Count) {
$arg = $Arguments[$i]
switch -Regex ($arg) {
'^--profile$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--profile'
$profile = $Arguments[$i + 1]
$i += 2
continue
}
'^--profile=' {
$profile = $arg -replace '^--profile=', ''
$i += 1
continue
}
'^(--help|-\\?)$' {
Show-InstallHelp
exit 0
}
default {
Fail "Unsupported option for 'headroom install': $arg"
}
}
}
return $profile
}
function Invoke-ClaudeRtkInit {
$rtkPath = Join-Path $HostHome '.headroom\bin\rtk.exe'
if (-not (Test-Path $rtkPath)) {
@ -300,6 +850,7 @@ function Parse-OpenClawWrapArgs {
$arg = $Arguments[$i]
switch -Regex ($arg) {
'^--plugin-path$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$pluginPath = $Arguments[$i + 1]
$i += 2
continue
@ -310,6 +861,7 @@ function Parse-OpenClawWrapArgs {
continue
}
'^--plugin-spec$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$pluginSpec = $Arguments[$i + 1]
$i += 2
continue
@ -330,26 +882,29 @@ function Parse-OpenClawWrapArgs {
continue
}
'^--proxy-port$' {
$proxyPort = [int]$Arguments[$i + 1]
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$proxyPort = Parse-PortValue -Value $Arguments[$i + 1]
$i += 2
continue
}
'^--proxy-port=' {
$proxyPort = [int]($arg -replace '^--proxy-port=', '')
$proxyPort = Parse-PortValue -Value ($arg -replace '^--proxy-port=', '')
$i += 1
continue
}
'^--startup-timeout-ms$' {
$startupTimeoutMs = [int]$Arguments[$i + 1]
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$startupTimeoutMs = Parse-PositiveIntegerValue -Value $Arguments[$i + 1]
$i += 2
continue
}
'^--startup-timeout-ms=' {
$startupTimeoutMs = [int]($arg -replace '^--startup-timeout-ms=', '')
$startupTimeoutMs = Parse-PositiveIntegerValue -Value ($arg -replace '^--startup-timeout-ms=', '')
$i += 1
continue
}
'^--gateway-provider-id$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$gatewayProviderIds.Add($Arguments[$i + 1])
$i += 2
continue
@ -360,6 +915,7 @@ function Parse-OpenClawWrapArgs {
continue
}
'^--python-path$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$pythonPath = $Arguments[$i + 1]
$i += 2
continue
@ -762,7 +1318,7 @@ function Parse-WrapArgs {
param([string[]]$Arguments)
$known = New-Object System.Collections.Generic.List[string]
$host = New-Object System.Collections.Generic.List[string]
$hostArgs = New-Object System.Collections.Generic.List[string]
$port = 8787
$noRtk = $false
$noProxy = $false
@ -777,20 +1333,21 @@ function Parse-WrapArgs {
switch -Regex ($arg) {
'^--$' {
for ($j = $i + 1; $j -lt $Arguments.Count; $j++) {
$host.Add($Arguments[$j])
$hostArgs.Add($Arguments[$j])
}
$i = $Arguments.Count
continue
}
'^--port$|^-p$' {
$port = [int]$Arguments[$i + 1]
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$port = Parse-PortValue -Value $Arguments[$i + 1]
$known.Add($arg)
$known.Add($Arguments[$i + 1])
$i += 2
continue
}
'^--port=' {
$port = [int]($arg -replace '^--port=', '')
$port = Parse-PortValue -Value ($arg -replace '^--port=', '')
$known.Add($arg)
$i += 1
continue
@ -819,6 +1376,7 @@ function Parse-WrapArgs {
continue
}
'^--backend$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$backend = $Arguments[$i + 1]
$known.Add($arg)
$known.Add($Arguments[$i + 1])
@ -832,6 +1390,7 @@ function Parse-WrapArgs {
continue
}
'^--anyllm-provider$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$anyllm = $Arguments[$i + 1]
$known.Add($arg)
$known.Add($Arguments[$i + 1])
@ -845,6 +1404,7 @@ function Parse-WrapArgs {
continue
}
'^--region$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$region = $Arguments[$i + 1]
$known.Add($arg)
$known.Add($Arguments[$i + 1])
@ -859,7 +1419,7 @@ function Parse-WrapArgs {
}
default {
for ($j = $i; $j -lt $Arguments.Count; $j++) {
$host.Add($Arguments[$j])
$hostArgs.Add($Arguments[$j])
}
$i = $Arguments.Count
}
@ -868,7 +1428,7 @@ function Parse-WrapArgs {
[pscustomobject]@{
KnownArgs = $known.ToArray()
HostArgs = $host.ToArray()
HostArgs = $hostArgs.ToArray()
Port = $port
NoRtk = $noRtk
NoProxy = $noProxy
@ -886,7 +1446,8 @@ function Invoke-PrepareOnly {
)
$dockerArgs = New-Object System.Collections.Generic.List[string]
$dockerArgs.AddRange([string[]]@('run','--rm','-it'))
$dockerArgs.AddRange([string[]]@('run','--rm'))
Add-TtyArgs -ArgsList $dockerArgs
$dockerArgs.AddRange((Get-SharedDockerArgs))
$dockerArgs.Add('--env')
$dockerArgs.Add("HEADROOM_RTK_TARGET=$(Get-RtkTarget)")
@ -912,6 +1473,58 @@ if ($args.Count -eq 0) {
}
switch ($args[0]) {
'install' {
if ($args.Count -eq 1 -or $args[1] -eq '--help' -or $args[1] -eq '-?') {
Show-InstallHelp
exit 0
}
$installCommand = $args[1]
$installArgs = if ($args.Count -gt 2) { $args[2..($args.Count - 1)] } else { @() }
switch ($installCommand) {
'apply' {
$parsed = Parse-InstallApplyArgs -Arguments $installArgs
Start-PersistentDockerInstall -Profile $parsed.Profile -Image $parsed.Image -Port $parsed.Port -Backend $parsed.Backend -AnyllmProvider $parsed.AnyllmProvider -Region $parsed.Region -Mode $parsed.Mode -Memory $parsed.Memory -TelemetryEnabled $parsed.TelemetryEnabled
Write-Host "Installed docker-native persistent deployment '$($parsed.Profile)' on port $($parsed.Port)."
exit 0
}
'status' {
$profile = Parse-InstallProfileArgs -Arguments $installArgs
Show-PersistentDockerInstallStatus -Profile $profile
exit 0
}
'start' {
$profile = Parse-InstallProfileArgs -Arguments $installArgs
$state = Read-PersistentState -Profile $profile
Start-PersistentDockerInstall -Profile $state.profile -Image $state.image -Port $state.port -Backend $state.backend -AnyllmProvider $state.anyllm_provider -Region $state.region -Mode $state.proxy_mode -Memory ([bool]$state.memory_enabled) -TelemetryEnabled ([bool]$state.telemetry_enabled)
Write-Host "Started docker-native persistent deployment '$profile'."
exit 0
}
'stop' {
$profile = Parse-InstallProfileArgs -Arguments $installArgs
Stop-PersistentDockerInstall -Profile $profile
Write-Host "Stopped docker-native persistent deployment '$profile'."
exit 0
}
'restart' {
$profile = Parse-InstallProfileArgs -Arguments $installArgs
$state = Read-PersistentState -Profile $profile
Start-PersistentDockerInstall -Profile $state.profile -Image $state.image -Port $state.port -Backend $state.backend -AnyllmProvider $state.anyllm_provider -Region $state.region -Mode $state.proxy_mode -Memory ([bool]$state.memory_enabled) -TelemetryEnabled ([bool]$state.telemetry_enabled)
Write-Host "Restarted docker-native persistent deployment '$profile'."
exit 0
}
'remove' {
$profile = Parse-InstallProfileArgs -Arguments $installArgs
Remove-PersistentDockerInstall -Profile $profile
Write-Host "Removed docker-native persistent deployment '$profile'."
exit 0
}
default {
Fail "Unsupported install target: $installCommand"
}
}
}
'wrap' {
if ($args.Count -eq 1 -or $args[1] -eq '--help' -or $args[1] -eq '-?') {
Invoke-HeadroomDocker -Arguments @('wrap','--help')
@ -1028,17 +1641,20 @@ switch ($args[0]) {
foreach ($arg in $args) { $forwardArgs.Add($arg) }
for ($i = 1; $i -lt $args.Count; $i++) {
if ($args[$i] -eq '--port' -or $args[$i] -eq '-p') {
$port = [int]$args[$i + 1]
Require-OptionValue -Arguments $args -Index $i -Option $args[$i]
$port = Parse-PortValue -Value $args[$i + 1]
break
}
if ($args[$i] -match '^--port=') {
$port = [int]($args[$i] -replace '^--port=', '')
$port = Parse-PortValue -Value ($args[$i] -replace '^--port=', '')
break
}
}
$dockerArgs = New-Object System.Collections.Generic.List[string]
$dockerArgs.AddRange([string[]]@('run','--rm','-it','-p',"$port`:$port"))
$dockerArgs.AddRange([string[]]@('run','--rm'))
Add-TtyArgs -ArgsList $dockerArgs
$dockerArgs.AddRange([string[]]@('-p',"$port`:$port"))
$dockerArgs.AddRange((Get-SharedDockerArgs))
$dockerArgs.Add('--entrypoint')
$dockerArgs.Add('headroom')
@ -1056,34 +1672,44 @@ switch ($args[0]) {
}
'@
$cmdWrapper = @'
@echo off
powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0headroom.ps1" %*
'@
$cmdWrapper = ([string][char]64) + "echo off`r`npowershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File ""%~dp0headroom.ps1"" %*`r`n"
Set-Content -Path $wrapperPath -Value $wrapper
Set-Content -Path $cmdPath -Value $cmdWrapper
Set-Content -Path $wrapperPath -Value $wrapper -Encoding utf8
Set-Content -Path $cmdPath -Value $cmdWrapper -Encoding ascii
}
Require-Command docker
docker version | Out-Null
if ($LASTEXITCODE -ne 0) {
throw 'Docker is installed but not available to the current user'
}
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
Write-Wrapper -TargetDir $InstallDir
Ensure-PathEntry -PathEntry $InstallDir
Ensure-ProfileBlock -PathEntry $InstallDir
Write-Info "Pulling $ImageDefault"
docker pull $ImageDefault | Out-Null
if ($env:HEADROOM_DOCKER_IMAGE) {
$null = docker image inspect $InstallImage 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Info "Using existing HEADROOM_DOCKER_IMAGE=$InstallImage"
} else {
Write-Info "Pulling $InstallImage"
docker pull $InstallImage | Out-Null
}
} else {
Write-Info "Pulling $ImageDefault"
docker pull $ImageDefault | Out-Null
}
Write-Host ''
Write-Host 'Headroom Docker-native install complete.'
Write-Host ''
Write-Host ""
Write-Host "Headroom Docker-native install complete."
Write-Host ""
Write-Host "Installed wrappers:"
Write-Host " $InstallDir\headroom.ps1"
Write-Host " $InstallDir\headroom.cmd"
Write-Host ''
Write-Host 'Next steps:'
Write-Host ""
Write-Host "Next steps:"
Write-Host " 1. Restart PowerShell"
Write-Host " 2. Try: headroom proxy"
Write-Host " 3. Docs: https://github.com/chopratejas/headroom/blob/main/docs/docker-install.md"

View file

@ -3,11 +3,18 @@
set -euo pipefail
IMAGE_DEFAULT="ghcr.io/chopratejas/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' "$*"
}
@ -46,8 +53,9 @@ ${marker_end}"
write_wrapper() {
local wrapper_path="${INSTALL_DIR}/headroom"
cat >"${wrapper_path}" <<'WRAPPER'
#!/usr/bin/env bash
{
printf '#!%s\n\n' "${BASH_PATH}"
cat <<'WRAPPER'
set -euo pipefail
@ -55,6 +63,11 @@ HEADROOM_IMAGE="${HEADROOM_DOCKER_IMAGE:-ghcr.io/chopratejas/headroom:latest}"
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
}
@ -168,7 +181,11 @@ wait_for_proxy() {
local attempt
for attempt in $(seq 1 45); do
if (echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1; then
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
@ -209,6 +226,531 @@ stop_proxy_container() {
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")
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"
}
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
args=(docker run -d --restart unless-stopped --name "${container_name}" -p "${port}:${port}")
append_persistent_container_args args
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.
--image TEXT Docker image to use. [default: HEADROOM_DOCKER_IMAGE or ghcr.io/chopratejas/headroom:latest]
-?, --help Show this message and exit.
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
}
run_claude_rtk_init() {
local rtk_bin="${HEADROOM_HOST_HOME}/.headroom/bin/rtk"
if [[ ! -x "${rtk_bin}" ]]; then
@ -251,12 +793,15 @@ parse_wrap_args() {
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
;;
@ -280,6 +825,7 @@ parse_wrap_args() {
shift
;;
--backend)
require_option_value "$@"
out_backend="$2"
out_known+=("$1" "$2")
shift 2
@ -290,6 +836,7 @@ parse_wrap_args() {
shift
;;
--anyllm-provider)
require_option_value "$@"
out_anyllm="$2"
out_known+=("$1" "$2")
shift 2
@ -300,6 +847,7 @@ parse_wrap_args() {
shift
;;
--region)
require_option_value "$@"
out_region="$2"
out_known+=("$1" "$2")
shift 2
@ -381,6 +929,7 @@ parse_openclaw_wrap_args() {
while (($#)); do
case "$1" in
--plugin-path)
require_option_value "$@"
out_plugin_path="$2"
shift 2
;;
@ -389,6 +938,7 @@ parse_openclaw_wrap_args() {
shift
;;
--plugin-spec)
require_option_value "$@"
out_plugin_spec="$2"
shift 2
;;
@ -405,22 +955,29 @@ parse_openclaw_wrap_args() {
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
;;
@ -429,6 +986,7 @@ parse_openclaw_wrap_args() {
shift
;;
--python-path)
require_option_value "$@"
out_python_path="$2"
shift 2
;;
@ -795,6 +1353,57 @@ main() {
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
run_headroom wrap --help
@ -911,12 +1520,15 @@ EOF
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
;;
@ -942,6 +1554,7 @@ EOF
main "$@"
WRAPPER
} >"${wrapper_path}"
chmod +x "${wrapper_path}"
}
@ -957,8 +1570,17 @@ main() {
append_path_block "${HOME}/.zshrc"
append_path_block "${HOME}/.profile"
info "Pulling ${IMAGE_DEFAULT}"
docker pull "${IMAGE_DEFAULT}" >/dev/null
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

View file

@ -0,0 +1,731 @@
from __future__ import annotations
import json
import os
import shutil
import signal
import socket
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
FAKE_DOCKER = r"""
from __future__ import annotations
import json
import os
import signal
import subprocess
import sys
from pathlib import Path
STATE_PATH = Path(os.environ["FAKE_DOCKER_STATE"])
LOG_PATH = Path(os.environ["FAKE_DOCKER_LOG"])
def load_state() -> dict[str, dict[str, dict[str, int]]]:
if not STATE_PATH.exists():
return {"containers": {}}
return json.loads(STATE_PATH.read_text(encoding="utf-8"))
def save_state(state: dict[str, dict[str, dict[str, int]]]) -> None:
STATE_PATH.write_text(json.dumps(state), encoding="utf-8")
def cleanup_dead(state: dict[str, dict[str, dict[str, int]]]) -> dict[str, dict[str, dict[str, int]]]:
save_state(state)
return state
def host_port_from_publish(value: str) -> int:
parts = value.split(":")
if len(parts) == 2:
return int(parts[0])
if len(parts) >= 3:
return int(parts[-2])
raise ValueError(f"Unsupported publish value: {value}")
def start_server(port: int) -> int:
code = '''
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
port = int(sys.argv[1])
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"ok")
def log_message(self, fmt, *args):
return
ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()
'''
process = subprocess.Popen(
[sys.executable, "-c", code, str(port)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return process.pid
def stop_container(state: dict[str, dict[str, dict[str, int]]], name: str) -> None:
data = state["containers"].pop(name, None)
if not data:
return
try:
os.kill(int(data["pid"]), signal.SIGTERM)
except OSError:
pass
save_state(state)
def main() -> int:
args = sys.argv[1:]
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
with LOG_PATH.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(args) + "\n")
if not args:
return 0
state = cleanup_dead(load_state())
command = args[0]
if command == "pull":
return 0
if command == "run":
detached = "-d" in args
if not detached:
return 0
name = None
publish = None
for index, arg in enumerate(args):
if arg == "--name":
name = args[index + 1]
elif arg == "-p":
publish = args[index + 1]
if name is None or publish is None:
raise SystemExit("missing --name or -p in fake docker run")
port = host_port_from_publish(publish)
state["containers"][name] = {"pid": start_server(port), "port": port}
save_state(state)
print(name)
return 0
if command == "ps":
names = sorted(state["containers"])
if "--format" in args:
print("\n".join(names))
return 0
if command == "stop":
for name in args[1:]:
if not name.startswith("-"):
stop_container(state, name)
return 0
if command == "rm":
for name in args[1:]:
if not name.startswith("-"):
stop_container(state, name)
return 0
if command == "logs":
if len(args) > 1:
print(f"fake logs for {args[1]}")
return 0
return 0
if __name__ == "__main__":
raise SystemExit(main())
"""
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _write_fake_docker_shims(tmp_path: Path) -> Path:
shim_dir = tmp_path / "fake-docker"
shim_dir.mkdir()
fake_docker = shim_dir / "fake_docker.py"
fake_docker.write_text(FAKE_DOCKER, encoding="utf-8")
docker_sh = shim_dir / "docker"
docker_sh.write_text(
f'#!/usr/bin/env bash\nexec "{sys.executable}" "{fake_docker}" "$@"\n',
encoding="utf-8",
)
docker_sh.chmod(0o755)
docker_cmd = shim_dir / "docker.cmd"
docker_cmd.write_text(
f'@echo off\r\n"{sys.executable}" "{fake_docker}" %*\r\n',
encoding="utf-8",
)
openclaw_sh = shim_dir / "openclaw"
openclaw_sh.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
openclaw_sh.chmod(0o755)
openclaw_cmd = shim_dir / "openclaw.cmd"
openclaw_cmd.write_text("@echo off\r\nexit /b 0\r\n", encoding="utf-8")
return shim_dir
def _build_env(home: Path, tmp_path: Path) -> dict[str, str]:
env = os.environ.copy()
shim_dir = _write_fake_docker_shims(tmp_path)
env["HOME"] = str(home)
env["USERPROFILE"] = str(home)
env["PATH"] = str(shim_dir) + os.pathsep + env.get("PATH", "")
env["FAKE_DOCKER_STATE"] = str(tmp_path / "fake-docker-state.json")
env["FAKE_DOCKER_LOG"] = str(tmp_path / "fake-docker.log")
return env
def _cleanup_fake_docker(env: dict[str, str]) -> None:
state_path = Path(env["FAKE_DOCKER_STATE"])
if not state_path.exists():
return
state = json.loads(state_path.read_text(encoding="utf-8"))
for container in state.get("containers", {}).values():
try:
os.kill(int(container["pid"]), signal.SIGTERM)
except OSError:
pass
def _read_fake_docker_log(env: dict[str, str]) -> list[list[str]]:
log_path = Path(env["FAKE_DOCKER_LOG"])
if not log_path.exists():
return []
return [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines() if line]
def _run(
command: list[str],
*,
env: dict[str, str],
cwd: Path | None = None,
check: bool = True,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
cwd=cwd,
env=env,
capture_output=True,
text=True,
check=check,
)
@pytest.mark.skipif(
os.name == "nt" or shutil.which("bash") is None,
reason="bash installer coverage runs on non-Windows hosts",
)
def test_bash_native_installer_supports_persistent_docker_lifecycle(tmp_path: Path) -> None:
home = tmp_path / "home"
(home / ".local").mkdir(parents=True)
env = _build_env(home, tmp_path)
try:
_run(["bash", str(REPO_ROOT / "scripts" / "install.sh")], env=env, cwd=REPO_ROOT)
wrapper = home / ".local" / "bin" / "headroom"
assert wrapper.exists()
help_result = _run([str(wrapper), "install", "-?"], env=env)
assert "persistent-docker preset only" in help_result.stdout
_run([str(wrapper), "--help"], env=env)
invalid_profile = _run(
[str(wrapper), "install", "status", "--profile", ".."],
env=env,
check=False,
)
assert invalid_profile.returncode != 0
assert "Invalid profile name '..'" in invalid_profile.stderr
missing_profile_value = _run(
[str(wrapper), "install", "apply", "--profile"],
env=env,
check=False,
)
assert missing_profile_value.returncode != 0
assert "Option --profile requires a value" in missing_profile_value.stderr
missing_proxy_port = _run(
[str(wrapper), "proxy", "--port"],
env=env,
check=False,
)
assert missing_proxy_port.returncode != 0
assert "Option --port requires a value" in missing_proxy_port.stderr
invalid_proxy_port = _run(
[str(wrapper), "proxy", "--port", "abc"],
env=env,
check=False,
)
assert invalid_proxy_port.returncode != 0
assert "Invalid port 'abc'" in invalid_proxy_port.stderr
missing_wrap_port = _run(
[str(wrapper), "wrap", "claude", "--port"],
env=env,
check=False,
)
assert missing_wrap_port.returncode != 0
assert "Option --port requires a value" in missing_wrap_port.stderr
invalid_wrap_port = _run(
[str(wrapper), "wrap", "claude", "--port", "abc"],
env=env,
check=False,
)
assert invalid_wrap_port.returncode != 0
assert "Invalid port 'abc'" in invalid_wrap_port.stderr
missing_openclaw_proxy_port = _run(
[str(wrapper), "wrap", "openclaw", "--proxy-port"],
env=env,
check=False,
)
assert missing_openclaw_proxy_port.returncode != 0
assert "Option --proxy-port requires a value" in missing_openclaw_proxy_port.stderr
invalid_openclaw_proxy_port = _run(
[str(wrapper), "wrap", "openclaw", "--proxy-port", "abc"],
env=env,
check=False,
)
assert invalid_openclaw_proxy_port.returncode != 0
assert "Invalid port 'abc'" in invalid_openclaw_proxy_port.stderr
for invalid_port in ("abc", "0", "65536"):
invalid_port_result = _run(
[str(wrapper), "install", "apply", "--port", invalid_port],
env=env,
check=False,
)
assert invalid_port_result.returncode != 0
assert f"Invalid port '{invalid_port}'" in invalid_port_result.stderr
port = _free_port()
_run(
[
str(wrapper),
"install",
"apply",
"--profile",
"smoke",
"--port",
str(port),
"--memory",
"--no-telemetry",
"--image",
"fake/headroom:test",
],
env=env,
)
manifest_path = home / ".headroom" / "deploy" / "smoke" / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
assert manifest["preset"] == "persistent-docker"
assert manifest["port"] == port
assert manifest["memory_enabled"] is True
assert manifest["memory_db_path"] == "/tmp/headroom-home/.headroom/memory.db"
assert manifest["telemetry_enabled"] is False
state_path = home / ".headroom" / "deploy" / "smoke" / "docker-native.env"
state_text = state_path.read_text(encoding="utf-8")
assert f"PORT={port!r}" in state_text
docker_calls = _read_fake_docker_log(env)
help_call = next(
call
for call in docker_calls
if call[:2] == ["run", "--rm"] and "--entrypoint" in call and "--help" in call
)
assert "-it" not in help_call
install_call = next(
call for call in docker_calls if call[:2] == ["run", "-d"] and "--name" in call
)
assert "/tmp/headroom-home/.headroom/memory.db" in install_call
status_result = _run(
[str(wrapper), "install", "status", "--profile", "smoke"],
env=env,
)
assert "Status: running" in status_result.stdout
_run([str(wrapper), "install", "stop", "--profile", "smoke"], env=env)
stopped_result = _run(
[str(wrapper), "install", "status", "--profile", "smoke"],
env=env,
)
assert "Status: stopped" in stopped_result.stdout
_run([str(wrapper), "install", "start", "--profile", "smoke"], env=env)
restarted_result = _run(
[str(wrapper), "install", "status", "--profile", "smoke"],
env=env,
)
assert "Status: running" in restarted_result.stdout
rejected = _run(
[str(wrapper), "install", "apply", "--scope", "user"],
env=env,
check=False,
)
assert rejected.returncode != 0
assert "does not support provider/user/system mutation flags" in rejected.stderr
_run([str(wrapper), "install", "restart", "--profile", "smoke"], env=env)
_run([str(wrapper), "install", "remove", "--profile", "smoke"], env=env)
assert not manifest_path.parent.exists()
finally:
_cleanup_fake_docker(env)
def _powershell_executable() -> str | None:
return shutil.which("pwsh") or shutil.which("powershell") or shutil.which("powershell.exe")
@pytest.mark.skipif(
os.name != "nt" or _powershell_executable() is None,
reason="Windows PowerShell coverage runs on Windows hosts only",
)
def test_powershell_native_installer_supports_persistent_docker_lifecycle(tmp_path: Path) -> None:
powershell = _powershell_executable()
assert powershell is not None
home = tmp_path / "home"
(home / ".local").mkdir(parents=True)
env = _build_env(home, tmp_path)
try:
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(REPO_ROOT / "scripts" / "install.ps1"),
],
env=env,
cwd=REPO_ROOT,
)
wrapper = home / ".local" / "bin" / "headroom.ps1"
assert wrapper.exists()
cmd_wrapper = home / ".local" / "bin" / "headroom.cmd"
assert cmd_wrapper.exists()
help_result = _run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"-?",
],
env=env,
)
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"proxy",
"--help",
],
env=env,
)
assert "persistent-docker preset only" in help_result.stdout
cmd_help_result = _run(
["cmd.exe", "/c", str(cmd_wrapper), "install", "-?"],
env=env,
)
assert "persistent-docker preset only" in cmd_help_result.stdout
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"--help",
],
env=env,
)
invalid_profile = _run(
["cmd.exe", "/c", str(cmd_wrapper), "install", "status", "--profile", ".."],
env=env,
check=False,
)
assert invalid_profile.returncode != 0
assert "Invalid profile name '..'" in invalid_profile.stderr
missing_profile_value = _run(
["cmd.exe", "/c", str(cmd_wrapper), "install", "apply", "--profile"],
env=env,
check=False,
)
assert missing_profile_value.returncode != 0
assert "Option --profile requires a value" in missing_profile_value.stderr
missing_proxy_port = _run(
["cmd.exe", "/c", str(cmd_wrapper), "proxy", "--port"],
env=env,
check=False,
)
assert missing_proxy_port.returncode != 0
assert "Option --port requires a value" in missing_proxy_port.stderr
invalid_proxy_port = _run(
["cmd.exe", "/c", str(cmd_wrapper), "proxy", "--port", "abc"],
env=env,
check=False,
)
assert invalid_proxy_port.returncode != 0
assert "Invalid port 'abc'" in invalid_proxy_port.stderr
missing_wrap_port = _run(
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "claude", "--port"],
env=env,
check=False,
)
assert missing_wrap_port.returncode != 0
assert "Option --port requires a value" in missing_wrap_port.stderr
invalid_wrap_port = _run(
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "claude", "--port", "abc"],
env=env,
check=False,
)
assert invalid_wrap_port.returncode != 0
assert "Invalid port 'abc'" in invalid_wrap_port.stderr
missing_openclaw_proxy_port = _run(
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "openclaw", "--proxy-port"],
env=env,
check=False,
)
assert missing_openclaw_proxy_port.returncode != 0
assert "Option --proxy-port requires a value" in missing_openclaw_proxy_port.stderr
invalid_openclaw_proxy_port = _run(
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "openclaw", "--proxy-port", "abc"],
env=env,
check=False,
)
assert invalid_openclaw_proxy_port.returncode != 0
assert "Invalid port 'abc'" in invalid_openclaw_proxy_port.stderr
for invalid_port in ("abc", "0", "65536"):
invalid_port_result = _run(
["cmd.exe", "/c", str(cmd_wrapper), "install", "apply", "--port", invalid_port],
env=env,
check=False,
)
assert invalid_port_result.returncode != 0
assert f"Invalid port '{invalid_port}'" in invalid_port_result.stderr
port = _free_port()
_run(
[
"cmd.exe",
"/c",
str(cmd_wrapper),
"install",
"apply",
"--profile",
"smoke",
"--port",
str(port),
"--memory",
"--no-telemetry",
"--image",
"fake/headroom:test",
],
env=env,
)
manifest_path = home / ".headroom" / "deploy" / "smoke" / "manifest.json"
state_path = home / ".headroom" / "deploy" / "smoke" / "docker-native.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
state = json.loads(state_path.read_text(encoding="utf-8"))
assert manifest["preset"] == "persistent-docker"
assert manifest["port"] == port
assert manifest["memory_enabled"] is True
assert manifest["memory_db_path"] == "/tmp/headroom-home/.headroom/memory.db"
assert manifest["telemetry_enabled"] is False
assert state["container_name"] == "headroom-smoke"
docker_calls = _read_fake_docker_log(env)
help_call = next(
call
for call in docker_calls
if call[:2] == ["run", "--rm"] and "--entrypoint" in call and "--help" in call
)
assert "-it" not in help_call
proxy_help_call = next(
call
for call in docker_calls
if call[:2] == ["run", "--rm"] and "-p" in call and "proxy" in call and "--help" in call
)
assert "-it" not in proxy_help_call
install_call = next(
call for call in docker_calls if call[:2] == ["run", "-d"] and "--name" in call
)
assert "/tmp/headroom-home/.headroom/memory.db" in install_call
status_result = _run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"status",
"--profile",
"smoke",
],
env=env,
)
assert "Status: running" in status_result.stdout
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"stop",
"--profile",
"smoke",
],
env=env,
)
stopped_result = _run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"status",
"--profile",
"smoke",
],
env=env,
)
assert "Status: stopped" in stopped_result.stdout
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"start",
"--profile",
"smoke",
],
env=env,
)
started_result = _run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"status",
"--profile",
"smoke",
],
env=env,
)
assert "Status: running" in started_result.stdout
rejected = _run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"apply",
"--scope",
"user",
],
env=env,
check=False,
)
assert rejected.returncode != 0
assert "does not support provider/user/system mutation flags" in rejected.stderr
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"restart",
"--profile",
"smoke",
],
env=env,
)
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"remove",
"--profile",
"smoke",
],
env=env,
)
assert not manifest_path.parent.exists()
finally:
_cleanup_fake_docker(env)

View file

@ -0,0 +1,94 @@
from __future__ import annotations
from pathlib import Path
from headroom.install.models import DeploymentManifest, SupervisorKind
from headroom.install.supervisors import (
_linux_service_unit,
_linux_task_spec,
_macos_launchd_plist,
_render_windows_runner,
)
def _manifest(
*, profile: str = "default", scope: str = "user", supervisor: str = "service"
) -> DeploymentManifest:
return DeploymentManifest(
profile=profile,
preset="persistent-service",
runtime_kind="python",
supervisor_kind=supervisor,
scope=scope,
provider_mode="manual",
targets=[],
port=8787,
host="127.0.0.1",
backend="anthropic",
service_name=f"headroom-{profile}",
)
def test_linux_service_unit_uses_user_systemd_path(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path)
manifest = _manifest()
unit_path, content = _linux_service_unit(manifest, tmp_path / "run-headroom.sh")
assert unit_path == tmp_path / ".config" / "systemd" / "user" / "headroom-default.service"
assert "ExecStart=" + str(tmp_path / "run-headroom.sh") in content
assert "Restart=on-failure" in content
def test_linux_task_spec_for_user_scope_includes_crontab_markers(tmp_path: Path) -> None:
manifest = _manifest(profile="smoke", supervisor=SupervisorKind.TASK.value)
cron_path, content = _linux_task_spec(manifest, tmp_path / "ensure-headroom.sh")
assert cron_path is None
assert "# >>> headroom smoke >>>" in content
assert "# <<< headroom smoke <<<" in content
assert "@reboot" in content
assert "*/5 * * * *" in content
def test_macos_launchd_plist_switches_between_keepalive_and_interval(
monkeypatch, tmp_path: Path
) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path)
service_manifest = _manifest(supervisor=SupervisorKind.SERVICE.value)
service_path, service_content = _macos_launchd_plist(
service_manifest, tmp_path / "run-headroom.sh"
)
assert service_path == tmp_path / "Library" / "LaunchAgents" / "com.headroom.default.plist"
assert "<key>KeepAlive</key>" in service_content
assert "<key>StartInterval</key>" not in service_content
task_manifest = _manifest(profile="tasky", supervisor=SupervisorKind.TASK.value)
task_path, task_content = _macos_launchd_plist(
task_manifest, tmp_path / "ensure-headroom.sh", interval=300
)
assert task_path == tmp_path / "Library" / "LaunchAgents" / "com.headroom.tasky.plist"
assert "<key>StartInterval</key>" in task_content
assert "<integer>300</integer>" in task_content
def test_render_windows_runner_writes_ps1_and_cmd_wrappers(tmp_path: Path) -> None:
ps1_path = tmp_path / "run-headroom.ps1"
cmd_path = tmp_path / "run-headroom.cmd"
records = _render_windows_runner(
ps1_path,
cmd_path,
["C:\\Program Files\\Python\\python.exe", "headroom", "install", "agent", "run"],
)
assert [record.path for record in records] == [str(ps1_path), str(cmd_path)]
ps1_content = ps1_path.read_text(encoding="utf-8")
cmd_content = cmd_path.read_text(encoding="utf-8")
assert '& "C:\\Program Files\\Python\\python.exe" headroom install agent run' in ps1_content
assert (
'powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0run-headroom.ps1" %*'
in cmd_content
)