bad195e9101bbeff3f2803b7de35e838f35fcfd2
433 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bad195e910 | Merge pull request 'feat(smolmachines): PRD 0022 sandbox-escape suite green under smolmachines (PRD 0023 chunk 5)' (#73) from prd-0023-chunk-5-sandbox-suite-smolmachines into main | ||
|
|
d7cef27584 |
feat(smolmachines): PRD 0022 sandbox-escape suite green under smolmachines (PRD 0023 chunk 5)
Final PRD 0023 chunk. The PRD 0022 attack suite was already backend-agnostic — it goes through get_bottle_backend(), so the right dispatch happens based on CLAUDE_BOTTLE_BACKEND. Two cleanups to make it actually run cleanly under CLAUDE_BOTTLE_BACKEND=smolmachines: - setUpClass raises unittest.SkipTest with a useful message when CLAUDE_BOTTLE_BACKEND=smolmachines but smolvm isn't on PATH, or when the host isn't macOS (libkrun + TSI single-IP allowlist is macOS-only in v1). Without this, the test would die deep inside backend.prepare's smolmachines_preflight rather than skipping. - test_5_readme_push_blocked switches from a hardcoded `git://git-gate/...` remote URL (only resolvable on docker via the bundle's short alias) to the bottle's declared upstream URL (`ssh://git@unreachable.invalid:22/throwaway.git`). The agent's ~/.gitconfig insteadOf rewrite — set up by provision_git on both backends — transparently redirects to the gate, so the same test exercises docker's `git://git-gate/...` and smolmachines's `git://<bundle_ip>:9418/...` URLs without branching on backend. README gets a "Backend selection" subsection under Quickstart documenting CLAUDE_BOTTLE_BACKEND, the macOS-only v1 scope for smolmachines, and the `curl -sSL .../install.sh | sh` install prerequisite — per PRD 0023's acceptance criteria. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
eceba96c68 | Merge pull request 'fix(smolmachines): docker push fails on Docker Desktop — daemon-side route differs from host loopback' (#74) from fix-local-registry-docker-desktop into main | ||
|
|
d02fe50193 |
fix(smolmachines): run claude mcp add as node so config lands in node's home
provision_supervise dispatched `claude mcp add --scope user` through `smolvm machine_exec`, which runs as root by default. The MCP entry got written to root's ~/.claude.json — but the agent's claude reads /home/node/.claude.json, so `/mcp` showed "No MCP servers configured" inside the bottle. Wrap the exec in `runuser -u node -- env HOME=/home/node ...` so the config writes to the right home. Same pattern as the interactive exec_claude / Bottle.exec wrappers — `smolvm machine_exec` is always root, so any command that touches user state has to switch UID + set HOME explicitly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
515306cd4a |
fix(smolmachines): restore /tmp + /var/tmp perms after smolvm pack remap
smolvm's pack process remaps OCI-layer ownership to the host invoker's uid for *every* directory, not just /home/node — so /tmp lands as `0755 501:dialout` instead of the standard `1777 root:root`. Non-root processes can't create per-uid scratch dirs in there. Claude-code's first Bash tool call fails with `EACCES: permission denied, mkdir '/tmp/claude-1000'`. Same workaround folded into the existing perms-repair sh -c: `chown root:root /tmp /var/tmp && chmod 1777 /tmp /var/tmp` next to the /home/node chown. One machine_exec round trip total. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
45c821a8f3 |
docs(smolmachines): note loopback-scope limitation + tracking issue
PR #74's Docker-Desktop pivot widened the smolmachines TSI allowlist from `<bundle-ip>/32` to `127.0.0.1/32` (TSI can't filter by port, and docker bridge IPs aren't reachable from macOS networking). The agent VM can therefore reach any service on macOS's loopback while the bottle is running — not just the bundle's published ports. README gets a "Smolmachines backend" subsection under Quickstart spelling this out as a known v1 limitation. PRD 0023 grows a new open question #8 with the proposed v2 fix (per-bottle loopback alias + TSI allowlist scoped to that /32, via sudo `ifconfig lo0 alias`). Tracking issue: gitea.dideric.is/didericis/claude-bottle/issues/75. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
5486170be1 |
fix(smolmachines): route agent through egress when routes declared, wait for VM warm-up
Two related bugs: 1. Auth chain bypassed egress. After the Docker-Desktop port pivot, the agent always dialed pipelock directly — meaning egress (which holds the real OAuth token and rewrites the Authorization header) wasn't in the request path. Bearer placeholder reached anthropic verbatim → 401 "Invalid bearer token". Fix: when the bottle declares egress.routes, the agent's first hop is egress (publish egress port 9099 to host loopback, leave pipelock bundle-internal). Without routes, the agent dials pipelock directly. Same hop order as the docker backend. 2. provision_ca's update-ca-certificates SIGKILLed at ~100ms on Docker Desktop. Back-to-back `smolvm machine exec` calls immediately after machine_start hit a VM warm-up race in libkrun's exec channel; the second exec's child got SIGKILL'd before producing more than the first line of stdout. The agent's trust store never got the egress MITM CA's hash symlink, so curl/openssl couldn't validate the TLS chain. Fix: 1.5s sleep after machine_start (empirically enough), plus fold provision_ca's chown + chmod + update-ca-certificates into one `sh -c` so we only pay one exec round trip. Bail with a clear error if update-ca- certificates doesn't report "1 added" (failing silently was how the original SIGKILL went unnoticed). Net effect on Docker Desktop / macOS: claude's HTTPS_PROXY is `http://127.0.0.1:<egress port>`, egress rewrites auth, pipelock allowlists + DLPs, request reaches api.anthropic.com with a real token. End-to-end verified. Also drops the PRD-0023-chunk-3 EGRESS_LISTEN_HOST=127.0.0.1 mitigation. The original concern (agent bypassing pipelock by dialing egress's port on the bundle IP) doesn't apply in this topology: the agent can only reach whatever port we publish on host loopback, and egress is the only HTTP/HTTPS chokepoint that gets published. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
4f136a9932 |
fix(smolmachines): agent dials bundle via host loopback ports, not docker bridge IP
Claude hung on outbound network calls under CLAUDE_BOTTLE_BACKEND=smolmachines: Unable to connect to API (FailedToOpenSocket) Root cause: the PRD-0023 design pinned the bundle at a docker bridge IP (192.168.X.2) and set the smolvm guest's TSI allowlist to `<bundle-ip>/32`. On native Linux this works — host shares the docker bridge's network namespace, TSI's syscall impersonation reaches the bridge IP directly. On Docker Desktop (macOS), the daemon runs in its own Linux VM and docker bridge IPs aren't reachable from macOS networking, so the smolvm guest's TSI requests die "Network is unreachable" before they hit pipelock. Fix: publish each agent-facing bundle daemon's port on host loopback (-p 127.0.0.1::PORT), discover the random host-side ports after start, and route the agent through `127.0.0.1:<host port>` instead of the bridge IP. macOS loopback is the surface Docker Desktop's gvproxy forwards into the daemon's VM, so the chain (guest TSI -> macOS loopback -> daemon VM port-forward -> bundle container) works on both Docker Desktop and native Linux. Concrete changes: - BundleLaunchSpec: add `ports_to_publish` so start_bundle adds `-p 127.0.0.1::PORT` for the agent-facing ports (pipelock always; git-gate when upstreams declared; supervise when enabled). Egress's port stays bundle-internal. - sidecar_bundle.bundle_host_port(): wrap `docker port <bundle> <container_port>/tcp` so launch can look up the random host-side mapping after start. - launch.py: discover the host ports, build URLs of the form `http://127.0.0.1:<host port>` / `git://127.0.0.1:<host port>`, stamp onto guest_env + new agent_*_url fields on the plan. - launch.py: TSI allow_cidrs flips to `["127.0.0.1/32"]`. The bundle IP is no longer the agent's target. - prepare.py: stop synthesizing HTTPS_PROXY / GIT_GATE_URL / MCP_SUPERVISE_URL at prepare time — launch owns those now (the values depend on a port docker hasn't assigned yet). - provision_git: gate_host from plan.agent_git_gate_host. - provision_supervise: URL from plan.agent_supervise_url. End-to-end verified on Docker Desktop / macOS: guest dials pipelock through TSI, pipelock forwards to api.anthropic.com, the API responds with 401 (i.e. it received the request). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
da1e5e1ba8 |
fix(smolmachines): pass --net explicitly when allow_cidrs is set
smolvm 0.8.0 docs say `--allow-cidr` implies `--net`, but empirically the implication only fires when no `--from` is set. `--from PATH --allow-cidr X/32` silently produces a machine with network: false and no routes in the guest — claude lands inside with HTTPS_PROXY pointing at the bundle's pinned IP but every connect fails with "Network is unreachable" / FailedToOpenSocket in claude's UI. Reproduce + verify: $ smolvm machine create --from <pack> --allow-cidr X/32 nettest $ smolvm machine ls --json | jq '.[].network' # false $ smolvm machine create --from <pack> --net --allow-cidr X/32 nettest2 $ smolvm machine ls --json | jq '.[].network' # true Add `--net` whenever `allow_cidrs` is non-empty. No change to the no-allow-cidr code path. Test added to lock down both branches. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
91955ec59f |
fix(smolmachines): forward guest env on every exec + chown /home/node
Two issues kept claude's TUI from drawing after launch: 1. smolvm pack remaps OCI-layer ownership to the host invoker's uid (501 on macOS) instead of preserving the image's USER node (uid 1000). /home/node ends up owned by some uid that doesn't exist in the VM, so when claude runs as node it can't appendFileSync to ~/.claude.json on startup — fails with ENOENT and the TUI hangs. Fix: chown -R node:node /home/node after machine_start, before provision. 2. smolvm machine_create -e sets env on PID 1 but it doesn't propagate to fresh exec process trees (verified empirically: `smolvm machine exec -- printenv` shows none of the machine_create env vars). Claude was running with no HTTPS_PROXY / CLAUDE_CODE_OAUTH_TOKEN / NODE_EXTRA_CA_CERTS, so even the auth-validation step bailed silently. Fix: thread `guest_env` through to the SmolmachinesBottle handle and re-pass every entry via `-e K=V` on every machine_exec call (interactive claude and shell exec both). Also fills in the same `CLAUDE_CODE_OAUTH_TOKEN=egress- placeholder` + telemetry-off env the docker backend's forwarded_env carries, plus the NODE_EXTRA_CA_CERTS / SSL_CERT_FILE / REQUESTS_CA_BUNDLE trust trio. Verified end-to-end on Docker Desktop / macOS: claude's TUI renders cleanly with the bypass-permissions banner. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
35edf50f21 |
fix(smolmachines): drop runuser -l in favor of UID switch + explicit HOME/USER
Interactive claude session hung silently after `attaching interactive claude session...` — `runuser -l` invokes a login shell that triggers PAM session setup / /etc/profile sourcing, and the minimal Debian agent VM doesn't have the PAM config files for that to complete cleanly. claude never got to draw its TUI. Switch UID via plain `runuser -u <user> --` (no `-l`) and inject HOME / USER through `smolvm machine exec -e` so the child process sees them. Avoids login-shell wiring entirely. Same pattern in `exec_claude` and `exec(script)`. `_HOME_FOR` maps the two users the codebase currently asks for (`node`, `root`); anything else falls back to `/home/<user>`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
af65c10361 |
refactor: Bottle.exec takes a user= kwarg, default node
Promote the user-switch from a hardcoded `node` to a keyword arg so callers can opt into root (or any other user) when needed. Default stays `node` — matches the docker image's USER and the smolmachines runuser default. Lifts the change through the base ABC, docker, and smolmachines backends: - Base: `def exec(self, script, *, user="node")`. - Docker: adds `-u <user>` to `docker exec` (no-op when user is node, the image's default). - Smolmachines: `runuser -l <user> -c <script>` — `runuser -l root` is the trivial no-op form when the caller asked for root. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
e26d459a97 |
fix(smolmachines): run claude + shell exec as the node user
`smolvm machine exec` runs commands as root in the VM, but the
agent image's USER is `node`. claude-code refuses
`--dangerously-skip-permissions` when invoked as root, killing
the interactive session right after `attaching interactive claude
session...`:
--dangerously-skip-permissions cannot be used with root/sudo
privileges for security reasons
Wrap both `exec_claude` and `exec(script)` in
`runuser -l node -c ...` so commands run as the node user with
node's $HOME / $USER (login shell). The docker backend gets
this behavior for free via the image's USER directive; this
restores parity.
shlex-quote each claude argv element when stitching the runuser
-c shell command so paths / flags with shell-special chars
survive the parse.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
906c9fd1bb |
fix(smolmachines): preflight print uses plan-level egress routes
`SmolmachinesBottlePlan.print` iterated over
`bottle.egress.routes` (the manifest's capitalized-attribute form
on `manifest.EgressRoute`) but accessed `r.host` (lowercase).
Worked when no egress routes were declared; AttributeError
("EgressRoute has no attribute 'host'") on the first bottle with
a route.
Switch to `self.egress_plan.routes` — the resolved plan-level
EgressRoute (lowercase `host`), same source the docker backend's
print uses.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
47eb56bd10 |
fix(smolmachines): use containerized crane to push, bypassing docker daemon's HTTPS preference
The previous fix (`host.docker.internal:<port>` for daemon-side push) still failed: Get "https://host.docker.internal:53958/v2/": http: server gave HTTP response to HTTPS client `host.docker.internal` is reachable from Docker Desktop's daemon VM but isn't in the daemon's default insecure-registries CIDRs (only `::1/128` and `127.0.0.0/8` are), so docker push tries HTTPS, hits a plain-HTTP registry, and refuses. The daemon.json fix (`"insecure-registries": ["host.docker.internal"]`) works but is a one-time manual step in Docker Desktop's UI — not something we can do for the user. Sidestep the daemon push entirely: 1. docker build (as before) — local layer cache makes no-change rebuilds cheap. 2. docker save the image to a per-digest tarball alongside the cached `.smolmachine`. 3. Start an ephemeral registry container on a per-session docker network, with `-p :5000` so the host can also reach it for the pack step. 4. docker run a one-shot crane container on the SAME network, mount the tarball, `crane push --insecure /img.tar <registry-container>:5000/...`. Container DNS resolves the registry on the network; `--insecure` forces plain HTTP. 5. `smolvm pack create --image localhost:<host port>/...` from the host. smolvm's bundled crane auto-falls-back to HTTP for localhost addresses, so no insecure-registries config is needed on that side. 6. Tear down everything; reap the tarball (registries hold the same bytes, no need to keep both around). Net effect: the docker daemon never does an HTTP/HTTPS-policy decision on our behalf. `docker push` is gone from the prepare path; `docker save`, `docker network create`, `docker run` (for registry + crane) replace it. Tested end-to-end on Docker Desktop / macOS: `_ensure_smolmachine ("claude-bottle:latest")` produces a 204MB `.smolmachine.smolmachine` artifact. Adds: - backend/docker/util.py:save() — thin docker save wrapper. - local_registry.crane_push_tarball() — one-shot crane run on the registry's network. - CRANE_IMAGE constant pinned by digest (gcr.io/go-containerregistry/crane@sha256:0ae17ecb...). Removes: - backend/docker/util.py:tag() / push() — unused without daemon push. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
f4026ea3ae |
fix(smolmachines): docker push fails on Docker Desktop — daemon-side route differs from host loopback
`./cli.py start <agent>` under CLAUDE_BOTTLE_BACKEND=smolmachines died at `docker push localhost:<port>/claude-bottle:<id>` with `Get "http://localhost:<port>/v2/": context deadline exceeded`. Cause: chunk 4c bound the ephemeral registry to `127.0.0.1::5000` and used `localhost:<port>` as the only image-ref hostname. On Docker Desktop the daemon runs inside its own Linux VM — its `localhost` is the VM's loopback, not the host's, so the daemon cannot reach a registry bound to the host's 127.0.0.1. Fix: bind the registry to all interfaces (`-p :5000`) so it's reachable from both sides, and yield two endpoints: - `daemon_endpoint` — `host.docker.internal:<port>` on Docker Desktop (daemon-side hostname for the host VM gateway), `localhost:<port>` on a native Linux daemon that shares the host's network namespace. Used for `docker tag` + `docker push`. - `host_endpoint` — always `localhost:<port>`. Used for `smolvm pack create`, which runs as a host process. The registry stores images by repo+tag, so a push to `host.docker.internal:<port>/cb:<id>` and a pull from `localhost:<port>/cb:<id>` resolve to the same blob — the hostname in a ref is just routing. Detection uses `docker info --format '{{.OperatingSystem}}'`, which returns "Docker Desktop" on macOS/Windows Desktop and the host's OS name on native daemons. Trade-off: all-interface binding briefly publishes the registry on every interface (~5-10s during prepare). The pushed image is built from the public repo Dockerfile (no secrets), the port is random, and the window is short — acceptable for v1 of a personal dev tool. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
ac8c7ba696 |
feat(smolmachines): provision_ca + provision_git + provision_supervise (PRD 0023 chunk 4d)
End-to-end provisioning parity with the docker backend. After this
chunk a smolmachines bottle has a working trust store, git-gate
gitconfig, and supervise MCP registration — same shape as docker,
dispatched via `smolvm machine cp` / `smolvm machine exec` instead
of `docker cp` / `docker exec`.
Adds three new provision modules:
- ca.py: select egress vs pipelock CA (same logic as
docker), machine cp + update-ca-certificates,
log sha256 fingerprint.
- git.py: copy host .git when --cwd was passed; render
~/.gitconfig with insteadOf URLs. URL prefix is
`git://<bundle_ip>:9418/...` (no DNS in the
TSI-allowlisted guest) vs docker's
`git://git-gate/...`.
- supervise.py: `claude mcp add` via machine_exec; URL is
`http://<bundle_ip>:9100/`. Failure is logged but
non-fatal (matches docker).
Shared render: `render_git_gate_gitconfig` moves out of
backend/docker/provision/git.py into the platform-neutral
claude_bottle/git_gate.py (renamed to git_gate_render_gitconfig
for consistency with the existing git_gate_render_* helpers),
parameterized on a `gate_host` argument so both backends use the
same logic with different addresses.
Path/user fixups for the post-chunk-4c agent image (real
claude-bottle image, USER node, $HOME=/home/node):
- prompt.py default path moves from /root/... to
/home/node/.claude-bottle-prompt.txt; chown + chmod after
machine cp.
- skills.py default skills dir moves from /root/.claude/skills to
/home/node/.claude/skills; chown -R per skill.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
1fa17d1822 |
feat(smolmachines): build agent image from repo Dockerfile (PRD 0023 chunk 4c)
Replaces the alpine:latest placeholder with a real claude-bottle agent image, converted into a .smolmachine artifact via an ephemeral local OCI registry. Why the registry hop: smolvm pack create only accepts OCI registry refs. Empirically it rejects docker-daemon://, oci-layout://, docker-archive: tarballs, and every other transport tested — the crane backend treats anything with a scheme prefix as a registry hostname. To convert a locally-built docker image into a .smolmachine we have to push it somewhere smolvm can pull from. Smallest path: bring up registry:2.8.3 bound to 127.0.0.1:<random>, docker tag + docker push into it, smolvm pack create --image localhost:<port>/claude-bottle:<id>, tear down the registry. The .smolmachine is cached under ~/.cache/claude-bottle/smolmachines/ keyed by the docker image ID (first 16 hex chars of the sha256), so a Dockerfile change picks up a new image ID and invalidates the cache. Unchanged rebuilds skip the whole build → registry → pack pipeline. This puts `docker build` in smolmachines prepare (the docker backend defers it to launch). Necessary because pack_create needs the image ID to derive the cache key, and prepare is the only hook ahead of launch that runs once per slug. Adds: - claude_bottle/backend/docker/util.py: image_id / tag / push helpers (thin docker CLI wrappers). - claude_bottle/backend/smolmachines/local_registry.py: ephemeral_registry() context manager; pins registry:2.8.3 by digest, binds 127.0.0.1::5000 (loopback-only), force-removes on exit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
4ac61a563b | Merge pull request 'feat(smolmachines): thread inner Plans + bundle daemons run (PRD 0023 chunk 4b)' (#70) from prd-0023-chunk-4b-inner-plans into main | ||
|
|
519a71f2e7 |
refactor(docker): drop legacy names from capability_apply teardown
Last of the per-sidecar legacy names. `_per_bottle_container_names` used to list the four pre-bundle sidecars (cred-proxy, pipelock, git-gate, supervise) so capability-apply's teardown would force-rm them on remediation. None of those containers exist anymore — the four daemons run in the sidecar bundle (PRD 0024), so the list collapses to the agent + the bundle. Integration test follows: the fake supervise-sidecar setup, which existed to give teardown an extra container to clean up, switches to a fake sidecar bundle with the current name. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
727f30d422 |
refactor(docker): drop legacy per-sidecar container_name functions
Same line of cleanup as the supervise rename: the per-sidecar container names (`claude-bottle-pipelock-<slug>`, `claude-bottle-egress-<slug>`, `claude-bottle-git-gate-<slug>`) were docker-network aliases pointing at the bundle, kept so legacy URLs would keep resolving. Replaces them with short hostnames (`pipelock`, `egress`, `git-gate`) matching the existing `EGRESS_HOSTNAME` pattern, and inlines the bundle-loopback URL (`http://127.0.0.1:8888`) for the in-bundle egress→pipelock hop — matching what smolmachines already does. Drops the three `*_container_name` functions, `pipelock_proxy_url`, and `git_gate_host`. Their callers move to the new constants: - `PIPELOCK_HOSTNAME = "pipelock"` (claude_bottle/pipelock.py) - `GIT_GATE_HOSTNAME = "git-gate"` (claude_bottle/git_gate.py) - `BUNDLE_LOCAL_PIPELOCK_URL` (backend/docker/pipelock.py) The agent's HTTP_PROXY now reads `http://pipelock:8888` (vs the old `http://claude-bottle-pipelock-<slug>:8888`); the gitconfig insteadOf rewrites become `git://git-gate/<repo>.git`. The prepare- time orphan probe is collapsed onto the bundle container name (`claude-bottle-sidecars-<slug>`) instead of the four legacy per-sidecar names that no backend creates anymore. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
8ecba2b458 |
refactor(docker): drop legacy supervise_container_name alias
Supervise runs inside the sidecar bundle (PRD 0024), not in its own container. The `claude-bottle-supervise-<slug>` per-sidecar name only existed as a docker-network alias on the bundle so legacy code paths that referenced the old name would still resolve. Nothing inside the project relies on that resolution anymore — the short `supervise` alias is the one all consumers use — so the legacy long-form is dead. Drops the function entirely, plus its registration as a network alias and as an orphan probe in prepare. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
73dc0d4a40 |
refactor(sidecars): instantiate sidecar ABCs directly from any backend
The four sidecar prepare-time helpers (PipelockProxy, Egress, GitGate, Supervise) had docker-flavored subclasses that existed only as instantiation shims for ABCs that already had no abstract methods. PipelockProxy.prepare() reached for class-level CA path constants that were only defined on the docker subclass — so smolmachines had to import DockerPipelockProxy to render pipelock yaml, reaching across the backend boundary for what's actually a platform-neutral operation. This moves the universal in-container CA paths (PIPELOCK_CA_CERT_IN_CONTAINER / PIPELOCK_CA_KEY_IN_CONTAINER) to claude_bottle/pipelock.py, drops the class-attr indirection on the ABC, and deletes the four empty docker subclasses. Both backends now instantiate the ABCs directly; the docker-side modules keep the docker-flavored helpers (image pin, container naming, host CA mint) and re-export the moved pipelock constants for compat. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
1dfc359141 |
feat(smolmachines): thread inner Plans + bundle daemons run (PRD 0023 chunk 4b)
Bundle daemons (pipelock, egress, optionally git-gate + supervise)
now actually start with their config files bind-mounted from the
inner Plans the docker backend already produces. Chunks 2d + 3
ran with daemons_csv="" so the bundle's init supervisor idled;
chunk 4b wires up the real path: agent → pipelock → egress →
internet (when routes declared) is now functional, modulo agent-
image gaps (claude-code / TLS-trust-store / git in the guest)
that chunk 4c addresses.
bottle_plan.py — added the four inner Plan fields:
proxy_plan: PipelockProxyPlan
git_gate_plan: GitGatePlan
egress_plan: EgressPlan
supervise_plan: SupervisePlan | None
Same shape the docker backend's plan uses. Docker-network-only
fields (internal_network, egress_network) stay at dataclass
defaults — the smolmachines bundle is on a per-bottle bridge
with a pinned IP, not docker's --internal + egress topology.
prepare.py — instantiates DockerPipelockProxy / DockerEgress /
DockerGitGate / DockerSupervise and calls their .prepare()
methods to write the per-bottle config files (pipelock.yaml,
routes.yaml, git-gate entrypoint/hooks, supervise queue dir)
under the per-bottle state dir. (The "Docker" prefix on the
class names is a misnomer here — .prepare() is platform-neutral,
inherited from each sidecar's ABC. A future cleanup could factor
the prepare logic out of the docker subpackage.)
launch.py — major rewrite:
- pipelock_tls_init at launch (always); egress_tls_init only
when the bottle declares routes (otherwise the CA files
aren't bind-mounted and openssl runs would be wasted).
- Inner Plans updated in place with launch-time CA paths +
EGRESS_UPSTREAM_PROXY = http://127.0.0.1:8888 (egress's
upstream is pipelock on the bundle's own loopback; same
container's network namespace).
- BundleLaunchSpec env + volumes built from the inner Plans:
pipelock.yaml + CA + key (always); egress routes + CAs +
upstream env + token-slot bare names (when routes); git-gate
entrypoint + hooks + per-upstream identity files (when
upstreams); supervise queue dir + env (when enabled).
- daemons_csv = ["egress", "pipelock"] + ["git-gate"] (if
upstreams) + ["supervise"] (if enabled).
- Token env values resolved from host env via
`egress_resolve_token_values` and threaded into the
docker-run subprocess env (bare-name -e entries in spec
inherit from there — values never land on argv).
Tests:
- 552 unit passing (no new unit cases; fixture updated to
populate the new plan fields).
- 5 integration cases passing locally (Darwin + smolvm + docker
+ not GITEA_ACTIONS):
* test_smoke_exec_echo — still works.
* test_localhost_reach_probe — host loopback still refused.
* test_egress_port_bypass_probe — <bundle-ip>:9099 still
refused, NOW WITH EGRESS ACTUALLY RUNNING (chunk 3's
127.0.0.1 bind-address is doing its job).
* test_prompt_file_lands_in_guest — still works.
* test_pipelock_answers_on_bundle_ip — NEW. From inside the
guest, wget to <bundle-ip>:8888 gets an HTTP response
(not "connection refused") — proves pipelock is actually
listening and the bind-mount + CA generation path works.
What's left in chunk 4:
- 4c: agent-image-conversion (claude-code + git + curl +
ca-certificates in the guest). Chunk 2d's alpine placeholder
stays for now.
- 4d: provision_ca + provision_git + provision_supervise once
the agent image has the required tools.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
085a0c1923 |
style(smolmachines): provision_git stub uses pass not del
Addresses PR #69 review comment: `del plan, target` was just a silence-the-unused-arg gesture but reads oddly for a stub. `pass` is the standard "this is a stub" sentinel. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
9e3b7e441e |
feat(smolmachines): provision_prompt + provision_skills (PRD 0023 chunk 4a)
First slice of chunk 4: implement the two provisioning methods
that don't depend on agent-image tooling beyond `cp` and
`mkdir`. provision_ca / provision_git / provision_supervise
land once the agent-image gap is solved (chunk 4b+) — they need
update-ca-certificates, git, and the claude binary respectively,
none of which the chunk-2d alpine placeholder provides.
What this PR ships:
- `claude_bottle/backend/smolmachines/provision/` subpackage
with `prompt.py` + `skills.py`. Each routes through
`smolvm.machine_cp` / `machine_exec`. provision_prompt mirrors
the docker contract (file always copied; return value drives
--append-system-prompt-file iff the agent has a non-empty
prompt). provision_skills mkdir + cp per skill, matching
the docker backend's loop.
- prepare.py now writes the prompt file under
agent_state_dir(slug) with the agent's `prompt` body, mode
0o600. The in-guest path is `/root/.claude-bottle-prompt.txt`
(alpine has no `node` user; will become `/home/node/...` once
the real claude-bottle image lands).
- launch.py calls `provision(plan, machine_name)` after
machine_start. The returned prompt path threads to
SmolmachinesBottle so exec_claude can add
--append-system-prompt-file when the agent has a prompt.
- backend.py: provision_prompt / provision_skills now real;
provision_git is a deliberate stub (waiting on the git-gate
inner Plan + git in the agent image). provision_supervise
stays the chunk-2d stub.
Tests:
- 7 new unit cases (test_smolmachines_provision.py): argv
shape (mocked smolvm.machine_cp / .machine_exec),
prompt return-value contract, no-op-with-no-skills,
CLAUDE_BOTTLE_GUEST_SKILLS_DIR override, fail-on-missing-skill.
- 1 new integration case in test_smolmachines_launch.py:
end-to-end verification that the prompt file lands in the
alpine guest at /root/.claude-bottle-prompt.txt with the
expected content (via `bottle.exec("cat ...")`). The smoke +
the two TSI probes stay green.
552 unit + 4 integration (Darwin+smolvm+docker gated) passing.
What's left in chunk 4:
- 4b: thread the inner Plans (PipelockProxyPlan / EgressPlan /
GitGatePlan / SupervisePlan) through prepare + launch so the
bundle daemons actually run (currently daemons_csv="").
- 4c: the agent-image-conversion gap — get claude-code + git +
curl + ca-certificates into the guest image (build a
.smolmachine via `pack create --from-vm` after manual setup,
or push the docker image to a registry smolvm can pull).
- 4d: provision_ca + provision_git + provision_supervise once
4b + 4c land.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
554d60324d | Merge pull request 'feat(sidecars): egress binds 127.0.0.1 when EGRESS_LISTEN_HOST is set (PRD 0023 chunk 3)' (#68) from prd-0023-chunk-3-egress-bind-localhost into main | ||
|
|
909029085e |
feat(sidecars): egress binds 127.0.0.1 when EGRESS_LISTEN_HOST is set (PRD 0023 chunk 3)
Egress's bind address is now env-driven via EGRESS_LISTEN_HOST. Unset → mitmdump's default (all interfaces) — the docker backend's behavior, unchanged. Set to `127.0.0.1` → mitmdump binds localhost only. The smolmachines launch sets EGRESS_LISTEN_HOST=127.0.0.1 in the bundle's env unconditionally. TSI's allowlist is `<bundle-ip>/32` (IP-only, not port-granular), which would otherwise let the agent dial `<bundle-ip>:9099` and bypass pipelock's DLP by talking to egress directly. Binding egress to localhost inside the bundle closes that gap at the socket level — the agent still reaches the IP (TSI permits it) but egress refuses the connect because it's not listening on the docker bridge interface. The docker backend doesn't set the env var because its agent dials egress directly via the docker network alias — egress MUST be reachable from outside the bundle there. The asymmetry is documented in the entrypoint script's comment. Changes: - egress_entrypoint.sh: read EGRESS_LISTEN_HOST, conditionally pass `--listen-host <host>` to mitmdump. - smolmachines/launch.py: BundleLaunchSpec.environment now includes `EGRESS_LISTEN_HOST=127.0.0.1`. - New unit tests (5): the entrypoint script's argv shape under various env combinations, verified via a fake mitmdump shim that prints its argv. 545 unit + 3 integration tests passing. The egress-port-bypass probe from chunk 2d still passes (chunk 2d ran with daemons_csv="" so no egress was up; chunk 3 makes the probe preserve its property once egress IS up in chunk 4). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
9f65b137b9 |
feat(smolmachines): end-to-end launch + Bottle.exec + smoke + probes (PRD 0023 chunk 2d)
End-to-end launch flow for the smolmachines backend. Brings up
the per-bottle docker bridge + sidecar bundle, creates and
starts the smolvm guest pointed at the bundle's pinned IP via
TSI's `--allow-cidr <bundle-ip>/32`, yields a SmolmachinesBottle
handle that routes exec/cp through `smolvm machine exec / cp`,
tears everything down on context exit.
launch.py:
- ExitStack-managed: create_bundle_network → start_bundle →
machine_create → machine_start (each registered for reverse
teardown).
- daemons_csv="" for chunk 2d — bundle init logs "no daemons
selected" and idles. Real daemon bringup with inner-Plan-driven
env + volumes lands in chunk 4.
bottle.py:
- SmolmachinesBottle.exec → smolvm.machine_exec (captured).
- SmolmachinesBottle.exec_claude → direct subprocess.run with
inherited TTY for interactive sessions.
- SmolmachinesBottle.cp_in → smolvm.machine_cp.
Architecture pivots forced by smolvm 0.8.0's CLI shape:
1. `--from <smolmachine>` and `--smolfile <toml>` are MUTUALLY
EXCLUSIVE in smolvm 0.8.0. We need --from to avoid the
registry-pull race that bit us on machine_start (libkrun
agent's network attempt got refused by macOS with
"connect: permission denied" on IPv6). So Smolfile is dropped
entirely; per-bottle env + allow_cidrs flow as CLI flags
(`--allow-cidr CIDR`, `-e K=V`) directly to machine_create.
2. `smolvm pack create --image` doesn't pull from the local
docker daemon — only OCI registries via crane. The real
claude-bottle:latest image lives in the local docker daemon
and isn't reachable that way. Chunk 2d ships with an alpine
placeholder; the agent-image-conversion gap belongs to
chunk 4 (push the image to a registry, or smolvm grows a
docker-daemon transport).
Other changes:
- machine_create grew `image=` / `from_path=` / `allow_cidrs=`
/ `env=` kwargs; smolfile= dropped.
- bottle_plan: smolfile_path → agent_from_path + guest_env.
- prepare: pack_create against `alpine:latest`, cached under
~/.cache/claude-bottle/smolmachines/ keyed by image ref.
- Deleted smolfile.py + test_smolfile.py (dead code now).
Tests:
- Unit: 540 passing (smolvm wrapper grew 4 new flag forms; one
test renamed to reflect --from + --allow-cidr + -e combo).
- Integration: 3 new cases in tests/integration/
test_smolmachines_launch.py, gated on Darwin + smolvm on PATH
+ docker + not GITEA_ACTIONS:
* smoke: bottle.exec("echo hello-from-vm") round-trips with
the correct stdout + returncode.
* localhost-reach probe: agent dials 127.0.0.1:9 → connect
refused (TSI's <bundle-ip>/32 allowlist doesn't include
loopback). The regression test for the gap the PRD design
pivot was about.
* egress-port-bypass probe: agent dials <bundle-ip>:9099
(egress's port) → connect refused. Chunk 2d has no
daemons running so nothing's listening anyway; chunk 3
will preserve this property once egress is up but bound
to 127.0.0.1 inside the bundle.
End-to-end smoke + both probes green locally on macOS with
smolvm 0.8.0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
6b861a1418 | Merge pull request 'feat(smolmachines): bundle bringup on per-bottle docker bridge (PRD 0023 chunk 2c)' (#66) from prd-0023-chunk-2c-bundle-bringup into main | ||
|
|
495be7f9c0 |
feat(smolmachines): bundle bringup on per-bottle docker bridge (PRD 0023 chunk 2c)
claude_bottle/backend/smolmachines/sidecar_bundle.py — primitives for the per-bottle bridge + bundle container with pinned IP: - bundle_network_name(slug) / bundle_container_name(slug) - create_bundle_network(name, subnet, gateway) - remove_bundle_network(name) - start_bundle(BundleLaunchSpec, env=) - stop_bundle(slug) `BundleLaunchSpec` carries the launch-time fields (network + subnet + gateway + bundle_ip + daemons_csv + environment + volumes). Wiring it up from the inner Plans (PipelockProxyPlan, EgressPlan, GitGatePlan, SupervisePlan) is chunk 2d's job; this module is the docker-argv surface only. Pinning the bundle IP via `docker run --ip <bundle-ip>` is what makes smolvm's TSI allowlist (`<bundle-ip>/32`) safe to compute at prepare time — without pinning, we'd have to inspect the assigned IP after start and feed it back into the Smolfile. Idempotent semantics where it matters: `create_bundle_network` treats "already exists" as success, `remove_bundle_network` + `stop_bundle` treat "no such ..." as success. Other failures die / warn depending on whether the launch flow can recover. Tests: - 15 unit cases (mocked subprocess.run): argv shape for create / remove / start / stop, idempotent paths, host-env inheritance to docker run subprocess. - 1 integration case (real docker daemon, gated on docker available + not GITEA_ACTIONS): end-to-end bringup of an empty-daemons bundle on a 192.168.211.0/24 bridge, confirms the container lands at the pinned IP. Skipped if the claude-bottle-sidecars:latest image isn't built (operator hasn't run a docker bottle yet). 546 unit tests passing. Real-docker bundle bringup green locally. Launch wiring + provisioning + PRD 0022 acceptance probes land in chunk 2d. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
09eb25904f | Merge pull request 'feat(smolmachines): smolvm subprocess wrapper (PRD 0023 chunk 2b)' (#65) from prd-0023-chunk-2b-smolvm-wrapper into main | ||
|
|
9c333bc130 |
feat(smolmachines): smolvm subprocess wrapper (PRD 0023 chunk 2b)
claude_bottle/backend/smolmachines/smolvm.py — one thin Python
function per smolvm CLI subcommand the launch flow needs:
- pack_create(image, output) → smolvm pack create
- machine_create(name, from_path,
smolfile) → smolvm machine create
- machine_start(name) → smolvm machine start
- machine_stop(name) → smolvm machine stop
- machine_delete(name) → smolvm machine delete -f
- machine_exec(name, argv, env,
workdir, timeout) → smolvm machine exec
- machine_cp(src, dst) → smolvm machine cp
- is_available() → shutil.which check
The wrapper hides the CLI's inconsistent name-flag style
(positional NAME on create/delete, --name on start/stop/exec/
status) behind a uniform `name=` kwarg.
Two return shapes:
- SmolvmRunResult (returncode + stdout + stderr) from
machine_exec, because callers care about the in-VM
command's exit code.
- Raises SmolvmError on non-zero for all other commands;
failure to create/start/stop a VM is fatal to the launch
flow, not branched on.
Tests:
- 15 unit cases mocking subprocess.run, covering argv shape
per subcommand (the --name vs positional inconsistency
locked down), SmolvmError on non-zero for non-exec paths,
SmolvmRunResult passthrough on exec, empty-path cp no-op.
- 2 integration cases against the real smolvm binary
(gated on Darwin + smolvm on PATH + not GITEA_ACTIONS):
smolvm --help responds, machine ls --json parses as a
list (the contract chunk 4's list_active will consume).
531 unit tests passing. Real-smolvm smoke green locally.
Bundle bringup + launch wiring + the localhost-reach /
egress-port-bypass probes land in chunks 2c + 2d.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
bd4b9de9e6 | Merge pull request 'feat(smolmachines): rewrite Smolfile to smolvm 0.8.0 schema + drop gvproxy (PRD 0023 chunk 2a)' (#64) from prd-0023-chunk-2a-smolfile-rewrite into main | ||
|
|
c73d717f71 |
feat(smolmachines): rewrite Smolfile to smolvm 0.8.0 schema + drop gvproxy (PRD 0023 chunk 2a)
First sub-PR of chunk 2: rewrite the renderer chunk 1 shipped to match smolvm 0.8.0's actual Smolfile shape, delete the dead gvproxy renderer + its tests, simplify the prepare flow now that there's no gvproxy socket + no loopback-port allocation. Smolfile renderer: - Old shape (under the abandoned gvproxy design): name = ..., command = [...], [[net]] attachment = "unixgram", socket = "...". - New shape (smolvm 0.8.0): env = [...] (sorted K=V pairs), [network] allow_cidrs = ["<bundle-ip>/32"]. Nothing else. image / entrypoint / cmd come from the .smolmachine artifact built in chunk 2b; cpus / memory left at smolvm defaults. - Tests assert no leakage of TSI's --outbound-localhost-only or the old gvproxy/unixgram keys. util.py: - smolmachines_gvproxy_subnet → smolmachines_bundle_subnet, returning (subnet, gateway, bundle_ip). bundle_ip is always at .2 (gateway .1); subnet is /24, third octet derived from the slug hash, skipping the docker-default 17 to avoid the common 192.168.17.x collision. - allocate_loopback_port: deleted. The bundle gets a pinned docker IP now; the agent dials that IP directly through TSI. - smolmachines_preflight: dropped the gvproxy check; only smolvm is required. prepare.py: - Drops the gvproxy.yaml render + the loopback port allocation + the gvproxy_socket field on the plan. - Derives subnet / gateway / bundle_ip from the slug and populates the new SmolmachinesBottlePlan fields. - Agent env now uses IP-literal URLs (http://<bundle-ip>:8888 etc) since the guest will have no DNS resolver inside TSI's allowlist. bottle_plan.py: - Old fields: gvproxy_config_path, gvproxy_socket, gvproxy_subnet, gvproxy_gateway, host_port_map. - New fields: bundle_subnet, bundle_gateway, bundle_ip, smolfile_path. (smolmachine artifact path lands in chunk 2b.) Net: -410 lines. Full unit suite: 516 passing. The VM lifecycle + bundle bringup + launch wiring + smoke tests land in chunk 2b. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
b57256789f | Merge pull request 'docs(prd-0023): pivot to smolvm + TSI single-IP allowlist' (#63) from prd-0023-revise-option-b into main | ||
|
|
5929caa219 |
docs(prd-0023): pivot to smolvm + TSI single-IP allowlist
Chunk-1's empirical spike against smolvm 0.8.0 contradicted the research note that motivated the gvproxy network design: smolvm exposes no virtio-net-over-unixgram attachment. The first draft's "why gvproxy, not TSI" argument turns out to apply only to `--outbound-localhost-only`, not to TSI generally. New design: - Bundle (PRD 0024) runs on a dedicated per-bottle docker bridge with a pinned IP. Smolfile sets `[network] allow_cidrs = ["<bundle-ip>/32"]` and nothing else. Agent can reach the bundle and nothing else — host loopback, LAN, public internet directly are all refused at the VMM (TSI) layer. - Bind-address mitigation: egress binds 127.0.0.1:9099 inside the bundle (pipelock-internal); pipelock / git-gate / supervise bind 0.0.0.0 so the agent (across the TSI allowlist) can reach them. This is the port-granularity TSI's IP-only allowlist doesn't provide. - Smolfile renderer rewritten in chunk 2 to smolvm 0.8.0's actual schema (image / entrypoint / cmd / env / [network] allow_cidrs). The chunk-1 renderer (name= / [[net]]= under the gvproxy design) emits the wrong shape and will be replaced. - Drop gvproxy + VZFileHandleNetworkDeviceAttachment + the PyObjC fallback. Backend layout loses gvproxy_config.py, gvproxy.py, vfkit_attach.py. - Acceptance plan adds an egress-port-bypass probe in addition to the localhost-reach probe. - Chunks reshape: chunk 1 stays (renderer rewrite is part of chunk 2's cost); chunk 2 covers VM lifecycle + bundle + new Smolfile renderer; chunk 3 is the bundle bind-address change; chunks 4-5 unchanged in spirit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
b1ad6295a4 | Merge pull request 'feat(smolmachines): backend skeleton + Smolfile/gvproxy renderers (PRD 0023 chunk 1)' (#62) from prd-0023-chunk-1-skeleton into main | ||
|
|
2aca9e609a |
refactor(backend): extract shared print_multi for plan preflights
Addresses PR #62 review comments on claude_bottle/backend/smolmachines/bottle_plan.py: - Lift the multi-value label printer (was a nested helper inside DockerBottlePlan.print) into a new module claude_bottle/backend/print_util.py:print_multi. Both backends use it for env / skills / git / egress lines. - Strip the three smolmachines-preflight lines the review flagged: the gvproxy subnet line, the smolfile path line, and the gvproxy-config path line. Internal detail — operators see the agent / env / skills / bottle / git / egress that already matter on the docker side, and nothing else. - Add `git → upstream` to the smolmachines git output to match what's useful at preflight time (the docker version shows upstream_host:port; this is similar shape). Leaves the slug=spec.identity-or-mint pattern alone pending a reply on PR comment #432 — the docker backend uses the same pattern to preserve identity across `resume`, so dropping it would silently break the resume path once smolmachines launch lands. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
20f411b22e |
feat(smolmachines): backend skeleton + Smolfile/gvproxy renderers (PRD 0023 chunk 1)
Ships the smolmachines backend's prepare side: subpackage layout,
`_BACKENDS` registration under "smolmachines", preflight check
for `smolvm` + `gvproxy` on PATH, and the two config-file
renderers (Smolfile TOML + gvproxy YAML). Launch raises
NotImplementedError until chunk 2.
New module layout (mirrors backend/docker/):
claude_bottle/backend/smolmachines/
__init__.py re-exports SmolmachinesBottleBackend
backend.py SmolmachinesBottleBackend façade
bottle.py SmolmachinesBottle stub (NotImpl until ch2)
bottle_plan.py SmolmachinesBottlePlan + .print()
bottle_cleanup_plan.py SmolmachinesBottleCleanupPlan stub
prepare.py resolve_plan: writes both config files
smolfile.py TOML renderer (stdlib, no tomli_w dep)
gvproxy_config.py YAML renderer (same shape as pipelock_yaml)
util.py preflight + per-slug subnet + loopback port
The renderers are pure functions. `resolve_plan` runs the
preflight, allocates one host-side loopback port per active
sidecar (pipelock always; git-gate / supervise conditional),
derives a per-slug gvproxy subnet (hash-mod-254, skipping the
docker-default 17), and writes:
- <stage>/gvproxy.yaml: subnet + DNS rule resolving only
`proxy.internal` + port_forwards (one per active sidecar).
- <stage>/smolfile.toml: guest command/env + virtio-net device
backed by gvproxy's unixgram socket. No TSI flags — see
PRD 0023 "Why gvproxy, not TSI".
The agent's HTTPS_PROXY etc. point at `proxy.internal:<gateway-
port>` so the guest dials through gvproxy. gvproxy resolves only
`proxy.internal` → the gateway IP, and forwards exactly the
listed ports to the host-side sidecar bundle (PRD 0024); every
other destination — host LAN, host loopback, public internet
directly — is unreachable by construction.
29 new unit tests covering renderer correctness, subnet
derivation stability + collision-avoidance, loopback port
allocation, and preflight error paths. Full unit suite: 532
passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
bce1ea21db | Merge pull request 'docs(prd-0023): smolmachines bottle backend' (#53) from prd-0023-smolmachines-backend into main | ||
|
|
a7ed571cf9 | Merge pull request 'fix(sidecars): per-daemon pipelock restart keeps supervise socket alive' (#61) from fix-pipelock-restart-keeps-bundle-up into main | ||
|
|
5b9ceaaaee |
fix(sidecars): per-daemon pipelock restart keeps supervise socket alive
`apply_allowlist_change` used `docker restart <bundle>` to make
pipelock reload, which bounced ALL four daemons — including
supervise, whose MCP socket the agent's claude-code client had
open. That dropped the connection. A second apply works because
supervise has come back up by then.
Fix: per-daemon restart via SIGUSR1.
- New `_Supervisor.restart_daemon(name)` terminates one named
child and spawns a replacement in place. Other daemons keep
running.
- main() wires SIGUSR1 → `restart_daemon("pipelock")`. Pipelock
has no in-process reload, so this is its analog of egress's
SIGHUP-reload-addon path. Pipelock is the only daemon that
currently needs hot-config reload via restart; if others
acquire the need, add a new signal.
- `apply_allowlist_change` now `docker kill --signal USR1
<bundle>` instead of `docker restart`. Supervise / egress /
git-gate keep running across the apply.
Tests:
- New `_Supervisor.restart_daemon` cases: replaces in place
(different pid post-restart, sibling daemon unchanged),
unknown name is a no-op, restart-during-shutdown is a no-op.
- `test_pipelock_apply` rewritten to bring up the bundle image
with `CLAUDE_BOTTLE_SIDECAR_DAEMONS=pipelock` so the
supervisor is PID 1 and handles SIGUSR1. The previous
standalone-pipelock setup wouldn't survive SIGUSR1 (pipelock
default disposition is terminate). Test builds the bundle
image in setUpClass (cached layers make repeat runs fast).
531 tests passing locally (unit + integration).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
c48f791d7d | Merge pull request 'fix(sidecars): apply_routes_change targets the bundle + SIGHUP forwarding' (#60) from fix-egress-apply-bundle-target into main | ||
|
|
0848344438 |
fix(sidecars): apply_routes_change targets the bundle + SIGHUP forwarding
Two bugs surfaced when applying an egress route change:
1. egress_apply.py still targeted claude-bottle-egress-<slug> —
the legacy per-sidecar container that no longer exists (it's
a docker-network alias on the bundle now). Switched it to
sidecar_bundle_container_name(slug), matching the chunk-5
fix already made to pipelock_apply.py.
2. `docker kill --signal HUP <bundle>` lands SIGHUP on the
supervisor (PID 1 in the bundle), which previously had no
SIGHUP handler — the signal was ignored. Added
`_Supervisor.forward_signal(sig, daemon_name)` and a SIGHUP
handler in main() that forwards to the egress daemon so
mitmdump's addon reload still works under the bundle.
Tests:
- New _Supervisor.forward_signal cases: forwards to the named
child (Python subprocess as the SIGHUP target — bash trap +
stdout=PIPE deferral interferes with the production-style
test); unknown-daemon name is a no-op.
Stale-reference cleanup (separate issue surfaced while looking
at this):
- claude_bottle/{egress,git_gate,egress_addon,
egress_addon_core,supervise_server}.py: Dockerfile.egress /
Dockerfile.git-gate / Dockerfile.supervise references updated
to Dockerfile.sidecars (the old per-sidecar Dockerfiles were
deleted in PRD 0024 chunk 5).
- tests/README.md: dropped the entry for
test_pipelock_sidecar_smoke (deleted in chunk 3) and added
the new bundle integration tests.
- git_gate.py: stale `DockerGitGate.start via docker cp`
reference (the method was deleted in chunk 3) rewritten to
the bind-mount path the renderer uses now.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
853d28bc89 | Merge pull request 'refactor(sidecars): bundle is the only shape (PRD 0024 chunk 5)' (#59) from prd-0024-chunk-5-flag-removal into main | ||
|
|
62f6f8db34 |
refactor(sidecars): bundle is the only shape (PRD 0024 chunk 5)
The CLAUDE_BOTTLE_SIDECAR_BUNDLE feature flag is gone. Every
bottle ships with the agent + bundle pair — no opt-in, no legacy
four-sidecar fallback.
Changes:
- Renderer (compose.py): bottle_plan_to_compose unconditionally
emits {agent, sidecars}. Deleted _pipelock_service,
_git_gate_service, _egress_service, _supervise_service helpers.
_agent_service.depends_on collapses to ["sidecars"].
- sidecar_bundle.py: deleted sidecar_bundle_enabled (the flag
parser). SIDECAR_BUNDLE_IMAGE + container-name helper stay.
- pipelock_apply.py: docker cp + docker restart now target
sidecar_bundle_container_name(slug). Bundle restart bounces
all four daemons together (per-daemon reload is the eventual
feature, not v1).
- Per-sidecar modules trimmed:
- egress.py: dropped EGRESS_IMAGE, EGRESS_DOCKERFILE,
build_egress_image, egress_url. Kept EGRESS_PORT, CA paths,
egress_container_name (still used by the renderer's network
aliases).
- git_gate.py: dropped GIT_GATE_IMAGE, GIT_GATE_DOCKERFILE,
build_git_gate_image. Kept git_gate_host + GIT_GATE_PORT.
- supervise.py: dropped SUPERVISE_IMAGE, SUPERVISE_DOCKERFILE,
build_supervise_image, supervise_url.
- Deleted Dockerfile.{egress,git-gate,supervise}. The bundle's
Dockerfile.sidecars is the only sidecar image now.
- test_compose.py: deleted TestPipelockAlwaysPresent,
TestConditionalGitGate, TestConditionalEgress,
TestConditionalSupervise, TestFullMatrix (legacy-shape only),
TestSidecarBundleFlag (flag is gone). TestSidecarBundleShape
drops its patch.dict wrapper. TestAgentAlwaysPresent's
depends_on cases collapse to one.
- test_pipelock_apply.py: bringup container name uses
sidecar_bundle_container_name(slug) to match the production
target.
- README.md Architecture section rewritten to describe the
agent + bundle pair.
Net: -626 lines.
Test status: 498 unit + 27 integration + 1 skipped (chunk-4
pending — superseded by this chunk's rewrite). Locally verified
end-to-end bottle launch produces exactly 2 containers
(claude-bottle-<slug> + claude-bottle-sidecars-<slug>).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
9348d4b343 | Merge pull request 'test(sidecars): integration sweep for the bundle path (PRD 0024 chunk 4)' (#58) from prd-0024-chunk-4-integration-tests into main | ||
|
|
2287b0dd08 |
test(sidecars): integration sweep for the bundle path (PRD 0024 chunk 4)
Three deliverables:
1. Rewrite test_pipelock_apply bringup with a direct `docker run`.
Replaces the .start-based bringup deleted in chunk 3. Stages
the yaml + CAs to the real pipelock_state_dir so the bind-
mount target matches what apply_allowlist_change writes to —
the legacy .start path did this implicitly because it lived
inside the production flow; the new bringup needs to be
explicit about the path. All 4 cases pass.
2. New tests/integration/test_sidecar_bundle_compose.py: end-
to-end smoke with CLAUDE_BOTTLE_SIDECAR_BUNDLE=1. Brings up
a real bottle via the compose path and verifies the agent
can reach pipelock + supervise through the bundle's legacy
aliases (no agent-side config changes between flag positions).
Skipped under act_runner — multi-stage build + bind mounts.
3. Two bundle-path bugs surfaced and fixed while running PRD
0022 with the flag on:
- egress_entrypoint.sh: add `--set confdir=/home/mitmproxy/
.mitmproxy` so mitmdump finds the bind-mounted CA. The
legacy Dockerfile.egress runs as user mitmproxy (~mitmproxy
resolves correctly); the bundle runs as root and otherwise
would look in /root/.mitmproxy/ and mint a NEW CA the agent
doesn't trust. Symptom: PRD 0022 attack-3 curl failed with
"unable to get local issuer certificate".
- sidecar_init.py: add `--listen 0.0.0.0:8888` to pipelock's
argv. Without it pipelock defaults to 127.0.0.1, so the
in-bundle egress's upstream connect to the
`claude-bottle-pipelock-<slug>` alias arrives over the
docker network and gets refused. The legacy renderer
passed this flag verbatim; the bundle dropped it. Symptom:
egress returned HTTP 502 with "Connect call failed
('172.x.x.x', 8888)".
PRD 0022's 5-attack sandbox-escape suite now passes with the
bundle flag on AND off.
Test status:
- Unit: 533 passing.
- Integration: 9 passing locally with flag off, 5 passing with
flag on. Bundle compose smoke + PRD 0022 sandbox-escape both
green under CLAUDE_BOTTLE_SIDECAR_BUNDLE=1.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
fff0391d1b | Merge pull request 'refactor(sidecars): drop vestigial start/stop methods (PRD 0024 chunk 3)' (#57) from prd-0024-chunk-3-backend-python-trim into main |