Compare commits

..

63 Commits

Author SHA1 Message Date
didericis-codex 47cb50db4b test(release): cover publication failure paths
prd-number-check / require-numbered-prds (pull_request) Successful in 8s
lint / lint (push) Successful in 58s
test / image-input-builds (pull_request) Successful in 42s
test / integration-docker (pull_request) Successful in 1m5s
test / unit (pull_request) Successful in 2m50s
test / coverage (pull_request) Successful in 42s
tracker-policy-pr / check-pr (pull_request) Successful in 5s
2026-07-27 17:29:06 +00:00
didericis-codex a750a242d1 ci: rerun release flow checks
prd-number-check / require-numbered-prds (pull_request) Successful in 9s
tracker-policy-pr / check-pr (pull_request) Successful in 15s
test / image-input-builds (pull_request) Successful in 41s
test / unit (pull_request) Successful in 59s
test / integration-docker (pull_request) Successful in 1m2s
test / coverage (pull_request) Failing after 20s
2026-07-27 17:25:00 +00:00
didericis-codex 4544899c79 docs(prd): finalize complete release flow
prd-number-check / require-numbered-prds (pull_request) Successful in 17s
lint / lint (push) Successful in 1m4s
tracker-policy-pr / check-pr (pull_request) Successful in 17s
test / image-input-builds (pull_request) Successful in 1m9s
test / integration-docker (pull_request) Failing after 2m58s
test / unit (pull_request) Failing after 11m2s
test / coverage (pull_request) Has been skipped
2026-07-27 17:18:36 +00:00
didericis-codex 89d2ba7e82 feat(release): automate artifact and tag flows
tracker-policy-pr / check-pr (pull_request) Successful in 12s
test / image-input-builds (pull_request) Successful in 59s
test / unit (pull_request) Successful in 1m1s
test / integration-docker (pull_request) Successful in 59s
test / coverage (pull_request) Failing after 19s
prd-number-check / require-numbered-prds (pull_request) Failing after 10m50s
lint / lint (push) Failing after 10m52s
2026-07-27 17:14:14 +00:00
didericis-codex 43a92945a3 feat(firecracker): pull pinned agent images 2026-07-27 17:14:14 +00:00
didericis-codex 7abcb2c7df feat(release): publish qualified channel pointers
prd-number-check / require-numbered-prds (pull_request) Successful in 12s
tracker-policy-pr / check-pr (pull_request) Successful in 12s
lint / lint (push) Failing after 1m2s
test / integration-docker (pull_request) Waiting to run
test / image-input-builds (pull_request) Has started running
test / coverage (pull_request) Blocked by required conditions
test / unit (pull_request) Has started running
2026-07-27 17:11:56 +00:00
didericis-codex efa1c8ee4b feat(installer): select immutable release bundles
prd-number-check / require-numbered-prds (pull_request) Successful in 11s
lint / lint (push) Successful in 1m2s
test / unit (pull_request) Successful in 58s
test / integration-docker (pull_request) Successful in 1m3s
test / image-input-builds (pull_request) Successful in 1m0s
tracker-policy-pr / check-pr (pull_request) Successful in 12s
test / coverage (pull_request) Failing after 20s
2026-07-27 17:10:19 +00:00
didericis-codex 249eaff53d feat(release): publish immutable commit bundles
prd-number-check / require-numbered-prds (pull_request) Successful in 10s
tracker-policy-pr / check-pr (pull_request) Successful in 20s
lint / lint (push) Successful in 1m1s
test / unit (pull_request) Successful in 56s
test / integration-docker (pull_request) Successful in 1m0s
test / coverage (pull_request) Failing after 23s
test / image-input-builds (pull_request) Successful in 2m31s
2026-07-27 17:09:13 +00:00
didericis-codex 9f65ab016d feat(release): pin first-party agent images
lint / lint (push) Successful in 1m15s
test / image-input-builds (pull_request) Successful in 1m12s
test / integration-docker (pull_request) Failing after 2m47s
tracker-policy-pr / check-pr (pull_request) Failing after 11m49s
test / unit (pull_request) Failing after 12m1s
prd-number-check / require-numbered-prds (pull_request) Failing after 12m6s
test / coverage (pull_request) Has been skipped
2026-07-27 17:02:54 +00:00
didericis-codex 46b7e06b37 feat(release): define commit-addressed bundle index
prd-number-check / require-numbered-prds (pull_request) Successful in 8s
lint / lint (push) Successful in 59s
tracker-policy-pr / check-pr (pull_request) Successful in 12s
test / integration-docker (pull_request) Waiting to run
test / coverage (pull_request) Blocked by required conditions
test / image-input-builds (pull_request) Has started running
test / unit (pull_request) Has started running
2026-07-27 17:00:56 +00:00
didericis-codex 93190d5e0c docs(prd): define complete release flow
prd-number-check / require-numbered-prds (pull_request) Successful in 10s
tracker-policy-pr / check-pr (pull_request) Successful in 18s
test / coverage (pull_request) Successful in 50s
test / image-input-builds (pull_request) Successful in 46s
test / integration-docker (pull_request) Successful in 1m6s
test / unit (pull_request) Successful in 58s
2026-07-27 16:34:54 +00:00
didericis-codex a2113b7f43 fix(release): publish only verified wheel
prd-number-check / require-numbered-prds (pull_request) Successful in 15s
test / image-input-builds (pull_request) Successful in 42s
test / unit (pull_request) Successful in 56s
test / integration-docker (pull_request) Successful in 1m1s
test / coverage (pull_request) Successful in 15s
tracker-policy-pr / check-pr (pull_request) Failing after 12m49s
2026-07-27 16:21:57 +00:00
didericis-codex d8c5532168 test(infra): cover malformed release manifests
test / image-input-builds (pull_request) Successful in 48s
lint / lint (push) Successful in 1m3s
test / unit (pull_request) Successful in 58s
test / integration-docker (pull_request) Successful in 1m2s
test / coverage (pull_request) Successful in 20s
prd-number-check / require-numbered-prds (pull_request) Successful in 17s
tracker-policy-pr / check-pr (pull_request) Successful in 10s
2026-07-27 15:30:04 +00:00
didericis-codex a27b1a5fe9 docs(prd): activate packaged infrastructure artifacts
prd-number-check / require-numbered-prds (pull_request) Successful in 11s
tracker-policy-pr / check-pr (pull_request) Successful in 17s
lint / lint (push) Successful in 3m41s
test / unit (pull_request) Successful in 1m8s
test / image-input-builds (pull_request) Successful in 1m4s
test / integration-docker (pull_request) Successful in 1m12s
test / coverage (pull_request) Failing after 13s
2026-07-27 15:14:35 +00:00
didericis-codex 1a9056c648 feat(infra): pull artifacts pinned by package release 2026-07-27 15:13:52 +00:00
didericis-codex 63595f123a docs(prd): define packaged infrastructure artifacts
prd-number-check / require-numbered-prds (pull_request) Failing after 8s
tracker-policy-pr / check-pr (pull_request) Successful in 8s
2026-07-27 15:07:52 +00:00
didericis c89847b626 fix(macos-container): tag the pinned base from its ref, not its ID
lint / lint (push) Successful in 59s
Update Quality Badges / update-badges (push) Successful in 1m6s
test / coverage (push) Successful in 22s
test / image-input-builds (push) Successful in 58s
test / integration-docker (push) Successful in 58s
test / unit (push) Successful in 2m51s
`container image tag` only accepts image-name[:tag] as its source and
rejects a bare 64-hex image ID ("cannot specify 64 byte hex string as
reference"), so pinning the agent image for the nested-containers layer
died before the build could start. Tag from the ref instead; the
existing post-tag inspection is what keeps the handoff fail-closed if
the ref moves between the two commands.

The unit test mocked subprocess and asserted the ID-as-source call
shape, so it never saw the CLI's rejection. It now pins the ref as the
source, and covers the bare-hex ID that `container image inspect`
actually reports plus the mid-flight tag move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:56:59 -04:00
didericis cd9f023f3d docs: name bot-bottle, not ./cli.py, in every instruction
prd-number-check / require-numbered-prds (pull_request) Successful in 11s
tracker-policy-pr / check-pr (pull_request) Successful in 14s
test / unit (pull_request) Successful in 59s
test / integration-docker (pull_request) Successful in 1m6s
test / coverage (push) Successful in 21s
test / image-input-builds (push) Successful in 44s
test / image-input-builds (pull_request) Successful in 1m15s
test / unit (push) Successful in 57s
test / coverage (pull_request) Successful in 20s
lint / lint (push) Successful in 1m3s
Update Quality Badges / update-badges (push) Successful in 1m12s
test / integration-docker (push) Successful in 1m0s
`doctor` told users to run `./cli.py backend setup`. cli.py is a four-line
wrapper at the repo root that calls bot_bottle.cli:main, and it does not ship —
[tool.setuptools.packages.find] includes only bot_bottle*, so anyone who
installed rather than cloned has no such file. Confirmed against a real
install: the user's tree contains exactly one executable, `bot-bottle`, and no
cli.py anywhere, while doctor recommended `./cli.py` three times. Invisible in
development, where ./cli.py works fine from a checkout, which is why only a
clean-install test surfaced it.

The two entry points are the same code, so the fix is to name the one that
always exists. 141 replacements across 45 files: the runtime messages that
caused this, plus README, docs, PRDs, research notes and test prose, so nothing
teaches the invocation a user cannot run.

scripts/demo.sh is deliberately untouched — it *executes* ./cli.py from a
checkout, where that is the correct and available path.

Verified end to end: a sandbox install now reports
"Run: bot-bottle backend setup --backend=firecracker", and bot-bottle is on
that user's PATH. Unit suite unchanged against the pre-existing baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 10:20:17 -04:00
didericis 5c499d290b docs: record what the passing runs established
test / image-input-builds (pull_request) Successful in 47s
test / unit (pull_request) Successful in 59s
test / integration-docker (pull_request) Failing after 2m50s
test / coverage (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Failing after 12m32s
prd-number-check / require-numbered-prds (pull_request) Failing after 12m45s
Both variants now pass, and the with-prerequisites run settled several things
that were guesses before:

* The Apple `container` service is per-user, confirmed directly rather than
  inferred: the throwaway account's appRoot is its own
  (/Users/bbtest/Library/Application Support/com.apple.container/), and
  starting it left the admin's service running and its agents intact.
* Setting up a new account is two steps, not one. The guest kernel lives in
  that same per-user app root, so a fresh account has none and
  `container system start` prompts to download it — and only prompts, since
  the flags default to asking. Headless callers need
  --enable-kernel-install.
* Neither step is performed by `bot-bottle backend setup
  --backend=macos-container`, which checks and then defers to `container
  system start`. So the real path for a new account is
  `container system start --enable-kernel-install`.
* Any of it requires entering the user's launchd domain with `launchctl
  asuser`, because the apiserver is a per-user agent reached over XPC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 10:12:21 -04:00
didericis 0500ae5f4c fix(harness): make the prereq step non-interactive
prd-number-check / require-numbered-prds (pull_request) Successful in 16s
tracker-policy-pr / check-pr (pull_request) Successful in 11s
test / image-input-builds (pull_request) Successful in 42s
test / unit (pull_request) Successful in 53s
test / integration-docker (pull_request) Successful in 1m1s
test / coverage (pull_request) Failing after 15s
The launchd fix worked. `container system start` now gets past "Testing access
to container-apiserver" and "Verifying machine API server is running" — the two
steps it could not reach before — so the XPC domain problem is solved. It then
failed somewhere new:

    No default kernel configured.
    Error: failed to read user input
    Install the recommended default kernel from [...kata-static...]? [Y/n]:

`container system start` PROMPTS for the default Linux kernel when given
neither --enable-kernel-install nor --disable-kernel-install ("default: prompt
user"). A throwaway account always hits that prompt, because the kernel lives
in the per-user app root and a fresh home has none — and with no TTY the
prompt dies immediately.

Pass --enable-kernel-install. That is also the honest choice for what this
variant claims: the backend cannot run a bottle without a guest kernel, so
"prerequisites satisfied" has to include it. The cost is a kernel download per
run, since the previous run's copy went with the deleted home;
BB_TEST_KERNEL_INSTALL=0 switches to --disable-kernel-install when you only
care that the service comes up.

This also means the per-user prerequisite is not one step but two — start the
service, install a kernel — which is worth knowing for anyone setting up a
second account by hand.

The rig's stub now models the prompt: an unflagged start fails the way the real
CLI does. Verified that guard bites by removing the flag and watching the
scenario fail, so a future edit cannot silently reintroduce an interactive
prereq step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 10:00:49 -04:00
didericis 5614a56ccd fix(harness): run as the user inside their launchd domain
prd-number-check / require-numbered-prds (pull_request) Successful in 11s
test / image-input-builds (pull_request) Successful in 44s
test / unit (pull_request) Successful in 57s
test / integration-docker (pull_request) Failing after 3m4s
test / coverage (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Failing after 13m12s
`test` passes. `test-ready` got as far as the service start and failed:

    Launching container-apiserver...
    Error: failed to get a response from apiserver: invalidState: "unauthorized request"

which is the limitation this script's own header predicted — "may need a full
launchd user session (`launchctl asuser`)".

`container system start` registers com.apple.container.apiserver as a per-USER
launchd agent and then talks to it over XPC. `launchctl list` confirms the
shape: the apiserver and every container-network/runtime job are agents in the
invoking user's domain. Under plain `sudo -u` the caller stays in root's
bootstrap namespace, so the lookup crosses domains and the apiserver rejects it
as unauthorized — the agent launched fine, the client just could not reach it.

run_as_user now enters the target user's domain with `launchctl asuser <uid>`,
which is also what a real user gets from Terminal, so it is the more faithful
way to run everything here rather than a special case for the service start.

A never-GUI-logged-in account may have no bootstrappable domain at all, so
availability is probed once and cached, and everything falls back to plain
`sudo -u` when it is missing. Without that fallback a missing domain would
break `test` — which passes today and does not need a session — for a reason
unrelated to what it tests. The rig covers that path explicitly.

If the start still fails, the error now names the cause and the way out
(log into the account once to get it a domain) instead of just relaying the
CLI's message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 09:56:51 -04:00
didericis 71c0f8ed88 feat(harness): add test-ready, the with-prerequisites variant
prd-number-check / require-numbered-prds (pull_request) Successful in 11s
tracker-policy-pr / check-pr (pull_request) Successful in 21s
test / image-input-builds (pull_request) Successful in 48s
test / integration-docker (pull_request) Successful in 1m4s
test / unit (pull_request) Successful in 2m52s
test / coverage (pull_request) Failing after 20s
"Does the installer work" and "can a new user actually run a bottle" are
different questions, and collapsing them is what made the previous run
ambiguous. Split them into two cycles over the same throwaway account:

  test        a macOS system WITHOUT the prerequisites set up for this user,
              which is the default state of every new account since the Apple
              `container` service is per-user. Asserts the install is sound;
              reports backend readiness without failing on it, because
              install.sh provides no backend and cannot regress one.

  test-ready  the same system WITH them. Runs `container system start` for the
              throwaway user, then demands doctor go fully green, backend
              included — the end-to-end claim.

The new `prereqs` step runs `container system start` rather than
`bot-bottle backend setup --backend=macos-container`, because that subcommand
does not start anything: it checks, then tells you to run `container system
start` yourself (backend/macos_container/setup.py, "no privileged host setup
required"). The harness runs what the product actually asks for.

test-ready sets BB_TEST_REQUIRE_BACKEND, so it demands exactly what `test`
merely reports. Both share one cycle function; the step count and the PASS
claim are the only differences beyond the extra step, so neither variant can
drift from the other's teardown or dirty-account guarantees.

Rig covers the distinguishing case directly: identical host state where `test`
passes on install soundness and `test-ready` starts the service and reaches a
green backend, plus the service-start failure and an install failure under
test-ready. 22 scenarios, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 09:51:35 -04:00
didericis 556217ae7b fix(harness): judge the install separately from backend readiness
prd-number-check / require-numbered-prds (pull_request) Successful in 9s
test / image-input-builds (pull_request) Successful in 46s
test / integration-docker (pull_request) Successful in 1m3s
test / unit (pull_request) Successful in 2m58s
test / coverage (pull_request) Failing after 18s
tracker-policy-pr / check-pr (pull_request) Failing after 14m1s
The first run against the branch's own code confirmed the doctor fix — no
traceback, the firecracker probes now report "TAP pool: 0/8" instead of dying
on PermissionError — and then failed for a reason the installer cannot fix.

The Apple `container` service is per-USER. `container system status` reports an
appRoot under ~/Library/Application Support/com.apple.container/, and bbtest
sees "container system service: NOT running" while the creating admin's is
running. A brand-new account therefore has no backend until it runs
`container system start` once, doctor rightly fails, and `test` would be
permanently red for host state that install.sh neither creates nor can regress.

So stop treating doctor's exit code as one verdict. `test` now fails when the
install is broken — no entry point, doctor crashed, or doctor could not report
a usable python and config dir — and reports backend readiness as a note.
BB_TEST_REQUIRE_BACKEND=1 restores the strict behaviour.

A traceback is checked for explicitly rather than inferred from the exit code,
because those are the same value. The PermissionError bug exited non-zero
exactly like a missing prerequisite does; only the traceback distinguishes a
defect from an environment fact, and that distinction is the whole point of
this change.

The research doc's footprint table had the service as a host-wide launchd
service that survives user deletion. It does not. The state lives in the home
and goes with it, so the reset is more complete than claimed — but the
corollary is that a fresh account cannot run a bottle until the service is
started for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 09:45:57 -04:00
didericis 7461802b62 fix(harness): install the branch under test, and stop mangling snippets
prd-number-check / require-numbered-prds (pull_request) Successful in 9s
tracker-policy-pr / check-pr (pull_request) Successful in 12s
test / unit (pull_request) Successful in 1m3s
test / integration-docker (pull_request) Successful in 1m2s
test / image-input-builds (pull_request) Successful in 1m6s
test / coverage (pull_request) Failing after 20s
lint / lint (push) Successful in 3m30s
Two harness bugs, both of which made the last run lie about what it verified.

The run installed main. install.sh's default spec is the repo's default
branch, so `run` piped *this checkout's* installer into the throwaway user and
then had it install a package that does not contain the branch's changes —
which is why the doctor PermissionError fix appeared not to work: it was never
in the code under test. The clone line said so plainly (commit 420184b, not
this branch's HEAD) and I read past it. The spec now pins to the current
branch, over https because the throwaway user has neither our SSH key nor read
access to this mode-700 checkout. Since what it clones is whatever the remote
has, a dirty tree or an unpushed branch now prints a warning rather than
quietly testing something other than what is on disk.

The doctor probe also died with "sh: -c: line 1: syntax error: unexpected end
of file". `sudo -i` joins its argv into one string for the login shell's -c, so
an argument's quoting is not preserved and a newline terminates the command.
The multi-line snippet added in the previous commit could not survive that.
run_as_user now feeds snippets on stdin to `sh -s`, which is the same trick
install.sh already arrives by, and is immune to the joining. The install spec
moves into the stream as an export for the same reason — as an argument it had
the identical latent quoting bug, unnoticed only because the default spec has
no shell metacharacters in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 09:38:39 -04:00
didericis 5c12c6bf4e fix(doctor): survive a PATH entry the user can't execute
The install itself now works end to end on a fresh macOS account — venv built,
package installed from git, entry point linked — and `doctor` then died with an
unhandled traceback:

    PermissionError: [Errno 13] Permission denied: 'ip'

netpool's probe helpers caught only FileNotFoundError. That is not the only way
a probe binary can be unavailable: when a name on PATH exists but this user
cannot execute it, exec fails with EACCES, and CPython reports that in
preference to the ENOENT from the other PATH entries. So `except
FileNotFoundError` misses it and the crash propagates all the way out of
`doctor`. Reproduced directly: a mode-000 file named `ip` on PATH yields
exactly the error above.

_run_ok's docstring already stated the intent — treat an unavailable binary as
failure rather than crashing — so this widens the catch to OSError to match
what it says. Any OSError means the probe could not run, which for a
fail-closed check is indistinguishable from "not present". The same narrow
catch is fixed in overlapping_routes and in the two docker probes
(compose ls, docker ps), which are the same shape and equally reachable.

Deliberately not touched: the FileNotFoundError catches around file I/O in
bottle_state and orchestrator/service, where the narrow exception is correct.

The harness also required `bot-bottle` on PATH before running doctor, which
could never be true: install.sh prints the PATH line rather than editing a
shell profile, by design, so on a fresh account the entry point is installed
and working but not on PATH. It now looks where the installer actually puts
it (~/.local/bin, then the venv) and notes when it's running by absolute path.
That was the harness failing a run for a reason the installer intends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 09:38:39 -04:00
didericis 4096409495 fix(install): name the profile the user's shell actually reads
The PATH hint hardcoded ~/.zprofile, which is right for macOS's zsh and wrong
for the Linux users this installer also serves. Pick from $SHELL instead.
~/.profile is the default for non-zsh rather than ~/.bash_profile, because
creating the latter would shadow an existing ~/.profile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 09:38:20 -04:00
didericis 8d0782d1c7 fix(install): install into a private venv when pipx is absent
The harness's second run hit the wall the first one predicted: a fresh account
has no pipx, so install.sh fell to `pip install --user`, and every Python a Mac
offers — Homebrew and python.org alike — is externally managed, so PEP 668
blocked it. That fallback was never a fallback on macOS; it was a dead end that
printed instructions.

Replace it with a venv at ~/.bot-bottle/venv (BOT_BOTTLE_VENV to move it),
with the console script symlinked into ~/.local/bin. PEP 668 does not apply
inside a venv, and venv is stdlib, so unlike pipx there is nothing to bootstrap
first. pipx stays the preferred path when present, so anyone already managing
their Python apps that way is unaffected — and the post-install PATH check now
asks pipx for PIPX_BIN_DIR instead of assuming ~/.local/bin.

Keeping the venv under ~/.bot-bottle rather than ~/.local/share means the whole
footprint stays in one directory, which is what lets the throwaway-account
teardown remain a complete reset.

This removes the PEP 668 pre-flight and the sysconfig user-scheme lookup, both
of which existed only to serve the --user path. Their tests go with them:

* `detects_externally_managed_python` asserted the check that is now moot;
  replaced by one asserting pipx is still preferred when present.
* `checks_pip_usable_before_fallback` pinned a pip probe that no longer runs;
  replaced by one asserting the venv's own pip does the install, since using
  the base interpreter's would install outside the venv.
* `resolves_user_scripts_dir_not_hardcoded` and
  `macos_user_scheme_is_not_dot_local_bin` guarded the ~/Library/Python
  scripts-dir lookup. Nothing installs there now. The surviving "don't
  hardcode" concern is pipx's bin dir, which has its own test.

Five tests are added for the new path: the venv fallback exists, no --user path
survives, the venv is under the config dir, venv creation failure names
python3-venv (Debian ships it separately), and the entry point is exposed
outside the venv.

Verified end to end in a sandbox HOME with a fresh-account PATH and no pipx:
venv built, package installed, symlink created, `doctor` reached and green
(python 3.14.5, macos-container ready), exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 09:38:20 -04:00
didericis 6793a09e2b docs: correct two claims the live harness run disproved
The PATH story was wrong in an instructive way. I said a Homebrew Python is on
PATH only because of a shell-profile line; on this host /etc/paths.d/homebrew
puts /opt/homebrew/bin on every login shell's PATH, fresh accounts included.
The stub still wins, because path_helper appends /etc/paths.d/* *after*
/etc/paths and /usr/bin is in the latter. Ordering, not absence, is what makes
bare `python3` the 3.9.6 stub — which is also why the versioned `python3.14`
candidate is the one that matched during the real run, rather than the
/opt/homebrew/bin/python3 fallback I expected.

The harness header also credited install.sh with writing a PATH line into the
login shell. It does not write one. The reset argument is unaffected (such a
line would live in the deleted home either way), but the claim was untrue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 09:38:20 -04:00
didericis af293d7036 fix(install): find a usable python instead of dead-ending on PATH's
The macOS install harness caught this on its first real run: a throwaway
account gets `bot-bottle install: error: python3 3.11 or newer is required`
and stops. A fresh account's PATH is just /etc/paths, which excludes
/opt/homebrew/bin, so `python3` resolves to the Command Line Tools stub —
still 3.9.6 on macOS 26. Homebrew's shellenv line lives in the *installing*
user's ~/.zprofile and is inherited by nobody. The documented `curl … | sh`
path therefore dead-ends for anyone whose profile isn't already set up, which
is every new user, launchd job, and CI runner.

So look past PATH before giving up: try `python3`, then python3.11-3.14, then
/opt/homebrew/bin, /usr/local/bin, ~/.local/bin, and python.org framework
builds, and say which one was picked when it isn't the obvious one. On this
host that turns the failure into a successful install.

The chosen interpreter is now threaded through everything downstream — the pip
probe, the PEP 668 check, the pip --user fallback, and the user-scripts-dir
lookup — which were all still hardcoding `python3` and would otherwise have
run against the 3.9 stub we just rejected. `pipx install` gains `--python`,
since pipx otherwise builds the venv with whichever interpreter pipx itself
was installed with, not the one that passed the version check.

When nothing usable is found the error is now actionable: what was found and
why it's insufficient, where else it looked, a platform-appropriate install
command, and BOT_BOTTLE_PYTHON to point at an interpreter directly. An
explicit BOT_BOTTLE_PYTHON that is too old or unusable is an error rather than
a silent fallback to a different interpreter than the caller asked for.

Three existing tests asserted on incidental literals rather than the behaviour
they describe, and are narrowed to their actual intent:

* `never_uses_sudo` matched the word anywhere, including the new "sudo apt
  install python3.12" remediation *advice*. It now strips string literals and
  comments first, so it still catches sudo as a bare command, in a pipeline,
  and in a command substitution — verified by mutation — while allowing the
  script to print the word.
* `checks_pip_usable_before_fallback` pinned the literal `python3 -m pip`.
* `resolves_user_scripts_dir_not_hardcoded` banned ".local/bin" script-wide;
  it's now scoped to the USER_SCRIPTS assignment it exists to guard, since
  ~/.local/bin is a legitimate place to *find an interpreter*.

README grows the install command and a Requirements section it never had,
leading with the Python floor and why a working `python3` in your own shell
says nothing about what a fresh account sees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 09:38:20 -04:00
didericis d415581adc feat: add a one-shot test cycle to the macOS install harness
`test` chains up -> run -> status -> down, which is the loop you actually want
when verifying a clean install. Three things make it more than a convenience
wrapper:

* It refuses to start against an existing account. A reused home is not a clean
  install, so testing one silently would defeat the harness.
* It tears the account down from an EXIT/INT trap armed the moment the account
  exists, so a failed run — or a Ctrl-C mid-install — still leaves the machine
  clean. BB_TEST_KEEP=1 opts out to poke at a failure.
* Its verdict is stricter than the installer's. install.sh exits 0 when it
  finishes but `doctor` reports unmet prerequisites, so "the installer
  succeeded" is not a useful assertion; `test` fails if the install fails, if
  bot-bottle never reached the new user's PATH, or if doctor is unhappy. That
  meant giving cmd_status a real exit status instead of swallowing doctor's.

Also fixes two bugs in `run`'s installer staging, by removing the staging
entirely and feeding install.sh in on stdin:

* `mktemp /tmp/bb-install.XXXXXX.sh` did not do what it looks like. BSD mktemp
  only substitutes trailing Xs, so every run wrote the *same* predictable path,
  as root, mode 644, in a world-writable directory.
* The cleanup only ran on the normal and failure returns, so an interrupted run
  leaked the file.

Piping on stdin sidesteps both: root opens the redirect before sudo drops
privileges, so the mode-700 home that motivated the staging is a non-issue,
there is no file to leak, and `sh -s` matches the documented `curl … | sh`
shape more closely than executing a staged copy did.

The command-level `exit 1`s become `return 1` so the steps compose under the
trap, and the "next, run this" hints are suppressed inside `test`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 09:38:20 -04:00
didericis-claude 27b9ba5247 feat: add macOS clean-install test harness
Add scripts/macos-install-test.sh, a throwaway-user harness for exercising
install.sh the way a brand-new user would on macOS, plus the research note
that motivates the approach.

The harness has up/run/status/down/deep-reset subcommands. Because install.sh
writes only to the user home (pipx venv, ~/.bot-bottle, a PATH line) and never
installs the backend, deleting the account is a complete, deterministic reset
of the install surface. A disposable macOS VM can't stand in on M1/M2: the
Apple `container` backend needs Virtualization.framework, and running it inside
a guest VM requires nested virtualization (M3+ only), so a throwaway user is
the only way to reach the real host backend from a clean $HOME.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 09:38:20 -04:00
didericis-codex 420184b874 fix: close consolidated quality review findings
test / image-input-builds (push) Successful in 44s
lint / lint (push) Successful in 1m3s
test / unit (push) Successful in 1m4s
test / integration-docker (push) Successful in 1m6s
test / coverage (push) Successful in 20s
Update Quality Badges / update-badges (push) Successful in 2m57s
2026-07-27 09:26:25 -04:00
didericis-codex 7938b90d19 fix: enforce shared storage permissions 2026-07-27 09:26:25 -04:00
didericis-codex d879258f62 fix: bound heavy gateway operations 2026-07-27 09:26:25 -04:00
didericis-codex 78d3b43061 fix: enforce cleanup and secret integrity 2026-07-27 09:26:25 -04:00
didericis-codex cdc5c8203d fix(cleanup): use authoritative resource identities 2026-07-27 09:26:25 -04:00
didericis-codex d04bbf1454 fix(gateway): contain output pump shutdown races 2026-07-27 09:26:25 -04:00
didericis-codex c00097d998 fix(gateway): bound stdlib HTTP request work 2026-07-27 09:26:25 -04:00
didericis-codex 6630a1f701 fix(cleanup): revalidate destructive backend plans 2026-07-27 09:26:25 -04:00
didericis-codex a7ad82f03a refactor(supervisor): separate MCP dispatch from transport 2026-07-27 09:26:25 -04:00
didericis-codex 9b8f126b8f refactor(egress): extract outbound DLP request stage 2026-07-27 09:26:25 -04:00
didericis-codex d167c03215 refactor(egress): extract request policy stages 2026-07-27 09:26:25 -04:00
didericis-codex 479b93bd0b fix(orchestrator): bound streamed request bodies 2026-07-27 09:26:25 -04:00
didericis-codex 2de61eeb17 fix(orchestrator): keep host client dependency-free 2026-07-27 09:26:25 -04:00
didericis-codex a4bc5d4508 refactor(orchestrator): replace manual HTTP dispatch with FastAPI 2026-07-27 09:26:25 -04:00
didericis-codex 7ebd067d2c build(orchestrator): pin FastAPI runtime dependencies 2026-07-27 09:26:25 -04:00
didericis-codex be50354100 docs(prd): require shared storage permissions 2026-07-27 09:26:25 -04:00
didericis-codex e1163184c9 docs(prd): bound heavy gateway operations 2026-07-27 09:26:25 -04:00
didericis-codex 70f6a9be20 docs(prd): require cleanup execution integrity 2026-07-27 09:26:25 -04:00
didericis-codex aa43fd032f docs(prd): extend authoritative cleanup boundaries 2026-07-27 09:26:25 -04:00
didericis-codex be5c803e8e docs(prd): define authoritative failure boundaries 2026-07-27 09:26:25 -04:00
didericis-codex 38ff4a5e88 fix(orchestrator): satisfy adapter type contracts 2026-07-27 09:26:25 -04:00
didericis-codex 8df76f6126 refactor(egress): split request policy pipeline stages 2026-07-27 09:26:25 -04:00
didericis-codex b63c41db9c feat(firecracker): enumerate running bottles 2026-07-27 09:26:25 -04:00
didericis-codex 6c52686069 fix(orchestrator): bound unauthenticated HTTP requests 2026-07-27 09:26:25 -04:00
didericis-codex a52245af95 fix(security): authenticate persisted egress secrets 2026-07-27 09:26:25 -04:00
didericis-codex d4e48a4169 fix(docker): fail closed on network address scan errors 2026-07-27 09:26:25 -04:00
didericis-codex 53cc3ca492 fix(firecracker): abort cleanup on scan errors 2026-07-27 09:26:25 -04:00
didericis-codex bafb73fdb9 refactor(backend): type enumeration failures 2026-07-27 09:26:25 -04:00
didericis-codex b0f317c499 fix(docker): surface enumeration failures 2026-07-27 09:26:25 -04:00
didericis-codex 57eb4394dc fix(security): prohibit unauthenticated orchestrator 2026-07-27 09:26:25 -04:00
didericis-codex 82e1a353bd docs: analyze Sandbox Agent SDK architecture 2026-07-27 03:53:02 +00:00
didericis-codex a6e1aebda1 docs: update agent sandbox competitor landscape 2026-07-27 03:44:41 +00:00
125 changed files with 5184 additions and 451 deletions
+24 -2
View File
@@ -19,6 +19,19 @@ name: pre-release-test
on:
workflow_dispatch:
inputs:
ref:
description: Revision to qualify
required: false
default: main
workflow_call:
inputs:
ref:
required: true
type: string
secrets:
BOT_BOTTLE_INFRA_ARTIFACT_TOKEN:
required: true
jobs:
unit:
@@ -26,6 +39,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
# No actions/setup-python: the runner image already ships Python 3.12,
# and older act_runner engines mishandle setup-python's PATH (coverage
@@ -66,6 +81,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
# No actions/setup-python (see the note in the `unit` job); the
# container's system Python 3.12 runs the stdlib test suite directly.
@@ -138,10 +155,11 @@ jobs:
# the old build-infra → integration-firecracker + coverage chain incurred.
integration-firecracker:
runs-on: [self-hosted, kvm]
if: github.event_name == 'workflow_dispatch'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- name: Preflight — Firecracker host is ready
run: |
@@ -212,13 +230,14 @@ jobs:
# Python >=3.11 with `coverage` importable on the launchd service PATH.
integration-macos:
runs-on: [self-hosted, macos]
if: github.event_name == 'workflow_dispatch'
concurrency:
group: integration-macos-infra
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
# Fail loudly if the backend this job promises isn't actually usable,
# rather than letting every test silently `unittest.skip` and the job go
@@ -290,6 +309,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
- name: Install coverage
@@ -340,6 +360,8 @@ jobs:
steps:
- name: Checkout the tested revision
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- name: Download the tested rootfs
uses: actions/download-artifact@v3
+189
View File
@@ -0,0 +1,189 @@
name: publish-artifacts
on:
workflow_dispatch:
inputs:
ref:
description: Branch, tag, or commit to publish
required: true
default: main
workflow_call:
inputs:
ref:
required: true
type: string
outputs:
source_commit:
description: Published immutable source commit
value: ${{ jobs.resolve.outputs.sha }}
secrets:
BOT_BOTTLE_RELEASE_TOKEN:
required: true
BOT_BOTTLE_INFRA_ARTIFACT_TOKEN:
required: true
concurrency:
group: publish-artifacts-${{ inputs.ref }}
cancel-in-progress: false
jobs:
resolve:
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.revision.outputs.sha }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
- id: revision
name: Resolve the immutable source revision
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
oci:
needs: resolve
runs-on: ubuntu-latest
outputs:
orchestrator: ${{ steps.images.outputs.orchestrator }}
gateway: ${{ steps.images.outputs.gateway }}
agent_claude: ${{ steps.images.outputs.agent_claude }}
agent_codex: ${{ steps.images.outputs.agent_codex }}
agent_pi: ${{ steps.images.outputs.agent_pi }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.resolve.outputs.sha }}
- name: Log in to the package registry
uses: docker/login-action@v3
with:
registry: gitea.dideric.is
username: ${{ gitea.actor }}
password: ${{ secrets.BOT_BOTTLE_RELEASE_TOKEN }}
- name: Set up multi-architecture builds
uses: docker/setup-buildx-action@v3
- id: images
name: Build, smoke-test, and publish OCI images
run: |
set -euo pipefail
python_base=$(python3 -c \
'import json; print(json.load(open("image-build-args.json"))["PYTHON_BASE_IMAGE"])')
node_base=$(python3 -c \
'import json; print(json.load(open("image-build-args.json"))["NODE_BASE_IMAGE"])')
sha='${{ needs.resolve.outputs.sha }}'
registry=gitea.dideric.is/didericis
build_image() {
key=$1
name=$2
dockerfile=$3
build_arg=$4
smoke=$5
local_ref="bot-bottle-${name}:candidate"
remote_ref="${registry}/bot-bottle-${name}:commit-${sha}"
docker build --build-arg "$build_arg" -t "$local_ref" \
-f "$dockerfile" .
smoke_command=$(printf "$smoke" "$local_ref")
sh -c "docker run --rm $smoke_command"
docker buildx build --platform linux/amd64,linux/arm64 \
--build-arg "$build_arg" --push --iidfile "${key}.iid" \
-t "$remote_ref" -f "$dockerfile" .
digest=$(cat "${key}.iid")
case "$digest" in sha256:*) ;; *) exit 1 ;; esac
echo "${key}=${registry}/bot-bottle-${name}@${digest}" \
>> "$GITHUB_OUTPUT"
}
build_image orchestrator orchestrator Dockerfile.orchestrator \
"PYTHON_BASE_IMAGE=$python_base" \
"--entrypoint python3 %s -c 'import bot_bottle.orchestrator'"
build_image gateway gateway Dockerfile.gateway \
"PYTHON_BASE_IMAGE=$python_base" \
"--entrypoint mitmdump %s --version"
build_image agent_claude claude bot_bottle/contrib/claude/Dockerfile \
"NODE_BASE_IMAGE=$node_base" "%s claude --version"
build_image agent_codex codex bot_bottle/contrib/codex/Dockerfile \
"NODE_BASE_IMAGE=$node_base" "%s codex --version"
build_image agent_pi pi bot_bottle/contrib/pi/Dockerfile \
"NODE_BASE_IMAGE=$node_base" "%s pi --version"
firecracker:
needs: resolve
runs-on: [self-hosted, kvm]
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.resolve.outputs.sha }}
- name: Build and smoke-test Firecracker artifacts
env:
BOT_BOTTLE_FC_DROPBEAR: /var/cache/bot-bottle-fc/dropbear
run: |
python3 cli.py backend status --backend=firecracker
python3 -m bot_bottle.backend.firecracker.publish_infra \
--output infra-candidate --reuse-published
BOT_BOTTLE_INFRA_ARTIFACT_DIR="$PWD/infra-candidate" \
python3 -m unittest discover -t . -s tests/integration -v
- name: Publish tested Firecracker artifacts
env:
BOT_BOTTLE_INFRA_ARTIFACT_TOKEN: ${{ secrets.BOT_BOTTLE_INFRA_ARTIFACT_TOKEN }}
BOT_BOTTLE_FC_DROPBEAR: /var/cache/bot-bottle-fc/dropbear
run: |
python3 -m bot_bottle.backend.firecracker.publish_infra \
--publish-dir infra-candidate
- name: Record Firecracker artifact identities
run: |
cp infra-candidate/orchestrator/version.txt fc-orchestrator-version
cp infra-candidate/orchestrator/rootfs.ext4.gz.sha256 fc-orchestrator-sha
cp infra-candidate/gateway/version.txt fc-gateway-version
cp infra-candidate/gateway/rootfs.ext4.gz.sha256 fc-gateway-sha
- uses: actions/upload-artifact@v3
with:
name: firecracker-identities
path: |
fc-orchestrator-version
fc-orchestrator-sha
fc-gateway-version
fc-gateway-sha
package:
needs: [resolve, oci, firecracker]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/download-artifact@v3
with:
name: firecracker-identities
- name: Build the wheel with the published identities
run: |
python3 -m pip install --break-system-packages build
python3 scripts/generate_release_manifest.py \
--source-commit '${{ needs.resolve.outputs.sha }}' \
--orchestrator-image '${{ needs.oci.outputs.orchestrator }}' \
--gateway-image '${{ needs.oci.outputs.gateway }}' \
--agent-claude-image '${{ needs.oci.outputs.agent_claude }}' \
--agent-codex-image '${{ needs.oci.outputs.agent_codex }}' \
--agent-pi-image '${{ needs.oci.outputs.agent_pi }}' \
--firecracker-orchestrator-version "$(cat fc-orchestrator-version)" \
--firecracker-orchestrator-sha256 "$(awk '{print $1}' fc-orchestrator-sha)" \
--firecracker-gateway-version "$(cat fc-gateway-version)" \
--firecracker-gateway-sha256 "$(awk '{print $1}' fc-gateway-sha)" \
--output release-manifest.json
BOT_BOTTLE_RELEASE_MANIFEST="$PWD/release-manifest.json" \
python3 -m build --wheel
- name: Verify and publish the immutable commit bundle
env:
BOT_BOTTLE_RELEASE_TOKEN: ${{ secrets.BOT_BOTTLE_RELEASE_TOKEN }}
run: |
wheel=$(find dist -maxdepth 1 -name '*.whl' -type f)
python3 -m venv verify-venv
verify-venv/bin/pip install --no-deps "$wheel"
verify-venv/bin/python -c \
'from bot_bottle.release_manifest import load_manifest; load_manifest()'
python3 scripts/generate_release_bundle.py \
--manifest release-manifest.json \
--wheel "$wheel" \
--workflow-run \
"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--published-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--output bundle-index.json --publish
+93
View File
@@ -0,0 +1,93 @@
name: release
on:
push:
tags:
- "v*"
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
jobs:
validate:
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.release.outputs.sha }}
tag: ${{ steps.release.outputs.tag }}
channel: ${{ steps.release.outputs.channel }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: release
name: Validate tag shape and promotion branch
run: |
set -euo pipefail
tag=${GITHUB_REF#refs/tags/}
channel=$(python3 -c \
'import sys; from bot_bottle.release_qualification import release_channel; print(release_channel(sys.argv[1]))' \
"$tag")
branch=$channel
git fetch origin "$branch"
sha=$(git rev-list -n 1 "$tag")
git merge-base --is-ancestor "$sha" "origin/$branch" || {
echo "$tag is not reachable from protected $branch" >&2
exit 1
}
echo "sha=$sha" >> "$GITHUB_OUTPUT"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "channel=$channel" >> "$GITHUB_OUTPUT"
qualify:
needs: validate
uses: ./.gitea/workflows/pre-release-test.yml
with:
ref: ${{ needs.validate.outputs.sha }}
secrets:
BOT_BOTTLE_INFRA_ARTIFACT_TOKEN: ${{ secrets.BOT_BOTTLE_INFRA_ARTIFACT_TOKEN }}
artifacts:
needs: [validate, qualify]
uses: ./.gitea/workflows/publish-artifacts.yml
with:
ref: ${{ needs.validate.outputs.sha }}
secrets:
BOT_BOTTLE_RELEASE_TOKEN: ${{ secrets.BOT_BOTTLE_RELEASE_TOKEN }}
BOT_BOTTLE_INFRA_ARTIFACT_TOKEN: ${{ secrets.BOT_BOTTLE_INFRA_ARTIFACT_TOKEN }}
promote:
needs: [validate, artifacts]
runs-on: ubuntu-latest
concurrency:
group: release-channel-${{ needs.validate.outputs.channel }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.sha }}
- name: Verify install from the published bundle
env:
BOT_BOTTLE_REF: ${{ needs.validate.outputs.sha }}
run: |
sh install.sh
"$HOME/.local/bin/bot-bottle" --help
- name: Publish release qualification and advance channel
env:
BOT_BOTTLE_RELEASE_TOKEN: ${{ secrets.BOT_BOTTLE_RELEASE_TOKEN }}
run: |
python3 scripts/publish_release_qualification.py \
--tag '${{ needs.validate.outputs.tag }}' \
--source-commit '${{ needs.validate.outputs.sha }}' \
--workflow-run \
"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--qualified-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- name: Create the Gitea release
env:
GITEA_TOKEN: ${{ secrets.BOT_BOTTLE_RELEASE_TOKEN }}
run: |
curl --fail-with-body --silent --show-error \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${{ needs.validate.outputs.tag }}\",\"name\":\"${{ needs.validate.outputs.tag }}\",\"target_commitish\":\"${{ needs.validate.outputs.sha }}\"}" \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases"
+41 -8
View File
@@ -15,7 +15,7 @@
## Features
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist; per-route path/method/header `matches` filtering; outbound DLP scanning for known tokens and secrets, inbound DLP scanning for prompt-injection attempts; DoH and arbitrary hosts blocked by default.
- **Per-route token-match policy** — each egress route picks what happens when the outbound DLP catches a token via `dlp.outbound_on_match`: `supervise` (default) holds the request and surfaces it in `./cli.py supervise` for approval (an approved value is remembered for the life of the proxy); `redact` scrubs the value and forwards; `block` is a hard `403`. Cuts false-positive friction without weakening default-deny.
- **Per-route token-match policy** — each egress route picks what happens when the outbound DLP catches a token via `dlp.outbound_on_match`: `supervise` (default) holds the request and surfaces it in `bot-bottle supervise` for approval (an approved value is remembered for the life of the proxy); `redact` scrubs the value and forwards; `block` is a hard `403`. Cuts false-positive friction without weakening default-deny.
- **Tokens the agent never sees** — host secrets live in a gateway; the agent dials `http://gateway: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.
@@ -39,7 +39,7 @@ On the legacy Docker backend, the same logical bottle is two containers per agen
The Docker topology looks like this:
```
host ( ./cli.py )
host ( bot-bottle )
starts │ stops
@@ -71,9 +71,32 @@ When the agent exits, `cli.py` tears down every gateway and both networks; nothi
## Quickstart
On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The Firecracker backend (Linux) requires Docker on the host for the gateway plus the `firecracker` binary and KVM. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
```sh
curl -fsSL https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh | sh
```
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend.
The installer is a bootstrapper: it finds a suitable Python, resolves the
latest qualified production bundle, verifies its wheel checksum, installs with
`pipx` (falling back to a private venv), creates `~/.bot-bottle`, and runs
`bot-bottle doctor`. It is idempotent and never uses `sudo`.
Select staging with `BOT_BOTTLE_CHANNEL=staging`, an exact qualified release
with `BOT_BOTTLE_VERSION=vX.Y.Z[-rc.N]`, or an exact published commit with
`BOT_BOTTLE_REF=<40-character-sha>`. Commit installs print an explicit warning
because snapshots may not have passed release qualification.
### Requirements
**Python ≥ 3.11**, and this is the one that trips people up on macOS: the `python3` Apple ships at `/usr/bin/python3` is **3.9.6**, which is too old. Bare `python3` resolves to that stub far more often than people expect. `path_helper` builds a login shell's `PATH` from `/etc/paths` and then appends `/etc/paths.d/*`, and `/usr/bin` sits in the former — so even when `/opt/homebrew/bin` *is* on the `PATH` (via `/etc/paths.d/homebrew`), it comes after `/usr/bin` and loses. Prepending a newer Python is something your shell profile does, and a fresh account, a launchd job, or a CI runner has no such profile. So the installer looks past bare `python3` before giving up: it tries `python3`, then the versioned `python3.11``python3.14` names, then `/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, and python.org framework builds — and tells you which one it picked when it isn't the obvious one. Point it somewhere specific with `BOT_BOTTLE_PYTHON=/path/to/python3`.
**No `pipx` required.** If `pipx` is present the installer uses it and stays out of the way. If it isn't, bot-bottle installs into a private venv at `~/.bot-bottle/venv` (override with `BOT_BOTTLE_VENV`) and symlinks the entry point into `~/.local/bin`. There is deliberately no `pip install --user` path: Homebrew, python.org and Debian/Ubuntu interpreters are all externally managed (PEP 668), which blocks `--user` outright — so on a Mac it is never the fallback it appears to be. A venv is exempt from PEP 668, and `venv` is stdlib, so unlike `pipx` there is nothing to bootstrap first.
**`git`** is needed only when `BOT_BOTTLE_INSTALL_SPEC` explicitly selects a
`git+` URL. The normal published-wheel path does not require it.
**A backend**, which the installer deliberately does *not* install for you — `doctor` reports what's missing afterwards. On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The Firecracker backend (Linux) requires Docker on the host for the gateway plus the `firecracker` binary and KVM. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
Use `BOT_BOTTLE_BACKEND=docker bot-bottle start <agent>` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend.
> **CI (macOS Apple Container):** the advisory `integration-macos` job in `.gitea/workflows/pre-release-test.yml` runs only on manual dispatch. It targets a self-hosted host-mode runner labelled `macos`; Apple Container cannot run inside the Linux pull-request runner. Provision an Apple Silicon host with the `container` CLI running and Python ≥ 3.11 plus `coverage` on the launchd service's explicit `PATH`. The infra container is a singleton (`bot-bottle-mac-infra`), so keep runner concurrency at 1. Its coverage is reported separately and never feeds the required pull-request gate.
@@ -166,10 +189,10 @@ On Linux, a KVM-capable host defaults to the Firecracker backend. It needs:
- **`/dev/kvm`** present and accessible. Load `kvm-intel` or `kvm-amd` (and enable virtualization in BIOS/firmware). The invoking user must be in the `kvm` group: `sudo usermod -aG kvm "$USER"` then re-login. bot-bottle preflights this and reports exactly what's missing.
- **`firecracker`** on `PATH`: grab a release from <https://github.com/firecracker-microvm/firecracker/releases>. Start flows print this pointer when the binary is missing.
- **Docker** for the gateway and image build.
- **A one-time privileged network setup** — the per-bottle TAP pool plus the fail-closed `nftables` isolation table. Run `./cli.py backend setup --backend=firecracker` for the host-appropriate config (a NixOS module, a `sudo` script elsewhere); `./cli.py backend status --backend=firecracker` reports what's present, including whether the pool range collides with an existing route. The pool defaults to `10.243.0.0/16` (an obscure RFC-1918 block that dodges docker/libvirt/LAN and, deliberately, Tailscale's `100.64.0.0/10` CGNAT range); override with `BOT_BOTTLE_FC_IP_BASE` if it clashes on your host.
- **A one-time privileged network setup** — the per-bottle TAP pool plus the fail-closed `nftables` isolation table. Run `bot-bottle backend setup --backend=firecracker` for the host-appropriate config (a NixOS module, a `sudo` script elsewhere); `bot-bottle backend status --backend=firecracker` reports what's present, including whether the pool range collides with an existing route. The pool defaults to `10.243.0.0/16` (an obscure RFC-1918 block that dodges docker/libvirt/LAN and, deliberately, Tailscale's `100.64.0.0/10` CGNAT range); override with `BOT_BOTTLE_FC_IP_BASE` if it clashes on your host.
```sh
BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent>
BOT_BOTTLE_BACKEND=firecracker bot-bottle start <agent>
```
> **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. For the network pool, consume the flake module — `imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ]; services.bot-bottle-firecracker = { enable = true; owner = "you"; };` — then `nixos-rebuild switch` (imperative nft/TAP rules don't survive a rebuild; channel users can `imports = [ <bot-bottle>/nix/firecracker-netpool.nix ]`). `firecracker` isn't in nixpkgs by default as a user binary — install the release binary (pin the version) and put it on `PATH`.
@@ -177,9 +200,19 @@ BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent>
> **CI:** Firecracker integration runs in the manually dispatched `.gitea/workflows/pre-release-test.yml` on a self-hosted runner labelled `kvm`; privileged KVM hosts never execute unreviewed PR code automatically. Provision it like a normal Firecracker host: `firecracker` on `PATH`, `/dev/kvm`, the cached guest kernel and static dropbear, and the persistent TAP/nft pool. The required pull-request workflow runs unit plus the complete Docker integration suite on `ubuntu-latest`; see `docs/ci.md`.
```sh
./cli.py start <agent> # builds the image on first run, drops you into claude
bot-bottle start <agent> # prepares the selected agent image, then attaches
```
Packaged releases carry immutable orchestrator, gateway, and first-party agent
image identities. Docker and Apple Container pull the package-selected OCI
digests; Firecracker pulls the matching checksum-verified rootfs artifacts.
Each verified wheel and its identities are published as an immutable generic
package keyed by the full source commit; `bundle-index.json` is uploaded last
and is the completeness marker. A source checkout uses local builds for
development. Set `BOT_BOTTLE_INFRA_BUILD=local` to make that development
override explicit when diagnosing a packaged release; production never falls
back to a build after an artifact pull fails.
## Manifest
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`.
@@ -253,7 +286,7 @@ You help maintain Gitea-hosted projects.
| `dlp.outbound_on_match` | no | What to do when an outbound token is detected: `supervise` (default for manifest routes — hold for operator approval), `redact` (scrub the value and forward), or `block` (hard 403). Agent-provider routes (e.g. `api.anthropic.com`) default to `redact`. |
| `git.fetch` | no | `true` permits smart HTTP clone/fetch (`git-upload-pack`) for this host. Push (`git-receive-pack`) remains blocked. |
When an outbound DLP detector matches a token, the route's `dlp.outbound_on_match` policy decides what happens. Under the default `supervise`, the proxy queues an `egress-token-allow` proposal for the operator's `./cli.py supervise` TUI and holds the request open until it is answered (or `EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS`, default 300s, elapses — after which it fails closed). The operator never sees the raw token, only the host, method, path, and a redacted snippet; approving adds the value to an in-memory safelist for the life of the egress proxy. Under `redact`, the matched value is scrubbed from the body, headers, and path and the request is forwarded (failing closed if a match lands somewhere unredactable, like the hostname). Under `block` it stays a hard `403`. Structural blocks (CRLF injection) and not-in-allowlist host blocks are always hard `403`s regardless of policy.
When an outbound DLP detector matches a token, the route's `dlp.outbound_on_match` policy decides what happens. Under the default `supervise`, the proxy queues an `egress-token-allow` proposal for the operator's `bot-bottle supervise` TUI and holds the request open until it is answered (or `EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS`, default 300s, elapses — after which it fails closed). The operator never sees the raw token, only the host, method, path, and a redacted snippet; approving adds the value to an in-memory safelist for the life of the egress proxy. Under `redact`, the matched value is scrubbed from the body, headers, and path and the request is forwarded (failing closed if a match lands somewhere unredactable, like the hostname). Under `block` it stays a hard `403`. Structural blocks (CRLF injection) and not-in-allowlist host blocks are always hard `403`s regardless of policy.
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
+1 -1
View File
@@ -226,7 +226,7 @@ class AgentProvider(ABC):
initial task in a non-interactive (headless) session.
Called only when ``--prompt`` is passed to
``./cli.py start --headless``; the returned args are appended
``bot-bottle start --headless``; the returned args are appended
after the provider's ``bypass_args`` and ``startup_args``."""
def provision_ca(self, bottle: "Bottle", plan: "BottlePlan") -> None:
+7 -3
View File
@@ -172,6 +172,10 @@ class BottleCleanupPlan(ABC):
"""True iff there is nothing to clean up; the CLI uses this to
short-circuit before showing the y/N."""
@abstractmethod
def intersect(self, current: "BottleCleanupPlan") -> "BottleCleanupPlan":
"""Resources both displayed to the operator and currently removable."""
@dataclass(frozen=True)
class ExecResult:
@@ -527,7 +531,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
host-appropriate config or commands. Prints to stdout/stderr and
returns a shell exit code (0 = nothing to report / success). A
backend that needs no host setup prints a short note and returns
0. Invoked generically by `./cli.py backend setup [--backend=…]`
0. Invoked generically by `bot-bottle backend setup [--backend=…]`
so operators can provision any backend without a
backend-specific command. Classmethod (like `is_available`) —
it's a host query, not per-bottle state."""
@@ -544,14 +548,14 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
stderr. When quiet=True returns the status code silently —
useful for cheap programmatic checks.
Invoked by `./cli.py backend status [--backend=…]` (quiet=False)
Invoked by `bot-bottle backend status [--backend=…]` (quiet=False)
and by is_backend_ready() (caller-controlled)."""
@classmethod
@abstractmethod
def teardown(cls) -> int:
"""Undo `setup()` — the inverse operation, surfaced as
`./cli.py backend teardown [--backend=…]` (uninstall). Symmetric
`bot-bottle backend teardown [--backend=…]` (uninstall). Symmetric
with setup: where setup is advisory (prints the privileged
commands / declarative config to apply), teardown prints the
commands / config change to remove the host prerequisites. A
+60
View File
@@ -0,0 +1,60 @@
"""Shared destructive-cleanup execution and failure accounting."""
from __future__ import annotations
import os
import shutil
import subprocess
from collections.abc import Sequence
from pathlib import Path
class CleanupError(RuntimeError):
"""One or more approved cleanup mutations did not complete."""
class CleanupFailures:
"""Attempt every approved mutation, then fail with complete diagnostics."""
def __init__(self) -> None:
self._messages: list[str] = []
def run(self, argv: Sequence[str], description: str) -> None:
raw_timeout = os.environ.get(
"BOT_BOTTLE_CLEANUP_COMMAND_TIMEOUT_SECONDS", "120",
)
try:
timeout = float(raw_timeout)
except ValueError:
timeout = 120.0
try:
result = subprocess.run(
list(argv), capture_output=True, text=True, check=False,
timeout=max(timeout, 1.0),
)
except (OSError, subprocess.SubprocessError) as exc:
self._messages.append(f"{description}: {exc}")
return
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
self._messages.append(
f"{description}: {detail or f'exit {result.returncode}'}"
)
def remove_tree(self, path: Path, description: str) -> None:
try:
shutil.rmtree(path)
except FileNotFoundError:
return
except OSError as exc:
self._messages.append(f"{description}: {exc}")
def record(self, message: str) -> None:
self._messages.append(message)
def raise_if_any(self) -> None:
if self._messages:
raise CleanupError("; ".join(self._messages))
__all__ = ["CleanupError", "CleanupFailures"]
@@ -46,6 +46,22 @@ class DockerBottleCleanupPlan(BottleCleanupPlan):
and not self.orphan_state_dirs
)
def intersect(self, current: BottleCleanupPlan) -> "DockerBottleCleanupPlan":
if not isinstance(current, DockerBottleCleanupPlan):
raise TypeError("cleanup plans must have the same backend type")
return DockerBottleCleanupPlan(
projects=tuple(x for x in self.projects if x in current.projects),
stray_containers=tuple(
x for x in self.stray_containers if x in current.stray_containers
),
stray_networks=tuple(
x for x in self.stray_networks if x in current.stray_networks
),
orphan_state_dirs=tuple(
x for x in self.orphan_state_dirs if x in current.orphan_state_dirs
),
)
def print(self) -> None:
print(file=sys.stderr)
for name in self.projects:
+11 -21
View File
@@ -23,12 +23,12 @@ Active-agent enumeration lives in `backend/docker/enumerate.py`.
from __future__ import annotations
import shutil
import subprocess
from ...paths import bot_bottle_root
from ...log import info, warn
from ...log import info
from .. import EnumerationError
from ..cleanup_control import CleanupFailures
from . import util as docker_mod
from .bottle_cleanup_plan import DockerBottleCleanupPlan
from ...bottle_state import bottle_state_dir, is_preserved
@@ -150,40 +150,30 @@ def cleanup(plan: DockerBottleCleanupPlan) -> None:
"""Remove everything in the plan. Projects first (whose `compose
down` reaps their containers + networks atomically), then stray
legacy resources, then orphan state dirs."""
failures = CleanupFailures()
for project in plan.projects:
info(f"docker compose down ({project})")
result = subprocess.run(
failures.run(
["docker", "compose", "-p", project, "down", "--volumes"],
capture_output=True, text=True, check=False,
f"docker compose down failed for {project}",
)
if result.returncode != 0:
warn(
f"compose down failed for {project}: "
f"{result.stderr.strip()}"
)
for name in plan.stray_containers:
info(f"removing stray container {name}")
subprocess.run(
failures.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
f"removing stray container {name}",
)
for name in plan.stray_networks:
info(f"removing stray network {name}")
subprocess.run(
failures.run(
["docker", "network", "rm", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
f"removing stray network {name}",
)
for identity in plan.orphan_state_dirs:
path = bottle_state_dir(identity)
info(f"removing orphan state dir {path}")
try:
shutil.rmtree(path, ignore_errors=True)
except OSError as e:
warn(f"failed to remove {path}: {e}")
failures.remove_tree(path, f"removing orphan state dir {path}")
failures.raise_if_any()
+5 -2
View File
@@ -74,10 +74,13 @@ def list_compose_projects(
result = subprocess.run(
argv, capture_output=True, text=True, check=False,
)
except FileNotFoundError as exc:
except OSError as exc:
# Not only "not found": docker on PATH but not executable by this
# user raises PermissionError. Either way the query never ran, so an
# enumeration caller must not read the empty result as authoritative.
if raise_on_error:
raise EnumerationError(
"docker compose ls failed: docker not found"
f"docker compose ls failed: docker unavailable ({exc})"
) from exc
return []
if result.returncode != 0:
+3 -2
View File
@@ -74,8 +74,9 @@ def _query_services_by_project() -> dict[str, set[str]]:
],
capture_output=True, text=True, check=False,
)
except FileNotFoundError as exc:
raise EnumerationError("docker ps failed: docker not found") from exc
except OSError as exc:
# Missing, or on PATH but not executable by this user (PermissionError).
raise EnumerationError(f"docker ps failed: docker unavailable ({exc})") from exc
if r.returncode != 0:
raise EnumerationError(f"docker ps failed: {r.stderr.strip()}")
return _parse_services_by_project(r.stdout or "")
+4
View File
@@ -89,6 +89,10 @@ class DockerGateway(Gateway):
when no dockerfile is configured (a pre-pulled image). BOT_BOTTLE_NO_CACHE
forces a full rebuild (parity with `start --no-cache`)."""
if self._dockerfile is None:
proc = run_docker(["docker", "pull", self.image_ref])
if proc.returncode != 0:
raise GatewayError(
f"gateway image pull failed: {proc.stderr.strip()}")
return
context = self._build_context or resources.build_root()
argv = ["docker", "build", "-t", self.image_ref,
+14 -2
View File
@@ -34,6 +34,7 @@ from .orchestrator import (
ORCHESTRATOR_NETWORK,
)
from ... import resources
from ... import release_manifest
from ...gateway import (
GATEWAY_IMAGE,
GATEWAY_NAME,
@@ -90,6 +91,14 @@ class DockerInfraService(InfraService):
self._orchestrator_name = orchestrator_name
self._orchestrator_label = orchestrator_label
self._gateway_name = gateway_name
resolved_orchestrator, orchestrator_local = release_manifest.oci_image(
"orchestrator", orchestrator_image)
resolved_gateway, gateway_local = release_manifest.oci_image(
"gateway", gateway_image)
self.orchestrator_image = resolved_orchestrator
self.gateway_image = resolved_gateway
self._orchestrator_local = orchestrator_local
self._gateway_local = gateway_local
def orchestrator(self) -> DockerOrchestrator:
"""The control-plane service. Cheap to reconstruct — `ensure_built`
@@ -104,6 +113,8 @@ class DockerInfraService(InfraService):
repo_root=self._repo_root,
host_root=self._host_root,
root_mount_source=self._root_mount_source,
dockerfile=(
"Dockerfile.orchestrator" if self._orchestrator_local else None),
)
def gateway(self) -> DockerGateway:
@@ -119,6 +130,7 @@ class DockerInfraService(InfraService):
control_network=self.control_network,
build_context=self._repo_root,
ca_mount_source=self._gateway_ca_mount_source,
dockerfile="Dockerfile.gateway" if self._gateway_local else None,
)
def ensure_running(
@@ -132,8 +144,8 @@ class DockerInfraService(InfraService):
gateway = self.gateway()
# Build both images (cache-aware; a no-op when nothing changed) before
# bringing either plane up.
orchestrator.ensure_built()
gateway.ensure_built()
orchestrator.ensure_available()
gateway.ensure_available()
orchestrator.ensure_running(startup_timeout=startup_timeout)
+9
View File
@@ -84,6 +84,15 @@ def build_or_load_images(plan: DockerBottlePlan) -> BottleImages:
)
info(f"using cached agent image {plan.image!r}")
return BottleImages(agent=plan.image)
if "@sha256:" in plan.image:
pulled = docker_mod.run_docker(["docker", "pull", plan.image])
if pulled.returncode != 0:
die(f"pulling packaged agent image {plan.image!r} failed: "
f"{pulled.stderr.strip()}")
docker_mod.verify_agent_image(
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
)
return BottleImages(agent=plan.image)
docker_mod.build_image(plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
docker_mod.verify_agent_image(
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
@@ -125,6 +125,10 @@ class DockerOrchestrator(Orchestrator):
no-op when nothing changed). No-op when no dockerfile is configured (a
pre-pulled image). BOT_BOTTLE_NO_CACHE forces a full rebuild."""
if self._dockerfile is None:
proc = run_docker(["docker", "pull", self.image_ref])
if proc.returncode != 0:
raise GatewayError(
f"orchestrator image pull failed: {proc.stderr.strip()}")
return
argv = ["docker", "build", "-t", self.image_ref,
"-f", str(self._repo_root / self._dockerfile),
+3 -3
View File
@@ -8,7 +8,7 @@ pointer, and `status()` reports whether docker is usable.
This is intentionally minimal; a richer version (daemon config checks,
gVisor/runsc install guidance, rootless-docker hints) is tracked
separately. Reached via `DockerBottleBackend.setup` / `.status`, which
the generic `./cli.py backend {setup,status}` dispatches to.
the generic `bot-bottle backend {setup,status}` dispatches to.
"""
from __future__ import annotations
@@ -69,7 +69,7 @@ def teardown() -> int:
sys.stderr.write(
"Docker backend: nothing to undo — it provisions no privileged host "
"state (networks and the gateway are per-launch and are "
"removed by `./cli.py cleanup`). Docker itself is left installed.\n"
"removed by `bot-bottle cleanup`). Docker itself is left installed.\n"
)
return 0
@@ -89,5 +89,5 @@ def status() -> int:
runsc = _docker_on_path() and _util.runsc_available()
sys.stderr.write(f"gVisor runsc runtime: {'registered' if runsc else 'not registered (optional)'}\n")
if not ok:
sys.stderr.write("\nRun: ./cli.py backend setup --backend=docker\n")
sys.stderr.write("\nRun: bot-bottle backend setup --backend=docker\n")
return 0 if ok else 1
@@ -27,3 +27,11 @@ class FirecrackerBottleCleanupPlan(BottleCleanupPlan):
@property
def empty(self) -> bool:
return not (self.vm_pids or self.run_dirs)
def intersect(self, current: BottleCleanupPlan) -> "FirecrackerBottleCleanupPlan":
if not isinstance(current, FirecrackerBottleCleanupPlan):
raise TypeError("cleanup plans must have the same backend type")
return FirecrackerBottleCleanupPlan(
vm_pids=tuple(x for x in self.vm_pids if x in current.vm_pids),
run_dirs=tuple(x for x in self.run_dirs if x in current.run_dirs),
)
+16 -4
View File
@@ -23,13 +23,13 @@ from __future__ import annotations
from collections.abc import Sequence
import os
import shutil
import signal
import subprocess
from pathlib import Path
from ...log import info
from .. import EnumerationError
from ..cleanup_control import CleanupError, CleanupFailures
from . import lifecycle_lock, util
from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan
@@ -151,11 +151,16 @@ def cleanup(plan: FirecrackerBottleCleanupPlan) -> None:
fresh = prepare_cleanup()
approved_pids = set(plan.vm_pids).intersection(fresh.vm_pids)
approved_dirs = set(plan.run_dirs).intersection(fresh.run_dirs)
failures = CleanupFailures()
for pid in sorted(approved_pids):
_terminate_orphan(pid, _run_root())
try:
_terminate_orphan(pid, _run_root())
except CleanupError as exc:
failures.record(str(exc))
for path in sorted(approved_dirs):
info(f"rm -rf {path}")
shutil.rmtree(path, ignore_errors=True)
failures.remove_tree(Path(path), f"removing Firecracker run dir {path}")
failures.raise_if_any()
def _terminate_orphan(pid: int, run_root: Path) -> None:
@@ -182,6 +187,13 @@ def _terminate_orphan(pid: int, run_root: Path) -> None:
if run_dir is None or run_dir.is_dir():
return
info(f"kill firecracker VM pid {pid}")
signal.pidfd_send_signal(pidfd, signal.SIGTERM)
try:
signal.pidfd_send_signal(pidfd, signal.SIGTERM)
except ProcessLookupError:
return
except OSError as exc:
raise CleanupError(
f"could not signal Firecracker pid {pid}: {exc}"
) from exc
finally:
os.close(pidfd)
@@ -72,6 +72,46 @@ def cached_agent_rootfs_dir(dockerfile: Path) -> Path | None:
return base if (base / ".bb-ready").is_file() else None
def _image_rootfs_digest(image: str) -> str:
h = hashlib.sha256()
h.update(image.encode())
h.update(b"\0")
h.update(util._GUEST_INIT.encode())
return h.hexdigest()[:16]
def cached_agent_image_rootfs_dir(image: str) -> Path | None:
"""Return a ready rootfs exported from an immutable OCI image."""
base = util.cache_dir() / "rootfs" / f"agent-image-{_image_rootfs_digest(image)}"
return base if (base / ".bb-ready").is_file() else None
def acquire_agent_image_rootfs_dir(
image: str, *, smoke_test: tuple[str, ...] = (),
) -> Path:
"""Pull a digest-pinned image in the infra VM and export its rootfs."""
if "@sha256:" not in image:
die(f"prebuilt Firecracker agent image is not digest-pinned: {image}")
digest = _image_rootfs_digest(image)
base = util.cache_dir() / "rootfs" / f"agent-image-{digest}"
cached = cached_agent_image_rootfs_dir(image)
if cached is not None:
info(f"using cached agent rootfs {cached.name}")
return cached
with _build_lock():
if (base / ".bb-ready").is_file():
return base
staging = util.cache_dir() / "rootfs" / f".pulling-{digest}"
shutil.rmtree(staging, ignore_errors=True)
staging.mkdir(parents=True)
_pull_in_infra(image, staging, smoke_test, digest)
util.inject_guest_boot(staging)
(staging / ".bb-ready").write_text("ok\n")
shutil.rmtree(base, ignore_errors=True)
os.rename(staging, base)
return base
def build_agent_rootfs_dir(
dockerfile: Path, *, image_tag: str, smoke_test: tuple[str, ...] = (),
) -> Path:
@@ -167,6 +207,43 @@ def _build_in_infra(
_cleanup()
def _pull_in_infra(
image: str, base: Path, smoke_test: tuple[str, ...], digest: str,
) -> None:
"""Pull and export a published agent image inside the orchestrator VM."""
service = FirecrackerInfraService()
service.ensure_running()
key, ip = service.orchestrator().ssh_target()
tag = f"bot-bottle-agent-pull-{digest}"
smoke_ctr, export_ctr = f"{tag}-smoke", f"{tag}-export"
quoted_image = shlex.quote(image)
def cleanup() -> None:
_ssh(
key,
ip,
f"buildah rm {smoke_ctr} {export_ctr} >/dev/null 2>&1; "
f"buildah rmi {_STORE_FLAG} {tag} >/dev/null 2>&1",
timeout=60,
)
cleanup()
try:
result = _ssh(
key,
ip,
f"buildah pull {_STORE_FLAG} {quoted_image} && "
f"buildah tag {_STORE_FLAG} {quoted_image} {tag}",
timeout=_BUILD_TIMEOUT_SECONDS,
)
if result.returncode != 0:
die(f"pulling pinned agent image failed: {result.stderr.strip()}")
_smoke_test(key, ip, tag, smoke_ctr, smoke_test)
_stream_rootfs(key, ip, tag, export_ctr, base)
finally:
cleanup()
def _ssh(private_key: Path, guest_ip: str, script: str,
*, timeout: float = 60.0) -> subprocess.CompletedProcess[str]:
return subprocess.run(
+1 -1
View File
@@ -61,7 +61,7 @@ class FirecrackerInfraService(InfraService):
return url
# Clear stale/hung/OUTDATED VMs holding either link before booting fresh.
infra_vm.stop()
infra_vm.ensure_built()
infra_vm.ensure_available()
# Orchestrator first — the gateway daemons reach the control plane at
# startup. Each service owns its own boot; the orchestrator (which
# holds the signing key) mints the role-scoped `gateway` JWT for the
@@ -195,7 +195,9 @@ def _sha256_file(path: Path) -> str:
return h.hexdigest()
def ensure_artifact_gz(version: str, *, role: str) -> Path:
def ensure_artifact_gz(
version: str, *, role: str, expected_sha256: str | None = None,
) -> Path:
"""The verified, cached `rootfs.ext4.gz` for `role` at `version` —
downloading it (and its `.sha256`) once, then reusing it. Fail-closed on a
checksum mismatch: the partial is removed and we die rather than boot an
@@ -222,6 +224,11 @@ def ensure_artifact_gz(version: str, *, role: str) -> Path:
if not gz.is_file() or not sha.is_file():
die(f"infra candidate bundle is incomplete: {root}")
expected = sha.read_text().split()[0].strip().lower()
if expected_sha256 is not None and expected != expected_sha256:
die(
f"infra candidate packaged checksum mismatch ({role}) for {version}:\n"
f" packaged {expected_sha256}\n candidate {expected}"
)
actual = _sha256_file(gz)
if actual != expected:
die(
@@ -235,7 +242,13 @@ def ensure_artifact_gz(version: str, *, role: str) -> Path:
gz = root / _GZ_NAME
ok = root / ".verified"
if gz.is_file() and ok.is_file():
return gz
actual = _sha256_file(gz)
recorded = ok.read_text(encoding="utf-8").strip()
wanted = expected_sha256 or recorded
if actual == recorded == wanted:
return gz
gz.unlink(missing_ok=True)
ok.unlink(missing_ok=True)
info(f"pulling infra rootfs artifact {_package(role)}/{version}")
_download(artifact_url(version, _GZ_NAME, role=role), gz)
@@ -243,6 +256,14 @@ def ensure_artifact_gz(version: str, *, role: str) -> Path:
_download(artifact_url(version, _SHA_NAME, role=role), sha)
expected = sha.read_text().split()[0].strip().lower()
if expected_sha256 is not None and expected != expected_sha256:
gz.unlink(missing_ok=True)
sha.unlink(missing_ok=True)
die(
f"infra artifact published checksum mismatch ({role}) for {version}:\n"
f" packaged {expected_sha256}\n"
f" published {expected}"
)
actual = _sha256_file(gz)
if actual != expected:
gz.unlink(missing_ok=True)
@@ -253,15 +274,22 @@ def ensure_artifact_gz(version: str, *, role: str) -> Path:
f" actual {actual}\n"
f" refusing to boot an unverified rootfs."
)
ok.write_text("ok\n")
ok.write_text(actual + "\n")
return gz
def materialize_ext4(version: str, dest: Path, *, role: str) -> None:
def materialize_ext4(
version: str,
dest: Path,
*,
role: str,
expected_sha256: str | None = None,
) -> None:
"""Ensure the verified `role` artifact is cached, then gunzip it to `dest` —
a fresh, writable per-boot rootfs (the VM mutates it; the cached `.gz` stays
pristine). Atomic via a `.part` sibling."""
gz = ensure_artifact_gz(version, role=role)
gz = ensure_artifact_gz(
version, role=role, expected_sha256=expected_sha256)
tmp = dest.with_suffix(dest.suffix + ".part")
info(f"expanding {role} infra rootfs -> {dest}")
with gzip.open(gz, "rb") as src, open(tmp, "wb") as out:
+26 -5
View File
@@ -43,6 +43,7 @@ from pathlib import Path
from typing import Generator
from ... import resources
from ... import release_manifest
from ...log import die, info
from ..docker import util as docker_mod
from . import firecracker_vm, infra_artifact, netpool, util
@@ -108,6 +109,18 @@ def _role_version(role: str) -> str:
return infra_artifact.infra_artifact_version(role_init(role), role)
def _role_release(role: str) -> tuple[str, str | None]:
manifest = release_manifest.packaged_manifest()
if manifest is None or release_manifest.local_build_requested():
return _role_version(role), None
artifact = (
manifest.firecracker_orchestrator
if role == "orchestrator"
else manifest.firecracker_gateway
)
return artifact.version, artifact.sha256
def ensure_built() -> None:
"""Ensure both infra rootfs artifacts are available before boot.
@@ -117,11 +130,16 @@ def ensure_built() -> None:
`BOT_BOTTLE_INFRA_BUILD=local` instead builds the images from source with
host Docker (the orchestrator-fc image is `FROM` the orchestrator image, so
it must exist first) for iterating on the Dockerfiles."""
if infra_artifact.local_build_requested():
if release_manifest.local_build_requested():
build_infra_images_with_docker()
return
for role in infra_artifact.ROLES:
infra_artifact.ensure_artifact_gz(_role_version(role), role=role)
version, expected = _role_release(role)
infra_artifact.ensure_artifact_gz(
version, role=role, expected_sha256=expected)
ensure_available = ensure_built
def build_infra_images_with_docker() -> None:
@@ -204,7 +222,7 @@ def boot_vm(
Records the PID."""
if not netpool.tap_present(slot.iface):
die(f"infra link {slot.iface} not present.\n"
f" ./cli.py backend setup --backend=firecracker")
f" bot-bottle backend setup --backend=firecracker")
run_dir.mkdir(parents=True, exist_ok=True)
rootfs = run_dir / "rootfs.ext4"
@@ -214,7 +232,9 @@ def boot_vm(
else:
# Prebuilt artifact already carries the role's build slack; expand it to
# a fresh writable rootfs for this boot.
infra_artifact.materialize_ext4(_role_version(role), rootfs, role=role)
version, expected = _role_release(role)
infra_artifact.materialize_ext4(
version, rootfs, role=role, expected_sha256=expected)
private_key, pubkey = _stable_keypair()
info(f"booting {role} VM on {slot.iface} (guest {slot.guest_ip})")
@@ -264,7 +284,8 @@ def _version_file() -> Path:
def expected_version() -> str:
"""The combined marker for the running pair: both per-plane artifact
versions, so a change to either rootfs dislodges the adopted pair."""
return " ".join(f"{role}={_role_version(role)}" for role in infra_artifact.ROLES)
return " ".join(
f"{role}={_role_release(role)[0]}" for role in infra_artifact.ROLES)
def adoptable(key: Path, url: str, want: str) -> bool:
@@ -108,7 +108,7 @@ def verify_isolation(private_key: Path, guest_ip: str) -> None:
die(f"ISOLATION FAILURE: the VM reached the host canary "
f"{canary_ip}:{canary_port}. The egress boundary is not in "
f"force — refusing to run the agent (fail-closed). Verify the "
f"nft table with: ./cli.py backend setup --backend=firecracker")
f"nft table with: bot-bottle backend setup --backend=firecracker")
if result.returncode == 2:
die("isolation probe inconclusive: the guest has no python3/bash/nc "
"to run the connectivity test. Refusing to continue "
+16 -2
View File
@@ -228,8 +228,13 @@ def build_or_load_agent_base(plan: FirecrackerBottlePlan) -> Path:
info(f"resuming from committed rootfs {committed_tar}")
return util.build_committed_rootfs_dir(committed_tar)
dockerfile = Path(plan.dockerfile_path)
prebuilt = "@sha256:" in plan.image
if plan.spec.image_policy == "cached":
cached = image_builder.cached_agent_rootfs_dir(dockerfile)
cached = (
image_builder.cached_agent_image_rootfs_dir(plan.image)
if prebuilt
else image_builder.cached_agent_rootfs_dir(dockerfile)
)
if cached is None:
die(
f"cached agent rootfs for {plan.image!r} not found; "
@@ -237,6 +242,11 @@ def build_or_load_agent_base(plan: FirecrackerBottlePlan) -> Path:
)
info(f"using cached agent rootfs {cached.name}")
return cached
if prebuilt:
return image_builder.acquire_agent_image_rootfs_dir(
plan.image,
smoke_test=runtime_for(plan.agent_provider_template).smoke_test,
)
return image_builder.build_agent_rootfs_dir(
dockerfile,
image_tag=plan.image,
@@ -253,7 +263,11 @@ def stale_checks(plan: FirecrackerBottlePlan) -> None:
if committed and committed_tar.is_file():
check_stale_path(f"agent rootfs {committed_tar}", committed_tar)
return
cached = image_builder.cached_agent_rootfs_dir(Path(plan.dockerfile_path))
cached = (
image_builder.cached_agent_image_rootfs_dir(plan.image)
if "@sha256:" in plan.image
else image_builder.cached_agent_rootfs_dir(Path(plan.dockerfile_path))
)
if cached is not None:
check_stale_path(f"agent rootfs {cached}", cached / ".bb-ready")
+14 -6
View File
@@ -3,7 +3,7 @@ config renderers (shell command + NixOS module) shown to operators.
The Firecracker backend needs a privileged one-time network setup:
a pool of point-to-point TAP devices (owned by the invoking user, so
`./cli.py start` never needs root) and a dedicated nftables table that
`bot-bottle start` never needs root) and a dedicated nftables table that
isolates every VM. The pool parameters live in exactly one place
`netpool.defaults.env`, a plain KEY=VALUE file next to this module
and every consumer reads *that*: this module (below), the shell script
@@ -177,14 +177,20 @@ def gw_slot() -> Slot:
# --- fail-closed verification ---------------------------------------
def _run_ok(argv: list[str]) -> bool:
"""Run a probe command, treating a missing binary as failure
"""Run a probe command, treating an unavailable binary as failure
(rather than crashing) so callers can stay fail-closed."""
try:
return subprocess.run(
argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False,
).returncode == 0
except FileNotFoundError:
except OSError:
# Not only "missing". A name on PATH that isn't executable by this
# user raises PermissionError, and CPython reports that EACCES in
# preference to the ENOENT from the other PATH entries — which is
# how `doctor` came to die with a traceback on a fresh macOS
# account. Any OSError means the probe couldn't run, which for a
# fail-closed check is indistinguishable from "not present".
return False
@@ -244,7 +250,9 @@ def overlapping_routes() -> list[RouteConflict]:
["ip", "-json", "route", "show", "table", "all"],
capture_output=True, text=True, check=False,
)
except FileNotFoundError:
except OSError:
# Missing, or present-but-not-executable for this user; either way
# there are no routes we can enumerate. See _run_ok.
return []
if proc.returncode != 0 or not proc.stdout.strip():
return []
@@ -307,11 +315,11 @@ def allocate(slug: str) -> tuple[Slot, IO[str]]:
return s, handle
die(f"Firecracker TAP pool exhausted ({pool_size()} slots, all in "
f"use). Stop a running bottle or raise BOT_BOTTLE_FC_POOL_SIZE "
f"and re-run `./cli.py backend setup --backend=firecracker`.")
f"and re-run `bot-bottle backend setup --backend=firecracker`.")
raise AssertionError("unreachable")
# --- config renderers (shown by `./cli.py backend setup`) -----------
# --- config renderers (shown by `bot-bottle backend setup`) -----------
# The persistent unit is the portable install: the same systemd oneshot
# on every systemd distro (Debian/Ubuntu/Fedora/RHEL/Arch/…).
+5 -5
View File
@@ -8,7 +8,7 @@ bundled setup script. `status()` reports what's present, including
whether the pool range collides with an existing route.
Called through `FirecrackerBottleBackend.setup` / `.status`, which the
generic `./cli.py backend {setup,status}` command dispatches to.
generic `bot-bottle backend {setup,status}` command dispatches to.
"""
from __future__ import annotations
@@ -34,7 +34,7 @@ _UNIT_PATH = Path("/etc/systemd/system") / netpool.SYSTEMD_UNIT
def _owner() -> str:
# Under `sudo`, USER is root but SUDO_USER is the real invoker — the
# TAPs must be owned by them so `./cli.py start` stays rootless.
# TAPs must be owned by them so `bot-bottle start` stays rootless.
return os.environ.get("SUDO_USER") or os.environ.get("USER") or "youruser"
@@ -161,7 +161,7 @@ def _setup_systemd() -> None:
if rc == 0:
sys.stderr.write(
f"Installed and started {netpool.SYSTEMD_UNIT}. Verify with "
f"`./cli.py backend status --backend=firecracker`.\n"
f"`bot-bottle backend status --backend=firecracker`.\n"
)
else:
sys.stderr.write(
@@ -181,7 +181,7 @@ def _setup_systemd() -> None:
)
sys.stderr.write(
f"\n(Or re-run this as root to install it directly: "
f"sudo ./cli.py backend setup --backend=firecracker)\n"
f"sudo bot-bottle backend setup --backend=firecracker)\n"
)
@@ -334,7 +334,7 @@ def status() -> int:
sys.stderr.write(f"range overlap: none (base {netpool.ip_base()})\n")
_report_persistence()
if not ok:
sys.stderr.write("\nRun: ./cli.py backend setup --backend=firecracker\n")
sys.stderr.write("\nRun: bot-bottle backend setup --backend=firecracker\n")
return 0 if ok else 1
+4 -4
View File
@@ -9,7 +9,7 @@ generation.
The privileged network setup (TAP pool + nft table) is a one-time
operator step see `netpool.py`, `scripts/firecracker-netpool.sh`,
and `./cli.py backend setup --backend=firecracker`.
and `bot-bottle backend setup --backend=firecracker`.
"""
from __future__ import annotations
@@ -132,18 +132,18 @@ def _require_network_pool() -> None:
f"{netpool.pool_size()} slots) overlaps existing routes: "
f"{detail}. This can shadow or be shadowed by that route; "
f"set BOT_BOTTLE_FC_IP_BASE to a free range and re-run "
f"./cli.py backend setup --backend=firecracker.")
f"bot-bottle backend setup --backend=firecracker.")
missing = netpool.missing_taps()
if missing:
die(f"network pool incomplete — missing TAP devices: "
f"{', '.join(missing)}.\n ./cli.py backend setup --backend=firecracker")
f"{', '.join(missing)}.\n bot-bottle backend setup --backend=firecracker")
if shutil.which("nft") is not None and not netpool.nft_table_present():
# nft is queryable and says the table is absent — that's a
# definite, catchable misconfiguration; fail early.
warn(f"isolation table `inet {netpool.NFT_TABLE}` not found via nft. "
"If this is a permissions issue it will be re-checked "
"empirically after boot; otherwise run: "
"./cli.py backend setup --backend=firecracker")
"bot-bottle backend setup --backend=firecracker")
# --- rootfs pipeline (rootless) -------------------------------------
+2 -2
View File
@@ -43,7 +43,7 @@ class Freezer(ABC):
Calls _freeze for the backend-specific snapshot, then writes the
committed image reference to per-bottle state and marks the bottle
preserved so the next `./cli.py resume` boots from the snapshot.
preserved so the next `bot-bottle resume` boots from the snapshot.
Raises CommitCancelled if the user declines an interactive
confirmation prompt (e.g. the macos-container stop prompt).
@@ -51,7 +51,7 @@ class Freezer(ABC):
image_ref = self._freeze(agent)
write_committed_image(agent.slug, image_ref)
mark_preserved(agent.slug)
info(f"to resume from this snapshot: ./cli.py resume {agent.slug}")
info(f"to resume from this snapshot: bot-bottle resume {agent.slug}")
self._export_hint(agent.slug, image_ref)
@abstractmethod
@@ -25,3 +25,11 @@ class MacosContainerBottleCleanupPlan(BottleCleanupPlan):
@property
def empty(self) -> bool:
return not self.containers and not self.networks
def intersect(self, current: BottleCleanupPlan) -> "MacosContainerBottleCleanupPlan":
if not isinstance(current, MacosContainerBottleCleanupPlan):
raise TypeError("cleanup plans must have the same backend type")
return MacosContainerBottleCleanupPlan(
containers=tuple(x for x in self.containers if x in current.containers),
networks=tuple(x for x in self.networks if x in current.networks),
)
@@ -5,6 +5,7 @@ from __future__ import annotations
import subprocess
from .. import EnumerationError
from ..cleanup_control import CleanupFailures
from ...log import info
from . import util as container_mod
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
@@ -53,19 +54,17 @@ def prepare_cleanup() -> MacosContainerBottleCleanupPlan:
def cleanup(plan: MacosContainerBottleCleanupPlan) -> None:
failures = CleanupFailures()
for name in plan.containers:
info(f"container delete --force {name}")
subprocess.run(
failures.run(
["container", "delete", "--force", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
f"deleting container {name}",
)
for name in plan.networks:
info(f"container network delete {name}")
subprocess.run(
failures.run(
["container", "network", "delete", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
f"deleting network {name}",
)
failures.raise_if_any()
@@ -84,6 +84,7 @@ class MacosGateway(Gateway):
egress_network: str = GATEWAY_EGRESS_NETWORK,
control_network: str = CONTROL_NETWORK,
repo_root: Path | None = None,
local_build: bool = True,
) -> None:
self.image_ref = image_ref
self.name = name
@@ -93,6 +94,7 @@ class MacosGateway(Gateway):
# Build context: the repo root in a checkout, a staged copy from the
# installed wheel otherwise (bot_bottle.resources).
self._repo_root = repo_root if repo_root is not None else resources.build_root()
self._local_build = local_build
# Set by `connect_to_orchestrator`: the URL the daemons resolve policy
# against + the pre-minted `gateway` token they present. The gateway
# never mints, so it never holds the signing key (#469).
@@ -101,8 +103,11 @@ class MacosGateway(Gateway):
def ensure_built(self) -> None:
"""Build the data-plane image from `Dockerfile.gateway`."""
container_mod.build_image(
self.image_ref, str(self._repo_root), dockerfile="Dockerfile.gateway")
if self._local_build:
container_mod.build_image(
self.image_ref, str(self._repo_root), dockerfile="Dockerfile.gateway")
else:
container_mod.pull_image(self.image_ref)
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None:
"""Bind the gateway to this orchestrator and (re)start it, dual-homed on
+13 -2
View File
@@ -25,6 +25,7 @@ from __future__ import annotations
from pathlib import Path
from ... import resources
from ... import release_manifest
from ...orchestrator.lifecycle import (
DEFAULT_PORT,
DEFAULT_STARTUP_TIMEOUT_SECONDS,
@@ -86,6 +87,14 @@ class MacosInfraService(InfraService):
self._orchestrator_name = orchestrator_name
self._gateway_name = gateway_name
self._db_volume = db_volume
resolved_orchestrator, orchestrator_local = release_manifest.oci_image(
"orchestrator", orchestrator_image)
resolved_gateway, gateway_local = release_manifest.oci_image(
"gateway", gateway_image)
self.orchestrator_image = resolved_orchestrator
self.gateway_image = resolved_gateway
self._orchestrator_local = orchestrator_local
self._gateway_local = gateway_local
def orchestrator(self) -> MacosOrchestrator:
"""The control-plane service on the host-only control network. Cheap to
@@ -98,6 +107,7 @@ class MacosInfraService(InfraService):
control_network=self.control_network,
repo_root=self._repo_root,
db_volume=self._db_volume,
local_build=self._orchestrator_local,
)
def gateway(self) -> MacosGateway:
@@ -112,6 +122,7 @@ class MacosInfraService(InfraService):
egress_network=self.egress_network,
control_network=self.control_network,
repo_root=self._repo_root,
local_build=self._gateway_local,
)
def ensure_running(
@@ -127,8 +138,8 @@ class MacosInfraService(InfraService):
orchestrator = self.orchestrator()
gateway = self.gateway()
orchestrator.ensure_built()
gateway.ensure_built()
orchestrator.ensure_available()
gateway.ensure_available()
orchestrator.ensure_running(startup_timeout=startup_timeout)
+9 -1
View File
@@ -38,6 +38,7 @@ import subprocess
from contextlib import ExitStack, contextmanager
from typing import Callable, Generator
from ...agent_provider import runtime_for
from ...bottle_state import (
egress_state_dir,
git_gate_state_dir,
@@ -93,7 +94,14 @@ def _agent_image(plan: MacosContainerBottlePlan) -> str:
)
info(f"using cached agent image {plan.image!r}")
return plan.image
container_mod.build_image(plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
if "@sha256:" in plan.image:
container_mod.pull_image(plan.image)
container_mod.verify_agent_image(
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
)
return plan.image
container_mod.build_image(
plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
return plan.image
@@ -63,6 +63,7 @@ class MacosOrchestrator(Orchestrator):
control_network: str = CONTROL_NETWORK,
repo_root: Path | None = None,
db_volume: str = ORCHESTRATOR_DB_VOLUME,
local_build: bool = True,
) -> None:
self.image_ref = image_ref
self.name = name
@@ -73,6 +74,7 @@ class MacosOrchestrator(Orchestrator):
# staged copy from the installed wheel otherwise (bot_bottle.resources).
self._repo_root = repo_root if repo_root is not None else resources.build_root()
self._db_volume = db_volume
self._local_build = local_build
def url(self) -> str:
"""The orchestrator's control-network address (host CLI + registration),
@@ -114,8 +116,12 @@ class MacosOrchestrator(Orchestrator):
"""Build the control-plane image. The source is bind-mounted so a code
change takes effect without a rebuild; the image still carries the
package for its entrypoint."""
container_mod.build_image(
self.image_ref, str(self._repo_root), dockerfile="Dockerfile.orchestrator")
if self._local_build:
container_mod.build_image(
self.image_ref, str(self._repo_root),
dockerfile="Dockerfile.orchestrator")
else:
container_mod.pull_image(self.image_ref)
def ensure_running(
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
@@ -146,11 +152,6 @@ class MacosOrchestrator(Orchestrator):
"--dns", container_mod.dns_server(),
# Container-only DB volume: exactly one kernel writes bot-bottle.db.
"--volume", f"{self._db_volume}:{_DB_ROOT_IN_CONTAINER}",
# Live control-plane source (a code change takes effect on relaunch).
"--mount",
container_mod.bind_mount_spec(
str(self._repo_root), _SRC_IN_CONTAINER, readonly=True),
"--env", f"PYTHONPATH={_SRC_IN_CONTAINER}",
"--env", f"BOT_BOTTLE_ROOT={_DB_ROOT_IN_CONTAINER}",
# Detect a real control-plane code change and recreate.
"--env", f"BOT_BOTTLE_SOURCE_HASH={current_hash}",
@@ -161,6 +162,14 @@ class MacosOrchestrator(Orchestrator):
# Dockerfile.orchestrator ENTRYPOINT is `-m bot_bottle.orchestrator`.
"--host", "0.0.0.0", "--port", str(self.port), "--broker", "stub",
]
if self._local_build:
image_index = argv.index(self.image_ref)
argv[image_index:image_index] = [
"--mount",
container_mod.bind_mount_spec(
str(self._repo_root), _SRC_IN_CONTAINER, readonly=True),
"--env", f"PYTHONPATH={_SRC_IN_CONTAINER}",
]
result = container_mod.run_container_argv(
argv, env={**os.environ, ORCHESTRATOR_TOKEN_ENV: _signing_key})
if result.returncode != 0:
+2 -2
View File
@@ -5,7 +5,7 @@ Like Docker, this backend needs no privileged network-pool provisioning
running. `setup()` points at the install/`container system start` steps;
`status()` reports readiness. Reached via
`MacosContainerBottleBackend.setup` / `.status`, dispatched from the
generic `./cli.py backend {setup,status}`.
generic `bot-bottle backend {setup,status}`.
"""
from __future__ import annotations
@@ -75,5 +75,5 @@ def status() -> int:
if not _service_running():
ok = False
if not ok:
sys.stderr.write("\nRun: ./cli.py backend setup --backend=macos-container\n")
sys.stderr.write("\nRun: bot-bottle backend setup --backend=macos-container\n")
return 0 if ok else 1
+22 -2
View File
@@ -101,6 +101,19 @@ def build_image(
subprocess.run(args, check=True)
def pull_image(ref: str) -> None:
"""Acquire an immutable OCI reference through Apple Container."""
result = subprocess.run(
[_CONTAINER, "image", "pull", ref],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
detail = (result.stderr or result.stdout or "").strip()
raise RuntimeError(f"container image pull failed for {ref}: {detail}")
def verify_agent_image(image: str, argv: tuple[str, ...]) -> None:
"""Run `argv` inside a throwaway container of a freshly built agent
image and die loudly if it fails, instead of shipping an image
@@ -689,6 +702,13 @@ def pinned_local_image_ref(ref: str) -> str:
nested-containers layer. A content-derived tag prevents another concurrent
build from moving the provider's ordinary ``:latest`` tag between those
two builds.
Unlike Docker, `container image tag` only accepts ``image-name[:tag]`` as
its source and rejects a bare image ID ("cannot specify 64 byte hex string
as reference"), so the mutable ``ref`` is what gets tagged here. The
post-tag inspection below is what keeps that fail-closed: if ``ref`` moved
between the two commands, the new tag will not resolve to the ID this call
derived its name from.
"""
image = image_id(ref)
digest = image.removeprefix("sha256:")
@@ -701,14 +721,14 @@ def pinned_local_image_ref(ref: str) -> str:
repository = repository[:last_colon]
pinned_ref = f"{repository}:sha256-{digest}"
result = subprocess.run(
[_CONTAINER, "image", "tag", image, pinned_ref],
[_CONTAINER, "image", "tag", ref, pinned_ref],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
die(
f"could not tag exact local image {image}: "
f"could not tag exact local image {image} via {ref!r}: "
f"{(result.stderr or result.stdout or '').strip() or '<no detail>'}"
)
if image_id(pinned_ref) != image:
+7 -1
View File
@@ -9,7 +9,7 @@ backend-specific final resolution.
from __future__ import annotations
import os
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Protocol
from ..agent_provider import AgentProvisionPlan, build_agent_provision_plan, get_provider
@@ -17,6 +17,7 @@ from ..egress import EgressPlan
from ..env import ResolvedEnv, resolve_env
from ..git_gate import GitGate, GitGatePlan
from ..manifest import Manifest
from ..release_manifest import oci_image
from ..supervisor.plan import SupervisePlan
from ..workspace import workspace_plan
from .resolve_common import (
@@ -112,6 +113,11 @@ class BottlePreparationPlanner:
color=spec.color,
provider_settings=provider_config.settings,
)
if not provider_config.dockerfile:
image, local = oci_image(
f"agent_{provider_config.template}", provision.image)
if not local:
provision = replace(provision, image=image)
provision = merge_provision_env_vars(provision)
return PreparedBottle(
manifest=manifest,
+13 -2
View File
@@ -63,12 +63,23 @@ def provision_git_gate(
transport.exec(["chmod", "+x", "/etc/git-gate/access-hook"])
creds = _creds_dir(bottle_id)
transport.exec(["mkdir", "-p", creds])
transport.exec(["chmod", "700", creds])
credential_paths: list[str] = []
for u in plan.upstreams:
if u.identity_file:
transport.cp_into(u.identity_file, f"{creds}/{u.name}-key")
key_path = f"{creds}/{u.name}-key"
transport.cp_into(u.identity_file, key_path)
credential_paths.append(key_path)
known_hosts = str(u.known_hosts_file)
if known_hosts and known_hosts != ".":
transport.cp_into(known_hosts, f"{creds}/{u.name}-known_hosts")
known_hosts_path = f"{creds}/{u.name}-known_hosts"
transport.cp_into(known_hosts, known_hosts_path)
credential_paths.append(known_hosts_path)
# Copy-mode behavior differs across Docker, Apple Container, and SSH.
# Apply the security contract inside the gateway so every backend produces
# the same private credential namespace.
if credential_paths:
transport.exec(["chmod", "600", *credential_paths])
# Init the bare repos + per-repo credential config for this namespace.
script = git_gate_render_provision(bottle_id, plan.upstreams)
transport.exec(["sh", "-c", script])
+1 -1
View File
@@ -86,7 +86,7 @@ def _print_vm_install_instructions() -> None:
info("Then start the service: container system start")
else:
info("Install Firecracker: https://github.com/firecracker-microvm/firecracker/releases")
info("Configure the host: ./cli.py backend setup")
info("Configure the host: bot-bottle backend setup")
def _auto_select_backend(prompt: bool = True) -> str:
+3 -3
View File
@@ -1,9 +1,9 @@
"""`backend` CLI command — generic host setup/status across backends.
`./cli.py backend setup [--backend=NAME]` provisions (or points at how
`bot-bottle backend setup [--backend=NAME]` provisions (or points at how
to provision) the chosen backend's one-time host prerequisites.
`./cli.py backend status [--backend=NAME]` reports readiness.
`./cli.py backend teardown [--backend=NAME]` undoes setup (uninstall).
`bot-bottle backend status [--backend=NAME]` reports readiness.
`bot-bottle backend teardown [--backend=NAME]` undoes setup (uninstall).
All dispatch to the backend's `setup()` / `status()` / `teardown()`
classmethods, so there are no backend-specific commands swapping
+13 -6
View File
@@ -1,7 +1,7 @@
"""cleanup: stop and remove all orphaned bot-bottle resources.
Walks every registered backend (docker, firecracker, macos-container)
so a single `./cli.py cleanup` reaps every backend's leftovers — a
so a single `bot-bottle cleanup` reaps every backend's leftovers — a
firecracker bottle's VM processes and run dirs won't survive a
docker-only cleanup pass (issue addressed alongside #77).
@@ -22,6 +22,7 @@ from __future__ import annotations
import sys
from ...backend import get_bottle_backend, has_backend, known_backend_names
from ...backend.cleanup_control import CleanupError
from ...log import info
from ...util import read_tty_line
@@ -54,12 +55,18 @@ def cmd_cleanup(_argv: list[str]) -> int:
# Confirmation authorizes a fresh authoritative snapshot, not blind use of
# identities that may have changed while the operator reviewed the preview.
refreshed = [(name, backend, backend.prepare_cleanup())
for name, backend, _plan in prepared]
for name, backend, plan in refreshed:
if plan.empty:
failures: list[str] = []
for name, backend, displayed in prepared:
current = backend.prepare_cleanup()
approved = displayed.intersect(current)
if approved.empty:
continue
backend.cleanup(plan)
try:
backend.cleanup(approved)
except CleanupError as exc:
failures.append(f"{name}: {exc}")
if failures:
raise CleanupError("cleanup incomplete: " + "; ".join(failures))
info("cleanup: done")
return 0
+2 -2
View File
@@ -4,7 +4,7 @@ Docker bottles are committed to a local Docker image. Macos-container
bottles are exported and rebuilt as a local Apple Container image.
Firecracker bottles stream the guest rootfs out over SSH and rebuild a
local Docker image. The resulting reference is stored in per-bottle
state so the next `./cli.py resume <slug>` boots from the snapshot
state so the next `bot-bottle resume <slug>` boots from the snapshot
instead of rebuilding from the Dockerfile.
"""
@@ -37,7 +37,7 @@ def cmd_commit(argv: list[str]) -> int:
if slug is None:
active = enumerate_active_agents()
if not active:
die("no active bottles; start one with `./cli.py start`")
die("no active bottles; start one with `bot-bottle start`")
choices = [a.slug for a in active]
slug = tui.filter_select(choices, title="Select bottle to commit")
if slug is None:
+1 -1
View File
@@ -8,7 +8,7 @@ override and transcript snapshot under the same state dir.
Use case: an interrupted or preserved bottle needs to be relaunched;
the operator runs
./cli.py resume <identity>
bot-bottle resume <identity>
to bring up the replacement from the recorded state.
"""
+6 -6
View File
@@ -203,19 +203,19 @@ def _start_headless(
if not os.isatty(stdin_fd):
die(
"--headless requires a PTY on stdin; run via:\n"
" script -q /dev/null ./cli.py start ..."
" script -q /dev/null bot-bottle start ..."
)
agent_name = args.name
if not agent_name:
die("--headless requires an agent name: ./cli.py start <agent> --headless")
die("--headless requires an agent name: bot-bottle start <agent> --headless")
manifest.require_agent(agent_name) # raises ManifestError if unknown
prompt = args.prompt
if not prompt:
die(
"--headless requires --prompt: "
"./cli.py start <agent> --headless --prompt 'Do the thing'"
"bot-bottle start <agent> --headless --prompt 'Do the thing'"
)
if args.bottle:
@@ -319,9 +319,9 @@ def attach_agent(
`resume=True` adds `--continue` so claude picks up its most
recent session non-interactively (no session-picker prompt).
First-attach paths (`./cli.py start`) leave it False.
First-attach paths (`bot-bottle start`) leave it False.
Used as the inner step of `./cli.py start`."""
Used as the inner step of `bot-bottle start`."""
runtime = runtime_for(agent_provider_template)
info(
f"attaching interactive {agent_provider_template} session "
@@ -354,7 +354,7 @@ def settle_state(identity: str) -> None:
if not identity:
return
if is_preserved(identity):
info(f"to resume this bottle: ./cli.py resume {identity}")
info(f"to resume this bottle: bot-bottle resume {identity}")
return
cleanup_state(identity)
+1 -1
View File
@@ -110,7 +110,7 @@ def discover_pending() -> list[QueuedProposal]:
def _approval_status(qp: QueuedProposal, verb: str) -> str:
"""Status-line text after a successful approval."""
base = f"{verb} {qp.proposal.tool} for [{qp.label}]"
return f"{base}; resume: ./cli.py resume {qp.label}"
return f"{base}; resume: bot-bottle resume {qp.label}"
def _detail_lines(
+5 -3
View File
@@ -115,10 +115,12 @@ class Gateway(abc.ABC):
name: str
def ensure_available(self) -> None:
"""Acquire the exact image/rootfs selected for this application."""
self.ensure_built()
def ensure_built(self) -> None:
"""Ensure the gateway's image / rootfs exists, building it if needed.
Default: nothing to build (e.g. a stub or a pre-pulled image). Call
before `connect_to_orchestrator`."""
"""Local-build implementation hook retained for backend compatibility."""
return
@abc.abstractmethod
+32 -4
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import http.server
import io
import socket
import threading
import time
@@ -14,6 +15,10 @@ class Readable(Protocol):
def read(self, size: int = -1, /) -> bytes: ...
class Writable(Protocol):
def write(self, data: bytes, /) -> object: ...
@dataclass(frozen=True)
class BodyReadError(Exception):
status: int
@@ -30,6 +35,25 @@ def read_declared_body(
require_length: bool,
) -> bytes:
"""Validate and read exactly one declared body under a read deadline."""
output = io.BytesIO()
copy_declared_body(
stream, output, connection, raw_length, maximum=maximum,
timeout_seconds=timeout_seconds, require_length=require_length,
)
return output.getvalue()
def copy_declared_body(
stream: Readable,
output: Writable,
connection: socket.socket,
raw_length: str | None,
*,
maximum: int,
timeout_seconds: float,
require_length: bool,
) -> int:
"""Copy one declared body to a sink without retaining it in memory."""
if raw_length is None:
if require_length:
raise BodyReadError(411, "Content-Length required")
@@ -44,7 +68,6 @@ def read_declared_body(
raise BodyReadError(413, "request body too large")
previous_timeout = connection.gettimeout()
deadline = time.monotonic() + timeout_seconds
chunks: list[bytes] = []
remaining = length
try:
while remaining:
@@ -55,13 +78,13 @@ def read_declared_body(
chunk = stream.read(min(remaining, 64 * 1024))
if not chunk:
raise BodyReadError(400, "incomplete request body")
chunks.append(chunk)
output.write(chunk)
remaining -= len(chunk)
except TimeoutError as exc:
raise BodyReadError(408, "request body read timed out") from exc
finally:
connection.settimeout(previous_timeout)
return b"".join(chunks)
return length
class BoundedThreadingHTTPServer(http.server.ThreadingHTTPServer):
@@ -106,4 +129,9 @@ class BoundedThreadingHTTPServer(http.server.ThreadingHTTPServer):
self._request_slots.release()
__all__ = ["BodyReadError", "BoundedThreadingHTTPServer", "read_declared_body"]
__all__ = [
"BodyReadError",
"BoundedThreadingHTTPServer",
"copy_declared_body",
"read_declared_body",
]
+38 -20
View File
@@ -21,6 +21,8 @@ from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import threading
import typing
from http.server import BaseHTTPRequestHandler
from pathlib import Path
@@ -30,7 +32,7 @@ from bot_bottle.constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER
from bot_bottle.gateway.bounded_http import (
BodyReadError,
BoundedThreadingHTTPServer,
read_declared_body,
copy_declared_body,
)
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
@@ -84,6 +86,8 @@ def resolve_sandbox_root(
MAX_BODY_BYTES = 100 * 1024 * 1024
REQUEST_BODY_TIMEOUT_SECONDS = 30.0
MAX_REQUEST_WORKERS = 16
MAX_BODY_WORKERS = 2
_BODY_WORK_SLOTS = threading.BoundedSemaphore(MAX_BODY_WORKERS)
class GitHttpHandler(BaseHTTPRequestHandler):
@@ -191,26 +195,40 @@ class GitHttpHandler(BaseHTTPRequestHandler):
value = self.headers.get(header)
if value:
env[variable] = value
try:
body = read_declared_body(
self.rfile,
self.connection,
self.headers.get("content-length"),
maximum=MAX_BODY_BYTES,
timeout_seconds=REQUEST_BODY_TIMEOUT_SECONDS,
require_length=False,
)
except BodyReadError as exc:
self.send_error(exc.status, exc.message)
if not _BODY_WORK_SLOTS.acquire(blocking=False):
self.send_error(503, "git request capacity exhausted")
return
proc = subprocess.run(
["git", "http-backend"],
input=body,
env=env,
capture_output=True,
check=False,
timeout=GIT_GATE_TIMEOUT_SECS,
)
try:
with tempfile.TemporaryFile() as body:
try:
copy_declared_body(
self.rfile,
body,
self.connection,
self.headers.get("content-length"),
maximum=MAX_BODY_BYTES,
timeout_seconds=REQUEST_BODY_TIMEOUT_SECONDS,
require_length=False,
)
except BodyReadError as exc:
self.send_error(exc.status, exc.message)
return
body.seek(0)
try:
proc = subprocess.run(
["git", "http-backend"],
stdin=body,
env=env,
capture_output=True,
check=False,
timeout=GIT_GATE_TIMEOUT_SECS,
)
except (OSError, subprocess.SubprocessError) as exc:
self.log_message("git http-backend unavailable: %s", exc)
self.send_error(503, "git backend unavailable")
return
finally:
_BODY_WORK_SLOTS.release()
self._write_cgi_response(proc.stdout)
def _repo_dir(self, sandbox_root: Path, path: str) -> Path | None:
+1 -1
View File
@@ -385,7 +385,7 @@ PY
;;
esac
echo "git-gate: queued # gitleaks:allow supervisor approval $proposal_id" >&2
echo "git-gate: approve with './cli.py supervise' to continue this push" >&2
echo "git-gate: approve with 'bot-bottle supervise' to continue this push" >&2
waited=0
while [ "$waited" -lt "$timeout" ]; do
status=$(PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" python3 - "$proposal_id" <<'PY'
+14
View File
@@ -7,6 +7,7 @@ import asyncio
import math
import sys
from fastapi import FastAPI, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, StrictStr
from starlette.types import ASGIApp, Message, Receive, Scope, Send
@@ -208,6 +209,19 @@ def create_app(orch: OrchestratorCore, *, signing_key: str) -> FastAPI:
)
app.add_middleware(ControlPlaneBoundary, signing_key=key)
@app.exception_handler(RequestValidationError)
async def invalid_request(
_request: object, exc: RequestValidationError,
) -> JSONResponse:
errors = exc.errors()
location = errors[0].get("loc", ()) if errors else ()
field = str(location[1]) if len(location) > 1 else ""
suffix = f": {field}" if field else ""
return JSONResponse(
{"error": f"invalid request body{suffix}"},
status_code=400,
)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
+5 -3
View File
@@ -63,10 +63,12 @@ class Orchestrator(abc.ABC):
# orchestrator never starts without its signing key.
provisioning: ControlPlaneProvisioning = ControlPlaneProvisioning()
def ensure_available(self) -> None:
"""Acquire the exact image/rootfs selected for this application."""
self.ensure_built()
def ensure_built(self) -> None:
"""Ensure the orchestrator's image / rootfs exists, building it if
needed. Default: nothing to build (e.g. a pre-pulled image). Call before
`ensure_running`."""
"""Local-build implementation hook retained for backend compatibility."""
return
@abc.abstractmethod
+1 -7
View File
@@ -366,7 +366,7 @@ class OrchestratorCore:
value with *env_var_secret*, and restores ``_tokens[bottle_id]``.
Returns True on success, False when no stored secrets exist for this
bottle or decryption fails (wrong key / corrupt data)."""
from .store.secret_store import decrypt_value, encrypt_value, is_legacy_blob
from .store.secret_store import decrypt_value
encrypted = self.registry.get_agent_secrets(bottle_id)
if not encrypted:
return False
@@ -377,12 +377,6 @@ class OrchestratorCore:
except ValueError:
return False
self._tokens[bottle_id] = decrypted
if any(is_legacy_blob(value) for value in encrypted.values()):
migrated = {
key: encrypt_value(env_var_secret, value)
for key, value in decrypted.items()
}
self.registry.store_agent_secrets(bottle_id, migrated)
return True
# --- consolidated gateway ----------------------------------------------
@@ -129,6 +129,10 @@ _MIGRATIONS = TableMigrations(
# v5 — index for fast per-bottle lookups and bulk DELETE on teardown.
"CREATE INDEX IF NOT EXISTS idx_bottled_agent_secrets_id "
"ON bottled_agent_secrets (bottled_agent_id, type)",
# v6 — unauthenticated legacy ciphertext must never be selected by
# attacker-controlled blob contents. Existing local agents are
# intentionally reprovisioned instead of retaining downgrade support.
"DELETE FROM bottled_agent_secrets",
],
)
+5 -32
View File
@@ -18,9 +18,9 @@ value is encrypted independently. New output blobs are:
``version || nonce (16 bytes) || ciphertext || tag (32 bytes)``
encoded as URL-safe base64 (no padding). The version marker lets the reader
accept legacy ``nonce || ciphertext`` rows long enough to rewrite them in the
authenticated format after a successful reprovision.
encoded as URL-safe base64 (no padding). Unversioned legacy ciphertext is
rejected; the registry migration clears those rows rather than allowing blob
contents to select an unauthenticated decoder.
keystream_block_i = HMAC-SHA256(key, nonce || i.to_bytes(4, "big"))
ciphertext_i = plaintext_i XOR keystream_block_i[:len(plaintext_i)]
@@ -84,44 +84,18 @@ def encrypt_value(secret_b64: str, plaintext: str) -> str:
return base64.urlsafe_b64encode(authenticated + tag).rstrip(b"=").decode()
def is_legacy_blob(blob_b64: str) -> bool:
"""Whether *blob_b64* uses the pre-authentication storage format."""
try:
return not _b64dec(blob_b64).startswith(_VERSION)
except (ValueError, TypeError):
return False
def _decrypt_legacy(key: bytes, blob: bytes) -> str:
"""Read the original ``nonce || ciphertext`` format for migration only."""
if len(blob) < _NONCE_BYTES:
raise ValueError("ciphertext blob too short")
nonce, ciphertext = blob[:_NONCE_BYTES], blob[_NONCE_BYTES:]
pt = bytearray()
# The legacy format used the byte offset as the PRF counter.
for i in range(0, len(ciphertext), _BLOCK):
chunk = ciphertext[i : i + _BLOCK]
ks = _keystream(key, nonce, i)[: len(chunk)]
pt.extend(c ^ k for c, k in zip(chunk, ks))
try:
return bytes(pt).decode()
except UnicodeDecodeError as exc:
raise ValueError(f"decryption produced non-UTF-8 output: {exc}") from exc
def decrypt_value(secret_b64: str, blob_b64: str) -> str:
"""Decrypt a blob produced by :func:`encrypt_value`.
Returns the original plaintext string. Raises ``ValueError`` for malformed
input, authentication failure, or a key mismatch. Legacy unauthenticated
rows remain readable so callers can migrate them immediately."""
input, authentication failure, or a key mismatch."""
key = _b64dec(secret_b64)
try:
blob = _b64dec(blob_b64)
except (ValueError, TypeError) as exc:
raise ValueError(f"invalid ciphertext blob: {exc}") from exc
if not blob.startswith(_VERSION):
return _decrypt_legacy(key, blob)
raise ValueError("unsupported ciphertext format")
minimum = len(_VERSION) + _NONCE_BYTES + _TAG_BYTES
if len(blob) < minimum:
raise ValueError("ciphertext blob too short")
@@ -152,5 +126,4 @@ __all__ = [
"new_env_var_secret",
"encrypt_value",
"decrypt_value",
"is_legacy_blob",
]
+1
View File
@@ -0,0 +1 @@
{"schema": 1, "development": true}
+127
View File
@@ -0,0 +1,127 @@
"""External, commit-addressed release bundle metadata."""
from __future__ import annotations
import hashlib
import json
import re
from pathlib import Path
from typing import Any
from .release_manifest import ReleaseManifestError, parse_manifest
_COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
_SHA_RE = re.compile(r"^[0-9a-f]{64}$")
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def build_bundle_index(
manifest_data: Any,
*,
wheel: Path,
wheel_url: str,
workflow_run: str,
published_at: str,
) -> dict[str, Any]:
"""Build and validate the immutable external index for one source commit."""
manifest = parse_manifest(manifest_data)
if not wheel.is_file() or wheel.suffix != ".whl":
raise ReleaseManifestError("release bundle wheel must be one .whl file")
if not wheel_url.startswith("https://") or not wheel_url.endswith(wheel.name):
raise ReleaseManifestError(
"release bundle wheel URL must be HTTPS and end with the wheel filename")
if not workflow_run.strip() or not published_at.strip():
raise ReleaseManifestError(
"release bundle provenance requires workflow_run and published_at")
return {
"schema": 1,
"source_commit": manifest.source_commit,
"wheel": {
"filename": wheel.name,
"url": wheel_url,
"sha256": sha256_file(wheel),
},
"oci": {
"orchestrator": manifest.orchestrator_image,
"gateway": manifest.gateway_image,
**{
f"agent_{role}": reference
for role, reference in manifest.agent_images.items()
},
},
"firecracker": {
"orchestrator": {
"version": manifest.firecracker_orchestrator.version,
"sha256": manifest.firecracker_orchestrator.sha256,
},
"gateway": {
"version": manifest.firecracker_gateway.version,
"sha256": manifest.firecracker_gateway.sha256,
},
},
"provenance": {
"workflow_run": workflow_run.strip(),
"published_at": published_at.strip(),
},
"qualifications": [],
}
def parse_bundle_index(data: Any) -> dict[str, Any]:
"""Validate an index before publication or installer consumption."""
if not isinstance(data, dict) or data.get("schema") != 1:
raise ReleaseManifestError("release bundle index must use schema 1")
commit = data.get("source_commit")
if not isinstance(commit, str) or _COMMIT_RE.fullmatch(commit) is None:
raise ReleaseManifestError(
"release bundle source_commit must be 40 lowercase hex")
wheel = data.get("wheel")
if not isinstance(wheel, dict):
raise ReleaseManifestError("release bundle wheel must be an object")
filename, url, digest = (
wheel.get("filename"), wheel.get("url"), wheel.get("sha256"))
if not isinstance(filename, str) or not filename.endswith(".whl"):
raise ReleaseManifestError("release bundle wheel filename must end in .whl")
if not isinstance(url, str) or not url.startswith("https://") or not url.endswith(filename):
raise ReleaseManifestError(
"release bundle wheel URL must be HTTPS and match its filename")
if not isinstance(digest, str) or _SHA_RE.fullmatch(digest) is None:
raise ReleaseManifestError(
"release bundle wheel sha256 must be 64 lowercase hex")
manifest_data = {
"schema": 1,
"source_commit": commit,
"oci": data.get("oci"),
"firecracker": data.get("firecracker"),
}
parse_manifest(manifest_data)
provenance = data.get("provenance")
if not isinstance(provenance, dict) or not all(
isinstance(provenance.get(key), str) and provenance[key].strip()
for key in ("workflow_run", "published_at")
):
raise ReleaseManifestError("release bundle provenance is incomplete")
qualifications = data.get("qualifications")
if not isinstance(qualifications, list):
raise ReleaseManifestError("release bundle qualifications must be an array")
return data
def canonical_bytes(data: Any) -> bytes:
parse_bundle_index(data)
return (json.dumps(data, indent=2, sort_keys=True) + "\n").encode("utf-8")
__all__ = [
"build_bundle_index",
"canonical_bytes",
"parse_bundle_index",
"sha256_file",
]
+154
View File
@@ -0,0 +1,154 @@
"""Immutable infrastructure identities assigned when bot-bottle is packaged."""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from . import resources
_MANIFEST_NAME = "release-manifest.json"
_OCI_RE = re.compile(r"^[^@\s]+@sha256:([0-9a-f]{64})$")
_SHA_RE = re.compile(r"^[0-9a-f]{64}$")
_COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
class ReleaseManifestError(RuntimeError):
"""The installed package has no usable infrastructure release identity."""
@dataclass(frozen=True)
class FirecrackerArtifact:
version: str
sha256: str
@dataclass(frozen=True)
class ReleaseManifest:
source_commit: str
orchestrator_image: str
gateway_image: str
agent_images: dict[str, str]
firecracker_orchestrator: FirecrackerArtifact
firecracker_gateway: FirecrackerArtifact
def manifest_path() -> Path:
override = os.environ.get("BOT_BOTTLE_RELEASE_MANIFEST", "").strip()
return Path(override) if override else Path(__file__).resolve().parent / _MANIFEST_NAME
def _artifact(value: Any, role: str) -> FirecrackerArtifact:
if not isinstance(value, dict):
raise ReleaseManifestError(f"release manifest firecracker.{role} must be an object")
version = value.get("version")
sha256 = value.get("sha256")
if not isinstance(version, str) or not version.strip():
raise ReleaseManifestError(
f"release manifest firecracker.{role}.version must be non-empty")
if not isinstance(sha256, str) or _SHA_RE.fullmatch(sha256) is None:
raise ReleaseManifestError(
f"release manifest firecracker.{role}.sha256 must be 64 lowercase hex")
return FirecrackerArtifact(version.strip(), sha256)
def parse_manifest(data: Any) -> ReleaseManifest:
if not isinstance(data, dict) or data.get("schema") != 1:
raise ReleaseManifestError("release manifest must use schema 1")
commit = data.get("source_commit")
if not isinstance(commit, str) or _COMMIT_RE.fullmatch(commit) is None:
raise ReleaseManifestError(
"release manifest source_commit must be 40 lowercase hex")
oci = data.get("oci")
if not isinstance(oci, dict):
raise ReleaseManifestError("release manifest oci must be an object")
images: dict[str, str] = {}
for role in ("orchestrator", "gateway", "agent_claude", "agent_codex", "agent_pi"):
ref = oci.get(role)
if not isinstance(ref, str) or _OCI_RE.fullmatch(ref) is None:
raise ReleaseManifestError(
f"release manifest oci.{role} must be digest-pinned")
images[role] = ref
firecracker = data.get("firecracker")
if not isinstance(firecracker, dict):
raise ReleaseManifestError("release manifest firecracker must be an object")
return ReleaseManifest(
source_commit=commit,
orchestrator_image=images["orchestrator"],
gateway_image=images["gateway"],
agent_images={
role: images[f"agent_{role}"] for role in ("claude", "codex", "pi")
},
firecracker_orchestrator=_artifact(
firecracker.get("orchestrator"), "orchestrator"),
firecracker_gateway=_artifact(firecracker.get("gateway"), "gateway"),
)
def load_manifest() -> ReleaseManifest:
path = manifest_path()
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ReleaseManifestError(
f"bot-bottle was packaged without valid infrastructure pins "
f"({path}): {exc}") from exc
return parse_manifest(data)
def packaged_manifest() -> ReleaseManifest | None:
"""Release identity, or ``None`` for checkouts/ad-hoc development wheels."""
if resources.is_source_checkout() and not os.environ.get(
"BOT_BOTTLE_RELEASE_MANIFEST"
):
return None
path = manifest_path()
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return load_manifest() # raise the detailed, fail-closed error
if isinstance(raw, dict) and raw.get("development") is True:
return None
return load_manifest()
def local_build_requested() -> bool:
return os.environ.get("BOT_BOTTLE_INFRA_BUILD", "").strip().lower() == "local"
def oci_image(role: str, local_default: str) -> tuple[str, bool]:
"""Return ``(reference, should_build_locally)`` for a fixed OCI service."""
override_name = f"BOT_BOTTLE_{role.upper()}_IMAGE"
override = os.environ.get(override_name, "").strip()
manifest = packaged_manifest()
local = local_build_requested() or manifest is None
if override:
if not local and _OCI_RE.fullmatch(override) is None:
raise ReleaseManifestError(
f"{override_name} must be digest-pinned outside local build mode")
return override, local
if local:
return local_default, True
assert manifest is not None
return (
manifest.orchestrator_image if role == "orchestrator"
else manifest.gateway_image if role == "gateway"
else manifest.agent_images[role.removeprefix("agent_")],
False,
)
__all__ = [
"FirecrackerArtifact",
"ReleaseManifest",
"ReleaseManifestError",
"load_manifest",
"local_build_requested",
"oci_image",
"packaged_manifest",
"parse_manifest",
]
+148
View File
@@ -0,0 +1,148 @@
"""Durable publication of immutable, commit-addressed release bundles."""
from __future__ import annotations
import hashlib
import os
import urllib.error
import urllib.request
from pathlib import Path
from .release_bundle import canonical_bytes, parse_bundle_index, sha256_file
from .release_manifest import ReleaseManifestError
HTTP_TIMEOUT_SECONDS = 60.0
INDEX_NAME = "bundle-index.json"
PACKAGE_NAME = "bot-bottle-builds"
def config() -> tuple[str, str, str]:
"""Return generic-package base URL, owner, and write token."""
base = os.environ.get(
"BOT_BOTTLE_RELEASE_BASE", "https://gitea.dideric.is").rstrip("/")
owner = os.environ.get("BOT_BOTTLE_RELEASE_OWNER", "didericis").strip()
token = os.environ.get("BOT_BOTTLE_RELEASE_TOKEN", "")
return base, owner, token
def bundle_url(source_commit: str, filename: str) -> str:
base, owner, _ = config()
return (
f"{base}/api/packages/{owner}/generic/{PACKAGE_NAME}/"
f"{source_commit}/{filename}"
)
def request(url: str, *, method: str = "GET", data: object | None = None,
length: int | None = None) -> urllib.request.Request:
result = urllib.request.Request(url, data=data, method=method) # type: ignore[arg-type]
_, _, token = config()
if token:
result.add_header("Authorization", f"token {token}")
if length is not None:
result.add_header("Content-Length", str(length))
result.add_header("Content-Type", "application/octet-stream")
return result
def remote_bytes(url: str) -> bytes | None:
try:
with urllib.request.urlopen(
request(url), timeout=HTTP_TIMEOUT_SECONDS,
) as response:
return response.read()
except urllib.error.HTTPError as exc:
if exc.code == 404:
return None
raise ReleaseManifestError(
f"checking release bundle failed (HTTP {exc.code}): {url}") from exc
except urllib.error.URLError as exc:
raise ReleaseManifestError(
f"release registry unreachable: {url} ({exc.reason})") from exc
def remote_sha256(url: str) -> str | None:
try:
with urllib.request.urlopen(
request(url), timeout=HTTP_TIMEOUT_SECONDS,
) as response:
digest = hashlib.sha256()
for chunk in iter(lambda: response.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
except urllib.error.HTTPError as exc:
if exc.code == 404:
return None
raise ReleaseManifestError(
f"checking release artifact failed (HTTP {exc.code}): {url}") from exc
except urllib.error.URLError as exc:
raise ReleaseManifestError(
f"release registry unreachable: {url} ({exc.reason})") from exc
def upload(url: str, source: bytes | Path) -> None:
handle = None
if isinstance(source, Path):
handle = source.open("rb")
data: object = handle
length = source.stat().st_size
else:
data = source
length = len(source)
try:
with urllib.request.urlopen(
request(url, method="PUT", data=data, length=length),
timeout=HTTP_TIMEOUT_SECONDS,
):
pass
except (urllib.error.HTTPError, urllib.error.URLError) as exc:
raise ReleaseManifestError(
f"publishing release artifact failed: {url} ({exc})") from exc
finally:
if handle is not None:
handle.close()
def publish_bundle(index: dict[str, object], wheel: Path) -> str:
"""Publish wheel first and index last, or verify an existing exact bundle."""
parse_bundle_index(index)
source_commit = str(index["source_commit"])
wheel_data = index["wheel"]
assert isinstance(wheel_data, dict)
filename = str(wheel_data["filename"])
expected_sha = str(wheel_data["sha256"])
if wheel.name != filename or sha256_file(wheel) != expected_sha:
raise ReleaseManifestError(
"release bundle wheel does not match its index")
wheel_url = bundle_url(source_commit, filename)
index_url = bundle_url(source_commit, INDEX_NAME)
if wheel_data["url"] != wheel_url:
raise ReleaseManifestError(
"release bundle wheel URL does not match its commit coordinate")
wanted_index = canonical_bytes(index)
existing_index = remote_bytes(index_url)
existing_wheel_sha = remote_sha256(wheel_url)
if existing_index is not None:
if existing_index != wanted_index or existing_wheel_sha != expected_sha:
raise ReleaseManifestError(
f"immutable release bundle {source_commit} already differs")
return index_url
if existing_wheel_sha is not None:
raise ReleaseManifestError(
f"partial release bundle {source_commit} exists without its index; "
"administrative cleanup is required")
upload(wheel_url, wheel)
upload(index_url, wanted_index)
return index_url
__all__ = [
"INDEX_NAME",
"PACKAGE_NAME",
"bundle_url",
"config",
"publish_bundle",
]
+197
View File
@@ -0,0 +1,197 @@
"""Validated release and channel pointers for qualified commit bundles."""
from __future__ import annotations
import json
import re
import urllib.error
import urllib.request
from typing import Any
from .release_bundle import parse_bundle_index
from .release_manifest import ReleaseManifestError
from .release_publish import (
HTTP_TIMEOUT_SECONDS,
bundle_url,
config,
remote_bytes,
request,
upload,
)
_COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
_STABLE_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
_STAGING_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)-rc\.(\d+)$")
def release_channel(tag: str) -> str:
"""Return the only channel eligible for *tag*."""
if _STABLE_TAG_RE.fullmatch(tag):
return "production"
if _STAGING_TAG_RE.fullmatch(tag):
return "staging"
raise ReleaseManifestError(
"release tag must be vX.Y.Z or vX.Y.Z-rc.N")
def release_version(tag: str) -> tuple[int, int, int, int]:
"""Return a monotonically comparable version tuple."""
stable = _STABLE_TAG_RE.fullmatch(tag)
if stable:
return (
int(stable.group(1)),
int(stable.group(2)),
int(stable.group(3)),
1 << 30,
)
staging = _STAGING_TAG_RE.fullmatch(tag)
if staging:
return (
int(staging.group(1)),
int(staging.group(2)),
int(staging.group(3)),
int(staging.group(4)),
)
raise ReleaseManifestError(
"release tag must be vX.Y.Z or vX.Y.Z-rc.N")
def pointer_url(package: str, version: str, filename: str) -> str:
base, owner, _ = config()
return f"{base}/api/packages/{owner}/generic/{package}/{version}/{filename}"
def build_release_pointer(
*,
tag: str,
source_commit: str,
workflow_run: str,
qualified_at: str,
) -> dict[str, Any]:
"""Build a qualified pointer to one immutable commit bundle."""
channel = release_channel(tag)
if _COMMIT_RE.fullmatch(source_commit) is None:
raise ReleaseManifestError(
"release source_commit must be 40 lowercase hex")
if not workflow_run.strip() or not qualified_at.strip():
raise ReleaseManifestError(
"release qualification provenance is incomplete")
return {
"schema": 1,
"qualified": True,
"tag": tag,
"channel": channel,
"source_commit": source_commit,
"bundle_index_url": bundle_url(source_commit, "bundle-index.json"),
"qualification": {
"workflow_run": workflow_run.strip(),
"qualified_at": qualified_at.strip(),
},
}
def parse_release_pointer(data: Any) -> dict[str, Any]:
"""Validate a release or channel pointer."""
if not isinstance(data, dict) or data.get("schema") != 1:
raise ReleaseManifestError("release pointer must use schema 1")
if data.get("qualified") is not True:
raise ReleaseManifestError("release pointer must be qualified")
tag = data.get("tag")
channel = data.get("channel")
commit = data.get("source_commit")
if not isinstance(tag, str) or release_channel(tag) != channel:
raise ReleaseManifestError("release pointer tag and channel disagree")
if not isinstance(commit, str) or _COMMIT_RE.fullmatch(commit) is None:
raise ReleaseManifestError("release pointer source commit is invalid")
if data.get("bundle_index_url") != bundle_url(commit, "bundle-index.json"):
raise ReleaseManifestError("release pointer bundle URL is invalid")
qualification = data.get("qualification")
if not isinstance(qualification, dict) or not all(
isinstance(qualification.get(key), str) and qualification[key].strip()
for key in ("workflow_run", "qualified_at")
):
raise ReleaseManifestError("release qualification is incomplete")
return data
def canonical_pointer(data: Any) -> bytes:
parse_release_pointer(data)
return (json.dumps(data, indent=2, sort_keys=True) + "\n").encode()
def delete(url: str) -> None:
try:
with urllib.request.urlopen(
request(url, method="DELETE"), timeout=HTTP_TIMEOUT_SECONDS,
):
pass
except urllib.error.HTTPError as exc:
if exc.code != 404:
raise ReleaseManifestError(
f"replacing release channel failed (HTTP {exc.code}): {url}"
) from exc
except urllib.error.URLError as exc:
raise ReleaseManifestError(
f"release registry unreachable: {url} ({exc.reason})") from exc
def publish_qualification(
pointer: dict[str, Any], *, allow_rollback: bool = False,
) -> tuple[str, str]:
"""Publish an immutable tag pointer and advance its channel."""
parse_release_pointer(pointer)
tag = str(pointer["tag"])
channel = str(pointer["channel"])
wanted = canonical_pointer(pointer)
release_url = pointer_url(
"bot-bottle-releases", tag, "release.json")
channel_url = pointer_url(
"bot-bottle-channels", channel, "channel.json")
bundle = remote_bytes(str(pointer["bundle_index_url"]))
if bundle is None:
raise ReleaseManifestError(
f"commit bundle does not exist for {pointer['source_commit']}")
try:
bundle_data = parse_bundle_index(json.loads(bundle))
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise ReleaseManifestError("commit bundle index is malformed") from exc
if bundle_data["source_commit"] != pointer["source_commit"]:
raise ReleaseManifestError(
"commit bundle index does not match the qualified source commit")
existing_release = remote_bytes(release_url)
if existing_release is not None and existing_release != wanted:
raise ReleaseManifestError(
f"immutable release pointer {tag} already differs")
if existing_release is None:
upload(release_url, wanted)
existing_channel = remote_bytes(channel_url)
if existing_channel == wanted:
return release_url, channel_url
if existing_channel is not None:
try:
current = parse_release_pointer(json.loads(existing_channel))
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise ReleaseManifestError(
f"existing {channel} channel pointer is malformed") from exc
if (
not allow_rollback
and release_version(tag) <= release_version(str(current["tag"]))
):
raise ReleaseManifestError(
f"{channel} channel update is not monotonic; "
"use the explicit rollback operation")
delete(channel_url)
upload(channel_url, wanted)
return release_url, channel_url
__all__ = [
"build_release_pointer",
"canonical_pointer",
"parse_release_pointer",
"publish_qualification",
"release_channel",
"release_version",
]
+57 -9
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
import os
import sqlite3
import stat
from contextlib import contextmanager
from pathlib import Path
@@ -19,9 +21,62 @@ class DbStore:
def __init__(self, db_path: Path, migrations: TableMigrations) -> None:
self.db_path = db_path
self._migrations = migrations
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._secure_parent()
if self.db_path.exists():
self._chmod()
def _secure_parent(self) -> None:
"""Create and verify the private parent directory."""
parent = self.db_path.parent
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
if parent.is_symlink():
raise PermissionError(f"database directory must not be a symlink: {parent}")
parent.chmod(0o700)
parent_stat = parent.lstat()
if not stat.S_ISDIR(parent_stat.st_mode):
raise PermissionError(f"database parent is not a directory: {parent}")
if stat.S_IMODE(parent_stat.st_mode) != 0o700:
raise PermissionError(f"database directory is not mode 0700: {parent}")
def _secure_db_file(self) -> None:
"""Create the database without a permissive filesystem window.
SQLite otherwise creates a missing database using the process umask.
This store contains control-plane identity tokens, so both creation and
repair are fail-closed rather than best-effort.
"""
flags = os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW
fd = os.open(
self.db_path,
flags,
stat.S_IRUSR | stat.S_IWUSR,
)
try:
self._secure_open_file(fd)
finally:
os.close(fd)
def _chmod(self) -> None:
"""Enforce and verify the private database mode after every write."""
fd = os.open(self.db_path, os.O_RDWR | os.O_NOFOLLOW)
try:
self._secure_open_file(fd)
finally:
os.close(fd)
def _secure_open_file(self, fd: int) -> None:
"""Pin, validate, and secure an opened database filesystem object."""
file_stat = os.fstat(fd)
if not stat.S_ISREG(file_stat.st_mode):
raise PermissionError(
f"database must be a regular file: {self.db_path}"
)
os.fchmod(fd, stat.S_IRUSR | stat.S_IWUSR)
if stat.S_IMODE(os.fstat(fd).st_mode) != 0o600:
raise PermissionError(f"database is not mode 0600: {self.db_path}")
def _connect(self) -> sqlite3.Connection:
self._secure_db_file()
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
@@ -51,16 +106,9 @@ class DbStore:
return version == len(self._migrations.migrations)
def migrate(self) -> None:
"""Apply any pending migrations and set permissions on the DB file."""
"""Apply any pending migrations to the already-secured DB file."""
with self._connection() as conn:
self._migrations.apply(conn)
self._chmod()
def _chmod(self) -> None:
try:
self.db_path.chmod(0o600)
except OSError:
pass
__all__ = ["DbStore", "DbVersionError"]
+17
View File
@@ -45,6 +45,23 @@ report; macOS reports advisory coverage in its own job. The macOS infra
container is a singleton, so its job uses a concurrency group and always tears
the service down.
## Artifact publication and promotion
[`publish-artifacts.yml`](../.gitea/workflows/publish-artifacts.yml) is the
manual deployment-test boundary. Dispatch it with any branch, tag, or commit;
it resolves that input once, publishes the orchestrator, gateway, Claude,
Codex, Pi, and Firecracker artifacts, then publishes only the wheel verified
against those identities. Install the reported commit with
`BOT_BOTTLE_REF=<sha> sh install.sh`; the installer warns that snapshots may
not have completed release qualification.
[`release.yml`](../.gitea/workflows/release.yml) handles promotion tags.
`vX.Y.Z-rc.N` must be reachable from `staging`; `vX.Y.Z` must be reachable
from `production`. It runs the complete pre-release workflow, ensures that the
same commit bundle is published, verifies a clean install, creates the
qualified release pointer, and advances the matching channel. Promotion
reuses immutable bundle bytes and never rebuilds them.
## Scheduled canary
[`.gitea/workflows/canaries.yml`](../.gitea/workflows/canaries.yml) runs weekly
+2 -2
View File
@@ -1,4 +1,4 @@
# VHS tape — drives `./cli.py start demo` interactively and asks
# VHS tape — drives `bot-bottle start demo` interactively and asks
# claude (the AI) to run four probes via natural-language prompts.
# Setup (manifest + dummy SSH key + image pre-warm) and teardown
# happen outside the tape; record via `bash scripts/demo-record.sh`,
@@ -29,7 +29,7 @@ Show
# defaults), one git upstream (unreachable on purpose so gitleaks runs
# before the gate would forward), and a FAKE_TOKEN env var shaped like
# a GitHub PAT.
Type "./cli.py start demo"
Type "bot-bottle start demo"
Enter
Sleep 8s
+1 -1
View File
@@ -132,7 +132,7 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
can revisit, but the v1 of this PRD is one file = one bottle.
- **Hot-reload.** Changes to manifest files take effect at next
`./cli.py start`; we do not watch the directory.
`bot-bottle start`; we do not watch the directory.
## Scope
+2 -2
View File
@@ -290,7 +290,7 @@ After this PRD:
### Cleanup CLI
`./cli.py cleanup` switches from "list every container with prefix
`bot-bottle cleanup` switches from "list every container with prefix
`bot-bottle-` and every network with prefix `bot-bottle-net-`
or `bot-bottle-egress-`" to:
@@ -369,7 +369,7 @@ Sized for one PR each, in order.
`docker compose up -d` + attach + teardown. Per-sidecar `start()`/
`stop()` lifecycle methods deleted in the same chunk. Compose-
log dump on teardown added.
4. **Cleanup CLI on compose.** Switch `./cli.py cleanup` to
4. **Cleanup CLI on compose.** Switch `bot-bottle cleanup` to
`docker compose ls`-based discovery; keep prefix-scan as
fallback for one release.
5. **Dashboard.** Decide on the discovery question (open question
+4 -4
View File
@@ -37,7 +37,7 @@ Two rough edges in the current dashboard:
shows only pending proposals. If no agent has called a tool,
the screen reads "no pending proposals" — even when five
bottles are quietly working. The operator has to `docker
compose ls` (or `./cli.py cleanup -n` to see the y/N preview)
compose ls` (or `bot-bottle cleanup -n` to see the y/N preview)
to find out what's actually live.
2. **`e` / `p` re-discover-and-disambiguate every invocation.**
@@ -82,12 +82,12 @@ the "operator wants to make an unprompted change" case.
global across bottles. Filtering ("show me only this agent's
proposals") might be a follow-up but isn't this PRD.
- **Agent lifecycle from the dashboard.** Starting / stopping
agents stays in `./cli.py start` / `./cli.py cleanup`. The
agents stays in `bot-bottle start` / `bot-bottle cleanup`. The
dashboard reads state; it doesn't change it.
- **Preserved-but-not-running bottles.** The active-agents list
is strictly "what's running now" (cross-referenced from
`docker compose ls`). Preserved state dirs without a live
project don't appear — `./cli.py resume <identity>` is the
project don't appear — `bot-bottle resume <identity>` is the
path for those.
- **A separate per-agent detail view.** The agent rows are
one-line summaries. Pressing Enter on a proposal still drops
@@ -125,7 +125,7 @@ the "operator wants to make an unprompted change" case.
- Changes to proposal handling (`a` / `m` / `r` / Enter all
unchanged).
- Changes to the queue-dir / supervise sidecar protocol.
- New CLI surface beyond what's in `./cli.py dashboard`.
- New CLI surface beyond what's in `bot-bottle dashboard`.
- Touching the manifest, compose renderer, launch lifecycle.
## Proposed design
@@ -14,8 +14,8 @@
Today the dashboard is read-only: it surfaces pending proposals
and active agents (PRD 0019) but can't *start* an agent or
*re-enter* one. The operator's path is split — they launch
agents from one terminal (`./cli.py start <name>`), and watch
them from another (`./cli.py dashboard`).
agents from one terminal (`bot-bottle start <name>`), and watch
them from another (`bot-bottle dashboard`).
This PRD collapses that split. The dashboard becomes the
operator's single surface: pressing a key opens an agent picker,
@@ -31,7 +31,7 @@ claude session AND the dashboard process. Exit claude → back to
dashboard, bottle still running. Start another agent → two
bottles up at once. Quit the dashboard → bottles continue
running. Teardown is **always explicit**: the operator presses
`x` on an agent, or runs `./cli.py cleanup` later.
`x` on an agent, or runs `bot-bottle cleanup` later.
## Problem
@@ -45,7 +45,7 @@ Two real frictions today:
open and the dashboard's "active agents" pane is hopelessly
behind reality because they just spawned three in a row.
2. **`./cli.py start` ties the bottle to a single claude
2. **`bot-bottle start` ties the bottle to a single claude
session.** The start command's `ExitStack` brings the bottle
up, runs claude, and tears down on Ctrl-D — fine for a one-
shot session, wrong for "let me bounce in and out of this
@@ -60,7 +60,7 @@ captures full-merged logs per bottle (PRD 0018). It already
## Goals / Success Criteria
1. From inside `./cli.py dashboard`, pressing `n` (new) opens
1. From inside `bot-bottle dashboard`, pressing `n` (new) opens
an agent picker listing every agent defined in the manifest.
Selecting one runs `prepare → preflight → launch`.
2. The preflight Y/N summary renders cleanly — either as a
@@ -83,7 +83,7 @@ captures full-merged logs per bottle (PRD 0018). It already
state cleanup) without quitting the dashboard.
7. Quitting the dashboard (`q`) leaves every running bottle
running. Bottle teardown is always explicit (per-bottle `x`
or `./cli.py cleanup`). The next `./cli.py dashboard`
or `bot-bottle cleanup`). The next `bot-bottle dashboard`
invocation re-discovers them via `list_active_slugs()` and
surfaces re-attach for any it can reconstruct context for
(see "Cross-dashboard re-attach" below).
@@ -94,13 +94,13 @@ captures full-merged logs per bottle (PRD 0018). It already
embedded-emulator option from the research doc is out of
scope. The handoff (option 1) is the v1; option 2 is a
separate PRD if and when handoff is observably insufficient.
- **Adopting bottles started by an out-of-dashboard `./cli.py
- **Adopting bottles started by an out-of-dashboard `bot-bottle
start` invocation.** Those have their own ExitStack-owner and
the dashboard treats them as read-only-watch (already does
today). Re-attach only applies to bottles the *current
dashboard process* started.
- **Resurrecting an out-of-process bottle into a new dashboard
with full re-attach.** A bottle started by `./cli.py start`
with full re-attach.** A bottle started by `bot-bottle start`
in another terminal — or by a previous dashboard run, now
exited — appears in the agents pane (already does, PRD 0019)
and can be re-attached via `docker exec -it claude` because
@@ -109,12 +109,12 @@ captures full-merged logs per bottle (PRD 0018). It already
context object to drive teardown — e.g., the
ExitStack-tracked CA + state cleanup `_settle_state` performs
today. Cross-dashboard re-attach uses the existing
`./cli.py cleanup` for teardown, not an `x` keypress (see
`bot-bottle cleanup` for teardown, not an `x` keypress (see
open questions).
- **Multi-window UI.** Single curses window, two existing
panes (proposals + agents); the agent picker is a modal, not
a third pane.
- **Removing `./cli.py start`.** Stays as the script-friendly /
- **Removing `bot-bottle start`.** Stays as the script-friendly /
legacy entry point. The dashboard is the new default.
## Scope
@@ -140,7 +140,7 @@ captures full-merged logs per bottle (PRD 0018). It already
### Out of scope
- Changes to `./cli.py start` itself. It keeps its current
- Changes to `bot-bottle start` itself. It keeps its current
shape; the dashboard reuses its internal pieces (backend.
prepare / backend.launch) without reaching through the CLI
layer.
@@ -157,7 +157,7 @@ captures full-merged logs per bottle (PRD 0018). It already
Today's flow:
```
./cli.py start agent
bot-bottle start agent
└─ with backend.launch(plan) as bottle: ← bottle alive while inside `with`
bottle.exec_agent([...], tty=True) ← blocks until claude exits
# context exits → compose down → state cleanup
@@ -166,7 +166,7 @@ Today's flow:
The proposed dashboard-driven flow:
```
./cli.py dashboard
bot-bottle dashboard
└─ bottles: dict[str, tuple[ContextManager, DockerBottle]] = {}
# operator presses `n`, picks agent
@@ -205,7 +205,7 @@ Two shifts:
evaluation, state-dir reap) doesn't fire on a quit-while-
running bottle. It DOES fire when the operator explicitly
stops via `x`, because that calls `cm.__exit__`. For
bottles a previous dashboard quit on, `./cli.py cleanup`
bottles a previous dashboard quit on, `bot-bottle cleanup`
is the path — its compose-down + state-reap logic
already covers the case.
@@ -213,7 +213,7 @@ Two shifts:
When the dashboard discovers a bottle in `discover_active_agents`
that it didn't itself start (a previous-dashboard or external
`./cli.py start` bottle), Enter still attaches via `docker exec
`bot-bottle start` bottle), Enter still attaches via `docker exec
-it … claude` — the agent container is running `sleep infinity`
exactly the same way regardless of who started it. The only
thing the current dashboard lacks for those bottles is the
@@ -221,8 +221,8 @@ launch-context object needed to drive a clean teardown via
`x`.
For v1 we surface this honestly: pressing `x` on a non-owned
agent shows a status hint pointing at `./cli.py cleanup` (or
`./cli.py cleanup` targeted at the slug if we add that flag
agent shows a status hint pointing at `bot-bottle cleanup` (or
`bot-bottle cleanup` targeted at the slug if we add that flag
later). The agent stays alive; the operator handles teardown
out-of-band. Enter (re-attach) works for both owned and
non-owned bottles.
@@ -288,7 +288,7 @@ agents pane.
`x` on a non-owned agent (discovered via `list_active_slugs`
but not in `bottles` dict): no-op with status hint pointing
at `./cli.py cleanup` (the existing path that tears down
at `bot-bottle cleanup` (the existing path that tears down
ANY bot-bottle compose project plus reaps state dirs).
### Dashboard quit
@@ -300,7 +300,7 @@ the `docker compose` project keeps running. The next dashboard
invocation discovers the bottles via `list_active_slugs` and
surfaces re-attach.
This is a real departure from today's `./cli.py start`
This is a real departure from today's `bot-bottle start`
semantics (which couples bottle lifetime to the process via
ExitStack). It's intentional: the dashboard is a watching +
acting surface, not a lifetime owner.
@@ -322,7 +322,7 @@ Sized for one PR each.
dashboard's ExitStack; handoff invokes `attach_agent`.
3. **Re-attach via Enter on owned agents-pane row.** Looks up
the slug in the dashboard's `bottles` map; if present →
handoff; else → status-line hint pointing at `./cli.py
handoff; else → status-line hint pointing at `bot-bottle
resume`.
4. **Explicit per-bottle stop (`x` keybinding).** Pop the
bottle's `close` callback off the stack, call it, refresh.
@@ -369,7 +369,7 @@ Sized for one PR each.
bottles dict goes out of scope without invoking `__exit__`,
so the `docker compose` projects keep running. Bottle
teardown is always explicit: per-bottle `x` (for
dashboard-owned), or `./cli.py cleanup` (for everything).
dashboard-owned), or `bot-bottle cleanup` (for everything).
## Open questions
+3 -3
View File
@@ -46,7 +46,7 @@ window, two panes, no terminal handoff.
## Goals / Success Criteria
1. When the operator runs `./cli.py dashboard` from inside a
1. When the operator runs `bot-bottle dashboard` from inside a
tmux session (`$TMUX` set), the dashboard establishes a
two-pane layout: dashboard in the left pane, an initially-
empty right pane reserved for claude sessions.
@@ -313,7 +313,7 @@ Sized small.
3. **Dashboard launched OUTSIDE tmux but tmux is installed.**
Should the dashboard auto-exec itself inside a fresh tmux
session to get the split-pane experience? Convenient but
surprising (`./cli.py dashboard` shouldn't silently
surprising (`bot-bottle dashboard` shouldn't silently
change what session you're in). v1 leaves this off —
operators who want split-pane mode start tmux themselves
and then run the dashboard.
@@ -332,7 +332,7 @@ Sized small.
PRD-0019 focus indicator?
6. **Concurrent dashboards in different tmux windows.**
Multiple `./cli.py dashboard` invocations in different
Multiple `bot-bottle dashboard` invocations in different
tmux windows would each create their own right pane —
probably fine, each has its own state, but worth
verifying that `tmux list-panes` is scoped to the right
+1 -1
View File
@@ -14,7 +14,7 @@ Today bot-bottle is hard-wired around Claude Code assumptions. When Claude runs
## Goals / Success Criteria
- A Codex agent can be started from the dashboard and via `./cli.py start` alongside a Claude agent.
- A Codex agent can be started from the dashboard and via `bot-bottle start` alongside a Claude agent.
- The manifest can express the agent provider/template and, where needed, a custom agent Dockerfile.
- Claude-specific default egress/auth behavior is no longer implicit; provider-specific auth is expressed through explicit bottle egress routes and roles.
- The launcher preserves required infrastructure behavior for sidecars, egress, pipelock, supervisor MCP, CA handling, git, and shell basics.
@@ -30,7 +30,7 @@ across every bottle spin-up. This has several consequences:
to grant that access.
- **Manual rotation burden.** Operators must manage key files on disk, keeping
them secure, rotating them on a schedule, and distributing them across hosts
that run `./cli.py start`.
that run `bot-bottle start`.
## Goals / Success Criteria
@@ -6,7 +6,7 @@
## Summary
The `./cli.py dashboard` command has grown from its PRD 0013 roots
The `bot-bottle dashboard` command has grown from its PRD 0013 roots
(triage supervise proposals) into a parallel-agent control surface
(PRDs 0019/0020/0021): an active-agents pane, agent picker + start,
re-attach, per-bottle stop, tmux split-pane handoff, operator-
@@ -21,7 +21,7 @@ proposals, approve / modify / reject each one, write audit entries,
deliver the response that unblocks the agent's tool call. Everything
that's about *starting / re-entering / stopping* bottles, or about
*operator-initiated* config edits, comes out. The command is renamed
`./cli.py supervise` so the name matches what it does after the cut.
`bot-bottle supervise` so the name matches what it does after the cut.
Future agent-management UX is explicitly punted: if and when a
control surface for parallel agents resurfaces, the working
@@ -41,9 +41,9 @@ Three concrete pains, all downstream of the dashboard's growth:
ExitStack-free bottle ownership are intricate enough that
shipping the next polish increment costs more than it returns.
2. **No clear ownership of "starts and stops bottles".** Today
that responsibility is split: `./cli.py start` owns one-shot
that responsibility is split: `bot-bottle start` owns one-shot
sessions; the dashboard owns multi-session bottles it started
itself; `./cli.py cleanup` owns everything else. The dashboard
itself; `bot-bottle cleanup` owns everything else. The dashboard
tracking its own `bottles: dict[str, (cm, bottle, identity)]`
that doesn't survive a quit is a confusing third lane.
3. **Wrong target shape for a "manage many agents" UI.** The
@@ -97,12 +97,12 @@ problem is everything that got bolted onto that core after.
dashboard. After this PRD they don't exist anywhere — operators
who need ad-hoc edits use the same path the agents do (call the
supervise tool from inside the bottle) or hand-edit the host-
side files and restart the sidecar. Adding a `./cli.py routes
side files and restart the sidecar. Adding a `bot-bottle routes
edit <slug>` verb is a follow-up if the loss bites.
- **Removing `./cli.py start` or changing its semantics.** Start
- **Removing `bot-bottle start` or changing its semantics.** Start
remains the one-shot launch path. PRD 0020's bottle-outlives-
process model is removed; the only path to a long-running
bottle is `./cli.py start` (foreground) plus `cli.py cleanup`
bottle is `bot-bottle start` (foreground) plus `cli.py cleanup`
for teardown.
- **Removing the supervise-sidecar protocol or any of the three
block-remediation engines.** PRDs 00130016 stay Active. The
@@ -122,8 +122,8 @@ problem is everything that got bolted onto that core after.
### In scope
- **Rename the subcommand.** `./cli.py dashboard` becomes
`./cli.py supervise`. The module moves from `bot_bottle/cli/
- **Rename the subcommand.** `bot-bottle dashboard` becomes
`bot-bottle supervise`. The module moves from `bot_bottle/cli/
dashboard.py` to `bot_bottle/cli/supervise.py`. The dispatcher
in `bot_bottle/cli/__init__.py` and the help text both update.
- **Strip the curses loop to proposal-only.** The remaining
@@ -167,7 +167,7 @@ problem is everything that got bolted onto that core after.
- Any new feature in the supervise TUI. The cut is purely
subtractive (except for the rename).
- Behavior changes in `./cli.py start`, `cli.py cleanup`,
- Behavior changes in `bot-bottle start`, `cli.py cleanup`,
`cli.py resume`, `cli.py list`, `cli.py info`, `cli.py edit`,
`cli.py init` — unchanged.
- Changes to the supervise sidecar (`supervise_server.py`,
@@ -181,7 +181,7 @@ problem is everything that got bolted onto that core after.
### Final shape of the TUI
After this PRD the `./cli.py supervise` curses surface is:
After this PRD the `bot-bottle supervise` curses surface is:
```
bot-bottle supervise (3 pending)
@@ -307,8 +307,8 @@ The PR closes issue #174.
1. **`e` / `p` operator-initiated edits — gone for good or
moved to a separate CLI verb?** The PRD removes them with no
replacement. The simplest replacement is `./cli.py routes
edit <slug>` and `./cli.py pipelock edit <slug>`, sharing
replacement. The simplest replacement is `bot-bottle routes
edit <slug>` and `bot-bottle pipelock edit <slug>`, sharing
the existing `apply_routes_change` / `apply_allowlist_change`
engines. If the loss is felt within the first parallel
run after this lands, that follow-up is a small PR. Leaving
+9 -9
View File
@@ -7,12 +7,12 @@
## Summary
When `./cli.py start` is run without an agent name, or without a backend
When `bot-bottle start` is run without an agent name, or without a backend
explicitly specified, the user currently gets an argparse error (missing
positional) or falls through to the `docker` default silently. This PRD
adds a terminal UI that appears in those gaps: a filter-select screen
built with `curses` that lets the operator pick the agent and/or backend
interactively rather than memorising names or consulting `./cli.py list`.
interactively rather than memorising names or consulting `bot-bottle list`.
## Problem
@@ -29,15 +29,15 @@ visible.
## Goals / Success Criteria
1. `./cli.py start` (no arguments) shows an interactive agent selector;
1. `bot-bottle start` (no arguments) shows an interactive agent selector;
the selected name is used exactly as if it had been passed on the
command line.
2. `./cli.py start <name>` (no `--backend`, no `BOT_BOTTLE_BACKEND`)
2. `bot-bottle start <name>` (no `--backend`, no `BOT_BOTTLE_BACKEND`)
shows an interactive backend selector; the selected backend is used
exactly as if `--backend=<selected>` had been passed.
3. `./cli.py start <name> --backend=<b>` (both explicit) shows neither
3. `bot-bottle start <name> --backend=<b>` (both explicit) shows neither
screen — no behavioural change from today.
4. `./cli.py start` (no arguments, no env backend) shows the agent
4. `bot-bottle start` (no arguments, no env backend) shows the agent
selector first, then the backend selector.
5. The filter-select widget is a standalone utility
(`bot_bottle/cli/tui.py`) shared by both selectors.
@@ -57,7 +57,7 @@ visible.
- No pagination beyond what fits in the terminal window (scroll via
cursor movement is sufficient for typical agent counts).
- No multi-select; exactly one item is chosen per invocation.
- No changes to `./cli.py resume`, `./cli.py list`, or any other
- No changes to `bot-bottle resume`, `bot-bottle list`, or any other
subcommand.
## Design
@@ -83,7 +83,7 @@ def filter_select(
The widget renders to the tty file descriptor opened via `curses.initscr`
(or `curses.newterm` on the tty fd so stdout remains clean for callers
that pipe `./cli.py`).
that pipe `bot-bottle`).
Layout (full-width, minimal):
@@ -140,7 +140,7 @@ agent picker can populate itself from the real manifest. The same
`filter_select` opens `/dev/tty` and feeds it as the input file to
`curses.wrapper`-equivalent code (using `curses.newterm` to avoid
clobbering the caller's stdout/stderr). This keeps the picker
composable — callers can pipe `./cli.py` output without the curses
composable — callers can pipe `bot-bottle` output without the curses
draw sequences contaminating the pipe.
## Implementation chunks
+3 -3
View File
@@ -30,12 +30,12 @@ snapshot before a planned host reboot or hardware migration.
## Goals / Success Criteria
- `./cli.py commit [<slug>]` takes a snapshot of the running agent and
- `bot-bottle commit [<slug>]` takes a snapshot of the running agent and
stores it as a local artifact.
- Without a slug argument the command shows the same interactive picker
as `start` (the list of active slugs).
- The committed artifact reference is stored in per-bottle state so
that the next `./cli.py resume <slug>` automatically uses the
that the next `bot-bottle resume <slug>` automatically uses the
snapshot instead of rebuilding from the Dockerfile.
- `mark_preserved` is called so the state dir survives the normal
session-end cleanup.
@@ -81,7 +81,7 @@ to the committed `.smolmachine` artifact.
### `commit` command
```
./cli.py commit [<slug>]
bot-bottle commit [<slug>]
```
1. Resolve slug (arg or interactive picker from `enumerate_active_agents`).
@@ -37,7 +37,7 @@ egress policy), the operator must duplicate the agent file and change the
selection order, as the effective bottle for the session.
5. Confirming with an empty selection falls back to the agent's `bottle:` field.
If neither is set, a ManifestError is raised pointing the operator at the fix.
6. The ordered bottle list is stored in launch metadata so `./cli.py resume`
6. The ordered bottle list is stored in launch metadata so `bot-bottle resume`
uses the same bottles.
7. The preflight summary (`y/N` screen) shows the effective bottle name(s).
8. The multi-select picker supports incremental filtering, Space/Enter to toggle
@@ -52,7 +52,7 @@ egress policy), the operator must duplicate the agent file and change the
- Reordering the selection list from within the picker (order = insertion order;
drag-and-drop is out of scope).
- Storing bottle selection history / MRU.
- Changes to `./cli.py edit`, `./cli.py list`, or `./cli.py info`.
- Changes to `bot-bottle edit`, `bot-bottle list`, or `bot-bottle info`.
- Removing the `bottle:` key from the agent schema (it stays, now optional).
## Design
+1 -1
View File
@@ -74,7 +74,7 @@ macOS-only for v1. Three concrete blockers:
## Goals / Success Criteria
- `BOT_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>` launches,
- `BOT_BOTTLE_BACKEND=smolmachines bot-bottle start <agent>` launches,
runs, and tears down a bottle on a Linux host with `/dev/kvm`.
- The TSI allowlist is enforced on Linux: PRD 0022's
`tests/integration/test_sandbox_escape.py` passes against
+2 -2
View File
@@ -39,7 +39,7 @@ end-to-end runner that would catch the *next* macOS-only launch regression.)
PR #470 (#414) already made the integration suite backend-agnostic:
`skip_unless_selected_backend_available()` gates on the *selected* backend's
own `is_backend_ready()` rather than `docker_available()`, and each
integration job runs `./cli.py backend status --backend=<name>` as a preflight
integration job runs `bot-bottle backend status --backend=<name>` as a preflight
that fails loudly when the backend is missing. That is the machinery this job
plugs into; this PRD supplies the runner and the job.
@@ -98,7 +98,7 @@ Modeled on `integration-firecracker`:
- `concurrency: { group: integration-macos-infra, cancel-in-progress: false }`
to serialize runs against the singleton.
- **Preflight**`command -v container`, `container system status`, then
`./cli.py backend status --backend=macos-container`; any failure exits
`bot-bottle backend status --backend=macos-container`; any failure exits
non-zero so a misprovisioned runner fails loudly instead of silently
skipping.
- Run the integration suite under coverage with
+1 -1
View File
@@ -16,7 +16,7 @@ verifies host prerequisites after install.
## Problem
There is currently no install path for new users. The only way to run
bot-bottle is to clone the repo and invoke `./cli.py`. This blocks any
bot-bottle is to clone the repo and invoke `bot-bottle`. This blocks any
public demo: readers want `curl | sh` or `pipx install`, not a manual
clone-and-configure flow. There is also no single command that tells a
user whether their host is actually ready to run a bottle.
+374
View File
@@ -0,0 +1,374 @@
# PRD 0083: Packaged infrastructure artifacts
- **Status:** Active
- **Author:** Codex
- **Created:** 2026-07-27
- **Issue:** #536
## Summary
Publish the orchestrator and gateway infrastructure before packaging
bot-bottle, record their immutable identities in the application package, and
make every production backend acquire those exact artifacts instead of
building infrastructure during bottle startup. Docker and Apple Container pull
digest-pinned OCI images; Firecracker pulls checksum-verified rootfs artifacts
selected by the same packaged release manifest.
## Problem
Starting a bottle currently invokes an infrastructure build on Docker and
Apple Container even when a healthy orchestrator is already running. Both
infra composers call `ensure_built()` before their health/currentness checks,
and both implementations unconditionally invoke their OCI builder. Layer
caching can make this less expensive, but startup still depends on a working
builder, build context, base-image registry, and package installation network.
The Apple Container orchestrator also bind-mounts the installed source over the
package baked into its image. A pinned image alone would therefore not pin the
code that actually runs.
Firecracker already downloads fixed rootfs artifacts, but it derives their
versions from the package contents at launch. There is no package-level record
that says which already-published infrastructure release belongs to an
installed bot-bottle release.
The result is three different delivery contracts:
- Docker and Apple Container build mutable `:latest` images on the launch host.
- Firecracker computes an artifact version locally and downloads it.
- An installed wheel contains Dockerfiles specifically so production startup
can rebuild its own infrastructure.
That makes startup slower and less reliable, prevents a packaged application
from identifying the infrastructure it was tested with, and weakens the
supply-chain boundary between release production and runtime.
## Goals / Success criteria
- A packaged bot-bottle release contains one validated release manifest with
immutable orchestrator and gateway identities.
- Docker and Apple Container acquire and run the manifest's digest-pinned OCI
images without invoking `docker build` or `container build`.
- The shipped Claude, Codex, and Pi agent images are built, smoke-tested,
published, and selected by digest as part of the same commit bundle.
- Firecracker acquires the manifest's versioned, checksum-verified
orchestrator and gateway rootfs artifacts rather than deriving versions at
launch.
- All three backends expose the same backend-neutral `ensure_available`
lifecycle contract.
- A healthy running orchestrator is retained only when its recorded release
identity matches the installed package.
- Apple Container runs the package baked into the orchestrator image; no host
source overlays production code.
- Missing, mutable, malformed, or internally inconsistent release metadata
fails closed before infrastructure starts.
- Source-checkout development retains an explicit local-build mode.
- Publishing produces the infrastructure first and the installable Python
package last, so the package cannot name artifacts that were not published.
- Any branch, tag, or commit can be resolved once to a full source SHA and
published as one immutable, commit-addressed artifact bundle.
- The installer defaults to the production channel, can select staging or an
exact commit, and warns for commit snapshots that have not been qualified as
a release.
- Staging and production releases promote the same tested artifact bytes;
promotion never rebuilds them.
- A release is published only after the complete pre-release suite and a clean
install from the published bundle succeed.
## Non-goals
- Prebuilding user-supplied Dockerfiles. Custom agent images continue to build
through the backend-specific agent-image paths.
- Installing Docker, Apple Container, Firecracker, or other host prerequisites.
- Replacing Gitea's OCI or generic-package registries.
- Adding image signing or a transparency log. Immutable digests and existing
Firecracker SHA-256 verification are the integrity boundary for this version.
- Forcing a network download when an immutable, verified artifact is already
cached locally. "Pull" means acquire the packaged identity, with safe cache
reuse.
- Supporting live source overlays in packaged production installs.
- Defining the project's version-numbering policy beyond stable
`vX.Y.Z` and staging `vX.Y.Z-rc.N` tag shapes.
## Design
### Release manifest
`bot_bottle/release-manifest.json` is generated during package production and
shipped as package data:
```json
{
"schema": 1,
"source_commit": "<40 lowercase hex>",
"oci": {
"orchestrator": "gitea.dideric.is/didericis/bot-bottle-orchestrator@sha256:<64 hex>",
"gateway": "gitea.dideric.is/didericis/bot-bottle-gateway@sha256:<64 hex>",
"agent_claude": "gitea.dideric.is/didericis/bot-bottle-claude@sha256:<64 hex>",
"agent_codex": "gitea.dideric.is/didericis/bot-bottle-codex@sha256:<64 hex>",
"agent_pi": "gitea.dideric.is/didericis/bot-bottle-pi@sha256:<64 hex>"
},
"firecracker": {
"orchestrator": {
"version": "<artifact version>",
"sha256": "<rootfs.ext4.gz sha256>"
},
"gateway": {
"version": "<artifact version>",
"sha256": "<rootfs.ext4.gz sha256>"
}
}
}
```
The runtime loader accepts only schema 1, digest-qualified OCI references,
non-empty Firecracker versions, 64-character lowercase SHA-256 values, and a
full source commit. It returns immutable value objects rather than passing raw
dictionaries through backend code.
The repository carries no pretend production pins. A source checkout or ad-hoc
Git wheel carries a `development: true` marker and selects local infrastructure
builds, preserving the contributor and current `install.sh` paths. The release
packaging workflow must replace that marker with the validated schema above;
it refuses mutable or incomplete release inputs.
### Packaging order
The release job operates on one tested commit:
1. Build and smoke-test the multi-architecture orchestrator, gateway, Claude,
Codex, and Pi OCI images.
2. Push them and resolve their registry manifest digests.
3. Build Firecracker's orchestrator and gateway rootfs artifacts from the same
source and pinned OCI bases.
4. Publish both generic-package artifacts and retain their versions and
compressed-file checksums.
5. Generate `release-manifest.json` from those published identities.
6. Build the wheel with that manifest. Do not produce a source distribution:
rebuilding an sdist outside this release boundary could replace the pins
with the development manifest.
7. Install the wheel in a clean environment and assert that every manifest
identity is valid and retrievable.
8. Publish only that verified wheel.
The build hook receives the manifest as a file input. It must not query a
registry or choose versions itself: package builds stay deterministic and
network-independent once their release inputs exist.
### Commit-addressed bundle index
Every publication resolves its requested branch, tag, or commit to one
40-character source SHA before doing any work. The publisher writes an
immutable external bundle index under that SHA. The index contains:
- schema version and source SHA;
- wheel URL and SHA-256;
- orchestrator and gateway OCI digest references;
- Claude, Codex, and Pi agent OCI digest references;
- Firecracker package versions and compressed-file SHA-256 values;
- producing workflow/run identity and publication timestamp; and
- producing workflow provenance. Qualification is recorded separately in an
immutable tag pointer so the commit bundle itself never changes.
The embedded wheel manifest contains the runtime subset of the same identities.
Publication fails if the embedded manifest and external index differ.
Bundle coordinates are immutable. Re-running publication for a SHA verifies
and reuses a byte-identical complete bundle; it never replaces one artifact or
mixes outputs from different runs. A partial existing bundle is an error and
requires an explicit administrative cleanup before retrying.
Commit bundles are durable release inputs, not expiring CI artifacts. The
project may garbage-collect unqualified snapshots only under a documented
retention policy; qualified staging and production bundles are retained.
### Branches, tags, and promotion
The protected promotion topology is:
```text
feature -> main -> staging -> production
| |
vX.Y.Z-rc.N vX.Y.Z
```
Changes reach `staging` and `production` through promotion pull requests; those
branches do not accept direct development commits. A staging tag must point to
a commit reachable from `staging` and uses `vX.Y.Z-rc.N`. A production tag must
point to a commit reachable from `production` and uses `vX.Y.Z`.
Channels are small, mutable pointers to immutable qualified bundles:
- `production` points to the newest promoted stable tag;
- `staging` points to the newest promoted release-candidate tag; and
- `main` is not a release channel; its commits are installable snapshots.
Channel updates are serialized and monotonic. Moving a channel to an older
release requires a distinct, explicit rollback operation that records the
reason and target. Deleting or moving a Git tag does not mutate a published
bundle or channel silently.
Promotion reuses the exact bundle selected by source SHA. It does not rebuild
the wheel, OCI images, or Firecracker artifacts. Thus production runs the same
bytes qualified in staging.
### Publication workflows
`publish-artifacts` is manually dispatchable for any branch, tag, or commit.
It resolves the input to a source SHA, checks out that SHA, builds and publishes
all infrastructure artifacts, generates the bundle index and embedded
manifest, builds the wheel, installs the wheel in a clean environment, and
publishes the verified wheel. It does not create a release or update staging or
production. Its output is an unqualified snapshot and publication is
serialized per source SHA.
After required CI succeeds, `main` may invoke the same publication operation
automatically. Manual publication remains available so a branch can be tested
before merge. Automatic and manual runs converge on the same idempotent bundle.
The release workflow is triggered by an eligible staging or production tag:
1. Resolve and validate the tag, source SHA, tag shape, and branch reachability.
2. Run the complete pre-release suite against that SHA, including Docker,
Firecracker, and Apple Container.
3. After the suite passes, ensure the immutable commit bundle exists. Build it
then if absent; otherwise verify every existing identity and checksum.
4. Install using the published installer and bundle index on clean supported
hosts, and run the post-install smoke test against the acquired artifacts.
5. Publish an immutable qualified tag pointer, create the Gitea release, and
advance the matching channel pointer.
No release artifact or channel update occurs before qualification succeeds.
Jobs use per-SHA and per-channel concurrency locks so concurrent dispatches
cannot publish twice or race a promotion.
### Installer selection and warnings
The installer installs the latest `production` channel by default. It accepts:
- `BOT_BOTTLE_CHANNEL=production` or `staging` to select the latest release in
that channel;
- `BOT_BOTTLE_REF=<40-character-sha>` to install an exact commit bundle; and
- `BOT_BOTTLE_VERSION=<tag>` to install an exact qualified release.
Selectors are mutually exclusive. Branch names are accepted by the publication
workflow but not by the installer because they are mutable; callers resolve
and publish them first, then install the reported SHA.
An exact-commit install prints a prominent warning that the snapshot may not
have passed release qualification, even if the same SHA is later associated
with a tag. A tag or channel install suppresses that warning only when its
validated pointer is marked qualified and selects the same immutable bundle
SHA.
The installer downloads the wheel named by the external index, verifies its
SHA-256 before invoking pipx or pip, and reports the selected channel/tag,
source SHA, and bundle identity. It never builds a Git checkout for a packaged
install and never trusts a mutable channel response without resolving it to an
immutable bundle index.
### Backend-neutral lifecycle
Rename the host-side control-plane lifecycle operation from `ensure_built()` to
`ensure_available()`. The production meaning is "make the package-selected
artifact locally available and verified." Infrastructure composers call it
before starting either plane.
The gateway receives the same contract so startup does not continue to rebuild
the adjacent half of the fixed infrastructure pair.
A source checkout is itself a development boundary and selects the existing
builders. `BOT_BOTTLE_INFRA_BUILD=local` provides the same explicit override
for release debugging. Both paths deliberately bypass release metadata.
Packaged production startup never silently falls back from a failed pull to a
local build.
### Docker
The Docker backend runs `docker pull` with each digest-qualified manifest
reference. Docker may reuse its content-addressed local store. Containers are
created from the immutable reference, and the running container's image ID is
compared with the locally resolved ID for currentness.
The orchestrator source-hash label is replaced by a release-identity label.
The label is diagnostic; the image ID comparison remains authoritative.
### Apple Container
The Apple Container backend pulls the same multi-architecture OCI references.
Because Apple Container's accepted syntax for pull, tag, inspect, and run is
not identical to Docker's, one utility owns normalization from a digest
reference to the local runnable name. Integration coverage on the macOS runner
guards that adapter.
Production orchestrator launch removes the build-root bind mount, `PYTHONPATH`,
and `BOT_BOTTLE_SOURCE_HASH`. It runs only the code baked into the pulled
image. Currentness is based on the packaged release identity/image ID.
### Firecracker
The publishing tool may continue computing content-derived artifact versions;
that is a producer concern. Runtime launch instead reads the version and
expected compressed checksum from the release manifest.
The artifact cache validates the packaged expected checksum even when a
previous `.verified` marker exists. The marker records the checksum it
validated, rather than an unqualified `ok`, so changing package metadata cannot
adopt an artifact verified against a different expectation.
The existing candidate-directory and explicit local-build paths remain for CI
and development. Candidate metadata must match the packaged identity unless
the caller selected local development mode.
### Overrides and failure behavior
`BOT_BOTTLE_ORCHESTRATOR_IMAGE` and `BOT_BOTTLE_GATEWAY_IMAGE` remain useful
for testing but production accepts them only when they are digest-qualified.
Mutable tag overrides require explicit local-build mode.
Artifact acquisition errors identify the packaged reference and backend. No
backend catches an acquisition failure and rebuilds from local source.
## Testing strategy
- Unit-test manifest parsing, immutability validation, missing-manifest errors,
package-data inclusion, and deterministic build-hook injection.
- Assert Docker and Apple production preparation pulls and never builds.
- Assert explicit local mode retains existing build behavior.
- Assert the Apple orchestrator command has no source bind mount or
`PYTHONPATH`.
- Assert running-image mismatches recreate infrastructure while matching,
healthy instances are retained.
- Assert Firecracker uses packaged versions/checksums and rejects mismatches in
downloaded, cached, and candidate artifacts.
- Extend wheel-install coverage to inspect the installed manifest.
- Run the full unit and Docker integration gates; run advisory Firecracker and
macOS integration before publishing a release.
- Test ref resolution, tag-shape and branch-reachability enforcement, bundle
idempotency, partial-publication rejection, and per-SHA concurrency.
- Test installer channel, tag, and exact-commit selection; checksum rejection;
mutually exclusive selectors; and snapshot warning behavior.
- Test that release qualification happens before publication and that a failed
suite or clean-install smoke test cannot create a release or move a channel.
- Test promotion reuses the qualified bundle byte-for-byte and that monotonic
channel movement rejects an accidental rollback.
## Rollout
Land runtime, installer, bundle publication, and release qualification support
together, but do not move the production installer away from its current
source-install path until an end-to-end staging release has passed on Docker,
Firecracker, and Apple Container.
Create and protect `staging` and `production`, then promote one commit from
`main` through both branches. Publish its commit bundle, qualify an RC tag,
perform clean installs through the staging channel, promote the same SHA, and
qualify a stable tag. Until that first stable qualification exists, the
installer's production default fails closed because there is no production
pointer. The stable promotion must demonstrate that every production artifact
identity matches the staging-qualified bundle.
Existing source checkouts use explicit local mode. Existing mutable local
images are ignored by production acquisition and may be pruned independently.
Snapshot retention and rollback procedures must be documented before automatic
publication from every successful `main` commit is enabled.
+293 -8
View File
@@ -32,9 +32,17 @@ not a principled scope exclusion: both are major hosted sandbox platforms and
belong in this landscape even though they target platform builders rather than
bot-bottle's local single-operator workflow.
Updated 2026-07-27 after a scan of recent Show HN launches: **Black LLAB,
Eve, CloudRouter, Nucleus, yolo-cage, and Sandbox Agent SDK** added as a
dated entrant cohort. They sharpen the comparison on three axes the original
table underweighted: the browser/preview loop, parallel-agent operator UX, and
a provider-neutral automation/session API.
## Summary
The main table compares bot-bottle against fifteen isolation/sandbox tools.
The main table compares bot-bottle against fifteen canonical
isolation/sandbox tools; a later section evaluates six recent HN entrants
without widening an already unwieldy table.
Governance/pre-action authorization and credential-only layers are covered
separately because they don't provide VM or container isolation. None
duplicate bot-bottle's combination of local
@@ -542,6 +550,199 @@ them.
framework runtime is not compromised.
- **Maturity**: Specification + reference implementation, 2026.
## Recent HN entrants (added 2026-07-27)
These are grouped by launch date rather than promoted into the main table.
Several are young or sparsely documented, and putting them beside mature
runtime platforms with false precision would obscure the useful comparison.
The HN launch posts are the evidence snapshot; feature claims should be
rechecked against their repositories before relying on them for a security
decision.
### Black LLAB
- **Source**: https://github.com/isaacdear/black-llab ;
HN launch https://news.ycombinator.com/item?id=47402394
- **Isolation/locality**: Local Docker environment, with an isolated container
created for each agent task. Shared host kernel; no stronger boundary is
claimed.
- **Agent integration**: General local/cloud model workspace. Its headline is
dynamic routing of simple prompts to local models and complex prompts to
hosted models, with code execution and web scraping inside the task
container.
- **Network/credentials**: No default-deny egress, payload inspection, or
host-side credential injection documented in the launch.
- **Competitive read**: Superficial overlap ("a container per agent task"),
but not a direct security-policy competitor. Its useful challenge is the
integrated model-selection UX, which bot-bottle intentionally leaves to the
selected agent provider.
- **Maturity**: Early solo project; HN launch received 1 point.
### Eve
- **Source**: https://eve.new/ ;
HN launch https://news.ycombinator.com/item?id=47721255
- **Isolation/locality**: Managed, hosted Linux sandbox per user/session
(claimed 2 vCPU, 4 GB RAM, 10 GB disk), with filesystem, code execution,
headless Chromium, and service connectors.
- **Agent integration**: End-user OpenClaw-style agent product. An orchestrator
routes subtasks to specialist models and can run parallel subagents that
coordinate through a shared filesystem. Web UI and iMessage are primary
interaction surfaces.
- **Network/credentials**: Broad connectors are a product feature; the launch
does not document bot-bottle-style default-deny route policy, content DLP,
or credentials held outside the sandbox.
- **Competitive read**: Adjacent, not direct. Eve sells a managed colleague;
bot-bottle lets an operator run existing coding-agent CLIs under local
containment. Eve nevertheless demonstrates the appeal of background work,
live progress, browser capability, and mobile notification.
- **Maturity**: Commercial hosted product; HN launch received 71 points and
39 comments.
### CloudRouter
- **Source**: https://github.com/manaflow-ai/manaflow/tree/main/packages/cloudrouter ;
HN launch https://news.ycombinator.com/item?id=47006393
- **Isolation/locality**: Claude Code or Codex runs locally and provisions
remote cloud VMs/GPUs for execution. Project files are uploaded to the VM;
each machine exposes auth-protected VNC, VS Code, and Jupyter surfaces.
- **Agent integration**: A skill plus CLI lets the coding agent itself start,
command, inspect, and tear down machines. Browser automation is integrated,
including snapshots and screenshots. Parallel disposable compute is the
central workflow.
- **Network/credentials**: The launch emphasizes remote resource isolation and
authenticated UI endpoints, not default-deny guest egress, payload DLP, or
proxy-held application credentials.
- **Competitive read**: The closest recent workflow competitor. It directly
addresses parallel coding agents, environmental conflict, and closing the
browser/test loop, but trades local custody for elastic cloud compute.
Cloud VMs and GPUs could be a future bot-bottle backend; they do not replace
its manifest/policy layer.
- **Maturity**: Active open-source monorepo project; HN launch received
138 points and 36 comments.
### Nucleus
- **Source**: https://github.com/coproduct-opensource/nucleus ;
HN launch https://news.ycombinator.com/item?id=46855770
- **Isolation/locality**: Firecracker microVM with an enforcing MCP tool proxy.
- **Agent integration/config**: Compositional permission envelope for
read/write/run actions. The envelope is non-escalating and can tighten or
terminate, with scoped approval tokens for gated operations.
- **Network/credentials**: Default-deny egress, DNS allowlist, iptables drift
detection, time/budget caps, and hash-chained audit logging are claimed.
Remote append-only audit storage and attestation were roadmap items at
launch.
- **Competitive read**: Direct on security architecture, especially
non-escalating policy and tamper-evident audit. It is an early execution/tool
proxy rather than a provider-neutral, one-command coding-agent product. Its
tool-level action envelope is semantically finer than bot-bottle's network
boundary; bot-bottle is stronger on turnkey agent/provider integration,
credential custody, Git mediation, and long-running operator workflow.
- **Maturity**: Early OSS experiment; HN launch received 3 points.
### yolo-cage
- **Source**: https://github.com/borenstein/yolo-cage ;
HN launch https://news.ycombinator.com/item?id=46706796
- **Isolation/locality**: Local sandbox for running multiple coding agents in
YOLO mode. The launch discussion describes a VM boundary.
- **Agent integration**: Built around the native Claude Code experience and
motivated by running many agents in parallel without permission-prompt
fatigue.
- **Network/Git/credentials**: Strict egress filtering, configurable HTTP
middleware, and mediated `git`/`gh` dispatch are the main value. The launch
discussion explicitly identifies provider credential handling as unfinished
and difficult because Claude state spans multiple host paths.
- **Competitive read**: The closest new threat-model competitor. It shares
bot-bottle's premise that filesystem isolation alone is insufficient and
that Git plus authorized HTTP channels need mediation. bot-bottle currently
leads on cross-provider support, proxy-held Claude/Codex/forge credentials,
typed per-role manifests, content DLP, and supervision. yolo-cage's simpler
pitch and narrower Claude-first setup may be easier to explain.
- **Maturity**: Early local tool; HN launch received 60 points and 76 comments.
### Sandbox Agent SDK
- **Source**: https://github.com/rivet-dev/sandbox-agent ;
HN launch https://news.ycombinator.com/item?id=46795584
- **Isolation/locality**: Does not provide the isolation primitive. It runs
inside E2B, Daytona, Modal, Cloudflare Containers, Agent Computer, BoxLite,
Docker, or another sandbox provider. Embedded mode can also run locally
without a sandbox.
- **Agent integration**: Provider-neutral Rust server/SDK exposing a common
HTTP/SSE/OpenAPI interface across Claude Code, Codex, OpenCode, Cursor, Amp,
and Pi, plus a universal event/session schema for external storage and
replay. It also exposes filesystem, managed-process, terminal, MCP, skills,
custom-tool, and computer-use APIs. TypeScript is the primary SDK surface.
- **Network/credentials**: Delegated to the chosen sandbox provider.
- **Credential posture**: Its documented convenience command extracts real
OpenAI/Anthropic credentials from local agent configuration and passes them
as environment variables into the sandbox. That is materially weaker than
bot-bottle's host-side credential custody, but it is an integration choice,
not a structural limitation: a sandbox provider could put a credential
proxy underneath the same SDK.
- **Competitive read**: A serious architectural threat despite not supplying
isolation. Sandbox Agent is trying to standardize the boundary *above* the
sandbox: one client protocol, session model, and UI/control surface across
every coding agent and runtime. If that boundary becomes the ecosystem
standard, users and application builders may choose a sandbox provider plus
Sandbox Agent rather than a vertically integrated launcher. bot-bottle's
manifests would then be valuable chiefly as a local policy/backend
implementation unless they expose an equally usable control contract.
- **Maturity**: Apache 2.0, ~1.5k stars and 426 commits at the 2026-07-27
check; HN launch received 41 points.
#### Why the Sandbox Agent architecture is strategically different
The manifest and the universal control protocol solve different layers:
- A bot-bottle manifest is a **trusted launch-time policy composition**. It
selects the agent role, isolation backend, image, skills, egress routes,
credentials, Git mediation, and supervision policy. Crucially, identity and
secret references live on the host side of the trust boundary.
- Sandbox Agent is a **runtime control and observation protocol**. A remote
client creates sessions, sends messages, handles permissions, configures
skills/MCP, manipulates files/processes/desktops, and streams normalized
events. It deliberately delegates sandbox lifecycle, Git management,
storage, network policy, and credential security to other products.
That makes it complementary in a component diagram but competitive in product
architecture. The layer that becomes the stable integration point tends to own
the ecosystem. Three plausible threat paths matter:
1. **Standard control plane, interchangeable runtimes.** Applications integrate
once with Sandbox Agent and treat E2B, Daytona, BoxLite, Docker, or a future
local microVM as replaceable compute. A provider that bundles adequate
egress and credential custody makes bot-bottle's end-to-end launcher less
necessary.
2. **Policy grows upward.** Sandbox Agent already configures permissions,
skills, MCP, custom tools, filesystem/process access, and computer use. If
it adds a declarative, host-verifiable policy document, the overlap with
agent/bottle manifests becomes substantial even if enforcement remains
delegated.
3. **UI and session ownership.** Its universal transcript schema, Inspector,
React components, event replay, and remote terminal/computer APIs can become
the natural basis for desktop, web, and mobile agent managers. bot-bottle's
security layer could remain stronger while losing the operator surface and
distribution channel.
The counter-position is not to claim that manifests and an API are mutually
exclusive. The defensible split is:
- bot-bottle owns the trusted policy and enforcement plane outside the agent;
- a provider-neutral protocol owns agent process control and normalized
events; and
- the operator UI consumes both.
This suggests an explicit compatibility decision rather than parallel,
accidental protocol design: evaluate running Sandbox Agent inside a bottle and
exposing it only through the authenticated bot-bottle control plane. If its
schema is suitable, adopting it could turn a threat into an integration while
keeping manifests as the higher-trust policy source. If it is unsuitable,
bot-bottle should still publish a stable provider-neutral session/event API so
frontends do not depend on Claude/Codex/Pi-specific process behavior.
## Comparison table
*Isolation/sandbox tools only. AGT and OAP are governance layers — see their per-project notes above.*
@@ -616,6 +817,70 @@ would be a *backend* bot-bottle could call, not a competitor to its
manifest layer. endo-familiar is in a different paradigm entirely:
capability passing rather than kernel boundaries.
**Recent entrants change two parts of this read.** yolo-cage is closer to the
actual threat model than agent-safehouse or litterbox: it combines a VM-style
boundary with mediated Git and filtered HTTP specifically for parallel coding
agents. Sandbox Agent SDK is the more important strategic entrant even though
it supplies no isolation. It can become the standard agent-control layer above
all of these runtimes, including a future bot-bottle backend. CloudRouter is
the clearest workflow challenge because its browser/desktop/GPU loop makes
parallel agents visibly more capable, not merely safer.
## Gap evaluation after the 2026-07-27 entrant scan
### Material gaps
1. **A stable provider-neutral control and event protocol.** This is the
largest newly visible gap. bot-bottle normalizes launch/provisioning across
providers, but an external UI or orchestrator still lacks one documented
contract for creating a Claude/Codex/Pi session, sending input, handling
permission/supervision events, streaming normalized output, reconnecting,
and replaying history. Sandbox Agent SDK addresses exactly this layer and
is already portable across many sandbox providers.
2. **Browser/preview closure.** CloudRouter and Eve make a browser or desktop
part of the standard agent environment and expose screenshots/live viewing
to the operator. bot-bottle can run dev servers and supports nested
containers, but it does not present a first-class browser/computer-use
primitive or an auth-protected preview surface. For coding agents expected
to verify UI work, this is a real product gap.
3. **Unified parallel-session operator UX.** Named persistent bottles and
supervision provide the substrate, but the recent products make task
switching, live progress, notifications, terminal attach, diffs, and
session history the product. Security depth will not compensate for a
visibly rougher daily loop.
4. **Normalized transcript persistence and replay.** bot-bottle preserves
provider-specific state for resume; it does not expose a provider-neutral
event record suitable for audit, replay, analytics, or a web/mobile client.
This is both a UX gap and an audit gap.
### Important, but not necessarily bot-bottle features
- **Cloud VM/GPU provisioning.** Valuable for elastic workloads and could be a
backend, but it conflicts with the local-custody default and should not
displace core policy work.
- **Automatic model routing.** Black LLAB and Eve sell task-to-model routing.
bot-bottle's provider-template boundary can host that choice without making
it part of the trusted sandbox policy.
- **A thousand SaaS connectors.** This broadens capability and blast radius.
The bot-bottle-native answer should remain explicit, scoped forge/egress
associations rather than connector count as a goal.
- **SDK-driven sandbox lifecycle as the primary configuration model.** Useful
for platform builders, but not a replacement for reviewable, host-owned
manifests. A control API and a declarative policy source are compatible;
neither should silently become the other.
### Areas where bot-bottle remains ahead
- real provider and forge credentials remain outside the agent process rather
than being extracted into its environment;
- authorized HTTP payloads are scanned, not merely destination-filtered;
- Git writes traverse a distinct gate with secret scanning and host-held
upstream credentials;
- role policy is host-owned, composable, and separate from untrusted repo
content; and
- local Firecracker/Apple Container execution preserves operator custody
without requiring a hosted sandbox platform.
## Borrowable ideas
### Already shipped or otherwise addressed
@@ -642,6 +907,19 @@ capability passing rather than kernel boundaries.
### Still worth considering
- **Sandbox Agent compatibility or an equivalent stable protocol (highest
priority):** spike running its server inside a bottle behind bot-bottle's
authenticated control plane. Compare its session/event schema, permission
model, restore semantics, and provider coverage with current provider
adapters. Adopt compatibility if it preserves the host-owned trust boundary;
otherwise specify bot-bottle's own stable API before building another UI.
- **First-class browser/preview loop** (from CloudRouter and Eve): give a
bottle an optional browser/computer-use capability plus an operator-visible,
authenticated preview/screenshot surface. Treat its network access as part
of the bottle policy, not an implicit bypass.
- **Provider-neutral transcript/event persistence** (from Sandbox Agent SDK):
retain enough normalized structure for replay and audit while preserving the
provider-native state needed for exact resume.
- **Live network activity in the supervisor TUI** (from Docker sbx): show
allowed and blocked connections and let the operator propose policy changes
from the existing supervision surface.
@@ -652,10 +930,11 @@ capability passing rather than kernel boundaries.
closer review. This needs a carefully specified trust model before it can be
more than a heuristic.
Not worth borrowing: the SDK-first programmatic API style of boxlite /
microsandbox (cuts against the declarative-manifest stance), and the
hosted-SaaS dashboard model of tilde.run (cuts against the
"infrastructure I control" goal).
Not worth borrowing: SDK-first *policy configuration* as used by boxlite /
microsandbox (cuts against the reviewable declarative-manifest stance), and
the hosted-SaaS custody model of tilde.run (cuts against the "infrastructure I
control" goal). A provider-neutral runtime-control API is a separate concern
and is worth borrowing.
## Publishing and positioning verdict
@@ -679,9 +958,15 @@ bot-bottle remains unusual in combining:
The practical wedge is “as easy as native yolo, with declarative role policy
and self-hosted custody,” including scoped access to private LAN/Tailnet
services that cloud-first runtimes cannot provide without additional network
plumbing. The main competitive risks are a local wrapper such as claudebox or
Docker sbx growing a role-manifest layer, and GUI products such as SuperHQ
adding equivalent policy and audit depth.
plumbing. The main competitive risks are now:
- a local wrapper such as yolo-cage, claudebox, or Docker sbx growing a
role-manifest and credential-custody layer;
- Sandbox Agent SDK becoming the standard control/session boundary and making
the runtime beneath it interchangeable; and
- GUI products such as SuperHQ or CloudRouter adding equivalent policy and
audit depth before bot-bottle closes the browser/preview and
parallel-session UX gaps.
## Caveats
+6 -6
View File
@@ -6,7 +6,7 @@ Can bot-bottle grow a built-in supervisor — TUI inventory plus PR-feedback rou
## Context
bot-bottle today is a fleet *executor*: `./cli.py start <agent>` brings up one bottle (agent container + pipelock + optional git-gate + optional cred-proxy on a per-bottle internal network), and `cli.py` tears it down when the session ends. There is no inventory view, no idle-detection, no automated reaction to PR or CI events. In parallel use, a human is the supervisor — opening one terminal per bottle, switching between them, and watching upstream PR state by hand.
bot-bottle today is a fleet *executor*: `bot-bottle start <agent>` brings up one bottle (agent container + pipelock + optional git-gate + optional cred-proxy on a per-bottle internal network), and `cli.py` tears it down when the session ends. There is no inventory view, no idle-detection, no automated reaction to PR or CI events. In parallel use, a human is the supervisor — opening one terminal per bottle, switching between them, and watching upstream PR state by hand.
A separate survey of the broader ecosystem ([agent control dashboards research, mid-2026](https://gitea.dideric.is/didericis/consilium-research/src/branch/main/developer-workflow/agent-control-dashboards-2026-05-24.md)) sorts dashboards into five tiers (session managers, parallel runners, Kanban boards, mission-control SPAs, observability backends). The earlier first-pass conclusion was that a full SPA tier conflicts with bot-bottle's isolation model. This doc reconsiders the smaller question: a TUI supervisor in the existing Python CLI.
@@ -25,13 +25,13 @@ A supervisor doesn't have to be heavy. A TUI built into the existing Python CLI,
Three layers, each independently useful, in order of ambition:
### 1. `./cli.py status` — read-only inventory
### 1. `bot-bottle status` — read-only inventory
Reads `docker ps` filtered by a bottle label and tails each bottle's session log. Reports per bottle: name, agent, uptime, last-activity timestamp, token spend if available, associated PR/branch if recorded.
No new daemons. No new ports. No new credentials. ~100 lines.
### 2. `./cli.py watch` — TUI over the same data
### 2. `bot-bottle watch` — TUI over the same data
Same data as `status`, rendered with auto-refresh and keyboard shortcuts that shell out to the existing `cli.py attach / stop / start` commands.
@@ -39,7 +39,7 @@ Library choice: prefer the stdlib `curses` module to stay stdlib-first; fall bac
This is the Claude Squad / tmux-agent-status pattern, applied to bottles instead of tmux sessions. The whole category exists *because* a TUI is the lightweight shape that doesn't require what the SPA tier requires.
### 3. `./cli.py supervise` — PR feedback router
### 3. `bot-bottle supervise` — PR feedback router
The optional, more ambitious layer. The bottle manifest gains an optional field:
@@ -49,7 +49,7 @@ pr_watch:
branch: agent/task-42
```
`./cli.py supervise` polls the named upstream for new review comments and CI failures on `branch`. When one fires, it surfaces as a desktop notification or a flash in the TUI. The human decides what to do with the feedback — there is no autonomous loop that feeds the comment back into a bottle's next prompt (see "Where to be conservative" for why).
`bot-bottle supervise` polls the named upstream for new review comments and CI failures on `branch`. When one fires, it surfaces as a desktop notification or a flash in the TUI. The human decides what to do with the feedback — there is no autonomous loop that feeds the comment back into a bottle's next prompt (see "Where to be conservative" for why).
The polling token is a **host** token (the same `GH_PAT` / Gitea token the host already keeps in shell env), not a bottle credential. The supervisor never holds bottle secrets.
@@ -62,7 +62,7 @@ The load-bearing question is whether the supervisor introduces the privileged-ch
| Reaching into running bottles | Supervisor reads `docker ps` and host-side log files. The host already sees both — Docker is the trust boundary, the supervisor is on the host side of it. |
| Holding bottle credentials | The polling token is a host token. The supervisor never receives `bottle.cred_proxy.routes` entries; it has no path to them. |
| Bridging between bottles | The supervisor does not relay state from bottle A to bottle B. It relays *upstream PR state* to a bottle's next prompt — and only if the manifest opts in. |
| New attack surface | All "control" actions go through `./cli.py start <agent>`, which already enforces the manifest. The supervisor is an automated caller of the existing CLI, not a parallel control plane. |
| New attack surface | All "control" actions go through `bot-bottle start <agent>`, which already enforces the manifest. The supervisor is an automated caller of the existing CLI, not a parallel control plane. |
The boundary stays at the bottle wall. The supervisor looks outward at git/PR state and downward at Docker; it does not look *inward* through pipelock.
@@ -13,11 +13,11 @@ What's the cheapest path to that, and where does it bottom out?
## What "interact" means
Today the flow is bimodal. `./cli.py start <agent>` brings the
Today the flow is bimodal. `bot-bottle start <agent>` brings the
bottle up and immediately drops you into an interactive
`docker exec -it bot-bottle-<slug> claude ...` — claude-code
owns the whole terminal until you Ctrl-D out, at which point the
bottle tears down. The dashboard (`./cli.py dashboard`) is a
bottle tears down. The dashboard (`bot-bottle dashboard`) is a
*separate* invocation that watches across bottles but never
exposes the claude TUI itself.
@@ -107,7 +107,7 @@ What's not good:
This is the v1 the project's existing code-shape strongly
prefers. It clears the bar of "let me talk to claude-code
without quitting `./cli.py dashboard`."
without quitting `bot-bottle dashboard`."
## Option 2: Embedded emulator
+2 -2
View File
@@ -84,7 +84,7 @@ dangerous changes before they left*.
(`supervise_gitleaks_allow`, [`git_gate_render.py`](../../bot_bottle/git_gate_render.py)).
Extend the same flow to **high-risk file classes**: any commit touching
CI/build/deploy scripts, auth/crypto code, egress config, or
adding/changing dependencies → route to `./cli.py supervise`. This is
adding/changing dependencies → route to `bot-bottle supervise`. This is
attribution/policy, not detection, and it's the strongest thing here —
a human on exactly the temporal-escape surfaces.
4. **LLM semantic diff-review — the behavioral backstop.** The only
@@ -146,7 +146,7 @@ preserves the bottom-up distribution funnel.
**governed code-egress review**, not "we resell inference" (the
monetization notes explicitly warn against reselling compute).
- **The web-console supervise/review flow — the strongest anchor.** Turn
the CLI `./cli.py supervise` approval into a real review surface:
the CLI `bot-bottle supervise` approval into a real review surface:
rendered diff + finding context, approve/reject, **who-approved audit
trail, RBAC on approvers, mobile/phone-control** (ties to the
dashboard/vault north star). This is "central enforcement +
@@ -100,7 +100,7 @@ resolver globs each directory.
becomes file ops (mkdir, mv, rm) instead of editing one file.
Power users prefer that; new users may not.
- Discovery requires `ls`, not "grep one file." Tooling helps
(e.g. `./cli.py list`) but the manifest is no longer a single
(e.g. `bot-bottle list`) but the manifest is no longer a single
artifact to email or ship.
- Atomicity: swapping a bottle name across agents touches
multiple files. Git handles this fine; a one-shot text editor
@@ -364,7 +364,7 @@ wins; none of the body-prose or dependency story.
warns / ignores / breaks. If it warns, we'd want a different
field name (e.g. `bot-bottle-bottle`) or a namespaced block.
- **Migration story.** Is the project willing to ship a one-shot
`./cli.py migrate-manifest` command that does the JSON → MD
`bot-bottle migrate-manifest` command that does the JSON → MD
conversion? Or do users just rewrite by hand from the new docs?
- **Bottle file body content.** If most bottle .md files have an
empty body, is the MD-with-frontmatter format still warranted?
+1 -1
View File
@@ -34,7 +34,7 @@ on top of working onboarding.
A first-time user today goes through five steps: install Docker,
install `uv`, set `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`, write
`bot-bottle.json`, run `./cli.py start`. One of those is
`bot-bottle.json`, run `bot-bottle start`. One of those is
"author a JSON manifest." Polished tools in this category let
users skip that step on day one. The fix is an `init` subcommand
that drops a working `bot-bottle.json` with a default `coder`
+1 -1
View File
@@ -131,7 +131,7 @@ The minimum-viable workflow, no bot-bottle code changes:
3. SSH in.
4. `git clone` bot-bottle on the VM, drop a manifest in place,
inject `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` via the provider's secrets path.
5. `./cli.py start <agent>` — the existing launcher handles the rest.
5. `bot-bottle start <agent>` — the existing launcher handles the rest.
6. On exit: destroy the VM. No host artifacts persist.
For the "VPN pivot" failure mode, see
@@ -0,0 +1,536 @@
# Sandbox Agent SDK and bot-bottle: protocol versus product
This note asks whether [Sandbox Agent SDK](https://github.com/rivet-dev/sandbox-agent)
and bot-bottle compete for the same architectural layer, whether bot-bottle
can productize the turnkey ecosystem/DX layer above it, and how far the
Docker/OCI analogy actually holds.
Research conducted 2026-07-27. Sandbox Agent SDK was at the `0.4.x` line,
Apache 2.0, and documented support for Claude Code, Codex, OpenCode, Cursor,
Amp, and Pi at the time of review.
## Summary
**The projects are complementary at the component boundary and competitive at
the product boundary.** Sandbox Agent SDK normalizes how software controls a
coding-agent process inside an arbitrary sandbox. bot-bottle decides what
sandbox to create, what trusted role and policy it receives, how credentials
and Git access cross the boundary, how traffic is constrained, and how an
operator launches and supervises the result.
The Docker analogy is useful with one correction:
- Sandbox Agent SDK is not equivalent to Linux container APIs or OCI itself.
It is closer to a **containerd shim plus a portable exec/session API for
coding agents**. It adapts incompatible agent processes to one HTTP/SSE
contract.
- A future independent agent-session specification would be the closer OCI
analogue.
- bot-bottle can credibly occupy the **Docker Engine / Compose / Desktop**
layer: packaging, policy composition, lifecycle, networking, credentials,
storage, operator UX, and a one-command experience above interchangeable
agent adapters and isolation runtimes.
That is a viable position, but “turnkey wrapper” undersells it. A thin wrapper
is replaceable. The valuable product is a **turnkey, policy-first coding-agent
runtime** whose manifest compiles trusted operator intent into multiple
enforcement planes. Sandbox Agent SDK may be one internal process-control
component of that product.
The recommended direction is:
1. Keep the bot-bottle manifest as the host-owned source of trusted policy.
2. Spike Sandbox Agent SDK as the in-bottle provider/session adapter.
3. Expose a stable, provider-neutral bot-bottle control API, compatible with
Sandbox Agent where practical.
4. Keep security decisions and authoritative audit outside the sandbox.
5. Build the ecosystem around policy packs, agent images, skills, backends,
operator UI, and trusted integrations—not around a proprietary transcript
protocol.
## What each project is today
### Sandbox Agent SDK
Sandbox Agent is a Rust server that runs alongside the coding agent. A client
connects over HTTP, streams events over SSE, and uses one API across agent
implementations. Its documented surface includes:
- creating and restoring agent sessions;
- sending messages and streaming normalized events;
- handling permissions;
- configuring MCP servers, skills, and custom tools;
- filesystem and managed-process APIs;
- interactive terminal access;
- computer-use/desktop operations;
- a universal session/transcript schema;
- an Inspector UI, React components, CLI, TypeScript SDK, and OpenAPI spec.
It can run in embedded mode or inside E2B, Daytona, Modal, Cloudflare
Containers, Agent Computer, BoxLite, Docker, and other environments. It
explicitly leaves these concerns to the caller or sandbox provider:
- sandbox creation and lifecycle;
- Git repository management;
- durable session storage;
- network policy;
- isolation strength; and
- secure credential delivery.
Its documented credential convenience path extracts real provider credentials
from local agent configuration and passes them into the sandbox environment.
That is convenient but is not an acceptable security boundary for bot-bottle.
Sources:
- [Sandbox Agent repository and architecture](https://github.com/rivet-dev/sandbox-agent)
- [Sandbox Agent documentation](https://sandboxagent.dev/docs)
- [HTTP API](https://sandboxagent.dev/docs/api-reference)
- [Universal session/transcript schema](https://sandboxagent.dev/docs/session-transcript-schema)
### bot-bottle
bot-bottle is a host-side launch, policy, and enforcement system for existing
coding-agent CLIs. Its current architecture includes:
- agent and bottle manifests with composition via `extends:`;
- a host-only trust boundary for roles, identity, and secret references;
- provider templates and plugins for Claude Code, Codex, Pi, and custom
providers;
- Firecracker on KVM Linux and Apple Container on macOS, with Docker fallback;
- image construction and provider-specific provisioning;
- default-deny inspected egress with path/method/header policy;
- payload DLP on authorized channels;
- real credentials held outside the agent and injected by the gateway;
- Git mediation, upstream credential custody, and gitleaks scanning;
- a per-host authenticated orchestrator and shared gateway;
- named bottle lifecycle, resume, supervision, and audit state; and
- a CLI/TUI intended to make full-permission agents operationally tolerable.
The provider layer currently normalizes launch-time concerns—command, image,
prompt delivery, files, skills, environment, verification, and provider-owned
egress routes. It does **not** yet expose a stable provider-neutral runtime
contract for sessions, messages, transcripts, terminals, or normalized events.
That is the gap Sandbox Agent directly illuminates.
Sources in this repository:
- [`README.md`](../../README.md)
- [`0070-per-host-orchestrator.md`](../prds/0070-per-host-orchestrator.md)
- [`0026-agent-provider-templates.md`](../prds/0026-agent-provider-templates.md)
- [`0053-user-provider-plugins.md`](../prds/0053-user-provider-plugins.md)
- [`agent_provider.py`](../../bot_bottle/agent_provider.py)
## The layer model
The cleanest architecture has four layers:
| Layer | Responsibility | Likely owner |
|---|---|---|
| Operator product | Install, select a role, launch, observe, intervene, resume, review changes | bot-bottle |
| Trusted policy and lifecycle | Compose manifest, choose backend/image, hold credentials, enforce egress/Git, persist authoritative audit | bot-bottle |
| Agent control protocol | Start provider process, create session, send input, stream normalized events, terminal/computer operations | Sandbox Agent or a compatible protocol |
| Isolation primitive | VM/container/process boundary, filesystem, CPU/memory, networking substrate | Firecracker, Apple Container, Docker, E2B, Daytona, BoxLite, etc. |
The important boundary is between trusted policy/lifecycle and agent control.
The agent-control daemon runs in the environment being treated as untrusted.
It can report what the agent says happened, but it cannot authoritatively prove
that policy was enforced. Egress decisions, credential custody, Git scanning,
bottle identity, and security audit must remain outside it.
### Proposed composition
```text
operator UI / CLI / API
|
v
bot-bottle orchestrator (trusted)
- resolves manifest
- owns bottle identity and lifecycle
- stores authoritative audit
- authenticates clients
|
+--------------------------+
| |
v v
isolation backend shared gateway (trusted)
Firecracker / Apple / Docker - egress policy + DLP
| - credential injection
| - Git mediation
v
bottle / guest (untrusted)
- Sandbox Agent server
- Claude Code / Codex / Pi subprocess
- workspace, skills, MCP configuration
```
The bot-bottle manifest would compile into both sides:
- **outside the bottle:** backend, network, egress, credentials, Git,
supervision, identity, and authoritative lifecycle;
- **inside the bottle:** selected provider, prompt, skills, MCP configuration,
startup arguments, and non-secret session metadata.
Sandbox Agent should never receive real secrets merely because its API offers
a credential extraction helper. Provider and forge requests should continue
to use bot-bottle's placeholder/proxy pattern.
## How accurate is the Docker/OCI analogy?
### The useful part
The container ecosystem separates low-level execution from a product that
ordinary developers operate. OCI defines interoperable image, runtime, and
distribution specifications. Docker Engine adds a daemon, API, CLI, object
model, images, networks, volumes, and lifecycle; Docker Desktop and related
products add installation, updates, UI, integrations, policy, and team
workflows.
The same separation can exist for coding agents:
| Container ecosystem | Agent-sandbox ecosystem |
|---|---|
| OCI/runtime contract | A future open agent session/event contract |
| `runc` / runtime adapter | Claude/Codex/Pi adapter |
| containerd shim and task/exec API | Sandbox Agent server and HTTP/SSE session API |
| containerd / CRI-style lifecycle | Sandbox-provider lifecycle APIs |
| Docker Engine / Compose | bot-bottle orchestrator + manifests + backends + gateway |
| Docker Desktop / Hub ecosystem | bot-bottle desktop/mobile UX, policy packs, agent images, skills, trusted integrations |
Sandbox Agent makes coding-agent processes portable in roughly the way a shim
makes runtimes consumable through a common lifecycle interface. bot-bottle can
make the entire safe-agent system usable without asking the operator to
assemble that plumbing.
Official container references:
- [Open Container Initiative](https://opencontainers.org/)
- [OCI Runtime Specification](https://github.com/opencontainers/runtime-spec)
- [Docker Engine architecture](https://docs.docker.com/engine/)
- [Docker alternative runtimes and containerd shims](https://docs.docker.com/engine/daemon/alternative-runtimes/)
### Where the analogy breaks
1. **Sandbox Agent is an implementation, not an independent standard.**
Its OpenAPI document is public, but the project currently owns the server,
adapters, schema, and evolution. OCI is an independently governed set of
specifications with multiple implementations.
2. **It sits above, not below, the isolation boundary.** Linux namespaces,
cgroups, VMs, and OCI runtimes create the boundary. Sandbox Agent controls a
process after some other system has created that boundary.
3. **It reaches into product territory.** Inspector, React components,
computer-use APIs, skills/MCP configuration, transcripts, and restoration
are not merely low-level primitives. Sandbox Agent can continue growing
upward into the same UI and orchestration space bot-bottle might occupy.
4. **Coding agents are semantically uneven.** Normalizing a container
lifecycle is easier than claiming full behavioral parity across Claude
Code, Codex, Cursor, Amp, OpenCode, and Pi. A universal schema can become a
lowest common denominator or accumulate provider-specific escape hatches.
5. **The security contract is not standardized.** An agent-session API says
little about whether credentials are visible, egress is controlled, Git is
mediated, or audit is trustworthy. Those are core bot-bottle concerns.
The positioning should therefore say “Docker-like product layer above an open
agent-control protocol,” not “Sandbox Agent is OCI” or “bot-bottle implements
OCI for agents.”
## Can bot-bottle be the turnkey product layer?
Yes, if it owns substantially more than launch syntax.
The turnkey promise is:
> Choose a trusted role, point it at a project, and run any supported coding
> agent with full permissions. bot-bottle builds the environment, isolates it,
> supplies only the capabilities it needs, keeps credentials outside, mediates
> external writes, and gives the operator one place to watch and intervene.
That product has several defensible jobs:
### 1. Packaging and reproducibility
- provider and toolchain images;
- pinned, verified build inputs;
- skills and MCP configuration;
- role/bottle composition;
- cached startup and portable environment definitions; and
- compatibility testing across agents and backends.
### 2. Trusted policy compilation
The manifest is valuable because one reviewable document compiles into:
- an isolation plan;
- gateway routes and DLP policy;
- credential slots;
- Git-gate repositories and identities;
- provider configuration;
- supervision behavior; and
- operator-facing preflight.
Sandbox Agent's runtime configuration does not replace this. The policy must
be resolved before an untrusted guest or agent-control daemon exists.
### 3. Security enforcement
- dedicated-kernel isolation where available;
- no direct guest route to the internet;
- credentials injected outside the agent;
- content inspection on allowed destinations;
- Git secrets scanning and upstream-key custody;
- fail-closed policy resolution; and
- authoritative host-side audit.
This is the strongest current differentiation from a generic
“Sandbox Agent + Docker/E2B” assembly.
### 4. Lifecycle and operations
- install and host preflight;
- image build/update;
- start, stop, resume, cleanup, and migration;
- concurrent named agents;
- state recovery after crashes;
- live supervision and policy remediation; and
- backend selection without changing the role definition.
### 5. Ecosystem and DX
A product layer can support:
- curated provider images;
- signed policy/bottle packs;
- reusable role templates;
- skills and MCP bundles;
- backend plugins;
- an authenticated desktop/web/mobile operator client;
- browser/preview integration;
- normalized transcripts and change review; and
- team policy distribution and compliance exports.
The analogy to Docker is strongest here: users adopt the coherent workflow and
ecosystem, not because the low-level process API is proprietary.
## Business and product positioning
“Turnkey wrapper” is understandable internally but weak externally. It implies
that the hard work lives underneath and that another wrapper can replace it.
Prefer one of:
- **The policy-first runtime for coding agents**
- **Run any coding agent with full permissions, without giving it your host or
credentials**
- **A turnkey local control plane for isolated coding agents**
- **Docker-like packaging and operations for coding agents, with the security
boundary outside the agent**
The open/product split could resemble the container ecosystem:
### Open foundation
- manifest schema and composition;
- local CLI and core orchestrator;
- provider adapters;
- Firecracker/Apple Container/Docker backends;
- gateway policy format and enforcement;
- Sandbox Agent compatibility;
- local audit and supervision; and
- conformance tests for providers/backends/policy.
### Productizable ecosystem/DX
- polished desktop and mobile clients;
- fleet/remote-host management;
- signed and curated role/image/policy registry;
- team policy distribution and administrative controls;
- durable searchable transcripts and audit exports;
- SSO, RBAC, retention, and tamper-evident audit;
- managed update/compatibility channels;
- remote browser/preview relay;
- enterprise support; and
- optional managed build/cache infrastructure.
OCI itself is not the thing Docker sells. Interoperability expands the market;
the product captures value through reliable packaging, workflow, distribution,
management, and trust. bot-bottle should follow that logic rather than trying
to make its session protocol the moat.
## Strategic threat from Sandbox Agent
Sandbox Agent is a real threat for three reasons:
1. **It can become the integration default.** A frontend or agent platform can
integrate one API and choose among many agents and sandbox vendors.
2. **It can own session data and UI.** The universal event schema, Inspector,
React components, restoration, terminal, and computer-use APIs give it a
natural path toward the operator surface.
3. **Sandbox providers can move upward.** If E2B, Daytona, BoxLite, or another
runtime combines Sandbox Agent with adequate network policy and credential
custody, it can offer much of the turnkey stack.
The threat is not that its manifest syntax is better. It currently has no
equivalent trusted policy composition. The threat is that **the ecosystem may
standardize around its API before bot-bottle has a stable external control
surface**. In that world bot-bottle is evaluated as one sandbox provider,
while the SDK and its consumers own the user relationship.
## Why bot-bottle can still win its layer
Sandbox Agent's scope exclusions align with bot-bottle's deepest work:
- it does not choose or operate the sandbox provider;
- it does not mediate Git;
- it does not own network policy;
- it does not securely deliver credentials;
- it does not durably store sessions; and
- it cannot make guest-generated telemetry authoritative.
Those are not incidental features. Together they define the trusted system
around an untrusted coding agent. bot-bottle also has a narrower and coherent
initial customer: a developer or small operator who wants existing agent CLIs
to run locally with broad permissions and bounded consequences.
The durable advantage is therefore:
> Sandbox Agent makes agents controllable. bot-bottle makes them safe and
> operable.
That sentence remains true only if bot-bottle closes its operator-DX gaps.
Security without a browser/preview loop, stable API, normalized session view,
and good parallel-task UX risks becoming an invisible backend feature.
## Integration options
### Option A — Embed Sandbox Agent inside each bottle
bot-bottle launches Sandbox Agent as the provider process supervisor and
connects it to the host orchestrator through a bottle-scoped authenticated
channel.
**Benefits**
- immediate provider-neutral session API;
- more supported agents;
- normalized streaming and transcripts;
- terminal, filesystem, process, and computer-use primitives;
- Inspector/React ecosystem; and
- less provider-specific reverse engineering in bot-bottle.
**Risks**
- `0.x` API/schema churn;
- extra binary and release-supply-chain dependency;
- lowest-common-denominator normalization;
- conflict with provider-native resume state;
- an in-guest daemon is attacker-controlled after guest compromise;
- duplicate orchestration responsibilities; and
- upstream can move into policy/lifecycle and compete more directly.
**Security rule**
Treat every event and state claim from Sandbox Agent as untrusted telemetry.
Never delegate egress authorization, credential release, bottle identity,
authoritative audit, or Git policy to it.
### Option B — Implement a Sandbox Agent-compatible endpoint
bot-bottle maps the external protocol onto its existing provider adapters and
process model without running the upstream server.
**Benefits**
- ecosystem compatibility with tighter component control;
- no in-guest daemon dependency; and
- room to preserve bot-bottle-native lifecycle semantics.
**Risks**
- large and continuing compatibility burden;
- “full feature coverage” is expensive across all providers;
- accidental protocol fork; and
- effort diverted from policy and UX differentiation.
### Option C — Define an independent bot-bottle session API
Build only the control surface bot-bottle needs.
**Benefits**
- clean fit with the trust model and persistent named bottles;
- no upstream dependency; and
- deliberate support for supervision and security events.
**Risks**
- recreates a fast-growing open-source project;
- no existing client ecosystem;
- slower browser/desktop/mobile work; and
- increases the chance that Sandbox Agent becomes the de facto standard first.
### Recommendation
Start with **Option A as a bounded compatibility spike**, not a product
commitment. Do not begin with a clean-room competing protocol.
The spike should answer:
1. Can Claude Code, Codex, and Pi retain exact native resume behavior?
2. Can Sandbox Agent run without receiving real provider credentials?
3. Can its server be reached through a bottle-scoped authenticated channel
without exposing the orchestrator or broadening guest egress?
4. Which permission events overlap or conflict with bot-bottle supervision?
5. Can normalized events be stored while clearly separating untrusted
transcript telemetry from authoritative gateway/Git audit?
6. Can manifest skills, MCP servers, prompt, and startup arguments compile
deterministically into its configuration?
7. Does its versioning policy permit a compatibility contract bot-bottle can
support?
8. What image-size, startup-time, and update burden does the binary add?
If the answers are favorable, adopt it behind a bot-bottle-owned interface and
pin/test the supported version. If not, implement the smallest compatible
subset needed by external clients before inventing a wholly separate API.
## Product roadmap implications
The competitor scan and this architecture comparison reorder the likely work:
1. **Provider-neutral control/session compatibility spike**
2. **Stable authenticated external bot-bottle API**
3. **Normalized transcript/event persistence**
4. **Parallel-session operator UI**
5. **Browser/preview/computer-use capability**
6. **Policy/image/skill distribution and signing**
7. **Remote host/fleet management**
This does not mean pausing security work. It means exposing the shipped
security work through a product surface that can compete with the SDK-plus-
sandbox ecosystem.
## Decision
Treat Sandbox Agent SDK as a potentially standard **agent process-control
layer**, not as a sandbox replacement and not as a minor complementary
library. Position bot-bottle one layer above it:
- manifests express trusted role and environment policy;
- bot-bottle compiles and enforces that policy across host, gateway, Git, and
isolation backends;
- Sandbox Agent or a compatible protocol controls the selected agent process;
and
- bot-bottle owns the turnkey operator experience.
The Docker analogy is strategically sound when stated as:
> Sandbox Agent can be the portable task/exec protocol; bot-bottle can be the
> opinionated engine, Compose-like policy layer, and Desktop-like operator
> product.
It is not sound when stated as:
> Sandbox Agent is OCI and bot-bottle is Docker.
There is no independent OCI-equivalent agent specification yet, and Sandbox
Agent already reaches into UI/session territory. Compatibility should be
pursued quickly, while the trusted manifest/enforcement plane and operator
experience remain the parts bot-bottle deliberately owns.
@@ -0,0 +1,305 @@
# Testing a clean bot-bottle install on macOS
How do you exercise `install.sh` (and, ideally, a first `bot-bottle start`)
the way a brand-new user would — on a pristine macOS environment you can
throw away afterward — *without* permanently polluting your daily-driver
Mac? The user's framing: is there a VM or boundary that avoids creating a
separate account, or is spinning up and tearing down a throwaway macOS
user on the CLI easy enough to just do that?
## Summary
There is no lightweight, in-place macOS sandbox that hands you a clean home
directory and wipeable system state without *either* a VM or a separate
user account. `sandbox-exec` (Seatbelt) is deprecated and confines a
process, not an environment; App Sandbox is for shipping apps, not for
provisioning a fresh dev host. So the real choice is exactly the two the
user named: **a disposable macOS VM** or **a throwaway user account**
and which one is right turns on a detail specific to *this* project.
bot-bottle's default macOS backend is Apple's `container`, which runs each
container in its own lightweight VM via `Virtualization.framework`
([`README.md:27`](../README.md), [`apple-container-backend.md`](apple-container-backend.md)).
That means a full end-to-end test — install *and* `bot-bottle start`
needs virtualization to work wherever bot-bottle runs. Inside a macOS guest
VM that requires **nested virtualization, which Apple gates to M3 or newer
chips on macOS 15+**. On M1/M2 you cannot run the Apple Container backend
(or Docker Desktop, same reason) inside a macOS VM at all.
The recommendation splits on what you're testing and what silicon you have:
- **Install-script correctness only** (does `curl | sh` → pipx → config dir
`doctor`'s Python/config checks pass?): a **disposable Tart VM** is the
cleanest boundary and works on any Apple Silicon Mac. `doctor` will report
the backend as not-ready inside the VM on M1/M2, which is fine — you're
testing the installer, not the runtime.
- **Full runtime** (actually launch a bottle) on **M3/M4**: a **disposable
Tart VM from a golden base image, cloned per run** is the gold standard —
a genuine kernel/state boundary that wipes to nothing.
- **Full runtime** on **M1/M2**, or when you'd rather not fight nested virt:
a **throwaway admin user via `sysadminctl`** is the pragmatic pick. It
tests the real backend because the backend runs on the host hypervisor —
but it is a *hygiene* boundary, not a security one, and it does **not**
clean the system-level footprint (see below).
Prefer the VM. Reach for the throwaway user only when nested virt is off the
table and you accept an imperfect wipe.
## Why "a boundary without a separate user" doesn't really exist on macOS
macOS has no namespace/overlay story like Linux `unshare` + tmpfs. The
options that sound like in-place sandboxes don't fit:
| Mechanism | Why it doesn't give you a clean, wipeable env |
|---|---|
| `sandbox-exec` / Seatbelt | Officially deprecated; confines *one process's* syscalls against a profile. It cannot present a fresh `$HOME` or a pristine `/usr/local`, and it won't let the Apple Container system service work. |
| App Sandbox | Entitlement-based confinement for signed `.app` bundles, not a provisioning tool for a CLI dev environment. |
| A second `$HOME` via `HOME=/tmp/foo` | Redirects only what honors `$HOME`. `install.sh` mostly does (it writes `~/.bot-bottle` and pipx/pip `--user` paths), but the Apple `container` install lands in `/usr/local` + a **system service**, and Homebrew lands in `/opt/homebrew` — all outside any `$HOME` you set. You'd get a false sense of "clean." |
| APFS snapshot rollback (`tmutil localsnapshot`) | You can't roll the live boot volume back to a local snapshot without booting to Recovery; it's not a per-run userspace undo. |
So the honest answer to "is there some boundary that avoids a separate
user?": yes — a **VM** — and it's the *stronger* boundary anyway. The only
lighter-weight option is the separate user, with the caveats below.
## What a clean install actually touches (the footprint that decides "wipeable")
Grounding the teardown story in what `install.sh` and the backend create:
| Artifact | Location | In `$HOME`? | Survives user deletion? |
|---|---|---|---|
| Config / state / db | `~/.bot-bottle/{agents,bottles,contrib,state,db}` ([`install.sh:80-83`](../install.sh), [`bot_bottle/paths.py:59`](../bot_bottle/paths.py)) | ✅ | ❌ removed with home |
| pipx venv + shim | `~/.local/pipx/venvs/bot-bottle`, shim in `~/.local/bin` ([`install.sh:87-89`](../install.sh)) | ✅ | ❌ removed with home |
| private venv fallback (no pipx) | `~/.bot-bottle/venv` + symlink in `~/.local/bin` ([`install.sh`](../install.sh)) | ✅ | ❌ removed with home |
| PATH / token exports | shell profile (`~/.zprofile`, etc.); `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` ([`README.md:74`](../README.md)) | ✅ | ❌ removed with home |
| **Apple `container` install** | `/usr/local/...` + notarized `.pkg` receipts | ❌ | ✅ **stays** |
| Apple `container` **service state** | per-user: `~/Library/Application Support/com.apple.container/` (`container system start`) | ✅ | ❌ removed with home |
| **Homebrew** (if used for `container`/python) | `/opt/homebrew` | ❌ | ✅ **stays** |
| Rosetta 2 (needed for image builds) | system | ❌ | ✅ **stays** |
The bold rows are the crux: **deleting the throwaway user does not uninstall
the Apple Container runtime, Homebrew, or Rosetta.** A VM, by contrast, wipes
100% of the above by definition — that's its entire advantage for this task.
The service row is the exception, and a live harness run corrected it: the
Apple `container` service is **per-user**, not a host-wide launchd service.
`container system status` reports an `appRoot` under
`~/Library/Application Support/com.apple.container/`, and a freshly created
account sees `container system service: NOT running` even while the creating
admin's is running. That cuts both ways — the state is genuinely removed with
the home, so the reset is *more* complete than this table first claimed, but
it also means **no brand-new user can run a bottle until they run
`container system start` once**. `doctor` correctly fails until they do, which
is why `test` judges backend readiness separately from install correctness.
## Option A — Disposable Tart VM (recommended)
[Tart](https://tart.run) is a CLI-first macOS/Linux VM manager built on
`Virtualization.framework`, purpose-built for exactly this "does it work on
a clean macOS, without my settings/permissions/data" workflow. Keep one
pristine *golden* image, clone a throwaway per run, delete it after.
```sh
brew install cirruslabs/cli/tart
# One-time: build a golden base (either a prebuilt image or a vanilla IPSW).
tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest golden # ~25 GB pull
# — or a truly vanilla install you click through once —
# tart create golden --from-ipsw latest --disk-size 60
# Per test run: clone → boot → test → destroy.
tart clone golden test-run
tart run test-run &
ssh admin@"$(tart ip test-run)"
# inside the guest:
# curl -fsSL https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh | sh
# bot-bottle doctor
tart stop test-run
tart delete test-run # back to pristine; golden is untouched
```
Cloning is cheap (sparse files), so the golden image is your reset button —
every `tart clone` is a fresh macOS. This is the closest thing to a Linux
`docker run --rm` for a whole Mac.
**The nested-virt caveat (read before relying on it for runtime tests).**
The Apple Container backend inside the guest needs
`Virtualization.framework` to work *inside* the VM. Apple enables nested
virtualization only on **M3 or newer**, on **macOS 15 (Sequoia) or later**;
M2 and earlier are excluded by Apple, confirmed by Apple DTS. Consequences:
- **M3/M4 host:** full runtime works in the guest. `bot-bottle doctor`
reports the backend ready and `start` can launch a bottle. Gold standard.
- **M1/M2 host:** the guest can install bot-bottle and pass the Python /
config-dir checks, but `doctor`'s backend check will fail and you cannot
launch a bottle in the VM. Still perfectly good for testing *the
installer*; not for the runtime.
- **M4-specific:** a known bug blocks pre-Ventura guests on M4; use a
current macOS guest (which you want anyway, since Apple `container`
targets macOS 26 Tahoe).
UTM is the GUI equivalent on the same framework (and was first to expose
nested virt) if you'd rather click; Tart wins for a scriptable
spin-up/tear-down loop.
## Option B — Throwaway user via `sysadminctl` (pragmatic fallback)
Creating and deleting a user from the CLI is genuinely a two-liner, and it
tests the **real** backend on any Apple Silicon Mac because the backend runs
on the host hypervisor — no nested virt needed.
```sh
# Create a self-contained admin user (admin needed for the container service).
sudo sysadminctl -addUser bbtest -fullName "bot-bottle test" \
-password 'throwaway' -admin
# Log into that account (fast-user-switch or the login window), then run the
# installer as bbtest exactly as a new user would. When done:
sudo sysadminctl -deleteUser bbtest -secure # -secure erases the home dir
```
Honest accounting of what this does and doesn't buy you:
- **Boundary strength:** it's a *hygiene / fresh-`$HOME`* boundary, **not a
security boundary.** Same kernel, same admin group; an admin test user can
touch system state. If the point is "clean environment," fine. If the point
is "contain something untrusted," this is the wrong tool — use a VM.
- **Wipe completeness:** `-secure` erases the home dir (so `~/.bot-bottle`,
the pipx venv, and profile exports go away), but as the footprint table
shows, the **Apple Container runtime, its launchd system service,
Homebrew, and Rosetta persist.** For a truly repeatable "did a *system with
nothing installed* work?" test, that residue defeats the purpose — the
second run isn't clean.
- **Operational gotchas:** don't pass real passwords on the command line (they
land in `ps` and history — this is a throwaway credential, so it's
tolerable here). Deletion must run as root from a normally-booted, admin-
logged-in session; the Terminal needs **Full Disk Access** or you'll hit
error `-14120` and a half-deleted account. Prefer letting the system place
the home dir (don't pass `-home`), or deletion can orphan it.
Use this when you're on M1/M2, you specifically want to exercise the live
backend, and you can tolerate the system-level runtime staying installed
between runs (or you uninstall Apple `container` / brew by hand to reset).
## Honorable mentions
- **External bootable macOS volume.** A fresh macOS on an external SSD (or a
separate APFS volume) is bare-metal disposable: no nested-virt limit, real
backend works, and you `diskutil` the volume away to reset. Cost is reboot
friction per run — good for an occasional thorough pass, poor for a tight
loop.
- **Rented / cloud Mac.** AWS EC2 Mac (dedicated Mac minis), Scaleway Apple
silicon, or MacStadium give a genuinely throwaway host you release when
done. Overkill for local iteration, but this is essentially what the
project's own advisory `integration-macos` CI job needs — a self-hosted
Apple Silicon runner with the `container` CLI, Python ≥ 3.11, and coverage
on the launchd service's PATH ([`README.md:78`](../README.md)). If you end
up standing up a cloud Mac for install testing, it doubles as that runner.
## Recommendation
Default to a **disposable Tart VM** — it's the only option that wipes the
*entire* footprint (including the Apple Container system service that a user
deletion leaves behind), it's a real boundary, and the spin-up/tear-down
loop is a two-command `tart clone` / `tart delete`. Confirm your chip first:
on **M3/M4** it tests install *and* runtime end-to-end; on **M1/M2** it still
cleanly tests `install.sh` + `doctor`'s Python/config path, and you fall back
to a **throwaway `sysadminctl` admin user** for live-backend testing —
accepting that it's a hygiene boundary and that you'll manually uninstall the
Apple Container runtime / Homebrew between runs to get back to truly clean.
There is no third, lighter-weight "in-place boundary without a user" that
actually delivers a clean, wipeable macOS — the VM *is* that answer, and it's
the better one.
## Harness
The throwaway-user loop is scripted in
[`scripts/macos-install-test.sh`](../../scripts/macos-install-test.sh):
`up` creates the account, `run` pipes *this checkout's* `install.sh` into it
headlessly (so a PR is verifiable before it lands) and lets the installer run
`doctor`, `down` deletes the account and its home (the full reset), and
`deep-reset` additionally uninstalls the host `container` runtime. It leans on
the footprint analysis above — the reset is just user deletion because
everything `install.sh` writes is user-home-local.
There are two one-shot cycles, because "does the installer work" and "can a new
user actually run a bottle" are different questions:
```sh
sudo ./scripts/macos-install-test.sh test # up → run → status → down
sudo ./scripts/macos-install-test.sh test-ready # ... + prereqs before status
```
`test` models a macOS system **without** the prerequisites set up for this user
— which is the default state of every new account, since the `container`
service is per-user. It asserts the install is sound and *reports* backend
readiness without failing on it, because install.sh provides no backend and so
cannot regress one.
`test-ready` models a system **with** them, then demands doctor go fully green,
backend included. Both variants pass as of this writing.
### The per-user prerequisite is two steps, not one
Running it revealed that "set up the backend for this account" is more than
starting a service:
1. **`container system start`** — the service is per-user. The run confirms it
directly: the throwaway account's `appRoot` is
`/Users/bbtest/Library/Application Support/com.apple.container/`, its own,
and starting it left the admin's service untouched.
2. **A guest kernel**, which also lives in that per-user app root. A fresh
account has none, so `container system start` prompts to download one —
and *only* prompts, since the flags default to asking. Headless callers
must pass `--enable-kernel-install` or the command dies on
`failed to read user input`.
Neither step is done by `bot-bottle backend setup --backend=macos-container`,
which only checks and then tells you to run `container system start` yourself.
So a new account's real path to a working backend is:
`container system start --enable-kernel-install`.
The harness must also enter the user's launchd domain via
`launchctl asuser <uid>` to do any of this. `container system start` registers
`com.apple.container.apiserver` as a per-user launchd agent and talks to it
over XPC; from plain `sudo -u` the caller is still in root's bootstrap
namespace, the lookup crosses domains, and the apiserver answers
`invalidState: "unauthorized request"` even though the agent started fine.
It refuses to start against an existing account (a reused home is not a clean
install), and it tears the account down from an `EXIT`/`INT` trap armed the
moment the account exists, so a failed or Ctrl-C'd run still leaves the machine
clean. Its verdict is deliberately stricter than the installer's own: note that
`install.sh` exits **0** when it finishes but `doctor` reports unmet
prerequisites, so "the installer succeeded" is not the assertion — `test` fails
if the install fails, if `bot-bottle` never reached the new user's `PATH`, or if
`doctor` is unhappy. `BB_TEST_KEEP=1` skips the teardown to poke at a failure.
### What a fresh account actually inherits
Expect the first honest run on a developer Mac to fail at the *Python* gate,
and expect that to be correct. A new account's `PATH` is just `/etc/paths`
(`/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin`),
which notably does **not** include `/opt/homebrew/bin`. Homebrew's `shellenv`
line lives in the *installing* user's `~/.zprofile` and is not inherited, so a
throwaway user resolves `python3` to `/usr/bin/python3` — the Command Line
Tools stub, still **3.9.6** on macOS 26 — and `install.sh` correctly dies on its
`3.11+` requirement. Your own shell resolving `python3` to a 3.14 Homebrew
build says nothing about what a new user sees; that gap is exactly what this
harness exists to expose.
## Sources
- [Apple Containers on macOS: technical comparison with Docker — The New Stack](https://thenewstack.io/apple-containers-on-macos-a-technical-comparison-with-docker/)
- [How to Set Up Apple Containerization on macOS 26 — Stéphane Paquet](https://spaquet.medium.com/how-to-set-up-apple-containerization-on-macos-26-f870cc8c26cd)
- [Install Apple Container CLI (macOS 15/26) — 4sysops](https://4sysops.com/archives/install-apple-container-cli-running-containers-natively-on-macos-15-sequoia-and-macos-26-tahoe/)
- [Nested virtualization on Apple Silicon (M3+, macOS 15) — UTM issue #6700](https://github.com/utmapp/UTM/issues/6700)
- [macOS 15 Sequoia nested virtualization for M3+ — Parallels Forums](https://forum.parallels.com/threads/macos-15-sequoia-nested-virtualization-for-m3-macs.364397/)
- [M2 nested virtualization restriction (Apple DTS) — Apple Developer Forums](https://developer.apple.com/forums/thread/756723)
- [M4 can't virtualize older macOS — Yahoo/Tech](https://tech.yahoo.com/computing/articles/m4-mac-computers-cant-virtualize-175122301.html)
- [Tart — macOS/Linux VMs on Apple Silicon (Cirrus Labs)](https://tart.run/quick-start/)
- [Tart GitHub](https://github.com/cirruslabs/tart)
- [macOS VMs in a single command — frr.dev](https://www.frr.dev/posts/tart-macos-vms-from-terminal/)
- [sysadminctl reference — SS64](https://ss64.com/mac/sysadminctl.html)
- [User management from the macOS command line — macnotes](https://macnotes.wordpress.com/2019/03/28/user-management-create-remove-change-password-secure-token-from-macos-command-line/)
+242 -55
View File
@@ -8,14 +8,23 @@
# pipx install bot-bottle # from a checkout or a published index
# uv tool install bot-bottle
#
# This script is a thin bootstrapper: it checks prerequisites, installs the
# package with pipx (falling back to pip --user), creates the config dir, and
# runs `bot-bottle doctor`. It is idempotent (safe to re-run) and never uses
# sudo. It does NOT install Docker or a VM backend for you — `doctor` reports
# what's missing after install.
# This script is a thin bootstrapper: it finds a Python 3.11+ interpreter,
# installs the package with pipx (falling back to a private venv), creates the
# config dir, and runs `bot-bottle doctor`. It is idempotent (safe to re-run)
# and never uses sudo. It does NOT install Docker or a VM backend for you —
# `doctor` reports what's missing after install.
#
# Env:
# BOT_BOTTLE_PYTHON interpreter to install with (skips the search)
# BOT_BOTTLE_INSTALL_SPEC pip/git spec to install instead of the default
# BOT_BOTTLE_CHANNEL production (default) or staging
# BOT_BOTTLE_VERSION exact qualified vX.Y.Z[-rc.N] release
# BOT_BOTTLE_REF exact 40-character commit snapshot (warns untested)
# BOT_BOTTLE_VENV where the non-pipx install lives (~/.bot-bottle/venv)
set -eu
PACKAGE_SPEC="${BOT_BOTTLE_INSTALL_SPEC:-git+https://gitea.dideric.is/didericis/bot-bottle.git}"
PACKAGE_SPEC="${BOT_BOTTLE_INSTALL_SPEC:-}"
VENV="${BOT_BOTTLE_VENV:-${HOME}/.bot-bottle/venv}"
MIN_PYTHON_MAJOR=3
MIN_PYTHON_MINOR=11
@@ -28,21 +37,205 @@ die() {
exit 1
}
# --- prerequisites -----------------------------------------------------------
# --- prerequisites: find an interpreter new enough ----------------------------
command -v python3 >/dev/null 2>&1 \
|| die "python3 ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+ is required but was not found"
python3 - "$MIN_PYTHON_MAJOR" "$MIN_PYTHON_MINOR" <<'PY' || die "python3 ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR} or newer is required"
# Is $1 an interpreter that exists and meets the floor?
python_ok() {
[ -n "${1:-}" ] || return 1
command -v "$1" >/dev/null 2>&1 || return 1
"$1" - "$MIN_PYTHON_MAJOR" "$MIN_PYTHON_MINOR" <<'PY' >/dev/null 2>&1
import sys
want = (int(sys.argv[1]), int(sys.argv[2]))
raise SystemExit(0 if sys.version_info[:2] >= want else 1)
PY
}
# Installing a `git+` spec (the default) shells out to git under the hood,
# whether via pipx or pip. Fail early with a clear message rather than deep
# inside the installer's output.
python_version() {
"$1" -c 'import sys; print("%d.%d.%d" % sys.version_info[:3])' 2>/dev/null
}
# `python3` on PATH is often NOT the newest interpreter installed, and on macOS
# it is usually the oldest: a fresh login shell's PATH is just /etc/paths, so
# python3 resolves to the Command Line Tools stub (3.9.x) while the usable
# 3.11+ build sits in /opt/homebrew/bin or a python.org framework directory,
# reachable only via a line in the *installing* user's shell profile. A new
# account inherits none of that. Look past PATH before giving up, so the common
# case installs instead of dead-ending on a version error.
find_python() {
for candidate in \
"${BOT_BOTTLE_PYTHON:-}" \
python3 \
python3.14 python3.13 python3.12 python3.11 \
/opt/homebrew/bin/python3 \
/usr/local/bin/python3 \
"${HOME}/.local/bin/python3" \
/Library/Frameworks/Python.framework/Versions/*/bin/python3
do
# An unmatched glob arrives here literally; python_ok rejects it.
if python_ok "$candidate"; then
command -v "$candidate"
return 0
fi
done
return 1
}
# An explicit choice that doesn't work is an error, not a reason to quietly
# search elsewhere and install somewhere the caller didn't ask for.
if [ -n "${BOT_BOTTLE_PYTHON:-}" ] && ! python_ok "${BOT_BOTTLE_PYTHON}"; then
if command -v "${BOT_BOTTLE_PYTHON}" >/dev/null 2>&1; then
die "BOT_BOTTLE_PYTHON=${BOT_BOTTLE_PYTHON} is $(python_version "${BOT_BOTTLE_PYTHON}"), "\
"below the ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR} floor. Unset it to search for a newer one."
fi
die "BOT_BOTTLE_PYTHON=${BOT_BOTTLE_PYTHON} is not an executable interpreter."
fi
PYTHON="$(find_python || true)"
if [ -z "${PYTHON}" ]; then
path_python="$(command -v python3 2>/dev/null || true)"
if [ -n "${path_python}" ]; then
found="the python3 on your PATH is ${path_python} ($(python_version "${path_python}")), which is too old"
else
found="no python3 was found on your PATH"
fi
case "$(uname -s)" in
Darwin) fix=" brew install python@3.12
# or install from https://www.python.org/downloads/macos/
# macOS itself ships only /usr/bin/python3, which is too old" ;;
*) fix=" sudo apt install python3.12 # Debian/Ubuntu
sudo dnf install python3.12 # Fedora/RHEL" ;;
esac
die "bot-bottle needs python3 ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR} or newer, and none was found.
${found}.
Also checked: python3.11-3.14, /opt/homebrew/bin, /usr/local/bin,
~/.local/bin, and python.org framework builds.
Install a newer Python, then re-run this installer:
${fix}
Already have one somewhere? Point at it directly:
BOT_BOTTLE_PYTHON=/path/to/python3 sh install.sh"
fi
# Be explicit when the interpreter isn't the obvious one, so nobody is left
# wondering which Python their install ended up on.
path_python="$(command -v python3 2>/dev/null || true)"
if [ "${PYTHON}" != "${path_python}" ]; then
say "using ${PYTHON} ($(python_version "${PYTHON}"))"
if [ -n "${path_python}" ]; then
say "note: 'python3' on your PATH is ${path_python} ($(python_version "${path_python}")), which is below the ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR} floor"
fi
fi
# --- resolve an immutable published wheel -----------------------------------
if [ -z "${PACKAGE_SPEC}" ]; then
selectors=0
[ -n "${BOT_BOTTLE_REF:-}" ] && selectors=$((selectors + 1))
[ -n "${BOT_BOTTLE_VERSION:-}" ] && selectors=$((selectors + 1))
[ -n "${BOT_BOTTLE_CHANNEL:-}" ] && selectors=$((selectors + 1))
[ "${selectors}" -le 1 ] || die \
"BOT_BOTTLE_REF, BOT_BOTTLE_VERSION, and BOT_BOTTLE_CHANNEL are mutually exclusive."
downloads="${HOME}/.bot-bottle/downloads"
mkdir -p "${downloads}"
PACKAGE_SPEC="$("${PYTHON}" - \
"${BOT_BOTTLE_REF:-}" \
"${BOT_BOTTLE_VERSION:-}" \
"${BOT_BOTTLE_CHANNEL:-production}" \
"${downloads}" <<'PY'
import hashlib
import json
import re
import sys
import urllib.request
from pathlib import Path
ref, version, channel, download_root = sys.argv[1:]
base = "https://gitea.dideric.is/api/packages/didericis/generic"
def fetch_json(url):
with urllib.request.urlopen(url, timeout=30) as response:
return json.load(response)
if ref:
if re.fullmatch(r"[0-9a-f]{40}", ref) is None:
raise SystemExit("bot-bottle install: error: BOT_BOTTLE_REF must be 40 lowercase hex")
index_url = f"{base}/bot-bottle-builds/{ref}/bundle-index.json"
print(
"bot-bottle install: WARNING: installing an exact commit snapshot; "
"it may not have passed release qualification.",
file=sys.stderr,
)
selector = f"commit {ref}"
else:
if version:
if re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-rc\.[0-9]+)?", version) is None:
raise SystemExit("bot-bottle install: error: invalid BOT_BOTTLE_VERSION")
pointer_url = f"{base}/bot-bottle-releases/{version}/release.json"
selector = f"release {version}"
else:
if channel not in ("production", "staging"):
raise SystemExit(
"bot-bottle install: error: BOT_BOTTLE_CHANNEL must be production or staging")
pointer_url = f"{base}/bot-bottle-channels/{channel}/channel.json"
selector = f"{channel} channel"
pointer = fetch_json(pointer_url)
if pointer.get("schema") != 1 or pointer.get("qualified") is not True:
raise SystemExit("bot-bottle install: error: release pointer is not qualified")
index_url = pointer.get("bundle_index_url", "")
if not isinstance(index_url, str) or not index_url.startswith("https://"):
raise SystemExit("bot-bottle install: error: release pointer has no immutable bundle")
index = fetch_json(index_url)
source_commit = index.get("source_commit", "")
if re.fullmatch(r"[0-9a-f]{40}", source_commit) is None:
raise SystemExit("bot-bottle install: error: bundle has an invalid source commit")
if ref and source_commit != ref:
raise SystemExit("bot-bottle install: error: bundle does not match BOT_BOTTLE_REF")
wheel = index.get("wheel")
if not isinstance(wheel, dict):
raise SystemExit("bot-bottle install: error: bundle has no wheel")
filename, url, expected = (
wheel.get("filename"), wheel.get("url"), wheel.get("sha256"))
if (
not isinstance(filename, str)
or re.fullmatch(r"[A-Za-z0-9_.-]+\.whl", filename) is None
or not isinstance(url, str)
or not url.startswith("https://")
or not url.endswith("/" + filename)
or not isinstance(expected, str)
or re.fullmatch(r"[0-9a-f]{64}", expected) is None
):
raise SystemExit("bot-bottle install: error: bundle wheel metadata is invalid")
destination = Path(download_root) / source_commit / filename
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_suffix(destination.suffix + ".part")
digest = hashlib.sha256()
with urllib.request.urlopen(url, timeout=60) as response, temporary.open("wb") as output:
while chunk := response.read(1 << 20):
digest.update(chunk)
output.write(chunk)
if digest.hexdigest() != expected:
temporary.unlink(missing_ok=True)
raise SystemExit("bot-bottle install: error: downloaded wheel checksum mismatch")
temporary.replace(destination)
print(
f"bot-bottle install: selected {selector} "
f"(source {source_commit}, sha256 {expected[:16]}…)",
file=sys.stderr,
)
print(destination)
PY
)"
fi
# An explicit `git+` override shells out to git under the hood, whether via
# pipx or pip. Fail early with a clear message rather than deep inside the
# installer's output.
case "${PACKAGE_SPEC}" in
git+*|*.git)
command -v git >/dev/null 2>&1 || die \
@@ -51,30 +244,6 @@ case "${PACKAGE_SPEC}" in
;;
esac
# The pip fallback needs a usable pip. Externally-managed interpreters
# (PEP 668, common on Debian/Ubuntu/Homebrew) reject `pip install --user`;
# pipx sidesteps that, so recommend it when pip can't be used.
if ! command -v pipx >/dev/null 2>&1; then
python3 -m pip --version >/dev/null 2>&1 || die \
"neither pipx nor a usable 'python3 -m pip' was found. Install pipx "\
"(recommended): 'python3 -m pip install --user pipx' or your OS package manager."
if python3 - <<'PY'
import os
import sys
import sysconfig
# PEP 668: an EXTERNALLY-MANAGED marker in the stdlib dir means pip refuses
# to install into this interpreter without --break-system-packages.
marker = os.path.join(sysconfig.get_path("stdlib"), "EXTERNALLY-MANAGED")
raise SystemExit(0 if os.path.exists(marker) else 1)
PY
then
die "this Python is externally managed (PEP 668), so 'pip install --user' is "\
"blocked. Install pipx and re-run: 'python3 -m pip install --user --break-system-packages pipx', "\
"then 'pipx ensurepath'."
fi
fi
# --- config directories ------------------------------------------------------
mkdir -p \
@@ -84,32 +253,50 @@ mkdir -p \
# --- install -----------------------------------------------------------------
BIN_DIR="${HOME}/.local/bin"
if command -v pipx >/dev/null 2>&1; then
say "installing with pipx"
pipx install --force "${PACKAGE_SPEC}"
# --python pins the venv to the interpreter we vetted. Without it pipx uses
# whichever Python it was itself installed with, which is not necessarily
# the one that passed the version check above.
say "installing with pipx (python: ${PYTHON})"
pipx install --python "${PYTHON}" --force "${PACKAGE_SPEC}"
# Ask pipx where it puts entry points rather than assuming ~/.local/bin.
pipx_bin="$(pipx environment --value PIPX_BIN_DIR 2>/dev/null || true)"
[ -n "${pipx_bin}" ] && BIN_DIR="${pipx_bin}"
else
say "pipx not found; installing with 'python3 -m pip install --user'"
python3 -m pip install --user --upgrade "${PACKAGE_SPEC}"
# No `pip install --user` fallback: PEP 668 makes it unusable on nearly
# every interpreter a Mac offers (Homebrew and python.org are both
# externally managed), and on Debian/Ubuntu too. A private venv sidesteps
# that entirely — PEP 668 does not apply inside a venv — and `venv` is
# stdlib, so unlike pipx there is nothing to bootstrap first.
say "pipx not found; installing into a managed venv at ${VENV}"
"${PYTHON}" -m venv --clear "${VENV}" || die \
"could not create a virtualenv at ${VENV} using ${PYTHON}. On Debian/Ubuntu "\
"the venv module ships separately: 'sudo apt install python3-venv'."
"${VENV}/bin/python" -m pip install --upgrade "${PACKAGE_SPEC}"
# Expose the entry point outside the venv, the way pipx would.
mkdir -p "${BIN_DIR}"
ln -sf "${VENV}/bin/bot-bottle" "${BIN_DIR}/bot-bottle"
fi
# --- locate the entry point --------------------------------------------------
# The pip --user scripts directory is platform-specific: ~/.local/bin on Linux,
# but ~/Library/Python/<X.Y>/bin on a python.org macOS interpreter. Ask the
# interpreter for its own user-scheme scripts dir instead of hardcoding.
USER_SCRIPTS="$(python3 - <<'PY'
import sysconfig
print(sysconfig.get_path("scripts", sysconfig.get_preferred_scheme("user")))
PY
)"
if command -v bot-bottle >/dev/null 2>&1; then
BOT_BOTTLE_BIN="bot-bottle"
elif [ -n "${USER_SCRIPTS}" ] && [ -x "${USER_SCRIPTS}/bot-bottle" ]; then
BOT_BOTTLE_BIN="${USER_SCRIPTS}/bot-bottle"
say "note: add ${USER_SCRIPTS} to your PATH to run 'bot-bottle' directly"
elif [ -x "${BIN_DIR}/bot-bottle" ]; then
BOT_BOTTLE_BIN="${BIN_DIR}/bot-bottle"
# Name the file the user's own login shell actually reads. ~/.profile is
# the safe default for non-zsh: bash falls back to it, and suggesting
# ~/.bash_profile could shadow an existing ~/.profile.
case "${SHELL:-}" in
*/zsh) profile="~/.zprofile" ;;
*) profile="~/.profile" ;;
esac
say "note: add ${BIN_DIR} to your PATH to run 'bot-bottle' directly:"
say " echo 'export PATH=\"${BIN_DIR}:\$PATH\"' >> ${profile}"
else
die "bot-bottle was installed but is not on PATH; add ${USER_SCRIPTS:-your user scripts dir} to PATH and re-run"
die "bot-bottle was installed but no entry point turned up in ${BIN_DIR}"
fi
# --- verify ------------------------------------------------------------------
+1
View File
@@ -36,6 +36,7 @@ include = ["bot_bottle*"]
# files shipped under bot_bottle/; test_pyproject.py asserts they exist.
[tool.setuptools.package-data]
bot_bottle = [
"release-manifest.json",
"gateway/egress/entrypoint.sh",
"contrib/claude/Dockerfile",
"contrib/claude/package.json",
+1 -1
View File
@@ -9,7 +9,7 @@
#
# Why a pool + one-time setup: creating a TAP and assigning it an IP
# needs CAP_NET_ADMIN. Pre-creating user-owned, pre-addressed TAPs
# means `./cli.py start` never needs root. The nft table is static
# means `bot-bottle start` never needs root. The nft table is static
# (keyed on the `bbfc*` interface wildcard), so it covers every slot
# without per-launch changes.
#
+39
View File
@@ -0,0 +1,39 @@
"""Generate the external commit-addressed release bundle index."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from bot_bottle.release_bundle import build_bundle_index, canonical_bytes
from bot_bottle.release_publish import bundle_url, publish_bundle
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--manifest", required=True, type=Path)
parser.add_argument("--wheel", required=True, type=Path)
parser.add_argument("--wheel-url")
parser.add_argument("--workflow-run", required=True)
parser.add_argument("--published-at", required=True)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--publish", action="store_true")
args = parser.parse_args(argv)
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
index = build_bundle_index(
manifest,
wheel=args.wheel,
wheel_url=args.wheel_url or bundle_url(
manifest["source_commit"], args.wheel.name),
workflow_run=args.workflow_run,
published_at=args.published_at,
)
args.output.write_bytes(canonical_bytes(index))
if args.publish:
publish_bundle(index, args.wheel)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+55
View File
@@ -0,0 +1,55 @@
"""Generate the validated infrastructure manifest consumed by package builds."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from bot_bottle.release_manifest import parse_manifest
def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser()
result.add_argument("--source-commit", required=True)
result.add_argument("--orchestrator-image", required=True)
result.add_argument("--gateway-image", required=True)
for provider in ("claude", "codex", "pi"):
result.add_argument(f"--agent-{provider}-image", required=True)
for role in ("orchestrator", "gateway"):
result.add_argument(f"--firecracker-{role}-version", required=True)
result.add_argument(f"--firecracker-{role}-sha256", required=True)
result.add_argument("--output", type=Path, required=True)
return result
def main(argv: list[str] | None = None) -> int:
args = parser().parse_args(argv)
data = {
"schema": 1,
"source_commit": args.source_commit,
"oci": {
"orchestrator": args.orchestrator_image,
"gateway": args.gateway_image,
**{
f"agent_{provider}": getattr(args, f"agent_{provider}_image")
for provider in ("claude", "codex", "pi")
},
},
"firecracker": {
role: {
"version": getattr(args, f"firecracker_{role}_version"),
"sha256": getattr(args, f"firecracker_{role}_sha256"),
}
for role in ("orchestrator", "gateway")
},
}
parse_manifest(data)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+531
View File
@@ -0,0 +1,531 @@
#!/usr/bin/env bash
# Clean-install test harness for the macOS (Apple `container`) path.
#
# Exercises install.sh the way a brand-new user would, inside a throwaway
# macOS account you create and delete from the CLI. install.sh's entire
# footprint is user-home-local — the pipx venv under ~/.local, or the private
# venv at ~/.bot-bottle/venv plus a ~/.local/bin symlink, and the ~/.bot-bottle
# config dir. It writes no shell-profile PATH line, and never installs the
# backend (see
# the header of install.sh), so deleting the user is a complete,
# deterministic reset of everything the installer touched. The Apple
# `container` runtime is a HOST prerequisite installed once and kept;
# `deep-reset` is the rare escape hatch that also removes it.
#
# Why a throwaway user and not a disposable VM: bot-bottle's default macOS
# backend is Apple `container`, which runs each container in its own
# Virtualization.framework microVM. Running that backend inside a macOS
# guest VM needs nested virtualization, which Apple gates to M3+ silicon.
# On M1/M2 a separate user account is the only way to get a clean $HOME
# while still reaching the real host backend. Full rationale in
# docs/research/testing-clean-install-on-macos.md.
#
# Usage:
# sudo ./scripts/macos-install-test.sh test # up -> run -> status -> down
# sudo ./scripts/macos-install-test.sh test-ready # ... with prereqs satisfied
# sudo ./scripts/macos-install-test.sh up # create the throwaway user
# sudo ./scripts/macos-install-test.sh run # run install.sh (+doctor) as it
# sudo ./scripts/macos-install-test.sh prereqs # start the per-user backend svc
# ./scripts/macos-install-test.sh status # user present? backend ready?
# sudo ./scripts/macos-install-test.sh down # delete user + home (the reset)
# sudo ./scripts/macos-install-test.sh deep-reset # ALSO uninstall host `container`
#
# TWO TEST VARIANTS, because "does the installer work" and "can a new user
# actually run a bottle" are different questions with different answers:
#
# test A macOS system WITHOUT the prerequisites set up for this user —
# the default state of any brand-new account, since the Apple
# `container` service is per-user. Asserts the INSTALL is sound
# (entry point present, doctor runs without crashing, python and
# config check out). Backend readiness is reported, not required:
# install.sh does not provide a backend and cannot regress one,
# so failing on it would make this permanently red.
#
# test-ready A macOS system WITH the prerequisites satisfied — it starts the
# per-user `container` service (and installs its guest kernel)
# for the throwaway user first, then demands doctor go fully
# green, backend included. This is the end-to-end claim: a new
# user can actually run a bottle. Note it downloads a guest
# kernel per run, because the previous run's went with the
# deleted home; BB_TEST_KERNEL_INSTALL=0 skips that.
#
# Both refuse to start if the account already exists (a reused home is not a
# clean install), and both tear the account down on the way out however they
# exit, so a failed run never leaves an orphan behind. install.sh itself exits
# 0 when doctor reports unmet prerequisites, so either variant is a stricter
# gate than running the installer by hand.
#
# Config via env:
# BB_TEST_USER account short name (default: bbtest)
# BB_TEST_FULLNAME account full name (default: "bot-bottle install test")
# BB_TEST_ADMIN 1=admin (reach container svc), 0=standard (default: 1)
# BB_TEST_INSTALL_URL curl this install.sh instead of piping the local checkout
# BB_TEST_KEEP 1=`test` skips its teardown, to poke at a failure
# BB_TEST_REQUIRE_BACKEND 1=`test` also fails when no backend is ready
# BB_TEST_KERNEL_INSTALL 0=`test-ready` skips the guest-kernel download
# BB_TEST_REPO_URL https repo the throwaway user clones (default: this one)
# BOT_BOTTLE_INSTALL_SPEC passed through to install.sh (pip / git spec)
#
# Notes:
# * Run from a normally-booted admin session. Grant Terminal *Full Disk
# Access* (System Settings -> Privacy & Security) or `down` half-fails
# with error -14120 and leaves an orphaned account.
# * `sysadminctl` always exits 0 even on failure, so `up`/`down` verify
# the result with `dscl` and fail loudly on a mismatch.
# * The account is created without a password: `run` drives it headlessly
# via `sudo -u`, which never needs the target's password. The account
# cannot GUI-login, which this harness does not require.
# * `run` covers the installer + `bot-bottle doctor`. Actually launching a
# bottle from the throwaway user may need a full launchd user session
# (`launchctl asuser`); on M1/M2 the backend can't run under nested virt
# anyway, so this harness stops at install + doctor.
set -euo pipefail
USER_NAME="${BB_TEST_USER:-bbtest}"
FULL_NAME="${BB_TEST_FULLNAME:-bot-bottle install test}"
ADMIN="${BB_TEST_ADMIN:-1}"
# https, not the ssh `origin`: the throwaway user has no key of ours.
BB_TEST_REPO_URL="${BB_TEST_REPO_URL:-https://gitea.dideric.is/didericis/bot-bottle.git}"
_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_REPO_ROOT="$(cd "$_SCRIPT_DIR/.." && pwd)"
# Set by `test`, which chains the steps itself and so suppresses the
# "here's the next command to run" hints the individual steps print.
IN_TEST=0
# --- guards ----------------------------------------------------------
require_macos() {
[ "$(uname -s)" = "Darwin" ] \
|| { echo "error: this harness is macOS-only (uname is $(uname -s))" >&2; exit 1; }
}
require_root() {
if [ "$(id -u)" -ne 0 ]; then
echo "error: '$1' needs root; re-run under sudo" >&2
exit 1
fi
}
user_exists() { dscl . -read "/Users/$USER_NAME" >/dev/null 2>&1; }
user_uid() {
dscl . -read "/Users/$USER_NAME" UniqueID 2>/dev/null | awk '{print $2}'
}
# Whether we can enter the target user's launchd domain.
#
# This matters more than it sounds. `container system start` registers
# com.apple.container.apiserver as a per-USER launchd agent and then talks to
# it over XPC. Under plain `sudo -u` the caller is still in root's bootstrap
# namespace, so the lookup crosses domains and the apiserver answers
# invalidState: "unauthorized request"
# even though it launched fine. `launchctl asuser <uid>` puts the whole
# invocation in that user's domain, which is also what a real user gets from
# Terminal — so it is the more faithful way to run *everything* here, not just
# the service start. Falls back to plain sudo when unavailable, since a
# never-logged-in account may have no bootstrappable domain at all.
_SESSION_OK=""
user_session_available() {
if [ -z "$_SESSION_OK" ]; then
local uid
uid="$(user_uid)"
if [ -n "$uid" ] && launchctl asuser "$uid" true >/dev/null 2>&1; then
_SESSION_OK=1
else
_SESSION_OK=0
fi
fi
[ "$_SESSION_OK" = "1" ]
}
# Run a shell snippet as the throwaway user in a fresh login shell.
#
# The snippet goes in on stdin, never as an argument. `sudo -i` joins its argv
# into a single string and hands that to the login shell's -c, so quoting in an
# argument is not preserved and a newline ends the command outright ("sh: -c:
# line 1: syntax error: unexpected end of file"). Feeding `sh -s` from stdin
# sidesteps the joining entirely and lets snippets be multi-line.
run_as_user() {
if user_session_available; then
printf '%s\n' "$1" | launchctl asuser "$(user_uid)" sudo -u "$USER_NAME" -i sh -s
else
printf '%s\n' "$1" | sudo -u "$USER_NAME" -i sh -s
fi
}
# `bot-bottle doctor` as the throwaway user. Non-zero when no entry point was
# installed at all, or when doctor itself is unhappy.
#
# Deliberately does NOT require `bot-bottle` on PATH: install.sh prints the
# PATH line rather than editing a shell profile, so on a fresh account the
# entry point is installed and working but not on PATH. Demanding PATH here
# would fail every run for a reason the installer intends.
#
# Doctor's own exit code conflates two unrelated things: whether the install
# works, and whether this user has a backend ready to run a bottle. Those need
# different verdicts here. install.sh explicitly does not install a backend,
# and the Apple `container` service is per-USER — its state lives in
# ~/Library/Application Support/com.apple.container — so a brand-new account
# never has one running, no matter how correct the install is. Failing on that
# would leave `test` permanently red for something the installer cannot fix
# and cannot regress. So: assert the install, report the backend.
# BB_TEST_REQUIRE_BACKEND=1 makes backend readiness fatal too.
doctor_as_user() {
local out rc=0 bad=0
out="$(mktemp "${TMPDIR:-/tmp}/bb-doctor.XXXXXX")"
# shellcheck disable=SC2016 # $HOME/$bb must expand in the *target* user's
# shell, not in this one — that's the whole point of the single quotes.
run_as_user '
for bb in bot-bottle "$HOME/.local/bin/bot-bottle" "$HOME/.bot-bottle/venv/bin/bot-bottle"; do
if command -v "$bb" >/dev/null 2>&1; then
case "$bb" in
bot-bottle) : ;;
*) echo " (not on PATH — running $bb directly, as install.sh advises)" ;;
esac
exec "$bb" doctor
fi
done
echo " no bot-bottle entry point found for this user" >&2
exit 1
' >"$out" 2>&1 || rc=$?
cat "$out"
# An unhandled exception is always an install/product defect, never an
# environment fact — this is exactly how the PermissionError on `ip` showed
# up, and doctor's non-zero exit alone would not have distinguished it.
if grep -q 'Traceback (most recent call last)' "$out"; then
echo " doctor crashed (traceback above) — a defect, not a missing prerequisite" >&2
bad=1
fi
grep -qE '^ok: +python:' "$out" \
|| { echo " doctor never reported a usable python" >&2; bad=1; }
grep -qE '^ok: +config:' "$out" \
|| { echo " doctor never reported a usable config dir" >&2; bad=1; }
if [ "$rc" -ne 0 ] && ! grep -qE '^(fail|warn): +backend' "$out"; then
echo " doctor failed for something other than backend readiness" >&2
bad=1
fi
local backend_ready=1
grep -qE '^fail: +backend' "$out" && backend_ready=0
rm -f "$out"
[ "$bad" -eq 0 ] || return 1
if [ "$backend_ready" -eq 0 ]; then
if [ "${BB_TEST_REQUIRE_BACKEND:-0}" = "1" ]; then
echo " no backend is ready and BB_TEST_REQUIRE_BACKEND=1" >&2
return 1
fi
echo " note: install is sound; no backend ready for this user."
echo " Expected on a fresh account — the Apple 'container' service is"
echo " per-user and needs one 'container system start'. Set"
echo " BB_TEST_REQUIRE_BACKEND=1 to treat this as a failure."
fi
return 0
}
# --- commands --------------------------------------------------------
cmd_up() {
require_macos
require_root up
if user_exists; then
echo "$USER_NAME already exists; nothing to do (run 'down' first to reset)"
return 0
fi
local admin_flag=()
[ "$ADMIN" = "1" ] && admin_flag=(-admin)
# No -password: the account is only ever driven headlessly via `sudo -u`,
# which doesn't need one. sysadminctl warns about FileVault here; that's
# irrelevant to a headless test account.
sysadminctl -addUser "$USER_NAME" -fullName "$FULL_NAME" "${admin_flag[@]}" || true
# sysadminctl exits 0 regardless of outcome, so confirm the account landed.
user_exists || { echo "error: failed to create $USER_NAME" >&2; return 1; }
if [ "$IN_TEST" = 1 ]; then
echo "created $USER_NAME (admin=$ADMIN)"
else
echo "created $USER_NAME (admin=$ADMIN). Install into it with: sudo $0 run"
fi
}
# The package spec to install. install.sh's own default is the repo's DEFAULT
# BRANCH, which would mean piping this checkout's installer into the throwaway
# user and then installing main's code — verifying the PR's install.sh against
# a package that doesn't contain the PR. Pin to the branch under test instead.
#
# The branch has to be pushed: the throwaway user clones over https and cannot
# read this checkout (mode-700 home, and no SSH key for the ssh remote), so
# what it gets is whatever the remote has. Say so when that differs from here.
resolve_install_spec() {
if [ -n "${BOT_BOTTLE_INSTALL_SPEC:-}" ]; then
echo "$BOT_BOTTLE_INSTALL_SPEC"
return 0
fi
local branch
branch="$(git -C "$_REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null)" || branch=""
if [ -z "$branch" ] || [ "$branch" = "HEAD" ]; then
echo "note: not on a named branch; installing the repo default" >&2
echo "git+${BB_TEST_REPO_URL}"
return 0
fi
if [ -n "$(git -C "$_REPO_ROOT" status --porcelain 2>/dev/null)" ]; then
echo "warning: working tree is dirty — the throwaway user installs" >&2
echo " $branch as pushed, not what is on disk here." >&2
elif ! git -C "$_REPO_ROOT" diff --quiet "@{upstream}" 2>/dev/null; then
echo "warning: $branch differs from its upstream — push first, or the" >&2
echo " throwaway user installs stale code." >&2
fi
echo "git+${BB_TEST_REPO_URL}@${branch}"
}
cmd_run() {
require_macos
require_root run
user_exists || { echo "error: $USER_NAME does not exist; run 'sudo $0 up' first" >&2; return 1; }
local spec
spec="$(resolve_install_spec)"
echo "== installing bot-bottle as $USER_NAME =="
echo "== spec: $spec =="
if [ -n "${BB_TEST_INSTALL_URL:-}" ]; then
run_as_user "BOT_BOTTLE_INSTALL_SPEC='$spec' curl -fsSL '$BB_TEST_INSTALL_URL' | sh"
else
# Test THIS checkout's install.sh, not the published one, so a PR is
# verifiable before it lands. Feed it in on stdin rather than staging a
# copy somewhere the throwaway user can read: the redirect is opened by
# root before sudo drops privileges, so the tester's mode-700 home is a
# non-issue, there's no temp file to leak if the run is interrupted, and
# `sh -s` is the same shape as the documented `curl … | sh` install.
#
# The spec is prepended to the stream as an export rather than passed
# as an argument, for the same argv-joining reason as run_as_user.
{
printf "export BOT_BOTTLE_INSTALL_SPEC='%s'\n" "$spec"
cat "$_REPO_ROOT/install.sh"
} | sudo -u "$USER_NAME" -i sh -s
fi
[ "$IN_TEST" = 1 ] \
|| echo "== install.sh runs 'doctor' itself; re-check anytime with: $0 status =="
}
# Informational, with one teeth-bearing case: when it can actually reach
# doctor (root, account present) its exit status is doctor's, so `test` and
# any other caller can use it as the post-install assertion.
cmd_status() {
require_macos
local rc=0
if user_exists; then
echo "user: $USER_NAME present"
if [ "$(id -u)" -eq 0 ]; then
echo "doctor (as $USER_NAME):"
doctor_as_user || rc=1
else
echo " (re-run under sudo to run 'bot-bottle doctor' as $USER_NAME)"
fi
else
echo "user: $USER_NAME absent"
fi
if command -v container >/dev/null 2>&1; then
echo "backend: apple 'container' present ($(container --version 2>/dev/null | head -1))"
else
echo "backend: apple 'container' NOT on PATH (host prerequisite; install once)"
fi
return "$rc"
}
cmd_down() {
require_macos
require_root down
if ! user_exists; then
echo "$USER_NAME not present; nothing to remove"
return 0
fi
# A plain -deleteUser removes the home dir, which is the whole reset.
# -secure is a no-op on modern macOS (secure erase of the home folder
# was removed in Sierra), so it buys nothing here.
sysadminctl -deleteUser "$USER_NAME" || true
if user_exists; then
echo "error: $USER_NAME still present after delete." >&2
echo " - grant Terminal Full Disk Access (System Settings > Privacy & Security), or" >&2
echo " - it may hold the last Secure Token (won't happen while another admin exists)" >&2
return 1
fi
echo "removed $USER_NAME and its home — install surface is clean."
}
# Satisfy the throwaway user's backend prerequisites.
#
# `bot-bottle backend setup --backend=macos-container` deliberately does NOT do
# this: it only checks, then tells you to run `container system start` yourself
# (see backend/macos_container/setup.py — "no privileged host setup required").
# So the harness runs the command the product asks for, as the user who needs
# it, which is the whole prerequisite for the macOS backend given the CLI is
# already installed host-wide.
cmd_prereqs() {
require_macos
require_root prereqs
user_exists || { echo "error: $USER_NAME does not exist" >&2; return 1; }
command -v container >/dev/null 2>&1 || {
echo "error: the Apple 'container' CLI is not on PATH. It is a HOST" >&2
echo " prerequisite this harness does not install; see the header." >&2
return 1
}
echo "starting the per-user Apple 'container' service for $USER_NAME"
user_session_available || {
echo "warning: no launchd session for $USER_NAME; the service start is" >&2
echo " likely to fail with 'unauthorized request'. See user_session_available." >&2
}
# The kernel flag is not optional for a headless run. `container system
# start` PROMPTS for the default Linux kernel when neither flag is given
# ("default: prompt user"), and a throwaway account has no kernel because
# it lives in the per-user app root — so the prompt always fires here and
# dies on "failed to read user input" with no TTY.
#
# Enabling it is also the honest choice for what this variant claims: the
# backend cannot actually run a bottle without a guest kernel. The cost is
# that every test-ready run downloads one afresh, since the previous run's
# copy went with the deleted home. BB_TEST_KERNEL_INSTALL=0 skips the
# download when you only care that the service comes up.
local kernel_flag=--enable-kernel-install
[ "${BB_TEST_KERNEL_INSTALL:-1}" = "0" ] && kernel_flag=--disable-kernel-install
echo " ($kernel_flag)"
run_as_user "container system start $kernel_flag" || {
echo "error: 'container system start' failed for $USER_NAME" >&2
echo " invalidState/\"unauthorized request\" means the CLI could not reach" >&2
echo " its per-user apiserver over XPC — a never-GUI-logged-in account may" >&2
echo " have no usable launchd domain; log into $USER_NAME once to get one." >&2
echo " A download failure means the guest kernel could not be fetched; retry" >&2
echo " with network, or BB_TEST_KERNEL_INSTALL=0 to skip it." >&2
return 1
}
run_as_user 'container system status' || {
echo "error: the service is still not running for $USER_NAME" >&2
return 1
}
}
# Teardown half of the test cycles, installed as an EXIT trap the moment the
# account exists so a failure — or a Ctrl-C — still leaves the machine clean.
_test_teardown() {
local rc=$?
trap - EXIT INT TERM
if [ "${BB_TEST_KEEP:-0}" = "1" ]; then
echo
echo "== [$_STEPS/$_STEPS] down: SKIPPED (BB_TEST_KEEP=1) =="
echo " $USER_NAME is still around; remove it with: sudo $0 down"
exit "$rc"
fi
echo
echo "== [$_STEPS/$_STEPS] down =="
cmd_down || rc=1
if [ "$rc" -eq 0 ]; then
echo
echo "PASS: $_PASS_CLAIM"
else
echo
echo "FAIL: see above (the throwaway account was torn down regardless)." >&2
fi
exit "$rc"
}
# The two variants differ only in whether the backend prerequisites get
# satisfied before doctor runs, which is exactly the question each one asks:
#
# test a brand-new user on a machine where nothing has been set up for
# them. Asserts the INSTALL is sound; backend readiness is
# reported, not required, because install.sh does not provide a
# backend and cannot regress one.
# test-ready the same user with the documented prerequisites satisfied.
# Asserts doctor goes fully green, backend included — the
# end-to-end "a new user can actually run a bottle" claim.
_test_cycle() {
local with_prereqs="$1"
require_macos
require_root test
# A pre-existing account means a pre-existing home, which is the one thing
# this harness exists to rule out. Don't silently test a dirty install.
if user_exists; then
echo "error: $USER_NAME already exists, so this would not be a clean install." >&2
echo " reset first: sudo $0 down" >&2
return 1
fi
IN_TEST=1
echo "== [1/$_STEPS] up =="
cmd_up
trap _test_teardown EXIT INT TERM
echo
echo "== [2/$_STEPS] run =="
cmd_run
if [ "$with_prereqs" = 1 ]; then
echo
echo "== [3/$_STEPS] prereqs =="
cmd_prereqs || {
echo "error: could not satisfy the backend prerequisites (see above)." >&2
return 1
}
fi
echo
echo "== [$((_STEPS - 1))/$_STEPS] status =="
# install.sh exits 0 even when doctor reports unmet prerequisites, so the
# install succeeding is not the verdict — this is.
cmd_status || {
echo "error: doctor is unhappy for a freshly installed user (see above)." >&2
echo " re-run with BB_TEST_KEEP=1 to keep $USER_NAME around and dig in." >&2
return 1
}
}
cmd_test() {
_STEPS=4
_PASS_CLAIM="a brand-new user can install bot-bottle; the install is sound."
_test_cycle 0
}
cmd_test_ready() {
_STEPS=5
_PASS_CLAIM="a brand-new user with the prerequisites met passes doctor outright."
# With the prerequisites satisfied there is no excuse for an unready
# backend, so this variant demands the thing `test` only reports.
BB_TEST_REQUIRE_BACKEND=1
_test_cycle 1
}
cmd_deep_reset() {
require_macos
require_root deep-reset
# Remove the user first (idempotent), then the HOST-level container
# runtime that a user deletion leaves behind under /usr/local + launchd.
cmd_down || true
if command -v container >/dev/null 2>&1; then
# The service can run in more than one launchd context (the invoking
# user's and root's), so stop both, best-effort.
[ -n "${SUDO_USER:-}" ] && sudo -u "$SUDO_USER" container system stop 2>/dev/null || true
container system stop 2>/dev/null || true
if [ -x /usr/local/bin/uninstall-container.sh ]; then
/usr/local/bin/uninstall-container.sh -d || true
echo "uninstalled the host Apple 'container' runtime"
else
echo "note: /usr/local/bin/uninstall-container.sh not found; runtime left as-is" >&2
fi
else
echo "no 'container' runtime on PATH; nothing further to remove"
fi
}
case "${1:-}" in
test) cmd_test ;;
test-ready) cmd_test_ready ;;
up) cmd_up ;;
run) cmd_run ;;
prereqs) cmd_prereqs ;;
status) cmd_status ;;
down) cmd_down ;;
deep-reset) cmd_deep_reset ;;
*) echo "usage: $0 {test|test-ready|up|run|prereqs|status|down|deep-reset}" >&2 ; exit 2 ;;
esac
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Publish a qualified release and advance its matching channel."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from bot_bottle.release_qualification import (
build_release_pointer,
canonical_pointer,
publish_qualification,
)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--tag", required=True)
parser.add_argument("--source-commit", required=True)
parser.add_argument("--workflow-run", required=True)
parser.add_argument("--qualified-at", required=True)
parser.add_argument("--output", type=Path, default=Path("release.json"))
parser.add_argument("--allow-rollback", action="store_true")
args = parser.parse_args()
pointer = build_release_pointer(
tag=args.tag,
source_commit=args.source_commit,
workflow_run=args.workflow_run,
qualified_at=args.qualified_at,
)
args.output.write_bytes(canonical_pointer(pointer))
release_url, channel_url = publish_qualification(
pointer, allow_rollback=args.allow_rollback)
print(json.dumps({
"release": release_url,
"channel": channel_url,
}, sort_keys=True))
if __name__ == "__main__":
main()
+14
View File
@@ -10,6 +10,8 @@ Kept in sync with ``bot_bottle.resources.BUNDLED_RESOURCES`` — the
from __future__ import annotations
import json
import os
import shutil
from pathlib import Path
@@ -44,6 +46,18 @@ class _BundleResources(build_py):
dst = pkg_resources / rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
manifest_dest = Path(self.build_lib) / "bot_bottle" / "release-manifest.json"
manifest_input = os.environ.get("BOT_BOTTLE_RELEASE_MANIFEST", "").strip()
if manifest_input:
shutil.copy2(manifest_input, manifest_dest)
else:
# Wheels built ad hoc retain the contributor/install.sh local-build
# behavior. The release workflow always replaces this marker with
# validated immutable artifact identities.
manifest_dest.write_text(
json.dumps({"schema": 1, "development": True}) + "\n",
encoding="utf-8",
)
setup(cmdclass={"build_py": _BundleResources})
+2 -2
View File
@@ -95,7 +95,7 @@ BOT_BOTTLE_RUN_CANARIES=1 python -m scripts.unittest_gate \
```
4. Skip guards live in `tests._backend` and gate on the backend's own
readiness check, `bot_bottle.backend.has_backend` — the same probe
behind `./cli.py backend status`:
behind `bot-bottle backend status`:
- Backend-agnostic tests (go through `get_bottle_backend()`) decorate
the class with `@skip_unless_selected_backend_available()` — the test
runs against whichever backend `BOT_BOTTLE_BACKEND` selects and skips
@@ -105,7 +105,7 @@ BOT_BOTTLE_RUN_CANARIES=1 python -m scripts.unittest_gate \
`backend.docker.*`, …) decorate with `@skip_unless_backend("docker")`
so they no-op under a run targeting a different backend.
Each CI integration job runs `./cli.py backend status --backend=<name>`
Each CI integration job runs `bot-bottle backend status --backend=<name>`
as a preflight, which prints a clear per-check summary and exits non-zero
when the backend is missing — so absent infrastructure fails the job
instead of hiding among per-test `unittest.skip` lines.
+1 -1
View File
@@ -2,7 +2,7 @@
Each integration test targets the backend named by ``BOT_BOTTLE_BACKEND``
(default ``docker``) and gates on that backend's full readiness check —
``is_backend_ready()`` (equivalent to ``./cli.py backend status``), not just
``is_backend_ready()`` (equivalent to ``bot-bottle backend status``), not just
a binary-on-PATH probe. When the backend is not ready, diagnostic output is
printed during test discovery so the operator sees a concrete reason for each
skip.
+1 -1
View File
@@ -1,7 +1,7 @@
"""Tests for the backend-aware skip guards in ``tests/_backend.py``.
The guards delegate their readiness check to
``bot_bottle.backend.is_backend_ready`` (the probe behind ``./cli.py backend
``bot_bottle.backend.is_backend_ready`` (the probe behind ``bot-bottle backend
status``); here that probe is mocked so the unit job asserts the
selection/skip logic without either backend present on the runner.
"""
+1 -1
View File
@@ -1,6 +1,6 @@
"""Unit: the generic `backend` CLI command.
`./cli.py backend {setup,status} [--backend=NAME]` resolves a backend
`bot-bottle backend {setup,status} [--backend=NAME]` resolves a backend
and dispatches to its `setup()` / `status()` classmethods no
backend-specific commands. Also covers the docker backend's own
setup/status logic (the firecracker path is exercised by
+6 -2
View File
@@ -17,6 +17,7 @@ from bot_bottle.cli.commands import cleanup as cmd
def _make_backend(empty: bool = True):
backend = MagicMock()
plan = MagicMock(empty=empty)
plan.intersect.return_value = plan
backend.prepare_cleanup.return_value = plan
backend.cleanup = MagicMock()
return backend, plan
@@ -135,10 +136,12 @@ class TestCmdCleanup(unittest.TestCase):
docker.cleanup.assert_called_once_with(docker_plan)
fc.cleanup.assert_not_called()
def test_executes_refreshed_plan_after_confirmation(self):
def test_executes_only_displayed_resources_still_current(self):
backend = MagicMock()
preview = MagicMock(empty=False)
refreshed = MagicMock(empty=False)
approved = MagicMock(empty=False)
preview.intersect.return_value = approved
backend.prepare_cleanup.side_effect = [preview, refreshed]
with patch.object(
@@ -152,7 +155,8 @@ class TestCmdCleanup(unittest.TestCase):
):
self.assertEqual(0, cmd.cmd_cleanup([]))
backend.cleanup.assert_called_once_with(refreshed)
preview.intersect.assert_called_once_with(refreshed)
backend.cleanup.assert_called_once_with(approved)
if __name__ == "__main__":
+11
View File
@@ -44,6 +44,17 @@ class TestProjectNaming(unittest.TestCase):
class TestComposeProjectListing(unittest.TestCase):
def test_compose_ls_empty_when_docker_unusable(self):
# Missing is the obvious case; present-but-not-executable raises
# PermissionError instead, which must not escape as a crash.
for exc in (FileNotFoundError, PermissionError(13, "Permission denied", "docker")):
with self.subTest(exc=type(exc).__name__):
with mock.patch(
"bot_bottle.backend.docker.compose.subprocess.run",
side_effect=exc,
):
self.assertEqual([], list_compose_projects())
def test_compose_ls_error_warns_by_default(self):
with (
mock.patch(

Some files were not shown because too many files have changed in this diff Show More