mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Merge pull request #145 from JerrettDavis/feat/persistent-installs
feat: add persistent install lifecycle management
This commit is contained in:
commit
65ee4b1124
42 changed files with 6117 additions and 105 deletions
85
.github/workflows/ci.yml
vendored
85
.github/workflows/ci.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
167
docs/cli.md
167
docs/cli.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>/`.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 →](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 →](persistent-installs.md).
|
||||
|
||||
=== "TypeScript SDK"
|
||||
|
||||
```typescript
|
||||
|
|
|
|||
154
docs/persistent-installs.md
Normal file
154
docs/persistent-installs.md
Normal 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)
|
||||
129
docs/plans/2026-04-11-persistent-installs-design.md
Normal file
129
docs/plans/2026-04-11-persistent-installs-design.md
Normal 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.
|
||||
|
|
@ -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
87
e2e/docker-native-install.sh
Executable 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}" ]]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
333
headroom/cli/install.py
Normal 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.")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
19
headroom/install/__init__.py
Normal file
19
headroom/install/__init__.py
Normal 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",
|
||||
]
|
||||
28
headroom/install/health.py
Normal file
28
headroom/install/health.py
Normal 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
116
headroom/install/models.py
Normal 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
118
headroom/install/paths.py
Normal 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
228
headroom/install/planner.py
Normal 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,
|
||||
)
|
||||
302
headroom/install/providers.py
Normal file
302
headroom/install/providers.py
Normal 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
270
headroom/install/runtime.py
Normal 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
61
headroom/install/state.py
Normal 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)
|
||||
413
headroom/install/supervisors.py
Normal file
413
headroom/install/supervisors.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
24
tests/test_ccr_mcp_server.py
Normal file
24
tests/test_ccr_mcp_server.py
Normal 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}]
|
||||
343
tests/test_cli/test_install_cli.py
Normal file
343
tests/test_cli/test_install_cli.py
Normal 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
|
||||
108
tests/test_cli/test_wrap_persistent.py
Normal file
108
tests/test_cli/test_wrap_persistent.py
Normal 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
|
||||
57
tests/test_install/test_health.py
Normal file
57
tests/test_install/test_health.py
Normal 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")
|
||||
763
tests/test_install/test_native_installers.py
Normal file
763
tests/test_install/test_native_installers.py
Normal 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)
|
||||
65
tests/test_install/test_planner.py
Normal file
65
tests/test_install/test_planner.py
Normal 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]
|
||||
179
tests/test_install/test_providers.py
Normal file
179
tests/test_install/test_providers.py
Normal 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"]
|
||||
170
tests/test_install/test_runtime.py
Normal file
170
tests/test_install/test_runtime.py
Normal 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"
|
||||
63
tests/test_install/test_state.py
Normal file
63
tests/test_install/test_state.py
Normal 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()
|
||||
173
tests/test_install/test_supervisors.py
Normal file
173
tests/test_install/test_supervisors.py
Normal 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", "-"]
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue