The control SSH logs in as root, so the agent (dropped to node via
runuser) inherited cwd=/root. That was harmless while the rootless
rootfs left /root node-owned, but the dropbear fix (chown /root →
root:root 0700) made /root unreadable to node — so the agent's cwd was
inaccessible, breaking Node's process.cwd(), Claude Code's shell-snapshot
machinery, and `/doctor` (which reported /root/.claude EACCES and failed
every Bash command).
Run the agent from its workdir (default /home/node) via `env --chdir`.
Not a `sh -c 'cd … && exec "$@"'` wrapper: ssh space-joins everything
after the host into one string for the guest shell, so the quoted script
+ $@ get re-split and mangled (it exec'd the $0 placeholder → exit 127).
`env --chdir=DIR …` is all simple words, so it survives the join.
Verified end-to-end: a real headless claude bottle now runs its Bash tool
and exits 0 (was EACCES/127 before).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
First real end-to-end launch (there was no firecracker integration test)
surfaced three bugs in the control/provision path, all now verified fixed
by tests/integration/test_firecracker_launch:
1. Guest SSH rejected the correct key. The rootless rootfs build
(`docker export | tar` as a non-root user) can't preserve uid 0, so
every path — including /root — is owned by the build uid (node in
guest). dropbear refuses root's authorized_keys when /root isn't
root-owned, so auth fell back to password → denied. bb-init now
`chown -R 0:0 /root`.
2. The SSH client (newer OpenSSH) didn't reliably present the -i key
against the operator's ~/.ssh config; add `IdentitiesOnly=yes` to
ssh_base_argv so only the per-bottle key is offered.
3. cp_in double-wrapped the remote command as `sh -c <remote>`, but ssh
space-joins everything after the host into one string for the guest
shell, collapsing `sh -c mkdir -p X && …` to `mkdir` with no operand
("missing operand"). Pass the command as a single arg and let the
guest login shell run it (stdin still carries the tar).
Also stop discarding dropbear's stderr (`-E` now reaches the host-side
console.log) so future guest-auth issues are debuggable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
Delete the smolmachines backend (the whole bot_bottle/backend/smolmachines
package and its tests). It had fatal Linux issues (TSI networking under
sustained use, exec-channel contention, no SIGWINCH) and is superseded by
the Firecracker backend (issue #342).
Backend selection now:
- default is macos-container on macOS, firecracker on KVM-capable Linux
hosts, and docker as the last resort (was smolmachines).
- firecracker is selected on a KVM host even when the `firecracker`
binary isn't installed, so start routes through its preflight and
prints an install pointer (same UX as require_container), instead of
silently falling back. Split is_host_capable() (Linux + KVM) out of
is_available() (adds the binary check) to drive this.
Retarget the cross-backend tests (parity, print-parity, prepare,
workspace, freezer, selection) from smolmachines to firecracker rather
than dropping the coverage. Remove docker.util.image_id/save, which only
smolmachines used. Update README/AGENTS/example bottles and stale
comments; historical docs/prds are left as a point-in-time record.
BREAKING: BOT_BOTTLE_BACKEND=smolmachines now errors as unknown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
Adds a Firecracker-based backend for Linux, providing mature KVM-based
microVM isolation to replace smolmachines/libkrun (issue #342, closes
the dead-end tracked in #332).
Architecture:
- Guest control over SSH (dropbear injected into the rootfs) on a
point-to-point TAP link. `ssh -t` forwards SIGWINCH natively, so no
resize bridge is needed.
- Networking: a one-time, root-provisioned pool of user-owned TAP
devices (no shared bridge → no docker0/virbr0/cni0 collisions) plus a
dedicated `table inet bot_bottle_fc` nftables table (independent of
Docker/ufw/firewalld rules). `./cli.py firecracker setup` prints the
host-appropriate config (NixOS module or sudo script).
- Rootfs: `docker export` → ext4 via `mke2fs -d` (rootless, no mount),
cached by image digest; per-bottle SSH pubkey + IP passed via the
kernel cmdline.
- Sidecar: reuses the Docker bundle, published on the slot's host TAP IP.
- Fail-closed isolation: TAP pool verified at preflight; the egress
boundary is proven empirically post-boot (before the agent runs) by a
canary probe — the VM must fail to reach the host directly, or launch
is refused.
Linux hosts with Firecracker + KVM now default to this backend;
macOS stays on macos-container.
Not yet validated end-to-end on live hardware (requires the one-time
network pool). Unit tests + pyright pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G8p32HJgPoS1hLPWubbftM