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

fix: harden persistent install wrappers and review gaps

Browse files

Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.

Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.

Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

.github/workflows/ci.yml CHANGED
@@ -121,6 +121,31 @@ jobs:
121
  run: |
122
  bash e2e/docker-native-install.sh
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  windows-native-wrapper:
125
  runs-on: windows-latest
126
  steps:
 
121
  run: |
122
  bash e2e/docker-native-install.sh
123
 
124
+ - name: Run Docker-native compose smoke test
125
+ env:
126
+ HEADROOM_IMAGE: headroom-native-e2e:latest
127
+ HEADROOM_HOST_HOME: ${{ github.workspace }}
128
+ HEADROOM_WORKSPACE: ${{ github.workspace }}
129
+ run: |
130
+ mkdir -p .headroom .claude .codex .gemini
131
+ trap 'docker compose -f docker/docker-compose.native.yml down -v' EXIT
132
+ docker compose -f docker/docker-compose.native.yml up -d proxy
133
+ for attempt in $(seq 1 30); do
134
+ if curl --fail --silent http://127.0.0.1:8787/readyz >/dev/null; then
135
+ break
136
+ fi
137
+ if [ "$attempt" -eq 30 ]; then
138
+ docker compose -f docker/docker-compose.native.yml logs proxy
139
+ exit 1
140
+ fi
141
+ sleep 1
142
+ done
143
+
144
+ - name: Run Docker-native wrap e2e
145
+ run: |
146
+ docker build -f e2e/wrap/Dockerfile -t headroom-wrap-e2e .
147
+ docker run --rm headroom-wrap-e2e
148
+
149
  windows-native-wrapper:
150
  runs-on: windows-latest
151
  steps:
README.md CHANGED
@@ -87,16 +87,23 @@ npm install headroom-ai
87
  curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.sh | bash
88
  ```
89
 
 
 
90
  PowerShell:
91
  ```powershell
92
  irm https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.ps1 | iex
93
  ```
94
 
95
- **Persistent local runtime:**
96
  ```bash
97
  headroom install apply --preset persistent-service --providers auto
98
  ```
99
 
 
 
 
 
 
100
  ### Any agent — one function
101
 
102
  **Python:**
@@ -149,7 +156,7 @@ Prefer Docker as the runtime provider? See **[Docker-native install](docs/docker
149
  ```bash
150
  headroom wrap claude # Starts proxy + launches Claude Code
151
  headroom wrap copilot -- --model claude-sonnet-4-20250514
152
- # Starts proxy + launches GitHub Copilot CLI
153
  headroom wrap codex # Starts proxy + launches OpenAI Codex CLI
154
  headroom wrap aider # Starts proxy + launches Aider
155
  headroom wrap cursor # Starts proxy + prints Cursor config
@@ -159,7 +166,7 @@ headroom wrap codex --memory # Shares the same memory store
159
  headroom wrap claude --code-graph # With code graph intelligence (codebase-memory-mcp)
160
  ```
161
 
162
- Headroom starts a proxy, points your tool at it, and compresses everything automatically. Add `--memory` for persistent memory that's shared across agents. Add `--code-graph` for code intelligence via [codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp) — indexes your codebase into a knowledge graph for call-chain traversal, impact analysis, and architectural queries.
163
 
164
  In Docker-native mode, Headroom still runs in Docker while wrapped tools run on the host. `wrap claude`, `wrap codex`, `wrap aider`, `wrap cursor`, and OpenClaw plugin setup (`wrap openclaw` / `unwrap openclaw`) are host-managed through the installed wrapper.
165
 
@@ -519,6 +526,9 @@ Python 3.10+
519
  | [MCP](docs/mcp.md) | Context engineering toolkit (compress, retrieve, stats) |
520
  | [SharedContext](docs/shared-context.md) | Compressed inter-agent context sharing |
521
  | [Learn](docs/learn.md) | Plugin-based failure learning (Claude, Codex, Gemini, extensible) |
 
 
 
522
  | [Configuration](docs/configuration.md) | All options |
523
 
524
  ---
 
87
  curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.sh | bash
88
  ```
89
 
90
+ macOS uses Bash 4.3+, so run the installer with a newer Bash such as Homebrew's `bash`.
91
+
92
  PowerShell:
93
  ```powershell
94
  irm https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.ps1 | iex
95
  ```
96
 
97
+ **Persistent local runtime (Python-native service/task flow):**
98
  ```bash
99
  headroom install apply --preset persistent-service --providers auto
100
  ```
101
 
102
+ **Persistent local runtime (Docker-native wrapper / compose flow):**
103
+ ```bash
104
+ headroom install apply --preset persistent-docker
105
+ ```
106
+
107
  ### Any agent — one function
108
 
109
  **Python:**
 
156
  ```bash
157
  headroom wrap claude # Starts proxy + launches Claude Code
158
  headroom wrap copilot -- --model claude-sonnet-4-20250514
159
+ # Starts proxy + launches GitHub Copilot CLI
160
  headroom wrap codex # Starts proxy + launches OpenAI Codex CLI
161
  headroom wrap aider # Starts proxy + launches Aider
162
  headroom wrap cursor # Starts proxy + prints Cursor config
 
166
  headroom wrap claude --code-graph # With code graph intelligence (codebase-memory-mcp)
167
  ```
168
 
169
+ Headroom starts a proxy, points your tool at it, and compresses everything automatically. Add `--memory` for persistent memory that's shared across agents. Add `--code-graph` for code intelligence via [codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp) — indexes your codebase into a knowledge graph for call-chain traversal, impact analysis, and architectural queries. `wrap copilot` is part of the Python-native CLI; the Docker-native wrapper currently supports `claude`, `codex`, `aider`, `cursor`, and `openclaw`.
170
 
171
  In Docker-native mode, Headroom still runs in Docker while wrapped tools run on the host. `wrap claude`, `wrap codex`, `wrap aider`, `wrap cursor`, and OpenClaw plugin setup (`wrap openclaw` / `unwrap openclaw`) are host-managed through the installed wrapper.
172
 
 
526
  | [MCP](docs/mcp.md) | Context engineering toolkit (compress, retrieve, stats) |
527
  | [SharedContext](docs/shared-context.md) | Compressed inter-agent context sharing |
528
  | [Learn](docs/learn.md) | Plugin-based failure learning (Claude, Codex, Gemini, extensible) |
529
+ | [CLI Reference](docs/cli.md) | Complete command surface, help output, and Docker parity matrix |
530
+ | [Docker-Native Install](docs/docker-install.md) | Host wrapper install, compose support, and Docker runtime behavior |
531
+ | [Persistent Installs](docs/persistent-installs.md) | Service/task/docker deployment models and provider scopes |
532
  | [Configuration](docs/configuration.md) | All options |
533
 
534
  ---
docs/docker-install.md CHANGED
@@ -108,6 +108,8 @@ In Docker-native mode this surface is intentionally scoped to **persistent-docke
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.
 
108
 
109
  Those broader lifecycle and config-mutation flows still belong to the Python-native `headroom install ...` command.
110
 
111
+ Persistent Docker deployments launched by the wrapper also tag the proxy process with deployment metadata, so `/health` reports the active `profile`, `preset`, `runtime`, `supervisor`, and `scope` the same way the Python install subsystem does.
112
+
113
  ## Docker Compose support
114
 
115
  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.
e2e/docker-native-install.sh CHANGED
@@ -24,6 +24,7 @@ trap cleanup EXIT
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
 
@@ -42,8 +43,9 @@ 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
@@ -51,10 +53,14 @@ from pathlib import Path
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
 
24
  mkdir -p "${TMP_HOME}/.local"
25
  export HOME="${TMP_HOME}"
26
  export PATH="${HOME}/.local/bin:${PATH}"
27
+ export HEADROOM_DOCKER_IMAGE="${IMAGE}"
28
 
29
  bash "${ROOT_DIR}/scripts/install.sh"
30
 
 
43
  printf '%s\n' "${status_output}"
44
  grep -Fq "Status: running" <<<"${status_output}"
45
  curl --fail --silent "http://127.0.0.1:${PORT}/readyz" >/dev/null
46
+ health_output="$(curl --fail --silent "http://127.0.0.1:${PORT}/health")"
47
 
48
+ python3 - <<'PY' "${HOME}" "${PROFILE}" "${PORT}" "${health_output}"
49
  import json
50
  import sys
51
  from pathlib import Path
 
53
  home = Path(sys.argv[1])
54
  profile = sys.argv[2]
55
  port = int(sys.argv[3])
56
+ health = json.loads(sys.argv[4])
57
  manifest = json.loads((home / ".headroom" / "deploy" / profile / "manifest.json").read_text())
58
  assert manifest["preset"] == "persistent-docker"
59
  assert manifest["port"] == port
60
  assert manifest["telemetry_enabled"] is False
61
+ assert health["deployment"]["profile"] == profile
62
+ assert health["deployment"]["preset"] == "persistent-docker"
63
+ assert health["deployment"]["runtime"] == "docker"
64
  PY
65
 
66
  if apply_error="$("${WRAPPER}" install apply --scope user 2>&1)"; then
e2e/wrap/run.py CHANGED
@@ -261,6 +261,7 @@ def create_shims(shim_dir: Path) -> None:
261
  raise SystemExit(0)
262
  """
263
  )
 
264
  write_executable(shim_dir / "codex", generic_shim)
265
  write_executable(shim_dir / "aider", generic_shim)
266
  write_executable(shim_dir / "rtk", rtk_shim)
@@ -466,6 +467,27 @@ def verify_codex_wrap(base_env: dict[str, str], project_dir: Path, log_dir: Path
466
  )
467
 
468
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  def verify_aider_wrap(base_env: dict[str, str], project_dir: Path, log_dir: Path) -> None:
470
  port = AIDER_PORT
471
  run(
@@ -624,6 +646,13 @@ def verify_openclaw_wrap(
624
  stop_process(gateway_proc)
625
  stop_openclaw_gateway(base_env, project_dir)
626
 
 
 
 
 
 
 
 
627
 
628
  def main() -> None:
629
  verify_installs()
@@ -653,6 +682,7 @@ def main() -> None:
653
 
654
  try:
655
  verify_proxy_round_trip(base_env, mock_server)
 
656
  verify_codex_wrap(base_env, project_dir, log_dir)
657
  verify_aider_wrap(base_env, project_dir, log_dir)
658
  verify_cursor_wrap(base_env, project_dir)
 
261
  raise SystemExit(0)
262
  """
263
  )
264
+ write_executable(shim_dir / "claude", generic_shim)
265
  write_executable(shim_dir / "codex", generic_shim)
266
  write_executable(shim_dir / "aider", generic_shim)
267
  write_executable(shim_dir / "rtk", rtk_shim)
 
467
  )
468
 
469
 
470
+ def verify_claude_wrap(base_env: dict[str, str], project_dir: Path, log_dir: Path) -> None:
471
+ port = PROXY_PORT + 10
472
+ run(
473
+ ["headroom", "wrap", "claude", "--port", str(port), "--", "--help"],
474
+ env=base_env,
475
+ cwd=project_dir,
476
+ timeout=120,
477
+ )
478
+ entries = read_jsonl(log_dir / "claude.jsonl")
479
+ assert_true(len(entries) > 0, "Claude shim should have been invoked")
480
+ env_vars = entries[-1]["env"]
481
+ assert_true(
482
+ env_vars.get("ANTHROPIC_BASE_URL") == f"http://127.0.0.1:{port}",
483
+ "Claude wrap should set ANTHROPIC_BASE_URL",
484
+ )
485
+ assert_true(
486
+ entries[-1]["probes"] == [{"url": f"http://127.0.0.1:{port}/health", "status": 200}],
487
+ "Claude shim should prove ANTHROPIC_BASE_URL points at a live proxy",
488
+ )
489
+
490
+
491
  def verify_aider_wrap(base_env: dict[str, str], project_dir: Path, log_dir: Path) -> None:
492
  port = AIDER_PORT
493
  run(
 
646
  stop_process(gateway_proc)
647
  stop_openclaw_gateway(base_env, project_dir)
648
 
649
+ run(["headroom", "unwrap", "openclaw"], env=base_env, cwd=project_dir, timeout=120)
650
+ state = json.loads(config_path.read_text(encoding="utf-8"))
651
+ assert_true(
652
+ state["plugins"]["slots"]["contextEngine"] == "legacy",
653
+ "OpenClaw unwrap should restore the context engine slot",
654
+ )
655
+
656
 
657
  def main() -> None:
658
  verify_installs()
 
682
 
683
  try:
684
  verify_proxy_round_trip(base_env, mock_server)
685
+ verify_claude_wrap(base_env, project_dir, log_dir)
686
  verify_codex_wrap(base_env, project_dir, log_dir)
687
  verify_aider_wrap(base_env, project_dir, log_dir)
688
  verify_cursor_wrap(base_env, project_dir)
headroom/cli/install.py CHANGED
@@ -1,305 +1,333 @@
1
- """Persistent install / deployment CLI commands."""
2
-
3
- from __future__ import annotations
4
-
5
- import click
6
-
7
- from headroom.install.health import probe_json, probe_ready
8
- from headroom.install.models import (
9
- ConfigScope,
10
- DeploymentManifest,
11
- InstallPreset,
12
- ProviderSelectionMode,
13
- RuntimeKind,
14
- SupervisorKind,
15
- )
16
- from headroom.install.planner import build_manifest
17
- from headroom.install.providers import apply_mutations, revert_mutations
18
- from headroom.install.runtime import (
19
- run_foreground,
20
- runtime_status,
21
- start_detached_agent,
22
- start_persistent_docker,
23
- stop_runtime,
24
- wait_ready,
25
- )
26
- from headroom.install.state import delete_manifest, load_manifest, save_manifest
27
- from headroom.install.supervisors import (
28
- install_supervisor,
29
- remove_supervisor,
30
- start_supervisor,
31
- stop_supervisor,
32
- )
33
-
34
- from .main import main
35
-
36
-
37
- @main.group()
38
- def install() -> None:
39
- """Install and manage persistent Headroom deployments."""
40
-
41
-
42
- def _require_manifest(profile: str) -> DeploymentManifest:
43
- manifest = load_manifest(profile)
44
- if manifest is None:
45
- raise click.ClickException(f"No deployment profile named '{profile}' is installed.")
46
- return manifest
47
-
48
-
49
- def _start_deployment(manifest: DeploymentManifest) -> None:
50
- if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
51
- start_persistent_docker(manifest)
52
- elif manifest.supervisor_kind == SupervisorKind.SERVICE.value:
53
- start_supervisor(manifest)
54
- else:
55
- start_detached_agent(manifest.profile)
56
-
57
- if not wait_ready(manifest, timeout_seconds=45):
58
- raise click.ClickException(
59
- f"Deployment '{manifest.profile}' did not become ready after start."
60
- )
61
-
62
-
63
- def _stop_deployment(manifest: DeploymentManifest) -> None:
64
- if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
65
- stop_supervisor(manifest)
66
- stop_runtime(manifest)
67
-
68
-
69
- @install.command("apply")
70
- @click.option(
71
- "--preset",
72
- type=click.Choice([preset.value for preset in InstallPreset]),
73
- default=InstallPreset.PERSISTENT_SERVICE.value,
74
- show_default=True,
75
- help="Persistent runtime preset to install.",
76
- )
77
- @click.option(
78
- "--runtime",
79
- type=click.Choice([runtime.value for runtime in RuntimeKind]),
80
- default=RuntimeKind.PYTHON.value,
81
- show_default=True,
82
- help="Runtime used to execute Headroom for service/task modes.",
83
- )
84
- @click.option(
85
- "--scope",
86
- type=click.Choice([scope.value for scope in ConfigScope]),
87
- default=ConfigScope.USER.value,
88
- show_default=True,
89
- help="Where to apply persistent configuration.",
90
- )
91
- @click.option(
92
- "--providers",
93
- "provider_mode",
94
- type=click.Choice([mode.value for mode in ProviderSelectionMode]),
95
- default=ProviderSelectionMode.AUTO.value,
96
- show_default=True,
97
- help="Target selection mode for direct tool configuration.",
98
- )
99
- @click.option(
100
- "--target",
101
- "targets",
102
- multiple=True,
103
- type=click.Choice(["claude", "copilot", "codex", "aider", "cursor", "openclaw"]),
104
- help="Tool target to configure when --providers manual is used.",
105
- )
106
- @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
107
- @click.option(
108
- "--port", "-p", default=8787, type=int, show_default=True, help="Persistent proxy port."
109
- )
110
- @click.option(
111
- "--backend",
112
- default="anthropic",
113
- show_default=True,
114
- help="Proxy backend for the persistent runtime.",
115
- )
116
- @click.option(
117
- "--anyllm-provider",
118
- default=None,
119
- help="Provider for any-llm backends when --backend anyllm is used.",
120
- )
121
- @click.option("--region", default=None, help="Cloud region for Bedrock / Vertex style backends.")
122
- @click.option(
123
- "--mode", "proxy_mode", default="token", show_default=True, help="Proxy optimization mode."
124
- )
125
- @click.option("--memory", is_flag=True, help="Enable persistent memory in the proxy runtime.")
126
- @click.option("--no-telemetry", is_flag=True, help="Disable anonymous telemetry in the runtime.")
127
- @click.option(
128
- "--image",
129
- default="ghcr.io/chopratejas/headroom:latest",
130
- show_default=True,
131
- help="Docker image to use when runtime=docker or preset=persistent-docker.",
132
- )
133
- def install_apply(
134
- preset: str,
135
- runtime: str,
136
- scope: str,
137
- provider_mode: str,
138
- targets: tuple[str, ...],
139
- profile: str,
140
- port: int,
141
- backend: str,
142
- anyllm_provider: str | None,
143
- region: str | None,
144
- proxy_mode: str,
145
- memory: bool,
146
- no_telemetry: bool,
147
- image: str,
148
- ) -> None:
149
- """Install a persistent Headroom deployment."""
150
-
151
- if preset == InstallPreset.PERSISTENT_DOCKER.value:
152
- runtime = RuntimeKind.DOCKER.value
153
-
154
- manifest = build_manifest(
155
- profile=profile,
156
- preset=preset,
157
- runtime_kind=runtime,
158
- scope=scope,
159
- provider_mode=provider_mode,
160
- targets=list(targets),
161
- port=port,
162
- backend=backend,
163
- anyllm_provider=anyllm_provider,
164
- region=region,
165
- proxy_mode=proxy_mode,
166
- memory_enabled=memory,
167
- telemetry_enabled=not no_telemetry,
168
- image=image,
169
- )
170
-
171
- existing = load_manifest(profile)
172
- if existing is not None:
173
- click.echo(f"Updating existing deployment profile '{profile}'...")
174
- revert_mutations(existing)
175
- try:
176
- remove_supervisor(existing)
177
- except Exception:
178
- pass
179
- stop_runtime(existing)
180
-
181
- manifest.mutations = apply_mutations(manifest)
182
- manifest.artifacts = install_supervisor(manifest)
183
- save_manifest(manifest)
184
-
185
- if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
186
- start_persistent_docker(manifest)
187
- elif manifest.supervisor_kind == SupervisorKind.SERVICE.value:
188
- start_supervisor(manifest)
189
- else:
190
- start_detached_agent(profile)
191
-
192
- if not wait_ready(manifest, timeout_seconds=45):
193
- raise click.ClickException(
194
- f"Persistent deployment '{profile}' did not become ready at {manifest.health_url}."
195
- )
196
-
197
- click.echo(
198
- f"Installed persistent deployment '{profile}' "
199
- f"({manifest.preset}, runtime={manifest.runtime_kind}, scope={manifest.scope})."
200
- )
201
- click.echo(f"Health: {manifest.health_url}")
202
- if manifest.targets:
203
- click.echo(f"Targets: {', '.join(manifest.targets)}")
204
-
205
-
206
- @install.command("status")
207
- @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
208
- def install_status(profile: str) -> None:
209
- """Show persistent deployment status."""
210
-
211
- manifest = _require_manifest(profile)
212
- payload = probe_json(manifest.health_url.replace("/readyz", "/health"))
213
- click.echo(f"Profile: {manifest.profile}")
214
- click.echo(f"Preset: {manifest.preset}")
215
- click.echo(f"Runtime: {manifest.runtime_kind}")
216
- click.echo(f"Supervisor: {manifest.supervisor_kind}")
217
- click.echo(f"Scope: {manifest.scope}")
218
- click.echo(f"Port: {manifest.port}")
219
- click.echo(f"Status: {runtime_status(manifest)}")
220
- click.echo(f"Healthy: {'yes' if probe_ready(manifest.health_url) else 'no'}")
221
- if payload and isinstance(payload, dict):
222
- click.echo(f"Health URL: {manifest.health_url.replace('/readyz', '/health')}")
223
- click.echo(f"Backend: {payload.get('config', {}).get('backend', manifest.backend)}")
224
-
225
-
226
- @install.command("start")
227
- @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
228
- def install_start(profile: str) -> None:
229
- """Start a persistent deployment."""
230
-
231
- manifest = _require_manifest(profile)
232
- _start_deployment(manifest)
233
- click.echo(f"Started deployment '{profile}'.")
234
-
235
-
236
- @install.command("stop")
237
- @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
238
- def install_stop(profile: str) -> None:
239
- """Stop a persistent deployment."""
240
-
241
- manifest = _require_manifest(profile)
242
- _stop_deployment(manifest)
243
- click.echo(f"Stopped deployment '{profile}'.")
244
-
245
-
246
- @install.command("restart")
247
- @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
248
- def install_restart(profile: str) -> None:
249
- """Restart a persistent deployment."""
250
-
251
- manifest = _require_manifest(profile)
252
- _stop_deployment(manifest)
253
- _start_deployment(manifest)
254
- click.echo(f"Restarted deployment '{profile}'.")
255
-
256
-
257
- @install.command("remove")
258
- @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
259
- def install_remove(profile: str) -> None:
260
- """Remove a persistent deployment and undo managed config."""
261
-
262
- manifest = _require_manifest(profile)
263
- try:
264
- if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
265
- stop_supervisor(manifest)
266
- except Exception:
267
- pass
268
- try:
269
- stop_runtime(manifest)
270
- except Exception:
271
- pass
272
- try:
273
- remove_supervisor(manifest)
274
- except Exception:
275
- pass
276
- revert_mutations(manifest)
277
- delete_manifest(profile)
278
- click.echo(f"Removed deployment '{profile}'.")
279
-
280
-
281
- @install.group("agent", hidden=True)
282
- def install_agent() -> None:
283
- """Hidden runtime helpers used by persistent supervisors."""
284
-
285
-
286
- @install_agent.command("run")
287
- @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
288
- def install_agent_run(profile: str) -> None:
289
- """Run the persistent runtime in the foreground."""
290
-
291
- manifest = _require_manifest(profile)
292
- raise SystemExit(run_foreground(manifest))
293
-
294
-
295
- @install_agent.command("ensure")
296
- @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
297
- def install_agent_ensure(profile: str) -> None:
298
- """Ensure a persistent deployment is healthy, starting it when needed."""
299
-
300
- manifest = _require_manifest(profile)
301
- if probe_ready(manifest.health_url):
302
- click.echo(f"Deployment '{profile}' is already healthy.")
303
- return
304
- _start_deployment(manifest)
305
- click.echo(f"Deployment '{profile}' is healthy.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persistent install / deployment CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+
7
+ import click
8
+
9
+ from headroom.install.health import probe_json, probe_ready
10
+ from headroom.install.models import (
11
+ ConfigScope,
12
+ DeploymentManifest,
13
+ InstallPreset,
14
+ ProviderSelectionMode,
15
+ RuntimeKind,
16
+ SupervisorKind,
17
+ )
18
+ from headroom.install.planner import build_manifest
19
+ from headroom.install.providers import apply_mutations, revert_mutations
20
+ from headroom.install.runtime import (
21
+ run_foreground,
22
+ runtime_status,
23
+ start_detached_agent,
24
+ start_persistent_docker,
25
+ stop_runtime,
26
+ wait_ready,
27
+ )
28
+ from headroom.install.state import delete_manifest, load_manifest, save_manifest
29
+ from headroom.install.supervisors import (
30
+ install_supervisor,
31
+ remove_supervisor,
32
+ start_supervisor,
33
+ stop_supervisor,
34
+ )
35
+
36
+ from .main import main
37
+
38
+
39
+ @main.group()
40
+ def install() -> None:
41
+ """Install and manage persistent Headroom deployments."""
42
+
43
+
44
+ def _require_manifest(profile: str) -> DeploymentManifest:
45
+ manifest = load_manifest(profile)
46
+ if manifest is None:
47
+ raise click.ClickException(f"No deployment profile named '{profile}' is installed.")
48
+ return manifest
49
+
50
+
51
+ def _start_deployment(manifest: DeploymentManifest) -> None:
52
+ if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
53
+ start_persistent_docker(manifest)
54
+ elif manifest.supervisor_kind == SupervisorKind.SERVICE.value:
55
+ start_supervisor(manifest)
56
+ else:
57
+ start_detached_agent(manifest.profile)
58
+
59
+ if not wait_ready(manifest, timeout_seconds=45):
60
+ raise click.ClickException(
61
+ f"Deployment '{manifest.profile}' did not become ready after start."
62
+ )
63
+
64
+
65
+ def _stop_deployment(manifest: DeploymentManifest) -> None:
66
+ if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
67
+ stop_supervisor(manifest)
68
+ stop_runtime(manifest)
69
+
70
+
71
+ def _remove_deployment(manifest: DeploymentManifest) -> None:
72
+ try:
73
+ _stop_deployment(manifest)
74
+ except Exception:
75
+ pass
76
+ try:
77
+ remove_supervisor(manifest)
78
+ except Exception:
79
+ pass
80
+ try:
81
+ revert_mutations(manifest)
82
+ except Exception:
83
+ pass
84
+ delete_manifest(manifest.profile)
85
+
86
+
87
+ def _restore_deployment(manifest: DeploymentManifest) -> None:
88
+ restored = deepcopy(manifest)
89
+ restored.mutations = apply_mutations(restored)
90
+ restored.artifacts = install_supervisor(restored)
91
+ save_manifest(restored)
92
+ _start_deployment(restored)
93
+
94
+
95
+ def _reject_task_lifecycle(manifest: DeploymentManifest, action: str) -> None:
96
+ if manifest.supervisor_kind == SupervisorKind.TASK.value:
97
+ raise click.ClickException(
98
+ f"Deployment '{manifest.profile}' uses persistent-task scheduling; "
99
+ f"`headroom install {action}` is not supported for task deployments."
100
+ )
101
+
102
+
103
+ @install.command("apply")
104
+ @click.option(
105
+ "--preset",
106
+ type=click.Choice([preset.value for preset in InstallPreset]),
107
+ default=InstallPreset.PERSISTENT_SERVICE.value,
108
+ show_default=True,
109
+ help="Persistent runtime preset to install.",
110
+ )
111
+ @click.option(
112
+ "--runtime",
113
+ type=click.Choice([runtime.value for runtime in RuntimeKind]),
114
+ default=RuntimeKind.PYTHON.value,
115
+ show_default=True,
116
+ help="Runtime used to execute Headroom for service/task modes.",
117
+ )
118
+ @click.option(
119
+ "--scope",
120
+ type=click.Choice([scope.value for scope in ConfigScope]),
121
+ default=ConfigScope.USER.value,
122
+ show_default=True,
123
+ help="Where to apply persistent configuration.",
124
+ )
125
+ @click.option(
126
+ "--providers",
127
+ "provider_mode",
128
+ type=click.Choice([mode.value for mode in ProviderSelectionMode]),
129
+ default=ProviderSelectionMode.AUTO.value,
130
+ show_default=True,
131
+ help="Target selection mode for direct tool configuration.",
132
+ )
133
+ @click.option(
134
+ "--target",
135
+ "targets",
136
+ multiple=True,
137
+ type=click.Choice(["claude", "copilot", "codex", "aider", "cursor", "openclaw"]),
138
+ help="Tool target to configure when --providers manual is used.",
139
+ )
140
+ @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
141
+ @click.option(
142
+ "--port", "-p", default=8787, type=int, show_default=True, help="Persistent proxy port."
143
+ )
144
+ @click.option(
145
+ "--backend",
146
+ default="anthropic",
147
+ show_default=True,
148
+ help="Proxy backend for the persistent runtime.",
149
+ )
150
+ @click.option(
151
+ "--anyllm-provider",
152
+ default=None,
153
+ help="Provider for any-llm backends when --backend anyllm is used.",
154
+ )
155
+ @click.option("--region", default=None, help="Cloud region for Bedrock / Vertex style backends.")
156
+ @click.option(
157
+ "--mode", "proxy_mode", default="token", show_default=True, help="Proxy optimization mode."
158
+ )
159
+ @click.option("--memory", is_flag=True, help="Enable persistent memory in the proxy runtime.")
160
+ @click.option("--no-telemetry", is_flag=True, help="Disable anonymous telemetry in the runtime.")
161
+ @click.option(
162
+ "--image",
163
+ default="ghcr.io/chopratejas/headroom:latest",
164
+ show_default=True,
165
+ help="Docker image to use when runtime=docker or preset=persistent-docker.",
166
+ )
167
+ def install_apply(
168
+ preset: str,
169
+ runtime: str,
170
+ scope: str,
171
+ provider_mode: str,
172
+ targets: tuple[str, ...],
173
+ profile: str,
174
+ port: int,
175
+ backend: str,
176
+ anyllm_provider: str | None,
177
+ region: str | None,
178
+ proxy_mode: str,
179
+ memory: bool,
180
+ no_telemetry: bool,
181
+ image: str,
182
+ ) -> None:
183
+ """Install a persistent Headroom deployment."""
184
+
185
+ if preset == InstallPreset.PERSISTENT_DOCKER.value:
186
+ runtime = RuntimeKind.DOCKER.value
187
+
188
+ manifest = build_manifest(
189
+ profile=profile,
190
+ preset=preset,
191
+ runtime_kind=runtime,
192
+ scope=scope,
193
+ provider_mode=provider_mode,
194
+ targets=list(targets),
195
+ port=port,
196
+ backend=backend,
197
+ anyllm_provider=anyllm_provider,
198
+ region=region,
199
+ proxy_mode=proxy_mode,
200
+ memory_enabled=memory,
201
+ telemetry_enabled=not no_telemetry,
202
+ image=image,
203
+ )
204
+
205
+ existing = load_manifest(profile)
206
+ if existing is not None:
207
+ click.echo(f"Updating existing deployment profile '{profile}'...")
208
+ _remove_deployment(existing)
209
+
210
+ try:
211
+ manifest.mutations = apply_mutations(manifest)
212
+ manifest.artifacts = install_supervisor(manifest)
213
+ save_manifest(manifest)
214
+ _start_deployment(manifest)
215
+ except Exception:
216
+ _remove_deployment(manifest)
217
+ if existing is not None:
218
+ click.echo(f"Restoring previous deployment '{profile}'...")
219
+ _restore_deployment(existing)
220
+ raise
221
+
222
+ click.echo(
223
+ f"Installed persistent deployment '{profile}' "
224
+ f"({manifest.preset}, runtime={manifest.runtime_kind}, scope={manifest.scope})."
225
+ )
226
+ click.echo(f"Health: {manifest.health_url}")
227
+ if manifest.targets:
228
+ click.echo(f"Targets: {', '.join(manifest.targets)}")
229
+
230
+
231
+ @install.command("status")
232
+ @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
233
+ def install_status(profile: str) -> None:
234
+ """Show persistent deployment status."""
235
+
236
+ manifest = _require_manifest(profile)
237
+ payload = probe_json(manifest.health_url.replace("/readyz", "/health"))
238
+ click.echo(f"Profile: {manifest.profile}")
239
+ click.echo(f"Preset: {manifest.preset}")
240
+ click.echo(f"Runtime: {manifest.runtime_kind}")
241
+ click.echo(f"Supervisor: {manifest.supervisor_kind}")
242
+ click.echo(f"Scope: {manifest.scope}")
243
+ click.echo(f"Port: {manifest.port}")
244
+ click.echo(f"Status: {runtime_status(manifest)}")
245
+ click.echo(f"Healthy: {'yes' if probe_ready(manifest.health_url) else 'no'}")
246
+ if payload and isinstance(payload, dict):
247
+ click.echo(f"Health URL: {manifest.health_url.replace('/readyz', '/health')}")
248
+ click.echo(f"Backend: {payload.get('config', {}).get('backend', manifest.backend)}")
249
+
250
+
251
+ @install.command("start")
252
+ @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
253
+ def install_start(profile: str) -> None:
254
+ """Start a persistent deployment."""
255
+
256
+ manifest = _require_manifest(profile)
257
+ _reject_task_lifecycle(manifest, "start")
258
+ _start_deployment(manifest)
259
+ click.echo(f"Started deployment '{profile}'.")
260
+
261
+
262
+ @install.command("stop")
263
+ @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
264
+ def install_stop(profile: str) -> None:
265
+ """Stop a persistent deployment."""
266
+
267
+ manifest = _require_manifest(profile)
268
+ _reject_task_lifecycle(manifest, "stop")
269
+ _stop_deployment(manifest)
270
+ click.echo(f"Stopped deployment '{profile}'.")
271
+
272
+
273
+ @install.command("restart")
274
+ @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
275
+ def install_restart(profile: str) -> None:
276
+ """Restart a persistent deployment."""
277
+
278
+ manifest = _require_manifest(profile)
279
+ _reject_task_lifecycle(manifest, "restart")
280
+ _stop_deployment(manifest)
281
+ _start_deployment(manifest)
282
+ click.echo(f"Restarted deployment '{profile}'.")
283
+
284
+
285
+ @install.command("remove")
286
+ @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
287
+ def install_remove(profile: str) -> None:
288
+ """Remove a persistent deployment and undo managed config."""
289
+
290
+ manifest = _require_manifest(profile)
291
+ try:
292
+ if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
293
+ stop_supervisor(manifest)
294
+ except Exception:
295
+ pass
296
+ try:
297
+ stop_runtime(manifest)
298
+ except Exception:
299
+ pass
300
+ try:
301
+ remove_supervisor(manifest)
302
+ except Exception:
303
+ pass
304
+ revert_mutations(manifest)
305
+ delete_manifest(profile)
306
+ click.echo(f"Removed deployment '{profile}'.")
307
+
308
+
309
+ @install.group("agent", hidden=True)
310
+ def install_agent() -> None:
311
+ """Hidden runtime helpers used by persistent supervisors."""
312
+
313
+
314
+ @install_agent.command("run")
315
+ @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
316
+ def install_agent_run(profile: str) -> None:
317
+ """Run the persistent runtime in the foreground."""
318
+
319
+ manifest = _require_manifest(profile)
320
+ raise SystemExit(run_foreground(manifest))
321
+
322
+
323
+ @install_agent.command("ensure")
324
+ @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
325
+ def install_agent_ensure(profile: str) -> None:
326
+ """Ensure a persistent deployment is healthy, starting it when needed."""
327
+
328
+ manifest = _require_manifest(profile)
329
+ if probe_ready(manifest.health_url):
330
+ click.echo(f"Deployment '{profile}' is already healthy.")
331
+ return
332
+ _start_deployment(manifest)
333
+ click.echo(f"Deployment '{profile}' is healthy.")
headroom/cli/wrap.py CHANGED
@@ -460,6 +460,12 @@ def _recover_persistent_proxy(port: int) -> bool:
460
  click.echo(f" Reusing persistent deployment '{manifest.profile}' on port {port}")
461
  return True
462
 
 
 
 
 
 
 
463
  click.echo(f" Recovering persistent deployment '{manifest.profile}' on port {port}...")
464
  try:
465
  if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
@@ -517,6 +523,9 @@ def _ensure_proxy(
517
  return None
518
  if _recover_persistent_proxy(port):
519
  return None
 
 
 
520
 
521
  if _check_proxy(port):
522
  click.echo(f" Proxy already running on port {port}")
 
460
  click.echo(f" Reusing persistent deployment '{manifest.profile}' on port {port}")
461
  return True
462
 
463
+ if manifest.supervisor_kind == SupervisorKind.TASK.value:
464
+ click.echo(
465
+ f" Warning: task-based deployment '{manifest.profile}' cannot be auto-recovered via wrap"
466
+ )
467
+ return False
468
+
469
  click.echo(f" Recovering persistent deployment '{manifest.profile}' on port {port}...")
470
  try:
471
  if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
 
523
  return None
524
  if _recover_persistent_proxy(port):
525
  return None
526
+ raise click.ClickException(
527
+ f"Persistent deployment '{manifest.profile}' on port {port} is not healthy."
528
+ )
529
 
530
  if _check_proxy(port):
531
  click.echo(f" Proxy already running on port {port}")
headroom/install/paths.py CHANGED
@@ -2,9 +2,22 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  import sys
6
  from pathlib import Path
7
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  def deploy_root() -> Path:
10
  """Return the root directory for deployment state."""
@@ -15,7 +28,7 @@ def deploy_root() -> Path:
15
  def profile_root(profile: str) -> Path:
16
  """Return the directory for a named deployment profile."""
17
 
18
- return deploy_root() / profile
19
 
20
 
21
  def manifest_path(profile: str) -> Path:
 
2
 
3
  from __future__ import annotations
4
 
5
+ import re
6
  import sys
7
  from pathlib import Path
8
 
9
+ import click
10
+
11
+ _PROFILE_RE = re.compile(r"^[A-Za-z0-9._-]+$")
12
+
13
+
14
+ def validate_profile_name(profile: str) -> str:
15
+ """Validate and normalize a deployment profile name."""
16
+
17
+ if profile in {".", ".."} or not _PROFILE_RE.fullmatch(profile):
18
+ raise click.ClickException(f"Invalid profile name '{profile}'")
19
+ return profile
20
+
21
 
22
  def deploy_root() -> Path:
23
  """Return the root directory for deployment state."""
 
28
  def profile_root(profile: str) -> Path:
29
  """Return the directory for a named deployment profile."""
30
 
31
+ return deploy_root() / validate_profile_name(profile)
32
 
33
 
34
  def manifest_path(profile: str) -> Path:
headroom/install/planner.py CHANGED
@@ -1,201 +1,228 @@
1
- """Planner for persistent deployment manifests."""
2
-
3
- from __future__ import annotations
4
-
5
- import shutil
6
- from collections.abc import Iterable
7
- from pathlib import Path
8
-
9
- from .models import (
10
- DeploymentManifest,
11
- InstallPreset,
12
- ProviderSelectionMode,
13
- SupervisorKind,
14
- ToolTarget,
15
- )
16
-
17
- SUPPORTED_TARGETS = [
18
- ToolTarget.CLAUDE,
19
- ToolTarget.COPILOT,
20
- ToolTarget.CODEX,
21
- ToolTarget.AIDER,
22
- ToolTarget.CURSOR,
23
- ToolTarget.OPENCLAW,
24
- ]
25
-
26
-
27
- def _binary_name(target: ToolTarget) -> str | None:
28
- if target == ToolTarget.CURSOR:
29
- return None
30
- return str(target.value)
31
-
32
-
33
- def detect_targets() -> list[str]:
34
- """Auto-detect available tool targets on the current host."""
35
-
36
- detected: list[str] = []
37
- for target in SUPPORTED_TARGETS:
38
- binary = _binary_name(target)
39
- if binary and shutil.which(binary):
40
- detected.append(target.value)
41
- continue
42
- if target == ToolTarget.CURSOR and shutil.which("cursor"):
43
- detected.append(target.value)
44
- return detected
45
-
46
-
47
- def resolve_targets(provider_mode: str, requested_targets: Iterable[str]) -> list[str]:
48
- """Resolve target selection according to the requested provider mode."""
49
-
50
- if provider_mode == ProviderSelectionMode.ALL.value:
51
- return [target.value for target in SUPPORTED_TARGETS]
52
-
53
- if provider_mode == ProviderSelectionMode.AUTO.value:
54
- detected = detect_targets()
55
- return detected or [
56
- ToolTarget.CLAUDE.value,
57
- ToolTarget.CODEX.value,
58
- ToolTarget.COPILOT.value,
59
- ]
60
-
61
- normalized = []
62
- seen: set[str] = set()
63
- valid = {target.value for target in SUPPORTED_TARGETS}
64
- for target in requested_targets:
65
- value = target.strip().lower()
66
- if value in valid and value not in seen:
67
- seen.add(value)
68
- normalized.append(value)
69
- return normalized
70
-
71
-
72
- def _copilot_env(port: int, backend: str) -> dict[str, str]:
73
- if backend == "anthropic":
74
- return {
75
- "COPILOT_PROVIDER_TYPE": "anthropic",
76
- "COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}",
77
- }
78
- return {
79
- "COPILOT_PROVIDER_TYPE": "openai",
80
- "COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}/v1",
81
- "COPILOT_PROVIDER_WIRE_API": "completions",
82
- }
83
-
84
-
85
- def build_tool_envs(port: int, backend: str, targets: list[str]) -> dict[str, dict[str, str]]:
86
- """Build per-target environment variables for the selected tools."""
87
-
88
- target_envs: dict[str, dict[str, str]] = {}
89
- if ToolTarget.CLAUDE.value in targets:
90
- target_envs[ToolTarget.CLAUDE.value] = {
91
- "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
92
- }
93
- if ToolTarget.CODEX.value in targets:
94
- target_envs[ToolTarget.CODEX.value] = {
95
- "OPENAI_BASE_URL": f"http://127.0.0.1:{port}/v1",
96
- }
97
- if ToolTarget.AIDER.value in targets:
98
- target_envs[ToolTarget.AIDER.value] = {
99
- "OPENAI_API_BASE": f"http://127.0.0.1:{port}/v1",
100
- "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
101
- }
102
- if ToolTarget.COPILOT.value in targets:
103
- target_envs[ToolTarget.COPILOT.value] = _copilot_env(port, backend)
104
- if ToolTarget.CURSOR.value in targets:
105
- target_envs[ToolTarget.CURSOR.value] = {
106
- "OPENAI_BASE_URL": f"http://127.0.0.1:{port}/v1",
107
- "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
108
- }
109
- return target_envs
110
-
111
-
112
- def build_manifest(
113
- *,
114
- profile: str,
115
- preset: str,
116
- runtime_kind: str,
117
- scope: str,
118
- provider_mode: str,
119
- targets: list[str],
120
- port: int,
121
- backend: str,
122
- anyllm_provider: str | None,
123
- region: str | None,
124
- proxy_mode: str,
125
- memory_enabled: bool,
126
- telemetry_enabled: bool,
127
- image: str,
128
- ) -> DeploymentManifest:
129
- """Create a normalized deployment manifest."""
130
-
131
- if preset == InstallPreset.PERSISTENT_SERVICE.value:
132
- supervisor_kind = SupervisorKind.SERVICE.value
133
- elif preset == InstallPreset.PERSISTENT_TASK.value:
134
- supervisor_kind = SupervisorKind.TASK.value
135
- else:
136
- supervisor_kind = SupervisorKind.NONE.value
137
-
138
- resolved_targets = resolve_targets(provider_mode, targets)
139
- tool_envs = build_tool_envs(port, backend, resolved_targets)
140
- base_env = {
141
- "HEADROOM_PORT": str(port),
142
- "HEADROOM_HOST": "127.0.0.1",
143
- "HEADROOM_MODE": proxy_mode,
144
- "HEADROOM_BACKEND": backend,
145
- }
146
- if anyllm_provider:
147
- base_env["HEADROOM_ANYLLM_PROVIDER"] = anyllm_provider
148
- if region:
149
- base_env["HEADROOM_REGION"] = region
150
- if not telemetry_enabled:
151
- base_env["HEADROOM_TELEMETRY"] = "off"
152
- if memory_enabled:
153
- base_env["HEADROOM_MEMORY_ENABLED"] = "1"
154
-
155
- proxy_args = [
156
- "--host",
157
- "127.0.0.1",
158
- "--port",
159
- str(port),
160
- "--mode",
161
- proxy_mode,
162
- "--backend",
163
- backend,
164
- ]
165
- if not telemetry_enabled:
166
- proxy_args.append("--no-telemetry")
167
- if memory_enabled:
168
- proxy_args.extend(
169
- ["--memory", "--memory-db-path", str(Path.home() / ".headroom" / "memory.db")]
170
- )
171
- if anyllm_provider:
172
- proxy_args.extend(["--anyllm-provider", anyllm_provider])
173
- if region:
174
- proxy_args.extend(["--region", region])
175
-
176
- container_name = f"headroom-{profile}"
177
- return DeploymentManifest(
178
- profile=profile,
179
- preset=preset,
180
- runtime_kind=runtime_kind,
181
- supervisor_kind=supervisor_kind,
182
- scope=scope,
183
- provider_mode=provider_mode,
184
- targets=resolved_targets,
185
- port=port,
186
- host="127.0.0.1",
187
- backend=backend,
188
- anyllm_provider=anyllm_provider,
189
- region=region,
190
- proxy_mode=proxy_mode,
191
- memory_enabled=memory_enabled,
192
- memory_db_path=str(Path.home() / ".headroom" / "memory.db"),
193
- telemetry_enabled=telemetry_enabled,
194
- image=image,
195
- service_name=f"headroom-{profile}",
196
- container_name=container_name,
197
- health_url=f"http://127.0.0.1:{port}/readyz",
198
- base_env=base_env,
199
- tool_envs=tool_envs,
200
- proxy_args=proxy_args,
201
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Planner for persistent deployment manifests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from collections.abc import Iterable
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from .models import (
12
+ ConfigScope,
13
+ DeploymentManifest,
14
+ InstallPreset,
15
+ ProviderSelectionMode,
16
+ SupervisorKind,
17
+ ToolTarget,
18
+ )
19
+ from .paths import validate_profile_name
20
+
21
+ SUPPORTED_TARGETS = [
22
+ ToolTarget.CLAUDE,
23
+ ToolTarget.COPILOT,
24
+ ToolTarget.CODEX,
25
+ ToolTarget.AIDER,
26
+ ToolTarget.CURSOR,
27
+ ToolTarget.OPENCLAW,
28
+ ]
29
+ PROVIDER_SCOPE_TARGETS = [
30
+ ToolTarget.CLAUDE,
31
+ ToolTarget.CODEX,
32
+ ToolTarget.OPENCLAW,
33
+ ]
34
+
35
+
36
+ def _binary_name(target: ToolTarget) -> str | None:
37
+ if target == ToolTarget.CURSOR:
38
+ return None
39
+ return str(target.value)
40
+
41
+
42
+ def detect_targets() -> list[str]:
43
+ """Auto-detect available tool targets on the current host."""
44
+
45
+ detected: list[str] = []
46
+ for target in SUPPORTED_TARGETS:
47
+ binary = _binary_name(target)
48
+ if binary and shutil.which(binary):
49
+ detected.append(target.value)
50
+ continue
51
+ if target == ToolTarget.CURSOR and shutil.which("cursor"):
52
+ detected.append(target.value)
53
+ return detected
54
+
55
+
56
+ def resolve_targets(
57
+ provider_mode: str, requested_targets: Iterable[str], *, scope: str = ConfigScope.USER.value
58
+ ) -> list[str]:
59
+ """Resolve target selection according to the requested provider mode."""
60
+
61
+ valid_targets = SUPPORTED_TARGETS
62
+ if scope == ConfigScope.PROVIDER.value:
63
+ valid_targets = PROVIDER_SCOPE_TARGETS
64
+
65
+ valid = {target.value for target in valid_targets}
66
+ requested = [target.strip().lower() for target in requested_targets]
67
+
68
+ if scope == ConfigScope.PROVIDER.value:
69
+ unsupported = [target for target in requested if target and target not in valid]
70
+ if unsupported:
71
+ unsupported_list = ", ".join(sorted(set(unsupported)))
72
+ raise click.ClickException(
73
+ "Provider scope supports only claude, codex, and openclaw; "
74
+ f"unsupported targets: {unsupported_list}"
75
+ )
76
+
77
+ if provider_mode == ProviderSelectionMode.ALL.value:
78
+ return [target.value for target in valid_targets]
79
+
80
+ if provider_mode == ProviderSelectionMode.AUTO.value:
81
+ detected = [target for target in detect_targets() if target in valid]
82
+ return detected or [
83
+ ToolTarget.CLAUDE.value,
84
+ ToolTarget.CODEX.value,
85
+ *([] if scope == ConfigScope.PROVIDER.value else [ToolTarget.COPILOT.value]),
86
+ ]
87
+
88
+ normalized = []
89
+ seen: set[str] = set()
90
+ for value in requested:
91
+ if value in valid and value not in seen:
92
+ seen.add(value)
93
+ normalized.append(value)
94
+ return normalized
95
+
96
+
97
+ def _copilot_env(port: int, backend: str) -> dict[str, str]:
98
+ if backend == "anthropic":
99
+ return {
100
+ "COPILOT_PROVIDER_TYPE": "anthropic",
101
+ "COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}",
102
+ }
103
+ return {
104
+ "COPILOT_PROVIDER_TYPE": "openai",
105
+ "COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}/v1",
106
+ "COPILOT_PROVIDER_WIRE_API": "completions",
107
+ }
108
+
109
+
110
+ def build_tool_envs(port: int, backend: str, targets: list[str]) -> dict[str, dict[str, str]]:
111
+ """Build per-target environment variables for the selected tools."""
112
+
113
+ target_envs: dict[str, dict[str, str]] = {}
114
+ if ToolTarget.CLAUDE.value in targets:
115
+ target_envs[ToolTarget.CLAUDE.value] = {
116
+ "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
117
+ }
118
+ if ToolTarget.CODEX.value in targets:
119
+ target_envs[ToolTarget.CODEX.value] = {
120
+ "OPENAI_BASE_URL": f"http://127.0.0.1:{port}/v1",
121
+ }
122
+ if ToolTarget.AIDER.value in targets:
123
+ target_envs[ToolTarget.AIDER.value] = {
124
+ "OPENAI_API_BASE": f"http://127.0.0.1:{port}/v1",
125
+ "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
126
+ }
127
+ if ToolTarget.COPILOT.value in targets:
128
+ target_envs[ToolTarget.COPILOT.value] = _copilot_env(port, backend)
129
+ if ToolTarget.CURSOR.value in targets:
130
+ target_envs[ToolTarget.CURSOR.value] = {
131
+ "OPENAI_BASE_URL": f"http://127.0.0.1:{port}/v1",
132
+ "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
133
+ }
134
+ return target_envs
135
+
136
+
137
+ def build_manifest(
138
+ *,
139
+ profile: str,
140
+ preset: str,
141
+ runtime_kind: str,
142
+ scope: str,
143
+ provider_mode: str,
144
+ targets: list[str],
145
+ port: int,
146
+ backend: str,
147
+ anyllm_provider: str | None,
148
+ region: str | None,
149
+ proxy_mode: str,
150
+ memory_enabled: bool,
151
+ telemetry_enabled: bool,
152
+ image: str,
153
+ ) -> DeploymentManifest:
154
+ """Create a normalized deployment manifest."""
155
+
156
+ normalized_profile = validate_profile_name(profile)
157
+
158
+ if preset == InstallPreset.PERSISTENT_SERVICE.value:
159
+ supervisor_kind = SupervisorKind.SERVICE.value
160
+ elif preset == InstallPreset.PERSISTENT_TASK.value:
161
+ supervisor_kind = SupervisorKind.TASK.value
162
+ else:
163
+ supervisor_kind = SupervisorKind.NONE.value
164
+
165
+ resolved_targets = resolve_targets(provider_mode, targets, scope=scope)
166
+ tool_envs = build_tool_envs(port, backend, resolved_targets)
167
+ base_env = {
168
+ "HEADROOM_PORT": str(port),
169
+ "HEADROOM_HOST": "127.0.0.1",
170
+ "HEADROOM_MODE": proxy_mode,
171
+ "HEADROOM_BACKEND": backend,
172
+ }
173
+ if anyllm_provider:
174
+ base_env["HEADROOM_ANYLLM_PROVIDER"] = anyllm_provider
175
+ if region:
176
+ base_env["HEADROOM_REGION"] = region
177
+ if not telemetry_enabled:
178
+ base_env["HEADROOM_TELEMETRY"] = "off"
179
+ if memory_enabled:
180
+ base_env["HEADROOM_MEMORY_ENABLED"] = "1"
181
+
182
+ proxy_args = [
183
+ "--host",
184
+ "127.0.0.1",
185
+ "--port",
186
+ str(port),
187
+ "--mode",
188
+ proxy_mode,
189
+ "--backend",
190
+ backend,
191
+ ]
192
+ if not telemetry_enabled:
193
+ proxy_args.append("--no-telemetry")
194
+ if memory_enabled:
195
+ proxy_args.extend(
196
+ ["--memory", "--memory-db-path", str(Path.home() / ".headroom" / "memory.db")]
197
+ )
198
+ if anyllm_provider:
199
+ proxy_args.extend(["--anyllm-provider", anyllm_provider])
200
+ if region:
201
+ proxy_args.extend(["--region", region])
202
+
203
+ container_name = f"headroom-{normalized_profile}"
204
+ return DeploymentManifest(
205
+ profile=normalized_profile,
206
+ preset=preset,
207
+ runtime_kind=runtime_kind,
208
+ supervisor_kind=supervisor_kind,
209
+ scope=scope,
210
+ provider_mode=provider_mode,
211
+ targets=resolved_targets,
212
+ port=port,
213
+ host="127.0.0.1",
214
+ backend=backend,
215
+ anyllm_provider=anyllm_provider,
216
+ region=region,
217
+ proxy_mode=proxy_mode,
218
+ memory_enabled=memory_enabled,
219
+ memory_db_path=str(Path.home() / ".headroom" / "memory.db"),
220
+ telemetry_enabled=telemetry_enabled,
221
+ image=image,
222
+ service_name=f"headroom-{normalized_profile}",
223
+ container_name=container_name,
224
+ health_url=f"http://127.0.0.1:{port}/readyz",
225
+ base_env=base_env,
226
+ tool_envs=tool_envs,
227
+ proxy_args=proxy_args,
228
+ )
headroom/install/providers.py CHANGED
@@ -1,277 +1,302 @@
1
- """Tool-target configuration for persistent deployments."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import os
7
- import re
8
- import subprocess
9
- from pathlib import Path
10
-
11
- import click
12
-
13
- from .models import ConfigScope, DeploymentManifest, ManagedMutation, ToolTarget
14
- from .paths import (
15
- claude_settings_path,
16
- codex_config_path,
17
- openclaw_config_path,
18
- unix_system_env_targets,
19
- unix_user_env_targets,
20
- )
21
- from .runtime import resolve_headroom_command
22
-
23
- _ENV_MARKER_START = "# >>> headroom persistent env >>>"
24
- _ENV_MARKER_END = "# <<< headroom persistent env <<<"
25
- _ENV_PATTERN = re.compile(
26
- re.escape(_ENV_MARKER_START) + r".*?" + re.escape(_ENV_MARKER_END),
27
- re.DOTALL,
28
- )
29
- _CODEX_MARKER_START = "# --- Headroom persistent provider ---"
30
- _CODEX_MARKER_END = "# --- end Headroom persistent provider ---"
31
- _CODEX_PATTERN = re.compile(
32
- re.escape(_CODEX_MARKER_START) + r".*?" + re.escape(_CODEX_MARKER_END),
33
- re.DOTALL,
34
- )
35
-
36
-
37
- def _merge_marker_block(file_path: Path, block: str, pattern: re.Pattern[str], marker: str) -> str:
38
- if file_path.exists():
39
- existing = file_path.read_text()
40
- if marker in existing:
41
- return pattern.sub(block, existing)
42
- return existing.rstrip() + "\n\n" + block + "\n"
43
- return block + "\n"
44
-
45
-
46
- def _env_block(values: dict[str, str]) -> str:
47
- lines = [_ENV_MARKER_START]
48
- for name, value in values.items():
49
- lines.append(f'export {name}="{value}"')
50
- lines.append(_ENV_MARKER_END)
51
- return "\n".join(lines)
52
-
53
-
54
- def _unix_scope_values(manifest: DeploymentManifest) -> dict[str, str]:
55
- merged = dict(manifest.base_env)
56
- for env_map in manifest.tool_envs.values():
57
- merged.update(env_map)
58
- return merged
59
-
60
-
61
- def _apply_unix_env_scope(manifest: DeploymentManifest) -> list[ManagedMutation]:
62
- values = _unix_scope_values(manifest)
63
- block = _env_block(values)
64
- if manifest.scope == ConfigScope.USER.value:
65
- targets = unix_user_env_targets()
66
- else:
67
- targets = unix_system_env_targets()
68
- mutations: list[ManagedMutation] = []
69
- for path in targets:
70
- path.parent.mkdir(parents=True, exist_ok=True)
71
- merged = _merge_marker_block(path, block, _ENV_PATTERN, _ENV_MARKER_START)
72
- path.write_text(merged)
73
- mutations.append(ManagedMutation(target="env", kind="shell-block", path=str(path)))
74
- return mutations
75
-
76
-
77
- def _remove_unix_env_scope(mutations: list[ManagedMutation]) -> None:
78
- for mutation in mutations:
79
- if mutation.kind != "shell-block" or not mutation.path:
80
- continue
81
- path = Path(mutation.path)
82
- if not path.exists():
83
- continue
84
- content = path.read_text()
85
- if _ENV_MARKER_START not in content:
86
- continue
87
- path.write_text(_ENV_PATTERN.sub("", content).strip() + "\n")
88
-
89
-
90
- def _apply_windows_env_scope(manifest: DeploymentManifest) -> list[ManagedMutation]:
91
- scope_name = "Machine" if manifest.scope == ConfigScope.SYSTEM.value else "User"
92
- merged = _unix_scope_values(manifest)
93
- mutations: list[ManagedMutation] = []
94
- for name, value in merged.items():
95
- command = [
96
- "powershell",
97
- "-NoProfile",
98
- "-Command",
99
- f"[Environment]::SetEnvironmentVariable('{name}','{value}','{scope_name}')",
100
- ]
101
- subprocess.run(command, check=True)
102
- mutations.append(
103
- ManagedMutation(
104
- target="env", kind="windows-env", data={"name": name, "scope": scope_name}
105
- )
106
- )
107
- return mutations
108
-
109
-
110
- def _remove_windows_env_scope(mutations: list[ManagedMutation]) -> None:
111
- for mutation in mutations:
112
- if mutation.kind != "windows-env":
113
- continue
114
- name = mutation.data.get("name")
115
- scope_name = mutation.data.get("scope", "User")
116
- command = [
117
- "powershell",
118
- "-NoProfile",
119
- "-Command",
120
- f"[Environment]::SetEnvironmentVariable('{name}',$null,'{scope_name}')",
121
- ]
122
- subprocess.run(command, check=True)
123
-
124
-
125
- def _apply_claude_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
126
- path = claude_settings_path()
127
- path.parent.mkdir(parents=True, exist_ok=True)
128
- payload: dict[str, object] = {}
129
- if path.exists():
130
- payload = json.loads(path.read_text())
131
- env = payload.get("env")
132
- env_map = dict(env) if isinstance(env, dict) else {}
133
- previous = {
134
- name: env_map.get(name) for name in manifest.tool_envs.get(ToolTarget.CLAUDE.value, {})
135
- }
136
- env_map.update(manifest.tool_envs[ToolTarget.CLAUDE.value])
137
- payload["env"] = env_map
138
- path.write_text(json.dumps(payload, indent=2) + "\n")
139
- return ManagedMutation(
140
- target=ToolTarget.CLAUDE.value,
141
- kind="json-env",
142
- path=str(path),
143
- data={"previous": previous},
144
- )
145
-
146
-
147
- def _revert_claude_provider_scope(mutation: ManagedMutation, values: dict[str, str]) -> None:
148
- if not mutation.path:
149
- return
150
- path = Path(mutation.path)
151
- if not path.exists():
152
- return
153
- payload = json.loads(path.read_text())
154
- env = payload.get("env")
155
- env_map = dict(env) if isinstance(env, dict) else {}
156
- previous: dict[str, object] = mutation.data.get("previous", {})
157
- for name in values:
158
- if previous.get(name) is None:
159
- env_map.pop(name, None)
160
- else:
161
- env_map[name] = previous[name]
162
- payload["env"] = env_map
163
- path.write_text(json.dumps(payload, indent=2) + "\n")
164
-
165
-
166
- def _apply_codex_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
167
- path = codex_config_path()
168
- path.parent.mkdir(parents=True, exist_ok=True)
169
- section = (
170
- f"{_CODEX_MARKER_START}\n"
171
- 'model_provider = "headroom"\n\n'
172
- "[model_providers.headroom]\n"
173
- 'name = "Headroom persistent proxy"\n'
174
- f'base_url = "http://127.0.0.1:{manifest.port}/v1"\n'
175
- 'env_key = "OPENAI_API_KEY"\n'
176
- "requires_openai_auth = true\n"
177
- "supports_websockets = true\n"
178
- f"{_CODEX_MARKER_END}\n"
179
- )
180
- merged = _merge_marker_block(path, section, _CODEX_PATTERN, _CODEX_MARKER_START)
181
- path.write_text(merged)
182
- return ManagedMutation(target=ToolTarget.CODEX.value, kind="toml-block", path=str(path))
183
-
184
-
185
- def _revert_codex_provider_scope(mutation: ManagedMutation) -> None:
186
- if not mutation.path:
187
- return
188
- path = Path(mutation.path)
189
- if not path.exists():
190
- return
191
- content = path.read_text()
192
- if _CODEX_MARKER_START not in content:
193
- return
194
- path.write_text(_CODEX_PATTERN.sub("", content).strip() + "\n")
195
-
196
-
197
- def _invoke_openclaw(command: list[str]) -> None:
198
- subprocess.run(command, check=True)
199
-
200
-
201
- def _apply_openclaw_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
202
- if not shutil_which("openclaw"):
203
- raise click.ClickException("openclaw not found in PATH; cannot apply provider scope.")
204
- command = [
205
- *resolve_headroom_command(),
206
- "wrap",
207
- "openclaw",
208
- "--no-auto-start",
209
- "--proxy-port",
210
- str(manifest.port),
211
- ]
212
- _invoke_openclaw(command)
213
- return ManagedMutation(
214
- target=ToolTarget.OPENCLAW.value, kind="openclaw-wrap", path=str(openclaw_config_path())
215
- )
216
-
217
-
218
- def _revert_openclaw_provider_scope() -> None:
219
- if not shutil_which("openclaw"):
220
- return
221
- command = [*resolve_headroom_command(), "unwrap", "openclaw"]
222
- _invoke_openclaw(command)
223
-
224
-
225
- def shutil_which(name: str) -> str | None:
226
- from shutil import which
227
-
228
- return which(name)
229
-
230
-
231
- def apply_mutations(manifest: DeploymentManifest) -> list[ManagedMutation]:
232
- """Apply provider/user/system configuration for a deployment."""
233
-
234
- mutations: list[ManagedMutation] = []
235
- if manifest.scope in {ConfigScope.USER.value, ConfigScope.SYSTEM.value}:
236
- if os.name == "nt":
237
- mutations.extend(_apply_windows_env_scope(manifest))
238
- else:
239
- mutations.extend(_apply_unix_env_scope(manifest))
240
- if ToolTarget.OPENCLAW.value in manifest.targets:
241
- try:
242
- mutations.append(_apply_openclaw_provider_scope(manifest))
243
- except click.ClickException:
244
- pass
245
- return mutations
246
-
247
- if ToolTarget.CLAUDE.value in manifest.targets:
248
- mutations.append(_apply_claude_provider_scope(manifest))
249
- if ToolTarget.CODEX.value in manifest.targets:
250
- mutations.append(_apply_codex_provider_scope(manifest))
251
- if ToolTarget.OPENCLAW.value in manifest.targets:
252
- try:
253
- mutations.append(_apply_openclaw_provider_scope(manifest))
254
- except click.ClickException:
255
- pass
256
- return mutations
257
-
258
-
259
- def revert_mutations(manifest: DeploymentManifest) -> None:
260
- """Undo the stored mutations for a deployment."""
261
-
262
- if manifest.scope in {ConfigScope.USER.value, ConfigScope.SYSTEM.value}:
263
- shell_mutations = [m for m in manifest.mutations if m.target == "env"]
264
- if os.name == "nt":
265
- _remove_windows_env_scope(shell_mutations)
266
- else:
267
- _remove_unix_env_scope(shell_mutations)
268
-
269
- for mutation in manifest.mutations:
270
- if mutation.target == ToolTarget.CLAUDE.value and mutation.kind == "json-env":
271
- _revert_claude_provider_scope(
272
- mutation, manifest.tool_envs.get(ToolTarget.CLAUDE.value, {})
273
- )
274
- elif mutation.target == ToolTarget.CODEX.value and mutation.kind == "toml-block":
275
- _revert_codex_provider_scope(mutation)
276
- elif mutation.target == ToolTarget.OPENCLAW.value and mutation.kind == "openclaw-wrap":
277
- _revert_openclaw_provider_scope()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tool-target configuration for persistent deployments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ import click
12
+
13
+ from .models import ConfigScope, DeploymentManifest, ManagedMutation, ToolTarget
14
+ from .paths import (
15
+ claude_settings_path,
16
+ codex_config_path,
17
+ openclaw_config_path,
18
+ unix_system_env_targets,
19
+ unix_user_env_targets,
20
+ )
21
+ from .runtime import resolve_headroom_command
22
+
23
+ _ENV_MARKER_START = "# >>> headroom persistent env >>>"
24
+ _ENV_MARKER_END = "# <<< headroom persistent env <<<"
25
+ _ENV_PATTERN = re.compile(
26
+ re.escape(_ENV_MARKER_START) + r".*?" + re.escape(_ENV_MARKER_END),
27
+ re.DOTALL,
28
+ )
29
+ _CODEX_MARKER_START = "# --- Headroom persistent provider ---"
30
+ _CODEX_MARKER_END = "# --- end Headroom persistent provider ---"
31
+ _CODEX_PATTERN = re.compile(
32
+ re.escape(_CODEX_MARKER_START) + r".*?" + re.escape(_CODEX_MARKER_END),
33
+ re.DOTALL,
34
+ )
35
+
36
+
37
+ def _merge_marker_block(file_path: Path, block: str, pattern: re.Pattern[str], marker: str) -> str:
38
+ if file_path.exists():
39
+ existing = file_path.read_text()
40
+ if marker in existing:
41
+ return pattern.sub(block, existing)
42
+ return existing.rstrip() + "\n\n" + block + "\n"
43
+ return block + "\n"
44
+
45
+
46
+ def _env_block(values: dict[str, str]) -> str:
47
+ lines = [_ENV_MARKER_START]
48
+ for name, value in values.items():
49
+ lines.append(f'export {name}="{value}"')
50
+ lines.append(_ENV_MARKER_END)
51
+ return "\n".join(lines)
52
+
53
+
54
+ def _powershell_literal(value: str) -> str:
55
+ return "'" + value.replace("'", "''") + "'"
56
+
57
+
58
+ def _unix_scope_values(manifest: DeploymentManifest) -> dict[str, str]:
59
+ merged = dict(manifest.base_env)
60
+ for env_map in manifest.tool_envs.values():
61
+ merged.update(env_map)
62
+ return merged
63
+
64
+
65
+ def _apply_unix_env_scope(manifest: DeploymentManifest) -> list[ManagedMutation]:
66
+ values = _unix_scope_values(manifest)
67
+ block = _env_block(values)
68
+ if manifest.scope == ConfigScope.USER.value:
69
+ targets = unix_user_env_targets()
70
+ else:
71
+ targets = unix_system_env_targets()
72
+ mutations: list[ManagedMutation] = []
73
+ for path in targets:
74
+ path.parent.mkdir(parents=True, exist_ok=True)
75
+ merged = _merge_marker_block(path, block, _ENV_PATTERN, _ENV_MARKER_START)
76
+ path.write_text(merged)
77
+ mutations.append(ManagedMutation(target="env", kind="shell-block", path=str(path)))
78
+ return mutations
79
+
80
+
81
+ def _remove_unix_env_scope(mutations: list[ManagedMutation]) -> None:
82
+ for mutation in mutations:
83
+ if mutation.kind != "shell-block" or not mutation.path:
84
+ continue
85
+ path = Path(mutation.path)
86
+ if not path.exists():
87
+ continue
88
+ content = path.read_text()
89
+ if _ENV_MARKER_START not in content:
90
+ continue
91
+ path.write_text(_ENV_PATTERN.sub("", content).strip() + "\n")
92
+
93
+
94
+ def _apply_windows_env_scope(manifest: DeploymentManifest) -> list[ManagedMutation]:
95
+ scope_name = "Machine" if manifest.scope == ConfigScope.SYSTEM.value else "User"
96
+ merged = _unix_scope_values(manifest)
97
+ mutations: list[ManagedMutation] = []
98
+ for name, value in merged.items():
99
+ previous = subprocess.run(
100
+ [
101
+ "powershell",
102
+ "-NoProfile",
103
+ "-Command",
104
+ f"$value = [Environment]::GetEnvironmentVariable({_powershell_literal(name)},{_powershell_literal(scope_name)}); "
105
+ "if ($null -eq $value) { '__HEADROOM_UNSET__' } else { $value }",
106
+ ],
107
+ capture_output=True,
108
+ text=True,
109
+ check=True,
110
+ ).stdout.strip()
111
+ command = [
112
+ "powershell",
113
+ "-NoProfile",
114
+ "-Command",
115
+ f"[Environment]::SetEnvironmentVariable({_powershell_literal(name)},{_powershell_literal(value)},{_powershell_literal(scope_name)})",
116
+ ]
117
+ subprocess.run(command, check=True)
118
+ mutations.append(
119
+ ManagedMutation(
120
+ target="env",
121
+ kind="windows-env",
122
+ data={
123
+ "name": name,
124
+ "scope": scope_name,
125
+ "previous": None if previous == "__HEADROOM_UNSET__" else previous,
126
+ },
127
+ )
128
+ )
129
+ return mutations
130
+
131
+
132
+ def _remove_windows_env_scope(mutations: list[ManagedMutation]) -> None:
133
+ for mutation in mutations:
134
+ if mutation.kind != "windows-env":
135
+ continue
136
+ name = mutation.data.get("name")
137
+ if not isinstance(name, str):
138
+ raise ValueError("Windows environment mutation is missing a variable name")
139
+ scope_name = mutation.data.get("scope", "User")
140
+ if not isinstance(scope_name, str):
141
+ raise ValueError("Windows environment mutation is missing a valid scope")
142
+ previous = mutation.data.get("previous")
143
+ if previous is None:
144
+ value_literal = "$null"
145
+ else:
146
+ value_literal = _powershell_literal(previous)
147
+ command = [
148
+ "powershell",
149
+ "-NoProfile",
150
+ "-Command",
151
+ f"[Environment]::SetEnvironmentVariable({_powershell_literal(name)},{value_literal},{_powershell_literal(scope_name)})",
152
+ ]
153
+ subprocess.run(command, check=True)
154
+
155
+
156
+ def _apply_claude_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
157
+ path = claude_settings_path()
158
+ path.parent.mkdir(parents=True, exist_ok=True)
159
+ payload: dict[str, object] = {}
160
+ if path.exists():
161
+ payload = json.loads(path.read_text())
162
+ env = payload.get("env")
163
+ env_map = dict(env) if isinstance(env, dict) else {}
164
+ previous = {
165
+ name: env_map.get(name) for name in manifest.tool_envs.get(ToolTarget.CLAUDE.value, {})
166
+ }
167
+ env_map.update(manifest.tool_envs[ToolTarget.CLAUDE.value])
168
+ payload["env"] = env_map
169
+ path.write_text(json.dumps(payload, indent=2) + "\n")
170
+ return ManagedMutation(
171
+ target=ToolTarget.CLAUDE.value,
172
+ kind="json-env",
173
+ path=str(path),
174
+ data={"previous": previous},
175
+ )
176
+
177
+
178
+ def _revert_claude_provider_scope(mutation: ManagedMutation, values: dict[str, str]) -> None:
179
+ if not mutation.path:
180
+ return
181
+ path = Path(mutation.path)
182
+ if not path.exists():
183
+ return
184
+ payload = json.loads(path.read_text())
185
+ env = payload.get("env")
186
+ env_map = dict(env) if isinstance(env, dict) else {}
187
+ previous: dict[str, object] = mutation.data.get("previous", {})
188
+ for name in values:
189
+ if previous.get(name) is None:
190
+ env_map.pop(name, None)
191
+ else:
192
+ env_map[name] = previous[name]
193
+ payload["env"] = env_map
194
+ path.write_text(json.dumps(payload, indent=2) + "\n")
195
+
196
+
197
+ def _apply_codex_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
198
+ path = codex_config_path()
199
+ path.parent.mkdir(parents=True, exist_ok=True)
200
+ section = (
201
+ f"{_CODEX_MARKER_START}\n"
202
+ 'model_provider = "headroom"\n\n'
203
+ "[model_providers.headroom]\n"
204
+ 'name = "Headroom persistent proxy"\n'
205
+ f'base_url = "http://127.0.0.1:{manifest.port}/v1"\n'
206
+ 'env_key = "OPENAI_API_KEY"\n'
207
+ "requires_openai_auth = true\n"
208
+ "supports_websockets = true\n"
209
+ f"{_CODEX_MARKER_END}\n"
210
+ )
211
+ merged = _merge_marker_block(path, section, _CODEX_PATTERN, _CODEX_MARKER_START)
212
+ path.write_text(merged)
213
+ return ManagedMutation(target=ToolTarget.CODEX.value, kind="toml-block", path=str(path))
214
+
215
+
216
+ def _revert_codex_provider_scope(mutation: ManagedMutation) -> None:
217
+ if not mutation.path:
218
+ return
219
+ path = Path(mutation.path)
220
+ if not path.exists():
221
+ return
222
+ content = path.read_text()
223
+ if _CODEX_MARKER_START not in content:
224
+ return
225
+ path.write_text(_CODEX_PATTERN.sub("", content).strip() + "\n")
226
+
227
+
228
+ def _invoke_openclaw(command: list[str]) -> None:
229
+ subprocess.run(command, check=True)
230
+
231
+
232
+ def _apply_openclaw_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
233
+ if not shutil_which("openclaw"):
234
+ raise click.ClickException("openclaw not found in PATH; cannot apply provider scope.")
235
+ command = [
236
+ *resolve_headroom_command(),
237
+ "wrap",
238
+ "openclaw",
239
+ "--no-auto-start",
240
+ "--proxy-port",
241
+ str(manifest.port),
242
+ ]
243
+ _invoke_openclaw(command)
244
+ return ManagedMutation(
245
+ target=ToolTarget.OPENCLAW.value, kind="openclaw-wrap", path=str(openclaw_config_path())
246
+ )
247
+
248
+
249
+ def _revert_openclaw_provider_scope() -> None:
250
+ if not shutil_which("openclaw"):
251
+ return
252
+ command = [*resolve_headroom_command(), "unwrap", "openclaw"]
253
+ _invoke_openclaw(command)
254
+
255
+
256
+ def shutil_which(name: str) -> str | None:
257
+ from shutil import which
258
+
259
+ return which(name)
260
+
261
+
262
+ def apply_mutations(manifest: DeploymentManifest) -> list[ManagedMutation]:
263
+ """Apply provider/user/system configuration for a deployment."""
264
+
265
+ mutations: list[ManagedMutation] = []
266
+ if manifest.scope in {ConfigScope.USER.value, ConfigScope.SYSTEM.value}:
267
+ if os.name == "nt":
268
+ mutations.extend(_apply_windows_env_scope(manifest))
269
+ else:
270
+ mutations.extend(_apply_unix_env_scope(manifest))
271
+ if ToolTarget.OPENCLAW.value in manifest.targets:
272
+ mutations.append(_apply_openclaw_provider_scope(manifest))
273
+ return mutations
274
+
275
+ if ToolTarget.CLAUDE.value in manifest.targets:
276
+ mutations.append(_apply_claude_provider_scope(manifest))
277
+ if ToolTarget.CODEX.value in manifest.targets:
278
+ mutations.append(_apply_codex_provider_scope(manifest))
279
+ if ToolTarget.OPENCLAW.value in manifest.targets:
280
+ mutations.append(_apply_openclaw_provider_scope(manifest))
281
+ return mutations
282
+
283
+
284
+ def revert_mutations(manifest: DeploymentManifest) -> None:
285
+ """Undo the stored mutations for a deployment."""
286
+
287
+ if manifest.scope in {ConfigScope.USER.value, ConfigScope.SYSTEM.value}:
288
+ shell_mutations = [m for m in manifest.mutations if m.target == "env"]
289
+ if os.name == "nt":
290
+ _remove_windows_env_scope(shell_mutations)
291
+ else:
292
+ _remove_unix_env_scope(shell_mutations)
293
+
294
+ for mutation in manifest.mutations:
295
+ if mutation.target == ToolTarget.CLAUDE.value and mutation.kind == "json-env":
296
+ _revert_claude_provider_scope(
297
+ mutation, manifest.tool_envs.get(ToolTarget.CLAUDE.value, {})
298
+ )
299
+ elif mutation.target == ToolTarget.CODEX.value and mutation.kind == "toml-block":
300
+ _revert_codex_provider_scope(mutation)
301
+ elif mutation.target == ToolTarget.OPENCLAW.value and mutation.kind == "openclaw-wrap":
302
+ _revert_openclaw_provider_scope()
headroom/install/runtime.py CHANGED
@@ -1,233 +1,270 @@
1
- """Runtime helpers for persistent deployments."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
- import shutil
7
- import signal
8
- import subprocess
9
- import sys
10
- import time
11
- from pathlib import Path
12
- from typing import Any
13
-
14
- from .health import probe_ready
15
- from .models import DeploymentManifest, InstallPreset, RuntimeKind
16
- from .paths import log_path, pid_path
17
-
18
-
19
- def _deployment_env(manifest: DeploymentManifest) -> dict[str, str]:
20
- return {
21
- "HEADROOM_DEPLOYMENT_PROFILE": manifest.profile,
22
- "HEADROOM_DEPLOYMENT_PRESET": manifest.preset,
23
- "HEADROOM_DEPLOYMENT_RUNTIME": manifest.runtime_kind,
24
- "HEADROOM_DEPLOYMENT_SUPERVISOR": manifest.supervisor_kind,
25
- "HEADROOM_DEPLOYMENT_SCOPE": manifest.scope,
26
- }
27
-
28
-
29
- def resolve_headroom_command() -> list[str]:
30
- """Resolve the most reliable command to invoke headroom."""
31
-
32
- headroom_bin = shutil.which("headroom")
33
- if headroom_bin:
34
- return [headroom_bin]
35
- return [sys.executable, "-m", "headroom.cli"]
36
-
37
-
38
- def _runtime_env(manifest: DeploymentManifest) -> dict[str, str]:
39
- env = os.environ.copy()
40
- env.update(manifest.base_env)
41
- env.update(_deployment_env(manifest))
42
- return env
43
-
44
-
45
- def build_runtime_command(manifest: DeploymentManifest) -> list[str]:
46
- """Build the raw foreground command that runs the proxy."""
47
-
48
- if manifest.runtime_kind == RuntimeKind.PYTHON.value:
49
- return [sys.executable, "-m", "headroom.cli", "proxy", *manifest.proxy_args]
50
-
51
- home = str(Path.home())
52
- container_home = "/tmp/headroom-home"
53
- command = [
54
- "docker",
55
- "run",
56
- "--rm",
57
- "--name",
58
- manifest.container_name,
59
- "-p",
60
- f"127.0.0.1:{manifest.port}:{manifest.port}",
61
- "--workdir",
62
- container_home,
63
- "--env",
64
- f"HOME={container_home}",
65
- "--env",
66
- "PYTHONUNBUFFERED=1",
67
- "--volume",
68
- f"{home}\\.headroom:{container_home}/.headroom"
69
- if os.name == "nt"
70
- else f"{home}/.headroom:{container_home}/.headroom",
71
- "--volume",
72
- f"{home}\\.claude:{container_home}/.claude"
73
- if os.name == "nt"
74
- else f"{home}/.claude:{container_home}/.claude",
75
- "--volume",
76
- f"{home}\\.codex:{container_home}/.codex"
77
- if os.name == "nt"
78
- else f"{home}/.codex:{container_home}/.codex",
79
- "--volume",
80
- f"{home}\\.gemini:{container_home}/.gemini"
81
- if os.name == "nt"
82
- else f"{home}/.gemini:{container_home}/.gemini",
83
- ]
84
- runtime_env = {**manifest.base_env, **_deployment_env(manifest)}
85
- for name, value in runtime_env.items():
86
- command.extend(["--env", f"{name}={value}"])
87
- command.extend(
88
- [
89
- manifest.image,
90
- "headroom",
91
- "proxy",
92
- "--host",
93
- "0.0.0.0",
94
- *manifest.proxy_args[2:],
95
- ]
96
- )
97
- return command
98
-
99
-
100
- def _write_pid(profile: str, pid: int) -> None:
101
- path = pid_path(profile)
102
- path.parent.mkdir(parents=True, exist_ok=True)
103
- path.write_text(str(pid))
104
-
105
-
106
- def _read_pid(profile: str) -> int | None:
107
- path = pid_path(profile)
108
- if not path.exists():
109
- return None
110
- try:
111
- return int(path.read_text().strip())
112
- except ValueError:
113
- return None
114
-
115
-
116
- def _clear_pid(profile: str) -> None:
117
- path = pid_path(profile)
118
- if path.exists():
119
- path.unlink()
120
-
121
-
122
- def run_foreground(manifest: DeploymentManifest) -> int:
123
- """Run the raw runtime command in the foreground."""
124
-
125
- command = build_runtime_command(manifest)
126
- env = _runtime_env(manifest)
127
- log_file_path = log_path(manifest.profile)
128
- log_file_path.parent.mkdir(parents=True, exist_ok=True)
129
-
130
- with open(log_file_path, "a", encoding="utf-8", errors="replace") as log_file:
131
- proc = subprocess.Popen(command, env=env, stdout=log_file, stderr=log_file)
132
- _write_pid(manifest.profile, proc.pid)
133
-
134
- def _cleanup(signum: int | None = None, frame: Any = None) -> None:
135
- if proc.poll() is None:
136
- proc.terminate()
137
- try:
138
- proc.wait(timeout=10)
139
- except subprocess.TimeoutExpired:
140
- proc.kill()
141
-
142
- signal.signal(signal.SIGINT, _cleanup)
143
- signal.signal(signal.SIGTERM, _cleanup)
144
- try:
145
- return proc.wait()
146
- finally:
147
- _clear_pid(manifest.profile)
148
-
149
-
150
- def start_detached_agent(profile: str) -> subprocess.Popen[str]:
151
- """Start `headroom install agent run` detached for the given profile."""
152
-
153
- command = [*resolve_headroom_command(), "install", "agent", "run", "--profile", profile]
154
- log_file_path = log_path(profile)
155
- log_file_path.parent.mkdir(parents=True, exist_ok=True)
156
- log_file = open(log_file_path, "a", encoding="utf-8", errors="replace") # noqa: SIM115
157
-
158
- kwargs: dict[str, Any] = {"stdout": log_file, "stderr": log_file}
159
- if os.name == "nt":
160
- kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
161
- subprocess, "CREATE_NEW_PROCESS_GROUP", 0
162
- )
163
- else:
164
- kwargs["start_new_session"] = True
165
- return subprocess.Popen(command, **kwargs)
166
-
167
-
168
- def start_persistent_docker(manifest: DeploymentManifest) -> None:
169
- """Start a persistent Docker container with restart policy."""
170
-
171
- command = build_runtime_command(manifest)
172
- docker_cmd = [
173
- "docker",
174
- "run",
175
- "-d",
176
- "--restart",
177
- "unless-stopped",
178
- "--name",
179
- manifest.container_name,
180
- *command[5:], # drop initial `docker run --rm --name ...`
181
- ]
182
- subprocess.run(["docker", "rm", "-f", manifest.container_name], capture_output=True, text=True)
183
- subprocess.run(docker_cmd, check=True)
184
-
185
-
186
- def stop_runtime(manifest: DeploymentManifest) -> None:
187
- """Stop the raw runtime for the deployment."""
188
-
189
- if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
190
- subprocess.run(["docker", "stop", manifest.container_name], capture_output=True, text=True)
191
- subprocess.run(
192
- ["docker", "rm", "-f", manifest.container_name], capture_output=True, text=True
193
- )
194
- return
195
-
196
- pid = _read_pid(manifest.profile)
197
- if pid is None:
198
- return
199
- try:
200
- os.kill(pid, signal.SIGTERM)
201
- except OSError:
202
- pass
203
- _clear_pid(manifest.profile)
204
-
205
-
206
- def wait_ready(manifest: DeploymentManifest, timeout_seconds: int = 30) -> bool:
207
- """Wait for the deployment to report ready."""
208
-
209
- for _ in range(timeout_seconds):
210
- if probe_ready(manifest.health_url):
211
- return True
212
- time.sleep(1)
213
- return False
214
-
215
-
216
- def runtime_status(manifest: DeploymentManifest) -> str:
217
- """Return a short status string for the deployment runtime."""
218
-
219
- if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
220
- result = subprocess.run(
221
- ["docker", "ps", "--format", "{{.Names}}"], capture_output=True, text=True
222
- )
223
- if manifest.container_name in result.stdout.splitlines():
224
- return "running"
225
- return "stopped"
226
- pid = _read_pid(manifest.profile)
227
- if pid is None:
228
- return "stopped"
229
- try:
230
- os.kill(pid, 0)
231
- except OSError:
232
- return "stopped"
233
- return "running"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runtime helpers for persistent deployments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .health import probe_ready
15
+ from .models import DeploymentManifest, InstallPreset, RuntimeKind
16
+ from .paths import log_path, pid_path
17
+
18
+ PASSTHROUGH_ENV_PREFIXES = (
19
+ "HEADROOM_",
20
+ "ANTHROPIC_",
21
+ "OPENAI_",
22
+ "GEMINI_",
23
+ "AWS_",
24
+ "AZURE_",
25
+ "VERTEX_",
26
+ "GOOGLE_",
27
+ "GOOGLE_CLOUD_",
28
+ "MISTRAL_",
29
+ "GROQ_",
30
+ "OPENROUTER_",
31
+ "XAI_",
32
+ "TOGETHER_",
33
+ "COHERE_",
34
+ "OLLAMA_",
35
+ "LITELLM_",
36
+ "OTEL_",
37
+ "SUPABASE_",
38
+ "QDRANT_",
39
+ "NEO4J_",
40
+ "LANGSMITH_",
41
+ )
42
+
43
+
44
+ def _deployment_env(manifest: DeploymentManifest) -> dict[str, str]:
45
+ return {
46
+ "HEADROOM_DEPLOYMENT_PROFILE": manifest.profile,
47
+ "HEADROOM_DEPLOYMENT_PRESET": manifest.preset,
48
+ "HEADROOM_DEPLOYMENT_RUNTIME": manifest.runtime_kind,
49
+ "HEADROOM_DEPLOYMENT_SUPERVISOR": manifest.supervisor_kind,
50
+ "HEADROOM_DEPLOYMENT_SCOPE": manifest.scope,
51
+ }
52
+
53
+
54
+ def resolve_headroom_command() -> list[str]:
55
+ """Resolve the most reliable command to invoke headroom."""
56
+
57
+ headroom_bin = shutil.which("headroom")
58
+ if headroom_bin:
59
+ return [headroom_bin]
60
+ return [sys.executable, "-m", "headroom.cli"]
61
+
62
+
63
+ def _runtime_env(manifest: DeploymentManifest) -> dict[str, str]:
64
+ env = os.environ.copy()
65
+ env.update(manifest.base_env)
66
+ env.update(_deployment_env(manifest))
67
+ return env
68
+
69
+
70
+ def _ensure_host_dirs() -> None:
71
+ for subdir in (".headroom", ".claude", ".codex", ".gemini"):
72
+ (Path.home() / subdir).mkdir(parents=True, exist_ok=True)
73
+
74
+
75
+ def _mount_source(home: str, subdir: str) -> str:
76
+ if os.name == "nt":
77
+ return f"{home}\\{subdir}"
78
+ return f"{home}/{subdir}"
79
+
80
+
81
+ def build_runtime_command(manifest: DeploymentManifest) -> list[str]:
82
+ """Build the raw foreground command that runs the proxy."""
83
+
84
+ if manifest.runtime_kind == RuntimeKind.PYTHON.value:
85
+ return [sys.executable, "-m", "headroom.cli", "proxy", *manifest.proxy_args]
86
+
87
+ _ensure_host_dirs()
88
+ home = str(Path.home())
89
+ container_home = "/tmp/headroom-home"
90
+ command = [
91
+ "docker",
92
+ "run",
93
+ "--rm",
94
+ "--name",
95
+ manifest.container_name,
96
+ "-p",
97
+ f"127.0.0.1:{manifest.port}:{manifest.port}",
98
+ "--workdir",
99
+ container_home,
100
+ "--env",
101
+ f"HOME={container_home}",
102
+ "--env",
103
+ "PYTHONUNBUFFERED=1",
104
+ "--volume",
105
+ f"{_mount_source(home, '.headroom')}:{container_home}/.headroom",
106
+ "--volume",
107
+ f"{_mount_source(home, '.claude')}:{container_home}/.claude",
108
+ "--volume",
109
+ f"{_mount_source(home, '.codex')}:{container_home}/.codex",
110
+ "--volume",
111
+ f"{_mount_source(home, '.gemini')}:{container_home}/.gemini",
112
+ ]
113
+ if os.name != "nt":
114
+ getuid = getattr(os, "getuid", None)
115
+ getgid = getattr(os, "getgid", None)
116
+ if callable(getuid) and callable(getgid):
117
+ command.extend(["--user", f"{getuid()}:{getgid()}"])
118
+ runtime_env = {**manifest.base_env, **_deployment_env(manifest)}
119
+ for name, value in runtime_env.items():
120
+ command.extend(["--env", f"{name}={value}"])
121
+ for name in sorted(os.environ):
122
+ if name.startswith(PASSTHROUGH_ENV_PREFIXES):
123
+ command.extend(["--env", name])
124
+ command.extend(
125
+ [
126
+ manifest.image,
127
+ "headroom",
128
+ "proxy",
129
+ "--host",
130
+ "0.0.0.0",
131
+ *manifest.proxy_args[2:],
132
+ ]
133
+ )
134
+ return command
135
+
136
+
137
+ def _write_pid(profile: str, pid: int) -> None:
138
+ path = pid_path(profile)
139
+ path.parent.mkdir(parents=True, exist_ok=True)
140
+ path.write_text(str(pid))
141
+
142
+
143
+ def _read_pid(profile: str) -> int | None:
144
+ path = pid_path(profile)
145
+ if not path.exists():
146
+ return None
147
+ try:
148
+ return int(path.read_text().strip())
149
+ except ValueError:
150
+ return None
151
+
152
+
153
+ def _clear_pid(profile: str) -> None:
154
+ path = pid_path(profile)
155
+ if path.exists():
156
+ path.unlink()
157
+
158
+
159
+ def run_foreground(manifest: DeploymentManifest) -> int:
160
+ """Run the raw runtime command in the foreground."""
161
+
162
+ command = build_runtime_command(manifest)
163
+ env = _runtime_env(manifest)
164
+ log_file_path = log_path(manifest.profile)
165
+ log_file_path.parent.mkdir(parents=True, exist_ok=True)
166
+
167
+ with open(log_file_path, "a", encoding="utf-8", errors="replace") as log_file:
168
+ proc = subprocess.Popen(command, env=env, stdout=log_file, stderr=log_file)
169
+ _write_pid(manifest.profile, proc.pid)
170
+
171
+ def _cleanup(signum: int | None = None, frame: Any = None) -> None:
172
+ if proc.poll() is None:
173
+ proc.terminate()
174
+ try:
175
+ proc.wait(timeout=10)
176
+ except subprocess.TimeoutExpired:
177
+ proc.kill()
178
+
179
+ signal.signal(signal.SIGINT, _cleanup)
180
+ signal.signal(signal.SIGTERM, _cleanup)
181
+ try:
182
+ return proc.wait()
183
+ finally:
184
+ _clear_pid(manifest.profile)
185
+
186
+
187
+ def start_detached_agent(profile: str) -> subprocess.Popen[str]:
188
+ """Start `headroom install agent run` detached for the given profile."""
189
+
190
+ command = [*resolve_headroom_command(), "install", "agent", "run", "--profile", profile]
191
+ log_file_path = log_path(profile)
192
+ log_file_path.parent.mkdir(parents=True, exist_ok=True)
193
+ log_file = open(log_file_path, "a", encoding="utf-8", errors="replace") # noqa: SIM115
194
+
195
+ kwargs: dict[str, Any] = {"stdout": log_file, "stderr": log_file}
196
+ if os.name == "nt":
197
+ kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
198
+ subprocess, "CREATE_NEW_PROCESS_GROUP", 0
199
+ )
200
+ else:
201
+ kwargs["start_new_session"] = True
202
+ return subprocess.Popen(command, **kwargs)
203
+
204
+
205
+ def start_persistent_docker(manifest: DeploymentManifest) -> None:
206
+ """Start a persistent Docker container with restart policy."""
207
+
208
+ command = build_runtime_command(manifest)
209
+ docker_cmd = [
210
+ "docker",
211
+ "run",
212
+ "-d",
213
+ "--restart",
214
+ "unless-stopped",
215
+ "--name",
216
+ manifest.container_name,
217
+ *command[5:], # drop initial `docker run --rm --name ...`
218
+ ]
219
+ subprocess.run(["docker", "rm", "-f", manifest.container_name], capture_output=True, text=True)
220
+ subprocess.run(docker_cmd, check=True)
221
+
222
+
223
+ def stop_runtime(manifest: DeploymentManifest) -> None:
224
+ """Stop the raw runtime for the deployment."""
225
+
226
+ if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
227
+ subprocess.run(["docker", "stop", manifest.container_name], capture_output=True, text=True)
228
+ subprocess.run(
229
+ ["docker", "rm", "-f", manifest.container_name], capture_output=True, text=True
230
+ )
231
+ return
232
+
233
+ pid = _read_pid(manifest.profile)
234
+ if pid is None:
235
+ return
236
+ try:
237
+ os.kill(pid, signal.SIGTERM)
238
+ except OSError:
239
+ pass
240
+ _clear_pid(manifest.profile)
241
+
242
+
243
+ def wait_ready(manifest: DeploymentManifest, timeout_seconds: int = 30) -> bool:
244
+ """Wait for the deployment to report ready."""
245
+
246
+ for _ in range(timeout_seconds):
247
+ if probe_ready(manifest.health_url):
248
+ return True
249
+ time.sleep(1)
250
+ return False
251
+
252
+
253
+ def runtime_status(manifest: DeploymentManifest) -> str:
254
+ """Return a short status string for the deployment runtime."""
255
+
256
+ if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
257
+ result = subprocess.run(
258
+ ["docker", "ps", "--format", "{{.Names}}"], capture_output=True, text=True
259
+ )
260
+ if manifest.container_name in result.stdout.splitlines():
261
+ return "running"
262
+ return "stopped"
263
+ pid = _read_pid(manifest.profile)
264
+ if pid is None:
265
+ return "stopped"
266
+ try:
267
+ os.kill(pid, 0)
268
+ except OSError:
269
+ return "stopped"
270
+ return "running"
headroom/install/state.py CHANGED
@@ -3,6 +3,7 @@
3
  from __future__ import annotations
4
 
5
  import json
 
6
  from dataclasses import asdict
7
 
8
  from .models import ArtifactRecord, DeploymentManifest, ManagedMutation, iso_utc_now
@@ -53,8 +54,8 @@ def list_manifests() -> list[DeploymentManifest]:
53
 
54
 
55
  def delete_manifest(profile: str = "default") -> None:
56
- """Delete the deployment manifest if present."""
57
 
58
- path = manifest_path(profile)
59
- if path.exists():
60
- path.unlink()
 
3
  from __future__ import annotations
4
 
5
  import json
6
+ import shutil
7
  from dataclasses import asdict
8
 
9
  from .models import ArtifactRecord, DeploymentManifest, ManagedMutation, iso_utc_now
 
54
 
55
 
56
  def delete_manifest(profile: str = "default") -> None:
57
+ """Delete the full deployment profile state if present."""
58
 
59
+ root = profile_root(profile)
60
+ if root.exists():
61
+ shutil.rmtree(root, ignore_errors=True)
scripts/install.ps1 CHANGED
@@ -66,11 +66,12 @@ function Write-Wrapper {
66
 
67
  $wrapperPath = Join-Path $TargetDir 'headroom.ps1'
68
  $cmdPath = Join-Path $TargetDir 'headroom.cmd'
 
69
 
70
  $wrapper = @'
71
  $ErrorActionPreference = 'Stop'
72
 
73
- $HeadroomImage = if ($env:HEADROOM_DOCKER_IMAGE) { $env:HEADROOM_DOCKER_IMAGE } else { 'ghcr.io/chopratejas/headroom:latest' }
74
  $ContainerHome = if ($env:HEADROOM_CONTAINER_HOME) { $env:HEADROOM_CONTAINER_HOME } else { '/tmp/headroom-home' }
75
  $HostHome = $HOME
76
 
@@ -306,6 +307,15 @@ function Require-OptionValue {
306
  }
307
  }
308
 
 
 
 
 
 
 
 
 
 
309
  function Get-PersistentDockerArgs {
310
  Ensure-HostDirs
311
  $args = New-Object System.Collections.Generic.List[string]
@@ -388,7 +398,7 @@ function Write-PersistentState {
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 {
@@ -443,7 +453,7 @@ function Write-PersistentManifest {
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 {
@@ -479,6 +489,13 @@ function Start-PersistentDockerInstall {
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')
@@ -595,6 +612,26 @@ function Show-InstallApplyHelp {
595
  Write-Host ($lines -join [Environment]::NewLine)
596
  }
597
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
598
  function Parse-InstallApplyArgs {
599
  param([string[]]$Arguments)
600
 
@@ -723,7 +760,7 @@ function Parse-InstallApplyArgs {
723
  $i += 1
724
  continue
725
  }
726
- '^(--help|-\\?)$' {
727
  Show-InstallApplyHelp
728
  exit 0
729
  }
@@ -765,7 +802,7 @@ function Parse-InstallProfileArgs {
765
  $i += 1
766
  continue
767
  }
768
- '^(--help|-\\?)$' {
769
  Show-InstallHelp
770
  exit 0
771
  }
@@ -1527,7 +1564,7 @@ switch ($args[0]) {
1527
  }
1528
  'wrap' {
1529
  if ($args.Count -eq 1 -or $args[1] -eq '--help' -or $args[1] -eq '-?') {
1530
- Invoke-HeadroomDocker -Arguments @('wrap','--help')
1531
  exit 0
1532
  }
1533
 
@@ -1538,6 +1575,17 @@ switch ($args[0]) {
1538
  $tool = $args[1]
1539
  $wrapArgs = if ($args.Count -gt 2) { $args[2..($args.Count - 1)] } else { @() }
1540
 
 
 
 
 
 
 
 
 
 
 
 
1541
  if ($tool -eq 'openclaw') {
1542
  if (Test-HelpFlag -Arguments $wrapArgs) {
1543
  $helpArgs = @('wrap','openclaw') + $wrapArgs
@@ -1562,14 +1610,6 @@ switch ($args[0]) {
1562
  if ($parsed.Anyllm) { $proxyArgs.AddRange([string[]]@('--anyllm-provider', $parsed.Anyllm)) }
1563
  if ($parsed.Region) { $proxyArgs.AddRange([string[]]@('--region', $parsed.Region)) }
1564
 
1565
- switch ($tool) {
1566
- 'claude' { }
1567
- 'codex' { }
1568
- 'aider' { }
1569
- 'cursor' { }
1570
- default { Fail "Unsupported wrap target: $tool" }
1571
- }
1572
-
1573
  $containerName = $null
1574
  try {
1575
  if (-not $parsed.NoProxy) {
@@ -1672,6 +1712,8 @@ switch ($args[0]) {
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
 
66
 
67
  $wrapperPath = Join-Path $TargetDir 'headroom.ps1'
68
  $cmdPath = Join-Path $TargetDir 'headroom.cmd'
69
+ $resolvedInstallImage = $InstallImage.Replace("'", "''")
70
 
71
  $wrapper = @'
72
  $ErrorActionPreference = 'Stop'
73
 
74
+ $HeadroomImage = if ($env:HEADROOM_DOCKER_IMAGE) { $env:HEADROOM_DOCKER_IMAGE } else { '__HEADROOM_INSTALL_IMAGE__' }
75
  $ContainerHome = if ($env:HEADROOM_CONTAINER_HOME) { $env:HEADROOM_CONTAINER_HOME } else { '/tmp/headroom-home' }
76
  $HostHome = $HOME
77
 
 
307
  }
308
  }
309
 
310
+ function Write-Utf8NoBomFile {
311
+ param(
312
+ [string]$Path,
313
+ [string]$Content
314
+ )
315
+
316
+ [System.IO.File]::WriteAllText($Path, $Content, [System.Text.UTF8Encoding]::new($false))
317
+ }
318
+
319
  function Get-PersistentDockerArgs {
320
  Ensure-HostDirs
321
  $args = New-Object System.Collections.Generic.List[string]
 
398
  container_name = Get-PersistentContainerName -Profile $Profile
399
  health_url = "http://127.0.0.1:$Port/readyz"
400
  }
401
+ Write-Utf8NoBomFile -Path (Get-PersistentStatePath -Profile $Profile) -Content ($state | ConvertTo-Json -Depth 4)
402
  }
403
 
404
  function Write-PersistentManifest {
 
453
  artifacts = @()
454
  }
455
 
456
+ Write-Utf8NoBomFile -Path (Get-PersistentManifestPath -Profile $Profile) -Content ($manifest | ConvertTo-Json -Depth 8)
457
  }
458
 
459
  function Read-PersistentState {
 
489
  $dockerArgs = New-Object System.Collections.Generic.List[string]
490
  $dockerArgs.AddRange([string[]]@('run','-d','--restart','unless-stopped','--name',$containerName,'-p',"$Port`:$Port"))
491
  $dockerArgs.AddRange((Get-PersistentDockerArgs))
492
+ $dockerArgs.AddRange([string[]]@(
493
+ '--env',"HEADROOM_DEPLOYMENT_PROFILE=$Profile",
494
+ '--env','HEADROOM_DEPLOYMENT_PRESET=persistent-docker',
495
+ '--env','HEADROOM_DEPLOYMENT_RUNTIME=docker',
496
+ '--env','HEADROOM_DEPLOYMENT_SUPERVISOR=none',
497
+ '--env','HEADROOM_DEPLOYMENT_SCOPE=user'
498
+ ))
499
  $dockerArgs.Add($Image)
500
  $dockerArgs.Add('--host')
501
  $dockerArgs.Add('0.0.0.0')
 
612
  Write-Host ($lines -join [Environment]::NewLine)
613
  }
614
 
615
+ function Show-WrapHelp {
616
+ $lines = @(
617
+ 'Usage: headroom wrap <COMMAND> [OPTIONS] [-- ARGS...]',
618
+ '',
619
+ ' Launch supported host tools through a Docker-native Headroom proxy.',
620
+ '',
621
+ 'Supported commands:',
622
+ ' claude',
623
+ ' codex',
624
+ ' aider',
625
+ ' cursor',
626
+ ' openclaw',
627
+ '',
628
+ 'Notes:',
629
+ ' - GitHub Copilot CLI wrapping is not supported by the Docker-native wrapper.',
630
+ ' - Use the Python-native CLI for unsupported wrap targets.'
631
+ )
632
+ Write-Host ($lines -join [Environment]::NewLine)
633
+ }
634
+
635
  function Parse-InstallApplyArgs {
636
  param([string[]]$Arguments)
637
 
 
760
  $i += 1
761
  continue
762
  }
763
+ '^(--help|-\?)$' {
764
  Show-InstallApplyHelp
765
  exit 0
766
  }
 
802
  $i += 1
803
  continue
804
  }
805
+ '^(--help|-\?)$' {
806
  Show-InstallHelp
807
  exit 0
808
  }
 
1564
  }
1565
  'wrap' {
1566
  if ($args.Count -eq 1 -or $args[1] -eq '--help' -or $args[1] -eq '-?') {
1567
+ Show-WrapHelp
1568
  exit 0
1569
  }
1570
 
 
1575
  $tool = $args[1]
1576
  $wrapArgs = if ($args.Count -gt 2) { $args[2..($args.Count - 1)] } else { @() }
1577
 
1578
+ switch ($tool) {
1579
+ 'claude' { }
1580
+ 'codex' { }
1581
+ 'aider' { }
1582
+ 'cursor' { }
1583
+ 'openclaw' { }
1584
+ default {
1585
+ Fail "Docker-native wrapper does not support 'wrap $tool'. Supported targets: claude, codex, aider, cursor, openclaw"
1586
+ }
1587
+ }
1588
+
1589
  if ($tool -eq 'openclaw') {
1590
  if (Test-HelpFlag -Arguments $wrapArgs) {
1591
  $helpArgs = @('wrap','openclaw') + $wrapArgs
 
1610
  if ($parsed.Anyllm) { $proxyArgs.AddRange([string[]]@('--anyllm-provider', $parsed.Anyllm)) }
1611
  if ($parsed.Region) { $proxyArgs.AddRange([string[]]@('--region', $parsed.Region)) }
1612
 
 
 
 
 
 
 
 
 
1613
  $containerName = $null
1614
  try {
1615
  if (-not $parsed.NoProxy) {
 
1712
  }
1713
  '@
1714
 
1715
+ $wrapper = $wrapper.Replace('__HEADROOM_INSTALL_IMAGE__', $resolvedInstallImage)
1716
+
1717
  $cmdWrapper = ([string][char]64) + "echo off`r`npowershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File ""%~dp0headroom.ps1"" %*`r`n"
1718
 
1719
  Set-Content -Path $wrapperPath -Value $wrapper -Encoding utf8
scripts/install.sh CHANGED
@@ -55,11 +55,12 @@ write_wrapper() {
55
 
56
  {
57
  printf '#!%s\n\n' "${BASH_PATH}"
 
58
  cat <<'WRAPPER'
59
 
60
  set -euo pipefail
61
 
62
- HEADROOM_IMAGE="${HEADROOM_DOCKER_IMAGE:-ghcr.io/chopratejas/headroom:latest}"
63
  HEADROOM_CONTAINER_HOME="${HEADROOM_CONTAINER_HOME:-/tmp/headroom-home}"
64
  HEADROOM_HOST_HOME="${HOME:?}"
65
 
@@ -487,6 +488,13 @@ start_persistent_docker_install() {
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
 
@@ -592,6 +600,25 @@ Options:
592
  EOF
593
  }
594
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
595
  parse_install_apply_args() {
596
  local -n out_profile=$1
597
  local -n out_port=$2
@@ -1406,7 +1433,7 @@ main() {
1406
  ;;
1407
  wrap)
1408
  if (($# == 1)) || [[ "$2" == "--help" || "$2" == "-?" ]]; then
1409
- run_headroom wrap --help
1410
  return
1411
  fi
1412
 
@@ -1414,6 +1441,14 @@ main() {
1414
  local tool="$2"
1415
  shift 2
1416
 
 
 
 
 
 
 
 
 
1417
  if [[ "${tool}" == "openclaw" ]]; then
1418
  if contains_help_flag "$@"; then
1419
  run_headroom wrap openclaw "$@"
@@ -1445,14 +1480,6 @@ main() {
1445
  proxy_args+=(--region "${region}")
1446
  fi
1447
 
1448
- case "${tool}" in
1449
- claude|codex|aider|cursor)
1450
- ;;
1451
- *)
1452
- die "Unsupported wrap target: ${tool}"
1453
- ;;
1454
- esac
1455
-
1456
  local container_name=""
1457
  if [[ "${no_proxy}" -eq 0 ]]; then
1458
  container_name="$(start_proxy_container "${port}" "${proxy_args[@]}")"
 
55
 
56
  {
57
  printf '#!%s\n\n' "${BASH_PATH}"
58
+ printf 'HEADROOM_IMAGE_DEFAULT=%q\n' "${INSTALL_IMAGE}"
59
  cat <<'WRAPPER'
60
 
61
  set -euo pipefail
62
 
63
+ HEADROOM_IMAGE="${HEADROOM_DOCKER_IMAGE:-${HEADROOM_IMAGE_DEFAULT}}"
64
  HEADROOM_CONTAINER_HOME="${HEADROOM_CONTAINER_HOME:-/tmp/headroom-home}"
65
  HEADROOM_HOST_HOME="${HOME:?}"
66
 
 
488
 
489
  args=(docker run -d --restart unless-stopped --name "${container_name}" -p "${port}:${port}")
490
  append_persistent_container_args args
491
+ args+=(
492
+ --env "HEADROOM_DEPLOYMENT_PROFILE=${profile}"
493
+ --env "HEADROOM_DEPLOYMENT_PRESET=persistent-docker"
494
+ --env "HEADROOM_DEPLOYMENT_RUNTIME=docker"
495
+ --env "HEADROOM_DEPLOYMENT_SUPERVISOR=none"
496
+ --env "HEADROOM_DEPLOYMENT_SCOPE=user"
497
+ )
498
  args+=("${image}" --host 0.0.0.0 "${proxy_args[@]:2}")
499
  "${args[@]}" >/dev/null
500
 
 
600
  EOF
601
  }
602
 
603
+ print_wrap_help() {
604
+ cat <<'EOF'
605
+ Usage: headroom wrap <COMMAND> [OPTIONS] [-- ARGS...]
606
+
607
+ Launch supported host tools through a Docker-native Headroom proxy.
608
+
609
+ Supported commands:
610
+ claude
611
+ codex
612
+ aider
613
+ cursor
614
+ openclaw
615
+
616
+ Notes:
617
+ - GitHub Copilot CLI wrapping is not supported by the Docker-native wrapper.
618
+ - Use the Python-native CLI for unsupported wrap targets.
619
+ EOF
620
+ }
621
+
622
  parse_install_apply_args() {
623
  local -n out_profile=$1
624
  local -n out_port=$2
 
1433
  ;;
1434
  wrap)
1435
  if (($# == 1)) || [[ "$2" == "--help" || "$2" == "-?" ]]; then
1436
+ print_wrap_help
1437
  return
1438
  fi
1439
 
 
1441
  local tool="$2"
1442
  shift 2
1443
 
1444
+ case "${tool}" in
1445
+ claude|codex|aider|cursor|openclaw)
1446
+ ;;
1447
+ *)
1448
+ die "Docker-native wrapper does not support 'wrap ${tool}'. Supported targets: claude, codex, aider, cursor, openclaw"
1449
+ ;;
1450
+ esac
1451
+
1452
  if [[ "${tool}" == "openclaw" ]]; then
1453
  if contains_help_flag "$@"; then
1454
  run_headroom wrap openclaw "$@"
 
1480
  proxy_args+=(--region "${region}")
1481
  fi
1482
 
 
 
 
 
 
 
 
 
1483
  local container_name=""
1484
  if [[ "${no_proxy}" -eq 0 ]]; then
1485
  container_name="$(start_proxy_container "${port}" "${proxy_args[@]}")"
tests/test_cli/test_install_cli.py CHANGED
@@ -1,5 +1,6 @@
1
  from __future__ import annotations
2
 
 
3
  from click.testing import CliRunner
4
 
5
  from headroom.cli.main import main
@@ -111,3 +112,130 @@ def test_install_restart_uses_internal_helpers(monkeypatch) -> None:
111
  assert result.exit_code == 0, result.output
112
  assert "Restarted deployment 'default'." in result.output
113
  assert calls == ["stop_supervisor", "stop_runtime", "start_supervisor"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ import click
4
  from click.testing import CliRunner
5
 
6
  from headroom.cli.main import main
 
112
  assert result.exit_code == 0, result.output
113
  assert "Restarted deployment 'default'." in result.output
114
  assert calls == ["stop_supervisor", "stop_runtime", "start_supervisor"]
115
+
116
+
117
+ def test_install_apply_rejects_invalid_profile() -> None:
118
+ runner = CliRunner()
119
+
120
+ result = runner.invoke(main, ["install", "apply", "--profile", "../bad"])
121
+
122
+ assert result.exit_code != 0
123
+ assert "Invalid profile name '../bad'" in result.output
124
+
125
+
126
+ def test_install_apply_rejects_provider_scope_targets_without_support() -> None:
127
+ runner = CliRunner()
128
+
129
+ result = runner.invoke(
130
+ main,
131
+ ["install", "apply", "--scope", "provider", "--providers", "manual", "--target", "copilot"],
132
+ )
133
+
134
+ assert result.exit_code != 0
135
+ assert "Provider scope supports only claude, codex, and openclaw" in result.output
136
+
137
+
138
+ def test_install_apply_restores_previous_deployment_after_failed_update(monkeypatch) -> None:
139
+ runner = CliRunner()
140
+ calls: list[str] = []
141
+
142
+ class Manifest:
143
+ def __init__(self, profile: str, targets: list[str]) -> None:
144
+ self.profile = profile
145
+ self.preset = "persistent-service"
146
+ self.runtime_kind = "python"
147
+ self.supervisor_kind = "service"
148
+ self.scope = "user"
149
+ self.health_url = "http://127.0.0.1:8787/readyz"
150
+ self.targets = targets
151
+ self.mutations = []
152
+ self.artifacts = []
153
+
154
+ new_manifest = Manifest("default", ["claude"])
155
+ existing_manifest = Manifest("default", ["codex"])
156
+
157
+ monkeypatch.setattr("headroom.cli.install.build_manifest", lambda **_: new_manifest)
158
+ monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: existing_manifest)
159
+ monkeypatch.setattr(
160
+ "headroom.cli.install.apply_mutations",
161
+ lambda deployment: calls.append(f"apply:{','.join(deployment.targets)}") or [],
162
+ )
163
+ monkeypatch.setattr(
164
+ "headroom.cli.install.install_supervisor",
165
+ lambda deployment: calls.append(f"supervisor:{','.join(deployment.targets)}") or [],
166
+ )
167
+ monkeypatch.setattr(
168
+ "headroom.cli.install.save_manifest",
169
+ lambda deployment: calls.append(f"save:{','.join(deployment.targets)}"),
170
+ )
171
+ monkeypatch.setattr(
172
+ "headroom.cli.install.stop_supervisor",
173
+ lambda deployment: calls.append(f"stop-supervisor:{','.join(deployment.targets)}"),
174
+ )
175
+ monkeypatch.setattr(
176
+ "headroom.cli.install.stop_runtime",
177
+ lambda deployment: calls.append(f"stop-runtime:{','.join(deployment.targets)}"),
178
+ )
179
+ monkeypatch.setattr(
180
+ "headroom.cli.install.remove_supervisor",
181
+ lambda deployment: calls.append(f"remove-supervisor:{','.join(deployment.targets)}"),
182
+ )
183
+ monkeypatch.setattr(
184
+ "headroom.cli.install.revert_mutations",
185
+ lambda deployment: calls.append(f"revert:{','.join(deployment.targets)}"),
186
+ )
187
+ monkeypatch.setattr(
188
+ "headroom.cli.install.delete_manifest",
189
+ lambda profile: calls.append(f"delete:{profile}"),
190
+ )
191
+
192
+ def _start(deployment) -> None:
193
+ calls.append(f"start:{','.join(deployment.targets)}")
194
+ if deployment is new_manifest:
195
+ raise click.ClickException("boom")
196
+
197
+ monkeypatch.setattr("headroom.cli.install._start_deployment", _start)
198
+
199
+ result = runner.invoke(main, ["install", "apply"])
200
+
201
+ assert result.exit_code != 0
202
+ assert "Restoring previous deployment 'default'" in result.output
203
+ assert calls == [
204
+ "stop-supervisor:codex",
205
+ "stop-runtime:codex",
206
+ "remove-supervisor:codex",
207
+ "revert:codex",
208
+ "delete:default",
209
+ "apply:claude",
210
+ "supervisor:claude",
211
+ "save:claude",
212
+ "start:claude",
213
+ "stop-supervisor:claude",
214
+ "stop-runtime:claude",
215
+ "remove-supervisor:claude",
216
+ "revert:claude",
217
+ "delete:default",
218
+ "apply:codex",
219
+ "supervisor:codex",
220
+ "save:codex",
221
+ "start:codex",
222
+ ]
223
+
224
+
225
+ def test_install_start_rejects_task_lifecycle(monkeypatch) -> None:
226
+ runner = CliRunner()
227
+
228
+ class Manifest:
229
+ profile = "default"
230
+ preset = "persistent-task"
231
+ runtime_kind = "python"
232
+ supervisor_kind = "task"
233
+ scope = "user"
234
+ health_url = "http://127.0.0.1:8787/readyz"
235
+
236
+ monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
237
+
238
+ result = runner.invoke(main, ["install", "start"])
239
+
240
+ assert result.exit_code != 0
241
+ assert "headroom install start" in result.output
tests/test_cli/test_wrap_persistent.py CHANGED
@@ -1,5 +1,7 @@
1
  from __future__ import annotations
2
 
 
 
3
  from headroom.cli.wrap import _ensure_proxy
4
 
5
 
@@ -54,3 +56,17 @@ def test_ensure_proxy_recovers_persistent_deployment_when_socket_is_bound(monkey
54
 
55
  assert result is None
56
  assert calls == ["start:default"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ import click
4
+
5
  from headroom.cli.wrap import _ensure_proxy
6
 
7
 
 
56
 
57
  assert result is None
58
  assert calls == ["start:default"]
59
+
60
+
61
+ def test_ensure_proxy_rejects_unhealthy_persistent_deployment(monkeypatch) -> None:
62
+ monkeypatch.setattr("headroom.cli.wrap._check_proxy", lambda port: True)
63
+ monkeypatch.setattr("headroom.cli.wrap._find_persistent_manifest", lambda port: _Manifest())
64
+ monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
65
+ monkeypatch.setattr("headroom.cli.wrap._recover_persistent_proxy", lambda port: False)
66
+
67
+ try:
68
+ _ensure_proxy(8787, False)
69
+ except click.ClickException as exc:
70
+ assert "is not healthy" in str(exc)
71
+ else:
72
+ raise AssertionError("expected unhealthy persistent deployment to raise")
tests/test_install/test_native_installers.py CHANGED
@@ -250,16 +250,28 @@ def test_bash_native_installer_supports_persistent_docker_lifecycle(tmp_path: Pa
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", ".."],
@@ -418,6 +430,7 @@ def test_powershell_native_installer_supports_persistent_docker_lifecycle(tmp_pa
418
  home = tmp_path / "home"
419
  (home / ".local").mkdir(parents=True)
420
  env = _build_env(home, tmp_path)
 
421
 
422
  try:
423
  _run(
@@ -435,6 +448,8 @@ def test_powershell_native_installer_supports_persistent_docker_lifecycle(tmp_pa
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
 
@@ -482,6 +497,19 @@ def test_powershell_native_installer_supports_persistent_docker_lifecycle(tmp_pa
482
  ],
483
  env=env,
484
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  invalid_profile = _run(
486
  ["cmd.exe", "/c", str(cmd_wrapper), "install", "status", "--profile", ".."],
487
  env=env,
@@ -569,8 +597,12 @@ def test_powershell_native_installer_supports_persistent_docker_lifecycle(tmp_pa
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
 
250
  home = tmp_path / "home"
251
  (home / ".local").mkdir(parents=True)
252
  env = _build_env(home, tmp_path)
253
+ env["HEADROOM_DOCKER_IMAGE"] = "headroom:test-image"
254
 
255
  try:
256
  _run(["bash", str(REPO_ROOT / "scripts" / "install.sh")], env=env, cwd=REPO_ROOT)
257
 
258
  wrapper = home / ".local" / "bin" / "headroom"
259
  assert wrapper.exists()
260
+ assert "HEADROOM_IMAGE_DEFAULT=headroom:test-image" in wrapper.read_text(encoding="utf-8")
261
 
262
  help_result = _run([str(wrapper), "install", "-?"], env=env)
263
  assert "persistent-docker preset only" in help_result.stdout
264
  _run([str(wrapper), "--help"], env=env)
265
+ wrap_help = _run([str(wrapper), "wrap", "--help"], env=env)
266
+ assert "Supported commands:" in wrap_help.stdout
267
+ assert "copilot" not in wrap_help.stdout
268
+ unsupported_wrap = _run(
269
+ [str(wrapper), "wrap", "copilot", "--help"],
270
+ env=env,
271
+ check=False,
272
+ )
273
+ assert unsupported_wrap.returncode != 0
274
+ assert "does not support 'wrap copilot'" in unsupported_wrap.stderr
275
 
276
  invalid_profile = _run(
277
  [str(wrapper), "install", "status", "--profile", ".."],
 
430
  home = tmp_path / "home"
431
  (home / ".local").mkdir(parents=True)
432
  env = _build_env(home, tmp_path)
433
+ env["HEADROOM_DOCKER_IMAGE"] = "headroom:test-image"
434
 
435
  try:
436
  _run(
 
448
 
449
  wrapper = home / ".local" / "bin" / "headroom.ps1"
450
  assert wrapper.exists()
451
+ assert "__HEADROOM_INSTALL_IMAGE__" not in wrapper.read_text(encoding="utf-8")
452
+ assert "headroom:test-image" in wrapper.read_text(encoding="utf-8")
453
  cmd_wrapper = home / ".local" / "bin" / "headroom.cmd"
454
  assert cmd_wrapper.exists()
455
 
 
497
  ],
498
  env=env,
499
  )
500
+ wrap_help = _run(
501
+ ["cmd.exe", "/c", str(cmd_wrapper), "wrap", "--help"],
502
+ env=env,
503
+ )
504
+ assert "Supported commands:" in wrap_help.stdout
505
+ assert "copilot" not in wrap_help.stdout
506
+ unsupported_wrap = _run(
507
+ ["cmd.exe", "/c", str(cmd_wrapper), "wrap", "copilot", "--help"],
508
+ env=env,
509
+ check=False,
510
+ )
511
+ assert unsupported_wrap.returncode != 0
512
+ assert "does not support 'wrap copilot'" in unsupported_wrap.stderr
513
  invalid_profile = _run(
514
  ["cmd.exe", "/c", str(cmd_wrapper), "install", "status", "--profile", ".."],
515
  env=env,
 
597
 
598
  manifest_path = home / ".headroom" / "deploy" / "smoke" / "manifest.json"
599
  state_path = home / ".headroom" / "deploy" / "smoke" / "docker-native.json"
600
+ manifest_bytes = manifest_path.read_bytes()
601
+ state_bytes = state_path.read_bytes()
602
+ assert not manifest_bytes.startswith(b"\xef\xbb\xbf")
603
+ assert not state_bytes.startswith(b"\xef\xbb\xbf")
604
+ manifest = json.loads(manifest_bytes.decode("utf-8"))
605
+ state = json.loads(state_bytes.decode("utf-8"))
606
  assert manifest["preset"] == "persistent-docker"
607
  assert manifest["port"] == port
608
  assert manifest["memory_enabled"] is True
tests/test_install/test_providers.py CHANGED
@@ -7,6 +7,8 @@ from headroom.install.models import DeploymentManifest
7
  from headroom.install.providers import (
8
  _apply_claude_provider_scope,
9
  _apply_codex_provider_scope,
 
 
10
  _revert_claude_provider_scope,
11
  _revert_codex_provider_scope,
12
  )
@@ -91,3 +93,48 @@ def test_apply_openclaw_provider_scope_uses_manifest_port(monkeypatch, tmp_path:
91
  _apply_openclaw_provider_scope(manifest)
92
 
93
  assert recorded == [["headroom", "wrap", "openclaw", "--no-auto-start", "--proxy-port", "9999"]]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  from headroom.install.providers import (
8
  _apply_claude_provider_scope,
9
  _apply_codex_provider_scope,
10
+ _apply_windows_env_scope,
11
+ _remove_windows_env_scope,
12
  _revert_claude_provider_scope,
13
  _revert_codex_provider_scope,
14
  )
 
93
  _apply_openclaw_provider_scope(manifest)
94
 
95
  assert recorded == [["headroom", "wrap", "openclaw", "--no-auto-start", "--proxy-port", "9999"]]
96
+
97
+
98
+ def test_windows_env_scope_restores_previous_values(monkeypatch, tmp_path: Path) -> None:
99
+ manifest = _manifest(tmp_path)
100
+ manifest.scope = "user"
101
+ manifest.targets = ["claude"]
102
+ manifest.base_env = {"HEADROOM_PORT": "8787"}
103
+ manifest.tool_envs = {"claude": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}
104
+
105
+ calls: list[list[str]] = []
106
+ previous_values = {
107
+ "HEADROOM_PORT": "7777",
108
+ "ANTHROPIC_BASE_URL": "https://old",
109
+ }
110
+
111
+ class Result:
112
+ def __init__(self, stdout: str = "") -> None:
113
+ self.stdout = stdout
114
+
115
+ def fake_run(command: list[str], **kwargs):
116
+ calls.append(command)
117
+ script = command[-1]
118
+ if "GetEnvironmentVariable" in script:
119
+ name = script.split("GetEnvironmentVariable('", 1)[1].split("'", 1)[0]
120
+ value = previous_values.get(name, "__HEADROOM_UNSET__")
121
+ return Result(stdout=value)
122
+ return Result()
123
+
124
+ monkeypatch.setattr("headroom.install.providers.subprocess.run", fake_run)
125
+
126
+ mutations = _apply_windows_env_scope(manifest)
127
+ _remove_windows_env_scope(mutations)
128
+
129
+ previous_by_name = {mutation.data["name"]: mutation.data["previous"] for mutation in mutations}
130
+ assert previous_by_name["HEADROOM_PORT"] == "7777"
131
+ assert previous_by_name["ANTHROPIC_BASE_URL"] == "https://old"
132
+ assert any(
133
+ "[Environment]::SetEnvironmentVariable('HEADROOM_PORT','7777','User')" in command[-1]
134
+ for command in calls
135
+ )
136
+ assert any(
137
+ "[Environment]::SetEnvironmentVariable('ANTHROPIC_BASE_URL','https://old','User')"
138
+ in command[-1]
139
+ for command in calls
140
+ )
tests/test_install/test_runtime.py CHANGED
@@ -34,3 +34,37 @@ def test_build_runtime_command_for_docker_includes_deployment_env(
34
  assert "HEADROOM_DEPLOYMENT_PRESET=persistent-docker" in joined
35
  assert "127.0.0.1:8787:8787" in joined
36
  assert "ghcr.io/chopratejas/headroom:latest" in command
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  assert "HEADROOM_DEPLOYMENT_PRESET=persistent-docker" in joined
35
  assert "127.0.0.1:8787:8787" in joined
36
  assert "ghcr.io/chopratejas/headroom:latest" in command
37
+
38
+
39
+ def test_build_runtime_command_for_docker_matches_wrapper_parity(
40
+ monkeypatch, tmp_path: Path
41
+ ) -> None:
42
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
43
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
44
+ monkeypatch.setenv("OPENAI_API_KEY", "test-openai")
45
+ manifest = DeploymentManifest(
46
+ profile="default",
47
+ preset="persistent-docker",
48
+ runtime_kind="docker",
49
+ supervisor_kind="none",
50
+ scope="user",
51
+ provider_mode="manual",
52
+ targets=["claude"],
53
+ port=8787,
54
+ host="127.0.0.1",
55
+ backend="anthropic",
56
+ image="ghcr.io/chopratejas/headroom:latest",
57
+ base_env={"HEADROOM_PORT": "8787"},
58
+ proxy_args=["--host", "127.0.0.1", "--port", "8787"],
59
+ )
60
+
61
+ command = build_runtime_command(manifest)
62
+
63
+ assert (tmp_path / ".headroom").is_dir()
64
+ assert (tmp_path / ".claude").is_dir()
65
+ assert (tmp_path / ".codex").is_dir()
66
+ assert (tmp_path / ".gemini").is_dir()
67
+ assert "--env" in command
68
+ joined = " ".join(command)
69
+ assert "ANTHROPIC_API_KEY" in joined
70
+ assert "OPENAI_API_KEY" in joined