Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3997a0a721 | |||
| ea66f63d45 | |||
| 83db7336c8 | |||
| bcdffc8400 | |||
| f44751c4b8 | |||
| 3d557beeee | |||
| 44365ecf68 | |||
| 703b12ee9a | |||
| d1556f4659 | |||
| 06eed5b236 | |||
| 98e4e2b7dc | |||
| 9eca46b408 | |||
| 0efc07ba67 |
@@ -6,96 +6,26 @@
|
||||
|
||||
[](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.
|
||||
|
||||

|
||||
**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:
|
||||
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`.
|
||||
## Features
|
||||
|
||||
## Why "bot-bottle"?
|
||||
|
||||
Each container is a bottle; Claude is the genie inside. The genie's
|
||||
powers are exactly what the manifest grants it — a specific set of
|
||||
skills, a specific set of secrets, and a specific set of hosts it can
|
||||
reach — nothing more. You uncork one bottle per agent
|
||||
(`./cli.py start <agent>`), many bottles run in parallel, and each is
|
||||
scoped to its task. When the session ends the bottle is destroyed and
|
||||
the genie does not persist.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
|
||||
- **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.
|
||||
- **Composable bottles (`extends:`)** — keep provider/runtime policy in one base bottle (e.g. `claude.md`) and overlay task bottles on top.
|
||||
- **Parallel, isolated bottles** — each bottle is its own per-agent Docker `--internal` network; bottles don't share state or talk to each other.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Architecture
|
||||
|
||||
A bottle is two containers per agent: an `agent` container, and a
|
||||
`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.
|
||||
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.
|
||||
|
||||
```
|
||||
host ( ./cli.py )
|
||||
@@ -104,26 +34,21 @@ and MCP endpoints resolve without an agent-side change.
|
||||
▼
|
||||
┌─────────────────────────── bottle ──────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌──────────────────┐ │
|
||||
│ │ agent image │ HTTPS_PROXY │
|
||||
│ │ (claude-code, │ ────────────────────────┐ │
|
||||
│ │ built locally) │ │ │
|
||||
│ │ │ plain HTTP │ │
|
||||
│ │ skills, env, │ (token injection) ┌────▼─────────┐ │
|
||||
│ │ ~/.gitconfig, │ ──────────────────►│ cred-proxy │ │
|
||||
│ │ ~/.npmrc, tea │ │ (strips/inj │ │
|
||||
│ │ │ │ Authoriz.) │ │
|
||||
│ │ environ: URLs │ └─────┬────────┘ │
|
||||
│ │ only, no real │ HTTPS_PROXY │ │
|
||||
│ │ tokens │ ▼ │
|
||||
│ │ │ ┌────────────────┐ │ HTTPS to
|
||||
│ ┌──────────────────┐ ┌──────────────┐ │
|
||||
│ │ agent image │ HTTP(S) proxy │ cred-proxy │ │
|
||||
│ │ (claude-code, │ ─────────────────►│ (strips/inj │ │
|
||||
│ │ codex, etc) │ │ Authoriz.) │ │
|
||||
│ │ │ └──────┬───────┘ │
|
||||
│ │ environ: URLs │ │ │
|
||||
│ │ only, no real │ ▼ │
|
||||
│ │ tokens │ ┌────────────────┐ │ HTTPS to
|
||||
│ │ │ │ pipelock image │──────────┼──► allowlisted
|
||||
│ │ │ │ (TLS bump, DLP │ │ hosts (incl.
|
||||
│ │ │ │ body scan, │ │ cred-proxy
|
||||
│ │ │ │ allowlist) │ │ upstreams)
|
||||
│ │ │ └────────────────┘ │
|
||||
│ │ │ │
|
||||
│ │ │ git:// ┌────────────────┐ │ SSH push/fetch
|
||||
│ │ │ git proxy ┌────────────────┐ │ SSH push/fetch
|
||||
│ │ │ ────────────────►│ git-gate image │──────────┼──► to bottle.git
|
||||
│ │ │ │ (gitleaks + │ │ upstreams
|
||||
│ └──────────────────┘ │ 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
|
||||
(`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.
|
||||
When the agent exits, `cli.py` tears down every sidecar and both networks; nothing about a bottle persists between runs.
|
||||
|
||||
## Quickstart
|
||||
|
||||
Requires Docker on the host and a long-lived Claude Code OAuth token in
|
||||
your shell env.
|
||||
Requires Docker on the host and a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
|
||||
|
||||
```sh
|
||||
./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
|
||||
|
||||
Bottles and agents live as Markdown files with YAML frontmatter under
|
||||
`~/.bot-bottle/`. Each bottle is one file in `bottles/`, each agent
|
||||
is one file in `agents/`:
|
||||
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/
|
||||
├── 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:
|
||||
**Bottle** (`~/.bot-bottle/bottles/gitea-dev.md`):
|
||||
|
||||
````markdown
|
||||
---
|
||||
agent_provider:
|
||||
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
|
||||
extends: claude # inherit the Claude provider boundary
|
||||
|
||||
env:
|
||||
GIT_AUTHOR_NAME: didericis
|
||||
@@ -337,148 +95,7 @@ git:
|
||||
Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
|
||||
IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea
|
||||
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:
|
||||
routes:
|
||||
- host: gitea.dideric.is
|
||||
@@ -486,38 +103,31 @@ egress:
|
||||
scheme: token
|
||||
token_ref: BOT_BOTTLE_GITEA_TOKEN
|
||||
pipelock:
|
||||
ssrf_ip_allowlist:
|
||||
- 100.78.141.42/32
|
||||
```
|
||||
ssrf_ip_allowlist: [100.78.141.42/32]
|
||||
---
|
||||
|
||||
At launch, `cli.py` reads `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` from the host
|
||||
env and forwards it into the cred-proxy container's environ — never
|
||||
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.
|
||||
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
|
||||
gitea over SSH for push, token over HTTPS for the API.
|
||||
````
|
||||
|
||||
A Claude bottle without a `claude_code_oauth` route has no path to the
|
||||
Anthropic API — there is no fallback that forwards the token directly
|
||||
to the agent. Caveats: the token is bound to your subscription tier
|
||||
(Pro/Max/Team/Enterprise), it does not work with `claude --bare`
|
||||
(which only reads `ANTHROPIC_API_KEY`), and if it leaks, regenerate
|
||||
via `claude setup-token` again. Reference:
|
||||
<https://code.claude.com/docs/en/authentication>.
|
||||
**Agent** (`~/.bot-bottle/agents/gitea-helper.md`):
|
||||
|
||||
````markdown
|
||||
---
|
||||
bottle: gitea-dev
|
||||
skills:
|
||||
- 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
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## License
|
||||
|
||||
Copyright 2026 Eric Bauerfeld
|
||||
|
||||
Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE)
|
||||
for the full text.
|
||||
Copyright 2026 Eric Bauerfeld. Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for the full text.
|
||||
|
||||
+107
-134
@@ -3,18 +3,32 @@
|
||||
The manifest owns the user-facing AgentProvider shape. This module is
|
||||
the launch-time table that turns a provider template into an executable
|
||||
command, default image, and prompt/auth behavior.
|
||||
|
||||
Per PRD 0050 the per-provider implementations live under
|
||||
`bot_bottle/contrib/<template>/agent_provider.py`. This module exposes:
|
||||
|
||||
- `AgentProvider` (ABC) — the contract each plugin implements.
|
||||
- `get_provider(template)` — lazy-imported registry; the analogue
|
||||
of `bot_bottle/deploy_key_provisioner.get_provisioner`.
|
||||
- `AgentProvisionPlan` (+ helper dataclasses) — declarative shape
|
||||
each provider produces and the backends consume unchanged.
|
||||
- `agent_provision_plan` / `runtime_for` — thin wrappers around the
|
||||
registry kept so existing callers keep working without per-call
|
||||
edits.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from .codex_auth import codex_host_access_token, write_codex_dummy_auth_file
|
||||
from .egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
|
||||
from .egress import EgressRoute
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .backend import Bottle, BottlePlan
|
||||
|
||||
|
||||
PROVIDER_CLAUDE = "claude"
|
||||
@@ -96,35 +110,88 @@ class AgentProvisionPlan:
|
||||
provisioned_env: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
class AgentProvider(ABC):
|
||||
"""Per-template plugin: produces the provision plan and applies
|
||||
the provider-specific in-guest setup steps (skills, prompt, the
|
||||
declarative `dirs`/`files`/`pre_copy`/`verify` apply loop, and
|
||||
supervise MCP registration). Concrete subclasses live under
|
||||
`bot_bottle/contrib/<template>/agent_provider.py`."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def runtime(self) -> AgentProviderRuntime:
|
||||
"""The static command / image / prompt-mode table for this
|
||||
template."""
|
||||
|
||||
@abstractmethod
|
||||
def provision_plan(
|
||||
self,
|
||||
*,
|
||||
dockerfile: str,
|
||||
state_dir: Path,
|
||||
guest_home: str,
|
||||
guest_env: dict[str, str] | None = None,
|
||||
auth_token: str = "",
|
||||
forward_host_credentials: bool = False,
|
||||
host_env: dict[str, str] | None = None,
|
||||
trusted_project_path: str = "",
|
||||
) -> AgentProvisionPlan:
|
||||
"""Build the declarative AgentProvisionPlan for one launch.
|
||||
Backends call this during `prepare` and consume the result as
|
||||
before."""
|
||||
|
||||
@abstractmethod
|
||||
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||
"""Copy each of the agent's named skills from the host into
|
||||
the guest. No-op when the agent has no skills. The in-guest
|
||||
layout is provider-specific (claude-code's
|
||||
`~/.claude/skills/` today; future providers may differ)."""
|
||||
|
||||
@abstractmethod
|
||||
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
||||
"""Copy the prompt file into the guest, fix ownership/mode,
|
||||
and return the in-guest path iff the agent has a non-empty
|
||||
prompt (drives the `--append-system-prompt-file` flag).
|
||||
|
||||
The file is copied either way so the path always exists."""
|
||||
|
||||
@abstractmethod
|
||||
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||
"""Apply the provider's declarative
|
||||
`dirs`/`pre_copy`/`files`/`verify` steps from
|
||||
`plan.agent_provision`. Was called `provision_provider_auth`
|
||||
on `BottleBackend` before PRD 0050."""
|
||||
|
||||
@abstractmethod
|
||||
def provision_supervise_mcp(
|
||||
self,
|
||||
plan: "BottlePlan",
|
||||
bottle: "Bottle",
|
||||
supervise_url: str,
|
||||
) -> None:
|
||||
"""Register the per-bottle supervise sidecar as an MCP server
|
||||
in the provider's in-guest config. Called by the backend after
|
||||
the supervise sidecar is reachable. No-op when
|
||||
`plan.supervise_plan is None`."""
|
||||
|
||||
|
||||
_RUNTIMES = {
|
||||
PROVIDER_CLAUDE: AgentProviderRuntime(
|
||||
template=PROVIDER_CLAUDE,
|
||||
command="claude",
|
||||
image="bot-bottle-claude:latest",
|
||||
dockerfile=str(_REPO_ROOT / "Dockerfile.claude"),
|
||||
prompt_mode="append_file",
|
||||
bypass_args=("--dangerously-skip-permissions",),
|
||||
resume_args=("--continue",),
|
||||
remote_control_args=("--remote-control",),
|
||||
),
|
||||
PROVIDER_CODEX: AgentProviderRuntime(
|
||||
template=PROVIDER_CODEX,
|
||||
command="codex",
|
||||
image="bot-bottle-codex:latest",
|
||||
dockerfile=str(_REPO_ROOT / "Dockerfile.codex"),
|
||||
prompt_mode="read_prompt_file",
|
||||
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
||||
resume_args=("resume", "--last"),
|
||||
remote_control_args=(),
|
||||
),
|
||||
}
|
||||
def get_provider(template: str) -> AgentProvider:
|
||||
"""Resolve a provider template name to its plugin instance.
|
||||
|
||||
Lazy-imports the contrib module so importing this module doesn't
|
||||
pull provider-specific code paths in. Mirrors the contrib
|
||||
convention PRD 0048 established for deploy key provisioners."""
|
||||
if template == PROVIDER_CLAUDE:
|
||||
from .contrib.claude.agent_provider import ClaudeAgentProvider
|
||||
return ClaudeAgentProvider()
|
||||
if template == PROVIDER_CODEX:
|
||||
from .contrib.codex.agent_provider import CodexAgentProvider
|
||||
return CodexAgentProvider()
|
||||
raise ValueError(f"unknown agent provider template: {template!r}")
|
||||
|
||||
|
||||
def runtime_for(template: str) -> AgentProviderRuntime:
|
||||
return _RUNTIMES[template]
|
||||
return get_provider(template).runtime
|
||||
|
||||
|
||||
def agent_provision_plan(
|
||||
@@ -132,118 +199,24 @@ def agent_provision_plan(
|
||||
template: str,
|
||||
dockerfile: str,
|
||||
state_dir: Path,
|
||||
guest_home: str = "/home/node",
|
||||
guest_home: str,
|
||||
guest_env: dict[str, str] | None = None,
|
||||
auth_token: str = "",
|
||||
forward_host_credentials: bool = False,
|
||||
host_env: dict[str, str] | None = None,
|
||||
trusted_project_path: str = "",
|
||||
) -> AgentProvisionPlan:
|
||||
runtime = runtime_for(template)
|
||||
resolved_guest_env = dict(guest_env or {})
|
||||
trusted_path = trusted_project_path or guest_home
|
||||
env_vars: dict[str, str] = {}
|
||||
provisioned_env: dict[str, str] = {}
|
||||
dirs: list[AgentProvisionDir] = []
|
||||
files: list[AgentProvisionFile] = []
|
||||
pre_copy: list[AgentProvisionCommand] = []
|
||||
verify: list[AgentProvisionCommand] = []
|
||||
egress_routes: list[EgressRoute] = []
|
||||
hidden_env_names: frozenset[str] = frozenset()
|
||||
|
||||
if template == PROVIDER_CODEX:
|
||||
env_vars["CODEX_CA_CERTIFICATE"] = "/etc/ssl/certs/ca-certificates.crt"
|
||||
auth_dir = resolved_guest_env.get("CODEX_HOME", f"{guest_home}/.codex")
|
||||
if forward_host_credentials:
|
||||
env_vars["CODEX_HOME"] = auth_dir
|
||||
dirs.append(AgentProvisionDir(auth_dir))
|
||||
config_path = f"{auth_dir}/config.toml"
|
||||
config_file = state_dir / "codex-config.toml"
|
||||
toml_path = trusted_path.replace("\\", "\\\\").replace('"', '\\"')
|
||||
config_file.write_text(
|
||||
f'[projects."{toml_path}"]\n'
|
||||
'trust_level = "trusted"\n'
|
||||
)
|
||||
config_file.chmod(0o600)
|
||||
files.append(AgentProvisionFile(config_file, config_path))
|
||||
|
||||
for host in CODEX_HOST_CREDENTIAL_HOSTS:
|
||||
egress_routes.append(EgressRoute(
|
||||
host=host,
|
||||
auth_scheme="Bearer" if forward_host_credentials else "",
|
||||
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials else "",
|
||||
tls_passthrough=True,
|
||||
))
|
||||
if forward_host_credentials:
|
||||
_host_env = host_env or dict(os.environ)
|
||||
provisioned_env[CODEX_HOST_CREDENTIAL_TOKEN_REF] = codex_host_access_token(
|
||||
_host_env,
|
||||
)
|
||||
auth_file = state_dir / "codex-auth.json"
|
||||
write_codex_dummy_auth_file(auth_file, _host_env)
|
||||
files.append(AgentProvisionFile(auth_file, f"{auth_dir}/auth.json"))
|
||||
pre_copy.append(AgentProvisionCommand((
|
||||
"find", auth_dir,
|
||||
"-maxdepth", "1",
|
||||
"-type", "f",
|
||||
"(",
|
||||
"-name", "*.sqlite",
|
||||
"-o", "-name", "*.sqlite-*",
|
||||
"-o", "-name", "*.codex-repair-*.bak",
|
||||
")",
|
||||
"-delete",
|
||||
), "codex host credentials: could not reset runtime db files"))
|
||||
verify.append(AgentProvisionCommand((
|
||||
"runuser", "-u", "node", "--",
|
||||
"env",
|
||||
f"HOME={guest_home}",
|
||||
f"CODEX_HOME={auth_dir}",
|
||||
"codex", "login", "status",
|
||||
), (
|
||||
"codex host credentials: dummy auth was copied into the "
|
||||
"guest, but Codex did not accept it"
|
||||
)))
|
||||
if template == PROVIDER_CLAUDE:
|
||||
env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1"
|
||||
env_vars["DISABLE_ERROR_REPORTING"] = "1"
|
||||
claude_config = state_dir / "claude.json"
|
||||
claude_projects = {
|
||||
guest_home: {"hasTrustDialogAccepted": True},
|
||||
}
|
||||
claude_projects[trusted_path] = {"hasTrustDialogAccepted": True}
|
||||
claude_config.write_text(json.dumps({
|
||||
"hasCompletedOnboarding": True,
|
||||
"theme": "dark",
|
||||
"bypassPermissionsModeAccepted": True,
|
||||
"projects": claude_projects,
|
||||
}, indent=2) + "\n")
|
||||
claude_config.chmod(0o600)
|
||||
files.append(AgentProvisionFile(claude_config, f"{guest_home}/.claude.json"))
|
||||
egress_routes.append(EgressRoute(
|
||||
host="api.anthropic.com",
|
||||
auth_scheme="Bearer" if auth_token else "",
|
||||
token_ref=auth_token,
|
||||
tls_passthrough=True,
|
||||
))
|
||||
if auth_token:
|
||||
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
||||
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
|
||||
|
||||
return AgentProvisionPlan(
|
||||
template=template,
|
||||
command=runtime.command,
|
||||
prompt_mode=runtime.prompt_mode,
|
||||
image=runtime.image,
|
||||
"""Back-compat shim — `prepare` callers stay the same; the work
|
||||
now lives on the provider plugin."""
|
||||
return get_provider(template).provision_plan(
|
||||
dockerfile=dockerfile,
|
||||
env_vars=env_vars,
|
||||
guest_env=resolved_guest_env,
|
||||
dirs=tuple(dirs),
|
||||
files=tuple(files),
|
||||
pre_copy=tuple(pre_copy),
|
||||
verify=tuple(verify),
|
||||
egress_routes=tuple(egress_routes),
|
||||
hidden_env_names=hidden_env_names,
|
||||
provisioned_env=provisioned_env,
|
||||
state_dir=state_dir,
|
||||
guest_home=guest_home,
|
||||
guest_env=guest_env,
|
||||
auth_token=auth_token,
|
||||
forward_host_credentials=forward_host_credentials,
|
||||
host_env=host_env,
|
||||
trusted_project_path=trusted_project_path,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Generic, Sequence, TypeVar
|
||||
|
||||
from ..agent_provider import AgentProvisionPlan
|
||||
from ..agent_provider import AgentProvisionPlan, get_provider
|
||||
from ..egress import EgressPlan
|
||||
from ..git_gate import GitGatePlan
|
||||
from ..log import die, info
|
||||
@@ -76,6 +76,7 @@ class BottlePlan(ABC):
|
||||
|
||||
spec: BottleSpec
|
||||
stage_dir: Path
|
||||
guest_home: str
|
||||
git_gate_plan: GitGatePlan
|
||||
egress_plan: EgressPlan
|
||||
supervise_plan: SupervisePlan | None
|
||||
@@ -312,37 +313,44 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]:
|
||||
"""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
|
||||
the running bottle. Called from `launch` after the container
|
||||
/ machine is up. `target` identifies the running instance in
|
||||
backend-specific terms (Docker: resolved container name; fly:
|
||||
machine id). Returns the in-container prompt path if a prompt
|
||||
was provisioned, else None — the Bottle handle uses it to
|
||||
decide whether to add provider-specific prompt args to the agent's
|
||||
argv.
|
||||
/ machine is up. Returns the in-container prompt path if a
|
||||
prompt was provisioned, else None — the Bottle handle uses it
|
||||
to decide whether to add provider-specific prompt args to the
|
||||
agent's argv.
|
||||
|
||||
Default orchestration: ca → prompt → skills → workspace → git →
|
||||
supervise. CA install runs first so the agent's trust store
|
||||
is rebuilt before anything inside the agent makes a TLS call.
|
||||
Subclasses typically don't override this; they implement the
|
||||
sub-methods below.
|
||||
Default orchestration: ca → prompt → provider apply → skills
|
||||
→ workspace → git → supervise-mcp. CA install runs first so
|
||||
the agent's trust store is rebuilt before anything inside the
|
||||
agent makes a TLS call.
|
||||
|
||||
Per PRD 0050 the per-provider steps (prompt, skills,
|
||||
declarative provision-plan apply, supervise MCP registration)
|
||||
live on the `AgentProvider` plugin. The backend only owns the
|
||||
steps that are about backend infrastructure (CA, workspace,
|
||||
git) and surfaces the supervise sidecar URL its launch step
|
||||
knows about via `supervise_mcp_url`.
|
||||
|
||||
PRD 0017: cred-proxy's agent-side dotfile rewrites (~/.npmrc,
|
||||
~/.gitconfig insteadOf, tea config) are gone. Egress-proxy is
|
||||
on the agent's HTTP_PROXY path so every tool that respects
|
||||
HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is
|
||||
intercepted without per-tool reconfiguration."""
|
||||
self.provision_ca(plan, target)
|
||||
prompt_path = self.provision_prompt(plan, target)
|
||||
self.provision_provider_auth(plan, target)
|
||||
self.provision_skills(plan, target)
|
||||
self.provision_workspace(plan, target)
|
||||
self.provision_git(plan, target)
|
||||
self.provision_supervise(plan, target)
|
||||
provider = get_provider(plan.agent_provision.template)
|
||||
self.provision_ca(plan, bottle)
|
||||
prompt_path = provider.provision_prompt(plan, bottle)
|
||||
provider.provision(plan, bottle)
|
||||
provider.provision_skills(plan, bottle)
|
||||
self.provision_workspace(plan, bottle)
|
||||
self.provision_git(plan, bottle)
|
||||
provider.provision_supervise_mcp(
|
||||
plan, bottle, self.supervise_mcp_url(plan),
|
||||
)
|
||||
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
|
||||
the agent trusts the bumped CONNECT cert egress (was
|
||||
pipelock, pre-PRD-0017) presents. Default impl is a no-op so
|
||||
@@ -351,39 +359,26 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
backend overrides to docker-cp the cert in and run
|
||||
`update-ca-certificates`."""
|
||||
|
||||
def provision_provider_auth(self, plan: PlanT, target: str) -> None:
|
||||
"""Install non-secret provider auth marker files into the agent
|
||||
home when a provider needs them to select the right auth mode.
|
||||
The default is no-op."""
|
||||
|
||||
@abstractmethod
|
||||
def provision_prompt(self, plan: PlanT, target: str) -> str | None:
|
||||
"""Copy the prompt file into the running bottle. Returns the
|
||||
in-container path iff the agent has a non-empty prompt;
|
||||
callers use the return value to decide whether to add
|
||||
provider-specific prompt args to the agent's argv."""
|
||||
|
||||
@abstractmethod
|
||||
def provision_skills(self, plan: PlanT, target: str) -> None:
|
||||
"""Copy the agent's named skills from the host into the
|
||||
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
|
||||
the backend cannot bake it into the agent image. Default is
|
||||
no-op for backends like Docker that handle this before launch."""
|
||||
|
||||
@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
|
||||
bottle if the user requested --cwd. No-op otherwise."""
|
||||
|
||||
def provision_supervise(self, plan: PlanT, target: str) -> None:
|
||||
"""Write the in-bottle Claude Code MCP config so the agent
|
||||
discovers the per-bottle supervise sidecar (PRD 0013).
|
||||
No-op when bottle.supervise is False or the backend doesn't
|
||||
support the supervise sidecar yet. The Docker backend
|
||||
overrides."""
|
||||
def supervise_mcp_url(self, plan: PlanT) -> str:
|
||||
"""Return the agent-side URL of the per-bottle supervise
|
||||
sidecar, or "" when this bottle has no sidecar. The provider
|
||||
plugin's `provision_supervise_mcp` uses it to register the
|
||||
MCP entry inside the guest.
|
||||
|
||||
Default returns "" so backends without supervise support
|
||||
don't have to implement it. Docker and smolmachines override."""
|
||||
del plan
|
||||
return ""
|
||||
|
||||
@abstractmethod
|
||||
def prepare_cleanup(self) -> CleanupT:
|
||||
|
||||
@@ -9,6 +9,12 @@ This module is a thin façade. The real work lives in four siblings:
|
||||
|
||||
The base class's `prepare` template runs cross-backend host-side
|
||||
validation before calling `_resolve_plan` here.
|
||||
|
||||
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
|
||||
the declarative provision-plan apply, supervise MCP registration)
|
||||
live on the `AgentProvider` plugin under `bot_bottle/contrib/`. The
|
||||
Docker backend only owns the steps that are about backend
|
||||
infrastructure: CA install and git copy-in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,7 +24,8 @@ from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Generator, Sequence
|
||||
|
||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
||||
from ...supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
|
||||
from .. import ActiveAgent, Bottle, BottleBackend, BottleSpec
|
||||
from . import cleanup as _cleanup
|
||||
from . import enumerate as _enumerate
|
||||
from . import launch as _launch
|
||||
@@ -28,10 +35,6 @@ from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
from .provision import ca as _ca
|
||||
from .provision import git as _git
|
||||
from .provision import prompt as _prompt
|
||||
from .provision import provider_auth as _provider_auth
|
||||
from .provision import skills as _skills
|
||||
from .provision import supervise as _supervise_prov
|
||||
|
||||
|
||||
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
|
||||
@@ -57,23 +60,19 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
||||
yield bottle
|
||||
|
||||
def provision_ca(self, plan: DockerBottlePlan, target: str) -> None:
|
||||
_ca.provision_ca(plan, target)
|
||||
def provision_ca(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||
_ca.provision_ca(plan, bottle)
|
||||
|
||||
def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None:
|
||||
return _prompt.provision_prompt(plan, target)
|
||||
def provision_git(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||
_git.provision_git(plan, bottle)
|
||||
|
||||
def provision_provider_auth(self, plan: DockerBottlePlan, target: str) -> None:
|
||||
_provider_auth.provision_provider_auth(plan, target)
|
||||
|
||||
def provision_skills(self, plan: DockerBottlePlan, target: str) -> None:
|
||||
_skills.provision_skills(plan, target)
|
||||
|
||||
def provision_git(self, plan: DockerBottlePlan, target: str) -> None:
|
||||
_git.provision_git(plan, target)
|
||||
|
||||
def provision_supervise(self, plan: DockerBottlePlan, target: str) -> None:
|
||||
_supervise_prov.provision_supervise(plan, target)
|
||||
def supervise_mcp_url(self, plan: DockerBottlePlan) -> str:
|
||||
"""Docker bottles reach the supervise sidecar via the
|
||||
compose-network alias `supervise:9100`. No per-bottle URL
|
||||
plumbing needed; the alias resolves inside the bridge."""
|
||||
if plan.supervise_plan is None:
|
||||
return ""
|
||||
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
|
||||
|
||||
def prepare_cleanup(self) -> DockerBottleCleanupPlan:
|
||||
return _cleanup.prepare_cleanup()
|
||||
|
||||
@@ -208,19 +208,21 @@ def launch(
|
||||
compose_dump_logs, project, compose_file, compose_log_path(state_dir),
|
||||
)
|
||||
|
||||
# Step 8: provision. Unchanged — uses `docker exec` against
|
||||
# the agent container by its known name.
|
||||
prompt_path = provision(plan, plan.container_name)
|
||||
# Step 8: provision. Create the bottle first so provisioners
|
||||
# can use bottle.exec / bottle.cp_in; set the prompt path
|
||||
# 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`
|
||||
# — the agent runs `sleep infinity` per the renderer's
|
||||
# service spec.
|
||||
yield DockerBottle(
|
||||
plan.container_name,
|
||||
teardown,
|
||||
prompt_path,
|
||||
agent_command=plan.agent_command,
|
||||
agent_prompt_mode=plan.agent_prompt_mode,
|
||||
)
|
||||
yield bottle
|
||||
finally:
|
||||
teardown()
|
||||
|
||||
@@ -63,7 +63,7 @@ def resolve_plan(
|
||||
bottle = manifest.bottle_for(spec.agent_name)
|
||||
provider = bottle.agent_provider
|
||||
provider_runtime = runtime_for(provider.template)
|
||||
guest_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||
guest_home = "/home/node"
|
||||
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
|
||||
|
||||
# PRD 0016 follow-up: identity, not bare slug. A fresh `start`
|
||||
@@ -233,6 +233,7 @@ def resolve_plan(
|
||||
return DockerBottlePlan(
|
||||
spec=spec,
|
||||
stage_dir=stage_dir,
|
||||
guest_home=guest_home,
|
||||
slug=slug,
|
||||
container_name=container_name,
|
||||
container_name_pinned=container_name_pinned,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Per-provisioner modules for the Docker backend.
|
||||
"""Backend-infrastructure provisioners for the Docker backend.
|
||||
|
||||
Each module exports one top-level function:
|
||||
provision_<thing>(plan: DockerBottlePlan, target: str) -> ...
|
||||
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
|
||||
declarative provision-plan apply, supervise MCP registration) live on
|
||||
the `AgentProvider` plugin under `bot_bottle/contrib/`. The modules
|
||||
left in this subpackage handle only the steps that are
|
||||
backend-specific:
|
||||
|
||||
`DockerBottleBackend.provision_*` methods delegate to these. The
|
||||
abstract `BottleBackend.provision_*` surface is unchanged; this
|
||||
subpackage exists only to keep `backend.py` from being a god-file."""
|
||||
- ca.py — install per-bottle CA bundle into the guest trust store
|
||||
- git.py — copy host cwd `.git` into the guest when --cwd is used
|
||||
"""
|
||||
|
||||
@@ -31,33 +31,21 @@ stage dir; nothing in the agent ever sees it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from ... import Bottle
|
||||
from ...util import AGENT_CA_PATH, log_ca_fingerprint, select_ca_cert
|
||||
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
|
||||
trust bundle, emit a one-line fingerprint log. Called from
|
||||
`BottleBackend.provision` after the agent container is up."""
|
||||
container = target
|
||||
cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan)
|
||||
|
||||
subprocess.run(
|
||||
["docker", "cp", str(cert_host_path), f"{container}:{AGENT_CA_PATH}"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
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,
|
||||
bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
|
||||
bottle.exec(
|
||||
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates",
|
||||
user="root",
|
||||
)
|
||||
|
||||
log_ca_fingerprint(cert_host_path, label)
|
||||
|
||||
@@ -18,75 +18,62 @@ Three concerns, all about git in the agent:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import shlex
|
||||
|
||||
from ....git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
|
||||
from ....log import info
|
||||
from .. import util as docker_mod
|
||||
from ... import Bottle
|
||||
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
|
||||
no-ops when its condition isn't met."""
|
||||
_provision_cwd_git(plan, target)
|
||||
_provision_git_gate_config(plan, target)
|
||||
_provision_git_user(plan, target)
|
||||
_provision_cwd_git(plan, bottle)
|
||||
_provision_git_gate_config(plan, bottle)
|
||||
_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
|
||||
it into /home/node/workspace/.git and fix ownership. No-op
|
||||
otherwise."""
|
||||
workspace = plan.workspace_plan
|
||||
if not (workspace.enabled and workspace.copy_git and workspace.has_host_git_dir):
|
||||
return
|
||||
container = target
|
||||
guest_workspace_git = f"{workspace.guest_path}/.git"
|
||||
host_git = str(workspace.host_path / ".git")
|
||||
info(f"copying {host_git} -> {container}:{guest_workspace_git}")
|
||||
subprocess.run(
|
||||
["docker", "cp", host_git, f"{container}:{guest_workspace_git}"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"docker", "exec", "-u", "0", container,
|
||||
"chown", "-R", workspace.owner, guest_workspace_git,
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=True,
|
||||
info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}")
|
||||
bottle.cp_in(host_git, guest_workspace_git)
|
||||
bottle.exec(
|
||||
f"chown -R {shlex.quote(workspace.owner)} {shlex.quote(guest_workspace_git)}",
|
||||
user="root",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
insteadOf rules. No-op when the bottle has no `git` entries."""
|
||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||
if not bottle.git:
|
||||
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||
if not manifest_bottle.git:
|
||||
return
|
||||
container = target
|
||||
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||
container_gitconfig = f"{container_home}/.gitconfig"
|
||||
container_gitconfig = f"{plan.guest_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.write_text(content)
|
||||
config_file.chmod(0o600)
|
||||
|
||||
info(f"writing {container_gitconfig} with {len(bottle.git)} insteadOf rule(s)")
|
||||
subprocess.run(
|
||||
["docker", "cp", str(config_file), f"{container}:{container_gitconfig}"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=True,
|
||||
info(f"writing {container_gitconfig} with {len(manifest_bottle.git)} insteadOf rule(s)")
|
||||
bottle.cp_in(str(config_file), container_gitconfig)
|
||||
bottle.exec(
|
||||
f"chown node:node {shlex.quote(container_gitconfig)} && "
|
||||
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
|
||||
bottle so the agent's commits are attributed to the operator-
|
||||
chosen identity instead of the agent image's default
|
||||
@@ -101,23 +88,19 @@ def _provision_git_user(plan: DockerBottlePlan, target: str) -> None:
|
||||
Each field set independently — name-only or email-only
|
||||
configs only run the `git config` line for the field
|
||||
present."""
|
||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||
gu = bottle.git_user
|
||||
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||
gu = manifest_bottle.git_user
|
||||
if gu.is_empty():
|
||||
return
|
||||
if gu.name:
|
||||
info(f"git config --global user.name = {gu.name!r}")
|
||||
subprocess.run(
|
||||
["docker", "exec", "-u", "node", target,
|
||||
"git", "config", "--global", "user.name", gu.name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=True,
|
||||
bottle.exec(
|
||||
f"git config --global user.name {shlex.quote(gu.name)}",
|
||||
user="node",
|
||||
)
|
||||
if gu.email:
|
||||
info(f"git config --global user.email = {gu.email!r}")
|
||||
subprocess.run(
|
||||
["docker", "exec", "-u", "node", target,
|
||||
"git", "config", "--global", "user.email", gu.email],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=True,
|
||||
bottle.exec(
|
||||
f"git config --global user.email {shlex.quote(gu.email)}",
|
||||
user="node",
|
||||
)
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""Copy the agent prompt into a running Docker bottle.
|
||||
|
||||
The prompt file is always copied (so the in-container path always
|
||||
exists) but `--append-system-prompt-file` only fires when the agent
|
||||
actually has a prompt — the return value signals which case."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from ..bottle_plan import DockerBottlePlan
|
||||
|
||||
|
||||
def provision_prompt(plan: DockerBottlePlan, target: str) -> str | None:
|
||||
"""Copy the prompt file into the container, fix ownership/mode.
|
||||
Returns the in-container path if the agent has a non-empty
|
||||
prompt (drives --append-system-prompt-file), else None. The
|
||||
file is copied either way so the path always exists."""
|
||||
container = target
|
||||
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||
in_container_prompt_path = f"{container_home}/.bot-bottle-prompt.txt"
|
||||
|
||||
subprocess.run(
|
||||
["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
|
||||
# can read its own mode-600 prompt regardless of host UID.
|
||||
subprocess.run(
|
||||
["docker", "exec", "-u", "0", container, "chown", "node:node", in_container_prompt_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
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]
|
||||
return in_container_prompt_path if agent.prompt else None
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Provision non-secret provider auth markers into a Docker bottle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from ..bottle_plan import DockerBottlePlan
|
||||
|
||||
|
||||
def provision_provider_auth(plan: DockerBottlePlan, target: str) -> None:
|
||||
"""Apply provider-owned guest setup through Docker primitives."""
|
||||
provision = plan.agent_provision
|
||||
for d in provision.dirs:
|
||||
_exec(target, ["mkdir", "-p", d.guest_path])
|
||||
_exec(target, ["chown", d.owner, d.guest_path])
|
||||
_exec(target, ["chmod", d.mode, d.guest_path])
|
||||
for command in provision.pre_copy:
|
||||
_exec(target, list(command.argv))
|
||||
for f in provision.files:
|
||||
subprocess.run(
|
||||
["docker", "cp", str(f.host_path), f"{target}:{f.guest_path}"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
_exec(target, ["chown", f.owner, f.guest_path])
|
||||
_exec(target, ["chmod", f.mode, f.guest_path])
|
||||
for command in provision.verify:
|
||||
_exec(target, list(command.argv))
|
||||
|
||||
|
||||
def _exec(target: str, argv: list[str]) -> None:
|
||||
subprocess.run(
|
||||
["docker", "exec", "-u", "0", target, *argv],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Copy host-side skill directories into a running Docker bottle.
|
||||
|
||||
Skills are validated on the host before launch by the base class's
|
||||
`BottleBackend._validate_skills` (called from `prepare`); this module
|
||||
assumes that validation has already run. A skill disappearing between
|
||||
validation and copy still dies loudly rather than silently producing
|
||||
a partial container."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from ....log import die, info
|
||||
from ...util import host_skill_dir
|
||||
from ..bottle_plan import DockerBottlePlan
|
||||
|
||||
|
||||
def provision_skills(plan: DockerBottlePlan, target: str) -> None:
|
||||
"""Copy each of the agent's named skills from the host's
|
||||
~/.claude/skills/<name>/ into the container's equivalent path.
|
||||
For each skill: ensure parent dir, wipe any prior copy, then
|
||||
`docker cp <host>/. <container>:<dst>/` so the contents are
|
||||
copied into a freshly-created destination dir. No-op when the
|
||||
agent has no skills."""
|
||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||
if not agent.skills:
|
||||
return
|
||||
|
||||
container = target
|
||||
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||
skills_dir = os.environ.get(
|
||||
"BOT_BOTTLE_CONTAINER_SKILLS_DIR", f"{container_home}/.claude/skills"
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["docker", "exec", container, "mkdir", "-p", skills_dir],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
|
||||
for n in agent.skills:
|
||||
src = host_skill_dir(n)
|
||||
if not os.path.isdir(src):
|
||||
die(f"skill '{n}' disappeared from host between validation and copy at {src}.")
|
||||
dst = f"{skills_dir}/{n}"
|
||||
info(f"copying skill {n} into {container}:{dst}")
|
||||
subprocess.run(
|
||||
["docker", "exec", container, "rm", "-rf", 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,
|
||||
)
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Supervise sidecar provisioning inside a running Docker bottle
|
||||
(PRD 0013).
|
||||
|
||||
Registers the per-bottle supervise sidecar as an HTTP MCP server in
|
||||
the agent's claude-code config so the agent discovers the three
|
||||
stuck-recovery MCP tools (cred-proxy-block, pipelock-block,
|
||||
capability-block) at startup.
|
||||
|
||||
Uses `claude mcp add` rather than writing JSON directly. claude-code
|
||||
owns the on-disk config format (`~/.claude.json` `mcpServers` shape,
|
||||
field names, scope semantics) and changes it between versions; the
|
||||
official command handles whatever the installed version expects.
|
||||
|
||||
No-op when bottle.supervise is False — bottles that haven't opted
|
||||
into the supervise sidecar shouldn't get an MCP entry pointing at a
|
||||
sidecar that isn't running.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from ....log import info, warn
|
||||
from ....supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
|
||||
from ..bottle_plan import DockerBottlePlan
|
||||
|
||||
|
||||
_SUPERVISE_MCP_NAME = "supervise"
|
||||
|
||||
|
||||
def supervise_mcp_url() -> str:
|
||||
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
|
||||
|
||||
|
||||
def provision_supervise(plan: DockerBottlePlan, target: str) -> None:
|
||||
"""Run `claude mcp add` inside the agent container to register
|
||||
the supervise sidecar in claude-code's user config. No-op when
|
||||
bottle.supervise is False.
|
||||
|
||||
Failure is logged but not fatal: the bottle still works (you
|
||||
just can't call supervise tools from the agent until the entry
|
||||
is added manually). The operator sees the warning at launch."""
|
||||
if plan.supervise_plan is None:
|
||||
return
|
||||
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}")
|
||||
r = subprocess.run(argv, capture_output=True, text=True, check=False)
|
||||
if r.returncode != 0:
|
||||
warn(
|
||||
f"`claude mcp add supervise` failed (exit {r.returncode}): "
|
||||
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
|
||||
f"register manually with: "
|
||||
f"claude mcp add --scope user --transport http supervise {url}"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["provision_supervise", "supervise_mcp_url"]
|
||||
@@ -1,5 +1,11 @@
|
||||
"""SmolmachinesBottleBackend — the smolmachines implementation of
|
||||
BottleBackend (PRD 0023)."""
|
||||
BottleBackend (PRD 0023).
|
||||
|
||||
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
|
||||
the declarative provision-plan apply, supervise MCP registration)
|
||||
live on the `AgentProvider` plugin under `bot_bottle/contrib/`. The
|
||||
smolmachines backend only owns the steps that are about backend
|
||||
infrastructure: CA install (no-op for now), workspace, git copy-in."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,7 +13,7 @@ from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Generator, Sequence
|
||||
|
||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
||||
from .. import ActiveAgent, Bottle, BottleBackend, BottleSpec
|
||||
from . import cleanup as _cleanup
|
||||
from . import enumerate as _enumerate
|
||||
from . import launch as _launch
|
||||
@@ -18,10 +24,6 @@ from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
|
||||
from .bottle_plan import SmolmachinesBottlePlan
|
||||
from .provision import ca as _ca
|
||||
from .provision import git as _git
|
||||
from .provision import prompt as _prompt
|
||||
from .provision import provider_auth as _provider_auth
|
||||
from .provision import skills as _skills
|
||||
from .provision import supervise as _supervise
|
||||
from .provision import workspace as _workspace
|
||||
|
||||
|
||||
@@ -54,39 +56,26 @@ class SmolmachinesBottleBackend(
|
||||
yield bottle
|
||||
|
||||
def provision_ca(
|
||||
self, plan: SmolmachinesBottlePlan, target: str
|
||||
self, plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||
) -> None:
|
||||
_ca.provision_ca(plan, target)
|
||||
|
||||
def provision_prompt(
|
||||
self, plan: SmolmachinesBottlePlan, target: str
|
||||
) -> str | None:
|
||||
return _prompt.provision_prompt(plan, target)
|
||||
|
||||
def provision_provider_auth(
|
||||
self, plan: SmolmachinesBottlePlan, target: str
|
||||
) -> None:
|
||||
_provider_auth.provision_provider_auth(plan, target)
|
||||
|
||||
def provision_skills(
|
||||
self, plan: SmolmachinesBottlePlan, target: str
|
||||
) -> None:
|
||||
_skills.provision_skills(plan, target)
|
||||
_ca.provision_ca(plan, bottle)
|
||||
|
||||
def provision_workspace(
|
||||
self, plan: SmolmachinesBottlePlan, target: str
|
||||
self, plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||
) -> None:
|
||||
_workspace.provision_workspace(plan, target)
|
||||
_workspace.provision_workspace(plan, bottle)
|
||||
|
||||
def provision_git(
|
||||
self, plan: SmolmachinesBottlePlan, target: str
|
||||
self, plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||
) -> None:
|
||||
_git.provision_git(plan, target)
|
||||
_git.provision_git(plan, bottle)
|
||||
|
||||
def provision_supervise(
|
||||
self, plan: SmolmachinesBottlePlan, target: str
|
||||
) -> None:
|
||||
_supervise.provision_supervise(plan, target)
|
||||
def supervise_mcp_url(self, plan: SmolmachinesBottlePlan) -> str:
|
||||
"""The smolmachines guest reaches the supervise sidecar via a
|
||||
host-published random port the launch step pinned earlier
|
||||
(`http://<loopback_ip>:<random_port>/`). `agent_supervise_url`
|
||||
on the plan is "" when the bottle has no sidecar."""
|
||||
return plan.agent_supervise_url
|
||||
|
||||
def prepare_cleanup(self) -> SmolmachinesBottleCleanupPlan:
|
||||
return _cleanup.prepare_cleanup()
|
||||
|
||||
@@ -113,15 +113,16 @@ def launch(
|
||||
_launch_vm(plan, agent_from_path, loopback_ip, stack)
|
||||
_init_vm(plan)
|
||||
|
||||
prompt_path = provision(plan, plan.machine_name)
|
||||
|
||||
yield SmolmachinesBottle(
|
||||
bottle = SmolmachinesBottle(
|
||||
plan.machine_name,
|
||||
prompt_path=prompt_path,
|
||||
prompt_path=None,
|
||||
guest_env=plan.guest_env,
|
||||
agent_command=plan.agent_command,
|
||||
agent_prompt_mode=plan.agent_prompt_mode,
|
||||
)
|
||||
bottle._prompt_path = provision(plan, bottle)
|
||||
|
||||
yield bottle
|
||||
finally:
|
||||
_teardown_smolmachines(stack, plan)
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ def resolve_plan(
|
||||
bottle = manifest.bottle_for(spec.agent_name)
|
||||
provider = bottle.agent_provider
|
||||
provider_runtime = runtime_for(provider.template)
|
||||
guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", "/home/node")
|
||||
guest_home = "/home/node"
|
||||
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
|
||||
|
||||
slug = spec.identity or bottle_identity(spec.agent_name)
|
||||
@@ -172,6 +172,7 @@ def resolve_plan(
|
||||
return SmolmachinesBottlePlan(
|
||||
spec=spec,
|
||||
stage_dir=stage_dir,
|
||||
guest_home=guest_home,
|
||||
slug=slug,
|
||||
bundle_subnet=subnet,
|
||||
bundle_gateway=gateway,
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
"""Provisioning helpers for the smolmachines backend (PRD 0023
|
||||
chunk 4).
|
||||
"""Backend-infrastructure provisioners for the smolmachines backend.
|
||||
|
||||
Each method maps onto one of `BottleBackend`'s `provision_*`
|
||||
overrides. They run after the VM is up + the bundle is reachable
|
||||
and copy host-side state (prompt, skills, .git, CA cert,
|
||||
supervise MCP config) into the guest via `smolvm machine cp` /
|
||||
`smolvm machine exec`.
|
||||
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
|
||||
declarative provision-plan apply, supervise MCP registration) live on
|
||||
the `AgentProvider` plugin under `bot_bottle/contrib/`. The modules
|
||||
left in this subpackage handle only the steps that are
|
||||
backend-specific:
|
||||
|
||||
Chunk 4a ships `provision_prompt` and `provision_skills` — the
|
||||
two that don't depend on agent-image tooling (claude-code,
|
||||
update-ca-certificates) beyond `cp` and `mkdir`. provision_ca /
|
||||
provision_git / provision_supervise land once the agent-image
|
||||
gap is solved."""
|
||||
- ca.py — install per-bottle CA bundle into the guest trust store
|
||||
- git.py — copy host cwd `.git` into the guest when --cwd is used
|
||||
- workspace.py — copy the operator workspace into the guest
|
||||
"""
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
trust store (PRD 0023 chunk 4d).
|
||||
|
||||
Mirrors `backend.docker.provision.ca`: select the right CA (egress
|
||||
when the bottle has routes, else pipelock), `smolvm machine cp` it
|
||||
to Debian's `/usr/local/share/ca-certificates/` path,
|
||||
when the bottle has routes, else pipelock), copy it to Debian's
|
||||
`/usr/local/share/ca-certificates/` path,
|
||||
`update-ca-certificates` to rebuild the trust bundle, and log the
|
||||
fingerprint once. The selected cert depends on the agent's
|
||||
HTTP_PROXY target — same logic as the docker backend, since the
|
||||
@@ -24,20 +24,20 @@ from ...util import (
|
||||
log_ca_fingerprint,
|
||||
select_ca_cert,
|
||||
)
|
||||
from .. import smolvm as _smolvm
|
||||
from ... import Bottle, ExecResult
|
||||
from ..bottle_plan import SmolmachinesBottlePlan
|
||||
|
||||
|
||||
_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
|
||||
trust bundle, emit a one-line fingerprint log. Called from
|
||||
`BottleBackend.provision` after the smolvm guest is up."""
|
||||
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.
|
||||
# update-ca-certificates rebuilds the bundle at AGENT_CA_BUNDLE,
|
||||
# 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` / libraries that don't load the system bundle.
|
||||
#
|
||||
r = _install_ca(target)
|
||||
r = _install_ca(bottle)
|
||||
if r.returncode == _SIGKILL_EXIT:
|
||||
# smolvm/libkrun can SIGKILL an otherwise-normal exec
|
||||
# during early-VM provisioning. `update-ca-certificates`
|
||||
# is idempotent, so retry the same install once after a
|
||||
# short settle delay before treating it as fatal.
|
||||
time.sleep(1.0)
|
||||
r = _install_ca(target)
|
||||
r = _install_ca(bottle)
|
||||
|
||||
if r.returncode != 0:
|
||||
# update-ca-certificates not adding our cert is fatal —
|
||||
# claude-code's TLS handshake against the egress-MITM'd
|
||||
# api.anthropic.com would fail downstream. Bail early
|
||||
# with what we can see (output is captured by smolvm so
|
||||
# we can surface it).
|
||||
# with what we can see (output is captured so we can
|
||||
# surface it).
|
||||
die(
|
||||
f"update-ca-certificates didn't add the agent CA "
|
||||
f"(exit {r.returncode}): "
|
||||
@@ -70,21 +70,21 @@ def provision_ca(plan: SmolmachinesBottlePlan, target: str) -> None:
|
||||
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
|
||||
# verification run in one `sh -c` so we only pay one
|
||||
# machine_exec round trip; the `&&` chaining surfaces the
|
||||
# first failure as the return code. The verify check is more
|
||||
# stable than requiring "1 added" in stdout: a retry after a
|
||||
# verification run in one exec so we only pay one
|
||||
# round trip; the `&&` chaining surfaces the first failure
|
||||
# as the return code. The verify check is more stable than
|
||||
# requiring "1 added" in stdout: a retry after a
|
||||
# partially-completed first run may legitimately report "0
|
||||
# added" while the cert is already installed.
|
||||
return _smolvm.machine_exec(target, [
|
||||
"sh", "-c",
|
||||
return bottle.exec(
|
||||
f"chown root:root {AGENT_CA_PATH} && "
|
||||
f"chmod 644 {AGENT_CA_PATH} && "
|
||||
f"update-ca-certificates && "
|
||||
f"openssl verify -CAfile {AGENT_CA_BUNDLE} {AGENT_CA_PATH}",
|
||||
])
|
||||
user="root",
|
||||
)
|
||||
|
||||
|
||||
# Re-exported for the launch/provision_ca caller + tests. The path
|
||||
|
||||
@@ -26,35 +26,25 @@ git_gate module."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from ....git_gate import git_gate_render_gitconfig
|
||||
from ....log import info
|
||||
from .. import smolvm as _smolvm
|
||||
from ... import Bottle
|
||||
from ..bottle_plan import SmolmachinesBottlePlan
|
||||
|
||||
|
||||
# `node` is the agent user from the repo Dockerfile. Override via
|
||||
# BOT_BOTTLE_GUEST_HOME mirrors the docker backend's
|
||||
# BOT_BOTTLE_CONTAINER_HOME knob — same purpose, different
|
||||
# transport.
|
||||
_DEFAULT_GUEST_HOME = "/home/node"
|
||||
|
||||
|
||||
def _guest_home() -> str:
|
||||
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
|
||||
no-ops when its condition isn't met."""
|
||||
_provision_cwd_git(plan, target)
|
||||
_provision_git_gate_config(plan, target)
|
||||
_provision_git_user(plan, target)
|
||||
_provision_cwd_git(plan, bottle)
|
||||
_provision_git_gate_config(plan, bottle)
|
||||
_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
|
||||
it into <guest_home>/workspace/.git and fix ownership. No-op
|
||||
otherwise."""
|
||||
@@ -63,25 +53,26 @@ def _provision_cwd_git(plan: SmolmachinesBottlePlan, target: str) -> None:
|
||||
return
|
||||
guest_workspace_git = f"{workspace.guest_path}/.git"
|
||||
host_git = str(workspace.host_path / ".git")
|
||||
info(f"copying {host_git} -> {target}:{guest_workspace_git}")
|
||||
# mkdir -p the workspace dir so `machine cp` lands the .git
|
||||
info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}")
|
||||
# mkdir -p the workspace dir so cp_in lands the .git
|
||||
# directly there even on first-time bottles.
|
||||
_smolvm.machine_exec(target, ["mkdir", "-p", workspace.guest_path])
|
||||
_smolvm.machine_cp(
|
||||
host_git, f"{target}:{guest_workspace_git}",
|
||||
)
|
||||
# `machine cp` lands files as root; the agent runs as node so
|
||||
bottle.exec(f"mkdir -p {shlex.quote(workspace.guest_path)}", user="root")
|
||||
bottle.cp_in(host_git, guest_workspace_git)
|
||||
# cp_in lands files as root; the agent runs as node so
|
||||
# the workspace tree must be chowned over.
|
||||
_smolvm.machine_exec(
|
||||
target, ["chown", "-R", workspace.owner, guest_workspace_git],
|
||||
bottle.exec(
|
||||
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
|
||||
rules. No-op when the bottle has no `git` entries."""
|
||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||
if not bottle.git:
|
||||
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||
if not manifest_bottle.git:
|
||||
return
|
||||
|
||||
# `<loopback alias>:<host port>` form: the bundle's git-gate
|
||||
@@ -90,11 +81,11 @@ def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> Non
|
||||
# TSI, not the docker bridge IP) can dial it. launch.py
|
||||
# populates `plan.agent_git_gate_host` after bundle bringup.
|
||||
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"
|
||||
# Stage the file under the plan's stage_dir so `machine cp`
|
||||
guest_gitconfig = f"{plan.guest_home}/.gitconfig"
|
||||
# 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
|
||||
# by start.py's session-end teardown.
|
||||
with tempfile.NamedTemporaryFile(
|
||||
@@ -105,41 +96,38 @@ def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> Non
|
||||
config_file = Path(f.name)
|
||||
os.chmod(config_file, 0o600)
|
||||
|
||||
info(f"writing {guest_gitconfig} with {len(bottle.git)} insteadOf rule(s)")
|
||||
_smolvm.machine_cp(str(config_file), f"{target}:{guest_gitconfig}")
|
||||
_smolvm.machine_exec(target, ["chown", "node:node", guest_gitconfig])
|
||||
_smolvm.machine_exec(target, ["chmod", "644", guest_gitconfig])
|
||||
info(f"writing {guest_gitconfig} with {len(manifest_bottle.git)} insteadOf rule(s)")
|
||||
bottle.cp_in(str(config_file), guest_gitconfig)
|
||||
bottle.exec(
|
||||
f"chown node:node {shlex.quote(guest_gitconfig)} && "
|
||||
f"chmod 644 {shlex.quote(guest_gitconfig)}",
|
||||
user="root",
|
||||
)
|
||||
|
||||
|
||||
def _provision_git_user(
|
||||
plan: SmolmachinesBottlePlan, target: str,
|
||||
plan: SmolmachinesBottlePlan, bottle: Bottle,
|
||||
) -> None:
|
||||
"""Apply `git config --global user.{name,email}` inside the
|
||||
guest as the node user so --global lands in the same
|
||||
`/home/node/.gitconfig` that `_provision_git_gate_config`
|
||||
writes to. No-op when the bottle didn't declare `git.user`.
|
||||
|
||||
Runs via `runuser -u node --`; HOME is forced via smolvm's
|
||||
`-e` flag because runuser (without -l) inherits root's
|
||||
HOME=/root, which would put --global in the wrong file."""
|
||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||
gu = bottle.git_user
|
||||
SmolmachinesBottle.exec(user="node") automatically sets
|
||||
HOME=/home/node so --global writes to /home/node/.gitconfig."""
|
||||
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||
gu = manifest_bottle.git_user
|
||||
if gu.is_empty():
|
||||
return
|
||||
env = {"HOME": _guest_home(), "USER": "node"}
|
||||
if gu.name:
|
||||
info(f"git config --global user.name = {gu.name!r}")
|
||||
_smolvm.machine_exec(
|
||||
target,
|
||||
["runuser", "-u", "node", "--",
|
||||
"git", "config", "--global", "user.name", gu.name],
|
||||
env=env,
|
||||
bottle.exec(
|
||||
f"git config --global user.name {shlex.quote(gu.name)}",
|
||||
user="node",
|
||||
)
|
||||
if gu.email:
|
||||
info(f"git config --global user.email = {gu.email!r}")
|
||||
_smolvm.machine_exec(
|
||||
target,
|
||||
["runuser", "-u", "node", "--",
|
||||
"git", "config", "--global", "user.email", gu.email],
|
||||
env=env,
|
||||
bottle.exec(
|
||||
f"git config --global user.email {shlex.quote(gu.email)}",
|
||||
user="node",
|
||||
)
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Copy the agent prompt into a running smolmachines bottle.
|
||||
|
||||
The prompt file is always copied (so the in-guest path always
|
||||
exists) but `--append-system-prompt-file` only fires when the
|
||||
agent actually has a prompt — the return value signals which
|
||||
case, mirroring the docker backend's contract.
|
||||
|
||||
`smolvm machine cp` lands files as root inside the VM; the claude
|
||||
process runs as `node`, so we chown + chmod the prompt after the
|
||||
copy. Same flow as the docker backend's provision_prompt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from .. import smolvm as _smolvm
|
||||
from ..bottle_plan import SmolmachinesBottlePlan
|
||||
|
||||
|
||||
# `node` is the agent user from the repo Dockerfile.
|
||||
# BOT_BOTTLE_GUEST_HOME mirrors the docker backend's
|
||||
# BOT_BOTTLE_CONTAINER_HOME knob.
|
||||
_DEFAULT_GUEST_HOME = "/home/node"
|
||||
|
||||
|
||||
def provision_prompt(plan: SmolmachinesBottlePlan, target: str) -> str | None:
|
||||
"""Copy the prompt file into the running smolvm guest, fix
|
||||
ownership/mode. Returns the in-guest path if the agent has a
|
||||
non-empty prompt (drives --append-system-prompt-file), else
|
||||
None. The file is copied either way so the path always
|
||||
exists — mirrors the docker backend's behavior."""
|
||||
guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
|
||||
in_guest_prompt_path = f"{guest_home}/.bot-bottle-prompt.txt"
|
||||
|
||||
_smolvm.machine_cp(str(plan.prompt_file), f"{target}:{in_guest_prompt_path}")
|
||||
# machine cp lands as root, source's 0o600 mode is preserved —
|
||||
# node can't read its own prompt without these two.
|
||||
_smolvm.machine_exec(target, ["chown", "node:node", in_guest_prompt_path])
|
||||
_smolvm.machine_exec(target, ["chmod", "600", in_guest_prompt_path])
|
||||
|
||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||
return in_guest_prompt_path if agent.prompt else None
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Provision non-secret provider auth markers into a smolmachines bottle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ....log import die
|
||||
from .. import smolvm as _smolvm
|
||||
from ..bottle_plan import SmolmachinesBottlePlan
|
||||
|
||||
|
||||
def provision_provider_auth(plan: SmolmachinesBottlePlan, target: str) -> None:
|
||||
"""Apply provider-owned guest setup through smolvm primitives."""
|
||||
provision = plan.agent_provision
|
||||
for d in provision.dirs:
|
||||
_exec(target, ["mkdir", "-p", 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(target, ["chmod", d.mode, d.guest_path], f"could not chmod {d.guest_path}")
|
||||
for command in provision.pre_copy:
|
||||
_exec(target, list(command.argv), command.error)
|
||||
for f in provision.files:
|
||||
_smolvm.machine_cp(str(f.host_path), f"{target}:{f.guest_path}")
|
||||
_exec(target, ["chown", f.owner, 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}")
|
||||
for command in provision.verify:
|
||||
_exec(target, list(command.argv), command.error)
|
||||
|
||||
|
||||
def _exec(target: str, argv: list[str], error: str) -> None:
|
||||
result = _smolvm.machine_exec(target, argv)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout).strip()
|
||||
if detail:
|
||||
detail = f": {detail}"
|
||||
die(f"agent provider provisioning: {error}{detail}")
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Copy host-side skill directories into a running smolmachines
|
||||
bottle.
|
||||
|
||||
Skills are validated on the host before launch by
|
||||
`BottleBackend._validate_skills`; this module assumes that
|
||||
validation has already run. A skill that disappears between
|
||||
validation and copy still dies loudly rather than silently
|
||||
producing a partial guest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from ....log import die, info
|
||||
from ...util import host_skill_dir
|
||||
from .. import smolvm as _smolvm
|
||||
from ..bottle_plan import SmolmachinesBottlePlan
|
||||
|
||||
|
||||
# In-guest path mirrors the docker backend's claude-skills
|
||||
# convention (~/.claude/skills/<name>/) under the node user's
|
||||
# home — same path as the real bot-bottle image's
|
||||
# /home/node/.claude/skills (pre-created in the Dockerfile).
|
||||
_DEFAULT_SKILLS_DIR = "/home/node/.claude/skills"
|
||||
|
||||
|
||||
def provision_skills(plan: SmolmachinesBottlePlan, target: str) -> None:
|
||||
"""Copy each of the agent's named skills from the host's
|
||||
~/.claude/skills/<name>/ into the guest's equivalent path.
|
||||
For each skill: `mkdir -p` the destination, `smolvm machine cp`
|
||||
the host source dir over, then chown the result to node:node so
|
||||
the agent can read it. No-op when the agent has no skills.
|
||||
|
||||
smolvm machine cp on a directory copies recursively (same
|
||||
semantics as `cp -r`); unlike docker cp's trailing-slash
|
||||
convention, smolvm doesn't need the `/.` suffix dance.
|
||||
|
||||
machine cp lands files as root inside the VM, so we chown each
|
||||
skill tree over to node:node after the copy — same pattern as
|
||||
the docker backend's provision_prompt."""
|
||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||
if not agent.skills:
|
||||
return
|
||||
|
||||
skills_dir = os.environ.get(
|
||||
"BOT_BOTTLE_GUEST_SKILLS_DIR", _DEFAULT_SKILLS_DIR,
|
||||
)
|
||||
|
||||
_smolvm.machine_exec(target, ["mkdir", "-p", skills_dir])
|
||||
|
||||
for name in agent.skills:
|
||||
src = host_skill_dir(name)
|
||||
if not os.path.isdir(src):
|
||||
die(
|
||||
f"skill {name!r} disappeared from host between "
|
||||
f"validation and copy at {src}."
|
||||
)
|
||||
dst = f"{skills_dir}/{name}"
|
||||
info(f"copying skill {name} into {target}:{dst}")
|
||||
# Wipe any prior copy so re-runs don't accumulate.
|
||||
_smolvm.machine_exec(target, ["rm", "-rf", dst])
|
||||
_smolvm.machine_cp(src, f"{target}:{dst}")
|
||||
_smolvm.machine_exec(target, ["chown", "-R", "node:node", dst])
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Supervise sidecar provisioning inside a running smolmachines
|
||||
bottle (PRD 0023 chunk 4d; PRD 0013 supervise plane).
|
||||
|
||||
Registers the per-bottle supervise sidecar as an HTTP MCP server
|
||||
in the agent's claude-code config so the agent discovers the
|
||||
stuck-recovery MCP tools (pipelock-block, capability-block) at
|
||||
startup.
|
||||
|
||||
Mirrors `backend.docker.provision.supervise` — same `claude mcp
|
||||
add` call, just dispatched via `smolvm machine exec` instead of
|
||||
`docker exec`, and against `<bundle_ip>:<port>` instead of the
|
||||
short `supervise` alias (no DNS in the TSI-allowlisted guest)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ....log import info, warn
|
||||
from .. import smolvm as _smolvm
|
||||
from ..bottle_plan import SmolmachinesBottlePlan
|
||||
|
||||
|
||||
_SUPERVISE_MCP_NAME = "supervise"
|
||||
|
||||
|
||||
def provision_supervise(plan: SmolmachinesBottlePlan, target: str) -> None:
|
||||
"""Run `claude mcp add` inside the guest to register the
|
||||
supervise sidecar in claude-code's user config. No-op when
|
||||
bottle.supervise is False.
|
||||
|
||||
The URL is the agent-side endpoint launch.py populated after
|
||||
bundle bringup — `http://127.0.0.1:<host port>/` rather than
|
||||
the bundle's docker bridge IP, because that bridge isn't
|
||||
reachable from the smolvm guest on macOS.
|
||||
|
||||
Failure is logged but not fatal: the bottle still works (you
|
||||
just can't call supervise tools from the agent until the entry
|
||||
is added manually). The operator sees the warning at launch."""
|
||||
if plan.supervise_plan is None:
|
||||
return
|
||||
url = plan.agent_supervise_url
|
||||
info(f"registering supervise MCP server in agent claude config → {url}")
|
||||
# `claude mcp add --scope user` writes to ~/.claude.json. The
|
||||
# agent is the `node` user; smolvm machine_exec runs as root
|
||||
# by default, so we have to switch user explicitly and set
|
||||
# HOME so the config lands in /home/node/.claude.json (where
|
||||
# the agent's claude actually reads it from).
|
||||
r = _smolvm.machine_exec(
|
||||
target,
|
||||
[
|
||||
"runuser", "-u", "node", "--",
|
||||
"env", "HOME=/home/node",
|
||||
"claude", "mcp", "add",
|
||||
"--scope", "user",
|
||||
"--transport", "http",
|
||||
_SUPERVISE_MCP_NAME,
|
||||
url,
|
||||
],
|
||||
)
|
||||
if r.returncode != 0:
|
||||
warn(
|
||||
f"`claude mcp add supervise` failed (exit {r.returncode}): "
|
||||
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
|
||||
f"register manually with: "
|
||||
f"claude mcp add --scope user --transport http supervise {url}"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["provision_supervise"]
|
||||
@@ -5,11 +5,11 @@ from __future__ import annotations
|
||||
import shlex
|
||||
|
||||
from ....log import info
|
||||
from .. import smolvm as _smolvm
|
||||
from ... import Bottle
|
||||
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."""
|
||||
workspace = plan.workspace_plan
|
||||
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)
|
||||
owner_q = shlex.quote(workspace.owner)
|
||||
mode_q = shlex.quote(workspace.mode)
|
||||
info(f"copying {workspace.host_path} -> {target}:{workspace.guest_path}")
|
||||
_smolvm.machine_exec(
|
||||
target,
|
||||
["sh", "-c", f"rm -rf {guest_path_q} && mkdir -p {guest_parent_q}"],
|
||||
info(f"copying {workspace.host_path} -> {bottle.name}:{workspace.guest_path}")
|
||||
bottle.exec(
|
||||
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}")
|
||||
_smolvm.machine_exec(
|
||||
target,
|
||||
[
|
||||
"sh", "-c",
|
||||
f"chown -R {owner_q} {guest_path_q} && "
|
||||
f"chmod {mode_q} {guest_path_q}",
|
||||
],
|
||||
bottle.cp_in(str(workspace.host_path), workspace.guest_path)
|
||||
bottle.exec(
|
||||
f"chown -R {owner_q} {guest_path_q} && chmod {mode_q} {guest_path_q}",
|
||||
user="root",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Claude agent provider plugin (PRD 0050, contrib).
|
||||
|
||||
The Claude-specific behavior previously inlined under
|
||||
`agent_provider.agent_provision_plan` (claude.json trust marker,
|
||||
api.anthropic.com egress route, OAuth-token placeholder), plus
|
||||
the `claude mcp add` invocation that registers the supervise
|
||||
sidecar in claude-code's user config (PRD 0013)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...agent_provider import (
|
||||
AgentProvider,
|
||||
AgentProviderRuntime,
|
||||
AgentProvisionFile,
|
||||
AgentProvisionPlan,
|
||||
)
|
||||
from ...egress import EgressRoute
|
||||
from ...log import die, info, warn
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...backend import Bottle, BottlePlan
|
||||
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
_SUPERVISE_MCP_NAME = "supervise"
|
||||
|
||||
|
||||
def _skills_dir(guest_home: str) -> str:
|
||||
return f"{guest_home}/.claude/skills"
|
||||
|
||||
|
||||
def _prompt_path(guest_home: str) -> str:
|
||||
return f"{guest_home}/.bot-bottle-prompt.txt"
|
||||
|
||||
_RUNTIME = AgentProviderRuntime(
|
||||
template="claude",
|
||||
command="claude",
|
||||
image="bot-bottle-claude:latest",
|
||||
dockerfile=str(_REPO_ROOT / "Dockerfile.claude"),
|
||||
prompt_mode="append_file",
|
||||
bypass_args=("--dangerously-skip-permissions",),
|
||||
resume_args=("--continue",),
|
||||
remote_control_args=("--remote-control",),
|
||||
)
|
||||
|
||||
|
||||
class ClaudeAgentProvider(AgentProvider):
|
||||
@property
|
||||
def runtime(self) -> AgentProviderRuntime:
|
||||
return _RUNTIME
|
||||
|
||||
def provision_plan(
|
||||
self,
|
||||
*,
|
||||
dockerfile: str,
|
||||
state_dir: Path,
|
||||
guest_home: str,
|
||||
guest_env: dict[str, str] | None = None,
|
||||
auth_token: str = "",
|
||||
forward_host_credentials: bool = False,
|
||||
host_env: dict[str, str] | None = None,
|
||||
trusted_project_path: str = "",
|
||||
) -> AgentProvisionPlan:
|
||||
del forward_host_credentials, host_env # Codex-only knobs
|
||||
resolved_guest_env = dict(guest_env or {})
|
||||
trusted_path = trusted_project_path or guest_home
|
||||
|
||||
env_vars: dict[str, str] = {
|
||||
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
||||
"DISABLE_ERROR_REPORTING": "1",
|
||||
}
|
||||
claude_config = state_dir / "claude.json"
|
||||
claude_projects = {guest_home: {"hasTrustDialogAccepted": True}}
|
||||
claude_projects[trusted_path] = {"hasTrustDialogAccepted": True}
|
||||
claude_config.write_text(json.dumps({
|
||||
"hasCompletedOnboarding": True,
|
||||
"theme": "dark",
|
||||
"bypassPermissionsModeAccepted": True,
|
||||
"projects": claude_projects,
|
||||
}, indent=2) + "\n")
|
||||
claude_config.chmod(0o600)
|
||||
files = (
|
||||
AgentProvisionFile(claude_config, f"{guest_home}/.claude.json"),
|
||||
)
|
||||
egress_routes = (EgressRoute(
|
||||
host="api.anthropic.com",
|
||||
auth_scheme="Bearer" if auth_token else "",
|
||||
token_ref=auth_token,
|
||||
tls_passthrough=True,
|
||||
),)
|
||||
hidden_env_names: frozenset[str] = frozenset()
|
||||
if auth_token:
|
||||
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
||||
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
|
||||
|
||||
return AgentProvisionPlan(
|
||||
template=_RUNTIME.template,
|
||||
command=_RUNTIME.command,
|
||||
prompt_mode=_RUNTIME.prompt_mode,
|
||||
image=_RUNTIME.image,
|
||||
dockerfile=dockerfile,
|
||||
env_vars=env_vars,
|
||||
guest_env=resolved_guest_env,
|
||||
files=files,
|
||||
egress_routes=egress_routes,
|
||||
hidden_env_names=hidden_env_names,
|
||||
)
|
||||
|
||||
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||
"""Copy each named skill tree from `~/.claude/skills/<name>/`
|
||||
on the host into the guest's claude-code skills dir. No-op
|
||||
when the agent has no skills."""
|
||||
from ...backend.util import host_skill_dir
|
||||
|
||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||
if not agent.skills:
|
||||
return
|
||||
skills_dir = _skills_dir(plan.guest_home)
|
||||
bottle.exec(f"mkdir -p {skills_dir}", user="root")
|
||||
for name in agent.skills:
|
||||
src = host_skill_dir(name)
|
||||
if not os.path.isdir(src):
|
||||
die(
|
||||
f"skill {name!r} disappeared from host between "
|
||||
f"validation and copy at {src}."
|
||||
)
|
||||
dst = f"{skills_dir}/{name}"
|
||||
info(f"copying skill {name} into {bottle.name}:{dst}")
|
||||
bottle.exec(f"rm -rf {dst} && mkdir -p {dst}", user="root")
|
||||
bottle.cp_in(f"{src}/.", f"{dst}/")
|
||||
bottle.exec(f"chown -R node:node {dst}", user="root")
|
||||
|
||||
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
||||
"""Copy the prompt file into the guest, fix ownership/mode.
|
||||
Returns the in-guest path iff the agent has a non-empty
|
||||
prompt (drives `--append-system-prompt-file`); the file is
|
||||
copied either way so the path always exists."""
|
||||
prompt_path = _prompt_path(plan.guest_home)
|
||||
bottle.cp_in(str(plan.prompt_file), prompt_path)
|
||||
bottle.exec(
|
||||
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
|
||||
user="root",
|
||||
)
|
||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||
return prompt_path if agent.prompt else None
|
||||
|
||||
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||
"""Apply the claude-side declarative provision steps from
|
||||
`plan.agent_provision` — today that's the `claude.json`
|
||||
trust-marker file. Hot-replace this with a richer flow as
|
||||
claude-code's harness shape evolves."""
|
||||
provision = plan.agent_provision
|
||||
for d in provision.dirs:
|
||||
path = shlex.quote(d.guest_path)
|
||||
_exec(bottle, f"mkdir -p {path}", f"could not create {d.guest_path}")
|
||||
_exec(
|
||||
bottle,
|
||||
f"chown {shlex.quote(d.owner)} {path}",
|
||||
f"could not chown {d.guest_path}",
|
||||
)
|
||||
_exec(
|
||||
bottle,
|
||||
f"chmod {shlex.quote(d.mode)} {path}",
|
||||
f"could not chmod {d.guest_path}",
|
||||
)
|
||||
for command in provision.pre_copy:
|
||||
_exec(bottle, shlex.join(command.argv), command.error)
|
||||
for f in provision.files:
|
||||
bottle.cp_in(str(f.host_path), f.guest_path)
|
||||
path = shlex.quote(f.guest_path)
|
||||
_exec(
|
||||
bottle,
|
||||
f"chown {shlex.quote(f.owner)} {path}",
|
||||
f"could not chown {f.guest_path}",
|
||||
)
|
||||
_exec(
|
||||
bottle,
|
||||
f"chmod {shlex.quote(f.mode)} {path}",
|
||||
f"could not chmod {f.guest_path}",
|
||||
)
|
||||
for command in provision.verify:
|
||||
_exec(bottle, shlex.join(command.argv), command.error)
|
||||
|
||||
def provision_supervise_mcp(
|
||||
self,
|
||||
plan: "BottlePlan",
|
||||
bottle: "Bottle",
|
||||
supervise_url: str,
|
||||
) -> None:
|
||||
"""Run `claude mcp add` inside the agent guest to register the
|
||||
supervise sidecar in claude-code's user config (~/.claude.json).
|
||||
|
||||
Failure is logged but not fatal — the bottle still works without
|
||||
the entry; the operator can register it manually."""
|
||||
if plan.supervise_plan is None:
|
||||
return
|
||||
info(f"registering supervise MCP server in agent claude config → {supervise_url}")
|
||||
r = bottle.exec(
|
||||
f"claude mcp add --scope user --transport http "
|
||||
f"{_SUPERVISE_MCP_NAME} {supervise_url}",
|
||||
user="node",
|
||||
)
|
||||
if r.returncode != 0:
|
||||
warn(
|
||||
f"`claude mcp add supervise` failed (exit {r.returncode}): "
|
||||
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
|
||||
f"register manually with: "
|
||||
f"claude mcp add --scope user --transport http supervise {supervise_url}"
|
||||
)
|
||||
|
||||
|
||||
def _exec(bottle: "Bottle", script: str, error: str) -> None:
|
||||
result = bottle.exec(script, user="root")
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout).strip()
|
||||
if detail:
|
||||
detail = f": {detail}"
|
||||
die(f"agent provider provisioning: {error}{detail}")
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Codex agent provider plugin (PRD 0050, contrib).
|
||||
|
||||
The Codex-specific behavior previously inlined under
|
||||
`agent_provider.agent_provision_plan` (config.toml trust marker,
|
||||
chatgpt.com / api.openai.com egress routes, optional host-credential
|
||||
forwarding with dummy-auth.json + verify), plus the `codex mcp add`
|
||||
invocation that registers the supervise sidecar in Codex's
|
||||
~/.codex/config.toml (PRD 0050)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...agent_provider import (
|
||||
CODEX_HOST_CREDENTIAL_HOSTS,
|
||||
AgentProvider,
|
||||
AgentProviderRuntime,
|
||||
AgentProvisionCommand,
|
||||
AgentProvisionDir,
|
||||
AgentProvisionFile,
|
||||
AgentProvisionPlan,
|
||||
)
|
||||
from ...codex_auth import codex_host_access_token, write_codex_dummy_auth_file
|
||||
from ...egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
|
||||
from ...log import die, info, warn
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...backend import Bottle, BottlePlan
|
||||
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
_SUPERVISE_MCP_NAME = "supervise"
|
||||
|
||||
|
||||
def _skills_dir(guest_home: str) -> str:
|
||||
# Codex agents still read skills from the claude-code convention
|
||||
# (~/.claude/skills/) — the bot-bottle-codex image follows the
|
||||
# same layout. If Codex grows native skill discovery later,
|
||||
# change here.
|
||||
return f"{guest_home}/.claude/skills"
|
||||
|
||||
|
||||
def _prompt_path(guest_home: str) -> str:
|
||||
return f"{guest_home}/.bot-bottle-prompt.txt"
|
||||
|
||||
_RUNTIME = AgentProviderRuntime(
|
||||
template="codex",
|
||||
command="codex",
|
||||
image="bot-bottle-codex:latest",
|
||||
dockerfile=str(_REPO_ROOT / "Dockerfile.codex"),
|
||||
prompt_mode="read_prompt_file",
|
||||
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
||||
resume_args=("resume", "--last"),
|
||||
remote_control_args=(),
|
||||
)
|
||||
|
||||
|
||||
class CodexAgentProvider(AgentProvider):
|
||||
@property
|
||||
def runtime(self) -> AgentProviderRuntime:
|
||||
return _RUNTIME
|
||||
|
||||
def provision_plan(
|
||||
self,
|
||||
*,
|
||||
dockerfile: str,
|
||||
state_dir: Path,
|
||||
guest_home: str,
|
||||
guest_env: dict[str, str] | None = None,
|
||||
auth_token: str = "",
|
||||
forward_host_credentials: bool = False,
|
||||
host_env: dict[str, str] | None = None,
|
||||
trusted_project_path: str = "",
|
||||
) -> AgentProvisionPlan:
|
||||
del auth_token # Claude-only knob
|
||||
resolved_guest_env = dict(guest_env or {})
|
||||
trusted_path = trusted_project_path or guest_home
|
||||
|
||||
env_vars: dict[str, str] = {
|
||||
"CODEX_CA_CERTIFICATE": "/etc/ssl/certs/ca-certificates.crt",
|
||||
}
|
||||
auth_dir = resolved_guest_env.get("CODEX_HOME", f"{guest_home}/.codex")
|
||||
if forward_host_credentials:
|
||||
env_vars["CODEX_HOME"] = auth_dir
|
||||
|
||||
dirs = [AgentProvisionDir(auth_dir)]
|
||||
files: list[AgentProvisionFile] = []
|
||||
pre_copy: list[AgentProvisionCommand] = []
|
||||
verify: list[AgentProvisionCommand] = []
|
||||
provisioned_env: dict[str, str] = {}
|
||||
|
||||
config_path = f"{auth_dir}/config.toml"
|
||||
config_file = state_dir / "codex-config.toml"
|
||||
toml_path = trusted_path.replace("\\", "\\\\").replace('"', '\\"')
|
||||
config_file.write_text(
|
||||
f'[projects."{toml_path}"]\n'
|
||||
'trust_level = "trusted"\n'
|
||||
)
|
||||
config_file.chmod(0o600)
|
||||
files.append(AgentProvisionFile(config_file, config_path))
|
||||
|
||||
egress_routes: list[EgressRoute] = []
|
||||
for host in CODEX_HOST_CREDENTIAL_HOSTS:
|
||||
egress_routes.append(EgressRoute(
|
||||
host=host,
|
||||
auth_scheme="Bearer" if forward_host_credentials else "",
|
||||
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials else "",
|
||||
tls_passthrough=True,
|
||||
))
|
||||
|
||||
if forward_host_credentials:
|
||||
_host_env = host_env or dict(os.environ)
|
||||
provisioned_env[CODEX_HOST_CREDENTIAL_TOKEN_REF] = (
|
||||
codex_host_access_token(_host_env)
|
||||
)
|
||||
auth_file = state_dir / "codex-auth.json"
|
||||
write_codex_dummy_auth_file(auth_file, _host_env)
|
||||
files.append(AgentProvisionFile(auth_file, f"{auth_dir}/auth.json"))
|
||||
pre_copy.append(AgentProvisionCommand((
|
||||
"find", auth_dir,
|
||||
"-maxdepth", "1",
|
||||
"-type", "f",
|
||||
"(",
|
||||
"-name", "*.sqlite",
|
||||
"-o", "-name", "*.sqlite-*",
|
||||
"-o", "-name", "*.codex-repair-*.bak",
|
||||
")",
|
||||
"-delete",
|
||||
), "codex host credentials: could not reset runtime db files"))
|
||||
verify.append(AgentProvisionCommand((
|
||||
"runuser", "-u", "node", "--",
|
||||
"env",
|
||||
f"HOME={guest_home}",
|
||||
f"CODEX_HOME={auth_dir}",
|
||||
"codex", "login", "status",
|
||||
), (
|
||||
"codex host credentials: dummy auth was copied into the "
|
||||
"guest, but Codex did not accept it"
|
||||
)))
|
||||
|
||||
return AgentProvisionPlan(
|
||||
template=_RUNTIME.template,
|
||||
command=_RUNTIME.command,
|
||||
prompt_mode=_RUNTIME.prompt_mode,
|
||||
image=_RUNTIME.image,
|
||||
dockerfile=dockerfile,
|
||||
env_vars=env_vars,
|
||||
guest_env=resolved_guest_env,
|
||||
dirs=tuple(dirs),
|
||||
files=tuple(files),
|
||||
pre_copy=tuple(pre_copy),
|
||||
verify=tuple(verify),
|
||||
egress_routes=tuple(egress_routes),
|
||||
provisioned_env=provisioned_env,
|
||||
)
|
||||
|
||||
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||
"""Copy each named skill tree from `~/.claude/skills/<name>/`
|
||||
on the host into the guest. No-op when the agent has no
|
||||
skills."""
|
||||
from ...backend.util import host_skill_dir
|
||||
|
||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||
if not agent.skills:
|
||||
return
|
||||
skills_dir = _skills_dir(plan.guest_home)
|
||||
bottle.exec(f"mkdir -p {skills_dir}", user="root")
|
||||
for name in agent.skills:
|
||||
src = host_skill_dir(name)
|
||||
if not os.path.isdir(src):
|
||||
die(
|
||||
f"skill {name!r} disappeared from host between "
|
||||
f"validation and copy at {src}."
|
||||
)
|
||||
dst = f"{skills_dir}/{name}"
|
||||
info(f"copying skill {name} into {bottle.name}:{dst}")
|
||||
bottle.exec(f"rm -rf {dst} && mkdir -p {dst}", user="root")
|
||||
bottle.cp_in(f"{src}/.", f"{dst}/")
|
||||
bottle.exec(f"chown -R node:node {dst}", user="root")
|
||||
|
||||
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
||||
"""Copy the prompt file into the guest, fix ownership/mode.
|
||||
Codex reads it via the agent's `Read and follow the
|
||||
instructions in <path>.` bootstrap (see `prompt_args`); the
|
||||
file is copied either way so the path always exists."""
|
||||
prompt_path = _prompt_path(plan.guest_home)
|
||||
bottle.cp_in(str(plan.prompt_file), prompt_path)
|
||||
bottle.exec(
|
||||
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
|
||||
user="root",
|
||||
)
|
||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||
return prompt_path if agent.prompt else None
|
||||
|
||||
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||
"""Apply the codex-side declarative provision steps from
|
||||
`plan.agent_provision`: the `~/.codex/` dir + config.toml
|
||||
trust marker, plus the dummy-auth.json drop + `codex login
|
||||
status` verify when host-credential forwarding is on."""
|
||||
provision = plan.agent_provision
|
||||
for d in provision.dirs:
|
||||
path = shlex.quote(d.guest_path)
|
||||
_exec(bottle, f"mkdir -p {path}", f"could not create {d.guest_path}")
|
||||
_exec(
|
||||
bottle,
|
||||
f"chown {shlex.quote(d.owner)} {path}",
|
||||
f"could not chown {d.guest_path}",
|
||||
)
|
||||
_exec(
|
||||
bottle,
|
||||
f"chmod {shlex.quote(d.mode)} {path}",
|
||||
f"could not chmod {d.guest_path}",
|
||||
)
|
||||
for command in provision.pre_copy:
|
||||
_exec(bottle, shlex.join(command.argv), command.error)
|
||||
for f in provision.files:
|
||||
bottle.cp_in(str(f.host_path), f.guest_path)
|
||||
path = shlex.quote(f.guest_path)
|
||||
_exec(
|
||||
bottle,
|
||||
f"chown {shlex.quote(f.owner)} {path}",
|
||||
f"could not chown {f.guest_path}",
|
||||
)
|
||||
_exec(
|
||||
bottle,
|
||||
f"chmod {shlex.quote(f.mode)} {path}",
|
||||
f"could not chmod {f.guest_path}",
|
||||
)
|
||||
for command in provision.verify:
|
||||
_exec(bottle, shlex.join(command.argv), command.error)
|
||||
|
||||
def provision_supervise_mcp(
|
||||
self,
|
||||
plan: "BottlePlan",
|
||||
bottle: "Bottle",
|
||||
supervise_url: str,
|
||||
) -> None:
|
||||
"""Run `codex mcp add` inside the agent guest to register the
|
||||
supervise sidecar in Codex's user config (~/.codex/config.toml).
|
||||
|
||||
Mirrors the Claude provider's `claude mcp add` flow — failure
|
||||
is logged but not fatal."""
|
||||
if plan.supervise_plan is None:
|
||||
return
|
||||
info(f"registering supervise MCP server in agent codex config → {supervise_url}")
|
||||
r = bottle.exec(
|
||||
f"codex mcp add --transport http "
|
||||
f"{_SUPERVISE_MCP_NAME} {supervise_url}",
|
||||
user="node",
|
||||
)
|
||||
if r.returncode != 0:
|
||||
warn(
|
||||
f"`codex mcp add supervise` failed (exit {r.returncode}): "
|
||||
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
|
||||
f"register manually with: "
|
||||
f"codex mcp add --transport http supervise {supervise_url}"
|
||||
)
|
||||
|
||||
|
||||
def _exec(bottle: "Bottle", script: str, error: str) -> None:
|
||||
result = bottle.exec(script, user="root")
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout).strip()
|
||||
if detail:
|
||||
detail = f": {detail}"
|
||||
die(f"agent provider provisioning: {error}{detail}")
|
||||
@@ -0,0 +1,401 @@
|
||||
# PRD 0050: Move provider-specific agent logic into contrib
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** claude
|
||||
- **Created:** 2026-06-03
|
||||
- **Issue:** #177
|
||||
|
||||
## Summary
|
||||
|
||||
The agent provider module (`bot_bottle/agent_provider.py`) hard-codes
|
||||
the Claude- and Codex-specific provisioning rules — auth file shapes,
|
||||
trust-dialog markers, egress routes, dummy-auth dance, env vars — in a
|
||||
single `if template == "codex": ... if template == "claude": ...`
|
||||
chain (lines 154–230 today). Other pieces of provider behavior live in
|
||||
each backend's `provision/` directory (`provision_skills`,
|
||||
`provision_prompt`, `provision_provider_auth`, `provision_supervise`),
|
||||
duplicated once per backend, even though almost none of what they do
|
||||
is actually backend-specific.
|
||||
|
||||
This PRD reshapes the agent provider into a proper plugin boundary.
|
||||
The two existing providers (Claude, Codex) move out of `agent_provider`
|
||||
into `bot_bottle/contrib/claude/` and `bot_bottle/contrib/codex/` —
|
||||
the same `contrib/` layout PRD 0048 established for the Gitea
|
||||
deploy-key provisioner. The four provisioner methods backends
|
||||
currently duplicate move into the provider plugin itself; the backend
|
||||
keeps only the bottle-side primitives (`cp_in`, `exec`) the plugin
|
||||
calls through. MCP server registration becomes a first-class part of
|
||||
the provider contract so Codex finally gets the supervise sidecar
|
||||
wired in alongside Claude.
|
||||
|
||||
The shipping artifact is two new provider plugins under `contrib/`, a
|
||||
narrower `AgentProvider` ABC in `bot_bottle/agent_provider.py`, four
|
||||
fewer provisioner hooks on `BottleBackend`, and a supervise-MCP entry
|
||||
visible from the Codex agent at launch.
|
||||
|
||||
## Problem
|
||||
|
||||
Three concrete pains, all downstream of the provider abstraction not
|
||||
being where the work happens:
|
||||
|
||||
1. **Adding a third provider is a five-file edit.** A hypothetical
|
||||
Gemini or Aider provider has to: (a) add a branch in
|
||||
`agent_provision_plan`, (b) add a runtime entry in `_RUNTIMES`,
|
||||
(c) thread a `prompt_mode` enum value, (d) potentially extend
|
||||
`provision_provider_auth` per backend, (e) wire MCP registration
|
||||
into both `backend/docker/provision/supervise.py` and
|
||||
`backend/smolmachines/provision/supervise.py`. Nothing about that
|
||||
spread is load-bearing; it's leftover from when there was one
|
||||
provider.
|
||||
|
||||
2. **MCP server registration is Claude-only.** Both
|
||||
`backend/docker/provision/supervise.py` and
|
||||
`backend/smolmachines/provision/supervise.py` run `claude mcp add`
|
||||
verbatim. Codex bottles silently get no MCP entry — the sidecar
|
||||
is running, the routes are open, but the agent can't see the
|
||||
tools because nothing wrote them into Codex's TOML config. Today
|
||||
this is a latent gap. The provider plugin is the only layer that
|
||||
knows how a given agent discovers MCP servers, so that's where
|
||||
the registration belongs.
|
||||
|
||||
3. **`provision_skills` / `provision_prompt` / `provision_provider_auth`
|
||||
are duplicated between backends.** Each backend has its own
|
||||
~50-line copy. The differences are entirely about which path the
|
||||
backend uses for `cp_in` and what user it `chown`s to. Same
|
||||
business logic, two implementations, two test surfaces, two
|
||||
places to update when the rules change.
|
||||
|
||||
The agent_provider module is the right home for all of this. It already
|
||||
owns the `AgentProvisionPlan` (the declarative description of what
|
||||
needs to land in the guest); extending it to own the imperative
|
||||
"actually land it" step is the natural next move. Putting
|
||||
provider-specific code under `contrib/` mirrors the convention PRD 0048
|
||||
established and keeps the core package provider-agnostic.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
1. `bot_bottle/agent_provider.py` contains no Claude- or
|
||||
Codex-specific branches. The Claude and Codex template strings
|
||||
themselves still live in the core module (they're the public
|
||||
manifest values), but everything keyed off them moves out.
|
||||
2. `bot_bottle/contrib/claude/agent_provider.py` and
|
||||
`bot_bottle/contrib/codex/agent_provider.py` exist and contain
|
||||
the provider-specific behavior previously in lines 154–230 of
|
||||
`agent_provider.py`. Each is reachable from the core registry via
|
||||
a lazy import (the same pattern PRD 0048 used for
|
||||
`GiteaDeployKeyProvisioner`).
|
||||
3. `AgentProvider` is an ABC (or protocol) with at minimum:
|
||||
- `provision_plan(...) -> AgentProvisionPlan` — what the existing
|
||||
`agent_provision_plan` produces today, scoped to one provider.
|
||||
- `provision_skills(bottle, plan)` — copy host skills into the guest.
|
||||
- `provision_prompt(bottle, plan)` — copy the prompt file, return
|
||||
the in-guest path (or None).
|
||||
- `provision_supervise_mcp(bottle, plan, supervise_url)` — register
|
||||
the supervise sidecar in the provider's MCP config. No-op when
|
||||
the bottle has no supervise sidecar.
|
||||
- The Claude implementation runs `claude mcp add`. The Codex
|
||||
implementation writes the corresponding entry into
|
||||
`~/.codex/config.toml`'s `[mcp_servers.supervise]` table.
|
||||
4. `BottleBackend` loses the four abstract methods being moved
|
||||
(`provision_skills`, `provision_prompt`, `provision_provider_auth`,
|
||||
`provision_supervise`). `BottleBackend.provision_in_bottle` calls
|
||||
the provider plugin directly via the bottle and plan it already
|
||||
has. `provision_ca`, `provision_workspace`, and `provision_git`
|
||||
stay on the backend — they're backend infrastructure, not
|
||||
provider behavior.
|
||||
5. `bot_bottle/backend/docker/provision/{skills,prompt,provider_auth,
|
||||
supervise}.py` and `bot_bottle/backend/smolmachines/provision/{skills,
|
||||
prompt,provider_auth,supervise}.py` are deleted. The
|
||||
backend-specific provisioners that remain (`ca`, `git`,
|
||||
`workspace`) stay.
|
||||
6. A Codex bottle launched with `--supervise` shows the
|
||||
supervise MCP server entry in its Codex config and can call
|
||||
supervise tools from inside the bottle (egress-block,
|
||||
pipelock-block, capability-block).
|
||||
7. Existing tests for the moved logic move with the code:
|
||||
provider-specific tests under `tests/unit/test_contrib_claude_*.py`
|
||||
and `tests/unit/test_contrib_codex_*.py`, mirroring
|
||||
`tests/unit/test_contrib_gitea_deploy_key.py`.
|
||||
8. PRD 0050's Status flips Draft → Active in the same commit that
|
||||
removes the last `if template == "claude"` branch from
|
||||
`agent_provider.py`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **A third agent provider.** This PRD reshapes the boundary so a
|
||||
third provider is cheap to add. It does not add one.
|
||||
- **Changing the manifest surface.** The `agent.provider`
|
||||
manifest field still takes `"claude"` or `"codex"`. The set of
|
||||
valid strings is unchanged.
|
||||
- **Changing `AgentProvisionPlan`'s shape.** The dataclasses
|
||||
(`AgentProvisionDir`, `AgentProvisionFile`, `AgentProvisionCommand`,
|
||||
`AgentProvisionPlan` itself) stay in the core module and keep their
|
||||
current fields. Provider plugins produce the same plan shape; only
|
||||
the producer moves.
|
||||
- **Changing the supervise sidecar protocol or the supervise tool
|
||||
surface.** PRDs 0013–0016 stay Active. What changes is how the
|
||||
agent discovers the sidecar's MCP endpoint, not what it does once
|
||||
connected.
|
||||
- **Per-skill provider differences.** A Codex agent and a Claude
|
||||
agent see the same `~/.claude/skills/<name>/` tree today (Codex
|
||||
reads it via its own skills mechanism). This PRD does not change
|
||||
that — `provision_skills` lands the same content for both.
|
||||
- **Removing the `prompt_args` helper from `agent_provider.py`.** It
|
||||
stays at module scope; it's already a pure dispatch on `prompt_mode`
|
||||
and has no Claude/Codex `if` chain to extract.
|
||||
- **`provision_provider_auth` migration.** The issue notes this method
|
||||
is "probably not needed anymore" once each provider owns its own
|
||||
provisioning. After the move, the work that
|
||||
`provision_provider_auth` did (apply `dirs` / `files` / `pre_copy` /
|
||||
`verify` from the plan) becomes a shared helper the per-provider
|
||||
`provision_skills` / `provision_prompt` calls dispatch through —
|
||||
or, more likely, a single `provision(bottle)` entry point on the
|
||||
provider. The hook is removed from `BottleBackend`; whether the
|
||||
underlying loop lives on `AgentProvider` as a default
|
||||
implementation or as a free function in `contrib/_apply.py` is
|
||||
decided at implementation time, not in this PRD.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- New `AgentProvider` ABC in `bot_bottle/agent_provider.py` with the
|
||||
five methods listed under Goal 3. Existing `agent_provision_plan`
|
||||
becomes `AgentProvider.provision_plan`.
|
||||
- New `bot_bottle/contrib/claude/__init__.py`,
|
||||
`bot_bottle/contrib/claude/agent_provider.py`,
|
||||
`bot_bottle/contrib/codex/__init__.py`,
|
||||
`bot_bottle/contrib/codex/agent_provider.py`. Each defines a
|
||||
`ClaudeAgentProvider` / `CodexAgentProvider` class.
|
||||
- A `get_provider(template) -> AgentProvider` registry in
|
||||
`bot_bottle/agent_provider.py`, lazy-imported from `contrib/`,
|
||||
mirroring `get_provisioner(provider, ...)` in
|
||||
`bot_bottle/deploy_key_provisioner.py`.
|
||||
- Backend changes:
|
||||
- `BottleBackend.provision_in_bottle` resolves the provider once
|
||||
and calls `provider.provision_skills(bottle, plan)`,
|
||||
`provider.provision_prompt(bottle, plan)`, and
|
||||
`provider.provision_supervise_mcp(bottle, plan, url)` in place
|
||||
of the current four abstract hooks.
|
||||
- `BottleBackend.provision_skills`, `provision_prompt`,
|
||||
`provision_provider_auth`, `provision_supervise` are removed.
|
||||
- Docker and smolmachines backends remove their corresponding
|
||||
`provision_*` implementations and the
|
||||
`backend/<name>/provision/{skills,prompt,provider_auth,
|
||||
supervise}.py` modules.
|
||||
- Codex MCP wiring: `CodexAgentProvider.provision_supervise_mcp`
|
||||
writes a `[mcp_servers.supervise]` block into
|
||||
`~/.codex/config.toml` pointing at the same agent-side supervise
|
||||
URL the Claude provider uses. The file already exists from the
|
||||
trust-dialog step; the MCP entry is appended (or the file is
|
||||
rewritten in a single shot, whichever's simpler).
|
||||
- Tests migrate. Backend tests that targeted the four moved
|
||||
provisioners are rewritten against the provider plugin, with one
|
||||
test file per provider mirroring `tests/unit/test_contrib_gitea_*.py`.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Adding a manifest field for "extra MCP servers the agent should
|
||||
see". The supervise sidecar is the only MCP server provisioned
|
||||
today, and the issue's "Add mcp server configuring into agent
|
||||
provision" line is about the supervise sidecar specifically. A
|
||||
general-purpose user-declared MCP list is a follow-up if and when
|
||||
the need surfaces.
|
||||
- Refactoring `AgentProvisionPlan`'s dataclasses. They stay byte-
|
||||
for-byte the same so the diff is purely "who owns the producer".
|
||||
- A `BottleBackend.provision_provider_auth` shim during transition.
|
||||
The hook is removed in one cut; the only caller is the backend
|
||||
itself, no manifest consumers reference it.
|
||||
- Renaming `agent_provider.py` → `agent_providers/`. The module
|
||||
still has core dataclasses + the ABC + the registry; it's a single
|
||||
file's worth of code.
|
||||
|
||||
## Proposed design
|
||||
|
||||
### Module shape after the cut
|
||||
|
||||
```
|
||||
bot_bottle/agent_provider.py
|
||||
PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_TEMPLATES
|
||||
PromptMode (Literal)
|
||||
AgentProvisionDir, AgentProvisionFile, AgentProvisionCommand,
|
||||
AgentProvisionPlan (dataclasses, unchanged)
|
||||
AgentProviderRuntime (dataclass — template/command/image/etc.)
|
||||
AgentProvider (ABC)
|
||||
.runtime() -> AgentProviderRuntime
|
||||
.provision_plan(state_dir, ..., trusted_project_path, ...) -> AgentProvisionPlan
|
||||
.provision_skills(bottle, plan) -> None
|
||||
.provision_prompt(bottle, plan) -> str | None
|
||||
.provision_supervise_mcp(bottle, plan, supervise_url) -> None
|
||||
get_provider(template: str) -> AgentProvider # lazy-imports contrib
|
||||
prompt_args(prompt_mode, prompt_path, *, argv) # unchanged
|
||||
|
||||
bot_bottle/contrib/claude/agent_provider.py
|
||||
ClaudeAgentProvider(AgentProvider)
|
||||
_RUNTIME = AgentProviderRuntime(template="claude", ...)
|
||||
.provision_plan(...) # owns the lines-204–230 chunk
|
||||
.provision_skills(...) # was backend/<name>/provision/skills.py
|
||||
.provision_prompt(...) # was backend/<name>/provision/prompt.py
|
||||
.provision_supervise_mcp(...)# was backend/<name>/provision/supervise.py
|
||||
|
||||
bot_bottle/contrib/codex/agent_provider.py
|
||||
CodexAgentProvider(AgentProvider)
|
||||
_RUNTIME = AgentProviderRuntime(template="codex", ...)
|
||||
.provision_plan(...) # owns the lines-154–204 chunk
|
||||
.provision_skills(...) # same as Claude impl, factored to shared helper
|
||||
.provision_prompt(...) # same as Claude impl, factored to shared helper
|
||||
.provision_supervise_mcp(...)# writes [mcp_servers.supervise] to config.toml
|
||||
```
|
||||
|
||||
The skills / prompt / provider-auth-apply implementations are 99%
|
||||
identical across providers — `cp_in` then `chown` / `chmod`. They are
|
||||
extracted to small free functions in
|
||||
`bot_bottle/contrib/_provision_apply.py` (or kept as default
|
||||
implementations on `AgentProvider` if every concrete subclass would
|
||||
just call them). Picked at implementation time; both options match
|
||||
PRD 0048's contrib convention. The visible contract is that
|
||||
provisioning lives on the provider plugin.
|
||||
|
||||
### MCP registration for Codex
|
||||
|
||||
Codex reads MCP servers from `~/.codex/config.toml` (or whatever
|
||||
`CODEX_HOME/config.toml` resolves to). The provider already writes
|
||||
this file once during `provision_plan` to set the project trust
|
||||
level. `CodexAgentProvider.provision_supervise_mcp` extends the
|
||||
existing write: same path, append a `[mcp_servers.supervise]` table
|
||||
pointing at the agent-side supervise URL.
|
||||
|
||||
Two implementation routes worth flagging:
|
||||
|
||||
- **Option A:** Pre-bake the MCP entry in the same config-write that
|
||||
happens during `provision_plan`, before bottle launch. Simpler;
|
||||
the supervise URL has to be known at plan time, which means
|
||||
`provision_plan` needs the supervise URL (or a sentinel that means
|
||||
"fill this in"). The smolmachines backend already plumbs
|
||||
`agent_supervise_url` through to its provision_supervise step, so
|
||||
the value is available.
|
||||
- **Option B:** Append at bottle-launch time via a `bottle.exec`
|
||||
that writes to the file inside the guest, matching the
|
||||
`claude mcp add` flow. Slower but uniform with how
|
||||
`ClaudeAgentProvider.provision_supervise_mcp` works.
|
||||
|
||||
Option B is the symmetric choice and the one this PRD assumes.
|
||||
The implementer can switch to A if Option B turns out to need a
|
||||
TOML-merge primitive the codebase doesn't already have.
|
||||
|
||||
### Backend after the cut
|
||||
|
||||
```python
|
||||
class BottleBackend:
|
||||
def provision_in_bottle(self, plan, bottle, supervise_url):
|
||||
provider = get_provider(plan.spec.manifest.agents[
|
||||
plan.spec.agent_name].provider)
|
||||
self.provision_ca(plan, bottle)
|
||||
prompt_path = provider.provision_prompt(bottle, plan)
|
||||
provider.provision_skills(bottle, plan)
|
||||
self.provision_workspace(plan, bottle)
|
||||
self.provision_git(plan, bottle)
|
||||
provider.provision_supervise_mcp(bottle, plan, supervise_url)
|
||||
return prompt_path
|
||||
```
|
||||
|
||||
`supervise_url` is the existing per-backend "where does the agent
|
||||
reach the sidecar from inside the guest" value. The Docker backend
|
||||
passes `http://supervise:<port>/`; smolmachines passes the
|
||||
`http://127.0.0.1:<port>/` it already computed. The backend's only
|
||||
remaining provider-touching duty is "tell the provider what the
|
||||
sidecar URL is".
|
||||
|
||||
### Registry
|
||||
|
||||
```python
|
||||
# bot_bottle/agent_provider.py
|
||||
def get_provider(template: str) -> AgentProvider:
|
||||
if template == PROVIDER_CLAUDE:
|
||||
from bot_bottle.contrib.claude.agent_provider import (
|
||||
ClaudeAgentProvider,
|
||||
)
|
||||
return ClaudeAgentProvider()
|
||||
if template == PROVIDER_CODEX:
|
||||
from bot_bottle.contrib.codex.agent_provider import (
|
||||
CodexAgentProvider,
|
||||
)
|
||||
return CodexAgentProvider()
|
||||
raise ValueError(f"unknown agent provider template: {template!r}")
|
||||
```
|
||||
|
||||
Lazy imports keep core import-time graph small and match PRD 0048.
|
||||
|
||||
## Implementation chunks
|
||||
|
||||
Each chunk is one commit on the PR; the PR ships as one cut.
|
||||
|
||||
1. **Lift `AgentProvider` ABC + registry.** Add the ABC and
|
||||
`get_provider` next to the existing `agent_provision_plan`
|
||||
function. Have `agent_provision_plan` delegate to
|
||||
`get_provider(template).provision_plan(...)` so callers keep
|
||||
working through the transition.
|
||||
2. **Move provider-specific `provision_plan` content into
|
||||
contrib.** Create `contrib/claude/` and `contrib/codex/`. The
|
||||
Claude and Codex branches of `agent_provision_plan` move into
|
||||
the respective provider classes. The shared scaffolding
|
||||
(initial dict setup, final `AgentProvisionPlan(...)` return)
|
||||
stays in the ABC as a template method or moves into each
|
||||
subclass — whichever needs less indirection.
|
||||
3. **Move backend provisioners onto the provider.** Add
|
||||
`provision_skills`, `provision_prompt`, `provision_supervise_mcp`
|
||||
to `AgentProvider` (with a shared apply helper for skills /
|
||||
prompt). Update `BottleBackend.provision_in_bottle` to call them.
|
||||
Delete the four backend hook methods and the eight
|
||||
`backend/<name>/provision/{skills,prompt,provider_auth,supervise}.py`
|
||||
modules.
|
||||
4. **Add Codex MCP support.** Implement
|
||||
`CodexAgentProvider.provision_supervise_mcp` against
|
||||
`~/.codex/config.toml`. Add a unit test that runs the method
|
||||
against an in-memory FakeBottle and asserts the
|
||||
`[mcp_servers.supervise]` block is present.
|
||||
5. **Migrate tests.** Per-backend tests for the moved
|
||||
provisioners turn into per-provider tests under
|
||||
`tests/unit/test_contrib_claude_*.py` and
|
||||
`tests/unit/test_contrib_codex_*.py`. Keep one integration-style
|
||||
test per backend that confirms `provision_in_bottle` still
|
||||
reaches every step.
|
||||
6. **Activate.** Flip Status: Draft → Active in this PRD; close
|
||||
#177 on merge.
|
||||
|
||||
## Open questions (resolved)
|
||||
|
||||
1. **`codex mcp add` exists.** Implementation calls
|
||||
`codex mcp add --transport http supervise <url>` as `node` —
|
||||
symmetric with `claude mcp add` (no `--scope user`; Codex writes
|
||||
`~/.codex/config.toml` by default). Failure logs a warning; the
|
||||
bottle still works without the entry.
|
||||
2. **Each provider owns its apply steps end-to-end.** The base
|
||||
ABC declares `provision_skills` / `provision_prompt` /
|
||||
`provision` as abstract; each concrete provider implements its
|
||||
own copy loop. No shared `_provision_apply.py`. The apply
|
||||
sequences look similar today, but Claude and Codex harnesses
|
||||
diverge over time (codex already grew a dummy-auth dance + a
|
||||
`codex login status` verify with no Claude analogue) and the
|
||||
"shared because both happen to call cp_in then chown" coupling
|
||||
would just rot. Duplication is intentional.
|
||||
3. **Env knobs removed.** `BOT_BOTTLE_CONTAINER_HOME`,
|
||||
`BOT_BOTTLE_GUEST_HOME`, `BOT_BOTTLE_CONTAINER_SKILLS_DIR`, and
|
||||
`BOT_BOTTLE_GUEST_SKILLS_DIR` are gone; `/home/node` is hardcoded
|
||||
everywhere it was read. The values were effectively constants;
|
||||
the knobs added surface area for no real flexibility.
|
||||
|
||||
## References
|
||||
|
||||
- Issue
|
||||
[#177](https://gitea.dideric.is/didericis/bot-bottle/issues/177)
|
||||
— the request: move provider logic into contrib, add MCP
|
||||
configuration to agent provision, rename provision_supervise →
|
||||
provision_supervise_mcp, ensure Codex gets MCP provisioned.
|
||||
- PRD 0013 — supervise plane foundation (defines the MCP-discoverable
|
||||
block-remediation tools this PRD makes available to Codex).
|
||||
- PRD 0048 — SSH deploy key provisioning (the `contrib/` convention
|
||||
this PRD follows).
|
||||
- Current source:
|
||||
[agent_provider.py L154-L230](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/bot_bottle/agent_provider.py#L154-L230)
|
||||
— the provider-specific block this PRD relocates to contrib.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Gitea Webhook Agent Dispatch
|
||||
|
||||
## Question
|
||||
|
||||
How should bot-bottle spawn and manage agents in response to Gitea PR events — and how do we reuse the same agent (with its full session context) across every event in a PR's lifecycle?
|
||||
|
||||
## Summary
|
||||
|
||||
A lightweight webhook receiver maps Gitea PR events to `cli.py` invocations. Spawning is straightforward: the existing work on non-interactive run mode (see [host-dispatch-to-container-agents.md](host-dispatch-to-container-agents.md)) is the missing piece. Session continuity is harder: it requires tracking two identifiers per open PR — the **bottle identity** (bot-bottle's slug for the container state dir) and the **Claude session ID** (the UUID Claude writes to its JSONL transcript). The transcript snapshot mechanism already used by capability-block is the right foundation; it just needs a non-interactive path and a PR-keyed store.
|
||||
|
||||
## Gitea Webhook Events for PR Lifecycle
|
||||
|
||||
Gitea fires `X-Gitea-Event: pull_request` (with an `action` field) for most PR state changes. The payload always includes `pull_request.number`, which is the stable key for correlating events to a running agent.
|
||||
|
||||
| `X-Gitea-Event` value | Relevant `action` values | When it fires |
|
||||
|---|---|---|
|
||||
| `pull_request` | `opened`, `reopened`, `closed`, `synchronized` | PR created, closed, or pushed to |
|
||||
| `pull_request_comment` | `created`, `edited` | Timeline comment posted |
|
||||
| `pull_request_review_approved` | — | Review submitted with approval |
|
||||
| `pull_request_review_rejected` | — | Review submitted requesting changes |
|
||||
| `pull_request_review_comment` | — | Inline code review comment |
|
||||
| `pull_request_sync` | — | New commits pushed to the PR branch |
|
||||
|
||||
`pull_request` with `action: synchronized` and `pull_request_sync` both fire on push; they carry the same information but are separate subscriptions in the webhook config UI. Subscribe to `pull_request` and `pull_request_review` (the umbrella) plus `pull_request_comment` to cover the full lifecycle.
|
||||
|
||||
The webhook receiver validates the `X-Gitea-Signature-256` HMAC header (SHA-256 of the raw body, keyed by the configured secret) before dispatching.
|
||||
|
||||
## Spawning an Agent From a Webhook
|
||||
|
||||
### What we need from bot-bottle
|
||||
|
||||
The current `cli.py start` is interactive — it prompts y/N and attaches a tty. A webhook handler needs a non-interactive mode that:
|
||||
|
||||
1. Starts the container for a named agent.
|
||||
2. Runs `claude -p "<task>" --output-format json --dangerously-skip-permissions` inside it (no tty, no session picker).
|
||||
3. Captures stdout as JSON, extracts `session_id`.
|
||||
4. Blocks until Claude exits, then tears down.
|
||||
|
||||
The [host-dispatch-to-container-agents](host-dispatch-to-container-agents.md) research proposes `cli.py run <agent> <task>` for exactly this. That command is the prerequisite for everything below. It should return the Claude JSON output so callers can extract `session_id`.
|
||||
|
||||
### Webhook receiver sketch
|
||||
|
||||
The receiver is a small HTTP service (Flask, FastAPI, or a Go net/http handler) running alongside bot-bottle on the host. It:
|
||||
|
||||
1. Validates the HMAC signature.
|
||||
2. Extracts `pull_request.number` and `X-Gitea-Event` / `action`.
|
||||
3. Looks up whether a bottle already exists for this PR number.
|
||||
4. Spawns or resumes accordingly (see next section).
|
||||
5. Optionally posts a comment back to the PR via Gitea API once Claude finishes.
|
||||
|
||||
The receiver does not need to be async or queue-based for a single-repo bot, but should at minimum serialize events for the same PR number (a per-PR lock) to avoid two concurrent sessions clobbering each other's transcript.
|
||||
|
||||
## Reusing the Same Agent Across a PR
|
||||
|
||||
This is the harder problem. Two separate identities need to be tracked and connected:
|
||||
|
||||
### Identity 1: bottle identity (bot-bottle slug)
|
||||
|
||||
The slug is the per-bottle state directory name (`~/.bot-bottle/state/<slug>/`). It's what `cli.py resume <slug>` uses to relaunch a container and mount the preserved state — including the transcript snapshot. This already works for the capability-block flow.
|
||||
|
||||
### Identity 2: Claude session ID
|
||||
|
||||
Claude Code's `--output-format json` response includes a `session_id` UUID. Passing `--resume <session_id>` on a subsequent non-interactive run makes Claude continue from exactly that conversation, with full memory of prior tool calls. `--continue` (which maps to `resume_args` in `agent_provider.py`) only picks up the *most recent* session in the project directory — unsafe when multiple sessions may be running concurrently.
|
||||
|
||||
The session JSONL lives at `~/.claude/projects/<encoded-cwd>/<session_id>.jsonl` inside the container guest. The transcript snapshot (`snapshot_transcript(slug)` in `capability_apply.py`) copies all of `~/.claude` out of the container before teardown, so the JSONL is preserved in `~/.bot-bottle/state/<slug>/transcript/.claude/`. When the bottle is relaunched and the transcript remounted, `claude --resume <session_id>` can find the JSONL at the right path.
|
||||
|
||||
### Per-PR session registry
|
||||
|
||||
The receiver needs a small persistent map:
|
||||
|
||||
```
|
||||
PR number → { bottle_identity: str, claude_session_id: str, agent_name: str }
|
||||
```
|
||||
|
||||
The simplest implementation is a JSON file at `~/.bot-bottle/pr-sessions.json`, written after each successful first-run and updated with each resume. A sqlite database is better if concurrent multi-repo support is needed.
|
||||
|
||||
### Full lifecycle flow
|
||||
|
||||
```
|
||||
PR opened
|
||||
→ webhook: action=opened
|
||||
→ no entry in pr-sessions.json
|
||||
→ cli.py run <agent> "Review PR #N: <title>\n<diff URL>"
|
||||
→ starts container, runs claude -p ... --output-format json
|
||||
→ on success: captures session_id from JSON output
|
||||
→ snapshot_transcript(slug)
|
||||
→ tears down container
|
||||
→ write pr-sessions.json: { pr: N, slug: <slug>, session_id: <uuid> }
|
||||
|
||||
PR gets new commit
|
||||
→ webhook: action=synchronized OR pull_request_sync
|
||||
→ look up pr-sessions.json: found slug + session_id
|
||||
→ cli.py run-resume <slug> --claude-session <session_id> "New commits pushed. Review the diff."
|
||||
→ relaunches container with transcript snapshot mounted
|
||||
→ runs claude -p ... --resume <session_id> --output-format json
|
||||
→ captures new session_id (same or rotated)
|
||||
→ snapshot_transcript(slug) again
|
||||
→ update pr-sessions.json with latest session_id
|
||||
|
||||
Comment @-mentions bot
|
||||
→ webhook: pull_request_comment, action=created
|
||||
→ extract comment body, check for bot mention
|
||||
→ same resume flow as above with comment as the prompt
|
||||
|
||||
PR closed / merged
|
||||
→ webhook: action=closed
|
||||
→ cli.py cleanup <slug> (or equivalent)
|
||||
→ remove from pr-sessions.json
|
||||
```
|
||||
|
||||
### What needs to be built
|
||||
|
||||
| Piece | Status | Notes |
|
||||
|---|---|---|
|
||||
| `cli.py run <agent> <task>` | Missing | Non-interactive start; see host-dispatch research |
|
||||
| `cli.py run-resume <slug> --claude-session <id> <task>` | Missing | Like `resume` but non-interactive, passes `--resume <id>` to claude |
|
||||
| `snapshot_transcript` on clean exit | Exists (PRD 0012) | Already called from `start.py`'s session-end path |
|
||||
| Transcript remount on resume | Exists | `bottle_state.py::transcript_snapshot_dir` → docker cp in on launch |
|
||||
| PR session registry | Missing | Needs to be designed; `~/.bot-bottle/pr-sessions.json` is the simplest start |
|
||||
| Webhook receiver service | Missing | New service; needs to be a declared bottle or run as a host process |
|
||||
|
||||
## Known Rough Edges
|
||||
|
||||
**Session ID is not available from within the session.** The ID is only in the `--output-format json` result, readable after the process exits. There is no env var or hook that exposes it mid-session ([upstream issue #44607](https://github.com/anthropics/claude-code/issues/44607)). For the webhook bot this is fine — the outer receiver reads it from the subprocess result.
|
||||
|
||||
**`--continue` vs `--resume <id>`:** The existing `resume_args = ("--continue",)` in `agent_provider.py` picks up the *most recent* session. For an interactive single-user resume this is fine. For a webhook bot that may have multiple open PRs, it is not safe — two PRs' transcripts would collide if they share a project directory encoding. Use `--resume <session_id>` explicitly.
|
||||
|
||||
**Project directory encoding.** Claude stores sessions keyed by the absolute cwd, encoded as a path. Inside the container the cwd is always `/home/node` or a subdir. As long as every run for the same PR uses the same cwd, `--resume <session_id>` will find the right JSONL. The cwd should be pinned per PR entry in the session registry.
|
||||
|
||||
**Concurrent events for the same PR.** If two webhooks arrive close together (e.g., push + CI comment), the receiver must serialize them. A per-PR asyncio lock or a simple file lock on the session registry entry is enough.
|
||||
|
||||
**Context window growth.** Each resume appends to the same session. A PR with many round trips will eventually hit the context limit. Mitigation options: start a fresh Claude session (new `cli.py run`) periodically and carry forward a summary; or rely on Claude's built-in compaction. The session registry could include a turn count to trigger rotation.
|
||||
|
||||
**Webhook delivery ordering.** Gitea does not guarantee ordered delivery or exactly-once delivery. The receiver should be idempotent (same PR event processed twice should not create two bottles) and should ignore events for closed PRs.
|
||||
|
||||
## Relationship to Existing Bot-Bottle Infrastructure
|
||||
|
||||
The transcript snapshot + bottle identity system (PRD 0012, `capability_apply.py`) was designed for the capability-block flow: an operator-triggered resume after a security event. The webhook flow is the same mechanism on a faster loop driven by Gitea events instead of operator action. The implementation delta is:
|
||||
|
||||
1. Non-interactive run mode (the `cli.py run` gap already identified in host-dispatch research).
|
||||
2. Passing `--resume <session_id>` explicitly rather than `--continue`.
|
||||
3. A PR-keyed registry to connect PR numbers to bottle identities and session IDs.
|
||||
4. A webhook receiver to drive the loop.
|
||||
|
||||
These are additive changes that sit on top of the existing transcript preservation machinery without altering it.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Start with the non-interactive run mode (`cli.py run`) since everything else depends on it. Once that exists, the webhook receiver and session registry are straightforward glue. The receiver should run as a host process (not inside a bottle) since it needs to call `cli.py` and manage the session registry file. Serialize per-PR to avoid concurrency bugs. Use `--resume <session_id>` (not `--continue`) for all resume paths.
|
||||
|
||||
The PR session registry is deliberately minimal to start — a JSON file is fine. If multi-repo or multi-agent scenarios appear, migrating to sqlite is a one-file change.
|
||||
@@ -0,0 +1,278 @@
|
||||
# Local Ollama: Deployment Topology, Harness Selection, and Model Sizing
|
||||
|
||||
Research notes on running Ollama locally for a bot-bottle coding agent workflow.
|
||||
Covers the native-vs-VM question, which harness integrates best with an agent loop,
|
||||
and which models make sense on an RTX 3070 (8 GB VRAM / 30 GB RAM) machine.
|
||||
|
||||
---
|
||||
|
||||
## 1. Deployment topology: native, container, or VM?
|
||||
|
||||
The core question is whether running Ollama in a VM significantly degrades inference
|
||||
performance. The short answer: a full KVM/QEMU VM with GPU passthrough adds roughly
|
||||
2–5% overhead, Docker on Linux adds roughly 1–2%, and LXC containers add sub-1%. None
|
||||
of these are significant for interactive coding use.
|
||||
|
||||
### Native (bare metal)
|
||||
|
||||
Zero overhead, immediate GPU access, simplest setup. The right default for a solo
|
||||
developer doing inference on their own workstation.
|
||||
|
||||
### Docker containers on Linux + NVIDIA
|
||||
|
||||
With `nvidia-container-toolkit` and `--gpus all`, containerized Ollama runs at
|
||||
essentially native speed (~1–2% overhead on Linux). The dramatic exception is macOS,
|
||||
where Docker Desktop runs a Linux VM with no access to Apple's Metal/GPU — inference
|
||||
is 5–6× slower. On Linux/Windows with NVIDIA hardware, Docker is fine.
|
||||
|
||||
Common pitfall: if `docker exec ollama ollama ps` shows 0 GPU layers, the container
|
||||
fell back to CPU. Usual causes: stale VRAM allocation, missing `nvidia-container-toolkit`,
|
||||
or a host driver too old for the container's CUDA version.
|
||||
|
||||
### KVM/QEMU VM with full PCIe passthrough
|
||||
|
||||
Full GPU passthrough makes the GPU invisible to the host while the VM owns it. Overhead
|
||||
from the IOMMU translation layer and virtualized PCIe bus is ~2–5%. This is viable if
|
||||
you need VM-level isolation (snapshotting, migration, separate kernel). Setup complexity
|
||||
is non-trivial: BIOS IOMMU, IOMMU group management, VFIO driver binding. Once configured
|
||||
it is stable.
|
||||
|
||||
**Critical gotcha:** set the VM's CPU type to `host`. If left at the default
|
||||
(`x86-64-v2-AES` / "QEMU Virtual CPU version 2.5+"), Ollama may silently disable GPU
|
||||
support even when drivers appear correct.
|
||||
|
||||
### LXC containers (Proxmox et al.)
|
||||
|
||||
The sweet spot for isolation without overhead. Sub-1% performance difference from bare
|
||||
metal because LXC shares the host kernel; GPU device files are bind-mounted into the
|
||||
container. The tradeoff is weaker isolation (shared kernel) and the requirement that
|
||||
host and container driver versions match. Not suitable if you need VM-level snapshots
|
||||
or live migration.
|
||||
|
||||
### Summary
|
||||
|
||||
| Topology | GPU overhead | Isolation | Complexity |
|
||||
|---|---|---|---|
|
||||
| Native | 0% | None | Low |
|
||||
| Docker (Linux) | ~1–2% | Process | Low |
|
||||
| LXC | <1% | Namespace | Medium |
|
||||
| KVM passthrough | 2–5% | Full VM | High |
|
||||
| VM no passthrough | CPU-only | Full VM | Medium |
|
||||
|
||||
Running Ollama in a VM will **not** significantly slow inference as long as GPU passthrough
|
||||
is configured. Without passthrough (software rendering / CPU fallback) performance
|
||||
collapses — that is what the user is rightly worried about.
|
||||
|
||||
### Local vs. remote server
|
||||
|
||||
| Factor | Local machine | Remote server |
|
||||
|---|---|---|
|
||||
| Latency | Near-zero | Network round-trip; cumulative in agent loops |
|
||||
| Cost | Zero after hardware | Per-token or subscription |
|
||||
| Privacy | 100% on-device | Data leaves the machine |
|
||||
| Model size ceiling | VRAM-limited | No hard limit (671B+ feasible) |
|
||||
| Offline use | Yes | No |
|
||||
| Concurrency under load | Sequential by default | Scales horizontally |
|
||||
|
||||
For agentic coding workflows making 20–50 tool calls per session, network latency
|
||||
accumulates quickly. Local inference eliminates this. A practical hybrid pattern:
|
||||
use the local GPU for routine coding loops; route only to a remote API for tasks
|
||||
requiring a 70B+ model or very long context (>128K tokens).
|
||||
|
||||
---
|
||||
|
||||
## 2. Harness selection
|
||||
|
||||
The landscape in 2026 has settled into three categories: IDE plugins, terminal agents,
|
||||
and chat UIs.
|
||||
|
||||
### Continue.dev — recommended IDE plugin
|
||||
|
||||
Open-source VS Code / JetBrains / Zed / Vim extension. Routes autocomplete, chat, and
|
||||
refactoring commands to any configured LLM backend (Ollama, cloud APIs). The recommended
|
||||
setup uses two models: a small FIM-capable model for inline autocomplete (Qwen2.5-Coder 7B)
|
||||
and a larger model for chat/edit. Handles inline completions, multi-file edits, and
|
||||
codebase-aware chat. No API key, no data leaving the machine.
|
||||
|
||||
### Aider — recommended for git-native terminal workflows
|
||||
|
||||
Terminal-based coding agent. Builds a codebase map before editing, makes changes
|
||||
directly, and auto-commits to git with readable messages. Every change is one
|
||||
`git revert` away. Supports 100+ languages; connects to any Ollama-served model
|
||||
via the OpenAI-compatible API. Best for terminal-first developers who want
|
||||
version-controlled agent interactions. Does not do inline autocomplete.
|
||||
|
||||
### OpenCode — recommended for bot-bottle–style agent loops
|
||||
|
||||
Terminal-based coding agent with 15 built-in tools (bash execution, file read/write/edit,
|
||||
grep, glob, web fetch, MCP support) and connections to 75+ model providers including
|
||||
local Ollama models. This is the closest open-source equivalent to a Claude Code–style
|
||||
plan → tool-call → execute → observe → loop. Native Ollama integration.
|
||||
|
||||
**Critical setup note:** Ollama defaults to a 4096-token context window, which is
|
||||
completely insufficient for an agent loop carrying conversation history, tool schemas,
|
||||
a system prompt, and code simultaneously. Configure at least 64K tokens explicitly
|
||||
in the model's context settings.
|
||||
|
||||
### Cline — agentic VS Code assistant
|
||||
|
||||
VS Code extension that operates as an autonomous agent: plans, edits files, runs commands
|
||||
in a loop, connects to Ollama's local endpoint. Compared to OpenCode it lives inside the
|
||||
IDE rather than the terminal; compared to Continue.dev it is a full agent rather than a
|
||||
plugin. Its system prompt overhead is higher (~7,000–10,000 tokens) than minimal harnesses.
|
||||
|
||||
### Open WebUI / Jan / LM Studio — chat UIs, not coding harnesses
|
||||
|
||||
These are browser or desktop chat interfaces useful for ad-hoc conversations (explaining
|
||||
APIs, drafting documentation, exploring ideas) but without IDE integration, autocomplete,
|
||||
or git integration. LM Studio offers the smoothest onboarding (visual model browser with
|
||||
VRAM estimates). Jan is the most privacy-auditable (fully open-source, Apache 2.0, no
|
||||
telemetry). Neither is a replacement for a coding harness.
|
||||
|
||||
### Harness comparison
|
||||
|
||||
| Harness | Type | Autocomplete | Agent loop | Ollama | Git integration |
|
||||
|---|---|---|---|---|---|
|
||||
| Continue.dev | IDE plugin | Yes (FIM) | Basic | Native | No |
|
||||
| Aider | Terminal agent | No | Multi-turn | Via API | Auto-commit |
|
||||
| OpenCode | Terminal agent | No | Full tools | Native | Via bash |
|
||||
| Cline | IDE agent | No | Full tools | Via API | Via bash |
|
||||
| Open WebUI | Chat UI | No | No | Native | No |
|
||||
| Jan | Chat UI | No | No | Native | No |
|
||||
|
||||
For a bot-bottle workflow (an isolated sandbox running an agentic loop with tool access),
|
||||
**OpenCode** is the closest open-source match. For an IDE-first developer who wants
|
||||
autocomplete + chat, **Continue.dev + Qwen2.5-Coder 7B** is the recommended pair.
|
||||
|
||||
---
|
||||
|
||||
## 3. Model selection: RTX 3070 (8 GB VRAM / 30 GB RAM)
|
||||
|
||||
### VRAM hard limits at Q4_K_M quantization
|
||||
|
||||
| Model size | Approx. VRAM (Q4_K_M) | Fits in 8 GB? | Tokens/sec (RTX 3070) |
|
||||
|---|---|---|---|
|
||||
| 3–4B | 2.5–3.5 GB | Yes, with headroom | 60–90 |
|
||||
| 7–8B | 5–6 GB | Yes | 35–55 |
|
||||
| 12–14B | 7.5–9 GB | Edge / RAM offload | 8–18 |
|
||||
| 22B+ | 14+ GB | No | — |
|
||||
|
||||
The RTX 3070 has high memory bandwidth for its VRAM tier and consistently outperforms
|
||||
the newer RTX 4060 Ti on token generation speed. Bandwidth matters more than raw compute
|
||||
for inference.
|
||||
|
||||
### Does Gemma 4 exist?
|
||||
|
||||
Yes. Google released **Gemma 4** on 2 April 2026 (Apache 2.0). The family includes
|
||||
E2B (2B), E4B (4B), a 26B MoE, and a 31B Dense. A 12B multimodal variant was announced
|
||||
2026-06-04. The 31B scores 80.0% on LiveCodeBench v6 — a major jump from Gemma 3 27B
|
||||
at 29.1%. However, only the E4B fits comfortably within 8 GB VRAM:
|
||||
|
||||
| Variant | VRAM (approx.) | Fits? |
|
||||
|---|---|---|
|
||||
| Gemma 4 E2B | ~2 GB | Yes |
|
||||
| Gemma 4 E4B | ~5 GB | Yes |
|
||||
| Gemma 4 12B | ~8–9 GB (Q4) | Edge |
|
||||
| Gemma 4 26B MoE | 14–18 GB | No |
|
||||
| Gemma 4 31B Dense | ~20 GB | No |
|
||||
|
||||
### Model-by-model evaluation
|
||||
|
||||
**Qwen2.5-Coder 7B — primary recommendation**
|
||||
|
||||
The strongest purpose-built coding model that fits fully within 8 GB VRAM. Leads
|
||||
HumanEval among 7–8B-class models. Strong on Python, JavaScript, TypeScript. Has
|
||||
FIM (fill-in-the-middle) support for inline autocomplete. 35–55 tok/sec on RTX 3070.
|
||||
|
||||
```
|
||||
ollama pull qwen2.5-coder:7b
|
||||
```
|
||||
|
||||
**Qwen2.5-Coder 14B — secondary, with RAM offloading**
|
||||
|
||||
At Q4_K_M this needs ~8.7 GB, just over the 8 GB limit. With 30 GB system RAM, Ollama
|
||||
automatically offloads the overflow layers to CPU. Performance drops to ~8–18 tok/sec
|
||||
versus 35–55 tok/sec for the 7B fully in VRAM. Quality is noticeably better for complex
|
||||
multi-file reasoning. Viable for chat-based coding tasks where quality matters more than
|
||||
speed; too slow for live autocomplete. Keep context window at 8K tokens to minimize
|
||||
VRAM pressure during offloaded inference.
|
||||
|
||||
```
|
||||
ollama pull qwen2.5-coder:14b
|
||||
```
|
||||
|
||||
**Gemma 4 E4B (~5 GB VRAM)**
|
||||
|
||||
Fits comfortably with 3 GB to spare. Strong on reasoning, multimodal, and general-purpose
|
||||
tasks. Less specialized for coding than Qwen2.5-Coder 7B. Good choice for one model that
|
||||
covers coding + general reasoning + image analysis. The E4B outperforms Gemma 3 equivalents
|
||||
significantly on coding benchmarks.
|
||||
|
||||
```
|
||||
ollama pull gemma4:e4b
|
||||
```
|
||||
|
||||
**Phi-4 Mini 3.8B (~3 GB VRAM)**
|
||||
|
||||
Best reasoning-per-VRAM model; leaves ~5 GB free for other applications. Strong on math,
|
||||
logic, and structured output. Good for agentic sub-tasks requiring tight reasoning. Not the
|
||||
strongest at raw code synthesis but excellent for reasoning-heavy parts of a coding loop.
|
||||
Viable as the autocomplete model in a two-model Continue.dev setup.
|
||||
|
||||
```
|
||||
ollama pull phi4-mini
|
||||
```
|
||||
|
||||
**DeepSeek-R1 8B (~5–6 GB VRAM)**
|
||||
|
||||
Strong reasoning model for logic-heavy code (algorithms, correctness proofs). The full
|
||||
DeepSeek-Coder-V2 (236B MoE) is impractical here — only the 8B distilled variants are
|
||||
relevant. Outperforms Gemma 4 E4B on reasoning-heavy benchmarks; weaker on raw code
|
||||
generation than Qwen2.5-Coder 7B.
|
||||
|
||||
**Codestral — not viable at 8 GB**
|
||||
|
||||
The top FIM autocomplete model on HumanEval-FIM benchmarks, but requires 12–16 GB VRAM
|
||||
minimum. Not an option here. Worth revisiting if upgrading to a 12 GB+ card (RTX 4070
|
||||
Super or newer).
|
||||
|
||||
### RAM offloading: does 30 GB help?
|
||||
|
||||
Yes, meaningfully. Ollama automatically splits layers between GPU and system RAM when
|
||||
VRAM is exceeded. With 30 GB RAM, models up to ~14B at Q4_K_M run with partial offloading.
|
||||
The tradeoff is a 2–5× throughput penalty (8–18 tok/sec vs 35–55 tok/sec). Acceptable
|
||||
for batch tasks (reviewing a PR, generating an algorithm); too slow for live autocomplete.
|
||||
|
||||
### Recommended setup
|
||||
|
||||
**Autocomplete (fast, always-in-VRAM):** `qwen2.5-coder:7b`
|
||||
- Configure in Continue.dev as the tab-completion model
|
||||
- FIM-capable; 35–55 tok/sec; fits with 2–3 GB VRAM to spare
|
||||
|
||||
**Chat / agent loop (quality-first):** `qwen2.5-coder:14b` or `gemma4:e4b`
|
||||
- 14B for strongest multi-file coding; expect 8–18 tok/sec with RAM offload
|
||||
- Gemma 4 E4B if you want vision + general reasoning + coding in one model; ~60 tok/sec
|
||||
|
||||
**Two-model Continue.dev config (lower VRAM pressure):**
|
||||
`phi4-mini` (autocomplete) + `qwen2.5-coder:7b` (chat) — both fit simultaneously with
|
||||
~1–2 GB to spare, keeping the OS and IDE from contending for VRAM.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [Ollama on Proxmox: GPU Passthrough for LXC and VM AI Workloads](https://linuxprofessional.ie/article.php?slug=ollama-proxmox-gpu-passthrough-lxc-vm)
|
||||
- [Run Ollama with NVIDIA GPU in Proxmox VMs and LXC containers](https://www.virtualizationhowto.com/2025/05/run-ollama-with-nvidia-gpu-in-proxmox-vms-and-lxc-containers/)
|
||||
- [Ollama Performance Tuning: Getting Maximum Speed from Local LLMs](https://dasroot.net/posts/2026/01/ollama-performance-tuning-gpu-acceleration-model-quantization/)
|
||||
- [Pros and Cons: Containerized Ollama vs. Local Setup](https://alain-airom.medium.com/pros-and-cons-using-containerized-ollama-vs-local-setup-d9bdf225bbb5)
|
||||
- [Best Local Coding Models Ranked: Every VRAM Tier (2026)](https://insiderllm.com/guides/best-local-coding-models-2026/)
|
||||
- [Best Local LLMs for RTX 4060, RTX 3070, and RTX 5060](https://aiagentskit.com/blog/best-local-llms-rtx-4060-3070-5060/)
|
||||
- [Best Local LLMs for 8GB VRAM: Real Hardware Benchmarks (2026)](https://localllm.in/blog/best-local-llms-8gb-vram-2025)
|
||||
- [Self-Hosted AI Coding Agent: Ollama + Continue + Open WebUI Setup in 2026](https://www.web3aiblog.com/blog/self-hosted-ai-coding-agent-ollama-continue-2026)
|
||||
- [Best Local-First AI Coding Tools 2026: 14 Compared](https://nimbalyst.com/blog/best-local-first-ai-coding-tools-2026/)
|
||||
- [OpenCode + Ollama: Private Local AI Coding Agent Setup](https://lushbinary.com/blog/opencode-ollama-local-ai-coding-privacy-guide/)
|
||||
- [Gemma 4: Google DeepMind](https://deepmind.google/models/gemma/gemma-4/)
|
||||
- [Running Gemma 4 Locally: VRAM Requirements](https://knightli.com/en/2026/05/01/gemma-4-local-vram-quantization-table/)
|
||||
- [Phi-4 Mini vs. Gemma 3 vs. Qwen 2.5: Best SLM for Coding Tasks in 2026](https://botmonster.com/ai/phi-4-mini-vs-gemma-3-vs-qwen-25-best-slm-coding-2026/)
|
||||
- [Qwen2.5-Coder 14B VRAM Requirements Guide](https://willitrunai.com/blog/qwen-2-5-coder-14b-vram-requirements)
|
||||
- [Comparing AI Harnesses: OpenCode, Ollama, LM Studio, Claude Code, Open WebUI, and VS Code](https://jace.pro/blog/comparing-ai-harnesses-opencode-ollama-lm-studio-claude-code-open-webui-and-vs-code/)
|
||||
@@ -27,6 +27,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
def test_codex_plan_declares_home_state(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
plan = agent_provision_plan(
|
||||
guest_home="/home/node",
|
||||
template="codex",
|
||||
dockerfile="/tmp/Dockerfile.codex",
|
||||
state_dir=Path(tmp),
|
||||
@@ -51,6 +52,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
def test_codex_trusts_requested_project_path(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
agent_provision_plan(
|
||||
guest_home="/home/node",
|
||||
template="codex",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
@@ -68,6 +70,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
"tokens": {"access_token": _jwt(2000000000)},
|
||||
}))
|
||||
plan = agent_provision_plan(
|
||||
guest_home="/home/node",
|
||||
template="codex",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
@@ -87,6 +90,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
def test_claude_with_auth_token_injects_provider_route_and_placeholder(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
plan = agent_provision_plan(
|
||||
guest_home="/home/node",
|
||||
template="claude",
|
||||
dockerfile="/tmp/Dockerfile.claude",
|
||||
state_dir=Path(tmp),
|
||||
@@ -109,6 +113,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
def test_claude_trusts_requested_project_path(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
agent_provision_plan(
|
||||
guest_home="/home/node",
|
||||
template="claude",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
@@ -127,6 +132,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
"tokens": {"access_token": _jwt(2000000000)},
|
||||
}))
|
||||
plan = agent_provision_plan(
|
||||
guest_home="/home/node",
|
||||
template="codex",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
@@ -143,6 +149,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
def test_codex_without_forward_host_credentials_has_passthrough_egress_routes(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
plan = agent_provision_plan(
|
||||
guest_home="/home/node",
|
||||
template="codex",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
@@ -160,6 +167,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
def test_claude_without_auth_token_has_passthrough_egress_route(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
plan = agent_provision_plan(
|
||||
guest_home="/home/node",
|
||||
template="claude",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
@@ -183,6 +191,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
"tokens": {"access_token": access},
|
||||
}))
|
||||
plan = agent_provision_plan(
|
||||
guest_home="/home/node",
|
||||
template="codex",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
@@ -197,6 +206,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
def test_codex_without_forward_host_credentials_has_empty_provisioned_env(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
plan = agent_provision_plan(
|
||||
guest_home="/home/node",
|
||||
template="codex",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
|
||||
@@ -164,6 +164,7 @@ def _plan(
|
||||
|
||||
spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress)
|
||||
return DockerBottlePlan(
|
||||
guest_home="/home/node",
|
||||
spec=spec,
|
||||
stage_dir=STAGE,
|
||||
slug=SLUG,
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Unit: ClaudeAgentProvider provisioning (PRD 0050, contrib/claude).
|
||||
|
||||
Each provider owns its own in-guest provisioning end-to-end —
|
||||
skills copy, prompt copy, declarative dirs/files/pre_copy/verify
|
||||
apply, and supervise MCP registration. The Claude / Codex paths
|
||||
intentionally don't share a helper module: harness changes on
|
||||
either side are expected to diverge the implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bot_bottle.agent_provider import (
|
||||
AgentProvisionCommand,
|
||||
AgentProvisionDir,
|
||||
AgentProvisionFile,
|
||||
AgentProvisionPlan,
|
||||
)
|
||||
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
|
||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||
from bot_bottle.contrib.claude.agent_provider import ClaudeAgentProvider
|
||||
from bot_bottle.egress import EgressPlan
|
||||
from bot_bottle.git_gate import GitGatePlan
|
||||
from bot_bottle.manifest import Manifest
|
||||
from bot_bottle.pipelock import PipelockProxyPlan
|
||||
from bot_bottle.supervise import SupervisePlan
|
||||
from bot_bottle.workspace import workspace_plan
|
||||
|
||||
|
||||
_URL = "http://supervise:9100/"
|
||||
|
||||
|
||||
def _make_bottle(exec_result: ExecResult | None = None) -> MagicMock:
|
||||
bottle = MagicMock(spec=Bottle)
|
||||
bottle.name = "bot-bottle-demo-abc12"
|
||||
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]:
|
||||
return [c.args[0] for c in bottle.exec.call_args_list]
|
||||
|
||||
|
||||
def _plan(
|
||||
*,
|
||||
agent_prompt: str = "",
|
||||
skills: list[str] | None = None,
|
||||
agent_provision: AgentProvisionPlan | None = None,
|
||||
supervise: bool = False,
|
||||
) -> DockerBottlePlan:
|
||||
bottle_json: dict = {"agent_provider": {"template": "claude"}}
|
||||
if supervise:
|
||||
bottle_json["supervise"] = True
|
||||
manifest = Manifest.from_json_obj({
|
||||
"bottles": {"dev": bottle_json},
|
||||
"agents": {
|
||||
"demo": {
|
||||
"skills": list(skills or []),
|
||||
"prompt": agent_prompt,
|
||||
"bottle": "dev",
|
||||
},
|
||||
},
|
||||
})
|
||||
spec = BottleSpec(
|
||||
manifest=manifest, agent_name="demo",
|
||||
copy_cwd=False, user_cwd="/tmp/x",
|
||||
)
|
||||
supervise_plan = None
|
||||
if supervise:
|
||||
supervise_plan = SupervisePlan(
|
||||
slug="demo-abc12",
|
||||
queue_dir=Path("/tmp/queue"),
|
||||
current_config_dir=Path("/tmp/current-config"),
|
||||
)
|
||||
return DockerBottlePlan(
|
||||
guest_home="/home/node",
|
||||
spec=spec,
|
||||
stage_dir=Path("/tmp/stage"),
|
||||
slug="demo-abc12",
|
||||
container_name="bot-bottle-demo-abc12",
|
||||
container_name_pinned=False,
|
||||
image="bot-bottle-claude:latest",
|
||||
derived_image="",
|
||||
runtime_image="bot-bottle-claude:latest",
|
||||
dockerfile_path="",
|
||||
env_file=Path("/tmp/agent.env"),
|
||||
forwarded_env={},
|
||||
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
|
||||
proxy_plan=PipelockProxyPlan(
|
||||
yaml_path=Path("/tmp/pipelock.yaml"), slug="demo-abc12",
|
||||
),
|
||||
git_gate_plan=GitGatePlan(
|
||||
slug="demo-abc12",
|
||||
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
|
||||
hook_script=Path("/tmp/git-gate-hook"),
|
||||
access_hook_script=Path("/tmp/git-gate-access-hook"),
|
||||
upstreams=(),
|
||||
),
|
||||
egress_plan=EgressPlan(
|
||||
slug="demo-abc12",
|
||||
routes_path=Path("/tmp/routes.yaml"),
|
||||
routes=(),
|
||||
token_env_map={},
|
||||
),
|
||||
supervise_plan=supervise_plan,
|
||||
use_runsc=False,
|
||||
agent_provision=agent_provision or AgentProvisionPlan(
|
||||
template="claude", command="claude", prompt_mode="append_file",
|
||||
image="", dockerfile="", guest_env={},
|
||||
),
|
||||
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
|
||||
)
|
||||
|
||||
|
||||
class TestClaudeProvisionPrompt(unittest.TestCase):
|
||||
def test_cp_uses_bottle_cp_in(self):
|
||||
bottle = _make_bottle()
|
||||
ClaudeAgentProvider().provision_prompt(_plan(), bottle)
|
||||
bottle.cp_in.assert_called_once_with(
|
||||
"/tmp/state/demo-abc12/agent/prompt.txt",
|
||||
"/home/node/.bot-bottle-prompt.txt",
|
||||
)
|
||||
|
||||
def test_returns_path_when_agent_has_prompt(self):
|
||||
bottle = _make_bottle()
|
||||
r = ClaudeAgentProvider().provision_prompt(
|
||||
_plan(agent_prompt="You are helpful."), bottle,
|
||||
)
|
||||
self.assertEqual("/home/node/.bot-bottle-prompt.txt", r)
|
||||
|
||||
def test_returns_none_when_agent_has_no_prompt(self):
|
||||
bottle = _make_bottle()
|
||||
r = ClaudeAgentProvider().provision_prompt(_plan(agent_prompt=""), bottle)
|
||||
self.assertIsNone(r)
|
||||
bottle.cp_in.assert_called_once()
|
||||
|
||||
def test_chowns_to_node_after_copy(self):
|
||||
bottle = _make_bottle()
|
||||
ClaudeAgentProvider().provision_prompt(_plan(), bottle)
|
||||
scripts = _exec_scripts(bottle)
|
||||
self.assertTrue(
|
||||
any("chown node:node" in s
|
||||
and "/home/node/.bot-bottle-prompt.txt" in s
|
||||
for s in scripts)
|
||||
)
|
||||
self.assertTrue(
|
||||
any("chmod 600" in s
|
||||
and "/home/node/.bot-bottle-prompt.txt" in s
|
||||
for s in scripts)
|
||||
)
|
||||
|
||||
|
||||
class TestClaudeProvisionSkills(unittest.TestCase):
|
||||
def test_noop_when_agent_has_no_skills(self):
|
||||
bottle = _make_bottle()
|
||||
ClaudeAgentProvider().provision_skills(_plan(skills=[]), bottle)
|
||||
bottle.cp_in.assert_not_called()
|
||||
bottle.exec.assert_not_called()
|
||||
|
||||
def test_mkdir_plus_cp_per_skill(self):
|
||||
bottle = _make_bottle()
|
||||
with patch(
|
||||
"bot_bottle.backend.util.host_skill_dir",
|
||||
side_effect=lambda n: f"/host/skills/{n}",
|
||||
), patch(
|
||||
"bot_bottle.contrib.claude.agent_provider.os.path.isdir",
|
||||
return_value=True,
|
||||
):
|
||||
ClaudeAgentProvider().provision_skills(
|
||||
_plan(skills=["init-prd", "verify"]), bottle,
|
||||
)
|
||||
scripts = _exec_scripts(bottle)
|
||||
self.assertTrue(
|
||||
any("mkdir -p" in s and "/home/node/.claude/skills" in s
|
||||
for s in scripts)
|
||||
)
|
||||
cp_targets = {c.args[1] for c in bottle.cp_in.call_args_list}
|
||||
self.assertEqual({
|
||||
"/home/node/.claude/skills/init-prd/",
|
||||
"/home/node/.claude/skills/verify/",
|
||||
}, cp_targets)
|
||||
self.assertEqual(
|
||||
2, sum(1 for s in scripts if "chown -R node:node" in s),
|
||||
)
|
||||
|
||||
def test_missing_skill_dies(self):
|
||||
bottle = _make_bottle()
|
||||
with patch(
|
||||
"bot_bottle.backend.util.host_skill_dir",
|
||||
side_effect=lambda n: f"/host/skills/{n}",
|
||||
), patch(
|
||||
"bot_bottle.contrib.claude.agent_provider.os.path.isdir",
|
||||
return_value=False,
|
||||
):
|
||||
with self.assertRaises(SystemExit):
|
||||
ClaudeAgentProvider().provision_skills(
|
||||
_plan(skills=["init-prd"]), bottle,
|
||||
)
|
||||
|
||||
|
||||
class TestClaudeProvision(unittest.TestCase):
|
||||
"""The declarative dirs/files/pre_copy/verify apply loop for
|
||||
the claude.json trust marker."""
|
||||
|
||||
def test_noop_on_empty_provision_plan(self):
|
||||
bottle = _make_bottle()
|
||||
ClaudeAgentProvider().provision(_plan(), bottle)
|
||||
bottle.cp_in.assert_not_called()
|
||||
bottle.exec.assert_not_called()
|
||||
|
||||
def test_copies_files_and_chowns(self):
|
||||
provision = AgentProvisionPlan(
|
||||
template="claude", command="claude", prompt_mode="append_file",
|
||||
image="", dockerfile="", guest_env={},
|
||||
files=(AgentProvisionFile(
|
||||
Path("/tmp/claude.json"), "/home/node/.claude.json",
|
||||
),),
|
||||
)
|
||||
bottle = _make_bottle()
|
||||
ClaudeAgentProvider().provision(
|
||||
_plan(agent_provision=provision), bottle,
|
||||
)
|
||||
bottle.cp_in.assert_called_once_with(
|
||||
"/tmp/claude.json", "/home/node/.claude.json",
|
||||
)
|
||||
scripts = _exec_scripts(bottle)
|
||||
self.assertTrue(
|
||||
any("chown" in s and "/home/node/.claude.json" in s for s in scripts)
|
||||
)
|
||||
self.assertTrue(
|
||||
any("chmod" in s and "/home/node/.claude.json" in s for s in scripts)
|
||||
)
|
||||
|
||||
def test_dies_when_file_chown_fails(self):
|
||||
provision = AgentProvisionPlan(
|
||||
template="claude", command="claude", prompt_mode="append_file",
|
||||
image="", dockerfile="", guest_env={},
|
||||
files=(AgentProvisionFile(
|
||||
Path("/tmp/claude.json"), "/home/node/.claude.json",
|
||||
),),
|
||||
)
|
||||
bottle = _make_bottle(
|
||||
exec_result=ExecResult(1, "", "chown: no such file\n"),
|
||||
)
|
||||
with self.assertRaises(SystemExit):
|
||||
ClaudeAgentProvider().provision(
|
||||
_plan(agent_provision=provision), bottle,
|
||||
)
|
||||
|
||||
def test_runs_verify_commands(self):
|
||||
provision = AgentProvisionPlan(
|
||||
template="claude", command="claude", prompt_mode="append_file",
|
||||
image="", dockerfile="", guest_env={},
|
||||
verify=(AgentProvisionCommand(
|
||||
("/usr/bin/true",), "verify failed",
|
||||
),),
|
||||
)
|
||||
bottle = _make_bottle()
|
||||
ClaudeAgentProvider().provision(
|
||||
_plan(agent_provision=provision), bottle,
|
||||
)
|
||||
scripts = _exec_scripts(bottle)
|
||||
self.assertTrue(any("/usr/bin/true" in s for s in scripts))
|
||||
|
||||
|
||||
class TestClaudeSuperviseMcp(unittest.TestCase):
|
||||
def test_noop_when_supervise_disabled(self):
|
||||
bottle = _make_bottle()
|
||||
ClaudeAgentProvider().provision_supervise_mcp(
|
||||
_plan(supervise=False), bottle, _URL,
|
||||
)
|
||||
bottle.exec.assert_not_called()
|
||||
|
||||
def test_runs_claude_mcp_add_as_node(self):
|
||||
bottle = _make_bottle()
|
||||
ClaudeAgentProvider().provision_supervise_mcp(
|
||||
_plan(supervise=True), bottle, _URL,
|
||||
)
|
||||
bottle.exec.assert_called_once()
|
||||
script = bottle.exec.call_args.args[0]
|
||||
self.assertEqual("node", bottle.exec.call_args.kwargs.get("user"))
|
||||
self.assertIn("claude mcp add", script)
|
||||
self.assertIn("--scope user", script)
|
||||
self.assertIn("--transport http", script)
|
||||
self.assertIn("supervise", script)
|
||||
self.assertIn(_URL, script)
|
||||
|
||||
def test_logs_warning_on_failure_but_does_not_raise(self):
|
||||
bottle = _make_bottle(
|
||||
exec_result=ExecResult(returncode=1, stdout="", stderr="boom"),
|
||||
)
|
||||
ClaudeAgentProvider().provision_supervise_mcp(
|
||||
_plan(supervise=True), bottle, _URL,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Unit: CodexAgentProvider provisioning (PRD 0050, contrib/codex).
|
||||
|
||||
The Codex provider owns its own skills / prompt / provision /
|
||||
supervise-mcp end-to-end — symmetric with the claude provider but
|
||||
not sharing a helper module, since codex's apply steps include
|
||||
the dummy-auth dance and a `codex login status` verify that have
|
||||
no claude equivalent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bot_bottle.agent_provider import (
|
||||
AgentProvisionCommand,
|
||||
AgentProvisionDir,
|
||||
AgentProvisionFile,
|
||||
AgentProvisionPlan,
|
||||
)
|
||||
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
|
||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||
from bot_bottle.contrib.codex.agent_provider import CodexAgentProvider
|
||||
from bot_bottle.egress import EgressPlan
|
||||
from bot_bottle.git_gate import GitGatePlan
|
||||
from bot_bottle.manifest import Manifest
|
||||
from bot_bottle.pipelock import PipelockProxyPlan
|
||||
from bot_bottle.supervise import SupervisePlan
|
||||
from bot_bottle.workspace import workspace_plan
|
||||
|
||||
|
||||
_URL = "http://supervise:9100/"
|
||||
|
||||
|
||||
def _make_bottle(exec_result: ExecResult | None = None) -> MagicMock:
|
||||
bottle = MagicMock(spec=Bottle)
|
||||
bottle.name = "bot-bottle-demo-abc12"
|
||||
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]:
|
||||
return [c.args[0] for c in bottle.exec.call_args_list]
|
||||
|
||||
|
||||
def _plan(
|
||||
*,
|
||||
agent_prompt: str = "",
|
||||
skills: list[str] | None = None,
|
||||
agent_provision: AgentProvisionPlan | None = None,
|
||||
supervise: bool = False,
|
||||
) -> DockerBottlePlan:
|
||||
bottle_json: dict = {"agent_provider": {"template": "codex"}}
|
||||
if supervise:
|
||||
bottle_json["supervise"] = True
|
||||
manifest = Manifest.from_json_obj({
|
||||
"bottles": {"dev": bottle_json},
|
||||
"agents": {
|
||||
"demo": {
|
||||
"skills": list(skills or []),
|
||||
"prompt": agent_prompt,
|
||||
"bottle": "dev",
|
||||
},
|
||||
},
|
||||
})
|
||||
spec = BottleSpec(
|
||||
manifest=manifest, agent_name="demo",
|
||||
copy_cwd=False, user_cwd="/tmp/x",
|
||||
)
|
||||
supervise_plan = None
|
||||
if supervise:
|
||||
supervise_plan = SupervisePlan(
|
||||
slug="demo-abc12",
|
||||
queue_dir=Path("/tmp/queue"),
|
||||
current_config_dir=Path("/tmp/current-config"),
|
||||
)
|
||||
return DockerBottlePlan(
|
||||
guest_home="/home/node",
|
||||
spec=spec,
|
||||
stage_dir=Path("/tmp/stage"),
|
||||
slug="demo-abc12",
|
||||
container_name="bot-bottle-demo-abc12",
|
||||
container_name_pinned=False,
|
||||
image="bot-bottle-codex:latest",
|
||||
derived_image="",
|
||||
runtime_image="bot-bottle-codex:latest",
|
||||
dockerfile_path="",
|
||||
env_file=Path("/tmp/agent.env"),
|
||||
forwarded_env={},
|
||||
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
|
||||
proxy_plan=PipelockProxyPlan(
|
||||
yaml_path=Path("/tmp/pipelock.yaml"), slug="demo-abc12",
|
||||
),
|
||||
git_gate_plan=GitGatePlan(
|
||||
slug="demo-abc12",
|
||||
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
|
||||
hook_script=Path("/tmp/git-gate-hook"),
|
||||
access_hook_script=Path("/tmp/git-gate-access-hook"),
|
||||
upstreams=(),
|
||||
),
|
||||
egress_plan=EgressPlan(
|
||||
slug="demo-abc12",
|
||||
routes_path=Path("/tmp/routes.yaml"),
|
||||
routes=(),
|
||||
token_env_map={},
|
||||
),
|
||||
supervise_plan=supervise_plan,
|
||||
use_runsc=False,
|
||||
agent_provision=agent_provision or AgentProvisionPlan(
|
||||
template="codex", command="codex", prompt_mode="read_prompt_file",
|
||||
image="", dockerfile="", guest_env={},
|
||||
),
|
||||
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
|
||||
)
|
||||
|
||||
|
||||
class TestCodexProvisionPrompt(unittest.TestCase):
|
||||
def test_cp_uses_bottle_cp_in_and_chowns(self):
|
||||
bottle = _make_bottle()
|
||||
r = CodexAgentProvider().provision_prompt(
|
||||
_plan(agent_prompt="hello"), bottle,
|
||||
)
|
||||
self.assertEqual("/home/node/.bot-bottle-prompt.txt", r)
|
||||
bottle.cp_in.assert_called_once_with(
|
||||
"/tmp/state/demo-abc12/agent/prompt.txt",
|
||||
"/home/node/.bot-bottle-prompt.txt",
|
||||
)
|
||||
scripts = _exec_scripts(bottle)
|
||||
self.assertTrue(
|
||||
any("chown node:node" in s
|
||||
and "/home/node/.bot-bottle-prompt.txt" in s
|
||||
for s in scripts)
|
||||
)
|
||||
|
||||
def test_returns_none_when_agent_has_no_prompt(self):
|
||||
bottle = _make_bottle()
|
||||
r = CodexAgentProvider().provision_prompt(_plan(agent_prompt=""), bottle)
|
||||
self.assertIsNone(r)
|
||||
bottle.cp_in.assert_called_once()
|
||||
|
||||
|
||||
class TestCodexProvisionSkills(unittest.TestCase):
|
||||
def test_noop_when_agent_has_no_skills(self):
|
||||
bottle = _make_bottle()
|
||||
CodexAgentProvider().provision_skills(_plan(skills=[]), bottle)
|
||||
bottle.cp_in.assert_not_called()
|
||||
bottle.exec.assert_not_called()
|
||||
|
||||
def test_mkdir_plus_cp_per_skill(self):
|
||||
bottle = _make_bottle()
|
||||
with patch(
|
||||
"bot_bottle.backend.util.host_skill_dir",
|
||||
side_effect=lambda n: f"/host/skills/{n}",
|
||||
), patch(
|
||||
"bot_bottle.contrib.codex.agent_provider.os.path.isdir",
|
||||
return_value=True,
|
||||
):
|
||||
CodexAgentProvider().provision_skills(
|
||||
_plan(skills=["init-prd"]), bottle,
|
||||
)
|
||||
scripts = _exec_scripts(bottle)
|
||||
self.assertTrue(
|
||||
any("mkdir -p" in s and "/home/node/.claude/skills" in s
|
||||
for s in scripts)
|
||||
)
|
||||
bottle.cp_in.assert_called_once()
|
||||
self.assertEqual(
|
||||
"/home/node/.claude/skills/init-prd/",
|
||||
bottle.cp_in.call_args.args[1],
|
||||
)
|
||||
|
||||
|
||||
class TestCodexProvision(unittest.TestCase):
|
||||
"""Codex's declarative provision step: ~/.codex/ dir + config.toml
|
||||
+ (optional) dummy-auth.json + `codex login status` verify."""
|
||||
|
||||
def test_creates_dir_and_copies_config(self):
|
||||
provision = AgentProvisionPlan(
|
||||
template="codex", command="codex",
|
||||
prompt_mode="read_prompt_file",
|
||||
image="", dockerfile="", guest_env={},
|
||||
dirs=(AgentProvisionDir("/home/node/.codex"),),
|
||||
files=(AgentProvisionFile(
|
||||
Path("/tmp/codex-config.toml"),
|
||||
"/home/node/.codex/config.toml",
|
||||
),),
|
||||
)
|
||||
bottle = _make_bottle()
|
||||
CodexAgentProvider().provision(
|
||||
_plan(agent_provision=provision), bottle,
|
||||
)
|
||||
bottle.cp_in.assert_called_once_with(
|
||||
"/tmp/codex-config.toml",
|
||||
"/home/node/.codex/config.toml",
|
||||
)
|
||||
scripts = _exec_scripts(bottle)
|
||||
self.assertTrue(any("mkdir -p" in s and "/home/node/.codex" in s for s in scripts))
|
||||
self.assertTrue(any("chown" in s and "/home/node/.codex/config.toml" in s for s in scripts))
|
||||
self.assertTrue(any("chmod" in s and "/home/node/.codex/config.toml" in s for s in scripts))
|
||||
|
||||
def test_runs_pre_copy_then_verify(self):
|
||||
provision = AgentProvisionPlan(
|
||||
template="codex", command="codex",
|
||||
prompt_mode="read_prompt_file",
|
||||
image="", dockerfile="", guest_env={},
|
||||
pre_copy=(AgentProvisionCommand(
|
||||
("find", "/home/node/.codex", "-name", "*.sqlite", "-delete"),
|
||||
"could not reset runtime db files",
|
||||
),),
|
||||
verify=(AgentProvisionCommand(
|
||||
("runuser", "-u", "node", "--", "codex", "login", "status"),
|
||||
"codex rejected the dummy auth",
|
||||
),),
|
||||
)
|
||||
bottle = _make_bottle()
|
||||
CodexAgentProvider().provision(
|
||||
_plan(agent_provision=provision), bottle,
|
||||
)
|
||||
scripts = _exec_scripts(bottle)
|
||||
self.assertTrue(any("find" in s and "-delete" in s for s in scripts))
|
||||
self.assertTrue(any("runuser" in s and "codex login status" in s for s in scripts))
|
||||
|
||||
def test_dies_when_dir_creation_fails(self):
|
||||
provision = AgentProvisionPlan(
|
||||
template="codex", command="codex",
|
||||
prompt_mode="read_prompt_file",
|
||||
image="", dockerfile="", guest_env={},
|
||||
dirs=(AgentProvisionDir("/home/node/.codex"),),
|
||||
)
|
||||
bottle = _make_bottle(exec_result=ExecResult(1, "", "mkdir: nope\n"))
|
||||
with self.assertRaises(SystemExit):
|
||||
CodexAgentProvider().provision(
|
||||
_plan(agent_provision=provision), bottle,
|
||||
)
|
||||
|
||||
|
||||
class TestCodexSuperviseMcp(unittest.TestCase):
|
||||
def test_noop_when_supervise_disabled(self):
|
||||
bottle = _make_bottle()
|
||||
CodexAgentProvider().provision_supervise_mcp(
|
||||
_plan(supervise=False), bottle, _URL,
|
||||
)
|
||||
bottle.exec.assert_not_called()
|
||||
|
||||
def test_runs_codex_mcp_add_as_node(self):
|
||||
bottle = _make_bottle()
|
||||
CodexAgentProvider().provision_supervise_mcp(
|
||||
_plan(supervise=True), bottle, _URL,
|
||||
)
|
||||
bottle.exec.assert_called_once()
|
||||
script = bottle.exec.call_args.args[0]
|
||||
self.assertEqual("node", bottle.exec.call_args.kwargs.get("user"))
|
||||
self.assertIn("codex mcp add", script)
|
||||
self.assertIn("--transport http", script)
|
||||
self.assertIn("supervise", script)
|
||||
self.assertIn(_URL, script)
|
||||
|
||||
def test_logs_warning_on_failure_but_does_not_raise(self):
|
||||
bottle = _make_bottle(
|
||||
exec_result=ExecResult(returncode=1, stdout="", stderr="boom"),
|
||||
)
|
||||
CodexAgentProvider().provision_supervise_mcp(
|
||||
_plan(supervise=True), bottle, _URL,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -44,6 +44,7 @@ def _plan(tmp: str) -> DockerBottlePlan:
|
||||
identity="test-teardown-00001",
|
||||
)
|
||||
return DockerBottlePlan(
|
||||
guest_home="/home/node",
|
||||
spec=spec,
|
||||
stage_dir=stage,
|
||||
git_gate_plan=GitGatePlan(
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
"""Unit: docker backend `_provision_git_user` (issue #86).
|
||||
|
||||
Mocks `subprocess.run` and asserts the `docker exec -u node …
|
||||
git config --global …` argv shape. The cwd + git-gate passes
|
||||
are covered indirectly by the existing integration-shaped tests
|
||||
in test_smolmachines_provision; this file targets just the new
|
||||
git_user pass."""
|
||||
Mocks `bottle.exec` / `bottle.cp_in` and asserts on the script
|
||||
strings and user parameter. The cwd + git-gate passes are covered
|
||||
indirectly by the existing integration-shaped tests in
|
||||
test_smolmachines_provision; this file targets just the git_user
|
||||
pass."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
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.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.provision import git as _git
|
||||
from bot_bottle.egress import EgressPlan
|
||||
@@ -40,6 +40,7 @@ def _plan(*, git_user: dict | None = None,
|
||||
copy_cwd=copy_cwd, user_cwd=user_cwd,
|
||||
)
|
||||
return DockerBottlePlan(
|
||||
guest_home="/home/node",
|
||||
spec=spec,
|
||||
stage_dir=stage_dir or Path("/tmp/stage"),
|
||||
slug="demo-abc12",
|
||||
@@ -82,16 +83,22 @@ def _plan(*, git_user: dict | None = None,
|
||||
)
|
||||
|
||||
|
||||
def _git_config_calls(mock_run) -> list[list[str]]:
|
||||
"""Filter `subprocess.run` calls down to the ones that run
|
||||
`git config --global` inside the bottle, returning each argv."""
|
||||
out: list[list[str]] = []
|
||||
for call in mock_run.call_args_list:
|
||||
argv = call.args[0]
|
||||
if (len(argv) >= 5
|
||||
and argv[0] == "docker" and argv[1] == "exec"
|
||||
and "git" in argv and "config" in argv):
|
||||
out.append(list(argv))
|
||||
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
|
||||
|
||||
|
||||
def _git_config_exec_calls(bottle: MagicMock) -> list[tuple[str, str]]:
|
||||
"""Filter bottle.exec calls to git-config invocations.
|
||||
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
|
||||
|
||||
|
||||
@@ -104,71 +111,65 @@ class TestProvisionGitUser(unittest.TestCase):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_noop_when_no_git_user(self):
|
||||
with patch.object(_git.subprocess, "run") as run:
|
||||
_git._provision_git_user(
|
||||
_plan(stage_dir=self.stage), "bot-bottle-demo-abc12",
|
||||
)
|
||||
self.assertEqual([], _git_config_calls(run))
|
||||
bottle = _make_bottle()
|
||||
_git._provision_git_user(_plan(stage_dir=self.stage), bottle)
|
||||
self.assertEqual([], _git_config_exec_calls(bottle))
|
||||
|
||||
def test_copies_cwd_git_to_workspace_plan_path(self):
|
||||
cwd = self.stage / "cwd"
|
||||
(cwd / ".git").mkdir(parents=True)
|
||||
plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage)
|
||||
with patch.object(_git.subprocess, "run") as run:
|
||||
_git._provision_cwd_git(plan, "bot-bottle-demo-abc12")
|
||||
bottle = _make_bottle()
|
||||
_git._provision_cwd_git(plan, bottle)
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
"docker", "cp", f"{cwd}/.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],
|
||||
bottle.cp_in.assert_called_once_with(
|
||||
f"{cwd}/.git",
|
||||
"/home/node/workspace/.git",
|
||||
)
|
||||
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):
|
||||
plan = _plan(
|
||||
git_user={"name": "Eric Bauerfeld", "email": "eric@dideric.is"},
|
||||
stage_dir=self.stage,
|
||||
)
|
||||
with patch.object(_git.subprocess, "run") as run:
|
||||
_git._provision_git_user(plan, "bot-bottle-demo-abc12")
|
||||
calls = _git_config_calls(run)
|
||||
bottle = _make_bottle()
|
||||
_git._provision_git_user(plan, bottle)
|
||||
calls = _git_config_exec_calls(bottle)
|
||||
self.assertEqual(2, len(calls))
|
||||
# All `docker exec` invocations run as `-u node` so the
|
||||
# --global config lands in /home/node/.gitconfig.
|
||||
for argv in calls:
|
||||
self.assertEqual(
|
||||
["docker", "exec", "-u", "node", "bot-bottle-demo-abc12",
|
||||
"git", "config", "--global"],
|
||||
argv[:8],
|
||||
)
|
||||
self.assertEqual(["user.name", "Eric Bauerfeld"], calls[0][8:])
|
||||
self.assertEqual(["user.email", "eric@dideric.is"], calls[1][8:])
|
||||
for script, user in calls:
|
||||
self.assertEqual("node", user)
|
||||
self.assertIn("git config --global", script)
|
||||
self.assertIn("user.name", calls[0][0])
|
||||
self.assertIn("Eric Bauerfeld", calls[0][0])
|
||||
self.assertIn("user.email", calls[1][0])
|
||||
self.assertIn("eric@dideric.is", calls[1][0])
|
||||
|
||||
def test_name_only_sets_only_name(self):
|
||||
plan = _plan(git_user={"name": "Bot"}, stage_dir=self.stage)
|
||||
with patch.object(_git.subprocess, "run") as run:
|
||||
_git._provision_git_user(plan, "bot-bottle-demo-abc12")
|
||||
calls = _git_config_calls(run)
|
||||
bottle = _make_bottle()
|
||||
_git._provision_git_user(plan, bottle)
|
||||
calls = _git_config_exec_calls(bottle)
|
||||
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):
|
||||
plan = _plan(
|
||||
git_user={"email": "bot@example.com"}, stage_dir=self.stage,
|
||||
)
|
||||
with patch.object(_git.subprocess, "run") as run:
|
||||
_git._provision_git_user(plan, "bot-bottle-demo-abc12")
|
||||
calls = _git_config_calls(run)
|
||||
bottle = _make_bottle()
|
||||
_git._provision_git_user(plan, bottle)
|
||||
calls = _git_config_exec_calls(bottle)
|
||||
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__":
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
"""Unit: docker provider auth marker provisioning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.agent_provider import (
|
||||
AgentProvisionDir,
|
||||
AgentProvisionFile,
|
||||
AgentProvisionPlan,
|
||||
)
|
||||
from bot_bottle.backend import BottleSpec
|
||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||
from bot_bottle.backend.docker.provision import provider_auth as _provider_auth
|
||||
from bot_bottle.egress import EgressPlan
|
||||
from bot_bottle.git_gate import GitGatePlan
|
||||
from bot_bottle.manifest import Manifest
|
||||
from bot_bottle.pipelock import PipelockProxyPlan
|
||||
from bot_bottle.workspace import workspace_plan
|
||||
|
||||
|
||||
def _plan(
|
||||
*,
|
||||
codex_auth_file: Path | None = None,
|
||||
agent_provider_template: str = "codex",
|
||||
) -> DockerBottlePlan:
|
||||
manifest = Manifest.from_json_obj({
|
||||
"bottles": {"dev": {"agent_provider": {"template": "codex"}}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
spec = BottleSpec(
|
||||
manifest=manifest,
|
||||
agent_name="demo",
|
||||
copy_cwd=False,
|
||||
user_cwd="/tmp/x",
|
||||
)
|
||||
return DockerBottlePlan(
|
||||
spec=spec,
|
||||
stage_dir=Path("/tmp/stage"),
|
||||
slug="demo-abc12",
|
||||
container_name="bot-bottle-demo-abc12",
|
||||
container_name_pinned=False,
|
||||
image="bot-bottle-codex:latest",
|
||||
derived_image="",
|
||||
runtime_image="bot-bottle-codex:latest",
|
||||
dockerfile_path="",
|
||||
env_file=Path("/tmp/agent.env"),
|
||||
forwarded_env={},
|
||||
prompt_file=Path("/tmp/prompt.txt"),
|
||||
proxy_plan=PipelockProxyPlan(
|
||||
yaml_path=Path("/tmp/pipelock.yaml"),
|
||||
slug="demo-abc12",
|
||||
),
|
||||
git_gate_plan=GitGatePlan(
|
||||
slug="demo-abc12",
|
||||
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
|
||||
hook_script=Path("/tmp/git-gate-hook"),
|
||||
access_hook_script=Path("/tmp/git-gate-access-hook"),
|
||||
upstreams=(),
|
||||
),
|
||||
egress_plan=EgressPlan(
|
||||
slug="demo-abc12",
|
||||
routes_path=Path("/tmp/routes.yaml"),
|
||||
routes=(),
|
||||
token_env_map={},
|
||||
),
|
||||
supervise_plan=None,
|
||||
use_runsc=False,
|
||||
agent_provision=_agent_provision(
|
||||
agent_provider_template, codex_auth_file=codex_auth_file,
|
||||
),
|
||||
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
|
||||
)
|
||||
|
||||
|
||||
def _agent_provision(
|
||||
template: str, *, codex_auth_file: Path | None = None,
|
||||
) -> AgentProvisionPlan:
|
||||
if template != "codex":
|
||||
return AgentProvisionPlan(
|
||||
template=template,
|
||||
command=template,
|
||||
prompt_mode="append_file",
|
||||
image="",
|
||||
dockerfile="",
|
||||
guest_env={},
|
||||
)
|
||||
files = [
|
||||
AgentProvisionFile(
|
||||
Path("/tmp/codex-config.toml"),
|
||||
"/home/node/.codex/config.toml",
|
||||
),
|
||||
]
|
||||
if codex_auth_file is not None:
|
||||
files.append(AgentProvisionFile(
|
||||
codex_auth_file,
|
||||
"/home/node/.codex/auth.json",
|
||||
))
|
||||
return AgentProvisionPlan(
|
||||
template="codex",
|
||||
command="codex",
|
||||
prompt_mode="read_prompt_file",
|
||||
image="bot-bottle-codex:latest",
|
||||
dockerfile="",
|
||||
guest_env={},
|
||||
dirs=(AgentProvisionDir("/home/node/.codex"),),
|
||||
files=tuple(files),
|
||||
)
|
||||
|
||||
|
||||
class TestProvisionProviderAuth(unittest.TestCase):
|
||||
def test_noop_for_non_codex_provider(self):
|
||||
with patch.object(_provider_auth.subprocess, "run") as run:
|
||||
_provider_auth.provision_provider_auth(
|
||||
_plan(agent_provider_template="claude"), "bot-bottle-demo-abc12",
|
||||
)
|
||||
self.assertEqual(0, run.call_count)
|
||||
|
||||
def test_codex_provider_trusts_launch_dir_without_auth_file(self):
|
||||
with patch.object(_provider_auth.subprocess, "run") as run:
|
||||
_provider_auth.provision_provider_auth(
|
||||
_plan(), "bot-bottle-demo-abc12",
|
||||
)
|
||||
argvs = [call.args[0] for call in run.call_args_list]
|
||||
self.assertIn(
|
||||
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12",
|
||||
"mkdir", "-p", "/home/node/.codex"],
|
||||
argvs,
|
||||
)
|
||||
trust_config = next(
|
||||
a for a in argvs
|
||||
if a[:2] == ["docker", "cp"] and a[2] == "/tmp/codex-config.toml"
|
||||
)
|
||||
self.assertEqual(
|
||||
"bot-bottle-demo-abc12:/home/node/.codex/config.toml",
|
||||
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):
|
||||
with patch.object(_provider_auth.subprocess, "run") as run:
|
||||
_provider_auth.provision_provider_auth(
|
||||
_plan(codex_auth_file=Path("/tmp/codex-auth.json")),
|
||||
"bot-bottle-demo-abc12",
|
||||
)
|
||||
argvs = [call.args[0] for call in run.call_args_list]
|
||||
self.assertIn(
|
||||
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12",
|
||||
"mkdir", "-p", "/home/node/.codex"],
|
||||
argvs,
|
||||
)
|
||||
self.assertIn(
|
||||
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12",
|
||||
"chown", "node:node", "/home/node/.codex"],
|
||||
argvs,
|
||||
)
|
||||
self.assertIn(
|
||||
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12",
|
||||
"chmod", "700", "/home/node/.codex"],
|
||||
argvs,
|
||||
)
|
||||
self.assertIn(
|
||||
["docker", "cp", "/tmp/codex-auth.json",
|
||||
"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,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -103,6 +103,7 @@ def _proxy_plan(tmp: str) -> PipelockProxyPlan:
|
||||
def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
|
||||
stage = Path(tmp)
|
||||
return DockerBottlePlan(
|
||||
guest_home="/home/node",
|
||||
spec=spec,
|
||||
stage_dir=stage,
|
||||
git_gate_plan=_git_gate_plan(tmp),
|
||||
@@ -128,6 +129,7 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
|
||||
def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan:
|
||||
stage = Path(tmp)
|
||||
return SmolmachinesBottlePlan(
|
||||
guest_home="/home/node",
|
||||
spec=spec,
|
||||
stage_dir=stage,
|
||||
git_gate_plan=_git_gate_plan(tmp),
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Unit: supervise MCP provisioning (PRD 0013 follow-up).
|
||||
|
||||
The real provisioning runs `claude mcp add` inside the agent
|
||||
container — exercised by the existing supervise integration test
|
||||
chain once the agent container is brought up. Here we just cover
|
||||
the URL computation so a regression in SUPERVISE_HOSTNAME / PORT
|
||||
plumbing surfaces in unit CI."""
|
||||
|
||||
import unittest
|
||||
|
||||
from bot_bottle.backend.docker.provision.supervise import supervise_mcp_url
|
||||
from bot_bottle.supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
|
||||
|
||||
|
||||
class TestSuperviseMcpUrl(unittest.TestCase):
|
||||
def test_url_matches_sidecar_constants(self):
|
||||
self.assertEqual(
|
||||
f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/",
|
||||
supervise_mcp_url(),
|
||||
)
|
||||
|
||||
def test_url_is_http_not_https(self):
|
||||
# The agent dials the sidecar on the internal docker network;
|
||||
# no TLS termination, no CA trust juggling. If this ever
|
||||
# needs HTTPS, the sidecar's listener side has to change too.
|
||||
self.assertTrue(supervise_mcp_url().startswith("http://"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Unit: smolmachines provisioning helpers (PRD 0023 chunks 4a + 4d).
|
||||
|
||||
Tests mock `smolvm.machine_cp` / `smolvm.machine_exec` and assert
|
||||
on the dispatched call shape. The real round-trip lives in the
|
||||
chunk-4 integration smoke."""
|
||||
Tests mock `bottle.exec` / `bottle.cp_in` and assert on the
|
||||
dispatched script shape. The real round-trip lives in the chunk-4
|
||||
integration smoke."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,7 +11,7 @@ import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bot_bottle.agent_provider import (
|
||||
AgentProvisionCommand,
|
||||
@@ -19,21 +19,16 @@ from bot_bottle.agent_provider import (
|
||||
AgentProvisionFile,
|
||||
AgentProvisionPlan,
|
||||
)
|
||||
from bot_bottle.backend import BottleSpec
|
||||
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
|
||||
from bot_bottle.backend.smolmachines.bottle_plan import (
|
||||
SmolmachinesBottlePlan,
|
||||
)
|
||||
from bot_bottle.backend.smolmachines.provision import (
|
||||
ca as _ca,
|
||||
git as _git,
|
||||
prompt as _prompt,
|
||||
provider_auth as _provider_auth,
|
||||
skills as _skills,
|
||||
supervise as _supervise,
|
||||
workspace as _workspace,
|
||||
)
|
||||
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.git_gate import GitGatePlan, GitGateUpstream
|
||||
from bot_bottle.manifest import GitEntry, Manifest
|
||||
@@ -42,6 +37,28 @@ from bot_bottle.supervise import SupervisePlan
|
||||
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(
|
||||
*,
|
||||
@@ -103,6 +120,7 @@ def _plan(
|
||||
current_config_dir=Path("/tmp/current-config"),
|
||||
)
|
||||
return SmolmachinesBottlePlan(
|
||||
guest_home="/home/node",
|
||||
spec=spec,
|
||||
stage_dir=stage_dir or Path("/tmp/stage"),
|
||||
slug="demo-abc12",
|
||||
@@ -202,340 +220,6 @@ def _agent_provision(
|
||||
)
|
||||
|
||||
|
||||
class TestProvisionPrompt(unittest.TestCase):
|
||||
def test_cp_uses_smolvm_machine_cp_with_machine_path_syntax(self):
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_cp"
|
||||
) as cp, patch(
|
||||
"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",
|
||||
"bot-bottle-demo-abc12:/home/node/.bot-bottle-prompt.txt",
|
||||
)
|
||||
|
||||
def test_returns_path_when_agent_has_prompt(self):
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_cp"
|
||||
), patch(
|
||||
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_exec"
|
||||
):
|
||||
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)
|
||||
|
||||
def test_returns_none_when_agent_has_no_prompt(self):
|
||||
# The file is still copied (path-must-exist contract);
|
||||
# only the return value differs.
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_cp"
|
||||
) 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)
|
||||
cp.assert_called_once()
|
||||
|
||||
def test_chowns_to_node_after_copy(self):
|
||||
# machine cp lands as root; without the chown, the node user
|
||||
# can't read its own mode-600 prompt.
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_cp"
|
||||
), patch(
|
||||
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_exec"
|
||||
) as ex:
|
||||
_prompt.provision_prompt(_plan(), "bot-bottle-demo-abc12")
|
||||
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(
|
||||
["chmod", "600", "/home/node/.bot-bottle-prompt.txt"],
|
||||
argv_seen,
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
cp_p, ex_p = self._patch()
|
||||
with cp_p as cp, ex_p as ex:
|
||||
_provider_auth.provision_provider_auth(_plan(), "bot-bottle-demo-abc12")
|
||||
self.assertEqual(0, cp.call_count)
|
||||
self.assertEqual(0, ex.call_count)
|
||||
|
||||
def test_codex_provider_trusts_launch_dir_without_auth_file(self):
|
||||
cp_p, ex_p = self._patch()
|
||||
with cp_p as cp, ex_p as ex:
|
||||
ex.return_value = SmolvmRunResult(0, "", "")
|
||||
_provider_auth.provision_provider_auth(
|
||||
_plan(agent_provider_template="codex"),
|
||||
"bot-bottle-demo-abc12",
|
||||
)
|
||||
cp.assert_called_once_with(
|
||||
"/tmp/codex-config.toml",
|
||||
"bot-bottle-demo-abc12:/home/node/.codex/config.toml",
|
||||
)
|
||||
argv_seen = [call.args[1] for call in ex.call_args_list]
|
||||
self.assertIn(["mkdir", "-p", "/home/node/.codex"], argv_seen)
|
||||
self.assertIn(
|
||||
["chown", "node:node", "/home/node/.codex/config.toml"],
|
||||
argv_seen,
|
||||
)
|
||||
self.assertIn(["chmod", "600", "/home/node/.codex/config.toml"], argv_seen)
|
||||
|
||||
def test_copies_dummy_auth_json_to_codex_home(self):
|
||||
cp_p, ex_p = self._patch()
|
||||
with cp_p as cp, ex_p as ex:
|
||||
ex.return_value = SmolvmRunResult(0, "Logged in using ChatGPT\n", "")
|
||||
_provider_auth.provision_provider_auth(
|
||||
_plan(
|
||||
agent_provider_template="codex",
|
||||
codex_auth_file=Path("/tmp/codex-auth.json"),
|
||||
),
|
||||
"bot-bottle-demo-abc12",
|
||||
)
|
||||
cp_calls = [call.args for call in cp.call_args_list]
|
||||
self.assertIn(
|
||||
("/tmp/codex-config.toml",
|
||||
"bot-bottle-demo-abc12:/home/node/.codex/config.toml"),
|
||||
cp_calls,
|
||||
)
|
||||
self.assertIn(
|
||||
("/tmp/codex-auth.json",
|
||||
"bot-bottle-demo-abc12:/home/node/.codex/auth.json"),
|
||||
cp_calls,
|
||||
)
|
||||
argv_seen = [call.args[1] for call in ex.call_args_list]
|
||||
self.assertIn(["mkdir", "-p", "/home/node/.codex"], argv_seen)
|
||||
self.assertIn(
|
||||
["chown", "node:node", "/home/node/.codex"],
|
||||
argv_seen,
|
||||
)
|
||||
self.assertIn(
|
||||
["chmod", "700", "/home/node/.codex"],
|
||||
argv_seen,
|
||||
)
|
||||
self.assertIn(
|
||||
[
|
||||
"find", "/home/node/.codex",
|
||||
"-maxdepth", "1",
|
||||
"-type", "f",
|
||||
"(",
|
||||
"-name", "*.sqlite",
|
||||
"-o", "-name", "*.sqlite-*",
|
||||
"-o", "-name", "*.codex-repair-*.bak",
|
||||
")",
|
||||
"-delete",
|
||||
],
|
||||
argv_seen,
|
||||
)
|
||||
self.assertIn(
|
||||
["chown", "node:node", "/home/node/.codex/auth.json"],
|
||||
argv_seen,
|
||||
)
|
||||
self.assertIn(["chmod", "600", "/home/node/.codex/auth.json"], argv_seen)
|
||||
self.assertIn(
|
||||
[
|
||||
"runuser", "-u", "node", "--",
|
||||
"env",
|
||||
"HOME=/home/node",
|
||||
"CODEX_HOME=/home/node/.codex",
|
||||
"codex", "login", "status",
|
||||
],
|
||||
argv_seen,
|
||||
)
|
||||
|
||||
def test_honors_codex_home_from_guest_env(self):
|
||||
cp_p, ex_p = self._patch()
|
||||
with cp_p as cp, ex_p as ex:
|
||||
ex.return_value = SmolvmRunResult(0, "Logged in using ChatGPT\n", "")
|
||||
_provider_auth.provision_provider_auth(
|
||||
_plan(
|
||||
agent_provider_template="codex",
|
||||
codex_auth_file=Path("/tmp/codex-auth.json"),
|
||||
guest_env={"CODEX_HOME": "/run/codex-home"},
|
||||
),
|
||||
"bot-bottle-demo-abc12",
|
||||
)
|
||||
cp_calls = [call.args for call in cp.call_args_list]
|
||||
self.assertIn(
|
||||
("/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):
|
||||
cp_p, ex_p = self._patch()
|
||||
with cp_p, ex_p as ex:
|
||||
# CODEX_HOME setup ok (0), but codex login status fails (1).
|
||||
ex.side_effect = [
|
||||
SmolvmRunResult(0, "", ""), # mkdir CODEX_HOME
|
||||
SmolvmRunResult(0, "", ""), # chown CODEX_HOME
|
||||
SmolvmRunResult(0, "", ""), # chmod CODEX_HOME
|
||||
SmolvmRunResult(0, "", ""), # reset runtime db files
|
||||
SmolvmRunResult(0, "", ""), # chown config.toml
|
||||
SmolvmRunResult(0, "", ""), # chmod config.toml
|
||||
SmolvmRunResult(0, "", ""), # chown auth.json
|
||||
SmolvmRunResult(0, "", ""), # chmod auth.json
|
||||
SmolvmRunResult(1, "Not logged in\n", ""), # login status
|
||||
]
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
class TestProvisionSkills(unittest.TestCase):
|
||||
def _patch_host_skill_dir(self, returns: dict[str, str]):
|
||||
return patch(
|
||||
"bot_bottle.backend.smolmachines.provision.skills.host_skill_dir",
|
||||
side_effect=lambda n: returns.get(n, f"/nope/{n}"),
|
||||
)
|
||||
|
||||
def test_no_op_when_agent_has_no_skills(self):
|
||||
with 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(_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):
|
||||
with self._patch_host_skill_dir({
|
||||
"init-prd": "/host/skills/init-prd",
|
||||
"verify": "/host/skills/verify",
|
||||
}), patch(
|
||||
"bot_bottle.backend.smolmachines.provision.skills.os.path.isdir",
|
||||
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(
|
||||
_plan(skills=["init-prd", "verify"]),
|
||||
"bot-bottle-demo-abc12",
|
||||
)
|
||||
|
||||
# mkdir -p once + (rm -rf + chown) per skill = 5 exec calls.
|
||||
self.assertEqual(5, ex.call_count)
|
||||
mkdir_call = ex.call_args_list[0]
|
||||
self.assertEqual(
|
||||
("bot-bottle-demo-abc12", ["mkdir", "-p", "/home/node/.claude/skills"]),
|
||||
mkdir_call.args,
|
||||
)
|
||||
# Two cp calls, one per skill, into the per-skill subdir.
|
||||
self.assertEqual(2, cp.call_count)
|
||||
cp_targets = {call.args[1] for call in cp.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(
|
||||
{
|
||||
"/home/node/.claude/skills/init-prd",
|
||||
"/home/node/.claude/skills/verify",
|
||||
},
|
||||
chown_targets,
|
||||
)
|
||||
|
||||
def test_skills_dir_overridable_via_env(self):
|
||||
import os
|
||||
with self._patch_host_skill_dir({"init-prd": "/host/skills/init-prd"}), \
|
||||
patch(
|
||||
"bot_bottle.backend.smolmachines.provision.skills.os.path.isdir",
|
||||
return_value=True,
|
||||
), \
|
||||
patch.dict(os.environ, {"BOT_BOTTLE_GUEST_SKILLS_DIR": "/home/node/.claude/skills"}), \
|
||||
patch(
|
||||
"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(
|
||||
"bot-bottle-demo-abc12:/home/node/.claude/skills/init-prd",
|
||||
cp.call_args.args[1],
|
||||
)
|
||||
|
||||
def test_missing_skill_dies(self):
|
||||
with self._patch_host_skill_dir({"init-prd": "/host/skills/init-prd"}), \
|
||||
patch(
|
||||
"bot_bottle.backend.smolmachines.provision.skills.os.path.isdir",
|
||||
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):
|
||||
_skills.provision_skills(_plan(skills=["init-prd"]), "bot-bottle-demo-abc12")
|
||||
|
||||
|
||||
def _write_self_signed_cert(path: Path) -> None:
|
||||
"""Drop a real self-signed PEM at `path` so provision_ca's
|
||||
fingerprint computation (PEM_cert_to_DER_cert + sha256) has
|
||||
@@ -553,7 +237,7 @@ def _write_self_signed_cert(path: Path) -> None:
|
||||
class TestProvisionCA(unittest.TestCase):
|
||||
"""provision_ca selects the right CA cert (egress when the
|
||||
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):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-ca.")
|
||||
@@ -566,10 +250,10 @@ class TestProvisionCA(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
# provision_ca dies hard if update-ca-certificates' stdout
|
||||
# doesn't include "1 added"; supply a stock success return
|
||||
# so the bulk of the tests below exercise the happy path.
|
||||
_UPDATE_OK = SmolvmRunResult(
|
||||
# provision_ca dies hard if update-ca-certificates' exit
|
||||
# is non-zero; supply a stock success return so the bulk of
|
||||
# the tests below exercise the happy path.
|
||||
_UPDATE_OK = ExecResult(
|
||||
returncode=0,
|
||||
stdout="Updating certificates in /etc/ssl/certs...\n1 added, 0 removed; done.\n",
|
||||
stderr="",
|
||||
@@ -577,27 +261,20 @@ class TestProvisionCA(unittest.TestCase):
|
||||
|
||||
def test_pipelock_path_when_no_routes(self):
|
||||
plan = _plan(pipelock_ca_path=self.pipelock_ca)
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_cp"
|
||||
) as cp, patch(
|
||||
"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(
|
||||
bottle = _make_bottle(exec_result=self._UPDATE_OK)
|
||||
_ca.provision_ca(plan, bottle)
|
||||
bottle.cp_in.assert_called_once_with(
|
||||
str(self.pipelock_ca),
|
||||
"bot-bottle-demo-abc12:" + _ca.AGENT_CA_PATH,
|
||||
_ca.AGENT_CA_PATH,
|
||||
)
|
||||
# chmod + chown + update-ca-certificates are now folded
|
||||
# into one `sh -c` invocation (working around a smolvm
|
||||
# exec warm-up SIGKILL race), so we look at the single
|
||||
# exec's argv rather than expecting separate calls.
|
||||
ex.assert_called_once()
|
||||
argv = ex.call_args.args[1]
|
||||
self.assertEqual("sh", argv[0])
|
||||
self.assertEqual("-c", argv[1])
|
||||
self.assertIn("chmod 644", argv[2])
|
||||
self.assertIn("update-ca-certificates", argv[2])
|
||||
# chmod + chown + update-ca-certificates are folded into
|
||||
# one exec invocation; look at the single exec's script
|
||||
# rather than expecting separate calls.
|
||||
bottle.exec.assert_called_once()
|
||||
script = bottle.exec.call_args.args[0]
|
||||
self.assertIn("chmod 644", script)
|
||||
self.assertIn("update-ca-certificates", script)
|
||||
self.assertEqual("root", bottle.exec.call_args.kwargs.get("user"))
|
||||
|
||||
def test_egress_path_when_routes_declared(self):
|
||||
plan = _plan(
|
||||
@@ -605,51 +282,39 @@ class TestProvisionCA(unittest.TestCase):
|
||||
egress_ca_path=self.egress_ca,
|
||||
pipelock_ca_path=self.pipelock_ca,
|
||||
)
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_cp"
|
||||
) 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")
|
||||
bottle = _make_bottle(exec_result=self._UPDATE_OK)
|
||||
_ca.provision_ca(plan, bottle)
|
||||
# When routes are declared, egress is the agent's first hop,
|
||||
# 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),
|
||||
"bot-bottle-demo-abc12:" + _ca.AGENT_CA_PATH,
|
||||
_ca.AGENT_CA_PATH,
|
||||
)
|
||||
|
||||
def test_retries_smolvm_sigkill_during_update_ca(self):
|
||||
plan = _plan(pipelock_ca_path=self.pipelock_ca)
|
||||
killed = SmolvmRunResult(
|
||||
killed = ExecResult(
|
||||
returncode=137,
|
||||
stdout="Updating certificates in /etc/ssl/certs...\n",
|
||||
stderr="",
|
||||
)
|
||||
bottle = _make_bottle()
|
||||
bottle.exec.side_effect = [killed, self._UPDATE_OK]
|
||||
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"
|
||||
) 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)
|
||||
|
||||
def test_dies_when_selected_cert_missing(self):
|
||||
# Plan claims a pipelock cert at a path that doesn't exist —
|
||||
# something went wrong in launch's pipelock_tls_init.
|
||||
plan = _plan(pipelock_ca_path=self.tmp / "does-not-exist.pem")
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_cp"
|
||||
), patch(
|
||||
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_exec"
|
||||
):
|
||||
with self.assertRaises(SystemExit):
|
||||
_ca.provision_ca(plan, "bot-bottle-demo-abc12")
|
||||
bottle = _make_bottle()
|
||||
with self.assertRaises(SystemExit):
|
||||
_ca.provision_ca(plan, bottle)
|
||||
|
||||
|
||||
class TestProvisionGit(unittest.TestCase):
|
||||
@@ -665,16 +330,10 @@ class TestProvisionGit(unittest.TestCase):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_noop_when_no_cwd_and_no_git_entries(self):
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_cp"
|
||||
) as cp, patch(
|
||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec"
|
||||
) as ex:
|
||||
_git.provision_git(
|
||||
_plan(stage_dir=self.stage), "bot-bottle-demo-abc12",
|
||||
)
|
||||
cp.assert_not_called()
|
||||
ex.assert_not_called()
|
||||
bottle = _make_bottle()
|
||||
_git.provision_git(_plan(stage_dir=self.stage), bottle)
|
||||
bottle.cp_in.assert_not_called()
|
||||
bottle.exec.assert_not_called()
|
||||
|
||||
def test_copies_cwd_git_when_copy_cwd_and_git_present(self):
|
||||
# Stage a fake host .git dir under user_cwd so the path-
|
||||
@@ -684,33 +343,25 @@ class TestProvisionGit(unittest.TestCase):
|
||||
plan = _plan(
|
||||
copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage,
|
||||
)
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_cp"
|
||||
) as cp, patch(
|
||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec"
|
||||
) as ex:
|
||||
_git.provision_git(plan, "bot-bottle-demo-abc12")
|
||||
cp.assert_called_once_with(
|
||||
bottle = _make_bottle()
|
||||
_git.provision_git(plan, bottle)
|
||||
bottle.cp_in.assert_called_once_with(
|
||||
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]
|
||||
self.assertIn(["mkdir", "-p", "/home/node/workspace"], argvs)
|
||||
scripts = _exec_scripts(bottle)
|
||||
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.
|
||||
self.assertIn(
|
||||
["chown", "-R", "node:node", "/home/node/workspace/.git"],
|
||||
argvs,
|
||||
self.assertTrue(
|
||||
any("chown -R" in s and "node:node" in s and "/home/node/workspace/.git" in s
|
||||
for s in scripts)
|
||||
)
|
||||
|
||||
def test_skips_cwd_when_copy_cwd_false(self):
|
||||
plan = _plan(copy_cwd=False, stage_dir=self.stage)
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_cp"
|
||||
) as cp, patch(
|
||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec"
|
||||
):
|
||||
_git.provision_git(plan, "bot-bottle-demo-abc12")
|
||||
cp.assert_not_called()
|
||||
bottle = _make_bottle()
|
||||
_git.provision_git(plan, bottle)
|
||||
bottle.cp_in.assert_not_called()
|
||||
|
||||
def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self):
|
||||
# Smolmachines's TSI-allowlisted guest dials git-gate via
|
||||
@@ -726,15 +377,11 @@ class TestProvisionGit(unittest.TestCase):
|
||||
stage_dir=self.stage,
|
||||
agent_git_gate_host="127.0.0.1:9418",
|
||||
)
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_cp"
|
||||
) as cp, patch(
|
||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec"
|
||||
):
|
||||
_git.provision_git(plan, "bot-bottle-demo-abc12")
|
||||
bottle = _make_bottle()
|
||||
_git.provision_git(plan, bottle)
|
||||
# The staged gitconfig path is whatever NamedTemporaryFile
|
||||
# picked; we read its contents.
|
||||
cp_call = cp.call_args
|
||||
cp_call = bottle.cp_in.call_args
|
||||
staged_path = Path(cp_call.args[0])
|
||||
self.assertEqual(self.stage, staged_path.parent)
|
||||
content = staged_path.read_text()
|
||||
@@ -776,71 +423,63 @@ class TestBundleLaunchSpec(unittest.TestCase):
|
||||
|
||||
class TestProvisionGitUser(unittest.TestCase):
|
||||
"""`_provision_git_user` runs `git config --global` inside the
|
||||
guest as the node user with HOME forced via `smolvm -e`
|
||||
(otherwise --global lands in /root/.gitconfig). No-op when the
|
||||
bottle didn't declare git_user (issue #86)."""
|
||||
guest as the node user. SmolmachinesBottle.exec sets HOME and
|
||||
USER automatically for the requested user, so --global lands
|
||||
in /home/node/.gitconfig. No-op when the bottle didn't declare
|
||||
git_user (issue #86)."""
|
||||
|
||||
def _git_config_calls(self, mock_exec):
|
||||
"""Filter machine_exec calls down to git-config invocations,
|
||||
return list of (argv, env-dict) tuples."""
|
||||
def _git_config_calls(self, bottle: MagicMock) -> list[tuple[str, str]]:
|
||||
"""Filter bottle.exec calls down to git-config invocations,
|
||||
return list of (script, user) tuples."""
|
||||
out = []
|
||||
for c in mock_exec.call_args_list:
|
||||
argv = c.args[1] if len(c.args) > 1 else c.kwargs.get("argv", [])
|
||||
if "git" in argv and "config" in argv:
|
||||
out.append((argv, c.kwargs.get("env") or {}))
|
||||
for c in bottle.exec.call_args_list:
|
||||
script = c.args[0] if c.args else ""
|
||||
user = c.kwargs.get("user", "node")
|
||||
if "git config" in script:
|
||||
out.append((script, user))
|
||||
return out
|
||||
|
||||
def test_noop_when_no_git_user(self):
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec"
|
||||
) as ex:
|
||||
_git._provision_git_user(_plan(), "bot-bottle-demo-abc12")
|
||||
self.assertEqual([], self._git_config_calls(ex))
|
||||
bottle = _make_bottle()
|
||||
_git._provision_git_user(_plan(), bottle)
|
||||
self.assertEqual([], self._git_config_calls(bottle))
|
||||
|
||||
def test_sets_name_and_email_as_node(self):
|
||||
plan = _plan(git_user={
|
||||
"name": "Eric Bauerfeld",
|
||||
"email": "eric@dideric.is",
|
||||
})
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec"
|
||||
) as ex:
|
||||
_git._provision_git_user(plan, "bot-bottle-demo-abc12")
|
||||
calls = self._git_config_calls(ex)
|
||||
bottle = _make_bottle()
|
||||
_git._provision_git_user(plan, bottle)
|
||||
calls = self._git_config_calls(bottle)
|
||||
self.assertEqual(2, len(calls))
|
||||
# Both go through `runuser -u node --` so they run as node;
|
||||
# HOME is forced via smolvm -e so --global writes to
|
||||
# /home/node/.gitconfig and not /root/.gitconfig.
|
||||
for argv, env in calls:
|
||||
self.assertEqual(
|
||||
["runuser", "-u", "node", "--",
|
||||
"git", "config", "--global"],
|
||||
argv[:7],
|
||||
)
|
||||
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:])
|
||||
# Both run as node so SmolmachinesBottle.exec sets HOME=/home/node
|
||||
# automatically, ensuring --global writes to /home/node/.gitconfig.
|
||||
for script, user in calls:
|
||||
self.assertEqual("node", user)
|
||||
self.assertIn("git config --global", script)
|
||||
self.assertIn("user.name", calls[0][0])
|
||||
self.assertIn("Eric Bauerfeld", calls[0][0])
|
||||
self.assertIn("user.email", calls[1][0])
|
||||
self.assertIn("eric@dideric.is", calls[1][0])
|
||||
|
||||
def test_name_only(self):
|
||||
plan = _plan(git_user={"name": "Bot"})
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec"
|
||||
) as ex:
|
||||
_git._provision_git_user(plan, "bot-bottle-demo-abc12")
|
||||
calls = self._git_config_calls(ex)
|
||||
bottle = _make_bottle()
|
||||
_git._provision_git_user(plan, bottle)
|
||||
calls = self._git_config_calls(bottle)
|
||||
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):
|
||||
plan = _plan(git_user={"email": "bot@example.com"})
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec"
|
||||
) as ex:
|
||||
_git._provision_git_user(plan, "bot-bottle-demo-abc12")
|
||||
calls = self._git_config_calls(ex)
|
||||
bottle = _make_bottle()
|
||||
_git._provision_git_user(plan, bottle)
|
||||
calls = self._git_config_calls(bottle)
|
||||
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):
|
||||
@@ -853,95 +492,33 @@ class TestProvisionWorkspace(unittest.TestCase):
|
||||
|
||||
def test_noop_when_copy_cwd_false(self):
|
||||
plan = _plan(copy_cwd=False, stage_dir=self.stage)
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.workspace._smolvm.machine_cp"
|
||||
) as cp, patch(
|
||||
"bot_bottle.backend.smolmachines.provision.workspace._smolvm.machine_exec"
|
||||
) as ex:
|
||||
_workspace.provision_workspace(plan, "bot-bottle-demo-abc12")
|
||||
cp.assert_not_called()
|
||||
ex.assert_not_called()
|
||||
bottle = _make_bottle()
|
||||
_workspace.provision_workspace(plan, bottle)
|
||||
bottle.cp_in.assert_not_called()
|
||||
bottle.exec.assert_not_called()
|
||||
|
||||
def test_copies_workspace_to_plan_path_and_chowns(self):
|
||||
cwd = self.stage / "cwd"
|
||||
cwd.mkdir()
|
||||
plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage)
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.workspace._smolvm.machine_cp"
|
||||
) as cp, patch(
|
||||
"bot_bottle.backend.smolmachines.provision.workspace._smolvm.machine_exec"
|
||||
) as ex:
|
||||
_workspace.provision_workspace(plan, "bot-bottle-demo-abc12")
|
||||
bottle = _make_bottle()
|
||||
_workspace.provision_workspace(plan, bottle)
|
||||
|
||||
cp.assert_called_once_with(
|
||||
bottle.cp_in.assert_called_once_with(
|
||||
str(cwd),
|
||||
"bot-bottle-demo-abc12:/home/node/workspace",
|
||||
"/home/node/workspace",
|
||||
)
|
||||
argvs = [c.args[1] for c in ex.call_args_list]
|
||||
self.assertIn(
|
||||
["sh", "-c", "rm -rf /home/node/workspace && mkdir -p /home/node"],
|
||||
argvs,
|
||||
scripts = _exec_scripts(bottle)
|
||||
self.assertTrue(
|
||||
any("rm -rf /home/node/workspace" in s and "mkdir -p /home/node" in s
|
||||
for s in scripts)
|
||||
)
|
||||
self.assertIn(
|
||||
[
|
||||
"sh", "-c",
|
||||
"chown -R node:node /home/node/workspace && "
|
||||
"chmod 755 /home/node/workspace",
|
||||
],
|
||||
argvs,
|
||||
self.assertTrue(
|
||||
any("chown -R node:node /home/node/workspace" in s
|
||||
and "chmod 755 /home/node/workspace" in s
|
||||
for s in scripts)
|
||||
)
|
||||
|
||||
|
||||
class TestProvisionSupervise(unittest.TestCase):
|
||||
def test_noop_when_supervise_not_enabled(self):
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.supervise._smolvm.machine_exec"
|
||||
) as ex:
|
||||
_supervise.provision_supervise(_plan(), "bot-bottle-demo-abc12")
|
||||
ex.assert_not_called()
|
||||
|
||||
def test_calls_claude_mcp_add_when_supervise_enabled(self):
|
||||
plan = _plan(
|
||||
supervise=True,
|
||||
agent_supervise_url="http://127.0.0.1:9100/",
|
||||
)
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.supervise._smolvm.machine_exec",
|
||||
return_value=SmolvmRunResult(returncode=0, stdout="", stderr=""),
|
||||
) as ex:
|
||||
_supervise.provision_supervise(plan, "bot-bottle-demo-abc12")
|
||||
ex.assert_called_once()
|
||||
argv = ex.call_args.args[1]
|
||||
# `claude mcp add --scope user` writes to ~/.claude.json,
|
||||
# and the agent is the `node` user — switch UID + set
|
||||
# HOME so the config lands in /home/node/.claude.json,
|
||||
# not root's. URL is the agent-side endpoint (host
|
||||
# loopback + discovered port), not the docker bridge IP.
|
||||
self.assertEqual(
|
||||
[
|
||||
"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):
|
||||
plan = _plan(supervise=True)
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.supervise._smolvm.machine_exec",
|
||||
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, "bot-bottle-demo-abc12")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user