JerrettDavis Copilot commited on
Commit
ff0bedf
·
1 Parent(s): ca89449

feat: harden persistent install wrappers

Browse files

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>

.github/workflows/ci.yml CHANGED
@@ -101,6 +101,66 @@ jobs:
101
  run: |
102
  pytest tests/test_integrations/agno/ -v
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  build:
105
  runs-on: ubuntu-latest
106
  steps:
 
101
  run: |
102
  pytest tests/test_integrations/agno/ -v
103
 
104
+ docker-native-e2e:
105
+ runs-on: ubuntu-latest
106
+ steps:
107
+ - uses: actions/checkout@v4
108
+
109
+ - name: Set up Python
110
+ uses: actions/setup-python@v5
111
+ with:
112
+ python-version: "3.11"
113
+
114
+ - name: Build local Headroom image
115
+ run: |
116
+ docker build -t headroom-native-e2e:latest .
117
+
118
+ - name: Run Docker-native installer e2e
119
+ env:
120
+ HEADROOM_DOCKER_IMAGE: headroom-native-e2e:latest
121
+ run: |
122
+ bash e2e/docker-native-install.sh
123
+
124
+ windows-native-wrapper:
125
+ runs-on: windows-latest
126
+ steps:
127
+ - uses: actions/checkout@v4
128
+
129
+ - name: Set up Python
130
+ uses: actions/setup-python@v5
131
+ with:
132
+ python-version: "3.12"
133
+
134
+ - name: Install test dependencies
135
+ run: |
136
+ python -m pip install --upgrade pip
137
+ pip install pytest
138
+
139
+ - name: Run native installer wrapper tests
140
+ run: |
141
+ pytest tests/test_install/test_native_installers.py -q
142
+
143
+ macos-native-wrapper:
144
+ runs-on: macos-latest
145
+ steps:
146
+ - uses: actions/checkout@v4
147
+
148
+ - name: Set up Python
149
+ uses: actions/setup-python@v5
150
+ with:
151
+ python-version: "3.11"
152
+
153
+ - name: Install bash and test dependencies
154
+ run: |
155
+ brew install bash
156
+ python -m pip install --upgrade pip
157
+ pip install pytest
158
+
159
+ - name: Run native installer wrapper tests
160
+ run: |
161
+ export PATH="$(brew --prefix bash)/bin:$PATH"
162
+ pytest tests/test_install/test_native_installers.py -q
163
+
164
  build:
165
  runs-on: ubuntu-latest
166
  steps:
docs/cli.md CHANGED
@@ -22,7 +22,7 @@ This page is the authoritative reference for the **Python Headroom CLI** exposed
22
 
23
  | Command | Purpose | Docker-native parity |
24
  |---|---|---|
25
- | `headroom install ...` | Install and manage persistent deployments | **python-native / compose-managed Docker** |
26
  | `headroom proxy` | Run the Headroom proxy server | **native in container** |
27
  | `headroom learn` | Learn from past tool-call failures | **native in container** |
28
  | `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
30
  | `headroom memory ...` | Inspect and manage stored memories | **native in container** |
31
  | `headroom mcp ...` | Install, inspect, remove, or serve MCP integration | **native in container** |
32
  | `headroom wrap claude` | Start proxy and launch Claude Code | **host-bridged** |
33
- | `headroom wrap copilot` | Start proxy and launch GitHub Copilot CLI | **host-bridged** |
34
  | `headroom wrap codex` | Start proxy and launch Codex CLI | **host-bridged** |
35
  | `headroom wrap aider` | Start proxy and launch Aider | **host-bridged** |
36
  | `headroom wrap cursor` | Start proxy and print Cursor config guidance | **host-bridged** |
@@ -630,6 +630,8 @@ headroom install apply --preset persistent-docker --scope user
630
 
631
  `apply` stores a manifest under `~/.headroom/deploy/<profile>/manifest.json`, applies managed tool configuration, starts the chosen runtime, and waits for `readyz`.
632
 
 
 
633
  ### `headroom install status`
634
 
635
  ```bash
@@ -852,9 +854,9 @@ Legend:
852
  | `headroom mcp uninstall` | native | native in container | full |
853
  | `headroom mcp status` | native | native in container | full |
854
  | `headroom mcp serve` | native | native in container | full |
855
- | `headroom install ...` | native | compose-managed persistent Docker path | partial |
856
  | `headroom wrap claude` | native | host-bridged | partial |
857
- | `headroom wrap copilot` | native | host-bridged | partial |
858
  | `headroom wrap codex` | native | host-bridged | partial |
859
  | `headroom wrap aider` | native | host-bridged | partial |
860
  | `headroom wrap cursor` | native | host-bridged | partial |
 
22
 
23
  | Command | Purpose | Docker-native parity |
24
  |---|---|---|
25
+ | `headroom install ...` | Install and manage persistent deployments | **python-native; Docker-native wrapper supports `persistent-docker` lifecycle subset** |
26
  | `headroom proxy` | Run the Headroom proxy server | **native in container** |
27
  | `headroom learn` | Learn from past tool-call failures | **native in container** |
28
  | `headroom perf` | Summarize recent proxy performance | **native in container** |
 
30
  | `headroom memory ...` | Inspect and manage stored memories | **native in container** |
31
  | `headroom mcp ...` | Install, inspect, remove, or serve MCP integration | **native in container** |
32
  | `headroom wrap claude` | Start proxy and launch Claude Code | **host-bridged** |
33
+ | `headroom wrap copilot` | Start proxy and launch GitHub Copilot CLI | **python-native only** |
34
  | `headroom wrap codex` | Start proxy and launch Codex CLI | **host-bridged** |
35
  | `headroom wrap aider` | Start proxy and launch Aider | **host-bridged** |
36
  | `headroom wrap cursor` | Start proxy and print Cursor config guidance | **host-bridged** |
 
630
 
631
  `apply` stores a manifest under `~/.headroom/deploy/<profile>/manifest.json`, applies managed tool configuration, starts the chosen runtime, and waits for `readyz`.
632
 
633
+ 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`.
634
+
635
  ### `headroom install status`
636
 
637
  ```bash
 
854
  | `headroom mcp uninstall` | native | native in container | full |
855
  | `headroom mcp status` | native | native in container | full |
856
  | `headroom mcp serve` | native | native in container | full |
857
+ | `headroom install apply|status|start|stop|restart|remove` | native | Docker-native wrapper for `persistent-docker`; compose remains an alternative | partial |
858
  | `headroom wrap claude` | native | host-bridged | partial |
859
+ | `headroom wrap copilot` | native | not implemented in Docker-native wrapper | none |
860
  | `headroom wrap codex` | native | host-bridged | partial |
861
  | `headroom wrap aider` | native | host-bridged | partial |
862
  | `headroom wrap cursor` | native | host-bridged | partial |
docs/docker-install.md CHANGED
@@ -4,12 +4,20 @@ Run Headroom without installing Python or Node.js on the host. The install scrip
4
 
5
  ## One-line install
6
 
7
- ### macOS / Linux
8
 
9
  ```bash
10
  curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.sh | bash
11
  ```
12
 
 
 
 
 
 
 
 
 
13
  ### Windows PowerShell
14
 
15
  ```powershell
@@ -19,7 +27,7 @@ irm https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.
19
  ## What the installer does
20
 
21
  1. Verifies Docker is installed and available.
22
- 2. Pulls `ghcr.io/chopratejas/headroom:latest`.
23
  3. Installs a `headroom` wrapper into `~/.local/bin` or `~/bin`.
24
  4. Updates shell startup files so the wrapper directory is on `PATH`.
25
 
@@ -81,9 +89,28 @@ OpenClaw remains host-native in Docker-native mode:
81
  - plugin auto-start still launches the installed host `headroom` wrapper from `PATH`, which then runs Headroom in Docker
82
  - local plugin source mode (`--plugin-path`) is also supported, but it may require host `npm` when build steps are needed
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  ## Docker Compose support
85
 
86
- Use `docker/docker-compose.native.yml` when you want an explicit compose-managed proxy or CLI shell.
87
 
88
  ### Persistent Docker runtime
89
 
@@ -101,7 +128,7 @@ $env:HEADROOM_WORKSPACE = (Get-Location).Path
101
  docker compose -f docker/docker-compose.native.yml up -d proxy
102
  ```
103
 
104
- This is the recommended persistent-Docker path when you installed Headroom through the Docker-native host wrapper.
105
 
106
  ### macOS / Linux
107
 
@@ -147,3 +174,4 @@ That keeps provider auth and runtime config working without maintaining a separa
147
  - Wrapped tools like Claude Code, Codex CLI, Aider, and Cursor still run on the host when you use `headroom wrap ...`.
148
  - The install scripts are idempotent: rerunning them refreshes the wrapper and image without duplicating shell profile blocks.
149
  - For persistent service and task installs, use the Python-native `headroom install ...` workflow described in [Persistent Installs](persistent-installs.md).
 
 
4
 
5
  ## One-line install
6
 
7
+ ### Linux
8
 
9
  ```bash
10
  curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.sh | bash
11
  ```
12
 
13
+ ### macOS (bash 4.3+)
14
+
15
+ ```bash
16
+ curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.sh | "$(brew --prefix bash)/bin/bash"
17
+ ```
18
+
19
+ 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.
20
+
21
  ### Windows PowerShell
22
 
23
  ```powershell
 
27
  ## What the installer does
28
 
29
  1. Verifies Docker is installed and available.
30
+ 2. Pulls `ghcr.io/chopratejas/headroom:latest` by default, or reuses / pulls `HEADROOM_DOCKER_IMAGE` when you set a custom image override.
31
  3. Installs a `headroom` wrapper into `~/.local/bin` or `~/bin`.
32
  4. Updates shell startup files so the wrapper directory is on `PATH`.
33
 
 
89
  - plugin auto-start still launches the installed host `headroom` wrapper from `PATH`, which then runs Headroom in Docker
90
  - local plugin source mode (`--plugin-path`) is also supported, but it may require host `npm` when build steps are needed
91
 
92
+ ## Persistent Docker lifecycle from the native wrapper
93
+
94
+ The Docker-native `headroom` wrapper now exposes the persistent Docker lifecycle directly:
95
+
96
+ ```bash
97
+ headroom install apply --profile default --preset persistent-docker
98
+ headroom install status
99
+ headroom install restart
100
+ headroom install remove
101
+ ```
102
+
103
+ In Docker-native mode this surface is intentionally scoped to **persistent-docker**:
104
+
105
+ - supported: `apply`, `status`, `start`, `stop`, `restart`, `remove`
106
+ - supported flags: `--profile`, `--port`, `--backend`, `--anyllm-provider`, `--region`, `--mode`, `--memory`, `--no-telemetry`, `--image`
107
+ - not supported: `persistent-service`, `persistent-task`, or provider/user/system mutation flags such as `--scope`, `--providers`, and `--target`
108
+
109
+ Those broader lifecycle and config-mutation flows still belong to the Python-native `headroom install ...` command.
110
+
111
  ## Docker Compose support
112
 
113
+ 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.
114
 
115
  ### Persistent Docker runtime
116
 
 
128
  docker compose -f docker/docker-compose.native.yml up -d proxy
129
  ```
130
 
131
+ This remains a supported persistent-Docker path when you want the proxy managed explicitly through Compose instead of the installed wrapper.
132
 
133
  ### macOS / Linux
134
 
 
174
  - Wrapped tools like Claude Code, Codex CLI, Aider, and Cursor still run on the host when you use `headroom wrap ...`.
175
  - The install scripts are idempotent: rerunning them refreshes the wrapper and image without duplicating shell profile blocks.
176
  - For persistent service and task installs, use the Python-native `headroom install ...` workflow described in [Persistent Installs](persistent-installs.md).
177
+ - For Docker-native `headroom install ...`, the wrapper persists its profile manifest under `~/.headroom/deploy/<profile>/`.
docs/persistent-installs.md CHANGED
@@ -2,7 +2,7 @@
2
 
3
  Headroom can now be installed as a durable local runtime instead of only being started ad hoc with `headroom proxy` or `headroom wrap ...`.
4
 
5
- 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.
6
 
7
  ## Runtime matrix
8
 
@@ -43,7 +43,7 @@ headroom install apply --preset persistent-docker --scope user --providers auto
43
 
44
  This uses Docker's restart policy instead of an OS supervisor.
45
 
46
- 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).
47
 
48
  ## Command surface
49
 
@@ -125,14 +125,16 @@ Persistent deployments publish the same `readyz` and `health` endpoints as ad ho
125
  }
126
  ```
127
 
128
- `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.
 
 
129
 
130
  ## Docker-native relationship
131
 
132
  The Docker-native host wrapper and the Python install CLI solve different layers of the runtime story:
133
 
134
- - [Docker-Native Install](docker-install.md) -> containerized on-demand CLI and wrapped host-tool flows
135
- - `headroom install ...` -> persistent service, task, and native Docker lifecycle management
136
 
137
  For a no-Python persistent Docker workflow, use the compose-managed proxy path from `docker/docker-compose.native.yml`:
138
 
 
2
 
3
  Headroom can now be installed as a durable local runtime instead of only being started ad hoc with `headroom proxy` or `headroom wrap ...`.
4
 
5
+ 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.
6
 
7
  ## Runtime matrix
8
 
 
43
 
44
  This uses Docker's restart policy instead of an OS supervisor.
45
 
46
+ 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.
47
 
48
  ## Command surface
49
 
 
125
  }
126
  ```
127
 
128
+ 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.
129
+
130
+ 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`.
131
 
132
  ## Docker-native relationship
133
 
134
  The Docker-native host wrapper and the Python install CLI solve different layers of the runtime story:
135
 
136
+ - [Docker-Native Install](docker-install.md) -> containerized on-demand CLI, wrapped host-tool flows, and Docker-native `persistent-docker` lifecycle commands
137
+ - `headroom install ...` -> full persistent service, task, and Docker lifecycle management, including provider/user/system mutation
138
 
139
  For a no-Python persistent Docker workflow, use the compose-managed proxy path from `docker/docker-compose.native.yml`:
140
 
e2e/docker-native-install.sh ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
6
+ IMAGE="${HEADROOM_DOCKER_IMAGE:?set HEADROOM_DOCKER_IMAGE to a built test image}"
7
+ PROFILE="ci-smoke"
8
+ TMP_HOME="$(mktemp -d)"
9
+ PORT="$(python3 - <<'PY'
10
+ import socket
11
+
12
+ with socket.socket() as sock:
13
+ sock.bind(("127.0.0.1", 0))
14
+ print(sock.getsockname()[1])
15
+ PY
16
+ )"
17
+
18
+ cleanup() {
19
+ docker rm -f "headroom-${PROFILE}" >/dev/null 2>&1 || true
20
+ rm -rf "${TMP_HOME}"
21
+ }
22
+ trap cleanup EXIT
23
+
24
+ mkdir -p "${TMP_HOME}/.local"
25
+ export HOME="${TMP_HOME}"
26
+ export PATH="${HOME}/.local/bin:${PATH}"
27
+
28
+ bash "${ROOT_DIR}/scripts/install.sh"
29
+
30
+ WRAPPER="${HOME}/.local/bin/headroom"
31
+ [[ -x "${WRAPPER}" ]]
32
+
33
+ "${WRAPPER}" install -? | grep -Fq "persistent-docker preset only"
34
+
35
+ "${WRAPPER}" install apply \
36
+ --profile "${PROFILE}" \
37
+ --port "${PORT}" \
38
+ --image "${IMAGE}" \
39
+ --no-telemetry
40
+
41
+ status_output="$("${WRAPPER}" install status --profile "${PROFILE}")"
42
+ printf '%s\n' "${status_output}"
43
+ grep -Fq "Status: running" <<<"${status_output}"
44
+ curl --fail --silent "http://127.0.0.1:${PORT}/readyz" >/dev/null
45
+
46
+ python3 - <<'PY' "${HOME}" "${PROFILE}" "${PORT}"
47
+ import json
48
+ import sys
49
+ from pathlib import Path
50
+
51
+ home = Path(sys.argv[1])
52
+ profile = sys.argv[2]
53
+ port = int(sys.argv[3])
54
+ manifest = json.loads((home / ".headroom" / "deploy" / profile / "manifest.json").read_text())
55
+ assert manifest["preset"] == "persistent-docker"
56
+ assert manifest["port"] == port
57
+ assert manifest["telemetry_enabled"] is False
58
+ PY
59
+
60
+ if apply_error="$("${WRAPPER}" install apply --scope user 2>&1)"; then
61
+ echo "expected docker-native install apply --scope user to fail" >&2
62
+ exit 1
63
+ fi
64
+ grep -Fq "does not support provider/user/system mutation flags" <<<"${apply_error}"
65
+
66
+ "${WRAPPER}" install stop --profile "${PROFILE}"
67
+ stopped_output="$("${WRAPPER}" install status --profile "${PROFILE}")"
68
+ printf '%s\n' "${stopped_output}"
69
+ grep -Fq "Status: stopped" <<<"${stopped_output}"
70
+
71
+ "${WRAPPER}" install start --profile "${PROFILE}"
72
+ started_output="$("${WRAPPER}" install status --profile "${PROFILE}")"
73
+ printf '%s\n' "${started_output}"
74
+ grep -Fq "Status: running" <<<"${started_output}"
75
+ curl --fail --silent "http://127.0.0.1:${PORT}/readyz" >/dev/null
76
+
77
+ "${WRAPPER}" install restart --profile "${PROFILE}"
78
+ curl --fail --silent "http://127.0.0.1:${PORT}/readyz" >/dev/null
79
+
80
+ "${WRAPPER}" install remove --profile "${PROFILE}"
81
+ [[ ! -e "${HOME}/.headroom/deploy/${PROFILE}" ]]
scripts/install.ps1 CHANGED
@@ -1,6 +1,7 @@
1
  $ErrorActionPreference = 'Stop'
2
 
3
  $ImageDefault = 'ghcr.io/chopratejas/headroom:latest'
 
4
  $InstallDir = Join-Path $HOME '.local\bin'
5
  if (-not (Test-Path (Join-Path $HOME '.local'))) {
6
  $InstallDir = Join-Path $HOME 'bin'
@@ -37,10 +38,11 @@ function Ensure-ProfileBlock {
37
 
38
  $markerStart = '# >>> headroom docker-native >>>'
39
  $markerEnd = '# <<< headroom docker-native <<<'
 
40
  $block = @"
41
  $markerStart
42
- if (-not ((`$env:Path -split ';') -contains '$PathEntry')) {
43
- `$env:Path = '$PathEntry;' + `$env:Path
44
  }
45
  $markerEnd
46
  "@
@@ -151,11 +153,27 @@ function Get-SharedDockerArgs {
151
  return ,$args.ToArray()
152
  }
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  function Invoke-HeadroomDocker {
155
  param([string[]]$Arguments)
156
 
157
  $dockerArgs = New-Object System.Collections.Generic.List[string]
158
- $dockerArgs.AddRange([string[]]@('run','--rm','-it'))
 
159
  $dockerArgs.AddRange((Get-SharedDockerArgs))
160
  $dockerArgs.Add('--entrypoint')
161
  $dockerArgs.Add('headroom')
@@ -228,6 +246,538 @@ function Stop-ProxyContainer {
228
  }
229
  }
230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  function Invoke-ClaudeRtkInit {
232
  $rtkPath = Join-Path $HostHome '.headroom\bin\rtk.exe'
233
  if (-not (Test-Path $rtkPath)) {
@@ -300,6 +850,7 @@ function Parse-OpenClawWrapArgs {
300
  $arg = $Arguments[$i]
301
  switch -Regex ($arg) {
302
  '^--plugin-path$' {
 
303
  $pluginPath = $Arguments[$i + 1]
304
  $i += 2
305
  continue
@@ -310,6 +861,7 @@ function Parse-OpenClawWrapArgs {
310
  continue
311
  }
312
  '^--plugin-spec$' {
 
313
  $pluginSpec = $Arguments[$i + 1]
314
  $i += 2
315
  continue
@@ -330,26 +882,29 @@ function Parse-OpenClawWrapArgs {
330
  continue
331
  }
332
  '^--proxy-port$' {
333
- $proxyPort = [int]$Arguments[$i + 1]
 
334
  $i += 2
335
  continue
336
  }
337
  '^--proxy-port=' {
338
- $proxyPort = [int]($arg -replace '^--proxy-port=', '')
339
  $i += 1
340
  continue
341
  }
342
  '^--startup-timeout-ms$' {
343
- $startupTimeoutMs = [int]$Arguments[$i + 1]
 
344
  $i += 2
345
  continue
346
  }
347
  '^--startup-timeout-ms=' {
348
- $startupTimeoutMs = [int]($arg -replace '^--startup-timeout-ms=', '')
349
  $i += 1
350
  continue
351
  }
352
  '^--gateway-provider-id$' {
 
353
  $gatewayProviderIds.Add($Arguments[$i + 1])
354
  $i += 2
355
  continue
@@ -360,6 +915,7 @@ function Parse-OpenClawWrapArgs {
360
  continue
361
  }
362
  '^--python-path$' {
 
363
  $pythonPath = $Arguments[$i + 1]
364
  $i += 2
365
  continue
@@ -762,7 +1318,7 @@ function Parse-WrapArgs {
762
  param([string[]]$Arguments)
763
 
764
  $known = New-Object System.Collections.Generic.List[string]
765
- $host = New-Object System.Collections.Generic.List[string]
766
  $port = 8787
767
  $noRtk = $false
768
  $noProxy = $false
@@ -777,20 +1333,21 @@ function Parse-WrapArgs {
777
  switch -Regex ($arg) {
778
  '^--$' {
779
  for ($j = $i + 1; $j -lt $Arguments.Count; $j++) {
780
- $host.Add($Arguments[$j])
781
  }
782
  $i = $Arguments.Count
783
  continue
784
  }
785
  '^--port$|^-p$' {
786
- $port = [int]$Arguments[$i + 1]
 
787
  $known.Add($arg)
788
  $known.Add($Arguments[$i + 1])
789
  $i += 2
790
  continue
791
  }
792
  '^--port=' {
793
- $port = [int]($arg -replace '^--port=', '')
794
  $known.Add($arg)
795
  $i += 1
796
  continue
@@ -819,6 +1376,7 @@ function Parse-WrapArgs {
819
  continue
820
  }
821
  '^--backend$' {
 
822
  $backend = $Arguments[$i + 1]
823
  $known.Add($arg)
824
  $known.Add($Arguments[$i + 1])
@@ -832,6 +1390,7 @@ function Parse-WrapArgs {
832
  continue
833
  }
834
  '^--anyllm-provider$' {
 
835
  $anyllm = $Arguments[$i + 1]
836
  $known.Add($arg)
837
  $known.Add($Arguments[$i + 1])
@@ -845,6 +1404,7 @@ function Parse-WrapArgs {
845
  continue
846
  }
847
  '^--region$' {
 
848
  $region = $Arguments[$i + 1]
849
  $known.Add($arg)
850
  $known.Add($Arguments[$i + 1])
@@ -859,7 +1419,7 @@ function Parse-WrapArgs {
859
  }
860
  default {
861
  for ($j = $i; $j -lt $Arguments.Count; $j++) {
862
- $host.Add($Arguments[$j])
863
  }
864
  $i = $Arguments.Count
865
  }
@@ -868,7 +1428,7 @@ function Parse-WrapArgs {
868
 
869
  [pscustomobject]@{
870
  KnownArgs = $known.ToArray()
871
- HostArgs = $host.ToArray()
872
  Port = $port
873
  NoRtk = $noRtk
874
  NoProxy = $noProxy
@@ -886,7 +1446,8 @@ function Invoke-PrepareOnly {
886
  )
887
 
888
  $dockerArgs = New-Object System.Collections.Generic.List[string]
889
- $dockerArgs.AddRange([string[]]@('run','--rm','-it'))
 
890
  $dockerArgs.AddRange((Get-SharedDockerArgs))
891
  $dockerArgs.Add('--env')
892
  $dockerArgs.Add("HEADROOM_RTK_TARGET=$(Get-RtkTarget)")
@@ -912,6 +1473,58 @@ if ($args.Count -eq 0) {
912
  }
913
 
914
  switch ($args[0]) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
915
  'wrap' {
916
  if ($args.Count -eq 1 -or $args[1] -eq '--help' -or $args[1] -eq '-?') {
917
  Invoke-HeadroomDocker -Arguments @('wrap','--help')
@@ -1028,17 +1641,20 @@ switch ($args[0]) {
1028
  foreach ($arg in $args) { $forwardArgs.Add($arg) }
1029
  for ($i = 1; $i -lt $args.Count; $i++) {
1030
  if ($args[$i] -eq '--port' -or $args[$i] -eq '-p') {
1031
- $port = [int]$args[$i + 1]
 
1032
  break
1033
  }
1034
  if ($args[$i] -match '^--port=') {
1035
- $port = [int]($args[$i] -replace '^--port=', '')
1036
  break
1037
  }
1038
  }
1039
 
1040
  $dockerArgs = New-Object System.Collections.Generic.List[string]
1041
- $dockerArgs.AddRange([string[]]@('run','--rm','-it','-p',"$port`:$port"))
 
 
1042
  $dockerArgs.AddRange((Get-SharedDockerArgs))
1043
  $dockerArgs.Add('--entrypoint')
1044
  $dockerArgs.Add('headroom')
@@ -1056,34 +1672,44 @@ switch ($args[0]) {
1056
  }
1057
  '@
1058
 
1059
- $cmdWrapper = @'
1060
- @echo off
1061
- powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0headroom.ps1" %*
1062
- '@
1063
 
1064
- Set-Content -Path $wrapperPath -Value $wrapper
1065
- Set-Content -Path $cmdPath -Value $cmdWrapper
1066
  }
1067
 
1068
  Require-Command docker
1069
  docker version | Out-Null
 
 
 
1070
 
1071
  New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
1072
  Write-Wrapper -TargetDir $InstallDir
1073
  Ensure-PathEntry -PathEntry $InstallDir
1074
  Ensure-ProfileBlock -PathEntry $InstallDir
1075
 
1076
- Write-Info "Pulling $ImageDefault"
1077
- docker pull $ImageDefault | Out-Null
 
 
 
 
 
 
 
 
 
 
1078
 
1079
- Write-Host ''
1080
- Write-Host 'Headroom Docker-native install complete.'
1081
- Write-Host ''
1082
  Write-Host "Installed wrappers:"
1083
  Write-Host " $InstallDir\headroom.ps1"
1084
  Write-Host " $InstallDir\headroom.cmd"
1085
- Write-Host ''
1086
- Write-Host 'Next steps:'
1087
  Write-Host " 1. Restart PowerShell"
1088
  Write-Host " 2. Try: headroom proxy"
1089
  Write-Host " 3. Docs: https://github.com/chopratejas/headroom/blob/main/docs/docker-install.md"
 
1
  $ErrorActionPreference = 'Stop'
2
 
3
  $ImageDefault = 'ghcr.io/chopratejas/headroom:latest'
4
+ $InstallImage = if ($env:HEADROOM_DOCKER_IMAGE) { $env:HEADROOM_DOCKER_IMAGE } else { $ImageDefault }
5
  $InstallDir = Join-Path $HOME '.local\bin'
6
  if (-not (Test-Path (Join-Path $HOME '.local'))) {
7
  $InstallDir = Join-Path $HOME 'bin'
 
38
 
39
  $markerStart = '# >>> headroom docker-native >>>'
40
  $markerEnd = '# <<< headroom docker-native <<<'
41
+ $escapedPathEntry = $PathEntry.Replace("'", "''")
42
  $block = @"
43
  $markerStart
44
+ if (-not ((`$env:Path -split ';') -contains '$escapedPathEntry')) {
45
+ `$env:Path = '$escapedPathEntry;' + `$env:Path
46
  }
47
  $markerEnd
48
  "@
 
153
  return ,$args.ToArray()
154
  }
155
 
156
+ function Add-TtyArgs {
157
+ param($ArgsList)
158
+
159
+ if (-not [Console]::IsInputRedirected -and -not [Console]::IsOutputRedirected) {
160
+ $ArgsList.Add('-it')
161
+ return
162
+ }
163
+ if (-not [Console]::IsInputRedirected) {
164
+ $ArgsList.Add('-i')
165
+ }
166
+ if (-not [Console]::IsOutputRedirected) {
167
+ $ArgsList.Add('-t')
168
+ }
169
+ }
170
+
171
  function Invoke-HeadroomDocker {
172
  param([string[]]$Arguments)
173
 
174
  $dockerArgs = New-Object System.Collections.Generic.List[string]
175
+ $dockerArgs.AddRange([string[]]@('run','--rm'))
176
+ Add-TtyArgs -ArgsList $dockerArgs
177
  $dockerArgs.AddRange((Get-SharedDockerArgs))
178
  $dockerArgs.Add('--entrypoint')
179
  $dockerArgs.Add('headroom')
 
246
  }
247
  }
248
 
249
+ function Get-PersistentProfileRoot {
250
+ param([string]$Profile)
251
+ Assert-ValidProfileName -Profile $Profile
252
+ return Join-Path (Join-Path $HostHome '.headroom\deploy') $Profile
253
+ }
254
+
255
+ function Get-PersistentStatePath {
256
+ param([string]$Profile)
257
+ return Join-Path (Get-PersistentProfileRoot -Profile $Profile) 'docker-native.json'
258
+ }
259
+
260
+ function Get-PersistentManifestPath {
261
+ param([string]$Profile)
262
+ return Join-Path (Get-PersistentProfileRoot -Profile $Profile) 'manifest.json'
263
+ }
264
+
265
+ function Get-PersistentContainerName {
266
+ param([string]$Profile)
267
+ return "headroom-$Profile"
268
+ }
269
+
270
+ function Assert-ValidProfileName {
271
+ param([string]$Profile)
272
+ if ($Profile -notmatch '^[A-Za-z0-9._-]+$' -or $Profile -in @('.', '..')) {
273
+ Fail "Invalid profile name '$Profile'"
274
+ }
275
+ }
276
+
277
+ function Parse-PortValue {
278
+ param([string]$Value)
279
+
280
+ $parsed = 0
281
+ if (-not [int]::TryParse($Value, [ref]$parsed) -or $parsed -lt 1 -or $parsed -gt 65535) {
282
+ Fail "Invalid port '$Value'"
283
+ }
284
+ return $parsed
285
+ }
286
+
287
+ function Parse-PositiveIntegerValue {
288
+ param([string]$Value)
289
+
290
+ $parsed = 0
291
+ if (-not [int]::TryParse($Value, [ref]$parsed) -or $parsed -lt 1) {
292
+ Fail "Invalid value '$Value'"
293
+ }
294
+ return $parsed
295
+ }
296
+
297
+ function Require-OptionValue {
298
+ param(
299
+ [string[]]$Arguments,
300
+ [int]$Index,
301
+ [string]$Option
302
+ )
303
+
304
+ if ($Index + 1 -ge $Arguments.Count) {
305
+ Fail "Option $Option requires a value"
306
+ }
307
+ }
308
+
309
+ function Get-PersistentDockerArgs {
310
+ Ensure-HostDirs
311
+ $args = New-Object System.Collections.Generic.List[string]
312
+ $args.Add('--workdir')
313
+ $args.Add($ContainerHome)
314
+ $args.Add('--env')
315
+ $args.Add("HOME=$ContainerHome")
316
+ $args.Add('--env')
317
+ $args.Add('PYTHONUNBUFFERED=1')
318
+ $args.Add('--volume')
319
+ $args.Add((Join-Path $HostHome '.headroom') + ":$ContainerHome/.headroom")
320
+ $args.Add('--volume')
321
+ $args.Add((Join-Path $HostHome '.claude') + ":$ContainerHome/.claude")
322
+ $args.Add('--volume')
323
+ $args.Add((Join-Path $HostHome '.codex') + ":$ContainerHome/.codex")
324
+ $args.Add('--volume')
325
+ $args.Add((Join-Path $HostHome '.gemini') + ":$ContainerHome/.gemini")
326
+
327
+ foreach ($entry in (Get-PassthroughEnvArgs)) {
328
+ $args.Add($entry)
329
+ }
330
+
331
+ return ,$args.ToArray()
332
+ }
333
+
334
+ function Get-ManifestProxyArgs {
335
+ param(
336
+ [int]$Port,
337
+ [string]$Backend,
338
+ [string]$AnyllmProvider,
339
+ [string]$Region,
340
+ [string]$Mode,
341
+ [bool]$Memory,
342
+ [bool]$TelemetryEnabled
343
+ )
344
+
345
+ $args = New-Object System.Collections.Generic.List[string]
346
+ $args.AddRange([string[]]@('--host','127.0.0.1','--port',"$Port",'--mode',$Mode,'--backend',$Backend))
347
+ if (-not $TelemetryEnabled) {
348
+ $args.Add('--no-telemetry')
349
+ }
350
+ if ($Memory) {
351
+ $args.AddRange([string[]]@('--memory','--memory-db-path',"$ContainerHome/.headroom/memory.db"))
352
+ }
353
+ if ($AnyllmProvider) {
354
+ $args.AddRange([string[]]@('--anyllm-provider', $AnyllmProvider))
355
+ }
356
+ if ($Region) {
357
+ $args.AddRange([string[]]@('--region', $Region))
358
+ }
359
+
360
+ return ,$args.ToArray()
361
+ }
362
+
363
+ function Write-PersistentState {
364
+ param(
365
+ [string]$Profile,
366
+ [string]$Image,
367
+ [int]$Port,
368
+ [string]$Backend,
369
+ [string]$AnyllmProvider,
370
+ [string]$Region,
371
+ [string]$Mode,
372
+ [bool]$Memory,
373
+ [bool]$TelemetryEnabled
374
+ )
375
+
376
+ $root = Get-PersistentProfileRoot -Profile $Profile
377
+ New-Item -ItemType Directory -Force -Path $root | Out-Null
378
+ $state = [ordered]@{
379
+ profile = $Profile
380
+ image = $Image
381
+ port = $Port
382
+ backend = $Backend
383
+ anyllm_provider = $AnyllmProvider
384
+ region = $Region
385
+ proxy_mode = $Mode
386
+ memory_enabled = $Memory
387
+ telemetry_enabled = $TelemetryEnabled
388
+ container_name = Get-PersistentContainerName -Profile $Profile
389
+ health_url = "http://127.0.0.1:$Port/readyz"
390
+ }
391
+ $state | ConvertTo-Json -Depth 4 | Set-Content -Path (Get-PersistentStatePath -Profile $Profile) -Encoding utf8
392
+ }
393
+
394
+ function Write-PersistentManifest {
395
+ param(
396
+ [string]$Profile,
397
+ [string]$Image,
398
+ [int]$Port,
399
+ [string]$Backend,
400
+ [string]$AnyllmProvider,
401
+ [string]$Region,
402
+ [string]$Mode,
403
+ [bool]$Memory,
404
+ [bool]$TelemetryEnabled,
405
+ [string[]]$ProxyArgs
406
+ )
407
+
408
+ $root = Get-PersistentProfileRoot -Profile $Profile
409
+ New-Item -ItemType Directory -Force -Path $root | Out-Null
410
+
411
+ $baseEnv = [ordered]@{
412
+ HEADROOM_PORT = "$Port"
413
+ HEADROOM_HOST = '127.0.0.1'
414
+ HEADROOM_MODE = $Mode
415
+ HEADROOM_BACKEND = $Backend
416
+ }
417
+
418
+ $manifest = [ordered]@{
419
+ profile = $Profile
420
+ preset = 'persistent-docker'
421
+ runtime_kind = 'docker'
422
+ supervisor_kind = 'none'
423
+ scope = 'user'
424
+ provider_mode = 'manual'
425
+ targets = @()
426
+ port = $Port
427
+ host = '127.0.0.1'
428
+ backend = $Backend
429
+ anyllm_provider = if ($AnyllmProvider) { $AnyllmProvider } else { $null }
430
+ region = if ($Region) { $Region } else { $null }
431
+ proxy_mode = $Mode
432
+ memory_enabled = $Memory
433
+ memory_db_path = "$ContainerHome/.headroom/memory.db"
434
+ telemetry_enabled = $TelemetryEnabled
435
+ image = $Image
436
+ service_name = "headroom-$Profile"
437
+ container_name = Get-PersistentContainerName -Profile $Profile
438
+ health_url = "http://127.0.0.1:$Port/readyz"
439
+ base_env = $baseEnv
440
+ tool_envs = @{}
441
+ proxy_args = $ProxyArgs
442
+ mutations = @()
443
+ artifacts = @()
444
+ }
445
+
446
+ $manifest | ConvertTo-Json -Depth 8 | Set-Content -Path (Get-PersistentManifestPath -Profile $Profile) -Encoding utf8
447
+ }
448
+
449
+ function Read-PersistentState {
450
+ param([string]$Profile)
451
+
452
+ Assert-ValidProfileName -Profile $Profile
453
+ $statePath = Get-PersistentStatePath -Profile $Profile
454
+ if (-not (Test-Path $statePath)) {
455
+ Fail "No docker-native persistent deployment profile named '$Profile'"
456
+ }
457
+ return Get-Content -Raw -Path $statePath | ConvertFrom-Json
458
+ }
459
+
460
+ function Start-PersistentDockerInstall {
461
+ param(
462
+ [string]$Profile,
463
+ [string]$Image,
464
+ [int]$Port,
465
+ [string]$Backend,
466
+ [string]$AnyllmProvider,
467
+ [string]$Region,
468
+ [string]$Mode,
469
+ [bool]$Memory,
470
+ [bool]$TelemetryEnabled
471
+ )
472
+
473
+ Assert-ValidProfileName -Profile $Profile
474
+ $containerName = Get-PersistentContainerName -Profile $Profile
475
+ $proxyArgs = Get-ManifestProxyArgs -Port $Port -Backend $Backend -AnyllmProvider $AnyllmProvider -Region $Region -Mode $Mode -Memory $Memory -TelemetryEnabled $TelemetryEnabled
476
+
477
+ docker rm -f $containerName | Out-Null 2>$null
478
+
479
+ $dockerArgs = New-Object System.Collections.Generic.List[string]
480
+ $dockerArgs.AddRange([string[]]@('run','-d','--restart','unless-stopped','--name',$containerName,'-p',"$Port`:$Port"))
481
+ $dockerArgs.AddRange((Get-PersistentDockerArgs))
482
+ $dockerArgs.Add($Image)
483
+ $dockerArgs.Add('--host')
484
+ $dockerArgs.Add('0.0.0.0')
485
+ for ($i = 2; $i -lt $proxyArgs.Count; $i++) {
486
+ $dockerArgs.Add($proxyArgs[$i])
487
+ }
488
+
489
+ & docker @dockerArgs | Out-Null
490
+ if ($LASTEXITCODE -ne 0) {
491
+ throw "Failed to start docker-native persistent deployment"
492
+ }
493
+
494
+ try {
495
+ Wait-Proxy -ContainerName $containerName -Port $Port
496
+ } catch {
497
+ docker rm -f $containerName | Out-Null 2>$null
498
+ throw
499
+ }
500
+ Write-PersistentState -Profile $Profile -Image $Image -Port $Port -Backend $Backend -AnyllmProvider $AnyllmProvider -Region $Region -Mode $Mode -Memory $Memory -TelemetryEnabled $TelemetryEnabled
501
+ Write-PersistentManifest -Profile $Profile -Image $Image -Port $Port -Backend $Backend -AnyllmProvider $AnyllmProvider -Region $Region -Mode $Mode -Memory $Memory -TelemetryEnabled $TelemetryEnabled -ProxyArgs $proxyArgs
502
+ }
503
+
504
+ function Stop-PersistentDockerInstall {
505
+ param([string]$Profile)
506
+
507
+ $state = Read-PersistentState -Profile $Profile
508
+ docker stop $state.container_name | Out-Null 2>$null
509
+ docker rm -f $state.container_name | Out-Null 2>$null
510
+ }
511
+
512
+ function Remove-PersistentDockerInstall {
513
+ param([string]$Profile)
514
+
515
+ $state = Read-PersistentState -Profile $Profile
516
+ docker stop $state.container_name | Out-Null 2>$null
517
+ docker rm -f $state.container_name | Out-Null 2>$null
518
+ $root = Get-PersistentProfileRoot -Profile $Profile
519
+ if (Test-Path $root) {
520
+ Remove-Item -Recurse -Force -Path $root
521
+ }
522
+ }
523
+
524
+ function Show-PersistentDockerInstallStatus {
525
+ param([string]$Profile)
526
+
527
+ $state = Read-PersistentState -Profile $Profile
528
+ $status = 'stopped'
529
+ $ready = 'no'
530
+ $running = docker ps --format '{{.Names}}'
531
+ if ($running -contains $state.container_name) {
532
+ $status = 'running'
533
+ try {
534
+ Invoke-WebRequest -UseBasicParsing -Uri $state.health_url | Out-Null
535
+ $ready = 'yes'
536
+ } catch {
537
+ $ready = 'no'
538
+ }
539
+ }
540
+
541
+ Write-Host "Profile: $($state.profile)"
542
+ Write-Host 'Preset: persistent-docker'
543
+ Write-Host 'Runtime: docker'
544
+ Write-Host 'Supervisor: none'
545
+ Write-Host "Port: $($state.port)"
546
+ Write-Host "Status: $status"
547
+ Write-Host "Ready: $ready"
548
+ Write-Host "Health URL: $($state.health_url)"
549
+ }
550
+
551
+ function Show-InstallHelp {
552
+ $lines = @(
553
+ 'Usage: headroom install [OPTIONS] COMMAND [ARGS]...',
554
+ '',
555
+ ' Manage persistent Docker-native Headroom deployments.',
556
+ '',
557
+ ' The Docker-native wrapper currently supports the persistent-docker preset only.',
558
+ ' Use the Python-native `headroom install` command for persistent-service and',
559
+ ' persistent-task installs, or when you need provider/user/system config mutation.',
560
+ '',
561
+ 'Options:',
562
+ ' -?, --help Show this message and exit.',
563
+ '',
564
+ 'Commands:',
565
+ ' apply Install a persistent Docker deployment.',
566
+ ' remove Remove a persistent Docker deployment.',
567
+ ' restart Restart a persistent Docker deployment.',
568
+ ' start Start a persistent Docker deployment.',
569
+ ' status Show persistent Docker deployment status.',
570
+ ' stop Stop a persistent Docker deployment.'
571
+ )
572
+ Write-Host ($lines -join [Environment]::NewLine)
573
+ }
574
+
575
+ function Show-InstallApplyHelp {
576
+ $lines = @(
577
+ 'Usage: headroom install apply [OPTIONS]',
578
+ '',
579
+ ' Install a persistent Docker deployment.',
580
+ '',
581
+ 'Options:',
582
+ ' --preset [persistent-docker] Docker-native wrapper supports persistent-docker only.',
583
+ ' --runtime [docker] Docker-native wrapper supports runtime=docker only.',
584
+ ' --profile TEXT Deployment profile name. [default: default]',
585
+ ' -p, --port INTEGER Persistent proxy port. [default: 8787]',
586
+ ' --backend TEXT Proxy backend. [default: anthropic]',
587
+ ' --anyllm-provider TEXT Provider for any-llm backends.',
588
+ ' --region TEXT Cloud region for Bedrock / Vertex style backends.',
589
+ ' --mode TEXT Proxy optimization mode. [default: token]',
590
+ ' --memory Enable persistent memory in the runtime.',
591
+ ' --no-telemetry Disable anonymous telemetry in the runtime.',
592
+ ' --image TEXT Docker image to use. [default: HEADROOM_DOCKER_IMAGE or ghcr.io/chopratejas/headroom:latest]',
593
+ ' -?, --help Show this message and exit.'
594
+ )
595
+ Write-Host ($lines -join [Environment]::NewLine)
596
+ }
597
+
598
+ function Parse-InstallApplyArgs {
599
+ param([string[]]$Arguments)
600
+
601
+ $profile = 'default'
602
+ $port = 8787
603
+ $backend = 'anthropic'
604
+ $anyllmProvider = $null
605
+ $region = $null
606
+ $mode = 'token'
607
+ $memory = $false
608
+ $telemetryEnabled = $true
609
+ $image = $HeadroomImage
610
+
611
+ $i = 0
612
+ while ($i -lt $Arguments.Count) {
613
+ $arg = $Arguments[$i]
614
+ switch -Regex ($arg) {
615
+ '^--preset$' {
616
+ Require-OptionValue -Arguments $Arguments -Index $i -Option '--preset'
617
+ if ($Arguments[$i + 1] -ne 'persistent-docker') { Fail 'Docker-native wrapper supports only --preset persistent-docker' }
618
+ $i += 2
619
+ continue
620
+ }
621
+ '^--preset=' {
622
+ if (($arg -replace '^--preset=', '') -ne 'persistent-docker') { Fail 'Docker-native wrapper supports only --preset persistent-docker' }
623
+ $i += 1
624
+ continue
625
+ }
626
+ '^--runtime$' {
627
+ Require-OptionValue -Arguments $Arguments -Index $i -Option '--runtime'
628
+ if ($Arguments[$i + 1] -ne 'docker') { Fail 'Docker-native wrapper supports only --runtime docker' }
629
+ $i += 2
630
+ continue
631
+ }
632
+ '^--runtime=' {
633
+ if (($arg -replace '^--runtime=', '') -ne 'docker') { Fail 'Docker-native wrapper supports only --runtime docker' }
634
+ $i += 1
635
+ continue
636
+ }
637
+ '^(--scope|--providers|--target)$' { Fail 'Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows' }
638
+ '^(--scope=|--providers=|--target=)' { Fail 'Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows' }
639
+ '^--profile$' {
640
+ Require-OptionValue -Arguments $Arguments -Index $i -Option '--profile'
641
+ $profile = $Arguments[$i + 1]
642
+ $i += 2
643
+ continue
644
+ }
645
+ '^--profile=' {
646
+ $profile = $arg -replace '^--profile=', ''
647
+ $i += 1
648
+ continue
649
+ }
650
+ '^(--port|-p)$' {
651
+ Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
652
+ $port = Parse-PortValue -Value $Arguments[$i + 1]
653
+ $i += 2
654
+ continue
655
+ }
656
+ '^(--port=|-p=)' {
657
+ $port = Parse-PortValue -Value ($arg -replace '^(--port=|-p=)', '')
658
+ $i += 1
659
+ continue
660
+ }
661
+ '^--backend$' {
662
+ Require-OptionValue -Arguments $Arguments -Index $i -Option '--backend'
663
+ $backend = $Arguments[$i + 1]
664
+ $i += 2
665
+ continue
666
+ }
667
+ '^--backend=' {
668
+ $backend = $arg -replace '^--backend=', ''
669
+ $i += 1
670
+ continue
671
+ }
672
+ '^--anyllm-provider$' {
673
+ Require-OptionValue -Arguments $Arguments -Index $i -Option '--anyllm-provider'
674
+ $anyllmProvider = $Arguments[$i + 1]
675
+ $i += 2
676
+ continue
677
+ }
678
+ '^--anyllm-provider=' {
679
+ $anyllmProvider = $arg -replace '^--anyllm-provider=', ''
680
+ $i += 1
681
+ continue
682
+ }
683
+ '^--region$' {
684
+ Require-OptionValue -Arguments $Arguments -Index $i -Option '--region'
685
+ $region = $Arguments[$i + 1]
686
+ $i += 2
687
+ continue
688
+ }
689
+ '^--region=' {
690
+ $region = $arg -replace '^--region=', ''
691
+ $i += 1
692
+ continue
693
+ }
694
+ '^--mode$' {
695
+ Require-OptionValue -Arguments $Arguments -Index $i -Option '--mode'
696
+ $mode = $Arguments[$i + 1]
697
+ $i += 2
698
+ continue
699
+ }
700
+ '^--mode=' {
701
+ $mode = $arg -replace '^--mode=', ''
702
+ $i += 1
703
+ continue
704
+ }
705
+ '^--memory$' {
706
+ $memory = $true
707
+ $i += 1
708
+ continue
709
+ }
710
+ '^--no-telemetry$' {
711
+ $telemetryEnabled = $false
712
+ $i += 1
713
+ continue
714
+ }
715
+ '^--image$' {
716
+ Require-OptionValue -Arguments $Arguments -Index $i -Option '--image'
717
+ $image = $Arguments[$i + 1]
718
+ $i += 2
719
+ continue
720
+ }
721
+ '^--image=' {
722
+ $image = $arg -replace '^--image=', ''
723
+ $i += 1
724
+ continue
725
+ }
726
+ '^(--help|-\\?)$' {
727
+ Show-InstallApplyHelp
728
+ exit 0
729
+ }
730
+ default {
731
+ Fail "Unsupported option for 'headroom install apply': $arg"
732
+ }
733
+ }
734
+ }
735
+
736
+ return [pscustomobject]@{
737
+ Profile = $profile
738
+ Port = $port
739
+ Backend = $backend
740
+ AnyllmProvider = $anyllmProvider
741
+ Region = $region
742
+ Mode = $mode
743
+ Memory = $memory
744
+ TelemetryEnabled = $telemetryEnabled
745
+ Image = $image
746
+ }
747
+ }
748
+
749
+ function Parse-InstallProfileArgs {
750
+ param([string[]]$Arguments)
751
+
752
+ $profile = 'default'
753
+ $i = 0
754
+ while ($i -lt $Arguments.Count) {
755
+ $arg = $Arguments[$i]
756
+ switch -Regex ($arg) {
757
+ '^--profile$' {
758
+ Require-OptionValue -Arguments $Arguments -Index $i -Option '--profile'
759
+ $profile = $Arguments[$i + 1]
760
+ $i += 2
761
+ continue
762
+ }
763
+ '^--profile=' {
764
+ $profile = $arg -replace '^--profile=', ''
765
+ $i += 1
766
+ continue
767
+ }
768
+ '^(--help|-\\?)$' {
769
+ Show-InstallHelp
770
+ exit 0
771
+ }
772
+ default {
773
+ Fail "Unsupported option for 'headroom install': $arg"
774
+ }
775
+ }
776
+ }
777
+
778
+ return $profile
779
+ }
780
+
781
  function Invoke-ClaudeRtkInit {
782
  $rtkPath = Join-Path $HostHome '.headroom\bin\rtk.exe'
783
  if (-not (Test-Path $rtkPath)) {
 
850
  $arg = $Arguments[$i]
851
  switch -Regex ($arg) {
852
  '^--plugin-path$' {
853
+ Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
854
  $pluginPath = $Arguments[$i + 1]
855
  $i += 2
856
  continue
 
861
  continue
862
  }
863
  '^--plugin-spec$' {
864
+ Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
865
  $pluginSpec = $Arguments[$i + 1]
866
  $i += 2
867
  continue
 
882
  continue
883
  }
884
  '^--proxy-port$' {
885
+ Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
886
+ $proxyPort = Parse-PortValue -Value $Arguments[$i + 1]
887
  $i += 2
888
  continue
889
  }
890
  '^--proxy-port=' {
891
+ $proxyPort = Parse-PortValue -Value ($arg -replace '^--proxy-port=', '')
892
  $i += 1
893
  continue
894
  }
895
  '^--startup-timeout-ms$' {
896
+ Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
897
+ $startupTimeoutMs = Parse-PositiveIntegerValue -Value $Arguments[$i + 1]
898
  $i += 2
899
  continue
900
  }
901
  '^--startup-timeout-ms=' {
902
+ $startupTimeoutMs = Parse-PositiveIntegerValue -Value ($arg -replace '^--startup-timeout-ms=', '')
903
  $i += 1
904
  continue
905
  }
906
  '^--gateway-provider-id$' {
907
+ Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
908
  $gatewayProviderIds.Add($Arguments[$i + 1])
909
  $i += 2
910
  continue
 
915
  continue
916
  }
917
  '^--python-path$' {
918
+ Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
919
  $pythonPath = $Arguments[$i + 1]
920
  $i += 2
921
  continue
 
1318
  param([string[]]$Arguments)
1319
 
1320
  $known = New-Object System.Collections.Generic.List[string]
1321
+ $hostArgs = New-Object System.Collections.Generic.List[string]
1322
  $port = 8787
1323
  $noRtk = $false
1324
  $noProxy = $false
 
1333
  switch -Regex ($arg) {
1334
  '^--$' {
1335
  for ($j = $i + 1; $j -lt $Arguments.Count; $j++) {
1336
+ $hostArgs.Add($Arguments[$j])
1337
  }
1338
  $i = $Arguments.Count
1339
  continue
1340
  }
1341
  '^--port$|^-p$' {
1342
+ Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
1343
+ $port = Parse-PortValue -Value $Arguments[$i + 1]
1344
  $known.Add($arg)
1345
  $known.Add($Arguments[$i + 1])
1346
  $i += 2
1347
  continue
1348
  }
1349
  '^--port=' {
1350
+ $port = Parse-PortValue -Value ($arg -replace '^--port=', '')
1351
  $known.Add($arg)
1352
  $i += 1
1353
  continue
 
1376
  continue
1377
  }
1378
  '^--backend$' {
1379
+ Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
1380
  $backend = $Arguments[$i + 1]
1381
  $known.Add($arg)
1382
  $known.Add($Arguments[$i + 1])
 
1390
  continue
1391
  }
1392
  '^--anyllm-provider$' {
1393
+ Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
1394
  $anyllm = $Arguments[$i + 1]
1395
  $known.Add($arg)
1396
  $known.Add($Arguments[$i + 1])
 
1404
  continue
1405
  }
1406
  '^--region$' {
1407
+ Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
1408
  $region = $Arguments[$i + 1]
1409
  $known.Add($arg)
1410
  $known.Add($Arguments[$i + 1])
 
1419
  }
1420
  default {
1421
  for ($j = $i; $j -lt $Arguments.Count; $j++) {
1422
+ $hostArgs.Add($Arguments[$j])
1423
  }
1424
  $i = $Arguments.Count
1425
  }
 
1428
 
1429
  [pscustomobject]@{
1430
  KnownArgs = $known.ToArray()
1431
+ HostArgs = $hostArgs.ToArray()
1432
  Port = $port
1433
  NoRtk = $noRtk
1434
  NoProxy = $noProxy
 
1446
  )
1447
 
1448
  $dockerArgs = New-Object System.Collections.Generic.List[string]
1449
+ $dockerArgs.AddRange([string[]]@('run','--rm'))
1450
+ Add-TtyArgs -ArgsList $dockerArgs
1451
  $dockerArgs.AddRange((Get-SharedDockerArgs))
1452
  $dockerArgs.Add('--env')
1453
  $dockerArgs.Add("HEADROOM_RTK_TARGET=$(Get-RtkTarget)")
 
1473
  }
1474
 
1475
  switch ($args[0]) {
1476
+ 'install' {
1477
+ if ($args.Count -eq 1 -or $args[1] -eq '--help' -or $args[1] -eq '-?') {
1478
+ Show-InstallHelp
1479
+ exit 0
1480
+ }
1481
+
1482
+ $installCommand = $args[1]
1483
+ $installArgs = if ($args.Count -gt 2) { $args[2..($args.Count - 1)] } else { @() }
1484
+
1485
+ switch ($installCommand) {
1486
+ 'apply' {
1487
+ $parsed = Parse-InstallApplyArgs -Arguments $installArgs
1488
+ 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
1489
+ Write-Host "Installed docker-native persistent deployment '$($parsed.Profile)' on port $($parsed.Port)."
1490
+ exit 0
1491
+ }
1492
+ 'status' {
1493
+ $profile = Parse-InstallProfileArgs -Arguments $installArgs
1494
+ Show-PersistentDockerInstallStatus -Profile $profile
1495
+ exit 0
1496
+ }
1497
+ 'start' {
1498
+ $profile = Parse-InstallProfileArgs -Arguments $installArgs
1499
+ $state = Read-PersistentState -Profile $profile
1500
+ 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)
1501
+ Write-Host "Started docker-native persistent deployment '$profile'."
1502
+ exit 0
1503
+ }
1504
+ 'stop' {
1505
+ $profile = Parse-InstallProfileArgs -Arguments $installArgs
1506
+ Stop-PersistentDockerInstall -Profile $profile
1507
+ Write-Host "Stopped docker-native persistent deployment '$profile'."
1508
+ exit 0
1509
+ }
1510
+ 'restart' {
1511
+ $profile = Parse-InstallProfileArgs -Arguments $installArgs
1512
+ $state = Read-PersistentState -Profile $profile
1513
+ 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)
1514
+ Write-Host "Restarted docker-native persistent deployment '$profile'."
1515
+ exit 0
1516
+ }
1517
+ 'remove' {
1518
+ $profile = Parse-InstallProfileArgs -Arguments $installArgs
1519
+ Remove-PersistentDockerInstall -Profile $profile
1520
+ Write-Host "Removed docker-native persistent deployment '$profile'."
1521
+ exit 0
1522
+ }
1523
+ default {
1524
+ Fail "Unsupported install target: $installCommand"
1525
+ }
1526
+ }
1527
+ }
1528
  'wrap' {
1529
  if ($args.Count -eq 1 -or $args[1] -eq '--help' -or $args[1] -eq '-?') {
1530
  Invoke-HeadroomDocker -Arguments @('wrap','--help')
 
1641
  foreach ($arg in $args) { $forwardArgs.Add($arg) }
1642
  for ($i = 1; $i -lt $args.Count; $i++) {
1643
  if ($args[$i] -eq '--port' -or $args[$i] -eq '-p') {
1644
+ Require-OptionValue -Arguments $args -Index $i -Option $args[$i]
1645
+ $port = Parse-PortValue -Value $args[$i + 1]
1646
  break
1647
  }
1648
  if ($args[$i] -match '^--port=') {
1649
+ $port = Parse-PortValue -Value ($args[$i] -replace '^--port=', '')
1650
  break
1651
  }
1652
  }
1653
 
1654
  $dockerArgs = New-Object System.Collections.Generic.List[string]
1655
+ $dockerArgs.AddRange([string[]]@('run','--rm'))
1656
+ Add-TtyArgs -ArgsList $dockerArgs
1657
+ $dockerArgs.AddRange([string[]]@('-p',"$port`:$port"))
1658
  $dockerArgs.AddRange((Get-SharedDockerArgs))
1659
  $dockerArgs.Add('--entrypoint')
1660
  $dockerArgs.Add('headroom')
 
1672
  }
1673
  '@
1674
 
1675
+ $cmdWrapper = ([string][char]64) + "echo off`r`npowershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File ""%~dp0headroom.ps1"" %*`r`n"
 
 
 
1676
 
1677
+ Set-Content -Path $wrapperPath -Value $wrapper -Encoding utf8
1678
+ Set-Content -Path $cmdPath -Value $cmdWrapper -Encoding ascii
1679
  }
1680
 
1681
  Require-Command docker
1682
  docker version | Out-Null
1683
+ if ($LASTEXITCODE -ne 0) {
1684
+ throw 'Docker is installed but not available to the current user'
1685
+ }
1686
 
1687
  New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
1688
  Write-Wrapper -TargetDir $InstallDir
1689
  Ensure-PathEntry -PathEntry $InstallDir
1690
  Ensure-ProfileBlock -PathEntry $InstallDir
1691
 
1692
+ if ($env:HEADROOM_DOCKER_IMAGE) {
1693
+ $null = docker image inspect $InstallImage 2>$null
1694
+ if ($LASTEXITCODE -eq 0) {
1695
+ Write-Info "Using existing HEADROOM_DOCKER_IMAGE=$InstallImage"
1696
+ } else {
1697
+ Write-Info "Pulling $InstallImage"
1698
+ docker pull $InstallImage | Out-Null
1699
+ }
1700
+ } else {
1701
+ Write-Info "Pulling $ImageDefault"
1702
+ docker pull $ImageDefault | Out-Null
1703
+ }
1704
 
1705
+ Write-Host ""
1706
+ Write-Host "Headroom Docker-native install complete."
1707
+ Write-Host ""
1708
  Write-Host "Installed wrappers:"
1709
  Write-Host " $InstallDir\headroom.ps1"
1710
  Write-Host " $InstallDir\headroom.cmd"
1711
+ Write-Host ""
1712
+ Write-Host "Next steps:"
1713
  Write-Host " 1. Restart PowerShell"
1714
  Write-Host " 2. Try: headroom proxy"
1715
  Write-Host " 3. Docs: https://github.com/chopratejas/headroom/blob/main/docs/docker-install.md"
scripts/install.sh CHANGED
@@ -3,11 +3,18 @@
3
  set -euo pipefail
4
 
5
  IMAGE_DEFAULT="ghcr.io/chopratejas/headroom:latest"
 
6
  INSTALL_DIR="${HOME}/.local/bin"
7
  if [[ ! -d "${HOME}/.local" ]]; then
8
  INSTALL_DIR="${HOME}/bin"
9
  fi
10
 
 
 
 
 
 
 
11
  info() {
12
  printf '==> %s\n' "$*"
13
  }
@@ -46,8 +53,9 @@ ${marker_end}"
46
  write_wrapper() {
47
  local wrapper_path="${INSTALL_DIR}/headroom"
48
 
49
- cat >"${wrapper_path}" <<'WRAPPER'
50
- #!/usr/bin/env bash
 
51
 
52
  set -euo pipefail
53
 
@@ -55,6 +63,11 @@ HEADROOM_IMAGE="${HEADROOM_DOCKER_IMAGE:-ghcr.io/chopratejas/headroom:latest}"
55
  HEADROOM_CONTAINER_HOME="${HEADROOM_CONTAINER_HOME:-/tmp/headroom-home}"
56
  HEADROOM_HOST_HOME="${HOME:?}"
57
 
 
 
 
 
 
58
  warn() {
59
  printf 'WARN: %s\n' "$*" >&2
60
  }
@@ -168,7 +181,11 @@ wait_for_proxy() {
168
  local attempt
169
 
170
  for attempt in $(seq 1 45); do
171
- if (echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1; then
 
 
 
 
172
  return 0
173
  fi
174
 
@@ -209,6 +226,531 @@ stop_proxy_container() {
209
  fi
210
  }
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  run_claude_rtk_init() {
213
  local rtk_bin="${HEADROOM_HOST_HOME}/.headroom/bin/rtk"
214
  if [[ ! -x "${rtk_bin}" ]]; then
@@ -251,12 +793,15 @@ parse_wrap_args() {
251
  break
252
  ;;
253
  --port|-p)
 
254
  out_port="$2"
 
255
  out_known+=("$1" "$2")
256
  shift 2
257
  ;;
258
  --port=*)
259
  out_port="${1#*=}"
 
260
  out_known+=("$1")
261
  shift
262
  ;;
@@ -280,6 +825,7 @@ parse_wrap_args() {
280
  shift
281
  ;;
282
  --backend)
 
283
  out_backend="$2"
284
  out_known+=("$1" "$2")
285
  shift 2
@@ -290,6 +836,7 @@ parse_wrap_args() {
290
  shift
291
  ;;
292
  --anyllm-provider)
 
293
  out_anyllm="$2"
294
  out_known+=("$1" "$2")
295
  shift 2
@@ -300,6 +847,7 @@ parse_wrap_args() {
300
  shift
301
  ;;
302
  --region)
 
303
  out_region="$2"
304
  out_known+=("$1" "$2")
305
  shift 2
@@ -381,6 +929,7 @@ parse_openclaw_wrap_args() {
381
  while (($#)); do
382
  case "$1" in
383
  --plugin-path)
 
384
  out_plugin_path="$2"
385
  shift 2
386
  ;;
@@ -389,6 +938,7 @@ parse_openclaw_wrap_args() {
389
  shift
390
  ;;
391
  --plugin-spec)
 
392
  out_plugin_spec="$2"
393
  shift 2
394
  ;;
@@ -405,22 +955,29 @@ parse_openclaw_wrap_args() {
405
  shift
406
  ;;
407
  --proxy-port)
 
408
  out_proxy_port="$2"
 
409
  shift 2
410
  ;;
411
  --proxy-port=*)
412
  out_proxy_port="${1#*=}"
 
413
  shift
414
  ;;
415
  --startup-timeout-ms)
 
416
  out_startup_timeout_ms="$2"
 
417
  shift 2
418
  ;;
419
  --startup-timeout-ms=*)
420
  out_startup_timeout_ms="${1#*=}"
 
421
  shift
422
  ;;
423
  --gateway-provider-id)
 
424
  out_gateway_provider_ids+=("$2")
425
  shift 2
426
  ;;
@@ -429,6 +986,7 @@ parse_openclaw_wrap_args() {
429
  shift
430
  ;;
431
  --python-path)
 
432
  out_python_path="$2"
433
  shift 2
434
  ;;
@@ -795,6 +1353,57 @@ main() {
795
  fi
796
 
797
  case "$1" in
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
798
  wrap)
799
  if (($# == 1)) || [[ "$2" == "--help" || "$2" == "-?" ]]; then
800
  run_headroom wrap --help
@@ -911,12 +1520,15 @@ EOF
911
  while (($#)); do
912
  case "$1" in
913
  --port|-p)
 
914
  port="$2"
 
915
  args+=("$1" "$2")
916
  shift 2
917
  ;;
918
  --port=*)
919
  port="${1#*=}"
 
920
  args+=("$1")
921
  shift
922
  ;;
@@ -942,6 +1554,7 @@ EOF
942
 
943
  main "$@"
944
  WRAPPER
 
945
 
946
  chmod +x "${wrapper_path}"
947
  }
@@ -957,8 +1570,17 @@ main() {
957
  append_path_block "${HOME}/.zshrc"
958
  append_path_block "${HOME}/.profile"
959
 
960
- info "Pulling ${IMAGE_DEFAULT}"
961
- docker pull "${IMAGE_DEFAULT}" >/dev/null
 
 
 
 
 
 
 
 
 
962
 
963
  cat <<EOF
964
 
 
3
  set -euo pipefail
4
 
5
  IMAGE_DEFAULT="ghcr.io/chopratejas/headroom:latest"
6
+ INSTALL_IMAGE="${HEADROOM_DOCKER_IMAGE:-${IMAGE_DEFAULT}}"
7
  INSTALL_DIR="${HOME}/.local/bin"
8
  if [[ ! -d "${HOME}/.local" ]]; then
9
  INSTALL_DIR="${HOME}/bin"
10
  fi
11
 
12
+ BASH_PATH="${BASH:-$(command -v bash)}"
13
+ if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3))); then
14
+ printf 'ERROR: Headroom Docker-native install requires bash >= 4.3\n' >&2
15
+ exit 1
16
+ fi
17
+
18
  info() {
19
  printf '==> %s\n' "$*"
20
  }
 
53
  write_wrapper() {
54
  local wrapper_path="${INSTALL_DIR}/headroom"
55
 
56
+ {
57
+ printf '#!%s\n\n' "${BASH_PATH}"
58
+ cat <<'WRAPPER'
59
 
60
  set -euo pipefail
61
 
 
63
  HEADROOM_CONTAINER_HOME="${HEADROOM_CONTAINER_HOME:-/tmp/headroom-home}"
64
  HEADROOM_HOST_HOME="${HOME:?}"
65
 
66
+ if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3))); then
67
+ printf 'ERROR: Headroom Docker-native wrapper requires bash >= 4.3\n' >&2
68
+ exit 1
69
+ fi
70
+
71
  warn() {
72
  printf 'WARN: %s\n' "$*" >&2
73
  }
 
181
  local attempt
182
 
183
  for attempt in $(seq 1 45); do
184
+ if command -v curl >/dev/null 2>&1; then
185
+ if curl --fail --silent "http://127.0.0.1:${port}/readyz" >/dev/null; then
186
+ return 0
187
+ fi
188
+ elif (echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1; then
189
  return 0
190
  fi
191
 
 
226
  fi
227
  }
228
 
229
+ persistent_profile_root() {
230
+ local profile="$1"
231
+ validate_profile_name "${profile}"
232
+ printf '%s/.headroom/deploy/%s\n' "${HEADROOM_HOST_HOME}" "${profile}"
233
+ }
234
+
235
+ persistent_state_path() {
236
+ local profile="$1"
237
+ printf '%s/docker-native.env\n' "$(persistent_profile_root "${profile}")"
238
+ }
239
+
240
+ persistent_manifest_path() {
241
+ local profile="$1"
242
+ printf '%s/manifest.json\n' "$(persistent_profile_root "${profile}")"
243
+ }
244
+
245
+ persistent_container_name() {
246
+ local profile="$1"
247
+ validate_profile_name "${profile}"
248
+ printf 'headroom-%s\n' "${profile}"
249
+ }
250
+
251
+ validate_profile_name() {
252
+ local profile="$1"
253
+ [[ "${profile}" =~ ^[A-Za-z0-9._-]+$ ]] || die "Invalid profile name '${profile}'"
254
+ [[ "${profile}" != "." && "${profile}" != ".." ]] || die "Invalid profile name '${profile}'"
255
+ }
256
+
257
+ validate_port() {
258
+ local port="$1"
259
+ [[ "${port}" =~ ^[0-9]+$ ]] || die "Invalid port '${port}'"
260
+ ((10#${port} >= 1 && 10#${port} <= 65535)) || die "Invalid port '${port}'"
261
+ }
262
+
263
+ validate_positive_integer() {
264
+ local value="$1"
265
+ [[ "${value}" =~ ^[0-9]+$ ]] || die "Invalid value '${value}'"
266
+ ((10#${value} >= 1)) || die "Invalid value '${value}'"
267
+ }
268
+
269
+ require_option_value() {
270
+ (($# >= 2)) || die "Option $1 requires a value"
271
+ }
272
+
273
+ json_escape() {
274
+ local value="$1"
275
+ value="${value//\\/\\\\}"
276
+ value="${value//\"/\\\"}"
277
+ value="${value//$'\n'/\\n}"
278
+ printf '%s' "${value}"
279
+ }
280
+
281
+ json_array_from_args() {
282
+ local first=1
283
+ local arg
284
+ printf '['
285
+ for arg in "$@"; do
286
+ if [[ "${first}" -eq 0 ]]; then
287
+ printf ','
288
+ fi
289
+ first=0
290
+ printf '"%s"' "$(json_escape "${arg}")"
291
+ done
292
+ printf ']'
293
+ }
294
+
295
+ append_persistent_container_args() {
296
+ local -n ref=$1
297
+
298
+ ensure_host_dirs
299
+ ref+=(--workdir "${HEADROOM_CONTAINER_HOME}")
300
+ ref+=(--env "HOME=${HEADROOM_CONTAINER_HOME}")
301
+ ref+=(--env "PYTHONUNBUFFERED=1")
302
+ ref+=(-v "${HEADROOM_HOST_HOME}/.headroom:${HEADROOM_CONTAINER_HOME}/.headroom")
303
+ ref+=(-v "${HEADROOM_HOST_HOME}/.claude:${HEADROOM_CONTAINER_HOME}/.claude")
304
+ ref+=(-v "${HEADROOM_HOST_HOME}/.codex:${HEADROOM_CONTAINER_HOME}/.codex")
305
+ ref+=(-v "${HEADROOM_HOST_HOME}/.gemini:${HEADROOM_CONTAINER_HOME}/.gemini")
306
+
307
+ if command -v id >/dev/null 2>&1; then
308
+ ref+=(--user "$(id -u):$(id -g)")
309
+ fi
310
+
311
+ append_passthrough_envs "$1"
312
+ }
313
+
314
+ build_manifest_proxy_args() {
315
+ local -n out_args=$1
316
+ local port="$2"
317
+ local proxy_mode="$3"
318
+ local backend="$4"
319
+ local anyllm="$5"
320
+ local region="$6"
321
+ local memory_enabled="$7"
322
+ local telemetry_enabled="$8"
323
+
324
+ out_args=(--host 127.0.0.1 --port "${port}" --mode "${proxy_mode}" --backend "${backend}")
325
+ if [[ "${telemetry_enabled}" -eq 0 ]]; then
326
+ out_args+=(--no-telemetry)
327
+ fi
328
+ if [[ "${memory_enabled}" -eq 1 ]]; then
329
+ out_args+=(--memory --memory-db-path "${HEADROOM_CONTAINER_HOME}/.headroom/memory.db")
330
+ fi
331
+ if [[ -n "${anyllm}" ]]; then
332
+ out_args+=(--anyllm-provider "${anyllm}")
333
+ fi
334
+ if [[ -n "${region}" ]]; then
335
+ out_args+=(--region "${region}")
336
+ fi
337
+ }
338
+
339
+ write_persistent_state() {
340
+ local profile="$1"
341
+ local image="$2"
342
+ local port="$3"
343
+ local backend="$4"
344
+ local anyllm="$5"
345
+ local region="$6"
346
+ local proxy_mode="$7"
347
+ local memory_enabled="$8"
348
+ local telemetry_enabled="$9"
349
+
350
+ local root
351
+ root="$(persistent_profile_root "${profile}")"
352
+ mkdir -p "${root}"
353
+
354
+ {
355
+ printf 'PROFILE=%s\n' "${profile}"
356
+ printf 'IMAGE=%s\n' "${image}"
357
+ printf 'PORT=%s\n' "${port}"
358
+ printf 'BACKEND=%s\n' "${backend}"
359
+ printf 'ANYLLM_PROVIDER=%s\n' "${anyllm}"
360
+ printf 'REGION=%s\n' "${region}"
361
+ printf 'PROXY_MODE=%s\n' "${proxy_mode}"
362
+ printf 'MEMORY_ENABLED=%s\n' "${memory_enabled}"
363
+ printf 'TELEMETRY_ENABLED=%s\n' "${telemetry_enabled}"
364
+ printf 'CONTAINER_NAME=%s\n' "$(persistent_container_name "${profile}")"
365
+ printf 'HEALTH_URL=%s\n' "http://127.0.0.1:${port}/readyz"
366
+ } >"$(persistent_state_path "${profile}")"
367
+ }
368
+
369
+ write_persistent_manifest() {
370
+ local profile="$1"
371
+ local image="$2"
372
+ local port="$3"
373
+ local backend="$4"
374
+ local anyllm="$5"
375
+ local region="$6"
376
+ local proxy_mode="$7"
377
+ local memory_enabled="$8"
378
+ local telemetry_enabled="$9"
379
+ local -n proxy_args_ref=${10}
380
+
381
+ local root
382
+ local manifest_path
383
+ local anyllm_json="null"
384
+ local region_json="null"
385
+ local memory_json="false"
386
+ local telemetry_json="true"
387
+
388
+ root="$(persistent_profile_root "${profile}")"
389
+ manifest_path="$(persistent_manifest_path "${profile}")"
390
+ mkdir -p "${root}"
391
+
392
+ if [[ -n "${anyllm}" ]]; then
393
+ anyllm_json="\"$(json_escape "${anyllm}")\""
394
+ fi
395
+ if [[ -n "${region}" ]]; then
396
+ region_json="\"$(json_escape "${region}")\""
397
+ fi
398
+ if [[ "${memory_enabled}" -eq 1 ]]; then
399
+ memory_json="true"
400
+ fi
401
+ if [[ "${telemetry_enabled}" -eq 0 ]]; then
402
+ telemetry_json="false"
403
+ fi
404
+
405
+ cat >"${manifest_path}" <<EOF
406
+ {
407
+ "profile": "$(json_escape "${profile}")",
408
+ "preset": "persistent-docker",
409
+ "runtime_kind": "docker",
410
+ "supervisor_kind": "none",
411
+ "scope": "user",
412
+ "provider_mode": "manual",
413
+ "targets": [],
414
+ "port": ${port},
415
+ "host": "127.0.0.1",
416
+ "backend": "$(json_escape "${backend}")",
417
+ "anyllm_provider": ${anyllm_json},
418
+ "region": ${region_json},
419
+ "proxy_mode": "$(json_escape "${proxy_mode}")",
420
+ "memory_enabled": ${memory_json},
421
+ "memory_db_path": "$(json_escape "${HEADROOM_CONTAINER_HOME}/.headroom/memory.db")",
422
+ "telemetry_enabled": ${telemetry_json},
423
+ "image": "$(json_escape "${image}")",
424
+ "service_name": "headroom-$(json_escape "${profile}")",
425
+ "container_name": "$(json_escape "$(persistent_container_name "${profile}")")",
426
+ "health_url": "http://127.0.0.1:${port}/readyz",
427
+ "base_env": {
428
+ "HEADROOM_PORT": "${port}",
429
+ "HEADROOM_HOST": "127.0.0.1",
430
+ "HEADROOM_MODE": "$(json_escape "${proxy_mode}")",
431
+ "HEADROOM_BACKEND": "$(json_escape "${backend}")"
432
+ },
433
+ "tool_envs": {},
434
+ "proxy_args": $(json_array_from_args "${proxy_args_ref[@]}"),
435
+ "mutations": [],
436
+ "artifacts": []
437
+ }
438
+ EOF
439
+ }
440
+
441
+ load_persistent_state() {
442
+ local profile="$1"
443
+ local state_path
444
+ validate_profile_name "${profile}"
445
+ state_path="$(persistent_state_path "${profile}")"
446
+ [[ -f "${state_path}" ]] || die "No docker-native persistent deployment profile named '${profile}'"
447
+ PROFILE=""
448
+ IMAGE=""
449
+ PORT=""
450
+ BACKEND=""
451
+ ANYLLM_PROVIDER=""
452
+ REGION=""
453
+ PROXY_MODE=""
454
+ MEMORY_ENABLED=""
455
+ TELEMETRY_ENABLED=""
456
+ CONTAINER_NAME=""
457
+ HEALTH_URL=""
458
+ while IFS='=' read -r key value; do
459
+ case "${key}" in
460
+ PROFILE|IMAGE|PORT|BACKEND|ANYLLM_PROVIDER|REGION|PROXY_MODE|MEMORY_ENABLED|TELEMETRY_ENABLED|CONTAINER_NAME|HEALTH_URL)
461
+ printf -v "${key}" '%s' "${value}"
462
+ ;;
463
+ esac
464
+ done <"${state_path}"
465
+ }
466
+
467
+ start_persistent_docker_install() {
468
+ local profile="$1"
469
+ local image="$2"
470
+ local port="$3"
471
+ local backend="$4"
472
+ local anyllm="$5"
473
+ local region="$6"
474
+ local proxy_mode="$7"
475
+ local memory_enabled="$8"
476
+ local telemetry_enabled="$9"
477
+
478
+ local container_name
479
+ local proxy_args=()
480
+ local args=()
481
+
482
+ validate_profile_name "${profile}"
483
+ container_name="$(persistent_container_name "${profile}")"
484
+ build_manifest_proxy_args proxy_args "${port}" "${proxy_mode}" "${backend}" "${anyllm}" "${region}" "${memory_enabled}" "${telemetry_enabled}"
485
+
486
+ docker rm -f "${container_name}" >/dev/null 2>&1 || true
487
+
488
+ args=(docker run -d --restart unless-stopped --name "${container_name}" -p "${port}:${port}")
489
+ append_persistent_container_args args
490
+ args+=("${image}" --host 0.0.0.0 "${proxy_args[@]:2}")
491
+ "${args[@]}" >/dev/null
492
+
493
+ if ! wait_for_proxy "${container_name}" "${port}"; then
494
+ docker rm -f "${container_name}" >/dev/null 2>&1 || true
495
+ die "Headroom persistent Docker deployment failed to start on port ${port}"
496
+ fi
497
+
498
+ write_persistent_state "${profile}" "${image}" "${port}" "${backend}" "${anyllm}" "${region}" "${proxy_mode}" "${memory_enabled}" "${telemetry_enabled}"
499
+ write_persistent_manifest "${profile}" "${image}" "${port}" "${backend}" "${anyllm}" "${region}" "${proxy_mode}" "${memory_enabled}" "${telemetry_enabled}" proxy_args
500
+ }
501
+
502
+ stop_persistent_docker_install() {
503
+ local profile="$1"
504
+ local container_name
505
+
506
+ load_persistent_state "${profile}"
507
+ container_name="${CONTAINER_NAME}"
508
+ docker stop "${container_name}" >/dev/null 2>&1 || true
509
+ docker rm -f "${container_name}" >/dev/null 2>&1 || true
510
+ }
511
+
512
+ status_persistent_docker_install() {
513
+ local profile="$1"
514
+ local status="stopped"
515
+ local ready="no"
516
+
517
+ load_persistent_state "${profile}"
518
+ if docker_container_exists "${CONTAINER_NAME}"; then
519
+ status="running"
520
+ if command -v curl >/dev/null 2>&1; then
521
+ if curl --fail --silent "${HEALTH_URL}" >/dev/null; then
522
+ ready="yes"
523
+ fi
524
+ elif (echo >/dev/tcp/127.0.0.1/"${PORT}") >/dev/null 2>&1; then
525
+ ready="yes"
526
+ fi
527
+ fi
528
+
529
+ printf 'Profile: %s\n' "${PROFILE}"
530
+ printf 'Preset: persistent-docker\n'
531
+ printf 'Runtime: docker\n'
532
+ printf 'Supervisor: none\n'
533
+ printf 'Port: %s\n' "${PORT}"
534
+ printf 'Status: %s\n' "${status}"
535
+ printf 'Ready: %s\n' "${ready}"
536
+ printf 'Health URL: %s\n' "${HEALTH_URL}"
537
+ }
538
+
539
+ remove_persistent_docker_install() {
540
+ local profile="$1"
541
+ local root
542
+
543
+ load_persistent_state "${profile}"
544
+ docker stop "${CONTAINER_NAME}" >/dev/null 2>&1 || true
545
+ docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
546
+ root="$(persistent_profile_root "${profile}")"
547
+ rm -rf "${root}"
548
+ }
549
+
550
+ print_install_help() {
551
+ cat <<'EOF'
552
+ Usage: headroom install [OPTIONS] COMMAND [ARGS]...
553
+
554
+ Manage persistent Docker-native Headroom deployments.
555
+
556
+ The Docker-native wrapper currently supports the persistent-docker preset only.
557
+ Use the Python-native `headroom install` command for persistent-service and
558
+ persistent-task installs, or when you need provider/user/system config mutation.
559
+
560
+ Options:
561
+ -?, --help Show this message and exit.
562
+
563
+ Commands:
564
+ apply Install a persistent Docker deployment.
565
+ remove Remove a persistent Docker deployment.
566
+ restart Restart a persistent Docker deployment.
567
+ start Start a persistent Docker deployment.
568
+ status Show persistent Docker deployment status.
569
+ stop Stop a persistent Docker deployment.
570
+ EOF
571
+ }
572
+
573
+ print_install_apply_help() {
574
+ cat <<'EOF'
575
+ Usage: headroom install apply [OPTIONS]
576
+
577
+ Install a persistent Docker deployment.
578
+
579
+ Options:
580
+ --preset [persistent-docker] Docker-native wrapper supports persistent-docker only.
581
+ --runtime [docker] Docker-native wrapper supports runtime=docker only.
582
+ --profile TEXT Deployment profile name. [default: default]
583
+ -p, --port INTEGER Persistent proxy port. [default: 8787]
584
+ --backend TEXT Proxy backend. [default: anthropic]
585
+ --anyllm-provider TEXT Provider for any-llm backends.
586
+ --region TEXT Cloud region for Bedrock / Vertex style backends.
587
+ --mode TEXT Proxy optimization mode. [default: token]
588
+ --memory Enable persistent memory in the runtime.
589
+ --no-telemetry Disable anonymous telemetry in the runtime.
590
+ --image TEXT Docker image to use. [default: HEADROOM_DOCKER_IMAGE or ghcr.io/chopratejas/headroom:latest]
591
+ -?, --help Show this message and exit.
592
+ EOF
593
+ }
594
+
595
+ parse_install_apply_args() {
596
+ local -n out_profile=$1
597
+ local -n out_port=$2
598
+ local -n out_backend=$3
599
+ local -n out_anyllm=$4
600
+ local -n out_region=$5
601
+ local -n out_mode=$6
602
+ local -n out_memory=$7
603
+ local -n out_telemetry=$8
604
+ local -n out_image=$9
605
+ shift 9
606
+
607
+ out_profile="default"
608
+ out_port=8787
609
+ out_backend="anthropic"
610
+ out_anyllm=""
611
+ out_region=""
612
+ out_mode="token"
613
+ out_memory=0
614
+ out_telemetry=1
615
+ out_image="${HEADROOM_IMAGE}"
616
+
617
+ while (($#)); do
618
+ case "$1" in
619
+ --preset)
620
+ require_option_value "$@"
621
+ [[ "$2" == "persistent-docker" ]] || die "Docker-native wrapper supports only --preset persistent-docker"
622
+ shift 2
623
+ ;;
624
+ --preset=*)
625
+ [[ "${1#*=}" == "persistent-docker" ]] || die "Docker-native wrapper supports only --preset persistent-docker"
626
+ shift
627
+ ;;
628
+ --runtime)
629
+ require_option_value "$@"
630
+ [[ "$2" == "docker" ]] || die "Docker-native wrapper supports only --runtime docker"
631
+ shift 2
632
+ ;;
633
+ --runtime=*)
634
+ [[ "${1#*=}" == "docker" ]] || die "Docker-native wrapper supports only --runtime docker"
635
+ shift
636
+ ;;
637
+ --scope|--providers|--target)
638
+ die "Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows"
639
+ ;;
640
+ --scope=*|--providers=*|--target=*)
641
+ die "Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows"
642
+ ;;
643
+ --profile)
644
+ require_option_value "$@"
645
+ out_profile="$2"
646
+ shift 2
647
+ ;;
648
+ --profile=*)
649
+ out_profile="${1#*=}"
650
+ shift
651
+ ;;
652
+ --port|-p)
653
+ require_option_value "$@"
654
+ out_port="$2"
655
+ shift 2
656
+ ;;
657
+ --port=*|-p=*)
658
+ out_port="${1#*=}"
659
+ shift
660
+ ;;
661
+ --backend)
662
+ require_option_value "$@"
663
+ out_backend="$2"
664
+ shift 2
665
+ ;;
666
+ --backend=*)
667
+ out_backend="${1#*=}"
668
+ shift
669
+ ;;
670
+ --anyllm-provider)
671
+ require_option_value "$@"
672
+ out_anyllm="$2"
673
+ shift 2
674
+ ;;
675
+ --anyllm-provider=*)
676
+ out_anyllm="${1#*=}"
677
+ shift
678
+ ;;
679
+ --region)
680
+ require_option_value "$@"
681
+ out_region="$2"
682
+ shift 2
683
+ ;;
684
+ --region=*)
685
+ out_region="${1#*=}"
686
+ shift
687
+ ;;
688
+ --mode)
689
+ require_option_value "$@"
690
+ out_mode="$2"
691
+ shift 2
692
+ ;;
693
+ --mode=*)
694
+ out_mode="${1#*=}"
695
+ shift
696
+ ;;
697
+ --memory)
698
+ out_memory=1
699
+ shift
700
+ ;;
701
+ --no-telemetry)
702
+ out_telemetry=0
703
+ shift
704
+ ;;
705
+ --image)
706
+ require_option_value "$@"
707
+ out_image="$2"
708
+ shift 2
709
+ ;;
710
+ --image=*)
711
+ out_image="${1#*=}"
712
+ shift
713
+ ;;
714
+ --help|-?)
715
+ print_install_apply_help
716
+ exit 0
717
+ ;;
718
+ *)
719
+ die "Unsupported option for 'headroom install apply': $1"
720
+ ;;
721
+ esac
722
+ done
723
+
724
+ validate_port "${out_port}"
725
+ }
726
+
727
+ parse_install_profile_arg() {
728
+ local -n out_profile=$1
729
+ shift
730
+
731
+ out_profile="default"
732
+ while (($#)); do
733
+ case "$1" in
734
+ --profile)
735
+ require_option_value "$@"
736
+ out_profile="$2"
737
+ shift 2
738
+ ;;
739
+ --profile=*)
740
+ out_profile="${1#*=}"
741
+ shift
742
+ ;;
743
+ --help|-?)
744
+ print_install_help
745
+ exit 0
746
+ ;;
747
+ *)
748
+ die "Unsupported option for 'headroom install': $1"
749
+ ;;
750
+ esac
751
+ done
752
+ }
753
+
754
  run_claude_rtk_init() {
755
  local rtk_bin="${HEADROOM_HOST_HOME}/.headroom/bin/rtk"
756
  if [[ ! -x "${rtk_bin}" ]]; then
 
793
  break
794
  ;;
795
  --port|-p)
796
+ require_option_value "$@"
797
  out_port="$2"
798
+ validate_port "${out_port}"
799
  out_known+=("$1" "$2")
800
  shift 2
801
  ;;
802
  --port=*)
803
  out_port="${1#*=}"
804
+ validate_port "${out_port}"
805
  out_known+=("$1")
806
  shift
807
  ;;
 
825
  shift
826
  ;;
827
  --backend)
828
+ require_option_value "$@"
829
  out_backend="$2"
830
  out_known+=("$1" "$2")
831
  shift 2
 
836
  shift
837
  ;;
838
  --anyllm-provider)
839
+ require_option_value "$@"
840
  out_anyllm="$2"
841
  out_known+=("$1" "$2")
842
  shift 2
 
847
  shift
848
  ;;
849
  --region)
850
+ require_option_value "$@"
851
  out_region="$2"
852
  out_known+=("$1" "$2")
853
  shift 2
 
929
  while (($#)); do
930
  case "$1" in
931
  --plugin-path)
932
+ require_option_value "$@"
933
  out_plugin_path="$2"
934
  shift 2
935
  ;;
 
938
  shift
939
  ;;
940
  --plugin-spec)
941
+ require_option_value "$@"
942
  out_plugin_spec="$2"
943
  shift 2
944
  ;;
 
955
  shift
956
  ;;
957
  --proxy-port)
958
+ require_option_value "$@"
959
  out_proxy_port="$2"
960
+ validate_port "${out_proxy_port}"
961
  shift 2
962
  ;;
963
  --proxy-port=*)
964
  out_proxy_port="${1#*=}"
965
+ validate_port "${out_proxy_port}"
966
  shift
967
  ;;
968
  --startup-timeout-ms)
969
+ require_option_value "$@"
970
  out_startup_timeout_ms="$2"
971
+ validate_positive_integer "${out_startup_timeout_ms}"
972
  shift 2
973
  ;;
974
  --startup-timeout-ms=*)
975
  out_startup_timeout_ms="${1#*=}"
976
+ validate_positive_integer "${out_startup_timeout_ms}"
977
  shift
978
  ;;
979
  --gateway-provider-id)
980
+ require_option_value "$@"
981
  out_gateway_provider_ids+=("$2")
982
  shift 2
983
  ;;
 
986
  shift
987
  ;;
988
  --python-path)
989
+ require_option_value "$@"
990
  out_python_path="$2"
991
  shift 2
992
  ;;
 
1353
  fi
1354
 
1355
  case "$1" in
1356
+ install)
1357
+ if (($# == 1)) || [[ "$2" == "--help" || "$2" == "-?" ]]; then
1358
+ print_install_help
1359
+ return
1360
+ fi
1361
+
1362
+ local install_command="$2"
1363
+ shift 2
1364
+ case "${install_command}" in
1365
+ apply)
1366
+ local profile port backend anyllm region proxy_mode memory_enabled telemetry_enabled image
1367
+ parse_install_apply_args profile port backend anyllm region proxy_mode memory_enabled telemetry_enabled image "$@"
1368
+ start_persistent_docker_install "${profile}" "${image}" "${port}" "${backend}" "${anyllm}" "${region}" "${proxy_mode}" "${memory_enabled}" "${telemetry_enabled}"
1369
+ printf "Installed docker-native persistent deployment '%s' on port %s.\n" "${profile}" "${port}"
1370
+ ;;
1371
+ status)
1372
+ local profile
1373
+ parse_install_profile_arg profile "$@"
1374
+ status_persistent_docker_install "${profile}"
1375
+ ;;
1376
+ start)
1377
+ local profile
1378
+ parse_install_profile_arg profile "$@"
1379
+ load_persistent_state "${profile}"
1380
+ start_persistent_docker_install "${PROFILE}" "${IMAGE}" "${PORT}" "${BACKEND}" "${ANYLLM_PROVIDER}" "${REGION}" "${PROXY_MODE}" "${MEMORY_ENABLED}" "${TELEMETRY_ENABLED}"
1381
+ printf "Started docker-native persistent deployment '%s'.\n" "${profile}"
1382
+ ;;
1383
+ stop)
1384
+ local profile
1385
+ parse_install_profile_arg profile "$@"
1386
+ stop_persistent_docker_install "${profile}"
1387
+ printf "Stopped docker-native persistent deployment '%s'.\n" "${profile}"
1388
+ ;;
1389
+ restart)
1390
+ local profile
1391
+ parse_install_profile_arg profile "$@"
1392
+ load_persistent_state "${profile}"
1393
+ start_persistent_docker_install "${PROFILE}" "${IMAGE}" "${PORT}" "${BACKEND}" "${ANYLLM_PROVIDER}" "${REGION}" "${PROXY_MODE}" "${MEMORY_ENABLED}" "${TELEMETRY_ENABLED}"
1394
+ printf "Restarted docker-native persistent deployment '%s'.\n" "${profile}"
1395
+ ;;
1396
+ remove)
1397
+ local profile
1398
+ parse_install_profile_arg profile "$@"
1399
+ remove_persistent_docker_install "${profile}"
1400
+ printf "Removed docker-native persistent deployment '%s'.\n" "${profile}"
1401
+ ;;
1402
+ *)
1403
+ die "Unsupported install target: ${install_command}"
1404
+ ;;
1405
+ esac
1406
+ ;;
1407
  wrap)
1408
  if (($# == 1)) || [[ "$2" == "--help" || "$2" == "-?" ]]; then
1409
  run_headroom wrap --help
 
1520
  while (($#)); do
1521
  case "$1" in
1522
  --port|-p)
1523
+ require_option_value "$@"
1524
  port="$2"
1525
+ validate_port "${port}"
1526
  args+=("$1" "$2")
1527
  shift 2
1528
  ;;
1529
  --port=*)
1530
  port="${1#*=}"
1531
+ validate_port "${port}"
1532
  args+=("$1")
1533
  shift
1534
  ;;
 
1554
 
1555
  main "$@"
1556
  WRAPPER
1557
+ } >"${wrapper_path}"
1558
 
1559
  chmod +x "${wrapper_path}"
1560
  }
 
1570
  append_path_block "${HOME}/.zshrc"
1571
  append_path_block "${HOME}/.profile"
1572
 
1573
+ if [[ -n "${HEADROOM_DOCKER_IMAGE:-}" ]]; then
1574
+ if docker image inspect "${INSTALL_IMAGE}" >/dev/null 2>&1; then
1575
+ info "Using existing HEADROOM_DOCKER_IMAGE=${INSTALL_IMAGE}"
1576
+ else
1577
+ info "Pulling ${INSTALL_IMAGE}"
1578
+ docker pull "${INSTALL_IMAGE}" >/dev/null
1579
+ fi
1580
+ else
1581
+ info "Pulling ${IMAGE_DEFAULT}"
1582
+ docker pull "${IMAGE_DEFAULT}" >/dev/null
1583
+ fi
1584
 
1585
  cat <<EOF
1586
 
tests/test_install/test_native_installers.py ADDED
@@ -0,0 +1,731 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import signal
7
+ import socket
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+ REPO_ROOT = Path(__file__).resolve().parents[2]
15
+
16
+ FAKE_DOCKER = r"""
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import signal
22
+ import subprocess
23
+ import sys
24
+ from pathlib import Path
25
+
26
+
27
+ STATE_PATH = Path(os.environ["FAKE_DOCKER_STATE"])
28
+ LOG_PATH = Path(os.environ["FAKE_DOCKER_LOG"])
29
+
30
+
31
+ def load_state() -> dict[str, dict[str, dict[str, int]]]:
32
+ if not STATE_PATH.exists():
33
+ return {"containers": {}}
34
+ return json.loads(STATE_PATH.read_text(encoding="utf-8"))
35
+
36
+
37
+ def save_state(state: dict[str, dict[str, dict[str, int]]]) -> None:
38
+ STATE_PATH.write_text(json.dumps(state), encoding="utf-8")
39
+
40
+
41
+ def cleanup_dead(state: dict[str, dict[str, dict[str, int]]]) -> dict[str, dict[str, dict[str, int]]]:
42
+ save_state(state)
43
+ return state
44
+
45
+
46
+ def host_port_from_publish(value: str) -> int:
47
+ parts = value.split(":")
48
+ if len(parts) == 2:
49
+ return int(parts[0])
50
+ if len(parts) >= 3:
51
+ return int(parts[-2])
52
+ raise ValueError(f"Unsupported publish value: {value}")
53
+
54
+
55
+ def start_server(port: int) -> int:
56
+ code = '''
57
+ import sys
58
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
59
+
60
+ port = int(sys.argv[1])
61
+
62
+ class Handler(BaseHTTPRequestHandler):
63
+ def do_GET(self):
64
+ self.send_response(200)
65
+ self.send_header("Content-Type", "text/plain")
66
+ self.end_headers()
67
+ self.wfile.write(b"ok")
68
+
69
+ def log_message(self, fmt, *args):
70
+ return
71
+
72
+ ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()
73
+ '''
74
+ process = subprocess.Popen(
75
+ [sys.executable, "-c", code, str(port)],
76
+ stdout=subprocess.DEVNULL,
77
+ stderr=subprocess.DEVNULL,
78
+ )
79
+ return process.pid
80
+
81
+
82
+ def stop_container(state: dict[str, dict[str, dict[str, int]]], name: str) -> None:
83
+ data = state["containers"].pop(name, None)
84
+ if not data:
85
+ return
86
+ try:
87
+ os.kill(int(data["pid"]), signal.SIGTERM)
88
+ except OSError:
89
+ pass
90
+ save_state(state)
91
+
92
+
93
+ def main() -> int:
94
+ args = sys.argv[1:]
95
+ LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
96
+ with LOG_PATH.open("a", encoding="utf-8") as handle:
97
+ handle.write(json.dumps(args) + "\n")
98
+
99
+ if not args:
100
+ return 0
101
+
102
+ state = cleanup_dead(load_state())
103
+ command = args[0]
104
+
105
+ if command == "pull":
106
+ return 0
107
+
108
+ if command == "run":
109
+ detached = "-d" in args
110
+ if not detached:
111
+ return 0
112
+
113
+ name = None
114
+ publish = None
115
+ for index, arg in enumerate(args):
116
+ if arg == "--name":
117
+ name = args[index + 1]
118
+ elif arg == "-p":
119
+ publish = args[index + 1]
120
+
121
+ if name is None or publish is None:
122
+ raise SystemExit("missing --name or -p in fake docker run")
123
+
124
+ port = host_port_from_publish(publish)
125
+ state["containers"][name] = {"pid": start_server(port), "port": port}
126
+ save_state(state)
127
+ print(name)
128
+ return 0
129
+
130
+ if command == "ps":
131
+ names = sorted(state["containers"])
132
+ if "--format" in args:
133
+ print("\n".join(names))
134
+ return 0
135
+
136
+ if command == "stop":
137
+ for name in args[1:]:
138
+ if not name.startswith("-"):
139
+ stop_container(state, name)
140
+ return 0
141
+
142
+ if command == "rm":
143
+ for name in args[1:]:
144
+ if not name.startswith("-"):
145
+ stop_container(state, name)
146
+ return 0
147
+
148
+ if command == "logs":
149
+ if len(args) > 1:
150
+ print(f"fake logs for {args[1]}")
151
+ return 0
152
+
153
+ return 0
154
+
155
+
156
+ if __name__ == "__main__":
157
+ raise SystemExit(main())
158
+ """
159
+
160
+
161
+ def _free_port() -> int:
162
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
163
+ sock.bind(("127.0.0.1", 0))
164
+ return int(sock.getsockname()[1])
165
+
166
+
167
+ def _write_fake_docker_shims(tmp_path: Path) -> Path:
168
+ shim_dir = tmp_path / "fake-docker"
169
+ shim_dir.mkdir()
170
+
171
+ fake_docker = shim_dir / "fake_docker.py"
172
+ fake_docker.write_text(FAKE_DOCKER, encoding="utf-8")
173
+
174
+ docker_sh = shim_dir / "docker"
175
+ docker_sh.write_text(
176
+ f'#!/usr/bin/env bash\nexec "{sys.executable}" "{fake_docker}" "$@"\n',
177
+ encoding="utf-8",
178
+ )
179
+ docker_sh.chmod(0o755)
180
+
181
+ docker_cmd = shim_dir / "docker.cmd"
182
+ docker_cmd.write_text(
183
+ f'@echo off\r\n"{sys.executable}" "{fake_docker}" %*\r\n',
184
+ encoding="utf-8",
185
+ )
186
+
187
+ openclaw_sh = shim_dir / "openclaw"
188
+ openclaw_sh.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
189
+ openclaw_sh.chmod(0o755)
190
+
191
+ openclaw_cmd = shim_dir / "openclaw.cmd"
192
+ openclaw_cmd.write_text("@echo off\r\nexit /b 0\r\n", encoding="utf-8")
193
+
194
+ return shim_dir
195
+
196
+
197
+ def _build_env(home: Path, tmp_path: Path) -> dict[str, str]:
198
+ env = os.environ.copy()
199
+ shim_dir = _write_fake_docker_shims(tmp_path)
200
+ env["HOME"] = str(home)
201
+ env["USERPROFILE"] = str(home)
202
+ env["PATH"] = str(shim_dir) + os.pathsep + env.get("PATH", "")
203
+ env["FAKE_DOCKER_STATE"] = str(tmp_path / "fake-docker-state.json")
204
+ env["FAKE_DOCKER_LOG"] = str(tmp_path / "fake-docker.log")
205
+ return env
206
+
207
+
208
+ def _cleanup_fake_docker(env: dict[str, str]) -> None:
209
+ state_path = Path(env["FAKE_DOCKER_STATE"])
210
+ if not state_path.exists():
211
+ return
212
+
213
+ state = json.loads(state_path.read_text(encoding="utf-8"))
214
+ for container in state.get("containers", {}).values():
215
+ try:
216
+ os.kill(int(container["pid"]), signal.SIGTERM)
217
+ except OSError:
218
+ pass
219
+
220
+
221
+ def _read_fake_docker_log(env: dict[str, str]) -> list[list[str]]:
222
+ log_path = Path(env["FAKE_DOCKER_LOG"])
223
+ if not log_path.exists():
224
+ return []
225
+ return [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines() if line]
226
+
227
+
228
+ def _run(
229
+ command: list[str],
230
+ *,
231
+ env: dict[str, str],
232
+ cwd: Path | None = None,
233
+ check: bool = True,
234
+ ) -> subprocess.CompletedProcess[str]:
235
+ return subprocess.run(
236
+ command,
237
+ cwd=cwd,
238
+ env=env,
239
+ capture_output=True,
240
+ text=True,
241
+ check=check,
242
+ )
243
+
244
+
245
+ @pytest.mark.skipif(
246
+ os.name == "nt" or shutil.which("bash") is None,
247
+ reason="bash installer coverage runs on non-Windows hosts",
248
+ )
249
+ def test_bash_native_installer_supports_persistent_docker_lifecycle(tmp_path: Path) -> None:
250
+ home = tmp_path / "home"
251
+ (home / ".local").mkdir(parents=True)
252
+ env = _build_env(home, tmp_path)
253
+
254
+ try:
255
+ _run(["bash", str(REPO_ROOT / "scripts" / "install.sh")], env=env, cwd=REPO_ROOT)
256
+
257
+ wrapper = home / ".local" / "bin" / "headroom"
258
+ assert wrapper.exists()
259
+
260
+ help_result = _run([str(wrapper), "install", "-?"], env=env)
261
+ assert "persistent-docker preset only" in help_result.stdout
262
+ _run([str(wrapper), "--help"], env=env)
263
+
264
+ invalid_profile = _run(
265
+ [str(wrapper), "install", "status", "--profile", ".."],
266
+ env=env,
267
+ check=False,
268
+ )
269
+ assert invalid_profile.returncode != 0
270
+ assert "Invalid profile name '..'" in invalid_profile.stderr
271
+ missing_profile_value = _run(
272
+ [str(wrapper), "install", "apply", "--profile"],
273
+ env=env,
274
+ check=False,
275
+ )
276
+ assert missing_profile_value.returncode != 0
277
+ assert "Option --profile requires a value" in missing_profile_value.stderr
278
+ missing_proxy_port = _run(
279
+ [str(wrapper), "proxy", "--port"],
280
+ env=env,
281
+ check=False,
282
+ )
283
+ assert missing_proxy_port.returncode != 0
284
+ assert "Option --port requires a value" in missing_proxy_port.stderr
285
+ invalid_proxy_port = _run(
286
+ [str(wrapper), "proxy", "--port", "abc"],
287
+ env=env,
288
+ check=False,
289
+ )
290
+ assert invalid_proxy_port.returncode != 0
291
+ assert "Invalid port 'abc'" in invalid_proxy_port.stderr
292
+ missing_wrap_port = _run(
293
+ [str(wrapper), "wrap", "claude", "--port"],
294
+ env=env,
295
+ check=False,
296
+ )
297
+ assert missing_wrap_port.returncode != 0
298
+ assert "Option --port requires a value" in missing_wrap_port.stderr
299
+ invalid_wrap_port = _run(
300
+ [str(wrapper), "wrap", "claude", "--port", "abc"],
301
+ env=env,
302
+ check=False,
303
+ )
304
+ assert invalid_wrap_port.returncode != 0
305
+ assert "Invalid port 'abc'" in invalid_wrap_port.stderr
306
+ missing_openclaw_proxy_port = _run(
307
+ [str(wrapper), "wrap", "openclaw", "--proxy-port"],
308
+ env=env,
309
+ check=False,
310
+ )
311
+ assert missing_openclaw_proxy_port.returncode != 0
312
+ assert "Option --proxy-port requires a value" in missing_openclaw_proxy_port.stderr
313
+ invalid_openclaw_proxy_port = _run(
314
+ [str(wrapper), "wrap", "openclaw", "--proxy-port", "abc"],
315
+ env=env,
316
+ check=False,
317
+ )
318
+ assert invalid_openclaw_proxy_port.returncode != 0
319
+ assert "Invalid port 'abc'" in invalid_openclaw_proxy_port.stderr
320
+ for invalid_port in ("abc", "0", "65536"):
321
+ invalid_port_result = _run(
322
+ [str(wrapper), "install", "apply", "--port", invalid_port],
323
+ env=env,
324
+ check=False,
325
+ )
326
+ assert invalid_port_result.returncode != 0
327
+ assert f"Invalid port '{invalid_port}'" in invalid_port_result.stderr
328
+
329
+ port = _free_port()
330
+ _run(
331
+ [
332
+ str(wrapper),
333
+ "install",
334
+ "apply",
335
+ "--profile",
336
+ "smoke",
337
+ "--port",
338
+ str(port),
339
+ "--memory",
340
+ "--no-telemetry",
341
+ "--image",
342
+ "fake/headroom:test",
343
+ ],
344
+ env=env,
345
+ )
346
+
347
+ manifest_path = home / ".headroom" / "deploy" / "smoke" / "manifest.json"
348
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
349
+ assert manifest["preset"] == "persistent-docker"
350
+ assert manifest["port"] == port
351
+ assert manifest["memory_enabled"] is True
352
+ assert manifest["memory_db_path"] == "/tmp/headroom-home/.headroom/memory.db"
353
+ assert manifest["telemetry_enabled"] is False
354
+
355
+ state_path = home / ".headroom" / "deploy" / "smoke" / "docker-native.env"
356
+ state_text = state_path.read_text(encoding="utf-8")
357
+ assert f"PORT={port!r}" in state_text
358
+
359
+ docker_calls = _read_fake_docker_log(env)
360
+ help_call = next(
361
+ call
362
+ for call in docker_calls
363
+ if call[:2] == ["run", "--rm"] and "--entrypoint" in call and "--help" in call
364
+ )
365
+ assert "-it" not in help_call
366
+ install_call = next(
367
+ call for call in docker_calls if call[:2] == ["run", "-d"] and "--name" in call
368
+ )
369
+ assert "/tmp/headroom-home/.headroom/memory.db" in install_call
370
+
371
+ status_result = _run(
372
+ [str(wrapper), "install", "status", "--profile", "smoke"],
373
+ env=env,
374
+ )
375
+ assert "Status: running" in status_result.stdout
376
+
377
+ _run([str(wrapper), "install", "stop", "--profile", "smoke"], env=env)
378
+ stopped_result = _run(
379
+ [str(wrapper), "install", "status", "--profile", "smoke"],
380
+ env=env,
381
+ )
382
+ assert "Status: stopped" in stopped_result.stdout
383
+
384
+ _run([str(wrapper), "install", "start", "--profile", "smoke"], env=env)
385
+ restarted_result = _run(
386
+ [str(wrapper), "install", "status", "--profile", "smoke"],
387
+ env=env,
388
+ )
389
+ assert "Status: running" in restarted_result.stdout
390
+
391
+ rejected = _run(
392
+ [str(wrapper), "install", "apply", "--scope", "user"],
393
+ env=env,
394
+ check=False,
395
+ )
396
+ assert rejected.returncode != 0
397
+ assert "does not support provider/user/system mutation flags" in rejected.stderr
398
+
399
+ _run([str(wrapper), "install", "restart", "--profile", "smoke"], env=env)
400
+ _run([str(wrapper), "install", "remove", "--profile", "smoke"], env=env)
401
+ assert not manifest_path.parent.exists()
402
+ finally:
403
+ _cleanup_fake_docker(env)
404
+
405
+
406
+ def _powershell_executable() -> str | None:
407
+ return shutil.which("pwsh") or shutil.which("powershell") or shutil.which("powershell.exe")
408
+
409
+
410
+ @pytest.mark.skipif(
411
+ os.name != "nt" or _powershell_executable() is None,
412
+ reason="Windows PowerShell coverage runs on Windows hosts only",
413
+ )
414
+ def test_powershell_native_installer_supports_persistent_docker_lifecycle(tmp_path: Path) -> None:
415
+ powershell = _powershell_executable()
416
+ assert powershell is not None
417
+
418
+ home = tmp_path / "home"
419
+ (home / ".local").mkdir(parents=True)
420
+ env = _build_env(home, tmp_path)
421
+
422
+ try:
423
+ _run(
424
+ [
425
+ powershell,
426
+ "-NoProfile",
427
+ "-ExecutionPolicy",
428
+ "Bypass",
429
+ "-File",
430
+ str(REPO_ROOT / "scripts" / "install.ps1"),
431
+ ],
432
+ env=env,
433
+ cwd=REPO_ROOT,
434
+ )
435
+
436
+ wrapper = home / ".local" / "bin" / "headroom.ps1"
437
+ assert wrapper.exists()
438
+ cmd_wrapper = home / ".local" / "bin" / "headroom.cmd"
439
+ assert cmd_wrapper.exists()
440
+
441
+ help_result = _run(
442
+ [
443
+ powershell,
444
+ "-NoProfile",
445
+ "-ExecutionPolicy",
446
+ "Bypass",
447
+ "-File",
448
+ str(wrapper),
449
+ "install",
450
+ "-?",
451
+ ],
452
+ env=env,
453
+ )
454
+ _run(
455
+ [
456
+ powershell,
457
+ "-NoProfile",
458
+ "-ExecutionPolicy",
459
+ "Bypass",
460
+ "-File",
461
+ str(wrapper),
462
+ "proxy",
463
+ "--help",
464
+ ],
465
+ env=env,
466
+ )
467
+ assert "persistent-docker preset only" in help_result.stdout
468
+ cmd_help_result = _run(
469
+ ["cmd.exe", "/c", str(cmd_wrapper), "install", "-?"],
470
+ env=env,
471
+ )
472
+ assert "persistent-docker preset only" in cmd_help_result.stdout
473
+ _run(
474
+ [
475
+ powershell,
476
+ "-NoProfile",
477
+ "-ExecutionPolicy",
478
+ "Bypass",
479
+ "-File",
480
+ str(wrapper),
481
+ "--help",
482
+ ],
483
+ env=env,
484
+ )
485
+ invalid_profile = _run(
486
+ ["cmd.exe", "/c", str(cmd_wrapper), "install", "status", "--profile", ".."],
487
+ env=env,
488
+ check=False,
489
+ )
490
+ assert invalid_profile.returncode != 0
491
+ assert "Invalid profile name '..'" in invalid_profile.stderr
492
+ missing_profile_value = _run(
493
+ ["cmd.exe", "/c", str(cmd_wrapper), "install", "apply", "--profile"],
494
+ env=env,
495
+ check=False,
496
+ )
497
+ assert missing_profile_value.returncode != 0
498
+ assert "Option --profile requires a value" in missing_profile_value.stderr
499
+ missing_proxy_port = _run(
500
+ ["cmd.exe", "/c", str(cmd_wrapper), "proxy", "--port"],
501
+ env=env,
502
+ check=False,
503
+ )
504
+ assert missing_proxy_port.returncode != 0
505
+ assert "Option --port requires a value" in missing_proxy_port.stderr
506
+ invalid_proxy_port = _run(
507
+ ["cmd.exe", "/c", str(cmd_wrapper), "proxy", "--port", "abc"],
508
+ env=env,
509
+ check=False,
510
+ )
511
+ assert invalid_proxy_port.returncode != 0
512
+ assert "Invalid port 'abc'" in invalid_proxy_port.stderr
513
+ missing_wrap_port = _run(
514
+ ["cmd.exe", "/c", str(cmd_wrapper), "wrap", "claude", "--port"],
515
+ env=env,
516
+ check=False,
517
+ )
518
+ assert missing_wrap_port.returncode != 0
519
+ assert "Option --port requires a value" in missing_wrap_port.stderr
520
+ invalid_wrap_port = _run(
521
+ ["cmd.exe", "/c", str(cmd_wrapper), "wrap", "claude", "--port", "abc"],
522
+ env=env,
523
+ check=False,
524
+ )
525
+ assert invalid_wrap_port.returncode != 0
526
+ assert "Invalid port 'abc'" in invalid_wrap_port.stderr
527
+ missing_openclaw_proxy_port = _run(
528
+ ["cmd.exe", "/c", str(cmd_wrapper), "wrap", "openclaw", "--proxy-port"],
529
+ env=env,
530
+ check=False,
531
+ )
532
+ assert missing_openclaw_proxy_port.returncode != 0
533
+ assert "Option --proxy-port requires a value" in missing_openclaw_proxy_port.stderr
534
+ invalid_openclaw_proxy_port = _run(
535
+ ["cmd.exe", "/c", str(cmd_wrapper), "wrap", "openclaw", "--proxy-port", "abc"],
536
+ env=env,
537
+ check=False,
538
+ )
539
+ assert invalid_openclaw_proxy_port.returncode != 0
540
+ assert "Invalid port 'abc'" in invalid_openclaw_proxy_port.stderr
541
+ for invalid_port in ("abc", "0", "65536"):
542
+ invalid_port_result = _run(
543
+ ["cmd.exe", "/c", str(cmd_wrapper), "install", "apply", "--port", invalid_port],
544
+ env=env,
545
+ check=False,
546
+ )
547
+ assert invalid_port_result.returncode != 0
548
+ assert f"Invalid port '{invalid_port}'" in invalid_port_result.stderr
549
+
550
+ port = _free_port()
551
+ _run(
552
+ [
553
+ "cmd.exe",
554
+ "/c",
555
+ str(cmd_wrapper),
556
+ "install",
557
+ "apply",
558
+ "--profile",
559
+ "smoke",
560
+ "--port",
561
+ str(port),
562
+ "--memory",
563
+ "--no-telemetry",
564
+ "--image",
565
+ "fake/headroom:test",
566
+ ],
567
+ env=env,
568
+ )
569
+
570
+ manifest_path = home / ".headroom" / "deploy" / "smoke" / "manifest.json"
571
+ state_path = home / ".headroom" / "deploy" / "smoke" / "docker-native.json"
572
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
573
+ state = json.loads(state_path.read_text(encoding="utf-8"))
574
+ assert manifest["preset"] == "persistent-docker"
575
+ assert manifest["port"] == port
576
+ assert manifest["memory_enabled"] is True
577
+ assert manifest["memory_db_path"] == "/tmp/headroom-home/.headroom/memory.db"
578
+ assert manifest["telemetry_enabled"] is False
579
+ assert state["container_name"] == "headroom-smoke"
580
+
581
+ docker_calls = _read_fake_docker_log(env)
582
+ help_call = next(
583
+ call
584
+ for call in docker_calls
585
+ if call[:2] == ["run", "--rm"] and "--entrypoint" in call and "--help" in call
586
+ )
587
+ assert "-it" not in help_call
588
+ proxy_help_call = next(
589
+ call
590
+ for call in docker_calls
591
+ if call[:2] == ["run", "--rm"] and "-p" in call and "proxy" in call and "--help" in call
592
+ )
593
+ assert "-it" not in proxy_help_call
594
+ install_call = next(
595
+ call for call in docker_calls if call[:2] == ["run", "-d"] and "--name" in call
596
+ )
597
+ assert "/tmp/headroom-home/.headroom/memory.db" in install_call
598
+
599
+ status_result = _run(
600
+ [
601
+ powershell,
602
+ "-NoProfile",
603
+ "-ExecutionPolicy",
604
+ "Bypass",
605
+ "-File",
606
+ str(wrapper),
607
+ "install",
608
+ "status",
609
+ "--profile",
610
+ "smoke",
611
+ ],
612
+ env=env,
613
+ )
614
+ assert "Status: running" in status_result.stdout
615
+
616
+ _run(
617
+ [
618
+ powershell,
619
+ "-NoProfile",
620
+ "-ExecutionPolicy",
621
+ "Bypass",
622
+ "-File",
623
+ str(wrapper),
624
+ "install",
625
+ "stop",
626
+ "--profile",
627
+ "smoke",
628
+ ],
629
+ env=env,
630
+ )
631
+ stopped_result = _run(
632
+ [
633
+ powershell,
634
+ "-NoProfile",
635
+ "-ExecutionPolicy",
636
+ "Bypass",
637
+ "-File",
638
+ str(wrapper),
639
+ "install",
640
+ "status",
641
+ "--profile",
642
+ "smoke",
643
+ ],
644
+ env=env,
645
+ )
646
+ assert "Status: stopped" in stopped_result.stdout
647
+
648
+ _run(
649
+ [
650
+ powershell,
651
+ "-NoProfile",
652
+ "-ExecutionPolicy",
653
+ "Bypass",
654
+ "-File",
655
+ str(wrapper),
656
+ "install",
657
+ "start",
658
+ "--profile",
659
+ "smoke",
660
+ ],
661
+ env=env,
662
+ )
663
+ started_result = _run(
664
+ [
665
+ powershell,
666
+ "-NoProfile",
667
+ "-ExecutionPolicy",
668
+ "Bypass",
669
+ "-File",
670
+ str(wrapper),
671
+ "install",
672
+ "status",
673
+ "--profile",
674
+ "smoke",
675
+ ],
676
+ env=env,
677
+ )
678
+ assert "Status: running" in started_result.stdout
679
+
680
+ rejected = _run(
681
+ [
682
+ powershell,
683
+ "-NoProfile",
684
+ "-ExecutionPolicy",
685
+ "Bypass",
686
+ "-File",
687
+ str(wrapper),
688
+ "install",
689
+ "apply",
690
+ "--scope",
691
+ "user",
692
+ ],
693
+ env=env,
694
+ check=False,
695
+ )
696
+ assert rejected.returncode != 0
697
+ assert "does not support provider/user/system mutation flags" in rejected.stderr
698
+
699
+ _run(
700
+ [
701
+ powershell,
702
+ "-NoProfile",
703
+ "-ExecutionPolicy",
704
+ "Bypass",
705
+ "-File",
706
+ str(wrapper),
707
+ "install",
708
+ "restart",
709
+ "--profile",
710
+ "smoke",
711
+ ],
712
+ env=env,
713
+ )
714
+ _run(
715
+ [
716
+ powershell,
717
+ "-NoProfile",
718
+ "-ExecutionPolicy",
719
+ "Bypass",
720
+ "-File",
721
+ str(wrapper),
722
+ "install",
723
+ "remove",
724
+ "--profile",
725
+ "smoke",
726
+ ],
727
+ env=env,
728
+ )
729
+ assert not manifest_path.parent.exists()
730
+ finally:
731
+ _cleanup_fake_docker(env)
tests/test_install/test_supervisors.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from headroom.install.models import DeploymentManifest, SupervisorKind
6
+ from headroom.install.supervisors import (
7
+ _linux_service_unit,
8
+ _linux_task_spec,
9
+ _macos_launchd_plist,
10
+ _render_windows_runner,
11
+ )
12
+
13
+
14
+ def _manifest(
15
+ *, profile: str = "default", scope: str = "user", supervisor: str = "service"
16
+ ) -> DeploymentManifest:
17
+ return DeploymentManifest(
18
+ profile=profile,
19
+ preset="persistent-service",
20
+ runtime_kind="python",
21
+ supervisor_kind=supervisor,
22
+ scope=scope,
23
+ provider_mode="manual",
24
+ targets=[],
25
+ port=8787,
26
+ host="127.0.0.1",
27
+ backend="anthropic",
28
+ service_name=f"headroom-{profile}",
29
+ )
30
+
31
+
32
+ def test_linux_service_unit_uses_user_systemd_path(monkeypatch, tmp_path: Path) -> None:
33
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
34
+ manifest = _manifest()
35
+
36
+ unit_path, content = _linux_service_unit(manifest, tmp_path / "run-headroom.sh")
37
+
38
+ assert unit_path == tmp_path / ".config" / "systemd" / "user" / "headroom-default.service"
39
+ assert "ExecStart=" + str(tmp_path / "run-headroom.sh") in content
40
+ assert "Restart=on-failure" in content
41
+
42
+
43
+ def test_linux_task_spec_for_user_scope_includes_crontab_markers(tmp_path: Path) -> None:
44
+ manifest = _manifest(profile="smoke", supervisor=SupervisorKind.TASK.value)
45
+
46
+ cron_path, content = _linux_task_spec(manifest, tmp_path / "ensure-headroom.sh")
47
+
48
+ assert cron_path is None
49
+ assert "# >>> headroom smoke >>>" in content
50
+ assert "# <<< headroom smoke <<<" in content
51
+ assert "@reboot" in content
52
+ assert "*/5 * * * *" in content
53
+
54
+
55
+ def test_macos_launchd_plist_switches_between_keepalive_and_interval(
56
+ monkeypatch, tmp_path: Path
57
+ ) -> None:
58
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
59
+
60
+ service_manifest = _manifest(supervisor=SupervisorKind.SERVICE.value)
61
+ service_path, service_content = _macos_launchd_plist(
62
+ service_manifest, tmp_path / "run-headroom.sh"
63
+ )
64
+ assert service_path == tmp_path / "Library" / "LaunchAgents" / "com.headroom.default.plist"
65
+ assert "<key>KeepAlive</key>" in service_content
66
+ assert "<key>StartInterval</key>" not in service_content
67
+
68
+ task_manifest = _manifest(profile="tasky", supervisor=SupervisorKind.TASK.value)
69
+ task_path, task_content = _macos_launchd_plist(
70
+ task_manifest, tmp_path / "ensure-headroom.sh", interval=300
71
+ )
72
+ assert task_path == tmp_path / "Library" / "LaunchAgents" / "com.headroom.tasky.plist"
73
+ assert "<key>StartInterval</key>" in task_content
74
+ assert "<integer>300</integer>" in task_content
75
+
76
+
77
+ def test_render_windows_runner_writes_ps1_and_cmd_wrappers(tmp_path: Path) -> None:
78
+ ps1_path = tmp_path / "run-headroom.ps1"
79
+ cmd_path = tmp_path / "run-headroom.cmd"
80
+
81
+ records = _render_windows_runner(
82
+ ps1_path,
83
+ cmd_path,
84
+ ["C:\\Program Files\\Python\\python.exe", "headroom", "install", "agent", "run"],
85
+ )
86
+
87
+ assert [record.path for record in records] == [str(ps1_path), str(cmd_path)]
88
+ ps1_content = ps1_path.read_text(encoding="utf-8")
89
+ cmd_content = cmd_path.read_text(encoding="utf-8")
90
+ assert '& "C:\\Program Files\\Python\\python.exe" headroom install agent run' in ps1_content
91
+ assert (
92
+ 'powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0run-headroom.ps1" %*'
93
+ in cmd_content
94
+ )