Compare commits

...

21 Commits

Author SHA1 Message Date
didericis 98e4e2b7dc docs(readme): additional tweaks
test / unit (pull_request) Successful in 34s
test / integration (pull_request) Successful in 50s
test / unit (push) Successful in 42s
test / integration (push) Successful in 52s
2026-06-03 21:19:00 -04:00
didericis-claude 9eca46b408 docs: slim README to threat model, features, one diagram, one manifest
test / unit (pull_request) Successful in 45s
test / integration (pull_request) Successful in 56s
2026-06-03 21:29:32 +00:00
didericis-claude 0efc07ba67 refactor(backend): pass Bottle to provisioners instead of target string
test / unit (pull_request) Successful in 50s
test / integration (pull_request) Successful in 59s
test / unit (push) Successful in 43s
test / integration (push) Successful in 1m3s
Closes #178.

The backend provision functions now receive a Bottle handle with
exec / cp_in methods instead of a raw target string. Provisioner
modules use bottle.exec and bottle.cp_in in place of inlined
subprocess.run(["docker", "exec"/"cp", ...]) and direct
_smolvm.machine_cp / machine_exec calls. This decouples the
provisioners from backend-specific runtime primitives so future
refactors (e.g. the supervise rework) can swap the bottle's exec
implementation without touching every provisioner.

Each launch.py constructs the Bottle handle before calling
provision so it can be passed in; provision_prompt's return value
is wired back onto the bottle's prompt path attribute after the
fact.
2026-06-03 20:47:37 +00:00
didericis-codex f12b0f754e docs(prd): reactivate PRD 0049 without tmux alert
test / unit (pull_request) Successful in 37s
test / integration (pull_request) Successful in 55s
test / unit (push) Successful in 51s
test / integration (push) Successful in 59s
2026-06-03 17:35:10 +00:00
didericis-codex a593b157d6 fix(cli): remove supervise tmux alert handling
test / unit (pull_request) Successful in 40s
test / integration (pull_request) Successful in 55s
2026-06-03 17:34:41 +00:00
didericis-codex 15b54cdff2 docs(prd): reactivate PRD 0049 without queue highlight
test / unit (pull_request) Successful in 44s
test / integration (pull_request) Successful in 54s
2026-06-03 17:31:49 +00:00
didericis-codex d3bc463295 fix(cli): remove supervise queue highlight
test / unit (pull_request) Successful in 43s
test / integration (pull_request) Successful in 52s
2026-06-03 17:31:19 +00:00
didericis-codex 50ec920243 docs(prd): activate PRD 0049 strip dashboard to supervise
test / unit (pull_request) Successful in 37s
test / integration (pull_request) Successful in 55s
2026-06-03 17:27:56 +00:00
didericis-codex 4372b8a6dd docs(cli): update supervise code references
test / unit (pull_request) Successful in 35s
test / integration (pull_request) Successful in 52s
2026-06-03 17:27:14 +00:00
didericis-codex 63a7e63ce9 test(cli): clean up supervise test naming
test / unit (pull_request) Successful in 40s
test / integration (pull_request) Successful in 48s
2026-06-03 17:26:15 +00:00
didericis-codex c0e1f5fd70 docs(prd): supersede dashboard agent PRDs
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 54s
2026-06-03 17:25:32 +00:00
didericis-codex 41570e04c0 test(cli): update supervise triage coverage
test / unit (pull_request) Successful in 40s
test / integration (pull_request) Successful in 51s
2026-06-03 17:25:09 +00:00
didericis-codex 6f0a42159f refactor(cli): rename dashboard command to supervise
test / unit (pull_request) Failing after 38s
test / integration (pull_request) Successful in 54s
2026-06-03 17:23:40 +00:00
didericis-codex 5c17f0de95 docs(prd): rename strip dashboard PRD to 0049 2026-06-03 17:19:41 +00:00
didericis 8a09e32fcc docs(prd): add PRD 0050 -- strip dashboard to supervisor tui
test / unit (pull_request) Successful in 51s
test / integration (pull_request) Successful in 54s
2026-06-03 13:15:05 -04:00
didericis-claude 83463f1cc8 docs(prd): activate PRD 0048 — SSH deploy-key provisioning
test / unit (push) Successful in 40s
test / integration (push) Successful in 41s
2026-06-03 11:58:36 -04:00
didericis-claude 0b5d59cf9e feat(prd-0048): implement SSH deploy-key provisioning with contrib/gitea
- manifest_git.py: add ProvisionedKeyConfig dataclass; extend GitEntry
  with ProvisionedKey field (optional); make IdentityFile default to ""
  so provisioned_key entries can be constructed without a static path;
  add _parse_provisioned_key_config; update from_repos_entry to accept
  provisioned_key as an alternative to identity (mutually exclusive,
  parser rejects both-or-neither)

- deploy_key_provisioner.py (new): DeployKeyProvisioner ABC with create()
  and delete() abstract methods; get_provisioner() factory with lazy
  contrib import for gitea

- contrib/gitea/deploy_key_provisioner.py (new): GiteaDeployKeyProvisioner
  generating ed25519 keypairs via ssh-keygen and managing them through
  the Gitea deploy-key API (POST/DELETE); 404 on delete is success;
  all other errors raise RuntimeError

- git_gate.py: add _provision_dynamic_key() called in GitGate.prepare()
  for entries with ProvisionedKey — generates key, writes private key
  and key ID files to stage_dir, patches GitGateUpstream.identity_file;
  add revoke_git_gate_provisioned_keys() for teardown — raises on failure

- docker/launch.py: call revoke_git_gate_provisioned_keys() in teardown()
  after stack.close() so revocation runs after containers stop and
  failures propagate (not suppressed)

- smolmachines/launch.py: extract _teardown_smolmachines() helper that
  catches stack.close() errors (warn + re-raise) then calls revocation;
  same fatal-on-failure contract as docker backend

- test_manifest_git.py: 9 new cases for provisioned_key parsing
- test_deploy_key_provisioner.py (new): factory smoke tests
- test_contrib_gitea_deploy_key.py (new): create/delete/error/split tests

Closes #169
2026-06-03 11:58:36 -04:00
didericis-claude 464012d97c docs(prd): address review feedback on PRD 0048
- Rename deploy_key → provisioned_key throughout (manifest key,
  dataclass names, internal field names, test descriptions)
- Revocation failure at teardown now halts cleanup and propagates
  loudly; a stranded key is a security concern that must surface
2026-06-03 11:58:36 -04:00
didericis-claude b5f8a27c47 docs(prd): add SSH deploy-key provisioning plan (PRD 0048)
Introduces the design for short-lived deploy keys provisioned at spin-up
and revoked at teardown, plus the contrib package structure for
platform-specific provisioner implementations. First contrib provider
targets the Gitea deploy-key API.

Closes #169
2026-06-03 11:58:36 -04:00
didericis-claude f0ca4e3527 refactor: extract dashboard state/model layer into dashboard_model.py
test / unit (pull_request) Successful in 41s
test / integration (pull_request) Successful in 47s
test / unit (push) Successful in 35s
test / integration (push) Successful in 44s
Splits the 2103-line dashboard.py into two modules. Pure data
structures (QueuedProposal), discovery helpers (discover_pending,
discover_active_agents), derived-value helpers (_is_recent,
_approval_status, _format_agent_row, _detail_lines, etc.), and
argv-builder helpers (_build_split_pane_argv, _build_respawn_pane_argv,
_build_resume_argv_with_fallback, _agent_runtime_args) all move to
dashboard_model.py. The curses TUI, $EDITOR integration, tmux
subprocess flows, and action handlers (approve, reject,
operator_edit_routes, operator_edit_allowlist) remain in dashboard.py,
which re-imports everything from dashboard_model so existing callers and
tests are unaffected.

Adds tests/unit/test_dashboard_model.py covering _approval_status,
_proposed_payload_label, and _suffix_for_tool — three helpers that had
no prior coverage. All 894 unit tests pass.

Closes #158
2026-06-03 15:52:27 +00:00
didericis-claude ca6d257f30 test(git-gate): add shell-escaping regression tests (issue #159)
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 44s
test / unit (push) Successful in 35s
test / integration (push) Successful in 42s
Cover all six pathological character classes (single-quote,
double-quote, space, semicolon, newline, backtick) in both
upstream URL and name positions.  Each case validates rendered
output via `sh -n` and asserts the original value is preserved
verbatim after shlex.quote encoding.  Also add `sh -n` smoke
tests for the static pre-receive and access-hook scripts.
2026-06-03 14:51:23 +00:00
49 changed files with 2843 additions and 4228 deletions
+48 -438
View File
@@ -6,96 +6,26 @@
[![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml) [![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
Run multiple Claude Code agents on your own machine, each scoped to its own secrets, skills, and egress allowlist. **Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
![pipelock and git-gate blocking exfil attempts against a live bottle](docs/demo.gif) **Solution:** Ephemeral, per agent "bottles" the agent cannot modify that scan all traffic for data exfiltration and limit capabilities and egress to only what the agent needs.
Four prompts to the agent inside a real bottle: ## Features
claude replies to `hello there` — proof api.anthropic.com routes
through pipelock's bumped TLS end-to-end;
asked to GET a non-allowlisted host, the agent's curl gets 403 back
from pipelock;
asked to POST a credential-shaped body to an allowlisted host, the
same 403 — pipelock's DLP body scanner caught it;
asked to commit and push an AKIA-shaped key, git-gate's gitleaks
pre-receive hook rejects the ref.
Run it yourself with `bash scripts/demo.sh`.
## Why "bot-bottle"? - **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist and request-body DLP scanner; DoH and arbitrary hosts blocked by default.
- **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only.
Each container is a bottle; Claude is the genie inside. The genie's - **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential.
powers are exactly what the manifest grants it — a specific set of - **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
skills, a specific set of secrets, and a specific set of hosts it can - **Trust boundary at `$HOME`** — bottles (credentials, egress, remotes) live only under `~/.bot-bottle/bottles/`. Repos may ship agents but not bottles, so a cloned repo can't redirect an env var to an attacker host.
reach — nothing more. You uncork one bottle per agent - **Composable bottles (`extends:`)** — keep provider/runtime policy in one base bottle (e.g. `claude.md`) and overlay task bottles on top.
(`./cli.py start <agent>`), many bottles run in parallel, and each is - **Parallel, isolated bottles** — each bottle is its own per-agent Docker `--internal` network; bottles don't share state or talk to each other.
scoped to its task. When the session ends the bottle is destroyed and - **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding.
the genie does not persist. - **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required.
- **Smolmachines backend (macOS)** — opt-in `BOT_BOTTLE_BACKEND=smolmachines` runs the agent in a libkrun micro-VM with the sidecar bundle still in Docker.
## Goals
- Scope each agent to the minimum credentials and network egress its task actually needs
- Run multiple agents in parallel, isolated from each other
- Keep code, credentials, and agent activity on infrastructure I control — no third-party agent runtime
## Project status
bot-bottle is a self-hosted secure runtime for AI coding agents.
Each agent runs in an isolated container or micro-VM-backed bottle with
scoped secrets, allowlisted egress, TLS-aware proxying, DLP checks, and
a git-gate that withholds upstream credentials and scans pushes before
forwarding. The project includes a documented threat model, PRD-driven
development history, Docker and smolmachines backends, dashboard and
remediation flows, and unit/integration tests covering exfiltration and
sandbox escape scenarios.
## Security model
Each agent runs in its own bottle: its own container, its own internal
Docker network, and its own pipelock sidecar. Bottles don't share
state, don't talk to each other, and only get the env vars, skills,
SSH identities, and egress hosts the manifest grants them — nothing
more. Any one agent only has the access it needs to do its job.
The bottle limits both what an agent can see and where it can send
it. Each bottle gets only the secrets and SSH identities the manifest
grants it — a Gitea token but not a GitHub token, a deploy key but
not a personal SSH key — so even a compromised or misbehaving agent
only handles credentials it was already trusted with for its job.
Egress flows through pipelock, which constrains where those
credentials can travel: an agent with a Gitea token can reach
`gitea.dideric.is`, not arbitrary attacker-controlled hosts. The same
constraint blocks DNS-over-HTTPS as an exfil channel — a DoH resolver
like `cloudflare-dns.com` would have to be on the allowlist for the
agent to reach it at all. The container itself adds a layer between
the agent and the host, but the v1 design leans more on secret
minimization and egress allowlisting than on the container as a
hardened boundary. On Linux hosts where [gVisor](https://gvisor.dev/)
is registered with Docker, bot-bottle auto-detects it and launches
every bottle under `runsc` for a userspace syscall barrier — no
manifest configuration required. The broader v2 discussion lives in
`docs/research/stronger-isolation-alternatives.md`.
The egress proxy and OAuth-token handling below are the load-bearing
pieces of v1.
## Architecture ## Architecture
A bottle is two containers per agent: an `agent` container, and a A bottle is two containers per agent: an `agent` container, and a `sidecars` container that bundles pipelock + cred-proxy + git-gate + supervise behind a Python init supervisor. They share a per-agent Docker `--internal` network; the agent has no default route off-box.
`sidecars` container that bundles pipelock + egress + git-gate +
supervise behind a Python init supervisor (PRD 0024). They share a
per-agent Docker `--internal` network; the agent has no default
route off-box. All HTTP and HTTPS egress funnels through pipelock,
where the egress allowlist, TLS interception, and request-body DLP
scanner enforce the manifest before any byte leaves the host. The
only egress that doesn't traverse pipelock is git-gate's SSH
push/fetch to `bottle.git` upstreams — pipelock can't proxy SSH,
so git-gate is its own L4-style egress path with gitleaks doing
the pre-receive scan.
The agent dials the bundle by the legacy short names (`pipelock`,
`egress`, `git-gate`, `supervise`); the renderer registers those as
docker-network aliases on the bundle so existing HTTPS_PROXY URLs
and MCP endpoints resolve without an agent-side change.
``` ```
host ( ./cli.py ) host ( ./cli.py )
@@ -104,26 +34,21 @@ and MCP endpoints resolve without an agent-side change.
┌─────────────────────────── bottle ──────────────────────────────────┐ ┌─────────────────────────── bottle ──────────────────────────────────┐
│ │ │ │
│ ┌──────────────────┐ │ ┌──────────────────┐ ┌──────────────┐
│ │ agent image │ HTTPS_PROXY │ │ agent image │ HTTP(S) proxy │ cred-proxy
│ │ (claude-code, │ ────────────────────────┐ │ │ (claude-code, │ ─────────────────►│ (strips/inj
│ │ built locally) │ │ codex, etc) Authoriz.)
│ │ │ plain HTTP │ │ │ └──────┬───────┘
│ │ skills, env, │ (token injection) ┌────▼─────────┐ │ │ environ: URLs │ │
│ │ ~/.gitconfig, │ ──────────────────►│ cred-proxy │ │ │ only, no real │ ▼
│ │ ~/.npmrc, tea │ (strips/inj │ │ │ tokens ┌────────────────┐ HTTPS to
│ │ │ │ Authoriz.) │ │
│ │ environ: URLs │ └─────┬────────┘ │
│ │ only, no real │ HTTPS_PROXY │ │
│ │ tokens │ ▼ │
│ │ │ ┌────────────────┐ │ HTTPS to
│ │ │ │ pipelock image │──────────┼──► allowlisted │ │ │ │ pipelock image │──────────┼──► allowlisted
│ │ │ │ (TLS bump, DLP │ │ hosts (incl. │ │ │ │ (TLS bump, DLP │ │ hosts (incl.
│ │ │ │ body scan, │ │ cred-proxy │ │ │ │ body scan, │ │ cred-proxy
│ │ │ │ allowlist) │ │ upstreams) │ │ │ │ allowlist) │ │ upstreams)
│ │ │ └────────────────┘ │ │ │ │ └────────────────┘ │
│ │ │ │ │ │ │ │
│ │ │ git:// ┌────────────────┐ │ SSH push/fetch │ │ │ git proxy ┌────────────────┐ │ SSH push/fetch
│ │ │ ────────────────►│ git-gate image │──────────┼──► to bottle.git │ │ │ ────────────────►│ git-gate image │──────────┼──► to bottle.git
│ │ │ │ (gitleaks + │ │ upstreams │ │ │ │ (gitleaks + │ │ upstreams
│ └──────────────────┘ │ git daemon) │ │ (direct — not │ └──────────────────┘ │ git daemon) │ │ (direct — not
@@ -137,192 +62,25 @@ and MCP endpoints resolve without an agent-side change.
└─────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────┘
``` ```
- **agent image** — built from the provider template Dockerfile When the agent exits, `cli.py` tears down every sidecar and both networks; nothing about a bottle persists between runs.
(`Dockerfile.claude` for Claude, `Dockerfile.codex` for Codex, or
`agent_provider.dockerfile`) on first run; runs the selected agent
CLI with the manifest-granted skills, env vars, and `~/.gitconfig`
(the latter for the git-gate's `insteadOf` rules when `bottle.git`
is set).
- **pipelock image** — per-agent sidecar. Terminates the agent's
outbound HTTP/HTTPS, enforces the resolved allowlist, runs DLP
scanning. Design in `docs/prds/0001-per-agent-egress-proxy-via-pipelock.md`
and `docs/prds/0006-pipelock-tls-interception.md`.
- **git-gate image** — per-agent sidecar built on `zricethezav/gitleaks`
(alpine + gitleaks + git-daemon + openssh-client). Runs
`git daemon` over `git://` as a bidirectional mirror of each
declared upstream. A pre-receive hook gitleaks-scans incoming
refs and forwards clean refs to the real upstream over SSH; an
access-hook runs `git fetch origin --prune` against the upstream
before every upload-pack so an agent fetch returns whatever the
upstream has *now* (fail-closed if unreachable). The agent's
`~/.gitconfig` rewrites the real URL to the gate via `insteadOf`,
so push, fetch, clone, and pull all route through. The agent
never sees the upstream credential. Brought up only when
`bottle.git` has entries. Design in `docs/prds/0008-git-gate.md`.
- **cred-proxy image** — per-bottle sidecar (`python:3.13-alpine`
base, stdlib-only) that holds API tokens declared in
`bottle.cred_proxy.routes`. Each route names a `path`,
`upstream`, `auth_scheme`, and `token_ref` (host env var); the
agent dials `http://cred-proxy:9099<path>...` over plain HTTP
and the proxy strips any inbound `Authorization`, injects
`<auth_scheme> <token>` using the value held only in its own
container's environ, and forwards to the real upstream over
HTTPS. SSE responses stream back unbuffered. The cred-proxy's
outbound HTTPS routes through pipelock (it trusts pipelock's
per-bottle CA), so pipelock's egress allowlist + body scanner
apply to cred-proxy traffic the same way they apply to direct
agent traffic. Smart-HTTP push paths (`/git-receive-pack`,
`/info/refs?service=git-receive-pack`) are refused at the
proxy — push must go through `bottle.git` / git-gate where
gitleaks runs. Optional per-route `role` tags drive agent-side
rewrites: `anthropic-base-url`, `npm-registry`, `git-insteadof`,
`tea-login`. The agent's `printenv` shows only proxy URLs —
none of the real token values. Design in
`docs/prds/0010-cred-proxy.md`.
When the agent exits, `cli.py` tears down every sidecar that was
brought up and the two networks; nothing about a bottle persists
between runs.
## Quickstart ## Quickstart
Requires Docker on the host and a long-lived Claude Code OAuth token in Requires Docker on the host and a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
your shell env.
```sh ```sh
./cli.py start <agent> # builds the image on first run, drops you into claude ./cli.py start <agent> # builds the image on first run, drops you into claude
``` ```
The container is removed automatically when the session ends. If the script
is killed with SIGKILL the exit trap won't fire and the container may be
left running; remove it with `docker rm -f <container-name>`.
### Smolmachines backend (experimental, macOS-only)
A second backend runs the agent in a smolvm micro-VM (libkrun) with the
sidecar bundle still in Docker. Selected via
`BOT_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>`. Requires
`smolvm` on PATH (`curl -sSL https://smolmachines.com/install.sh | sh`).
The integration tests run against whichever backend the env var
selects and skip cleanly when its prerequisites are missing.
**One-time sudo on first launch (macOS):** smolmachines bottles
each reserve a loopback alias from a pool (`127.0.0.16` ..
`127.0.0.31`) and bind their bundle's port-forwards to it; the
first `./cli.py start` after each reboot prompts for sudo to add
missing aliases via `ifconfig lo0 alias`. Aliases persist until
reboot; subsequent launches don't prompt. The agent's TSI
allowlist is the alias's `/32`, so each bottle can only reach
its own bundle's published ports — not other bottles' ports,
not other host loopback services (postgres, dev servers, etc.).
This enforcement requires a workaround for a smolvm 0.8.0 bug:
the CLI's `--allow-cidr` flag is silently dropped when combined
with `--from <smolmachine>`. The launcher patches smolvm's
persistent state DB
(`~/Library/Application Support/smolvm/server/smolvm.db`)
directly between `machine create` and `machine start` to set
the allowlist. The hack falls away automatically when smolvm
honors the flag upstream — see the `loopback_alias` module's
docstring for the investigation trail.
## Manifest ## Manifest
Bottles and agents live as Markdown files with YAML frontmatter under Bottles and agents are Markdown files with YAML frontmatter under `~/.bot-bottle/`. The Markdown body is the system prompt. Bottles live in `~/.bot-bottle/bottles/`; agents may also be shipped by a repo at `<repo>/.bot-bottle/agents/<name>.md`.
`~/.bot-bottle/`. Each bottle is one file in `bottles/`, each agent
is one file in `agents/`:
``` **Bottle** (`~/.bot-bottle/bottles/gitea-dev.md`):
~/.bot-bottle/
├── bottles/
│ ├── dev.md
│ └── gitea-dev.md
└── agents/
├── implementer.md
└── researcher.md
```
The filename (without `.md`) is the entity's name. Filenames must
match `[a-z][a-z0-9-]*`; files that don't are skipped with a warning.
A repo can ship its own agent files alongside its code at
`<repo>/.bot-bottle/agents/<name>.md`. Those agents reference
bottles defined in `~/.bot-bottle/bottles/` (the only place
bottles can come from); a `bottles/` subdir in a repo is ignored
with a warning. **This is the trust boundary**: bottle infrastructure
— credentials, egress allowlists, git remotes — comes from your home
directory only. A cloned repo cannot redirect a host env var to an
attacker-named upstream because it has no way to declare a bottle.
### Bottle composition with `extends:`
A bottle can inherit from another via `extends: <bottle-name>` so
operators don't have to duplicate a whole bottle file to vary one
field (PRD 0025). The parent's resolved config is the base; the
child's declared fields overlay. Merge rules:
- `env:` — dict merge, child wins on key collision.
- `git.user:` — per-field overlay (child's non-empty `name` /
`email` wins; empty falls through to parent).
- `git.remotes:` — dict merge by host, child wins on host collision.
An explicit `git.remotes: {}` clears the parent's remotes; omitting
`git.remotes` inherits the parent's remotes.
- `agent_provider:`, `egress:`, `supervise:` — full replace when the
child declares the field.
```yaml
---
extends: dev # inherit everything from bottles/dev.md
egress:
routes:
- host: staging.example.com
auth:
scheme: Bearer
token_ref: STAGING_TOKEN
---
```
Cycles (`A extends B extends A`), self-references, and missing
parents die at parse with a clear pointer. Bottles remain
`$HOME`-only — `extends:` preserves the trust boundary above.
### Provider base bottles
Keep provider/runtime policy in one home-owned base bottle, then have
task bottles extend it. That keeps provider egress/auth in one place
without hiding security-relevant routes behind `agent_provider.template`.
For example, `~/.bot-bottle/bottles/claude.md` can hold the Claude
provider selection and Anthropic API egress:
````markdown ````markdown
--- ---
agent_provider: extends: claude # inherit the Claude provider boundary
template: claude
egress:
routes:
- host: api.anthropic.com
role: claude_code_oauth
auth:
scheme: Bearer
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
pipelock:
tls_passthrough: true
---
Common Claude provider boundary.
````
Task bottles can then inherit that provider boundary and add their own
env/git configuration without repeating the Claude route.
### Example bottle (`~/.bot-bottle/bottles/gitea-dev.md`)
````markdown
---
extends: claude
env: env:
GIT_AUTHOR_NAME: didericis GIT_AUTHOR_NAME: didericis
@@ -337,148 +95,7 @@ git:
Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea
KnownHostKey: ssh-ed25519 AAAA... KnownHostKey: ssh-ed25519 AAAA...
---
The `gitea-dev` bottle. Backs my work on personal projects: provider
auth through egress and gitea.dideric.is over SSH.
````
For a Codex-backed base bottle, set `agent_provider.template: codex`.
The Codex template expects ChatGPT/device login state instead of an
`OPENAI_API_KEY` env var; no API-key placeholder is forwarded into the
agent. To let bot-bottle read the host's current Codex ChatGPT access
token and inject it from egress only for Codex's API calls, opt in
explicitly:
```yaml
agent_provider:
template: codex
forward_host_credentials: true
egress:
routes:
- host: auth.openai.com
path_allowlist:
- /api/accounts/deviceauth/
```
Run `codex login --device-auth` on the host before launch. The
launcher reads `tokens.access_token` from the host's
`~/.codex/auth.json`, verifies it is fresh user/device auth, and passes
it to the sidecar's `EGRESS_TOKEN_N` env slot. The agent container gets
a dummy `~/.codex/auth.json` that preserves the host auth-mode shape
but replaces credential values with placeholders. It keeps the selected
ChatGPT account id so Codex sends requests for the same account while
egress owns the real bearer token. The agent never receives real access
tokens, refresh tokens, or `OPENAI_API_KEY`. The effective egress table
automatically adds or upgrades `api.openai.com` and `chatgpt.com` to
authenticated routes when `forward_host_credentials` is true.
The built-in Codex template uses `Dockerfile.codex`; set
`agent_provider.dockerfile` to build the agent from a custom Dockerfile
while keeping the bot-bottle sidecars in place.
### Example agent (`~/.bot-bottle/agents/gitea-helper.md`)
````markdown
---
bottle: gitea-dev
skills:
- init-prd
git:
user:
name: gitea-helper
email: eric+gitea-helper@dideric.is
---
You help maintain Gitea-hosted projects.
````
The agent's Markdown body is its system prompt (whitespace
stripped). The frontmatter declares the bottle to launch in and any
skills to mount. You can also include Claude Code subagent fields
(`name`, `description`, `model`, `color`, `memory`) in the
frontmatter — bot-bottle ignores them at launch but doesn't
reject them, so the same file can drop into `~/.claude/agents/` as a
Claude Code subagent.
An agent may also declare `git.user` (`name` / `email`). It overlays
the referenced bottle's `git.user` per-field — the agent's non-empty
fields win, the rest fall through to the bottle — so two agents can
share one bottle and still commit under distinct identities without
an identity-only bottle (PRD 0027). Only `git.user` is allowed at the
agent level; `git.remotes` stays bottle-only because it carries
credentials and host trust. The launch preflight and `cli.py info`
print the effective identity annotated `(agent)` / `(bottle)` so you
can see where each field came from. Git authorship is not a
credential — push auth is the bottle's remote key/token — so a
repo-shipped agent setting its own identity grants no access; treat
an agent identity as *claimed, not vouched*.
Unknown top-level frontmatter keys die at load with a "did you mean"
pointer; typos don't silently ghost into an empty config.
The YAML subset the frontmatter accepts is bounded (flat keys,
strings / ints / true-or-false bools / null / lists / one-level
nested dicts). Anchors, multi-line block scalars, tags, and
ambiguous bare strings (`yes` / `NO` / `2026-05-24` /
`0x...`) all die with a clear pointer at the spec — quote your
strings when in doubt. The full schema lives in
`bot_bottle/yaml_subset.py` (~450 lines, stdlib-only, no PyYAML).
Working examples live under `examples/`. Pipelock's design lives in
`docs/prds/0001-per-agent-egress-proxy-via-pipelock.md` and the
rationale in `docs/research/pipelock-assessment.md`. The trust
boundary rationale lives in `docs/prds/0011-per-file-md-manifest.md`.
## Auth: Claude OAuth token, not API key
Bottles that use `agent_provider.template: claude` authenticate
`claude` inside the container with the same Pro/Max subscription you
already use on the host, via a long-lived OAuth token. No
`ANTHROPIC_API_KEY` is needed.
**Why a token instead of mounting `~/.claude.json`:** on macOS, Claude
Code stores OAuth credentials in the encrypted Keychain, not in
`~/.claude.json`. Mounting that file into a Linux container does not
carry the credentials with it. Linux hosts keep credentials in
`~/.claude/.credentials.json`, but to keep the launcher portable
bot-bottle uses the env-var path on every host.
**One-time setup on the host:**
```sh
claude setup-token # browser login, prints a ~1-year OAuth token
```
Stash the token in your shell env (e.g. `~/.zshrc` or a secret manager)
as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`:
```sh
export BOT_BOTTLE_CLAUDE_OAUTH_TOKEN="<token>"
```
The Claude bottle reaches the Anthropic API only through the cred-proxy
sidecar. To let `claude` authenticate, declare an egress route with
`role: claude_code_oauth` and
`token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`:
```yaml
egress:
routes:
- host: api.anthropic.com
role: claude_code_oauth
auth:
scheme: Bearer
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
pipelock:
tls_passthrough: true
```
Routes that resolve to private or Tailscale addresses can opt into
pipelock's SSRF destination allowlist explicitly:
```yaml
egress: egress:
routes: routes:
- host: gitea.dideric.is - host: gitea.dideric.is
@@ -486,38 +103,31 @@ egress:
scheme: token scheme: token
token_ref: BOT_BOTTLE_GITEA_TOKEN token_ref: BOT_BOTTLE_GITEA_TOKEN
pipelock: pipelock:
ssrf_ip_allowlist: ssrf_ip_allowlist: [100.78.141.42/32]
- 100.78.141.42/32 ---
```
At launch, `cli.py` reads `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` from the host The `gitea-dev` bottle. Provider auth via the inherited Claude route;
env and forwards it into the cred-proxy container's environ — never gitea over SSH for push, token over HTTPS for the API.
into the agent's. The agent receives `ANTHROPIC_BASE_URL` pointing at ````
`http://cred-proxy:9099/anthropic` and a non-secret placeholder for
`CLAUDE_CODE_OAUTH_TOKEN` (claude-code refuses to start without one;
the proxy strips and replaces the header on every request). `printenv`
inside the agent does not surface the real token, and the value is
never written to disk or placed on argv on the host.
A Claude bottle without a `claude_code_oauth` route has no path to the **Agent** (`~/.bot-bottle/agents/gitea-helper.md`):
Anthropic API — there is no fallback that forwards the token directly
to the agent. Caveats: the token is bound to your subscription tier ````markdown
(Pro/Max/Team/Enterprise), it does not work with `claude --bare` ---
(which only reads `ANTHROPIC_API_KEY`), and if it leaks, regenerate bottle: gitea-dev
via `claude setup-token` again. Reference: skills:
<https://code.claude.com/docs/en/authentication>. - init-prd
---
You help maintain Gitea-hosted projects.
````
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
## Trademarks ## Trademarks
bot-bottle is an independent project and is not affiliated with, bot-bottle is an independent project and is not affiliated with, endorsed by, or sponsored by Anthropic, PBC. "Claude" and "Claude Code" are trademarks of Anthropic, PBC; the project name uses "claude" descriptively to indicate that the tool runs Claude Code inside a sandbox.
endorsed by, or sponsored by Anthropic, PBC. "Claude" and "Claude
Code" are trademarks of Anthropic, PBC; the project name uses
"claude" descriptively to indicate that the tool runs Claude Code
inside a sandbox.
## License ## License
Copyright 2026 Eric Bauerfeld Copyright 2026 Eric Bauerfeld. Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for the full text.
Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE)
for the full text.
+19 -21
View File
@@ -312,15 +312,13 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]: def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]:
"""Build/run the bottle and yield a handle; tear down on exit.""" """Build/run the bottle and yield a handle; tear down on exit."""
def provision(self, plan: PlanT, target: str) -> str | None: def provision(self, plan: PlanT, bottle: "Bottle") -> str | None:
"""Copy host-side files (CA cert, prompt, skills, .git) into """Copy host-side files (CA cert, prompt, skills, .git) into
the running bottle. Called from `launch` after the container the running bottle. Called from `launch` after the container
/ machine is up. `target` identifies the running instance in / machine is up. Returns the in-container prompt path if a
backend-specific terms (Docker: resolved container name; fly: prompt was provisioned, else None — the Bottle handle uses it
machine id). Returns the in-container prompt path if a prompt to decide whether to add provider-specific prompt args to the
was provisioned, else None — the Bottle handle uses it to agent's argv.
decide whether to add provider-specific prompt args to the agent's
argv.
Default orchestration: ca → prompt → skills → workspace → git → Default orchestration: ca → prompt → skills → workspace → git →
supervise. CA install runs first so the agent's trust store supervise. CA install runs first so the agent's trust store
@@ -333,16 +331,16 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
on the agent's HTTP_PROXY path so every tool that respects on the agent's HTTP_PROXY path so every tool that respects
HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is
intercepted without per-tool reconfiguration.""" intercepted without per-tool reconfiguration."""
self.provision_ca(plan, target) self.provision_ca(plan, bottle)
prompt_path = self.provision_prompt(plan, target) prompt_path = self.provision_prompt(plan, bottle)
self.provision_provider_auth(plan, target) self.provision_provider_auth(plan, bottle)
self.provision_skills(plan, target) self.provision_skills(plan, bottle)
self.provision_workspace(plan, target) self.provision_workspace(plan, bottle)
self.provision_git(plan, target) self.provision_git(plan, bottle)
self.provision_supervise(plan, target) self.provision_supervise(plan, bottle)
return prompt_path return prompt_path
def provision_ca(self, plan: PlanT, target: str) -> None: def provision_ca(self, plan: PlanT, bottle: "Bottle") -> None:
"""Install the per-bottle CA into the agent's trust store so """Install the per-bottle CA into the agent's trust store so
the agent trusts the bumped CONNECT cert egress (was the agent trusts the bumped CONNECT cert egress (was
pipelock, pre-PRD-0017) presents. Default impl is a no-op so pipelock, pre-PRD-0017) presents. Default impl is a no-op so
@@ -351,34 +349,34 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
backend overrides to docker-cp the cert in and run backend overrides to docker-cp the cert in and run
`update-ca-certificates`.""" `update-ca-certificates`."""
def provision_provider_auth(self, plan: PlanT, target: str) -> None: def provision_provider_auth(self, plan: PlanT, bottle: "Bottle") -> None:
"""Install non-secret provider auth marker files into the agent """Install non-secret provider auth marker files into the agent
home when a provider needs them to select the right auth mode. home when a provider needs them to select the right auth mode.
The default is no-op.""" The default is no-op."""
@abstractmethod @abstractmethod
def provision_prompt(self, plan: PlanT, target: str) -> str | None: def provision_prompt(self, plan: PlanT, bottle: "Bottle") -> str | None:
"""Copy the prompt file into the running bottle. Returns the """Copy the prompt file into the running bottle. Returns the
in-container path iff the agent has a non-empty prompt; in-container path iff the agent has a non-empty prompt;
callers use the return value to decide whether to add callers use the return value to decide whether to add
provider-specific prompt args to the agent's argv.""" provider-specific prompt args to the agent's argv."""
@abstractmethod @abstractmethod
def provision_skills(self, plan: PlanT, target: str) -> None: def provision_skills(self, plan: PlanT, bottle: "Bottle") -> None:
"""Copy the agent's named skills from the host into the """Copy the agent's named skills from the host into the
running bottle. No-op when the agent has no skills.""" running bottle. No-op when the agent has no skills."""
def provision_workspace(self, plan: PlanT, target: str) -> None: def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> None:
"""Copy the operator workspace into the running bottle when """Copy the operator workspace into the running bottle when
the backend cannot bake it into the agent image. Default is the backend cannot bake it into the agent image. Default is
no-op for backends like Docker that handle this before launch.""" no-op for backends like Docker that handle this before launch."""
@abstractmethod @abstractmethod
def provision_git(self, plan: PlanT, target: str) -> None: def provision_git(self, plan: PlanT, bottle: "Bottle") -> None:
"""Copy the host's cwd `.git` directory into the running """Copy the host's cwd `.git` directory into the running
bottle if the user requested --cwd. No-op otherwise.""" bottle if the user requested --cwd. No-op otherwise."""
def provision_supervise(self, plan: PlanT, target: str) -> None: def provision_supervise(self, plan: PlanT, bottle: "Bottle") -> None:
"""Write the in-bottle Claude Code MCP config so the agent """Write the in-bottle Claude Code MCP config so the agent
discovers the per-bottle supervise sidecar (PRD 0013). discovers the per-bottle supervise sidecar (PRD 0013).
No-op when bottle.supervise is False or the backend doesn't No-op when bottle.supervise is False or the backend doesn't
+13 -13
View File
@@ -18,7 +18,7 @@ from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Generator, Sequence from typing import Generator, Sequence
from .. import ActiveAgent, BottleBackend, BottleSpec from .. import ActiveAgent, Bottle, BottleBackend, BottleSpec
from . import cleanup as _cleanup from . import cleanup as _cleanup
from . import enumerate as _enumerate from . import enumerate as _enumerate
from . import launch as _launch from . import launch as _launch
@@ -57,23 +57,23 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
with _launch.launch(plan, provision=self.provision) as bottle: with _launch.launch(plan, provision=self.provision) as bottle:
yield bottle yield bottle
def provision_ca(self, plan: DockerBottlePlan, target: str) -> None: def provision_ca(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
_ca.provision_ca(plan, target) _ca.provision_ca(plan, bottle)
def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None: def provision_prompt(self, plan: DockerBottlePlan, bottle: Bottle) -> str | None:
return _prompt.provision_prompt(plan, target) return _prompt.provision_prompt(plan, bottle)
def provision_provider_auth(self, plan: DockerBottlePlan, target: str) -> None: def provision_provider_auth(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
_provider_auth.provision_provider_auth(plan, target) _provider_auth.provision_provider_auth(plan, bottle)
def provision_skills(self, plan: DockerBottlePlan, target: str) -> None: def provision_skills(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
_skills.provision_skills(plan, target) _skills.provision_skills(plan, bottle)
def provision_git(self, plan: DockerBottlePlan, target: str) -> None: def provision_git(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
_git.provision_git(plan, target) _git.provision_git(plan, bottle)
def provision_supervise(self, plan: DockerBottlePlan, target: str) -> None: def provision_supervise(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
_supervise_prov.provision_supervise(plan, target) _supervise_prov.provision_supervise(plan, bottle)
def prepare_cleanup(self) -> DockerBottleCleanupPlan: def prepare_cleanup(self) -> DockerBottleCleanupPlan:
return _cleanup.prepare_cleanup() return _cleanup.prepare_cleanup()
+20 -10
View File
@@ -43,6 +43,7 @@ from pathlib import Path
from typing import Callable, Generator from typing import Callable, Generator
from ...egress import egress_resolve_token_values from ...egress import egress_resolve_token_values
from ...git_gate import revoke_git_gate_provisioned_keys
from ...log import info, warn from ...log import info, warn
from . import network as network_mod from . import network as network_mod
from . import util as docker_mod from . import util as docker_mod
@@ -51,6 +52,7 @@ from .bottle_plan import DockerBottlePlan
from .bottle_state import ( from .bottle_state import (
bottle_state_dir, bottle_state_dir,
egress_state_dir, egress_state_dir,
git_gate_state_dir,
pipelock_state_dir, pipelock_state_dir,
) )
from .compose import ( from .compose import (
@@ -84,6 +86,9 @@ def launch(
Teardown on exit.""" Teardown on exit."""
stack = ExitStack() stack = ExitStack()
_bottle_for_revoke = plan.spec.manifest.bottle_for(plan.spec.agent_name)
_git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
def teardown() -> None: def teardown() -> None:
try: try:
stack.close() stack.close()
@@ -92,6 +97,9 @@ def launch(
f"teardown failed for container {plan.container_name}" f"teardown failed for container {plan.container_name}"
f" (compose-down): {exc!r}" f" (compose-down): {exc!r}"
) )
revoke_git_gate_provisioned_keys(
_bottle_for_revoke, _git_gate_dir_for_revoke
)
try: try:
# Step 1: agent image build. Sidecar images get built lazily by # Step 1: agent image build. Sidecar images get built lazily by
@@ -200,19 +208,21 @@ def launch(
compose_dump_logs, project, compose_file, compose_log_path(state_dir), compose_dump_logs, project, compose_file, compose_log_path(state_dir),
) )
# Step 8: provision. Unchanged — uses `docker exec` against # Step 8: provision. Create the bottle first so provisioners
# the agent container by its known name. # can use bottle.exec / bottle.cp_in; set the prompt path
prompt_path = provision(plan, plan.container_name) # returned by provision_prompt after the fact.
bottle = DockerBottle(
plan.container_name,
teardown,
None,
agent_command=plan.agent_command,
agent_prompt_mode=plan.agent_prompt_mode,
)
bottle._prompt_path = provision(plan, bottle)
# Step 9: yield. exec_agent continues to use `docker exec -it` # Step 9: yield. exec_agent continues to use `docker exec -it`
# — the agent runs `sleep infinity` per the renderer's # — the agent runs `sleep infinity` per the renderer's
# service spec. # service spec.
yield DockerBottle( yield bottle
plan.container_name,
teardown,
prompt_path,
agent_command=plan.agent_command,
agent_prompt_mode=plan.agent_prompt_mode,
)
finally: finally:
teardown() teardown()
@@ -1,7 +1,7 @@
"""Per-provisioner modules for the Docker backend. """Per-provisioner modules for the Docker backend.
Each module exports one top-level function: Each module exports one top-level function:
provision_<thing>(plan: DockerBottlePlan, target: str) -> ... provision_<thing>(plan: DockerBottlePlan, bottle: Bottle) -> ...
`DockerBottleBackend.provision_*` methods delegate to these. The `DockerBottleBackend.provision_*` methods delegate to these. The
abstract `BottleBackend.provision_*` surface is unchanged; this abstract `BottleBackend.provision_*` surface is unchanged; this
+6 -18
View File
@@ -31,33 +31,21 @@ stage dir; nothing in the agent ever sees it."""
from __future__ import annotations from __future__ import annotations
import subprocess from ... import Bottle
from ...util import AGENT_CA_PATH, log_ca_fingerprint, select_ca_cert from ...util import AGENT_CA_PATH, log_ca_fingerprint, select_ca_cert
from ..bottle_plan import DockerBottlePlan from ..bottle_plan import DockerBottlePlan
def provision_ca(plan: DockerBottlePlan, target: str) -> None: def provision_ca(plan: DockerBottlePlan, bottle: Bottle) -> None:
"""Copy the agent-facing CA cert into the agent, rebuild the """Copy the agent-facing CA cert into the agent, rebuild the
trust bundle, emit a one-line fingerprint log. Called from trust bundle, emit a one-line fingerprint log. Called from
`BottleBackend.provision` after the agent container is up.""" `BottleBackend.provision` after the agent container is up."""
container = target
cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan) cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan)
subprocess.run( bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
["docker", "cp", str(cert_host_path), f"{container}:{AGENT_CA_PATH}"], bottle.exec(
stdout=subprocess.DEVNULL, f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates",
check=True, user="root",
)
subprocess.run(
["docker", "exec", "-u", "0", container, "chmod", "644", AGENT_CA_PATH],
stdout=subprocess.DEVNULL,
check=True,
)
subprocess.run(
["docker", "exec", "-u", "0", container, "update-ca-certificates"],
stdout=subprocess.DEVNULL,
check=True,
) )
log_ca_fingerprint(cert_host_path, label) log_ca_fingerprint(cert_host_path, label)
+31 -46
View File
@@ -19,74 +19,63 @@ Three concerns, all about git in the agent:
from __future__ import annotations from __future__ import annotations
import os import os
import subprocess import shlex
from ....git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig from ....git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
from ....log import info from ....log import info
from .. import util as docker_mod from ... import Bottle
from ..bottle_plan import DockerBottlePlan from ..bottle_plan import DockerBottlePlan
def provision_git(plan: DockerBottlePlan, target: str) -> None: def provision_git(plan: DockerBottlePlan, bottle: Bottle) -> None:
"""Set up git inside the bottle. Runs all three subcases; each """Set up git inside the bottle. Runs all three subcases; each
no-ops when its condition isn't met.""" no-ops when its condition isn't met."""
_provision_cwd_git(plan, target) _provision_cwd_git(plan, bottle)
_provision_git_gate_config(plan, target) _provision_git_gate_config(plan, bottle)
_provision_git_user(plan, target) _provision_git_user(plan, bottle)
def _provision_cwd_git(plan: DockerBottlePlan, target: str) -> None: def _provision_cwd_git(plan: DockerBottlePlan, bottle: Bottle) -> None:
"""If --cwd was set and the host cwd has a .git directory, copy """If --cwd was set and the host cwd has a .git directory, copy
it into /home/node/workspace/.git and fix ownership. No-op it into /home/node/workspace/.git and fix ownership. No-op
otherwise.""" otherwise."""
workspace = plan.workspace_plan workspace = plan.workspace_plan
if not (workspace.enabled and workspace.copy_git and workspace.has_host_git_dir): if not (workspace.enabled and workspace.copy_git and workspace.has_host_git_dir):
return return
container = target
guest_workspace_git = f"{workspace.guest_path}/.git" guest_workspace_git = f"{workspace.guest_path}/.git"
host_git = str(workspace.host_path / ".git") host_git = str(workspace.host_path / ".git")
info(f"copying {host_git} -> {container}:{guest_workspace_git}") info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}")
subprocess.run( bottle.cp_in(host_git, guest_workspace_git)
["docker", "cp", host_git, f"{container}:{guest_workspace_git}"], bottle.exec(
stdout=subprocess.DEVNULL, f"chown -R {shlex.quote(workspace.owner)} {shlex.quote(guest_workspace_git)}",
check=True, user="root",
)
subprocess.run(
[
"docker", "exec", "-u", "0", container,
"chown", "-R", workspace.owner, guest_workspace_git,
],
stdout=subprocess.DEVNULL,
check=True,
) )
def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None: def _provision_git_gate_config(plan: DockerBottlePlan, bottle: Bottle) -> None:
"""Write ~/.gitconfig in the bottle with the git-gate """Write ~/.gitconfig in the bottle with the git-gate
insteadOf rules. No-op when the bottle has no `git` entries.""" insteadOf rules. No-op when the bottle has no `git` entries."""
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
if not bottle.git: if not manifest_bottle.git:
return return
container = target
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node") container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
container_gitconfig = f"{container_home}/.gitconfig" container_gitconfig = f"{container_home}/.gitconfig"
content = git_gate_render_gitconfig(bottle.git, GIT_GATE_HOSTNAME) content = git_gate_render_gitconfig(manifest_bottle.git, GIT_GATE_HOSTNAME)
config_file = plan.stage_dir / "agent_gitconfig" config_file = plan.stage_dir / "agent_gitconfig"
config_file.write_text(content) config_file.write_text(content)
config_file.chmod(0o600) config_file.chmod(0o600)
info(f"writing {container_gitconfig} with {len(bottle.git)} insteadOf rule(s)") info(f"writing {container_gitconfig} with {len(manifest_bottle.git)} insteadOf rule(s)")
subprocess.run( bottle.cp_in(str(config_file), container_gitconfig)
["docker", "cp", str(config_file), f"{container}:{container_gitconfig}"], bottle.exec(
stdout=subprocess.DEVNULL, f"chown node:node {shlex.quote(container_gitconfig)} && "
check=True, f"chmod 644 {shlex.quote(container_gitconfig)}",
user="root",
) )
docker_mod.docker_exec_root(container, ["chown", "node:node", container_gitconfig])
docker_mod.docker_exec_root(container, ["chmod", "644", container_gitconfig])
def _provision_git_user(plan: DockerBottlePlan, target: str) -> None: def _provision_git_user(plan: DockerBottlePlan, bottle: Bottle) -> None:
"""Apply `git config --global user.{name,email}` inside the """Apply `git config --global user.{name,email}` inside the
bottle so the agent's commits are attributed to the operator- bottle so the agent's commits are attributed to the operator-
chosen identity instead of the agent image's default chosen identity instead of the agent image's default
@@ -101,23 +90,19 @@ def _provision_git_user(plan: DockerBottlePlan, target: str) -> None:
Each field set independently — name-only or email-only Each field set independently — name-only or email-only
configs only run the `git config` line for the field configs only run the `git config` line for the field
present.""" present."""
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
gu = bottle.git_user gu = manifest_bottle.git_user
if gu.is_empty(): if gu.is_empty():
return return
if gu.name: if gu.name:
info(f"git config --global user.name = {gu.name!r}") info(f"git config --global user.name = {gu.name!r}")
subprocess.run( bottle.exec(
["docker", "exec", "-u", "node", target, f"git config --global user.name {shlex.quote(gu.name)}",
"git", "config", "--global", "user.name", gu.name], user="node",
stdout=subprocess.DEVNULL,
check=True,
) )
if gu.email: if gu.email:
info(f"git config --global user.email = {gu.email!r}") info(f"git config --global user.email = {gu.email!r}")
subprocess.run( bottle.exec(
["docker", "exec", "-u", "node", target, f"git config --global user.email {shlex.quote(gu.email)}",
"git", "config", "--global", "user.email", gu.email], user="node",
stdout=subprocess.DEVNULL,
check=True,
) )
+7 -17
View File
@@ -7,36 +7,26 @@ actually has a prompt — the return value signals which case."""
from __future__ import annotations from __future__ import annotations
import os import os
import subprocess
from ... import Bottle
from ..bottle_plan import DockerBottlePlan from ..bottle_plan import DockerBottlePlan
def provision_prompt(plan: DockerBottlePlan, target: str) -> str | None: def provision_prompt(plan: DockerBottlePlan, bottle: Bottle) -> str | None:
"""Copy the prompt file into the container, fix ownership/mode. """Copy the prompt file into the container, fix ownership/mode.
Returns the in-container path if the agent has a non-empty Returns the in-container path if the agent has a non-empty
prompt (drives --append-system-prompt-file), else None. The prompt (drives --append-system-prompt-file), else None. The
file is copied either way so the path always exists.""" file is copied either way so the path always exists."""
container = target
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node") container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
in_container_prompt_path = f"{container_home}/.bot-bottle-prompt.txt" in_container_prompt_path = f"{container_home}/.bot-bottle-prompt.txt"
subprocess.run( bottle.cp_in(str(plan.prompt_file), in_container_prompt_path)
["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"],
stdout=subprocess.DEVNULL,
check=True,
)
# `docker cp` preserves host UID; re-own/mode as root so node # `docker cp` preserves host UID; re-own/mode as root so node
# can read its own mode-600 prompt regardless of host UID. # can read its own mode-600 prompt regardless of host UID.
subprocess.run( bottle.exec(
["docker", "exec", "-u", "0", container, "chown", "node:node", in_container_prompt_path], f"chown node:node {in_container_prompt_path} && "
stdout=subprocess.DEVNULL, f"chmod 600 {in_container_prompt_path}",
check=True, user="root",
)
subprocess.run(
["docker", "exec", "-u", "0", container, "chmod", "600", in_container_prompt_path],
stdout=subprocess.DEVNULL,
check=True,
) )
agent = plan.spec.manifest.agents[plan.spec.agent_name] agent = plan.spec.manifest.agents[plan.spec.agent_name]
@@ -2,35 +2,34 @@
from __future__ import annotations from __future__ import annotations
import subprocess import shlex
from ....log import die
from ... import Bottle
from ..bottle_plan import DockerBottlePlan from ..bottle_plan import DockerBottlePlan
def provision_provider_auth(plan: DockerBottlePlan, target: str) -> None: def provision_provider_auth(plan: DockerBottlePlan, bottle: Bottle) -> None:
"""Apply provider-owned guest setup through Docker primitives.""" """Apply provider-owned guest setup through the bottle's exec / cp_in."""
provision = plan.agent_provision provision = plan.agent_provision
for d in provision.dirs: for d in provision.dirs:
_exec(target, ["mkdir", "-p", d.guest_path]) _exec(bottle, f"mkdir -p {shlex.quote(d.guest_path)}", d.guest_path)
_exec(target, ["chown", d.owner, d.guest_path]) _exec(bottle, f"chown {shlex.quote(d.owner)} {shlex.quote(d.guest_path)}", d.guest_path)
_exec(target, ["chmod", d.mode, d.guest_path]) _exec(bottle, f"chmod {shlex.quote(d.mode)} {shlex.quote(d.guest_path)}", d.guest_path)
for command in provision.pre_copy: for command in provision.pre_copy:
_exec(target, list(command.argv)) _exec(bottle, shlex.join(command.argv), command.error)
for f in provision.files: for f in provision.files:
subprocess.run( bottle.cp_in(str(f.host_path), f.guest_path)
["docker", "cp", str(f.host_path), f"{target}:{f.guest_path}"], _exec(bottle, f"chown {shlex.quote(f.owner)} {shlex.quote(f.guest_path)}", f.guest_path)
stdout=subprocess.DEVNULL, _exec(bottle, f"chmod {shlex.quote(f.mode)} {shlex.quote(f.guest_path)}", f.guest_path)
check=True,
)
_exec(target, ["chown", f.owner, f.guest_path])
_exec(target, ["chmod", f.mode, f.guest_path])
for command in provision.verify: for command in provision.verify:
_exec(target, list(command.argv)) _exec(bottle, shlex.join(command.argv), command.error)
def _exec(target: str, argv: list[str]) -> None: def _exec(bottle: Bottle, script: str, error: str) -> None:
subprocess.run( result = bottle.exec(script, user="root")
["docker", "exec", "-u", "0", target, *argv], if result.returncode != 0:
stdout=subprocess.DEVNULL, detail = (result.stderr or result.stdout).strip()
check=True, if detail:
) detail = f": {detail}"
die(f"agent provider provisioning: {error}{detail}")
+7 -25
View File
@@ -9,54 +9,36 @@ a partial container."""
from __future__ import annotations from __future__ import annotations
import os import os
import subprocess
from ....log import die, info from ....log import die, info
from ...util import host_skill_dir from ...util import host_skill_dir
from ... import Bottle
from ..bottle_plan import DockerBottlePlan from ..bottle_plan import DockerBottlePlan
def provision_skills(plan: DockerBottlePlan, target: str) -> None: def provision_skills(plan: DockerBottlePlan, bottle: Bottle) -> None:
"""Copy each of the agent's named skills from the host's """Copy each of the agent's named skills from the host's
~/.claude/skills/<name>/ into the container's equivalent path. ~/.claude/skills/<name>/ into the container's equivalent path.
For each skill: ensure parent dir, wipe any prior copy, then For each skill: ensure parent dir, wipe any prior copy, then
`docker cp <host>/. <container>:<dst>/` so the contents are `cp_in <host>/. <container>:<dst>/` so the contents are
copied into a freshly-created destination dir. No-op when the copied into a freshly-created destination dir. No-op when the
agent has no skills.""" agent has no skills."""
agent = plan.spec.manifest.agents[plan.spec.agent_name] agent = plan.spec.manifest.agents[plan.spec.agent_name]
if not agent.skills: if not agent.skills:
return return
container = target
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node") container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
skills_dir = os.environ.get( skills_dir = os.environ.get(
"BOT_BOTTLE_CONTAINER_SKILLS_DIR", f"{container_home}/.claude/skills" "BOT_BOTTLE_CONTAINER_SKILLS_DIR", f"{container_home}/.claude/skills"
) )
subprocess.run( bottle.exec(f"mkdir -p {skills_dir}", user="node")
["docker", "exec", container, "mkdir", "-p", skills_dir],
stdout=subprocess.DEVNULL,
check=True,
)
for n in agent.skills: for n in agent.skills:
src = host_skill_dir(n) src = host_skill_dir(n)
if not os.path.isdir(src): if not os.path.isdir(src):
die(f"skill '{n}' disappeared from host between validation and copy at {src}.") die(f"skill '{n}' disappeared from host between validation and copy at {src}.")
dst = f"{skills_dir}/{n}" dst = f"{skills_dir}/{n}"
info(f"copying skill {n} into {container}:{dst}") info(f"copying skill {n} into {bottle.name}:{dst}")
subprocess.run( bottle.exec(f"rm -rf {dst} && mkdir -p {dst}", user="node")
["docker", "exec", container, "rm", "-rf", dst], bottle.cp_in(f"{src}/.", f"{dst}/")
stdout=subprocess.DEVNULL,
check=True,
)
subprocess.run(
["docker", "exec", container, "mkdir", "-p", dst],
stdout=subprocess.DEVNULL,
check=True,
)
subprocess.run(
["docker", "cp", f"{src}/.", f"{container}:{dst}/"],
stdout=subprocess.DEVNULL,
check=True,
)
@@ -18,10 +18,9 @@ sidecar that isn't running.
from __future__ import annotations from __future__ import annotations
import subprocess
from ....log import info, warn from ....log import info, warn
from ....supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT from ....supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
from ... import Bottle
from ..bottle_plan import DockerBottlePlan from ..bottle_plan import DockerBottlePlan
@@ -32,7 +31,7 @@ def supervise_mcp_url() -> str:
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/" return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
def provision_supervise(plan: DockerBottlePlan, target: str) -> None: def provision_supervise(plan: DockerBottlePlan, bottle: Bottle) -> None:
"""Run `claude mcp add` inside the agent container to register """Run `claude mcp add` inside the agent container to register
the supervise sidecar in claude-code's user config. No-op when the supervise sidecar in claude-code's user config. No-op when
bottle.supervise is False. bottle.supervise is False.
@@ -43,16 +42,11 @@ def provision_supervise(plan: DockerBottlePlan, target: str) -> None:
if plan.supervise_plan is None: if plan.supervise_plan is None:
return return
url = supervise_mcp_url() url = supervise_mcp_url()
argv = [
"docker", "exec", "-u", "node", target,
"claude", "mcp", "add",
"--scope", "user",
"--transport", "http",
_SUPERVISE_MCP_NAME,
url,
]
info(f"registering supervise MCP server in agent claude config → {url}") info(f"registering supervise MCP server in agent claude config → {url}")
r = subprocess.run(argv, capture_output=True, text=True, check=False) r = bottle.exec(
f"claude mcp add --scope user --transport http {_SUPERVISE_MCP_NAME} {url}",
user="node",
)
if r.returncode != 0: if r.returncode != 0:
warn( warn(
f"`claude mcp add supervise` failed (exit {r.returncode}): " f"`claude mcp add supervise` failed (exit {r.returncode}): "
+15 -15
View File
@@ -7,7 +7,7 @@ from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Generator, Sequence from typing import Generator, Sequence
from .. import ActiveAgent, BottleBackend, BottleSpec from .. import ActiveAgent, Bottle, BottleBackend, BottleSpec
from . import cleanup as _cleanup from . import cleanup as _cleanup
from . import enumerate as _enumerate from . import enumerate as _enumerate
from . import launch as _launch from . import launch as _launch
@@ -54,39 +54,39 @@ class SmolmachinesBottleBackend(
yield bottle yield bottle
def provision_ca( def provision_ca(
self, plan: SmolmachinesBottlePlan, target: str self, plan: SmolmachinesBottlePlan, bottle: Bottle
) -> None: ) -> None:
_ca.provision_ca(plan, target) _ca.provision_ca(plan, bottle)
def provision_prompt( def provision_prompt(
self, plan: SmolmachinesBottlePlan, target: str self, plan: SmolmachinesBottlePlan, bottle: Bottle
) -> str | None: ) -> str | None:
return _prompt.provision_prompt(plan, target) return _prompt.provision_prompt(plan, bottle)
def provision_provider_auth( def provision_provider_auth(
self, plan: SmolmachinesBottlePlan, target: str self, plan: SmolmachinesBottlePlan, bottle: Bottle
) -> None: ) -> None:
_provider_auth.provision_provider_auth(plan, target) _provider_auth.provision_provider_auth(plan, bottle)
def provision_skills( def provision_skills(
self, plan: SmolmachinesBottlePlan, target: str self, plan: SmolmachinesBottlePlan, bottle: Bottle
) -> None: ) -> None:
_skills.provision_skills(plan, target) _skills.provision_skills(plan, bottle)
def provision_workspace( def provision_workspace(
self, plan: SmolmachinesBottlePlan, target: str self, plan: SmolmachinesBottlePlan, bottle: Bottle
) -> None: ) -> None:
_workspace.provision_workspace(plan, target) _workspace.provision_workspace(plan, bottle)
def provision_git( def provision_git(
self, plan: SmolmachinesBottlePlan, target: str self, plan: SmolmachinesBottlePlan, bottle: Bottle
) -> None: ) -> None:
_git.provision_git(plan, target) _git.provision_git(plan, bottle)
def provision_supervise( def provision_supervise(
self, plan: SmolmachinesBottlePlan, target: str self, plan: SmolmachinesBottlePlan, bottle: Bottle
) -> None: ) -> None:
_supervise.provision_supervise(plan, target) _supervise.provision_supervise(plan, bottle)
def prepare_cleanup(self) -> SmolmachinesBottleCleanupPlan: def prepare_cleanup(self) -> SmolmachinesBottleCleanupPlan:
return _cleanup.prepare_cleanup() return _cleanup.prepare_cleanup()
+29 -4
View File
@@ -53,6 +53,9 @@ from ..docker.pipelock import (
PIPELOCK_PORT as _PIPELOCK_PORT_STR, PIPELOCK_PORT as _PIPELOCK_PORT_STR,
pipelock_tls_init, pipelock_tls_init,
) )
from ...git_gate import revoke_git_gate_provisioned_keys
from ...log import warn
from ..docker.bottle_state import git_gate_state_dir
from . import loopback_alias as _loopback from . import loopback_alias as _loopback
from . import sidecar_bundle as _bundle from . import sidecar_bundle as _bundle
from . import smolvm as _smolvm from . import smolvm as _smolvm
@@ -110,17 +113,39 @@ def launch(
_launch_vm(plan, agent_from_path, loopback_ip, stack) _launch_vm(plan, agent_from_path, loopback_ip, stack)
_init_vm(plan) _init_vm(plan)
prompt_path = provision(plan, plan.machine_name) bottle = SmolmachinesBottle(
yield SmolmachinesBottle(
plan.machine_name, plan.machine_name,
prompt_path=prompt_path, prompt_path=None,
guest_env=plan.guest_env, guest_env=plan.guest_env,
agent_command=plan.agent_command, agent_command=plan.agent_command,
agent_prompt_mode=plan.agent_prompt_mode, agent_prompt_mode=plan.agent_prompt_mode,
) )
bottle._prompt_path = provision(plan, bottle)
yield bottle
finally: finally:
_teardown_smolmachines(stack, plan)
def _teardown_smolmachines(
stack: ExitStack,
plan: SmolmachinesBottlePlan,
) -> None:
"""Unwind the ExitStack, then revoke any provisioned deploy keys.
ExitStack errors are caught and logged (non-fatal) so that key
revocation always runs. Revocation errors propagate — a stranded
deploy key is a security concern the operator must address."""
teardown_exc: BaseException | None = None
try:
stack.close() stack.close()
except BaseException as exc:
teardown_exc = exc
warn(f"smolmachines teardown failed: {exc!r}")
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
revoke_git_gate_provisioned_keys(bottle, git_gate_state_dir(plan.slug))
if teardown_exc is not None:
raise teardown_exc
def _allocate_resources( def _allocate_resources(
+17 -17
View File
@@ -2,8 +2,8 @@
trust store (PRD 0023 chunk 4d). trust store (PRD 0023 chunk 4d).
Mirrors `backend.docker.provision.ca`: select the right CA (egress Mirrors `backend.docker.provision.ca`: select the right CA (egress
when the bottle has routes, else pipelock), `smolvm machine cp` it when the bottle has routes, else pipelock), copy it to Debian's
to Debian's `/usr/local/share/ca-certificates/` path, `/usr/local/share/ca-certificates/` path,
`update-ca-certificates` to rebuild the trust bundle, and log the `update-ca-certificates` to rebuild the trust bundle, and log the
fingerprint once. The selected cert depends on the agent's fingerprint once. The selected cert depends on the agent's
HTTP_PROXY target — same logic as the docker backend, since the HTTP_PROXY target — same logic as the docker backend, since the
@@ -24,20 +24,20 @@ from ...util import (
log_ca_fingerprint, log_ca_fingerprint,
select_ca_cert, select_ca_cert,
) )
from .. import smolvm as _smolvm from ... import Bottle, ExecResult
from ..bottle_plan import SmolmachinesBottlePlan from ..bottle_plan import SmolmachinesBottlePlan
_SIGKILL_EXIT = 128 + 9 _SIGKILL_EXIT = 128 + 9
def provision_ca(plan: SmolmachinesBottlePlan, target: str) -> None: def provision_ca(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
"""Copy the agent-facing CA cert into the guest, rebuild the """Copy the agent-facing CA cert into the guest, rebuild the
trust bundle, emit a one-line fingerprint log. Called from trust bundle, emit a one-line fingerprint log. Called from
`BottleBackend.provision` after the smolvm guest is up.""" `BottleBackend.provision` after the smolvm guest is up."""
cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan) cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan)
_smolvm.machine_cp(str(cert_host_path), f"{target}:{AGENT_CA_PATH}") bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
# Mode 0644 — readable to non-root tools in the guest. # Mode 0644 — readable to non-root tools in the guest.
# update-ca-certificates rebuilds the bundle at AGENT_CA_BUNDLE, # update-ca-certificates rebuilds the bundle at AGENT_CA_BUNDLE,
# which is what curl / Python ssl / OpenSSL-based tools read by # which is what curl / Python ssl / OpenSSL-based tools read by
@@ -45,21 +45,21 @@ def provision_ca(plan: SmolmachinesBottlePlan, target: str) -> None:
# REQUESTS_CA_BUNDLE) on the guest_env covers Node + Python # REQUESTS_CA_BUNDLE) on the guest_env covers Node + Python
# `requests` / libraries that don't load the system bundle. # `requests` / libraries that don't load the system bundle.
# #
r = _install_ca(target) r = _install_ca(bottle)
if r.returncode == _SIGKILL_EXIT: if r.returncode == _SIGKILL_EXIT:
# smolvm/libkrun can SIGKILL an otherwise-normal exec # smolvm/libkrun can SIGKILL an otherwise-normal exec
# during early-VM provisioning. `update-ca-certificates` # during early-VM provisioning. `update-ca-certificates`
# is idempotent, so retry the same install once after a # is idempotent, so retry the same install once after a
# short settle delay before treating it as fatal. # short settle delay before treating it as fatal.
time.sleep(1.0) time.sleep(1.0)
r = _install_ca(target) r = _install_ca(bottle)
if r.returncode != 0: if r.returncode != 0:
# update-ca-certificates not adding our cert is fatal — # update-ca-certificates not adding our cert is fatal —
# claude-code's TLS handshake against the egress-MITM'd # claude-code's TLS handshake against the egress-MITM'd
# api.anthropic.com would fail downstream. Bail early # api.anthropic.com would fail downstream. Bail early
# with what we can see (output is captured by smolvm so # with what we can see (output is captured so we can
# we can surface it). # surface it).
die( die(
f"update-ca-certificates didn't add the agent CA " f"update-ca-certificates didn't add the agent CA "
f"(exit {r.returncode}): " f"(exit {r.returncode}): "
@@ -70,21 +70,21 @@ def provision_ca(plan: SmolmachinesBottlePlan, target: str) -> None:
log_ca_fingerprint(cert_host_path, label) log_ca_fingerprint(cert_host_path, label)
def _install_ca(target: str) -> _smolvm.SmolvmRunResult: def _install_ca(bottle: Bottle) -> ExecResult:
# chown + chmod + update-ca-certificates + bundle # chown + chmod + update-ca-certificates + bundle
# verification run in one `sh -c` so we only pay one # verification run in one exec so we only pay one
# machine_exec round trip; the `&&` chaining surfaces the # round trip; the `&&` chaining surfaces the first failure
# first failure as the return code. The verify check is more # as the return code. The verify check is more stable than
# stable than requiring "1 added" in stdout: a retry after a # requiring "1 added" in stdout: a retry after a
# partially-completed first run may legitimately report "0 # partially-completed first run may legitimately report "0
# added" while the cert is already installed. # added" while the cert is already installed.
return _smolvm.machine_exec(target, [ return bottle.exec(
"sh", "-c",
f"chown root:root {AGENT_CA_PATH} && " f"chown root:root {AGENT_CA_PATH} && "
f"chmod 644 {AGENT_CA_PATH} && " f"chmod 644 {AGENT_CA_PATH} && "
f"update-ca-certificates && " f"update-ca-certificates && "
f"openssl verify -CAfile {AGENT_CA_BUNDLE} {AGENT_CA_PATH}", f"openssl verify -CAfile {AGENT_CA_BUNDLE} {AGENT_CA_PATH}",
]) user="root",
)
# Re-exported for the launch/provision_ca caller + tests. The path # Re-exported for the launch/provision_ca caller + tests. The path
@@ -26,12 +26,13 @@ git_gate module."""
from __future__ import annotations from __future__ import annotations
import os import os
import shlex
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from ....git_gate import git_gate_render_gitconfig from ....git_gate import git_gate_render_gitconfig
from ....log import info from ....log import info
from .. import smolvm as _smolvm from ... import Bottle
from ..bottle_plan import SmolmachinesBottlePlan from ..bottle_plan import SmolmachinesBottlePlan
@@ -46,15 +47,15 @@ def _guest_home() -> str:
return os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME) return os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
def provision_git(plan: SmolmachinesBottlePlan, target: str) -> None: def provision_git(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
"""Set up git inside the guest. Runs all three subcases; each """Set up git inside the guest. Runs all three subcases; each
no-ops when its condition isn't met.""" no-ops when its condition isn't met."""
_provision_cwd_git(plan, target) _provision_cwd_git(plan, bottle)
_provision_git_gate_config(plan, target) _provision_git_gate_config(plan, bottle)
_provision_git_user(plan, target) _provision_git_user(plan, bottle)
def _provision_cwd_git(plan: SmolmachinesBottlePlan, target: str) -> None: def _provision_cwd_git(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
"""If --cwd was set and the host cwd has a .git directory, copy """If --cwd was set and the host cwd has a .git directory, copy
it into <guest_home>/workspace/.git and fix ownership. No-op it into <guest_home>/workspace/.git and fix ownership. No-op
otherwise.""" otherwise."""
@@ -63,25 +64,26 @@ def _provision_cwd_git(plan: SmolmachinesBottlePlan, target: str) -> None:
return return
guest_workspace_git = f"{workspace.guest_path}/.git" guest_workspace_git = f"{workspace.guest_path}/.git"
host_git = str(workspace.host_path / ".git") host_git = str(workspace.host_path / ".git")
info(f"copying {host_git} -> {target}:{guest_workspace_git}") info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}")
# mkdir -p the workspace dir so `machine cp` lands the .git # mkdir -p the workspace dir so cp_in lands the .git
# directly there even on first-time bottles. # directly there even on first-time bottles.
_smolvm.machine_exec(target, ["mkdir", "-p", workspace.guest_path]) bottle.exec(f"mkdir -p {shlex.quote(workspace.guest_path)}", user="root")
_smolvm.machine_cp( bottle.cp_in(host_git, guest_workspace_git)
host_git, f"{target}:{guest_workspace_git}", # cp_in lands files as root; the agent runs as node so
)
# `machine cp` lands files as root; the agent runs as node so
# the workspace tree must be chowned over. # the workspace tree must be chowned over.
_smolvm.machine_exec( bottle.exec(
target, ["chown", "-R", workspace.owner, guest_workspace_git], f"chown -R {shlex.quote(workspace.owner)} {shlex.quote(guest_workspace_git)}",
user="root",
) )
def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> None: def _provision_git_gate_config(
plan: SmolmachinesBottlePlan, bottle: Bottle
) -> None:
"""Write ~/.gitconfig in the guest with the git-gate insteadOf """Write ~/.gitconfig in the guest with the git-gate insteadOf
rules. No-op when the bottle has no `git` entries.""" rules. No-op when the bottle has no `git` entries."""
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
if not bottle.git: if not manifest_bottle.git:
return return
# `<loopback alias>:<host port>` form: the bundle's git-gate # `<loopback alias>:<host port>` form: the bundle's git-gate
@@ -90,11 +92,11 @@ def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> Non
# TSI, not the docker bridge IP) can dial it. launch.py # TSI, not the docker bridge IP) can dial it. launch.py
# populates `plan.agent_git_gate_host` after bundle bringup. # populates `plan.agent_git_gate_host` after bundle bringup.
content = git_gate_render_gitconfig( content = git_gate_render_gitconfig(
bottle.git, plan.agent_git_gate_host, scheme="http", manifest_bottle.git, plan.agent_git_gate_host, scheme="http",
) )
guest_gitconfig = f"{_guest_home()}/.gitconfig" guest_gitconfig = f"{_guest_home()}/.gitconfig"
# Stage the file under the plan's stage_dir so `machine cp` # Stage the file under the plan's stage_dir so cp_in
# has a stable host path. The plan's stage_dir is cleaned up # has a stable host path. The plan's stage_dir is cleaned up
# by start.py's session-end teardown. # by start.py's session-end teardown.
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
@@ -105,41 +107,38 @@ def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> Non
config_file = Path(f.name) config_file = Path(f.name)
os.chmod(config_file, 0o600) os.chmod(config_file, 0o600)
info(f"writing {guest_gitconfig} with {len(bottle.git)} insteadOf rule(s)") info(f"writing {guest_gitconfig} with {len(manifest_bottle.git)} insteadOf rule(s)")
_smolvm.machine_cp(str(config_file), f"{target}:{guest_gitconfig}") bottle.cp_in(str(config_file), guest_gitconfig)
_smolvm.machine_exec(target, ["chown", "node:node", guest_gitconfig]) bottle.exec(
_smolvm.machine_exec(target, ["chmod", "644", guest_gitconfig]) f"chown node:node {shlex.quote(guest_gitconfig)} && "
f"chmod 644 {shlex.quote(guest_gitconfig)}",
user="root",
)
def _provision_git_user( def _provision_git_user(
plan: SmolmachinesBottlePlan, target: str, plan: SmolmachinesBottlePlan, bottle: Bottle,
) -> None: ) -> None:
"""Apply `git config --global user.{name,email}` inside the """Apply `git config --global user.{name,email}` inside the
guest as the node user so --global lands in the same guest as the node user so --global lands in the same
`/home/node/.gitconfig` that `_provision_git_gate_config` `/home/node/.gitconfig` that `_provision_git_gate_config`
writes to. No-op when the bottle didn't declare `git.user`. writes to. No-op when the bottle didn't declare `git.user`.
Runs via `runuser -u node --`; HOME is forced via smolvm's SmolmachinesBottle.exec(user="node") automatically sets
`-e` flag because runuser (without -l) inherits root's HOME=/home/node so --global writes to /home/node/.gitconfig."""
HOME=/root, which would put --global in the wrong file.""" manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) gu = manifest_bottle.git_user
gu = bottle.git_user
if gu.is_empty(): if gu.is_empty():
return return
env = {"HOME": _guest_home(), "USER": "node"}
if gu.name: if gu.name:
info(f"git config --global user.name = {gu.name!r}") info(f"git config --global user.name = {gu.name!r}")
_smolvm.machine_exec( bottle.exec(
target, f"git config --global user.name {shlex.quote(gu.name)}",
["runuser", "-u", "node", "--", user="node",
"git", "config", "--global", "user.name", gu.name],
env=env,
) )
if gu.email: if gu.email:
info(f"git config --global user.email = {gu.email!r}") info(f"git config --global user.email = {gu.email!r}")
_smolvm.machine_exec( bottle.exec(
target, f"git config --global user.email {shlex.quote(gu.email)}",
["runuser", "-u", "node", "--", user="node",
"git", "config", "--global", "user.email", gu.email],
env=env,
) )
@@ -5,7 +5,7 @@ exists) but `--append-system-prompt-file` only fires when the
agent actually has a prompt — the return value signals which agent actually has a prompt — the return value signals which
case, mirroring the docker backend's contract. case, mirroring the docker backend's contract.
`smolvm machine cp` lands files as root inside the VM; the claude cp_in lands files as root inside the VM; the claude
process runs as `node`, so we chown + chmod the prompt after the process runs as `node`, so we chown + chmod the prompt after the
copy. Same flow as the docker backend's provision_prompt.""" copy. Same flow as the docker backend's provision_prompt."""
@@ -13,7 +13,7 @@ from __future__ import annotations
import os import os
from .. import smolvm as _smolvm from ... import Bottle
from ..bottle_plan import SmolmachinesBottlePlan from ..bottle_plan import SmolmachinesBottlePlan
@@ -23,7 +23,7 @@ from ..bottle_plan import SmolmachinesBottlePlan
_DEFAULT_GUEST_HOME = "/home/node" _DEFAULT_GUEST_HOME = "/home/node"
def provision_prompt(plan: SmolmachinesBottlePlan, target: str) -> str | None: def provision_prompt(plan: SmolmachinesBottlePlan, bottle: Bottle) -> str | None:
"""Copy the prompt file into the running smolvm guest, fix """Copy the prompt file into the running smolvm guest, fix
ownership/mode. Returns the in-guest path if the agent has a ownership/mode. Returns the in-guest path if the agent has a
non-empty prompt (drives --append-system-prompt-file), else non-empty prompt (drives --append-system-prompt-file), else
@@ -32,11 +32,13 @@ def provision_prompt(plan: SmolmachinesBottlePlan, target: str) -> str | None:
guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME) guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
in_guest_prompt_path = f"{guest_home}/.bot-bottle-prompt.txt" in_guest_prompt_path = f"{guest_home}/.bot-bottle-prompt.txt"
_smolvm.machine_cp(str(plan.prompt_file), f"{target}:{in_guest_prompt_path}") bottle.cp_in(str(plan.prompt_file), in_guest_prompt_path)
# machine cp lands as root, source's 0o600 mode is preserved — # cp_in lands as root, source's 0o600 mode is preserved —
# node can't read its own prompt without these two. # node can't read its own prompt without these two.
_smolvm.machine_exec(target, ["chown", "node:node", in_guest_prompt_path]) bottle.exec(
_smolvm.machine_exec(target, ["chmod", "600", in_guest_prompt_path]) f"chown node:node {in_guest_prompt_path} && chmod 600 {in_guest_prompt_path}",
user="root",
)
agent = plan.spec.manifest.agents[plan.spec.agent_name] agent = plan.spec.manifest.agents[plan.spec.agent_name]
return in_guest_prompt_path if agent.prompt else None return in_guest_prompt_path if agent.prompt else None
@@ -2,30 +2,32 @@
from __future__ import annotations from __future__ import annotations
import shlex
from ....log import die from ....log import die
from .. import smolvm as _smolvm from ... import Bottle
from ..bottle_plan import SmolmachinesBottlePlan from ..bottle_plan import SmolmachinesBottlePlan
def provision_provider_auth(plan: SmolmachinesBottlePlan, target: str) -> None: def provision_provider_auth(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
"""Apply provider-owned guest setup through smolvm primitives.""" """Apply provider-owned guest setup through the bottle's exec / cp_in."""
provision = plan.agent_provision provision = plan.agent_provision
for d in provision.dirs: for d in provision.dirs:
_exec(target, ["mkdir", "-p", d.guest_path], f"could not create {d.guest_path}") _exec(bottle, f"mkdir -p {shlex.quote(d.guest_path)}", f"could not create {d.guest_path}")
_exec(target, ["chown", d.owner, d.guest_path], f"could not chown {d.guest_path}") _exec(bottle, f"chown {shlex.quote(d.owner)} {shlex.quote(d.guest_path)}", f"could not chown {d.guest_path}")
_exec(target, ["chmod", d.mode, d.guest_path], f"could not chmod {d.guest_path}") _exec(bottle, f"chmod {shlex.quote(d.mode)} {shlex.quote(d.guest_path)}", f"could not chmod {d.guest_path}")
for command in provision.pre_copy: for command in provision.pre_copy:
_exec(target, list(command.argv), command.error) _exec(bottle, shlex.join(command.argv), command.error)
for f in provision.files: for f in provision.files:
_smolvm.machine_cp(str(f.host_path), f"{target}:{f.guest_path}") bottle.cp_in(str(f.host_path), f.guest_path)
_exec(target, ["chown", f.owner, f.guest_path], f"could not chown {f.guest_path}") _exec(bottle, f"chown {shlex.quote(f.owner)} {shlex.quote(f.guest_path)}", f"could not chown {f.guest_path}")
_exec(target, ["chmod", f.mode, f.guest_path], f"could not chmod {f.guest_path}") _exec(bottle, f"chmod {shlex.quote(f.mode)} {shlex.quote(f.guest_path)}", f"could not chmod {f.guest_path}")
for command in provision.verify: for command in provision.verify:
_exec(target, list(command.argv), command.error) _exec(bottle, shlex.join(command.argv), command.error)
def _exec(target: str, argv: list[str], error: str) -> None: def _exec(bottle: Bottle, script: str, error: str) -> None:
result = _smolvm.machine_exec(target, argv) result = bottle.exec(script, user="root")
if result.returncode != 0: if result.returncode != 0:
detail = (result.stderr or result.stdout).strip() detail = (result.stderr or result.stdout).strip()
if detail: if detail:
@@ -13,7 +13,7 @@ import os
from ....log import die, info from ....log import die, info
from ...util import host_skill_dir from ...util import host_skill_dir
from .. import smolvm as _smolvm from ... import Bottle
from ..bottle_plan import SmolmachinesBottlePlan from ..bottle_plan import SmolmachinesBottlePlan
@@ -24,18 +24,18 @@ from ..bottle_plan import SmolmachinesBottlePlan
_DEFAULT_SKILLS_DIR = "/home/node/.claude/skills" _DEFAULT_SKILLS_DIR = "/home/node/.claude/skills"
def provision_skills(plan: SmolmachinesBottlePlan, target: str) -> None: def provision_skills(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
"""Copy each of the agent's named skills from the host's """Copy each of the agent's named skills from the host's
~/.claude/skills/<name>/ into the guest's equivalent path. ~/.claude/skills/<name>/ into the guest's equivalent path.
For each skill: `mkdir -p` the destination, `smolvm machine cp` For each skill: `mkdir -p` the destination, cp_in the host
the host source dir over, then chown the result to node:node so source dir over, then chown the result to node:node so the
the agent can read it. No-op when the agent has no skills. agent can read it. No-op when the agent has no skills.
smolvm machine cp on a directory copies recursively (same cp_in on a directory copies recursively; unlike docker cp's
semantics as `cp -r`); unlike docker cp's trailing-slash trailing-slash convention, smolvm doesn't need the `/.` suffix
convention, smolvm doesn't need the `/.` suffix dance. dance.
machine cp lands files as root inside the VM, so we chown each cp_in lands files as root inside the VM, so we chown each
skill tree over to node:node after the copy — same pattern as skill tree over to node:node after the copy — same pattern as
the docker backend's provision_prompt.""" the docker backend's provision_prompt."""
agent = plan.spec.manifest.agents[plan.spec.agent_name] agent = plan.spec.manifest.agents[plan.spec.agent_name]
@@ -46,7 +46,7 @@ def provision_skills(plan: SmolmachinesBottlePlan, target: str) -> None:
"BOT_BOTTLE_GUEST_SKILLS_DIR", _DEFAULT_SKILLS_DIR, "BOT_BOTTLE_GUEST_SKILLS_DIR", _DEFAULT_SKILLS_DIR,
) )
_smolvm.machine_exec(target, ["mkdir", "-p", skills_dir]) bottle.exec(f"mkdir -p {skills_dir}", user="root")
for name in agent.skills: for name in agent.skills:
src = host_skill_dir(name) src = host_skill_dir(name)
@@ -56,8 +56,8 @@ def provision_skills(plan: SmolmachinesBottlePlan, target: str) -> None:
f"validation and copy at {src}." f"validation and copy at {src}."
) )
dst = f"{skills_dir}/{name}" dst = f"{skills_dir}/{name}"
info(f"copying skill {name} into {target}:{dst}") info(f"copying skill {name} into {bottle.name}:{dst}")
# Wipe any prior copy so re-runs don't accumulate. # Wipe any prior copy so re-runs don't accumulate.
_smolvm.machine_exec(target, ["rm", "-rf", dst]) bottle.exec(f"rm -rf {dst}", user="root")
_smolvm.machine_cp(src, f"{target}:{dst}") bottle.cp_in(src, dst)
_smolvm.machine_exec(target, ["chown", "-R", "node:node", dst]) bottle.exec(f"chown -R node:node {dst}", user="root")
@@ -7,21 +7,21 @@ stuck-recovery MCP tools (pipelock-block, capability-block) at
startup. startup.
Mirrors `backend.docker.provision.supervise` — same `claude mcp Mirrors `backend.docker.provision.supervise` — same `claude mcp
add` call, just dispatched via `smolvm machine exec` instead of add` call, just dispatched via bottle.exec instead of
`docker exec`, and against `<bundle_ip>:<port>` instead of the `docker exec`, and against `<bundle_ip>:<port>` instead of the
short `supervise` alias (no DNS in the TSI-allowlisted guest).""" short `supervise` alias (no DNS in the TSI-allowlisted guest)."""
from __future__ import annotations from __future__ import annotations
from ....log import info, warn from ....log import info, warn
from .. import smolvm as _smolvm from ... import Bottle
from ..bottle_plan import SmolmachinesBottlePlan from ..bottle_plan import SmolmachinesBottlePlan
_SUPERVISE_MCP_NAME = "supervise" _SUPERVISE_MCP_NAME = "supervise"
def provision_supervise(plan: SmolmachinesBottlePlan, target: str) -> None: def provision_supervise(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
"""Run `claude mcp add` inside the guest to register the """Run `claude mcp add` inside the guest to register the
supervise sidecar in claude-code's user config. No-op when supervise sidecar in claude-code's user config. No-op when
bottle.supervise is False. bottle.supervise is False.
@@ -38,22 +38,13 @@ def provision_supervise(plan: SmolmachinesBottlePlan, target: str) -> None:
return return
url = plan.agent_supervise_url url = plan.agent_supervise_url
info(f"registering supervise MCP server in agent claude config → {url}") info(f"registering supervise MCP server in agent claude config → {url}")
# `claude mcp add --scope user` writes to ~/.claude.json. The # `claude mcp add --scope user` writes to ~/.claude.json. Run
# agent is the `node` user; smolvm machine_exec runs as root # as node so the config lands in /home/node/.claude.json.
# by default, so we have to switch user explicitly and set # SmolmachinesBottle.exec sets HOME and USER automatically
# HOME so the config lands in /home/node/.claude.json (where # for the requested user.
# the agent's claude actually reads it from). r = bottle.exec(
r = _smolvm.machine_exec( f"claude mcp add --scope user --transport http {_SUPERVISE_MCP_NAME} {url}",
target, user="node",
[
"runuser", "-u", "node", "--",
"env", "HOME=/home/node",
"claude", "mcp", "add",
"--scope", "user",
"--transport", "http",
_SUPERVISE_MCP_NAME,
url,
],
) )
if r.returncode != 0: if r.returncode != 0:
warn( warn(
@@ -5,11 +5,11 @@ from __future__ import annotations
import shlex import shlex
from ....log import info from ....log import info
from .. import smolvm as _smolvm from ... import Bottle
from ..bottle_plan import SmolmachinesBottlePlan from ..bottle_plan import SmolmachinesBottlePlan
def provision_workspace(plan: SmolmachinesBottlePlan, target: str) -> None: def provision_workspace(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
"""Copy host cwd contents to the planned guest workspace.""" """Copy host cwd contents to the planned guest workspace."""
workspace = plan.workspace_plan workspace = plan.workspace_plan
if not (workspace.enabled and workspace.copy_contents): if not (workspace.enabled and workspace.copy_contents):
@@ -20,17 +20,13 @@ def provision_workspace(plan: SmolmachinesBottlePlan, target: str) -> None:
guest_parent_q = shlex.quote(guest_parent) guest_parent_q = shlex.quote(guest_parent)
owner_q = shlex.quote(workspace.owner) owner_q = shlex.quote(workspace.owner)
mode_q = shlex.quote(workspace.mode) mode_q = shlex.quote(workspace.mode)
info(f"copying {workspace.host_path} -> {target}:{workspace.guest_path}") info(f"copying {workspace.host_path} -> {bottle.name}:{workspace.guest_path}")
_smolvm.machine_exec( bottle.exec(
target, f"rm -rf {guest_path_q} && mkdir -p {guest_parent_q}",
["sh", "-c", f"rm -rf {guest_path_q} && mkdir -p {guest_parent_q}"], user="root",
) )
_smolvm.machine_cp(str(workspace.host_path), f"{target}:{workspace.guest_path}") bottle.cp_in(str(workspace.host_path), workspace.guest_path)
_smolvm.machine_exec( bottle.exec(
target, f"chown -R {owner_q} {guest_path_q} && chmod {mode_q} {guest_path_q}",
[ user="root",
"sh", "-c",
f"chown -R {owner_q} {guest_path_q} && "
f"chmod {mode_q} {guest_path_q}",
],
) )
+5 -5
View File
@@ -1,6 +1,6 @@
"""Main CLI dispatcher. """Main CLI dispatcher.
Commands: cleanup, dashboard, edit, info, init, list, resume, start Commands: cleanup, edit, info, init, list, resume, start, supervise
""" """
from __future__ import annotations from __future__ import annotations
@@ -12,24 +12,24 @@ from ..manifest import ManifestError
from ._common import PROG from ._common import PROG
from . import list as _list_mod from . import list as _list_mod
from .cleanup import cmd_cleanup from .cleanup import cmd_cleanup
from .dashboard import cmd_dashboard
from .edit import cmd_edit from .edit import cmd_edit
from .info import cmd_info from .info import cmd_info
from .init import cmd_init from .init import cmd_init
from .resume import cmd_resume from .resume import cmd_resume
from .start import cmd_start from .start import cmd_start
from .supervise import cmd_supervise
cmd_list = _list_mod.cmd_list cmd_list = _list_mod.cmd_list
COMMANDS = { COMMANDS = {
"cleanup": cmd_cleanup, "cleanup": cmd_cleanup,
"dashboard": cmd_dashboard,
"edit": cmd_edit, "edit": cmd_edit,
"info": cmd_info, "info": cmd_info,
"init": cmd_init, "init": cmd_init,
"list": cmd_list, "list": cmd_list,
"resume": cmd_resume, "resume": cmd_resume,
"start": cmd_start, "start": cmd_start,
"supervise": cmd_supervise,
} }
@@ -37,13 +37,13 @@ def usage() -> None:
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n") sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
sys.stderr.write("Commands:\n") sys.stderr.write("Commands:\n")
sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n") sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n")
sys.stderr.write(" dashboard view + approve/modify/reject pending supervise proposals (PRD 0013)\n")
sys.stderr.write(" edit open an agent in vim for editing\n") sys.stderr.write(" edit open an agent in vim for editing\n")
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n") sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n") sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
sys.stderr.write(" list list available agents or active containers\n") sys.stderr.write(" list list available agents or active containers\n")
sys.stderr.write(" resume re-launch a bottle by its identity (continues state from PRD 0016)\n") sys.stderr.write(" resume re-launch a bottle by its identity (continues state from PRD 0016)\n")
sys.stderr.write(" start boot a container for a named agent and attach an interactive session\n\n") sys.stderr.write(" start boot a container for a named agent and attach an interactive session\n")
sys.stderr.write(" supervise view + approve/modify/reject pending supervise proposals (PRD 0013)\n\n")
sys.stderr.write(f"Run '{PROG} <command> --help' for command-specific usage.\n") sys.stderr.write(f"Run '{PROG} <command> --help' for command-specific usage.\n")
File diff suppressed because it is too large Load Diff
+11 -25
View File
@@ -2,10 +2,8 @@
interactive claude-code session. The container is torn down when the interactive claude-code session. The container is torn down when the
session ends. session ends.
The launch core is shared with `cli.py resume <identity>` and (PRD The launch core is shared with `cli.py resume <identity>` through
0020 chunk 1+) the dashboard's in-process start flow: see the the private orchestrator `_launch_bottle`.
public helpers `prepare_with_preflight`, `attach_agent`, and the
private orchestrator `_launch_bottle`.
""" """
from __future__ import annotations from __future__ import annotations
@@ -71,7 +69,7 @@ def cmd_start(argv: list[str]) -> int:
) )
# --- Public helpers shared with the dashboard (PRD 0020) ----------------- # --- Launch helpers ------------------------------------------------------
def prepare_with_preflight( def prepare_with_preflight(
@@ -84,14 +82,11 @@ def prepare_with_preflight(
backend_name: str | None = None, backend_name: str | None = None,
) -> tuple[DockerBottlePlan | None, str]: ) -> tuple[DockerBottlePlan | None, str]:
"""Run `backend.prepare`, render the preflight summary via the """Run `backend.prepare`, render the preflight summary via the
injected callable, prompt y/N via the injected callable. The CLI injected callable, prompt y/N via the injected callable.
binds these to stderr/stdin; the dashboard binds them to a
curses modal.
`backend_name` selects which backend prepares the plan `backend_name` selects which backend prepares the plan
(`None` `$BOT_BOTTLE_BACKEND` `docker`). Dashboard (`None` `$BOT_BOTTLE_BACKEND` `docker`). The CLI passes
passes the value from its new-agent backend-picker modal; the whatever `--backend` resolved to.
CLI passes whatever `--backend` resolved to.
Returns `(plan, identity)`. `plan` is None on dry-run or Returns `(plan, identity)`. `plan` is None on dry-run or
operator-N, but `identity` is set as soon as `backend.prepare` operator-N, but `identity` is set as soon as `backend.prepare`
@@ -122,16 +117,10 @@ def attach_agent(
agent process's exit code. agent process's exit code.
`resume=True` adds `--continue` so claude picks up its most `resume=True` adds `--continue` so claude picks up its most
recent session non-interactively (no session-picker prompt) recent session non-interactively (no session-picker prompt).
the right shape for the dashboard's Enter re-attach (PRD 0020 First-attach paths (`./cli.py start`) leave it False.
chunk 3), where a bottle typically has exactly one session.
First-attach paths (`./cli.py start`, the dashboard's new-agent
flow) leave it False.
Used as the inner step of `./cli.py start` (one-shot) and by the Used as the inner step of `./cli.py start`."""
dashboard, which calls it from inside a `curses.endwin
stdscr.refresh()` handoff so the curses surface gets out of the
terminal's way while the agent has it."""
runtime = runtime_for(agent_provider_template) runtime = runtime_for(agent_provider_template)
info( info(
f"attaching interactive {agent_provider_template} session " f"attaching interactive {agent_provider_template} session "
@@ -148,8 +137,7 @@ def attach_agent(
def capture_claude_session_state(identity: str, exit_code: int) -> None: def capture_claude_session_state(identity: str, exit_code: int) -> None:
"""Inside the launch context, while the container is still """Inside the launch context, while the container is still
alive: snapshot the transcript and mark for preservation if alive: snapshot the transcript and mark for preservation if
claude crashed. Public for the dashboard's death-handling path claude crashed."""
(PRD 0020 open question 3)."""
# FIXME: this captures Claude-specific session state. A follow-up # FIXME: this captures Claude-specific session state. A follow-up
# spike should explore freezing provider-neutral container state # spike should explore freezing provider-neutral container state
# instead of relying on each agent's transcript layout. # instead of relying on each agent's transcript layout.
@@ -162,9 +150,7 @@ def capture_claude_session_state(identity: str, exit_code: int) -> None:
def settle_state(identity: str) -> None: def settle_state(identity: str) -> None:
"""Post-teardown housekeeping: print the resume hint if the """Post-teardown housekeeping: print the resume hint if the
state was preserved, otherwise reap the per-bottle state dir. state was preserved, otherwise reap the per-bottle state dir."""
Public so the dashboard's explicit-stop path calls the same
settlement the CLI uses on context exit."""
if not identity: if not identity:
return return
if is_preserved(identity): if is_preserved(identity):
+577
View File
@@ -0,0 +1,577 @@
"""supervise: list pending supervise proposals across all bottles and
act on them (approve / modify / reject).
Curses-based TUI; modify-then-approve shells out to $EDITOR. The
approval handlers wire to the per-tool remediation engines:
PRD 0014 (egress, retargeted from cred-proxy in PRD 0017
chunk 3) writes routes.yaml + SIGHUPs egress; PRD 0015
(pipelock) writes the allowlist + restarts pipelock; PRD 0016
(capability) rebuilds the bottle Dockerfile.
"""
from __future__ import annotations
import argparse
import curses
import os
import subprocess
import sys
import tempfile
import traceback
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from .. import supervise as _supervise
from ..backend.docker.bottle_state import read_metadata
from ..backend.docker.capability_apply import (
CapabilityApplyError,
apply_capability_change,
)
from ..backend.docker.egress_apply import EgressApplyError, add_route
from ..backend.docker.pipelock_apply import (
PipelockApplyError,
apply_allowlist_change,
fetch_current_allowlist,
parse_allowlist_content,
render_allowlist_content,
)
from ..log import Die, error, info
from ..supervise import (
COMPONENT_FOR_TOOL,
AuditEntry,
Proposal,
Response,
STATUS_APPROVED,
STATUS_MODIFIED,
STATUS_REJECTED,
TOOL_CAPABILITY_BLOCK,
TOOL_EGRESS_BLOCK,
TOOL_PIPELOCK_BLOCK,
archive_proposal,
list_pending_proposals,
render_diff,
write_audit_entry,
write_response,
)
from ._common import PROG
_REFRESH_INTERVAL_MS = 1000
@dataclass(frozen=True)
class QueuedProposal:
"""A pending proposal plus the queue dir it was found in."""
proposal: Proposal
queue_dir: Path
# Errors any remediation engine may raise. Caught by the TUI key
# handlers and surfaced in the status line so a failed apply keeps
# the proposal pending rather than crashing curses.
ApplyError = (EgressApplyError, PipelockApplyError, CapabilityApplyError)
def discover_pending() -> list[QueuedProposal]:
"""Walk ~/.bot-bottle/queue/* and collect pending proposals."""
queue_root = _supervise.bot_bottle_root() / "queue"
if not queue_root.is_dir():
return []
out: list[QueuedProposal] = []
for slug_dir in sorted(queue_root.iterdir()):
if not slug_dir.is_dir():
continue
for proposal in list_pending_proposals(slug_dir):
out.append(QueuedProposal(proposal=proposal, queue_dir=slug_dir))
out.sort(key=lambda q: q.proposal.arrival_timestamp)
return out
def _approval_status(qp: QueuedProposal, verb: str) -> str:
"""Status-line text after a successful approval."""
base = f"{verb} {qp.proposal.tool} for [{qp.proposal.bottle_slug}]"
if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
return f"{base}; resume: ./cli.py resume {qp.proposal.bottle_slug}"
return base
def _detail_lines(
qp: QueuedProposal,
*,
green_attr: int = 0,
) -> list[tuple[str, int]]:
"""Return the detail-view body as (text, curses-attr) tuples."""
p = qp.proposal
out: list[tuple[str, int]] = [
(f"bottle: {p.bottle_slug}", 0),
(f"tool: {p.tool}", 0),
(f"id: {p.id}", 0),
(f"arrived: {p.arrival_timestamp}", 0),
(f"queue: {qp.queue_dir}", 0),
("", 0),
("justification:", 0),
]
out.extend((" " + line, 0) for line in p.justification.splitlines() or [""])
out.extend([
("", 0),
(_proposed_payload_label(p.tool) + ":", 0),
])
out.extend((line, 0) for line in p.proposed_file.splitlines() or [""])
if p.tool == TOOL_PIPELOCK_BLOCK:
host = _failed_url_host(p.proposed_file)
if host:
out.append(("", 0))
out.append((host, green_attr))
return out
def _failed_url_host(url: str) -> str:
"""Best-effort hostname extraction from a pipelock-block proposal."""
import urllib.parse
try:
return urllib.parse.urlsplit(url.strip()).hostname or ""
except ValueError:
return ""
def _proposed_payload_label(tool: str) -> str:
if tool == TOOL_PIPELOCK_BLOCK:
return "failed URL"
return "proposed file"
def _suffix_for_tool(tool: str) -> str:
if tool == TOOL_CAPABILITY_BLOCK:
return ".dockerfile"
return ".txt"
# --- Operator actions ------------------------------------------------------
def approve(
qp: QueuedProposal,
*,
notes: str = "",
final_file: str | None = None,
) -> None:
"""Apply the proposal, write the waiting response, and audit it."""
status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED
file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file
diff_before, diff_after = "", ""
if qp.proposal.tool == TOOL_EGRESS_BLOCK:
diff_before, diff_after = add_route(
qp.proposal.bottle_slug, file_to_apply,
)
elif qp.proposal.tool == TOOL_PIPELOCK_BLOCK:
diff_before, diff_after = _apply_pipelock_url(
qp.proposal.bottle_slug, file_to_apply,
)
elif qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
_meta = read_metadata(qp.proposal.bottle_slug)
if _meta is not None and not _meta.compose_project:
raise CapabilityApplyError(
"capability-block remediation is not supported for smolmachines "
"bottles. Reject this proposal or handle the capability change "
"manually, then restart the bottle."
)
diff_before, diff_after = apply_capability_change(
qp.proposal.bottle_slug, file_to_apply,
)
response = Response(
proposal_id=qp.proposal.id,
status=status,
notes=notes,
final_file=final_file,
)
write_response(qp.queue_dir, response)
_write_audit(
qp, action=status, notes=notes,
diff_before=diff_before, diff_after=diff_after,
)
if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
archive_proposal(qp.queue_dir, qp.proposal.id)
def reject(qp: QueuedProposal, *, reason: str) -> None:
"""Write a rejection response and an audit entry."""
response = Response(
proposal_id=qp.proposal.id,
status=STATUS_REJECTED,
notes=reason,
final_file=None,
)
write_response(qp.queue_dir, response)
_write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="")
def _apply_pipelock_url(slug: str, failed_url: str) -> tuple[str, str]:
"""Merge a pipelock-block failed URL's host into the allowlist."""
import urllib.parse
parsed = urllib.parse.urlsplit(failed_url.strip())
host = parsed.hostname or ""
if not host:
raise PipelockApplyError(
f"proposed failed_url has no extractable host: {failed_url!r}"
)
current = fetch_current_allowlist(slug)
hosts = parse_allowlist_content(current)
if host not in hosts:
hosts.append(host)
return apply_allowlist_change(slug, render_allowlist_content(hosts))
def _write_audit(
qp: QueuedProposal,
*,
action: str,
notes: str,
diff_before: str,
diff_after: str,
) -> None:
"""Audit log for egress / pipelock tools."""
component = COMPONENT_FOR_TOOL.get(qp.proposal.tool)
if component is None:
return
write_audit_entry(AuditEntry(
timestamp=datetime.now(timezone.utc).isoformat(),
bottle_slug=qp.proposal.bottle_slug,
component=component,
operator_action=action,
operator_notes=notes,
justification=qp.proposal.justification,
diff=render_diff(diff_before, diff_after, label=component),
))
# --- $EDITOR integration --------------------------------------------------
def edit_in_editor(content: str, *, suffix: str = ".tmp") -> str | None:
"""Open `content` in $EDITOR and return edited content, if changed."""
editor = os.environ.get("EDITOR", "vim")
with tempfile.NamedTemporaryFile(
mode="w", suffix=suffix, delete=False, prefix="supervise-modify.",
) as f:
f.write(content)
path = f.name
try:
subprocess.run([editor, path], check=False)
with open(path) as f:
edited = f.read()
return edited if edited != content else None
finally:
try:
os.unlink(path)
except OSError:
pass
# --- TUI -------------------------------------------------------------------
def cmd_supervise(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} supervise", add_help=True)
parser.add_argument(
"--once", action="store_true",
help="list pending proposals once and exit (no TUI)",
)
args = parser.parse_args(argv)
if args.once:
return _list_once()
try:
curses.wrapper(_main_loop)
except KeyboardInterrupt:
return 130
except Die as e:
if e.message:
error(e.message)
else:
error("supervise exited on a fatal error (no detail captured).")
return e.code if isinstance(e.code, int) else 1
except Exception as e:
log_path = _write_crash_log(e)
error(f"supervise crashed: {type(e).__name__}: {e}")
error(f"full traceback written to {log_path}")
return 1
return 0
def _write_crash_log(exc: BaseException) -> Path:
"""Persist `exc`'s traceback to a stable file under ~/.bot-bottle/."""
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
body = "".join(
traceback.format_exception(type(exc), exc, exc.__traceback__)
)
entry = f"=== supervise crash {stamp} ===\n{body}\n"
try:
log_dir = _supervise.bot_bottle_root() / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
path = log_dir / "supervise-crash.log"
with path.open("a", encoding="utf-8") as fh:
fh.write(entry)
return path
except OSError:
fd, tmp = tempfile.mkstemp(
prefix="bot-bottle-supervise-crash-", suffix=".log",
)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(entry)
return Path(tmp)
def _list_once() -> int:
pending = discover_pending()
if not pending:
info("no pending proposals")
return 0
for qp in pending:
sys.stdout.write(
f"{qp.proposal.arrival_timestamp} "
f"[{qp.proposal.bottle_slug}] "
f"{qp.proposal.tool} "
f"{qp.proposal.id}\n"
)
sys.stdout.write(f" {qp.proposal.justification}\n")
return 0
def _try_init_green() -> int:
"""Initialise a green color pair and return its attr, or 0."""
try:
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
return curses.color_pair(1)
except curses.error:
return 0
def _main_loop(stdscr: "curses._CursesWindow") -> None:
curses.curs_set(0)
stdscr.timeout(_REFRESH_INTERVAL_MS)
green_attr = _try_init_green()
selected = 0
status_line = ""
seen_ids: set[str] = set()
while True:
pending = discover_pending()
if selected >= len(pending):
selected = max(0, len(pending) - 1)
live_ids = {qp.proposal.id for qp in pending}
newly_arrived = live_ids - seen_ids
if seen_ids and newly_arrived:
try:
curses.beep()
except curses.error:
pass
for i, qp in enumerate(pending):
if qp.proposal.id in newly_arrived:
selected = i
break
seen_ids = live_ids
_render(
stdscr, pending, selected, status_line,
green_attr=green_attr,
)
try:
key = stdscr.getch()
except KeyboardInterrupt:
return
if key == -1:
continue
status_line = ""
if key in (ord("q"), 27):
return
if not pending:
continue
qp = pending[selected]
if key in (curses.KEY_DOWN, ord("j")):
selected = min(selected + 1, len(pending) - 1)
elif key in (curses.KEY_UP, ord("k")):
selected = max(selected - 1, 0)
elif key in (curses.KEY_ENTER, 10, 13):
_detail_view(stdscr, qp, green_attr=green_attr)
elif key == ord("a"):
try:
approve(qp)
status_line = _approval_status(qp, "approved")
except ApplyError as e:
status_line = f"apply failed: {e}"
elif key == ord("m"):
edited = _modify(stdscr, qp)
if edited is None:
status_line = "modify aborted (no change)"
else:
try:
approve(qp, final_file=edited, notes="operator modified before approving")
status_line = _approval_status(qp, "modified+approved")
except ApplyError as e:
status_line = f"apply failed: {e}"
elif key == ord("r"):
reason = _prompt(stdscr, "reject reason: ")
if reason:
reject(qp, reason=reason)
status_line = f"rejected {qp.proposal.tool} for [{qp.proposal.bottle_slug}]"
else:
status_line = "reject aborted (empty reason)"
def _render(
stdscr: "curses._CursesWindow",
pending: list[QueuedProposal],
selected: int,
status_line: str,
*,
green_attr: int = 0,
) -> None:
stdscr.erase()
h, w = stdscr.getmaxyx()
header = f"bot-bottle supervise ({len(pending)} pending)"
stdscr.addnstr(0, 0, header, w - 1, curses.A_BOLD)
stdscr.hline(1, 0, curses.ACS_HLINE, w)
row = 2
if not pending:
stdscr.addnstr(
row, 2,
"no pending proposals; agents will queue here when they call a "
"supervise tool",
w - 4,
)
else:
for i, qp in enumerate(pending):
if row >= h - 3:
break
p = qp.proposal
ts_short = (
p.arrival_timestamp.split("T", 1)[1][:8]
if "T" in p.arrival_timestamp else p.arrival_timestamp
)
cursor = "> " if i == selected else " "
line = (
f"{cursor}{ts_short} "
f"[{p.bottle_slug}] {p.tool:<18} {p.id[:8]} "
f"{_proposed_payload_label(p.tool)}"
)
attr = curses.A_REVERSE if i == selected else curses.A_NORMAL
stdscr.addnstr(row, 0, line, w - 1, attr)
row += 1
if row >= h - 3:
break
if p.justification:
stdscr.addnstr(row, 4, p.justification[: max(0, w - 5)], w - 5)
row += 1
footer = "[j/k] move [Enter] view [a] approve [m] modify [r] reject [q] quit"
stdscr.hline(h - 2, 0, curses.ACS_HLINE, w)
stdscr.addnstr(h - 1, 0, footer, w - 1, curses.A_DIM)
if status_line:
stdscr.addnstr(h - 3, 0, status_line, w - 1, curses.A_BOLD)
stdscr.refresh()
def _detail_view(
stdscr: "curses._CursesWindow",
qp: QueuedProposal,
*,
green_attr: int = 0,
) -> None:
"""Render the full proposal. Scrollable. Press q to return."""
lines = _detail_lines(qp, green_attr=green_attr)
offset = 0
while True:
stdscr.erase()
h, w = stdscr.getmaxyx()
for i, (text, attr) in enumerate(lines[offset:offset + h - 1]):
stdscr.addnstr(i, 0, text, w - 1, attr)
stdscr.addnstr(
h - 1, 0,
"[j/k] scroll [g/G] top/bottom [a] approve [m] modify [r] reject [q] back",
w - 1, curses.A_DIM,
)
stdscr.refresh()
key = stdscr.getch()
if key in (ord("q"), 27):
return
if key in (curses.KEY_DOWN, ord("j")):
offset = min(offset + 1, max(0, len(lines) - 1))
elif key in (curses.KEY_UP, ord("k")):
offset = max(offset - 1, 0)
elif key == ord("g"):
offset = 0
elif key == ord("G"):
offset = max(0, len(lines) - 1)
elif key == ord("a"):
try:
approve(qp)
except ApplyError:
pass
return
elif key == ord("m"):
edited = _modify(stdscr, qp)
if edited is not None:
try:
approve(qp, final_file=edited, notes="operator modified before approving")
except ApplyError:
pass
return
elif key == ord("r"):
reason = _prompt(stdscr, "reject reason: ")
if reason:
reject(qp, reason=reason)
return
def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None:
"""Suspend curses, open $EDITOR on the proposed file, return edited content."""
suffix = _suffix_for_tool(qp.proposal.tool)
curses.endwin()
try:
edited = edit_in_editor(qp.proposal.proposed_file, suffix=suffix)
finally:
stdscr.refresh()
return edited
def _prompt(stdscr: "curses._CursesWindow", label: str) -> str:
"""One-line input at the bottom of the screen."""
curses.curs_set(1)
h, _ = stdscr.getmaxyx()
stdscr.move(h - 2, 0)
stdscr.clrtoeol()
stdscr.addstr(h - 2, 0, label)
stdscr.refresh()
curses.echo()
try:
raw = stdscr.getstr(h - 2, len(label), 200)
finally:
curses.noecho()
curses.curs_set(0)
return raw.decode("utf-8", errors="replace").strip()
__all__ = [
"QueuedProposal",
"approve",
"cmd_supervise",
"discover_pending",
"edit_in_editor",
"reject",
]
View File
@@ -0,0 +1,121 @@
"""Gitea deploy-key provisioner (PRD 0048, contrib).
Generates ed25519 keypairs via `ssh-keygen` and registers / deletes
them using the Gitea deploy-key HTTP API. No new Python dependencies
only stdlib `urllib.request` and `subprocess`."""
from __future__ import annotations
import json
import subprocess
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
from ...deploy_key_provisioner import DeployKeyProvisioner
class GiteaDeployKeyProvisioner(DeployKeyProvisioner):
"""Manages deploy keys on a Gitea instance."""
def __init__(self, *, token: str, api_url: str) -> None:
self._token = token
self._api_url = api_url.rstrip("/")
def create(self, owner_repo: str, title: str) -> tuple[str, bytes]:
"""Generate an ed25519 keypair, register the public half as a
repo deploy key, and return `(key_id, private_key_bytes)`.
The key is registered with `read_only=False` because git-gate
needs push access to forward gitleaks-scanned refs upstream."""
with tempfile.TemporaryDirectory() as tmpdir:
key_path = Path(tmpdir) / "key"
subprocess.run(
[
"ssh-keygen", "-t", "ed25519",
"-f", str(key_path),
"-N", "",
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
private_key = key_path.read_bytes()
public_key = key_path.with_suffix(".pub").read_text().strip()
owner, repo = _split_owner_repo(owner_repo)
url = f"{self._api_url}/api/v1/repos/{owner}/{repo}/keys"
payload = json.dumps({
"key": public_key,
"read_only": False,
"title": title,
}).encode()
req = urllib.request.Request(
url,
data=payload,
headers={
"Authorization": f"token {self._token}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req) as resp:
body = json.loads(resp.read())
except urllib.error.HTTPError as exc:
_body = _read_error_body(exc)
raise RuntimeError(
f"failed to create deploy key for {owner_repo}: "
f"HTTP {exc.code}{_body}"
) from exc
except urllib.error.URLError as exc:
raise RuntimeError(
f"failed to create deploy key for {owner_repo}: {exc.reason}"
) from exc
return str(body["id"]), private_key
def delete(self, owner_repo: str, key_id: str) -> None:
"""Delete the deploy key. HTTP 404 (already gone) is success.
All other errors raise RuntimeError so teardown halts."""
owner, repo = _split_owner_repo(owner_repo)
url = f"{self._api_url}/api/v1/repos/{owner}/{repo}/keys/{key_id}"
req = urllib.request.Request(
url,
headers={"Authorization": f"token {self._token}"},
method="DELETE",
)
try:
with urllib.request.urlopen(req):
pass
except urllib.error.HTTPError as exc:
if exc.code == 404:
return
_body = _read_error_body(exc)
raise RuntimeError(
f"failed to delete deploy key {key_id} for {owner_repo}: "
f"HTTP {exc.code}{_body}"
) from exc
except urllib.error.URLError as exc:
raise RuntimeError(
f"failed to delete deploy key {key_id} for {owner_repo}: "
f"{exc.reason}"
) from exc
def _split_owner_repo(owner_repo: str) -> tuple[str, str]:
"""Split `'owner/repo'` into `('owner', 'repo')`."""
parts = owner_repo.split("/", 1)
if len(parts) != 2 or not all(parts):
raise ValueError(
f"expected 'owner/repo' format, got {owner_repo!r}"
)
return parts[0], parts[1]
def _read_error_body(exc: urllib.error.HTTPError) -> str:
try:
return exc.read().decode("utf-8", errors="replace")
except Exception:
return ""
+52
View File
@@ -0,0 +1,52 @@
"""Deploy-key provisioner interface and factory (PRD 0048).
The core defines the abstract contract; concrete implementations live
in `bot_bottle/contrib/<provider>/deploy_key_provisioner.py`. The
factory `get_provisioner` imports contrib modules lazily so that a
missing optional dependency in one provider doesn't break unrelated
features."""
from __future__ import annotations
from abc import ABC, abstractmethod
class DeployKeyProvisioner(ABC):
"""Manages a single deploy-key lifecycle on a remote forge."""
@abstractmethod
def create(self, owner_repo: str, title: str) -> tuple[str, bytes]:
"""Generate a keypair and register the public half as a
deploy key on the forge.
`owner_repo` is the `<owner>/<repo>` path (no `.git` suffix).
`title` is the human-readable label shown in the forge UI.
Returns `(key_id, private_key_bytes)` where `key_id` is opaque
to the caller and is only ever passed back to `delete`."""
@abstractmethod
def delete(self, owner_repo: str, key_id: str) -> None:
"""Delete the registered deploy key.
Must not raise if the key is already absent (HTTP 404 is
success). Must raise for all other failures so teardown halts."""
def get_provisioner(
provider: str, token: str, api_url: str
) -> DeployKeyProvisioner:
"""Instantiate the contrib provisioner for `provider`.
Raises `ManifestError` for unknown providers so the error surfaces
at parse time rather than at runtime."""
if provider == "gitea":
from bot_bottle.contrib.gitea.deploy_key_provisioner import (
GiteaDeployKeyProvisioner,
)
return GiteaDeployKeyProvisioner(token=token, api_url=api_url)
from .manifest_util import ManifestError
raise ManifestError(
f"unknown provisioned_key provider: {provider!r}; "
f"available: gitea"
)
+89 -1
View File
@@ -29,11 +29,14 @@ backend-specific and lives on concrete subclasses (see
from __future__ import annotations from __future__ import annotations
import dataclasses
import os
import shlex import shlex
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from .log import info
from .manifest import Bottle, GitEntry from .manifest import Bottle, GitEntry
@@ -357,6 +360,80 @@ exit 0
""" """
def _provision_dynamic_key(
entry: GitEntry,
slug: str,
stage_dir: Path,
) -> str:
"""Generate a fresh ed25519 keypair, register the public half with
the forge, and persist the private key + key ID under `stage_dir`.
Returns the host-side path to the private key file so the caller
can inject it into the GitGateUpstream as `identity_file`."""
from .deploy_key_provisioner import get_provisioner
pk = entry.ProvisionedKey
assert pk is not None
token = os.environ.get(pk.token_env)
if token is None:
raise RuntimeError(
f"git-gate.repos[{entry.Name!r}] provisioned_key.token_env"
f" = {pk.token_env!r}: env var is not set"
)
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
provisioner = get_provisioner(pk.provider, token, api_url)
owner_repo = entry.UpstreamPath
if owner_repo.endswith(".git"):
owner_repo = owner_repo[:-4]
title = f"bot-bottle:{slug}:{entry.Name}"
info(f"provisioning deploy key for git-gate.repos[{entry.Name!r}]")
key_id, private_key_bytes = provisioner.create(owner_repo, title)
key_file = stage_dir / f"{entry.Name}-key"
key_file.write_bytes(private_key_bytes)
key_file.chmod(0o600)
id_file = stage_dir / f"{entry.Name}-deploy-key-id"
id_file.write_text(key_id)
id_file.chmod(0o600)
info(f"provisioned deploy key {key_id} for git-gate.repos[{entry.Name!r}]")
return str(key_file)
def revoke_git_gate_provisioned_keys(bottle: Bottle, stage_dir: Path) -> None:
"""Revoke all deploy keys provisioned for `bottle` during prepare.
Called at teardown after containers stop. Raises if any revocation
fails a stranded key is a security concern that the operator must
address manually."""
from .deploy_key_provisioner import get_provisioner
for entry in bottle.git:
if entry.ProvisionedKey is None:
continue
pk = entry.ProvisionedKey
id_file = stage_dir / f"{entry.Name}-deploy-key-id"
if not id_file.exists():
continue
key_id = id_file.read_text().strip()
token = os.environ.get(pk.token_env)
if token is None:
raise RuntimeError(
f"git-gate.repos[{entry.Name!r}] provisioned_key.token_env"
f" = {pk.token_env!r}: env var is not set;"
f" cannot revoke deploy key {key_id}"
)
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
provisioner = get_provisioner(pk.provider, token, api_url)
owner_repo = entry.UpstreamPath
if owner_repo.endswith(".git"):
owner_repo = owner_repo[:-4]
info(f"revoking deploy key {key_id} for git-gate.repos[{entry.Name!r}]")
provisioner.delete(owner_repo, key_id)
info(f"revoked deploy key {key_id} for git-gate.repos[{entry.Name!r}]")
class GitGate(ABC): class GitGate(ABC):
"""The per-agent git-gate. Encapsulates the host-side prepare """The per-agent git-gate. Encapsulates the host-side prepare
(upstream lift + entrypoint/hook render); the sidecar's (upstream lift + entrypoint/hook render); the sidecar's
@@ -368,10 +445,21 @@ class GitGate(ABC):
entrypoint, pre-receive hook, and access-hook scripts (mode entrypoint, pre-receive hook, and access-hook scripts (mode
600) under `stage_dir`. Pure host-side, no docker subprocess. 600) under `stage_dir`. Pure host-side, no docker subprocess.
For `provisioned_key` entries, also generates and registers
a fresh deploy key via the forge API and writes the private key
+ key ID to `stage_dir`.
Returned plan is incomplete: the launch step must fill Returned plan is incomplete: the launch step must fill
`internal_network` / `egress_network` via `dataclasses.replace` `internal_network` / `egress_network` via `dataclasses.replace`
before passing the plan to `.start`.""" before passing the plan to `.start`."""
upstreams = git_gate_upstreams_for_bottle(bottle) upstreams_list = list(git_gate_upstreams_for_bottle(bottle))
for i, entry in enumerate(bottle.git):
if entry.ProvisionedKey is not None:
key_file = _provision_dynamic_key(entry, slug, stage_dir)
upstreams_list[i] = dataclasses.replace(
upstreams_list[i], identity_file=key_file
)
upstreams = tuple(upstreams_list)
entrypoint = stage_dir / "git_gate_entrypoint.sh" entrypoint = stage_dir / "git_gate_entrypoint.sh"
entrypoint.write_text(git_gate_render_entrypoint(upstreams)) entrypoint.write_text(git_gate_render_entrypoint(upstreams))
entrypoint.chmod(0o600) entrypoint.chmod(0o600)
+90 -11
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional
from .manifest_util import ManifestError, as_json_object from .manifest_util import ManifestError, as_json_object
@@ -61,6 +62,24 @@ def validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> No
seen[g.Name] = None seen[g.Name] = None
@dataclass(frozen=True)
class ProvisionedKeyConfig:
"""Configuration for automatic deploy-key lifecycle management
(PRD 0048). Used when a git-gate.repos entry opts out of a
static identity file and instead wants a fresh SSH keypair
generated at spin-up and revoked at teardown.
`provider` names the contrib sub-package to load (e.g. `gitea`).
`token_env` is the name of a host-side env var carrying the API
token; the value is read at provision time, never stored on the
plan. `api_url` is the forge's HTTP API root; if empty, it is
derived from the upstream URL's host at provision time."""
provider: str
token_env: str
api_url: str = ""
@dataclass(frozen=True) @dataclass(frozen=True)
class GitEntry: class GitEntry:
"""One upstream the per-agent git-gate (PRD 0008) is allowed to """One upstream the per-agent git-gate (PRD 0008) is allowed to
@@ -74,14 +93,15 @@ class GitEntry:
stashed in the `Upstream*` fields so the git-gate render step stashed in the `Upstream*` fields so the git-gate render step
doesn't have to re-parse. doesn't have to re-parse.
Manifest source: `git-gate.repos.<Name>` (PRD 0047). The YAML keys Manifest source: `git-gate.repos.<Name>` (PRD 0047/0048). Exactly
are `url`, `identity`, and `host_key`; the internal field names are one of `identity` (static key path) or `provisioned_key` (automatic
stable across that rename.""" lifecycle) must be present. The internal field names are stable."""
Name: str Name: str
Upstream: str Upstream: str
IdentityFile: str IdentityFile: str = ""
KnownHostKey: str = "" KnownHostKey: str = ""
ProvisionedKey: Optional[ProvisionedKeyConfig] = None
RemoteKey: str = "" RemoteKey: str = ""
UpstreamUser: str = "" UpstreamUser: str = ""
UpstreamHost: str = "" UpstreamHost: str = ""
@@ -94,8 +114,9 @@ class GitEntry:
) -> "GitEntry": ) -> "GitEntry":
"""Parse one entry from `git-gate.repos.<repo_name>`. """Parse one entry from `git-gate.repos.<repo_name>`.
YAML keys: `url` (required), `identity` (required), YAML keys: `url` (required), exactly one of `identity` or
`host_key` (optional). The repo_name becomes `Name`.""" `provisioned_key` (required), `host_key` (optional).
The repo_name becomes `Name`."""
if not repo_name: if not repo_name:
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' git-gate.repos has an empty key" f"bottle '{bottle_name}' git-gate.repos has an empty key"
@@ -108,21 +129,44 @@ class GitEntry:
label = f"git-gate.repos[{repo_name!r}]" label = f"git-gate.repos[{repo_name!r}]"
d = as_json_object(raw, f"bottle '{bottle_name}' {label}") d = as_json_object(raw, f"bottle '{bottle_name}' {label}")
for k in d: for k in d:
if k not in {"url", "identity", "host_key"}: if k not in {"url", "identity", "provisioned_key", "host_key"}:
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' {label} has unknown key {k!r}; " f"bottle '{bottle_name}' {label} has unknown key {k!r}; "
f"allowed: url, identity, host_key" f"allowed: url, identity, provisioned_key, host_key"
) )
upstream = d.get("url") upstream = d.get("url")
if not isinstance(upstream, str) or not upstream: if not isinstance(upstream, str) or not upstream:
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' {label} missing required string field 'url'" f"bottle '{bottle_name}' {label} missing required string field 'url'"
) )
ident = d.get("identity")
if not isinstance(ident, str) or not ident: has_identity = "identity" in d
has_provisioned = "provisioned_key" in d
if has_identity and has_provisioned:
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' {label} missing required string field 'identity'" f"bottle '{bottle_name}' {label} must set exactly one of "
f"'identity' or 'provisioned_key'; got both."
) )
if not has_identity and not has_provisioned:
raise ManifestError(
f"bottle '{bottle_name}' {label} must set exactly one of "
f"'identity' or 'provisioned_key'; got neither."
)
ident = ""
provisioned_key: Optional[ProvisionedKeyConfig] = None
if has_identity:
raw_ident = d.get("identity")
if not isinstance(raw_ident, str) or not raw_ident:
raise ManifestError(
f"bottle '{bottle_name}' {label} 'identity' must be a non-empty string"
)
ident = raw_ident
else:
provisioned_key = _parse_provisioned_key_config(
bottle_name, label, d["provisioned_key"]
)
khk = _opt_str( khk = _opt_str(
d.get("host_key"), d.get("host_key"),
f"bottle '{bottle_name}' {label} host_key", f"bottle '{bottle_name}' {label} host_key",
@@ -135,6 +179,7 @@ class GitEntry:
Upstream=upstream, Upstream=upstream,
IdentityFile=ident, IdentityFile=ident,
KnownHostKey=khk, KnownHostKey=khk,
ProvisionedKey=provisioned_key,
RemoteKey=host, RemoteKey=host,
UpstreamUser=user, UpstreamUser=user,
UpstreamHost=host, UpstreamHost=host,
@@ -143,6 +188,40 @@ class GitEntry:
) )
def _parse_provisioned_key_config(
bottle_name: str, label: str, raw: object
) -> ProvisionedKeyConfig:
d = as_json_object(raw, f"bottle '{bottle_name}' {label}.provisioned_key")
for k in d:
if k not in {"provider", "token_env", "api_url"}:
raise ManifestError(
f"bottle '{bottle_name}' {label}.provisioned_key has unknown key {k!r}; "
f"allowed: provider, token_env, api_url"
)
provider = d.get("provider")
if not isinstance(provider, str) or not provider:
raise ManifestError(
f"bottle '{bottle_name}' {label}.provisioned_key missing required "
f"string field 'provider'"
)
token_env = d.get("token_env")
if not isinstance(token_env, str) or not token_env:
raise ManifestError(
f"bottle '{bottle_name}' {label}.provisioned_key missing required "
f"string field 'token_env'"
)
api_url_raw = d.get("api_url", "")
if not isinstance(api_url_raw, str):
raise ManifestError(
f"bottle '{bottle_name}' {label}.provisioned_key 'api_url' must be a string"
)
return ProvisionedKeyConfig(
provider=provider,
token_env=token_env,
api_url=api_url_raw,
)
@dataclass(frozen=True) @dataclass(frozen=True)
class GitUser: class GitUser:
"""Per-bottle `git config --global user.name` / `user.email` """Per-bottle `git config --global user.name` / `user.email`
+2 -2
View File
@@ -12,8 +12,8 @@ agent calls when it hits a stuck-recovery category:
Each tool call: the agent passes the full proposed file plus a Each tool call: the agent passes the full proposed file plus a
justification text. The sidecar validates the proposal syntactically, justification text. The sidecar validates the proposal syntactically,
writes it to the host's per-bottle queue dir, and holds the tool-call writes it to the host's per-bottle queue dir, and holds the tool-call
connection open. The operator's TUI dashboard connection open. The operator's supervise TUI
(bot_bottle.cli.dashboard) sees the proposal, accepts (bot_bottle.cli.supervise) sees the proposal, accepts
approve / modify / reject, and writes a response file alongside the approve / modify / reject, and writes a response file alongside the
proposal. The sidecar sees the response and returns `{status, notes}` proposal. The sidecar sees the response and returns `{status, notes}`
to the agent. to the agent.
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0019: Active agents in the dashboard, agent-scoped edit verbs # PRD 0019: Active agents in the dashboard, agent-scoped edit verbs
- **Status:** Active - **Status:** Superseded by [PRD 0049](0049-strip-dashboard-to-supervisor-tui.md)
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-26 - **Created:** 2026-05-26
@@ -1,6 +1,6 @@
# PRD 0020: Start and attach to agents from inside the dashboard # PRD 0020: Start and attach to agents from inside the dashboard
- **Status:** Active - **Status:** Superseded by [PRD 0049](0049-strip-dashboard-to-supervisor-tui.md)
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-26 - **Created:** 2026-05-26
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0021: Dashboard as left tmux pane, selected agent as right pane # PRD 0021: Dashboard as left tmux pane, selected agent as right pane
- **Status:** Active - **Status:** Superseded by [PRD 0049](0049-strip-dashboard-to-supervisor-tui.md)
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-26 - **Created:** 2026-05-26
@@ -0,0 +1,296 @@
# PRD 0048: SSH Deploy-Key Provisioning
- **Status:** Active
- **Author:** didericis-claude
- **Created:** 2026-06-03
- **Issue:** #169
## Summary
Replace per-repo static SSH identity files with short-lived ed25519 deploy
keys that are generated at spin-up and revoked at teardown. Introduce
`bot_bottle/contrib/` as the package for platform-specific provisioners and
ship the first contrib sub-package: `bot_bottle/contrib/gitea/` with
`GiteaDeployKeyProvisioner`. A new `provisioned_key:` block in `git-gate.repos`
entries opts a repo into automatic key lifecycle management; `identity:` stays
valid for operators who supply their own key material.
## Problem
The current `git-gate.repos` entries require an `identity:` field pointing to
a host-side SSH private key (PRD 0047). Keys are static: the operator generates
them once, registers them with the upstream forge, and the same key is reused
across every bottle spin-up. This has several consequences:
- **No automatic revocation.** If a bottle misbehaves or a key leaks, the
operator must notice and manually delete the key from the forge. There is no
teardown hook that does it.
- **Broad blast radius.** A forge deploy key typically grants write access for
the lifetime of the key. A static key that survives bottle teardown continues
to grant that access.
- **Manual rotation burden.** Operators must manage key files on disk, keeping
them secure, rotating them on a schedule, and distributing them across hosts
that run `./cli.py start`.
## Goals / Success Criteria
- `git-gate.repos` entries accept `provisioned_key:` as an alternative to
`identity:`. The parser rejects entries that have both, or neither.
- `provisioned_key.provider: gitea` provisions and revokes deploy keys via the
Gitea HTTP API.
- At prepare time the provisioner generates a fresh ed25519 keypair, registers
the public half as a repo-scoped deploy key, and makes the private key
available to git-gate at the path it expects — the rest of the pipeline is
unchanged.
- At teardown the provisioner deletes the registered deploy key. Failure to
delete halts teardown and propagates the error loudly.
- `bot_bottle/contrib/` is introduced as the package for platform-specific
implementations; the core defines the abstract interface; contrib sub-packages
provide concrete implementations.
- Existing `identity:`-based repos continue to work without change.
- The unit test suite passes unchanged for `identity:` paths; new tests cover
`provisioned_key:` parse, validation, and provisioner dispatch.
## Non-goals
- GitHub, GitLab, or other forge providers (a future contrib sub-package each).
- Dashboard UI for listing or revoking orphaned deploy keys.
- SSH CA certificate approach (rejected in the issue thread in favour of
per-repo deploy keys for simpler revocation, smaller blast radius, and forge
compatibility).
- Key rotation mid-session (keys live for exactly one spin-up / teardown cycle).
- Any change to how `identity:` repos are provisioned.
## Design
### Manifest changes (builds on PRD 0047)
`git-gate.repos.<name>` currently accepts exactly:
```
url (required string)
identity (required string)
host_key (optional string)
```
After this PRD:
```
url (required string)
identity (optional string — mutually exclusive with provisioned_key)
provisioned_key (optional object — mutually exclusive with identity)
host_key (optional string)
```
Exactly one of `identity` or `provisioned_key` must be present. The parser
emits a targeted error for each violation:
```
bottle 'dev' git-gate.repos['bot-bottle'] must set exactly one of
'identity' or 'provisioned_key'; got neither.
bottle 'dev' git-gate.repos['bot-bottle'] must set exactly one of
'identity' or 'provisioned_key'; got both.
```
`provisioned_key` object schema:
```yaml
provisioned_key:
provider: gitea # required; names the contrib module to load
token_env: GITEA_TOKEN # required; name of a host env var holding the API token
api_url: https://... # optional; defaults to https://<host from url>
```
| Field | Type | Notes |
|-------|------|-------|
| `provider` | required string | Must match a sub-package under `bot_bottle/contrib/` |
| `token_env` | required string | Resolved at provision time via `os.environ`; never stored in plan |
| `api_url` | optional string | Override when the API endpoint differs from the git host |
**Example bottle manifest:**
```yaml
git-gate:
user:
name: implementer-bot
email: eric+implementer@dideric.is
repos:
bot-bottle:
url: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
provisioned_key:
provider: gitea
token_env: GITEA_DEPLOY_TOKEN
host_key: "ssh-rsa AAAA..."
```
### `contrib` package structure
```
bot_bottle/
contrib/
__init__.py # empty; no core symbols
gitea/
__init__.py # empty
deploy_key_provisioner.py
```
`contrib` is a flat namespace of forge/platform sub-packages. Each sub-package
is self-contained; the core imports from contrib lazily (inside factory
functions) so that missing optional dependencies in a contrib sub-package don't
break unrelated features.
### Core interface
New file: `bot_bottle/deploy_key_provisioner.py`
```python
from abc import ABC, abstractmethod
class DeployKeyProvisioner(ABC):
@abstractmethod
def create(self, owner_repo: str, title: str) -> tuple[str, bytes]:
"""Generate a keypair and register the public half.
owner_repo: '<owner>/<repo>' portion of the git upstream URL.
title: human-readable label shown in the forge key list.
Returns (key_id, private_key_pem) where key_id is opaque to
the caller and is only passed back to delete()."""
@abstractmethod
def delete(self, owner_repo: str, key_id: str) -> None:
"""Delete the registered deploy key.
Must not raise if the key is already absent (HTTP 404 is success).
Must raise for all other failures so that teardown halts."""
def get_provisioner(provider: str, token: str, api_url: str) -> DeployKeyProvisioner:
"""Instantiate the named contrib provisioner.
Raises ManifestError for unknown providers so the error is caught
at parse time rather than at runtime."""
if provider == "gitea":
from bot_bottle.contrib.gitea.deploy_key_provisioner import (
GiteaDeployKeyProvisioner,
)
return GiteaDeployKeyProvisioner(token=token, api_url=api_url)
from .manifest_util import ManifestError
raise ManifestError(f"unknown provisioned_key provider: {provider!r}")
```
### Gitea contrib implementation
`bot_bottle/contrib/gitea/deploy_key_provisioner.py`:
`create(owner_repo, title)`:
1. Generate an ed25519 keypair via `ssh-keygen -t ed25519 -f <tmpfile> -N ''`
(uses the SSH tooling already required by git-gate; no new Python dependency).
2. Read the private key bytes and the `.pub` file.
3. `POST /api/v1/repos/{owner}/{repo}/keys` with the public key, `title`, and
`read_only: false` (deploy keys always need push access for git-gate).
4. Return `(str(response["id"]), private_key_bytes)`.
`delete(owner_repo, key_id)`:
1. `DELETE /api/v1/repos/{owner}/{repo}/keys/{id}`.
2. Treat HTTP 404 as success (key already gone).
3. Raise `RuntimeError` for any other non-2xx response or network error,
including the status code and response body in the message.
HTTP calls use `urllib.request` from the stdlib; no new runtime dependency.
### `GitEntry` dataclass changes
`bot_bottle/manifest_git.py`:
- Add `ProvisionedKeyConfig` dataclass:
```python
@dataclass(frozen=True)
class ProvisionedKeyConfig:
provider: str
token_env: str
api_url: str # empty string means "derive from UpstreamHost"
```
- `GitEntry`:
- `IdentityFile: str` unchanged internally; empty string when
`provisioned_key` is used; set at provision time, not parse time.
- New field: `ProvisionedKey: ProvisionedKeyConfig | None = None`
- `from_repos_entry` validates the mutually-exclusive constraint and parses
the `provisioned_key` block when present.
### `GitGateUpstream` / prepare-time changes
`bot_bottle/git_gate.py` and `bot_bottle/backend/docker/provision/git.py`:
The existing path writes the identity file path into `GitGateUpstream.IdentityFile`
and docker-cp's it into `/git-gate/creds/<name>-key`. That path stays unchanged
for `identity:` repos.
For `provisioned_key:` repos, a new helper `provision_deploy_key(entry,
stage_dir, bottle_name)` runs before the git-gate sidecar starts:
1. Resolve `token = os.environ[entry.ProvisionedKey.token_env]`. Missing key
raises `RuntimeError` with a clear message naming the env var.
2. Resolve `api_url = entry.ProvisionedKey.api_url or f"https://{entry.UpstreamHost}"`.
3. Instantiate `get_provisioner(entry.ProvisionedKey.provider, token, api_url)`.
4. Call `provisioner.create(entry.UpstreamPath.lstrip("/"), title)` where
`title = f"bot-bottle:{bottle_name}:{entry.Name}"`.
5. Write private key to `stage_dir / f"{entry.Name}-key"` (mode 0o600).
6. Write key ID to `stage_dir / f"{entry.Name}-deploy-key-id"` (plain text).
7. Return the key file path; caller sets `GitGateUpstream.IdentityFile` to it.
`owner_repo` is extracted from `entry.UpstreamPath` (the path component of the
`ssh://` URL, e.g. `/didericis/bot-bottle.git``didericis/bot-bottle`).
### Teardown changes
`bot_bottle/backend/docker/cleanup.py` (or the equivalent teardown path):
After the git-gate sidecar stops, for each `GitEntry` with `ProvisionedKey`
set:
1. Check that `stage_dir / f"{entry.Name}-deploy-key-id"` exists; skip if
absent (provision never ran or already cleaned up).
2. Resolve token and API URL as above.
3. Instantiate provisioner and call `provisioner.delete(owner_repo, key_id)`.
4. On success, log at INFO. On failure, allow the exception to propagate —
teardown halts and the error surfaces to the operator.
A stranded deploy key is a security concern: the operator must know about it
and address it manually. Silent continuation is not acceptable.
The private key file in `stage_dir` is cleaned up as part of normal stage-dir
teardown (no extra step needed).
## Testing strategy
```
python3 -m unittest discover -s tests/unit
```
New / modified test files:
- `tests/unit/test_manifest_git.py` — add cases for:
- `provisioned_key:` accepted with valid `provider`, `token_env`, optional `api_url`
- Both `identity` and `provisioned_key` present → `ManifestError`
- Neither `identity` nor `provisioned_key` present → `ManifestError`
- Unknown key inside `provisioned_key` block → `ManifestError`
- Missing `provider` or `token_env` inside `provisioned_key``ManifestError`
- `tests/unit/test_deploy_key_provisioner.py` — new:
- `get_provisioner("gitea", ...)` returns `GiteaDeployKeyProvisioner`
- `get_provisioner("unknown", ...)` raises `ManifestError`
- `tests/unit/test_contrib_gitea_deploy_key.py` — new (using `unittest.mock`
to stub `urllib.request.urlopen` and `subprocess.run`):
- `create()` calls `ssh-keygen`, POSTs to correct endpoint, returns key ID
- `delete()` DELETEs to correct endpoint
- `delete()` tolerates HTTP 404 (already-deleted key)
- `delete()` raises `RuntimeError` on non-404 HTTP error
## Open questions
None.
@@ -0,0 +1,343 @@
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-06-03
- **Issue:** #174
## Summary
The `./cli.py dashboard` command has grown from its PRD 0013 roots
(triage supervise proposals) into a parallel-agent control surface
(PRDs 0019/0020/0021): an active-agents pane, agent picker + start,
re-attach, per-bottle stop, tmux split-pane handoff, operator-
initiated `routes`/`pipelock` edits. Each chunk is reasonable on its
own; together they make the dashboard the largest CLI file in the
repo and the thing most likely to break on a rough edge (curses /
tmux / docker-exec / metadata-discovery interactions).
This PRD reverses that scope creep. The dashboard is reduced to the
**supervise-plane triage TUI** it was in PRDs 00130016: list pending
proposals, approve / modify / reject each one, write audit entries,
deliver the response that unblocks the agent's tool call. Everything
that's about *starting / re-entering / stopping* bottles, or about
*operator-initiated* config edits, comes out. The command is renamed
`./cli.py supervise` so the name matches what it does after the cut.
Future agent-management UX is explicitly punted: if and when a
control surface for parallel agents resurfaces, the working
assumption (per the issue) is that a web GUI — usable from mobile
— is a better second pass than another round of curses iteration.
That decision is not in this PRD's scope; this PRD only removes the
half-built local-curses path so we stop maintaining it.
## Problem
Three concrete pains, all downstream of the dashboard's growth:
1. **Surface area vs. polish.** `dashboard.py` is ~1740 lines;
`dashboard_model.py` adds another ~420. The interactions among
curses, modals, tmux split-pane, docker-exec handoff, agent
provider templates, metadata-driven re-attach, and
ExitStack-free bottle ownership are intricate enough that
shipping the next polish increment costs more than it returns.
2. **No clear ownership of "starts and stops bottles".** Today
that responsibility is split: `./cli.py start` owns one-shot
sessions; the dashboard owns multi-session bottles it started
itself; `./cli.py cleanup` owns everything else. The dashboard
tracking its own `bottles: dict[str, (cm, bottle, identity)]`
that doesn't survive a quit is a confusing third lane.
3. **Wrong target shape for a "manage many agents" UI.** The
parallel-agent experience the dashboard reaches for is mobile-
meaningful — checking in on agents from a phone is the high-
value case — and curses inside an SSH session is the wrong
tool for that. Continuing to polish a local-only TUI delays
the right next investment.
The triage half of the dashboard isn't suffering from any of these.
Pending proposals are a small, well-scoped, real workload, and the
PRD 00130016 surface for handling them is the right shape. The
problem is everything that got bolted onto that core after.
## Goals / Success Criteria
1. The supervise TUI starts up, lists pending proposals across all
running bottles, and supports approve / modify / reject + the
`--once` non-interactive mode — exactly as PRDs 00130016
specified, minus everything 0019/0020/0021 added.
2. The CLI subcommand is renamed `supervise` (was `dashboard`). The
old name is not aliased — this PRD is intentionally a
compat/breaking change (the issue carries the
`Compat/Breaking` label).
3. `dashboard.py` shrinks to a single proposal-triage curses loop:
no agents pane, no Tab pane switching, no agent picker, no
start / re-attach / stop verbs, no tmux split-pane, no
`e`/`p` operator-edit verbs, no per-process `bottles` dict.
4. `dashboard_model.py` is collapsed into whatever
`supervise.py` (CLI) needs; the model module is removed if it
has no purpose after the cut.
5. The proposal-side apply paths in `bot_bottle/backend/docker/
egress_apply.py`, `pipelock_apply.py`, and `capability_apply.py`
are unchanged — they are still called by the approve path.
6. The supervise-sidecar / proposal-queue protocol (PRD 0013) is
unchanged: the agent's experience is identical.
7. The previously-active PRDs that this one undoes are marked
`Superseded by PRD 0049`:
- PRD 0019 — active-agents pane + agent-scoped edit verbs
- PRD 0020 — start / re-attach / stop from the dashboard
- PRD 0021 — tmux split-pane
## Non-goals
- **A web GUI for managing agents.** The issue floats this as a
second pass; this PRD does not design or commit to it. The cut
is "remove the path we no longer want to invest in", not
"build the replacement".
- **A separate CLI for operator-initiated routes / pipelock
edits.** Today those edits live as `e` / `p` keys inside the
dashboard. After this PRD they don't exist anywhere — operators
who need ad-hoc edits use the same path the agents do (call the
supervise tool from inside the bottle) or hand-edit the host-
side files and restart the sidecar. Adding a `./cli.py routes
edit <slug>` verb is a follow-up if the loss bites.
- **Removing `./cli.py start` or changing its semantics.** Start
remains the one-shot launch path. PRD 0020's bottle-outlives-
process model is removed; the only path to a long-running
bottle is `./cli.py start` (foreground) plus `cli.py cleanup`
for teardown.
- **Removing the supervise-sidecar protocol or any of the three
block-remediation engines.** PRDs 00130016 stay Active. The
agent's view of the world doesn't change.
- **Renaming `dashboard` anywhere other than the CLI entry
point.** The dashboard-related docs (PRDs, decision records,
research notes) keep their historical references — they
describe the state of the world at the time they were written,
and the Status: Superseded line is the marker that the world
has moved on.
- **Migrating the proposal-queue file layout.** The queue still
lives at `~/.bot-bottle/queue/<slug>/`; the audit log still
lives at `~/.bot-bottle/audit/<component>-<slug>.log`. The CLI
surface changes; the on-disk surface does not.
## Scope
### In scope
- **Rename the subcommand.** `./cli.py dashboard` becomes
`./cli.py supervise`. The module moves from `bot_bottle/cli/
dashboard.py` to `bot_bottle/cli/supervise.py`. The dispatcher
in `bot_bottle/cli/__init__.py` and the help text both update.
- **Strip the curses loop to proposal-only.** The remaining
surface is: list pending proposals (with the new-arrival bell
from PRD 0013), Enter for detail view,
`a`/`m`/`r` for approve / modify / reject, `q` to quit. No
agents pane, no Tab, no agent picker, no `n`/`x`/`e`/`p`, no
tmux dispatch, no `bottles` dict on the main loop.
- **Drop unused helpers.** `_picker_modal`, `_preflight_modal`,
`_backend_picker_modal`, `_new_agent_flow`, `_attach_to_bottle`,
`_attach_in_tmux`, `_attach_via_handoff`, `_tmux_*`,
`_ensure_right_pane`, `_redirect_stderr_to_file`,
`_route_op_to_right_pane`, `_stop_bottle_flow`,
`_operator_edit_*_flow`, `operator_edit_routes`,
`operator_edit_allowlist`, and their imports come out.
- **Collapse the model module.** `dashboard_model.py`'s
proposal-side helpers (`QueuedProposal`, `discover_pending`,
`_approval_status`, `_detail_lines`,
`_failed_url_host`, `_proposed_payload_label`,
`_suffix_for_tool`, `_REFRESH_INTERVAL_MS`) move back into
`supervise.py` (CLI) or into `bot_bottle/supervise.py`
(the daemon-side module) — wherever they fit. The agents /
picker / tmux helpers in that module (`PANE_*`,
`_filter_agents`, `_running_counts`, `_format_agent_row`,
`_selection_status`, `_selected_agent`, `_bottle_for_slug`,
`_pick_next_after_stop`, `_agent_runtime_args`,
`_build_resume_argv_with_fallback`, `_build_split_pane_argv`,
`_build_respawn_pane_argv`, `_in_tmux`,
`discover_active_agents`) are deleted.
- **Mark superseded PRDs.** The Status line on PRDs 0019, 0020,
and 0021 changes to `Superseded by [PRD 0049](0049-strip-
dashboard-to-supervisor-tui.md)`.
- **Test cleanup.** Any test that targets a removed surface (the
agent picker, the tmux split helpers, the start-from-dashboard
flow, the operator-edit flows, `discover_active_agents`)
comes out. Tests covering proposal triage stay.
- **Help / usage strings.** `bot_bottle/cli/__init__.py`'s usage
block updates the command name and one-liner.
### Out of scope
- Any new feature in the supervise TUI. The cut is purely
subtractive (except for the rename).
- Behavior changes in `./cli.py start`, `cli.py cleanup`,
`cli.py resume`, `cli.py list`, `cli.py info`, `cli.py edit`,
`cli.py init` — unchanged.
- Changes to the supervise sidecar (`supervise_server.py`,
`supervise.py` daemon module). The wire protocol stays.
- Changes to the routes / pipelock / capability apply engines.
- Migration helpers, deprecation warnings, or a transitional
`dashboard` alias for `supervise`. The label on the issue says
Compat/Breaking; the rename is a hard cutover.
## Proposed design
### Final shape of the TUI
After this PRD the `./cli.py supervise` curses surface is:
```
bot-bottle supervise (3 pending)
─────────────────────────────────────────────────────────
> 03:14:22 [implementer-cy7a6] egress-block abc123… add
github.com/foo
03:13:55 [researcher-9xqs1] pipelock-block def456… allow
registry.npmjs.org
03:13:10 [implementer-cy7a6] capability-block ghi789… install
ripgrep
─────────────────────────────────────────────────────────
[j/k] move [Enter] view [a] approve [m] modify [r] reject [q] quit
```
- One pane. No Tab. `j` / `k` / arrows move through the queue.
- Enter opens the existing detail view (justification +
proposed-file body + the green pipelock host-extraction hint).
`a` / `m` / `r` work from both the list view and the detail
view, same as today.
- `q` / Esc quits. There are no dashboard-owned bottles, so no
per-process teardown decision — `q` just exits.
- The new-arrival bell stays, because it is a real win for the
operator's "I was typing at claude and a proposal landed" case.
No tmux-specific focus management remains.
### Code organisation
After the cut, the CLI module looks roughly like:
```
bot_bottle/cli/supervise.py
- cmd_supervise(argv)
- _list_once() # --once mode
- _main_loop(stdscr) # proposal-only
- _render(stdscr, pending, ...)
- _detail_view(stdscr, qp, ...)
- _modify(stdscr, qp)
- _prompt(stdscr, label)
- _write_crash_log(exc)
- approve(qp, *, notes, final_file)
- reject(qp, *, reason)
- QueuedProposal, discover_pending
- _detail_lines, _approval_status,
_failed_url_host,
_proposed_payload_label,
_suffix_for_tool
```
`dashboard_model.py` has no purpose once the agents / picker /
tmux helpers are gone, so it is removed and the surviving
proposal-side helpers move into `supervise.py` directly. The
PRD-0013 refactor that split model out (`refactor: extract
dashboard state/model layer into dashboard_model.py`) was
load-bearing for the bigger dashboard surface; with the surface
shrunk back, the split is no longer justified.
### Removed PRDs: how to mark them
The three superseded PRDs keep their bodies intact. Only the
Status line at the top changes:
```
- **Status:** Superseded by [PRD
0049](0049-strip-dashboard-to-supervisor-tui.md)
```
The PRD's own Goals / Success Criteria are left as the historical
record of what the feature shipped — readers tracing back from the
code or the git log land in a PRD that explains what once was, with
a clear pointer forward. No PRD body is rewritten.
### Tests to keep, tests to remove
Keep:
- `tests/cli/test_dashboard*.py` cases that exercise
`discover_pending`, `approve`, `reject`, `_detail_lines`,
`_approval_status`, `_failed_url_host`,
`_proposed_payload_label`, `_suffix_for_tool`,
`_modify` / `edit_in_editor`.
- `tests/cli/test_dashboard_once.py` (or equivalent) — the
`--once` listing mode.
Remove:
- Any test of `_picker_modal`, `_preflight_modal`,
`_backend_picker_modal`, `_new_agent_flow`, `_attach_*`,
`_tmux_*`, `_route_op_to_right_pane`,
`_redirect_stderr_to_file`, `_stop_bottle_flow`,
`_operator_edit_*`, `_filter_agents`, `_running_counts`,
`_format_agent_row`, `_selection_status`,
`_selected_agent`, `_bottle_for_slug`,
`_pick_next_after_stop`, `_agent_runtime_args`,
`_build_*_argv`, `discover_active_agents`.
- The test files that exist solely to cover those (e.g.,
`test_dashboard_picker.py`, `test_dashboard_tmux.py`,
`test_dashboard_attach.py`, `test_dashboard_agents.py`
whichever of these exist after the file walk).
Files are renamed `test_supervise_*.py` to mirror the module
rename. The rename is mechanical; no test logic changes.
## Implementation chunks
Sized for a single PR each.
1. **Strip + rename in one cut.** Move `bot_bottle/cli/
dashboard.py` to `bot_bottle/cli/supervise.py`, delete the
removed helpers, delete `dashboard_model.py`, inline the
surviving helpers, update the dispatcher + usage in
`bot_bottle/cli/__init__.py`, rename tests to match, mark
PRDs 0019/0020/0021 as superseded. One commit per logical
piece inside the PR (rename, strip, supersede notes,
tests).
2. **Activate PRD 0049.** Flip this PRD's Status line from
Draft to Active in the same PR as chunk 1 once the
implementation lands. (The repo convention is that a PRD's
shipping commit is also the Status flip — see the recent
`docs(prd): activate PRD 0048…` commit shape.)
The PR closes issue #174.
## Open questions
1. **`e` / `p` operator-initiated edits — gone for good or
moved to a separate CLI verb?** The PRD removes them with no
replacement. The simplest replacement is `./cli.py routes
edit <slug>` and `./cli.py pipelock edit <slug>`, sharing
the existing `apply_routes_change` / `apply_allowlist_change`
engines. If the loss is felt within the first parallel
run after this lands, that follow-up is a small PR. Leaving
it for a separate PRD so this one stays subtractive.
2. **`--once` output shape.** The text listing today emits one
proposal per line. Worth keeping exactly as-is for
scripting consumers; this PRD does not change it. Flagging
only because the rename could tempt a tweak.
3. **Audit-log entry shape for an unprompted edit applied via
a future `routes edit` CLI verb.** Today's
`operator_edit_routes` writes an `ACTION_OPERATOR_EDIT`
audit entry. With those flows removed the constant has no
callers inside this PRD's scope. Keep the constant exported
from `supervise.py` (it's already an `__all__` member) so a
follow-up CLI verb can re-use the same audit shape without
re-introducing dead code first.
## References
- Issue
[#174](https://gitea.dideric.is/didericis/bot-bottle/issues/174)
— the request: "strip the dashboard down into just a TUI for
managing agent requests for new egress routes and new
capabilities."
- PRD 0013 — supervise plane foundation (the floor this PRD
reverts the dashboard to).
- PRDs 0014 / 0015 / 0016 — block-remediation engines that the
supervise TUI continues to drive on approve.
- PRDs 0019 / 0020 / 0021 — the bolted-on capabilities this PRD
removes.
-46
View File
@@ -277,51 +277,5 @@ class TestBottleMetadataBackend(_FakeHomeMixin, unittest.TestCase):
self.assertEqual("", loaded.backend) self.assertEqual("", loaded.backend)
class TestBottleForSlugBackend(_FakeHomeMixin, unittest.TestCase):
"""PRD 0040: _bottle_for_slug constructs the right bottle type."""
def setUp(self):
self._setup_fake_home()
def tearDown(self):
self._teardown_fake_home()
def test_docker_metadata_returns_docker_bottle(self):
from bot_bottle.backend.docker.bottle import DockerBottle
from bot_bottle.cli.dashboard import _bottle_for_slug
write_metadata(BottleMetadata(
identity="dev-d1",
agent_name="dev",
cwd="",
copy_cwd=False,
started_at="2026-06-02T00:00:00+00:00",
compose_project="bot-bottle-dev-d1",
backend="docker",
))
bottle, _ = _bottle_for_slug("dev-d1", {}, None)
self.assertIsInstance(bottle, DockerBottle)
def test_smolmachines_metadata_returns_smolmachines_bottle(self):
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
from bot_bottle.cli.dashboard import _bottle_for_slug
write_metadata(BottleMetadata(
identity="dev-s1",
agent_name="dev",
cwd="",
copy_cwd=False,
started_at="2026-06-02T00:00:00+00:00",
compose_project="",
backend="smolmachines",
))
bottle, _ = _bottle_for_slug("dev-s1", {}, None)
self.assertIsInstance(bottle, SmolmachinesBottle)
def test_no_metadata_defaults_to_docker_bottle(self):
from bot_bottle.backend.docker.bottle import DockerBottle
from bot_bottle.cli.dashboard import _bottle_for_slug
bottle, _ = _bottle_for_slug("unknown-slug", {}, None)
self.assertIsInstance(bottle, DockerBottle)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+166
View File
@@ -0,0 +1,166 @@
"""Unit: GiteaDeployKeyProvisioner (PRD 0048, contrib/gitea)."""
from __future__ import annotations
import json
import unittest
import urllib.error
from io import BytesIO
from pathlib import Path
from tempfile import mkdtemp
from unittest.mock import MagicMock, call, patch
from bot_bottle.contrib.gitea.deploy_key_provisioner import (
GiteaDeployKeyProvisioner,
_split_owner_repo,
)
def _provisioner() -> GiteaDeployKeyProvisioner:
return GiteaDeployKeyProvisioner(
token="test-token", api_url="https://gitea.example.com"
)
def _urlopen_response(body: dict, status: int = 200) -> MagicMock:
resp = MagicMock()
resp.read.return_value = json.dumps(body).encode()
resp.status = status
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
def _http_error(code: int, body: str = "") -> urllib.error.HTTPError:
return urllib.error.HTTPError(
url="http://x",
code=code,
msg="err",
hdrs=None, # type: ignore[arg-type]
fp=BytesIO(body.encode()),
)
class TestCreate(unittest.TestCase):
def test_create_calls_ssh_keygen_and_posts_to_api(self):
provisioner = _provisioner()
fake_key_id = 42
fake_private = b"PRIVATE_KEY"
fake_public = "ssh-ed25519 AAAA fake"
with patch(
"bot_bottle.contrib.gitea.deploy_key_provisioner.subprocess.run"
) as mock_run, patch(
"bot_bottle.contrib.gitea.deploy_key_provisioner.urllib.request.urlopen"
) as mock_urlopen, patch(
"bot_bottle.contrib.gitea.deploy_key_provisioner.Path.read_bytes",
return_value=fake_private,
), patch(
"bot_bottle.contrib.gitea.deploy_key_provisioner.Path.read_text",
return_value=fake_public + "\n",
):
mock_urlopen.return_value = _urlopen_response({"id": fake_key_id})
key_id, private_bytes = provisioner.create(
"didericis/bot-bottle", "bot-bottle:slug:repo"
)
# ssh-keygen called with ed25519
mock_run.assert_called_once()
run_args = mock_run.call_args.args[0]
self.assertIn("ssh-keygen", run_args)
self.assertIn("-t", run_args)
self.assertIn("ed25519", run_args)
# POST body contains public key
post_call = mock_urlopen.call_args.args[0]
payload = json.loads(post_call.data)
self.assertEqual(fake_public, payload["key"])
self.assertFalse(payload["read_only"])
# Correct URL
self.assertIn(
"/api/v1/repos/didericis/bot-bottle/keys", post_call.full_url
)
self.assertEqual(str(fake_key_id), key_id)
self.assertEqual(fake_private, private_bytes)
def test_create_raises_on_http_error(self):
provisioner = _provisioner()
with patch(
"bot_bottle.contrib.gitea.deploy_key_provisioner.subprocess.run"
), patch(
"bot_bottle.contrib.gitea.deploy_key_provisioner.urllib.request.urlopen",
side_effect=_http_error(403, "forbidden"),
), patch(
"bot_bottle.contrib.gitea.deploy_key_provisioner.Path.read_bytes",
return_value=b"pk",
), patch(
"bot_bottle.contrib.gitea.deploy_key_provisioner.Path.read_text",
return_value="ssh-ed25519 AAAA\n",
):
with self.assertRaises(RuntimeError) as ctx:
provisioner.create("owner/repo", "title")
self.assertIn("403", str(ctx.exception))
class TestDelete(unittest.TestCase):
def test_delete_calls_correct_endpoint(self):
provisioner = _provisioner()
with patch(
"bot_bottle.contrib.gitea.deploy_key_provisioner.urllib.request.urlopen"
) as mock_urlopen:
mock_urlopen.return_value = _urlopen_response({})
provisioner.delete("didericis/bot-bottle", "99")
req = mock_urlopen.call_args.args[0]
self.assertIn("/api/v1/repos/didericis/bot-bottle/keys/99", req.full_url)
self.assertEqual("DELETE", req.get_method())
def test_delete_tolerates_404(self):
provisioner = _provisioner()
with patch(
"bot_bottle.contrib.gitea.deploy_key_provisioner.urllib.request.urlopen",
side_effect=_http_error(404),
):
provisioner.delete("owner/repo", "123") # must not raise
def test_delete_raises_on_non_404_http_error(self):
provisioner = _provisioner()
with patch(
"bot_bottle.contrib.gitea.deploy_key_provisioner.urllib.request.urlopen",
side_effect=_http_error(500, "internal server error"),
):
with self.assertRaises(RuntimeError) as ctx:
provisioner.delete("owner/repo", "7")
self.assertIn("500", str(ctx.exception))
def test_delete_raises_on_url_error(self):
provisioner = _provisioner()
with patch(
"bot_bottle.contrib.gitea.deploy_key_provisioner.urllib.request.urlopen",
side_effect=urllib.error.URLError("connection refused"),
):
with self.assertRaises(RuntimeError) as ctx:
provisioner.delete("owner/repo", "7")
self.assertIn("connection refused", str(ctx.exception))
class TestSplitOwnerRepo(unittest.TestCase):
def test_simple(self):
self.assertEqual(("owner", "repo"), _split_owner_repo("owner/repo"))
def test_raises_on_missing_slash(self):
with self.assertRaises(ValueError):
_split_owner_repo("noslash")
def test_raises_on_empty_owner(self):
with self.assertRaises(ValueError):
_split_owner_repo("/repo")
def test_raises_on_empty_repo(self):
with self.assertRaises(ValueError):
_split_owner_repo("owner/")
if __name__ == "__main__":
unittest.main()
-492
View File
@@ -1,492 +0,0 @@
"""Unit: dashboard's row-formatting + selection helpers (PRD 0019)."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from bot_bottle import supervise
from bot_bottle.cli import dashboard
class _FakeHomeMixin:
def _setup_fake_home(self) -> None:
self._tmp = tempfile.TemporaryDirectory(prefix="dashboard-aa-test.")
original = supervise.bot_bottle_root
def fake_root() -> Path:
return Path(self._tmp.name) / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
self._restore_home = lambda: setattr(supervise, "bot_bottle_root", original)
def _teardown_fake_home(self) -> None:
self._restore_home()
self._tmp.cleanup()
class TestFormatAgentRow(unittest.TestCase):
"""One-line row formatting for the agents pane (PRD 0019 chunk 2)."""
def _agent(self, **overrides) -> dashboard.ActiveAgent:
defaults = dict(
backend_name="docker",
slug="dev-abc12",
agent_name="implementer",
started_at="2026-05-26T02:55:01+00:00",
services=("egress", "git-gate", "pipelock", "supervise"),
)
defaults.update(overrides)
return dashboard.ActiveAgent(**defaults)
def test_renders_slug_name_time_services(self):
s = dashboard._format_agent_row(self._agent(), 200)
self.assertIn("dev-abc12", s)
self.assertIn("implementer", s)
self.assertIn("02:55:01", s)
self.assertIn("egress,git-gate,pipelock,supervise", s)
def test_starting_label_when_no_services(self):
# Race window: compose project is up but containers haven't
# been picked up by `docker ps` yet.
s = dashboard._format_agent_row(self._agent(services=()), 200)
self.assertIn("(starting)", s)
def test_filters_agent_service_from_display(self):
# The `agent` service is always present for an active bottle;
# listing it is noise. The row should show only the sidecars.
s = dashboard._format_agent_row(
self._agent(services=("agent", "pipelock", "supervise")), 200,
)
self.assertIn("[pipelock,supervise]", s)
self.assertNotIn("agent,", s)
self.assertNotIn(",agent", s)
def test_only_agent_service_shows_starting(self):
# A bottle whose only running service is `agent` (sidecars
# still warming up) renders as `(starting)`.
s = dashboard._format_agent_row(self._agent(services=("agent",)), 200)
self.assertIn("(starting)", s)
def test_question_mark_when_no_started_at(self):
s = dashboard._format_agent_row(self._agent(started_at=""), 200)
self.assertIn("started ?", s)
def test_truncates_to_maxw(self):
s = dashboard._format_agent_row(self._agent(), 30)
self.assertLessEqual(len(s), 30)
self.assertTrue(s.endswith(""))
class TestSelectionStatus(unittest.TestCase):
"""Idle-state status-line text for the agents-pane focus
(PRD 0019 chunk 3). Empty when the proposals pane is focused;
surfaces the selected agent (or a clear placeholder) when the
agents pane is focused."""
def _agent(self, slug: str) -> dashboard.ActiveAgent:
return dashboard.ActiveAgent(
backend_name="docker",
slug=slug, agent_name="x", started_at="", services=(),
)
def test_empty_when_proposals_focused(self):
s = dashboard._selection_status(
dashboard.PANE_PROPOSALS, [self._agent("a-1")], 0,
)
self.assertEqual("", s)
def test_no_agents_message_when_agents_pane_empty(self):
s = dashboard._selection_status(dashboard.PANE_AGENTS, [], 0)
self.assertEqual("[no active agents]", s)
def test_shows_selected_slug(self):
agents = [self._agent("a-1"), self._agent("b-2"), self._agent("c-3")]
s = dashboard._selection_status(dashboard.PANE_AGENTS, agents, 1)
self.assertEqual("[selected: b-2]", s)
def test_out_of_bounds_falls_back_to_no_selection(self):
agents = [self._agent("only")]
s = dashboard._selection_status(dashboard.PANE_AGENTS, agents, 99)
self.assertEqual("[no agent selected]", s)
class TestFilterAgents(unittest.TestCase):
"""Pure-function picker filter (PRD 0020 chunk 2). Curses-free
so we can exercise the substring + case-insensitivity rules
directly."""
NAMES = ["implementer", "researcher", "triage-bot", "ImplDeluxe"]
def test_empty_query_returns_all(self):
self.assertEqual(self.NAMES, dashboard._filter_agents("", self.NAMES))
def test_substring_match(self):
self.assertEqual(
["implementer", "ImplDeluxe"],
dashboard._filter_agents("impl", self.NAMES),
)
def test_case_insensitive(self):
self.assertEqual(
["implementer", "ImplDeluxe"],
dashboard._filter_agents("IMPL", self.NAMES),
)
def test_no_match_returns_empty(self):
self.assertEqual([], dashboard._filter_agents("zzz", self.NAMES))
def test_preserves_input_order(self):
# Filtering should never re-sort; the picker draws in the
# order the manifest exposed.
out = dashboard._filter_agents("e", ["beta", "alpha", "echo"])
self.assertEqual(["beta", "echo"], out)
class TestDashboardManifestLoading(unittest.TestCase):
def test_new_agent_flow_empty_manifest_has_no_picker_entries(self):
manifest = dashboard.Manifest.from_json_obj({"bottles": {}, "agents": {}})
with mock.patch("bot_bottle.cli.dashboard._picker_modal", return_value=None) as picker:
status = dashboard._new_agent_flow(
None, manifest, {}, [], tmux_state=None, # type: ignore[arg-type]
)
picker.assert_called_once()
self.assertEqual([], picker.call_args.args[1])
self.assertIn("no agents configured", status)
class TestRunningCounts(unittest.TestCase):
"""Per-agent running-count surfaced in the picker so the
operator sees `(N running)` before picking. Counts come from
the dashboard's current `discover_active_agents` snapshot."""
def _agent(self, agent_name: str) -> dashboard.ActiveAgent:
return dashboard.ActiveAgent(
backend_name="docker",
slug=f"{agent_name}-abc",
agent_name=agent_name,
started_at="",
services=(),
)
def test_empty_when_no_active_agents(self):
self.assertEqual({}, dashboard._running_counts({}, []))
def test_one_per_unique_agent_name(self):
agents = [self._agent("a"), self._agent("b"), self._agent("c")]
self.assertEqual(
{"a": 1, "b": 1, "c": 1},
dashboard._running_counts({}, agents),
)
def test_counts_collisions(self):
agents = [
self._agent("implementer"),
self._agent("implementer"),
self._agent("researcher"),
]
self.assertEqual(
{"implementer": 2, "researcher": 1},
dashboard._running_counts({}, agents),
)
class TestSelectedAgent(unittest.TestCase):
"""`_selected_agent` is what chunk 4's e/p key handlers use to
decide whether to fire and which agent to target."""
def _agent(self, slug: str, services: tuple[str, ...] = ()) -> dashboard.ActiveAgent:
return dashboard.ActiveAgent(
backend_name="docker",
slug=slug, agent_name="x", started_at="", services=services,
)
def test_none_when_proposals_focused(self):
agents = [self._agent("a-1")]
self.assertIsNone(
dashboard._selected_agent(dashboard.PANE_PROPOSALS, agents, 0),
)
def test_none_when_no_agents(self):
self.assertIsNone(
dashboard._selected_agent(dashboard.PANE_AGENTS, [], 0),
)
def test_returns_indexed_agent_when_in_range(self):
agents = [self._agent("a-1"), self._agent("b-2")]
result = dashboard._selected_agent(dashboard.PANE_AGENTS, agents, 1)
self.assertIsNotNone(result)
assert result is not None # for type checker
self.assertEqual("b-2", result.slug)
def test_none_when_index_out_of_range(self):
agents = [self._agent("only")]
self.assertIsNone(
dashboard._selected_agent(dashboard.PANE_AGENTS, agents, 99),
)
class TestBottleForSlug(unittest.TestCase):
"""Re-attach target resolution (PRD 0020 chunk 3). Dashboard-
owned bottles return the stored handle as-is; non-owned bottles
get a synthesized DockerBottle backed by the slug-derived
container name."""
def test_owned_bottle_returns_held_handle(self):
sentinel = object()
bottles = {"dev-abc": (None, sentinel, "dev-abc")}
bottle, _ = dashboard._bottle_for_slug("dev-abc", bottles, None)
self.assertIs(sentinel, bottle)
def test_unowned_synthesizes_docker_bottle(self):
bottle, _ = dashboard._bottle_for_slug("dev-xyz", {}, None)
# The synth wraps the slug-derived container name.
self.assertEqual("bot-bottle-dev-xyz", bottle.name)
def test_unowned_without_manifest_omits_prompt_path(self):
bottle, hint = dashboard._bottle_for_slug("dev-xyz", {}, None)
self.assertEqual("", hint)
class TestPickNextAfterStop(unittest.TestCase):
"""After `x` stops a bottle, the dashboard slides focus to
the next agent the one filling the stopped row, or the
new last row if the stopped was last. Pure helper, easy
to unit-test."""
def _agent(self, slug: str) -> dashboard.ActiveAgent:
return dashboard.ActiveAgent(
backend_name="docker",
slug=slug, agent_name=slug, started_at="", services=(),
)
def test_empty_list_returns_none(self):
self.assertIsNone(
dashboard._pick_next_after_stop([], 0, "anything"),
)
def test_only_agent_being_stopped_returns_none(self):
# Stopping the last agent → nothing to focus.
agents = [self._agent("only")]
self.assertIsNone(
dashboard._pick_next_after_stop(agents, 0, "only"),
)
def test_middle_row_slides_up_to_same_index(self):
agents = [self._agent("a"), self._agent("b"), self._agent("c")]
# Cursor was on "b" at index 1; stopping "b" → "c" now sits
# at index 1 and takes focus.
out = dashboard._pick_next_after_stop(agents, 1, "b")
self.assertEqual((1, self._agent("c")), out)
def test_last_row_wraps_to_new_last(self):
agents = [self._agent("a"), self._agent("b"), self._agent("c")]
# Cursor on "c" at index 2; stopping "c" leaves a 2-agent
# list — index 2 is out of bounds, fall back to new last (1).
out = dashboard._pick_next_after_stop(agents, 2, "c")
self.assertEqual((1, self._agent("b")), out)
def test_first_row(self):
agents = [self._agent("a"), self._agent("b")]
out = dashboard._pick_next_after_stop(agents, 0, "a")
self.assertEqual((0, self._agent("b")), out)
def test_clamps_negative_selection(self):
# Defensive: a stale negative index doesn't crash.
agents = [self._agent("a"), self._agent("b")]
out = dashboard._pick_next_after_stop(agents, -1, "a")
self.assertEqual((0, self._agent("b")), out)
class TestTmuxPaneArgvBuilders(unittest.TestCase):
"""Pure argv builders for the tmux split-pane integration
(PRD 0021 chunk 2). The subprocess invocation itself is
environment-dependent; here we lock the wrapping shape so
a regression surfaces in CI without needing a real tmux."""
DOCKER_ARGV = [
"docker", "exec", "-it",
"bot-bottle-dev-abc",
"claude", "--dangerously-skip-permissions", "--continue",
]
def test_split_pane_argv_horizontal_with_pane_id_capture(self):
argv = dashboard._build_split_pane_argv(self.DOCKER_ARGV)
self.assertEqual(
["tmux", "split-window", "-h",
"-P", "-F", "#{pane_id}",
*self.DOCKER_ARGV],
argv,
)
def test_respawn_pane_argv_kills_existing_process(self):
argv = dashboard._build_respawn_pane_argv("%12", self.DOCKER_ARGV)
self.assertEqual(
["tmux", "respawn-pane", "-k", "-t", "%12", *self.DOCKER_ARGV],
argv,
)
def test_respawn_pane_argv_threads_pane_id_unmodified(self):
# Pane ids contain `%`; make sure we pass them straight
# through to `-t` without quoting or substitution surprises.
argv = dashboard._build_respawn_pane_argv("%abc.123", ["sh"])
self.assertIn("%abc.123", argv)
class TestResumeArgvWithFallback(unittest.TestCase):
"""The `claude --continue || claude` shell fallback for the
tmux re-attach path. Without it, an agent that's been spun
up but never typed at crashes the pane on Enter because
--continue has no session to resume."""
def _bottle(self, prompt_path: str | None = None):
from bot_bottle.backend.docker.bottle import DockerBottle
return DockerBottle(
container="bot-bottle-dev-abc",
teardown=lambda: None,
prompt_path_in_container=prompt_path,
)
def test_wraps_in_sh_c_with_or_fallback(self):
argv = dashboard._build_resume_argv_with_fallback(self._bottle())
# Must end with `sh -c '<cmd> --continue || <cmd>'`.
self.assertEqual(
["docker", "exec", "-it", "bot-bottle-dev-abc", "sh", "-c"],
argv[:6],
)
inner = argv[6]
self.assertIn("--continue", inner)
self.assertIn("||", inner)
# Both branches mention claude.
self.assertEqual(2, inner.count("claude"))
def test_inner_args_quoted_safely(self):
# Paths with spaces would break naive concatenation.
bottle = self._bottle("/home/with space/.prompt")
argv = dashboard._build_resume_argv_with_fallback(bottle)
inner = argv[-1]
# shlex.quote should single-quote any token with a space.
self.assertIn("'/home/with space/.prompt'", inner)
def test_includes_skip_permissions(self):
argv = dashboard._build_resume_argv_with_fallback(self._bottle())
self.assertIn("--dangerously-skip-permissions", argv[-1])
def test_includes_prompt_file_flag_when_set(self):
bottle = self._bottle("/home/node/.bot-bottle-prompt.txt")
argv = dashboard._build_resume_argv_with_fallback(bottle)
self.assertIn("--append-system-prompt-file", argv[-1])
self.assertIn("/home/node/.bot-bottle-prompt.txt", argv[-1])
class TestClaudeRuntimeArgs(unittest.TestCase):
"""The argv passed to `bottle.agent_argv` on each
attach. Locked here so the tmux + foreground paths build
identical agent invocations."""
def test_default_skip_permissions_only(self):
self.assertEqual(
["--dangerously-skip-permissions"],
dashboard._agent_runtime_args(resume=False),
)
def test_resume_appends_continue(self):
self.assertEqual(
["--dangerously-skip-permissions", "--continue"],
dashboard._agent_runtime_args(resume=True),
)
def test_remote_control(self):
args = dashboard._agent_runtime_args(
resume=False, remote_control=True,
)
self.assertIn("--remote-control", args)
class TestStopBottleFlow(unittest.TestCase):
"""Explicit per-bottle stop (PRD 0020 chunk 4). The non-owned
path is the one safe to test without curses + docker the
owned path drives `cm.__exit__` against a real launch context
and belongs in integration tests."""
def test_non_owned_returns_cleanup_hint(self):
# stdscr is None here on purpose — the non-owned branch
# returns before any curses call.
msg = dashboard._stop_bottle_flow(
stdscr=None, # type: ignore[arg-type]
bottles={},
slug="ghost-zzz",
)
self.assertIn("not dashboard-owned", msg)
self.assertIn("./cli.py cleanup", msg)
def test_non_owned_does_not_touch_tmux_state(self):
# PRD 0021: a stop on an unknown slug should never clear
# the right-pane occupant tracking, even if the slugs
# happen to match (defensive — non-owned can't be in the
# right pane via the dashboard's normal flow anyway).
tmux_state = {"pane_id": "%5", "slug": "live-bbb"}
dashboard._stop_bottle_flow(
stdscr=None, # type: ignore[arg-type]
bottles={},
slug="ghost-zzz",
tmux_state=tmux_state,
)
self.assertEqual({"pane_id": "%5", "slug": "live-bbb"}, tmux_state)
class TestOperatorEditFlowGuards(_FakeHomeMixin, unittest.TestCase):
"""Chunk-4 contract: the edit flow refuses when the selected
agent doesn't have the required sidecar running. The discover-
and-prompt scaffolding is gone, so the gating happens here
instead of in the key handler."""
def setUp(self) -> None:
self._setup_fake_home()
def tearDown(self) -> None:
self._teardown_fake_home()
def _agent(self, services: tuple[str, ...]) -> dashboard.ActiveAgent:
return dashboard.ActiveAgent(
backend_name="docker",
slug="dev-abc12",
agent_name="impl",
started_at="",
services=services,
)
def test_routes_edit_refuses_without_egress(self):
# Bottle without bottle.egress.routes → no egress sidecar.
msg = dashboard._operator_edit_flow(
stdscr=None, # type: ignore[arg-type]
agent=self._agent(("pipelock", "supervise")),
required_service="egress",
label="routes",
fetch=lambda _: "x",
apply=lambda _slug, _content: None,
suffix=".yaml",
)
self.assertIn("no running egress sidecar", msg)
self.assertIn("dev-abc12", msg)
def test_pipelock_edit_refuses_when_pipelock_missing(self):
# Belt-and-braces — pipelock should always be there, but
# the race window between `compose up` and `docker ps`
# update is real.
msg = dashboard._operator_edit_flow(
stdscr=None, # type: ignore[arg-type]
agent=self._agent(()),
required_service="pipelock",
label="pipelock",
fetch=lambda _: "x",
apply=lambda _slug, _content: None,
suffix=".txt",
)
self.assertIn("no running pipelock sidecar", msg)
if __name__ == "__main__":
unittest.main()
-39
View File
@@ -1,39 +0,0 @@
"""Unit: dashboard's new-proposal highlight window.
The curses rendering itself is exercised manually; this isolates
the pure decision `is the proposal still in its post-arrival
highlight window?`"""
import unittest
from bot_bottle.cli import dashboard
class TestIsRecent(unittest.TestCase):
def test_just_seen_is_recent(self):
self.assertTrue(dashboard._is_recent("p1", {"p1": 100.0}, now=100.5))
def test_seen_within_window(self):
# Default window is 5s.
self.assertTrue(
dashboard._is_recent("p1", {"p1": 100.0}, now=104.9),
)
def test_seen_past_window_is_not_recent(self):
self.assertFalse(
dashboard._is_recent("p1", {"p1": 100.0}, now=106.0),
)
def test_unknown_proposal_is_not_recent(self):
self.assertFalse(
dashboard._is_recent("p2", {"p1": 100.0}, now=100.5),
)
def test_none_args_safe_default(self):
self.assertFalse(dashboard._is_recent("p1", None, None))
self.assertFalse(dashboard._is_recent("p1", {"p1": 100.0}, None))
self.assertFalse(dashboard._is_recent("p1", None, 100.5))
if __name__ == "__main__":
unittest.main()
+29
View File
@@ -0,0 +1,29 @@
"""Unit: deploy_key_provisioner factory (PRD 0048)."""
from __future__ import annotations
import unittest
from unittest.mock import patch
from bot_bottle.deploy_key_provisioner import DeployKeyProvisioner, get_provisioner
from bot_bottle.manifest import ManifestError
class TestGetProvisioner(unittest.TestCase):
def test_gitea_returns_gitea_provisioner(self):
from bot_bottle.contrib.gitea.deploy_key_provisioner import (
GiteaDeployKeyProvisioner,
)
p = get_provisioner("gitea", token="tok", api_url="https://gitea.example.com")
self.assertIsInstance(p, GiteaDeployKeyProvisioner)
self.assertIsInstance(p, DeployKeyProvisioner)
def test_unknown_provider_raises_manifest_error(self):
with self.assertRaises(ManifestError) as ctx:
get_provisioner("github", token="tok", api_url="https://github.com")
self.assertIn("github", str(ctx.exception))
self.assertIn("provisioned_key provider", str(ctx.exception))
if __name__ == "__main__":
unittest.main()
+58 -58
View File
@@ -1,20 +1,20 @@
"""Unit: docker backend `_provision_git_user` (issue #86). """Unit: docker backend `_provision_git_user` (issue #86).
Mocks `subprocess.run` and asserts the `docker exec -u node Mocks `bottle.exec` / `bottle.cp_in` and asserts on the script
git config --global ` argv shape. The cwd + git-gate passes strings and user parameter. The cwd + git-gate passes are covered
are covered indirectly by the existing integration-shaped tests indirectly by the existing integration-shaped tests in
in test_smolmachines_provision; this file targets just the new test_smolmachines_provision; this file targets just the git_user
git_user pass.""" pass."""
from __future__ import annotations from __future__ import annotations
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import MagicMock, call
from bot_bottle.agent_provider import AgentProvisionPlan from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.backend import BottleSpec from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.backend.docker.provision import git as _git from bot_bottle.backend.docker.provision import git as _git
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
@@ -82,16 +82,22 @@ def _plan(*, git_user: dict | None = None,
) )
def _git_config_calls(mock_run) -> list[list[str]]: def _make_bottle(name: str = "bot-bottle-demo-abc12") -> MagicMock:
"""Filter `subprocess.run` calls down to the ones that run bottle = MagicMock(spec=Bottle)
`git config --global` inside the bottle, returning each argv.""" bottle.name = name
out: list[list[str]] = [] bottle.exec.return_value = ExecResult(returncode=0, stdout="", stderr="")
for call in mock_run.call_args_list: return bottle
argv = call.args[0]
if (len(argv) >= 5
and argv[0] == "docker" and argv[1] == "exec" def _git_config_exec_calls(bottle: MagicMock) -> list[tuple[str, str]]:
and "git" in argv and "config" in argv): """Filter bottle.exec calls to git-config invocations.
out.append(list(argv)) Returns list of (script, user) tuples."""
out = []
for c in bottle.exec.call_args_list:
script = c.args[0] if c.args else c.kwargs.get("script", "")
user = c.kwargs.get("user", c.args[1] if len(c.args) > 1 else "node")
if "git config" in script:
out.append((script, user))
return out return out
@@ -104,71 +110,65 @@ class TestProvisionGitUser(unittest.TestCase):
self._tmp.cleanup() self._tmp.cleanup()
def test_noop_when_no_git_user(self): def test_noop_when_no_git_user(self):
with patch.object(_git.subprocess, "run") as run: bottle = _make_bottle()
_git._provision_git_user( _git._provision_git_user(_plan(stage_dir=self.stage), bottle)
_plan(stage_dir=self.stage), "bot-bottle-demo-abc12", self.assertEqual([], _git_config_exec_calls(bottle))
)
self.assertEqual([], _git_config_calls(run))
def test_copies_cwd_git_to_workspace_plan_path(self): def test_copies_cwd_git_to_workspace_plan_path(self):
cwd = self.stage / "cwd" cwd = self.stage / "cwd"
(cwd / ".git").mkdir(parents=True) (cwd / ".git").mkdir(parents=True)
plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage) plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage)
with patch.object(_git.subprocess, "run") as run: bottle = _make_bottle()
_git._provision_cwd_git(plan, "bot-bottle-demo-abc12") _git._provision_cwd_git(plan, bottle)
self.assertEqual( bottle.cp_in.assert_called_once_with(
[ f"{cwd}/.git",
"docker", "cp", f"{cwd}/.git", "/home/node/workspace/.git",
"bot-bottle-demo-abc12:/home/node/workspace/.git",
],
run.call_args_list[0].args[0],
)
self.assertEqual(
[
"docker", "exec", "-u", "0", "bot-bottle-demo-abc12",
"chown", "-R", "node:node", "/home/node/workspace/.git",
],
run.call_args_list[1].args[0],
) )
chown_calls = [
c for c in bottle.exec.call_args_list
if "chown" in (c.args[0] if c.args else "")
]
self.assertEqual(1, len(chown_calls))
self.assertIn("node:node", chown_calls[0].args[0])
self.assertIn("/home/node/workspace/.git", chown_calls[0].args[0])
def test_sets_name_and_email(self): def test_sets_name_and_email(self):
plan = _plan( plan = _plan(
git_user={"name": "Eric Bauerfeld", "email": "eric@dideric.is"}, git_user={"name": "Eric Bauerfeld", "email": "eric@dideric.is"},
stage_dir=self.stage, stage_dir=self.stage,
) )
with patch.object(_git.subprocess, "run") as run: bottle = _make_bottle()
_git._provision_git_user(plan, "bot-bottle-demo-abc12") _git._provision_git_user(plan, bottle)
calls = _git_config_calls(run) calls = _git_config_exec_calls(bottle)
self.assertEqual(2, len(calls)) self.assertEqual(2, len(calls))
# All `docker exec` invocations run as `-u node` so the for script, user in calls:
# --global config lands in /home/node/.gitconfig. self.assertEqual("node", user)
for argv in calls: self.assertIn("git config --global", script)
self.assertEqual( self.assertIn("user.name", calls[0][0])
["docker", "exec", "-u", "node", "bot-bottle-demo-abc12", self.assertIn("Eric Bauerfeld", calls[0][0])
"git", "config", "--global"], self.assertIn("user.email", calls[1][0])
argv[:8], self.assertIn("eric@dideric.is", calls[1][0])
)
self.assertEqual(["user.name", "Eric Bauerfeld"], calls[0][8:])
self.assertEqual(["user.email", "eric@dideric.is"], calls[1][8:])
def test_name_only_sets_only_name(self): def test_name_only_sets_only_name(self):
plan = _plan(git_user={"name": "Bot"}, stage_dir=self.stage) plan = _plan(git_user={"name": "Bot"}, stage_dir=self.stage)
with patch.object(_git.subprocess, "run") as run: bottle = _make_bottle()
_git._provision_git_user(plan, "bot-bottle-demo-abc12") _git._provision_git_user(plan, bottle)
calls = _git_config_calls(run) calls = _git_config_exec_calls(bottle)
self.assertEqual(1, len(calls)) self.assertEqual(1, len(calls))
self.assertEqual(["user.name", "Bot"], calls[0][8:]) self.assertIn("user.name", calls[0][0])
self.assertIn("Bot", calls[0][0])
def test_email_only_sets_only_email(self): def test_email_only_sets_only_email(self):
plan = _plan( plan = _plan(
git_user={"email": "bot@example.com"}, stage_dir=self.stage, git_user={"email": "bot@example.com"}, stage_dir=self.stage,
) )
with patch.object(_git.subprocess, "run") as run: bottle = _make_bottle()
_git._provision_git_user(plan, "bot-bottle-demo-abc12") _git._provision_git_user(plan, bottle)
calls = _git_config_calls(run) calls = _git_config_exec_calls(bottle)
self.assertEqual(1, len(calls)) self.assertEqual(1, len(calls))
self.assertEqual(["user.email", "bot@example.com"], calls[0][8:]) self.assertIn("user.email", calls[0][0])
self.assertIn("bot@example.com", calls[0][0])
if __name__ == "__main__": if __name__ == "__main__":
@@ -4,14 +4,14 @@ from __future__ import annotations
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import MagicMock
from bot_bottle.agent_provider import ( from bot_bottle.agent_provider import (
AgentProvisionDir, AgentProvisionDir,
AgentProvisionFile, AgentProvisionFile,
AgentProvisionPlan, AgentProvisionPlan,
) )
from bot_bottle.backend import BottleSpec from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.backend.docker.provision import provider_auth as _provider_auth from bot_bottle.backend.docker.provision import provider_auth as _provider_auth
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
@@ -110,80 +110,62 @@ def _agent_provision(
) )
def _make_bottle(name: str = "bot-bottle-demo-abc12") -> MagicMock:
bottle = MagicMock(spec=Bottle)
bottle.name = name
bottle.exec.return_value = ExecResult(returncode=0, stdout="", stderr="")
return bottle
class TestProvisionProviderAuth(unittest.TestCase): class TestProvisionProviderAuth(unittest.TestCase):
def test_noop_for_non_codex_provider(self): def test_noop_for_non_codex_provider(self):
with patch.object(_provider_auth.subprocess, "run") as run: bottle = _make_bottle()
_provider_auth.provision_provider_auth( _provider_auth.provision_provider_auth(
_plan(agent_provider_template="claude"), "bot-bottle-demo-abc12", _plan(agent_provider_template="claude"), bottle,
) )
self.assertEqual(0, run.call_count) self.assertEqual(0, bottle.cp_in.call_count)
self.assertEqual(0, bottle.exec.call_count)
def test_codex_provider_trusts_launch_dir_without_auth_file(self): def test_codex_provider_trusts_launch_dir_without_auth_file(self):
with patch.object(_provider_auth.subprocess, "run") as run: bottle = _make_bottle()
_provider_auth.provision_provider_auth( _provider_auth.provision_provider_auth(_plan(), bottle)
_plan(), "bot-bottle-demo-abc12", scripts = [c.args[0] for c in bottle.exec.call_args_list]
) self.assertTrue(
argvs = [call.args[0] for call in run.call_args_list] any("mkdir -p" in s and "/home/node/.codex" in s for s in scripts)
)
cp_calls = [c.args for c in bottle.cp_in.call_args_list]
self.assertIn( self.assertIn(
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12", ("/tmp/codex-config.toml", "/home/node/.codex/config.toml"),
"mkdir", "-p", "/home/node/.codex"], cp_calls,
argvs,
) )
trust_config = next( self.assertTrue(
a for a in argvs any("chown" in s and "/home/node/.codex/config.toml" in s for s in scripts)
if a[:2] == ["docker", "cp"] and a[2] == "/tmp/codex-config.toml"
) )
self.assertEqual( self.assertTrue(
"bot-bottle-demo-abc12:/home/node/.codex/config.toml", any("chmod" in s and "/home/node/.codex/config.toml" in s for s in scripts)
trust_config[3],
)
self.assertIn(
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12",
"chown", "node:node", "/home/node/.codex/config.toml"],
argvs,
)
self.assertIn(
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12",
"chmod", "600", "/home/node/.codex/config.toml"],
argvs,
) )
def test_copies_dummy_auth_json_to_codex_home(self): def test_copies_dummy_auth_json_to_codex_home(self):
with patch.object(_provider_auth.subprocess, "run") as run: bottle = _make_bottle()
_provider_auth.provision_provider_auth( _provider_auth.provision_provider_auth(
_plan(codex_auth_file=Path("/tmp/codex-auth.json")), _plan(codex_auth_file=Path("/tmp/codex-auth.json")),
"bot-bottle-demo-abc12", bottle,
) )
argvs = [call.args[0] for call in run.call_args_list] cp_calls = [c.args for c in bottle.cp_in.call_args_list]
self.assertIn( self.assertIn(
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12", ("/tmp/codex-config.toml", "/home/node/.codex/config.toml"),
"mkdir", "-p", "/home/node/.codex"], cp_calls,
argvs,
) )
self.assertIn( self.assertIn(
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12", ("/tmp/codex-auth.json", "/home/node/.codex/auth.json"),
"chown", "node:node", "/home/node/.codex"], cp_calls,
argvs,
) )
self.assertIn( scripts = [c.args[0] for c in bottle.exec.call_args_list]
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12", self.assertTrue(
"chmod", "700", "/home/node/.codex"], any("chown" in s and "/home/node/.codex/auth.json" in s for s in scripts)
argvs,
) )
self.assertIn( self.assertTrue(
["docker", "cp", "/tmp/codex-auth.json", any("chmod" in s and "/home/node/.codex/auth.json" in s for s in scripts)
"bot-bottle-demo-abc12:/home/node/.codex/auth.json"],
argvs,
)
self.assertIn(
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12",
"chown", "node:node", "/home/node/.codex/auth.json"],
argvs,
)
self.assertIn(
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12",
"chmod", "600", "/home/node/.codex/auth.json"],
argvs,
) )
+98
View File
@@ -282,5 +282,103 @@ class TestPrepare(unittest.TestCase):
self.assertIn("exec git daemon", content) self.assertIn("exec git daemon", content)
class TestShellEscaping(unittest.TestCase):
"""Regression tests: all three render functions must produce syntactically
valid sh code even when names and upstream URLs contain shell-special
characters. Tests construct GitGateUpstream directly bypassing manifest
name validation so the rendering layer is exercised in isolation."""
_MALICIOUS_URL_CASES = [
("single_quote", "ssh://git@host/path'with'quotes.git"),
("double_quote", 'ssh://git@host/path"with"quotes.git'),
("space", "ssh://git@host/path with spaces.git"),
("semicolon", "ssh://git@host/path;evil.git"),
("newline", "ssh://git@host/path\nwith\nnewlines.git"),
("backtick", "ssh://git@host/path`whoami`.git"),
]
_MALICIOUS_NAME_CASES = [
("single_quote", "repo'name"),
("double_quote", 'repo"name'),
("space", "repo name"),
("semicolon", "repo;name"),
("newline", "repo\nname"),
("backtick", "repo`name"),
]
def _make_upstream(self, url: str, name: str = "myrepo") -> GitGateUpstream:
return GitGateUpstream(
name=name,
upstream_url=url,
upstream_host="host",
upstream_port="22",
identity_file="/key",
known_host_key="",
)
def _assert_valid_sh(self, script: str, label: str = "") -> None:
import subprocess
fd, path = tempfile.mkstemp(suffix=".sh")
try:
with os.fdopen(fd, "w") as f:
f.write(script)
result = subprocess.run(
["sh", "-n", path], capture_output=True, text=True,
)
self.assertEqual(
0, result.returncode,
f"sh -n failed{(' for ' + label) if label else ''}: {result.stderr}",
)
finally:
os.unlink(path)
def test_hook_renders_valid_sh(self):
self._assert_valid_sh(git_gate_render_hook(), "pre-receive hook")
def test_access_hook_renders_valid_sh(self):
self._assert_valid_sh(git_gate_render_access_hook(), "access hook")
def test_entrypoint_with_pathological_upstream_url_renders_valid_sh(self):
for label, url in self._MALICIOUS_URL_CASES:
with self.subTest(char=label):
script = git_gate_render_entrypoint((self._make_upstream(url),))
self._assert_valid_sh(script, label)
def test_entrypoint_upstream_url_value_preserved_after_quoting(self):
import shlex as _shlex
for label, url in self._MALICIOUS_URL_CASES:
with self.subTest(char=label):
script = git_gate_render_entrypoint((self._make_upstream(url),))
# The quoted form of the URL must appear verbatim in the script so
# the shell reconstructs exactly the original value at runtime.
expected = f"init_repo {_shlex.quote('myrepo')} {_shlex.quote(url)}"
self.assertIn(
expected, script,
f"{label}: expected quoted form not found in script",
)
def test_entrypoint_with_pathological_name_renders_valid_sh(self):
for label, name in self._MALICIOUS_NAME_CASES:
with self.subTest(char=label):
script = git_gate_render_entrypoint((
self._make_upstream("ssh://git@github.com/foo/bar.git", name=name),
))
self._assert_valid_sh(script, label)
def test_entrypoint_name_value_preserved_after_quoting(self):
import shlex as _shlex
url = "ssh://git@github.com/foo/bar.git"
for label, name in self._MALICIOUS_NAME_CASES:
with self.subTest(char=label):
script = git_gate_render_entrypoint((
self._make_upstream(url, name=name),
))
expected = f"init_repo {_shlex.quote(name)} {_shlex.quote(url)}"
self.assertIn(
expected, script,
f"{label}: expected quoted form not found in script",
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+107
View File
@@ -243,6 +243,113 @@ class TestGitEntryCrossValidation(unittest.TestCase):
self.assertIn("PRD 0047", msg) self.assertIn("PRD 0047", msg)
class TestProvisionedKey(unittest.TestCase):
"""git-gate.repos entries that use provisioned_key (PRD 0048)."""
def test_provisioned_key_minimal(self):
m = Manifest.from_json_obj(_manifest({
"bot-bottle": {
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
"provisioned_key": {
"provider": "gitea",
"token_env": "GITEA_TOKEN",
},
},
}))
e = m.bottles["dev"].git[0]
self.assertEqual("bot-bottle", e.Name)
self.assertIsNotNone(e.ProvisionedKey)
assert e.ProvisionedKey is not None
self.assertEqual("gitea", e.ProvisionedKey.provider)
self.assertEqual("GITEA_TOKEN", e.ProvisionedKey.token_env)
self.assertEqual("", e.ProvisionedKey.api_url)
self.assertEqual("", e.IdentityFile)
def test_provisioned_key_with_api_url(self):
m = Manifest.from_json_obj(_manifest({
"repo": {
"url": "ssh://git@gitea.example.com/org/repo.git",
"provisioned_key": {
"provider": "gitea",
"token_env": "MY_TOKEN",
"api_url": "https://gitea.example.com",
},
},
}))
pk = m.bottles["dev"].git[0].ProvisionedKey
assert pk is not None
self.assertEqual("https://gitea.example.com", pk.api_url)
def test_both_identity_and_provisioned_key_dies(self):
with self.assertRaises(ManifestError) as ctx:
Manifest.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"identity": "/dev/null",
"provisioned_key": {"provider": "gitea", "token_env": "T"},
},
}))
self.assertIn("exactly one of", str(ctx.exception))
self.assertIn("got both", str(ctx.exception))
def test_neither_identity_nor_provisioned_key_dies(self):
with self.assertRaises(ManifestError) as ctx:
Manifest.from_json_obj(_manifest({
"foo": {"url": "ssh://git@github.com/foo.git"},
}))
self.assertIn("exactly one of", str(ctx.exception))
self.assertIn("got neither", str(ctx.exception))
def test_unknown_key_in_provisioned_key_block_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"provisioned_key": {
"provider": "gitea",
"token_env": "T",
"key_type": "rsa", # not allowed
},
},
}))
def test_missing_provider_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"provisioned_key": {"token_env": "T"},
},
}))
def test_missing_token_env_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"provisioned_key": {"provider": "gitea"},
},
}))
def test_provisioned_key_entry_has_no_identity_file(self):
m = Manifest.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/didericis/foo.git",
"provisioned_key": {"provider": "gitea", "token_env": "T"},
},
}))
self.assertEqual("", m.bottles["dev"].git[0].IdentityFile)
def test_identity_entry_has_no_provisioned_key(self):
m = Manifest.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"identity": "/dev/null",
},
}))
self.assertIsNone(m.bottles["dev"].git[0].ProvisionedKey)
class TestEmptyGitGateField(unittest.TestCase): class TestEmptyGitGateField(unittest.TestCase):
def test_no_git_gate_field_yields_empty_tuple(self): def test_no_git_gate_field_yields_empty_tuple(self):
m = Manifest.from_json_obj({ m = Manifest.from_json_obj({
+304 -437
View File
@@ -1,8 +1,8 @@
"""Unit: smolmachines provisioning helpers (PRD 0023 chunks 4a + 4d). """Unit: smolmachines provisioning helpers (PRD 0023 chunks 4a + 4d).
Tests mock `smolvm.machine_cp` / `smolvm.machine_exec` and assert Tests mock `bottle.exec` / `bottle.cp_in` and assert on the
on the dispatched call shape. The real round-trip lives in the dispatched script shape. The real round-trip lives in the chunk-4
chunk-4 integration smoke.""" integration smoke."""
from __future__ import annotations from __future__ import annotations
@@ -11,7 +11,7 @@ import tempfile
import unittest import unittest
from dataclasses import replace from dataclasses import replace
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import MagicMock, patch
from bot_bottle.agent_provider import ( from bot_bottle.agent_provider import (
AgentProvisionCommand, AgentProvisionCommand,
@@ -19,7 +19,7 @@ from bot_bottle.agent_provider import (
AgentProvisionFile, AgentProvisionFile,
AgentProvisionPlan, AgentProvisionPlan,
) )
from bot_bottle.backend import BottleSpec from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.smolmachines.bottle_plan import ( from bot_bottle.backend.smolmachines.bottle_plan import (
SmolmachinesBottlePlan, SmolmachinesBottlePlan,
) )
@@ -33,7 +33,6 @@ from bot_bottle.backend.smolmachines.provision import (
workspace as _workspace, workspace as _workspace,
) )
from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
from bot_bottle.backend.smolmachines.smolvm import SmolvmRunResult
from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import GitEntry, Manifest from bot_bottle.manifest import GitEntry, Manifest
@@ -42,6 +41,28 @@ from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan from bot_bottle.workspace import workspace_plan
def _make_bottle(
name: str = "bot-bottle-demo-abc12",
exec_result: ExecResult | None = None,
) -> MagicMock:
bottle = MagicMock(spec=Bottle)
bottle.name = name
bottle.exec.return_value = (
exec_result if exec_result is not None
else ExecResult(returncode=0, stdout="", stderr="")
)
return bottle
def _exec_scripts(bottle: MagicMock) -> list[str]:
"""All script strings passed to bottle.exec, in call order."""
return [c.args[0] for c in bottle.exec.call_args_list]
def _exec_users(bottle: MagicMock) -> list[str]:
"""user= kwarg from each bottle.exec call, in order."""
return [c.kwargs.get("user", "node") for c in bottle.exec.call_args_list]
def _plan( def _plan(
*, *,
@@ -203,235 +224,181 @@ def _agent_provision(
class TestProvisionPrompt(unittest.TestCase): class TestProvisionPrompt(unittest.TestCase):
def test_cp_uses_smolvm_machine_cp_with_machine_path_syntax(self): def test_cp_uses_bottle_cp_in(self):
with patch( bottle = _make_bottle()
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_cp" _prompt.provision_prompt(_plan(), bottle)
) as cp, patch( bottle.cp_in.assert_called_once_with(
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_exec"
):
_prompt.provision_prompt(_plan(), "bot-bottle-demo-abc12")
cp.assert_called_once_with(
"/tmp/state/demo-abc12/agent/prompt.txt", "/tmp/state/demo-abc12/agent/prompt.txt",
"bot-bottle-demo-abc12:/home/node/.bot-bottle-prompt.txt", "/home/node/.bot-bottle-prompt.txt",
) )
def test_returns_path_when_agent_has_prompt(self): def test_returns_path_when_agent_has_prompt(self):
with patch( bottle = _make_bottle()
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_cp" r = _prompt.provision_prompt(
), patch( _plan(agent_prompt="You are a helpful assistant."),
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_exec" bottle,
): )
r = _prompt.provision_prompt(
_plan(agent_prompt="You are a helpful assistant."),
"bot-bottle-demo-abc12",
)
self.assertEqual("/home/node/.bot-bottle-prompt.txt", r) self.assertEqual("/home/node/.bot-bottle-prompt.txt", r)
def test_returns_none_when_agent_has_no_prompt(self): def test_returns_none_when_agent_has_no_prompt(self):
# The file is still copied (path-must-exist contract); # The file is still copied (path-must-exist contract);
# only the return value differs. # only the return value differs.
with patch( bottle = _make_bottle()
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_cp" r = _prompt.provision_prompt(_plan(agent_prompt=""), bottle)
) as cp, patch(
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_exec"
):
r = _prompt.provision_prompt(_plan(agent_prompt=""), "bot-bottle-demo-abc12")
self.assertIsNone(r) self.assertIsNone(r)
cp.assert_called_once() bottle.cp_in.assert_called_once()
def test_chowns_to_node_after_copy(self): def test_chowns_to_node_after_copy(self):
# machine cp lands as root; without the chown, the node user # cp_in lands as root; without the chown, the node user
# can't read its own mode-600 prompt. # can't read its own mode-600 prompt.
with patch( bottle = _make_bottle()
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_cp" _prompt.provision_prompt(_plan(), bottle)
), patch( scripts = _exec_scripts(bottle)
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_exec" self.assertTrue(
) as ex: any("chown node:node" in s and "/home/node/.bot-bottle-prompt.txt" in s
_prompt.provision_prompt(_plan(), "bot-bottle-demo-abc12") for s in scripts)
argv_seen = [call.args[1] for call in ex.call_args_list]
self.assertIn(
["chown", "node:node", "/home/node/.bot-bottle-prompt.txt"],
argv_seen,
) )
self.assertIn( self.assertTrue(
["chmod", "600", "/home/node/.bot-bottle-prompt.txt"], any("chmod 600" in s and "/home/node/.bot-bottle-prompt.txt" in s
argv_seen, for s in scripts)
) )
class TestProvisionProviderAuth(unittest.TestCase): class TestProvisionProviderAuth(unittest.TestCase):
def _patch(self):
return (
patch(
"bot_bottle.backend.smolmachines.provision.provider_auth._smolvm.machine_cp"
),
patch(
"bot_bottle.backend.smolmachines.provision.provider_auth._smolvm.machine_exec"
),
)
def test_noop_for_non_codex_provider(self): def test_noop_for_non_codex_provider(self):
cp_p, ex_p = self._patch() bottle = _make_bottle()
with cp_p as cp, ex_p as ex: _provider_auth.provision_provider_auth(_plan(), bottle)
_provider_auth.provision_provider_auth(_plan(), "bot-bottle-demo-abc12") self.assertEqual(0, bottle.cp_in.call_count)
self.assertEqual(0, cp.call_count) self.assertEqual(0, bottle.exec.call_count)
self.assertEqual(0, ex.call_count)
def test_codex_provider_trusts_launch_dir_without_auth_file(self): def test_codex_provider_trusts_launch_dir_without_auth_file(self):
cp_p, ex_p = self._patch() bottle = _make_bottle()
with cp_p as cp, ex_p as ex: _provider_auth.provision_provider_auth(
ex.return_value = SmolvmRunResult(0, "", "") _plan(agent_provider_template="codex"),
_provider_auth.provision_provider_auth( bottle,
_plan(agent_provider_template="codex"), )
"bot-bottle-demo-abc12", bottle.cp_in.assert_called_once_with(
)
cp.assert_called_once_with(
"/tmp/codex-config.toml", "/tmp/codex-config.toml",
"bot-bottle-demo-abc12:/home/node/.codex/config.toml", "/home/node/.codex/config.toml",
) )
argv_seen = [call.args[1] for call in ex.call_args_list] scripts = _exec_scripts(bottle)
self.assertIn(["mkdir", "-p", "/home/node/.codex"], argv_seen) self.assertTrue(any("mkdir -p" in s and "/home/node/.codex" in s for s in scripts))
self.assertIn( self.assertTrue(
["chown", "node:node", "/home/node/.codex/config.toml"], any("chown" in s and "node:node" in s and "/home/node/.codex/config.toml" in s
argv_seen, for s in scripts)
)
self.assertTrue(
any("chmod" in s and "600" in s and "/home/node/.codex/config.toml" in s
for s in scripts)
) )
self.assertIn(["chmod", "600", "/home/node/.codex/config.toml"], argv_seen)
def test_copies_dummy_auth_json_to_codex_home(self): def test_copies_dummy_auth_json_to_codex_home(self):
cp_p, ex_p = self._patch() bottle = _make_bottle()
with cp_p as cp, ex_p as ex: _provider_auth.provision_provider_auth(
ex.return_value = SmolvmRunResult(0, "Logged in using ChatGPT\n", "") _plan(
_provider_auth.provision_provider_auth( agent_provider_template="codex",
_plan( codex_auth_file=Path("/tmp/codex-auth.json"),
agent_provider_template="codex", ),
codex_auth_file=Path("/tmp/codex-auth.json"), bottle,
), )
"bot-bottle-demo-abc12", cp_calls = [c.args for c in bottle.cp_in.call_args_list]
)
cp_calls = [call.args for call in cp.call_args_list]
self.assertIn( self.assertIn(
("/tmp/codex-config.toml", ("/tmp/codex-config.toml", "/home/node/.codex/config.toml"),
"bot-bottle-demo-abc12:/home/node/.codex/config.toml"),
cp_calls, cp_calls,
) )
self.assertIn( self.assertIn(
("/tmp/codex-auth.json", ("/tmp/codex-auth.json", "/home/node/.codex/auth.json"),
"bot-bottle-demo-abc12:/home/node/.codex/auth.json"),
cp_calls, cp_calls,
) )
argv_seen = [call.args[1] for call in ex.call_args_list] scripts = _exec_scripts(bottle)
self.assertIn(["mkdir", "-p", "/home/node/.codex"], argv_seen) self.assertTrue(any("mkdir -p" in s and "/home/node/.codex" in s for s in scripts))
self.assertIn( self.assertTrue(
["chown", "node:node", "/home/node/.codex"], any("chown" in s and "node:node" in s and s.rstrip().endswith("/home/node/.codex")
argv_seen, for s in scripts)
) )
self.assertIn( self.assertTrue(
["chmod", "700", "/home/node/.codex"], any("chmod" in s and "700" in s and s.rstrip().endswith("/home/node/.codex")
argv_seen, for s in scripts)
) )
self.assertIn( # The pre_copy `find ... -delete` script should be present
[ # (shlex.join properly quotes the `(`/`)`/`*.sqlite`).
"find", "/home/node/.codex", self.assertTrue(
"-maxdepth", "1", any("find" in s and "-delete" in s and "*.sqlite" in s for s in scripts)
"-type", "f",
"(",
"-name", "*.sqlite",
"-o", "-name", "*.sqlite-*",
"-o", "-name", "*.codex-repair-*.bak",
")",
"-delete",
],
argv_seen,
) )
self.assertIn( self.assertTrue(
["chown", "node:node", "/home/node/.codex/auth.json"], any("chown" in s and "/home/node/.codex/auth.json" in s for s in scripts)
argv_seen,
) )
self.assertIn(["chmod", "600", "/home/node/.codex/auth.json"], argv_seen) self.assertTrue(
self.assertIn( any("chmod" in s and "600" in s and "/home/node/.codex/auth.json" in s
[ for s in scripts)
"runuser", "-u", "node", "--", )
"env", # Verify command runs `codex login status` via runuser node.
"HOME=/home/node", self.assertTrue(
"CODEX_HOME=/home/node/.codex", any("runuser" in s and "codex login status" in s for s in scripts)
"codex", "login", "status",
],
argv_seen,
) )
def test_honors_codex_home_from_guest_env(self): def test_honors_codex_home_from_guest_env(self):
cp_p, ex_p = self._patch() bottle = _make_bottle()
with cp_p as cp, ex_p as ex: _provider_auth.provision_provider_auth(
ex.return_value = SmolvmRunResult(0, "Logged in using ChatGPT\n", "") _plan(
agent_provider_template="codex",
codex_auth_file=Path("/tmp/codex-auth.json"),
guest_env={"CODEX_HOME": "/run/codex-home"},
),
bottle,
)
cp_calls = [c.args for c in bottle.cp_in.call_args_list]
self.assertIn(
("/tmp/codex-config.toml", "/run/codex-home/config.toml"),
cp_calls,
)
self.assertIn(
("/tmp/codex-auth.json", "/run/codex-home/auth.json"),
cp_calls,
)
scripts = _exec_scripts(bottle)
self.assertTrue(
any("runuser" in s and "CODEX_HOME=/run/codex-home" in s and "codex login status" in s
for s in scripts)
)
def test_dies_when_codex_home_cannot_be_created(self):
bottle = _make_bottle(
exec_result=ExecResult(1, "", "mkdir: nope\n"),
)
with self.assertRaises(SystemExit):
_provider_auth.provision_provider_auth( _provider_auth.provision_provider_auth(
_plan( _plan(
agent_provider_template="codex", agent_provider_template="codex",
codex_auth_file=Path("/tmp/codex-auth.json"), codex_auth_file=Path("/tmp/codex-auth.json"),
guest_env={"CODEX_HOME": "/run/codex-home"},
), ),
"bot-bottle-demo-abc12", bottle,
) )
cp_calls = [call.args for call in cp.call_args_list] self.assertEqual(0, bottle.cp_in.call_count)
self.assertIn( self.assertEqual(1, bottle.exec.call_count)
("/tmp/codex-config.toml",
"bot-bottle-demo-abc12:/run/codex-home/config.toml"),
cp_calls,
)
self.assertIn(
("/tmp/codex-auth.json",
"bot-bottle-demo-abc12:/run/codex-home/auth.json"),
cp_calls,
)
argv_seen = [call.args[1] for call in ex.call_args_list]
self.assertIn(
[
"runuser", "-u", "node", "--",
"env",
"HOME=/home/node",
"CODEX_HOME=/run/codex-home",
"codex", "login", "status",
],
argv_seen,
)
def test_dies_when_codex_home_cannot_be_created(self):
cp_p, ex_p = self._patch()
with cp_p as cp, ex_p as ex:
ex.return_value = SmolvmRunResult(1, "", "mkdir: nope\n")
with self.assertRaises(SystemExit):
_provider_auth.provision_provider_auth(
_plan(
agent_provider_template="codex",
codex_auth_file=Path("/tmp/codex-auth.json"),
),
"bot-bottle-demo-abc12",
)
self.assertEqual(0, cp.call_count)
self.assertEqual(1, ex.call_count)
def test_dies_when_codex_rejects_dummy_auth(self): def test_dies_when_codex_rejects_dummy_auth(self):
cp_p, ex_p = self._patch() # CODEX_HOME setup ok, but codex login status fails (last exec).
with cp_p, ex_p as ex: bottle = _make_bottle()
# CODEX_HOME setup ok (0), but codex login status fails (1). bottle.exec.side_effect = [
ex.side_effect = [ ExecResult(0, "", ""), # mkdir CODEX_HOME
SmolvmRunResult(0, "", ""), # mkdir CODEX_HOME ExecResult(0, "", ""), # chown CODEX_HOME
SmolvmRunResult(0, "", ""), # chown CODEX_HOME ExecResult(0, "", ""), # chmod CODEX_HOME
SmolvmRunResult(0, "", ""), # chmod CODEX_HOME ExecResult(0, "", ""), # find ... -delete (pre_copy)
SmolvmRunResult(0, "", ""), # reset runtime db files ExecResult(0, "", ""), # chown config.toml
SmolvmRunResult(0, "", ""), # chown config.toml ExecResult(0, "", ""), # chmod config.toml
SmolvmRunResult(0, "", ""), # chmod config.toml ExecResult(0, "", ""), # chown auth.json
SmolvmRunResult(0, "", ""), # chown auth.json ExecResult(0, "", ""), # chmod auth.json
SmolvmRunResult(0, "", ""), # chmod auth.json ExecResult(1, "Not logged in\n", ""), # login status (verify)
SmolvmRunResult(1, "Not logged in\n", ""), # login status ]
] with self.assertRaises(SystemExit):
with self.assertRaises(SystemExit): _provider_auth.provision_provider_auth(
_provider_auth.provision_provider_auth( _plan(
_plan( agent_provider_template="codex",
agent_provider_template="codex", codex_auth_file=Path("/tmp/codex-auth.json"),
codex_auth_file=Path("/tmp/codex-auth.json"), ),
), bottle,
"bot-bottle-demo-abc12", )
)
class TestProvisionSkills(unittest.TestCase): class TestProvisionSkills(unittest.TestCase):
@@ -442,98 +409,69 @@ class TestProvisionSkills(unittest.TestCase):
) )
def test_no_op_when_agent_has_no_skills(self): def test_no_op_when_agent_has_no_skills(self):
with patch( bottle = _make_bottle()
"bot_bottle.backend.smolmachines.provision.skills._smolvm.machine_cp" _skills.provision_skills(_plan(skills=[]), bottle)
) as cp, patch( self.assertEqual(0, bottle.cp_in.call_count)
"bot_bottle.backend.smolmachines.provision.skills._smolvm.machine_exec" self.assertEqual(0, bottle.exec.call_count)
) as ex:
_skills.provision_skills(_plan(skills=[]), "bot-bottle-demo-abc12")
self.assertEqual(0, cp.call_count)
self.assertEqual(0, ex.call_count)
def test_mkdir_plus_cp_per_skill(self): def test_mkdir_plus_cp_per_skill(self):
bottle = _make_bottle()
with self._patch_host_skill_dir({ with self._patch_host_skill_dir({
"init-prd": "/host/skills/init-prd", "init-prd": "/host/skills/init-prd",
"verify": "/host/skills/verify", "verify": "/host/skills/verify",
}), patch( }), patch(
"bot_bottle.backend.smolmachines.provision.skills.os.path.isdir", "bot_bottle.backend.smolmachines.provision.skills.os.path.isdir",
return_value=True, return_value=True,
), patch( ):
"bot_bottle.backend.smolmachines.provision.skills._smolvm.machine_cp"
) as cp, patch(
"bot_bottle.backend.smolmachines.provision.skills._smolvm.machine_exec"
) as ex:
_skills.provision_skills( _skills.provision_skills(
_plan(skills=["init-prd", "verify"]), _plan(skills=["init-prd", "verify"]),
"bot-bottle-demo-abc12", bottle,
) )
# mkdir -p once + (rm -rf + chown) per skill = 5 exec calls. # mkdir skills_dir once + (rm -rf + chown) per skill = 5 exec calls.
self.assertEqual(5, ex.call_count) self.assertEqual(5, bottle.exec.call_count)
mkdir_call = ex.call_args_list[0] scripts = _exec_scripts(bottle)
self.assertEqual( self.assertTrue(
("bot-bottle-demo-abc12", ["mkdir", "-p", "/home/node/.claude/skills"]), any("mkdir -p" in s and "/home/node/.claude/skills" in s for s in scripts)
mkdir_call.args,
) )
# Two cp calls, one per skill, into the per-skill subdir. # Two cp calls, one per skill, into the per-skill subdir.
self.assertEqual(2, cp.call_count) self.assertEqual(2, bottle.cp_in.call_count)
cp_targets = {call.args[1] for call in cp.call_args_list} cp_targets = {call.args[1] for call in bottle.cp_in.call_args_list}
self.assertEqual(
{
"bot-bottle-demo-abc12:/home/node/.claude/skills/init-prd",
"bot-bottle-demo-abc12:/home/node/.claude/skills/verify",
},
cp_targets,
)
# Each skill gets a chown -R node:node so claude can read it.
chown_argvs = [
call.args[1] for call in ex.call_args_list
if call.args[1][:1] == ["chown"]
]
self.assertEqual(2, len(chown_argvs))
chown_targets = {argv[-1] for argv in chown_argvs}
self.assertEqual( self.assertEqual(
{ {
"/home/node/.claude/skills/init-prd", "/home/node/.claude/skills/init-prd",
"/home/node/.claude/skills/verify", "/home/node/.claude/skills/verify",
}, },
chown_targets, cp_targets,
) )
# Each skill gets a chown -R node:node so claude can read it.
chown_scripts = [s for s in scripts if "chown -R node:node" in s]
self.assertEqual(2, len(chown_scripts))
def test_skills_dir_overridable_via_env(self): def test_skills_dir_overridable_via_env(self):
import os import os
bottle = _make_bottle()
with self._patch_host_skill_dir({"init-prd": "/host/skills/init-prd"}), \ with self._patch_host_skill_dir({"init-prd": "/host/skills/init-prd"}), \
patch( patch(
"bot_bottle.backend.smolmachines.provision.skills.os.path.isdir", "bot_bottle.backend.smolmachines.provision.skills.os.path.isdir",
return_value=True, return_value=True,
), \ ), \
patch.dict(os.environ, {"BOT_BOTTLE_GUEST_SKILLS_DIR": "/home/node/.claude/skills"}), \ patch.dict(os.environ, {"BOT_BOTTLE_GUEST_SKILLS_DIR": "/home/node/.claude/skills"}):
patch( _skills.provision_skills(_plan(skills=["init-prd"]), bottle)
"bot_bottle.backend.smolmachines.provision.skills._smolvm.machine_cp"
) as cp, \
patch(
"bot_bottle.backend.smolmachines.provision.skills._smolvm.machine_exec"
):
_skills.provision_skills(_plan(skills=["init-prd"]), "bot-bottle-demo-abc12")
self.assertEqual( self.assertEqual(
"bot-bottle-demo-abc12:/home/node/.claude/skills/init-prd", "/home/node/.claude/skills/init-prd",
cp.call_args.args[1], bottle.cp_in.call_args.args[1],
) )
def test_missing_skill_dies(self): def test_missing_skill_dies(self):
bottle = _make_bottle()
with self._patch_host_skill_dir({"init-prd": "/host/skills/init-prd"}), \ with self._patch_host_skill_dir({"init-prd": "/host/skills/init-prd"}), \
patch( patch(
"bot_bottle.backend.smolmachines.provision.skills.os.path.isdir", "bot_bottle.backend.smolmachines.provision.skills.os.path.isdir",
return_value=False, return_value=False,
), \
patch(
"bot_bottle.backend.smolmachines.provision.skills._smolvm.machine_cp"
), \
patch(
"bot_bottle.backend.smolmachines.provision.skills._smolvm.machine_exec"
): ):
with self.assertRaises(SystemExit): with self.assertRaises(SystemExit):
_skills.provision_skills(_plan(skills=["init-prd"]), "bot-bottle-demo-abc12") _skills.provision_skills(_plan(skills=["init-prd"]), bottle)
def _write_self_signed_cert(path: Path) -> None: def _write_self_signed_cert(path: Path) -> None:
@@ -553,7 +491,7 @@ def _write_self_signed_cert(path: Path) -> None:
class TestProvisionCA(unittest.TestCase): class TestProvisionCA(unittest.TestCase):
"""provision_ca selects the right CA cert (egress when the """provision_ca selects the right CA cert (egress when the
bottle has routes, else pipelock) and dispatches bottle has routes, else pipelock) and dispatches
machine_cp + machine_exec in the right order.""" cp_in + exec in the right order."""
def setUp(self): def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-ca.") self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-ca.")
@@ -566,10 +504,10 @@ class TestProvisionCA(unittest.TestCase):
def tearDown(self): def tearDown(self):
self._tmp.cleanup() self._tmp.cleanup()
# provision_ca dies hard if update-ca-certificates' stdout # provision_ca dies hard if update-ca-certificates' exit
# doesn't include "1 added"; supply a stock success return # is non-zero; supply a stock success return so the bulk of
# so the bulk of the tests below exercise the happy path. # the tests below exercise the happy path.
_UPDATE_OK = SmolvmRunResult( _UPDATE_OK = ExecResult(
returncode=0, returncode=0,
stdout="Updating certificates in /etc/ssl/certs...\n1 added, 0 removed; done.\n", stdout="Updating certificates in /etc/ssl/certs...\n1 added, 0 removed; done.\n",
stderr="", stderr="",
@@ -577,27 +515,20 @@ class TestProvisionCA(unittest.TestCase):
def test_pipelock_path_when_no_routes(self): def test_pipelock_path_when_no_routes(self):
plan = _plan(pipelock_ca_path=self.pipelock_ca) plan = _plan(pipelock_ca_path=self.pipelock_ca)
with patch( bottle = _make_bottle(exec_result=self._UPDATE_OK)
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_cp" _ca.provision_ca(plan, bottle)
) as cp, patch( bottle.cp_in.assert_called_once_with(
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_exec",
return_value=self._UPDATE_OK,
) as ex:
_ca.provision_ca(plan, "bot-bottle-demo-abc12")
cp.assert_called_once_with(
str(self.pipelock_ca), str(self.pipelock_ca),
"bot-bottle-demo-abc12:" + _ca.AGENT_CA_PATH, _ca.AGENT_CA_PATH,
) )
# chmod + chown + update-ca-certificates are now folded # chmod + chown + update-ca-certificates are folded into
# into one `sh -c` invocation (working around a smolvm # one exec invocation; look at the single exec's script
# exec warm-up SIGKILL race), so we look at the single # rather than expecting separate calls.
# exec's argv rather than expecting separate calls. bottle.exec.assert_called_once()
ex.assert_called_once() script = bottle.exec.call_args.args[0]
argv = ex.call_args.args[1] self.assertIn("chmod 644", script)
self.assertEqual("sh", argv[0]) self.assertIn("update-ca-certificates", script)
self.assertEqual("-c", argv[1]) self.assertEqual("root", bottle.exec.call_args.kwargs.get("user"))
self.assertIn("chmod 644", argv[2])
self.assertIn("update-ca-certificates", argv[2])
def test_egress_path_when_routes_declared(self): def test_egress_path_when_routes_declared(self):
plan = _plan( plan = _plan(
@@ -605,51 +536,39 @@ class TestProvisionCA(unittest.TestCase):
egress_ca_path=self.egress_ca, egress_ca_path=self.egress_ca,
pipelock_ca_path=self.pipelock_ca, pipelock_ca_path=self.pipelock_ca,
) )
with patch( bottle = _make_bottle(exec_result=self._UPDATE_OK)
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_cp" _ca.provision_ca(plan, bottle)
) as cp, patch(
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_exec",
return_value=self._UPDATE_OK,
):
_ca.provision_ca(plan, "bot-bottle-demo-abc12")
# When routes are declared, egress is the agent's first hop, # When routes are declared, egress is the agent's first hop,
# so egress's CA is the one that gets installed. # so egress's CA is the one that gets installed.
cp.assert_called_once_with( bottle.cp_in.assert_called_once_with(
str(self.egress_ca), str(self.egress_ca),
"bot-bottle-demo-abc12:" + _ca.AGENT_CA_PATH, _ca.AGENT_CA_PATH,
) )
def test_retries_smolvm_sigkill_during_update_ca(self): def test_retries_smolvm_sigkill_during_update_ca(self):
plan = _plan(pipelock_ca_path=self.pipelock_ca) plan = _plan(pipelock_ca_path=self.pipelock_ca)
killed = SmolvmRunResult( killed = ExecResult(
returncode=137, returncode=137,
stdout="Updating certificates in /etc/ssl/certs...\n", stdout="Updating certificates in /etc/ssl/certs...\n",
stderr="", stderr="",
) )
bottle = _make_bottle()
bottle.exec.side_effect = [killed, self._UPDATE_OK]
with patch( with patch(
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_cp"
), patch(
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_exec",
side_effect=[killed, self._UPDATE_OK],
) as ex, patch(
"bot_bottle.backend.smolmachines.provision.ca.time.sleep" "bot_bottle.backend.smolmachines.provision.ca.time.sleep"
) as sleep: ) as sleep:
_ca.provision_ca(plan, "bot-bottle-demo-abc12") _ca.provision_ca(plan, bottle)
self.assertEqual(2, ex.call_count) self.assertEqual(2, bottle.exec.call_count)
sleep.assert_called_once_with(1.0) sleep.assert_called_once_with(1.0)
def test_dies_when_selected_cert_missing(self): def test_dies_when_selected_cert_missing(self):
# Plan claims a pipelock cert at a path that doesn't exist — # Plan claims a pipelock cert at a path that doesn't exist —
# something went wrong in launch's pipelock_tls_init. # something went wrong in launch's pipelock_tls_init.
plan = _plan(pipelock_ca_path=self.tmp / "does-not-exist.pem") plan = _plan(pipelock_ca_path=self.tmp / "does-not-exist.pem")
with patch( bottle = _make_bottle()
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_cp" with self.assertRaises(SystemExit):
), patch( _ca.provision_ca(plan, bottle)
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_exec"
):
with self.assertRaises(SystemExit):
_ca.provision_ca(plan, "bot-bottle-demo-abc12")
class TestProvisionGit(unittest.TestCase): class TestProvisionGit(unittest.TestCase):
@@ -665,16 +584,10 @@ class TestProvisionGit(unittest.TestCase):
self._tmp.cleanup() self._tmp.cleanup()
def test_noop_when_no_cwd_and_no_git_entries(self): def test_noop_when_no_cwd_and_no_git_entries(self):
with patch( bottle = _make_bottle()
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_cp" _git.provision_git(_plan(stage_dir=self.stage), bottle)
) as cp, patch( bottle.cp_in.assert_not_called()
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec" bottle.exec.assert_not_called()
) as ex:
_git.provision_git(
_plan(stage_dir=self.stage), "bot-bottle-demo-abc12",
)
cp.assert_not_called()
ex.assert_not_called()
def test_copies_cwd_git_when_copy_cwd_and_git_present(self): def test_copies_cwd_git_when_copy_cwd_and_git_present(self):
# Stage a fake host .git dir under user_cwd so the path- # Stage a fake host .git dir under user_cwd so the path-
@@ -684,33 +597,25 @@ class TestProvisionGit(unittest.TestCase):
plan = _plan( plan = _plan(
copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage, copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage,
) )
with patch( bottle = _make_bottle()
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_cp" _git.provision_git(plan, bottle)
) as cp, patch( bottle.cp_in.assert_called_once_with(
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec"
) as ex:
_git.provision_git(plan, "bot-bottle-demo-abc12")
cp.assert_called_once_with(
f"{cwd}/.git", f"{cwd}/.git",
"bot-bottle-demo-abc12:/home/node/workspace/.git", "/home/node/workspace/.git",
) )
argvs = [c.args[1] for c in ex.call_args_list] scripts = _exec_scripts(bottle)
self.assertIn(["mkdir", "-p", "/home/node/workspace"], argvs) self.assertTrue(any("mkdir -p" in s and "/home/node/workspace" in s for s in scripts))
# chown the workspace tree so the agent (node) owns it. # chown the workspace tree so the agent (node) owns it.
self.assertIn( self.assertTrue(
["chown", "-R", "node:node", "/home/node/workspace/.git"], any("chown -R" in s and "node:node" in s and "/home/node/workspace/.git" in s
argvs, for s in scripts)
) )
def test_skips_cwd_when_copy_cwd_false(self): def test_skips_cwd_when_copy_cwd_false(self):
plan = _plan(copy_cwd=False, stage_dir=self.stage) plan = _plan(copy_cwd=False, stage_dir=self.stage)
with patch( bottle = _make_bottle()
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_cp" _git.provision_git(plan, bottle)
) as cp, patch( bottle.cp_in.assert_not_called()
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec"
):
_git.provision_git(plan, "bot-bottle-demo-abc12")
cp.assert_not_called()
def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self): def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self):
# Smolmachines's TSI-allowlisted guest dials git-gate via # Smolmachines's TSI-allowlisted guest dials git-gate via
@@ -726,15 +631,11 @@ class TestProvisionGit(unittest.TestCase):
stage_dir=self.stage, stage_dir=self.stage,
agent_git_gate_host="127.0.0.1:9418", agent_git_gate_host="127.0.0.1:9418",
) )
with patch( bottle = _make_bottle()
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_cp" _git.provision_git(plan, bottle)
) as cp, patch(
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec"
):
_git.provision_git(plan, "bot-bottle-demo-abc12")
# The staged gitconfig path is whatever NamedTemporaryFile # The staged gitconfig path is whatever NamedTemporaryFile
# picked; we read its contents. # picked; we read its contents.
cp_call = cp.call_args cp_call = bottle.cp_in.call_args
staged_path = Path(cp_call.args[0]) staged_path = Path(cp_call.args[0])
self.assertEqual(self.stage, staged_path.parent) self.assertEqual(self.stage, staged_path.parent)
content = staged_path.read_text() content = staged_path.read_text()
@@ -776,71 +677,63 @@ class TestBundleLaunchSpec(unittest.TestCase):
class TestProvisionGitUser(unittest.TestCase): class TestProvisionGitUser(unittest.TestCase):
"""`_provision_git_user` runs `git config --global` inside the """`_provision_git_user` runs `git config --global` inside the
guest as the node user with HOME forced via `smolvm -e` guest as the node user. SmolmachinesBottle.exec sets HOME and
(otherwise --global lands in /root/.gitconfig). No-op when the USER automatically for the requested user, so --global lands
bottle didn't declare git_user (issue #86).""" in /home/node/.gitconfig. No-op when the bottle didn't declare
git_user (issue #86)."""
def _git_config_calls(self, mock_exec): def _git_config_calls(self, bottle: MagicMock) -> list[tuple[str, str]]:
"""Filter machine_exec calls down to git-config invocations, """Filter bottle.exec calls down to git-config invocations,
return list of (argv, env-dict) tuples.""" return list of (script, user) tuples."""
out = [] out = []
for c in mock_exec.call_args_list: for c in bottle.exec.call_args_list:
argv = c.args[1] if len(c.args) > 1 else c.kwargs.get("argv", []) script = c.args[0] if c.args else ""
if "git" in argv and "config" in argv: user = c.kwargs.get("user", "node")
out.append((argv, c.kwargs.get("env") or {})) if "git config" in script:
out.append((script, user))
return out return out
def test_noop_when_no_git_user(self): def test_noop_when_no_git_user(self):
with patch( bottle = _make_bottle()
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec" _git._provision_git_user(_plan(), bottle)
) as ex: self.assertEqual([], self._git_config_calls(bottle))
_git._provision_git_user(_plan(), "bot-bottle-demo-abc12")
self.assertEqual([], self._git_config_calls(ex))
def test_sets_name_and_email_as_node(self): def test_sets_name_and_email_as_node(self):
plan = _plan(git_user={ plan = _plan(git_user={
"name": "Eric Bauerfeld", "name": "Eric Bauerfeld",
"email": "eric@dideric.is", "email": "eric@dideric.is",
}) })
with patch( bottle = _make_bottle()
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec" _git._provision_git_user(plan, bottle)
) as ex: calls = self._git_config_calls(bottle)
_git._provision_git_user(plan, "bot-bottle-demo-abc12")
calls = self._git_config_calls(ex)
self.assertEqual(2, len(calls)) self.assertEqual(2, len(calls))
# Both go through `runuser -u node --` so they run as node; # Both run as node so SmolmachinesBottle.exec sets HOME=/home/node
# HOME is forced via smolvm -e so --global writes to # automatically, ensuring --global writes to /home/node/.gitconfig.
# /home/node/.gitconfig and not /root/.gitconfig. for script, user in calls:
for argv, env in calls: self.assertEqual("node", user)
self.assertEqual( self.assertIn("git config --global", script)
["runuser", "-u", "node", "--", self.assertIn("user.name", calls[0][0])
"git", "config", "--global"], self.assertIn("Eric Bauerfeld", calls[0][0])
argv[:7], self.assertIn("user.email", calls[1][0])
) self.assertIn("eric@dideric.is", calls[1][0])
self.assertEqual("/home/node", env.get("HOME"))
self.assertEqual("node", env.get("USER"))
self.assertEqual(["user.name", "Eric Bauerfeld"], calls[0][0][7:])
self.assertEqual(["user.email", "eric@dideric.is"], calls[1][0][7:])
def test_name_only(self): def test_name_only(self):
plan = _plan(git_user={"name": "Bot"}) plan = _plan(git_user={"name": "Bot"})
with patch( bottle = _make_bottle()
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec" _git._provision_git_user(plan, bottle)
) as ex: calls = self._git_config_calls(bottle)
_git._provision_git_user(plan, "bot-bottle-demo-abc12")
calls = self._git_config_calls(ex)
self.assertEqual(1, len(calls)) self.assertEqual(1, len(calls))
self.assertEqual(["user.name", "Bot"], calls[0][0][7:]) self.assertIn("user.name", calls[0][0])
self.assertIn("Bot", calls[0][0])
def test_email_only(self): def test_email_only(self):
plan = _plan(git_user={"email": "bot@example.com"}) plan = _plan(git_user={"email": "bot@example.com"})
with patch( bottle = _make_bottle()
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec" _git._provision_git_user(plan, bottle)
) as ex: calls = self._git_config_calls(bottle)
_git._provision_git_user(plan, "bot-bottle-demo-abc12")
calls = self._git_config_calls(ex)
self.assertEqual(1, len(calls)) self.assertEqual(1, len(calls))
self.assertEqual(["user.email", "bot@example.com"], calls[0][0][7:]) self.assertIn("user.email", calls[0][0])
self.assertIn("bot@example.com", calls[0][0])
class TestProvisionWorkspace(unittest.TestCase): class TestProvisionWorkspace(unittest.TestCase):
@@ -853,94 +746,68 @@ class TestProvisionWorkspace(unittest.TestCase):
def test_noop_when_copy_cwd_false(self): def test_noop_when_copy_cwd_false(self):
plan = _plan(copy_cwd=False, stage_dir=self.stage) plan = _plan(copy_cwd=False, stage_dir=self.stage)
with patch( bottle = _make_bottle()
"bot_bottle.backend.smolmachines.provision.workspace._smolvm.machine_cp" _workspace.provision_workspace(plan, bottle)
) as cp, patch( bottle.cp_in.assert_not_called()
"bot_bottle.backend.smolmachines.provision.workspace._smolvm.machine_exec" bottle.exec.assert_not_called()
) as ex:
_workspace.provision_workspace(plan, "bot-bottle-demo-abc12")
cp.assert_not_called()
ex.assert_not_called()
def test_copies_workspace_to_plan_path_and_chowns(self): def test_copies_workspace_to_plan_path_and_chowns(self):
cwd = self.stage / "cwd" cwd = self.stage / "cwd"
cwd.mkdir() cwd.mkdir()
plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage) plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage)
with patch( bottle = _make_bottle()
"bot_bottle.backend.smolmachines.provision.workspace._smolvm.machine_cp" _workspace.provision_workspace(plan, bottle)
) as cp, patch(
"bot_bottle.backend.smolmachines.provision.workspace._smolvm.machine_exec"
) as ex:
_workspace.provision_workspace(plan, "bot-bottle-demo-abc12")
cp.assert_called_once_with( bottle.cp_in.assert_called_once_with(
str(cwd), str(cwd),
"bot-bottle-demo-abc12:/home/node/workspace", "/home/node/workspace",
) )
argvs = [c.args[1] for c in ex.call_args_list] scripts = _exec_scripts(bottle)
self.assertIn( self.assertTrue(
["sh", "-c", "rm -rf /home/node/workspace && mkdir -p /home/node"], any("rm -rf /home/node/workspace" in s and "mkdir -p /home/node" in s
argvs, for s in scripts)
) )
self.assertIn( self.assertTrue(
[ any("chown -R node:node /home/node/workspace" in s
"sh", "-c", and "chmod 755 /home/node/workspace" in s
"chown -R node:node /home/node/workspace && " for s in scripts)
"chmod 755 /home/node/workspace",
],
argvs,
) )
class TestProvisionSupervise(unittest.TestCase): class TestProvisionSupervise(unittest.TestCase):
def test_noop_when_supervise_not_enabled(self): def test_noop_when_supervise_not_enabled(self):
with patch( bottle = _make_bottle()
"bot_bottle.backend.smolmachines.provision.supervise._smolvm.machine_exec" _supervise.provision_supervise(_plan(), bottle)
) as ex: bottle.exec.assert_not_called()
_supervise.provision_supervise(_plan(), "bot-bottle-demo-abc12")
ex.assert_not_called()
def test_calls_claude_mcp_add_when_supervise_enabled(self): def test_calls_claude_mcp_add_when_supervise_enabled(self):
plan = _plan( plan = _plan(
supervise=True, supervise=True,
agent_supervise_url="http://127.0.0.1:9100/", agent_supervise_url="http://127.0.0.1:9100/",
) )
with patch( bottle = _make_bottle()
"bot_bottle.backend.smolmachines.provision.supervise._smolvm.machine_exec", _supervise.provision_supervise(plan, bottle)
return_value=SmolvmRunResult(returncode=0, stdout="", stderr=""), bottle.exec.assert_called_once()
) as ex: script = bottle.exec.call_args.args[0]
_supervise.provision_supervise(plan, "bot-bottle-demo-abc12") user = bottle.exec.call_args.kwargs.get("user")
ex.assert_called_once() self.assertEqual("node", user)
argv = ex.call_args.args[1] # SmolmachinesBottle.exec(user="node") handles uid switch +
# `claude mcp add --scope user` writes to ~/.claude.json, # HOME setup automatically — the script itself is just the
# and the agent is the `node` user — switch UID + set # claude command.
# HOME so the config lands in /home/node/.claude.json, self.assertIn("claude mcp add", script)
# not root's. URL is the agent-side endpoint (host self.assertIn("--scope user", script)
# loopback + discovered port), not the docker bridge IP. self.assertIn("--transport http", script)
self.assertEqual( self.assertIn("supervise", script)
[ self.assertIn("http://127.0.0.1:9100/", script)
"runuser", "-u", "node", "--",
"env", "HOME=/home/node",
"claude", "mcp", "add",
"--scope", "user",
"--transport", "http",
"supervise",
"http://127.0.0.1:9100/",
],
argv,
)
def test_non_zero_exit_logs_warning_but_does_not_raise(self): def test_non_zero_exit_logs_warning_but_does_not_raise(self):
plan = _plan(supervise=True) plan = _plan(supervise=True)
with patch( bottle = _make_bottle(
"bot_bottle.backend.smolmachines.provision.supervise._smolvm.machine_exec", exec_result=ExecResult(returncode=1, stdout="", stderr="boom"),
return_value=SmolvmRunResult( )
returncode=1, stdout="", stderr="boom", # No raise — the bottle still works without the MCP
), # entry, so we log and move on.
): _supervise.provision_supervise(plan, bottle)
# No raise — the bottle still works without the MCP
# entry, so we log and move on.
_supervise.provision_supervise(plan, "bot-bottle-demo-abc12")
if __name__ == "__main__": if __name__ == "__main__":
@@ -1,12 +1,12 @@
"""Unit: dashboard headless paths (PRD 0013 phase 4, PRD 0014). """Unit: supervise headless paths (PRD 0013 phase 4, PRD 0014).
The curses TUI itself isn't exercised here — these tests cover the The curses TUI itself isn't exercised here — these tests cover the
discovery + approve/reject + audit-write paths that the TUI's key discovery + approve/reject + audit-write paths that the TUI's key
handlers call into. handlers call into.
apply_routes_change is stubbed at the dashboard module level so the add_route is stubbed at the supervise CLI module level so the tests
tests don't need a running cred-proxy sidecar; the real docker don't need a running egress sidecar; the real docker exec/cp/SIGHUP
exec/cp/SIGHUP plumbing is covered by the integration test. plumbing is covered by the integration test.
""" """
import os import os
@@ -19,7 +19,7 @@ from bot_bottle import supervise
from bot_bottle.backend.docker.capability_apply import CapabilityApplyError from bot_bottle.backend.docker.capability_apply import CapabilityApplyError
from bot_bottle.backend.docker.egress_apply import EgressApplyError from bot_bottle.backend.docker.egress_apply import EgressApplyError
from bot_bottle.backend.docker.pipelock_apply import PipelockApplyError from bot_bottle.backend.docker.pipelock_apply import PipelockApplyError
from bot_bottle.cli import dashboard from bot_bottle.cli import supervise as supervise_cli
from bot_bottle.supervise import ( from bot_bottle.supervise import (
Proposal, Proposal,
STATUS_APPROVED, STATUS_APPROVED,
@@ -61,7 +61,7 @@ class _FakeHomeMixin:
"""Patch supervise.bot_bottle_root to a temp dir for the test.""" """Patch supervise.bot_bottle_root to a temp dir for the test."""
def _setup_fake_home(self): def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="dashboard-test.") self._tmp = tempfile.TemporaryDirectory(prefix="supervise-test.")
original = supervise.bot_bottle_root original = supervise.bot_bottle_root
def fake_root() -> Path: def fake_root() -> Path:
@@ -83,14 +83,14 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
self._teardown_fake_home() self._teardown_fake_home()
def test_empty_when_no_queues(self): def test_empty_when_no_queues(self):
self.assertEqual([], dashboard.discover_pending()) self.assertEqual([], supervise_cli.discover_pending())
def test_walks_all_slug_subdirs(self): def test_walks_all_slug_subdirs(self):
for slug in ("dev", "api"): for slug in ("dev", "api"):
qdir = supervise.queue_dir_for_slug(slug) qdir = supervise.queue_dir_for_slug(slug)
qdir.mkdir(parents=True) qdir.mkdir(parents=True)
supervise.write_proposal(qdir, _proposal(slug=slug)) supervise.write_proposal(qdir, _proposal(slug=slug))
pending = dashboard.discover_pending() pending = supervise_cli.discover_pending()
self.assertEqual({"dev", "api"}, {qp.proposal.bottle_slug for qp in pending}) self.assertEqual({"dev", "api"}, {qp.proposal.bottle_slug for qp in pending})
def test_sorted_by_arrival_across_bottles(self): def test_sorted_by_arrival_across_bottles(self):
@@ -110,7 +110,7 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
qdir = supervise.queue_dir_for_slug(p.bottle_slug) qdir = supervise.queue_dir_for_slug(p.bottle_slug)
qdir.mkdir(parents=True, exist_ok=True) qdir.mkdir(parents=True, exist_ok=True)
supervise.write_proposal(qdir, p) supervise.write_proposal(qdir, p)
pending = dashboard.discover_pending() pending = supervise_cli.discover_pending()
self.assertEqual([early.id, late.id], [qp.proposal.id for qp in pending]) self.assertEqual([early.id, late.id], [qp.proposal.id for qp in pending])
def test_excludes_already_responded(self): def test_excludes_already_responded(self):
@@ -121,34 +121,34 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
supervise.write_response(qdir, supervise.Response( supervise.write_response(qdir, supervise.Response(
proposal_id=p.id, status=STATUS_APPROVED, notes="", proposal_id=p.id, status=STATUS_APPROVED, notes="",
)) ))
self.assertEqual([], dashboard.discover_pending()) self.assertEqual([], supervise_cli.discover_pending())
class TestApproveReject(_FakeHomeMixin, unittest.TestCase): class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def setUp(self): def setUp(self):
self._setup_fake_home() self._setup_fake_home()
self._original_add_route = dashboard.add_route self._original_add_route = supervise_cli.add_route
self._original_apply_allowlist = dashboard.apply_allowlist_change self._original_apply_allowlist = supervise_cli.apply_allowlist_change
self._original_fetch_allowlist = dashboard.fetch_current_allowlist self._original_fetch_allowlist = supervise_cli.fetch_current_allowlist
self._original_apply_capability = dashboard.apply_capability_change self._original_apply_capability = supervise_cli.apply_capability_change
# Default stubs: succeed with deterministic before/after so the # Default stubs: succeed with deterministic before/after so the
# audit log shows a non-empty diff. # audit log shows a non-empty diff.
dashboard.add_route = lambda slug, content: ( supervise_cli.add_route = lambda slug, content: (
'{"routes": []}\n', '{"routes": [{"host": "x"}]}\n', '{"routes": []}\n', '{"routes": [{"host": "x"}]}\n',
) )
dashboard.apply_allowlist_change = lambda slug, content: ( supervise_cli.apply_allowlist_change = lambda slug, content: (
"old.example\n", content, "old.example\n", content,
) )
dashboard.fetch_current_allowlist = lambda slug: "old.example\n" supervise_cli.fetch_current_allowlist = lambda slug: "old.example\n"
dashboard.apply_capability_change = lambda slug, content: ( supervise_cli.apply_capability_change = lambda slug, content: (
"FROM old\n", content, "FROM old\n", content,
) )
def tearDown(self): def tearDown(self):
dashboard.add_route = self._original_add_route supervise_cli.add_route = self._original_add_route
dashboard.apply_allowlist_change = self._original_apply_allowlist supervise_cli.apply_allowlist_change = self._original_apply_allowlist
dashboard.fetch_current_allowlist = self._original_fetch_allowlist supervise_cli.fetch_current_allowlist = self._original_fetch_allowlist
dashboard.apply_capability_change = self._original_apply_capability supervise_cli.apply_capability_change = self._original_apply_capability
self._teardown_fake_home() self._teardown_fake_home()
def _enqueue(self, tool: str = TOOL_EGRESS_BLOCK): def _enqueue(self, tool: str = TOOL_EGRESS_BLOCK):
@@ -156,11 +156,11 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
qdir = supervise.queue_dir_for_slug("dev") qdir = supervise.queue_dir_for_slug("dev")
qdir.mkdir(parents=True, exist_ok=True) qdir.mkdir(parents=True, exist_ok=True)
supervise.write_proposal(qdir, p) supervise.write_proposal(qdir, p)
return dashboard.QueuedProposal(proposal=p, queue_dir=qdir) return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir)
def test_approve_writes_response_and_audit(self): def test_approve_writes_response_and_audit(self):
qp = self._enqueue() qp = self._enqueue()
dashboard.approve(qp) supervise_cli.approve(qp)
resp = read_response(qp.queue_dir, qp.proposal.id) resp = read_response(qp.queue_dir, qp.proposal.id)
self.assertEqual(STATUS_APPROVED, resp.status) self.assertEqual(STATUS_APPROVED, resp.status)
self.assertIsNone(resp.final_file) self.assertIsNone(resp.final_file)
@@ -170,7 +170,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def test_approve_with_final_file_marks_modified(self): def test_approve_with_final_file_marks_modified(self):
qp = self._enqueue() qp = self._enqueue()
dashboard.approve(qp, final_file='{"routes": [{"path": "/x/"}]}\n', notes="tweaked") supervise_cli.approve(qp, final_file='{"routes": [{"path": "/x/"}]}\n', notes="tweaked")
resp = read_response(qp.queue_dir, qp.proposal.id) resp = read_response(qp.queue_dir, qp.proposal.id)
self.assertEqual(STATUS_MODIFIED, resp.status) self.assertEqual(STATUS_MODIFIED, resp.status)
self.assertEqual('{"routes": [{"path": "/x/"}]}\n', resp.final_file) self.assertEqual('{"routes": [{"path": "/x/"}]}\n', resp.final_file)
@@ -180,7 +180,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def test_reject_writes_rejection(self): def test_reject_writes_rejection(self):
qp = self._enqueue() qp = self._enqueue()
dashboard.reject(qp, reason="nope") supervise_cli.reject(qp, reason="nope")
resp = read_response(qp.queue_dir, qp.proposal.id) resp = read_response(qp.queue_dir, qp.proposal.id)
self.assertEqual(STATUS_REJECTED, resp.status) self.assertEqual(STATUS_REJECTED, resp.status)
self.assertEqual("nope", resp.notes) self.assertEqual("nope", resp.notes)
@@ -190,7 +190,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def test_capability_block_skips_audit_log(self): def test_capability_block_skips_audit_log(self):
qp = self._enqueue(tool=TOOL_CAPABILITY_BLOCK) qp = self._enqueue(tool=TOOL_CAPABILITY_BLOCK)
dashboard.approve(qp) supervise_cli.approve(qp)
# No audit log for capability-block (per PRD 0013 / 0016). # No audit log for capability-block (per PRD 0013 / 0016).
# cred-proxy and pipelock logs both empty. # cred-proxy and pipelock logs both empty.
self.assertEqual([], read_audit_entries("egress", "dev")) self.assertEqual([], read_audit_entries("egress", "dev"))
@@ -198,7 +198,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def test_pipelock_audit_distinct_from_egress(self): def test_pipelock_audit_distinct_from_egress(self):
qp = self._enqueue(tool=TOOL_PIPELOCK_BLOCK) qp = self._enqueue(tool=TOOL_PIPELOCK_BLOCK)
dashboard.approve(qp) supervise_cli.approve(qp)
self.assertEqual(1, len(read_audit_entries("pipelock", "dev"))) self.assertEqual(1, len(read_audit_entries("pipelock", "dev")))
self.assertEqual(0, len(read_audit_entries("egress", "dev"))) self.assertEqual(0, len(read_audit_entries("egress", "dev")))
@@ -210,10 +210,10 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase):
def setUp(self): def setUp(self):
self._setup_fake_home() self._setup_fake_home()
self._original_add_route = dashboard.add_route self._original_add_route = supervise_cli.add_route
def tearDown(self): def tearDown(self):
dashboard.add_route = self._original_add_route supervise_cli.add_route = self._original_add_route
self._teardown_fake_home() self._teardown_fake_home()
def _enqueue_egress(self, proposed: str = '{"host": "x.example"}\n'): def _enqueue_egress(self, proposed: str = '{"host": "x.example"}\n'):
@@ -227,17 +227,17 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase):
qdir = supervise.queue_dir_for_slug("dev") qdir = supervise.queue_dir_for_slug("dev")
qdir.mkdir(parents=True, exist_ok=True) qdir.mkdir(parents=True, exist_ok=True)
supervise.write_proposal(qdir, p) supervise.write_proposal(qdir, p)
return dashboard.QueuedProposal(proposal=p, queue_dir=qdir) return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir)
def test_egress_block_calls_add_route_with_proposed_json(self): def test_egress_block_calls_add_route_with_proposed_json(self):
calls = [] calls = []
dashboard.add_route = lambda slug, content: ( supervise_cli.add_route = lambda slug, content: (
calls.append((slug, content)) or ("before", "after") calls.append((slug, content)) or ("before", "after")
) )
qp = self._enqueue_egress( qp = self._enqueue_egress(
proposed='{"host": "new.example", "path_allowlist": ["/x/"]}\n' proposed='{"host": "new.example", "path_allowlist": ["/x/"]}\n'
) )
dashboard.approve(qp) supervise_cli.approve(qp)
self.assertEqual(1, len(calls)) self.assertEqual(1, len(calls))
slug, content = calls[0] slug, content = calls[0]
self.assertEqual("dev", slug) self.assertEqual("dev", slug)
@@ -250,11 +250,11 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase):
def test_modify_passes_final_file_to_add_route(self): def test_modify_passes_final_file_to_add_route(self):
calls = [] calls = []
dashboard.add_route = lambda slug, content: ( supervise_cli.add_route = lambda slug, content: (
calls.append(content) or ("before", "after") calls.append(content) or ("before", "after")
) )
qp = self._enqueue_egress() qp = self._enqueue_egress()
dashboard.approve( supervise_cli.approve(
qp, qp,
final_file='{"host": "edited.example"}\n', final_file='{"host": "edited.example"}\n',
notes="tweaked", notes="tweaked",
@@ -262,12 +262,12 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase):
self.assertEqual(['{"host": "edited.example"}\n'], calls) self.assertEqual(['{"host": "edited.example"}\n'], calls)
def test_apply_failure_blocks_response_and_audit(self): def test_apply_failure_blocks_response_and_audit(self):
dashboard.add_route = lambda slug, content: (_ for _ in ()).throw( supervise_cli.add_route = lambda slug, content: (_ for _ in ()).throw(
EgressApplyError("docker exec failed") EgressApplyError("docker exec failed")
) )
qp = self._enqueue_egress() qp = self._enqueue_egress()
with self.assertRaises(EgressApplyError): with self.assertRaises(EgressApplyError):
dashboard.approve(qp) supervise_cli.approve(qp)
# No response file (proposal stays pending). # No response file (proposal stays pending).
self.assertEqual( self.assertEqual(
[qp.proposal.id], [qp.proposal.id],
@@ -277,25 +277,20 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase):
self.assertEqual([], read_audit_entries("egress", "dev")) self.assertEqual([], read_audit_entries("egress", "dev"))
def test_real_diff_lands_in_audit(self): def test_real_diff_lands_in_audit(self):
dashboard.add_route = lambda slug, content: ( supervise_cli.add_route = lambda slug, content: (
'{"routes": []}\n', # before '{"routes": []}\n', # before
'{"routes": [{"host": "new.example"}]}\n', # after '{"routes": [{"host": "new.example"}]}\n', # after
) )
qp = self._enqueue_egress(proposed='{"host": "new.example"}\n') qp = self._enqueue_egress(proposed='{"host": "new.example"}\n')
dashboard.approve(qp) supervise_cli.approve(qp)
entries = read_audit_entries("egress", "dev") entries = read_audit_entries("egress", "dev")
self.assertEqual(1, len(entries)) self.assertEqual(1, len(entries))
self.assertIn('+{"routes": [{"host": "new.example"}]}', entries[0].diff) self.assertIn('+{"routes": [{"host": "new.example"}]}', entries[0].diff)
self.assertIn('-{"routes": []}', entries[0].diff) self.assertIn('-{"routes": []}', entries[0].diff)
def test_reject_does_not_call_apply(self): def test_reject_does_not_call_apply(self):
called = []
dashboard.apply_routes_change = lambda slug, content: (
called.append(True) or ("", content)
)
qp = self._enqueue_egress() qp = self._enqueue_egress()
dashboard.reject(qp, reason="no thanks") supervise_cli.reject(qp, reason="no thanks")
self.assertEqual([], called)
# Reject still writes a response + audit entry with empty diff. # Reject still writes a response + audit entry with empty diff.
resp = read_response(qp.queue_dir, qp.proposal.id) resp = read_response(qp.queue_dir, qp.proposal.id)
self.assertEqual(STATUS_REJECTED, resp.status) self.assertEqual(STATUS_REJECTED, resp.status)
@@ -306,18 +301,18 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase):
class TestPipelockApplyWiring(_FakeHomeMixin, unittest.TestCase): class TestPipelockApplyWiring(_FakeHomeMixin, unittest.TestCase):
"""PRD 0015 Phase 2 + PR #25 follow-up: approve() on a """PRD 0015 Phase 2 + PR #25 follow-up: approve() on a
pipelock-block proposal carries the failed URL; the dashboard pipelock-block proposal carries the failed URL; the supervise TUI
extracts the host, merges it into the running allowlist, and extracts the host, merges it into the running allowlist, and
calls apply_allowlist_change with the merged content.""" calls apply_allowlist_change with the merged content."""
def setUp(self): def setUp(self):
self._setup_fake_home() self._setup_fake_home()
self._original_apply = dashboard.apply_allowlist_change self._original_apply = supervise_cli.apply_allowlist_change
self._original_fetch = dashboard.fetch_current_allowlist self._original_fetch = supervise_cli.fetch_current_allowlist
def tearDown(self): def tearDown(self):
dashboard.apply_allowlist_change = self._original_apply supervise_cli.apply_allowlist_change = self._original_apply
dashboard.fetch_current_allowlist = self._original_fetch supervise_cli.fetch_current_allowlist = self._original_fetch
self._teardown_fake_home() self._teardown_fake_home()
def _enqueue_pipelock(self, failed_url: str = "https://api.github.com/repos/foo/bar"): def _enqueue_pipelock(self, failed_url: str = "https://api.github.com/repos/foo/bar"):
@@ -331,17 +326,17 @@ class TestPipelockApplyWiring(_FakeHomeMixin, unittest.TestCase):
qdir = supervise.queue_dir_for_slug("dev") qdir = supervise.queue_dir_for_slug("dev")
qdir.mkdir(parents=True, exist_ok=True) qdir.mkdir(parents=True, exist_ok=True)
supervise.write_proposal(qdir, p) supervise.write_proposal(qdir, p)
return dashboard.QueuedProposal(proposal=p, queue_dir=qdir) return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir)
def test_url_host_merged_into_current_allowlist(self): def test_url_host_merged_into_current_allowlist(self):
dashboard.fetch_current_allowlist = lambda slug: "existing.example\n" supervise_cli.fetch_current_allowlist = lambda slug: "existing.example\n"
applied = [] applied = []
dashboard.apply_allowlist_change = lambda slug, content: ( supervise_cli.apply_allowlist_change = lambda slug, content: (
applied.append((slug, content)) applied.append((slug, content))
or ("existing.example\n", content) or ("existing.example\n", content)
) )
qp = self._enqueue_pipelock("https://api.github.com/repos/foo/bar") qp = self._enqueue_pipelock("https://api.github.com/repos/foo/bar")
dashboard.approve(qp) supervise_cli.approve(qp)
# apply_allowlist_change was called with the merged content: # apply_allowlist_change was called with the merged content:
# existing host + the URL's host (no path, since pipelock is # existing host + the URL's host (no path, since pipelock is
# hostname-only). # hostname-only).
@@ -353,27 +348,27 @@ class TestPipelockApplyWiring(_FakeHomeMixin, unittest.TestCase):
self.assertNotIn("/repos/foo/bar", content) # path stripped self.assertNotIn("/repos/foo/bar", content) # path stripped
def test_host_already_in_allowlist_is_idempotent(self): def test_host_already_in_allowlist_is_idempotent(self):
dashboard.fetch_current_allowlist = lambda slug: "api.github.com\n" supervise_cli.fetch_current_allowlist = lambda slug: "api.github.com\n"
applied = [] applied = []
dashboard.apply_allowlist_change = lambda slug, content: ( supervise_cli.apply_allowlist_change = lambda slug, content: (
applied.append(content) applied.append(content)
or ("api.github.com\n", content) or ("api.github.com\n", content)
) )
qp = self._enqueue_pipelock("https://api.github.com/some/path") qp = self._enqueue_pipelock("https://api.github.com/some/path")
dashboard.approve(qp) supervise_cli.approve(qp)
# Still applied, but the content is unchanged from current — # Still applied, but the content is unchanged from current —
# before/after diff is empty. # before/after diff is empty.
self.assertEqual(1, len(applied)) self.assertEqual(1, len(applied))
self.assertEqual("api.github.com\n", applied[0]) self.assertEqual("api.github.com\n", applied[0])
def test_apply_failure_blocks_response_and_audit(self): def test_apply_failure_blocks_response_and_audit(self):
dashboard.fetch_current_allowlist = lambda slug: "existing.example\n" supervise_cli.fetch_current_allowlist = lambda slug: "existing.example\n"
dashboard.apply_allowlist_change = lambda slug, content: (_ for _ in ()).throw( supervise_cli.apply_allowlist_change = lambda slug, content: (_ for _ in ()).throw(
PipelockApplyError("docker exec failed") PipelockApplyError("docker exec failed")
) )
qp = self._enqueue_pipelock() qp = self._enqueue_pipelock()
with self.assertRaises(PipelockApplyError): with self.assertRaises(PipelockApplyError):
dashboard.approve(qp) supervise_cli.approve(qp)
self.assertEqual( self.assertEqual(
[qp.proposal.id], [qp.proposal.id],
[p.id for p in supervise.list_pending_proposals(qp.queue_dir)], [p.id for p in supervise.list_pending_proposals(qp.queue_dir)],
@@ -381,12 +376,12 @@ class TestPipelockApplyWiring(_FakeHomeMixin, unittest.TestCase):
self.assertEqual([], read_audit_entries("pipelock", "dev")) self.assertEqual([], read_audit_entries("pipelock", "dev"))
def test_url_without_host_raises(self): def test_url_without_host_raises(self):
dashboard.fetch_current_allowlist = lambda slug: "" supervise_cli.fetch_current_allowlist = lambda slug: ""
# supervise_server's validator would catch this; if a broken # supervise_server's validator would catch this; if a broken
# URL ever makes it through, the dashboard surfaces it too. # URL ever makes it through, the supervise TUI surfaces it too.
qp = self._enqueue_pipelock("https:///nohost") qp = self._enqueue_pipelock("https:///nohost")
with self.assertRaises(PipelockApplyError): with self.assertRaises(PipelockApplyError):
dashboard.approve(qp) supervise_cli.approve(qp)
class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase): class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
@@ -397,10 +392,10 @@ class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
def setUp(self): def setUp(self):
self._setup_fake_home() self._setup_fake_home()
self._original = dashboard.apply_capability_change self._original = supervise_cli.apply_capability_change
def tearDown(self): def tearDown(self):
dashboard.apply_capability_change = self._original supervise_cli.apply_capability_change = self._original
self._teardown_fake_home() self._teardown_fake_home()
def _enqueue_capability(self, proposed: str = "FROM python:3.13\nRUN apk add ripgrep\n"): def _enqueue_capability(self, proposed: str = "FROM python:3.13\nRUN apk add ripgrep\n"):
@@ -414,112 +409,50 @@ class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
qdir = supervise.queue_dir_for_slug("dev") qdir = supervise.queue_dir_for_slug("dev")
qdir.mkdir(parents=True, exist_ok=True) qdir.mkdir(parents=True, exist_ok=True)
supervise.write_proposal(qdir, p) supervise.write_proposal(qdir, p)
return dashboard.QueuedProposal(proposal=p, queue_dir=qdir) return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir)
def test_capability_block_calls_apply_with_proposed_file(self): def test_capability_block_calls_apply_with_proposed_file(self):
calls = [] calls = []
dashboard.apply_capability_change = lambda slug, content: ( supervise_cli.apply_capability_change = lambda slug, content: (
calls.append((slug, content)) or ("FROM old\n", content) calls.append((slug, content)) or ("FROM old\n", content)
) )
qp = self._enqueue_capability("FROM bookworm\n") qp = self._enqueue_capability("FROM bookworm\n")
dashboard.approve(qp) supervise_cli.approve(qp)
self.assertEqual([("dev", "FROM bookworm\n")], calls) self.assertEqual([("dev", "FROM bookworm\n")], calls)
def test_apply_failure_blocks_response_and_keeps_pending(self): def test_apply_failure_blocks_response_and_keeps_pending(self):
dashboard.apply_capability_change = lambda slug, content: (_ for _ in ()).throw( supervise_cli.apply_capability_change = lambda slug, content: (_ for _ in ()).throw(
CapabilityApplyError("teardown failed") CapabilityApplyError("teardown failed")
) )
qp = self._enqueue_capability() qp = self._enqueue_capability()
with self.assertRaises(CapabilityApplyError): with self.assertRaises(CapabilityApplyError):
dashboard.approve(qp) supervise_cli.approve(qp)
self.assertEqual( self.assertEqual(
[qp.proposal.id], [qp.proposal.id],
[p.id for p in supervise.list_pending_proposals(qp.queue_dir)], [p.id for p in supervise.list_pending_proposals(qp.queue_dir)],
) )
def test_no_audit_log_for_capability(self): def test_no_audit_log_for_capability(self):
dashboard.apply_capability_change = lambda slug, content: ("FROM old\n", content) supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content)
qp = self._enqueue_capability() qp = self._enqueue_capability()
dashboard.approve(qp) supervise_cli.approve(qp)
# capability-block has no audit log per PRD 0013 — its record # capability-block has no audit log per PRD 0013 — its record
# lives in the per-bottle Dockerfile + transcript state. # lives in the per-bottle Dockerfile + transcript state.
self.assertEqual([], read_audit_entries("egress", "dev")) self.assertEqual([], read_audit_entries("egress", "dev"))
self.assertEqual([], read_audit_entries("pipelock", "dev")) self.assertEqual([], read_audit_entries("pipelock", "dev"))
def test_proposal_archived_after_apply(self): def test_proposal_archived_after_apply(self):
dashboard.apply_capability_change = lambda slug, content: ("FROM old\n", content) supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content)
qp = self._enqueue_capability() qp = self._enqueue_capability()
dashboard.approve(qp) supervise_cli.approve(qp)
# Sidecar would normally archive after delivering the response, # Sidecar would normally archive after delivering the response,
# but it's gone by then. The dashboard archives so # but it's gone by then. The supervise TUI archives so
# discover_pending stops surfacing the resolved proposal. # discover_pending stops surfacing the resolved proposal.
self.assertEqual([], supervise.list_pending_proposals(qp.queue_dir)) self.assertEqual([], supervise.list_pending_proposals(qp.queue_dir))
processed = list((qp.queue_dir / "processed").glob("*.json")) processed = list((qp.queue_dir / "processed").glob("*.json"))
self.assertEqual(2, len(processed)) self.assertEqual(2, len(processed))
class TestOperatorEditRoutes(_FakeHomeMixin, unittest.TestCase):
"""PRD 0014 Phase 4: operator-initiated routes edit (not gated
on a pending proposal)."""
def setUp(self):
self._setup_fake_home()
self._original_apply = dashboard.apply_routes_change
def tearDown(self):
dashboard.apply_routes_change = self._original_apply
self._teardown_fake_home()
def test_writes_audit_with_operator_edit_action(self):
dashboard.apply_routes_change = lambda slug, content: (
'{"routes": []}\n', content,
)
dashboard.operator_edit_routes("dev", '{"routes": [{"path": "/x/"}]}\n')
entries = read_audit_entries("egress", "dev")
self.assertEqual(1, len(entries))
self.assertEqual(supervise.ACTION_OPERATOR_EDIT, entries[0].operator_action)
self.assertEqual("", entries[0].justification)
self.assertIn("+", entries[0].diff)
def test_failure_does_not_write_audit(self):
dashboard.apply_routes_change = lambda slug, content: (_ for _ in ()).throw(
EgressApplyError("nope")
)
with self.assertRaises(EgressApplyError):
dashboard.operator_edit_routes("dev", '{"routes": []}\n')
self.assertEqual([], read_audit_entries("egress", "dev"))
class TestOperatorEditAllowlist(_FakeHomeMixin, unittest.TestCase):
"""PRD 0015 Phase 3: operator-initiated pipelock allowlist edit."""
def setUp(self):
self._setup_fake_home()
self._original = dashboard.apply_allowlist_change
def tearDown(self):
dashboard.apply_allowlist_change = self._original
self._teardown_fake_home()
def test_writes_audit_with_operator_edit_action(self):
dashboard.apply_allowlist_change = lambda slug, content: (
"old.example\n", content,
)
dashboard.operator_edit_allowlist("dev", "old.example\nnew.example\n")
entries = read_audit_entries("pipelock", "dev")
self.assertEqual(1, len(entries))
self.assertEqual(supervise.ACTION_OPERATOR_EDIT, entries[0].operator_action)
self.assertIn("+new.example", entries[0].diff)
def test_failure_does_not_write_audit(self):
dashboard.apply_allowlist_change = lambda slug, content: (_ for _ in ()).throw(
PipelockApplyError("nope")
)
with self.assertRaises(PipelockApplyError):
dashboard.operator_edit_allowlist("dev", "x.example\n")
self.assertEqual([], read_audit_entries("pipelock", "dev"))
class TestEditInEditor(unittest.TestCase): class TestEditInEditor(unittest.TestCase):
def test_runs_editor_returns_edited_content(self): def test_runs_editor_returns_edited_content(self):
# Fake "editor" is /bin/sh -c 'cat <<EOF > $1 ... EOF' # Fake "editor" is /bin/sh -c 'cat <<EOF > $1 ... EOF'
@@ -544,7 +477,7 @@ class TestEditInEditor(unittest.TestCase):
os.chmod(editor_script, 0o755) os.chmod(editor_script, 0o755)
os.environ["EDITOR"] = editor_script os.environ["EDITOR"] = editor_script
try: try:
result = dashboard.edit_in_editor("original") result = supervise_cli.edit_in_editor("original")
self.assertEqual("edited", result) self.assertEqual("edited", result)
finally: finally:
os.unlink(editor_script) os.unlink(editor_script)
@@ -566,7 +499,7 @@ class TestEditInEditor(unittest.TestCase):
os.chmod(editor_script, 0o755) os.chmod(editor_script, 0o755)
os.environ["EDITOR"] = editor_script os.environ["EDITOR"] = editor_script
try: try:
result = dashboard.edit_in_editor("original") result = supervise_cli.edit_in_editor("original")
self.assertIsNone(result) self.assertIsNone(result)
finally: finally:
os.unlink(editor_script) os.unlink(editor_script)
@@ -583,19 +516,19 @@ class TestCapabilityBlockSmolmachinesGuard(_FakeHomeMixin, unittest.TestCase):
def setUp(self): def setUp(self):
self._setup_fake_home() self._setup_fake_home()
self._original_apply_capability = dashboard.apply_capability_change self._original_apply_capability = supervise_cli.apply_capability_change
dashboard.apply_capability_change = lambda slug, content: ("", content) supervise_cli.apply_capability_change = lambda slug, content: ("", content)
def tearDown(self): def tearDown(self):
dashboard.apply_capability_change = self._original_apply_capability supervise_cli.apply_capability_change = self._original_apply_capability
self._teardown_fake_home() self._teardown_fake_home()
def _enqueue_capability(self, slug: str = "dev") -> "dashboard.QueuedProposal": def _enqueue_capability(self, slug: str = "dev") -> "supervise_cli.QueuedProposal":
p = _proposal(slug=slug, tool=TOOL_CAPABILITY_BLOCK) p = _proposal(slug=slug, tool=TOOL_CAPABILITY_BLOCK)
qdir = supervise.queue_dir_for_slug(slug) qdir = supervise.queue_dir_for_slug(slug)
qdir.mkdir(parents=True, exist_ok=True) qdir.mkdir(parents=True, exist_ok=True)
supervise.write_proposal(qdir, p) supervise.write_proposal(qdir, p)
return dashboard.QueuedProposal(proposal=p, queue_dir=qdir) return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir)
def _write_metadata(self, slug: str, compose_project: str) -> None: def _write_metadata(self, slug: str, compose_project: str) -> None:
from bot_bottle.backend.docker.bottle_state import BottleMetadata, write_metadata from bot_bottle.backend.docker.bottle_state import BottleMetadata, write_metadata
@@ -612,18 +545,18 @@ class TestCapabilityBlockSmolmachinesGuard(_FakeHomeMixin, unittest.TestCase):
self._write_metadata("dev", compose_project="") self._write_metadata("dev", compose_project="")
qp = self._enqueue_capability("dev") qp = self._enqueue_capability("dev")
with self.assertRaises(CapabilityApplyError) as ctx: with self.assertRaises(CapabilityApplyError) as ctx:
dashboard.approve(qp) supervise_cli.approve(qp)
self.assertIn("smolmachines", str(ctx.exception)) self.assertIn("smolmachines", str(ctx.exception))
def test_docker_bottle_calls_apply_capability_change(self): def test_docker_bottle_calls_apply_capability_change(self):
self._write_metadata("dev", compose_project="bot-bottle-dev") self._write_metadata("dev", compose_project="bot-bottle-dev")
qp = self._enqueue_capability("dev") qp = self._enqueue_capability("dev")
dashboard.approve(qp) # must not raise supervise_cli.approve(qp) # must not raise
def test_no_metadata_falls_through_to_docker_path(self): def test_no_metadata_falls_through_to_docker_path(self):
# No metadata at all → assume Docker (backward-compatible). # No metadata at all → assume Docker (backward-compatible).
qp = self._enqueue_capability("dev") qp = self._enqueue_capability("dev")
dashboard.approve(qp) # must not raise supervise_cli.approve(qp) # must not raise
if __name__ == "__main__": if __name__ == "__main__":
@@ -1,6 +1,6 @@
"""Unit: dashboard launch/crash failure logging (issue #100). """Unit: supervise launch/crash failure logging (issue #100).
The dashboard runs under curses, so anything written to stderr while the The supervise TUI runs under curses, so anything written to stderr while the
TUI owns the terminal is wiped when the terminal is restored. These TUI owns the terminal is wiped when the terminal is restored. These
tests lock the recovery paths: a config error (`Die`) is re-surfaced tests lock the recovery paths: a config error (`Die`) is re-surfaced
after the wrapper returns, and an unexpected crash is persisted to a after the wrapper returns, and an unexpected crash is persisted to a
@@ -17,7 +17,7 @@ from pathlib import Path
from unittest import mock from unittest import mock
from bot_bottle import supervise from bot_bottle import supervise
from bot_bottle.cli import dashboard from bot_bottle.cli import supervise as supervise_cli
from bot_bottle.log import Die, die from bot_bottle.log import Die, die
@@ -44,7 +44,7 @@ class _FakeHomeMixin:
~/.bot-bottle.""" ~/.bot-bottle."""
def _setup_fake_home(self): def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="dash-crash-test.") self._tmp = tempfile.TemporaryDirectory(prefix="supervise-crash-test.")
self._orig_root = supervise.bot_bottle_root self._orig_root = supervise.bot_bottle_root
self._root = Path(self._tmp.name) / ".bot-bottle" self._root = Path(self._tmp.name) / ".bot-bottle"
supervise.bot_bottle_root = lambda: self._root # type: ignore[assignment] supervise.bot_bottle_root = lambda: self._root # type: ignore[assignment]
@@ -54,7 +54,7 @@ class _FakeHomeMixin:
self._tmp.cleanup() self._tmp.cleanup()
class TestCmdDashboardErrorPaths(_FakeHomeMixin, unittest.TestCase): class TestCmdSuperviseErrorPaths(_FakeHomeMixin, unittest.TestCase):
def setUp(self): def setUp(self):
self._setup_fake_home() self._setup_fake_home()
@@ -63,42 +63,42 @@ class TestCmdDashboardErrorPaths(_FakeHomeMixin, unittest.TestCase):
def test_keyboard_interrupt_returns_130(self): def test_keyboard_interrupt_returns_130(self):
with mock.patch.object( with mock.patch.object(
dashboard.curses, "wrapper", side_effect=KeyboardInterrupt supervise_cli.curses, "wrapper", side_effect=KeyboardInterrupt
): ):
self.assertEqual(130, dashboard.cmd_dashboard([])) self.assertEqual(130, supervise_cli.cmd_supervise([]))
def test_die_resurfaces_message_after_curses(self): def test_die_resurfaces_message_after_curses(self):
buf = io.StringIO() buf = io.StringIO()
with mock.patch.object( with mock.patch.object(
dashboard.curses, "wrapper", supervise_cli.curses, "wrapper",
side_effect=Die(1, "manifest parse error at line 3"), side_effect=Die(1, "manifest parse error at line 3"),
): ):
with contextlib.redirect_stderr(buf): with contextlib.redirect_stderr(buf):
rc = dashboard.cmd_dashboard([]) rc = supervise_cli.cmd_supervise([])
self.assertEqual(1, rc) self.assertEqual(1, rc)
self.assertIn("manifest parse error at line 3", buf.getvalue()) self.assertIn("manifest parse error at line 3", buf.getvalue())
def test_die_without_message_has_fallback(self): def test_die_without_message_has_fallback(self):
buf = io.StringIO() buf = io.StringIO()
with mock.patch.object(dashboard.curses, "wrapper", side_effect=Die(1)): with mock.patch.object(supervise_cli.curses, "wrapper", side_effect=Die(1)):
with contextlib.redirect_stderr(buf): with contextlib.redirect_stderr(buf):
rc = dashboard.cmd_dashboard([]) rc = supervise_cli.cmd_supervise([])
self.assertEqual(1, rc) self.assertEqual(1, rc)
self.assertIn("fatal error", buf.getvalue()) self.assertIn("fatal error", buf.getvalue())
def test_unexpected_exception_writes_crash_log(self): def test_unexpected_exception_writes_crash_log(self):
buf = io.StringIO() buf = io.StringIO()
with mock.patch.object( with mock.patch.object(
dashboard.curses, "wrapper", supervise_cli.curses, "wrapper",
side_effect=ValueError("kaboom in render"), side_effect=ValueError("kaboom in render"),
): ):
with contextlib.redirect_stderr(buf): with contextlib.redirect_stderr(buf):
rc = dashboard.cmd_dashboard([]) rc = supervise_cli.cmd_supervise([])
self.assertEqual(1, rc) self.assertEqual(1, rc)
out = buf.getvalue() out = buf.getvalue()
self.assertIn("dashboard crashed: ValueError: kaboom in render", out) self.assertIn("supervise crashed: ValueError: kaboom in render", out)
self.assertIn("full traceback written to", out) self.assertIn("full traceback written to", out)
log_path = self._root / "logs" / "dashboard-crash.log" log_path = self._root / "logs" / "supervise-crash.log"
self.assertTrue(log_path.exists()) self.assertTrue(log_path.exists())
content = log_path.read_text() content = log_path.read_text()
self.assertIn("kaboom in render", content) self.assertIn("kaboom in render", content)
@@ -116,10 +116,10 @@ class TestWriteCrashLog(_FakeHomeMixin, unittest.TestCase):
try: try:
raise RuntimeError("explode") raise RuntimeError("explode")
except RuntimeError as e: except RuntimeError as e:
path = dashboard._write_crash_log(e) path = supervise_cli._write_crash_log(e)
self.assertEqual(self._root / "logs" / "dashboard-crash.log", path) self.assertEqual(self._root / "logs" / "supervise-crash.log", path)
text = path.read_text() text = path.read_text()
self.assertIn("=== dashboard crash", text) self.assertIn("=== supervise crash", text)
self.assertIn("RuntimeError: explode", text) self.assertIn("RuntimeError: explode", text)
def test_falls_back_to_tempfile_when_home_unwritable(self): def test_falls_back_to_tempfile_when_home_unwritable(self):
@@ -131,7 +131,7 @@ class TestWriteCrashLog(_FakeHomeMixin, unittest.TestCase):
try: try:
raise RuntimeError("explode2") raise RuntimeError("explode2")
except RuntimeError as e: except RuntimeError as e:
path = dashboard._write_crash_log(e) path = supervise_cli._write_crash_log(e)
self.assertTrue(path.exists()) self.assertTrue(path.exists())
self.assertIn("explode2", path.read_text()) self.assertIn("explode2", path.read_text())
@@ -1,4 +1,4 @@
"""Unit: dashboard's detail-view line builder. """Unit: supervise's detail-view line builder.
_detail_lines returns (text, attr) tuples. Most are plain; for _detail_lines returns (text, attr) tuples. Most are plain; for
pipelock-block proposals it appends a "→ would allow host: <host>" pipelock-block proposals it appends a "→ would allow host: <host>"
@@ -8,7 +8,7 @@ which hostname will land in pipelock's allowlist on approval."""
import unittest import unittest
from bot_bottle import supervise from bot_bottle import supervise
from bot_bottle.cli import dashboard from bot_bottle.cli import supervise as supervise_cli
from bot_bottle.supervise import ( from bot_bottle.supervise import (
Proposal, Proposal,
TOOL_CAPABILITY_BLOCK, TOOL_CAPABILITY_BLOCK,
@@ -18,7 +18,7 @@ from bot_bottle.supervise import (
) )
def _qp(tool: str, payload: str) -> dashboard.QueuedProposal: def _qp(tool: str, payload: str) -> supervise_cli.QueuedProposal:
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
p = Proposal.new( p = Proposal.new(
@@ -29,14 +29,14 @@ def _qp(tool: str, payload: str) -> dashboard.QueuedProposal:
current_file_hash=sha256_hex(payload), current_file_hash=sha256_hex(payload),
now=datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc), now=datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc),
) )
return dashboard.QueuedProposal(proposal=p, queue_dir=Path("/tmp/q")) return supervise_cli.QueuedProposal(proposal=p, queue_dir=Path("/tmp/q"))
class TestPipelockHostHighlight(unittest.TestCase): class TestPipelockHostHighlight(unittest.TestCase):
GREEN = 0xDEADBEEF # arbitrary sentinel; _detail_lines passes through GREEN = 0xDEADBEEF # arbitrary sentinel; _detail_lines passes through
def test_appends_green_host_line_for_pipelock_block(self): def test_appends_green_host_line_for_pipelock_block(self):
lines = dashboard._detail_lines( lines = supervise_cli._detail_lines(
_qp(TOOL_PIPELOCK_BLOCK, "https://api.github.com/repos/foo/bar"), _qp(TOOL_PIPELOCK_BLOCK, "https://api.github.com/repos/foo/bar"),
green_attr=self.GREEN, green_attr=self.GREEN,
) )
@@ -47,14 +47,14 @@ class TestPipelockHostHighlight(unittest.TestCase):
self.assertEqual(["api.github.com"], green_lines) self.assertEqual(["api.github.com"], green_lines)
def test_no_green_lines_for_egress_block(self): def test_no_green_lines_for_egress_block(self):
lines = dashboard._detail_lines( lines = supervise_cli._detail_lines(
_qp(TOOL_EGRESS_BLOCK, '{"routes": []}'), _qp(TOOL_EGRESS_BLOCK, '{"routes": []}'),
green_attr=self.GREEN, green_attr=self.GREEN,
) )
self.assertEqual([], [t for t, a in lines if a == self.GREEN]) self.assertEqual([], [t for t, a in lines if a == self.GREEN])
def test_no_green_lines_for_capability_block(self): def test_no_green_lines_for_capability_block(self):
lines = dashboard._detail_lines( lines = supervise_cli._detail_lines(
_qp(TOOL_CAPABILITY_BLOCK, "FROM python:3.13\n"), _qp(TOOL_CAPABILITY_BLOCK, "FROM python:3.13\n"),
green_attr=self.GREEN, green_attr=self.GREEN,
) )
@@ -63,8 +63,8 @@ class TestPipelockHostHighlight(unittest.TestCase):
def test_skips_host_line_when_url_unparseable(self): def test_skips_host_line_when_url_unparseable(self):
# Shouldn't happen in production — supervise_server validates # Shouldn't happen in production — supervise_server validates
# the URL before queuing — but if a malformed payload ever # the URL before queuing — but if a malformed payload ever
# reaches the dashboard, don't render a misleading host line. # reaches the supervise TUI, don't render a misleading host line.
lines = dashboard._detail_lines( lines = supervise_cli._detail_lines(
_qp(TOOL_PIPELOCK_BLOCK, "garbage-not-a-url"), _qp(TOOL_PIPELOCK_BLOCK, "garbage-not-a-url"),
green_attr=self.GREEN, green_attr=self.GREEN,
) )
@@ -73,7 +73,7 @@ class TestPipelockHostHighlight(unittest.TestCase):
def test_no_green_attr_passed_still_renders_host(self): def test_no_green_attr_passed_still_renders_host(self):
# Even without color support (green_attr=0), the host line # Even without color support (green_attr=0), the host line
# is still present — it just won't be coloured. # is still present — it just won't be coloured.
lines = dashboard._detail_lines( lines = supervise_cli._detail_lines(
_qp(TOOL_PIPELOCK_BLOCK, "https://api.github.com/x"), _qp(TOOL_PIPELOCK_BLOCK, "https://api.github.com/x"),
green_attr=0, green_attr=0,
) )
@@ -86,14 +86,14 @@ class TestFailedUrlHost(unittest.TestCase):
def test_extracts_hostname(self): def test_extracts_hostname(self):
self.assertEqual( self.assertEqual(
"api.github.com", "api.github.com",
dashboard._failed_url_host("https://api.github.com/repos/foo"), supervise_cli._failed_url_host("https://api.github.com/repos/foo"),
) )
def test_returns_empty_for_unparseable(self): def test_returns_empty_for_unparseable(self):
self.assertEqual("", dashboard._failed_url_host("not a url")) self.assertEqual("", supervise_cli._failed_url_host("not a url"))
def test_returns_empty_for_url_without_host(self): def test_returns_empty_for_url_without_host(self):
self.assertEqual("", dashboard._failed_url_host("https:///nohost")) self.assertEqual("", supervise_cli._failed_url_host("https:///nohost"))
if __name__ == "__main__": if __name__ == "__main__":