Merge pull request #145 from JerrettDavis/feat/persistent-installs

feat: add persistent install lifecycle management
This commit is contained in:
Tejas Chopra 2026-04-11 18:30:44 -07:00 committed by GitHub
commit 65ee4b1124
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 6117 additions and 105 deletions

View file

@ -101,6 +101,91 @@ jobs:
run: |
pytest tests/test_integrations/agno/ -v
docker-native-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Build local Headroom image
run: |
docker build -t headroom-native-e2e:latest .
- name: Run Docker-native installer e2e
env:
HEADROOM_DOCKER_IMAGE: headroom-native-e2e:latest
run: |
bash e2e/docker-native-install.sh
- name: Run Docker-native compose smoke test
env:
HEADROOM_IMAGE: headroom-native-e2e:latest
HEADROOM_HOST_HOME: ${{ github.workspace }}
HEADROOM_WORKSPACE: ${{ github.workspace }}
run: |
mkdir -p .headroom .claude .codex .gemini
trap 'docker compose -f docker/docker-compose.native.yml down -v' EXIT
docker compose -f docker/docker-compose.native.yml up -d proxy
for attempt in $(seq 1 30); do
if curl --fail --silent http://127.0.0.1:8787/readyz >/dev/null; then
break
fi
if [ "$attempt" -eq 30 ]; then
docker compose -f docker/docker-compose.native.yml logs proxy
exit 1
fi
sleep 1
done
- name: Run Docker-native wrap e2e
run: |
docker build -f e2e/wrap/Dockerfile -t headroom-wrap-e2e .
docker run --rm headroom-wrap-e2e
windows-native-wrapper:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install test dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
- name: Run native installer wrapper tests
run: |
pytest tests/test_install/test_native_installers.py -q
macos-native-wrapper:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install bash and test dependencies
run: |
brew install bash
python -m pip install --upgrade pip
pip install pytest
- name: Run native installer wrapper tests
run: |
export PATH="$(brew --prefix bash)/bin:$PATH"
pytest tests/test_install/test_native_installers.py -q
build:
runs-on: ubuntu-latest
steps:

View file

@ -87,11 +87,23 @@ npm install headroom-ai
curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.sh | bash
```
macOS uses Bash 4.3+, so run the installer with a newer Bash such as Homebrew's `bash`.
PowerShell:
```powershell
irm https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.ps1 | iex
```
**Persistent local runtime (Python-native service/task flow):**
```bash
headroom install apply --preset persistent-service --providers auto
```
**Persistent local runtime (Docker-native wrapper / compose flow):**
```bash
headroom install apply --preset persistent-docker
```
### Any agent — one function
**Python:**
@ -137,14 +149,14 @@ Use `cache` mode for long-running chats where preserving prior-turn bytes improv
Works with any language, any tool, any framework. **[Proxy docs](docs/proxy.md)**
Prefer Docker as the runtime provider? See **[Docker-native install](docs/docker-install.md)**.
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)**.
### Coding agents — one command
```bash
headroom wrap claude # Starts proxy + launches Claude Code
headroom wrap copilot -- --model claude-sonnet-4-20250514
# Starts proxy + launches GitHub Copilot CLI
# Starts proxy + launches GitHub Copilot CLI
headroom wrap codex # Starts proxy + launches OpenAI Codex CLI
headroom wrap aider # Starts proxy + launches Aider
headroom wrap cursor # Starts proxy + prints Cursor config
@ -154,7 +166,7 @@ headroom wrap codex --memory # Shares the same memory store
headroom wrap claude --code-graph # With code graph intelligence (codebase-memory-mcp)
```
Headroom starts a proxy, points your tool at it, and compresses everything automatically. Add `--memory` for persistent memory that's shared across agents. Add `--code-graph` for code intelligence via [codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp) — indexes your codebase into a knowledge graph for call-chain traversal, impact analysis, and architectural queries.
Headroom starts a proxy, points your tool at it, and compresses everything automatically. Add `--memory` for persistent memory that's shared across agents. Add `--code-graph` for code intelligence via [codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp) — indexes your codebase into a knowledge graph for call-chain traversal, impact analysis, and architectural queries. `wrap copilot` is part of the Python-native CLI; the Docker-native wrapper currently supports `claude`, `codex`, `aider`, `cursor`, and `openclaw`.
In Docker-native mode, Headroom still runs in Docker while wrapped tools run on the host. `wrap claude`, `wrap codex`, `wrap aider`, `wrap cursor`, and OpenClaw plugin setup (`wrap openclaw` / `unwrap openclaw`) are host-managed through the installed wrapper.
@ -197,6 +209,7 @@ Gives your AI tool three MCP tools: `headroom_compress`, `headroom_retrieve`, `h
| **Claude Code** | Wrap | `headroom wrap claude` |
| **GitHub Copilot CLI** | Wrap | `headroom wrap copilot -- --model claude-sonnet-4-20250514` |
| **Codex / Aider** | Wrap | `headroom wrap codex` or `headroom wrap aider` |
| **Always-on local proxy** | Persistent install | `headroom install apply --preset persistent-service --providers auto` |
**[Full Integration Guide](docs/integration-guide.md)** | **[TypeScript SDK](docs/typescript-sdk.md)**
@ -513,6 +526,9 @@ Python 3.10+
| [MCP](docs/mcp.md) | Context engineering toolkit (compress, retrieve, stats) |
| [SharedContext](docs/shared-context.md) | Compressed inter-agent context sharing |
| [Learn](docs/learn.md) | Plugin-based failure learning (Claude, Codex, Gemini, extensible) |
| [CLI Reference](docs/cli.md) | Complete command surface, help output, and Docker parity matrix |
| [Docker-Native Install](docs/docker-install.md) | Host wrapper install, compose support, and Docker runtime behavior |
| [Persistent Installs](docs/persistent-installs.md) | Service/task/docker deployment models and provider scopes |
| [Configuration](docs/configuration.md) | All options |
---

View file

@ -19,6 +19,7 @@ services:
image: ${HEADROOM_IMAGE:-ghcr.io/chopratejas/headroom:latest}
entrypoint: ["headroom", "proxy"]
working_dir: /workspace
restart: unless-stopped
environment:
HOME: /tmp/headroom-home
HEADROOM_HOST: 0.0.0.0

View file

@ -22,6 +22,7 @@ This page is the authoritative reference for the **Python Headroom CLI** exposed
| Command | Purpose | Docker-native parity |
|---|---|---|
| `headroom install ...` | Install and manage persistent deployments | **python-native; Docker-native wrapper supports `persistent-docker` lifecycle subset** |
| `headroom proxy` | Run the Headroom proxy server | **native in container** |
| `headroom learn` | Learn from past tool-call failures | **native in container** |
| `headroom perf` | Summarize recent proxy performance | **native in container** |
@ -29,6 +30,7 @@ This page is the authoritative reference for the **Python Headroom CLI** exposed
| `headroom memory ...` | Inspect and manage stored memories | **native in container** |
| `headroom mcp ...` | Install, inspect, remove, or serve MCP integration | **native in container** |
| `headroom wrap claude` | Start proxy and launch Claude Code | **host-bridged** |
| `headroom wrap copilot` | Start proxy and launch GitHub Copilot CLI | **python-native only** |
| `headroom wrap codex` | Start proxy and launch Codex CLI | **host-bridged** |
| `headroom wrap aider` | Start proxy and launch Aider | **host-bridged** |
| `headroom wrap cursor` | Start proxy and print Cursor config guidance | **host-bridged** |
@ -59,6 +61,7 @@ Options:
Commands:
evals Memory evaluation commands.
install Install and manage persistent Headroom deployments.
learn Learn from past tool call failures to prevent future ones.
mcp MCP server for Claude Code integration.
memory Manage memories stored in Headroom.
@ -168,6 +171,28 @@ Commands:
</details>
<details>
<summary><code>headroom install --help</code></summary>
```text
Usage: headroom install [OPTIONS] COMMAND [ARGS]...
Install and manage persistent Headroom deployments.
Options:
-?, --help Show this message and exit.
Commands:
apply Install a persistent Headroom deployment.
remove Remove a persistent deployment and undo managed config.
restart Restart a persistent deployment.
start Start a persistent deployment.
status Show persistent deployment status.
stop Stop a persistent deployment.
```
</details>
<details>
<summary><code>headroom wrap --help</code></summary>
@ -179,6 +204,7 @@ Usage: headroom wrap [OPTIONS] COMMAND [ARGS]...
Commands:
aider Launch aider through Headroom proxy.
claude Launch Claude Code through Headroom proxy.
copilot Launch GitHub Copilot CLI through Headroom proxy.
codex Launch OpenAI Codex CLI through Headroom proxy.
cursor Start Headroom proxy for use with Cursor.
openclaw Install and configure Headroom OpenClaw plugin in one command.
@ -536,6 +562,120 @@ headroom mcp serve --proxy-url http://127.0.0.1:9000 --debug
See also: [MCP Tools](mcp.md)
## `headroom install`
Install and manage persistent local Headroom deployments.
### `headroom install apply --help`
```text
Usage: headroom install apply [OPTIONS]
Install a persistent Headroom deployment.
Options:
--preset [persistent-service|persistent-task|persistent-docker]
Persistent runtime preset to install.
[default: persistent-service]
--runtime [python|docker] Runtime used to execute Headroom for
service/task modes. [default: python]
--scope [provider|user|system] Where to apply persistent configuration.
[default: user]
--providers [auto|all|manual] Target selection mode for direct tool
configuration. [default: auto]
--target [claude|copilot|codex|aider|cursor|openclaw]
Tool target to configure when --providers
manual is used.
--profile TEXT Deployment profile name. [default: default]
-p, --port INTEGER Persistent proxy port. [default: 8787]
--backend TEXT Proxy backend for the persistent runtime.
[default: anthropic]
--anyllm-provider TEXT Provider for any-llm backends when --backend
anyllm is used.
--region TEXT Cloud region for Bedrock / Vertex style
backends.
--mode TEXT Proxy optimization mode. [default: token]
--memory Enable persistent memory in the proxy runtime.
--no-telemetry Disable anonymous telemetry in the runtime.
--image TEXT Docker image to use when runtime=docker or
preset=persistent-docker. [default:
ghcr.io/chopratejas/headroom:latest]
-?, --help Show this message and exit.
```
### `headroom install apply`
```bash
headroom install apply --preset persistent-service --providers auto
headroom install apply --preset persistent-task --providers manual --target claude --target codex
headroom install apply --preset persistent-docker --scope user
```
| Option | Default | Meaning |
|---|---|---|
| `--preset` | `persistent-service` | Lifecycle preset: `persistent-service`, `persistent-task`, or `persistent-docker` |
| `--runtime` | `python` | Runtime used for service/task installs: `python` or `docker` |
| `--scope` | `user` | Config scope: `provider`, `user`, or `system` |
| `--providers` | `auto` | Target selection mode: `auto`, `all`, or `manual` |
| `--target` | repeatable | Tool target used with `--providers manual` |
| `--profile` | `default` | Deployment profile name |
| `--port`, `-p` | `8787` | Persistent proxy port |
| `--backend` | `anthropic` | Backend for the managed runtime |
| `--anyllm-provider` | unset | Provider name used with `--backend anyllm` |
| `--region` | unset | Cloud region override |
| `--mode` | `token` | Proxy optimization mode |
| `--memory` | off | Enable persistent memory in the managed runtime |
| `--no-telemetry` | off | Disable anonymous telemetry |
| `--image` | `ghcr.io/chopratejas/headroom:latest` | Docker image for Docker-backed installs |
`apply` stores a manifest under `~/.headroom/deploy/<profile>/manifest.json`, applies managed tool configuration, starts the chosen runtime, and waits for `readyz`.
Docker-native host wrappers expose a narrower `headroom install` subset for `persistent-docker` only: `apply`, `status`, `start`, `stop`, `restart`, and `remove`. Those wrapper flows preserve the same port and manifest behavior, but they intentionally reject `persistent-service`, `persistent-task`, and provider mutation flags like `--scope`, `--providers`, and `--target`.
### `headroom install status`
```bash
headroom install status
headroom install status --profile default
```
Shows the stored profile, preset, runtime, supervisor kind, scope, port, runtime status, readiness, and backend from `/health`.
### `headroom install start`
```bash
headroom install start
headroom install start --profile default
```
Starts a previously installed deployment profile without reapplying mutations.
### `headroom install stop`
```bash
headroom install stop
```
Stops the managed runtime for an installed deployment profile.
### `headroom install restart`
```bash
headroom install restart
```
Stops and starts the selected deployment profile.
### `headroom install remove`
```bash
headroom install remove
```
Stops the runtime, removes installed supervisor artifacts, reverts managed configuration changes, and deletes the stored manifest.
See also: [Persistent Installs](persistent-installs.md)
## `headroom wrap`
Wrap external coding tools so their traffic flows through Headroom.
@ -589,6 +729,29 @@ headroom wrap codex --backend anyllm --anyllm-provider groq
Requires the `codex` binary on the host.
### `headroom wrap copilot`
```bash
headroom wrap copilot -- --model claude-sonnet-4-20250514
headroom wrap copilot --backend anyllm --anyllm-provider groq -- --model gpt-4o
```
| Option / arg | Default | Meaning |
|---|---|---|
| `--port`, `-p` | `8787` | Proxy port |
| `--no-rtk` | off | Skip `rtk` installation and GitHub Copilot instructions injection |
| `--no-proxy` | off | Reuse an existing proxy |
| `--learn` | off | Enable live traffic learning |
| `--backend` | unset | Proxy backend override |
| `--anyllm-provider` | unset | `anyllm` provider override |
| `--region` | unset | Cloud region override |
| `--provider-type` | `auto` | Force Copilot BYOK provider type (`anthropic` or `openai`) |
| `--wire-api` | unset | OpenAI wire API override for OpenAI-style backends |
| `--verbose`, `-v` | off | Verbose output |
| `copilot_args...` | passthrough | Additional Copilot CLI arguments |
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.
### `headroom wrap aider`
```bash
@ -691,14 +854,16 @@ Legend:
| `headroom mcp uninstall` | native | native in container | full |
| `headroom mcp status` | native | native in container | full |
| `headroom mcp serve` | native | native in container | full |
| `headroom install apply|status|start|stop|restart|remove` | native | Docker-native wrapper for `persistent-docker`; compose remains an alternative | partial |
| `headroom wrap claude` | native | host-bridged | partial |
| `headroom wrap copilot` | native | not implemented in Docker-native wrapper | none |
| `headroom wrap codex` | native | host-bridged | partial |
| `headroom wrap aider` | native | host-bridged | partial |
| `headroom wrap cursor` | native | host-bridged | partial |
| `headroom wrap openclaw` | native | host-bridged | partial |
| `headroom unwrap openclaw` | native | host-bridged | partial |
For the Docker-native execution model itself, see [Docker-Native Install](docker-install.md).
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).
## Hidden and compatibility-only command paths

View file

@ -4,12 +4,20 @@ Run Headroom without installing Python or Node.js on the host. The install scrip
## One-line install
### macOS / Linux
### Linux
```bash
curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.sh | bash
```
### macOS (bash 4.3+)
```bash
curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.sh | "$(brew --prefix bash)/bin/bash"
```
Stock `/bin/bash` on macOS is 3.2, so install a newer bash first (for example via Homebrew) and run the installer with that shell. The installed wrapper pins that same bash interpreter so later invocations stay on the supported runtime.
### Windows PowerShell
```powershell
@ -19,7 +27,7 @@ irm https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.
## What the installer does
1. Verifies Docker is installed and available.
2. Pulls `ghcr.io/chopratejas/headroom:latest`.
2. Pulls `ghcr.io/chopratejas/headroom:latest` by default, or reuses / pulls `HEADROOM_DOCKER_IMAGE` when you set a custom image override.
3. Installs a `headroom` wrapper into `~/.local/bin` or `~/bin`.
4. Updates shell startup files so the wrapper directory is on `PATH`.
@ -81,9 +89,48 @@ OpenClaw remains host-native in Docker-native mode:
- plugin auto-start still launches the installed host `headroom` wrapper from `PATH`, which then runs Headroom in Docker
- local plugin source mode (`--plugin-path`) is also supported, but it may require host `npm` when build steps are needed
## Persistent Docker lifecycle from the native wrapper
The Docker-native `headroom` wrapper now exposes the persistent Docker lifecycle directly:
```bash
headroom install apply --profile default --preset persistent-docker
headroom install status
headroom install restart
headroom install remove
```
In Docker-native mode this surface is intentionally scoped to **persistent-docker**:
- supported: `apply`, `status`, `start`, `stop`, `restart`, `remove`
- supported flags: `--profile`, `--port`, `--backend`, `--anyllm-provider`, `--region`, `--mode`, `--memory`, `--no-telemetry`, `--image`
- not supported: `persistent-service`, `persistent-task`, or provider/user/system mutation flags such as `--scope`, `--providers`, and `--target`
Those broader lifecycle and config-mutation flows still belong to the Python-native `headroom install ...` command.
Persistent Docker deployments launched by the wrapper also tag the proxy process with deployment metadata, so `/health` reports the active `profile`, `preset`, `runtime`, `supervisor`, and `scope` the same way the Python install subsystem does.
## Docker Compose support
Use `docker/docker-compose.native.yml` when you want an explicit compose-managed proxy or CLI shell.
Use `docker/docker-compose.native.yml` when you want an explicit compose-managed proxy or CLI shell, or when you prefer compose over the native wrapper's `headroom install ...` surface.
### Persistent Docker runtime
The `proxy` service now uses `restart: unless-stopped`, so compose can act as the always-on Docker runtime for Headroom:
```bash
export HEADROOM_HOST_HOME="$HOME"
export HEADROOM_WORKSPACE="$PWD"
docker compose -f docker/docker-compose.native.yml up -d proxy
```
```powershell
$env:HEADROOM_HOST_HOME = $HOME
$env:HEADROOM_WORKSPACE = (Get-Location).Path
docker compose -f docker/docker-compose.native.yml up -d proxy
```
This remains a supported persistent-Docker path when you want the proxy managed explicitly through Compose instead of the installed wrapper.
### macOS / Linux
@ -128,3 +175,5 @@ That keeps provider auth and runtime config working without maintaining a separa
- Docker is the only required Headroom runtime dependency on the host.
- Wrapped tools like Claude Code, Codex CLI, Aider, and Cursor still run on the host when you use `headroom wrap ...`.
- The install scripts are idempotent: rerunning them refreshes the wrapper and image without duplicating shell profile blocks.
- For persistent service and task installs, use the Python-native `headroom install ...` workflow described in [Persistent Installs](persistent-installs.md).
- For Docker-native `headroom install ...`, the wrapper persists its profile manifest under `~/.headroom/deploy/<profile>/`.

View file

@ -40,6 +40,12 @@ irm https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.
See [Docker-native install](docker-install.md) for wrapper behavior, compose usage, and host-integrated `wrap` flows.
If you want Headroom to stay up in the background and automatically serve supported tools, use [Persistent Installs](persistent-installs.md):
```bash
headroom install apply --preset persistent-service --providers auto
```
## Quick Start: Proxy Mode (Recommended)
The easiest way to use Headroom is as a proxy server:

View file

@ -76,6 +76,8 @@ Headroom works as a **transparent proxy** (zero code changes), a **Python functi
That's it. Your existing code works unchanged, with 40-90% fewer tokens.
Want an always-on local runtime instead? See [Persistent Installs &rarr;](persistent-installs.md).
=== "Python SDK"
```python
@ -104,6 +106,8 @@ Headroom works as a **transparent proxy** (zero code changes), a **Python functi
Starts the proxy, points your tool at it, compresses everything automatically.
If you prefer an always-on proxy that `wrap` can reuse or recover, see [Persistent Installs &rarr;](persistent-installs.md).
=== "TypeScript SDK"
```typescript

154
docs/persistent-installs.md Normal file
View file

@ -0,0 +1,154 @@
# Persistent Installs
Headroom can now be installed as a durable local runtime instead of only being started ad hoc with `headroom proxy` or `headroom wrap ...`.
Use the Python-native `headroom install` CLI when you want supported tools to keep talking to an always-on proxy at `http://127.0.0.1:8787` and have `wrap` reuse or recover that deployment instead of starting a second ephemeral proxy.
## Runtime matrix
| Mode | What stays running | Primary entrypoint |
|---|---|---|
| Persistent Service | Native background service | `headroom install apply --preset persistent-service` |
| Persistent Task | Scheduled watchdog + on-demand runner | `headroom install apply --preset persistent-task` |
| Persistent Docker | Restartable Docker container | `headroom install apply --preset persistent-docker` |
| On-Demand CLI (Python) | Nothing after command exits | `headroom proxy` |
| On-Demand CLI (Docker) | Nothing after container exits | Docker-native wrapper / compose CLI |
| Wrapped (Python) | Proxy lasts for wrapped session | `headroom wrap ...` |
| Wrapped (Docker) | Containerized proxy + host tool session | Docker-native wrapper |
## Quick examples
### Persistent service on the local machine
```bash
headroom install apply --preset persistent-service --providers auto
headroom install status
```
This installs a background service on the current machine, applies persistent tool wiring, and keeps the proxy healthy on port `8787`.
### Persistent watchdog task
```bash
headroom install apply --preset persistent-task --providers manual --target claude --target codex
```
This installs a scheduled recovery path instead of a traditional always-running service.
### Persistent Docker
```bash
headroom install apply --preset persistent-docker --scope user --providers auto
```
This uses Docker's restart policy instead of an OS supervisor.
If you are using the Docker-native host wrapper instead of a Python install, you can now use `headroom install apply|status|start|stop|restart|remove` for the `persistent-docker` preset directly from the installed wrapper. Service/task installs and provider/user/system mutation flows still belong to the Python-native CLI.
## Command surface
```text
headroom install apply
headroom install status
headroom install start
headroom install stop
headroom install restart
headroom install remove
```
`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.
## Presets and runtime kinds
### Presets
- `persistent-service` -> native service supervisor
- `persistent-task` -> scheduled watchdog / recovery supervisor
- `persistent-docker` -> Docker restart policy with no extra OS supervisor
### Runtime kinds
- `--runtime python` runs `headroom proxy` directly
- `--runtime docker` runs Headroom inside Docker while keeping the deployment managed locally
For `persistent-docker`, the runtime is always Docker.
## Configuration scopes
| Scope | What changes |
|---|---|
| `provider` | Tool-specific config surfaces where Headroom can make a precise reversible edit |
| `user` | User-level shell or environment surfaces |
| `system` | Machine-wide shell or environment surfaces |
### Provider scope today
Provider scope is intentionally conservative. The current direct adapters are:
- Claude Code -> `~/.claude/settings.json` `env`
- Codex -> managed block in `~/.codex/config.toml`
- OpenClaw -> existing `wrap openclaw` / `unwrap openclaw` flow
For Copilot, Aider, Cursor, and broader env-driven setups, prefer `--scope user` or `--scope system`.
## Provider selection
| Option | Meaning |
|---|---|
| `--providers auto` | Detect supported tools on the host and configure the best available defaults |
| `--providers all` | Configure all known targets |
| `--providers manual --target ...` | Configure only the named tools |
Examples:
```bash
headroom install apply --providers auto
headroom install apply --providers all --scope user
headroom install apply --providers manual --target claude --target copilot
```
## Health and wrap behavior
Persistent deployments publish the same `readyz` and `health` endpoints as ad hoc proxy runs.
`/health` now also exposes deployment metadata when the proxy was launched through the install subsystem:
```json
{
"deployment": {
"profile": "default",
"preset": "persistent-service",
"runtime": "python",
"supervisor": "service",
"scope": "user"
}
}
```
The Python-native `headroom wrap ...` flow checks for a matching persistent deployment on the requested port before it starts a new ephemeral proxy. If an installed deployment exists but is stopped or unhealthy, it attempts to recover it first.
The Docker-native host wrapper does **not** yet reuse or recover persistent profiles automatically; it still starts a fresh proxy container unless you opt into `--no-proxy`.
## Docker-native relationship
The Docker-native host wrapper and the Python install CLI solve different layers of the runtime story:
- [Docker-Native Install](docker-install.md) -> containerized on-demand CLI, wrapped host-tool flows, and Docker-native `persistent-docker` lifecycle commands
- `headroom install ...` -> full persistent service, task, and Docker lifecycle management, including provider/user/system mutation
For a no-Python persistent Docker workflow, use the compose-managed proxy path from `docker/docker-compose.native.yml`:
```bash
export HEADROOM_HOST_HOME="$HOME"
export HEADROOM_WORKSPACE="$PWD"
docker compose -f docker/docker-compose.native.yml up -d proxy
```
That keeps `localhost:8787` stable and restarts the proxy automatically.
## Related guides
- [CLI Reference](cli.md)
- [Docker-Native Install](docker-install.md)
- [Proxy Server](proxy.md)
- [macOS LaunchAgent](macos-deployment.md)

View file

@ -0,0 +1,129 @@
# Persistent Deployments / Installs Design
## Problem
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.
The new feature should make Headroom deployable as a persistent local runtime while keeping the existing on-demand and wrapped flows intact.
## Goals
- Support these runtime/install modes as one coherent system:
- Persistent Service
- Persistent Task
- Persistent Docker
- On-Demand CLI (Python)
- On-Demand CLI (Docker)
- Wrapped (Python)
- Wrapped (Docker)
- Support install target selection modes:
- Auto-Detect
- All
- Manual Select
- Support configuration scopes:
- Provider
- User
- System
- Keep `wrap` idempotent and persistent-aware.
- Preserve local defaults such as `localhost:8787`.
## Architecture
Introduce a new shared deployment subsystem under `headroom.install`.
Core model:
- `execution_mode`: `persistent | on_demand | wrapped`
- `runtime_kind`: `python | docker`
- `supervisor_kind`: `service | task | none`
These three axes normalize all seven user-facing runtime modes without duplicating logic across CLI commands, install scripts, and platform-specific deployment adapters.
The subsystem centers on a persisted deployment manifest in `~/.headroom/deploy/` that records:
- resolved proxy configuration
- runtime type
- supervisor type
- configured tool targets
- applied config mutations
- generated artifact paths
- health URL and port
## Command model
Add a new public `headroom install` group:
- `headroom install apply`
- `headroom install status`
- `headroom install start`
- `headroom install stop`
- `headroom install restart`
- `headroom install remove`
Add hidden helper commands for artifact runners and health recovery:
- `headroom install agent run --profile <name>`
- `headroom install agent ensure --profile <name>`
Platform supervisors should register the hidden agent entrypoint rather than raw `headroom proxy ...` so restart, health polling, and manifest handling live in one place.
## Runtime adapters
- `PythonRuntimeAdapter`: launches `headroom proxy` directly.
- `DockerRuntimeAdapter`: launches a detached or foreground Docker container with the existing host mounts and loopback-only port publishing.
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.
## Supervisor adapters
- Linux
- Service: systemd unit
- Task: cron watchdog + reboot/start entry
- macOS
- Service: LaunchDaemon / LaunchAgent variant
- Task: launchd user agent or cron-style watchdog where appropriate
- Windows
- Service: Windows Service wrapper
- Task: Scheduled Task startup + periodic health-check task
Each adapter renders artifacts into `~/.headroom/deploy/` and stores enough metadata for clean removal.
## Tool target configuration
Provider-level configuration should be target-specific and reversible.
Initial target adapters:
- Claude Code
- write `env` keys into Claude settings JSON where appropriate
- Codex
- manage a marked block or targeted settings in `~/.codex/config.toml`
- Copilot CLI
- configure BYOK environment surfaces using persistent env strategy
- OpenClaw
- reuse existing OpenClaw config/plugin merge logic where possible
- Aider / Cursor
- use env-based integration first, with tool-specific config only where a stable supported surface exists
All non-marker edits must store previous values in the deployment manifest so uninstall removes only Headroom-managed changes.
## Wrap behavior
`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.
## Docs strategy
The public docs should be reframed around:
- runtime mode
- lifecycle mode
- configuration scope
- direct-use vs `wrap`
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.
## Risks
- Windows service installation is the highest-risk platform path and needs strong test isolation.
- Provider-specific config mutation must remain conservative and reversible.
- `/health` should gain deployment metadata without breaking the existing `config` payload shape expected by current tests and docs.

View file

@ -33,6 +33,14 @@ curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/i
See [Docker-native install](docker-install.md) if you want Docker to provide the Headroom runtime while your agent CLIs stay on the host.
**Persistent background runtime:**
```bash
headroom install apply --preset persistent-service --providers auto
```
See [Persistent Installs](persistent-installs.md) if you want Headroom to stay up in the background and be reused by `wrap`.
---
## Option 1: Proxy Server (Zero Code Changes)

87
e2e/docker-native-install.sh Executable file
View file

@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="${HEADROOM_DOCKER_IMAGE:?set HEADROOM_DOCKER_IMAGE to a built test image}"
PROFILE="ci-smoke"
TMP_HOME="$(mktemp -d)"
PORT="$(python3 - <<'PY'
import socket
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
print(sock.getsockname()[1])
PY
)"
cleanup() {
docker rm -f "headroom-${PROFILE}" >/dev/null 2>&1 || true
rm -rf "${TMP_HOME}"
}
trap cleanup EXIT
mkdir -p "${TMP_HOME}/.local"
export HOME="${TMP_HOME}"
export PATH="${HOME}/.local/bin:${PATH}"
export HEADROOM_DOCKER_IMAGE="${IMAGE}"
bash "${ROOT_DIR}/scripts/install.sh"
WRAPPER="${HOME}/.local/bin/headroom"
[[ -x "${WRAPPER}" ]]
"${WRAPPER}" install -? | grep -Fq "persistent-docker preset only"
"${WRAPPER}" install apply \
--profile "${PROFILE}" \
--port "${PORT}" \
--image "${IMAGE}" \
--no-telemetry
status_output="$("${WRAPPER}" install status --profile "${PROFILE}")"
printf '%s\n' "${status_output}"
grep -Fq "Status: running" <<<"${status_output}"
curl --fail --silent "http://127.0.0.1:${PORT}/readyz" >/dev/null
health_output="$(curl --fail --silent "http://127.0.0.1:${PORT}/health")"
python3 - <<'PY' "${HOME}" "${PROFILE}" "${PORT}" "${health_output}"
import json
import sys
from pathlib import Path
home = Path(sys.argv[1])
profile = sys.argv[2]
port = int(sys.argv[3])
health = json.loads(sys.argv[4])
manifest = json.loads((home / ".headroom" / "deploy" / profile / "manifest.json").read_text())
assert manifest["preset"] == "persistent-docker"
assert manifest["port"] == port
assert manifest["telemetry_enabled"] is False
assert health["deployment"]["profile"] == profile
assert health["deployment"]["preset"] == "persistent-docker"
assert health["deployment"]["runtime"] == "docker"
PY
if apply_error="$("${WRAPPER}" install apply --scope user 2>&1)"; then
echo "expected docker-native install apply --scope user to fail" >&2
exit 1
fi
grep -Fq "does not support provider/user/system mutation flags" <<<"${apply_error}"
"${WRAPPER}" install stop --profile "${PROFILE}"
stopped_output="$("${WRAPPER}" install status --profile "${PROFILE}")"
printf '%s\n' "${stopped_output}"
grep -Fq "Status: stopped" <<<"${stopped_output}"
"${WRAPPER}" install start --profile "${PROFILE}"
started_output="$("${WRAPPER}" install status --profile "${PROFILE}")"
printf '%s\n' "${started_output}"
grep -Fq "Status: running" <<<"${started_output}"
curl --fail --silent "http://127.0.0.1:${PORT}/readyz" >/dev/null
"${WRAPPER}" install restart --profile "${PROFILE}"
curl --fail --silent "http://127.0.0.1:${PORT}/readyz" >/dev/null
"${WRAPPER}" install remove --profile "${PROFILE}"
[[ ! -e "${HOME}/.headroom/deploy/${PROFILE}" ]]

View file

@ -261,6 +261,7 @@ def create_shims(shim_dir: Path) -> None:
raise SystemExit(0)
"""
)
write_executable(shim_dir / "claude", generic_shim)
write_executable(shim_dir / "codex", generic_shim)
write_executable(shim_dir / "aider", generic_shim)
write_executable(shim_dir / "rtk", rtk_shim)
@ -466,6 +467,27 @@ def verify_codex_wrap(base_env: dict[str, str], project_dir: Path, log_dir: Path
)
def verify_claude_wrap(base_env: dict[str, str], project_dir: Path, log_dir: Path) -> None:
port = PROXY_PORT + 10
run(
["headroom", "wrap", "claude", "--port", str(port), "--", "--help"],
env=base_env,
cwd=project_dir,
timeout=120,
)
entries = read_jsonl(log_dir / "claude.jsonl")
assert_true(len(entries) > 0, "Claude shim should have been invoked")
env_vars = entries[-1]["env"]
assert_true(
env_vars.get("ANTHROPIC_BASE_URL") == f"http://127.0.0.1:{port}",
"Claude wrap should set ANTHROPIC_BASE_URL",
)
assert_true(
entries[-1]["probes"] == [{"url": f"http://127.0.0.1:{port}/health", "status": 200}],
"Claude shim should prove ANTHROPIC_BASE_URL points at a live proxy",
)
def verify_aider_wrap(base_env: dict[str, str], project_dir: Path, log_dir: Path) -> None:
port = AIDER_PORT
run(
@ -624,6 +646,13 @@ def verify_openclaw_wrap(
stop_process(gateway_proc)
stop_openclaw_gateway(base_env, project_dir)
run(["headroom", "unwrap", "openclaw"], env=base_env, cwd=project_dir, timeout=120)
state = json.loads(config_path.read_text(encoding="utf-8"))
assert_true(
state["plugins"]["slots"]["contextEngine"] == "legacy",
"OpenClaw unwrap should restore the context engine slot",
)
def main() -> None:
verify_installs()
@ -653,6 +682,7 @@ def main() -> None:
try:
verify_proxy_round_trip(base_env, mock_server)
verify_claude_wrap(base_env, project_dir, log_dir)
verify_codex_wrap(base_env, project_dir, log_dir)
verify_aider_wrap(base_env, project_dir, log_dir)
verify_cursor_wrap(base_env, project_dir)

View file

@ -32,10 +32,13 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# fcntl is Unix-only; on Windows we skip file locking (stats are best-effort)
# fcntl is Unix-only; on Windows we skip file locking (stats are best-effort).
# Keep the module typed as Any so Windows mypy runs don't try to resolve Unix-only attrs.
fcntl: Any = None
try:
import fcntl
import fcntl as _fcntl
fcntl = _fcntl
_HAS_FCNTL = True
except ImportError:
_HAS_FCNTL = False
@ -329,10 +332,10 @@ class HeadroomMCPServer:
# File read cache: path → (content_hash, ccr_hash, line_count, token_count)
self._file_cache: dict[str, tuple[str, str, int, int]] = {}
if not MCP_AVAILABLE:
if not MCP_AVAILABLE or Server is None:
raise ImportError("MCP SDK not installed. Install with: pip install mcp")
self.server = Server("headroom")
self.server: Server = Server("headroom")
self._setup_handlers()
def _get_local_store(self) -> Any:

333
headroom/cli/install.py Normal file
View file

@ -0,0 +1,333 @@
"""Persistent install / deployment CLI commands."""
from __future__ import annotations
from copy import deepcopy
import click
from headroom.install.health import probe_json, probe_ready
from headroom.install.models import (
ConfigScope,
DeploymentManifest,
InstallPreset,
ProviderSelectionMode,
RuntimeKind,
SupervisorKind,
)
from headroom.install.planner import build_manifest
from headroom.install.providers import apply_mutations, revert_mutations
from headroom.install.runtime import (
run_foreground,
runtime_status,
start_detached_agent,
start_persistent_docker,
stop_runtime,
wait_ready,
)
from headroom.install.state import delete_manifest, load_manifest, save_manifest
from headroom.install.supervisors import (
install_supervisor,
remove_supervisor,
start_supervisor,
stop_supervisor,
)
from .main import main
@main.group()
def install() -> None:
"""Install and manage persistent Headroom deployments."""
def _require_manifest(profile: str) -> DeploymentManifest:
manifest = load_manifest(profile)
if manifest is None:
raise click.ClickException(f"No deployment profile named '{profile}' is installed.")
return manifest
def _start_deployment(manifest: DeploymentManifest) -> None:
if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
start_persistent_docker(manifest)
elif manifest.supervisor_kind == SupervisorKind.SERVICE.value:
start_supervisor(manifest)
else:
start_detached_agent(manifest.profile)
if not wait_ready(manifest, timeout_seconds=45):
raise click.ClickException(
f"Deployment '{manifest.profile}' did not become ready after start."
)
def _stop_deployment(manifest: DeploymentManifest) -> None:
if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
stop_supervisor(manifest)
stop_runtime(manifest)
def _remove_deployment(manifest: DeploymentManifest) -> None:
try:
_stop_deployment(manifest)
except Exception:
pass
try:
remove_supervisor(manifest)
except Exception:
pass
try:
revert_mutations(manifest)
except Exception:
pass
delete_manifest(manifest.profile)
def _restore_deployment(manifest: DeploymentManifest) -> None:
restored = deepcopy(manifest)
restored.mutations = apply_mutations(restored)
restored.artifacts = install_supervisor(restored)
save_manifest(restored)
_start_deployment(restored)
def _reject_task_lifecycle(manifest: DeploymentManifest, action: str) -> None:
if manifest.supervisor_kind == SupervisorKind.TASK.value:
raise click.ClickException(
f"Deployment '{manifest.profile}' uses persistent-task scheduling; "
f"`headroom install {action}` is not supported for task deployments."
)
@install.command("apply")
@click.option(
"--preset",
type=click.Choice([preset.value for preset in InstallPreset]),
default=InstallPreset.PERSISTENT_SERVICE.value,
show_default=True,
help="Persistent runtime preset to install.",
)
@click.option(
"--runtime",
type=click.Choice([runtime.value for runtime in RuntimeKind]),
default=RuntimeKind.PYTHON.value,
show_default=True,
help="Runtime used to execute Headroom for service/task modes.",
)
@click.option(
"--scope",
type=click.Choice([scope.value for scope in ConfigScope]),
default=ConfigScope.USER.value,
show_default=True,
help="Where to apply persistent configuration.",
)
@click.option(
"--providers",
"provider_mode",
type=click.Choice([mode.value for mode in ProviderSelectionMode]),
default=ProviderSelectionMode.AUTO.value,
show_default=True,
help="Target selection mode for direct tool configuration.",
)
@click.option(
"--target",
"targets",
multiple=True,
type=click.Choice(["claude", "copilot", "codex", "aider", "cursor", "openclaw"]),
help="Tool target to configure when --providers manual is used.",
)
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
@click.option(
"--port", "-p", default=8787, type=int, show_default=True, help="Persistent proxy port."
)
@click.option(
"--backend",
default="anthropic",
show_default=True,
help="Proxy backend for the persistent runtime.",
)
@click.option(
"--anyllm-provider",
default=None,
help="Provider for any-llm backends when --backend anyllm is used.",
)
@click.option("--region", default=None, help="Cloud region for Bedrock / Vertex style backends.")
@click.option(
"--mode", "proxy_mode", default="token", show_default=True, help="Proxy optimization mode."
)
@click.option("--memory", is_flag=True, help="Enable persistent memory in the proxy runtime.")
@click.option("--no-telemetry", is_flag=True, help="Disable anonymous telemetry in the runtime.")
@click.option(
"--image",
default="ghcr.io/chopratejas/headroom:latest",
show_default=True,
help="Docker image to use when runtime=docker or preset=persistent-docker.",
)
def install_apply(
preset: str,
runtime: str,
scope: str,
provider_mode: str,
targets: tuple[str, ...],
profile: str,
port: int,
backend: str,
anyllm_provider: str | None,
region: str | None,
proxy_mode: str,
memory: bool,
no_telemetry: bool,
image: str,
) -> None:
"""Install a persistent Headroom deployment."""
if preset == InstallPreset.PERSISTENT_DOCKER.value:
runtime = RuntimeKind.DOCKER.value
manifest = build_manifest(
profile=profile,
preset=preset,
runtime_kind=runtime,
scope=scope,
provider_mode=provider_mode,
targets=list(targets),
port=port,
backend=backend,
anyllm_provider=anyllm_provider,
region=region,
proxy_mode=proxy_mode,
memory_enabled=memory,
telemetry_enabled=not no_telemetry,
image=image,
)
existing = load_manifest(profile)
if existing is not None:
click.echo(f"Updating existing deployment profile '{profile}'...")
_remove_deployment(existing)
try:
manifest.mutations = apply_mutations(manifest)
manifest.artifacts = install_supervisor(manifest)
save_manifest(manifest)
_start_deployment(manifest)
except Exception:
_remove_deployment(manifest)
if existing is not None:
click.echo(f"Restoring previous deployment '{profile}'...")
_restore_deployment(existing)
raise
click.echo(
f"Installed persistent deployment '{profile}' "
f"({manifest.preset}, runtime={manifest.runtime_kind}, scope={manifest.scope})."
)
click.echo(f"Health: {manifest.health_url}")
if manifest.targets:
click.echo(f"Targets: {', '.join(manifest.targets)}")
@install.command("status")
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
def install_status(profile: str) -> None:
"""Show persistent deployment status."""
manifest = _require_manifest(profile)
payload = probe_json(manifest.health_url.replace("/readyz", "/health"))
click.echo(f"Profile: {manifest.profile}")
click.echo(f"Preset: {manifest.preset}")
click.echo(f"Runtime: {manifest.runtime_kind}")
click.echo(f"Supervisor: {manifest.supervisor_kind}")
click.echo(f"Scope: {manifest.scope}")
click.echo(f"Port: {manifest.port}")
click.echo(f"Status: {runtime_status(manifest)}")
click.echo(f"Healthy: {'yes' if probe_ready(manifest.health_url) else 'no'}")
if payload and isinstance(payload, dict):
click.echo(f"Health URL: {manifest.health_url.replace('/readyz', '/health')}")
click.echo(f"Backend: {payload.get('config', {}).get('backend', manifest.backend)}")
@install.command("start")
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
def install_start(profile: str) -> None:
"""Start a persistent deployment."""
manifest = _require_manifest(profile)
_reject_task_lifecycle(manifest, "start")
_start_deployment(manifest)
click.echo(f"Started deployment '{profile}'.")
@install.command("stop")
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
def install_stop(profile: str) -> None:
"""Stop a persistent deployment."""
manifest = _require_manifest(profile)
_reject_task_lifecycle(manifest, "stop")
_stop_deployment(manifest)
click.echo(f"Stopped deployment '{profile}'.")
@install.command("restart")
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
def install_restart(profile: str) -> None:
"""Restart a persistent deployment."""
manifest = _require_manifest(profile)
_reject_task_lifecycle(manifest, "restart")
_stop_deployment(manifest)
_start_deployment(manifest)
click.echo(f"Restarted deployment '{profile}'.")
@install.command("remove")
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
def install_remove(profile: str) -> None:
"""Remove a persistent deployment and undo managed config."""
manifest = _require_manifest(profile)
try:
if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
stop_supervisor(manifest)
except Exception:
pass
try:
stop_runtime(manifest)
except Exception:
pass
try:
remove_supervisor(manifest)
except Exception:
pass
revert_mutations(manifest)
delete_manifest(profile)
click.echo(f"Removed deployment '{profile}'.")
@install.group("agent", hidden=True)
def install_agent() -> None:
"""Hidden runtime helpers used by persistent supervisors."""
@install_agent.command("run")
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
def install_agent_run(profile: str) -> None:
"""Run the persistent runtime in the foreground."""
manifest = _require_manifest(profile)
raise SystemExit(run_foreground(manifest))
@install_agent.command("ensure")
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
def install_agent_ensure(profile: str) -> None:
"""Ensure a persistent deployment is healthy, starting it when needed."""
manifest = _require_manifest(profile)
if probe_ready(manifest.health_url):
click.echo(f"Deployment '{profile}' is already healthy.")
return
_start_deployment(manifest)
click.echo(f"Deployment '{profile}' is healthy.")

View file

@ -37,6 +37,7 @@ def _register_commands() -> None:
"""Register all subcommand groups."""
from . import (
evals, # noqa: F401
install, # noqa: F401
learn, # noqa: F401
mcp, # noqa: F401
perf, # noqa: F401

View file

@ -436,6 +436,58 @@ def _detect_running_proxy_backend(port: int) -> str | None:
return backend if isinstance(backend, str) else None
def _find_persistent_manifest(port: int) -> Any:
"""Return a matching persistent deployment manifest for the requested port."""
from headroom.install.state import list_manifests
manifests = [manifest for manifest in list_manifests() if manifest.port == port]
manifests.sort(key=lambda manifest: (manifest.profile != "default", manifest.profile))
return manifests[0] if manifests else None
def _recover_persistent_proxy(port: int) -> bool:
"""Start or recover a matching persistent deployment for the requested port."""
from headroom.install.health import probe_ready
from headroom.install.models import InstallPreset, SupervisorKind
from headroom.install.runtime import start_detached_agent, start_persistent_docker, wait_ready
from headroom.install.supervisors import start_supervisor
manifest = _find_persistent_manifest(port)
if manifest is None:
return False
if probe_ready(manifest.health_url):
click.echo(f" Reusing persistent deployment '{manifest.profile}' on port {port}")
return True
if manifest.supervisor_kind == SupervisorKind.TASK.value:
click.echo(
f" Warning: task-based deployment '{manifest.profile}' cannot be auto-recovered via wrap"
)
return False
click.echo(f" Recovering persistent deployment '{manifest.profile}' on port {port}...")
try:
if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
start_persistent_docker(manifest)
elif manifest.supervisor_kind == SupervisorKind.SERVICE.value:
start_supervisor(manifest)
else:
start_detached_agent(manifest.profile)
except Exception as exc:
click.echo(
f" Warning: could not recover persistent deployment '{manifest.profile}': {exc}"
)
return False
if wait_ready(manifest, timeout_seconds=45):
click.echo(f" Recovered persistent deployment '{manifest.profile}' on port {port}")
return True
click.echo(f" Warning: persistent deployment '{manifest.profile}' did not become ready")
return False
def _copilot_model_configured(copilot_args: tuple[str, ...], env: dict[str, str]) -> bool:
"""Return True when Copilot BYOK model selection is configured."""
if env.get("COPILOT_MODEL") or env.get("COPILOT_PROVIDER_MODEL_ID"):
@ -462,6 +514,19 @@ def _ensure_proxy(
) -> subprocess.Popen | None:
"""Start or verify proxy. Returns process handle if we started it."""
if not no_proxy:
manifest = _find_persistent_manifest(port)
if manifest is not None:
from headroom.install.health import probe_ready
if probe_ready(manifest.health_url):
click.echo(f" Proxy already running on port {port}")
return None
if _recover_persistent_proxy(port):
return None
raise click.ClickException(
f"Persistent deployment '{manifest.profile}' on port {port} is not healthy."
)
if _check_proxy(port):
click.echo(f" Proxy already running on port {port}")
return None

View file

@ -0,0 +1,19 @@
"""Persistent install / deployment helpers for Headroom."""
from .models import (
ConfigScope,
DeploymentManifest,
InstallPreset,
ProviderSelectionMode,
SupervisorKind,
ToolTarget,
)
__all__ = [
"ConfigScope",
"DeploymentManifest",
"InstallPreset",
"ProviderSelectionMode",
"SupervisorKind",
"ToolTarget",
]

View file

@ -0,0 +1,28 @@
"""Health helpers for persistent deployments."""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from typing import Any
def probe_json(url: str, timeout: float = 2.0) -> dict[str, Any] | None:
"""Return a JSON payload from the URL when reachable."""
try:
with urllib.request.urlopen(url, timeout=timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
except (OSError, urllib.error.URLError, ValueError, json.JSONDecodeError):
return None
return payload if isinstance(payload, dict) else None
def probe_ready(url: str, timeout: float = 2.0) -> bool:
"""Return True when the ready endpoint reports readiness."""
payload = probe_json(url, timeout=timeout)
if not isinstance(payload, dict):
return False
return bool(payload.get("ready", False) or payload.get("status") == "healthy")

116
headroom/install/models.py Normal file
View file

@ -0,0 +1,116 @@
"""Models used by the install / deployment subsystem."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any
class InstallPreset(str, Enum):
"""User-facing persistent runtime presets."""
PERSISTENT_SERVICE = "persistent-service"
PERSISTENT_TASK = "persistent-task"
PERSISTENT_DOCKER = "persistent-docker"
class RuntimeKind(str, Enum):
"""Runtime used to execute Headroom."""
PYTHON = "python"
DOCKER = "docker"
class SupervisorKind(str, Enum):
"""How a persistent deployment is kept alive."""
SERVICE = "service"
TASK = "task"
NONE = "none"
class ProviderSelectionMode(str, Enum):
"""How tool targets are selected for configuration."""
AUTO = "auto"
ALL = "all"
MANUAL = "manual"
class ConfigScope(str, Enum):
"""Where persistent configuration should be applied."""
PROVIDER = "provider"
USER = "user"
SYSTEM = "system"
class ToolTarget(str, Enum):
"""Supported tool targets for persistent proxy wiring."""
CLAUDE = "claude"
COPILOT = "copilot"
CODEX = "codex"
AIDER = "aider"
CURSOR = "cursor"
OPENCLAW = "openclaw"
def iso_utc_now() -> str:
"""Return the current UTC timestamp in ISO-8601 format."""
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
@dataclass
class ManagedMutation:
"""A reversible change applied by `headroom install`."""
target: str
kind: str
path: str | None = None
data: dict[str, Any] = field(default_factory=dict)
@dataclass
class ArtifactRecord:
"""A rendered file or platform object owned by the deployment."""
kind: str
path: str
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class DeploymentManifest:
"""Persisted deployment state for a named profile."""
profile: str
preset: str
runtime_kind: str
supervisor_kind: str
scope: str
provider_mode: str
targets: list[str]
port: int
host: str
backend: str
anyllm_provider: str | None = None
region: str | None = None
proxy_mode: str = "token"
memory_enabled: bool = False
memory_db_path: str = ""
telemetry_enabled: bool = True
image: str = "ghcr.io/chopratejas/headroom:latest"
service_name: str = "headroom"
container_name: str = "headroom-persistent"
health_url: str = "http://127.0.0.1:8787/readyz"
base_env: dict[str, str] = field(default_factory=dict)
tool_envs: dict[str, dict[str, str]] = field(default_factory=dict)
proxy_args: list[str] = field(default_factory=list)
mutations: list[ManagedMutation] = field(default_factory=list)
artifacts: list[ArtifactRecord] = field(default_factory=list)
created_at: str = field(default_factory=iso_utc_now)
updated_at: str = field(default_factory=iso_utc_now)

118
headroom/install/paths.py Normal file
View file

@ -0,0 +1,118 @@
"""Path helpers for persistent deployments."""
from __future__ import annotations
import re
import sys
from pathlib import Path
import click
_PROFILE_RE = re.compile(r"^[A-Za-z0-9._-]+$")
def validate_profile_name(profile: str) -> str:
"""Validate and normalize a deployment profile name."""
if profile in {".", ".."} or not _PROFILE_RE.fullmatch(profile):
raise click.ClickException(f"Invalid profile name '{profile}'")
return profile
def deploy_root() -> Path:
"""Return the root directory for deployment state."""
return Path.home() / ".headroom" / "deploy"
def profile_root(profile: str) -> Path:
"""Return the directory for a named deployment profile."""
return deploy_root() / validate_profile_name(profile)
def manifest_path(profile: str) -> Path:
"""Return the manifest path for a named profile."""
return profile_root(profile) / "manifest.json"
def log_path(profile: str) -> Path:
"""Return the log path used by persistent runner scripts."""
return profile_root(profile) / "runner.log"
def pid_path(profile: str) -> Path:
"""Return the pid file for the raw runtime process."""
return profile_root(profile) / "runner.pid"
def unix_run_script_path(profile: str) -> Path:
"""Return the foreground runner shell script path."""
return profile_root(profile) / "run-headroom.sh"
def unix_ensure_script_path(profile: str) -> Path:
"""Return the watchdog shell script path."""
return profile_root(profile) / "ensure-headroom.sh"
def windows_run_script_path(profile: str) -> Path:
"""Return the foreground runner PowerShell script path."""
return profile_root(profile) / "run-headroom.ps1"
def windows_run_cmd_path(profile: str) -> Path:
"""Return the foreground runner CMD shim path."""
return profile_root(profile) / "run-headroom.cmd"
def windows_ensure_script_path(profile: str) -> Path:
"""Return the watchdog PowerShell script path."""
return profile_root(profile) / "ensure-headroom.ps1"
def windows_ensure_cmd_path(profile: str) -> Path:
"""Return the watchdog CMD shim path."""
return profile_root(profile) / "ensure-headroom.cmd"
def unix_user_env_targets() -> list[Path]:
"""Return user shell files that can carry the persistent env block."""
home = Path.home()
return [home / ".bashrc", home / ".zshrc", home / ".profile"]
def unix_system_env_targets() -> list[Path]:
"""Return system shell files that can carry the persistent env block."""
if sys.platform == "darwin":
return [Path("/etc/profile"), Path("/etc/zprofile"), Path("/etc/bashrc")]
return [Path("/etc/profile.d/headroom.sh")]
def claude_settings_path() -> Path:
"""Return the Claude user settings path."""
return Path.home() / ".claude" / "settings.json"
def codex_config_path() -> Path:
"""Return the Codex config path."""
return Path.home() / ".codex" / "config.toml"
def openclaw_config_path() -> Path:
"""Return the OpenClaw config path."""
return Path.home() / ".openclaw" / "openclaw.json"

228
headroom/install/planner.py Normal file
View file

@ -0,0 +1,228 @@
"""Planner for persistent deployment manifests."""
from __future__ import annotations
import shutil
from collections.abc import Iterable
from pathlib import Path
import click
from .models import (
ConfigScope,
DeploymentManifest,
InstallPreset,
ProviderSelectionMode,
SupervisorKind,
ToolTarget,
)
from .paths import validate_profile_name
SUPPORTED_TARGETS = [
ToolTarget.CLAUDE,
ToolTarget.COPILOT,
ToolTarget.CODEX,
ToolTarget.AIDER,
ToolTarget.CURSOR,
ToolTarget.OPENCLAW,
]
PROVIDER_SCOPE_TARGETS = [
ToolTarget.CLAUDE,
ToolTarget.CODEX,
ToolTarget.OPENCLAW,
]
def _binary_name(target: ToolTarget) -> str | None:
if target == ToolTarget.CURSOR:
return None
return str(target.value)
def detect_targets() -> list[str]:
"""Auto-detect available tool targets on the current host."""
detected: list[str] = []
for target in SUPPORTED_TARGETS:
binary = _binary_name(target)
if binary and shutil.which(binary):
detected.append(target.value)
continue
if target == ToolTarget.CURSOR and shutil.which("cursor"):
detected.append(target.value)
return detected
def resolve_targets(
provider_mode: str, requested_targets: Iterable[str], *, scope: str = ConfigScope.USER.value
) -> list[str]:
"""Resolve target selection according to the requested provider mode."""
valid_targets = SUPPORTED_TARGETS
if scope == ConfigScope.PROVIDER.value:
valid_targets = PROVIDER_SCOPE_TARGETS
valid = {target.value for target in valid_targets}
requested = [target.strip().lower() for target in requested_targets]
if scope == ConfigScope.PROVIDER.value:
unsupported = [target for target in requested if target and target not in valid]
if unsupported:
unsupported_list = ", ".join(sorted(set(unsupported)))
raise click.ClickException(
"Provider scope supports only claude, codex, and openclaw; "
f"unsupported targets: {unsupported_list}"
)
if provider_mode == ProviderSelectionMode.ALL.value:
return [target.value for target in valid_targets]
if provider_mode == ProviderSelectionMode.AUTO.value:
detected = [target for target in detect_targets() if target in valid]
return detected or [
ToolTarget.CLAUDE.value,
ToolTarget.CODEX.value,
*([] if scope == ConfigScope.PROVIDER.value else [ToolTarget.COPILOT.value]),
]
normalized = []
seen: set[str] = set()
for value in requested:
if value in valid and value not in seen:
seen.add(value)
normalized.append(value)
return normalized
def _copilot_env(port: int, backend: str) -> dict[str, str]:
if backend == "anthropic":
return {
"COPILOT_PROVIDER_TYPE": "anthropic",
"COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}",
}
return {
"COPILOT_PROVIDER_TYPE": "openai",
"COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}/v1",
"COPILOT_PROVIDER_WIRE_API": "completions",
}
def build_tool_envs(port: int, backend: str, targets: list[str]) -> dict[str, dict[str, str]]:
"""Build per-target environment variables for the selected tools."""
target_envs: dict[str, dict[str, str]] = {}
if ToolTarget.CLAUDE.value in targets:
target_envs[ToolTarget.CLAUDE.value] = {
"ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
}
if ToolTarget.CODEX.value in targets:
target_envs[ToolTarget.CODEX.value] = {
"OPENAI_BASE_URL": f"http://127.0.0.1:{port}/v1",
}
if ToolTarget.AIDER.value in targets:
target_envs[ToolTarget.AIDER.value] = {
"OPENAI_API_BASE": f"http://127.0.0.1:{port}/v1",
"ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
}
if ToolTarget.COPILOT.value in targets:
target_envs[ToolTarget.COPILOT.value] = _copilot_env(port, backend)
if ToolTarget.CURSOR.value in targets:
target_envs[ToolTarget.CURSOR.value] = {
"OPENAI_BASE_URL": f"http://127.0.0.1:{port}/v1",
"ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
}
return target_envs
def build_manifest(
*,
profile: str,
preset: str,
runtime_kind: str,
scope: str,
provider_mode: str,
targets: list[str],
port: int,
backend: str,
anyllm_provider: str | None,
region: str | None,
proxy_mode: str,
memory_enabled: bool,
telemetry_enabled: bool,
image: str,
) -> DeploymentManifest:
"""Create a normalized deployment manifest."""
normalized_profile = validate_profile_name(profile)
if preset == InstallPreset.PERSISTENT_SERVICE.value:
supervisor_kind = SupervisorKind.SERVICE.value
elif preset == InstallPreset.PERSISTENT_TASK.value:
supervisor_kind = SupervisorKind.TASK.value
else:
supervisor_kind = SupervisorKind.NONE.value
resolved_targets = resolve_targets(provider_mode, targets, scope=scope)
tool_envs = build_tool_envs(port, backend, resolved_targets)
base_env = {
"HEADROOM_PORT": str(port),
"HEADROOM_HOST": "127.0.0.1",
"HEADROOM_MODE": proxy_mode,
"HEADROOM_BACKEND": backend,
}
if anyllm_provider:
base_env["HEADROOM_ANYLLM_PROVIDER"] = anyllm_provider
if region:
base_env["HEADROOM_REGION"] = region
if not telemetry_enabled:
base_env["HEADROOM_TELEMETRY"] = "off"
if memory_enabled:
base_env["HEADROOM_MEMORY_ENABLED"] = "1"
proxy_args = [
"--host",
"127.0.0.1",
"--port",
str(port),
"--mode",
proxy_mode,
"--backend",
backend,
]
if not telemetry_enabled:
proxy_args.append("--no-telemetry")
if memory_enabled:
proxy_args.extend(
["--memory", "--memory-db-path", str(Path.home() / ".headroom" / "memory.db")]
)
if anyllm_provider:
proxy_args.extend(["--anyllm-provider", anyllm_provider])
if region:
proxy_args.extend(["--region", region])
container_name = f"headroom-{normalized_profile}"
return DeploymentManifest(
profile=normalized_profile,
preset=preset,
runtime_kind=runtime_kind,
supervisor_kind=supervisor_kind,
scope=scope,
provider_mode=provider_mode,
targets=resolved_targets,
port=port,
host="127.0.0.1",
backend=backend,
anyllm_provider=anyllm_provider,
region=region,
proxy_mode=proxy_mode,
memory_enabled=memory_enabled,
memory_db_path=str(Path.home() / ".headroom" / "memory.db"),
telemetry_enabled=telemetry_enabled,
image=image,
service_name=f"headroom-{normalized_profile}",
container_name=container_name,
health_url=f"http://127.0.0.1:{port}/readyz",
base_env=base_env,
tool_envs=tool_envs,
proxy_args=proxy_args,
)

View file

@ -0,0 +1,302 @@
"""Tool-target configuration for persistent deployments."""
from __future__ import annotations
import json
import os
import re
import subprocess
from pathlib import Path
import click
from .models import ConfigScope, DeploymentManifest, ManagedMutation, ToolTarget
from .paths import (
claude_settings_path,
codex_config_path,
openclaw_config_path,
unix_system_env_targets,
unix_user_env_targets,
)
from .runtime import resolve_headroom_command
_ENV_MARKER_START = "# >>> headroom persistent env >>>"
_ENV_MARKER_END = "# <<< headroom persistent env <<<"
_ENV_PATTERN = re.compile(
re.escape(_ENV_MARKER_START) + r".*?" + re.escape(_ENV_MARKER_END),
re.DOTALL,
)
_CODEX_MARKER_START = "# --- Headroom persistent provider ---"
_CODEX_MARKER_END = "# --- end Headroom persistent provider ---"
_CODEX_PATTERN = re.compile(
re.escape(_CODEX_MARKER_START) + r".*?" + re.escape(_CODEX_MARKER_END),
re.DOTALL,
)
def _merge_marker_block(file_path: Path, block: str, pattern: re.Pattern[str], marker: str) -> str:
if file_path.exists():
existing = file_path.read_text()
if marker in existing:
return pattern.sub(block, existing)
return existing.rstrip() + "\n\n" + block + "\n"
return block + "\n"
def _env_block(values: dict[str, str]) -> str:
lines = [_ENV_MARKER_START]
for name, value in values.items():
lines.append(f'export {name}="{value}"')
lines.append(_ENV_MARKER_END)
return "\n".join(lines)
def _powershell_literal(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def _unix_scope_values(manifest: DeploymentManifest) -> dict[str, str]:
merged = dict(manifest.base_env)
for env_map in manifest.tool_envs.values():
merged.update(env_map)
return merged
def _apply_unix_env_scope(manifest: DeploymentManifest) -> list[ManagedMutation]:
values = _unix_scope_values(manifest)
block = _env_block(values)
if manifest.scope == ConfigScope.USER.value:
targets = unix_user_env_targets()
else:
targets = unix_system_env_targets()
mutations: list[ManagedMutation] = []
for path in targets:
path.parent.mkdir(parents=True, exist_ok=True)
merged = _merge_marker_block(path, block, _ENV_PATTERN, _ENV_MARKER_START)
path.write_text(merged)
mutations.append(ManagedMutation(target="env", kind="shell-block", path=str(path)))
return mutations
def _remove_unix_env_scope(mutations: list[ManagedMutation]) -> None:
for mutation in mutations:
if mutation.kind != "shell-block" or not mutation.path:
continue
path = Path(mutation.path)
if not path.exists():
continue
content = path.read_text()
if _ENV_MARKER_START not in content:
continue
path.write_text(_ENV_PATTERN.sub("", content).strip() + "\n")
def _apply_windows_env_scope(manifest: DeploymentManifest) -> list[ManagedMutation]:
scope_name = "Machine" if manifest.scope == ConfigScope.SYSTEM.value else "User"
merged = _unix_scope_values(manifest)
mutations: list[ManagedMutation] = []
for name, value in merged.items():
previous = subprocess.run(
[
"powershell",
"-NoProfile",
"-Command",
f"$value = [Environment]::GetEnvironmentVariable({_powershell_literal(name)},{_powershell_literal(scope_name)}); "
"if ($null -eq $value) { '__HEADROOM_UNSET__' } else { $value }",
],
capture_output=True,
text=True,
check=True,
).stdout.strip()
command = [
"powershell",
"-NoProfile",
"-Command",
f"[Environment]::SetEnvironmentVariable({_powershell_literal(name)},{_powershell_literal(value)},{_powershell_literal(scope_name)})",
]
subprocess.run(command, check=True)
mutations.append(
ManagedMutation(
target="env",
kind="windows-env",
data={
"name": name,
"scope": scope_name,
"previous": None if previous == "__HEADROOM_UNSET__" else previous,
},
)
)
return mutations
def _remove_windows_env_scope(mutations: list[ManagedMutation]) -> None:
for mutation in mutations:
if mutation.kind != "windows-env":
continue
name = mutation.data.get("name")
if not isinstance(name, str):
raise ValueError("Windows environment mutation is missing a variable name")
scope_name = mutation.data.get("scope", "User")
if not isinstance(scope_name, str):
raise ValueError("Windows environment mutation is missing a valid scope")
previous = mutation.data.get("previous")
if previous is None:
value_literal = "$null"
else:
value_literal = _powershell_literal(previous)
command = [
"powershell",
"-NoProfile",
"-Command",
f"[Environment]::SetEnvironmentVariable({_powershell_literal(name)},{value_literal},{_powershell_literal(scope_name)})",
]
subprocess.run(command, check=True)
def _apply_claude_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
path = claude_settings_path()
path.parent.mkdir(parents=True, exist_ok=True)
payload: dict[str, object] = {}
if path.exists():
payload = json.loads(path.read_text())
env = payload.get("env")
env_map = dict(env) if isinstance(env, dict) else {}
previous = {
name: env_map.get(name) for name in manifest.tool_envs.get(ToolTarget.CLAUDE.value, {})
}
env_map.update(manifest.tool_envs[ToolTarget.CLAUDE.value])
payload["env"] = env_map
path.write_text(json.dumps(payload, indent=2) + "\n")
return ManagedMutation(
target=ToolTarget.CLAUDE.value,
kind="json-env",
path=str(path),
data={"previous": previous},
)
def _revert_claude_provider_scope(mutation: ManagedMutation, values: dict[str, str]) -> None:
if not mutation.path:
return
path = Path(mutation.path)
if not path.exists():
return
payload = json.loads(path.read_text())
env = payload.get("env")
env_map = dict(env) if isinstance(env, dict) else {}
previous: dict[str, object] = mutation.data.get("previous", {})
for name in values:
if previous.get(name) is None:
env_map.pop(name, None)
else:
env_map[name] = previous[name]
payload["env"] = env_map
path.write_text(json.dumps(payload, indent=2) + "\n")
def _apply_codex_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
path = codex_config_path()
path.parent.mkdir(parents=True, exist_ok=True)
section = (
f"{_CODEX_MARKER_START}\n"
'model_provider = "headroom"\n\n'
"[model_providers.headroom]\n"
'name = "Headroom persistent proxy"\n'
f'base_url = "http://127.0.0.1:{manifest.port}/v1"\n'
'env_key = "OPENAI_API_KEY"\n'
"requires_openai_auth = true\n"
"supports_websockets = true\n"
f"{_CODEX_MARKER_END}\n"
)
merged = _merge_marker_block(path, section, _CODEX_PATTERN, _CODEX_MARKER_START)
path.write_text(merged)
return ManagedMutation(target=ToolTarget.CODEX.value, kind="toml-block", path=str(path))
def _revert_codex_provider_scope(mutation: ManagedMutation) -> None:
if not mutation.path:
return
path = Path(mutation.path)
if not path.exists():
return
content = path.read_text()
if _CODEX_MARKER_START not in content:
return
path.write_text(_CODEX_PATTERN.sub("", content).strip() + "\n")
def _invoke_openclaw(command: list[str]) -> None:
subprocess.run(command, check=True)
def _apply_openclaw_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
if not shutil_which("openclaw"):
raise click.ClickException("openclaw not found in PATH; cannot apply provider scope.")
command = [
*resolve_headroom_command(),
"wrap",
"openclaw",
"--no-auto-start",
"--proxy-port",
str(manifest.port),
]
_invoke_openclaw(command)
return ManagedMutation(
target=ToolTarget.OPENCLAW.value, kind="openclaw-wrap", path=str(openclaw_config_path())
)
def _revert_openclaw_provider_scope() -> None:
if not shutil_which("openclaw"):
return
command = [*resolve_headroom_command(), "unwrap", "openclaw"]
_invoke_openclaw(command)
def shutil_which(name: str) -> str | None:
from shutil import which
return which(name)
def apply_mutations(manifest: DeploymentManifest) -> list[ManagedMutation]:
"""Apply provider/user/system configuration for a deployment."""
mutations: list[ManagedMutation] = []
if manifest.scope in {ConfigScope.USER.value, ConfigScope.SYSTEM.value}:
if os.name == "nt":
mutations.extend(_apply_windows_env_scope(manifest))
else:
mutations.extend(_apply_unix_env_scope(manifest))
if ToolTarget.OPENCLAW.value in manifest.targets:
mutations.append(_apply_openclaw_provider_scope(manifest))
return mutations
if ToolTarget.CLAUDE.value in manifest.targets:
mutations.append(_apply_claude_provider_scope(manifest))
if ToolTarget.CODEX.value in manifest.targets:
mutations.append(_apply_codex_provider_scope(manifest))
if ToolTarget.OPENCLAW.value in manifest.targets:
mutations.append(_apply_openclaw_provider_scope(manifest))
return mutations
def revert_mutations(manifest: DeploymentManifest) -> None:
"""Undo the stored mutations for a deployment."""
if manifest.scope in {ConfigScope.USER.value, ConfigScope.SYSTEM.value}:
shell_mutations = [m for m in manifest.mutations if m.target == "env"]
if os.name == "nt":
_remove_windows_env_scope(shell_mutations)
else:
_remove_unix_env_scope(shell_mutations)
for mutation in manifest.mutations:
if mutation.target == ToolTarget.CLAUDE.value and mutation.kind == "json-env":
_revert_claude_provider_scope(
mutation, manifest.tool_envs.get(ToolTarget.CLAUDE.value, {})
)
elif mutation.target == ToolTarget.CODEX.value and mutation.kind == "toml-block":
_revert_codex_provider_scope(mutation)
elif mutation.target == ToolTarget.OPENCLAW.value and mutation.kind == "openclaw-wrap":
_revert_openclaw_provider_scope()

270
headroom/install/runtime.py Normal file
View file

@ -0,0 +1,270 @@
"""Runtime helpers for persistent deployments."""
from __future__ import annotations
import os
import shutil
import signal
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
from .health import probe_ready
from .models import DeploymentManifest, InstallPreset, RuntimeKind
from .paths import log_path, pid_path
PASSTHROUGH_ENV_PREFIXES = (
"HEADROOM_",
"ANTHROPIC_",
"OPENAI_",
"GEMINI_",
"AWS_",
"AZURE_",
"VERTEX_",
"GOOGLE_",
"GOOGLE_CLOUD_",
"MISTRAL_",
"GROQ_",
"OPENROUTER_",
"XAI_",
"TOGETHER_",
"COHERE_",
"OLLAMA_",
"LITELLM_",
"OTEL_",
"SUPABASE_",
"QDRANT_",
"NEO4J_",
"LANGSMITH_",
)
def _deployment_env(manifest: DeploymentManifest) -> dict[str, str]:
return {
"HEADROOM_DEPLOYMENT_PROFILE": manifest.profile,
"HEADROOM_DEPLOYMENT_PRESET": manifest.preset,
"HEADROOM_DEPLOYMENT_RUNTIME": manifest.runtime_kind,
"HEADROOM_DEPLOYMENT_SUPERVISOR": manifest.supervisor_kind,
"HEADROOM_DEPLOYMENT_SCOPE": manifest.scope,
}
def resolve_headroom_command() -> list[str]:
"""Resolve the most reliable command to invoke headroom."""
headroom_bin = shutil.which("headroom")
if headroom_bin:
return [headroom_bin]
return [sys.executable, "-m", "headroom.cli"]
def _runtime_env(manifest: DeploymentManifest) -> dict[str, str]:
env = os.environ.copy()
env.update(manifest.base_env)
env.update(_deployment_env(manifest))
return env
def _ensure_host_dirs() -> None:
for subdir in (".headroom", ".claude", ".codex", ".gemini"):
(Path.home() / subdir).mkdir(parents=True, exist_ok=True)
def _mount_source(home: str, subdir: str) -> str:
if os.name == "nt":
return f"{home}\\{subdir}"
return f"{home}/{subdir}"
def build_runtime_command(manifest: DeploymentManifest) -> list[str]:
"""Build the raw foreground command that runs the proxy."""
if manifest.runtime_kind == RuntimeKind.PYTHON.value:
return [sys.executable, "-m", "headroom.cli", "proxy", *manifest.proxy_args]
_ensure_host_dirs()
home = str(Path.home())
container_home = "/tmp/headroom-home"
command = [
"docker",
"run",
"--rm",
"--name",
manifest.container_name,
"-p",
f"127.0.0.1:{manifest.port}:{manifest.port}",
"--workdir",
container_home,
"--env",
f"HOME={container_home}",
"--env",
"PYTHONUNBUFFERED=1",
"--volume",
f"{_mount_source(home, '.headroom')}:{container_home}/.headroom",
"--volume",
f"{_mount_source(home, '.claude')}:{container_home}/.claude",
"--volume",
f"{_mount_source(home, '.codex')}:{container_home}/.codex",
"--volume",
f"{_mount_source(home, '.gemini')}:{container_home}/.gemini",
]
if os.name != "nt":
getuid = getattr(os, "getuid", None)
getgid = getattr(os, "getgid", None)
if callable(getuid) and callable(getgid):
command.extend(["--user", f"{getuid()}:{getgid()}"])
runtime_env = {**manifest.base_env, **_deployment_env(manifest)}
for name, value in runtime_env.items():
command.extend(["--env", f"{name}={value}"])
for name in sorted(os.environ):
if name.startswith(PASSTHROUGH_ENV_PREFIXES):
command.extend(["--env", name])
command.extend(
[
manifest.image,
"headroom",
"proxy",
"--host",
"0.0.0.0",
*manifest.proxy_args[2:],
]
)
return command
def _write_pid(profile: str, pid: int) -> None:
path = pid_path(profile)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(str(pid))
def _read_pid(profile: str) -> int | None:
path = pid_path(profile)
if not path.exists():
return None
try:
return int(path.read_text().strip())
except ValueError:
return None
def _clear_pid(profile: str) -> None:
path = pid_path(profile)
if path.exists():
path.unlink()
def run_foreground(manifest: DeploymentManifest) -> int:
"""Run the raw runtime command in the foreground."""
command = build_runtime_command(manifest)
env = _runtime_env(manifest)
log_file_path = log_path(manifest.profile)
log_file_path.parent.mkdir(parents=True, exist_ok=True)
with open(log_file_path, "a", encoding="utf-8", errors="replace") as log_file:
proc = subprocess.Popen(command, env=env, stdout=log_file, stderr=log_file)
_write_pid(manifest.profile, proc.pid)
def _cleanup(signum: int | None = None, frame: Any = None) -> None:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
signal.signal(signal.SIGINT, _cleanup)
signal.signal(signal.SIGTERM, _cleanup)
try:
return proc.wait()
finally:
_clear_pid(manifest.profile)
def start_detached_agent(profile: str) -> subprocess.Popen[str]:
"""Start `headroom install agent run` detached for the given profile."""
command = [*resolve_headroom_command(), "install", "agent", "run", "--profile", profile]
log_file_path = log_path(profile)
log_file_path.parent.mkdir(parents=True, exist_ok=True)
log_file = open(log_file_path, "a", encoding="utf-8", errors="replace") # noqa: SIM115
kwargs: dict[str, Any] = {"stdout": log_file, "stderr": log_file}
if os.name == "nt":
kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
subprocess, "CREATE_NEW_PROCESS_GROUP", 0
)
else:
kwargs["start_new_session"] = True
return subprocess.Popen(command, **kwargs)
def start_persistent_docker(manifest: DeploymentManifest) -> None:
"""Start a persistent Docker container with restart policy."""
command = build_runtime_command(manifest)
docker_cmd = [
"docker",
"run",
"-d",
"--restart",
"unless-stopped",
"--name",
manifest.container_name,
*command[5:], # drop initial `docker run --rm --name ...`
]
subprocess.run(["docker", "rm", "-f", manifest.container_name], capture_output=True, text=True)
subprocess.run(docker_cmd, check=True)
def stop_runtime(manifest: DeploymentManifest) -> None:
"""Stop the raw runtime for the deployment."""
if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
subprocess.run(["docker", "stop", manifest.container_name], capture_output=True, text=True)
subprocess.run(
["docker", "rm", "-f", manifest.container_name], capture_output=True, text=True
)
return
pid = _read_pid(manifest.profile)
if pid is None:
return
try:
os.kill(pid, signal.SIGTERM)
except OSError:
pass
_clear_pid(manifest.profile)
def wait_ready(manifest: DeploymentManifest, timeout_seconds: int = 30) -> bool:
"""Wait for the deployment to report ready."""
for _ in range(timeout_seconds):
if probe_ready(manifest.health_url):
return True
time.sleep(1)
return False
def runtime_status(manifest: DeploymentManifest) -> str:
"""Return a short status string for the deployment runtime."""
if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
result = subprocess.run(
["docker", "ps", "--format", "{{.Names}}"], capture_output=True, text=True
)
if manifest.container_name in result.stdout.splitlines():
return "running"
return "stopped"
pid = _read_pid(manifest.profile)
if pid is None:
return "stopped"
try:
os.kill(pid, 0)
except OSError:
return "stopped"
return "running"

61
headroom/install/state.py Normal file
View file

@ -0,0 +1,61 @@
"""Persistence helpers for deployment manifests."""
from __future__ import annotations
import json
import shutil
from dataclasses import asdict
from .models import ArtifactRecord, DeploymentManifest, ManagedMutation, iso_utc_now
from .paths import deploy_root, manifest_path, profile_root
def save_manifest(manifest: DeploymentManifest) -> None:
"""Persist a deployment manifest to disk."""
root = profile_root(manifest.profile)
root.mkdir(parents=True, exist_ok=True)
manifest.updated_at = iso_utc_now()
path = manifest_path(manifest.profile)
path.write_text(json.dumps(asdict(manifest), indent=2) + "\n")
def load_manifest(profile: str = "default") -> DeploymentManifest | None:
"""Load a deployment manifest when present."""
path = manifest_path(profile)
if not path.exists():
return None
payload = json.loads(path.read_text())
payload["mutations"] = [ManagedMutation(**item) for item in payload.get("mutations", [])]
payload["artifacts"] = [ArtifactRecord(**item) for item in payload.get("artifacts", [])]
return DeploymentManifest(**payload)
def list_manifests() -> list[DeploymentManifest]:
"""Load all deployment manifests under the deployment root."""
root = deploy_root()
if not root.exists():
return []
manifests: list[DeploymentManifest] = []
for candidate in sorted(root.glob("*/manifest.json")):
try:
payload = json.loads(candidate.read_text())
payload["mutations"] = [
ManagedMutation(**item) for item in payload.get("mutations", [])
]
payload["artifacts"] = [ArtifactRecord(**item) for item in payload.get("artifacts", [])]
manifests.append(DeploymentManifest(**payload))
except (OSError, ValueError, TypeError):
continue
return manifests
def delete_manifest(profile: str = "default") -> None:
"""Delete the full deployment profile state if present."""
root = profile_root(profile)
if root.exists():
shutil.rmtree(root, ignore_errors=True)

View file

@ -0,0 +1,413 @@
"""Supervisor installation helpers for persistent deployments."""
from __future__ import annotations
import os
import re
import shlex
import subprocess
import sys
from pathlib import Path
import click
from .models import ArtifactRecord, DeploymentManifest, SupervisorKind
from .paths import (
unix_ensure_script_path,
unix_run_script_path,
windows_ensure_cmd_path,
windows_ensure_script_path,
windows_run_cmd_path,
windows_run_script_path,
)
from .runtime import resolve_headroom_command
def _command_for_script(*parts: str) -> list[str]:
return [*resolve_headroom_command(), *parts]
def _render_unix_runner(path: Path, command: list[str]) -> ArtifactRecord:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
"#!/usr/bin/env bash\nset -euo pipefail\nexec "
+ " ".join(shlex.quote(x) for x in command)
+ "\n"
)
path.chmod(0o755)
return ArtifactRecord(kind="script", path=str(path))
def _render_windows_runner(
ps1_path: Path, cmd_path: Path, command: list[str]
) -> list[ArtifactRecord]:
ps1_path.parent.mkdir(parents=True, exist_ok=True)
escaped = " ".join(
[f'"{item}"' if (" " in item or item.endswith(".cmd")) else item for item in command]
)
ps1_path.write_text(f"$ErrorActionPreference = 'Stop'\n& {escaped}\nexit $LASTEXITCODE\n")
cmd_path.write_text(
'@echo off\r\npowershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0'
+ ps1_path.name
+ '" %*\r\n'
)
return [
ArtifactRecord(kind="script", path=str(ps1_path)),
ArtifactRecord(kind="script", path=str(cmd_path)),
]
def render_runner_scripts(manifest: DeploymentManifest) -> list[ArtifactRecord]:
"""Render runner/watchdog scripts for the deployment profile."""
if os.name == "nt":
records = []
records.extend(
_render_windows_runner(
windows_run_script_path(manifest.profile),
windows_run_cmd_path(manifest.profile),
_command_for_script("install", "agent", "run", "--profile", manifest.profile),
)
)
records.extend(
_render_windows_runner(
windows_ensure_script_path(manifest.profile),
windows_ensure_cmd_path(manifest.profile),
_command_for_script("install", "agent", "ensure", "--profile", manifest.profile),
)
)
return records
return [
_render_unix_runner(
unix_run_script_path(manifest.profile),
_command_for_script("install", "agent", "run", "--profile", manifest.profile),
),
_render_unix_runner(
unix_ensure_script_path(manifest.profile),
_command_for_script("install", "agent", "ensure", "--profile", manifest.profile),
),
]
def _linux_service_unit(manifest: DeploymentManifest, run_script: Path) -> tuple[Path, str]:
if manifest.scope == "system":
unit_path = Path("/etc/systemd/system") / f"{manifest.service_name}.service"
else:
unit_path = (
Path.home() / ".config" / "systemd" / "user" / f"{manifest.service_name}.service"
)
content = f"""[Unit]
Description=Headroom ({manifest.profile})
After=network-online.target
[Service]
Type=simple
ExecStart={run_script}
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
"""
return unit_path, content
def _macos_launchd_plist(
manifest: DeploymentManifest, command_path: Path, *, interval: int | None = None
) -> tuple[Path, str]:
if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
base_dir = (
Path("/Library/LaunchDaemons")
if manifest.scope == "system"
else Path.home() / "Library" / "LaunchAgents"
)
else:
base_dir = Path.home() / "Library" / "LaunchAgents"
plist_path = base_dir / f"com.headroom.{manifest.profile}.plist"
program = str(command_path)
keys = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
'<plist version="1.0">',
"<dict>",
" <key>Label</key>",
f" <string>com.headroom.{manifest.profile}</string>",
" <key>ProgramArguments</key>",
" <array>",
f" <string>{program}</string>",
" </array>",
" <key>RunAtLoad</key>",
" <true/>",
]
if interval is not None:
keys.extend([" <key>StartInterval</key>", f" <integer>{interval}</integer>"])
else:
keys.extend([" <key>KeepAlive</key>", " <true/>"])
keys.extend(["</dict>", "</plist>"])
return plist_path, "\n".join(keys) + "\n"
def _linux_task_spec(manifest: DeploymentManifest, ensure_script: Path) -> tuple[Path | None, str]:
if manifest.scope == "system":
cron_path = Path("/etc/cron.d") / manifest.service_name
content = f"@reboot root {ensure_script}\n*/5 * * * * root {ensure_script}\n"
return cron_path, content
marker_start = f"# >>> headroom {manifest.profile} >>>"
marker_end = f"# <<< headroom {manifest.profile} <<<"
content = (
f"{marker_start}\n@reboot {ensure_script}\n*/5 * * * * {ensure_script}\n{marker_end}\n"
)
return None, content
def install_supervisor(manifest: DeploymentManifest) -> list[ArtifactRecord]:
"""Install service/task artifacts for the deployment."""
records = render_runner_scripts(manifest)
artifact_paths = {Path(item.path).name: Path(item.path) for item in records}
if manifest.supervisor_kind == SupervisorKind.NONE.value:
return records
if (
sys.platform.startswith("linux")
and manifest.supervisor_kind == SupervisorKind.SERVICE.value
):
unit_path, content = _linux_service_unit(manifest, artifact_paths["run-headroom.sh"])
unit_path.parent.mkdir(parents=True, exist_ok=True)
unit_path.write_text(content)
flags = [] if manifest.scope == "system" else ["--user"]
subprocess.run(["systemctl", *flags, "daemon-reload"], check=True)
subprocess.run(["systemctl", *flags, "enable", manifest.service_name], check=True)
records.append(ArtifactRecord(kind="service-unit", path=str(unit_path)))
return records
if sys.platform.startswith("linux") and manifest.supervisor_kind == SupervisorKind.TASK.value:
cron_path, content = _linux_task_spec(manifest, artifact_paths["ensure-headroom.sh"])
if cron_path is not None:
cron_path.parent.mkdir(parents=True, exist_ok=True)
cron_path.write_text(content)
records.append(ArtifactRecord(kind="cron", path=str(cron_path)))
else:
current = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
existing = current.stdout if current.returncode == 0 else ""
marker_start = f"# >>> headroom {manifest.profile} >>>"
marker_end = f"# <<< headroom {manifest.profile} <<<"
pattern = re.compile(
re.escape(marker_start) + r".*?" + re.escape(marker_end), re.DOTALL
)
merged = pattern.sub("", existing).strip()
new_content = (merged + "\n\n" + content).strip() + "\n"
subprocess.run(["crontab", "-"], input=new_content, text=True, check=True)
records.append(ArtifactRecord(kind="crontab", path=f"user:{manifest.profile}"))
return records
if sys.platform == "darwin":
if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
plist_path, content = _macos_launchd_plist(manifest, artifact_paths["run-headroom.sh"])
else:
plist_path, content = _macos_launchd_plist(
manifest, artifact_paths["ensure-headroom.sh"], interval=300
)
plist_path.parent.mkdir(parents=True, exist_ok=True)
plist_path.write_text(content)
domain = (
f"system/{plist_path.stem}"
if manifest.scope == "system"
and manifest.supervisor_kind == SupervisorKind.SERVICE.value
else f"gui/{os.getuid()}/{plist_path.stem}"
)
subprocess.run(["launchctl", "bootout", domain], capture_output=True, text=True)
bootstrap_domain = (
"system"
if manifest.scope == "system"
and manifest.supervisor_kind == SupervisorKind.SERVICE.value
else f"gui/{os.getuid()}"
)
subprocess.run(["launchctl", "bootstrap", bootstrap_domain, str(plist_path)], check=True)
records.append(ArtifactRecord(kind="plist", path=str(plist_path)))
return records
if os.name == "nt" and manifest.supervisor_kind == SupervisorKind.SERVICE.value:
service_bin = f'cmd.exe /c "{windows_run_cmd_path(manifest.profile)}"'
subprocess.run(
["sc.exe", "create", manifest.service_name, f"binPath= {service_bin}", "start= auto"],
check=True,
)
subprocess.run(
["sc.exe", "failure", manifest.service_name, "reset= 0", "actions= restart/5000"],
check=True,
)
records.append(ArtifactRecord(kind="windows-service", path=manifest.service_name))
return records
if os.name == "nt" and manifest.supervisor_kind == SupervisorKind.TASK.value:
startup_name = f"{manifest.service_name}-startup"
health_name = f"{manifest.service_name}-health"
startup_cmd = str(windows_ensure_cmd_path(manifest.profile))
user_args = ["/RU", "SYSTEM"] if manifest.scope == "system" else []
start_schedule = [
"schtasks",
"/Create",
"/TN",
startup_name,
"/TR",
startup_cmd,
"/SC",
"ONSTART",
"/F",
*user_args,
]
health_schedule = [
"schtasks",
"/Create",
"/TN",
health_name,
"/TR",
startup_cmd,
"/SC",
"MINUTE",
"/MO",
"5",
"/F",
*user_args,
]
subprocess.run(start_schedule, check=True)
subprocess.run(health_schedule, check=True)
records.extend(
[
ArtifactRecord(kind="windows-task", path=startup_name),
ArtifactRecord(kind="windows-task", path=health_name),
]
)
return records
raise click.ClickException(
f"Persistent {manifest.supervisor_kind} mode is not supported on this platform."
)
def start_supervisor(manifest: DeploymentManifest) -> None:
"""Start the installed supervisor or runtime for a deployment."""
if manifest.supervisor_kind == SupervisorKind.NONE.value:
return
if sys.platform.startswith("linux"):
flags = [] if manifest.scope == "system" else ["--user"]
subprocess.run(["systemctl", *flags, "restart", manifest.service_name], check=True)
return
if sys.platform == "darwin":
label = f"com.headroom.{manifest.profile}"
domain = (
"system"
if manifest.scope == "system"
and manifest.supervisor_kind == SupervisorKind.SERVICE.value
else f"gui/{os.getuid()}"
)
subprocess.run(["launchctl", "kickstart", "-k", f"{domain}/{label}"], check=True)
return
if os.name == "nt" and manifest.supervisor_kind == SupervisorKind.SERVICE.value:
subprocess.run(["sc.exe", "start", manifest.service_name], check=True)
def stop_supervisor(manifest: DeploymentManifest) -> None:
"""Stop the installed supervisor for a deployment."""
if manifest.supervisor_kind == SupervisorKind.NONE.value:
return
if sys.platform.startswith("linux"):
flags = [] if manifest.scope == "system" else ["--user"]
subprocess.run(["systemctl", *flags, "stop", manifest.service_name], check=True)
return
if sys.platform == "darwin":
label = f"com.headroom.{manifest.profile}"
domain = (
"system"
if manifest.scope == "system"
and manifest.supervisor_kind == SupervisorKind.SERVICE.value
else f"gui/{os.getuid()}"
)
subprocess.run(["launchctl", "bootout", f"{domain}/{label}"], check=True)
return
if os.name == "nt" and manifest.supervisor_kind == SupervisorKind.SERVICE.value:
subprocess.run(["sc.exe", "stop", manifest.service_name], check=True)
def remove_supervisor(manifest: DeploymentManifest) -> None:
"""Remove installed service/task artifacts."""
if manifest.supervisor_kind == SupervisorKind.NONE.value:
return
if sys.platform.startswith("linux"):
if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
flags = [] if manifest.scope == "system" else ["--user"]
subprocess.run(
["systemctl", *flags, "disable", "--now", manifest.service_name],
capture_output=True,
text=True,
)
unit_path, _ = _linux_service_unit(manifest, unix_run_script_path(manifest.profile))
if unit_path.exists():
unit_path.unlink()
subprocess.run(["systemctl", *flags, "daemon-reload"], capture_output=True, text=True)
return
cron_path, _ = _linux_task_spec(manifest, unix_ensure_script_path(manifest.profile))
if cron_path and cron_path.exists():
cron_path.unlink()
return
current = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
if current.returncode != 0:
return
marker_start = f"# >>> headroom {manifest.profile} >>>"
marker_end = f"# <<< headroom {manifest.profile} <<<"
pattern = re.compile(re.escape(marker_start) + r".*?" + re.escape(marker_end), re.DOTALL)
content = pattern.sub("", current.stdout).strip()
subprocess.run(
["crontab", "-"], input=(content + "\n") if content else "", text=True, check=True
)
return
if sys.platform == "darwin":
plist_path, _ = _macos_launchd_plist(
manifest,
unix_run_script_path(manifest.profile)
if manifest.supervisor_kind == SupervisorKind.SERVICE.value
else unix_ensure_script_path(manifest.profile),
interval=300 if manifest.supervisor_kind == SupervisorKind.TASK.value else None,
)
label = f"com.headroom.{manifest.profile}"
domain = (
"system"
if manifest.scope == "system"
and manifest.supervisor_kind == SupervisorKind.SERVICE.value
else f"gui/{os.getuid()}"
)
subprocess.run(
["launchctl", "bootout", f"{domain}/{label}"], capture_output=True, text=True
)
if plist_path.exists():
plist_path.unlink()
return
if os.name == "nt":
if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
subprocess.run(
["sc.exe", "stop", manifest.service_name], capture_output=True, text=True
)
subprocess.run(
["sc.exe", "delete", manifest.service_name], capture_output=True, text=True
)
return
subprocess.run(
["schtasks", "/Delete", "/TN", f"{manifest.service_name}-startup", "/F"],
capture_output=True,
text=True,
)
subprocess.run(
["schtasks", "/Delete", "/TN", f"{manifest.service_name}-health", "/F"],
capture_output=True,
text=True,
)

View file

@ -30,35 +30,50 @@ import logging
import re
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any
from typing import TYPE_CHECKING, Any
# LangChain imports - these are optional dependencies
try:
if TYPE_CHECKING:
from langchain_core.callbacks import Callbacks
from langchain_core.documents import Document
# BaseDocumentCompressor location varies by langchain version
try:
from langchain.retrievers.document_compressors import BaseDocumentCompressor
except ImportError:
try:
from langchain_core.documents.compressors import BaseDocumentCompressor
except ImportError:
# Fallback: create a minimal base class
class BaseDocumentCompressor: # type: ignore[no-redef]
"""Minimal base class for document compression."""
def compress_documents(
self, documents: Sequence[Any], query: str, callbacks: Any = None
) -> Sequence[Any]:
raise NotImplementedError
LANGCHAIN_AVAILABLE = True
except ImportError:
LANGCHAIN_AVAILABLE = False
BaseDocumentCompressor = object # type: ignore[misc,assignment]
Document = object # type: ignore[misc,assignment]
Callbacks = None # type: ignore[misc,assignment]
class BaseDocumentCompressor:
"""Type-checking stub for LangChain's document compressor base."""
def compress_documents(
self, documents: Sequence[Any], query: str, callbacks: Any = None
) -> Sequence[Any]:
raise NotImplementedError
# LangChain imports - these are optional dependencies
else:
try:
from langchain_core.callbacks import Callbacks
from langchain_core.documents import Document
# BaseDocumentCompressor location varies by langchain version
try:
from langchain.retrievers.document_compressors import BaseDocumentCompressor
except ImportError:
try:
from langchain_core.documents.compressors import BaseDocumentCompressor
except ImportError:
# Fallback: create a minimal base class
class BaseDocumentCompressor:
"""Minimal base class for document compression."""
def compress_documents(
self, documents: Sequence[Any], query: str, callbacks: Any = None
) -> Sequence[Any]:
raise NotImplementedError
LANGCHAIN_AVAILABLE = True
except ImportError:
LANGCHAIN_AVAILABLE = False
BaseDocumentCompressor = object # type: ignore[misc,assignment]
Document = object # type: ignore[misc,assignment]
Callbacks = None # type: ignore[misc,assignment]
logger = logging.getLogger(__name__)

View file

@ -55,7 +55,8 @@ def _normalize_embeddings_batch(embeddings: np.ndarray) -> np.ndarray:
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
# Avoid division by zero
norms = np.where(norms > 0, norms, 1.0)
return (embeddings / norms).astype(np.float32)
result: np.ndarray = (embeddings / norms).astype(np.float32)
return result
# =============================================================================

View file

@ -144,6 +144,15 @@ from headroom.transforms import (
is_tree_sitter_available,
)
fcntl: Any = None
try:
import fcntl as _fcntl
fcntl = _fcntl
HAS_FCNTL = True
except ImportError:
HAS_FCNTL = False
_build_prefix_cache_stats = build_prefix_cache_stats
_build_session_summary = build_session_summary
_merge_cost_stats = merge_cost_stats
@ -921,34 +930,30 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
Returns True if this process is the beacon owner.
"""
if not HAS_FCNTL:
return True
fd = None
try:
_beacon_lock_path.parent.mkdir(parents=True, exist_ok=True)
import fcntl
fd = open(_beacon_lock_path, "w") # noqa: SIM115
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
fd.write(str(os.getpid()))
fd.flush()
_beacon_lock_fd[0] = fd
return True
except (OSError, ImportError):
# Lock held by another worker, or fcntl not available (Windows)
# On Windows, skip locking — workers are rare on Windows anyway
try:
import fcntl # noqa: F811
return False # Lock held by another worker
except ImportError:
return True # Windows: no fcntl, just allow it
except OSError:
if fd is not None:
fd.close()
return False
def _release_beacon_lock() -> None:
"""Release the beacon file lock."""
fd = _beacon_lock_fd[0]
if fd:
try:
import fcntl
fcntl.flock(fd, fcntl.LOCK_UN)
if HAS_FCNTL:
fcntl.flock(fd, fcntl.LOCK_UN)
fd.close()
except Exception:
pass
@ -1093,6 +1098,15 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
"uptime_seconds": _uptime_seconds(),
"checks": checks,
}
deployment_profile = os.environ.get("HEADROOM_DEPLOYMENT_PROFILE")
if deployment_profile:
payload["deployment"] = {
"profile": deployment_profile,
"preset": os.environ.get("HEADROOM_DEPLOYMENT_PRESET"),
"runtime": os.environ.get("HEADROOM_DEPLOYMENT_RUNTIME"),
"supervisor": os.environ.get("HEADROOM_DEPLOYMENT_SUPERVISOR"),
"scope": os.environ.get("HEADROOM_DEPLOYMENT_SCOPE"),
}
if include_config:
payload["config"] = {
"backend": config.backend,

View file

@ -87,6 +87,7 @@ nav:
- Quickstart: quickstart.md
- Installation: getting-started.md
- Docker-Native Install: docker-install.md
- Persistent Installs: persistent-installs.md
- Configuration: configuration.md
- User Guide:
- Proxy Server: proxy.md

View file

@ -1,6 +1,7 @@
$ErrorActionPreference = 'Stop'
$ImageDefault = 'ghcr.io/chopratejas/headroom:latest'
$InstallImage = if ($env:HEADROOM_DOCKER_IMAGE) { $env:HEADROOM_DOCKER_IMAGE } else { $ImageDefault }
$InstallDir = Join-Path $HOME '.local\bin'
if (-not (Test-Path (Join-Path $HOME '.local'))) {
$InstallDir = Join-Path $HOME 'bin'
@ -37,10 +38,11 @@ function Ensure-ProfileBlock {
$markerStart = '# >>> headroom docker-native >>>'
$markerEnd = '# <<< headroom docker-native <<<'
$escapedPathEntry = $PathEntry.Replace("'", "''")
$block = @"
$markerStart
if (-not ((`$env:Path -split ';') -contains '$PathEntry')) {
`$env:Path = '$PathEntry;' + `$env:Path
if (-not ((`$env:Path -split ';') -contains '$escapedPathEntry')) {
`$env:Path = '$escapedPathEntry;' + `$env:Path
}
$markerEnd
"@
@ -64,11 +66,12 @@ function Write-Wrapper {
$wrapperPath = Join-Path $TargetDir 'headroom.ps1'
$cmdPath = Join-Path $TargetDir 'headroom.cmd'
$resolvedInstallImage = $InstallImage.Replace("'", "''")
$wrapper = @'
$ErrorActionPreference = 'Stop'
$HeadroomImage = if ($env:HEADROOM_DOCKER_IMAGE) { $env:HEADROOM_DOCKER_IMAGE } else { 'ghcr.io/chopratejas/headroom:latest' }
$HeadroomImage = if ($env:HEADROOM_DOCKER_IMAGE) { $env:HEADROOM_DOCKER_IMAGE } else { '__HEADROOM_INSTALL_IMAGE__' }
$ContainerHome = if ($env:HEADROOM_CONTAINER_HOME) { $env:HEADROOM_CONTAINER_HOME } else { '/tmp/headroom-home' }
$HostHome = $HOME
@ -151,11 +154,27 @@ function Get-SharedDockerArgs {
return ,$args.ToArray()
}
function Add-TtyArgs {
param($ArgsList)
if (-not [Console]::IsInputRedirected -and -not [Console]::IsOutputRedirected) {
$ArgsList.Add('-it')
return
}
if (-not [Console]::IsInputRedirected) {
$ArgsList.Add('-i')
}
if (-not [Console]::IsOutputRedirected) {
$ArgsList.Add('-t')
}
}
function Invoke-HeadroomDocker {
param([string[]]$Arguments)
$dockerArgs = New-Object System.Collections.Generic.List[string]
$dockerArgs.AddRange([string[]]@('run','--rm','-it'))
$dockerArgs.AddRange([string[]]@('run','--rm'))
Add-TtyArgs -ArgsList $dockerArgs
$dockerArgs.AddRange((Get-SharedDockerArgs))
$dockerArgs.Add('--entrypoint')
$dockerArgs.Add('headroom')
@ -228,6 +247,574 @@ function Stop-ProxyContainer {
}
}
function Get-PersistentProfileRoot {
param([string]$Profile)
Assert-ValidProfileName -Profile $Profile
return Join-Path (Join-Path $HostHome '.headroom\deploy') $Profile
}
function Get-PersistentStatePath {
param([string]$Profile)
return Join-Path (Get-PersistentProfileRoot -Profile $Profile) 'docker-native.json'
}
function Get-PersistentManifestPath {
param([string]$Profile)
return Join-Path (Get-PersistentProfileRoot -Profile $Profile) 'manifest.json'
}
function Get-PersistentContainerName {
param([string]$Profile)
return "headroom-$Profile"
}
function Assert-ValidProfileName {
param([string]$Profile)
if ($Profile -notmatch '^[A-Za-z0-9._-]+$' -or $Profile -in @('.', '..')) {
Fail "Invalid profile name '$Profile'"
}
}
function Parse-PortValue {
param([string]$Value)
$parsed = 0
if (-not [int]::TryParse($Value, [ref]$parsed) -or $parsed -lt 1 -or $parsed -gt 65535) {
Fail "Invalid port '$Value'"
}
return $parsed
}
function Parse-PositiveIntegerValue {
param([string]$Value)
$parsed = 0
if (-not [int]::TryParse($Value, [ref]$parsed) -or $parsed -lt 1) {
Fail "Invalid value '$Value'"
}
return $parsed
}
function Require-OptionValue {
param(
[string[]]$Arguments,
[int]$Index,
[string]$Option
)
if ($Index + 1 -ge $Arguments.Count) {
Fail "Option $Option requires a value"
}
}
function Write-Utf8NoBomFile {
param(
[string]$Path,
[string]$Content
)
[System.IO.File]::WriteAllText($Path, $Content, [System.Text.UTF8Encoding]::new($false))
}
function Get-PersistentDockerArgs {
Ensure-HostDirs
$args = New-Object System.Collections.Generic.List[string]
$args.Add('--workdir')
$args.Add($ContainerHome)
$args.Add('--env')
$args.Add("HOME=$ContainerHome")
$args.Add('--env')
$args.Add('PYTHONUNBUFFERED=1')
$args.Add('--volume')
$args.Add((Join-Path $HostHome '.headroom') + ":$ContainerHome/.headroom")
$args.Add('--volume')
$args.Add((Join-Path $HostHome '.claude') + ":$ContainerHome/.claude")
$args.Add('--volume')
$args.Add((Join-Path $HostHome '.codex') + ":$ContainerHome/.codex")
$args.Add('--volume')
$args.Add((Join-Path $HostHome '.gemini') + ":$ContainerHome/.gemini")
foreach ($entry in (Get-PassthroughEnvArgs)) {
$args.Add($entry)
}
return ,$args.ToArray()
}
function Get-ManifestProxyArgs {
param(
[int]$Port,
[string]$Backend,
[string]$AnyllmProvider,
[string]$Region,
[string]$Mode,
[bool]$Memory,
[bool]$TelemetryEnabled
)
$args = New-Object System.Collections.Generic.List[string]
$args.AddRange([string[]]@('--host','127.0.0.1','--port',"$Port",'--mode',$Mode,'--backend',$Backend))
if (-not $TelemetryEnabled) {
$args.Add('--no-telemetry')
}
if ($Memory) {
$args.AddRange([string[]]@('--memory','--memory-db-path',"$ContainerHome/.headroom/memory.db"))
}
if ($AnyllmProvider) {
$args.AddRange([string[]]@('--anyllm-provider', $AnyllmProvider))
}
if ($Region) {
$args.AddRange([string[]]@('--region', $Region))
}
return ,$args.ToArray()
}
function Write-PersistentState {
param(
[string]$Profile,
[string]$Image,
[int]$Port,
[string]$Backend,
[string]$AnyllmProvider,
[string]$Region,
[string]$Mode,
[bool]$Memory,
[bool]$TelemetryEnabled
)
$root = Get-PersistentProfileRoot -Profile $Profile
New-Item -ItemType Directory -Force -Path $root | Out-Null
$state = [ordered]@{
profile = $Profile
image = $Image
port = $Port
backend = $Backend
anyllm_provider = $AnyllmProvider
region = $Region
proxy_mode = $Mode
memory_enabled = $Memory
telemetry_enabled = $TelemetryEnabled
container_name = Get-PersistentContainerName -Profile $Profile
health_url = "http://127.0.0.1:$Port/readyz"
}
Write-Utf8NoBomFile -Path (Get-PersistentStatePath -Profile $Profile) -Content ($state | ConvertTo-Json -Depth 4)
}
function Write-PersistentManifest {
param(
[string]$Profile,
[string]$Image,
[int]$Port,
[string]$Backend,
[string]$AnyllmProvider,
[string]$Region,
[string]$Mode,
[bool]$Memory,
[bool]$TelemetryEnabled,
[string[]]$ProxyArgs
)
$root = Get-PersistentProfileRoot -Profile $Profile
New-Item -ItemType Directory -Force -Path $root | Out-Null
$baseEnv = [ordered]@{
HEADROOM_PORT = "$Port"
HEADROOM_HOST = '127.0.0.1'
HEADROOM_MODE = $Mode
HEADROOM_BACKEND = $Backend
}
$manifest = [ordered]@{
profile = $Profile
preset = 'persistent-docker'
runtime_kind = 'docker'
supervisor_kind = 'none'
scope = 'user'
provider_mode = 'manual'
targets = @()
port = $Port
host = '127.0.0.1'
backend = $Backend
anyllm_provider = if ($AnyllmProvider) { $AnyllmProvider } else { $null }
region = if ($Region) { $Region } else { $null }
proxy_mode = $Mode
memory_enabled = $Memory
memory_db_path = "$ContainerHome/.headroom/memory.db"
telemetry_enabled = $TelemetryEnabled
image = $Image
service_name = "headroom-$Profile"
container_name = Get-PersistentContainerName -Profile $Profile
health_url = "http://127.0.0.1:$Port/readyz"
base_env = $baseEnv
tool_envs = @{}
proxy_args = $ProxyArgs
mutations = @()
artifacts = @()
}
Write-Utf8NoBomFile -Path (Get-PersistentManifestPath -Profile $Profile) -Content ($manifest | ConvertTo-Json -Depth 8)
}
function Read-PersistentState {
param([string]$Profile)
Assert-ValidProfileName -Profile $Profile
$statePath = Get-PersistentStatePath -Profile $Profile
if (-not (Test-Path $statePath)) {
Fail "No docker-native persistent deployment profile named '$Profile'"
}
return Get-Content -Raw -Path $statePath | ConvertFrom-Json
}
function Start-PersistentDockerInstall {
param(
[string]$Profile,
[string]$Image,
[int]$Port,
[string]$Backend,
[string]$AnyllmProvider,
[string]$Region,
[string]$Mode,
[bool]$Memory,
[bool]$TelemetryEnabled
)
Assert-ValidProfileName -Profile $Profile
$containerName = Get-PersistentContainerName -Profile $Profile
$proxyArgs = Get-ManifestProxyArgs -Port $Port -Backend $Backend -AnyllmProvider $AnyllmProvider -Region $Region -Mode $Mode -Memory $Memory -TelemetryEnabled $TelemetryEnabled
docker rm -f $containerName | Out-Null 2>$null
$dockerArgs = New-Object System.Collections.Generic.List[string]
$dockerArgs.AddRange([string[]]@('run','-d','--restart','unless-stopped','--name',$containerName,'-p',"$Port`:$Port"))
$dockerArgs.AddRange((Get-PersistentDockerArgs))
$dockerArgs.AddRange([string[]]@(
'--env',"HEADROOM_DEPLOYMENT_PROFILE=$Profile",
'--env','HEADROOM_DEPLOYMENT_PRESET=persistent-docker',
'--env','HEADROOM_DEPLOYMENT_RUNTIME=docker',
'--env','HEADROOM_DEPLOYMENT_SUPERVISOR=none',
'--env','HEADROOM_DEPLOYMENT_SCOPE=user'
))
$dockerArgs.Add($Image)
$dockerArgs.Add('--host')
$dockerArgs.Add('0.0.0.0')
for ($i = 2; $i -lt $proxyArgs.Count; $i++) {
$dockerArgs.Add($proxyArgs[$i])
}
& docker @dockerArgs | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Failed to start docker-native persistent deployment"
}
try {
Wait-Proxy -ContainerName $containerName -Port $Port
} catch {
docker rm -f $containerName | Out-Null 2>$null
throw
}
Write-PersistentState -Profile $Profile -Image $Image -Port $Port -Backend $Backend -AnyllmProvider $AnyllmProvider -Region $Region -Mode $Mode -Memory $Memory -TelemetryEnabled $TelemetryEnabled
Write-PersistentManifest -Profile $Profile -Image $Image -Port $Port -Backend $Backend -AnyllmProvider $AnyllmProvider -Region $Region -Mode $Mode -Memory $Memory -TelemetryEnabled $TelemetryEnabled -ProxyArgs $proxyArgs
}
function Stop-PersistentDockerInstall {
param([string]$Profile)
$state = Read-PersistentState -Profile $Profile
docker stop $state.container_name | Out-Null 2>$null
docker rm -f $state.container_name | Out-Null 2>$null
}
function Remove-PersistentDockerInstall {
param([string]$Profile)
$state = Read-PersistentState -Profile $Profile
docker stop $state.container_name | Out-Null 2>$null
docker rm -f $state.container_name | Out-Null 2>$null
$root = Get-PersistentProfileRoot -Profile $Profile
if (Test-Path $root) {
Remove-Item -Recurse -Force -Path $root
}
}
function Show-PersistentDockerInstallStatus {
param([string]$Profile)
$state = Read-PersistentState -Profile $Profile
$status = 'stopped'
$ready = 'no'
$running = docker ps --format '{{.Names}}'
if ($running -contains $state.container_name) {
$status = 'running'
try {
Invoke-WebRequest -UseBasicParsing -Uri $state.health_url | Out-Null
$ready = 'yes'
} catch {
$ready = 'no'
}
}
Write-Host "Profile: $($state.profile)"
Write-Host 'Preset: persistent-docker'
Write-Host 'Runtime: docker'
Write-Host 'Supervisor: none'
Write-Host "Port: $($state.port)"
Write-Host "Status: $status"
Write-Host "Ready: $ready"
Write-Host "Health URL: $($state.health_url)"
}
function Show-InstallHelp {
$lines = @(
'Usage: headroom install [OPTIONS] COMMAND [ARGS]...',
'',
' Manage persistent Docker-native Headroom deployments.',
'',
' The Docker-native wrapper currently supports the persistent-docker preset only.',
' Use the Python-native `headroom install` command for persistent-service and',
' persistent-task installs, or when you need provider/user/system config mutation.',
'',
'Options:',
' -?, --help Show this message and exit.',
'',
'Commands:',
' apply Install a persistent Docker deployment.',
' remove Remove a persistent Docker deployment.',
' restart Restart a persistent Docker deployment.',
' start Start a persistent Docker deployment.',
' status Show persistent Docker deployment status.',
' stop Stop a persistent Docker deployment.'
)
Write-Host ($lines -join [Environment]::NewLine)
}
function Show-InstallApplyHelp {
$lines = @(
'Usage: headroom install apply [OPTIONS]',
'',
' Install a persistent Docker deployment.',
'',
'Options:',
' --preset [persistent-docker] Docker-native wrapper supports persistent-docker only.',
' --runtime [docker] Docker-native wrapper supports runtime=docker only.',
' --profile TEXT Deployment profile name. [default: default]',
' -p, --port INTEGER Persistent proxy port. [default: 8787]',
' --backend TEXT Proxy backend. [default: anthropic]',
' --anyllm-provider TEXT Provider for any-llm backends.',
' --region TEXT Cloud region for Bedrock / Vertex style backends.',
' --mode TEXT Proxy optimization mode. [default: token]',
' --memory Enable persistent memory in the runtime.',
' --no-telemetry Disable anonymous telemetry in the runtime.',
' --image TEXT Docker image to use. [default: HEADROOM_DOCKER_IMAGE or ghcr.io/chopratejas/headroom:latest]',
' -?, --help Show this message and exit.'
)
Write-Host ($lines -join [Environment]::NewLine)
}
function Show-WrapHelp {
$lines = @(
'Usage: headroom wrap <COMMAND> [OPTIONS] [-- ARGS...]',
'',
' Launch supported host tools through a Docker-native Headroom proxy.',
'',
'Supported commands:',
' claude',
' codex',
' aider',
' cursor',
' openclaw',
'',
'Notes:',
' - GitHub Copilot CLI wrapping is not supported by the Docker-native wrapper.',
' - Use the Python-native CLI for unsupported wrap targets.'
)
Write-Host ($lines -join [Environment]::NewLine)
}
function Parse-InstallApplyArgs {
param([string[]]$Arguments)
$profile = 'default'
$port = 8787
$backend = 'anthropic'
$anyllmProvider = $null
$region = $null
$mode = 'token'
$memory = $false
$telemetryEnabled = $true
$image = $HeadroomImage
$i = 0
while ($i -lt $Arguments.Count) {
$arg = $Arguments[$i]
switch -Regex ($arg) {
'^--preset$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--preset'
if ($Arguments[$i + 1] -ne 'persistent-docker') { Fail 'Docker-native wrapper supports only --preset persistent-docker' }
$i += 2
continue
}
'^--preset=' {
if (($arg -replace '^--preset=', '') -ne 'persistent-docker') { Fail 'Docker-native wrapper supports only --preset persistent-docker' }
$i += 1
continue
}
'^--runtime$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--runtime'
if ($Arguments[$i + 1] -ne 'docker') { Fail 'Docker-native wrapper supports only --runtime docker' }
$i += 2
continue
}
'^--runtime=' {
if (($arg -replace '^--runtime=', '') -ne 'docker') { Fail 'Docker-native wrapper supports only --runtime docker' }
$i += 1
continue
}
'^(--scope|--providers|--target)$' { Fail 'Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows' }
'^(--scope=|--providers=|--target=)' { Fail 'Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows' }
'^--profile$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--profile'
$profile = $Arguments[$i + 1]
$i += 2
continue
}
'^--profile=' {
$profile = $arg -replace '^--profile=', ''
$i += 1
continue
}
'^(--port|-p)$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$port = Parse-PortValue -Value $Arguments[$i + 1]
$i += 2
continue
}
'^(--port=|-p=)' {
$port = Parse-PortValue -Value ($arg -replace '^(--port=|-p=)', '')
$i += 1
continue
}
'^--backend$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--backend'
$backend = $Arguments[$i + 1]
$i += 2
continue
}
'^--backend=' {
$backend = $arg -replace '^--backend=', ''
$i += 1
continue
}
'^--anyllm-provider$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--anyllm-provider'
$anyllmProvider = $Arguments[$i + 1]
$i += 2
continue
}
'^--anyllm-provider=' {
$anyllmProvider = $arg -replace '^--anyllm-provider=', ''
$i += 1
continue
}
'^--region$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--region'
$region = $Arguments[$i + 1]
$i += 2
continue
}
'^--region=' {
$region = $arg -replace '^--region=', ''
$i += 1
continue
}
'^--mode$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--mode'
$mode = $Arguments[$i + 1]
$i += 2
continue
}
'^--mode=' {
$mode = $arg -replace '^--mode=', ''
$i += 1
continue
}
'^--memory$' {
$memory = $true
$i += 1
continue
}
'^--no-telemetry$' {
$telemetryEnabled = $false
$i += 1
continue
}
'^--image$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--image'
$image = $Arguments[$i + 1]
$i += 2
continue
}
'^--image=' {
$image = $arg -replace '^--image=', ''
$i += 1
continue
}
'^(--help|-\?)$' {
Show-InstallApplyHelp
exit 0
}
default {
Fail "Unsupported option for 'headroom install apply': $arg"
}
}
}
return [pscustomobject]@{
Profile = $profile
Port = $port
Backend = $backend
AnyllmProvider = $anyllmProvider
Region = $region
Mode = $mode
Memory = $memory
TelemetryEnabled = $telemetryEnabled
Image = $image
}
}
function Parse-InstallProfileArgs {
param([string[]]$Arguments)
$profile = 'default'
$i = 0
while ($i -lt $Arguments.Count) {
$arg = $Arguments[$i]
switch -Regex ($arg) {
'^--profile$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option '--profile'
$profile = $Arguments[$i + 1]
$i += 2
continue
}
'^--profile=' {
$profile = $arg -replace '^--profile=', ''
$i += 1
continue
}
'^(--help|-\?)$' {
Show-InstallHelp
exit 0
}
default {
Fail "Unsupported option for 'headroom install': $arg"
}
}
}
return $profile
}
function Invoke-ClaudeRtkInit {
$rtkPath = Join-Path $HostHome '.headroom\bin\rtk.exe'
if (-not (Test-Path $rtkPath)) {
@ -300,6 +887,7 @@ function Parse-OpenClawWrapArgs {
$arg = $Arguments[$i]
switch -Regex ($arg) {
'^--plugin-path$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$pluginPath = $Arguments[$i + 1]
$i += 2
continue
@ -310,6 +898,7 @@ function Parse-OpenClawWrapArgs {
continue
}
'^--plugin-spec$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$pluginSpec = $Arguments[$i + 1]
$i += 2
continue
@ -330,26 +919,29 @@ function Parse-OpenClawWrapArgs {
continue
}
'^--proxy-port$' {
$proxyPort = [int]$Arguments[$i + 1]
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$proxyPort = Parse-PortValue -Value $Arguments[$i + 1]
$i += 2
continue
}
'^--proxy-port=' {
$proxyPort = [int]($arg -replace '^--proxy-port=', '')
$proxyPort = Parse-PortValue -Value ($arg -replace '^--proxy-port=', '')
$i += 1
continue
}
'^--startup-timeout-ms$' {
$startupTimeoutMs = [int]$Arguments[$i + 1]
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$startupTimeoutMs = Parse-PositiveIntegerValue -Value $Arguments[$i + 1]
$i += 2
continue
}
'^--startup-timeout-ms=' {
$startupTimeoutMs = [int]($arg -replace '^--startup-timeout-ms=', '')
$startupTimeoutMs = Parse-PositiveIntegerValue -Value ($arg -replace '^--startup-timeout-ms=', '')
$i += 1
continue
}
'^--gateway-provider-id$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$gatewayProviderIds.Add($Arguments[$i + 1])
$i += 2
continue
@ -360,6 +952,7 @@ function Parse-OpenClawWrapArgs {
continue
}
'^--python-path$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$pythonPath = $Arguments[$i + 1]
$i += 2
continue
@ -762,7 +1355,7 @@ function Parse-WrapArgs {
param([string[]]$Arguments)
$known = New-Object System.Collections.Generic.List[string]
$host = New-Object System.Collections.Generic.List[string]
$hostArgs = New-Object System.Collections.Generic.List[string]
$port = 8787
$noRtk = $false
$noProxy = $false
@ -777,20 +1370,21 @@ function Parse-WrapArgs {
switch -Regex ($arg) {
'^--$' {
for ($j = $i + 1; $j -lt $Arguments.Count; $j++) {
$host.Add($Arguments[$j])
$hostArgs.Add($Arguments[$j])
}
$i = $Arguments.Count
continue
}
'^--port$|^-p$' {
$port = [int]$Arguments[$i + 1]
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$port = Parse-PortValue -Value $Arguments[$i + 1]
$known.Add($arg)
$known.Add($Arguments[$i + 1])
$i += 2
continue
}
'^--port=' {
$port = [int]($arg -replace '^--port=', '')
$port = Parse-PortValue -Value ($arg -replace '^--port=', '')
$known.Add($arg)
$i += 1
continue
@ -819,6 +1413,7 @@ function Parse-WrapArgs {
continue
}
'^--backend$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$backend = $Arguments[$i + 1]
$known.Add($arg)
$known.Add($Arguments[$i + 1])
@ -832,6 +1427,7 @@ function Parse-WrapArgs {
continue
}
'^--anyllm-provider$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$anyllm = $Arguments[$i + 1]
$known.Add($arg)
$known.Add($Arguments[$i + 1])
@ -845,6 +1441,7 @@ function Parse-WrapArgs {
continue
}
'^--region$' {
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
$region = $Arguments[$i + 1]
$known.Add($arg)
$known.Add($Arguments[$i + 1])
@ -859,7 +1456,7 @@ function Parse-WrapArgs {
}
default {
for ($j = $i; $j -lt $Arguments.Count; $j++) {
$host.Add($Arguments[$j])
$hostArgs.Add($Arguments[$j])
}
$i = $Arguments.Count
}
@ -868,7 +1465,7 @@ function Parse-WrapArgs {
[pscustomobject]@{
KnownArgs = $known.ToArray()
HostArgs = $host.ToArray()
HostArgs = $hostArgs.ToArray()
Port = $port
NoRtk = $noRtk
NoProxy = $noProxy
@ -886,7 +1483,8 @@ function Invoke-PrepareOnly {
)
$dockerArgs = New-Object System.Collections.Generic.List[string]
$dockerArgs.AddRange([string[]]@('run','--rm','-it'))
$dockerArgs.AddRange([string[]]@('run','--rm'))
Add-TtyArgs -ArgsList $dockerArgs
$dockerArgs.AddRange((Get-SharedDockerArgs))
$dockerArgs.Add('--env')
$dockerArgs.Add("HEADROOM_RTK_TARGET=$(Get-RtkTarget)")
@ -912,9 +1510,61 @@ if ($args.Count -eq 0) {
}
switch ($args[0]) {
'install' {
if ($args.Count -eq 1 -or $args[1] -eq '--help' -or $args[1] -eq '-?') {
Show-InstallHelp
exit 0
}
$installCommand = $args[1]
$installArgs = if ($args.Count -gt 2) { $args[2..($args.Count - 1)] } else { @() }
switch ($installCommand) {
'apply' {
$parsed = Parse-InstallApplyArgs -Arguments $installArgs
Start-PersistentDockerInstall -Profile $parsed.Profile -Image $parsed.Image -Port $parsed.Port -Backend $parsed.Backend -AnyllmProvider $parsed.AnyllmProvider -Region $parsed.Region -Mode $parsed.Mode -Memory $parsed.Memory -TelemetryEnabled $parsed.TelemetryEnabled
Write-Host "Installed docker-native persistent deployment '$($parsed.Profile)' on port $($parsed.Port)."
exit 0
}
'status' {
$profile = Parse-InstallProfileArgs -Arguments $installArgs
Show-PersistentDockerInstallStatus -Profile $profile
exit 0
}
'start' {
$profile = Parse-InstallProfileArgs -Arguments $installArgs
$state = Read-PersistentState -Profile $profile
Start-PersistentDockerInstall -Profile $state.profile -Image $state.image -Port $state.port -Backend $state.backend -AnyllmProvider $state.anyllm_provider -Region $state.region -Mode $state.proxy_mode -Memory ([bool]$state.memory_enabled) -TelemetryEnabled ([bool]$state.telemetry_enabled)
Write-Host "Started docker-native persistent deployment '$profile'."
exit 0
}
'stop' {
$profile = Parse-InstallProfileArgs -Arguments $installArgs
Stop-PersistentDockerInstall -Profile $profile
Write-Host "Stopped docker-native persistent deployment '$profile'."
exit 0
}
'restart' {
$profile = Parse-InstallProfileArgs -Arguments $installArgs
$state = Read-PersistentState -Profile $profile
Start-PersistentDockerInstall -Profile $state.profile -Image $state.image -Port $state.port -Backend $state.backend -AnyllmProvider $state.anyllm_provider -Region $state.region -Mode $state.proxy_mode -Memory ([bool]$state.memory_enabled) -TelemetryEnabled ([bool]$state.telemetry_enabled)
Write-Host "Restarted docker-native persistent deployment '$profile'."
exit 0
}
'remove' {
$profile = Parse-InstallProfileArgs -Arguments $installArgs
Remove-PersistentDockerInstall -Profile $profile
Write-Host "Removed docker-native persistent deployment '$profile'."
exit 0
}
default {
Fail "Unsupported install target: $installCommand"
}
}
}
'wrap' {
if ($args.Count -eq 1 -or $args[1] -eq '--help' -or $args[1] -eq '-?') {
Invoke-HeadroomDocker -Arguments @('wrap','--help')
Show-WrapHelp
exit 0
}
@ -925,6 +1575,17 @@ switch ($args[0]) {
$tool = $args[1]
$wrapArgs = if ($args.Count -gt 2) { $args[2..($args.Count - 1)] } else { @() }
switch ($tool) {
'claude' { }
'codex' { }
'aider' { }
'cursor' { }
'openclaw' { }
default {
Fail "Docker-native wrapper does not support 'wrap $tool'. Supported targets: claude, codex, aider, cursor, openclaw"
}
}
if ($tool -eq 'openclaw') {
if (Test-HelpFlag -Arguments $wrapArgs) {
$helpArgs = @('wrap','openclaw') + $wrapArgs
@ -949,14 +1610,6 @@ switch ($args[0]) {
if ($parsed.Anyllm) { $proxyArgs.AddRange([string[]]@('--anyllm-provider', $parsed.Anyllm)) }
if ($parsed.Region) { $proxyArgs.AddRange([string[]]@('--region', $parsed.Region)) }
switch ($tool) {
'claude' { }
'codex' { }
'aider' { }
'cursor' { }
default { Fail "Unsupported wrap target: $tool" }
}
$containerName = $null
try {
if (-not $parsed.NoProxy) {
@ -1028,17 +1681,20 @@ switch ($args[0]) {
foreach ($arg in $args) { $forwardArgs.Add($arg) }
for ($i = 1; $i -lt $args.Count; $i++) {
if ($args[$i] -eq '--port' -or $args[$i] -eq '-p') {
$port = [int]$args[$i + 1]
Require-OptionValue -Arguments $args -Index $i -Option $args[$i]
$port = Parse-PortValue -Value $args[$i + 1]
break
}
if ($args[$i] -match '^--port=') {
$port = [int]($args[$i] -replace '^--port=', '')
$port = Parse-PortValue -Value ($args[$i] -replace '^--port=', '')
break
}
}
$dockerArgs = New-Object System.Collections.Generic.List[string]
$dockerArgs.AddRange([string[]]@('run','--rm','-it','-p',"$port`:$port"))
$dockerArgs.AddRange([string[]]@('run','--rm'))
Add-TtyArgs -ArgsList $dockerArgs
$dockerArgs.AddRange([string[]]@('-p',"$port`:$port"))
$dockerArgs.AddRange((Get-SharedDockerArgs))
$dockerArgs.Add('--entrypoint')
$dockerArgs.Add('headroom')
@ -1056,34 +1712,46 @@ switch ($args[0]) {
}
'@
$cmdWrapper = @'
@echo off
powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0headroom.ps1" %*
'@
$wrapper = $wrapper.Replace('__HEADROOM_INSTALL_IMAGE__', $resolvedInstallImage)
Set-Content -Path $wrapperPath -Value $wrapper
Set-Content -Path $cmdPath -Value $cmdWrapper
$cmdWrapper = ([string][char]64) + "echo off`r`npowershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File ""%~dp0headroom.ps1"" %*`r`n"
Set-Content -Path $wrapperPath -Value $wrapper -Encoding utf8
Set-Content -Path $cmdPath -Value $cmdWrapper -Encoding ascii
}
Require-Command docker
docker version | Out-Null
if ($LASTEXITCODE -ne 0) {
throw 'Docker is installed but not available to the current user'
}
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
Write-Wrapper -TargetDir $InstallDir
Ensure-PathEntry -PathEntry $InstallDir
Ensure-ProfileBlock -PathEntry $InstallDir
Write-Info "Pulling $ImageDefault"
docker pull $ImageDefault | Out-Null
if ($env:HEADROOM_DOCKER_IMAGE) {
$null = docker image inspect $InstallImage 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Info "Using existing HEADROOM_DOCKER_IMAGE=$InstallImage"
} else {
Write-Info "Pulling $InstallImage"
docker pull $InstallImage | Out-Null
}
} else {
Write-Info "Pulling $ImageDefault"
docker pull $ImageDefault | Out-Null
}
Write-Host ''
Write-Host 'Headroom Docker-native install complete.'
Write-Host ''
Write-Host ""
Write-Host "Headroom Docker-native install complete."
Write-Host ""
Write-Host "Installed wrappers:"
Write-Host " $InstallDir\headroom.ps1"
Write-Host " $InstallDir\headroom.cmd"
Write-Host ''
Write-Host 'Next steps:'
Write-Host ""
Write-Host "Next steps:"
Write-Host " 1. Restart PowerShell"
Write-Host " 2. Try: headroom proxy"
Write-Host " 3. Docs: https://github.com/chopratejas/headroom/blob/main/docs/docker-install.md"

View file

@ -3,11 +3,18 @@
set -euo pipefail
IMAGE_DEFAULT="ghcr.io/chopratejas/headroom:latest"
INSTALL_IMAGE="${HEADROOM_DOCKER_IMAGE:-${IMAGE_DEFAULT}}"
INSTALL_DIR="${HOME}/.local/bin"
if [[ ! -d "${HOME}/.local" ]]; then
INSTALL_DIR="${HOME}/bin"
fi
BASH_PATH="${BASH:-$(command -v bash)}"
if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3))); then
printf 'ERROR: Headroom Docker-native install requires bash >= 4.3\n' >&2
exit 1
fi
info() {
printf '==> %s\n' "$*"
}
@ -46,15 +53,22 @@ ${marker_end}"
write_wrapper() {
local wrapper_path="${INSTALL_DIR}/headroom"
cat >"${wrapper_path}" <<'WRAPPER'
#!/usr/bin/env bash
{
printf '#!%s\n\n' "${BASH_PATH}"
printf 'HEADROOM_IMAGE_DEFAULT=%q\n' "${INSTALL_IMAGE}"
cat <<'WRAPPER'
set -euo pipefail
HEADROOM_IMAGE="${HEADROOM_DOCKER_IMAGE:-ghcr.io/chopratejas/headroom:latest}"
HEADROOM_IMAGE="${HEADROOM_DOCKER_IMAGE:-${HEADROOM_IMAGE_DEFAULT}}"
HEADROOM_CONTAINER_HOME="${HEADROOM_CONTAINER_HOME:-/tmp/headroom-home}"
HEADROOM_HOST_HOME="${HOME:?}"
if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3))); then
printf 'ERROR: Headroom Docker-native wrapper requires bash >= 4.3\n' >&2
exit 1
fi
warn() {
printf 'WARN: %s\n' "$*" >&2
}
@ -168,7 +182,11 @@ wait_for_proxy() {
local attempt
for attempt in $(seq 1 45); do
if (echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1; then
if command -v curl >/dev/null 2>&1; then
if curl --fail --silent "http://127.0.0.1:${port}/readyz" >/dev/null; then
return 0
fi
elif (echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1; then
return 0
fi
@ -209,6 +227,557 @@ stop_proxy_container() {
fi
}
persistent_profile_root() {
local profile="$1"
validate_profile_name "${profile}"
printf '%s/.headroom/deploy/%s\n' "${HEADROOM_HOST_HOME}" "${profile}"
}
persistent_state_path() {
local profile="$1"
printf '%s/docker-native.env\n' "$(persistent_profile_root "${profile}")"
}
persistent_manifest_path() {
local profile="$1"
printf '%s/manifest.json\n' "$(persistent_profile_root "${profile}")"
}
persistent_container_name() {
local profile="$1"
validate_profile_name "${profile}"
printf 'headroom-%s\n' "${profile}"
}
validate_profile_name() {
local profile="$1"
[[ "${profile}" =~ ^[A-Za-z0-9._-]+$ ]] || die "Invalid profile name '${profile}'"
[[ "${profile}" != "." && "${profile}" != ".." ]] || die "Invalid profile name '${profile}'"
}
validate_port() {
local port="$1"
[[ "${port}" =~ ^[0-9]+$ ]] || die "Invalid port '${port}'"
((10#${port} >= 1 && 10#${port} <= 65535)) || die "Invalid port '${port}'"
}
validate_positive_integer() {
local value="$1"
[[ "${value}" =~ ^[0-9]+$ ]] || die "Invalid value '${value}'"
((10#${value} >= 1)) || die "Invalid value '${value}'"
}
require_option_value() {
(($# >= 2)) || die "Option $1 requires a value"
}
json_escape() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
value="${value//$'\n'/\\n}"
printf '%s' "${value}"
}
json_array_from_args() {
local first=1
local arg
printf '['
for arg in "$@"; do
if [[ "${first}" -eq 0 ]]; then
printf ','
fi
first=0
printf '"%s"' "$(json_escape "${arg}")"
done
printf ']'
}
append_persistent_container_args() {
local -n ref=$1
ensure_host_dirs
ref+=(--workdir "${HEADROOM_CONTAINER_HOME}")
ref+=(--env "HOME=${HEADROOM_CONTAINER_HOME}")
ref+=(--env "PYTHONUNBUFFERED=1")
ref+=(-v "${HEADROOM_HOST_HOME}/.headroom:${HEADROOM_CONTAINER_HOME}/.headroom")
ref+=(-v "${HEADROOM_HOST_HOME}/.claude:${HEADROOM_CONTAINER_HOME}/.claude")
ref+=(-v "${HEADROOM_HOST_HOME}/.codex:${HEADROOM_CONTAINER_HOME}/.codex")
ref+=(-v "${HEADROOM_HOST_HOME}/.gemini:${HEADROOM_CONTAINER_HOME}/.gemini")
if command -v id >/dev/null 2>&1; then
ref+=(--user "$(id -u):$(id -g)")
fi
append_passthrough_envs "$1"
}
build_manifest_proxy_args() {
local -n out_args=$1
local port="$2"
local proxy_mode="$3"
local backend="$4"
local anyllm="$5"
local region="$6"
local memory_enabled="$7"
local telemetry_enabled="$8"
out_args=(--host 127.0.0.1 --port "${port}" --mode "${proxy_mode}" --backend "${backend}")
if [[ "${telemetry_enabled}" -eq 0 ]]; then
out_args+=(--no-telemetry)
fi
if [[ "${memory_enabled}" -eq 1 ]]; then
out_args+=(--memory --memory-db-path "${HEADROOM_CONTAINER_HOME}/.headroom/memory.db")
fi
if [[ -n "${anyllm}" ]]; then
out_args+=(--anyllm-provider "${anyllm}")
fi
if [[ -n "${region}" ]]; then
out_args+=(--region "${region}")
fi
}
write_persistent_state() {
local profile="$1"
local image="$2"
local port="$3"
local backend="$4"
local anyllm="$5"
local region="$6"
local proxy_mode="$7"
local memory_enabled="$8"
local telemetry_enabled="$9"
local root
root="$(persistent_profile_root "${profile}")"
mkdir -p "${root}"
{
printf 'PROFILE=%s\n' "${profile}"
printf 'IMAGE=%s\n' "${image}"
printf 'PORT=%s\n' "${port}"
printf 'BACKEND=%s\n' "${backend}"
printf 'ANYLLM_PROVIDER=%s\n' "${anyllm}"
printf 'REGION=%s\n' "${region}"
printf 'PROXY_MODE=%s\n' "${proxy_mode}"
printf 'MEMORY_ENABLED=%s\n' "${memory_enabled}"
printf 'TELEMETRY_ENABLED=%s\n' "${telemetry_enabled}"
printf 'CONTAINER_NAME=%s\n' "$(persistent_container_name "${profile}")"
printf 'HEALTH_URL=%s\n' "http://127.0.0.1:${port}/readyz"
} >"$(persistent_state_path "${profile}")"
}
write_persistent_manifest() {
local profile="$1"
local image="$2"
local port="$3"
local backend="$4"
local anyllm="$5"
local region="$6"
local proxy_mode="$7"
local memory_enabled="$8"
local telemetry_enabled="$9"
local -n proxy_args_ref=${10}
local root
local manifest_path
local anyllm_json="null"
local region_json="null"
local memory_json="false"
local telemetry_json="true"
root="$(persistent_profile_root "${profile}")"
manifest_path="$(persistent_manifest_path "${profile}")"
mkdir -p "${root}"
if [[ -n "${anyllm}" ]]; then
anyllm_json="\"$(json_escape "${anyllm}")\""
fi
if [[ -n "${region}" ]]; then
region_json="\"$(json_escape "${region}")\""
fi
if [[ "${memory_enabled}" -eq 1 ]]; then
memory_json="true"
fi
if [[ "${telemetry_enabled}" -eq 0 ]]; then
telemetry_json="false"
fi
cat >"${manifest_path}" <<EOF
{
"profile": "$(json_escape "${profile}")",
"preset": "persistent-docker",
"runtime_kind": "docker",
"supervisor_kind": "none",
"scope": "user",
"provider_mode": "manual",
"targets": [],
"port": ${port},
"host": "127.0.0.1",
"backend": "$(json_escape "${backend}")",
"anyllm_provider": ${anyllm_json},
"region": ${region_json},
"proxy_mode": "$(json_escape "${proxy_mode}")",
"memory_enabled": ${memory_json},
"memory_db_path": "$(json_escape "${HEADROOM_CONTAINER_HOME}/.headroom/memory.db")",
"telemetry_enabled": ${telemetry_json},
"image": "$(json_escape "${image}")",
"service_name": "headroom-$(json_escape "${profile}")",
"container_name": "$(json_escape "$(persistent_container_name "${profile}")")",
"health_url": "http://127.0.0.1:${port}/readyz",
"base_env": {
"HEADROOM_PORT": "${port}",
"HEADROOM_HOST": "127.0.0.1",
"HEADROOM_MODE": "$(json_escape "${proxy_mode}")",
"HEADROOM_BACKEND": "$(json_escape "${backend}")"
},
"tool_envs": {},
"proxy_args": $(json_array_from_args "${proxy_args_ref[@]}"),
"mutations": [],
"artifacts": []
}
EOF
}
load_persistent_state() {
local profile="$1"
local state_path
validate_profile_name "${profile}"
state_path="$(persistent_state_path "${profile}")"
[[ -f "${state_path}" ]] || die "No docker-native persistent deployment profile named '${profile}'"
PROFILE=""
IMAGE=""
PORT=""
BACKEND=""
ANYLLM_PROVIDER=""
REGION=""
PROXY_MODE=""
MEMORY_ENABLED=""
TELEMETRY_ENABLED=""
CONTAINER_NAME=""
HEALTH_URL=""
while IFS='=' read -r key value; do
case "${key}" in
PROFILE|IMAGE|PORT|BACKEND|ANYLLM_PROVIDER|REGION|PROXY_MODE|MEMORY_ENABLED|TELEMETRY_ENABLED|CONTAINER_NAME|HEALTH_URL)
printf -v "${key}" '%s' "${value}"
;;
esac
done <"${state_path}"
}
start_persistent_docker_install() {
local profile="$1"
local image="$2"
local port="$3"
local backend="$4"
local anyllm="$5"
local region="$6"
local proxy_mode="$7"
local memory_enabled="$8"
local telemetry_enabled="$9"
local container_name
local proxy_args=()
local args=()
validate_profile_name "${profile}"
container_name="$(persistent_container_name "${profile}")"
build_manifest_proxy_args proxy_args "${port}" "${proxy_mode}" "${backend}" "${anyllm}" "${region}" "${memory_enabled}" "${telemetry_enabled}"
docker rm -f "${container_name}" >/dev/null 2>&1 || true
args=(docker run -d --restart unless-stopped --name "${container_name}" -p "${port}:${port}")
append_persistent_container_args args
args+=(
--env "HEADROOM_DEPLOYMENT_PROFILE=${profile}"
--env "HEADROOM_DEPLOYMENT_PRESET=persistent-docker"
--env "HEADROOM_DEPLOYMENT_RUNTIME=docker"
--env "HEADROOM_DEPLOYMENT_SUPERVISOR=none"
--env "HEADROOM_DEPLOYMENT_SCOPE=user"
)
args+=("${image}" --host 0.0.0.0 "${proxy_args[@]:2}")
"${args[@]}" >/dev/null
if ! wait_for_proxy "${container_name}" "${port}"; then
docker rm -f "${container_name}" >/dev/null 2>&1 || true
die "Headroom persistent Docker deployment failed to start on port ${port}"
fi
write_persistent_state "${profile}" "${image}" "${port}" "${backend}" "${anyllm}" "${region}" "${proxy_mode}" "${memory_enabled}" "${telemetry_enabled}"
write_persistent_manifest "${profile}" "${image}" "${port}" "${backend}" "${anyllm}" "${region}" "${proxy_mode}" "${memory_enabled}" "${telemetry_enabled}" proxy_args
}
stop_persistent_docker_install() {
local profile="$1"
local container_name
load_persistent_state "${profile}"
container_name="${CONTAINER_NAME}"
docker stop "${container_name}" >/dev/null 2>&1 || true
docker rm -f "${container_name}" >/dev/null 2>&1 || true
}
status_persistent_docker_install() {
local profile="$1"
local status="stopped"
local ready="no"
load_persistent_state "${profile}"
if docker_container_exists "${CONTAINER_NAME}"; then
status="running"
if command -v curl >/dev/null 2>&1; then
if curl --fail --silent "${HEALTH_URL}" >/dev/null; then
ready="yes"
fi
elif (echo >/dev/tcp/127.0.0.1/"${PORT}") >/dev/null 2>&1; then
ready="yes"
fi
fi
printf 'Profile: %s\n' "${PROFILE}"
printf 'Preset: persistent-docker\n'
printf 'Runtime: docker\n'
printf 'Supervisor: none\n'
printf 'Port: %s\n' "${PORT}"
printf 'Status: %s\n' "${status}"
printf 'Ready: %s\n' "${ready}"
printf 'Health URL: %s\n' "${HEALTH_URL}"
}
remove_persistent_docker_install() {
local profile="$1"
local root
load_persistent_state "${profile}"
docker stop "${CONTAINER_NAME}" >/dev/null 2>&1 || true
docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
root="$(persistent_profile_root "${profile}")"
rm -rf "${root}"
}
print_install_help() {
cat <<'EOF'
Usage: headroom install [OPTIONS] COMMAND [ARGS]...
Manage persistent Docker-native Headroom deployments.
The Docker-native wrapper currently supports the persistent-docker preset only.
Use the Python-native `headroom install` command for persistent-service and
persistent-task installs, or when you need provider/user/system config mutation.
Options:
-?, --help Show this message and exit.
Commands:
apply Install a persistent Docker deployment.
remove Remove a persistent Docker deployment.
restart Restart a persistent Docker deployment.
start Start a persistent Docker deployment.
status Show persistent Docker deployment status.
stop Stop a persistent Docker deployment.
EOF
}
print_install_apply_help() {
cat <<'EOF'
Usage: headroom install apply [OPTIONS]
Install a persistent Docker deployment.
Options:
--preset [persistent-docker] Docker-native wrapper supports persistent-docker only.
--runtime [docker] Docker-native wrapper supports runtime=docker only.
--profile TEXT Deployment profile name. [default: default]
-p, --port INTEGER Persistent proxy port. [default: 8787]
--backend TEXT Proxy backend. [default: anthropic]
--anyllm-provider TEXT Provider for any-llm backends.
--region TEXT Cloud region for Bedrock / Vertex style backends.
--mode TEXT Proxy optimization mode. [default: token]
--memory Enable persistent memory in the runtime.
--no-telemetry Disable anonymous telemetry in the runtime.
--image TEXT Docker image to use. [default: HEADROOM_DOCKER_IMAGE or ghcr.io/chopratejas/headroom:latest]
-?, --help Show this message and exit.
EOF
}
print_wrap_help() {
cat <<'EOF'
Usage: headroom wrap <COMMAND> [OPTIONS] [-- ARGS...]
Launch supported host tools through a Docker-native Headroom proxy.
Supported commands:
claude
codex
aider
cursor
openclaw
Notes:
- GitHub Copilot CLI wrapping is not supported by the Docker-native wrapper.
- Use the Python-native CLI for unsupported wrap targets.
EOF
}
parse_install_apply_args() {
local -n out_profile=$1
local -n out_port=$2
local -n out_backend=$3
local -n out_anyllm=$4
local -n out_region=$5
local -n out_mode=$6
local -n out_memory=$7
local -n out_telemetry=$8
local -n out_image=$9
shift 9
out_profile="default"
out_port=8787
out_backend="anthropic"
out_anyllm=""
out_region=""
out_mode="token"
out_memory=0
out_telemetry=1
out_image="${HEADROOM_IMAGE}"
while (($#)); do
case "$1" in
--preset)
require_option_value "$@"
[[ "$2" == "persistent-docker" ]] || die "Docker-native wrapper supports only --preset persistent-docker"
shift 2
;;
--preset=*)
[[ "${1#*=}" == "persistent-docker" ]] || die "Docker-native wrapper supports only --preset persistent-docker"
shift
;;
--runtime)
require_option_value "$@"
[[ "$2" == "docker" ]] || die "Docker-native wrapper supports only --runtime docker"
shift 2
;;
--runtime=*)
[[ "${1#*=}" == "docker" ]] || die "Docker-native wrapper supports only --runtime docker"
shift
;;
--scope|--providers|--target)
die "Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows"
;;
--scope=*|--providers=*|--target=*)
die "Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows"
;;
--profile)
require_option_value "$@"
out_profile="$2"
shift 2
;;
--profile=*)
out_profile="${1#*=}"
shift
;;
--port|-p)
require_option_value "$@"
out_port="$2"
shift 2
;;
--port=*|-p=*)
out_port="${1#*=}"
shift
;;
--backend)
require_option_value "$@"
out_backend="$2"
shift 2
;;
--backend=*)
out_backend="${1#*=}"
shift
;;
--anyllm-provider)
require_option_value "$@"
out_anyllm="$2"
shift 2
;;
--anyllm-provider=*)
out_anyllm="${1#*=}"
shift
;;
--region)
require_option_value "$@"
out_region="$2"
shift 2
;;
--region=*)
out_region="${1#*=}"
shift
;;
--mode)
require_option_value "$@"
out_mode="$2"
shift 2
;;
--mode=*)
out_mode="${1#*=}"
shift
;;
--memory)
out_memory=1
shift
;;
--no-telemetry)
out_telemetry=0
shift
;;
--image)
require_option_value "$@"
out_image="$2"
shift 2
;;
--image=*)
out_image="${1#*=}"
shift
;;
--help|-?)
print_install_apply_help
exit 0
;;
*)
die "Unsupported option for 'headroom install apply': $1"
;;
esac
done
validate_port "${out_port}"
}
parse_install_profile_arg() {
local -n out_profile=$1
shift
out_profile="default"
while (($#)); do
case "$1" in
--profile)
require_option_value "$@"
out_profile="$2"
shift 2
;;
--profile=*)
out_profile="${1#*=}"
shift
;;
--help|-?)
print_install_help
exit 0
;;
*)
die "Unsupported option for 'headroom install': $1"
;;
esac
done
}
run_claude_rtk_init() {
local rtk_bin="${HEADROOM_HOST_HOME}/.headroom/bin/rtk"
if [[ ! -x "${rtk_bin}" ]]; then
@ -251,12 +820,15 @@ parse_wrap_args() {
break
;;
--port|-p)
require_option_value "$@"
out_port="$2"
validate_port "${out_port}"
out_known+=("$1" "$2")
shift 2
;;
--port=*)
out_port="${1#*=}"
validate_port "${out_port}"
out_known+=("$1")
shift
;;
@ -280,6 +852,7 @@ parse_wrap_args() {
shift
;;
--backend)
require_option_value "$@"
out_backend="$2"
out_known+=("$1" "$2")
shift 2
@ -290,6 +863,7 @@ parse_wrap_args() {
shift
;;
--anyllm-provider)
require_option_value "$@"
out_anyllm="$2"
out_known+=("$1" "$2")
shift 2
@ -300,6 +874,7 @@ parse_wrap_args() {
shift
;;
--region)
require_option_value "$@"
out_region="$2"
out_known+=("$1" "$2")
shift 2
@ -381,6 +956,7 @@ parse_openclaw_wrap_args() {
while (($#)); do
case "$1" in
--plugin-path)
require_option_value "$@"
out_plugin_path="$2"
shift 2
;;
@ -389,6 +965,7 @@ parse_openclaw_wrap_args() {
shift
;;
--plugin-spec)
require_option_value "$@"
out_plugin_spec="$2"
shift 2
;;
@ -405,22 +982,29 @@ parse_openclaw_wrap_args() {
shift
;;
--proxy-port)
require_option_value "$@"
out_proxy_port="$2"
validate_port "${out_proxy_port}"
shift 2
;;
--proxy-port=*)
out_proxy_port="${1#*=}"
validate_port "${out_proxy_port}"
shift
;;
--startup-timeout-ms)
require_option_value "$@"
out_startup_timeout_ms="$2"
validate_positive_integer "${out_startup_timeout_ms}"
shift 2
;;
--startup-timeout-ms=*)
out_startup_timeout_ms="${1#*=}"
validate_positive_integer "${out_startup_timeout_ms}"
shift
;;
--gateway-provider-id)
require_option_value "$@"
out_gateway_provider_ids+=("$2")
shift 2
;;
@ -429,6 +1013,7 @@ parse_openclaw_wrap_args() {
shift
;;
--python-path)
require_option_value "$@"
out_python_path="$2"
shift 2
;;
@ -795,9 +1380,60 @@ main() {
fi
case "$1" in
install)
if (($# == 1)) || [[ "$2" == "--help" || "$2" == "-?" ]]; then
print_install_help
return
fi
local install_command="$2"
shift 2
case "${install_command}" in
apply)
local profile port backend anyllm region proxy_mode memory_enabled telemetry_enabled image
parse_install_apply_args profile port backend anyllm region proxy_mode memory_enabled telemetry_enabled image "$@"
start_persistent_docker_install "${profile}" "${image}" "${port}" "${backend}" "${anyllm}" "${region}" "${proxy_mode}" "${memory_enabled}" "${telemetry_enabled}"
printf "Installed docker-native persistent deployment '%s' on port %s.\n" "${profile}" "${port}"
;;
status)
local profile
parse_install_profile_arg profile "$@"
status_persistent_docker_install "${profile}"
;;
start)
local profile
parse_install_profile_arg profile "$@"
load_persistent_state "${profile}"
start_persistent_docker_install "${PROFILE}" "${IMAGE}" "${PORT}" "${BACKEND}" "${ANYLLM_PROVIDER}" "${REGION}" "${PROXY_MODE}" "${MEMORY_ENABLED}" "${TELEMETRY_ENABLED}"
printf "Started docker-native persistent deployment '%s'.\n" "${profile}"
;;
stop)
local profile
parse_install_profile_arg profile "$@"
stop_persistent_docker_install "${profile}"
printf "Stopped docker-native persistent deployment '%s'.\n" "${profile}"
;;
restart)
local profile
parse_install_profile_arg profile "$@"
load_persistent_state "${profile}"
start_persistent_docker_install "${PROFILE}" "${IMAGE}" "${PORT}" "${BACKEND}" "${ANYLLM_PROVIDER}" "${REGION}" "${PROXY_MODE}" "${MEMORY_ENABLED}" "${TELEMETRY_ENABLED}"
printf "Restarted docker-native persistent deployment '%s'.\n" "${profile}"
;;
remove)
local profile
parse_install_profile_arg profile "$@"
remove_persistent_docker_install "${profile}"
printf "Removed docker-native persistent deployment '%s'.\n" "${profile}"
;;
*)
die "Unsupported install target: ${install_command}"
;;
esac
;;
wrap)
if (($# == 1)) || [[ "$2" == "--help" || "$2" == "-?" ]]; then
run_headroom wrap --help
print_wrap_help
return
fi
@ -805,6 +1441,14 @@ main() {
local tool="$2"
shift 2
case "${tool}" in
claude|codex|aider|cursor|openclaw)
;;
*)
die "Docker-native wrapper does not support 'wrap ${tool}'. Supported targets: claude, codex, aider, cursor, openclaw"
;;
esac
if [[ "${tool}" == "openclaw" ]]; then
if contains_help_flag "$@"; then
run_headroom wrap openclaw "$@"
@ -836,14 +1480,6 @@ main() {
proxy_args+=(--region "${region}")
fi
case "${tool}" in
claude|codex|aider|cursor)
;;
*)
die "Unsupported wrap target: ${tool}"
;;
esac
local container_name=""
if [[ "${no_proxy}" -eq 0 ]]; then
container_name="$(start_proxy_container "${port}" "${proxy_args[@]}")"
@ -911,12 +1547,15 @@ EOF
while (($#)); do
case "$1" in
--port|-p)
require_option_value "$@"
port="$2"
validate_port "${port}"
args+=("$1" "$2")
shift 2
;;
--port=*)
port="${1#*=}"
validate_port "${port}"
args+=("$1")
shift
;;
@ -942,6 +1581,7 @@ EOF
main "$@"
WRAPPER
} >"${wrapper_path}"
chmod +x "${wrapper_path}"
}
@ -957,8 +1597,17 @@ main() {
append_path_block "${HOME}/.zshrc"
append_path_block "${HOME}/.profile"
info "Pulling ${IMAGE_DEFAULT}"
docker pull "${IMAGE_DEFAULT}" >/dev/null
if [[ -n "${HEADROOM_DOCKER_IMAGE:-}" ]]; then
if docker image inspect "${INSTALL_IMAGE}" >/dev/null 2>&1; then
info "Using existing HEADROOM_DOCKER_IMAGE=${INSTALL_IMAGE}"
else
info "Pulling ${INSTALL_IMAGE}"
docker pull "${INSTALL_IMAGE}" >/dev/null
fi
else
info "Pulling ${IMAGE_DEFAULT}"
docker pull "${IMAGE_DEFAULT}" >/dev/null
fi
cat <<EOF

View file

@ -0,0 +1,24 @@
from __future__ import annotations
import json
from headroom.ccr import mcp_server
def test_shared_stats_work_without_fcntl(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(mcp_server, "_HAS_FCNTL", False)
monkeypatch.setattr(mcp_server, "fcntl", None)
monkeypatch.setattr(mcp_server, "SHARED_STATS_DIR", tmp_path)
monkeypatch.setattr(mcp_server, "SHARED_STATS_FILE", tmp_path / "session_stats.jsonl")
monkeypatch.setattr(mcp_server.os, "getpid", lambda: 4242)
monkeypatch.setattr(mcp_server.time, "time", lambda: 1001.0)
event = {"type": "compress", "timestamp": 1000.0}
mcp_server._append_shared_event(event)
raw_lines = mcp_server.SHARED_STATS_FILE.read_text(encoding="utf-8").splitlines()
assert len(raw_lines) == 1
assert json.loads(raw_lines[0]) == {"type": "compress", "timestamp": 1000.0, "pid": 4242}
events = mcp_server._read_shared_events(window_seconds=60)
assert events == [{"type": "compress", "timestamp": 1000.0, "pid": 4242}]

View file

@ -0,0 +1,343 @@
from __future__ import annotations
import click
from click.testing import CliRunner
from headroom.cli.main import main
def test_install_apply_starts_service_supervisor(monkeypatch) -> None:
runner = CliRunner()
calls: list[str] = []
class Manifest:
profile = "default"
preset = "persistent-service"
runtime_kind = "python"
supervisor_kind = "service"
scope = "user"
health_url = "http://127.0.0.1:8787/readyz"
targets = ["claude", "codex"]
mutations = []
artifacts = []
manifest = Manifest()
monkeypatch.setattr("headroom.cli.install.build_manifest", lambda **_: manifest)
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: None)
monkeypatch.setattr("headroom.cli.install.apply_mutations", lambda deployment: [])
monkeypatch.setattr("headroom.cli.install.install_supervisor", lambda deployment: [])
monkeypatch.setattr(
"headroom.cli.install.save_manifest", lambda deployment: calls.append("save")
)
monkeypatch.setattr(
"headroom.cli.install.start_supervisor", lambda deployment: calls.append("start_service")
)
monkeypatch.setattr(
"headroom.cli.install.start_detached_agent", lambda profile: calls.append("start_agent")
)
monkeypatch.setattr(
"headroom.cli.install.start_persistent_docker",
lambda deployment: calls.append("start_docker"),
)
monkeypatch.setattr(
"headroom.cli.install.wait_ready", lambda deployment, timeout_seconds=45: True
)
result = runner.invoke(main, ["install", "apply"])
assert result.exit_code == 0, result.output
assert "Installed persistent deployment 'default'" in result.output
assert "Targets: claude, codex" in result.output
assert calls == ["save", "start_service"]
def test_install_status_includes_backend_from_health_probe(monkeypatch) -> None:
runner = CliRunner()
class Manifest:
profile = "default"
preset = "persistent-service"
runtime_kind = "python"
supervisor_kind = "service"
scope = "user"
port = 8787
backend = "anthropic"
health_url = "http://127.0.0.1:8787/readyz"
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
monkeypatch.setattr("headroom.cli.install.runtime_status", lambda manifest: "running")
monkeypatch.setattr("headroom.cli.install.probe_ready", lambda url: True)
monkeypatch.setattr(
"headroom.cli.install.probe_json",
lambda url: {"config": {"backend": "anthropic"}},
)
result = runner.invoke(main, ["install", "status"])
assert result.exit_code == 0, result.output
assert "Status: running" in result.output
assert "Healthy: yes" in result.output
assert "Backend: anthropic" in result.output
def test_install_restart_uses_internal_helpers(monkeypatch) -> None:
runner = CliRunner()
calls: list[str] = []
class Manifest:
profile = "default"
preset = "persistent-service"
runtime_kind = "python"
supervisor_kind = "service"
scope = "user"
health_url = "http://127.0.0.1:8787/readyz"
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
monkeypatch.setattr(
"headroom.cli.install.stop_supervisor", lambda manifest: calls.append("stop_supervisor")
)
monkeypatch.setattr(
"headroom.cli.install.stop_runtime", lambda manifest: calls.append("stop_runtime")
)
monkeypatch.setattr(
"headroom.cli.install.start_supervisor", lambda manifest: calls.append("start_supervisor")
)
monkeypatch.setattr(
"headroom.cli.install.wait_ready", lambda manifest, timeout_seconds=45: True
)
result = runner.invoke(main, ["install", "restart"])
assert result.exit_code == 0, result.output
assert "Restarted deployment 'default'." in result.output
assert calls == ["stop_supervisor", "stop_runtime", "start_supervisor"]
def test_install_apply_rejects_invalid_profile() -> None:
runner = CliRunner()
result = runner.invoke(main, ["install", "apply", "--profile", "../bad"])
assert result.exit_code != 0
assert "Invalid profile name '../bad'" in result.output
def test_install_apply_rejects_provider_scope_targets_without_support() -> None:
runner = CliRunner()
result = runner.invoke(
main,
["install", "apply", "--scope", "provider", "--providers", "manual", "--target", "copilot"],
)
assert result.exit_code != 0
assert "Provider scope supports only claude, codex, and openclaw" in result.output
def test_install_apply_restores_previous_deployment_after_failed_update(monkeypatch) -> None:
runner = CliRunner()
calls: list[str] = []
class Manifest:
def __init__(self, profile: str, targets: list[str]) -> None:
self.profile = profile
self.preset = "persistent-service"
self.runtime_kind = "python"
self.supervisor_kind = "service"
self.scope = "user"
self.health_url = "http://127.0.0.1:8787/readyz"
self.targets = targets
self.mutations = []
self.artifacts = []
new_manifest = Manifest("default", ["claude"])
existing_manifest = Manifest("default", ["codex"])
monkeypatch.setattr("headroom.cli.install.build_manifest", lambda **_: new_manifest)
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: existing_manifest)
monkeypatch.setattr(
"headroom.cli.install.apply_mutations",
lambda deployment: calls.append(f"apply:{','.join(deployment.targets)}") or [],
)
monkeypatch.setattr(
"headroom.cli.install.install_supervisor",
lambda deployment: calls.append(f"supervisor:{','.join(deployment.targets)}") or [],
)
monkeypatch.setattr(
"headroom.cli.install.save_manifest",
lambda deployment: calls.append(f"save:{','.join(deployment.targets)}"),
)
monkeypatch.setattr(
"headroom.cli.install.stop_supervisor",
lambda deployment: calls.append(f"stop-supervisor:{','.join(deployment.targets)}"),
)
monkeypatch.setattr(
"headroom.cli.install.stop_runtime",
lambda deployment: calls.append(f"stop-runtime:{','.join(deployment.targets)}"),
)
monkeypatch.setattr(
"headroom.cli.install.remove_supervisor",
lambda deployment: calls.append(f"remove-supervisor:{','.join(deployment.targets)}"),
)
monkeypatch.setattr(
"headroom.cli.install.revert_mutations",
lambda deployment: calls.append(f"revert:{','.join(deployment.targets)}"),
)
monkeypatch.setattr(
"headroom.cli.install.delete_manifest",
lambda profile: calls.append(f"delete:{profile}"),
)
def _start(deployment) -> None:
calls.append(f"start:{','.join(deployment.targets)}")
if deployment is new_manifest:
raise click.ClickException("boom")
monkeypatch.setattr("headroom.cli.install._start_deployment", _start)
result = runner.invoke(main, ["install", "apply"])
assert result.exit_code != 0
assert "Restoring previous deployment 'default'" in result.output
assert calls == [
"stop-supervisor:codex",
"stop-runtime:codex",
"remove-supervisor:codex",
"revert:codex",
"delete:default",
"apply:claude",
"supervisor:claude",
"save:claude",
"start:claude",
"stop-supervisor:claude",
"stop-runtime:claude",
"remove-supervisor:claude",
"revert:claude",
"delete:default",
"apply:codex",
"supervisor:codex",
"save:codex",
"start:codex",
]
def test_install_start_rejects_task_lifecycle(monkeypatch) -> None:
runner = CliRunner()
class Manifest:
profile = "default"
preset = "persistent-task"
runtime_kind = "python"
supervisor_kind = "task"
scope = "user"
health_url = "http://127.0.0.1:8787/readyz"
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
result = runner.invoke(main, ["install", "start"])
assert result.exit_code != 0
assert "headroom install start" in result.output
def test_install_apply_uses_docker_runtime_for_persistent_docker(monkeypatch) -> None:
runner = CliRunner()
calls: list[str] = []
class Manifest:
profile = "default"
preset = "persistent-docker"
runtime_kind = "docker"
supervisor_kind = "none"
scope = "user"
health_url = "http://127.0.0.1:8787/readyz"
targets: list[str] = []
mutations = []
artifacts = []
monkeypatch.setattr("headroom.cli.install.build_manifest", lambda **_: Manifest())
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: None)
monkeypatch.setattr("headroom.cli.install.apply_mutations", lambda deployment: [])
monkeypatch.setattr("headroom.cli.install.install_supervisor", lambda deployment: [])
monkeypatch.setattr("headroom.cli.install.save_manifest", lambda deployment: None)
monkeypatch.setattr(
"headroom.cli.install.start_persistent_docker",
lambda deployment: calls.append("start_docker"),
)
monkeypatch.setattr(
"headroom.cli.install.wait_ready", lambda deployment, timeout_seconds=45: True
)
result = runner.invoke(main, ["install", "apply", "--preset", "persistent-docker"])
assert result.exit_code == 0, result.output
assert calls == ["start_docker"]
def test_install_remove_continues_when_runtime_teardown_errors(monkeypatch) -> None:
runner = CliRunner()
calls: list[str] = []
class Manifest:
profile = "default"
preset = "persistent-service"
runtime_kind = "python"
supervisor_kind = "service"
scope = "user"
health_url = "http://127.0.0.1:8787/readyz"
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
monkeypatch.setattr(
"headroom.cli.install.stop_supervisor",
lambda manifest: (_ for _ in ()).throw(RuntimeError("boom")),
)
monkeypatch.setattr(
"headroom.cli.install.stop_runtime",
lambda manifest: (_ for _ in ()).throw(RuntimeError("boom")),
)
monkeypatch.setattr(
"headroom.cli.install.remove_supervisor", lambda manifest: calls.append("remove_supervisor")
)
monkeypatch.setattr(
"headroom.cli.install.revert_mutations", lambda manifest: calls.append("revert")
)
monkeypatch.setattr(
"headroom.cli.install.delete_manifest", lambda profile: calls.append("delete")
)
result = runner.invoke(main, ["install", "remove"])
assert result.exit_code == 0, result.output
assert calls == ["remove_supervisor", "revert", "delete"]
def test_install_agent_ensure_reports_already_healthy(monkeypatch) -> None:
runner = CliRunner()
class Manifest:
profile = "default"
health_url = "http://127.0.0.1:8787/readyz"
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
monkeypatch.setattr("headroom.cli.install.probe_ready", lambda url: True)
result = runner.invoke(main, ["install", "agent", "ensure"])
assert result.exit_code == 0, result.output
assert "already healthy" in result.output
def test_install_agent_run_exits_with_foreground_status(monkeypatch) -> None:
runner = CliRunner()
class Manifest:
profile = "default"
health_url = "http://127.0.0.1:8787/readyz"
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
monkeypatch.setattr("headroom.cli.install.run_foreground", lambda manifest: 7)
result = runner.invoke(main, ["install", "agent", "run"])
assert result.exit_code == 7

View file

@ -0,0 +1,108 @@
from __future__ import annotations
import click
from headroom.cli.wrap import _ensure_proxy, _find_persistent_manifest, _recover_persistent_proxy
class _Manifest:
profile = "default"
preset = "persistent-service"
supervisor_kind = "service"
health_url = "http://127.0.0.1:8787/readyz"
def test_ensure_proxy_recovers_matching_persistent_deployment(monkeypatch) -> None:
calls: list[str] = []
monkeypatch.setattr("headroom.cli.wrap._check_proxy", lambda port: False)
monkeypatch.setattr("headroom.cli.wrap._find_persistent_manifest", lambda port: _Manifest())
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
monkeypatch.setattr(
"headroom.install.supervisors.start_supervisor",
lambda manifest: calls.append(f"start:{manifest.profile}"),
)
monkeypatch.setattr(
"headroom.install.runtime.wait_ready", lambda manifest, timeout_seconds=45: True
)
monkeypatch.setattr(
"headroom.cli.wrap._start_proxy",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("ephemeral proxy should not start")
),
)
result = _ensure_proxy(8787, False)
assert result is None
assert calls == ["start:default"]
def test_ensure_proxy_recovers_persistent_deployment_when_socket_is_bound(monkeypatch) -> None:
calls: list[str] = []
monkeypatch.setattr("headroom.cli.wrap._check_proxy", lambda port: True)
monkeypatch.setattr("headroom.cli.wrap._find_persistent_manifest", lambda port: _Manifest())
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
monkeypatch.setattr(
"headroom.install.supervisors.start_supervisor",
lambda manifest: calls.append(f"start:{manifest.profile}"),
)
monkeypatch.setattr(
"headroom.install.runtime.wait_ready", lambda manifest, timeout_seconds=45: True
)
result = _ensure_proxy(8787, False)
assert result is None
assert calls == ["start:default"]
def test_ensure_proxy_rejects_unhealthy_persistent_deployment(monkeypatch) -> None:
monkeypatch.setattr("headroom.cli.wrap._check_proxy", lambda port: True)
monkeypatch.setattr("headroom.cli.wrap._find_persistent_manifest", lambda port: _Manifest())
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
monkeypatch.setattr("headroom.cli.wrap._recover_persistent_proxy", lambda port: False)
try:
_ensure_proxy(8787, False)
except click.ClickException as exc:
assert "is not healthy" in str(exc)
else:
raise AssertionError("expected unhealthy persistent deployment to raise")
def test_find_persistent_manifest_prefers_default_profile(monkeypatch) -> None:
class DefaultManifest:
profile = "default"
port = 8787
class OtherManifest:
profile = "custom"
port = 8787
monkeypatch.setattr(
"headroom.install.state.list_manifests",
lambda: [OtherManifest(), DefaultManifest()],
)
manifest = _find_persistent_manifest(8787)
assert manifest.profile == "default"
def test_recover_persistent_proxy_reuses_healthy_deployment(monkeypatch) -> None:
monkeypatch.setattr("headroom.cli.wrap._find_persistent_manifest", lambda port: _Manifest())
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True)
assert _recover_persistent_proxy(8787) is True
def test_recover_persistent_proxy_warns_for_task_deployment(monkeypatch) -> None:
class TaskManifest(_Manifest):
supervisor_kind = "task"
monkeypatch.setattr("headroom.cli.wrap._find_persistent_manifest", lambda port: TaskManifest())
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
assert _recover_persistent_proxy(8787) is False

View file

@ -0,0 +1,57 @@
from __future__ import annotations
import urllib.error
from headroom.install.health import probe_json, probe_ready
class _Response:
def __init__(self, payload: bytes) -> None:
self._payload = payload
def __enter__(self) -> _Response:
return self
def __exit__(self, exc_type, exc, tb) -> None:
return None
def read(self) -> bytes:
return self._payload
def test_probe_json_returns_dict(monkeypatch) -> None:
monkeypatch.setattr(
"urllib.request.urlopen",
lambda url, timeout=2.0: _Response(b'{"ready": true}'),
)
assert probe_json("http://example.test") == {"ready": True}
def test_probe_json_returns_none_for_invalid_payloads(monkeypatch) -> None:
monkeypatch.setattr("urllib.request.urlopen", lambda url, timeout=2.0: _Response(b"[]"))
assert probe_json("http://example.test") is None
monkeypatch.setattr("urllib.request.urlopen", lambda url, timeout=2.0: _Response(b"{"))
assert probe_json("http://example.test") is None
monkeypatch.setattr(
"urllib.request.urlopen",
lambda url, timeout=2.0: (_ for _ in ()).throw(urllib.error.URLError("boom")),
)
assert probe_json("http://example.test") is None
def test_probe_ready_accepts_ready_and_healthy(monkeypatch) -> None:
monkeypatch.setattr(
"headroom.install.health.probe_json", lambda url, timeout=2.0: {"ready": True}
)
assert probe_ready("http://example.test")
monkeypatch.setattr(
"headroom.install.health.probe_json", lambda url, timeout=2.0: {"status": "healthy"}
)
assert probe_ready("http://example.test")
monkeypatch.setattr("headroom.install.health.probe_json", lambda url, timeout=2.0: None)
assert not probe_ready("http://example.test")

View file

@ -0,0 +1,763 @@
from __future__ import annotations
import json
import os
import shutil
import signal
import socket
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
FAKE_DOCKER = r"""
from __future__ import annotations
import json
import os
import signal
import subprocess
import sys
from pathlib import Path
STATE_PATH = Path(os.environ["FAKE_DOCKER_STATE"])
LOG_PATH = Path(os.environ["FAKE_DOCKER_LOG"])
def load_state() -> dict[str, dict[str, dict[str, int]]]:
if not STATE_PATH.exists():
return {"containers": {}}
return json.loads(STATE_PATH.read_text(encoding="utf-8"))
def save_state(state: dict[str, dict[str, dict[str, int]]]) -> None:
STATE_PATH.write_text(json.dumps(state), encoding="utf-8")
def cleanup_dead(state: dict[str, dict[str, dict[str, int]]]) -> dict[str, dict[str, dict[str, int]]]:
save_state(state)
return state
def host_port_from_publish(value: str) -> int:
parts = value.split(":")
if len(parts) == 2:
return int(parts[0])
if len(parts) >= 3:
return int(parts[-2])
raise ValueError(f"Unsupported publish value: {value}")
def start_server(port: int) -> int:
code = '''
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
port = int(sys.argv[1])
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"ok")
def log_message(self, fmt, *args):
return
ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()
'''
process = subprocess.Popen(
[sys.executable, "-c", code, str(port)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return process.pid
def stop_container(state: dict[str, dict[str, dict[str, int]]], name: str) -> None:
data = state["containers"].pop(name, None)
if not data:
return
try:
os.kill(int(data["pid"]), signal.SIGTERM)
except OSError:
pass
save_state(state)
def main() -> int:
args = sys.argv[1:]
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
with LOG_PATH.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(args) + "\n")
if not args:
return 0
state = cleanup_dead(load_state())
command = args[0]
if command == "pull":
return 0
if command == "run":
detached = "-d" in args
if not detached:
return 0
name = None
publish = None
for index, arg in enumerate(args):
if arg == "--name":
name = args[index + 1]
elif arg == "-p":
publish = args[index + 1]
if name is None or publish is None:
raise SystemExit("missing --name or -p in fake docker run")
port = host_port_from_publish(publish)
state["containers"][name] = {"pid": start_server(port), "port": port}
save_state(state)
print(name)
return 0
if command == "ps":
names = sorted(state["containers"])
if "--format" in args:
print("\n".join(names))
return 0
if command == "stop":
for name in args[1:]:
if not name.startswith("-"):
stop_container(state, name)
return 0
if command == "rm":
for name in args[1:]:
if not name.startswith("-"):
stop_container(state, name)
return 0
if command == "logs":
if len(args) > 1:
print(f"fake logs for {args[1]}")
return 0
return 0
if __name__ == "__main__":
raise SystemExit(main())
"""
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _write_fake_docker_shims(tmp_path: Path) -> Path:
shim_dir = tmp_path / "fake-docker"
shim_dir.mkdir()
fake_docker = shim_dir / "fake_docker.py"
fake_docker.write_text(FAKE_DOCKER, encoding="utf-8")
docker_sh = shim_dir / "docker"
docker_sh.write_text(
f'#!/usr/bin/env bash\nexec "{sys.executable}" "{fake_docker}" "$@"\n',
encoding="utf-8",
)
docker_sh.chmod(0o755)
docker_cmd = shim_dir / "docker.cmd"
docker_cmd.write_text(
f'@echo off\r\n"{sys.executable}" "{fake_docker}" %*\r\n',
encoding="utf-8",
)
openclaw_sh = shim_dir / "openclaw"
openclaw_sh.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
openclaw_sh.chmod(0o755)
openclaw_cmd = shim_dir / "openclaw.cmd"
openclaw_cmd.write_text("@echo off\r\nexit /b 0\r\n", encoding="utf-8")
return shim_dir
def _build_env(home: Path, tmp_path: Path) -> dict[str, str]:
env = os.environ.copy()
shim_dir = _write_fake_docker_shims(tmp_path)
env["HOME"] = str(home)
env["USERPROFILE"] = str(home)
env["PATH"] = str(shim_dir) + os.pathsep + env.get("PATH", "")
env["FAKE_DOCKER_STATE"] = str(tmp_path / "fake-docker-state.json")
env["FAKE_DOCKER_LOG"] = str(tmp_path / "fake-docker.log")
return env
def _cleanup_fake_docker(env: dict[str, str]) -> None:
state_path = Path(env["FAKE_DOCKER_STATE"])
if not state_path.exists():
return
state = json.loads(state_path.read_text(encoding="utf-8"))
for container in state.get("containers", {}).values():
try:
os.kill(int(container["pid"]), signal.SIGTERM)
except OSError:
pass
def _read_fake_docker_log(env: dict[str, str]) -> list[list[str]]:
log_path = Path(env["FAKE_DOCKER_LOG"])
if not log_path.exists():
return []
return [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines() if line]
def _run(
command: list[str],
*,
env: dict[str, str],
cwd: Path | None = None,
check: bool = True,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
cwd=cwd,
env=env,
capture_output=True,
text=True,
check=check,
)
@pytest.mark.skipif(
os.name == "nt" or shutil.which("bash") is None,
reason="bash installer coverage runs on non-Windows hosts",
)
def test_bash_native_installer_supports_persistent_docker_lifecycle(tmp_path: Path) -> None:
home = tmp_path / "home"
(home / ".local").mkdir(parents=True)
env = _build_env(home, tmp_path)
env["HEADROOM_DOCKER_IMAGE"] = "headroom:test-image"
try:
_run(["bash", str(REPO_ROOT / "scripts" / "install.sh")], env=env, cwd=REPO_ROOT)
wrapper = home / ".local" / "bin" / "headroom"
assert wrapper.exists()
assert "HEADROOM_IMAGE_DEFAULT=headroom:test-image" in wrapper.read_text(encoding="utf-8")
help_result = _run([str(wrapper), "install", "-?"], env=env)
assert "persistent-docker preset only" in help_result.stdout
_run([str(wrapper), "--help"], env=env)
wrap_help = _run([str(wrapper), "wrap", "--help"], env=env)
assert "Supported commands:" in wrap_help.stdout
assert "copilot" not in wrap_help.stdout
unsupported_wrap = _run(
[str(wrapper), "wrap", "copilot", "--help"],
env=env,
check=False,
)
assert unsupported_wrap.returncode != 0
assert "does not support 'wrap copilot'" in unsupported_wrap.stderr
invalid_profile = _run(
[str(wrapper), "install", "status", "--profile", ".."],
env=env,
check=False,
)
assert invalid_profile.returncode != 0
assert "Invalid profile name '..'" in invalid_profile.stderr
missing_profile_value = _run(
[str(wrapper), "install", "apply", "--profile"],
env=env,
check=False,
)
assert missing_profile_value.returncode != 0
assert "Option --profile requires a value" in missing_profile_value.stderr
missing_proxy_port = _run(
[str(wrapper), "proxy", "--port"],
env=env,
check=False,
)
assert missing_proxy_port.returncode != 0
assert "Option --port requires a value" in missing_proxy_port.stderr
invalid_proxy_port = _run(
[str(wrapper), "proxy", "--port", "abc"],
env=env,
check=False,
)
assert invalid_proxy_port.returncode != 0
assert "Invalid port 'abc'" in invalid_proxy_port.stderr
missing_wrap_port = _run(
[str(wrapper), "wrap", "claude", "--port"],
env=env,
check=False,
)
assert missing_wrap_port.returncode != 0
assert "Option --port requires a value" in missing_wrap_port.stderr
invalid_wrap_port = _run(
[str(wrapper), "wrap", "claude", "--port", "abc"],
env=env,
check=False,
)
assert invalid_wrap_port.returncode != 0
assert "Invalid port 'abc'" in invalid_wrap_port.stderr
missing_openclaw_proxy_port = _run(
[str(wrapper), "wrap", "openclaw", "--proxy-port"],
env=env,
check=False,
)
assert missing_openclaw_proxy_port.returncode != 0
assert "Option --proxy-port requires a value" in missing_openclaw_proxy_port.stderr
invalid_openclaw_proxy_port = _run(
[str(wrapper), "wrap", "openclaw", "--proxy-port", "abc"],
env=env,
check=False,
)
assert invalid_openclaw_proxy_port.returncode != 0
assert "Invalid port 'abc'" in invalid_openclaw_proxy_port.stderr
for invalid_port in ("abc", "0", "65536"):
invalid_port_result = _run(
[str(wrapper), "install", "apply", "--port", invalid_port],
env=env,
check=False,
)
assert invalid_port_result.returncode != 0
assert f"Invalid port '{invalid_port}'" in invalid_port_result.stderr
port = _free_port()
_run(
[
str(wrapper),
"install",
"apply",
"--profile",
"smoke",
"--port",
str(port),
"--memory",
"--no-telemetry",
"--image",
"fake/headroom:test",
],
env=env,
)
manifest_path = home / ".headroom" / "deploy" / "smoke" / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
assert manifest["preset"] == "persistent-docker"
assert manifest["port"] == port
assert manifest["memory_enabled"] is True
assert manifest["memory_db_path"] == "/tmp/headroom-home/.headroom/memory.db"
assert manifest["telemetry_enabled"] is False
state_path = home / ".headroom" / "deploy" / "smoke" / "docker-native.env"
state_text = state_path.read_text(encoding="utf-8")
assert f"PORT={port!r}" in state_text
docker_calls = _read_fake_docker_log(env)
help_call = next(
call
for call in docker_calls
if call[:2] == ["run", "--rm"] and "--entrypoint" in call and "--help" in call
)
assert "-it" not in help_call
install_call = next(
call for call in docker_calls if call[:2] == ["run", "-d"] and "--name" in call
)
assert "/tmp/headroom-home/.headroom/memory.db" in install_call
status_result = _run(
[str(wrapper), "install", "status", "--profile", "smoke"],
env=env,
)
assert "Status: running" in status_result.stdout
_run([str(wrapper), "install", "stop", "--profile", "smoke"], env=env)
stopped_result = _run(
[str(wrapper), "install", "status", "--profile", "smoke"],
env=env,
)
assert "Status: stopped" in stopped_result.stdout
_run([str(wrapper), "install", "start", "--profile", "smoke"], env=env)
restarted_result = _run(
[str(wrapper), "install", "status", "--profile", "smoke"],
env=env,
)
assert "Status: running" in restarted_result.stdout
rejected = _run(
[str(wrapper), "install", "apply", "--scope", "user"],
env=env,
check=False,
)
assert rejected.returncode != 0
assert "does not support provider/user/system mutation flags" in rejected.stderr
_run([str(wrapper), "install", "restart", "--profile", "smoke"], env=env)
_run([str(wrapper), "install", "remove", "--profile", "smoke"], env=env)
assert not manifest_path.parent.exists()
finally:
_cleanup_fake_docker(env)
def _powershell_executable() -> str | None:
return shutil.which("pwsh") or shutil.which("powershell") or shutil.which("powershell.exe")
@pytest.mark.skipif(
os.name != "nt" or _powershell_executable() is None,
reason="Windows PowerShell coverage runs on Windows hosts only",
)
def test_powershell_native_installer_supports_persistent_docker_lifecycle(tmp_path: Path) -> None:
powershell = _powershell_executable()
assert powershell is not None
home = tmp_path / "home"
(home / ".local").mkdir(parents=True)
env = _build_env(home, tmp_path)
env["HEADROOM_DOCKER_IMAGE"] = "headroom:test-image"
try:
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(REPO_ROOT / "scripts" / "install.ps1"),
],
env=env,
cwd=REPO_ROOT,
)
wrapper = home / ".local" / "bin" / "headroom.ps1"
assert wrapper.exists()
assert "__HEADROOM_INSTALL_IMAGE__" not in wrapper.read_text(encoding="utf-8")
assert "headroom:test-image" in wrapper.read_text(encoding="utf-8")
cmd_wrapper = home / ".local" / "bin" / "headroom.cmd"
assert cmd_wrapper.exists()
help_result = _run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"-?",
],
env=env,
)
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"proxy",
"--help",
],
env=env,
)
assert "persistent-docker preset only" in help_result.stdout
cmd_help_result = _run(
["cmd.exe", "/c", str(cmd_wrapper), "install", "-?"],
env=env,
)
assert "persistent-docker preset only" in cmd_help_result.stdout
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"--help",
],
env=env,
)
wrap_help = _run(
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "--help"],
env=env,
)
assert "Supported commands:" in wrap_help.stdout
assert "copilot" not in wrap_help.stdout
unsupported_wrap = _run(
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "copilot", "--help"],
env=env,
check=False,
)
assert unsupported_wrap.returncode != 0
assert "does not support 'wrap copilot'" in unsupported_wrap.stderr
invalid_profile = _run(
["cmd.exe", "/c", str(cmd_wrapper), "install", "status", "--profile", ".."],
env=env,
check=False,
)
assert invalid_profile.returncode != 0
assert "Invalid profile name '..'" in invalid_profile.stderr
missing_profile_value = _run(
["cmd.exe", "/c", str(cmd_wrapper), "install", "apply", "--profile"],
env=env,
check=False,
)
assert missing_profile_value.returncode != 0
assert "Option --profile requires a value" in missing_profile_value.stderr
missing_proxy_port = _run(
["cmd.exe", "/c", str(cmd_wrapper), "proxy", "--port"],
env=env,
check=False,
)
assert missing_proxy_port.returncode != 0
assert "Option --port requires a value" in missing_proxy_port.stderr
invalid_proxy_port = _run(
["cmd.exe", "/c", str(cmd_wrapper), "proxy", "--port", "abc"],
env=env,
check=False,
)
assert invalid_proxy_port.returncode != 0
assert "Invalid port 'abc'" in invalid_proxy_port.stderr
missing_wrap_port = _run(
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "claude", "--port"],
env=env,
check=False,
)
assert missing_wrap_port.returncode != 0
assert "Option --port requires a value" in missing_wrap_port.stderr
invalid_wrap_port = _run(
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "claude", "--port", "abc"],
env=env,
check=False,
)
assert invalid_wrap_port.returncode != 0
assert "Invalid port 'abc'" in invalid_wrap_port.stderr
missing_openclaw_proxy_port = _run(
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "openclaw", "--proxy-port"],
env=env,
check=False,
)
assert missing_openclaw_proxy_port.returncode != 0
assert "Option --proxy-port requires a value" in missing_openclaw_proxy_port.stderr
invalid_openclaw_proxy_port = _run(
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "openclaw", "--proxy-port", "abc"],
env=env,
check=False,
)
assert invalid_openclaw_proxy_port.returncode != 0
assert "Invalid port 'abc'" in invalid_openclaw_proxy_port.stderr
for invalid_port in ("abc", "0", "65536"):
invalid_port_result = _run(
["cmd.exe", "/c", str(cmd_wrapper), "install", "apply", "--port", invalid_port],
env=env,
check=False,
)
assert invalid_port_result.returncode != 0
assert f"Invalid port '{invalid_port}'" in invalid_port_result.stderr
port = _free_port()
_run(
[
"cmd.exe",
"/c",
str(cmd_wrapper),
"install",
"apply",
"--profile",
"smoke",
"--port",
str(port),
"--memory",
"--no-telemetry",
"--image",
"fake/headroom:test",
],
env=env,
)
manifest_path = home / ".headroom" / "deploy" / "smoke" / "manifest.json"
state_path = home / ".headroom" / "deploy" / "smoke" / "docker-native.json"
manifest_bytes = manifest_path.read_bytes()
state_bytes = state_path.read_bytes()
assert not manifest_bytes.startswith(b"\xef\xbb\xbf")
assert not state_bytes.startswith(b"\xef\xbb\xbf")
manifest = json.loads(manifest_bytes.decode("utf-8"))
state = json.loads(state_bytes.decode("utf-8"))
assert manifest["preset"] == "persistent-docker"
assert manifest["port"] == port
assert manifest["memory_enabled"] is True
assert manifest["memory_db_path"] == "/tmp/headroom-home/.headroom/memory.db"
assert manifest["telemetry_enabled"] is False
assert state["container_name"] == "headroom-smoke"
docker_calls = _read_fake_docker_log(env)
help_call = next(
call
for call in docker_calls
if call[:2] == ["run", "--rm"] and "--entrypoint" in call and "--help" in call
)
assert "-it" not in help_call
proxy_help_call = next(
call
for call in docker_calls
if call[:2] == ["run", "--rm"] and "-p" in call and "proxy" in call and "--help" in call
)
assert "-it" not in proxy_help_call
install_call = next(
call for call in docker_calls if call[:2] == ["run", "-d"] and "--name" in call
)
assert "/tmp/headroom-home/.headroom/memory.db" in install_call
status_result = _run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"status",
"--profile",
"smoke",
],
env=env,
)
assert "Status: running" in status_result.stdout
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"stop",
"--profile",
"smoke",
],
env=env,
)
stopped_result = _run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"status",
"--profile",
"smoke",
],
env=env,
)
assert "Status: stopped" in stopped_result.stdout
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"start",
"--profile",
"smoke",
],
env=env,
)
started_result = _run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"status",
"--profile",
"smoke",
],
env=env,
)
assert "Status: running" in started_result.stdout
rejected = _run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"apply",
"--scope",
"user",
],
env=env,
check=False,
)
assert rejected.returncode != 0
assert "does not support provider/user/system mutation flags" in rejected.stderr
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"restart",
"--profile",
"smoke",
],
env=env,
)
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"remove",
"--profile",
"smoke",
],
env=env,
)
assert not manifest_path.parent.exists()
finally:
_cleanup_fake_docker(env)

View file

@ -0,0 +1,65 @@
from __future__ import annotations
from headroom.install.models import ConfigScope, InstallPreset, ProviderSelectionMode, ToolTarget
from headroom.install.planner import build_manifest, resolve_targets
def test_resolve_targets_auto_falls_back_when_detection_empty(monkeypatch) -> None:
monkeypatch.setattr("headroom.install.planner.detect_targets", lambda: [])
targets = resolve_targets(ProviderSelectionMode.AUTO.value, [])
assert targets == [
ToolTarget.CLAUDE.value,
ToolTarget.CODEX.value,
ToolTarget.COPILOT.value,
]
def test_build_manifest_for_persistent_docker_sets_expected_defaults() -> None:
manifest = build_manifest(
profile="default",
preset=InstallPreset.PERSISTENT_DOCKER.value,
runtime_kind="docker",
scope="user",
provider_mode="manual",
targets=["claude", "copilot"],
port=8787,
backend="anthropic",
anyllm_provider=None,
region=None,
proxy_mode="token",
memory_enabled=True,
telemetry_enabled=False,
image="ghcr.io/chopratejas/headroom:latest",
)
assert manifest.supervisor_kind == "none"
assert manifest.runtime_kind == "docker"
assert manifest.health_url == "http://127.0.0.1:8787/readyz"
assert manifest.base_env["HEADROOM_PORT"] == "8787"
assert manifest.base_env["HEADROOM_TELEMETRY"] == "off"
assert manifest.tool_envs["claude"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
assert manifest.tool_envs["copilot"]["COPILOT_PROVIDER_TYPE"] == "anthropic"
assert "--memory" in manifest.proxy_args
def test_resolve_targets_provider_scope_auto_excludes_copilot(monkeypatch) -> None:
monkeypatch.setattr("headroom.install.planner.detect_targets", lambda: [])
targets = resolve_targets(
ProviderSelectionMode.AUTO.value,
[],
scope=ConfigScope.PROVIDER.value,
)
assert targets == [ToolTarget.CLAUDE.value, ToolTarget.CODEX.value]
def test_resolve_targets_manual_dedupes_and_filters_invalid() -> None:
targets = resolve_targets(
ProviderSelectionMode.MANUAL.value,
["claude", "copilot", "claude", "invalid"],
)
assert targets == [ToolTarget.CLAUDE.value, ToolTarget.COPILOT.value]

View file

@ -0,0 +1,179 @@
from __future__ import annotations
import json
from pathlib import Path
from headroom.install.models import DeploymentManifest, ManagedMutation
from headroom.install.providers import (
_apply_claude_provider_scope,
_apply_codex_provider_scope,
_apply_windows_env_scope,
_remove_windows_env_scope,
_revert_claude_provider_scope,
_revert_codex_provider_scope,
)
def _manifest(tmp_path: Path) -> DeploymentManifest:
return DeploymentManifest(
profile="default",
preset="persistent-service",
runtime_kind="python",
supervisor_kind="service",
scope="provider",
provider_mode="manual",
targets=["claude", "codex"],
port=8787,
host="127.0.0.1",
backend="anthropic",
memory_db_path=str(tmp_path / "memory.db"),
tool_envs={
"claude": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"},
"codex": {"OPENAI_BASE_URL": "http://127.0.0.1:8787/v1"},
},
)
def test_apply_and_revert_claude_provider_scope(monkeypatch, tmp_path: Path) -> None:
settings_path = tmp_path / "settings.json"
settings_path.write_text(
json.dumps({"env": {"ANTHROPIC_API_KEY": "keep", "ANTHROPIC_BASE_URL": "https://old"}})
)
monkeypatch.setattr("headroom.install.providers.claude_settings_path", lambda: settings_path)
manifest = _manifest(tmp_path)
mutation = _apply_claude_provider_scope(manifest)
payload = json.loads(settings_path.read_text())
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
assert payload["env"]["ANTHROPIC_API_KEY"] == "keep"
_revert_claude_provider_scope(mutation, manifest.tool_envs["claude"])
reverted = json.loads(settings_path.read_text())
assert reverted["env"]["ANTHROPIC_BASE_URL"] == "https://old"
assert reverted["env"]["ANTHROPIC_API_KEY"] == "keep"
def test_apply_and_revert_codex_provider_scope(monkeypatch, tmp_path: Path) -> None:
config_path = tmp_path / "config.toml"
config_path.write_text('model = "gpt-4o"\n')
monkeypatch.setattr("headroom.install.providers.codex_config_path", lambda: config_path)
manifest = _manifest(tmp_path)
mutation = _apply_codex_provider_scope(manifest)
content = config_path.read_text()
assert 'model_provider = "headroom"' in content
assert 'base_url = "http://127.0.0.1:8787/v1"' in content
_revert_codex_provider_scope(mutation)
reverted = config_path.read_text()
assert 'model_provider = "headroom"' not in reverted
assert reverted.strip() == 'model = "gpt-4o"'
def test_apply_openclaw_provider_scope_uses_manifest_port(monkeypatch, tmp_path: Path) -> None:
recorded: list[list[str]] = []
monkeypatch.setattr("headroom.install.providers.shutil_which", lambda name: "openclaw")
monkeypatch.setattr(
"headroom.install.providers.resolve_headroom_command",
lambda: ["headroom"],
)
monkeypatch.setattr(
"headroom.install.providers._invoke_openclaw",
lambda command: recorded.append(command),
)
monkeypatch.setattr(
"headroom.install.providers.openclaw_config_path",
lambda: tmp_path / "openclaw.json",
)
manifest = _manifest(tmp_path)
manifest.port = 9999
from headroom.install.providers import _apply_openclaw_provider_scope
_apply_openclaw_provider_scope(manifest)
assert recorded == [["headroom", "wrap", "openclaw", "--no-auto-start", "--proxy-port", "9999"]]
def test_windows_env_scope_restores_previous_values(monkeypatch, tmp_path: Path) -> None:
manifest = _manifest(tmp_path)
manifest.scope = "user"
manifest.targets = ["claude"]
manifest.base_env = {"HEADROOM_PORT": "8787"}
manifest.tool_envs = {"claude": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}
calls: list[list[str]] = []
previous_values = {
"HEADROOM_PORT": "7777",
"ANTHROPIC_BASE_URL": "https://old",
}
class Result:
def __init__(self, stdout: str = "") -> None:
self.stdout = stdout
def fake_run(command: list[str], **kwargs):
calls.append(command)
script = command[-1]
if "GetEnvironmentVariable" in script:
name = script.split("GetEnvironmentVariable('", 1)[1].split("'", 1)[0]
value = previous_values.get(name, "__HEADROOM_UNSET__")
return Result(stdout=value)
return Result()
monkeypatch.setattr("headroom.install.providers.subprocess.run", fake_run)
mutations = _apply_windows_env_scope(manifest)
_remove_windows_env_scope(mutations)
previous_by_name = {mutation.data["name"]: mutation.data["previous"] for mutation in mutations}
assert previous_by_name["HEADROOM_PORT"] == "7777"
assert previous_by_name["ANTHROPIC_BASE_URL"] == "https://old"
assert any(
"[Environment]::SetEnvironmentVariable('HEADROOM_PORT','7777','User')" in command[-1]
for command in calls
)
assert any(
"[Environment]::SetEnvironmentVariable('ANTHROPIC_BASE_URL','https://old','User')"
in command[-1]
for command in calls
)
def test_remove_windows_env_scope_requires_name_and_scope() -> None:
try:
_remove_windows_env_scope([ManagedMutation(target="env", kind="windows-env", data={})])
except ValueError as exc:
assert "variable name" in str(exc)
else:
raise AssertionError("expected missing variable name to raise")
try:
_remove_windows_env_scope(
[ManagedMutation(target="env", kind="windows-env", data={"name": "X", "scope": 1})]
)
except ValueError as exc:
assert "valid scope" in str(exc)
else:
raise AssertionError("expected invalid scope to raise")
def test_apply_mutations_runs_openclaw_for_user_scope(monkeypatch, tmp_path: Path) -> None:
manifest = _manifest(tmp_path)
manifest.scope = "user"
manifest.targets = ["openclaw"]
manifest.base_env = {"HEADROOM_PORT": "8787"}
manifest.tool_envs = {}
monkeypatch.setattr("headroom.install.providers.os.name", "posix")
monkeypatch.setattr("headroom.install.providers._apply_unix_env_scope", lambda deployment: [])
monkeypatch.setattr(
"headroom.install.providers._apply_openclaw_provider_scope",
lambda deployment: ManagedMutation(target="openclaw", kind="openclaw-wrap"),
)
from headroom.install.providers import apply_mutations
mutations = apply_mutations(manifest)
assert [mutation.kind for mutation in mutations] == ["openclaw-wrap"]

View file

@ -0,0 +1,170 @@
from __future__ import annotations
from pathlib import Path
from headroom.install.models import DeploymentManifest
from headroom.install.runtime import (
_clear_pid,
_read_pid,
build_runtime_command,
resolve_headroom_command,
runtime_status,
stop_runtime,
)
def test_build_runtime_command_for_docker_includes_deployment_env(
monkeypatch, tmp_path: Path
) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path)
manifest = DeploymentManifest(
profile="default",
preset="persistent-docker",
runtime_kind="docker",
supervisor_kind="none",
scope="user",
provider_mode="manual",
targets=["claude"],
port=8787,
host="127.0.0.1",
backend="anthropic",
image="ghcr.io/chopratejas/headroom:latest",
base_env={"HEADROOM_PORT": "8787"},
proxy_args=["--host", "127.0.0.1", "--port", "8787"],
)
command = build_runtime_command(manifest)
joined = " ".join(command)
assert command[:3] == ["docker", "run", "--rm"]
assert "HEADROOM_DEPLOYMENT_PROFILE=default" in joined
assert "HEADROOM_DEPLOYMENT_PRESET=persistent-docker" in joined
assert "127.0.0.1:8787:8787" in joined
assert "ghcr.io/chopratejas/headroom:latest" in command
def test_build_runtime_command_for_docker_matches_wrapper_parity(
monkeypatch, tmp_path: Path
) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
monkeypatch.setenv("OPENAI_API_KEY", "test-openai")
manifest = DeploymentManifest(
profile="default",
preset="persistent-docker",
runtime_kind="docker",
supervisor_kind="none",
scope="user",
provider_mode="manual",
targets=["claude"],
port=8787,
host="127.0.0.1",
backend="anthropic",
image="ghcr.io/chopratejas/headroom:latest",
base_env={"HEADROOM_PORT": "8787"},
proxy_args=["--host", "127.0.0.1", "--port", "8787"],
)
command = build_runtime_command(manifest)
assert (tmp_path / ".headroom").is_dir()
assert (tmp_path / ".claude").is_dir()
assert (tmp_path / ".codex").is_dir()
assert (tmp_path / ".gemini").is_dir()
assert "--env" in command
joined = " ".join(command)
assert "ANTHROPIC_API_KEY" in joined
assert "OPENAI_API_KEY" in joined
def test_resolve_headroom_command_prefers_headroom_binary(monkeypatch) -> None:
monkeypatch.setattr(
"shutil.which", lambda name: "/usr/bin/headroom" if name == "headroom" else None
)
assert resolve_headroom_command() == ["/usr/bin/headroom"]
def test_read_pid_handles_invalid_content(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path)
pid_file = tmp_path / ".headroom" / "deploy" / "default" / "runner.pid"
pid_file.parent.mkdir(parents=True)
pid_file.write_text("not-a-pid", encoding="utf-8")
assert _read_pid("default") is None
_clear_pid("default")
assert not pid_file.exists()
def test_stop_runtime_for_docker_stops_and_removes_container(monkeypatch) -> None:
calls: list[list[str]] = []
manifest = DeploymentManifest(
profile="default",
preset="persistent-docker",
runtime_kind="docker",
supervisor_kind="none",
scope="user",
provider_mode="manual",
targets=[],
port=8787,
host="127.0.0.1",
backend="anthropic",
container_name="headroom-default",
)
monkeypatch.setattr(
"headroom.install.runtime.subprocess.run",
lambda command, **kwargs: calls.append(command),
)
stop_runtime(manifest)
assert calls == [
["docker", "stop", "headroom-default"],
["docker", "rm", "-f", "headroom-default"],
]
def test_runtime_status_reads_container_and_pid_state(monkeypatch, tmp_path: Path) -> None:
docker_manifest = DeploymentManifest(
profile="default",
preset="persistent-docker",
runtime_kind="docker",
supervisor_kind="none",
scope="user",
provider_mode="manual",
targets=[],
port=8787,
host="127.0.0.1",
backend="anthropic",
container_name="headroom-default",
)
class Result:
def __init__(self, stdout: str = "") -> None:
self.stdout = stdout
monkeypatch.setattr(
"headroom.install.runtime.subprocess.run",
lambda command, **kwargs: Result(stdout="headroom-default\n"),
)
assert runtime_status(docker_manifest) == "running"
monkeypatch.setattr(Path, "home", lambda: tmp_path)
pid_file = tmp_path / ".headroom" / "deploy" / "default" / "runner.pid"
pid_file.parent.mkdir(parents=True)
pid_file.write_text("123", encoding="utf-8")
monkeypatch.setattr("headroom.install.runtime.os.kill", lambda pid, sig: None)
python_manifest = DeploymentManifest(
profile="default",
preset="persistent-service",
runtime_kind="python",
supervisor_kind="service",
scope="user",
provider_mode="manual",
targets=[],
port=8787,
host="127.0.0.1",
backend="anthropic",
)
assert runtime_status(python_manifest) == "running"

View file

@ -0,0 +1,63 @@
from __future__ import annotations
from pathlib import Path
from headroom.install.models import ArtifactRecord, DeploymentManifest, ManagedMutation
from headroom.install.state import delete_manifest, list_manifests, load_manifest, save_manifest
def _manifest() -> DeploymentManifest:
return DeploymentManifest(
profile="default",
preset="persistent-service",
runtime_kind="python",
supervisor_kind="service",
scope="user",
provider_mode="manual",
targets=["claude"],
port=8787,
host="127.0.0.1",
backend="anthropic",
mutations=[ManagedMutation(target="env", kind="shell-block", path="x")],
artifacts=[ArtifactRecord(kind="script", path="run-headroom.sh")],
)
def test_save_and_load_manifest_round_trip(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path)
manifest = _manifest()
save_manifest(manifest)
loaded = load_manifest("default")
assert loaded is not None
assert loaded.profile == "default"
assert loaded.mutations[0].kind == "shell-block"
assert loaded.artifacts[0].kind == "script"
def test_list_manifests_ignores_invalid_payloads(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path)
valid = _manifest()
save_manifest(valid)
broken_dir = tmp_path / ".headroom" / "deploy" / "broken"
broken_dir.mkdir(parents=True)
(broken_dir / "manifest.json").write_text("{not json", encoding="utf-8")
manifests = list_manifests()
assert [manifest.profile for manifest in manifests] == ["default"]
def test_delete_manifest_removes_profile_root(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path)
manifest = _manifest()
save_manifest(manifest)
extra_file = tmp_path / ".headroom" / "deploy" / "default" / "runner.log"
extra_file.write_text("log", encoding="utf-8")
delete_manifest("default")
assert load_manifest("default") is None
assert not extra_file.parent.exists()

View file

@ -0,0 +1,173 @@
from __future__ import annotations
from pathlib import Path
from headroom.install.models import DeploymentManifest, SupervisorKind
from headroom.install.supervisors import (
_linux_service_unit,
_linux_task_spec,
_macos_launchd_plist,
_render_windows_runner,
install_supervisor,
remove_supervisor,
render_runner_scripts,
start_supervisor,
stop_supervisor,
)
def _manifest(
*, profile: str = "default", scope: str = "user", supervisor: str = "service"
) -> DeploymentManifest:
return DeploymentManifest(
profile=profile,
preset="persistent-service",
runtime_kind="python",
supervisor_kind=supervisor,
scope=scope,
provider_mode="manual",
targets=[],
port=8787,
host="127.0.0.1",
backend="anthropic",
service_name=f"headroom-{profile}",
)
def test_linux_service_unit_uses_user_systemd_path(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path)
manifest = _manifest()
unit_path, content = _linux_service_unit(manifest, tmp_path / "run-headroom.sh")
assert unit_path == tmp_path / ".config" / "systemd" / "user" / "headroom-default.service"
assert "ExecStart=" + str(tmp_path / "run-headroom.sh") in content
assert "Restart=on-failure" in content
def test_linux_task_spec_for_user_scope_includes_crontab_markers(tmp_path: Path) -> None:
manifest = _manifest(profile="smoke", supervisor=SupervisorKind.TASK.value)
cron_path, content = _linux_task_spec(manifest, tmp_path / "ensure-headroom.sh")
assert cron_path is None
assert "# >>> headroom smoke >>>" in content
assert "# <<< headroom smoke <<<" in content
assert "@reboot" in content
assert "*/5 * * * *" in content
def test_macos_launchd_plist_switches_between_keepalive_and_interval(
monkeypatch, tmp_path: Path
) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path)
service_manifest = _manifest(supervisor=SupervisorKind.SERVICE.value)
service_path, service_content = _macos_launchd_plist(
service_manifest, tmp_path / "run-headroom.sh"
)
assert service_path == tmp_path / "Library" / "LaunchAgents" / "com.headroom.default.plist"
assert "<key>KeepAlive</key>" in service_content
assert "<key>StartInterval</key>" not in service_content
task_manifest = _manifest(profile="tasky", supervisor=SupervisorKind.TASK.value)
task_path, task_content = _macos_launchd_plist(
task_manifest, tmp_path / "ensure-headroom.sh", interval=300
)
assert task_path == tmp_path / "Library" / "LaunchAgents" / "com.headroom.tasky.plist"
assert "<key>StartInterval</key>" in task_content
assert "<integer>300</integer>" in task_content
def test_render_windows_runner_writes_ps1_and_cmd_wrappers(tmp_path: Path) -> None:
ps1_path = tmp_path / "run-headroom.ps1"
cmd_path = tmp_path / "run-headroom.cmd"
records = _render_windows_runner(
ps1_path,
cmd_path,
["C:\\Program Files\\Python\\python.exe", "headroom", "install", "agent", "run"],
)
assert [record.path for record in records] == [str(ps1_path), str(cmd_path)]
ps1_content = ps1_path.read_text(encoding="utf-8")
cmd_content = cmd_path.read_text(encoding="utf-8")
assert '& "C:\\Program Files\\Python\\python.exe" headroom install agent run' in ps1_content
assert (
'powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0run-headroom.ps1" %*'
in cmd_content
)
def test_render_runner_scripts_writes_unix_scripts(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr("headroom.install.supervisors.os.name", "posix")
monkeypatch.setattr(
"headroom.install.supervisors.resolve_headroom_command", lambda: ["headroom"]
)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
manifest = _manifest()
records = render_runner_scripts(manifest)
assert {record.path.split("\\")[-1].split("/")[-1] for record in records} == {
"run-headroom.sh",
"ensure-headroom.sh",
}
def test_install_supervisor_none_returns_runner_records(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr("headroom.install.supervisors.os.name", "posix")
monkeypatch.setattr(
"headroom.install.supervisors.resolve_headroom_command", lambda: ["headroom"]
)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
manifest = _manifest(supervisor=SupervisorKind.NONE.value)
records = install_supervisor(manifest)
assert len(records) == 2
assert all(record.kind == "script" for record in records)
def test_start_and_stop_supervisor_use_linux_systemctl(monkeypatch) -> None:
calls: list[list[str]] = []
monkeypatch.setattr("headroom.install.supervisors.sys.platform", "linux")
monkeypatch.setattr(
"headroom.install.supervisors.subprocess.run",
lambda command, **kwargs: calls.append(command),
)
manifest = _manifest()
start_supervisor(manifest)
stop_supervisor(manifest)
assert calls == [
["systemctl", "--user", "restart", "headroom-default"],
["systemctl", "--user", "stop", "headroom-default"],
]
def test_remove_supervisor_removes_user_crontab_block(monkeypatch) -> None:
calls: list[tuple[list[str], str | None]] = []
monkeypatch.setattr("headroom.install.supervisors.sys.platform", "linux")
class Result:
def __init__(self, returncode: int = 0, stdout: str = "") -> None:
self.returncode = returncode
self.stdout = stdout
def fake_run(command: list[str], **kwargs):
calls.append((command, kwargs.get("input")))
if command == ["crontab", "-l"]:
return Result(
stdout="# >>> headroom default >>>\n@reboot /tmp/ensure\n# <<< headroom default <<<\n"
)
return Result()
monkeypatch.setattr("headroom.install.supervisors.subprocess.run", fake_run)
manifest = _manifest(supervisor=SupervisorKind.TASK.value)
remove_supervisor(manifest)
assert calls[0][0] == ["crontab", "-l"]
assert calls[1][0] == ["crontab", "-"]

View file

@ -64,6 +64,34 @@ def test_health_preserves_backwards_compatible_config_payload(client):
}
def test_health_includes_deployment_metadata_when_present(monkeypatch):
monkeypatch.setenv("HEADROOM_DEPLOYMENT_PROFILE", "default")
monkeypatch.setenv("HEADROOM_DEPLOYMENT_PRESET", "persistent-service")
monkeypatch.setenv("HEADROOM_DEPLOYMENT_RUNTIME", "python")
monkeypatch.setenv("HEADROOM_DEPLOYMENT_SUPERVISOR", "service")
monkeypatch.setenv("HEADROOM_DEPLOYMENT_SCOPE", "user")
config = ProxyConfig(
optimize=False,
cache_enabled=False,
rate_limit_enabled=False,
cost_tracking_enabled=False,
)
app = create_app(config)
with TestClient(app) as client:
response = client.get("/health")
assert response.status_code == 200
assert response.json()["deployment"] == {
"profile": "default",
"preset": "persistent-service",
"runtime": "python",
"supervisor": "service",
"scope": "user",
}
def test_health_remains_200_when_proxy_is_not_ready(client):
client.app.state.ready = False