Spaces:
Build error
Build error
Commit ·
0fa27d4
1
Parent(s): ff0bedf
fix: harden persistent install wrappers and review gaps
Browse filesAlign 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 +25 -0
- README.md +13 -3
- docs/docker-install.md +2 -0
- e2e/docker-native-install.sh +7 -1
- e2e/wrap/run.py +30 -0
- headroom/cli/install.py +333 -305
- headroom/cli/wrap.py +9 -0
- headroom/install/paths.py +14 -1
- headroom/install/planner.py +228 -201
- headroom/install/providers.py +302 -277
- headroom/install/runtime.py +270 -233
- headroom/install/state.py +5 -4
- scripts/install.ps1 +56 -14
- scripts/install.sh +37 -10
- tests/test_cli/test_install_cli.py +128 -0
- tests/test_cli/test_wrap_persistent.py +16 -0
- tests/test_install/test_native_installers.py +34 -2
- tests/test_install/test_providers.py +47 -0
- tests/test_install/test_runtime.py +34 -0
.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 |
-
|
| 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
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
from headroom.install.
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
)
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
)
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
)
|
| 125 |
-
@click.option(
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
default=
|
| 130 |
-
show_default=True,
|
| 131 |
-
help="
|
| 132 |
-
)
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
if
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
"
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
click.echo(f"
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
@
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
ToolTarget.
|
| 23 |
-
ToolTarget.
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
"
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
if
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
port
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 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
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
"
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
path
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
if
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
path.write_text(
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
def
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
"
|
| 207 |
-
"
|
| 208 |
-
"
|
| 209 |
-
"
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
return ManagedMutation(
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
""
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 57 |
|
| 58 |
-
|
| 59 |
-
if
|
| 60 |
-
|
|
|
|
| 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 { '
|
| 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 |
-
|
| 392 |
}
|
| 393 |
|
| 394 |
function Write-PersistentManifest {
|
|
@@ -443,7 +453,7 @@ function Write-PersistentManifest {
|
|
| 443 |
artifacts = @()
|
| 444 |
}
|
| 445 |
|
| 446 |
-
|
| 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 |
-
|
| 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:-
|
| 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 |
-
|
| 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 |
-
|
| 573 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|