diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06f86bd66..2ec9b0772 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/docs/cli.md b/docs/cli.md index a35b67559..b162de9bb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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//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 | diff --git a/docs/docker-install.md b/docs/docker-install.md index 02addaeb3..c1ac3c4f7 100644 --- a/docs/docker-install.md +++ b/docs/docker-install.md @@ -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//`. diff --git a/docs/persistent-installs.md b/docs/persistent-installs.md index dca7cfd23..98e424fdd 100644 --- a/docs/persistent-installs.md +++ b/docs/persistent-installs.md @@ -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`: diff --git a/e2e/docker-native-install.sh b/e2e/docker-native-install.sh new file mode 100755 index 000000000..fe06134ec --- /dev/null +++ b/e2e/docker-native-install.sh @@ -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}" ]] diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 77db634ca..8db3498ee 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -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" diff --git a/scripts/install.sh b/scripts/install.sh index d921edc85..24de7170d 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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}" </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 < 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) diff --git a/tests/test_install/test_supervisors.py b/tests/test_install/test_supervisors.py new file mode 100644 index 000000000..fcfa62e21 --- /dev/null +++ b/tests/test_install/test_supervisors.py @@ -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 "KeepAlive" in service_content + assert "StartInterval" 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 "StartInterval" in task_content + assert "300" 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 + )