Spaces:
Build error
Build error
Commit ·
ca89449
1
Parent(s): 97b851c
feat: add persistent install lifecycle management
Browse filesCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- README.md +7 -1
- docker/docker-compose.native.yml +1 -0
- docs/cli.md +164 -1
- docs/docker-install.md +19 -0
- docs/getting-started.md +6 -0
- docs/index.md +4 -0
- docs/persistent-installs.md +152 -0
- docs/plans/2026-04-11-persistent-installs-design.md +129 -0
- docs/quickstart.md +8 -0
- headroom/cli/install.py +305 -0
- headroom/cli/main.py +1 -0
- headroom/cli/wrap.py +56 -0
- headroom/install/__init__.py +19 -0
- headroom/install/health.py +28 -0
- headroom/install/models.py +116 -0
- headroom/install/paths.py +105 -0
- headroom/install/planner.py +201 -0
- headroom/install/providers.py +277 -0
- headroom/install/runtime.py +233 -0
- headroom/install/state.py +60 -0
- headroom/install/supervisors.py +413 -0
- headroom/proxy/server.py +9 -0
- mkdocs.yml +1 -0
- tests/test_cli/test_install_cli.py +113 -0
- tests/test_cli/test_wrap_persistent.py +56 -0
- tests/test_install/test_planner.py +44 -0
- tests/test_install/test_providers.py +93 -0
- tests/test_install/test_runtime.py +36 -0
- tests/test_proxy_healthchecks.py +28 -0
README.md
CHANGED
|
@@ -92,6 +92,11 @@ PowerShell:
|
|
| 92 |
irm https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.ps1 | iex
|
| 93 |
```
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
### Any agent — one function
|
| 96 |
|
| 97 |
**Python:**
|
|
@@ -137,7 +142,7 @@ Use `cache` mode for long-running chats where preserving prior-turn bytes improv
|
|
| 137 |
|
| 138 |
Works with any language, any tool, any framework. **[Proxy docs](docs/proxy.md)**
|
| 139 |
|
| 140 |
-
Prefer Docker as the runtime provider? See **[Docker-native install](docs/docker-install.md)**.
|
| 141 |
|
| 142 |
### Coding agents — one command
|
| 143 |
|
|
@@ -197,6 +202,7 @@ Gives your AI tool three MCP tools: `headroom_compress`, `headroom_retrieve`, `h
|
|
| 197 |
| **Claude Code** | Wrap | `headroom wrap claude` |
|
| 198 |
| **GitHub Copilot CLI** | Wrap | `headroom wrap copilot -- --model claude-sonnet-4-20250514` |
|
| 199 |
| **Codex / Aider** | Wrap | `headroom wrap codex` or `headroom wrap aider` |
|
|
|
|
| 200 |
|
| 201 |
**[Full Integration Guide](docs/integration-guide.md)** | **[TypeScript SDK](docs/typescript-sdk.md)**
|
| 202 |
|
|
|
|
| 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:**
|
|
|
|
| 142 |
|
| 143 |
Works with any language, any tool, any framework. **[Proxy docs](docs/proxy.md)**
|
| 144 |
|
| 145 |
+
Prefer Docker as the runtime provider? See **[Docker-native install](docs/docker-install.md)**. Want Headroom to stay up in the background? See **[Persistent installs](docs/persistent-installs.md)**.
|
| 146 |
|
| 147 |
### Coding agents — one command
|
| 148 |
|
|
|
|
| 202 |
| **Claude Code** | Wrap | `headroom wrap claude` |
|
| 203 |
| **GitHub Copilot CLI** | Wrap | `headroom wrap copilot -- --model claude-sonnet-4-20250514` |
|
| 204 |
| **Codex / Aider** | Wrap | `headroom wrap codex` or `headroom wrap aider` |
|
| 205 |
+
| **Always-on local proxy** | Persistent install | `headroom install apply --preset persistent-service --providers auto` |
|
| 206 |
|
| 207 |
**[Full Integration Guide](docs/integration-guide.md)** | **[TypeScript SDK](docs/typescript-sdk.md)**
|
| 208 |
|
docker/docker-compose.native.yml
CHANGED
|
@@ -19,6 +19,7 @@ services:
|
|
| 19 |
image: ${HEADROOM_IMAGE:-ghcr.io/chopratejas/headroom:latest}
|
| 20 |
entrypoint: ["headroom", "proxy"]
|
| 21 |
working_dir: /workspace
|
|
|
|
| 22 |
environment:
|
| 23 |
HOME: /tmp/headroom-home
|
| 24 |
HEADROOM_HOST: 0.0.0.0
|
|
|
|
| 19 |
image: ${HEADROOM_IMAGE:-ghcr.io/chopratejas/headroom:latest}
|
| 20 |
entrypoint: ["headroom", "proxy"]
|
| 21 |
working_dir: /workspace
|
| 22 |
+
restart: unless-stopped
|
| 23 |
environment:
|
| 24 |
HOME: /tmp/headroom-home
|
| 25 |
HEADROOM_HOST: 0.0.0.0
|
docs/cli.md
CHANGED
|
@@ -22,6 +22,7 @@ This page is the authoritative reference for the **Python Headroom CLI** exposed
|
|
| 22 |
|
| 23 |
| Command | Purpose | Docker-native parity |
|
| 24 |
|---|---|---|
|
|
|
|
| 25 |
| `headroom proxy` | Run the Headroom proxy server | **native in container** |
|
| 26 |
| `headroom learn` | Learn from past tool-call failures | **native in container** |
|
| 27 |
| `headroom perf` | Summarize recent proxy performance | **native in container** |
|
|
@@ -29,6 +30,7 @@ This page is the authoritative reference for the **Python Headroom CLI** exposed
|
|
| 29 |
| `headroom memory ...` | Inspect and manage stored memories | **native in container** |
|
| 30 |
| `headroom mcp ...` | Install, inspect, remove, or serve MCP integration | **native in container** |
|
| 31 |
| `headroom wrap claude` | Start proxy and launch Claude Code | **host-bridged** |
|
|
|
|
| 32 |
| `headroom wrap codex` | Start proxy and launch Codex CLI | **host-bridged** |
|
| 33 |
| `headroom wrap aider` | Start proxy and launch Aider | **host-bridged** |
|
| 34 |
| `headroom wrap cursor` | Start proxy and print Cursor config guidance | **host-bridged** |
|
|
@@ -59,6 +61,7 @@ Options:
|
|
| 59 |
|
| 60 |
Commands:
|
| 61 |
evals Memory evaluation commands.
|
|
|
|
| 62 |
learn Learn from past tool call failures to prevent future ones.
|
| 63 |
mcp MCP server for Claude Code integration.
|
| 64 |
memory Manage memories stored in Headroom.
|
|
@@ -168,6 +171,28 @@ Commands:
|
|
| 168 |
|
| 169 |
</details>
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
<details>
|
| 172 |
<summary><code>headroom wrap --help</code></summary>
|
| 173 |
|
|
@@ -179,6 +204,7 @@ Usage: headroom wrap [OPTIONS] COMMAND [ARGS]...
|
|
| 179 |
Commands:
|
| 180 |
aider Launch aider through Headroom proxy.
|
| 181 |
claude Launch Claude Code through Headroom proxy.
|
|
|
|
| 182 |
codex Launch OpenAI Codex CLI through Headroom proxy.
|
| 183 |
cursor Start Headroom proxy for use with Cursor.
|
| 184 |
openclaw Install and configure Headroom OpenClaw plugin in one command.
|
|
@@ -536,6 +562,118 @@ headroom mcp serve --proxy-url http://127.0.0.1:9000 --debug
|
|
| 536 |
|
| 537 |
See also: [MCP Tools](mcp.md)
|
| 538 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
## `headroom wrap`
|
| 540 |
|
| 541 |
Wrap external coding tools so their traffic flows through Headroom.
|
|
@@ -589,6 +727,29 @@ headroom wrap codex --backend anyllm --anyllm-provider groq
|
|
| 589 |
|
| 590 |
Requires the `codex` binary on the host.
|
| 591 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
### `headroom wrap aider`
|
| 593 |
|
| 594 |
```bash
|
|
@@ -691,14 +852,16 @@ Legend:
|
|
| 691 |
| `headroom mcp uninstall` | native | native in container | full |
|
| 692 |
| `headroom mcp status` | native | native in container | full |
|
| 693 |
| `headroom mcp serve` | native | native in container | full |
|
|
|
|
| 694 |
| `headroom wrap claude` | native | host-bridged | partial |
|
|
|
|
| 695 |
| `headroom wrap codex` | native | host-bridged | partial |
|
| 696 |
| `headroom wrap aider` | native | host-bridged | partial |
|
| 697 |
| `headroom wrap cursor` | native | host-bridged | partial |
|
| 698 |
| `headroom wrap openclaw` | native | host-bridged | partial |
|
| 699 |
| `headroom unwrap openclaw` | native | host-bridged | partial |
|
| 700 |
|
| 701 |
-
For the Docker-native execution model itself, see [Docker-Native Install](docker-install.md).
|
| 702 |
|
| 703 |
## Hidden and compatibility-only command paths
|
| 704 |
|
|
|
|
| 22 |
|
| 23 |
| Command | Purpose | Docker-native parity |
|
| 24 |
|---|---|---|
|
| 25 |
+
| `headroom install ...` | Install and manage persistent deployments | **python-native / compose-managed Docker** |
|
| 26 |
| `headroom proxy` | Run the Headroom proxy server | **native in container** |
|
| 27 |
| `headroom learn` | Learn from past tool-call failures | **native in container** |
|
| 28 |
| `headroom perf` | Summarize recent proxy performance | **native in container** |
|
|
|
|
| 30 |
| `headroom memory ...` | Inspect and manage stored memories | **native in container** |
|
| 31 |
| `headroom mcp ...` | Install, inspect, remove, or serve MCP integration | **native in container** |
|
| 32 |
| `headroom wrap claude` | Start proxy and launch Claude Code | **host-bridged** |
|
| 33 |
+
| `headroom wrap copilot` | Start proxy and launch GitHub Copilot CLI | **host-bridged** |
|
| 34 |
| `headroom wrap codex` | Start proxy and launch Codex CLI | **host-bridged** |
|
| 35 |
| `headroom wrap aider` | Start proxy and launch Aider | **host-bridged** |
|
| 36 |
| `headroom wrap cursor` | Start proxy and print Cursor config guidance | **host-bridged** |
|
|
|
|
| 61 |
|
| 62 |
Commands:
|
| 63 |
evals Memory evaluation commands.
|
| 64 |
+
install Install and manage persistent Headroom deployments.
|
| 65 |
learn Learn from past tool call failures to prevent future ones.
|
| 66 |
mcp MCP server for Claude Code integration.
|
| 67 |
memory Manage memories stored in Headroom.
|
|
|
|
| 171 |
|
| 172 |
</details>
|
| 173 |
|
| 174 |
+
<details>
|
| 175 |
+
<summary><code>headroom install --help</code></summary>
|
| 176 |
+
|
| 177 |
+
```text
|
| 178 |
+
Usage: headroom install [OPTIONS] COMMAND [ARGS]...
|
| 179 |
+
|
| 180 |
+
Install and manage persistent Headroom deployments.
|
| 181 |
+
|
| 182 |
+
Options:
|
| 183 |
+
-?, --help Show this message and exit.
|
| 184 |
+
|
| 185 |
+
Commands:
|
| 186 |
+
apply Install a persistent Headroom deployment.
|
| 187 |
+
remove Remove a persistent deployment and undo managed config.
|
| 188 |
+
restart Restart a persistent deployment.
|
| 189 |
+
start Start a persistent deployment.
|
| 190 |
+
status Show persistent deployment status.
|
| 191 |
+
stop Stop a persistent deployment.
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
</details>
|
| 195 |
+
|
| 196 |
<details>
|
| 197 |
<summary><code>headroom wrap --help</code></summary>
|
| 198 |
|
|
|
|
| 204 |
Commands:
|
| 205 |
aider Launch aider through Headroom proxy.
|
| 206 |
claude Launch Claude Code through Headroom proxy.
|
| 207 |
+
copilot Launch GitHub Copilot CLI through Headroom proxy.
|
| 208 |
codex Launch OpenAI Codex CLI through Headroom proxy.
|
| 209 |
cursor Start Headroom proxy for use with Cursor.
|
| 210 |
openclaw Install and configure Headroom OpenClaw plugin in one command.
|
|
|
|
| 562 |
|
| 563 |
See also: [MCP Tools](mcp.md)
|
| 564 |
|
| 565 |
+
## `headroom install`
|
| 566 |
+
|
| 567 |
+
Install and manage persistent local Headroom deployments.
|
| 568 |
+
|
| 569 |
+
### `headroom install apply --help`
|
| 570 |
+
|
| 571 |
+
```text
|
| 572 |
+
Usage: headroom install apply [OPTIONS]
|
| 573 |
+
|
| 574 |
+
Install a persistent Headroom deployment.
|
| 575 |
+
|
| 576 |
+
Options:
|
| 577 |
+
--preset [persistent-service|persistent-task|persistent-docker]
|
| 578 |
+
Persistent runtime preset to install.
|
| 579 |
+
[default: persistent-service]
|
| 580 |
+
--runtime [python|docker] Runtime used to execute Headroom for
|
| 581 |
+
service/task modes. [default: python]
|
| 582 |
+
--scope [provider|user|system] Where to apply persistent configuration.
|
| 583 |
+
[default: user]
|
| 584 |
+
--providers [auto|all|manual] Target selection mode for direct tool
|
| 585 |
+
configuration. [default: auto]
|
| 586 |
+
--target [claude|copilot|codex|aider|cursor|openclaw]
|
| 587 |
+
Tool target to configure when --providers
|
| 588 |
+
manual is used.
|
| 589 |
+
--profile TEXT Deployment profile name. [default: default]
|
| 590 |
+
-p, --port INTEGER Persistent proxy port. [default: 8787]
|
| 591 |
+
--backend TEXT Proxy backend for the persistent runtime.
|
| 592 |
+
[default: anthropic]
|
| 593 |
+
--anyllm-provider TEXT Provider for any-llm backends when --backend
|
| 594 |
+
anyllm is used.
|
| 595 |
+
--region TEXT Cloud region for Bedrock / Vertex style
|
| 596 |
+
backends.
|
| 597 |
+
--mode TEXT Proxy optimization mode. [default: token]
|
| 598 |
+
--memory Enable persistent memory in the proxy runtime.
|
| 599 |
+
--no-telemetry Disable anonymous telemetry in the runtime.
|
| 600 |
+
--image TEXT Docker image to use when runtime=docker or
|
| 601 |
+
preset=persistent-docker. [default:
|
| 602 |
+
ghcr.io/chopratejas/headroom:latest]
|
| 603 |
+
-?, --help Show this message and exit.
|
| 604 |
+
```
|
| 605 |
+
|
| 606 |
+
### `headroom install apply`
|
| 607 |
+
|
| 608 |
+
```bash
|
| 609 |
+
headroom install apply --preset persistent-service --providers auto
|
| 610 |
+
headroom install apply --preset persistent-task --providers manual --target claude --target codex
|
| 611 |
+
headroom install apply --preset persistent-docker --scope user
|
| 612 |
+
```
|
| 613 |
+
|
| 614 |
+
| Option | Default | Meaning |
|
| 615 |
+
|---|---|---|
|
| 616 |
+
| `--preset` | `persistent-service` | Lifecycle preset: `persistent-service`, `persistent-task`, or `persistent-docker` |
|
| 617 |
+
| `--runtime` | `python` | Runtime used for service/task installs: `python` or `docker` |
|
| 618 |
+
| `--scope` | `user` | Config scope: `provider`, `user`, or `system` |
|
| 619 |
+
| `--providers` | `auto` | Target selection mode: `auto`, `all`, or `manual` |
|
| 620 |
+
| `--target` | repeatable | Tool target used with `--providers manual` |
|
| 621 |
+
| `--profile` | `default` | Deployment profile name |
|
| 622 |
+
| `--port`, `-p` | `8787` | Persistent proxy port |
|
| 623 |
+
| `--backend` | `anthropic` | Backend for the managed runtime |
|
| 624 |
+
| `--anyllm-provider` | unset | Provider name used with `--backend anyllm` |
|
| 625 |
+
| `--region` | unset | Cloud region override |
|
| 626 |
+
| `--mode` | `token` | Proxy optimization mode |
|
| 627 |
+
| `--memory` | off | Enable persistent memory in the managed runtime |
|
| 628 |
+
| `--no-telemetry` | off | Disable anonymous telemetry |
|
| 629 |
+
| `--image` | `ghcr.io/chopratejas/headroom:latest` | Docker image for Docker-backed installs |
|
| 630 |
+
|
| 631 |
+
`apply` stores a manifest under `~/.headroom/deploy/<profile>/manifest.json`, applies managed tool configuration, starts the chosen runtime, and waits for `readyz`.
|
| 632 |
+
|
| 633 |
+
### `headroom install status`
|
| 634 |
+
|
| 635 |
+
```bash
|
| 636 |
+
headroom install status
|
| 637 |
+
headroom install status --profile default
|
| 638 |
+
```
|
| 639 |
+
|
| 640 |
+
Shows the stored profile, preset, runtime, supervisor kind, scope, port, runtime status, readiness, and backend from `/health`.
|
| 641 |
+
|
| 642 |
+
### `headroom install start`
|
| 643 |
+
|
| 644 |
+
```bash
|
| 645 |
+
headroom install start
|
| 646 |
+
headroom install start --profile default
|
| 647 |
+
```
|
| 648 |
+
|
| 649 |
+
Starts a previously installed deployment profile without reapplying mutations.
|
| 650 |
+
|
| 651 |
+
### `headroom install stop`
|
| 652 |
+
|
| 653 |
+
```bash
|
| 654 |
+
headroom install stop
|
| 655 |
+
```
|
| 656 |
+
|
| 657 |
+
Stops the managed runtime for an installed deployment profile.
|
| 658 |
+
|
| 659 |
+
### `headroom install restart`
|
| 660 |
+
|
| 661 |
+
```bash
|
| 662 |
+
headroom install restart
|
| 663 |
+
```
|
| 664 |
+
|
| 665 |
+
Stops and starts the selected deployment profile.
|
| 666 |
+
|
| 667 |
+
### `headroom install remove`
|
| 668 |
+
|
| 669 |
+
```bash
|
| 670 |
+
headroom install remove
|
| 671 |
+
```
|
| 672 |
+
|
| 673 |
+
Stops the runtime, removes installed supervisor artifacts, reverts managed configuration changes, and deletes the stored manifest.
|
| 674 |
+
|
| 675 |
+
See also: [Persistent Installs](persistent-installs.md)
|
| 676 |
+
|
| 677 |
## `headroom wrap`
|
| 678 |
|
| 679 |
Wrap external coding tools so their traffic flows through Headroom.
|
|
|
|
| 727 |
|
| 728 |
Requires the `codex` binary on the host.
|
| 729 |
|
| 730 |
+
### `headroom wrap copilot`
|
| 731 |
+
|
| 732 |
+
```bash
|
| 733 |
+
headroom wrap copilot -- --model claude-sonnet-4-20250514
|
| 734 |
+
headroom wrap copilot --backend anyllm --anyllm-provider groq -- --model gpt-4o
|
| 735 |
+
```
|
| 736 |
+
|
| 737 |
+
| Option / arg | Default | Meaning |
|
| 738 |
+
|---|---|---|
|
| 739 |
+
| `--port`, `-p` | `8787` | Proxy port |
|
| 740 |
+
| `--no-rtk` | off | Skip `rtk` installation and GitHub Copilot instructions injection |
|
| 741 |
+
| `--no-proxy` | off | Reuse an existing proxy |
|
| 742 |
+
| `--learn` | off | Enable live traffic learning |
|
| 743 |
+
| `--backend` | unset | Proxy backend override |
|
| 744 |
+
| `--anyllm-provider` | unset | `anyllm` provider override |
|
| 745 |
+
| `--region` | unset | Cloud region override |
|
| 746 |
+
| `--provider-type` | `auto` | Force Copilot BYOK provider type (`anthropic` or `openai`) |
|
| 747 |
+
| `--wire-api` | unset | OpenAI wire API override for OpenAI-style backends |
|
| 748 |
+
| `--verbose`, `-v` | off | Verbose output |
|
| 749 |
+
| `copilot_args...` | passthrough | Additional Copilot CLI arguments |
|
| 750 |
+
|
| 751 |
+
Requires the `copilot` binary on the host. When a matching persistent deployment exists on the requested port, `wrap copilot` reuses or recovers it before falling back to an ephemeral proxy.
|
| 752 |
+
|
| 753 |
### `headroom wrap aider`
|
| 754 |
|
| 755 |
```bash
|
|
|
|
| 852 |
| `headroom mcp uninstall` | native | native in container | full |
|
| 853 |
| `headroom mcp status` | native | native in container | full |
|
| 854 |
| `headroom mcp serve` | native | native in container | full |
|
| 855 |
+
| `headroom install ...` | native | compose-managed persistent Docker path | partial |
|
| 856 |
| `headroom wrap claude` | native | host-bridged | partial |
|
| 857 |
+
| `headroom wrap copilot` | native | host-bridged | partial |
|
| 858 |
| `headroom wrap codex` | native | host-bridged | partial |
|
| 859 |
| `headroom wrap aider` | native | host-bridged | partial |
|
| 860 |
| `headroom wrap cursor` | native | host-bridged | partial |
|
| 861 |
| `headroom wrap openclaw` | native | host-bridged | partial |
|
| 862 |
| `headroom unwrap openclaw` | native | host-bridged | partial |
|
| 863 |
|
| 864 |
+
For the Docker-native execution model itself, see [Docker-Native Install](docker-install.md). For persistent service/task/docker lifecycle management, see [Persistent Installs](persistent-installs.md).
|
| 865 |
|
| 866 |
## Hidden and compatibility-only command paths
|
| 867 |
|
docs/docker-install.md
CHANGED
|
@@ -85,6 +85,24 @@ OpenClaw remains host-native in Docker-native mode:
|
|
| 85 |
|
| 86 |
Use `docker/docker-compose.native.yml` when you want an explicit compose-managed proxy or CLI shell.
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
### macOS / Linux
|
| 89 |
|
| 90 |
```bash
|
|
@@ -128,3 +146,4 @@ That keeps provider auth and runtime config working without maintaining a separa
|
|
| 128 |
- Docker is the only required Headroom runtime dependency on the host.
|
| 129 |
- Wrapped tools like Claude Code, Codex CLI, Aider, and Cursor still run on the host when you use `headroom wrap ...`.
|
| 130 |
- The install scripts are idempotent: rerunning them refreshes the wrapper and image without duplicating shell profile blocks.
|
|
|
|
|
|
| 85 |
|
| 86 |
Use `docker/docker-compose.native.yml` when you want an explicit compose-managed proxy or CLI shell.
|
| 87 |
|
| 88 |
+
### Persistent Docker runtime
|
| 89 |
+
|
| 90 |
+
The `proxy` service now uses `restart: unless-stopped`, so compose can act as the always-on Docker runtime for Headroom:
|
| 91 |
+
|
| 92 |
+
```bash
|
| 93 |
+
export HEADROOM_HOST_HOME="$HOME"
|
| 94 |
+
export HEADROOM_WORKSPACE="$PWD"
|
| 95 |
+
docker compose -f docker/docker-compose.native.yml up -d proxy
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
```powershell
|
| 99 |
+
$env:HEADROOM_HOST_HOME = $HOME
|
| 100 |
+
$env:HEADROOM_WORKSPACE = (Get-Location).Path
|
| 101 |
+
docker compose -f docker/docker-compose.native.yml up -d proxy
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
This is the recommended persistent-Docker path when you installed Headroom through the Docker-native host wrapper.
|
| 105 |
+
|
| 106 |
### macOS / Linux
|
| 107 |
|
| 108 |
```bash
|
|
|
|
| 146 |
- Docker is the only required Headroom runtime dependency on the host.
|
| 147 |
- Wrapped tools like Claude Code, Codex CLI, Aider, and Cursor still run on the host when you use `headroom wrap ...`.
|
| 148 |
- The install scripts are idempotent: rerunning them refreshes the wrapper and image without duplicating shell profile blocks.
|
| 149 |
+
- For persistent service and task installs, use the Python-native `headroom install ...` workflow described in [Persistent Installs](persistent-installs.md).
|
docs/getting-started.md
CHANGED
|
@@ -40,6 +40,12 @@ irm https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.
|
|
| 40 |
|
| 41 |
See [Docker-native install](docker-install.md) for wrapper behavior, compose usage, and host-integrated `wrap` flows.
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
## Quick Start: Proxy Mode (Recommended)
|
| 44 |
|
| 45 |
The easiest way to use Headroom is as a proxy server:
|
|
|
|
| 40 |
|
| 41 |
See [Docker-native install](docker-install.md) for wrapper behavior, compose usage, and host-integrated `wrap` flows.
|
| 42 |
|
| 43 |
+
If you want Headroom to stay up in the background and automatically serve supported tools, use [Persistent Installs](persistent-installs.md):
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
headroom install apply --preset persistent-service --providers auto
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
## Quick Start: Proxy Mode (Recommended)
|
| 50 |
|
| 51 |
The easiest way to use Headroom is as a proxy server:
|
docs/index.md
CHANGED
|
@@ -76,6 +76,8 @@ Headroom works as a **transparent proxy** (zero code changes), a **Python functi
|
|
| 76 |
|
| 77 |
That's it. Your existing code works unchanged, with 40-90% fewer tokens.
|
| 78 |
|
|
|
|
|
|
|
| 79 |
=== "Python SDK"
|
| 80 |
|
| 81 |
```python
|
|
@@ -104,6 +106,8 @@ Headroom works as a **transparent proxy** (zero code changes), a **Python functi
|
|
| 104 |
|
| 105 |
Starts the proxy, points your tool at it, compresses everything automatically.
|
| 106 |
|
|
|
|
|
|
|
| 107 |
=== "TypeScript SDK"
|
| 108 |
|
| 109 |
```typescript
|
|
|
|
| 76 |
|
| 77 |
That's it. Your existing code works unchanged, with 40-90% fewer tokens.
|
| 78 |
|
| 79 |
+
Want an always-on local runtime instead? See [Persistent Installs →](persistent-installs.md).
|
| 80 |
+
|
| 81 |
=== "Python SDK"
|
| 82 |
|
| 83 |
```python
|
|
|
|
| 106 |
|
| 107 |
Starts the proxy, points your tool at it, compresses everything automatically.
|
| 108 |
|
| 109 |
+
If you prefer an always-on proxy that `wrap` can reuse or recover, see [Persistent Installs →](persistent-installs.md).
|
| 110 |
+
|
| 111 |
=== "TypeScript SDK"
|
| 112 |
|
| 113 |
```typescript
|
docs/persistent-installs.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Persistent Installs
|
| 2 |
+
|
| 3 |
+
Headroom can now be installed as a durable local runtime instead of only being started ad hoc with `headroom proxy` or `headroom wrap ...`.
|
| 4 |
+
|
| 5 |
+
Use `headroom install` when you want supported tools to keep talking to an always-on proxy at `http://127.0.0.1:8787` and have `wrap` reuse or recover that deployment instead of starting a second ephemeral proxy.
|
| 6 |
+
|
| 7 |
+
## Runtime matrix
|
| 8 |
+
|
| 9 |
+
| Mode | What stays running | Primary entrypoint |
|
| 10 |
+
|---|---|---|
|
| 11 |
+
| Persistent Service | Native background service | `headroom install apply --preset persistent-service` |
|
| 12 |
+
| Persistent Task | Scheduled watchdog + on-demand runner | `headroom install apply --preset persistent-task` |
|
| 13 |
+
| Persistent Docker | Restartable Docker container | `headroom install apply --preset persistent-docker` |
|
| 14 |
+
| On-Demand CLI (Python) | Nothing after command exits | `headroom proxy` |
|
| 15 |
+
| On-Demand CLI (Docker) | Nothing after container exits | Docker-native wrapper / compose CLI |
|
| 16 |
+
| Wrapped (Python) | Proxy lasts for wrapped session | `headroom wrap ...` |
|
| 17 |
+
| Wrapped (Docker) | Containerized proxy + host tool session | Docker-native wrapper |
|
| 18 |
+
|
| 19 |
+
## Quick examples
|
| 20 |
+
|
| 21 |
+
### Persistent service on the local machine
|
| 22 |
+
|
| 23 |
+
```bash
|
| 24 |
+
headroom install apply --preset persistent-service --providers auto
|
| 25 |
+
headroom install status
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
This installs a background service on the current machine, applies persistent tool wiring, and keeps the proxy healthy on port `8787`.
|
| 29 |
+
|
| 30 |
+
### Persistent watchdog task
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
headroom install apply --preset persistent-task --providers manual --target claude --target codex
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
This installs a scheduled recovery path instead of a traditional always-running service.
|
| 37 |
+
|
| 38 |
+
### Persistent Docker
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
headroom install apply --preset persistent-docker --scope user --providers auto
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
This uses Docker's restart policy instead of an OS supervisor.
|
| 45 |
+
|
| 46 |
+
If you are using the Docker-native host wrapper instead of a Python install, use the compose-managed path documented in [Docker-Native Install](docker-install.md).
|
| 47 |
+
|
| 48 |
+
## Command surface
|
| 49 |
+
|
| 50 |
+
```text
|
| 51 |
+
headroom install apply
|
| 52 |
+
headroom install status
|
| 53 |
+
headroom install start
|
| 54 |
+
headroom install stop
|
| 55 |
+
headroom install restart
|
| 56 |
+
headroom install remove
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
`apply` creates or updates a named deployment profile, stores its manifest under `~/.headroom/deploy/<profile>/manifest.json`, applies reversible configuration changes, and starts the selected runtime.
|
| 60 |
+
|
| 61 |
+
## Presets and runtime kinds
|
| 62 |
+
|
| 63 |
+
### Presets
|
| 64 |
+
|
| 65 |
+
- `persistent-service` -> native service supervisor
|
| 66 |
+
- `persistent-task` -> scheduled watchdog / recovery supervisor
|
| 67 |
+
- `persistent-docker` -> Docker restart policy with no extra OS supervisor
|
| 68 |
+
|
| 69 |
+
### Runtime kinds
|
| 70 |
+
|
| 71 |
+
- `--runtime python` runs `headroom proxy` directly
|
| 72 |
+
- `--runtime docker` runs Headroom inside Docker while keeping the deployment managed locally
|
| 73 |
+
|
| 74 |
+
For `persistent-docker`, the runtime is always Docker.
|
| 75 |
+
|
| 76 |
+
## Configuration scopes
|
| 77 |
+
|
| 78 |
+
| Scope | What changes |
|
| 79 |
+
|---|---|
|
| 80 |
+
| `provider` | Tool-specific config surfaces where Headroom can make a precise reversible edit |
|
| 81 |
+
| `user` | User-level shell or environment surfaces |
|
| 82 |
+
| `system` | Machine-wide shell or environment surfaces |
|
| 83 |
+
|
| 84 |
+
### Provider scope today
|
| 85 |
+
|
| 86 |
+
Provider scope is intentionally conservative. The current direct adapters are:
|
| 87 |
+
|
| 88 |
+
- Claude Code -> `~/.claude/settings.json` `env`
|
| 89 |
+
- Codex -> managed block in `~/.codex/config.toml`
|
| 90 |
+
- OpenClaw -> existing `wrap openclaw` / `unwrap openclaw` flow
|
| 91 |
+
|
| 92 |
+
For Copilot, Aider, Cursor, and broader env-driven setups, prefer `--scope user` or `--scope system`.
|
| 93 |
+
|
| 94 |
+
## Provider selection
|
| 95 |
+
|
| 96 |
+
| Option | Meaning |
|
| 97 |
+
|---|---|
|
| 98 |
+
| `--providers auto` | Detect supported tools on the host and configure the best available defaults |
|
| 99 |
+
| `--providers all` | Configure all known targets |
|
| 100 |
+
| `--providers manual --target ...` | Configure only the named tools |
|
| 101 |
+
|
| 102 |
+
Examples:
|
| 103 |
+
|
| 104 |
+
```bash
|
| 105 |
+
headroom install apply --providers auto
|
| 106 |
+
headroom install apply --providers all --scope user
|
| 107 |
+
headroom install apply --providers manual --target claude --target copilot
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
## Health and wrap behavior
|
| 111 |
+
|
| 112 |
+
Persistent deployments publish the same `readyz` and `health` endpoints as ad hoc proxy runs.
|
| 113 |
+
|
| 114 |
+
`/health` now also exposes deployment metadata when the proxy was launched through the install subsystem:
|
| 115 |
+
|
| 116 |
+
```json
|
| 117 |
+
{
|
| 118 |
+
"deployment": {
|
| 119 |
+
"profile": "default",
|
| 120 |
+
"preset": "persistent-service",
|
| 121 |
+
"runtime": "python",
|
| 122 |
+
"supervisor": "service",
|
| 123 |
+
"scope": "user"
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
`headroom wrap ...` checks for a matching persistent deployment on the requested port before it starts a new ephemeral proxy. If an installed deployment exists but is stopped or unhealthy, `wrap` attempts to recover it first.
|
| 129 |
+
|
| 130 |
+
## Docker-native relationship
|
| 131 |
+
|
| 132 |
+
The Docker-native host wrapper and the Python install CLI solve different layers of the runtime story:
|
| 133 |
+
|
| 134 |
+
- [Docker-Native Install](docker-install.md) -> containerized on-demand CLI and wrapped host-tool flows
|
| 135 |
+
- `headroom install ...` -> persistent service, task, and native Docker lifecycle management
|
| 136 |
+
|
| 137 |
+
For a no-Python persistent Docker workflow, use the compose-managed proxy path from `docker/docker-compose.native.yml`:
|
| 138 |
+
|
| 139 |
+
```bash
|
| 140 |
+
export HEADROOM_HOST_HOME="$HOME"
|
| 141 |
+
export HEADROOM_WORKSPACE="$PWD"
|
| 142 |
+
docker compose -f docker/docker-compose.native.yml up -d proxy
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
That keeps `localhost:8787` stable and restarts the proxy automatically.
|
| 146 |
+
|
| 147 |
+
## Related guides
|
| 148 |
+
|
| 149 |
+
- [CLI Reference](cli.md)
|
| 150 |
+
- [Docker-Native Install](docker-install.md)
|
| 151 |
+
- [Proxy Server](proxy.md)
|
| 152 |
+
- [macOS LaunchAgent](macos-deployment.md)
|
docs/plans/2026-04-11-persistent-installs-design.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Persistent Deployments / Installs Design
|
| 2 |
+
|
| 3 |
+
## Problem
|
| 4 |
+
|
| 5 |
+
Headroom already supports session-oriented usage through `headroom proxy`, `headroom wrap ...`, and the Docker-native wrapper scripts, but there is no first-class way to install Headroom as a durable background runtime. That leaves users to hand-roll launch agents, services, scheduled tasks, or Docker restart policies, and it keeps direct tool usage (`claude`, `codex`, `copilot`, `openclaw`, etc.) tied to explicit `wrap` commands.
|
| 6 |
+
|
| 7 |
+
The new feature should make Headroom deployable as a persistent local runtime while keeping the existing on-demand and wrapped flows intact.
|
| 8 |
+
|
| 9 |
+
## Goals
|
| 10 |
+
|
| 11 |
+
- Support these runtime/install modes as one coherent system:
|
| 12 |
+
- Persistent Service
|
| 13 |
+
- Persistent Task
|
| 14 |
+
- Persistent Docker
|
| 15 |
+
- On-Demand CLI (Python)
|
| 16 |
+
- On-Demand CLI (Docker)
|
| 17 |
+
- Wrapped (Python)
|
| 18 |
+
- Wrapped (Docker)
|
| 19 |
+
- Support install target selection modes:
|
| 20 |
+
- Auto-Detect
|
| 21 |
+
- All
|
| 22 |
+
- Manual Select
|
| 23 |
+
- Support configuration scopes:
|
| 24 |
+
- Provider
|
| 25 |
+
- User
|
| 26 |
+
- System
|
| 27 |
+
- Keep `wrap` idempotent and persistent-aware.
|
| 28 |
+
- Preserve local defaults such as `localhost:8787`.
|
| 29 |
+
|
| 30 |
+
## Architecture
|
| 31 |
+
|
| 32 |
+
Introduce a new shared deployment subsystem under `headroom.install`.
|
| 33 |
+
|
| 34 |
+
Core model:
|
| 35 |
+
|
| 36 |
+
- `execution_mode`: `persistent | on_demand | wrapped`
|
| 37 |
+
- `runtime_kind`: `python | docker`
|
| 38 |
+
- `supervisor_kind`: `service | task | none`
|
| 39 |
+
|
| 40 |
+
These three axes normalize all seven user-facing runtime modes without duplicating logic across CLI commands, install scripts, and platform-specific deployment adapters.
|
| 41 |
+
|
| 42 |
+
The subsystem centers on a persisted deployment manifest in `~/.headroom/deploy/` that records:
|
| 43 |
+
|
| 44 |
+
- resolved proxy configuration
|
| 45 |
+
- runtime type
|
| 46 |
+
- supervisor type
|
| 47 |
+
- configured tool targets
|
| 48 |
+
- applied config mutations
|
| 49 |
+
- generated artifact paths
|
| 50 |
+
- health URL and port
|
| 51 |
+
|
| 52 |
+
## Command model
|
| 53 |
+
|
| 54 |
+
Add a new public `headroom install` group:
|
| 55 |
+
|
| 56 |
+
- `headroom install apply`
|
| 57 |
+
- `headroom install status`
|
| 58 |
+
- `headroom install start`
|
| 59 |
+
- `headroom install stop`
|
| 60 |
+
- `headroom install restart`
|
| 61 |
+
- `headroom install remove`
|
| 62 |
+
|
| 63 |
+
Add hidden helper commands for artifact runners and health recovery:
|
| 64 |
+
|
| 65 |
+
- `headroom install agent run --profile <name>`
|
| 66 |
+
- `headroom install agent ensure --profile <name>`
|
| 67 |
+
|
| 68 |
+
Platform supervisors should register the hidden agent entrypoint rather than raw `headroom proxy ...` so restart, health polling, and manifest handling live in one place.
|
| 69 |
+
|
| 70 |
+
## Runtime adapters
|
| 71 |
+
|
| 72 |
+
- `PythonRuntimeAdapter`: launches `headroom proxy` directly.
|
| 73 |
+
- `DockerRuntimeAdapter`: launches a detached or foreground Docker container with the existing host mounts and loopback-only port publishing.
|
| 74 |
+
|
| 75 |
+
Persistent Docker uses the same deployment manifest and status semantics as service/task installs, but the child runtime is Docker-managed rather than OS-supervised.
|
| 76 |
+
|
| 77 |
+
## Supervisor adapters
|
| 78 |
+
|
| 79 |
+
- Linux
|
| 80 |
+
- Service: systemd unit
|
| 81 |
+
- Task: cron watchdog + reboot/start entry
|
| 82 |
+
- macOS
|
| 83 |
+
- Service: LaunchDaemon / LaunchAgent variant
|
| 84 |
+
- Task: launchd user agent or cron-style watchdog where appropriate
|
| 85 |
+
- Windows
|
| 86 |
+
- Service: Windows Service wrapper
|
| 87 |
+
- Task: Scheduled Task startup + periodic health-check task
|
| 88 |
+
|
| 89 |
+
Each adapter renders artifacts into `~/.headroom/deploy/` and stores enough metadata for clean removal.
|
| 90 |
+
|
| 91 |
+
## Tool target configuration
|
| 92 |
+
|
| 93 |
+
Provider-level configuration should be target-specific and reversible.
|
| 94 |
+
|
| 95 |
+
Initial target adapters:
|
| 96 |
+
|
| 97 |
+
- Claude Code
|
| 98 |
+
- write `env` keys into Claude settings JSON where appropriate
|
| 99 |
+
- Codex
|
| 100 |
+
- manage a marked block or targeted settings in `~/.codex/config.toml`
|
| 101 |
+
- Copilot CLI
|
| 102 |
+
- configure BYOK environment surfaces using persistent env strategy
|
| 103 |
+
- OpenClaw
|
| 104 |
+
- reuse existing OpenClaw config/plugin merge logic where possible
|
| 105 |
+
- Aider / Cursor
|
| 106 |
+
- use env-based integration first, with tool-specific config only where a stable supported surface exists
|
| 107 |
+
|
| 108 |
+
All non-marker edits must store previous values in the deployment manifest so uninstall removes only Headroom-managed changes.
|
| 109 |
+
|
| 110 |
+
## Wrap behavior
|
| 111 |
+
|
| 112 |
+
`headroom wrap ...` should consult the active deployment manifest before starting a new proxy. If a compatible persistent deployment is present and healthy, `wrap` should reuse it and only perform any remaining tool-specific preparation. If the deployment exists but is unhealthy, `wrap` should attempt to recover it through the install subsystem before falling back to an ephemeral proxy.
|
| 113 |
+
|
| 114 |
+
## Docs strategy
|
| 115 |
+
|
| 116 |
+
The public docs should be reframed around:
|
| 117 |
+
|
| 118 |
+
- runtime mode
|
| 119 |
+
- lifecycle mode
|
| 120 |
+
- configuration scope
|
| 121 |
+
- direct-use vs `wrap`
|
| 122 |
+
|
| 123 |
+
Create a new top-level guide at `docs/persistent-installs.md` and update the existing Docker install, proxy, CLI, getting-started, quickstart, configuration, troubleshooting, and integration docs to reflect the broader runtime story.
|
| 124 |
+
|
| 125 |
+
## Risks
|
| 126 |
+
|
| 127 |
+
- Windows service installation is the highest-risk platform path and needs strong test isolation.
|
| 128 |
+
- Provider-specific config mutation must remain conservative and reversible.
|
| 129 |
+
- `/health` should gain deployment metadata without breaking the existing `config` payload shape expected by current tests and docs.
|
docs/quickstart.md
CHANGED
|
@@ -33,6 +33,14 @@ curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/i
|
|
| 33 |
|
| 34 |
See [Docker-native install](docker-install.md) if you want Docker to provide the Headroom runtime while your agent CLIs stay on the host.
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
---
|
| 37 |
|
| 38 |
## Option 1: Proxy Server (Zero Code Changes)
|
|
|
|
| 33 |
|
| 34 |
See [Docker-native install](docker-install.md) if you want Docker to provide the Headroom runtime while your agent CLIs stay on the host.
|
| 35 |
|
| 36 |
+
**Persistent background runtime:**
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
headroom install apply --preset persistent-service --providers auto
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
See [Persistent Installs](persistent-installs.md) if you want Headroom to stay up in the background and be reused by `wrap`.
|
| 43 |
+
|
| 44 |
---
|
| 45 |
|
| 46 |
## Option 1: Proxy Server (Zero Code Changes)
|
headroom/cli/install.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persistent install / deployment CLI commands."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import click
|
| 6 |
+
|
| 7 |
+
from headroom.install.health import probe_json, probe_ready
|
| 8 |
+
from headroom.install.models import (
|
| 9 |
+
ConfigScope,
|
| 10 |
+
DeploymentManifest,
|
| 11 |
+
InstallPreset,
|
| 12 |
+
ProviderSelectionMode,
|
| 13 |
+
RuntimeKind,
|
| 14 |
+
SupervisorKind,
|
| 15 |
+
)
|
| 16 |
+
from headroom.install.planner import build_manifest
|
| 17 |
+
from headroom.install.providers import apply_mutations, revert_mutations
|
| 18 |
+
from headroom.install.runtime import (
|
| 19 |
+
run_foreground,
|
| 20 |
+
runtime_status,
|
| 21 |
+
start_detached_agent,
|
| 22 |
+
start_persistent_docker,
|
| 23 |
+
stop_runtime,
|
| 24 |
+
wait_ready,
|
| 25 |
+
)
|
| 26 |
+
from headroom.install.state import delete_manifest, load_manifest, save_manifest
|
| 27 |
+
from headroom.install.supervisors import (
|
| 28 |
+
install_supervisor,
|
| 29 |
+
remove_supervisor,
|
| 30 |
+
start_supervisor,
|
| 31 |
+
stop_supervisor,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
from .main import main
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@main.group()
|
| 38 |
+
def install() -> None:
|
| 39 |
+
"""Install and manage persistent Headroom deployments."""
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _require_manifest(profile: str) -> DeploymentManifest:
|
| 43 |
+
manifest = load_manifest(profile)
|
| 44 |
+
if manifest is None:
|
| 45 |
+
raise click.ClickException(f"No deployment profile named '{profile}' is installed.")
|
| 46 |
+
return manifest
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _start_deployment(manifest: DeploymentManifest) -> None:
|
| 50 |
+
if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
|
| 51 |
+
start_persistent_docker(manifest)
|
| 52 |
+
elif manifest.supervisor_kind == SupervisorKind.SERVICE.value:
|
| 53 |
+
start_supervisor(manifest)
|
| 54 |
+
else:
|
| 55 |
+
start_detached_agent(manifest.profile)
|
| 56 |
+
|
| 57 |
+
if not wait_ready(manifest, timeout_seconds=45):
|
| 58 |
+
raise click.ClickException(
|
| 59 |
+
f"Deployment '{manifest.profile}' did not become ready after start."
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _stop_deployment(manifest: DeploymentManifest) -> None:
|
| 64 |
+
if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
|
| 65 |
+
stop_supervisor(manifest)
|
| 66 |
+
stop_runtime(manifest)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@install.command("apply")
|
| 70 |
+
@click.option(
|
| 71 |
+
"--preset",
|
| 72 |
+
type=click.Choice([preset.value for preset in InstallPreset]),
|
| 73 |
+
default=InstallPreset.PERSISTENT_SERVICE.value,
|
| 74 |
+
show_default=True,
|
| 75 |
+
help="Persistent runtime preset to install.",
|
| 76 |
+
)
|
| 77 |
+
@click.option(
|
| 78 |
+
"--runtime",
|
| 79 |
+
type=click.Choice([runtime.value for runtime in RuntimeKind]),
|
| 80 |
+
default=RuntimeKind.PYTHON.value,
|
| 81 |
+
show_default=True,
|
| 82 |
+
help="Runtime used to execute Headroom for service/task modes.",
|
| 83 |
+
)
|
| 84 |
+
@click.option(
|
| 85 |
+
"--scope",
|
| 86 |
+
type=click.Choice([scope.value for scope in ConfigScope]),
|
| 87 |
+
default=ConfigScope.USER.value,
|
| 88 |
+
show_default=True,
|
| 89 |
+
help="Where to apply persistent configuration.",
|
| 90 |
+
)
|
| 91 |
+
@click.option(
|
| 92 |
+
"--providers",
|
| 93 |
+
"provider_mode",
|
| 94 |
+
type=click.Choice([mode.value for mode in ProviderSelectionMode]),
|
| 95 |
+
default=ProviderSelectionMode.AUTO.value,
|
| 96 |
+
show_default=True,
|
| 97 |
+
help="Target selection mode for direct tool configuration.",
|
| 98 |
+
)
|
| 99 |
+
@click.option(
|
| 100 |
+
"--target",
|
| 101 |
+
"targets",
|
| 102 |
+
multiple=True,
|
| 103 |
+
type=click.Choice(["claude", "copilot", "codex", "aider", "cursor", "openclaw"]),
|
| 104 |
+
help="Tool target to configure when --providers manual is used.",
|
| 105 |
+
)
|
| 106 |
+
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
|
| 107 |
+
@click.option(
|
| 108 |
+
"--port", "-p", default=8787, type=int, show_default=True, help="Persistent proxy port."
|
| 109 |
+
)
|
| 110 |
+
@click.option(
|
| 111 |
+
"--backend",
|
| 112 |
+
default="anthropic",
|
| 113 |
+
show_default=True,
|
| 114 |
+
help="Proxy backend for the persistent runtime.",
|
| 115 |
+
)
|
| 116 |
+
@click.option(
|
| 117 |
+
"--anyllm-provider",
|
| 118 |
+
default=None,
|
| 119 |
+
help="Provider for any-llm backends when --backend anyllm is used.",
|
| 120 |
+
)
|
| 121 |
+
@click.option("--region", default=None, help="Cloud region for Bedrock / Vertex style backends.")
|
| 122 |
+
@click.option(
|
| 123 |
+
"--mode", "proxy_mode", default="token", show_default=True, help="Proxy optimization mode."
|
| 124 |
+
)
|
| 125 |
+
@click.option("--memory", is_flag=True, help="Enable persistent memory in the proxy runtime.")
|
| 126 |
+
@click.option("--no-telemetry", is_flag=True, help="Disable anonymous telemetry in the runtime.")
|
| 127 |
+
@click.option(
|
| 128 |
+
"--image",
|
| 129 |
+
default="ghcr.io/chopratejas/headroom:latest",
|
| 130 |
+
show_default=True,
|
| 131 |
+
help="Docker image to use when runtime=docker or preset=persistent-docker.",
|
| 132 |
+
)
|
| 133 |
+
def install_apply(
|
| 134 |
+
preset: str,
|
| 135 |
+
runtime: str,
|
| 136 |
+
scope: str,
|
| 137 |
+
provider_mode: str,
|
| 138 |
+
targets: tuple[str, ...],
|
| 139 |
+
profile: str,
|
| 140 |
+
port: int,
|
| 141 |
+
backend: str,
|
| 142 |
+
anyllm_provider: str | None,
|
| 143 |
+
region: str | None,
|
| 144 |
+
proxy_mode: str,
|
| 145 |
+
memory: bool,
|
| 146 |
+
no_telemetry: bool,
|
| 147 |
+
image: str,
|
| 148 |
+
) -> None:
|
| 149 |
+
"""Install a persistent Headroom deployment."""
|
| 150 |
+
|
| 151 |
+
if preset == InstallPreset.PERSISTENT_DOCKER.value:
|
| 152 |
+
runtime = RuntimeKind.DOCKER.value
|
| 153 |
+
|
| 154 |
+
manifest = build_manifest(
|
| 155 |
+
profile=profile,
|
| 156 |
+
preset=preset,
|
| 157 |
+
runtime_kind=runtime,
|
| 158 |
+
scope=scope,
|
| 159 |
+
provider_mode=provider_mode,
|
| 160 |
+
targets=list(targets),
|
| 161 |
+
port=port,
|
| 162 |
+
backend=backend,
|
| 163 |
+
anyllm_provider=anyllm_provider,
|
| 164 |
+
region=region,
|
| 165 |
+
proxy_mode=proxy_mode,
|
| 166 |
+
memory_enabled=memory,
|
| 167 |
+
telemetry_enabled=not no_telemetry,
|
| 168 |
+
image=image,
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
existing = load_manifest(profile)
|
| 172 |
+
if existing is not None:
|
| 173 |
+
click.echo(f"Updating existing deployment profile '{profile}'...")
|
| 174 |
+
revert_mutations(existing)
|
| 175 |
+
try:
|
| 176 |
+
remove_supervisor(existing)
|
| 177 |
+
except Exception:
|
| 178 |
+
pass
|
| 179 |
+
stop_runtime(existing)
|
| 180 |
+
|
| 181 |
+
manifest.mutations = apply_mutations(manifest)
|
| 182 |
+
manifest.artifacts = install_supervisor(manifest)
|
| 183 |
+
save_manifest(manifest)
|
| 184 |
+
|
| 185 |
+
if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
|
| 186 |
+
start_persistent_docker(manifest)
|
| 187 |
+
elif manifest.supervisor_kind == SupervisorKind.SERVICE.value:
|
| 188 |
+
start_supervisor(manifest)
|
| 189 |
+
else:
|
| 190 |
+
start_detached_agent(profile)
|
| 191 |
+
|
| 192 |
+
if not wait_ready(manifest, timeout_seconds=45):
|
| 193 |
+
raise click.ClickException(
|
| 194 |
+
f"Persistent deployment '{profile}' did not become ready at {manifest.health_url}."
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
click.echo(
|
| 198 |
+
f"Installed persistent deployment '{profile}' "
|
| 199 |
+
f"({manifest.preset}, runtime={manifest.runtime_kind}, scope={manifest.scope})."
|
| 200 |
+
)
|
| 201 |
+
click.echo(f"Health: {manifest.health_url}")
|
| 202 |
+
if manifest.targets:
|
| 203 |
+
click.echo(f"Targets: {', '.join(manifest.targets)}")
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
@install.command("status")
|
| 207 |
+
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
|
| 208 |
+
def install_status(profile: str) -> None:
|
| 209 |
+
"""Show persistent deployment status."""
|
| 210 |
+
|
| 211 |
+
manifest = _require_manifest(profile)
|
| 212 |
+
payload = probe_json(manifest.health_url.replace("/readyz", "/health"))
|
| 213 |
+
click.echo(f"Profile: {manifest.profile}")
|
| 214 |
+
click.echo(f"Preset: {manifest.preset}")
|
| 215 |
+
click.echo(f"Runtime: {manifest.runtime_kind}")
|
| 216 |
+
click.echo(f"Supervisor: {manifest.supervisor_kind}")
|
| 217 |
+
click.echo(f"Scope: {manifest.scope}")
|
| 218 |
+
click.echo(f"Port: {manifest.port}")
|
| 219 |
+
click.echo(f"Status: {runtime_status(manifest)}")
|
| 220 |
+
click.echo(f"Healthy: {'yes' if probe_ready(manifest.health_url) else 'no'}")
|
| 221 |
+
if payload and isinstance(payload, dict):
|
| 222 |
+
click.echo(f"Health URL: {manifest.health_url.replace('/readyz', '/health')}")
|
| 223 |
+
click.echo(f"Backend: {payload.get('config', {}).get('backend', manifest.backend)}")
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@install.command("start")
|
| 227 |
+
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
|
| 228 |
+
def install_start(profile: str) -> None:
|
| 229 |
+
"""Start a persistent deployment."""
|
| 230 |
+
|
| 231 |
+
manifest = _require_manifest(profile)
|
| 232 |
+
_start_deployment(manifest)
|
| 233 |
+
click.echo(f"Started deployment '{profile}'.")
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
@install.command("stop")
|
| 237 |
+
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
|
| 238 |
+
def install_stop(profile: str) -> None:
|
| 239 |
+
"""Stop a persistent deployment."""
|
| 240 |
+
|
| 241 |
+
manifest = _require_manifest(profile)
|
| 242 |
+
_stop_deployment(manifest)
|
| 243 |
+
click.echo(f"Stopped deployment '{profile}'.")
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
@install.command("restart")
|
| 247 |
+
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
|
| 248 |
+
def install_restart(profile: str) -> None:
|
| 249 |
+
"""Restart a persistent deployment."""
|
| 250 |
+
|
| 251 |
+
manifest = _require_manifest(profile)
|
| 252 |
+
_stop_deployment(manifest)
|
| 253 |
+
_start_deployment(manifest)
|
| 254 |
+
click.echo(f"Restarted deployment '{profile}'.")
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
@install.command("remove")
|
| 258 |
+
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
|
| 259 |
+
def install_remove(profile: str) -> None:
|
| 260 |
+
"""Remove a persistent deployment and undo managed config."""
|
| 261 |
+
|
| 262 |
+
manifest = _require_manifest(profile)
|
| 263 |
+
try:
|
| 264 |
+
if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
|
| 265 |
+
stop_supervisor(manifest)
|
| 266 |
+
except Exception:
|
| 267 |
+
pass
|
| 268 |
+
try:
|
| 269 |
+
stop_runtime(manifest)
|
| 270 |
+
except Exception:
|
| 271 |
+
pass
|
| 272 |
+
try:
|
| 273 |
+
remove_supervisor(manifest)
|
| 274 |
+
except Exception:
|
| 275 |
+
pass
|
| 276 |
+
revert_mutations(manifest)
|
| 277 |
+
delete_manifest(profile)
|
| 278 |
+
click.echo(f"Removed deployment '{profile}'.")
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
@install.group("agent", hidden=True)
|
| 282 |
+
def install_agent() -> None:
|
| 283 |
+
"""Hidden runtime helpers used by persistent supervisors."""
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
@install_agent.command("run")
|
| 287 |
+
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
|
| 288 |
+
def install_agent_run(profile: str) -> None:
|
| 289 |
+
"""Run the persistent runtime in the foreground."""
|
| 290 |
+
|
| 291 |
+
manifest = _require_manifest(profile)
|
| 292 |
+
raise SystemExit(run_foreground(manifest))
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
@install_agent.command("ensure")
|
| 296 |
+
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
|
| 297 |
+
def install_agent_ensure(profile: str) -> None:
|
| 298 |
+
"""Ensure a persistent deployment is healthy, starting it when needed."""
|
| 299 |
+
|
| 300 |
+
manifest = _require_manifest(profile)
|
| 301 |
+
if probe_ready(manifest.health_url):
|
| 302 |
+
click.echo(f"Deployment '{profile}' is already healthy.")
|
| 303 |
+
return
|
| 304 |
+
_start_deployment(manifest)
|
| 305 |
+
click.echo(f"Deployment '{profile}' is healthy.")
|
headroom/cli/main.py
CHANGED
|
@@ -37,6 +37,7 @@ def _register_commands() -> None:
|
|
| 37 |
"""Register all subcommand groups."""
|
| 38 |
from . import (
|
| 39 |
evals, # noqa: F401
|
|
|
|
| 40 |
learn, # noqa: F401
|
| 41 |
mcp, # noqa: F401
|
| 42 |
perf, # noqa: F401
|
|
|
|
| 37 |
"""Register all subcommand groups."""
|
| 38 |
from . import (
|
| 39 |
evals, # noqa: F401
|
| 40 |
+
install, # noqa: F401
|
| 41 |
learn, # noqa: F401
|
| 42 |
mcp, # noqa: F401
|
| 43 |
perf, # noqa: F401
|
headroom/cli/wrap.py
CHANGED
|
@@ -436,6 +436,52 @@ def _detect_running_proxy_backend(port: int) -> str | None:
|
|
| 436 |
return backend if isinstance(backend, str) else None
|
| 437 |
|
| 438 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
def _copilot_model_configured(copilot_args: tuple[str, ...], env: dict[str, str]) -> bool:
|
| 440 |
"""Return True when Copilot BYOK model selection is configured."""
|
| 441 |
if env.get("COPILOT_MODEL") or env.get("COPILOT_PROVIDER_MODEL_ID"):
|
|
@@ -462,6 +508,16 @@ def _ensure_proxy(
|
|
| 462 |
) -> subprocess.Popen | None:
|
| 463 |
"""Start or verify proxy. Returns process handle if we started it."""
|
| 464 |
if not no_proxy:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
if _check_proxy(port):
|
| 466 |
click.echo(f" Proxy already running on port {port}")
|
| 467 |
return None
|
|
|
|
| 436 |
return backend if isinstance(backend, str) else None
|
| 437 |
|
| 438 |
|
| 439 |
+
def _find_persistent_manifest(port: int) -> Any:
|
| 440 |
+
"""Return a matching persistent deployment manifest for the requested port."""
|
| 441 |
+
from headroom.install.state import list_manifests
|
| 442 |
+
|
| 443 |
+
manifests = [manifest for manifest in list_manifests() if manifest.port == port]
|
| 444 |
+
manifests.sort(key=lambda manifest: (manifest.profile != "default", manifest.profile))
|
| 445 |
+
return manifests[0] if manifests else None
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
def _recover_persistent_proxy(port: int) -> bool:
|
| 449 |
+
"""Start or recover a matching persistent deployment for the requested port."""
|
| 450 |
+
from headroom.install.health import probe_ready
|
| 451 |
+
from headroom.install.models import InstallPreset, SupervisorKind
|
| 452 |
+
from headroom.install.runtime import start_detached_agent, start_persistent_docker, wait_ready
|
| 453 |
+
from headroom.install.supervisors import start_supervisor
|
| 454 |
+
|
| 455 |
+
manifest = _find_persistent_manifest(port)
|
| 456 |
+
if manifest is None:
|
| 457 |
+
return False
|
| 458 |
+
|
| 459 |
+
if probe_ready(manifest.health_url):
|
| 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:
|
| 466 |
+
start_persistent_docker(manifest)
|
| 467 |
+
elif manifest.supervisor_kind == SupervisorKind.SERVICE.value:
|
| 468 |
+
start_supervisor(manifest)
|
| 469 |
+
else:
|
| 470 |
+
start_detached_agent(manifest.profile)
|
| 471 |
+
except Exception as exc:
|
| 472 |
+
click.echo(
|
| 473 |
+
f" Warning: could not recover persistent deployment '{manifest.profile}': {exc}"
|
| 474 |
+
)
|
| 475 |
+
return False
|
| 476 |
+
|
| 477 |
+
if wait_ready(manifest, timeout_seconds=45):
|
| 478 |
+
click.echo(f" Recovered persistent deployment '{manifest.profile}' on port {port}")
|
| 479 |
+
return True
|
| 480 |
+
|
| 481 |
+
click.echo(f" Warning: persistent deployment '{manifest.profile}' did not become ready")
|
| 482 |
+
return False
|
| 483 |
+
|
| 484 |
+
|
| 485 |
def _copilot_model_configured(copilot_args: tuple[str, ...], env: dict[str, str]) -> bool:
|
| 486 |
"""Return True when Copilot BYOK model selection is configured."""
|
| 487 |
if env.get("COPILOT_MODEL") or env.get("COPILOT_PROVIDER_MODEL_ID"):
|
|
|
|
| 508 |
) -> subprocess.Popen | None:
|
| 509 |
"""Start or verify proxy. Returns process handle if we started it."""
|
| 510 |
if not no_proxy:
|
| 511 |
+
manifest = _find_persistent_manifest(port)
|
| 512 |
+
if manifest is not None:
|
| 513 |
+
from headroom.install.health import probe_ready
|
| 514 |
+
|
| 515 |
+
if probe_ready(manifest.health_url):
|
| 516 |
+
click.echo(f" Proxy already running on port {port}")
|
| 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}")
|
| 523 |
return None
|
headroom/install/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persistent install / deployment helpers for Headroom."""
|
| 2 |
+
|
| 3 |
+
from .models import (
|
| 4 |
+
ConfigScope,
|
| 5 |
+
DeploymentManifest,
|
| 6 |
+
InstallPreset,
|
| 7 |
+
ProviderSelectionMode,
|
| 8 |
+
SupervisorKind,
|
| 9 |
+
ToolTarget,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
"ConfigScope",
|
| 14 |
+
"DeploymentManifest",
|
| 15 |
+
"InstallPreset",
|
| 16 |
+
"ProviderSelectionMode",
|
| 17 |
+
"SupervisorKind",
|
| 18 |
+
"ToolTarget",
|
| 19 |
+
]
|
headroom/install/health.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Health helpers for persistent deployments."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import urllib.error
|
| 7 |
+
import urllib.request
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def probe_json(url: str, timeout: float = 2.0) -> dict[str, Any] | None:
|
| 12 |
+
"""Return a JSON payload from the URL when reachable."""
|
| 13 |
+
|
| 14 |
+
try:
|
| 15 |
+
with urllib.request.urlopen(url, timeout=timeout) as response:
|
| 16 |
+
payload = json.loads(response.read().decode("utf-8"))
|
| 17 |
+
except (OSError, urllib.error.URLError, ValueError, json.JSONDecodeError):
|
| 18 |
+
return None
|
| 19 |
+
return payload if isinstance(payload, dict) else None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def probe_ready(url: str, timeout: float = 2.0) -> bool:
|
| 23 |
+
"""Return True when the ready endpoint reports readiness."""
|
| 24 |
+
|
| 25 |
+
payload = probe_json(url, timeout=timeout)
|
| 26 |
+
if not isinstance(payload, dict):
|
| 27 |
+
return False
|
| 28 |
+
return bool(payload.get("ready", False) or payload.get("status") == "healthy")
|
headroom/install/models.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Models used by the install / deployment subsystem."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from enum import Enum
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class InstallPreset(str, Enum):
|
| 12 |
+
"""User-facing persistent runtime presets."""
|
| 13 |
+
|
| 14 |
+
PERSISTENT_SERVICE = "persistent-service"
|
| 15 |
+
PERSISTENT_TASK = "persistent-task"
|
| 16 |
+
PERSISTENT_DOCKER = "persistent-docker"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class RuntimeKind(str, Enum):
|
| 20 |
+
"""Runtime used to execute Headroom."""
|
| 21 |
+
|
| 22 |
+
PYTHON = "python"
|
| 23 |
+
DOCKER = "docker"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class SupervisorKind(str, Enum):
|
| 27 |
+
"""How a persistent deployment is kept alive."""
|
| 28 |
+
|
| 29 |
+
SERVICE = "service"
|
| 30 |
+
TASK = "task"
|
| 31 |
+
NONE = "none"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ProviderSelectionMode(str, Enum):
|
| 35 |
+
"""How tool targets are selected for configuration."""
|
| 36 |
+
|
| 37 |
+
AUTO = "auto"
|
| 38 |
+
ALL = "all"
|
| 39 |
+
MANUAL = "manual"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class ConfigScope(str, Enum):
|
| 43 |
+
"""Where persistent configuration should be applied."""
|
| 44 |
+
|
| 45 |
+
PROVIDER = "provider"
|
| 46 |
+
USER = "user"
|
| 47 |
+
SYSTEM = "system"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class ToolTarget(str, Enum):
|
| 51 |
+
"""Supported tool targets for persistent proxy wiring."""
|
| 52 |
+
|
| 53 |
+
CLAUDE = "claude"
|
| 54 |
+
COPILOT = "copilot"
|
| 55 |
+
CODEX = "codex"
|
| 56 |
+
AIDER = "aider"
|
| 57 |
+
CURSOR = "cursor"
|
| 58 |
+
OPENCLAW = "openclaw"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def iso_utc_now() -> str:
|
| 62 |
+
"""Return the current UTC timestamp in ISO-8601 format."""
|
| 63 |
+
|
| 64 |
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@dataclass
|
| 68 |
+
class ManagedMutation:
|
| 69 |
+
"""A reversible change applied by `headroom install`."""
|
| 70 |
+
|
| 71 |
+
target: str
|
| 72 |
+
kind: str
|
| 73 |
+
path: str | None = None
|
| 74 |
+
data: dict[str, Any] = field(default_factory=dict)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@dataclass
|
| 78 |
+
class ArtifactRecord:
|
| 79 |
+
"""A rendered file or platform object owned by the deployment."""
|
| 80 |
+
|
| 81 |
+
kind: str
|
| 82 |
+
path: str
|
| 83 |
+
metadata: dict[str, Any] = field(default_factory=dict)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@dataclass
|
| 87 |
+
class DeploymentManifest:
|
| 88 |
+
"""Persisted deployment state for a named profile."""
|
| 89 |
+
|
| 90 |
+
profile: str
|
| 91 |
+
preset: str
|
| 92 |
+
runtime_kind: str
|
| 93 |
+
supervisor_kind: str
|
| 94 |
+
scope: str
|
| 95 |
+
provider_mode: str
|
| 96 |
+
targets: list[str]
|
| 97 |
+
port: int
|
| 98 |
+
host: str
|
| 99 |
+
backend: str
|
| 100 |
+
anyllm_provider: str | None = None
|
| 101 |
+
region: str | None = None
|
| 102 |
+
proxy_mode: str = "token"
|
| 103 |
+
memory_enabled: bool = False
|
| 104 |
+
memory_db_path: str = ""
|
| 105 |
+
telemetry_enabled: bool = True
|
| 106 |
+
image: str = "ghcr.io/chopratejas/headroom:latest"
|
| 107 |
+
service_name: str = "headroom"
|
| 108 |
+
container_name: str = "headroom-persistent"
|
| 109 |
+
health_url: str = "http://127.0.0.1:8787/readyz"
|
| 110 |
+
base_env: dict[str, str] = field(default_factory=dict)
|
| 111 |
+
tool_envs: dict[str, dict[str, str]] = field(default_factory=dict)
|
| 112 |
+
proxy_args: list[str] = field(default_factory=list)
|
| 113 |
+
mutations: list[ManagedMutation] = field(default_factory=list)
|
| 114 |
+
artifacts: list[ArtifactRecord] = field(default_factory=list)
|
| 115 |
+
created_at: str = field(default_factory=iso_utc_now)
|
| 116 |
+
updated_at: str = field(default_factory=iso_utc_now)
|
headroom/install/paths.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Path helpers for persistent deployments."""
|
| 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."""
|
| 11 |
+
|
| 12 |
+
return Path.home() / ".headroom" / "deploy"
|
| 13 |
+
|
| 14 |
+
|
| 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:
|
| 22 |
+
"""Return the manifest path for a named profile."""
|
| 23 |
+
|
| 24 |
+
return profile_root(profile) / "manifest.json"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def log_path(profile: str) -> Path:
|
| 28 |
+
"""Return the log path used by persistent runner scripts."""
|
| 29 |
+
|
| 30 |
+
return profile_root(profile) / "runner.log"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def pid_path(profile: str) -> Path:
|
| 34 |
+
"""Return the pid file for the raw runtime process."""
|
| 35 |
+
|
| 36 |
+
return profile_root(profile) / "runner.pid"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def unix_run_script_path(profile: str) -> Path:
|
| 40 |
+
"""Return the foreground runner shell script path."""
|
| 41 |
+
|
| 42 |
+
return profile_root(profile) / "run-headroom.sh"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def unix_ensure_script_path(profile: str) -> Path:
|
| 46 |
+
"""Return the watchdog shell script path."""
|
| 47 |
+
|
| 48 |
+
return profile_root(profile) / "ensure-headroom.sh"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def windows_run_script_path(profile: str) -> Path:
|
| 52 |
+
"""Return the foreground runner PowerShell script path."""
|
| 53 |
+
|
| 54 |
+
return profile_root(profile) / "run-headroom.ps1"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def windows_run_cmd_path(profile: str) -> Path:
|
| 58 |
+
"""Return the foreground runner CMD shim path."""
|
| 59 |
+
|
| 60 |
+
return profile_root(profile) / "run-headroom.cmd"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def windows_ensure_script_path(profile: str) -> Path:
|
| 64 |
+
"""Return the watchdog PowerShell script path."""
|
| 65 |
+
|
| 66 |
+
return profile_root(profile) / "ensure-headroom.ps1"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def windows_ensure_cmd_path(profile: str) -> Path:
|
| 70 |
+
"""Return the watchdog CMD shim path."""
|
| 71 |
+
|
| 72 |
+
return profile_root(profile) / "ensure-headroom.cmd"
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def unix_user_env_targets() -> list[Path]:
|
| 76 |
+
"""Return user shell files that can carry the persistent env block."""
|
| 77 |
+
|
| 78 |
+
home = Path.home()
|
| 79 |
+
return [home / ".bashrc", home / ".zshrc", home / ".profile"]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def unix_system_env_targets() -> list[Path]:
|
| 83 |
+
"""Return system shell files that can carry the persistent env block."""
|
| 84 |
+
|
| 85 |
+
if sys.platform == "darwin":
|
| 86 |
+
return [Path("/etc/profile"), Path("/etc/zprofile"), Path("/etc/bashrc")]
|
| 87 |
+
return [Path("/etc/profile.d/headroom.sh")]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def claude_settings_path() -> Path:
|
| 91 |
+
"""Return the Claude user settings path."""
|
| 92 |
+
|
| 93 |
+
return Path.home() / ".claude" / "settings.json"
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def codex_config_path() -> Path:
|
| 97 |
+
"""Return the Codex config path."""
|
| 98 |
+
|
| 99 |
+
return Path.home() / ".codex" / "config.toml"
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def openclaw_config_path() -> Path:
|
| 103 |
+
"""Return the OpenClaw config path."""
|
| 104 |
+
|
| 105 |
+
return Path.home() / ".openclaw" / "openclaw.json"
|
headroom/install/planner.py
ADDED
|
@@ -0,0 +1,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 |
+
from .models import (
|
| 10 |
+
DeploymentManifest,
|
| 11 |
+
InstallPreset,
|
| 12 |
+
ProviderSelectionMode,
|
| 13 |
+
SupervisorKind,
|
| 14 |
+
ToolTarget,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
SUPPORTED_TARGETS = [
|
| 18 |
+
ToolTarget.CLAUDE,
|
| 19 |
+
ToolTarget.COPILOT,
|
| 20 |
+
ToolTarget.CODEX,
|
| 21 |
+
ToolTarget.AIDER,
|
| 22 |
+
ToolTarget.CURSOR,
|
| 23 |
+
ToolTarget.OPENCLAW,
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _binary_name(target: ToolTarget) -> str | None:
|
| 28 |
+
if target == ToolTarget.CURSOR:
|
| 29 |
+
return None
|
| 30 |
+
return str(target.value)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def detect_targets() -> list[str]:
|
| 34 |
+
"""Auto-detect available tool targets on the current host."""
|
| 35 |
+
|
| 36 |
+
detected: list[str] = []
|
| 37 |
+
for target in SUPPORTED_TARGETS:
|
| 38 |
+
binary = _binary_name(target)
|
| 39 |
+
if binary and shutil.which(binary):
|
| 40 |
+
detected.append(target.value)
|
| 41 |
+
continue
|
| 42 |
+
if target == ToolTarget.CURSOR and shutil.which("cursor"):
|
| 43 |
+
detected.append(target.value)
|
| 44 |
+
return detected
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def resolve_targets(provider_mode: str, requested_targets: Iterable[str]) -> list[str]:
|
| 48 |
+
"""Resolve target selection according to the requested provider mode."""
|
| 49 |
+
|
| 50 |
+
if provider_mode == ProviderSelectionMode.ALL.value:
|
| 51 |
+
return [target.value for target in SUPPORTED_TARGETS]
|
| 52 |
+
|
| 53 |
+
if provider_mode == ProviderSelectionMode.AUTO.value:
|
| 54 |
+
detected = detect_targets()
|
| 55 |
+
return detected or [
|
| 56 |
+
ToolTarget.CLAUDE.value,
|
| 57 |
+
ToolTarget.CODEX.value,
|
| 58 |
+
ToolTarget.COPILOT.value,
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
normalized = []
|
| 62 |
+
seen: set[str] = set()
|
| 63 |
+
valid = {target.value for target in SUPPORTED_TARGETS}
|
| 64 |
+
for target in requested_targets:
|
| 65 |
+
value = target.strip().lower()
|
| 66 |
+
if value in valid and value not in seen:
|
| 67 |
+
seen.add(value)
|
| 68 |
+
normalized.append(value)
|
| 69 |
+
return normalized
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _copilot_env(port: int, backend: str) -> dict[str, str]:
|
| 73 |
+
if backend == "anthropic":
|
| 74 |
+
return {
|
| 75 |
+
"COPILOT_PROVIDER_TYPE": "anthropic",
|
| 76 |
+
"COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}",
|
| 77 |
+
}
|
| 78 |
+
return {
|
| 79 |
+
"COPILOT_PROVIDER_TYPE": "openai",
|
| 80 |
+
"COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}/v1",
|
| 81 |
+
"COPILOT_PROVIDER_WIRE_API": "completions",
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def build_tool_envs(port: int, backend: str, targets: list[str]) -> dict[str, dict[str, str]]:
|
| 86 |
+
"""Build per-target environment variables for the selected tools."""
|
| 87 |
+
|
| 88 |
+
target_envs: dict[str, dict[str, str]] = {}
|
| 89 |
+
if ToolTarget.CLAUDE.value in targets:
|
| 90 |
+
target_envs[ToolTarget.CLAUDE.value] = {
|
| 91 |
+
"ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
|
| 92 |
+
}
|
| 93 |
+
if ToolTarget.CODEX.value in targets:
|
| 94 |
+
target_envs[ToolTarget.CODEX.value] = {
|
| 95 |
+
"OPENAI_BASE_URL": f"http://127.0.0.1:{port}/v1",
|
| 96 |
+
}
|
| 97 |
+
if ToolTarget.AIDER.value in targets:
|
| 98 |
+
target_envs[ToolTarget.AIDER.value] = {
|
| 99 |
+
"OPENAI_API_BASE": f"http://127.0.0.1:{port}/v1",
|
| 100 |
+
"ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
|
| 101 |
+
}
|
| 102 |
+
if ToolTarget.COPILOT.value in targets:
|
| 103 |
+
target_envs[ToolTarget.COPILOT.value] = _copilot_env(port, backend)
|
| 104 |
+
if ToolTarget.CURSOR.value in targets:
|
| 105 |
+
target_envs[ToolTarget.CURSOR.value] = {
|
| 106 |
+
"OPENAI_BASE_URL": f"http://127.0.0.1:{port}/v1",
|
| 107 |
+
"ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
|
| 108 |
+
}
|
| 109 |
+
return target_envs
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def build_manifest(
|
| 113 |
+
*,
|
| 114 |
+
profile: str,
|
| 115 |
+
preset: str,
|
| 116 |
+
runtime_kind: str,
|
| 117 |
+
scope: str,
|
| 118 |
+
provider_mode: str,
|
| 119 |
+
targets: list[str],
|
| 120 |
+
port: int,
|
| 121 |
+
backend: str,
|
| 122 |
+
anyllm_provider: str | None,
|
| 123 |
+
region: str | None,
|
| 124 |
+
proxy_mode: str,
|
| 125 |
+
memory_enabled: bool,
|
| 126 |
+
telemetry_enabled: bool,
|
| 127 |
+
image: str,
|
| 128 |
+
) -> DeploymentManifest:
|
| 129 |
+
"""Create a normalized deployment manifest."""
|
| 130 |
+
|
| 131 |
+
if preset == InstallPreset.PERSISTENT_SERVICE.value:
|
| 132 |
+
supervisor_kind = SupervisorKind.SERVICE.value
|
| 133 |
+
elif preset == InstallPreset.PERSISTENT_TASK.value:
|
| 134 |
+
supervisor_kind = SupervisorKind.TASK.value
|
| 135 |
+
else:
|
| 136 |
+
supervisor_kind = SupervisorKind.NONE.value
|
| 137 |
+
|
| 138 |
+
resolved_targets = resolve_targets(provider_mode, targets)
|
| 139 |
+
tool_envs = build_tool_envs(port, backend, resolved_targets)
|
| 140 |
+
base_env = {
|
| 141 |
+
"HEADROOM_PORT": str(port),
|
| 142 |
+
"HEADROOM_HOST": "127.0.0.1",
|
| 143 |
+
"HEADROOM_MODE": proxy_mode,
|
| 144 |
+
"HEADROOM_BACKEND": backend,
|
| 145 |
+
}
|
| 146 |
+
if anyllm_provider:
|
| 147 |
+
base_env["HEADROOM_ANYLLM_PROVIDER"] = anyllm_provider
|
| 148 |
+
if region:
|
| 149 |
+
base_env["HEADROOM_REGION"] = region
|
| 150 |
+
if not telemetry_enabled:
|
| 151 |
+
base_env["HEADROOM_TELEMETRY"] = "off"
|
| 152 |
+
if memory_enabled:
|
| 153 |
+
base_env["HEADROOM_MEMORY_ENABLED"] = "1"
|
| 154 |
+
|
| 155 |
+
proxy_args = [
|
| 156 |
+
"--host",
|
| 157 |
+
"127.0.0.1",
|
| 158 |
+
"--port",
|
| 159 |
+
str(port),
|
| 160 |
+
"--mode",
|
| 161 |
+
proxy_mode,
|
| 162 |
+
"--backend",
|
| 163 |
+
backend,
|
| 164 |
+
]
|
| 165 |
+
if not telemetry_enabled:
|
| 166 |
+
proxy_args.append("--no-telemetry")
|
| 167 |
+
if memory_enabled:
|
| 168 |
+
proxy_args.extend(
|
| 169 |
+
["--memory", "--memory-db-path", str(Path.home() / ".headroom" / "memory.db")]
|
| 170 |
+
)
|
| 171 |
+
if anyllm_provider:
|
| 172 |
+
proxy_args.extend(["--anyllm-provider", anyllm_provider])
|
| 173 |
+
if region:
|
| 174 |
+
proxy_args.extend(["--region", region])
|
| 175 |
+
|
| 176 |
+
container_name = f"headroom-{profile}"
|
| 177 |
+
return DeploymentManifest(
|
| 178 |
+
profile=profile,
|
| 179 |
+
preset=preset,
|
| 180 |
+
runtime_kind=runtime_kind,
|
| 181 |
+
supervisor_kind=supervisor_kind,
|
| 182 |
+
scope=scope,
|
| 183 |
+
provider_mode=provider_mode,
|
| 184 |
+
targets=resolved_targets,
|
| 185 |
+
port=port,
|
| 186 |
+
host="127.0.0.1",
|
| 187 |
+
backend=backend,
|
| 188 |
+
anyllm_provider=anyllm_provider,
|
| 189 |
+
region=region,
|
| 190 |
+
proxy_mode=proxy_mode,
|
| 191 |
+
memory_enabled=memory_enabled,
|
| 192 |
+
memory_db_path=str(Path.home() / ".headroom" / "memory.db"),
|
| 193 |
+
telemetry_enabled=telemetry_enabled,
|
| 194 |
+
image=image,
|
| 195 |
+
service_name=f"headroom-{profile}",
|
| 196 |
+
container_name=container_name,
|
| 197 |
+
health_url=f"http://127.0.0.1:{port}/readyz",
|
| 198 |
+
base_env=base_env,
|
| 199 |
+
tool_envs=tool_envs,
|
| 200 |
+
proxy_args=proxy_args,
|
| 201 |
+
)
|
headroom/install/providers.py
ADDED
|
@@ -0,0 +1,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 _unix_scope_values(manifest: DeploymentManifest) -> dict[str, str]:
|
| 55 |
+
merged = dict(manifest.base_env)
|
| 56 |
+
for env_map in manifest.tool_envs.values():
|
| 57 |
+
merged.update(env_map)
|
| 58 |
+
return merged
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _apply_unix_env_scope(manifest: DeploymentManifest) -> list[ManagedMutation]:
|
| 62 |
+
values = _unix_scope_values(manifest)
|
| 63 |
+
block = _env_block(values)
|
| 64 |
+
if manifest.scope == ConfigScope.USER.value:
|
| 65 |
+
targets = unix_user_env_targets()
|
| 66 |
+
else:
|
| 67 |
+
targets = unix_system_env_targets()
|
| 68 |
+
mutations: list[ManagedMutation] = []
|
| 69 |
+
for path in targets:
|
| 70 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 71 |
+
merged = _merge_marker_block(path, block, _ENV_PATTERN, _ENV_MARKER_START)
|
| 72 |
+
path.write_text(merged)
|
| 73 |
+
mutations.append(ManagedMutation(target="env", kind="shell-block", path=str(path)))
|
| 74 |
+
return mutations
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _remove_unix_env_scope(mutations: list[ManagedMutation]) -> None:
|
| 78 |
+
for mutation in mutations:
|
| 79 |
+
if mutation.kind != "shell-block" or not mutation.path:
|
| 80 |
+
continue
|
| 81 |
+
path = Path(mutation.path)
|
| 82 |
+
if not path.exists():
|
| 83 |
+
continue
|
| 84 |
+
content = path.read_text()
|
| 85 |
+
if _ENV_MARKER_START not in content:
|
| 86 |
+
continue
|
| 87 |
+
path.write_text(_ENV_PATTERN.sub("", content).strip() + "\n")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _apply_windows_env_scope(manifest: DeploymentManifest) -> list[ManagedMutation]:
|
| 91 |
+
scope_name = "Machine" if manifest.scope == ConfigScope.SYSTEM.value else "User"
|
| 92 |
+
merged = _unix_scope_values(manifest)
|
| 93 |
+
mutations: list[ManagedMutation] = []
|
| 94 |
+
for name, value in merged.items():
|
| 95 |
+
command = [
|
| 96 |
+
"powershell",
|
| 97 |
+
"-NoProfile",
|
| 98 |
+
"-Command",
|
| 99 |
+
f"[Environment]::SetEnvironmentVariable('{name}','{value}','{scope_name}')",
|
| 100 |
+
]
|
| 101 |
+
subprocess.run(command, check=True)
|
| 102 |
+
mutations.append(
|
| 103 |
+
ManagedMutation(
|
| 104 |
+
target="env", kind="windows-env", data={"name": name, "scope": scope_name}
|
| 105 |
+
)
|
| 106 |
+
)
|
| 107 |
+
return mutations
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _remove_windows_env_scope(mutations: list[ManagedMutation]) -> None:
|
| 111 |
+
for mutation in mutations:
|
| 112 |
+
if mutation.kind != "windows-env":
|
| 113 |
+
continue
|
| 114 |
+
name = mutation.data.get("name")
|
| 115 |
+
scope_name = mutation.data.get("scope", "User")
|
| 116 |
+
command = [
|
| 117 |
+
"powershell",
|
| 118 |
+
"-NoProfile",
|
| 119 |
+
"-Command",
|
| 120 |
+
f"[Environment]::SetEnvironmentVariable('{name}',$null,'{scope_name}')",
|
| 121 |
+
]
|
| 122 |
+
subprocess.run(command, check=True)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _apply_claude_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
|
| 126 |
+
path = claude_settings_path()
|
| 127 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 128 |
+
payload: dict[str, object] = {}
|
| 129 |
+
if path.exists():
|
| 130 |
+
payload = json.loads(path.read_text())
|
| 131 |
+
env = payload.get("env")
|
| 132 |
+
env_map = dict(env) if isinstance(env, dict) else {}
|
| 133 |
+
previous = {
|
| 134 |
+
name: env_map.get(name) for name in manifest.tool_envs.get(ToolTarget.CLAUDE.value, {})
|
| 135 |
+
}
|
| 136 |
+
env_map.update(manifest.tool_envs[ToolTarget.CLAUDE.value])
|
| 137 |
+
payload["env"] = env_map
|
| 138 |
+
path.write_text(json.dumps(payload, indent=2) + "\n")
|
| 139 |
+
return ManagedMutation(
|
| 140 |
+
target=ToolTarget.CLAUDE.value,
|
| 141 |
+
kind="json-env",
|
| 142 |
+
path=str(path),
|
| 143 |
+
data={"previous": previous},
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _revert_claude_provider_scope(mutation: ManagedMutation, values: dict[str, str]) -> None:
|
| 148 |
+
if not mutation.path:
|
| 149 |
+
return
|
| 150 |
+
path = Path(mutation.path)
|
| 151 |
+
if not path.exists():
|
| 152 |
+
return
|
| 153 |
+
payload = json.loads(path.read_text())
|
| 154 |
+
env = payload.get("env")
|
| 155 |
+
env_map = dict(env) if isinstance(env, dict) else {}
|
| 156 |
+
previous: dict[str, object] = mutation.data.get("previous", {})
|
| 157 |
+
for name in values:
|
| 158 |
+
if previous.get(name) is None:
|
| 159 |
+
env_map.pop(name, None)
|
| 160 |
+
else:
|
| 161 |
+
env_map[name] = previous[name]
|
| 162 |
+
payload["env"] = env_map
|
| 163 |
+
path.write_text(json.dumps(payload, indent=2) + "\n")
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _apply_codex_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
|
| 167 |
+
path = codex_config_path()
|
| 168 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 169 |
+
section = (
|
| 170 |
+
f"{_CODEX_MARKER_START}\n"
|
| 171 |
+
'model_provider = "headroom"\n\n'
|
| 172 |
+
"[model_providers.headroom]\n"
|
| 173 |
+
'name = "Headroom persistent proxy"\n'
|
| 174 |
+
f'base_url = "http://127.0.0.1:{manifest.port}/v1"\n'
|
| 175 |
+
'env_key = "OPENAI_API_KEY"\n'
|
| 176 |
+
"requires_openai_auth = true\n"
|
| 177 |
+
"supports_websockets = true\n"
|
| 178 |
+
f"{_CODEX_MARKER_END}\n"
|
| 179 |
+
)
|
| 180 |
+
merged = _merge_marker_block(path, section, _CODEX_PATTERN, _CODEX_MARKER_START)
|
| 181 |
+
path.write_text(merged)
|
| 182 |
+
return ManagedMutation(target=ToolTarget.CODEX.value, kind="toml-block", path=str(path))
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _revert_codex_provider_scope(mutation: ManagedMutation) -> None:
|
| 186 |
+
if not mutation.path:
|
| 187 |
+
return
|
| 188 |
+
path = Path(mutation.path)
|
| 189 |
+
if not path.exists():
|
| 190 |
+
return
|
| 191 |
+
content = path.read_text()
|
| 192 |
+
if _CODEX_MARKER_START not in content:
|
| 193 |
+
return
|
| 194 |
+
path.write_text(_CODEX_PATTERN.sub("", content).strip() + "\n")
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _invoke_openclaw(command: list[str]) -> None:
|
| 198 |
+
subprocess.run(command, check=True)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _apply_openclaw_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
|
| 202 |
+
if not shutil_which("openclaw"):
|
| 203 |
+
raise click.ClickException("openclaw not found in PATH; cannot apply provider scope.")
|
| 204 |
+
command = [
|
| 205 |
+
*resolve_headroom_command(),
|
| 206 |
+
"wrap",
|
| 207 |
+
"openclaw",
|
| 208 |
+
"--no-auto-start",
|
| 209 |
+
"--proxy-port",
|
| 210 |
+
str(manifest.port),
|
| 211 |
+
]
|
| 212 |
+
_invoke_openclaw(command)
|
| 213 |
+
return ManagedMutation(
|
| 214 |
+
target=ToolTarget.OPENCLAW.value, kind="openclaw-wrap", path=str(openclaw_config_path())
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _revert_openclaw_provider_scope() -> None:
|
| 219 |
+
if not shutil_which("openclaw"):
|
| 220 |
+
return
|
| 221 |
+
command = [*resolve_headroom_command(), "unwrap", "openclaw"]
|
| 222 |
+
_invoke_openclaw(command)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def shutil_which(name: str) -> str | None:
|
| 226 |
+
from shutil import which
|
| 227 |
+
|
| 228 |
+
return which(name)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def apply_mutations(manifest: DeploymentManifest) -> list[ManagedMutation]:
|
| 232 |
+
"""Apply provider/user/system configuration for a deployment."""
|
| 233 |
+
|
| 234 |
+
mutations: list[ManagedMutation] = []
|
| 235 |
+
if manifest.scope in {ConfigScope.USER.value, ConfigScope.SYSTEM.value}:
|
| 236 |
+
if os.name == "nt":
|
| 237 |
+
mutations.extend(_apply_windows_env_scope(manifest))
|
| 238 |
+
else:
|
| 239 |
+
mutations.extend(_apply_unix_env_scope(manifest))
|
| 240 |
+
if ToolTarget.OPENCLAW.value in manifest.targets:
|
| 241 |
+
try:
|
| 242 |
+
mutations.append(_apply_openclaw_provider_scope(manifest))
|
| 243 |
+
except click.ClickException:
|
| 244 |
+
pass
|
| 245 |
+
return mutations
|
| 246 |
+
|
| 247 |
+
if ToolTarget.CLAUDE.value in manifest.targets:
|
| 248 |
+
mutations.append(_apply_claude_provider_scope(manifest))
|
| 249 |
+
if ToolTarget.CODEX.value in manifest.targets:
|
| 250 |
+
mutations.append(_apply_codex_provider_scope(manifest))
|
| 251 |
+
if ToolTarget.OPENCLAW.value in manifest.targets:
|
| 252 |
+
try:
|
| 253 |
+
mutations.append(_apply_openclaw_provider_scope(manifest))
|
| 254 |
+
except click.ClickException:
|
| 255 |
+
pass
|
| 256 |
+
return mutations
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def revert_mutations(manifest: DeploymentManifest) -> None:
|
| 260 |
+
"""Undo the stored mutations for a deployment."""
|
| 261 |
+
|
| 262 |
+
if manifest.scope in {ConfigScope.USER.value, ConfigScope.SYSTEM.value}:
|
| 263 |
+
shell_mutations = [m for m in manifest.mutations if m.target == "env"]
|
| 264 |
+
if os.name == "nt":
|
| 265 |
+
_remove_windows_env_scope(shell_mutations)
|
| 266 |
+
else:
|
| 267 |
+
_remove_unix_env_scope(shell_mutations)
|
| 268 |
+
|
| 269 |
+
for mutation in manifest.mutations:
|
| 270 |
+
if mutation.target == ToolTarget.CLAUDE.value and mutation.kind == "json-env":
|
| 271 |
+
_revert_claude_provider_scope(
|
| 272 |
+
mutation, manifest.tool_envs.get(ToolTarget.CLAUDE.value, {})
|
| 273 |
+
)
|
| 274 |
+
elif mutation.target == ToolTarget.CODEX.value and mutation.kind == "toml-block":
|
| 275 |
+
_revert_codex_provider_scope(mutation)
|
| 276 |
+
elif mutation.target == ToolTarget.OPENCLAW.value and mutation.kind == "openclaw-wrap":
|
| 277 |
+
_revert_openclaw_provider_scope()
|
headroom/install/runtime.py
ADDED
|
@@ -0,0 +1,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 |
+
|
| 19 |
+
def _deployment_env(manifest: DeploymentManifest) -> dict[str, str]:
|
| 20 |
+
return {
|
| 21 |
+
"HEADROOM_DEPLOYMENT_PROFILE": manifest.profile,
|
| 22 |
+
"HEADROOM_DEPLOYMENT_PRESET": manifest.preset,
|
| 23 |
+
"HEADROOM_DEPLOYMENT_RUNTIME": manifest.runtime_kind,
|
| 24 |
+
"HEADROOM_DEPLOYMENT_SUPERVISOR": manifest.supervisor_kind,
|
| 25 |
+
"HEADROOM_DEPLOYMENT_SCOPE": manifest.scope,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def resolve_headroom_command() -> list[str]:
|
| 30 |
+
"""Resolve the most reliable command to invoke headroom."""
|
| 31 |
+
|
| 32 |
+
headroom_bin = shutil.which("headroom")
|
| 33 |
+
if headroom_bin:
|
| 34 |
+
return [headroom_bin]
|
| 35 |
+
return [sys.executable, "-m", "headroom.cli"]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _runtime_env(manifest: DeploymentManifest) -> dict[str, str]:
|
| 39 |
+
env = os.environ.copy()
|
| 40 |
+
env.update(manifest.base_env)
|
| 41 |
+
env.update(_deployment_env(manifest))
|
| 42 |
+
return env
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def build_runtime_command(manifest: DeploymentManifest) -> list[str]:
|
| 46 |
+
"""Build the raw foreground command that runs the proxy."""
|
| 47 |
+
|
| 48 |
+
if manifest.runtime_kind == RuntimeKind.PYTHON.value:
|
| 49 |
+
return [sys.executable, "-m", "headroom.cli", "proxy", *manifest.proxy_args]
|
| 50 |
+
|
| 51 |
+
home = str(Path.home())
|
| 52 |
+
container_home = "/tmp/headroom-home"
|
| 53 |
+
command = [
|
| 54 |
+
"docker",
|
| 55 |
+
"run",
|
| 56 |
+
"--rm",
|
| 57 |
+
"--name",
|
| 58 |
+
manifest.container_name,
|
| 59 |
+
"-p",
|
| 60 |
+
f"127.0.0.1:{manifest.port}:{manifest.port}",
|
| 61 |
+
"--workdir",
|
| 62 |
+
container_home,
|
| 63 |
+
"--env",
|
| 64 |
+
f"HOME={container_home}",
|
| 65 |
+
"--env",
|
| 66 |
+
"PYTHONUNBUFFERED=1",
|
| 67 |
+
"--volume",
|
| 68 |
+
f"{home}\\.headroom:{container_home}/.headroom"
|
| 69 |
+
if os.name == "nt"
|
| 70 |
+
else f"{home}/.headroom:{container_home}/.headroom",
|
| 71 |
+
"--volume",
|
| 72 |
+
f"{home}\\.claude:{container_home}/.claude"
|
| 73 |
+
if os.name == "nt"
|
| 74 |
+
else f"{home}/.claude:{container_home}/.claude",
|
| 75 |
+
"--volume",
|
| 76 |
+
f"{home}\\.codex:{container_home}/.codex"
|
| 77 |
+
if os.name == "nt"
|
| 78 |
+
else f"{home}/.codex:{container_home}/.codex",
|
| 79 |
+
"--volume",
|
| 80 |
+
f"{home}\\.gemini:{container_home}/.gemini"
|
| 81 |
+
if os.name == "nt"
|
| 82 |
+
else f"{home}/.gemini:{container_home}/.gemini",
|
| 83 |
+
]
|
| 84 |
+
runtime_env = {**manifest.base_env, **_deployment_env(manifest)}
|
| 85 |
+
for name, value in runtime_env.items():
|
| 86 |
+
command.extend(["--env", f"{name}={value}"])
|
| 87 |
+
command.extend(
|
| 88 |
+
[
|
| 89 |
+
manifest.image,
|
| 90 |
+
"headroom",
|
| 91 |
+
"proxy",
|
| 92 |
+
"--host",
|
| 93 |
+
"0.0.0.0",
|
| 94 |
+
*manifest.proxy_args[2:],
|
| 95 |
+
]
|
| 96 |
+
)
|
| 97 |
+
return command
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _write_pid(profile: str, pid: int) -> None:
|
| 101 |
+
path = pid_path(profile)
|
| 102 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 103 |
+
path.write_text(str(pid))
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _read_pid(profile: str) -> int | None:
|
| 107 |
+
path = pid_path(profile)
|
| 108 |
+
if not path.exists():
|
| 109 |
+
return None
|
| 110 |
+
try:
|
| 111 |
+
return int(path.read_text().strip())
|
| 112 |
+
except ValueError:
|
| 113 |
+
return None
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _clear_pid(profile: str) -> None:
|
| 117 |
+
path = pid_path(profile)
|
| 118 |
+
if path.exists():
|
| 119 |
+
path.unlink()
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def run_foreground(manifest: DeploymentManifest) -> int:
|
| 123 |
+
"""Run the raw runtime command in the foreground."""
|
| 124 |
+
|
| 125 |
+
command = build_runtime_command(manifest)
|
| 126 |
+
env = _runtime_env(manifest)
|
| 127 |
+
log_file_path = log_path(manifest.profile)
|
| 128 |
+
log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
| 129 |
+
|
| 130 |
+
with open(log_file_path, "a", encoding="utf-8", errors="replace") as log_file:
|
| 131 |
+
proc = subprocess.Popen(command, env=env, stdout=log_file, stderr=log_file)
|
| 132 |
+
_write_pid(manifest.profile, proc.pid)
|
| 133 |
+
|
| 134 |
+
def _cleanup(signum: int | None = None, frame: Any = None) -> None:
|
| 135 |
+
if proc.poll() is None:
|
| 136 |
+
proc.terminate()
|
| 137 |
+
try:
|
| 138 |
+
proc.wait(timeout=10)
|
| 139 |
+
except subprocess.TimeoutExpired:
|
| 140 |
+
proc.kill()
|
| 141 |
+
|
| 142 |
+
signal.signal(signal.SIGINT, _cleanup)
|
| 143 |
+
signal.signal(signal.SIGTERM, _cleanup)
|
| 144 |
+
try:
|
| 145 |
+
return proc.wait()
|
| 146 |
+
finally:
|
| 147 |
+
_clear_pid(manifest.profile)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def start_detached_agent(profile: str) -> subprocess.Popen[str]:
|
| 151 |
+
"""Start `headroom install agent run` detached for the given profile."""
|
| 152 |
+
|
| 153 |
+
command = [*resolve_headroom_command(), "install", "agent", "run", "--profile", profile]
|
| 154 |
+
log_file_path = log_path(profile)
|
| 155 |
+
log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
| 156 |
+
log_file = open(log_file_path, "a", encoding="utf-8", errors="replace") # noqa: SIM115
|
| 157 |
+
|
| 158 |
+
kwargs: dict[str, Any] = {"stdout": log_file, "stderr": log_file}
|
| 159 |
+
if os.name == "nt":
|
| 160 |
+
kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
|
| 161 |
+
subprocess, "CREATE_NEW_PROCESS_GROUP", 0
|
| 162 |
+
)
|
| 163 |
+
else:
|
| 164 |
+
kwargs["start_new_session"] = True
|
| 165 |
+
return subprocess.Popen(command, **kwargs)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def start_persistent_docker(manifest: DeploymentManifest) -> None:
|
| 169 |
+
"""Start a persistent Docker container with restart policy."""
|
| 170 |
+
|
| 171 |
+
command = build_runtime_command(manifest)
|
| 172 |
+
docker_cmd = [
|
| 173 |
+
"docker",
|
| 174 |
+
"run",
|
| 175 |
+
"-d",
|
| 176 |
+
"--restart",
|
| 177 |
+
"unless-stopped",
|
| 178 |
+
"--name",
|
| 179 |
+
manifest.container_name,
|
| 180 |
+
*command[5:], # drop initial `docker run --rm --name ...`
|
| 181 |
+
]
|
| 182 |
+
subprocess.run(["docker", "rm", "-f", manifest.container_name], capture_output=True, text=True)
|
| 183 |
+
subprocess.run(docker_cmd, check=True)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def stop_runtime(manifest: DeploymentManifest) -> None:
|
| 187 |
+
"""Stop the raw runtime for the deployment."""
|
| 188 |
+
|
| 189 |
+
if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
|
| 190 |
+
subprocess.run(["docker", "stop", manifest.container_name], capture_output=True, text=True)
|
| 191 |
+
subprocess.run(
|
| 192 |
+
["docker", "rm", "-f", manifest.container_name], capture_output=True, text=True
|
| 193 |
+
)
|
| 194 |
+
return
|
| 195 |
+
|
| 196 |
+
pid = _read_pid(manifest.profile)
|
| 197 |
+
if pid is None:
|
| 198 |
+
return
|
| 199 |
+
try:
|
| 200 |
+
os.kill(pid, signal.SIGTERM)
|
| 201 |
+
except OSError:
|
| 202 |
+
pass
|
| 203 |
+
_clear_pid(manifest.profile)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def wait_ready(manifest: DeploymentManifest, timeout_seconds: int = 30) -> bool:
|
| 207 |
+
"""Wait for the deployment to report ready."""
|
| 208 |
+
|
| 209 |
+
for _ in range(timeout_seconds):
|
| 210 |
+
if probe_ready(manifest.health_url):
|
| 211 |
+
return True
|
| 212 |
+
time.sleep(1)
|
| 213 |
+
return False
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def runtime_status(manifest: DeploymentManifest) -> str:
|
| 217 |
+
"""Return a short status string for the deployment runtime."""
|
| 218 |
+
|
| 219 |
+
if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
|
| 220 |
+
result = subprocess.run(
|
| 221 |
+
["docker", "ps", "--format", "{{.Names}}"], capture_output=True, text=True
|
| 222 |
+
)
|
| 223 |
+
if manifest.container_name in result.stdout.splitlines():
|
| 224 |
+
return "running"
|
| 225 |
+
return "stopped"
|
| 226 |
+
pid = _read_pid(manifest.profile)
|
| 227 |
+
if pid is None:
|
| 228 |
+
return "stopped"
|
| 229 |
+
try:
|
| 230 |
+
os.kill(pid, 0)
|
| 231 |
+
except OSError:
|
| 232 |
+
return "stopped"
|
| 233 |
+
return "running"
|
headroom/install/state.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persistence helpers for deployment manifests."""
|
| 2 |
+
|
| 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
|
| 9 |
+
from .paths import deploy_root, manifest_path, profile_root
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def save_manifest(manifest: DeploymentManifest) -> None:
|
| 13 |
+
"""Persist a deployment manifest to disk."""
|
| 14 |
+
|
| 15 |
+
root = profile_root(manifest.profile)
|
| 16 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 17 |
+
manifest.updated_at = iso_utc_now()
|
| 18 |
+
path = manifest_path(manifest.profile)
|
| 19 |
+
path.write_text(json.dumps(asdict(manifest), indent=2) + "\n")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def load_manifest(profile: str = "default") -> DeploymentManifest | None:
|
| 23 |
+
"""Load a deployment manifest when present."""
|
| 24 |
+
|
| 25 |
+
path = manifest_path(profile)
|
| 26 |
+
if not path.exists():
|
| 27 |
+
return None
|
| 28 |
+
payload = json.loads(path.read_text())
|
| 29 |
+
payload["mutations"] = [ManagedMutation(**item) for item in payload.get("mutations", [])]
|
| 30 |
+
payload["artifacts"] = [ArtifactRecord(**item) for item in payload.get("artifacts", [])]
|
| 31 |
+
return DeploymentManifest(**payload)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def list_manifests() -> list[DeploymentManifest]:
|
| 35 |
+
"""Load all deployment manifests under the deployment root."""
|
| 36 |
+
|
| 37 |
+
root = deploy_root()
|
| 38 |
+
if not root.exists():
|
| 39 |
+
return []
|
| 40 |
+
|
| 41 |
+
manifests: list[DeploymentManifest] = []
|
| 42 |
+
for candidate in sorted(root.glob("*/manifest.json")):
|
| 43 |
+
try:
|
| 44 |
+
payload = json.loads(candidate.read_text())
|
| 45 |
+
payload["mutations"] = [
|
| 46 |
+
ManagedMutation(**item) for item in payload.get("mutations", [])
|
| 47 |
+
]
|
| 48 |
+
payload["artifacts"] = [ArtifactRecord(**item) for item in payload.get("artifacts", [])]
|
| 49 |
+
manifests.append(DeploymentManifest(**payload))
|
| 50 |
+
except (OSError, ValueError, TypeError):
|
| 51 |
+
continue
|
| 52 |
+
return manifests
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def delete_manifest(profile: str = "default") -> None:
|
| 56 |
+
"""Delete the deployment manifest if present."""
|
| 57 |
+
|
| 58 |
+
path = manifest_path(profile)
|
| 59 |
+
if path.exists():
|
| 60 |
+
path.unlink()
|
headroom/install/supervisors.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Supervisor installation helpers for persistent deployments."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
import shlex
|
| 8 |
+
import subprocess
|
| 9 |
+
import sys
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import click
|
| 13 |
+
|
| 14 |
+
from .models import ArtifactRecord, DeploymentManifest, SupervisorKind
|
| 15 |
+
from .paths import (
|
| 16 |
+
unix_ensure_script_path,
|
| 17 |
+
unix_run_script_path,
|
| 18 |
+
windows_ensure_cmd_path,
|
| 19 |
+
windows_ensure_script_path,
|
| 20 |
+
windows_run_cmd_path,
|
| 21 |
+
windows_run_script_path,
|
| 22 |
+
)
|
| 23 |
+
from .runtime import resolve_headroom_command
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _command_for_script(*parts: str) -> list[str]:
|
| 27 |
+
return [*resolve_headroom_command(), *parts]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _render_unix_runner(path: Path, command: list[str]) -> ArtifactRecord:
|
| 31 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 32 |
+
path.write_text(
|
| 33 |
+
"#!/usr/bin/env bash\nset -euo pipefail\nexec "
|
| 34 |
+
+ " ".join(shlex.quote(x) for x in command)
|
| 35 |
+
+ "\n"
|
| 36 |
+
)
|
| 37 |
+
path.chmod(0o755)
|
| 38 |
+
return ArtifactRecord(kind="script", path=str(path))
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _render_windows_runner(
|
| 42 |
+
ps1_path: Path, cmd_path: Path, command: list[str]
|
| 43 |
+
) -> list[ArtifactRecord]:
|
| 44 |
+
ps1_path.parent.mkdir(parents=True, exist_ok=True)
|
| 45 |
+
escaped = " ".join(
|
| 46 |
+
[f'"{item}"' if (" " in item or item.endswith(".cmd")) else item for item in command]
|
| 47 |
+
)
|
| 48 |
+
ps1_path.write_text(f"$ErrorActionPreference = 'Stop'\n& {escaped}\nexit $LASTEXITCODE\n")
|
| 49 |
+
cmd_path.write_text(
|
| 50 |
+
'@echo off\r\npowershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0'
|
| 51 |
+
+ ps1_path.name
|
| 52 |
+
+ '" %*\r\n'
|
| 53 |
+
)
|
| 54 |
+
return [
|
| 55 |
+
ArtifactRecord(kind="script", path=str(ps1_path)),
|
| 56 |
+
ArtifactRecord(kind="script", path=str(cmd_path)),
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def render_runner_scripts(manifest: DeploymentManifest) -> list[ArtifactRecord]:
|
| 61 |
+
"""Render runner/watchdog scripts for the deployment profile."""
|
| 62 |
+
|
| 63 |
+
if os.name == "nt":
|
| 64 |
+
records = []
|
| 65 |
+
records.extend(
|
| 66 |
+
_render_windows_runner(
|
| 67 |
+
windows_run_script_path(manifest.profile),
|
| 68 |
+
windows_run_cmd_path(manifest.profile),
|
| 69 |
+
_command_for_script("install", "agent", "run", "--profile", manifest.profile),
|
| 70 |
+
)
|
| 71 |
+
)
|
| 72 |
+
records.extend(
|
| 73 |
+
_render_windows_runner(
|
| 74 |
+
windows_ensure_script_path(manifest.profile),
|
| 75 |
+
windows_ensure_cmd_path(manifest.profile),
|
| 76 |
+
_command_for_script("install", "agent", "ensure", "--profile", manifest.profile),
|
| 77 |
+
)
|
| 78 |
+
)
|
| 79 |
+
return records
|
| 80 |
+
|
| 81 |
+
return [
|
| 82 |
+
_render_unix_runner(
|
| 83 |
+
unix_run_script_path(manifest.profile),
|
| 84 |
+
_command_for_script("install", "agent", "run", "--profile", manifest.profile),
|
| 85 |
+
),
|
| 86 |
+
_render_unix_runner(
|
| 87 |
+
unix_ensure_script_path(manifest.profile),
|
| 88 |
+
_command_for_script("install", "agent", "ensure", "--profile", manifest.profile),
|
| 89 |
+
),
|
| 90 |
+
]
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _linux_service_unit(manifest: DeploymentManifest, run_script: Path) -> tuple[Path, str]:
|
| 94 |
+
if manifest.scope == "system":
|
| 95 |
+
unit_path = Path("/etc/systemd/system") / f"{manifest.service_name}.service"
|
| 96 |
+
else:
|
| 97 |
+
unit_path = (
|
| 98 |
+
Path.home() / ".config" / "systemd" / "user" / f"{manifest.service_name}.service"
|
| 99 |
+
)
|
| 100 |
+
content = f"""[Unit]
|
| 101 |
+
Description=Headroom ({manifest.profile})
|
| 102 |
+
After=network-online.target
|
| 103 |
+
|
| 104 |
+
[Service]
|
| 105 |
+
Type=simple
|
| 106 |
+
ExecStart={run_script}
|
| 107 |
+
Restart=on-failure
|
| 108 |
+
RestartSec=5
|
| 109 |
+
|
| 110 |
+
[Install]
|
| 111 |
+
WantedBy=default.target
|
| 112 |
+
"""
|
| 113 |
+
return unit_path, content
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _macos_launchd_plist(
|
| 117 |
+
manifest: DeploymentManifest, command_path: Path, *, interval: int | None = None
|
| 118 |
+
) -> tuple[Path, str]:
|
| 119 |
+
if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
|
| 120 |
+
base_dir = (
|
| 121 |
+
Path("/Library/LaunchDaemons")
|
| 122 |
+
if manifest.scope == "system"
|
| 123 |
+
else Path.home() / "Library" / "LaunchAgents"
|
| 124 |
+
)
|
| 125 |
+
else:
|
| 126 |
+
base_dir = Path.home() / "Library" / "LaunchAgents"
|
| 127 |
+
plist_path = base_dir / f"com.headroom.{manifest.profile}.plist"
|
| 128 |
+
program = str(command_path)
|
| 129 |
+
keys = [
|
| 130 |
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
| 131 |
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
| 132 |
+
'<plist version="1.0">',
|
| 133 |
+
"<dict>",
|
| 134 |
+
" <key>Label</key>",
|
| 135 |
+
f" <string>com.headroom.{manifest.profile}</string>",
|
| 136 |
+
" <key>ProgramArguments</key>",
|
| 137 |
+
" <array>",
|
| 138 |
+
f" <string>{program}</string>",
|
| 139 |
+
" </array>",
|
| 140 |
+
" <key>RunAtLoad</key>",
|
| 141 |
+
" <true/>",
|
| 142 |
+
]
|
| 143 |
+
if interval is not None:
|
| 144 |
+
keys.extend([" <key>StartInterval</key>", f" <integer>{interval}</integer>"])
|
| 145 |
+
else:
|
| 146 |
+
keys.extend([" <key>KeepAlive</key>", " <true/>"])
|
| 147 |
+
keys.extend(["</dict>", "</plist>"])
|
| 148 |
+
return plist_path, "\n".join(keys) + "\n"
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _linux_task_spec(manifest: DeploymentManifest, ensure_script: Path) -> tuple[Path | None, str]:
|
| 152 |
+
if manifest.scope == "system":
|
| 153 |
+
cron_path = Path("/etc/cron.d") / manifest.service_name
|
| 154 |
+
content = f"@reboot root {ensure_script}\n*/5 * * * * root {ensure_script}\n"
|
| 155 |
+
return cron_path, content
|
| 156 |
+
|
| 157 |
+
marker_start = f"# >>> headroom {manifest.profile} >>>"
|
| 158 |
+
marker_end = f"# <<< headroom {manifest.profile} <<<"
|
| 159 |
+
content = (
|
| 160 |
+
f"{marker_start}\n@reboot {ensure_script}\n*/5 * * * * {ensure_script}\n{marker_end}\n"
|
| 161 |
+
)
|
| 162 |
+
return None, content
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def install_supervisor(manifest: DeploymentManifest) -> list[ArtifactRecord]:
|
| 166 |
+
"""Install service/task artifacts for the deployment."""
|
| 167 |
+
|
| 168 |
+
records = render_runner_scripts(manifest)
|
| 169 |
+
artifact_paths = {Path(item.path).name: Path(item.path) for item in records}
|
| 170 |
+
|
| 171 |
+
if manifest.supervisor_kind == SupervisorKind.NONE.value:
|
| 172 |
+
return records
|
| 173 |
+
|
| 174 |
+
if (
|
| 175 |
+
sys.platform.startswith("linux")
|
| 176 |
+
and manifest.supervisor_kind == SupervisorKind.SERVICE.value
|
| 177 |
+
):
|
| 178 |
+
unit_path, content = _linux_service_unit(manifest, artifact_paths["run-headroom.sh"])
|
| 179 |
+
unit_path.parent.mkdir(parents=True, exist_ok=True)
|
| 180 |
+
unit_path.write_text(content)
|
| 181 |
+
flags = [] if manifest.scope == "system" else ["--user"]
|
| 182 |
+
subprocess.run(["systemctl", *flags, "daemon-reload"], check=True)
|
| 183 |
+
subprocess.run(["systemctl", *flags, "enable", manifest.service_name], check=True)
|
| 184 |
+
records.append(ArtifactRecord(kind="service-unit", path=str(unit_path)))
|
| 185 |
+
return records
|
| 186 |
+
|
| 187 |
+
if sys.platform.startswith("linux") and manifest.supervisor_kind == SupervisorKind.TASK.value:
|
| 188 |
+
cron_path, content = _linux_task_spec(manifest, artifact_paths["ensure-headroom.sh"])
|
| 189 |
+
if cron_path is not None:
|
| 190 |
+
cron_path.parent.mkdir(parents=True, exist_ok=True)
|
| 191 |
+
cron_path.write_text(content)
|
| 192 |
+
records.append(ArtifactRecord(kind="cron", path=str(cron_path)))
|
| 193 |
+
else:
|
| 194 |
+
current = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
|
| 195 |
+
existing = current.stdout if current.returncode == 0 else ""
|
| 196 |
+
marker_start = f"# >>> headroom {manifest.profile} >>>"
|
| 197 |
+
marker_end = f"# <<< headroom {manifest.profile} <<<"
|
| 198 |
+
pattern = re.compile(
|
| 199 |
+
re.escape(marker_start) + r".*?" + re.escape(marker_end), re.DOTALL
|
| 200 |
+
)
|
| 201 |
+
merged = pattern.sub("", existing).strip()
|
| 202 |
+
new_content = (merged + "\n\n" + content).strip() + "\n"
|
| 203 |
+
subprocess.run(["crontab", "-"], input=new_content, text=True, check=True)
|
| 204 |
+
records.append(ArtifactRecord(kind="crontab", path=f"user:{manifest.profile}"))
|
| 205 |
+
return records
|
| 206 |
+
|
| 207 |
+
if sys.platform == "darwin":
|
| 208 |
+
if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
|
| 209 |
+
plist_path, content = _macos_launchd_plist(manifest, artifact_paths["run-headroom.sh"])
|
| 210 |
+
else:
|
| 211 |
+
plist_path, content = _macos_launchd_plist(
|
| 212 |
+
manifest, artifact_paths["ensure-headroom.sh"], interval=300
|
| 213 |
+
)
|
| 214 |
+
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
| 215 |
+
plist_path.write_text(content)
|
| 216 |
+
domain = (
|
| 217 |
+
f"system/{plist_path.stem}"
|
| 218 |
+
if manifest.scope == "system"
|
| 219 |
+
and manifest.supervisor_kind == SupervisorKind.SERVICE.value
|
| 220 |
+
else f"gui/{os.getuid()}/{plist_path.stem}"
|
| 221 |
+
)
|
| 222 |
+
subprocess.run(["launchctl", "bootout", domain], capture_output=True, text=True)
|
| 223 |
+
bootstrap_domain = (
|
| 224 |
+
"system"
|
| 225 |
+
if manifest.scope == "system"
|
| 226 |
+
and manifest.supervisor_kind == SupervisorKind.SERVICE.value
|
| 227 |
+
else f"gui/{os.getuid()}"
|
| 228 |
+
)
|
| 229 |
+
subprocess.run(["launchctl", "bootstrap", bootstrap_domain, str(plist_path)], check=True)
|
| 230 |
+
records.append(ArtifactRecord(kind="plist", path=str(plist_path)))
|
| 231 |
+
return records
|
| 232 |
+
|
| 233 |
+
if os.name == "nt" and manifest.supervisor_kind == SupervisorKind.SERVICE.value:
|
| 234 |
+
service_bin = f'cmd.exe /c "{windows_run_cmd_path(manifest.profile)}"'
|
| 235 |
+
subprocess.run(
|
| 236 |
+
["sc.exe", "create", manifest.service_name, f"binPath= {service_bin}", "start= auto"],
|
| 237 |
+
check=True,
|
| 238 |
+
)
|
| 239 |
+
subprocess.run(
|
| 240 |
+
["sc.exe", "failure", manifest.service_name, "reset= 0", "actions= restart/5000"],
|
| 241 |
+
check=True,
|
| 242 |
+
)
|
| 243 |
+
records.append(ArtifactRecord(kind="windows-service", path=manifest.service_name))
|
| 244 |
+
return records
|
| 245 |
+
|
| 246 |
+
if os.name == "nt" and manifest.supervisor_kind == SupervisorKind.TASK.value:
|
| 247 |
+
startup_name = f"{manifest.service_name}-startup"
|
| 248 |
+
health_name = f"{manifest.service_name}-health"
|
| 249 |
+
startup_cmd = str(windows_ensure_cmd_path(manifest.profile))
|
| 250 |
+
user_args = ["/RU", "SYSTEM"] if manifest.scope == "system" else []
|
| 251 |
+
start_schedule = [
|
| 252 |
+
"schtasks",
|
| 253 |
+
"/Create",
|
| 254 |
+
"/TN",
|
| 255 |
+
startup_name,
|
| 256 |
+
"/TR",
|
| 257 |
+
startup_cmd,
|
| 258 |
+
"/SC",
|
| 259 |
+
"ONSTART",
|
| 260 |
+
"/F",
|
| 261 |
+
*user_args,
|
| 262 |
+
]
|
| 263 |
+
health_schedule = [
|
| 264 |
+
"schtasks",
|
| 265 |
+
"/Create",
|
| 266 |
+
"/TN",
|
| 267 |
+
health_name,
|
| 268 |
+
"/TR",
|
| 269 |
+
startup_cmd,
|
| 270 |
+
"/SC",
|
| 271 |
+
"MINUTE",
|
| 272 |
+
"/MO",
|
| 273 |
+
"5",
|
| 274 |
+
"/F",
|
| 275 |
+
*user_args,
|
| 276 |
+
]
|
| 277 |
+
subprocess.run(start_schedule, check=True)
|
| 278 |
+
subprocess.run(health_schedule, check=True)
|
| 279 |
+
records.extend(
|
| 280 |
+
[
|
| 281 |
+
ArtifactRecord(kind="windows-task", path=startup_name),
|
| 282 |
+
ArtifactRecord(kind="windows-task", path=health_name),
|
| 283 |
+
]
|
| 284 |
+
)
|
| 285 |
+
return records
|
| 286 |
+
|
| 287 |
+
raise click.ClickException(
|
| 288 |
+
f"Persistent {manifest.supervisor_kind} mode is not supported on this platform."
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def start_supervisor(manifest: DeploymentManifest) -> None:
|
| 293 |
+
"""Start the installed supervisor or runtime for a deployment."""
|
| 294 |
+
|
| 295 |
+
if manifest.supervisor_kind == SupervisorKind.NONE.value:
|
| 296 |
+
return
|
| 297 |
+
if sys.platform.startswith("linux"):
|
| 298 |
+
flags = [] if manifest.scope == "system" else ["--user"]
|
| 299 |
+
subprocess.run(["systemctl", *flags, "restart", manifest.service_name], check=True)
|
| 300 |
+
return
|
| 301 |
+
if sys.platform == "darwin":
|
| 302 |
+
label = f"com.headroom.{manifest.profile}"
|
| 303 |
+
domain = (
|
| 304 |
+
"system"
|
| 305 |
+
if manifest.scope == "system"
|
| 306 |
+
and manifest.supervisor_kind == SupervisorKind.SERVICE.value
|
| 307 |
+
else f"gui/{os.getuid()}"
|
| 308 |
+
)
|
| 309 |
+
subprocess.run(["launchctl", "kickstart", "-k", f"{domain}/{label}"], check=True)
|
| 310 |
+
return
|
| 311 |
+
if os.name == "nt" and manifest.supervisor_kind == SupervisorKind.SERVICE.value:
|
| 312 |
+
subprocess.run(["sc.exe", "start", manifest.service_name], check=True)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def stop_supervisor(manifest: DeploymentManifest) -> None:
|
| 316 |
+
"""Stop the installed supervisor for a deployment."""
|
| 317 |
+
|
| 318 |
+
if manifest.supervisor_kind == SupervisorKind.NONE.value:
|
| 319 |
+
return
|
| 320 |
+
if sys.platform.startswith("linux"):
|
| 321 |
+
flags = [] if manifest.scope == "system" else ["--user"]
|
| 322 |
+
subprocess.run(["systemctl", *flags, "stop", manifest.service_name], check=True)
|
| 323 |
+
return
|
| 324 |
+
if sys.platform == "darwin":
|
| 325 |
+
label = f"com.headroom.{manifest.profile}"
|
| 326 |
+
domain = (
|
| 327 |
+
"system"
|
| 328 |
+
if manifest.scope == "system"
|
| 329 |
+
and manifest.supervisor_kind == SupervisorKind.SERVICE.value
|
| 330 |
+
else f"gui/{os.getuid()}"
|
| 331 |
+
)
|
| 332 |
+
subprocess.run(["launchctl", "bootout", f"{domain}/{label}"], check=True)
|
| 333 |
+
return
|
| 334 |
+
if os.name == "nt" and manifest.supervisor_kind == SupervisorKind.SERVICE.value:
|
| 335 |
+
subprocess.run(["sc.exe", "stop", manifest.service_name], check=True)
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def remove_supervisor(manifest: DeploymentManifest) -> None:
|
| 339 |
+
"""Remove installed service/task artifacts."""
|
| 340 |
+
|
| 341 |
+
if manifest.supervisor_kind == SupervisorKind.NONE.value:
|
| 342 |
+
return
|
| 343 |
+
|
| 344 |
+
if sys.platform.startswith("linux"):
|
| 345 |
+
if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
|
| 346 |
+
flags = [] if manifest.scope == "system" else ["--user"]
|
| 347 |
+
subprocess.run(
|
| 348 |
+
["systemctl", *flags, "disable", "--now", manifest.service_name],
|
| 349 |
+
capture_output=True,
|
| 350 |
+
text=True,
|
| 351 |
+
)
|
| 352 |
+
unit_path, _ = _linux_service_unit(manifest, unix_run_script_path(manifest.profile))
|
| 353 |
+
if unit_path.exists():
|
| 354 |
+
unit_path.unlink()
|
| 355 |
+
subprocess.run(["systemctl", *flags, "daemon-reload"], capture_output=True, text=True)
|
| 356 |
+
return
|
| 357 |
+
cron_path, _ = _linux_task_spec(manifest, unix_ensure_script_path(manifest.profile))
|
| 358 |
+
if cron_path and cron_path.exists():
|
| 359 |
+
cron_path.unlink()
|
| 360 |
+
return
|
| 361 |
+
current = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
|
| 362 |
+
if current.returncode != 0:
|
| 363 |
+
return
|
| 364 |
+
marker_start = f"# >>> headroom {manifest.profile} >>>"
|
| 365 |
+
marker_end = f"# <<< headroom {manifest.profile} <<<"
|
| 366 |
+
pattern = re.compile(re.escape(marker_start) + r".*?" + re.escape(marker_end), re.DOTALL)
|
| 367 |
+
content = pattern.sub("", current.stdout).strip()
|
| 368 |
+
subprocess.run(
|
| 369 |
+
["crontab", "-"], input=(content + "\n") if content else "", text=True, check=True
|
| 370 |
+
)
|
| 371 |
+
return
|
| 372 |
+
|
| 373 |
+
if sys.platform == "darwin":
|
| 374 |
+
plist_path, _ = _macos_launchd_plist(
|
| 375 |
+
manifest,
|
| 376 |
+
unix_run_script_path(manifest.profile)
|
| 377 |
+
if manifest.supervisor_kind == SupervisorKind.SERVICE.value
|
| 378 |
+
else unix_ensure_script_path(manifest.profile),
|
| 379 |
+
interval=300 if manifest.supervisor_kind == SupervisorKind.TASK.value else None,
|
| 380 |
+
)
|
| 381 |
+
label = f"com.headroom.{manifest.profile}"
|
| 382 |
+
domain = (
|
| 383 |
+
"system"
|
| 384 |
+
if manifest.scope == "system"
|
| 385 |
+
and manifest.supervisor_kind == SupervisorKind.SERVICE.value
|
| 386 |
+
else f"gui/{os.getuid()}"
|
| 387 |
+
)
|
| 388 |
+
subprocess.run(
|
| 389 |
+
["launchctl", "bootout", f"{domain}/{label}"], capture_output=True, text=True
|
| 390 |
+
)
|
| 391 |
+
if plist_path.exists():
|
| 392 |
+
plist_path.unlink()
|
| 393 |
+
return
|
| 394 |
+
|
| 395 |
+
if os.name == "nt":
|
| 396 |
+
if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
|
| 397 |
+
subprocess.run(
|
| 398 |
+
["sc.exe", "stop", manifest.service_name], capture_output=True, text=True
|
| 399 |
+
)
|
| 400 |
+
subprocess.run(
|
| 401 |
+
["sc.exe", "delete", manifest.service_name], capture_output=True, text=True
|
| 402 |
+
)
|
| 403 |
+
return
|
| 404 |
+
subprocess.run(
|
| 405 |
+
["schtasks", "/Delete", "/TN", f"{manifest.service_name}-startup", "/F"],
|
| 406 |
+
capture_output=True,
|
| 407 |
+
text=True,
|
| 408 |
+
)
|
| 409 |
+
subprocess.run(
|
| 410 |
+
["schtasks", "/Delete", "/TN", f"{manifest.service_name}-health", "/F"],
|
| 411 |
+
capture_output=True,
|
| 412 |
+
text=True,
|
| 413 |
+
)
|
headroom/proxy/server.py
CHANGED
|
@@ -1093,6 +1093,15 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
|
|
| 1093 |
"uptime_seconds": _uptime_seconds(),
|
| 1094 |
"checks": checks,
|
| 1095 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1096 |
if include_config:
|
| 1097 |
payload["config"] = {
|
| 1098 |
"backend": config.backend,
|
|
|
|
| 1093 |
"uptime_seconds": _uptime_seconds(),
|
| 1094 |
"checks": checks,
|
| 1095 |
}
|
| 1096 |
+
deployment_profile = os.environ.get("HEADROOM_DEPLOYMENT_PROFILE")
|
| 1097 |
+
if deployment_profile:
|
| 1098 |
+
payload["deployment"] = {
|
| 1099 |
+
"profile": deployment_profile,
|
| 1100 |
+
"preset": os.environ.get("HEADROOM_DEPLOYMENT_PRESET"),
|
| 1101 |
+
"runtime": os.environ.get("HEADROOM_DEPLOYMENT_RUNTIME"),
|
| 1102 |
+
"supervisor": os.environ.get("HEADROOM_DEPLOYMENT_SUPERVISOR"),
|
| 1103 |
+
"scope": os.environ.get("HEADROOM_DEPLOYMENT_SCOPE"),
|
| 1104 |
+
}
|
| 1105 |
if include_config:
|
| 1106 |
payload["config"] = {
|
| 1107 |
"backend": config.backend,
|
mkdocs.yml
CHANGED
|
@@ -87,6 +87,7 @@ nav:
|
|
| 87 |
- Quickstart: quickstart.md
|
| 88 |
- Installation: getting-started.md
|
| 89 |
- Docker-Native Install: docker-install.md
|
|
|
|
| 90 |
- Configuration: configuration.md
|
| 91 |
- User Guide:
|
| 92 |
- Proxy Server: proxy.md
|
|
|
|
| 87 |
- Quickstart: quickstart.md
|
| 88 |
- Installation: getting-started.md
|
| 89 |
- Docker-Native Install: docker-install.md
|
| 90 |
+
- Persistent Installs: persistent-installs.md
|
| 91 |
- Configuration: configuration.md
|
| 92 |
- User Guide:
|
| 93 |
- Proxy Server: proxy.md
|
tests/test_cli/test_install_cli.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from click.testing import CliRunner
|
| 4 |
+
|
| 5 |
+
from headroom.cli.main import main
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_install_apply_starts_service_supervisor(monkeypatch) -> None:
|
| 9 |
+
runner = CliRunner()
|
| 10 |
+
calls: list[str] = []
|
| 11 |
+
|
| 12 |
+
class Manifest:
|
| 13 |
+
profile = "default"
|
| 14 |
+
preset = "persistent-service"
|
| 15 |
+
runtime_kind = "python"
|
| 16 |
+
supervisor_kind = "service"
|
| 17 |
+
scope = "user"
|
| 18 |
+
health_url = "http://127.0.0.1:8787/readyz"
|
| 19 |
+
targets = ["claude", "codex"]
|
| 20 |
+
mutations = []
|
| 21 |
+
artifacts = []
|
| 22 |
+
|
| 23 |
+
manifest = Manifest()
|
| 24 |
+
|
| 25 |
+
monkeypatch.setattr("headroom.cli.install.build_manifest", lambda **_: manifest)
|
| 26 |
+
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: None)
|
| 27 |
+
monkeypatch.setattr("headroom.cli.install.apply_mutations", lambda deployment: [])
|
| 28 |
+
monkeypatch.setattr("headroom.cli.install.install_supervisor", lambda deployment: [])
|
| 29 |
+
monkeypatch.setattr(
|
| 30 |
+
"headroom.cli.install.save_manifest", lambda deployment: calls.append("save")
|
| 31 |
+
)
|
| 32 |
+
monkeypatch.setattr(
|
| 33 |
+
"headroom.cli.install.start_supervisor", lambda deployment: calls.append("start_service")
|
| 34 |
+
)
|
| 35 |
+
monkeypatch.setattr(
|
| 36 |
+
"headroom.cli.install.start_detached_agent", lambda profile: calls.append("start_agent")
|
| 37 |
+
)
|
| 38 |
+
monkeypatch.setattr(
|
| 39 |
+
"headroom.cli.install.start_persistent_docker",
|
| 40 |
+
lambda deployment: calls.append("start_docker"),
|
| 41 |
+
)
|
| 42 |
+
monkeypatch.setattr(
|
| 43 |
+
"headroom.cli.install.wait_ready", lambda deployment, timeout_seconds=45: True
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
result = runner.invoke(main, ["install", "apply"])
|
| 47 |
+
|
| 48 |
+
assert result.exit_code == 0, result.output
|
| 49 |
+
assert "Installed persistent deployment 'default'" in result.output
|
| 50 |
+
assert "Targets: claude, codex" in result.output
|
| 51 |
+
assert calls == ["save", "start_service"]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_install_status_includes_backend_from_health_probe(monkeypatch) -> None:
|
| 55 |
+
runner = CliRunner()
|
| 56 |
+
|
| 57 |
+
class Manifest:
|
| 58 |
+
profile = "default"
|
| 59 |
+
preset = "persistent-service"
|
| 60 |
+
runtime_kind = "python"
|
| 61 |
+
supervisor_kind = "service"
|
| 62 |
+
scope = "user"
|
| 63 |
+
port = 8787
|
| 64 |
+
backend = "anthropic"
|
| 65 |
+
health_url = "http://127.0.0.1:8787/readyz"
|
| 66 |
+
|
| 67 |
+
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
|
| 68 |
+
monkeypatch.setattr("headroom.cli.install.runtime_status", lambda manifest: "running")
|
| 69 |
+
monkeypatch.setattr("headroom.cli.install.probe_ready", lambda url: True)
|
| 70 |
+
monkeypatch.setattr(
|
| 71 |
+
"headroom.cli.install.probe_json",
|
| 72 |
+
lambda url: {"config": {"backend": "anthropic"}},
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
result = runner.invoke(main, ["install", "status"])
|
| 76 |
+
|
| 77 |
+
assert result.exit_code == 0, result.output
|
| 78 |
+
assert "Status: running" in result.output
|
| 79 |
+
assert "Healthy: yes" in result.output
|
| 80 |
+
assert "Backend: anthropic" in result.output
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_install_restart_uses_internal_helpers(monkeypatch) -> None:
|
| 84 |
+
runner = CliRunner()
|
| 85 |
+
calls: list[str] = []
|
| 86 |
+
|
| 87 |
+
class Manifest:
|
| 88 |
+
profile = "default"
|
| 89 |
+
preset = "persistent-service"
|
| 90 |
+
runtime_kind = "python"
|
| 91 |
+
supervisor_kind = "service"
|
| 92 |
+
scope = "user"
|
| 93 |
+
health_url = "http://127.0.0.1:8787/readyz"
|
| 94 |
+
|
| 95 |
+
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
|
| 96 |
+
monkeypatch.setattr(
|
| 97 |
+
"headroom.cli.install.stop_supervisor", lambda manifest: calls.append("stop_supervisor")
|
| 98 |
+
)
|
| 99 |
+
monkeypatch.setattr(
|
| 100 |
+
"headroom.cli.install.stop_runtime", lambda manifest: calls.append("stop_runtime")
|
| 101 |
+
)
|
| 102 |
+
monkeypatch.setattr(
|
| 103 |
+
"headroom.cli.install.start_supervisor", lambda manifest: calls.append("start_supervisor")
|
| 104 |
+
)
|
| 105 |
+
monkeypatch.setattr(
|
| 106 |
+
"headroom.cli.install.wait_ready", lambda manifest, timeout_seconds=45: True
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
result = runner.invoke(main, ["install", "restart"])
|
| 110 |
+
|
| 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"]
|
tests/test_cli/test_wrap_persistent.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from headroom.cli.wrap import _ensure_proxy
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class _Manifest:
|
| 7 |
+
profile = "default"
|
| 8 |
+
preset = "persistent-service"
|
| 9 |
+
supervisor_kind = "service"
|
| 10 |
+
health_url = "http://127.0.0.1:8787/readyz"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_ensure_proxy_recovers_matching_persistent_deployment(monkeypatch) -> None:
|
| 14 |
+
calls: list[str] = []
|
| 15 |
+
|
| 16 |
+
monkeypatch.setattr("headroom.cli.wrap._check_proxy", lambda port: False)
|
| 17 |
+
monkeypatch.setattr("headroom.cli.wrap._find_persistent_manifest", lambda port: _Manifest())
|
| 18 |
+
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
| 19 |
+
monkeypatch.setattr(
|
| 20 |
+
"headroom.install.supervisors.start_supervisor",
|
| 21 |
+
lambda manifest: calls.append(f"start:{manifest.profile}"),
|
| 22 |
+
)
|
| 23 |
+
monkeypatch.setattr(
|
| 24 |
+
"headroom.install.runtime.wait_ready", lambda manifest, timeout_seconds=45: True
|
| 25 |
+
)
|
| 26 |
+
monkeypatch.setattr(
|
| 27 |
+
"headroom.cli.wrap._start_proxy",
|
| 28 |
+
lambda *args, **kwargs: (_ for _ in ()).throw(
|
| 29 |
+
AssertionError("ephemeral proxy should not start")
|
| 30 |
+
),
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
result = _ensure_proxy(8787, False)
|
| 34 |
+
|
| 35 |
+
assert result is None
|
| 36 |
+
assert calls == ["start:default"]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_ensure_proxy_recovers_persistent_deployment_when_socket_is_bound(monkeypatch) -> None:
|
| 40 |
+
calls: list[str] = []
|
| 41 |
+
|
| 42 |
+
monkeypatch.setattr("headroom.cli.wrap._check_proxy", lambda port: True)
|
| 43 |
+
monkeypatch.setattr("headroom.cli.wrap._find_persistent_manifest", lambda port: _Manifest())
|
| 44 |
+
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
| 45 |
+
monkeypatch.setattr(
|
| 46 |
+
"headroom.install.supervisors.start_supervisor",
|
| 47 |
+
lambda manifest: calls.append(f"start:{manifest.profile}"),
|
| 48 |
+
)
|
| 49 |
+
monkeypatch.setattr(
|
| 50 |
+
"headroom.install.runtime.wait_ready", lambda manifest, timeout_seconds=45: True
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
result = _ensure_proxy(8787, False)
|
| 54 |
+
|
| 55 |
+
assert result is None
|
| 56 |
+
assert calls == ["start:default"]
|
tests/test_install/test_planner.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from headroom.install.models import InstallPreset, ProviderSelectionMode, ToolTarget
|
| 4 |
+
from headroom.install.planner import build_manifest, resolve_targets
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_resolve_targets_auto_falls_back_when_detection_empty(monkeypatch) -> None:
|
| 8 |
+
monkeypatch.setattr("headroom.install.planner.detect_targets", lambda: [])
|
| 9 |
+
|
| 10 |
+
targets = resolve_targets(ProviderSelectionMode.AUTO.value, [])
|
| 11 |
+
|
| 12 |
+
assert targets == [
|
| 13 |
+
ToolTarget.CLAUDE.value,
|
| 14 |
+
ToolTarget.CODEX.value,
|
| 15 |
+
ToolTarget.COPILOT.value,
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_build_manifest_for_persistent_docker_sets_expected_defaults() -> None:
|
| 20 |
+
manifest = build_manifest(
|
| 21 |
+
profile="default",
|
| 22 |
+
preset=InstallPreset.PERSISTENT_DOCKER.value,
|
| 23 |
+
runtime_kind="docker",
|
| 24 |
+
scope="user",
|
| 25 |
+
provider_mode="manual",
|
| 26 |
+
targets=["claude", "copilot"],
|
| 27 |
+
port=8787,
|
| 28 |
+
backend="anthropic",
|
| 29 |
+
anyllm_provider=None,
|
| 30 |
+
region=None,
|
| 31 |
+
proxy_mode="token",
|
| 32 |
+
memory_enabled=True,
|
| 33 |
+
telemetry_enabled=False,
|
| 34 |
+
image="ghcr.io/chopratejas/headroom:latest",
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
assert manifest.supervisor_kind == "none"
|
| 38 |
+
assert manifest.runtime_kind == "docker"
|
| 39 |
+
assert manifest.health_url == "http://127.0.0.1:8787/readyz"
|
| 40 |
+
assert manifest.base_env["HEADROOM_PORT"] == "8787"
|
| 41 |
+
assert manifest.base_env["HEADROOM_TELEMETRY"] == "off"
|
| 42 |
+
assert manifest.tool_envs["claude"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
|
| 43 |
+
assert manifest.tool_envs["copilot"]["COPILOT_PROVIDER_TYPE"] == "anthropic"
|
| 44 |
+
assert "--memory" in manifest.proxy_args
|
tests/test_install/test_providers.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
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 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _manifest(tmp_path: Path) -> DeploymentManifest:
|
| 16 |
+
return DeploymentManifest(
|
| 17 |
+
profile="default",
|
| 18 |
+
preset="persistent-service",
|
| 19 |
+
runtime_kind="python",
|
| 20 |
+
supervisor_kind="service",
|
| 21 |
+
scope="provider",
|
| 22 |
+
provider_mode="manual",
|
| 23 |
+
targets=["claude", "codex"],
|
| 24 |
+
port=8787,
|
| 25 |
+
host="127.0.0.1",
|
| 26 |
+
backend="anthropic",
|
| 27 |
+
memory_db_path=str(tmp_path / "memory.db"),
|
| 28 |
+
tool_envs={
|
| 29 |
+
"claude": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"},
|
| 30 |
+
"codex": {"OPENAI_BASE_URL": "http://127.0.0.1:8787/v1"},
|
| 31 |
+
},
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_apply_and_revert_claude_provider_scope(monkeypatch, tmp_path: Path) -> None:
|
| 36 |
+
settings_path = tmp_path / "settings.json"
|
| 37 |
+
settings_path.write_text(
|
| 38 |
+
json.dumps({"env": {"ANTHROPIC_API_KEY": "keep", "ANTHROPIC_BASE_URL": "https://old"}})
|
| 39 |
+
)
|
| 40 |
+
monkeypatch.setattr("headroom.install.providers.claude_settings_path", lambda: settings_path)
|
| 41 |
+
manifest = _manifest(tmp_path)
|
| 42 |
+
|
| 43 |
+
mutation = _apply_claude_provider_scope(manifest)
|
| 44 |
+
payload = json.loads(settings_path.read_text())
|
| 45 |
+
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
|
| 46 |
+
assert payload["env"]["ANTHROPIC_API_KEY"] == "keep"
|
| 47 |
+
|
| 48 |
+
_revert_claude_provider_scope(mutation, manifest.tool_envs["claude"])
|
| 49 |
+
reverted = json.loads(settings_path.read_text())
|
| 50 |
+
assert reverted["env"]["ANTHROPIC_BASE_URL"] == "https://old"
|
| 51 |
+
assert reverted["env"]["ANTHROPIC_API_KEY"] == "keep"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_apply_and_revert_codex_provider_scope(monkeypatch, tmp_path: Path) -> None:
|
| 55 |
+
config_path = tmp_path / "config.toml"
|
| 56 |
+
config_path.write_text('model = "gpt-4o"\n')
|
| 57 |
+
monkeypatch.setattr("headroom.install.providers.codex_config_path", lambda: config_path)
|
| 58 |
+
manifest = _manifest(tmp_path)
|
| 59 |
+
|
| 60 |
+
mutation = _apply_codex_provider_scope(manifest)
|
| 61 |
+
content = config_path.read_text()
|
| 62 |
+
assert 'model_provider = "headroom"' in content
|
| 63 |
+
assert 'base_url = "http://127.0.0.1:8787/v1"' in content
|
| 64 |
+
|
| 65 |
+
_revert_codex_provider_scope(mutation)
|
| 66 |
+
reverted = config_path.read_text()
|
| 67 |
+
assert 'model_provider = "headroom"' not in reverted
|
| 68 |
+
assert reverted.strip() == 'model = "gpt-4o"'
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_apply_openclaw_provider_scope_uses_manifest_port(monkeypatch, tmp_path: Path) -> None:
|
| 72 |
+
recorded: list[list[str]] = []
|
| 73 |
+
monkeypatch.setattr("headroom.install.providers.shutil_which", lambda name: "openclaw")
|
| 74 |
+
monkeypatch.setattr(
|
| 75 |
+
"headroom.install.providers.resolve_headroom_command",
|
| 76 |
+
lambda: ["headroom"],
|
| 77 |
+
)
|
| 78 |
+
monkeypatch.setattr(
|
| 79 |
+
"headroom.install.providers._invoke_openclaw",
|
| 80 |
+
lambda command: recorded.append(command),
|
| 81 |
+
)
|
| 82 |
+
monkeypatch.setattr(
|
| 83 |
+
"headroom.install.providers.openclaw_config_path",
|
| 84 |
+
lambda: tmp_path / "openclaw.json",
|
| 85 |
+
)
|
| 86 |
+
manifest = _manifest(tmp_path)
|
| 87 |
+
manifest.port = 9999
|
| 88 |
+
|
| 89 |
+
from headroom.install.providers import _apply_openclaw_provider_scope
|
| 90 |
+
|
| 91 |
+
_apply_openclaw_provider_scope(manifest)
|
| 92 |
+
|
| 93 |
+
assert recorded == [["headroom", "wrap", "openclaw", "--no-auto-start", "--proxy-port", "9999"]]
|
tests/test_install/test_runtime.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from headroom.install.models import DeploymentManifest
|
| 6 |
+
from headroom.install.runtime import build_runtime_command
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def test_build_runtime_command_for_docker_includes_deployment_env(
|
| 10 |
+
monkeypatch, tmp_path: Path
|
| 11 |
+
) -> None:
|
| 12 |
+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
| 13 |
+
manifest = DeploymentManifest(
|
| 14 |
+
profile="default",
|
| 15 |
+
preset="persistent-docker",
|
| 16 |
+
runtime_kind="docker",
|
| 17 |
+
supervisor_kind="none",
|
| 18 |
+
scope="user",
|
| 19 |
+
provider_mode="manual",
|
| 20 |
+
targets=["claude"],
|
| 21 |
+
port=8787,
|
| 22 |
+
host="127.0.0.1",
|
| 23 |
+
backend="anthropic",
|
| 24 |
+
image="ghcr.io/chopratejas/headroom:latest",
|
| 25 |
+
base_env={"HEADROOM_PORT": "8787"},
|
| 26 |
+
proxy_args=["--host", "127.0.0.1", "--port", "8787"],
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
command = build_runtime_command(manifest)
|
| 30 |
+
|
| 31 |
+
joined = " ".join(command)
|
| 32 |
+
assert command[:3] == ["docker", "run", "--rm"]
|
| 33 |
+
assert "HEADROOM_DEPLOYMENT_PROFILE=default" in joined
|
| 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
|
tests/test_proxy_healthchecks.py
CHANGED
|
@@ -64,6 +64,34 @@ def test_health_preserves_backwards_compatible_config_payload(client):
|
|
| 64 |
}
|
| 65 |
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
def test_health_remains_200_when_proxy_is_not_ready(client):
|
| 68 |
client.app.state.ready = False
|
| 69 |
|
|
|
|
| 64 |
}
|
| 65 |
|
| 66 |
|
| 67 |
+
def test_health_includes_deployment_metadata_when_present(monkeypatch):
|
| 68 |
+
monkeypatch.setenv("HEADROOM_DEPLOYMENT_PROFILE", "default")
|
| 69 |
+
monkeypatch.setenv("HEADROOM_DEPLOYMENT_PRESET", "persistent-service")
|
| 70 |
+
monkeypatch.setenv("HEADROOM_DEPLOYMENT_RUNTIME", "python")
|
| 71 |
+
monkeypatch.setenv("HEADROOM_DEPLOYMENT_SUPERVISOR", "service")
|
| 72 |
+
monkeypatch.setenv("HEADROOM_DEPLOYMENT_SCOPE", "user")
|
| 73 |
+
|
| 74 |
+
config = ProxyConfig(
|
| 75 |
+
optimize=False,
|
| 76 |
+
cache_enabled=False,
|
| 77 |
+
rate_limit_enabled=False,
|
| 78 |
+
cost_tracking_enabled=False,
|
| 79 |
+
)
|
| 80 |
+
app = create_app(config)
|
| 81 |
+
|
| 82 |
+
with TestClient(app) as client:
|
| 83 |
+
response = client.get("/health")
|
| 84 |
+
|
| 85 |
+
assert response.status_code == 200
|
| 86 |
+
assert response.json()["deployment"] == {
|
| 87 |
+
"profile": "default",
|
| 88 |
+
"preset": "persistent-service",
|
| 89 |
+
"runtime": "python",
|
| 90 |
+
"supervisor": "service",
|
| 91 |
+
"scope": "user",
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
def test_health_remains_200_when_proxy_is_not_ready(client):
|
| 96 |
client.app.state.ready = False
|
| 97 |
|