Compare commits

..

94 Commits

Author SHA1 Message Date
didericis bd663196dc docs: reposition README around provider-neutral secure substrate
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 17s
Lead with the agnostic + security story instead of the single-user
security framing. New hero positions bot-bottle as a neutral control
plane that runs any agent (Claude, Codex, or a drop-in contrib plugin)
inside an isolation boundary the agent can't touch.

Restructure Features into three pillars — neutral substrate, isolation
boundary, host-matched isolation — promoting provider-agnosticism (PRD
0053 user plugins) from a buried bullet to a headline. No capability
claims changed; per-provider auth/image detail preserved as a note
linking to Manifest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YcU7nerbg8cVj9R4EkpfLJ
2026-06-24 01:21:05 -04:00
didericis-codex 6b0de88be6 docs: activate install script prd
lint / lint (push) Successful in 1m39s
test / unit (pull_request) Successful in 31s
test / integration (pull_request) Successful in 16s
2026-06-23 21:47:12 -04:00
didericis-codex 9a941e59be feat: add install script packaging 2026-06-23 21:47:12 -04:00
didericis d7a3539755 ci(prd): rename PRD to prd-new placeholder per new convention 2026-06-23 21:46:44 -04:00
didericis cfe57a50d0 docs(prd): renumber PRD 0054 → 0057 (0054 slot taken by named-labelled-agents) 2026-06-23 21:46:44 -04:00
didericis e5d551861c docs(prd): PRD 0054 - install script 2026-06-23 21:46:44 -04:00
didericis 369d332204 Default the supervise flag to true
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 17s
lint / lint (push) Successful in 1m40s
test / unit (push) Successful in 30s
test / integration (push) Successful in 15s
Update Quality Badges / update-badges (push) Successful in 1m44s
Issue #249: bottles should be supervised by default. Rather than
remove the flag (which would make supervision mandatory and is the
wrong plane for cost-control enforcement — see #251), keep the
opt-out and flip the default. Bottles that omit `supervise:` now get
the stuck-recovery sidecar; `supervise: false` still skips it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YcU7nerbg8cVj9R4EkpfLJ
2026-06-23 20:48:04 -04:00
didericis 31cde11b0d docs: correct stale role field and claude provider auth example
lint / lint (push) Successful in 1m53s
The egress route fields table described `role` as a functional field
that wires built-in auth flows. PRD 0029 removed the
`claude_code_oauth` role; the manifest parser now rejects any `role`
value as reserved-for-future-use. Provider auth routes are injected
from `agent_provider.auth_token`.

- README: fix the `role` row to state it is reserved and any value is
  rejected at load.
- examples/bottles/claude.md: the manual `api.anthropic.com` route used
  the rejected `role` key and, even without it, would be silently
  dropped (provider-injected routes win for a provisioned host) — so its
  auth never took effect and the dlp comments described a route that
  never exists in the plan. Replace it with the canonical
  `agent_provider.auth_token` shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YcU7nerbg8cVj9R4EkpfLJ
2026-06-23 17:53:18 -04:00
didericis-claude c41751f3b9 docs: add role and git.fetch to egress route fields table
Both fields were missing from the reference table added in the preceding
commit — `role` is visible in examples/bottles/claude.md and `git.fetch`
is documented in PRD 0052 but neither appeared in the README table.
2026-06-23 17:48:19 -04:00
didericis e2422c20a0 docs: document egress matches, dlp fields, and detector defaults 2026-06-23 17:48:19 -04:00
github-actions[bot] de71533a17 ci(prd): assign sequential numbers to new PRDs 2026-06-23 21:47:01 +00:00
didericis-claude 88c4f61901 fix: don't archive gitleaks-allow response before gate reads it
test / unit (pull_request) Successful in 41s
test / integration (pull_request) Successful in 18s
lint / lint (push) Successful in 1m52s
prd-number / assign-numbers (push) Successful in 45s
test / unit (push) Successful in 36s
test / integration (push) Successful in 21s
Update Quality Badges / update-badges (push) Successful in 1m19s
The TUI was calling archive_proposal for gitleaks-allow immediately
after write_response, moving the response file to processed/ within
microseconds. The git-gate shell loop polls queue_dir for the response
file every second — it never sees it and hangs until timeout.

capability-block is handled by the MCP sidecar which archives after
reading; gitleaks-allow is handled by the shell gate which archives
after processing. Let the gate own the archive step.
2026-06-23 17:37:01 -04:00
didericis-claude c666eaa63f fix: add TOOL_GITLEAKS_ALLOW to __all__ in supervise.py 2026-06-23 17:36:08 -04:00
didericis-codex 83eb9e4041 docs(prd): add gitleaks allow supervision 2026-06-23 17:36:08 -04:00
didericis-codex 33333ac4d9 Supervise gitleaks inline allow exceptions 2026-06-23 17:36:08 -04:00
github-actions[bot] 4d56f515bc ci(prd): assign sequential numbers to new PRDs 2026-06-23 21:32:54 +00:00
didericis-claude c48c3688b8 fix(smolmachines): exclude /tmp+/var/tmp from snapshot; mkdir -p on boot
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 23s
lint / lint (push) Successful in 1m59s
prd-number / assign-numbers (push) Successful in 1m8s
test / unit (push) Successful in 35s
test / integration (push) Successful in 21s
Update Quality Badges / update-badges (push) Successful in 1m22s
On resume from a committed snapshot, smolvm's pack process remaps all
file uids to the host uid (501 on macOS). Files in /tmp that were
created during the session (e.g. /tmp/claude-1000 owned by node=uid
1000) get remapped to 501. Claude Code then refuses to use the temp
directory because it's owned by a different uid.

Two-part fix:
- Exclude ./tmp and ./var/tmp from the tar in _exec_tar_to_file.
  Both directories are ephemeral; a resumed VM should start with clean
  temp directories identical to a fresh VM.
- Add mkdir -p /tmp /var/tmp to _init_vm before chown/chmod, so the
  directories are created if the committed snapshot omitted them.
2026-06-23 16:53:41 -04:00
didericis-claude 6040b20e6e fix(smolmachines): write tar to VM file then machine_cp to host
Replace the Popen/stdout=PIPE approach with a write-then-copy
strategy that avoids binary-stdout piping through the smolvm exec
channel entirely:

1. Probe connectivity with `machine_exec(machine, ["true"])` first.
   If this fails while an interactive session is running, the error
   now says "concurrent exec not available" instead of the opaque
   "<no stderr>".

2. Run `tar --create --gzip --file=/var/tmp/.bot-bottle-commit.tar.gz`
   inside the VM via machine_exec (same mechanism used during
   provisioning). tar writes to a file in the VM, not stdout, so
   smolvm never has to transmit binary data over the exec channel.

3. Copy the compressed archive to the host with machine_cp.

4. Dockerfile switches to ADD rootfs.tar.gz / — Docker decompresses
   gzip tarballs automatically.
2026-06-23 16:53:41 -04:00
didericis-claude f2775101a0 fix(smolmachines): pipe tar stdout via PIPE not file fd
smolvm machine exec requires stdout to be a pipe, not a regular
file descriptor. Passing stdout=file caused smolvm to return
non-zero with no stderr (the error was silently swallowed or went
to the regular-file fd instead of reaching us).

Switch _snapshot_running_vm to a new _exec_tar_to_file helper that
uses Popen with stdout=PIPE and streams the tar to disk via
shutil.copyfileobj. A background thread drains stderr concurrently
to prevent deadlock when the stderr pipe buffer fills while we are
writing stdout data.
2026-06-23 16:53:41 -04:00
didericis-claude dd99c495f4 fix(smolmachines): use sh -c not sh -lc in exec_agent
The terminal-decoration wrapper script is invoked with sh -lc, which
sources login-shell init files (/etc/profile, ~/.profile) rather than
interactive-shell files (~/.zshrc). smolvm is typically installed via
homebrew whose PATH setup lands in ~/.zprofile or ~/.zshrc — not picked
up by sh -l — so pty_resize.py's Popen(["smolvm", ...]) raises
FileNotFoundError, pty_resize exits non-zero, and the trailing reset-
printf makes sh exit 0. The caller sees "session ended (exit 0)"
immediately with no agent output.

Use sh -c instead. The calling process (./cli.py) inherits the user's
interactive shell PATH where smolvm is present, confirmed by the
provision steps (machine_exec) succeeding before exec_agent is reached.
2026-06-23 16:53:41 -04:00
didericis-claude eb64a52ffa fix(smolmachines): commit via exec-tar instead of stop→pack
smolvm pack create --from-vm requires the VM to be stopped, and stopping
a smolmachines VM terminates any running interactive session.

Instead, mirror the macos-container approach: exec into the running VM as
root and stream the root filesystem via tar (smolvm machine exec -- tar),
build a Docker image from the archive, push to an ephemeral local registry,
and run smolvm pack create --image to produce the .smolmachine artifact.
The VM stays running throughout the commit.

Remove the stop-confirm prompt and machine_is_running check that were
added in the previous commit — neither is needed when we no longer stop.
2026-06-23 16:53:41 -04:00
didericis-claude d11e3940fa fix(smolmachines): stop VM before pack commit, with confirm prompt
smolvm pack create --from-vm requires the VM to be stopped. Add
machine_is_running() to smolvm.py (via machine ls --json state field),
and add the same confirm-stop flow to SmolmachinesFreezer that was
originally designed for macos-container: if running, prompt the user,
stop the VM, then pack. Already-stopped VMs are packed directly.
2026-06-23 16:53:41 -04:00
didericis-claude a32c0c7865 test: update macos-container tests for exec-tar commit approach
- Rename export test to reflect new exec-tar mechanism; update argv
  assertions to match the new `container exec ... tar` command shape
- Change mock stderr from str to bytes (subprocess.PIPE without text=True)
- Add type annotation to capture_freeze closure to satisfy pyright
2026-06-23 16:53:41 -04:00
didericis-claude ccb2956562 fix(macos-container): commit via exec-tar instead of stop→export
Apple Container removes containers when they stop, making the
stop-then-export flow impossible regardless of the --rm flag.

Replace `container export` (requires stopped container) with
`container exec --user root <name> tar --create ... --file=- --directory=/ .`
streamed to a temp file, then build the committed image from that archive
as before. The bottle stays running after commit, which is better UX.

Drop the stop-confirm prompt from MacosContainerFreezer since we no longer
need to stop the container at all.
2026-06-23 16:53:41 -04:00
didericis-claude c6362fda7b fix(macos-container): remove --rm from agent run so commit can export
container stop was removing the container immediately (due to --rm)
before container export could run. The force_remove_container teardown
callback on the ExitStack already handles cleanup on normal exit, so
--rm was redundant. Without it, the stopped container stays available
for container export to snapshot.
2026-06-23 16:53:41 -04:00
didericis-claude cb321f7ad4 refactor(freezer): drop Bottle from commit signature
Freezer._freeze only ever used bottle.name, which is always
f"bot-bottle-{agent.slug}". Remove the Bottle parameter from
commit() and _freeze(), derive the container name from agent.slug
directly in each subclass, and delete the _NamedBottle stub that
existed solely to paper over this.
2026-06-23 16:53:41 -04:00
didericis-claude 311cd46185 refactor(commit): introduce Freezer class hierarchy across backends
Adds a Freezer ABC (backend/freeze.py) that encapsulates the
stop-commit-mark-preserved flow for all backends, following the same
pattern as BottleBackend. Each backend gets its own Freezer subclass:

  DockerFreezer           — docker commit
  MacosContainerFreezer   — container export + image rebuild; prompts
                            to stop if the container is running
  SmolmachinesFreezer     — smolvm pack create --from-vm

The base class owns write_committed_image, mark_preserved, and the
resume hint. Subclasses implement _freeze() and optionally override
_export_hint() for migration instructions.

Freezer.commit(agent, bottle) is the primary entry point for use
within a live launch context. Freezer.commit_slug(slug) is a
convenience wrapper for cmd_commit, which no longer branches on
backend names itself.

get_freezer(backend_name) is the factory, analogous to
get_bottle_backend(). CommitCancelled is raised by MacosContainerFreezer
when the user declines the stop prompt; cmd_commit catches it and
returns 0.
2026-06-23 16:53:41 -04:00
didericis-claude 28335f453f fix(commit): stop running macos-container bottle before committing
`container export` requires the container to be stopped first. When a
running bottle is detected, prompt the user to confirm, stop the
container, then commit. Adds `container_is_running` and
`stop_container` helpers to the macos-container util.

Addresses #240 (comment)
2026-06-23 16:53:41 -04:00
didericis-claude a1aa8feb85 fix: correct Manifest/ManifestIndex usage and add missing type annotations in tests
- test_docker_launch_committed_image: replace Manifest.from_json_obj
  (nonexistent) with ManifestIndex.from_json_obj; pass manifest= arg
  to DockerBottlePlan constructor (required by BottlePlan base class)
- test_macos_container_launch: cast SimpleNamespace stubs to their
  expected types (BottleSpec, GitGatePlan, EgressPlan) in _build_plan;
  add str type annotations to fake_build parameter signatures
- test_macos_container_util: add str type annotations to fake_build_image
  parameter signatures
2026-06-23 16:53:41 -04:00
didericis cb3bb209d6 feat: support macos-container bottle commits 2026-06-23 16:53:41 -04:00
didericis-codex 6e73cc4d86 feat: support smolmachines bottle commit 2026-06-23 16:53:41 -04:00
didericis-claude 64fac71025 docs(prd): mark commit-bottle-state PRD as Active 2026-06-23 16:53:41 -04:00
didericis-claude f8ac22c316 feat(cli): add commit command to snapshot running bottle state
Adds `./cli.py commit [<slug>]` which runs `docker commit` on the
active agent container and stores the resulting image tag in per-bottle
state. The next `./cli.py resume <slug>` automatically boots from the
committed snapshot instead of rebuilding from the Dockerfile, preserving
all in-container state across restarts and migrations.

- bottle_state: add write_committed_image / read_committed_image helpers
- docker/util: add commit_container wrapper around `docker commit`
- docker/launch: check for a committed image before the Dockerfile build
  step; fall back to normal build if the image is absent from the daemon
- cli/commit: new command with interactive slug picker; errors clearly on
  non-Docker backends
- 50 new unit tests covering all paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 16:53:41 -04:00
Quality Badge Bot 9465857a99 chore: update quality badges
- Pylint: 9.93/10
- Pyright: 0 errors

[skip ci]
2026-06-23 20:46:17 +00:00
didericis-claude 200306f1cf refactor: export applicator singletons from egress_apply backends
test / unit (pull_request) Successful in 35s
test / integration (pull_request) Successful in 21s
lint / lint (push) Successful in 1m44s
test / unit (push) Successful in 32s
test / integration (push) Successful in 19s
Update Quality Badges / update-badges (push) Successful in 1m17s
Replace module-level apply_routes_change wrappers with a public
applicator singleton in each backend. Callers now work with the
EgressApplicator instance directly (applicator.apply_routes_change)
rather than through a function shim.
2026-06-23 20:39:05 +00:00
didericis-claude 77bdaf0a96 refactor: extract EgressApplicator base class shared between backends
lint / lint (push) Successful in 1m56s
test / unit (pull_request) Successful in 42s
test / integration (pull_request) Successful in 20s
Pulls the duplicated apply_routes_change / validate_routes_content /
_routes_path logic into EgressApplicator (ABC) in backend/egress_apply.py.
DockerEgressApplicator and MacOSContainerEgressApplicator override the
single abstract _signal_bundle_reload method with their respective kill
commands. Module-level shims preserve the existing public API.
2026-06-23 20:33:43 +00:00
didericis 7e344bbb53 fix: add lowercase proxy env vars, route_to_yaml_dict, and richer tool descriptions
lint / lint (push) Successful in 1m51s
test / unit (pull_request) Successful in 41s
test / integration (pull_request) Successful in 18s
- Set http_proxy/https_proxy (lowercase) alongside uppercase variants in smolmachines guest env for tools that only check lowercase
- Replace dataclasses.asdict with route_to_yaml_dict in /allowlist introspection so returned routes use YAML-schema-compatible keys
- Expand routes_yaml tool description in supervise_server to document all accepted route keys, making the round-trip from list-egress-routes to propose/apply explicit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 16:13:07 -04:00
didericis-claude 5eb27cd9a8 fix: mount egress dir (not file) for docker and smolmachines backends
lint / lint (push) Successful in 1m37s
test / unit (pull_request) Successful in 31s
test / integration (pull_request) Successful in 16s
Mirrors the fix already applied to the macos-container backend in
eb3e64e: bind-mount the parent egress directory instead of the
routes file itself, so the live routes update is visible inside the
running sidecar bundle when the host overwrites the file.
2026-06-23 09:05:44 +00:00
didericis-claude 5808d0b828 feat: add smolmachines/egress_apply proxying docker backend
lint / lint (push) Successful in 1m40s
test / unit (pull_request) Successful in 33s
test / integration (pull_request) Successful in 16s
2026-06-23 06:53:56 +00:00
didericis-claude 7a991e1f5e refactor: split _signal_bundle_reload per backend, move macos egress to macos_container
lint / lint (push) Successful in 1m47s
test / unit (pull_request) Successful in 38s
test / integration (pull_request) Successful in 19s
2026-06-23 05:57:07 +00:00
didericis-claude 5606797ac2 refactor: drop legacy routes path fallback from _routes_path
lint / lint (push) Successful in 1m37s
test / unit (pull_request) Failing after 29s
test / integration (pull_request) Successful in 18s
2026-06-23 05:48:50 +00:00
didericis-claude ebbb4053cf fix: add type annotations to fake_run in test_egress_apply
lint / lint (push) Successful in 1m40s
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 18s
2026-06-23 05:47:11 +00:00
didericis eb3e64ea8f fix(macos-container): mount live egress routes dir
lint / lint (push) Failing after 1m35s
test / unit (pull_request) Successful in 33s
test / integration (pull_request) Successful in 16s
2026-06-23 01:39:29 -04:00
didericis 0ec1085238 fix(supervise): apply egress approvals
lint / lint (push) Failing after 1m34s
test / unit (pull_request) Successful in 33s
test / integration (pull_request) Successful in 15s
2026-06-23 01:33:35 -04:00
didericis 4c39b45e34 fix(supervise): restore egress proposal tools
lint / lint (push) Successful in 1m35s
test / unit (pull_request) Successful in 30s
test / integration (pull_request) Successful in 16s
2026-06-23 01:24:28 -04:00
didericis-codex 3ea35ba5d2 fix: update codex supervise mcp registration
lint / lint (push) Successful in 1m54s
test / unit (pull_request) Successful in 38s
test / integration (pull_request) Successful in 22s
2026-06-23 04:06:21 +00:00
Quality Badge Bot 7c6ab62e26 chore: update quality badges
- Pylint: 9.92/10
- Pyright: 0 errors

[skip ci]
2026-06-23 04:05:16 +00:00
didericis-claude da42740156 refactor(types): move loaded manifest from BottleSpec to BottlePlan
test / integration (pull_request) Successful in 21s
test / unit (pull_request) Successful in 49s
lint / lint (push) Successful in 2m15s
test / unit (push) Successful in 56s
test / integration (push) Successful in 27s
Update Quality Badges / update-badges (push) Successful in 2m37s
BottleSpec.manifest was ManifestIndex | Manifest — a union encoding
two lifecycle stages in one field. The union was unjustifiable:
it forced a type-narrowing workaround (loaded_manifest property)
on every consumer.

Clean split:
- BottleSpec.manifest: ManifestIndex (always; CLI-supplied intent)
- BottlePlan.manifest: Manifest (always; loaded by _validate())

_validate() returns the loaded Manifest directly. prepare() passes
it to _resolve_plan(), which stores it on the plan. All provisioner
code now reads plan.manifest.agent / plan.manifest.bottle — no
union, no asserts, no type: ignore.
2026-06-22 23:54:02 -04:00
didericis-claude 56ef71060a fix(types): add BottleSpec.loaded_manifest to satisfy pyright on union type
BottleSpec.manifest is ManifestIndex | Manifest (pre/post _validate()).
Downstream code always runs post-validate so it needs Manifest, but
pyright flagged every .agent/.bottle access. The new loaded_manifest
property asserts isinstance and returns Manifest, giving pyright a
narrowed type without scattering type: ignore everywhere.

Also remove unused Manifest imports from test files and annotate the
_index() helper in test_manifest_agent_git_user.
2026-06-22 23:54:02 -04:00
didericis-claude 294a6ed023 refactor(manifest): split Manifest into ManifestIndex + Manifest single-value type
Manifest now holds exactly one agent and one effective bottle (with
git_user overlay already applied). The old multi-agent/bottle
collection is renamed ManifestIndex. BottleSpec.manifest starts as
ManifestIndex from the CLI and becomes Manifest after _validate()
calls load_for_agent(); all provisioning code downstream reads
spec.manifest.agent / spec.manifest.bottle instead of indexing by name.
2026-06-22 23:54:02 -04:00
didericis-claude 468ab8c290 docs: clarify load_for_agent invariant in docstring 2026-06-22 23:54:02 -04:00
didericis-claude 2596c18954 fix: load_for_agent always returns single-agent manifest
Filter to exactly one agent and one bottle in both the lazy (md-dirs)
and eager (from_json_obj) paths so the returned manifest invariant
holds regardless of how the manifest was constructed.
2026-06-22 23:54:02 -04:00
didericis-claude 3ccd09ed0d refactor: scan filenames at resolve, parse only selected agent at preflight
Manifest.resolve() now returns an empty-dict manifest with only directory
paths recorded (home_md, cwd_md). No content is read from any .md file
until load_for_agent() is called for a specific agent at preflight.

- Manifest.from_md_dirs: scan-only, no frontmatter parsing
- Manifest.load_for_agent: parses the selected agent file and its bottle
  chain; works on eager (from_json_obj) manifests too by returning self
- Manifest.all_agent_names: scans filenames in lazy mode
- backend._validate: calls load_for_agent and propagates upgraded spec
- cli/info.py, cli/list.py, cli/start.py: use load_for_agent / all_agent_names
- manifest_extends.py: reverted to original (no partial-resolve helpers)
- manifest_loader.py: only scan_agent_names + load_bottle_chain_from_dir
- Tests updated to call load_for_agent before accessing agents/bottles;
  test_md_agent_repos_deferred renamed to test_md_agent_repos_fails_at_preflight
2026-06-22 23:54:02 -04:00
didericis-claude 996a260a98 fix: resolve pyright reportUnusedImport in manifest_extends
Import ManifestError at module level from manifest_util (no circular
dep) and remove the redundant local imports from function bodies that
were shadowing it. ManifestBottle retains its local import pattern to
avoid the circular manifest ↔ manifest_extends dependency.
2026-06-22 23:54:02 -04:00
didericis-claude 3375df3f52 feat: defer broken manifest parse errors to preflight
Broken bottle/agent files no longer block the agent selector or prevent
unrelated agents from loading. Per-file parse errors are collected in
`Manifest.broken_agents`; the CLI selector includes them via
`all_agent_names`, and the error surfaces only when the specific agent
is selected and launch is attempted (in `require_agent`/`bottle_for`).

Closes #236
2026-06-22 23:54:02 -04:00
Quality Badge Bot c9842ce831 chore: update quality badges
- Pylint: 9.93/10
- Pyright: 0 errors

[skip ci]
2026-06-23 03:46:30 +00:00
didericis-codex d314ccf455 test(macos-container): satisfy pyright mock typing
test / unit (pull_request) Successful in 33s
test / integration (pull_request) Successful in 18s
lint / lint (push) Successful in 1m35s
test / unit (push) Successful in 31s
test / integration (push) Successful in 15s
Update Quality Badges / update-badges (push) Successful in 1m20s
2026-06-23 03:02:03 +00:00
didericis 31b29631b6 fix(macos-container): forward terminal capability env
lint / lint (push) Failing after 1m48s
test / unit (pull_request) Successful in 34s
test / integration (pull_request) Successful in 20s
2026-06-22 22:57:16 -04:00
didericis-claude 1c11110da5 fix(macos-container): set host terminal to raw mode for container exec
lint / lint (push) Failing after 1m42s
test / unit (pull_request) Successful in 33s
test / integration (pull_request) Successful in 19s
Apple's container exec --interactive --tty does not put the host
terminal into raw mode before starting its I/O relay.  In cooked
(canonical) mode the kernel line discipline buffers modifier-key
escape sequences — e.g. Shift+Enter in modifyOtherKeys mode generates
\x1b[13;2~ — until a carriage-return arrives, so they never reach
Claude Code inside the container.

Add pty_forward.py, a stdlib-only wrapper (modelled on the existing
smolmachines pty_resize.py) that sets the host terminal to raw mode
via tty.setraw(), spawns the container exec command, and restores the
original terminal attributes on exit.  Falls back to a bare
subprocess.run when stdin is not a TTY (piped invocations, CI) or
when termios operations fail.

Also retain the --env TERM=<host> forwarding from the previous commit:
without TERM inside the container session, Claude Code cannot determine
which modifier-key protocol to enable even with raw mode correctly set.

Non-TTY exec paths (bottle.exec, cp_in) are unaffected.
2026-06-23 02:30:46 +00:00
didericis-claude 25ca14a8a2 fix(macos-container): forward TERM env var in container exec --tty
lint / lint (push) Successful in 1m41s
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 21s
Without TERM, Claude Code inside the container cannot determine which
modifier-key protocol to enable (modifyOtherKeys / kitty). The inner
PTY session has no terminal-type context, so Shift+Enter and Enter
produce identical byte sequences (\r), making them indistinguishable.

Pass the host TERM via --env TERM=<value> on every container exec
--interactive --tty call, falling back to xterm-256color when TERM
is not set on the host. Non-TTY exec paths are unaffected.

Closes #245
2026-06-23 01:53:14 +00:00
Quality Badge Bot b5b7f15ef9 chore: update quality badges
- Pylint: 9.92/10
- Pyright: 0 errors

[skip ci]
2026-06-23 00:57:24 +00:00
didericis-claude 85e64b5134 feat: display agent name alongside label in terminal title and list output
test / unit (pull_request) Successful in 40s
test / integration (pull_request) Successful in 17s
lint / lint (push) Successful in 1m42s
test / unit (push) Successful in 41s
test / integration (push) Successful in 20s
Update Quality Badges / update-badges (push) Successful in 1m26s
When a label is set (e.g. "bob"), the display becomes "bob (claude-implementer)"
so the agent type is always visible. Affects all three backends (docker,
macos-container, smolmachines) and the `cli.py list active` output.

Closes #243
2026-06-23 00:28:16 +00:00
didericis-claude 1a5b6e25f8 fix: add type annotations to _modal stub for pyright
test / unit (pull_request) Successful in 34s
test / integration (pull_request) Successful in 19s
lint / lint (push) Successful in 1m36s
test / unit (push) Successful in 30s
test / integration (push) Successful in 18s
Update Quality Badges / update-badges (push) Successful in 1m19s
2026-06-22 19:30:53 +00:00
didericis-claude 54760964cf fix: label becomes the full slug; re-prompt on collision
lint / lint (push) Failing after 1m44s
test / unit (pull_request) Successful in 33s
test / integration (pull_request) Successful in 17s
When a label is given it is now used verbatim as the slug (no random
suffix), so two launches with the same label collide by design.  The
CLI re-prompts via the TUI name modal with a disclaimer when the
candidate slug is already in use among running bottles.
2026-06-22 19:26:39 +00:00
didericis-claude e463670649 feat: use label as container slug prefix when provided
lint / lint (push) Successful in 1m45s
test / unit (pull_request) Successful in 33s
test / integration (pull_request) Successful in 19s
When a user names a bottle via the TUI label field, that name is now
used as the slug prefix for the container identity instead of always
falling back to the agent name.
2026-06-22 19:16:53 +00:00
didericis-claude 6e6890ebd9 feat: remove cyan and white from color palette
lint / lint (push) Successful in 1m41s
test / unit (push) Successful in 33s
test / integration (push) Successful in 18s
Update Quality Badges / update-badges (push) Successful in 1m16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 19:09:22 +00:00
didericis-claude 609b3ed090 feat: drop dim colors, keep only bright variants renamed to base names
test / unit (pull_request) Successful in 38s
test / integration (pull_request) Successful in 21s
lint / lint (push) Successful in 1m40s
test / unit (push) Successful in 34s
test / integration (push) Successful in 19s
Update Quality Badges / update-badges (push) Successful in 1m19s
Remove the 8 non-bright and 1 bright-black colors from all color maps.
Rename the remaining 7 bright-* colors to their base names (e.g.
bright-green → green) so the palette is smaller and always vibrant.

Update _init_color_pairs in tui.py to always apply A_BOLD (all palette
entries are now bright variants), and fix all tests to match.
2026-06-22 18:59:51 +00:00
didericis 65faa40b9a refactor(backend): remove _validate_git_entries host key-file check
test / unit (pull_request) Successful in 37s
test / integration (pull_request) Successful in 18s
lint / lint (push) Successful in 1m39s
test / unit (push) Successful in 37s
test / integration (push) Successful in 18s
Update Quality Badges / update-badges (push) Successful in 1m38s
The git-gate copies the identity file at start time and surfaces a
clear failure then; the pre-launch presence check was redundant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:44:46 -04:00
didericis 9f97de115b fix(git-gate): skip host key-file check for gitea-provider repos
lint / lint (push) Successful in 1m39s
test / unit (pull_request) Successful in 34s
test / integration (pull_request) Successful in 18s
_validate_git_entries was written for static keys (PRD 0008) and ran
os.path.isfile() on every entry's IdentityFile. gitea-provider repos
(PRD 0047/0048) create their deploy key at provision time, so
IdentityFile is empty at parse — tripping the check with an empty path
("git upstream key file not found for '<name>': "). Gate the host-file
check on the static provider; gitea entries have nothing to verify here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 13:21:11 -04:00
didericis 8f21f4df19 refactor(manifest-extends): thread resolved repos through recursion
lint / lint (push) Successful in 1m32s
test / unit (pull_request) Successful in 28s
test / integration (pull_request) Successful in 16s
Replace the lossy _entry_to_raw round-trip with a repos_cache threaded
alongside the ManifestBottle cache in _resolve_one_bottle. Each bottle's
effective git-gate.repos is stored as raw dicts keyed by name, so a child
field-merges directly against its parent's raw repos instead of
reconstructing them from parsed ManifestGitEntry objects.

_resolve_repos_raw now owns the union/clear/inherit semantics on plain
dicts; _merge_bottles just injects the precomputed merged set before
parsing. Drops _entry_to_raw entirely, removing the maintenance hazard
where a new ManifestGitEntry field would silently vanish from inherited
repos.

Addresses review feedback on #238.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgEFTXcWZjA8n7ntq2zHQQ
2026-06-19 22:53:27 -04:00
didericis-claude ff7a52c1d2 refactor(manifest-extends): simplify git-gate repo merge to union + dict unpack
lint / lint (push) Failing after 1m31s
test / unit (pull_request) Successful in 30s
test / integration (pull_request) Successful in 17s
Replace the bespoke _pre_merge_git_repos loop and _merge_git_remotes
with a single _merge_git_repos_raw that does a name-keyed union merge
at the raw dict level: build parent_repos from _entry_to_raw, then
for each name in set(child) | set(parent) produce {**parent.get(n,{}),
**child.get(n,{})}. child.git after from_dict already has the full
merged set, so _merge_git_remotes is no longer needed.
2026-06-20 02:25:09 +00:00
didericis-claude 4ed6b84863 feat(manifest-extends): field-merge same-name git-gate repos on extends
lint / lint (push) Successful in 1m34s
test / unit (pull_request) Successful in 27s
test / integration (pull_request) Successful in 15s
When a child bottle declares a git-gate repo with the same name as a
parent repo, merge field-by-field (child wins, parent provides fallback)
instead of letting the child entry silently replace the parent entry.
This lets a child override only `key:` without repeating `url:` and
`host_key:`. Change the merge key in _merge_git_remotes from UpstreamHost
to Name, which is the natural unique identity for a repo entry.

Closes #237
2026-06-20 02:02:12 +00:00
didericis-claude 7a124d7d25 refactor: make static the default branch in _parse_key_config
test / unit (pull_request) Successful in 29s
test / integration (pull_request) Successful in 16s
lint / lint (push) Successful in 1m31s
test / unit (push) Successful in 28s
test / integration (push) Successful in 15s
Update Quality Badges / update-badges (push) Successful in 1m10s
2026-06-19 22:25:14 +00:00
didericis-claude f00c567469 rename: provisioner_token -> forge_token_env
lint / lint (push) Successful in 2m6s
test / unit (pull_request) Successful in 30s
test / integration (pull_request) Successful in 17s
2026-06-19 22:21:37 +00:00
didericis-claude 6f0e5b4589 refactor: extract _resolve_identity_file from prepare loop
lint / lint (push) Successful in 1m33s
test / unit (pull_request) Successful in 31s
test / integration (pull_request) Successful in 16s
2026-06-19 22:14:15 +00:00
didericis-claude 5da4d05bf2 fix: remove unused Optional import flagged by pyright
lint / lint (push) Successful in 1m33s
test / unit (pull_request) Successful in 30s
test / integration (pull_request) Successful in 17s
2026-06-19 22:09:52 +00:00
didericis-claude 1a8718ca9d refactor: unify identity/provisioned_key into key block
lint / lint (push) Failing after 1m45s
test / unit (pull_request) Successful in 35s
test / integration (pull_request) Successful in 17s
Replace the two mutually-exclusive repo keys (identity and
provisioned_key) with a single required key block. key.provider
is "static" (path to host SSH key) or "gitea" (deploy-key lifecycle
via provisioner_token env var, replacing token_env).

Internal fields: ManifestProvisionedKeyConfig → ManifestKeyConfig;
ProvisionedKey field removed from ManifestGitEntry; Key field added.
git_gate.py checks entry.Key.provider == "gitea" instead of
entry.ProvisionedKey is not None.
2026-06-19 22:01:43 +00:00
didericis-claude c1c225aa05 docs(gitea-provisioner): document required GITEA_DEPLOY_TOKEN permissions
lint / lint (push) Successful in 1m46s
test / unit (push) Successful in 34s
test / integration (push) Successful in 20s
Update Quality Badges / update-badges (push) Successful in 1m21s
2026-06-11 03:43:13 +00:00
didericis dc7c10d6fe fix(macos-container): use correct system status probe in preflight
lint / lint (push) Successful in 1m41s
test / unit (push) Successful in 34s
test / integration (push) Successful in 21s
Update Quality Badges / update-badges (push) Successful in 1m23s
`container system info` is not a valid subcommand and always returned
non-zero, causing a false-positive on the service check. Switch to
`container system status` which is the correct command.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:31:32 -04:00
Quality Badge Bot a827b0841e chore: update quality badges
- Pylint: 9.93/10
- Pyright: 0 errors

[skip ci]
2026-06-11 03:28:32 +00:00
didericis a9c93ea9df fix(macos-container): preflight check for container system service
lint / lint (push) Successful in 1m43s
test / unit (push) Successful in 34s
test / integration (push) Successful in 19s
Update Quality Badges / update-badges (push) Successful in 1m27s
Fail early with a clear message when the Apple Container system service
isn't running, instead of surfacing an opaque XPC connection error mid-build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:24:09 -04:00
didericis bb69af31f8 chore(claude): bump claude-code to 2.1.170
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:44:46 -04:00
didericis 7644da4280 docs: add Apple Container transparent egress spike 2026-06-10 22:36:55 -04:00
didericis 13e4af421d docs: add Apple Container networking spike 2026-06-10 22:36:55 -04:00
github-actions[bot] f2d5307573 ci(prd): assign sequential numbers to new PRDs 2026-06-11 02:36:07 +00:00
didericis bc9a22b46a fix(macos-container): support git-gate launch
test / unit (pull_request) Successful in 32s
test / integration (pull_request) Successful in 20s
lint / lint (push) Successful in 1m45s
prd-number / assign-numbers (push) Successful in 25s
test / unit (push) Successful in 32s
test / integration (push) Successful in 19s
Update Quality Badges / update-badges (push) Failing after 1m23s
2026-06-10 22:25:00 -04:00
didericis 932e71c0bf fix(macos-container): make backend the macos default 2026-06-10 22:25:00 -04:00
didericis d3b0b330aa fix(macos-container): preserve working builder dns 2026-06-10 22:25:00 -04:00
didericis 5e927bcd13 fix(macos-container): start builder with dns 2026-06-10 22:25:00 -04:00
didericis 890a146413 test(macos-container): add launch integration smoke 2026-06-10 22:25:00 -04:00
didericis afdf0779a1 feat(macos-container): launch explicit-proxy bottles 2026-06-10 22:25:00 -04:00
didericis-codex eb7cae1fea docs: link macos container prd to review comment 2026-06-10 22:25:00 -04:00
didericis-codex fe82dc7f2b feat: add macos container backend scaffold 2026-06-10 22:25:00 -04:00
didericis-claude b00b0ba4aa fix(git-gate): forward force push as +refspec to upstream
test / unit (pull_request) Successful in 38s
test / integration (pull_request) Successful in 22s
lint / lint (push) Successful in 1m47s
test / unit (push) Successful in 34s
test / integration (push) Successful in 20s
Update Quality Badges / update-badges (push) Successful in 1m21s
When $old != zero and $new is not a descendant of $old (detected via
git merge-base --is-ancestor), the hook now forwards +$new:$ref so the
upstream accepts the force push instead of rejecting it as a
non-fast-forward.

Closes #233
2026-06-11 02:17:27 +00:00
113 changed files with 5343 additions and 931 deletions
+68 -8
View File
@@ -5,28 +5,43 @@
# bot-bottle
[![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
[![pylint](https://img.shields.io/badge/pylint-9.94%2F10-brightgreen)](https://github.com/PyCQA/pylint)
[![pylint](https://img.shields.io/badge/pylint-9.93%2F10-brightgreen)](https://github.com/PyCQA/pylint)
[![pyright](https://img.shields.io/badge/pyright-0%20errors-brightgreen)](https://github.com/microsoft/pyright)
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
**Run any coding agent like it might be compromised — and lose nothing when it is.**
**Solution:** Ephemeral, per agent "bottles" the agent cannot modify that scan all traffic for data exfiltration and limit capabilities and egress to only what the agent needs.
bot-bottle is a provider-neutral, security-first substrate for autonomous agents. Bring Claude Code, Codex, or your own harness; each one runs in an ephemeral, per-agent "bottle" it cannot modify, where every byte of egress is scanned for exfiltration and capabilities are narrowed to exactly what the task declares.
## Features
**Problem:** You want to let a coding agent run unsupervised, but a prompt-injected or misbehaving agent — or a poisoned repo, MCP server, or skill — can wreck your environment or exfiltrate your secrets. Locking yourself to one vendor's cloud doesn't fix that; it just moves the blast radius.
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist and request-body DLP scanner; DoH and arbitrary hosts blocked by default.
**Solution:** A neutral control plane that runs *whatever agent you choose* inside an isolation boundary the agent can't touch: TLS-bumped egress allowlisting, outbound/inbound DLP, gitleaks-gated pushes, and host secrets the agent never sees. Swap the agent; keep the guarantees.
## Why bot-bottle
### A neutral substrate — bring your own agent
- **Provider-agnostic by design** — Claude and Codex ship built in; any other agent (Gemini, Aider, a local-model wrapper) is a drop-in plugin at `~/.bot-bottle/contrib/<name>/` — no fork, no PR against this repo. The manifest accepts any provider template, and the isolation, egress, and git guarantees are identical across all of them.
- **One control plane, every harness** — the same bottle, egress policy, and supervise flow wrap whichever agent you run, so switching or mixing providers doesn't change your security posture.
- **Composable bottles (`extends:`)** — keep provider/runtime policy in one base bottle (e.g. `claude.md`) and overlay task bottles on top.
### An isolation boundary the agent can't touch
- **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.
- **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only.
- **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential.
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
- **Trust boundary at `$HOME`** — bottles (credentials, egress, remotes) live only under `~/.bot-bottle/bottles/`. Repos may ship agents but not bottles, so a cloned repo can't redirect an env var to an attacker host.
- **Composable bottles (`extends:`)** — keep provider/runtime policy in one base bottle (e.g. `claude.md`) and overlay task bottles on top.
### Isolation that matches your host
- **Parallel, isolated bottles** — each bottle runs in its own backend-owned isolation boundary; bottles don't share state or talk to each other.
- **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding.
- **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required.
- **Apple Container backend (macOS default when available)** — runs the agent and sidecar bundle with Apple's `container` CLI, using a host-only agent network plus a separate sidecar egress network.
- **Smolmachines backend** — runs the agent in a libkrun micro-VM while the sidecar bundle stays in Docker. TSI and smolmachines DNS filtering close the raw DNS exfiltration gap that exists in the legacy Docker backend.
- **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`.
Per-provider auth (Claude long-lived OAuth token; Codex opt-in host device-auth forwarding) and per-provider images (`Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile) are configured on the bottle — see [Manifest](#manifest).
## Architecture
On the default macOS Apple Container backend, a bottle is an agent container on a host-only internal network plus a sidecar bundle attached to both that internal network and a NAT egress network. The agent gets HTTP(S)_PROXY and CA bundle env vars pointing at the sidecar's internal-network IP, so HTTP/HTTPS traffic flows through the sidecar instead of direct egress. `bottle.git` / git-gate is intentionally deferred on this backend until a safe Apple Container key-delivery path exists.
@@ -68,6 +83,27 @@ The Docker topology looks like this:
When the agent exits, `cli.py` tears down every sidecar and both networks; nothing about a bottle persists between runs.
## Install
Install the CLI with the bootstrap script:
```sh
curl -fsSL https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh | sh
```
The script checks Python 3.11+, checks Docker daemon reachability, creates the `~/.bot-bottle/` config directories, installs the Python package with `pipx` when available or `pip --user` otherwise, then runs:
```sh
bot-bottle doctor
```
Python-native installers can use the package metadata directly:
```sh
pipx install git+https://gitea.dideric.is/didericis/bot-bottle.git
uv tool install git+https://gitea.dideric.is/didericis/bot-bottle.git
```
## Quickstart
On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The smolmachines backend requires Docker on the host for the sidecar bundle plus smolvm. 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`.
@@ -106,8 +142,15 @@ egress:
routes:
- host: gitea.dideric.is
auth:
scheme: token
scheme: token # Bearer | token
token_ref: BOT_BOTTLE_GITEA_TOKEN
matches: # optional — restrict to specific paths/methods/headers
- paths:
- {type: prefix, value: /api/v1/}
methods: [GET, POST, PATCH, DELETE]
dlp: # optional — per-route detector overrides (default: all on)
outbound_detectors: [token_patterns, known_secrets]
inbound_detectors: false # disable response scanning for this host
---
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
@@ -126,6 +169,23 @@ skills:
You help maintain Gitea-hosted projects.
````
**Egress route fields:**
| Field | Required | Description |
|---|---|---|
| `host` | yes | Hostname to allowlist. One entry per host. |
| `role` | no | Reserved for future use. The key is recognised but any value is currently rejected at load. Provider auth routes (e.g. Claude's `api.anthropic.com`) are injected automatically from `agent_provider.auth_token`, not via `role`. |
| `auth.scheme` | when `auth` present | `Bearer` or `token`. Injected by the proxy; the agent never sees the value. |
| `auth.token_ref` | when `auth` present | Env-var name holding the secret on the host. |
| `matches` | no | Array of `{paths, methods, headers}` filters. A request must match at least one entry (if any are given) to be forwarded. |
| `matches[].paths` | no | Array of `{type, value}`. `type` is `prefix` (default), `exact`, or `regex`. |
| `matches[].methods` | no | Array of HTTP method strings, e.g. `[GET, POST]`. |
| `matches[].headers` | no | Array of `{name, value, type}`. `type` is `exact` (default) or `regex`. |
| `dlp` | no | Per-route DLP overrides. Omit to use defaults (all detectors on). |
| `dlp.outbound_detectors` | no | `false` disables outbound scanning; list restricts to named detectors (`token_patterns`, `known_secrets`). |
| `dlp.inbound_detectors` | no | `false` disables inbound scanning; list restricts to named detectors (`naive_injection_detection`). |
| `git.fetch` | no | `true` permits smart HTTP clone/fetch (`git-upload-pack`) for this host. Push (`git-receive-pack`) remains blocked. |
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
## Trademarks
+96
View File
@@ -0,0 +1,96 @@
# Per-bottle sidecar bundle image (PRD 0024).
#
# Collapses the prior per-sidecar images (egress, git-gate,
# supervise) into one. A small stdlib-Python init supervisor at
# /app/sidecar_init.py spawns all daemons, forwards SIGTERM, and
# propagates per-daemon stdout/stderr to the container log with a
# `[name]` prefix. See PRD 0024 for the rationale.
#
# Layout:
#
# /usr/bin/gitleaks gitleaks binary
# /app/egress_addon.py + siblings mitmproxy addon (egress)
# /app/egress-entrypoint.sh mitmdump launcher
# /app/supervise_server.py + .py supervise MCP server
# /app/sidecar_init.py PID 1 supervisor
# /etc/egress/routes.yaml bind-mounted at run time
# /etc/git-gate/pre-receive docker-cp'd at start time
# /git-gate-entrypoint.sh docker-cp'd at start time
# /git-gate/creds/* docker-cp'd at start time
# /git/* bare repos, populated at runtime
# /run/supervise/queue/ bind-mounted at run time
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
#
# Exposed ports inside the container:
# 9099 egress (mitmproxy, agent-facing HTTPS proxy)
# 9418 git-gate (git-daemon)
# 9420 git-gate smart HTTP (smolmachines agent-facing transport)
# 9100 supervise (MCP HTTP)
# Stage 1: gitleaks binary. The upstream gitleaks image is alpine
# with the binary at /usr/bin/gitleaks. Pinned by digest in lockstep
# with Dockerfile.git-gate's prior base (now deleted at chunk 3).
FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f AS gitleaks-src
# Stage 2: assembly. mitmproxy/mitmproxy is debian-slim-based with
# Python + mitmdump pre-installed — heavier than the others, so
# this stage starts there and pulls the standalone binaries in.
FROM mitmproxy/mitmproxy:11.1.3
# Run as root inside the bundle. The bundle is the isolation
# boundary; per-daemon user separation inside it is not load-bearing
# and complicates the supervisor's spawn path.
USER root
# Runtime system deps:
# git supplies the `git daemon` subcommand (no separate package)
# plus the core `git` binary the pre-receive hook invokes.
# openssh-client supplies the upstream SSH transport the
# pre-receive hook uses to forward accepted refs.
# ca-certificates is needed for mitmdump upstream TLS (the
# base image already has it; listed for explicitness).
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git openssh-client ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Pull the standalone binaries into the final image.
COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks
# Project Python: addon + server modules + the init supervisor.
# Kept flat under /app/ so mitmdump's loader resolves them as
# top-level siblings (absolute imports), matching the prior
# Dockerfile.egress / Dockerfile.supervise layout.
COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
COPY bot_bottle/egress_addon.py /app/egress_addon.py
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
COPY bot_bottle/supervise.py /app/supervise.py
COPY bot_bottle/supervise_server.py /app/supervise_server.py
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
COPY bot_bottle/git_http_backend.py /app/git_http_backend.py
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
RUN chmod +x /app/egress-entrypoint.sh
# Pre-create runtime directories the compose renderer + start
# step expect to exist. `docker cp` does not create intermediate
# dirs, and bind mounts won't either if the parent is missing.
RUN mkdir -p \
/etc/egress \
/etc/git-gate \
/git-gate/creds \
/git \
/run/supervise/queue \
/home/mitmproxy/.mitmproxy
# Documentation only — the compose renderer publishes whichever
# subset the bottle uses.
EXPOSE 8888 9099 9418 9420 9100
# WORKDIR matches Dockerfile.supervise's prior layout so the
# in-app same-dir import in supervise_server.py stays deterministic.
WORKDIR /app
# PID 1 is the supervisor. It owns signal handling and exit-code
# propagation; no `exec` chain in the entrypoint itself.
ENTRYPOINT ["python3", "/app/sidecar_init.py"]
+1 -1
View File
@@ -240,7 +240,7 @@ class AgentProvider(ABC):
BottleBackend.provision_workspace against the running bottle."""
from .log import info
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
manifest_bottle = plan.manifest.bottle
if manifest_bottle.git:
from .git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
gate_host = getattr(plan, "git_gate_insteadof_host", GIT_GATE_HOSTNAME)
+36 -36
View File
@@ -45,7 +45,7 @@ from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provi
from ..egress import EgressPlan
from ..git_gate import GitGatePlan
from ..log import die, info
from ..manifest import ManifestGitEntry, Manifest
from ..manifest import Manifest, ManifestIndex
from ..supervise import SupervisePlan
from ..util import expand_tilde
from ..env import resolve_env, ResolvedEnv
@@ -61,7 +61,7 @@ class BottleSpec:
Resolved values (image names, container name, scratch paths, runsc
availability) live on the plan, not the spec."""
manifest: Manifest
manifest: ManifestIndex
agent_name: str
copy_cwd: bool
user_cwd: str
@@ -80,6 +80,7 @@ class BottlePlan(ABC):
(e.g. DockerBottlePlan) add backend-specific resolved fields."""
spec: BottleSpec
manifest: Manifest
stage_dir: Path
git_gate_plan: GitGatePlan
@@ -112,9 +113,9 @@ class BottlePlan(ABC):
"""Render the y/N preflight summary to stderr."""
del remote_control
spec = self.spec
manifest = spec.manifest
agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
manifest = self.manifest
agent = manifest.agent
bottle = manifest.bottle
env_names = visible_agent_env_names(
sorted(
@@ -131,7 +132,7 @@ class BottlePlan(ABC):
print_multi("skills ", list(agent.skills))
info(f"bottle : {agent.bottle}")
identity = manifest.git_identity_summary(spec.agent_name)
identity = manifest.git_identity_summary()
if identity:
info(f" git identity : {identity}")
@@ -289,15 +290,14 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
write_launch_metadata,
)
self._validate(spec)
manifest = self._validate(spec)
self._preflight()
manifest = spec.manifest
manifest_bottle = manifest.bottle_for(spec.agent_name)
manifest_bottle = manifest.bottle
manifest_agent_provider = manifest_bottle.agent_provider
agent_provider = get_provider(manifest_agent_provider.template)
resolved_env = resolve_env(manifest, spec.agent_name)
resolved_env = resolve_env(manifest)
workspace = workspace_plan(spec, guest_home=agent_provider.guest_home)
slug = mint_slug(spec)
@@ -313,7 +313,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
else:
agent_dockerfile_path = str(agent_provider.dockerfile)
agent_dir, prompt_file = prepare_agent_state_dir(slug, spec)
agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest)
agent_provision_plan = build_agent_provision_plan(
template=manifest_agent_provider.template,
@@ -337,6 +337,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
return self._resolve_plan(
spec,
manifest=manifest,
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan,
@@ -355,18 +356,18 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""
pass
def _validate(self, spec: BottleSpec) -> None:
"""Cross-backend pre-launch checks. Confirms the agent exists,
the named skills are present on the host, and every git
IdentityFile resolves. Subclasses with additional preconditions
should override and call `super()._validate(spec)` first."""
manifest = spec.manifest
manifest.require_agent(spec.agent_name)
agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
self._validate_skills(agent.skills)
self._validate_git_entries(bottle.git)
self._validate_agent_provider_dockerfile(spec)
def _validate(self, spec: BottleSpec) -> Manifest:
"""Cross-backend pre-launch checks. Parses the selected agent and
its bottle (raising ManifestError on invalid content), confirms
skills are present on the host, and every git IdentityFile resolves.
Returns the loaded Manifest for the selected agent. Subclasses with
additional preconditions should override and call
`super()._validate(spec)` first."""
manifest = spec.manifest.load_for_agent(spec.agent_name)
self._validate_skills(manifest.agent.skills)
self._validate_agent_provider_dockerfile(spec, manifest)
return manifest
def _validate_skills(self, skills: Sequence[str]) -> None:
"""Each named skill must be a directory under the host's
@@ -380,18 +381,8 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
f"Create it under ~/.claude/skills/, then re-run."
)
def _validate_git_entries(self, entries: Sequence[ManifestGitEntry]) -> None:
"""Each entry's IdentityFile must exist on the host (after
expanding leading ~) — the git-gate copies it in at start time
to authenticate the upstream push (PRD 0008). Shape is already
enforced by Manifest validation; this only checks presence."""
for entry in entries:
key = expand_tilde(entry.IdentityFile)
if not os.path.isfile(key):
die(f"git upstream key file not found for '{entry.Name}': {key}")
def _validate_agent_provider_dockerfile(self, spec: BottleSpec) -> None:
bottle = spec.manifest.bottle_for(spec.agent_name)
def _validate_agent_provider_dockerfile(self, spec: BottleSpec, manifest: Manifest) -> None:
bottle = manifest.bottle
dockerfile = bottle.agent_provider.dockerfile
if not dockerfile:
return
@@ -401,13 +392,14 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
if not path.is_file():
die(
f"agent_provider.dockerfile for bottle "
f"'{spec.manifest.agents[spec.agent_name].bottle}' not found: {path}"
f"'{manifest.agent.bottle}' not found: {path}"
)
@abstractmethod
def _resolve_plan(self,
spec: BottleSpec,
*,
manifest: Manifest,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
@@ -534,6 +526,11 @@ from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-i
from .macos_container import MacosContainerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
# Freezer is imported after the backend classes for the same reason:
# Freezer.commit_slug constructs ActiveAgent, which must be fully
# defined first.
from .freeze import CommitCancelled, Freezer, get_freezer # noqa: E402 # pylint: disable=wrong-import-position
# The dict is heterogeneous: each value is a BottleBackend specialized
# over its own plan type. Concrete plan types are erased here because
@@ -621,9 +618,12 @@ __all__ = [
"BottleCleanupPlan",
"BottlePlan",
"BottleSpec",
"CommitCancelled",
"ExecResult",
"Freezer",
"enumerate_active_agents",
"get_bottle_backend",
"get_freezer",
"has_backend",
"known_backend_names",
]
+3
View File
@@ -30,6 +30,7 @@ from ...egress import EgressPlan
from ...env import ResolvedEnv
from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan
from ...manifest import Manifest
from .. import ActiveAgent, BottleBackend, BottleSpec
from . import cleanup as _cleanup
from . import enumerate as _enumerate
@@ -63,6 +64,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
self,
spec: BottleSpec,
*,
manifest: Manifest,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
@@ -73,6 +75,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
) -> DockerBottlePlan:
return _resolve_plan.resolve_plan(
spec,
manifest=manifest,
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan,
+10 -3
View File
@@ -58,10 +58,17 @@ from .sidecar_bundle import (
)
# Repo root, used as the build context for the bundle Dockerfile.
# Repo root or installed site-packages root, used as the build context for
# Dockerfiles that COPY bot_bottle source files.
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
def _sidecar_bundle_dockerfile() -> str:
if (Path(_REPO_DIR) / SIDECAR_BUNDLE_DOCKERFILE).is_file():
return SIDECAR_BUNDLE_DOCKERFILE
return f"bot_bottle/{SIDECAR_BUNDLE_DOCKERFILE}"
def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]:
"""Render a Compose v2 spec dict from a fully-resolved
DockerBottlePlan.
@@ -134,7 +141,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
ep = plan.egress_plan
volumes.append(_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER))
if ep.routes:
volumes.append(_bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER))
volumes.append(_bind(ep.routes_path.parent, str(Path(EGRESS_ROUTES_IN_CONTAINER).parent)))
for token_env in sorted(ep.token_env_map.keys()):
env.append(token_env)
@@ -183,7 +190,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
"image": SIDECAR_BUNDLE_IMAGE,
"build": {
"context": _REPO_DIR,
"dockerfile": SIDECAR_BUNDLE_DOCKERFILE,
"dockerfile": _sidecar_bundle_dockerfile(),
},
"container_name": sidecar_bundle_container_name(plan.slug),
"networks": {
+29 -18
View File
@@ -1,24 +1,21 @@
"""Host-side helper for egress sidecar inspection (issue #198).
"""Host-side helper for egress sidecar inspection and live updates.
`_merge_single_route`, `add_route`, and `apply_routes_change` were
removed when the egress-block MCP tool was dropped. The remaining
helpers support runtime inspection and validation of the routes file
without modifying it at runtime.
The approve path uses this module to validate a proposed routes file,
write it to the bottle's live egress state dir, and signal the sidecar
bundle so the mitmproxy addon reloads it.
"""
from __future__ import annotations
import os
import subprocess
from ...egress import EGRESS_ROUTES_IN_CONTAINER
from ...egress_addon_core import load_routes
from ...log import warn
from ..egress_apply import EgressApplicator, EgressApplyError
from .sidecar_bundle import sidecar_bundle_container_name
class EgressApplyError(RuntimeError):
pass
def fetch_current_routes(slug: str) -> str:
container = sidecar_bundle_container_name(slug)
r = subprocess.run(
@@ -33,17 +30,31 @@ def fetch_current_routes(slug: str) -> str:
return r.stdout
def validate_routes_content(content: str) -> None:
try:
load_routes(content)
except ValueError as e:
raise EgressApplyError(
f"proposed routes.yaml is not valid: {e}"
) from e
class DockerEgressApplicator(EgressApplicator):
def _signal_bundle_reload(self, slug: str) -> None:
container = sidecar_bundle_container_name(slug)
result = subprocess.run(
["docker", "kill", "--signal", "HUP", container],
capture_output=True, text=True, check=False, env=os.environ,
)
if result.returncode != 0:
last_error = (result.stderr or "").strip() or (result.stdout or "").strip()
warn(
f"egress: routes updated on disk for {slug}, but bundle reload failed: "
f"{last_error or 'docker kill failed'}"
)
raise EgressApplyError(
f"could not reload egress bundle {container}: "
f"{last_error or 'docker kill failed'}"
)
applicator = DockerEgressApplicator()
__all__ = [
"DockerEgressApplicator",
"EgressApplyError",
"applicator",
"fetch_current_routes",
"validate_routes_content",
]
+23
View File
@@ -0,0 +1,23 @@
"""DockerFreezer — snapshot a Docker bottle via `docker commit`."""
from __future__ import annotations
from .. import ActiveAgent
from ..freeze import Freezer
from .util import commit_container
from ...log import info
class DockerFreezer(Freezer):
"""Freezes a Docker bottle by running `docker commit`."""
backend_name = "docker"
def _freeze(self, agent: ActiveAgent) -> str:
container = f"bot-bottle-{agent.slug}"
image_tag = f"bot-bottle-committed-{agent.slug}:latest"
commit_container(container, image_tag)
return image_tag
def _export_hint(self, slug: str, image_ref: str) -> None:
info(f"to export for migration: docker save {image_ref} -o {slug}.tar")
+19 -8
View File
@@ -47,6 +47,7 @@ from ...bottle_state import (
bottle_state_dir,
egress_state_dir,
git_gate_state_dir,
read_committed_image,
)
from .compose import (
bottle_plan_to_compose,
@@ -75,7 +76,7 @@ def launch(
Teardown on exit."""
stack = ExitStack()
_bottle_for_revoke = plan.spec.manifest.bottle_for(plan.spec.agent_name)
_bottle_for_revoke = plan.manifest.bottle
_git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
def teardown() -> None:
@@ -91,12 +92,22 @@ def launch(
)
try:
# Step 1: agent image build. Sidecar images get built lazily by
# `docker compose up` via the renderer's `build:` directives.
docker_mod.build_image(
plan.image, _REPO_DIR,
dockerfile=plan.dockerfile_path,
)
# Step 1: agent image. Use a committed snapshot when one exists
# and is present in the local daemon; otherwise build from the
# Dockerfile. Sidecar images get built lazily by `docker compose
# up` via the renderer's `build:` directives.
committed = read_committed_image(plan.slug)
if committed and docker_mod.image_exists(committed):
info(f"using committed image {committed!r}")
plan = dataclasses.replace(
plan,
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
)
else:
docker_mod.build_image(
plan.image, _REPO_DIR,
dockerfile=plan.dockerfile_path,
)
internal_network = network_mod.network_name_for_slug(plan.slug)
egress_network = network_mod.network_egress_name_for_slug(plan.slug)
@@ -176,7 +187,7 @@ def launch(
agent_command=plan.agent_command,
agent_prompt_mode=plan.agent_prompt_mode,
agent_provider_template=plan.agent_provider_template,
terminal_title=plan.spec.label or plan.spec.agent_name,
terminal_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name,
terminal_color=plan.spec.color,
agent_workdir=plan.workspace_plan.workdir,
)
@@ -18,6 +18,7 @@ from .. import BottleSpec
from ...env import ResolvedEnv
from ...agent_provider import AgentProvisionPlan
from ...egress import EgressPlan
from ...manifest import Manifest
from ...supervise import SupervisePlan
from ...git_gate import GitGatePlan
@@ -31,6 +32,7 @@ def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
def resolve_plan(
spec: BottleSpec,
manifest: Manifest,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
@@ -48,6 +50,7 @@ def resolve_plan(
return DockerBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=stage_dir,
slug=slug,
forwarded_env=dict(resolved_env.forwarded),
+4 -3
View File
@@ -12,9 +12,10 @@ from __future__ import annotations
import os
# Bundle image. Defaults to a built-locally tag (built from the
# repo's Dockerfile.sidecars via compose `build:`). Operators
# pinning to a published digest can override via env.
# Bundle image. Defaults to a built-locally tag. Source checkouts
# build from the repo-root Dockerfile.sidecars; installed packages
# build from the packaged copy under bot_bottle/.
# Operators pinning to a published digest can override via env.
SIDECAR_BUNDLE_IMAGE = os.environ.get(
"BOT_BOTTLE_SIDECAR_IMAGE",
"bot-bottle-sidecars:latest",
+15
View File
@@ -152,6 +152,21 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
# )
def commit_container(container_name: str, image_tag: str) -> None:
"""Run `docker commit <container_name> <image_tag>` to snapshot the
running container's filesystem state as a local Docker image."""
result = subprocess.run(
["docker", "commit", container_name, image_tag],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
die(
f"docker commit {container_name!r}{image_tag!r} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
info(f"committed {container_name!r}{image_tag!r}")
def image_id(ref: str) -> str:
"""Return the content-addressed image ID (e.g.
`sha256:abcd...`) for `ref`. The smolmachines backend keys its
+50
View File
@@ -0,0 +1,50 @@
"""Shared base class for host-side egress apply across backends.
Each backend subclasses EgressApplicator and overrides _signal_bundle_reload
with the backend-specific kill command.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from pathlib import Path
from ..bottle_state import egress_state_dir
from ..egress import EGRESS_ROUTES_FILENAME
from ..egress_addon_core import load_routes
class EgressApplyError(RuntimeError):
pass
class EgressApplicator(ABC):
def apply_routes_change(self, slug: str, content: str) -> tuple[str, str]:
"""Persist `content` to the live routes file and reload egress."""
self.validate_routes_content(content)
routes_path = self._routes_path(slug)
routes_path.parent.mkdir(parents=True, exist_ok=True)
before = routes_path.read_text(encoding="utf-8") if routes_path.exists() else ""
routes_path.write_text(content, encoding="utf-8")
routes_path.chmod(0o600)
self._signal_bundle_reload(slug)
return before, content
@staticmethod
def validate_routes_content(content: str) -> None:
try:
load_routes(content)
except ValueError as e:
raise EgressApplyError(
f"proposed routes.yaml is not valid: {e}"
) from e
@staticmethod
def _routes_path(slug: str) -> Path:
return egress_state_dir(slug) / EGRESS_ROUTES_FILENAME
@abstractmethod
def _signal_bundle_reload(self, slug: str) -> None: ...
__all__ = ["EgressApplicator", "EgressApplyError"]
+100
View File
@@ -0,0 +1,100 @@
"""Freezer — snapshot a running bottle to a resumable artifact.
Follows the same pattern as BottleBackend: a shared base class with
common post-freeze steps (write committed-image path, mark preserved,
print resume hint) and backend-specific subclasses in their respective
backend directories.
Entry points:
Freezer.commit(agent) — freeze by ActiveAgent
Freezer.commit_slug(slug) — convenience wrapper for cmd_commit
get_freezer(backend_name) — factory
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from . import ActiveAgent
from ..bottle_state import mark_preserved, write_committed_image
from ..log import die, info
class CommitCancelled(Exception):
"""Raised by Freezer._freeze when the user declines a confirmation prompt."""
class Freezer(ABC):
"""Freezes a running bottle to a resumable artifact.
The base class owns the shared post-commit steps:
- write_committed_image — records the artifact path in per-bottle state
- mark_preserved — prevents teardown from removing the state dir
- resume hint — printed to stderr after the snapshot
Subclasses implement _freeze with the backend-specific snapshot
operation and optionally override _export_hint for migration hints.
"""
backend_name: str
def commit(self, agent: ActiveAgent) -> None:
"""Freeze the bottle for `agent` to a resumable artifact.
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.
Raises CommitCancelled if the user declines an interactive
confirmation prompt (e.g. the macos-container stop prompt).
"""
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}")
self._export_hint(agent.slug, image_ref)
@abstractmethod
def _freeze(self, agent: ActiveAgent) -> str:
"""Backend-specific snapshot. Returns the image tag or artifact path
stored by write_committed_image. Raises CommitCancelled if the user
declines a stop-confirmation prompt."""
def _export_hint(self, slug: str, image_ref: str) -> None:
"""Optionally print an export-for-migration hint after committing.
Overridden by backends that provide a meaningful export command."""
def commit_slug(self, slug: str) -> None:
"""Convenience entry for cmd_commit when only a slug is available."""
from ..bottle_state import read_metadata
metadata = read_metadata(slug)
agent = ActiveAgent(
backend_name=self.backend_name,
slug=slug,
agent_name=metadata.agent_name if metadata else "",
started_at=metadata.started_at if metadata else "",
services=(),
)
self.commit(agent)
def get_freezer(backend_name: str) -> Freezer:
"""Return the Freezer for the named backend.
backend_name "" is treated as "docker" for backward compatibility
with state dirs written before the backend field was added."""
resolved = backend_name or "docker"
if resolved == "docker":
from .docker.freezer import DockerFreezer
return DockerFreezer()
if resolved == "macos-container":
from .macos_container.freezer import MacosContainerFreezer
return MacosContainerFreezer()
if resolved == "smolmachines":
from .smolmachines.freezer import SmolmachinesFreezer
return SmolmachinesFreezer()
die(
f"commit is only supported for docker, macos-container, and "
f"smolmachines; backend {backend_name!r} has no freezer"
)
raise AssertionError("unreachable")
@@ -11,6 +11,7 @@ from ...egress import EgressPlan
from ...env import ResolvedEnv
from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan
from ...manifest import Manifest
from .. import ActiveAgent, BottleBackend, BottleSpec
from . import cleanup as _cleanup
from . import enumerate as _enumerate
@@ -45,6 +46,7 @@ class MacosContainerBottleBackend(
self,
spec: BottleSpec,
*,
manifest: Manifest,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
@@ -55,6 +57,7 @@ class MacosContainerBottleBackend(
) -> MacosContainerBottlePlan:
return _resolve_plan.resolve_plan(
spec,
manifest=manifest,
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan,
+45 -5
View File
@@ -2,12 +2,41 @@
from __future__ import annotations
import os
import subprocess
import sys
from typing import Callable, cast
from ...agent_provider import PromptMode, prompt_args
from .. import Bottle, ExecResult
from ..terminal import exec_shell_script
from . import pty_forward as _pty_forward
_PTY_FORWARD_SCRIPT = _pty_forward.__file__
_TERMINAL_ENV_NAMES = (
"TERM",
"COLORTERM",
"TERM_PROGRAM",
"TERM_PROGRAM_VERSION",
"KITTY_WINDOW_ID",
"KITTY_PID",
"WEZTERM_PANE",
"WEZTERM_UNIX_SOCKET",
"GHOSTTY_BIN_DIR",
"GHOSTTY_RESOURCES_DIR",
"ITERM_SESSION_ID",
"VTE_VERSION",
"KONSOLE_VERSION",
"ALACRITTY_WINDOW_ID",
)
def _terminal_env_names() -> tuple[str, ...]:
return tuple(
name for name in _TERMINAL_ENV_NAMES
if name == "TERM" or os.environ.get(name)
)
class MacosContainerBottle(Bottle):
@@ -44,13 +73,24 @@ class MacosContainerBottle(Bottle):
argv=full_argv,
)
)
cmd = ["container", "exec"]
container_exec = ["container", "exec"]
if tty:
cmd.extend(["--interactive", "--tty"])
container_exec.extend(["--interactive", "--tty"])
# Forward terminal capability hints so TUIs can enable modified-key
# protocols. Use bare env names: values stay in the child env, not
# on argv, and pty_forward supplies a TERM fallback when needed.
for name in _terminal_env_names():
container_exec.extend(["--env", name])
if self.agent_workdir and self.agent_workdir != "/home/node":
cmd.extend(["--workdir", self.agent_workdir])
cmd.extend([self.name, self.agent_command, *full_argv])
return cmd
container_exec.extend(["--workdir", self.agent_workdir])
container_exec.extend([self.name, self.agent_command, *full_argv])
if tty:
# Wrap with the raw-mode forwarder: container exec does not put
# the host terminal into raw mode itself, so the line discipline
# buffers modifier-key sequences until CR. The wrapper sets raw
# mode before exec and restores it on exit.
return [sys.executable, _PTY_FORWARD_SCRIPT, "--", *container_exec]
return container_exec
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
agent_argv = self.agent_argv(argv, tty=tty)
@@ -0,0 +1,39 @@
"""Host-side egress apply for the macos-container backend.
Uses `container kill --signal HUP` (Apple Container framework) instead
of `docker kill` to signal the sidecar bundle.
"""
from __future__ import annotations
import os
import subprocess
from ...log import warn
from ..egress_apply import EgressApplicator, EgressApplyError
from .launch import sidecar_container_name
class MacOSContainerEgressApplicator(EgressApplicator):
def _signal_bundle_reload(self, slug: str) -> None:
container = sidecar_container_name(slug)
result = subprocess.run(
["container", "kill", "--signal", "HUP", container],
capture_output=True, text=True, check=False, env=os.environ,
)
if result.returncode != 0:
last_error = (result.stderr or "").strip() or (result.stdout or "").strip()
warn(
f"egress: routes updated on disk for {slug}, but bundle reload failed: "
f"{last_error or 'container kill failed'}"
)
raise EgressApplyError(
f"could not reload egress bundle {container}: "
f"{last_error or 'container kill failed'}"
)
applicator = MacOSContainerEgressApplicator()
__all__ = ["MacOSContainerEgressApplicator", "EgressApplyError", "applicator"]
@@ -0,0 +1,31 @@
"""MacosContainerFreezer — snapshot a macOS container bottle.
Apple Container removes containers when they stop, making stop-then-export
impossible. Instead, commit_container execs into the running container and
streams the root filesystem via tar. The bottle continues running after commit.
"""
from __future__ import annotations
from .. import ActiveAgent
from ..freeze import Freezer
from .util import commit_container
from ...log import info
class MacosContainerFreezer(Freezer):
"""Freezes a macOS-container bottle via exec-tar + image rebuild."""
backend_name = "macos-container"
def _freeze(self, agent: ActiveAgent) -> str:
container = f"bot-bottle-{agent.slug}"
image_tag = f"bot-bottle-committed-{agent.slug}:latest"
commit_container(container, image_tag)
return image_tag
def _export_hint(self, slug: str, image_ref: str) -> None:
info(
f"to export for migration: "
f"container image save {image_ref} -o {slug}.tar"
)
+21 -19
View File
@@ -12,13 +12,16 @@ from __future__ import annotations
import dataclasses
import os
import shutil
import subprocess
from contextlib import ExitStack, contextmanager
from pathlib import Path
from typing import Callable, Generator
from ...bottle_state import egress_state_dir, git_gate_state_dir
from ...bottle_state import (
egress_state_dir,
git_gate_state_dir,
read_committed_image,
)
from ...egress import EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values
from ...git_gate import revoke_git_gate_provisioned_keys
from ...log import die, info, warn
@@ -68,7 +71,7 @@ def launch(
) -> Generator[MacosContainerBottle, None, None]:
"""Build, run, provision, and yield an Apple Container bottle."""
stack = ExitStack()
bottle_for_revoke = plan.spec.manifest.bottle_for(plan.spec.agent_name)
bottle_for_revoke = plan.manifest.bottle
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
def teardown() -> None:
@@ -84,7 +87,7 @@ def launch(
try:
plan = _mint_certs(plan)
_build_images(plan)
plan = _build_images(plan)
internal_network = internal_network_name(plan.slug)
egress_network = egress_network_name(plan.slug)
@@ -112,7 +115,7 @@ def launch(
agent_command=plan.agent_command,
agent_prompt_mode=plan.agent_prompt_mode,
agent_provider_template=plan.agent_provider_template,
terminal_title=plan.spec.label or plan.spec.agent_name,
terminal_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name,
terminal_color=plan.spec.color,
agent_workdir=plan.workspace_plan.workdir,
)
@@ -135,17 +138,28 @@ def _mint_certs(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
return dataclasses.replace(plan, egress_plan=egress_plan)
def _build_images(plan: MacosContainerBottlePlan) -> None:
def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
container_mod.build_image(
SIDECAR_BUNDLE_IMAGE,
_REPO_DIR,
dockerfile=SIDECAR_BUNDLE_DOCKERFILE,
)
committed = read_committed_image(plan.slug)
if committed and container_mod.image_exists(committed):
info(f"using committed image {committed!r}")
return dataclasses.replace(
plan,
agent_provision=dataclasses.replace(
plan.agent_provision,
image=committed,
),
)
container_mod.build_image(
plan.image,
_REPO_DIR,
dockerfile=plan.dockerfile_path,
)
return plan
def _create_networks(
@@ -314,7 +328,6 @@ def _agent_run_argv(
"container", "run",
"--name", plan.container_name,
"--detach",
"--rm",
"--network", internal_network,
]
for entry in _agent_env_entries(plan, sidecar_ip):
@@ -364,7 +377,7 @@ def _sidecar_mounts(
))
if ep.routes:
mounts.append((
str(_stage_routes_dir(plan)),
str(ep.routes_path.parent),
str(Path(EGRESS_ROUTES_IN_CONTAINER).parent),
True,
))
@@ -375,17 +388,6 @@ def _sidecar_mounts(
return tuple(mounts)
def _stage_routes_dir(plan: MacosContainerBottlePlan) -> Path:
routes_dir = plan.stage_dir / "macos-container-egress"
routes_dir.mkdir(parents=True, exist_ok=True)
shutil.copyfile(
plan.egress_plan.routes_path,
routes_dir / Path(EGRESS_ROUTES_IN_CONTAINER).name,
)
return routes_dir
def _mount_spec(host_path: str, container_path: str, read_only: bool) -> str:
spec = f"type=bind,source={host_path},target={container_path}"
if read_only:
@@ -0,0 +1,70 @@
"""Host-side raw-mode wrapper for `container exec --interactive --tty`.
Apple's `container exec --interactive --tty` does not set the host terminal to
raw mode before starting its I/O relay. Without raw mode the kernel line
discipline buffers modifier-key escape sequences (e.g. Shift+Enter in
modifyOtherKeys mode produces \\x1b[13;2~) until a carriage-return arrives, so
they never reach Claude Code inside the container.
This module sets the host terminal to raw mode, spawns the inner argv (the
container exec command), and restores the original terminal attributes on
exit. When stdin is not a TTY (piped invocations, CI) it falls through to a
bare subprocess.run so callers do not need to special-case non-interactive
contexts.
Usage (the `--` separator is the API contract — everything after it is the
inner command):
python pty_forward.py -- container exec --interactive --tty <name> <cmd>
"""
from __future__ import annotations
import os
import subprocess
import sys
import termios
import tty
def _inner_env() -> dict[str, str]:
env = dict(os.environ)
env.setdefault("TERM", "xterm-256color")
return env
def _run_inner(inner: list[str]) -> int:
return subprocess.run(inner, check=False, env=_inner_env()).returncode
def main(argv: list[str]) -> int:
"""Entry point. ``argv`` shape: ``-- <inner-argv...>``."""
if len(argv) < 2 or argv[0] != "--":
sys.stderr.write(
"usage: python pty_forward.py -- <container-exec-argv...>\n"
)
return 2
inner = argv[1:]
try:
fd = sys.stdin.fileno()
except OSError:
return _run_inner(inner)
if not os.isatty(fd):
return _run_inner(inner)
try:
old = termios.tcgetattr(fd)
except termios.error:
return _run_inner(inner)
try:
tty.setraw(fd)
return _run_inner(inner)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
@@ -9,6 +9,7 @@ from ...egress import EgressPlan
from ...env import ResolvedEnv
from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan
from ...manifest import Manifest
from .. import BottleSpec
from . import util as container_mod
from .bottle_plan import MacosContainerBottlePlan
@@ -24,6 +25,7 @@ def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
def resolve_plan(
spec: BottleSpec,
manifest: Manifest,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
@@ -34,6 +36,7 @@ def resolve_plan(
) -> MacosContainerBottlePlan:
return MacosContainerBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=stage_dir,
slug=slug,
forwarded_env=dict(resolved_env.forwarded),
@@ -8,6 +8,7 @@ import ipaddress
import platform
import shutil
import subprocess
import tempfile
import time
from typing import Iterable
@@ -35,6 +36,20 @@ def require_container() -> None:
info("Apple Container is required but was not found on PATH.")
info("Install: https://github.com/apple/container/releases")
die("container not found")
_require_container_service()
def _require_container_service() -> None:
result = subprocess.run(
[_CONTAINER, "system", "status"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
if result.returncode != 0:
info("Apple Container system service is not running.")
info("Start it with: container system start")
die("container system service not running")
def dns_server() -> str:
@@ -58,6 +73,53 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
subprocess.run(args, check=True)
def commit_container(container_name: str, image_tag: str) -> None:
"""Snapshot a running Apple Container as a local image.
`container export` requires a stopped container, but Apple Container
removes containers when they stop, making stop-then-export impossible.
Instead, exec into the running container as root and stream the root
filesystem out via tar, then build a new image from that archive.
The bottle continues running after commit.
"""
with tempfile.TemporaryDirectory(prefix="bot-bottle-container-commit.") as tmp:
rootfs_tar = os.path.join(tmp, "rootfs.tar")
dockerfile = os.path.join(tmp, "Dockerfile")
with open(rootfs_tar, "wb") as tar_out:
result = subprocess.run(
[
_CONTAINER, "exec",
"--user", "root",
container_name,
"tar", "--create",
"--exclude=./proc",
"--exclude=./sys",
"--exclude=./dev",
"--exclude=./run",
"--file=-",
"--directory=/",
".",
],
stdout=tar_out,
stderr=subprocess.PIPE,
check=False,
)
if result.returncode != 0:
die(
f"container exec tar {container_name!r} failed: "
f"{(result.stderr or b'').decode().strip() or '<no stderr>'}"
)
with open(dockerfile, "w", encoding="utf-8") as f:
f.write(
"FROM scratch\n"
"ADD rootfs.tar /\n"
"USER node\n"
"WORKDIR /home/node\n"
)
build_image(image_tag, tmp, dockerfile=dockerfile)
info(f"committed {container_name!r}{image_tag!r}")
def _ensure_builder_dns() -> None:
dns = dns_server()
status = _builder_status()
@@ -204,6 +266,36 @@ def container_exists(name: str) -> bool:
return name in {line.strip() for line in result.stdout.splitlines()}
def container_is_running(name: str) -> bool:
"""Return True if the named container is currently running.
`container list` without `--all` lists only running containers."""
result = subprocess.run(
[_CONTAINER, "list", "--quiet"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return False
return name in {line.strip() for line in result.stdout.splitlines()}
def stop_container(name: str) -> None:
"""Stop the named container without deleting it."""
result = subprocess.run(
[_CONTAINER, "stop", name],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
die(
f"container stop {name!r} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
def force_remove_container(name: str) -> None:
if container_exists(name):
subprocess.run(
+15 -6
View File
@@ -26,15 +26,25 @@ from ..bottle_state import (
)
from ..egress import Egress, EgressPlan
from ..git_gate import GitGate, GitGatePlan
from ..manifest import ManifestBottle
from ..manifest import Manifest, ManifestBottle
from ..supervise import Supervise, SupervisePlan
from . import BottleSpec
def mint_slug(spec: BottleSpec) -> str:
"""Return the bottle identity: the recorded identity for a resume,
or a freshly minted one for a new start."""
return spec.identity or bottle_identity(spec.agent_name)
or a freshly minted one for a new start.
When a label is provided it becomes the full slug (no random suffix),
so two launches with the same label collide by design. When no label
is given the identity is minted with a random suffix to avoid
collisions between anonymous launches of the same agent."""
if spec.identity:
return spec.identity
if spec.label:
from .docker import util as docker_mod
return docker_mod.slugify(spec.label)
return bottle_identity(spec.agent_name)
def write_launch_metadata(
@@ -56,11 +66,10 @@ def write_launch_metadata(
))
def prepare_agent_state_dir(slug: str, spec: BottleSpec) -> tuple[Path, Path]:
def prepare_agent_state_dir(slug: str, manifest: Manifest) -> tuple[Path, Path]:
"""Create the agent state subdir, write the prompt file.
Returns (agent_dir, prompt_file)."""
manifest = spec.manifest
agent = manifest.agents[spec.agent_name]
agent = manifest.agent
agent_dir = agent_state_dir(slug)
agent_dir.mkdir(parents=True, exist_ok=True)
prompt_file = agent_dir / "prompt.txt"
@@ -18,6 +18,7 @@ from ...egress import EgressPlan
from ...env import ResolvedEnv
from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan
from ...manifest import Manifest
from .. import ActiveAgent, BottleBackend, BottleSpec
from . import cleanup as _cleanup
from . import enumerate as _enumerate
@@ -55,6 +56,7 @@ class SmolmachinesBottleBackend(
self,
spec: BottleSpec,
*,
manifest: Manifest,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
@@ -65,6 +67,7 @@ class SmolmachinesBottleBackend(
) -> SmolmachinesBottlePlan:
return _resolve_plan.resolve_plan(
spec,
manifest=manifest,
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan,
+6 -1
View File
@@ -145,7 +145,12 @@ class SmolmachinesBottle(Bottle):
script = exec_shell_script(agent_argv, self.terminal_title, self.terminal_color) if tty else None
if script is None:
return subprocess.run(agent_argv, check=False).returncode
return subprocess.run(["sh", "-lc", script], check=False).returncode
# Use sh -c (not -lc) so the script inherits PATH from the calling
# process. sh -l sources login-shell init files (e.g. /etc/profile)
# which may NOT include smolvm's location when it was installed via
# homebrew. The calling process (./cli.py) already has smolvm on PATH
# (provision steps succeed), so -c is sufficient.
return subprocess.run(["sh", "-c", script], check=False).returncode
# smolvm/libkrun can SIGKILL an otherwise-normal exec during
# early-VM provisioning. Retry once after a short settle so
@@ -0,0 +1,21 @@
"""Egress apply for the smolmachines backend.
The smolmachines sidecar bundle runs as a host-side Docker container,
so egress signalling is identical to the docker backend.
"""
from __future__ import annotations
from ..docker.egress_apply import ( # noqa: F401
DockerEgressApplicator,
EgressApplyError,
applicator,
fetch_current_routes,
)
__all__ = [
"DockerEgressApplicator",
"EgressApplyError",
"applicator",
"fetch_current_routes",
]
+145
View File
@@ -0,0 +1,145 @@
"""SmolmachinesFreezer — snapshot a smolmachines bottle.
`smolvm pack create --from-vm` requires the VM to be stopped, and smolvm
removes VMs when stopped (same issue as Apple Container). Instead, exec
into the running VM as root to write a gzip-compressed tar of the root
filesystem to /var/tmp, then copy it to the host with `smolvm machine cp`,
build a Docker image from the archive, convert it to a smolmachine artifact
via the existing registry pipeline, and record the sidecar path. The VM
stays running throughout."""
from __future__ import annotations
import tempfile
from pathlib import Path
from .. import ActiveAgent
from ..freeze import Freezer
from ..docker import util as docker_mod
from .local_registry import crane_push_tarball, ephemeral_registry
from .smolvm import machine_cp, machine_exec, pack_create
from ...bottle_state import bottle_state_dir
from ...log import die, info
# Temp file written inside the VM during commit. Lives in /var/tmp
# (on-disk, unlike tmpfs /tmp) to survive for machine_cp.
_VM_COMMIT_TAR = "/var/tmp/.bot-bottle-commit.tar.gz"
class SmolmachinesFreezer(Freezer):
"""Freezes a smolmachines bottle via exec-tar + Docker image + smolmachine pack.
The VM is NOT stopped. We exec into the running VM to write a compressed
tar of the root filesystem to /var/tmp, copy it to the host with
machine_cp, build a Docker image (Docker's ADD decompresses .tar.gz
automatically), then run the same image→registry→pack_create pipeline
that _ensure_smolmachine uses for fresh builds."""
backend_name = "smolmachines"
def _freeze(self, agent: ActiveAgent) -> str:
machine = f"bot-bottle-{agent.slug}"
image_ref = f"bot-bottle-committed-{agent.slug}:latest"
output_dir = bottle_state_dir(agent.slug)
output_dir.mkdir(parents=True, exist_ok=True)
binary = output_dir / "committed-smolmachine"
sidecar = output_dir / "committed-smolmachine.smolmachine"
_snapshot_running_vm(machine, image_ref, binary)
return str(sidecar)
def _export_hint(self, slug: str, image_ref: str) -> None:
info(f"to export for migration: cp {image_ref} {slug}.smolmachine")
def _snapshot_running_vm(machine: str, image_ref: str, binary: Path) -> None:
"""Exec-tar the running VM, build a Docker image, and pack to a smolmachine.
binary: destination for the launcher (sibling .smolmachine is the artifact
that machine_create --from consumes, same convention as pack_create).
"""
with tempfile.TemporaryDirectory(prefix="bot-bottle-vm-commit.") as tmp:
tmp_path = Path(tmp)
# Use .tar.gz — Docker ADD decompresses automatically and the
# compressed archive fits in the VM's /var/tmp more easily.
rootfs_tar_gz = tmp_path / "rootfs.tar.gz"
dockerfile = tmp_path / "Dockerfile"
_exec_tar_to_file(machine, rootfs_tar_gz)
dockerfile.write_text(
"FROM scratch\n"
"ADD rootfs.tar.gz /\n"
"USER node\n"
"WORKDIR /home/node\n"
)
docker_mod.build_image(image_ref, str(tmp_path), dockerfile=str(dockerfile))
image_tarball = binary.parent / "committed.image.tar"
docker_mod.save(image_ref, str(image_tarball))
try:
with ephemeral_registry() as handle:
digest = docker_mod.image_id(image_ref).split(":", 1)[-1][:16]
push_ref = f"{handle.push_endpoint}/bot-bottle-committed:{digest}"
pack_ref = f"{handle.pull_endpoint}/bot-bottle-committed:{digest}"
crane_push_tarball(handle, str(image_tarball), push_ref)
pack_create(pack_ref, binary)
finally:
image_tarball.unlink(missing_ok=True)
def _exec_tar_to_file(machine: str, dest: Path) -> None:
"""Snapshot the running VM's root filesystem to dest (.tar.gz).
Writes a gzip-compressed tar to _VM_COMMIT_TAR inside the VM via
machine_exec (same mechanism as provisioning), then copies it to the
host with machine_cp. This avoids binary-stdout piping through the
smolvm exec channel, which does not reliably handle large binary output.
A connectivity probe (machine_exec true) runs first so a concurrent-exec
limitation (smolvm may reject a second exec while -i -t is active) is
reported clearly rather than as a silent failure."""
# Connectivity probe — if smolvm rejects concurrent exec while an
# interactive session is running, fail clearly here.
probe = machine_exec(machine, ["true"])
if probe.returncode != 0:
die(
f"smolvm exec is not available for {machine!r} "
f"(exit {probe.returncode}: {probe.stderr.strip() or probe.stdout.strip() or '<no output>'}). "
f"If an interactive session is active, smolvm may not support concurrent exec."
)
# Create the compressed tar inside the VM.
# tar exits 1 when files change during archiving (normal for a live
# filesystem); only treat exit > 1 as fatal.
tar_result = machine_exec(
machine,
[
"tar", "--create", "--gzip",
"--exclude=./proc",
"--exclude=./sys",
"--exclude=./dev",
"--exclude=./run",
# /tmp and /var/tmp are ephemeral. Their stale contents
# (e.g. /tmp/claude-<uid>) have uid remapped by smolvm's
# pack process, causing Claude Code to refuse to use them
# on resume. Exclude both; _init_vm recreates them with
# mkdir -p + correct ownership on every boot.
"--exclude=./tmp",
"--exclude=./var/tmp",
f"--file={_VM_COMMIT_TAR}",
"--directory=/",
".",
],
)
if tar_result.returncode > 1:
die(
f"smolvm exec tar {machine!r} failed (exit {tar_result.returncode}): "
f"{tar_result.stderr.strip() or tar_result.stdout.strip() or '<no output>'}"
)
# Copy from VM to host, then clean up.
try:
machine_cp(f"{machine}:{_VM_COMMIT_TAR}", str(dest))
finally:
machine_exec(machine, ["rm", "-f", _VM_COMMIT_TAR])
+45 -14
View File
@@ -40,8 +40,12 @@ from ..docker.git_gate import (
GIT_GATE_HOOK_IN_CONTAINER,
)
from ...git_gate import revoke_git_gate_provisioned_keys
from ...log import warn
from ...bottle_state import egress_state_dir, git_gate_state_dir
from ...log import info, warn
from ...bottle_state import (
egress_state_dir,
git_gate_state_dir,
read_committed_image,
)
from . import loopback_alias as _loopback
from . import sidecar_bundle as _bundle
from . import smolvm as _smolvm
@@ -85,14 +89,7 @@ def launch(
plan = _start_bundle(plan, network, loopback_ip, stack)
plan = _discover_urls(plan, loopback_ip)
# Build the agent image and pack it into a `.smolmachine`
# artifact (or hit the per-Dockerfile-digest cache). Runs
# here, not in prepare, so the docker-build output doesn't
# garble the dashboard's preflight modal.
agent_from_path = _ensure_smolmachine(
plan.agent_image,
dockerfile=plan.agent_dockerfile_path,
)
agent_from_path = _agent_from_path(plan)
_launch_vm(plan, agent_from_path, loopback_ip, stack)
_init_vm(plan)
@@ -104,7 +101,7 @@ def launch(
agent_command=plan.agent_command,
agent_prompt_mode=plan.agent_prompt_mode,
agent_provider_template=plan.agent_provider_template,
terminal_title=plan.spec.label or plan.spec.agent_name,
terminal_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name,
terminal_color=plan.spec.color,
agent_workdir=plan.workspace_plan.workdir,
)
@@ -130,7 +127,7 @@ def _teardown_smolmachines(
except BaseException as exc: # noqa: W0718 — teardown must not fail
teardown_exc = exc
warn(f"smolmachines teardown failed: {exc!r}")
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
bottle = plan.manifest.bottle
revoke_git_gate_provisioned_keys(bottle, git_gate_state_dir(plan.slug))
if teardown_exc is not None:
raise teardown_exc
@@ -217,11 +214,15 @@ def _discover_urls(
agent_supervise_url = f"http://{loopback_ip}:{supervise_host_port}/"
existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1")
no_proxy = f"{existing_no_proxy},{loopback_ip}"
guest_env = {
**plan.guest_env,
"HTTPS_PROXY": agent_proxy_url,
"HTTP_PROXY": agent_proxy_url,
"NO_PROXY": f"{existing_no_proxy},{loopback_ip}",
"https_proxy": agent_proxy_url,
"http_proxy": agent_proxy_url,
"NO_PROXY": no_proxy,
"no_proxy": no_proxy,
}
if agent_git_gate_host:
guest_env["GIT_GATE_URL"] = f"http://{agent_git_gate_host}"
@@ -275,10 +276,16 @@ def _init_vm(plan: SmolmachinesBottlePlan) -> None:
All folded into one sh -c to avoid back-to-back exec calls
immediately after machine_start (libkrun exec-channel race).
mkdir -p guards: when booting from a committed snapshot, /tmp and
/var/tmp are excluded from the archive (they're ephemeral and their
stale contents would have wrong uid after smolvm's uid remap). The
directories must be created before chown/chmod can set permissions.
wait_exec_ready polls until the exec channel is ready for the
subsequent provision calls, replacing the empirical sleep."""
_smolvm.machine_exec(plan.machine_name, [
"sh", "-c",
"mkdir -p /tmp /var/tmp && "
"chown -R node:node /home/node && "
"chown root:root /tmp /var/tmp && "
"chmod 1777 /tmp /var/tmp",
@@ -308,7 +315,7 @@ def _bundle_launch_spec(
ep = plan.egress_plan
volumes.append((str(ep.mitmproxy_ca_host_path), EGRESS_CA_IN_CONTAINER, True))
if ep.routes:
volumes.append((str(ep.routes_path), EGRESS_ROUTES_IN_CONTAINER, True))
volumes.append((str(ep.routes_path.parent), str(Path(EGRESS_ROUTES_IN_CONTAINER).parent), True))
# Bare-name entries for upstream-token slots. Their values
# come from the docker-run subprocess env (inherited from
# the operator's shell), never landing on argv.
@@ -382,6 +389,30 @@ def _resolve_token_env(
return egress_resolve_token_values(plan.egress_plan.token_env_map, effective_env)
def _agent_from_path(plan: SmolmachinesBottlePlan) -> Path:
"""Return the `.smolmachine` artifact used for `machine create --from`.
Prefer a committed VM artifact when one is recorded and still
present. If the file was removed, fall back to the normal image
build + pack cache path.
"""
committed = read_committed_image(plan.slug)
if committed:
committed_path = Path(committed)
if committed_path.is_file():
info(f"using committed smolmachine {str(committed_path)!r}")
return committed_path
# Build the agent image and pack it into a `.smolmachine`
# artifact (or hit the per-Dockerfile-digest cache). Runs here,
# not in prepare, so the docker-build output doesn't garble the
# dashboard's preflight modal.
return _ensure_smolmachine(
plan.agent_image,
dockerfile=plan.agent_dockerfile_path,
)
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
"""Build the agent docker image and convert it into a
`.smolmachine` artifact, caching the result under
@@ -13,6 +13,7 @@ from __future__ import annotations
from pathlib import Path
from .. import BottleSpec
from ...manifest import Manifest
from ...env import ResolvedEnv
from ...agent_provider import AgentProvisionPlan
from ...egress import EgressPlan
@@ -46,6 +47,7 @@ def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
def resolve_plan(
spec: BottleSpec,
manifest: Manifest,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
@@ -67,6 +69,7 @@ def resolve_plan(
return SmolmachinesBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=stage_dir,
slug=slug,
bundle_subnet=subnet,
+26
View File
@@ -25,6 +25,7 @@ smolvm binary."""
from __future__ import annotations
import json
import shutil
import subprocess
import time
@@ -94,6 +95,16 @@ def pack_create(image: str, output: Path) -> None:
_smolvm("pack", "create", "--image", image, "-o", str(output))
def pack_create_from_vm(name: str, output: Path) -> None:
"""`smolvm pack create --from-vm <name> -o <output>`.
Snapshots an existing persistent VM into a pack artifact. As
with `pack_create`, smolvm writes a launcher at `output` and the
bootable sidecar at `output.smolmachine`.
"""
_smolvm("pack", "create", "--from-vm", name, "-o", str(output))
# --- Machine lifecycle ---------------------------------------------------
@@ -143,6 +154,21 @@ def machine_create(
_smolvm(*args)
def machine_is_running(name: str) -> bool:
"""Return True if the named VM is in the 'running' state."""
result = _smolvm("machine", "ls", "--json", check=False)
if result.returncode != 0:
return False
try:
machines = json.loads(result.stdout or "[]")
except ValueError:
return False
return any(
isinstance(m, dict) and m.get("name") == name and m.get("state") == "running"
for m in machines
)
def machine_start(name: str) -> None:
"""`smolvm machine start --name NAME`."""
_smolvm("machine", "start", "--name", name)
+5 -16
View File
@@ -12,22 +12,11 @@ import shlex
# uses true/24-bit colors for its own chrome, which would otherwise bypass
# the palette entirely.
_COLORS: dict[str, tuple[int, str, int, str, str]] = {
"black": (0, "#2d2d2d", 8, "#5c5c5c", "#0a0a0a"),
"red": (1, "#c0392b", 9, "#e74c3c", "#1a0707"),
"green": (2, "#27ae60", 10, "#2ecc71", "#071a09"),
"yellow": (3, "#d4ac0d", 11, "#f1c40f", "#1a1507"),
"blue": (4, "#2471a3", 12, "#3498db", "#07071a"),
"magenta": (5, "#7d3c98", 13, "#9b59b6", "#12071a"),
"cyan": (6, "#148f77", 14, "#1abc9c", "#071a1a"),
"white": (7, "#bdc3c7", 15, "#ecf0f1", "#111111"),
"bright-black": (8, "#5c5c5c", 0, "#2d2d2d", "#111111"),
"bright-red": (9, "#e74c3c", 1, "#c0392b", "#200808"),
"bright-green": (10, "#2ecc71", 2, "#27ae60", "#082008"),
"bright-yellow": (11, "#f1c40f", 3, "#d4ac0d", "#201808"),
"bright-blue": (12, "#3498db", 4, "#2471a3", "#080820"),
"bright-magenta": (13, "#9b59b6", 5, "#7d3c98", "#160820"),
"bright-cyan": (14, "#1abc9c", 6, "#148f77", "#082020"),
"bright-white": (15, "#ecf0f1", 7, "#bdc3c7", "#151515"),
"red": (9, "#e74c3c", 1, "#c0392b", "#200808"),
"green": (10, "#2ecc71", 2, "#27ae60", "#082008"),
"yellow": (11, "#f1c40f", 3, "#d4ac0d", "#201808"),
"blue": (12, "#3498db", 4, "#2471a3", "#080820"),
"magenta": (13, "#9b59b6", 5, "#7d3c98", "#160820"),
}
# OSC 104 resets all indexed palette entries; OSC 111 resets default background.
+30
View File
@@ -43,6 +43,7 @@ from . import supervise as _supervise
# Directory layout: ~/.bot-bottle/state/<identity>/...
_STATE_SUBDIR = "state"
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
_COMMITTED_IMAGE_NAME = "committed-image"
_TRANSCRIPT_SUBDIR = "transcript"
# Per-sidecar scratch subdirs. PRD 0018 chunk 2: bind-mount sources
# live here so chunk 3's `docker compose up` can find them at stable
@@ -179,6 +180,32 @@ def write_per_bottle_dockerfile(identity: str, content: str) -> Path:
return p
def committed_image_path(identity: str) -> Path:
return bottle_state_dir(identity) / _COMMITTED_IMAGE_NAME
def write_committed_image(identity: str, image_tag: str) -> Path:
"""Persist the committed image tag for `identity`. The next
`cli.py resume <identity>` will boot from this image instead of
rebuilding from the Dockerfile."""
path = committed_image_path(identity)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(image_tag.strip() + "\n")
path.chmod(0o644)
return path
def read_committed_image(identity: str) -> str | None:
"""Return the committed image tag for `identity`, or None if no
commit has been recorded. Used by the Docker launch step to skip
the Dockerfile build when a committed snapshot exists."""
path = committed_image_path(identity)
if not path.is_file():
return None
tag = path.read_text().strip()
return tag or None
def per_bottle_image_tag(identity: str) -> str:
"""Image tag for a rebuilt bottle. Distinct from the base
bot-bottle-claude:latest so per-bottle rebuilds don't collide in
@@ -314,6 +341,7 @@ __all__ = [
"bottle_state_dir",
"cleanup_state",
"clear_preserve_marker",
"committed_image_path",
"egress_state_dir",
"git_gate_state_dir",
"is_preserved",
@@ -323,9 +351,11 @@ __all__ = [
"per_bottle_dockerfile_path",
"per_bottle_image_tag",
"preserve_marker_path",
"read_committed_image",
"read_metadata",
"supervise_state_dir",
"transcript_snapshot_dir",
"write_committed_image",
"write_metadata",
"write_per_bottle_dockerfile",
]
+7 -1
View File
@@ -1,6 +1,6 @@
"""Main CLI dispatcher.
Commands: cleanup, edit, info, init, list, resume, start, supervise
Commands: cleanup, commit, doctor, edit, info, init, list, resume, start, supervise
"""
from __future__ import annotations
@@ -12,6 +12,8 @@ from ..manifest import ManifestError
from ._common import PROG
from . import list as _list_mod
from .cleanup import cmd_cleanup
from .commit import cmd_commit
from .doctor import cmd_doctor
from .edit import cmd_edit
from .info import cmd_info
from .init import cmd_init
@@ -23,6 +25,8 @@ cmd_list = _list_mod.cmd_list
COMMANDS = {
"cleanup": cmd_cleanup,
"commit": cmd_commit,
"doctor": cmd_doctor,
"edit": cmd_edit,
"info": cmd_info,
"init": cmd_init,
@@ -37,6 +41,8 @@ def usage() -> None:
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
sys.stderr.write("Commands:\n")
sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n")
sys.stderr.write(" commit snapshot a running bottle's container state to a Docker image\n")
sys.stderr.write(" doctor check Python, Docker, and bot-bottle config prerequisites\n")
sys.stderr.write(" edit open an agent in vim for editing\n")
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
+1 -1
View File
@@ -6,7 +6,7 @@ import os
import sys
from pathlib import Path
PROG = "cli.py"
PROG = Path(sys.argv[0]).name or "bot-bottle"
USER_CWD = os.getcwd()
REPO_DIR = str(Path(__file__).resolve().parent.parent.parent)
+53
View File
@@ -0,0 +1,53 @@
"""commit: freeze a running bottle's state to a resumable artifact.
Docker bottles are committed to a local Docker image. Macos-container
bottles are exported and rebuilt as a local Apple Container image.
Smolmachines bottles are packed from the running VM into a
`.smolmachine` artifact. The resulting reference is stored in
per-bottle state so the next `./cli.py resume <slug>` boots from the
snapshot instead of rebuilding from the Dockerfile.
"""
from __future__ import annotations
import argparse
from ..backend import enumerate_active_agents
from ..backend.freeze import CommitCancelled, get_freezer
from ..bottle_state import read_metadata
from ..log import die
from ._common import PROG
from . import tui
def cmd_commit(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} commit", add_help=True)
parser.add_argument(
"slug",
nargs="?",
default=None,
help=(
"bottle slug from `cli.py list active` "
"(omit to pick interactively)"
),
)
args = parser.parse_args(argv)
slug = args.slug
if slug is None:
active = enumerate_active_agents()
if not active:
die("no active bottles; start one with `./cli.py start`")
choices = [a.slug for a in active]
slug = tui.filter_select(choices, title="Select bottle to commit")
if slug is None:
return 0
metadata = read_metadata(slug)
backend = metadata.backend if metadata else ""
try:
get_freezer(backend).commit_slug(slug)
except CommitCancelled:
return 0
return 0
+73
View File
@@ -0,0 +1,73 @@
"""doctor: validate host prerequisites for running bot-bottle."""
from __future__ import annotations
import argparse
import shutil
import subprocess
import sys
from pathlib import Path
from ._common import PROG
def _ok(label: str, detail: str) -> None:
print(f"ok: {label}: {detail}")
def _fail(label: str, detail: str) -> None:
print(f"fail: {label}: {detail}")
def _check_python() -> bool:
version = sys.version_info
detail = f"{version.major}.{version.minor}.{version.micro}"
if version >= (3, 11):
_ok("python", detail)
return True
_fail("python", f"{detail}; need 3.11 or newer")
return False
def _check_docker() -> bool:
docker = shutil.which("docker")
if not docker:
_fail("docker", "docker command not found")
return False
try:
result = subprocess.run(
[docker, "info"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
timeout=10,
)
except (OSError, subprocess.TimeoutExpired) as exc:
_fail("docker", f"daemon check failed: {exc}")
return False
if result.returncode == 0:
_ok("docker", "daemon reachable")
return True
_fail("docker", "daemon not reachable")
return False
def _check_config_dir() -> bool:
config = Path.home() / ".bot-bottle"
if config.is_dir():
_ok("config", str(config))
return True
_fail("config", f"{config} does not exist")
return False
def cmd_doctor(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} doctor", add_help=True)
parser.parse_args(argv)
checks = (
_check_python(),
_check_docker(),
_check_config_dir(),
)
return 0 if all(checks) else 1
+7 -6
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import argparse
from ..log import info
from ..manifest import Manifest
from ..manifest import ManifestIndex
from ._common import PROG, USER_CWD
@@ -14,11 +14,12 @@ def cmd_info(argv: list[str]) -> int:
parser.add_argument("name", help="agent name defined in bot-bottle.json")
args = parser.parse_args(argv)
manifest = Manifest.resolve(USER_CWD)
manifest.require_agent(args.name)
names = ManifestIndex.resolve(USER_CWD)
names.require_agent(args.name)
manifest = names.load_for_agent(args.name)
agent = manifest.agents[args.name]
bottle = manifest.bottle_for(args.name)
agent = manifest.agent
bottle = manifest.bottle
env_names = list(bottle.env.keys())
prompt_first_line = agent.prompt.splitlines()[0] if agent.prompt else ""
@@ -31,7 +32,7 @@ def cmd_info(argv: list[str]) -> int:
f"first line: {prompt_first_line or '(empty)'}"
)
info(f"bottle : {agent.bottle}")
identity = manifest.git_identity_summary(args.name)
identity = manifest.git_identity_summary()
if identity:
info(f" git identity : {identity}")
if bottle.git:
+9 -20
View File
@@ -7,26 +7,15 @@ import os
import sys
from ..backend import enumerate_active_agents
from ..manifest import Manifest
from ..manifest import ManifestIndex
from ._common import PROG, USER_CWD
_ANSI_COLOR_CODES: dict[str, str] = {
"black": "\033[30m",
"red": "\033[31m",
"green": "\033[32m",
"yellow": "\033[33m",
"blue": "\033[34m",
"magenta": "\033[35m",
"cyan": "\033[36m",
"white": "\033[37m",
"bright-black": "\033[90m",
"bright-red": "\033[91m",
"bright-green": "\033[92m",
"bright-yellow": "\033[93m",
"bright-blue": "\033[94m",
"bright-magenta": "\033[95m",
"bright-cyan": "\033[96m",
"bright-white": "\033[97m",
"red": "\033[91m",
"green": "\033[92m",
"yellow": "\033[93m",
"blue": "\033[94m",
"magenta": "\033[95m",
}
_ANSI_RESET = "\033[0m"
@@ -51,8 +40,8 @@ def cmd_list(argv: list[str]) -> int:
args = parser.parse_args(argv)
if args.scope == "available":
manifest = Manifest.resolve(USER_CWD)
for name in manifest.agents.keys():
manifest = ManifestIndex.resolve(USER_CWD)
for name in manifest.all_agent_names:
print(name)
return 0
@@ -66,7 +55,7 @@ def cmd_list(argv: list[str]) -> int:
# Tab-separated keeps the format stable for shell pipelines.
for b in active:
services = ",".join(b.services) if b.services else "-"
display_name = b.label if b.label else b.agent_name
display_name = f"{b.label} ({b.agent_name})" if b.label else b.agent_name
colored_name = _ansi_label(display_name, b.color)
print(f"{b.backend_name}\t{b.slug}\t{colored_name}\t{services}")
return 0
+2 -2
View File
@@ -20,7 +20,7 @@ import argparse
from ..backend import BottleSpec
from ..bottle_state import read_metadata
from ..log import die
from ..manifest import Manifest
from ..manifest import ManifestIndex
from ._common import PROG, USER_CWD
from .start import _launch_bottle
@@ -42,7 +42,7 @@ def cmd_resume(argv: list[str]) -> int:
f"check ~/.bot-bottle/state/ or run `cli.py start` to create a new bottle"
)
manifest = Manifest.resolve(USER_CWD)
manifest = ManifestIndex.resolve(USER_CWD)
manifest.require_agent(metadata.agent_name)
spec = BottleSpec(
+21 -3
View File
@@ -20,9 +20,11 @@ from ..agent_provider import runtime_for
from ..backend import (
Bottle,
BottleSpec,
enumerate_active_agents,
get_bottle_backend,
known_backend_names,
)
from ..backend.docker import util as docker_mod
from ..backend.docker.bottle_plan import DockerBottlePlan
from ..bottle_state import (
cleanup_state,
@@ -31,7 +33,7 @@ from ..bottle_state import (
)
# from ..backend.docker.capability_apply import snapshot_transcript
from ..log import info
from ..manifest import Manifest
from ..manifest import ManifestIndex
from ._common import PROG, USER_CWD, read_tty_line
from . import tui
@@ -60,12 +62,12 @@ def cmd_start(argv: list[str]) -> int:
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
manifest = Manifest.resolve(USER_CWD)
manifest = ManifestIndex.resolve(USER_CWD)
agent_name: str | None = args.name
if agent_name is None:
agent_name = tui.filter_select(
sorted(manifest.agents.keys()),
manifest.all_agent_names,
title="Select agent",
)
if agent_name is None:
@@ -74,6 +76,7 @@ def cmd_start(argv: list[str]) -> int:
backend_name: str | None = args.backend
label, color = tui.name_color_modal(default_label=agent_name)
label, color = _resolve_unique_label(label, color)
spec = BottleSpec(
manifest=manifest,
@@ -191,6 +194,21 @@ def _identity_from_plan(plan: object) -> str:
return getattr(plan, "slug", "")
def _resolve_unique_label(label: str, color: str) -> tuple[str, str]:
"""Re-prompt with a disclaimer until the label's slug is not already
in use among running bottles. Passes through unchanged when no
collision is found on the first check."""
while True:
slug_candidate = docker_mod.slugify(label)
active_slugs = {a.slug for a in enumerate_active_agents()}
if slug_candidate not in active_slugs:
return label, color
label, color = tui.name_color_modal(
default_label=label,
disclaimer=f'"{label}" is already in use',
)
def _text_prompt_yes() -> bool:
"""Default `prompt_yes` for CLI use: reads y/N from the
controlling tty via stderr prompt + tty-line read."""
+69 -9
View File
@@ -3,7 +3,8 @@ act on them (approve / modify / reject).
Curses-based TUI; modify-then-approve shells out to $EDITOR. The
approval handler wires to PRD 0016 (capability-block), which rebuilds
the bottle Dockerfile. The egress-block tool was removed in issue #198.
the bottle Dockerfile. Egress proposals are queued for operator review
as full routes.yaml updates.
"""
from __future__ import annotations
@@ -20,11 +21,21 @@ from datetime import datetime, timezone
from pathlib import Path
from .. import supervise as _supervise
# from ..bottle_state import read_metadata
from ..bottle_state import read_metadata
# from ..backend.docker.capability_apply import (
# CapabilityApplyError,
# apply_capability_change,
# )
from ..backend.docker.egress_apply import (
EgressApplyError,
applicator as _docker_applicator,
)
from ..backend.macos_container.egress_apply import (
applicator as _macos_applicator,
)
from ..backend.smolmachines.egress_apply import (
applicator as _smolmachines_applicator,
)
from ..log import Die, error, info
@@ -40,6 +51,9 @@ from ..supervise import (
STATUS_MODIFIED,
STATUS_REJECTED,
TOOL_CAPABILITY_BLOCK,
TOOL_ALLOW,
TOOL_EGRESS_BLOCK,
TOOL_GITLEAKS_ALLOW,
archive_proposal,
list_pending_proposals,
render_diff,
@@ -63,7 +77,17 @@ class QueuedProposal:
# Errors any remediation engine may raise. Caught by the TUI key
# handlers and surfaced in the status line so a failed apply keeps
# the proposal pending rather than crashing curses.
ApplyError = (CapabilityApplyError,)
ApplyError = (CapabilityApplyError, EgressApplyError)
def apply_routes_change(slug: str, content: str) -> tuple[str, str]:
meta = read_metadata(slug)
backend = meta.backend if meta is not None else ""
if backend == "macos-container":
return _macos_applicator.apply_routes_change(slug, content)
if backend == "smolmachines":
return _smolmachines_applicator.apply_routes_change(slug, content)
return _docker_applicator.apply_routes_change(slug, content)
def discover_pending() -> list[QueuedProposal]:
@@ -115,6 +139,10 @@ def _detail_lines(
def _suffix_for_tool(tool: str) -> str:
if tool == TOOL_CAPABILITY_BLOCK:
return ".dockerfile"
if tool in (TOOL_ALLOW, TOOL_EGRESS_BLOCK):
return ".yaml"
if tool == TOOL_GITLEAKS_ALLOW:
return ".txt"
return ".txt"
@@ -129,6 +157,7 @@ def approve(
) -> None:
"""Apply the proposal, write the waiting response, and audit it."""
status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED
file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file
diff_before, diff_after = "", ""
# if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
@@ -142,6 +171,11 @@ def approve(
# diff_before, diff_after = apply_capability_change(
# qp.proposal.bottle_slug, file_to_apply,
# )
if qp.proposal.tool in (TOOL_ALLOW, TOOL_EGRESS_BLOCK):
diff_before, diff_after = apply_routes_change(
qp.proposal.bottle_slug,
file_to_apply,
)
response = Response(
proposal_id=qp.proposal.id,
@@ -170,6 +204,23 @@ def reject(qp: QueuedProposal, *, reason: str) -> None:
_write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="")
def _approve_from_tui(
stdscr: "curses._CursesWindow", # type: ignore
qp: QueuedProposal,
*,
final_file: str | None = None,
notes: str = "",
) -> str:
"""Approve from curses, prompting for any tool-specific audit note."""
if qp.proposal.tool == TOOL_GITLEAKS_ALLOW and final_file is None:
notes = _prompt(stdscr, "allow reason (test fixture/false positive): ")
if not notes:
return "approve aborted (empty reason)"
approve(qp, final_file=final_file, notes=notes)
verb = "modified+approved" if final_file is not None else "approved"
return _approval_status(qp, verb)
def _write_audit(
qp: QueuedProposal,
*,
@@ -353,18 +404,22 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore
_detail_view(stdscr, qp, green_attr=green_attr)
elif key == ord("a"):
try:
approve(qp)
status_line = _approval_status(qp, "approved")
status_line = _approve_from_tui(stdscr, qp)
except ApplyError as e:
status_line = f"apply failed: {e}"
elif key == ord("m"):
if qp.proposal.tool == TOOL_GITLEAKS_ALLOW:
status_line = "modify unavailable for gitleaks-allow"
continue
edited = _modify(stdscr, qp)
if edited is None:
status_line = "modify aborted (no change)"
else:
try:
approve(qp, final_file=edited, notes="operator modified before approving")
status_line = _approval_status(qp, "modified+approved")
status_line = _approve_from_tui(
stdscr, qp, final_file=edited,
notes="operator modified before approving",
)
except ApplyError as e:
status_line = f"apply failed: {e}"
elif key == ord("r"):
@@ -462,15 +517,20 @@ def _detail_view(
offset = max(0, len(lines) - 1)
elif key == ord("a"):
try:
approve(qp)
_approve_from_tui(stdscr, qp)
except ApplyError:
pass
return
elif key == ord("m"):
if qp.proposal.tool == TOOL_GITLEAKS_ALLOW:
return
edited = _modify(stdscr, qp)
if edited is not None:
try:
approve(qp, final_file=edited, notes="operator modified before approving")
_approve_from_tui(
stdscr, qp, final_file=edited,
notes="operator modified before approving",
)
except ApplyError:
pass
return
+19 -19
View File
@@ -226,20 +226,15 @@ def _addstr_safe(screen: Any, row: int, col: int, text: str, attr: int = curses.
# ---------------------------------------------------------------------------
_ANSI_COLORS = [
"red", "green", "blue", "yellow", "magenta", "cyan", "white", "black",
"bright-red", "bright-green", "bright-blue", "bright-yellow",
"bright-magenta", "bright-cyan", "bright-white", "bright-black",
"red", "green", "yellow", "blue", "magenta",
]
_CURSES_COLOR_MAP: dict[str, int] = {
"black": curses.COLOR_BLACK,
"red": curses.COLOR_RED,
"green": curses.COLOR_GREEN,
"yellow": curses.COLOR_YELLOW,
"blue": curses.COLOR_BLUE,
"magenta": curses.COLOR_MAGENTA,
"cyan": curses.COLOR_CYAN,
"white": curses.COLOR_WHITE,
}
_COLOR_NONE = "(none)"
@@ -248,11 +243,15 @@ _COLOR_NONE = "(none)"
def name_color_modal(
default_label: str,
*,
disclaimer: str = "",
tty_path: str = "/dev/tty",
) -> tuple[str, str]:
"""Present a two-step curses modal: first edit the agent label,
then optionally pick a color.
``disclaimer`` is shown below the input field use it to surface
an error from a previous attempt (e.g. name already in use).
Returns ``(label, color)`` where ``color`` is one of the 16 ANSI
color name strings or ``""`` for no color. Falls back to
``(default_label, "")`` on any error (terminal too small, not a tty).
@@ -264,14 +263,14 @@ def name_color_modal(
try:
fd_dup = os.dup(tty_fd.fileno())
return _run_name_color(default_label, tty_fd=fd_dup)
return _run_name_color(default_label, tty_fd=fd_dup, disclaimer=disclaimer)
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
return default_label, ""
finally:
tty_fd.close()
def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]:
def _run_name_color(default_label: str, *, tty_fd: int, disclaimer: str = "") -> tuple[str, str]:
import io
orig_stdin = sys.__stdin__
orig_stdout = sys.__stdout__
@@ -286,7 +285,7 @@ def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]:
curses.cbreak()
screen.keypad(True)
try:
label = _label_step(screen, default_label)
label = _label_step(screen, default_label, disclaimer=disclaimer)
color = _color_step(screen, label)
finally:
screen.keypad(False)
@@ -299,14 +298,14 @@ def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]:
return label, color
def _label_step(screen: Any, default_label: str) -> str:
def _label_step(screen: Any, default_label: str, *, disclaimer: str = "") -> str:
"""Step 1: edit the label. First printable key replaces the
pre-fill; subsequent keys append. Enter confirms."""
text = default_label
replaced = False # True once the user has typed their first char
while True:
_render_label(screen, text)
_render_label(screen, text, disclaimer=disclaimer)
try:
key = screen.getch()
except KeyboardInterrupt:
@@ -330,7 +329,7 @@ def _label_step(screen: Any, default_label: str) -> str:
text += chr(key)
def _render_label(screen: Any, text: str) -> None:
def _render_label(screen: Any, text: str, *, disclaimer: str = "") -> None:
screen.erase()
rows, cols = screen.getmaxyx()
sep = "" * min(cols - 1, 40)
@@ -338,8 +337,12 @@ def _render_label(screen: Any, text: str) -> None:
_addstr_safe(screen, 1, 0, sep)
_addstr_safe(screen, 2, 0, text[:cols - 1], curses.A_REVERSE)
_addstr_safe(screen, 3, 0, sep)
if rows > 5:
_addstr_safe(screen, 5, 0, "[any key] edit [Enter] confirm", curses.A_DIM)
row = 4
if disclaimer and rows > row + 1:
_addstr_safe(screen, row, 0, disclaimer[:cols - 1], curses.A_BOLD)
row += 1
if rows > row + 1:
_addstr_safe(screen, row, 0, "[any key] edit [Enter] confirm", curses.A_DIM)
screen.refresh()
@@ -379,13 +382,10 @@ def _init_color_pairs() -> dict[str, int]:
curses.use_default_colors()
pair_idx = 2 # pair 1 reserved for other uses
for name in _ANSI_COLORS:
base = name.replace("bright-", "")
fg = _CURSES_COLOR_MAP.get(base, curses.COLOR_WHITE)
fg = _CURSES_COLOR_MAP.get(name, curses.COLOR_WHITE)
try:
curses.init_pair(pair_idx, fg, -1)
attr = curses.color_pair(pair_idx)
if name.startswith("bright-"):
attr |= curses.A_BOLD
attr = curses.color_pair(pair_idx) | curses.A_BOLD
attrs[name] = attr
pair_idx += 1
except curses.error:
+1 -1
View File
@@ -36,7 +36,7 @@ RUN apt-get update \
# build (`claude --version` returns 2.1.126). Bump deliberately when
# rolling forward; an unpinned install would mean rebuilds silently pick
# up new behavior.
RUN npm install -g --no-fund --no-audit @anthropic-ai/claude-code@2.1.126 \
RUN npm install -g --no-fund --no-audit @anthropic-ai/claude-code@2.1.172 \
&& npm cache clean --force
# Run as a non-root user. The node image already provides a `node` user
+12 -34
View File
@@ -42,41 +42,19 @@ def _prompt_path(guest_home: str) -> str:
_STATUS_LINE_COLORS = {
"black": "\033[30m",
"red": "\033[31m",
"green": "\033[32m",
"yellow": "\033[33m",
"blue": "\033[34m",
"magenta": "\033[35m",
"cyan": "\033[36m",
"white": "\033[37m",
"bright-black": "\033[90m",
"bright-red": "\033[91m",
"bright-green": "\033[92m",
"bright-yellow": "\033[93m",
"bright-blue": "\033[94m",
"bright-magenta": "\033[95m",
"bright-cyan": "\033[96m",
"bright-white": "\033[97m",
"red": "\033[91m",
"green": "\033[92m",
"yellow": "\033[93m",
"blue": "\033[94m",
"magenta": "\033[95m",
}
_CLAUDE_THEME_COLORS = {
"black": "black",
"red": "red",
"green": "green",
"yellow": "yellow",
"blue": "blue",
"magenta": "magenta",
"cyan": "cyan",
"white": "white",
"bright-black": "blackBright",
"bright-red": "redBright",
"bright-green": "greenBright",
"bright-yellow": "yellowBright",
"bright-blue": "blueBright",
"bright-magenta": "magentaBright",
"bright-cyan": "cyanBright",
"bright-white": "whiteBright",
"red": "redBright",
"green": "greenBright",
"yellow": "yellowBright",
"blue": "blueBright",
"magenta": "magentaBright",
}
@@ -233,7 +211,7 @@ class ClaudeAgentProvider(AgentProvider):
when the agent has no skills."""
from ...backend.util import host_skill_dir
agent = plan.spec.manifest.agents[plan.spec.agent_name]
agent = plan.manifest.agent
if not agent.skills:
return
skills_dir = _skills_dir(plan.guest_home)
@@ -262,7 +240,7 @@ class ClaudeAgentProvider(AgentProvider):
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
user="root",
)
agent = plan.spec.manifest.agents[plan.spec.agent_name]
agent = plan.manifest.agent
return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
+5 -5
View File
@@ -177,7 +177,7 @@ class CodexAgentProvider(AgentProvider):
skills."""
from ...backend.util import host_skill_dir
agent = plan.spec.manifest.agents[plan.spec.agent_name]
agent = plan.manifest.agent
if not agent.skills:
return
skills_dir = _skills_dir(plan.guest_home)
@@ -206,7 +206,7 @@ class CodexAgentProvider(AgentProvider):
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
user="root",
)
agent = plan.spec.manifest.agents[plan.spec.agent_name]
agent = plan.manifest.agent
return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
@@ -261,8 +261,8 @@ class CodexAgentProvider(AgentProvider):
return
info(f"registering supervise MCP server in agent codex config → {supervise_url}")
r = bottle.exec(
f"codex mcp add --transport http "
f"{_SUPERVISE_MCP_NAME} {supervise_url}",
f"codex mcp add {_SUPERVISE_MCP_NAME} --url "
f"{shlex.quote(supervise_url)}",
user="node",
)
if r.returncode != 0:
@@ -270,7 +270,7 @@ class CodexAgentProvider(AgentProvider):
f"`codex mcp add supervise` failed (exit {r.returncode}): "
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
f"register manually with: "
f"codex mcp add --transport http supervise {supervise_url}"
f"codex mcp add supervise --url {shlex.quote(supervise_url)}"
)
@@ -2,7 +2,13 @@
Generates ed25519 keypairs via `ssh-keygen` and registers / deletes
them using the Gitea deploy-key HTTP API. No new Python dependencies
only stdlib `urllib.request` and `subprocess`."""
only stdlib `urllib.request` and `subprocess`.
Required token permissions (Gitea "Applications" "Generate Token"):
- Repository: Read & Write
Grants POST /api/v1/repos/{owner}/{repo}/keys (create deploy key)
and DELETE /api/v1/repos/{owner}/{repo}/keys/{id} (revoke deploy key).
No other scopes are needed."""
from __future__ import annotations
+1 -1
View File
@@ -232,7 +232,7 @@ class PiAgentProvider(AgentProvider):
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
from ...backend.util import host_skill_dir
agent = plan.spec.manifest.agents[plan.spec.agent_name]
agent = plan.manifest.agent
if not agent.skills:
return
skills_dir = _skills_dir(plan.guest_home)
+3 -1
View File
@@ -31,6 +31,7 @@ CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
EGRESS_HOSTNAME = "egress"
EGRESS_ROUTES_IN_CONTAINER = "/etc/egress/routes.yaml"
EGRESS_ROUTES_FILENAME = Path(EGRESS_ROUTES_IN_CONTAINER).name
@dataclass(frozen=True)
@@ -295,7 +296,7 @@ class Egress(ABC):
) -> EgressPlan:
routes = egress_routes_for_bottle(bottle, provider_routes)
log = bottle.egress.Log
routes_path = stage_dir / "egress_routes.yaml"
routes_path = stage_dir / EGRESS_ROUTES_FILENAME
routes_path.write_text(egress_render_routes(routes, log=log))
routes_path.chmod(0o600)
return EgressPlan(
@@ -309,6 +310,7 @@ class Egress(ABC):
__all__ = [
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
"EGRESS_HOSTNAME",
"EGRESS_ROUTES_FILENAME",
"EGRESS_ROUTES_IN_CONTAINER",
"Egress",
"EgressPlan",
+2 -2
View File
@@ -5,7 +5,6 @@ egress container."""
from __future__ import annotations
import dataclasses
import json
import os
import signal
@@ -27,6 +26,7 @@ from egress_addon_core import ( # type: ignore[import-not-found] # pylint: dis
load_config,
match_route,
outbound_scan_headers,
route_to_yaml_dict,
scan_inbound,
scan_outbound,
)
@@ -82,7 +82,7 @@ class EgressAddon:
def _serve_introspection(self, flow: http.HTTPFlow, path: str) -> None:
if path == "/allowlist":
payload = json.dumps(
{"routes": [dataclasses.asdict(r) for r in self.config.routes]},
{"routes": [route_to_yaml_dict(r) for r in self.config.routes]},
indent=2,
).encode("utf-8")
flow.response = http.Response.make(
+51
View File
@@ -359,6 +359,56 @@ def _parse_one(idx: int, raw: object) -> Route:
)
def _path_match_to_dict(pm: PathMatch) -> dict[str, object]:
d: dict[str, object] = {"value": pm.value}
if pm.type != "prefix":
d["type"] = pm.type
return d
def _header_match_to_dict(hm: HeaderMatch) -> dict[str, object]:
d: dict[str, object] = {"name": hm.name, "value": hm.value}
if hm.type != "exact":
d["type"] = hm.type
return d
def _match_entry_to_dict(me: MatchEntry) -> dict[str, object]:
d: dict[str, object] = {}
if me.paths:
d["paths"] = [_path_match_to_dict(p) for p in me.paths]
if me.methods:
d["methods"] = list(me.methods)
if me.headers:
d["headers"] = [_header_match_to_dict(h) for h in me.headers]
return d
def route_to_yaml_dict(r: Route) -> dict[str, object]:
"""Serialize a Route to YAML-schema-compatible dict.
Uses the same field names the YAML parser accepts, so the output
can be round-tripped directly into an `allow` or `egress-block`
proposal without translation. Fields that are empty/default are
omitted so the agent doesn't copy irrelevant keys."""
d: dict[str, object] = {"host": r.host}
if r.auth_scheme:
d["auth_scheme"] = r.auth_scheme
d["token_env"] = r.token_env
if r.matches:
d["matches"] = [_match_entry_to_dict(m) for m in r.matches]
if r.git_fetch:
d["git"] = {"fetch": True}
dlp: dict[str, object] = {}
if r.outbound_detectors is not None:
dlp["outbound_detectors"] = list(r.outbound_detectors)
if r.inbound_detectors is not None:
dlp["inbound_detectors"] = list(r.inbound_detectors)
if dlp:
d["dlp"] = dlp
return d
def load_routes(text: str) -> tuple[Route, ...]:
"""Parse YAML text → routes."""
try:
@@ -698,6 +748,7 @@ def scan_inbound(
__all__ = [
"LOG_BLOCKS",
"route_to_yaml_dict",
"LOG_FULL",
"LOG_OFF",
"Config",
+2 -2
View File
@@ -114,7 +114,7 @@ def _read_secret_silent(name: str, prompt_body: str) -> str:
return value
def resolve_env(manifest: Manifest, agent: str) -> ResolvedEnv:
def resolve_env(manifest: Manifest) -> ResolvedEnv:
"""Iterate the agent's env entries:
- secret: prompt at runtime; carry value in forwarded
- interpolated: read $HOST_VAR from os.environ; carry value in forwarded
@@ -124,7 +124,7 @@ def resolve_env(manifest: Manifest, agent: str) -> ResolvedEnv:
backend injects forwarded values via its launcher's env parameter."""
forwarded: dict[str, str] = {}
literals: dict[str, str] = {}
bottle = manifest.bottle_for(agent)
bottle = manifest.bottle
for name, raw in bottle.env.items():
if not name:
continue
+185 -16
View File
@@ -247,6 +247,164 @@ cat > "$refs_file"
zero=0000000000000000000000000000000000000000
supervise_gitleaks_allow() {
log_opts=$1
ref=$2
report_file=$(mktemp)
if ! gitleaks git \
--log-opts="$log_opts" \
--no-banner \
--redact \
--ignore-gitleaks-allow \
--report-format=json \
--report-path="$report_file" \
--exit-code 0 \
1>&2; then
rm -f "$report_file"
echo "git-gate: gitleaks inline-suppression scan failed for $ref" >&2
return 1
fi
proposal_id=$(
GITLEAKS_ALLOW_REF="$ref" python3 - "$report_file" <<'PY'
import datetime
import hashlib
import json
import os
import sys
import uuid
from pathlib import Path
report_path = Path(sys.argv[1])
queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "")
slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
if not queue_dir or not slug:
sys.exit(2)
try:
raw = json.loads(report_path.read_text() or "[]")
except json.JSONDecodeError:
sys.exit(3)
if not isinstance(raw, list):
sys.exit(3)
if not raw:
sys.exit(0)
ref = os.environ.get("GITLEAKS_ALLOW_REF", "")
lines = [
"gitleaks inline suppression requires supervisor approval",
f"ref: {ref}",
"",
]
for i, finding in enumerate(raw, 1):
if not isinstance(finding, dict):
continue
file_path = finding.get("File", "")
line_no = finding.get("StartLine", finding.get("Line", ""))
rule_id = finding.get("RuleID", "")
commit = finding.get("Commit", "")
line = finding.get("Line", "")
lines.extend([
f"finding {i}:",
f" file: {file_path}",
f" line: {line_no}",
f" rule: {rule_id}",
f" commit: {commit}",
f" code: {line}",
"",
])
payload = "\n".join(lines).rstrip() + "\n"
proposal_id = str(uuid.uuid4())
proposal = {
"id": proposal_id,
"bottle_slug": slug,
"tool": "gitleaks-allow",
"proposed_file": payload,
"justification": (
"git-gate found gitleaks findings hidden by # gitleaks:allow; "
"approve only for dummy test fixtures or confirmed false positives"
),
"arrival_timestamp": datetime.datetime.now(
datetime.timezone.utc
).isoformat(),
"current_file_hash": hashlib.sha256(payload.encode("utf-8")).hexdigest(),
}
queue = Path(queue_dir)
queue.mkdir(parents=True, exist_ok=True)
path = queue / f"{proposal_id}.proposal.json"
tmp = path.with_suffix(path.suffix + ".tmp")
with tmp.open("w", encoding="utf-8") as f:
json.dump(proposal, f, indent=2)
f.write("\n")
os.chmod(tmp, 0o600)
os.replace(tmp, path)
print(proposal_id)
PY
)
rc=$?
rm -f "$report_file"
if [ "$rc" -eq 0 ] && [ -z "$proposal_id" ]; then
return 0
fi
if [ "$rc" -ne 0 ]; then
echo "git-gate: cannot route # gitleaks:allow finding to supervisor; refusing push" >&2
return 1
fi
queue_dir=${SUPERVISE_QUEUE_DIR:-}
response_file="$queue_dir/${proposal_id}.response.json"
timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300}
case "$timeout" in
''|*[!0-9]*)
echo "git-gate: invalid SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS=$timeout" >&2
return 1
;;
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
waited=0
while [ "$waited" -lt "$timeout" ]; do
if [ -f "$response_file" ]; then
status=$(python3 - "$response_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as f:
raw = json.load(f)
except (OSError, json.JSONDecodeError):
sys.exit(1)
status = raw.get("status")
if not isinstance(status, str):
sys.exit(1)
print(status)
PY
) || status=""
case "$status" in
approved|modified)
mkdir -p "$queue_dir/processed"
mv -f "$queue_dir/${proposal_id}.proposal.json" "$queue_dir/processed/" 2>/dev/null || true
mv -f "$queue_dir/${proposal_id}.response.json" "$queue_dir/processed/" 2>/dev/null || true
echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2
return 0
;;
rejected)
echo "git-gate: supervisor rejected # gitleaks:allow for $ref" >&2
return 1
;;
*)
echo "git-gate: invalid supervisor response for # gitleaks:allow" >&2
return 1
;;
esac
fi
sleep 1
waited=$((waited + 1))
done
echo "git-gate: supervisor approval timed out for # gitleaks:allow; refusing push" >&2
return 1
}
# Phase 1: gitleaks scan each ref's incoming commits.
while IFS=' ' read -r old new ref; do
[ -z "$ref" ] && continue
@@ -268,6 +426,9 @@ while IFS=' ' read -r old new ref; do
echo "git-gate: gitleaks rejected push to $ref" >&2
exit 1
fi
if ! supervise_gitleaks_allow "$log_opts" "$ref"; then
exit 1
fi
done < "$refs_file"
# Phase 2: forward each ref to the upstream (`origin`, configured
@@ -300,6 +461,8 @@ while IFS=' ' read -r old new ref; do
[ -z "$ref" ] && continue
if [ "$new" = "$zero" ]; then
refspec=":$ref"
elif [ "$old" != "$zero" ] && ! git merge-base --is-ancestor "$old" "$new" 2>/dev/null; then
refspec="+$new:$ref"
else
refspec="$new:$ref"
fi
@@ -387,13 +550,12 @@ def _provision_dynamic_key(
Returns the host-side path to the private key file so the caller
can inject it into the GitGateUpstream as `identity_file`."""
from .deploy_key_provisioner import get_provisioner
pk = entry.ProvisionedKey
assert pk is not None
token = os.environ.get(pk.token_env)
pk = entry.Key
token = os.environ.get(pk.forge_token_env)
if token is None:
raise RuntimeError(
f"git-gate.repos[{entry.Name!r}] provisioned_key.token_env"
f" = {pk.token_env!r}: env var is not set"
f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
f" = {pk.forge_token_env!r}: env var is not set"
)
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
provisioner = get_provisioner(pk.provider, token, api_url)
@@ -426,18 +588,18 @@ def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) ->
address manually."""
from .deploy_key_provisioner import get_provisioner
for entry in bottle.git:
if entry.ProvisionedKey is None:
if entry.Key.provider != "gitea":
continue
pk = entry.ProvisionedKey
pk = entry.Key
id_file = stage_dir / f"{entry.Name}-deploy-key-id"
if not id_file.exists():
continue
key_id = id_file.read_text().strip()
token = os.environ.get(pk.token_env)
token = os.environ.get(pk.forge_token_env)
if token is None:
raise RuntimeError(
f"git-gate.repos[{entry.Name!r}] provisioned_key.token_env"
f" = {pk.token_env!r}: env var is not set;"
f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
f" = {pk.forge_token_env!r}: env var is not set;"
f" cannot revoke deploy key {key_id}"
)
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
@@ -450,6 +612,14 @@ def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) ->
info(f"revoked deploy key {key_id} for git-gate.repos[{entry.Name!r}]")
def _resolve_identity_file(entry: ManifestGitEntry, slug: str, stage_dir: Path) -> str:
"""Return the host-side SSH identity file path for this entry.
For gitea entries, provisions a fresh deploy key first."""
if entry.Key.provider == "gitea":
return _provision_dynamic_key(entry, slug, stage_dir)
return entry.IdentityFile
class GitGate(ABC):
"""The per-agent git-gate. Encapsulates the host-side prepare
(upstream lift + entrypoint/hook render); the sidecar's
@@ -461,7 +631,7 @@ class GitGate(ABC):
entrypoint, pre-receive hook, and access-hook scripts (mode
600) under `stage_dir`. Pure host-side, no docker subprocess.
For `provisioned_key` entries, also generates and registers
For `gitea` key entries, also generates and registers
a fresh deploy key via the forge API and writes the private key
+ key ID to `stage_dir`.
@@ -470,11 +640,10 @@ class GitGate(ABC):
before passing the plan to `.start`."""
upstreams_list = list(git_gate_upstreams_for_bottle(bottle))
for i, entry in enumerate(bottle.git):
if entry.ProvisionedKey is not None:
key_file = _provision_dynamic_key(entry, slug, stage_dir)
upstreams_list[i] = dataclasses.replace(
upstreams_list[i], identity_file=key_file
)
upstreams_list[i] = dataclasses.replace(
upstreams_list[i],
identity_file=_resolve_identity_file(entry, slug, stage_dir),
)
upstreams = tuple(upstreams_list)
entrypoint = stage_dir / "git_gate_entrypoint.sh"
entrypoint.write_text(git_gate_render_entrypoint(upstreams))
+203 -107
View File
@@ -19,7 +19,7 @@ Bottle schema (frontmatter):
repos: { <name>: <git-gate-entry>, ... } # optional
egress: { routes: [ <egress-route>, ... ] }
# route keys: host, matches, auth, role, dlp
supervise: <bool> # optional
supervise: <bool> # optional (default true)
Agent schema (frontmatter):
bottle: <bottle-name> # required
@@ -36,10 +36,23 @@ Bottles can ONLY live under $HOME. A bottles/ dir under $CWD is a
warn at load time and contributes nothing. The trust boundary is
expressed as filesystem layout rather than resolver logic.
Validation runs once at load. Manifest.from_json_obj is preserved
as a programmatic entry point (used by tests) that takes a dict
with the same field names useful for building manifests without
on-disk files.
Two types are exported:
ManifestIndex the multi-agent/bottle collection returned by
resolve() and from_json_obj(). Used for agent
selection (all_agent_names), validation
(require_agent), and lazy loading (load_for_agent).
This is the pre-preflight form.
Manifest a single-agent/bottle value type holding exactly
one agent: ManifestAgent and one bottle:
ManifestBottle (with the agent's git-gate.user
already overlaid). Returned by load_for_agent().
This is the post-preflight form passed to backends.
ManifestIndex.from_json_obj is preserved as a programmatic entry
point (used by tests) that takes a dict with the same field names
useful for building manifests without on-disk files.
"""
from __future__ import annotations
@@ -56,7 +69,7 @@ from .manifest_egress import (
ManifestEgressConfig,
ManifestEgressRoute,
)
from .manifest_git import ManifestGitEntry, ManifestGitUser, parse_git_gate_config
from .manifest_git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig, parse_git_gate_config
from .manifest_schema import BOTTLE_KEYS
# Re-export everything that callers currently import from this module.
@@ -64,12 +77,14 @@ __all__ = [
"ManifestError",
"ManifestGitEntry",
"ManifestGitUser",
"ManifestKeyConfig",
"ManifestAgentProvider",
"EGRESS_AUTH_SCHEMES",
"ManifestEgressRoute",
"ManifestEgressConfig",
"ManifestAgent",
"ManifestBottle",
"ManifestIndex",
"Manifest",
]
@@ -96,13 +111,13 @@ class ManifestBottle:
# identity without any git-gate.repos upstreams, and vice versa.
git_user: ManifestGitUser = field(default_factory=ManifestGitUser)
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
# Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true,
# the launch step brings up a supervise sidecar that exposes MCP
# tools to the agent (egress-block, capability-block) plus mounts
# the current-config dir read-only into the agent at
# /etc/bot-bottle/current-config. False (the default) skips the
# sidecar and mount.
supervise: bool = False
# Per-bottle stuck-recovery sidecar (PRD 0013). When true (the
# default, issue #249), the launch step brings up a supervise
# sidecar that exposes MCP tools to the agent (egress-block,
# capability-block) plus mounts the current-config dir read-only
# into the agent at /etc/bot-bottle/current-config. Set
# `supervise: false` to skip the sidecar and mount.
supervise: bool = True
@classmethod
def from_dict(cls, name: str, raw: object) -> "ManifestBottle":
@@ -175,7 +190,7 @@ class ManifestBottle:
else ManifestEgressConfig()
)
supervise_raw = d.get("supervise", False)
supervise_raw = d.get("supervise", True)
if not isinstance(supervise_raw, bool):
raise ManifestError(
f"bottle '{name}' supervise must be a boolean "
@@ -188,14 +203,64 @@ class ManifestBottle:
)
def _merge_git_user(
agent_user: ManifestGitUser, base_user: ManifestGitUser
) -> ManifestGitUser:
"""Merge the agent's git.user over the bottle's, agent-wins-on-non-empty."""
if agent_user.is_empty():
return base_user
return ManifestGitUser(
name=agent_user.name or base_user.name,
email=agent_user.email or base_user.email,
)
@dataclass(frozen=True)
class Manifest:
"""Single-agent/bottle value type. Returned by ManifestIndex.load_for_agent().
`bottle` is the effective bottle with the agent's git-gate.user already
overlaid per-field (agent wins on non-empty). Backends and provisioners
use this directly no agent_name lookup needed."""
agent: ManifestAgent
bottle: ManifestBottle
def git_identity_summary(self) -> str | None:
"""One-line effective git identity with per-field provenance, e.g.
`name=claude (agent), email=eric@dideric.is (bottle)`.
Returns None when neither agent nor bottle sets an identity."""
over = self.agent.git_user # agent's declared git_user (pre-merge)
merged = self.bottle.git_user # effective git_user (post-merge)
if merged.is_empty():
return None
parts: list[str] = []
if merged.name:
parts.append(f"name={merged.name} ({'agent' if over.name else 'bottle'})")
if merged.email:
parts.append(f"email={merged.email} ({'agent' if over.email else 'bottle'})")
return ", ".join(parts)
@dataclass(frozen=True)
class ManifestIndex:
"""Multi-agent/bottle collection. The pre-preflight form.
In lazy mode (from resolve()/from_md_dirs()) only filenames are scanned;
no file content is read. In eager mode (from from_json_obj()) all agents
and bottles are pre-parsed. Call load_for_agent() to get a single-value
Manifest ready for backend use."""
bottles: Mapping[str, ManifestBottle]
agents: Mapping[str, ManifestAgent]
# Set by from_md_dirs; None in from_json_obj (test/programmatic) mode.
# Stores the manifest root dirs so load_for_agent can locate files later.
home_md: Path | None = field(default=None)
cwd_md: Path | None = field(default=None)
@classmethod
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest":
"""Walk the per-file manifest tree and build a Manifest.
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "ManifestIndex":
"""Walk the per-file manifest tree and build a ManifestIndex.
Layout (PRD 0011):
$HOME/.bot-bottle/bottles/<name>.md bottles (home-only)
@@ -208,7 +273,7 @@ class Manifest:
boundary.
If `missing_ok` is true, a missing `$HOME/.bot-bottle/`
returns an empty manifest instead of dying. This is for
returns an empty index instead of dying. This is for
passive UI surfaces like the dashboard, which can still
monitor already-running agents without launch config.
@@ -247,25 +312,16 @@ class Manifest:
cls,
home_dir: Path,
cwd_dir: Path | None,
) -> "Manifest":
"""Programmatic entry point. Loads bottles from
`<home_dir>/bottles/`, home agents from `<home_dir>/agents/`,
and (if `cwd_dir` is passed) cwd agents from
`<cwd_dir>/agents/`. Cwd agents override home agents on
name collision. A `bottles/` subdir under `cwd_dir` is
logged as a warning and ignored.
) -> "ManifestIndex":
"""Return a names-only ManifestIndex. No file content is read; only
filenames are scanned for the agent selector. Full parsing happens
later, per-agent, via `load_for_agent`.
Used by tests to build a Manifest from fixture directories
A `bottles/` subdir under `cwd_dir` is logged as a warning and
ignored the filesystem layout IS the trust boundary.
Used by tests to build a ManifestIndex from fixture directories
without touching `os.environ`."""
bottles_dir = home_dir / "bottles"
from .manifest_loader import load_agents_from_dir, load_bottles_from_dir
bottles = load_bottles_from_dir(bottles_dir)
bottle_names = set(bottles.keys())
agents_dir = home_dir / "agents"
agents = load_agents_from_dir(agents_dir, bottle_names, source="$HOME")
if cwd_dir is not None:
stale_bottles = cwd_dir / "bottles"
if stale_bottles.is_dir():
@@ -279,17 +335,11 @@ class Manifest:
f"live under $HOME/.bot-bottle/bottles/ "
f"(PRD 0011). Move them or delete."
)
cwd_agents_dir = cwd_dir / "agents"
cwd_agents = load_agents_from_dir(
cwd_agents_dir, bottle_names, source="$CWD"
)
agents = {**agents, **cwd_agents}
return cls(bottles=bottles, agents=agents)
return cls(bottles={}, agents={}, home_md=home_dir, cwd_md=cwd_dir)
@classmethod
def from_json_obj(cls, obj: object) -> "Manifest":
"""Validate and build a Manifest from a raw JSON-like dict."""
def from_json_obj(cls, obj: object) -> "ManifestIndex":
"""Validate and build a ManifestIndex from a raw JSON-like dict."""
d = as_json_object(obj, "manifest")
raw_bottles_obj = _section_dict(d.get("bottles"), "manifest 'bottles'")
raw_agents = _section_dict(d.get("agents"), "manifest 'agents'")
@@ -310,75 +360,121 @@ class Manifest:
}
return cls(bottles=bottles, agents=agents)
@property
def all_agent_names(self) -> list[str]:
"""Sorted list of all discoverable agent names.
In names-only mode (from resolve/from_md_dirs) this scans agent
filenames without reading their content. In eager mode (from
from_json_obj) it returns the pre-parsed agents' names."""
if self.home_md is not None:
from .manifest_loader import scan_agent_names
home_names = set(scan_agent_names(self.home_md / "agents").keys())
cwd_names: set[str] = set()
if self.cwd_md is not None:
cwd_names = set(scan_agent_names(self.cwd_md / "agents").keys())
return sorted(home_names | cwd_names)
return sorted(self.agents.keys())
def load_for_agent(self, agent_name: str) -> "Manifest":
"""Parse the named agent and its bottle; return a single-value Manifest.
In lazy mode (from resolve/from_md_dirs) the agent file and its
bottle chain are read from disk for the first time here. In eager
mode (from_json_obj) the data is already parsed; this just filters
down to the requested agent and its bottle.
The returned Manifest.bottle has the agent's git-gate.user already
overlaid (agent wins on non-empty, per-field).
Always raises ManifestError if the agent is unknown or invalid.
Backends call this at preflight inside _validate."""
if self.home_md is None:
# Eager manifest (from_json_obj): data already parsed; filter to
# the one requested agent and its bottle so the returned Manifest
# always holds exactly one agent and one bottle regardless of path.
if agent_name not in self.agents:
available = ", ".join(sorted(self.agents.keys())) or "(none)"
raise ManifestError(
f"agent '{agent_name}' not defined. Available: {available}"
)
agent = self.agents[agent_name]
raw_bottle = self.bottles[agent.bottle]
merged = _merge_git_user(agent.git_user, raw_bottle.git_user)
bottle = raw_bottle if merged == raw_bottle.git_user else replace(raw_bottle, git_user=merged)
return Manifest(agent=agent, bottle=bottle)
from .manifest_loader import load_bottle_chain_from_dir, scan_agent_names
from .manifest_schema import validate_agent_frontmatter_keys
from .yaml_subset import YamlSubsetError, parse_frontmatter
# Locate the agent file; cwd wins over home on name collision.
home_agents = scan_agent_names(self.home_md / "agents")
cwd_agents: dict[str, Path] = {}
if self.cwd_md is not None:
cwd_agents = scan_agent_names(self.cwd_md / "agents")
merged_agents = {**home_agents, **cwd_agents}
if agent_name not in merged_agents:
available = ", ".join(sorted(merged_agents.keys())) or "(none)"
raise ManifestError(
f"agent '{agent_name}' not defined. Available: {available}"
)
agent_path = merged_agents[agent_name]
try:
fm, body = parse_frontmatter(agent_path.read_text())
except OSError as e:
raise ManifestError(f"could not read {agent_path}: {e}") from e
except YamlSubsetError as e:
raise ManifestError(f"{agent_path}: {e}") from e
validate_agent_frontmatter_keys(agent_path, fm.keys())
bottle_name = fm.get("bottle")
if not isinstance(bottle_name, str) or not bottle_name:
raise ManifestError(
f"agent '{agent_name}' must declare a 'bottle' field "
f"naming a defined bottle"
)
# Load the bottle chain (may raise ManifestError).
bottles_dir = self.home_md / "bottles"
raw_bottle = load_bottle_chain_from_dir(bottle_name, bottles_dir)
# Build and validate the full ManifestAgent.
agent_dict: dict[str, object] = {
"bottle": bottle_name,
"skills": fm.get("skills", []),
"prompt": body.strip(),
}
if "git-gate" in fm:
agent_dict["git-gate"] = fm["git-gate"]
agent = ManifestAgent.from_dict(agent_name, agent_dict, {bottle_name})
merged_user = _merge_git_user(agent.git_user, raw_bottle.git_user)
bottle = raw_bottle if merged_user == raw_bottle.git_user else replace(raw_bottle, git_user=merged_user)
return Manifest(agent=agent, bottle=bottle)
def has_agent(self, name: str) -> bool:
return name in self.agents
def require_agent(self, name: str) -> None:
"""Check that `name` is a discoverable agent. In names-only mode
this checks whether the .md file exists; in eager mode it checks
the pre-parsed agents dict. Does NOT parse file content."""
if self.has_agent(name):
return
available = ", ".join(self.agents.keys())
if available:
msg = f"agent '{name}' not defined in bot-bottle.json. Available: {available}"
raise ManifestError(msg)
raise ManifestError(
f"agent '{name}' not defined in bot-bottle.json (manifest is empty)."
)
def has_bottle(self, name: str) -> bool:
return name in self.bottles
def require_bottle(self, name: str) -> None:
if self.has_bottle(name):
return
available = ", ".join(self.bottles.keys())
if available:
raise ManifestError(
f"bottle '{name}' not defined in bot-bottle.json. "
f"Available bottles: {available}"
if self.home_md is not None:
# Names-only mode: check file existence without parsing.
home_path = self.home_md / "agents" / f"{name}.md"
cwd_path = (
self.cwd_md / "agents" / f"{name}.md"
if self.cwd_md else None
)
raise ManifestError(f"bottle '{name}' not defined in bot-bottle.json (no bottles defined).")
def _effective_git_user(self, agent_name: str) -> ManifestGitUser:
"""Merge the agent's git.user over the referenced bottle's,
per-field, agent-wins-on-non-empty (issue #94). Same overlay
the `extends:` resolver applies between bottles
(`_merge_bottles`)."""
agent = self.agents[agent_name]
base = self.bottles[agent.bottle].git_user
over = agent.git_user
if over.is_empty():
return base
return ManifestGitUser(
name=over.name or base.name,
email=over.email or base.email,
if home_path.is_file() or (cwd_path and cwd_path.is_file()):
return
available = ", ".join(self.all_agent_names) or "(none)"
raise ManifestError(
f"agent '{name}' not defined. Available: {available}"
)
def bottle_for(self, agent_name: str) -> ManifestBottle:
"""Resolve the Bottle the named agent references, with the
agent's git.user overlaid on top. The validator guarantees both
lookups succeed for a manifest built via from_json_obj.
The overlay lives here, the single point both backends call to
resolve an agent's bottle, so the docker / smolmachines git
provisioners pick up the merged identity unchanged."""
bottle = self.bottles[self.agents[agent_name].bottle]
merged = self._effective_git_user(agent_name)
if merged == bottle.git_user:
return bottle
return replace(bottle, git_user=merged)
def git_identity_summary(self, agent_name: str) -> str | None:
"""One-line effective git identity with per-field provenance
for launch summaries, e.g.
`name=claude (agent), email=eric@dideric.is (bottle)`.
Returns None when neither agent nor bottle sets an identity."""
over = self.agents[agent_name].git_user
merged = self._effective_git_user(agent_name)
if merged.is_empty():
return None
parts: list[str] = []
if merged.name:
parts.append(f"name={merged.name} ({'agent' if over.name else 'bottle'})")
if merged.email:
parts.append(f"email={merged.email} ({'agent' if over.email else 'bottle'})")
return ", ".join(parts)
+66 -18
View File
@@ -5,16 +5,20 @@ from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .manifest import ManifestBottle, ManifestGitEntry
from .manifest import ManifestBottle
from .manifest_egress import ManifestEgressConfig
def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, ManifestBottle]:
"""Apply `extends:` chains and return resolved ManifestBottle objects."""
cache: dict[str, ManifestBottle] = {}
# Per-bottle effective git-gate.repos, as raw dicts keyed by repo name.
# Threaded alongside `cache` so a child can field-merge against its
# parent's repos without reconstructing them from parsed entries.
repos_cache: dict[str, dict[str, object]] = {}
for name in raws:
if name not in cache:
_resolve_one_bottle(name, raws, cache, ())
_resolve_one_bottle(name, raws, cache, repos_cache, ())
return cache
@@ -22,6 +26,7 @@ def _resolve_one_bottle(
name: str,
raws: dict[str, dict[str, object]],
cache: dict[str, ManifestBottle],
repos_cache: dict[str, dict[str, object]],
seen: tuple[str, ...],
) -> ManifestBottle:
from .manifest import ManifestBottle, ManifestError
@@ -41,6 +46,7 @@ def _resolve_one_bottle(
if parent_name_raw is None:
bottle = ManifestBottle.from_dict(name, child_raw)
cache[name] = bottle
repos_cache[name] = _resolve_repos_raw({}, child_raw)
return bottle
if not isinstance(parent_name_raw, str):
@@ -60,20 +66,33 @@ def _resolve_one_bottle(
f"bottle '{name}' extends '{parent_name}' which is not "
f"defined. Available bottles: {avail}"
)
parent = _resolve_one_bottle(parent_name, raws, cache, seen + (name,))
bottle = _merge_bottles(parent, child_raw, name)
parent = _resolve_one_bottle(
parent_name, raws, cache, repos_cache, seen + (name,)
)
merged_repos_raw = _resolve_repos_raw(repos_cache[parent_name], child_raw)
bottle = _merge_bottles(parent, child_raw, merged_repos_raw, name)
cache[name] = bottle
repos_cache[name] = merged_repos_raw
return bottle
def _merge_bottles(
parent: ManifestBottle,
child_raw: dict[str, object],
merged_repos_raw: dict[str, object],
name: str,
) -> ManifestBottle:
"""Apply PRD 0025 merge rules."""
from .manifest import ManifestBottle, ManifestGitUser
from .manifest_egress import validate_egress_routes
from .manifest_util import as_json_object
# git-gate.repos: when the child declares repos, inject the already
# name-merged repo set (computed by _resolve_repos_raw) so the child
# parses with the full inherited+overridden list (issue #237).
if _child_declares_git_gate_repos(child_raw):
git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate")
child_raw = {**child_raw, "git-gate": {**git_raw, "repos": merged_repos_raw}}
# Parse the child's declared fields into a ManifestBottle (with the
# usual defaults for anything missing). Validation runs the same
@@ -92,11 +111,11 @@ def _merge_bottles(
email=child.git_user.email or parent.git_user.email,
)
# git-gate.repos: missing means inherit; an explicit empty object
# clears; otherwise parent and child merge by UpstreamHost with
# child entries replacing duplicate hosts.
# git-gate.repos: when declared, child.git already holds the merged
# set (an explicit empty dict clears parent, leaving child.git empty).
# When omitted, the parent's entries are inherited verbatim.
if _child_declares_git_gate_repos(child_raw):
merged_git = _merge_git_remotes(parent.git, child.git) if child.git else ()
merged_git = child.git
else:
merged_git = parent.git
@@ -130,6 +149,45 @@ def _merge_bottles(
)
def _resolve_repos_raw(
parent_repos: dict[str, object],
child_raw: dict[str, object],
) -> dict[str, object]:
"""Compute a bottle's effective git-gate.repos as raw dicts.
Repos are keyed by name. When the child omits git-gate.repos it
inherits the parent's set verbatim; an explicit empty dict clears it.
Otherwise parent and child unite by name, with same-name entries
field-merged (parent fields are defaults, child fields win)."""
from .manifest_util import as_json_object
if not _child_declares_git_gate_repos(child_raw):
return parent_repos
child_repos = _declared_repos_raw(child_raw)
if not child_repos:
return {}
# Parent entries keep their order; child-only names are appended.
names = list(parent_repos) + [n for n in child_repos if n not in parent_repos]
return {
name: {
**as_json_object(parent_repos.get(name, {}), "parent git-gate repo"),
**as_json_object(child_repos.get(name, {}), "child git-gate repo"),
}
for name in names
}
def _declared_repos_raw(child_raw: dict[str, object]) -> dict[str, object]:
"""Return the child's explicitly declared git-gate.repos as raw dicts,
or an empty dict when none are declared."""
from .manifest_util import as_json_object
if not _child_declares_git_gate_repos(child_raw):
return {}
git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate")
return as_json_object(git_raw.get("repos", {}), "child git-gate.repos")
def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
from .manifest_util import as_json_object
@@ -140,16 +198,6 @@ def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
return "repos" in git_obj
def _merge_git_remotes(
parent: tuple[ManifestGitEntry, ...],
child: tuple[ManifestGitEntry, ...],
) -> tuple[ManifestGitEntry, ...]:
by_host = {entry.UpstreamHost: entry for entry in parent}
for entry in child:
by_host[entry.UpstreamHost] = entry
return tuple(by_host.values())
def _merge_egress(
parent: ManifestEgressConfig,
child: ManifestEgressConfig,
+73 -66
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Optional
from .manifest_util import ManifestError, as_json_object
@@ -13,6 +12,8 @@ from .manifest_util import ManifestError, as_json_object
# defence; this regex is belt-and-suspenders and documents intent).
_GIT_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
_KEY_PROVIDERS = {"static", "gitea"}
def _opt_str(value: object, label: str) -> str:
if value is None:
@@ -69,20 +70,22 @@ def validate_unique_git_names(bottle_name: str, git: tuple[ManifestGitEntry, ...
@dataclass(frozen=True)
class ManifestProvisionedKeyConfig:
"""Configuration for automatic deploy-key lifecycle management
(PRD 0048). Used when a git-gate.repos entry opts out of a
static identity file and instead wants a fresh SSH keypair
generated at spin-up and revoked at teardown.
class ManifestKeyConfig:
"""Configuration for a repo's SSH key in git-gate.repos.
`provider` names the contrib sub-package to load (e.g. `gitea`).
`token_env` is the name of a host-side env var carrying the API
token; the value is read at provision time, never stored on the
plan. `api_url` is the forge's HTTP API root; if empty, it is
derived from the upstream URL's host at provision time."""
`provider` is either `"static"` (a pre-existing key on the host) or
`"gitea"` (automatic deploy-key lifecycle via the Gitea API).
For `static`: `path` is the host-side absolute path to the SSH private key.
For `gitea`: `forge_token_env` is the name of a host-side env var
carrying the Gitea API token; the value is read at provision time,
never stored on the plan. `api_url` is the forge's HTTP API root; if
empty, it is derived from the upstream URL's host at provision time."""
provider: str
token_env: str
path: str = ""
forge_token_env: str = ""
api_url: str = ""
@@ -99,15 +102,16 @@ class ManifestGitEntry:
stashed in the `Upstream*` fields so the git-gate render step
doesn't have to re-parse.
Manifest source: `git-gate.repos.<Name>` (PRD 0047/0048). Exactly
one of `identity` (static key path) or `provisioned_key` (automatic
lifecycle) must be present. The internal field names are stable."""
Manifest source: `git-gate.repos.<Name>` (PRD 0047/0048). A `key`
block is required; `key.provider` is `"static"` or `"gitea"`. For
`static`, `IdentityFile` is populated at parse time from `key.path`.
For `gitea`, `IdentityFile` is populated at provision time."""
Name: str
Upstream: str
Key: ManifestKeyConfig = ManifestKeyConfig(provider="")
IdentityFile: str = ""
KnownHostKey: str = ""
ProvisionedKey: Optional[ManifestProvisionedKeyConfig] = None
RemoteKey: str = ""
UpstreamUser: str = ""
UpstreamHost: str = ""
@@ -120,8 +124,8 @@ class ManifestGitEntry:
) -> "ManifestGitEntry":
"""Parse one entry from `git-gate.repos.<repo_name>`.
YAML keys: `url` (required), exactly one of `identity` or
`provisioned_key` (required), `host_key` (optional).
YAML keys: `url` (required), `key` (required object with
`provider`, and provider-specific fields), `host_key` (optional).
The repo_name becomes `Name`."""
if not repo_name:
raise ManifestError(
@@ -135,10 +139,10 @@ class ManifestGitEntry:
label = f"git-gate.repos[{repo_name!r}]"
d = as_json_object(raw, f"bottle '{bottle_name}' {label}")
for k in d:
if k not in {"url", "identity", "provisioned_key", "host_key"}:
if k not in {"url", "key", "host_key"}:
raise ManifestError(
f"bottle '{bottle_name}' {label} has unknown key {k!r}; "
f"allowed: url, identity, provisioned_key, host_key"
f"allowed: url, key, host_key"
)
upstream = d.get("url")
if not isinstance(upstream, str) or not upstream:
@@ -146,32 +150,13 @@ class ManifestGitEntry:
f"bottle '{bottle_name}' {label} missing required string field 'url'"
)
has_identity = "identity" in d
has_provisioned = "provisioned_key" in d
if has_identity and has_provisioned:
if "key" not in d:
raise ManifestError(
f"bottle '{bottle_name}' {label} must set exactly one of "
f"'identity' or 'provisioned_key'; got both."
)
if not has_identity and not has_provisioned:
raise ManifestError(
f"bottle '{bottle_name}' {label} must set exactly one of "
f"'identity' or 'provisioned_key'; got neither."
f"bottle '{bottle_name}' {label} missing required 'key' block"
)
key_config = _parse_key_config(bottle_name, label, d["key"])
ident = ""
provisioned_key: Optional[ManifestProvisionedKeyConfig] = None
if has_identity:
raw_ident = d.get("identity")
if not isinstance(raw_ident, str) or not raw_ident:
raise ManifestError(
f"bottle '{bottle_name}' {label} 'identity' must be a non-empty string"
)
ident = raw_ident
else:
provisioned_key = _parse_provisioned_key_config(
bottle_name, label, d["provisioned_key"]
)
ident = key_config.path if key_config.provider == "static" else ""
khk = _opt_str(
d.get("host_key"),
@@ -183,9 +168,9 @@ class ManifestGitEntry:
return cls(
Name=repo_name,
Upstream=upstream,
Key=key_config,
IdentityFile=ident,
KnownHostKey=khk,
ProvisionedKey=provisioned_key,
RemoteKey=host,
UpstreamUser=user,
UpstreamHost=host,
@@ -194,38 +179,60 @@ class ManifestGitEntry:
)
def _parse_provisioned_key_config(
def _parse_key_config(
bottle_name: str, label: str, raw: object
) -> ManifestProvisionedKeyConfig:
d = as_json_object(raw, f"bottle '{bottle_name}' {label}.provisioned_key")
for k in d:
if k not in {"provider", "token_env", "api_url"}:
raise ManifestError(
f"bottle '{bottle_name}' {label}.provisioned_key has unknown key {k!r}; "
f"allowed: provider, token_env, api_url"
)
) -> ManifestKeyConfig:
d = as_json_object(raw, f"bottle '{bottle_name}' {label}.key")
provider = d.get("provider")
if not isinstance(provider, str) or not provider:
raise ManifestError(
f"bottle '{bottle_name}' {label}.provisioned_key missing required "
f"bottle '{bottle_name}' {label}.key missing required "
f"string field 'provider'"
)
token_env = d.get("token_env")
if not isinstance(token_env, str) or not token_env:
if provider not in _KEY_PROVIDERS:
raise ManifestError(
f"bottle '{bottle_name}' {label}.provisioned_key missing required "
f"string field 'token_env'"
f"bottle '{bottle_name}' {label}.key provider {provider!r} is unknown; "
f"allowed: {', '.join(sorted(_KEY_PROVIDERS))}"
)
api_url_raw = d.get("api_url", "")
if not isinstance(api_url_raw, str):
if provider == "gitea":
for k in d:
if k not in {"provider", "forge_token_env", "api_url"}:
raise ManifestError(
f"bottle '{bottle_name}' {label}.key has unknown key {k!r} "
f"for provider 'gitea'; allowed: provider, forge_token_env, api_url"
)
forge_token_env = d.get("forge_token_env")
if not isinstance(forge_token_env, str) or not forge_token_env:
raise ManifestError(
f"bottle '{bottle_name}' {label}.key missing required "
f"string field 'forge_token_env' for provider 'gitea'"
)
api_url_raw = d.get("api_url", "")
if not isinstance(api_url_raw, str):
raise ManifestError(
f"bottle '{bottle_name}' {label}.key 'api_url' must be a string"
)
return ManifestKeyConfig(
provider=provider,
forge_token_env=forge_token_env,
api_url=api_url_raw,
)
# provider == "static"
for k in d:
if k not in {"provider", "path"}:
raise ManifestError(
f"bottle '{bottle_name}' {label}.key has unknown key {k!r} "
f"for provider 'static'; allowed: provider, path"
)
path = d.get("path")
if not isinstance(path, str) or not path:
raise ManifestError(
f"bottle '{bottle_name}' {label}.provisioned_key 'api_url' must be a string"
f"bottle '{bottle_name}' {label}.key missing required "
f"string field 'path' for provider 'static'"
)
return ManifestProvisionedKeyConfig(
provider=provider,
token_env=token_env,
api_url=api_url_raw,
)
return ManifestKeyConfig(provider=provider, path=path)
@dataclass(frozen=True)
+44 -58
View File
@@ -8,21 +8,19 @@ from typing import TYPE_CHECKING
from .log import warn
from .manifest_schema import (
entity_name_from_path,
validate_agent_frontmatter_keys,
validate_bottle_frontmatter_keys,
)
from .manifest_util import ManifestError
from .yaml_subset import YamlSubsetError, parse_frontmatter
if TYPE_CHECKING:
from .manifest import ManifestAgent, ManifestBottle
from .manifest import ManifestBottle
def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
"""Die if `<dir_path>/bot-bottle.json` exists but `md_dir` does
not. The manifest format changed in PRD 0011 and we do not want
to silently leave the JSON content unused."""
from .manifest import ManifestError
legacy = dir_path / "bot-bottle.json"
if legacy.is_file() and not md_dir.exists():
raise ManifestError(
@@ -34,48 +32,13 @@ def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
)
def load_bottles_from_dir(bottles_dir: Path) -> dict[str, ManifestBottle]:
"""Walk `<bottles_dir>/*.md`, parse each as a bottle, and return
`{name: Bottle}`. Missing dir returns an empty dict."""
from .manifest import ManifestError
from .manifest_extends import resolve_bottles
def scan_agent_names(agents_dir: Path) -> dict[str, Path]:
"""Scan `<agents_dir>/*.md` for valid filenames and return `{name: path}`.
raws: dict[str, dict[str, object]] = {}
if not bottles_dir.is_dir():
return {}
for path in sorted(bottles_dir.glob("*.md")):
name = entity_name_from_path(path)
if name is None:
warn(
f"skipping {path}: filename must match "
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
)
continue
try:
fm, _body = parse_frontmatter(path.read_text())
except OSError as e:
raise ManifestError(f"could not read {path}: {e}") from e
except YamlSubsetError as e:
raise ManifestError(f"{path}: {e}") from e
validate_bottle_frontmatter_keys(path, fm.keys())
raws[name] = fm
return resolve_bottles(raws)
def load_agents_from_dir(
agents_dir: Path,
bottle_names: set[str],
*,
source: str, # noqa: F841 — unused, but required by interface
) -> dict[str, ManifestAgent]:
"""Walk `<agents_dir>/*.md`, parse each as an agent, and return
`{name: Agent}`. The Markdown body becomes the agent's prompt.
Missing dir returns an empty dict."""
from .manifest import ManifestAgent, ManifestError
out: dict[str, ManifestAgent] = {}
No file content is read. Invalid filenames are skipped with a warning."""
result: dict[str, Path] = {}
if not agents_dir.is_dir():
return out
return result
for path in sorted(agents_dir.glob("*.md")):
name = entity_name_from_path(path)
if name is None:
@@ -84,22 +47,45 @@ def load_agents_from_dir(
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
)
continue
result[name] = path
return result
def load_bottle_chain_from_dir(
bottle_name: str, bottles_dir: Path
) -> ManifestBottle:
"""Load `bottle_name` and its full `extends:` chain from `bottles_dir`,
returning the resolved ManifestBottle.
Only the files in the extends chain are read unrelated bottle files
are never touched. Raises ManifestError on parse or validation failure."""
from .manifest_extends import resolve_bottles
raws: dict[str, dict[str, object]] = {}
to_load = [bottle_name]
while to_load:
name = to_load.pop()
if name in raws:
continue
path = bottles_dir / f"{name}.md"
if not path.is_file():
avail = ", ".join(
p.stem for p in sorted(bottles_dir.glob("*.md")) if p.is_file()
) or "(none)"
raise ManifestError(
f"bottle '{name}' not found at {path}. "
f"Available: {avail}"
)
try:
fm, body = parse_frontmatter(path.read_text())
fm, _body = parse_frontmatter(path.read_text())
except OSError as e:
raise ManifestError(f"could not read {path}: {e}") from e
except YamlSubsetError as e:
raise ManifestError(f"{path}: {e}") from e
validate_agent_frontmatter_keys(path, fm.keys())
# Build the dict Agent.from_dict expects. The body becomes
# prompt; Claude Code passthrough fields stay in fm and get
# ignored by Agent.from_dict (reads bottle/skills/git-gate/prompt).
agent_dict: dict[str, object] = {
"bottle": fm.get("bottle"),
"skills": fm.get("skills", []),
"prompt": body.strip(),
}
if "git-gate" in fm:
agent_dict["git-gate"] = fm["git-gate"]
out[name] = ManifestAgent.from_dict(name, agent_dict, bottle_names)
return out
validate_bottle_frontmatter_keys(path, fm.keys())
raws[name] = dict(fm)
parent = fm.get("extends")
if isinstance(parent, str):
to_load.append(parent)
return resolve_bottles(raws)[bottle_name]
+22 -12
View File
@@ -5,7 +5,7 @@ queue/audit support. The sidecar (bot_bottle.supervise_server)
sits on the bottle's internal network and exposes three MCP tools the
agent calls when it hits a stuck-recovery category:
* egress-block agent proposes a new routes.yaml
* egress-block / allow agent proposes a new routes.yaml
* capability-block agent proposes a new agent Dockerfile
Each tool call: the agent passes the full proposed file plus a
@@ -49,27 +49,36 @@ SUPERVISE_HOSTNAME = "supervise"
SUPERVISE_PORT = 9100
TOOL_CAPABILITY_BLOCK = "capability-block"
TOOL_EGRESS_BLOCK = "egress-block"
TOOL_ALLOW = "allow"
TOOL_GITLEAKS_ALLOW = "gitleaks-allow"
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
TOOLS: tuple[str, ...] = (
TOOL_ALLOW,
TOOL_CAPABILITY_BLOCK,
TOOL_EGRESS_BLOCK,
TOOL_GITLEAKS_ALLOW,
TOOL_LIST_EGRESS_ROUTES,
)
# The supervise sidecar uses these to query egress's
# introspection endpoint for the `list-egress-routes` MCP
# tool. The hostname + port match egress's docker network
# alias + listen port (see bot_bottle.egress.EGRESS_HOSTNAME
# and backend.docker.egress.EGRESS_PORT — the values
# are inlined here so the in-container supervise_server doesn't
# need to import the egress package).
EGRESS_FORWARD_PROXY = "http://egress:9099"
# listen port (see backend.docker.egress.EGRESS_PORT). The supervise
# daemon runs inside the sidecar bundle alongside egress, so loopback
# is the stable address across docker, smolmachines, and Apple
# Container backends.
EGRESS_FORWARD_PROXY = "http://127.0.0.1:9099"
EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist"
# capability-block has no on-disk config the operator edits in place
# (the Dockerfile is rebuilt, not patched), so it has no audit log
# here — those changes are captured by git history + the rebuild
# record laid down in PRD 0016. egress-block was removed in issue #198.
COMPONENT_FOR_TOOL: dict[str, str] = {}
# here — those changes are captured by git history + the rebuild record
# laid down in PRD 0016.
COMPONENT_FOR_TOOL: dict[str, str] = {
TOOL_ALLOW: "egress",
TOOL_EGRESS_BLOCK: "egress",
}
STATUS_APPROVED = "approved"
STATUS_MODIFIED = "modified"
@@ -431,9 +440,9 @@ def sha256_hex(content: str) -> str:
# Dockerfile and propose modifications.
#
# routes.yaml + allowlist used to live here too; PRD 0017 chunk 3
# moved them behind the `list-egress-routes` MCP tool (live
# state from egress's introspection endpoint) so the agent
# always sees current data rather than a launch-time snapshot.
# moved them behind the `list-egress-routes` MCP tool (live state
# from egress's introspection endpoint) so the agent always sees
# current data rather than a launch-time snapshot.
CURRENT_CONFIG_DOCKERFILE = "Dockerfile"
@@ -546,6 +555,7 @@ __all__ = [
"EGRESS_FORWARD_PROXY",
"EGRESS_INTROSPECT_URL",
"TOOL_CAPABILITY_BLOCK",
"TOOL_GITLEAKS_ALLOW",
"TOOL_LIST_EGRESS_ROUTES",
"archive_proposal",
"audit_dir",
+108 -10
View File
@@ -1,8 +1,8 @@
"""Supervise sidecar HTTP server (PRD 0013).
Per-bottle MCP server exposing tools the agent calls to propose config
changes when stuck. The egress-block tool was removed in issue #198;
the remaining tools are `capability-block` and `list-egress-routes`.
changes when stuck. The tools are `allow`, `egress-block`,
`capability-block`, and `list-egress-routes`.
Each queued tool call:
@@ -44,9 +44,15 @@ import urllib.request
from dataclasses import dataclass
from pathlib import Path
# Same-directory import inside the bundle container; `supervise.py`
# is COPYed alongside this file by Dockerfile.sidecars.
import supervise as _sv
try:
# Same-directory imports inside the bundle container; these files are
# COPYed flat under /app by Dockerfile.sidecars.
from egress_addon_core import load_routes
import supervise as _sv
except ModuleNotFoundError:
# Package imports for host-side tests and tooling.
from .egress_addon_core import load_routes
from . import supervise as _sv
# --- JSON-RPC / MCP plumbing ----------------------------------------------
@@ -142,8 +148,9 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
"allowlist. Returns JSON with one entry per allowed host, "
"each carrying its matches rules (if any) and whether "
"the proxy injects Authorization for the route. Use this "
"before composing an `egress-block` proposal so the new "
"routes file extends the live one rather than replacing it."
"before composing an `allow` or `egress-block` proposal so "
"the new routes file extends the live one rather than "
"replacing it."
),
"inputSchema": {
"type": "object",
@@ -151,6 +158,88 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
"additionalProperties": False,
},
},
{
"name": _sv.TOOL_ALLOW,
"description": (
"Request operator approval to change the bottle's egress "
"allowlist. Pass the full proposed routes.yaml content, not "
"just the new host, plus a justification. Use "
"`list-egress-routes` first so the proposal preserves existing "
"routes."
),
"inputSchema": {
"type": "object",
"properties": {
"routes_yaml": {
"type": "string",
"description": (
"Full proposed /etc/egress/routes.yaml content. "
"Each route entry accepts these keys:\n"
" host: <hostname> (required)\n"
" auth_scheme: Bearer|token (must pair with token_env)\n"
" token_env: <ENV_VAR_NAME> (must pair with auth_scheme)\n"
" matches: (optional list of match entries)\n"
" - paths: [{type: prefix|exact|regex, value: /...}]\n"
" methods: [GET, POST, ...]\n"
" headers: [{name: X-Hdr, value: val, type: exact|regex}]\n"
" git: (optional; omit to block git clone/fetch)\n"
" fetch: true\n"
" dlp: (optional DLP scanner overrides)\n"
" outbound_detectors: [token_patterns, known_secrets]\n"
" inbound_detectors: [naive_injection_detection]\n"
"Omit any key that should use its default. "
"`list-egress-routes` returns routes in this same format."
),
},
"justification": {
"type": "string",
"description": "Why this egress route is needed.",
},
},
"required": ["routes_yaml", "justification"],
},
},
{
"name": _sv.TOOL_EGRESS_BLOCK,
"description": (
"Request operator approval to change the bottle's egress "
"allowlist after a blocked outbound request. Pass the full "
"proposed routes.yaml content plus a justification. Use "
"`list-egress-routes` first so the proposal preserves existing "
"routes."
),
"inputSchema": {
"type": "object",
"properties": {
"routes_yaml": {
"type": "string",
"description": (
"Full proposed /etc/egress/routes.yaml content. "
"Each route entry accepts these keys:\n"
" host: <hostname> (required)\n"
" auth_scheme: Bearer|token (must pair with token_env)\n"
" token_env: <ENV_VAR_NAME> (must pair with auth_scheme)\n"
" matches: (optional list of match entries)\n"
" - paths: [{type: prefix|exact|regex, value: /...}]\n"
" methods: [GET, POST, ...]\n"
" headers: [{name: X-Hdr, value: val, type: exact|regex}]\n"
" git: (optional; omit to block git clone/fetch)\n"
" fetch: true\n"
" dlp: (optional DLP scanner overrides)\n"
" outbound_detectors: [token_patterns, known_secrets]\n"
" inbound_detectors: [naive_injection_detection]\n"
"Omit any key that should use its default. "
"`list-egress-routes` returns routes in this same format."
),
},
"justification": {
"type": "string",
"description": "Why this egress route is needed.",
},
},
"required": ["routes_yaml", "justification"],
},
},
{
"name": _sv.TOOL_CAPABILITY_BLOCK,
"description": (
@@ -182,11 +271,12 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
]
# Map each non-egress tool to the input field that carries the agent's
# payload (stored in Proposal.proposed_file). egress-block builds its
# payload from structured input fields in `handle_egress_block`.
# Map each proposal tool to the input field that carries the agent's
# payload (stored in Proposal.proposed_file).
PROPOSED_FILE_FIELD: dict[str, str] = {
_sv.TOOL_ALLOW: "routes_yaml",
_sv.TOOL_CAPABILITY_BLOCK: "dockerfile",
_sv.TOOL_EGRESS_BLOCK: "routes_yaml",
}
@@ -203,6 +293,14 @@ def validate_proposed_file(tool: str, content: str) -> None:
# Dockerfiles are too varied to validate syntactically beyond
# non-empty. The operator reads the diff in the TUI.
pass
elif tool in (_sv.TOOL_ALLOW, _sv.TOOL_EGRESS_BLOCK):
try:
load_routes(content)
except ValueError as e:
raise _RpcError(
ERR_INVALID_PARAMS,
f"{tool}: proposed routes.yaml is not valid: {e}",
) from e
else:
raise _RpcError(ERR_INVALID_PARAMS, f"unknown tool {tool!r}")
@@ -1,6 +1,6 @@
# PRD prd-new: macOS Container backend
# PRD 0059: macOS Container backend
- **Status:** Draft
- **Status:** Active
- **Author:** Codex
- **Created:** 2026-06-10
- **Issue:** #220
+159
View File
@@ -0,0 +1,159 @@
# PRD 0060: Commit bottle state to an image
- **Status:** Active
- **Author:** Claude
- **Created:** 2026-06-20
- **Issue:** #194
## Summary
Add a `commit` CLI command that freezes a running bottle's state to a
resumable local artifact. Docker bottles are stored as Docker images;
smolmachines bottles are stored as `.smolmachine` artifacts. Operators
can then resume the bottle from that exact filesystem snapshot, or
export the artifact to migrate work to a different host.
## Problem
When a long-running agent session is interrupted — by a host reboot, a
network failure, or a planned infrastructure migration — the in-progress
container state is lost. `cli.py resume` rebuilds the agent image from
the Dockerfile and reprovi-sions the bottle, but that returns the guest
to its initial state, not to wherever the agent was mid-task.
There is no mechanism today to capture "what's installed / configured
inside the running container right now" and make it reproducible. The
`capability-block` flow writes a new Dockerfile and marks the bottle for
resume, but that only applies when the agent itself has requested a
capability change; it doesn't help the operator who wants to take a
snapshot before a planned host reboot or hardware migration.
## Goals / Success Criteria
- `./cli.py 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
snapshot instead of rebuilding from the Dockerfile.
- `mark_preserved` is called so the state dir survives the normal
session-end cleanup.
- A backend-specific export hint is printed so operators know how to
migrate the snapshot.
- The command errors clearly on unsupported backends.
## Non-goals
- macOS-container backend support.
- Automatic commit on agent exit.
- Image push to a remote registry.
- Storing the image tag in the manifest or sharing it between operators.
## Design
### Docker image tag
`bot-bottle-committed-<slug>:latest` — namespaced under `bot-bottle-`
to match existing image naming conventions; `committed` distinguishes it
from the build-time image (`bot-bottle-claude:latest`) and the
capability-block rebuild image (`bot-bottle-rebuilt-<identity>:latest`).
### State storage
A new plain-text file `committed-image` is added to the per-bottle state
directory:
```
~/.bot-bottle/state/<identity>/
metadata.json
Dockerfile (capability-block override; optional)
committed-image (committed artifact reference; optional)
transcript/
```
`bottle_state.committed_image_path(identity)` returns the path.
`write_committed_image` / `read_committed_image` are the read/write
helpers, matching the existing `per_bottle_dockerfile` pattern. Docker
stores a Docker tag in this file; smolmachines stores the absolute path
to the committed `.smolmachine` artifact.
### `commit` command
```
./cli.py commit [<slug>]
```
1. Resolve slug (arg or interactive picker from `enumerate_active_agents`).
2. Check metadata and branch by backend.
3. For Docker, derive container name `bot-bottle-<slug>` and run
`docker commit <container> bot-bottle-committed-<slug>:latest`.
4. For smolmachines, derive machine name `bot-bottle-<slug>` and run
`smolvm pack create --from-vm <machine> -o ~/.bot-bottle/state/<slug>/committed-smolmachine`.
5. Write the Docker image tag or smolmachine artifact path to
`~/.bot-bottle/state/<slug>/committed-image`.
6. Call `mark_preserved(<slug>)` so the state dir survives session-end.
7. Print the resume hint and a backend-specific export example.
### Resume from committed image
`bot_bottle/backend/docker/launch.py` already rebuilds the agent image
at the top of the `launch` context manager. The change is a check
immediately before that step:
```python
committed = read_committed_image(plan.slug)
if committed and docker_mod.image_exists(committed):
info(f"using committed image {committed!r}")
plan = dataclasses.replace(
plan,
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
)
else:
docker_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
```
Replacing `agent_provision.image` propagates to `plan.image` (a
property) and from there to the Compose spec renderer's `_agent_service`
`image:` field, so the container boots from the committed snapshot.
The build step is skipped entirely when a committed image is found and
exists locally.
If the committed image has been deleted from the local daemon (e.g.
after `docker rmi` or a `docker system prune`), the launch falls back
to a normal Dockerfile build, matching the pre-commit behavior.
### Resume from committed smolmachine
`bot_bottle/backend/smolmachines/launch.py` checks the committed
reference before the normal Docker build -> pack cache path:
```python
committed = read_committed_image(plan.slug)
if committed and Path(committed).is_file():
return Path(committed)
return _ensure_smolmachine(plan.agent_image, dockerfile=plan.agent_dockerfile_path)
```
The returned path is passed to `smolvm machine create --from`, so the
resumed VM boots from the committed snapshot. If the artifact has been
deleted, launch falls back to the normal build and pack flow.
## Testing strategy
- Unit tests for `write_committed_image` / `read_committed_image` in
`tests/unit/test_bottle_state.py`, using the existing `_FakeHomeMixin`
pattern.
- Unit tests for `commit_container` in `tests/unit/test_docker_util_image.py`,
mocking `subprocess.run` and asserting on the `docker commit` argv.
- Unit tests for `cmd_commit` argument parsing, Docker commit,
smolmachines pack, and the unsupported backend error path, mocking
`enumerate_active_agents`, `commit_container`, and
`pack_create_from_vm`.
- Unit tests for the launch-step committed-image branch: patch
`read_committed_image` to return a tag, patch `image_exists` to return
True, and assert that `build_image` is not called and `plan.image` is
overridden.
- Unit tests for the smolmachines launch-step committed-artifact branch:
patch `read_committed_image` to return an existing path and assert the
normal `_ensure_smolmachine` path is skipped.
@@ -0,0 +1,101 @@
# PRD 0061: Gitleaks inline suppression supervision
- **Status:** Active
- **Author:** codex
- **Created:** 2026-06-10
- **Issue:** #208
## Summary
Route every `# gitleaks:allow` inline suppression through the existing
supervisor approval queue before git-gate forwards a push upstream. The
annotation remains available for test fixtures and confirmed false positives,
but it no longer lets an agent silently bypass the gate's secret scan.
## Problem
Gitleaks supports inline suppression comments. With the default behaviour,
a pushed line that contains a secret-like value and `# gitleaks:allow` is
ignored by gitleaks and accepted by git-gate. That is convenient for dummy
credentials in tests, but it also gives an agent a one-line bypass for a real
secret. Once the commit lands, the diff alone cannot prove whether the
suppression was legitimate.
## Goals / Success Criteria
1. Git-gate continues to run the normal gitleaks scan for every incoming ref.
2. After the normal scan passes, git-gate runs a second scan with
`--ignore-gitleaks-allow` and a JSON report so suppressed findings become
visible.
3. If that second scan reports no suppressed findings, the push proceeds
unchanged.
4. If it reports suppressed findings, git-gate creates a `gitleaks-allow`
supervisor proposal containing the ref, file path, line number, rule,
commit, and flagged line for each finding.
5. The push proceeds only when the supervisor explicitly approves the
proposal; rejection, malformed responses, missing supervisor configuration,
and timeout all refuse the push.
6. The supervisor TUI requires a reason when approving a `gitleaks-allow`
proposal, so the audit trail records whether the approval was for a test
fixture or a false positive.
## Non-goals
- Replacing gitleaks or changing the main secret-detection rule set.
- Removing support for `# gitleaks:allow`.
- Automatically classifying fixture files or false positives.
- Adding new supervisor transport or authentication mechanisms.
## Design
### Git-gate flow
`git_gate_render_hook()` emits a `supervise_gitleaks_allow` shell helper.
For each incoming ref, git-gate first runs the existing gitleaks command. If
that scan passes, it runs:
```sh
gitleaks git \
--log-opts="$log_opts" \
--no-banner \
--redact \
--ignore-gitleaks-allow \
--report-format=json \
--report-path="$report_file" \
--exit-code 0
```
The second pass keeps the push path non-interactive while producing a report
of findings that would otherwise have been hidden by inline suppression.
### Supervisor proposal
When the JSON report contains findings, an embedded Python helper writes a
proposal into `SUPERVISE_QUEUE_DIR` using the existing proposal schema. The
proposal uses:
- `tool: "gitleaks-allow"`
- a text payload with the ref and each finding's file, line, rule, commit,
and redacted code line
- a justification that tells the operator to approve only dummy test fixtures
or confirmed false positives
Git-gate then waits for `<proposal-id>.response.json` for
`SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS`, defaulting to 300 seconds.
`approved` and `modified` responses allow the push; `rejected`, invalid
responses, invalid timeout configuration, or timeout refuse it.
### Supervisor UI
`TOOL_GITLEAKS_ALLOW` is added to the supervisor tool registry. The curses
supervisor renders the proposal as text and allows approval or rejection.
Modification is unavailable for this proposal type because there is no file
patch to apply. Approval from the TUI prompts for a non-empty reason and
writes that reason to the response/audit path.
### Tests
Unit tests assert that the rendered git-gate hook includes the second gitleaks
pass, supervisor queue fields, and fail-closed messages. Supervisor tests cover
the new tool constant, proposal archiving, and the required TUI approval
reason.
+75
View File
@@ -0,0 +1,75 @@
# PRD prd-new: Install script
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-06-06
- **Issue:** #197
## Summary
Add a proper Python package distribution and a thin `install.sh` bootstrapper so users can install bot-bottle with a single command without cloning the repo.
## 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` directly. This blocks any HN-style public demo: readers want `curl | sh` or `pipx install`, not a manual clone-and-configure flow.
## Goals / Success Criteria
- `curl -fsSL <url>/install.sh | sh` (or equivalent) leaves a working `bot-bottle` command on PATH.
- Python-native users can install with `pipx install bot-bottle` or `uv tool install bot-bottle`.
- `install.sh` validates prerequisites (Python ≥ 3.11, Docker) and exits with a clear message if they are missing. It does not silently install Docker.
- `install.sh` runs `bot-bottle doctor` (or equivalent diagnostic) after install to confirm the environment is ready.
- The package has no runtime pip dependencies (stdlib-only, matching the existing constraint).
## Non-goals
- Bundling a Python runtime or producing a standalone binary.
- Automatic Docker installation.
- Plugin architecture changes (out of scope; see issue #197 for future direction).
- Publishing to PyPI in this PR — the package structure is the deliverable; publishing is a separate step.
## Design
### Package structure
Add a minimal `pyproject.toml` at the repo root:
```toml
[project]
name = "bot-bottle"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = []
[project.scripts]
bot-bottle = "bot_bottle.cli:main"
```
The existing `bot_bottle/` package and `cli.py` entry point already contain the logic; this just wires up the standard entry point. `cli.py` may need a small refactor to expose a `main()` callable if it uses `if __name__ == "__main__"` only.
### `install.sh`
A thin bootstrapper that:
1. Checks `python3 --version` ≥ 3.11; exits with instructions if not met.
2. Checks `docker info` exits 0; exits with instructions if Docker is not running.
3. Installs via `pipx` if available, otherwise falls back to `pip install --user`.
4. Runs `bot-bottle doctor` to verify the install.
The script must be idempotent (safe to re-run) and must not require `sudo`.
### `bot-bottle doctor`
A new subcommand that checks and reports:
- Python version.
- Docker daemon reachability.
- Whether `~/.bot-bottle/` config directory exists.
Exits 0 if all checks pass, non-zero otherwise.
## Decisions
- `install.sh` is hosted from the repo's raw Gitea URL for now:
`https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh`.
- Should `version` in `pyproject.toml` be driven by a git tag at build time (e.g. via `hatch-vcs`) or kept as a static string? Static is simpler for now.
@@ -0,0 +1,360 @@
# Apple Container networking spike
Issue: https://gitea.dideric.is/didericis/bot-bottle/issues/230
## Summary
Apple Container 1.0.0 on macOS 26 can support the core two-network
sidecar shape, but not as a drop-in Docker Compose clone.
The viable shape is:
- agent container on one `--internal` host-only network;
- sidecar bundle container on both the NAT egress network and the
host-only agent network;
- sidecar network flags ordered with the NAT network first, because
Apple Container chooses the first network as the default route;
- explicit DNS on the sidecar, because the tested NAT gateway routed
packets but did not resolve DNS;
- agent talks to sidecar by the sidecar's host-only-network IP, not by
container name or host-published loopback alias.
This is enough to unblock a cautious `macos-container` launch spike if
the backend records inspect-derived IPs and avoids depending on Docker
Compose-style aliases. It is not enough to reuse the Docker backend's
service-name assumptions unchanged.
## Local Environment
Tested on 2026-06-10:
```console
$ sw_vers
ProductName: macOS
ProductVersion: 26.5.1
BuildVersion: 25F80
$ uname -m
arm64
$ container --version
container CLI version 1.0.0 (build: release, commit: ee848e3)
$ container system version --format json
[
{
"appName": "container",
"buildType": "release",
"commit": "ee848e3ebfd7c73b04dd419683be54fb450b8779",
"version": "1.0.0"
},
{
"appName": "container-apiserver",
"buildType": "release",
"commit": "ee848e3ebfd7c73b04dd419683be54fb450b8779",
"version": "container-apiserver version 1.0.0 (build: release, commit: ee848e3)"
}
]
$ container system status --format json
{
"apiServerAppName": "container-apiserver",
"apiServerBuild": "release",
"apiServerCommit": "ee848e3ebfd7c73b04dd419683be54fb450b8779",
"apiServerVersion": "container-apiserver version 1.0.0 (build: release, commit: ee848e3)",
"appRoot": "/Users/didericis/Library/Application Support/com.apple.container/",
"installRoot": "/usr/local/",
"status": "running"
}
```
Apple Container was installed from the official signed 1.0.0 GitHub
release package, `container-1.0.0-installer-signed.pkg`. The package was
signed by `Developer ID Installer: Apple Inc. - Containerization
(UPBK2H6LZM)` and notarized by Apple.
## Commands Run
Create the networks:
```bash
container network create bb-spike-230-agent \
--internal \
--label bot-bottle.spike=apple-container-networking
container network create bb-spike-230-egress \
--label bot-bottle.spike=apple-container-networking
```
`container network inspect bb-spike-230-agent bb-spike-230-egress`
showed:
```json
[
{
"configuration": {
"labels": {"bot-bottle.spike": "apple-container-networking"},
"mode": "hostOnly",
"name": "bb-spike-230-agent",
"plugin": "container-network-vmnet"
},
"id": "bb-spike-230-agent",
"status": {
"ipv4Gateway": "192.168.128.1",
"ipv4Subnet": "192.168.128.0/24"
}
},
{
"configuration": {
"labels": {"bot-bottle.spike": "apple-container-networking"},
"mode": "nat",
"name": "bb-spike-230-egress",
"plugin": "container-network-vmnet"
},
"id": "bb-spike-230-egress",
"status": {
"ipv4Gateway": "192.168.66.1",
"ipv4Subnet": "192.168.66.0/24"
}
}
]
```
Repeated `--network` flags are accepted. With the agent network first,
the sidecar got two interfaces but the default route pointed at the
host-only gateway, so egress failed:
```bash
container run --name bb-spike-230-sidecar \
--label bot-bottle.spike=apple-container-networking \
--network bb-spike-230-agent \
--network bb-spike-230-egress \
--detach --rm docker.io/python:alpine \
sh -c 'mkdir -p /srv && printf ok >/srv/index.html && cd /srv && python3 -m http.server 80 --bind 0.0.0.0'
container exec bb-spike-230-sidecar sh -c 'ip route && cat /etc/resolv.conf'
```
Observed:
```console
default via 192.168.128.1 dev eth0
192.168.66.0/24 dev eth1 scope link src 192.168.66.3
192.168.128.0/24 dev eth0 scope link src 192.168.128.3
nameserver 192.168.128.1
```
With the NAT network first and explicit DNS, the sidecar can egress:
```bash
container run --name bb-spike-230-sidecar \
--label bot-bottle.spike=apple-container-networking \
--network bb-spike-230-egress \
--network bb-spike-230-agent \
--dns 1.1.1.1 \
--detach docker.io/python:alpine \
sh -c 'mkdir -p /srv && printf ok >/srv/index.html && cd /srv && python3 -m http.server 80 --bind 0.0.0.0'
container exec bb-spike-230-sidecar sh -c 'ip route; cat /etc/resolv.conf; wget -T 8 -O- https://example.com'
```
Observed:
```console
default via 192.168.66.1 dev eth0
192.168.66.0/24 dev eth0 scope link src 192.168.66.5
192.168.128.0/24 dev eth1 scope link src 192.168.128.7
nameserver 1.1.1.1
Connecting to example.com (172.66.147.243:443)
... 100%
```
Start an agent only on the host-only network:
```bash
container run --name bb-spike-230-agent \
--label bot-bottle.spike=apple-container-networking \
--network bb-spike-230-agent \
--detach docker.io/alpine:latest sleep 600
```
Agent network probes:
```bash
container exec bb-spike-230-agent sh -c '
ip route
cat /etc/resolv.conf
wget -T 5 -O- http://192.168.128.7
wget -T 5 -O- http://bb-spike-230-sidecar || true
ping -c 2 1.1.1.1 || true
wget -T 5 -O- https://example.com || true
'
```
Observed:
```console
default via 192.168.128.1 dev eth0
192.168.128.0/24 dev eth0 scope link src 192.168.128.8
nameserver 192.168.128.1
Connecting to 192.168.128.7 (192.168.128.7:80)
ok
wget: bad address 'bb-spike-230-sidecar'
2 packets transmitted, 0 packets received, 100% packet loss
wget: bad address 'example.com'
```
Host-published loopback aliases work and are constrained to the bound
alias on the host:
```bash
container run --name bb-spike-230-sidecar-alias \
--label bot-bottle.spike=apple-container-networking \
--network bb-spike-230-egress \
--network bb-spike-230-agent \
--dns 1.1.1.1 \
--publish 127.0.0.31:18080:80 \
--detach docker.io/python:alpine \
sh -c 'mkdir -p /srv && printf ok >/srv/index.html && cd /srv && python3 -m http.server 80 --bind 0.0.0.0'
curl -fsS --max-time 5 http://127.0.0.31:18080
curl -fsS --max-time 5 http://127.0.0.1:18080
lsof -nP -iTCP:18080 -sTCP:LISTEN
```
Observed:
```console
$ curl -fsS --max-time 5 http://127.0.0.31:18080
ok
$ curl -fsS --max-time 5 http://127.0.0.1:18080
curl: (7) Failed to connect to 127.0.0.1 port 18080 after 0 ms: Couldn't connect to server
$ lsof -nP -iTCP:18080 -sTCP:LISTEN
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
container 17908 didericis 25u IPv4 ... 0t0 TCP 127.0.0.31:18080 (LISTEN)
```
The guest cannot reach that host loopback-published listener through
the host-only gateway or through its own loopback address:
```bash
container exec bb-spike-230-agent sh -c '
wget -T 5 -O- http://192.168.128.10
wget -T 5 -O- http://192.168.128.1:18080 || true
wget -T 5 -O- http://127.0.0.31:18080 || true
wget -T 5 -O- http://bb-spike-230-sidecar-alias || true
'
```
Observed:
```console
Connecting to 192.168.128.10 (192.168.128.10:80)
ok
Connecting to 192.168.128.1:18080 (192.168.128.1:18080)
wget: can't connect to remote host (192.168.128.1): Connection refused
Connecting to 127.0.0.31:18080 (127.0.0.31:18080)
wget: can't connect to remote host (127.0.0.31): Connection refused
wget: bad address 'bb-spike-230-sidecar-alias'
```
## Answers
### 1. Does `container network create --internal` prevent outbound internet access?
Yes in this run. `--internal` produced a `hostOnly` network. An
internal-only agent had a default route to the host-only gateway, but
could not ping `1.1.1.1` and could not resolve or fetch
`https://example.com`.
### 2. Can `container run` attach one container to multiple networks?
Yes. Repeated `--network` flags produced multiple interfaces and the
inspect JSON preserved both network attachments.
Important caveat: network order matters. The first network became
`eth0`, supplied the default route, and supplied `/etc/resolv.conf`.
For a sidecar that needs internet egress, put the NAT network first and
the internal agent network second.
### 3. Can the sidecar bundle sit on both an internal agent network and an egress-capable network?
Yes. The sidecar had a NAT interface and a host-only interface. With the
NAT network first and explicit DNS, it could fetch `https://example.com`
while the agent on only the host-only network could not.
### 4. Can Apple Container provide stable network aliases or service discovery equivalent to Docker Compose aliases?
Not by default in this run. The agent could not resolve
`bb-spike-230-sidecar` or `bb-spike-230-sidecar-alias`, even though
those were the container names and hostnames in inspect output. The
agent could reach the sidecar by the sidecar's host-only-network IP.
The backend should not assume Docker Compose-style aliases. It should
read the sidecar's host-only IP from `container inspect` and inject
that concrete endpoint into the agent environment/config, or run a
small internal DNS/hosts-file setup as an explicit backend feature.
### 5. Can a published sidecar port bound to a per-bottle loopback alias be reached from another Apple Container guest and constrained to that alias?
Host-side alias binding works and is constrained on the host:
`127.0.0.31:18080` reached the sidecar, while `127.0.0.1:18080` failed.
Guest-to-host-published-loopback did not work. From the agent,
`192.168.128.1:18080` and `127.0.0.31:18080` both failed. For
agent-to-sidecar traffic, use the sidecar's internal network IP rather
than a host-published loopback alias.
### 6. What structured output is available for robust enumeration and cleanup?
Confirmed structured output:
- `container list --all --format json`
- `container inspect <container...>` as JSON
- `container image inspect <image...>` as JSON
- `container network list --format json`
- `container network inspect <network...>` as JSON
- `container system status --format json`
- `container system version --format json`
Useful fields observed:
- containers: `id`, `configuration.labels`,
`configuration.networks`, `configuration.publishedPorts`,
`status.state`, `status.networks[].network`,
`status.networks[].ipv4Address`, `status.networks[].ipv4Gateway`;
- networks: `id`, `configuration.name`, `configuration.labels`,
`configuration.mode`, `status.ipv4Gateway`, `status.ipv4Subnet`;
- images: `id`, `configuration.name`, `configuration.descriptor`,
`variants[].platform`, `variants[].size`.
### 7. Are labels supported on containers and networks enough to replace prefix-only discovery?
Labels are present in container and network inspect/list JSON, so they
are sufficient as metadata if the backend lists resources and filters
client-side. I did not find or validate a server-side label filter for
`container list` or `container network list`.
## Recommendation
Proceed with a narrow `macos-container` launch prototype, but encode
the Apple Container-specific constraints directly:
- create one host-only agent network and one NAT egress network per
bottle;
- start the sidecar bundle with `--network <egress>` before
`--network <agent>`;
- set sidecar DNS explicitly, ideally from the bottle/host policy
rather than hardcoding a public resolver;
- start the agent only on the host-only network;
- discover the sidecar's host-only IP from `container inspect` and pass
concrete URLs to the agent;
- use host loopback publishing only for host-to-sidecar access, not
guest-to-sidecar access;
- enumerate and clean up by labels plus name prefixes until/unless the
CLI adds label filters.
Do not implement the backend as a direct clone of Docker Compose
service aliases. That assumption failed in this run.
@@ -0,0 +1,476 @@
# Apple Container transparent egress spike
Issue: https://gitea.dideric.is/didericis/bot-bottle/issues/230#issuecomment-1994
## Summary
Transparent egress is mechanically possible on Apple Container 1.0.0,
but it is not a free property of the platform and it is not a drop-in
replacement for `HTTP_PROXY` yet.
The spike proved two separate things:
- Plain routing/NAT works if the sidecar has `CAP_NET_ADMIN`, IP
forwarding, and masquerade rules, and if the agent default route is
changed to the sidecar's host-only-network IP.
- Transparent mitmproxy interception works if the sidecar redirects
agent-facing TCP 80/443 traffic to `mitmdump --mode transparent`.
Direct HTTP was logged by mitmproxy. Direct HTTPS reached mitmproxy;
it failed with normal certificate verification until the client
skipped verification, which is consistent with bot-bottle's existing
requirement that agents trust the sidecar CA.
- Running DNS on the sidecar and pointing the agent at the sidecar's
host-only IP also works. This is cleaner than relying on forwarded
UDP DNS to a public resolver and gives the backend a natural place to
enforce or observe DNS policy.
The hard blocker is agent routing. Apple Container 1.0.0 exposes no
documented `--network` gateway option. An ordinary agent container
cannot replace its default route:
```console
$ container exec bb-spike-230t-agent sh -c \
'ip route replace default via 192.168.128.2 dev eth0; ip route'
default via 192.168.128.1 dev eth0
192.168.128.0/24 dev eth0 scope link src 192.168.128.3
ip: RTNETLINK answers: Operation not permitted
```
The successful route-through-sidecar tests used `--cap-add
CAP_NET_ADMIN` on the agent so the route could be changed after start.
That is not an acceptable final design by itself: it expands the
agent's kernel-facing privilege and lets the agent mutate its own
network namespace. A production design needs either a backend-owned
init/shim that sets the route then drops privilege in a way the agent
cannot regain, a platform-supported gateway option, or a different
network attachment layer.
## Environment
Tested on 2026-06-10:
```console
$ sw_vers
ProductName: macOS
ProductVersion: 26.5.1
BuildVersion: 25F80
$ uname -m
arm64
$ container --version
container CLI version 1.0.0 (build: release, commit: ee848e3)
```
Apple Container system status:
```json
{
"apiServerAppName": "container-apiserver",
"apiServerBuild": "release",
"apiServerCommit": "ee848e3ebfd7c73b04dd419683be54fb450b8779",
"apiServerVersion": "container-apiserver version 1.0.0 (build: release, commit: ee848e3)",
"appRoot": "/Users/didericis/Library/Application Support/com.apple.container/",
"installRoot": "/usr/local/",
"status": "running"
}
```
## Baseline
Networks:
```bash
container network create bb-spike-230t-agent \
--internal \
--label bot-bottle.spike=transparent-egress
container network create bb-spike-230t-egress \
--label bot-bottle.spike=transparent-egress
```
Sidecar, dual-homed with NAT first:
```bash
container run --name bb-spike-230t-sidecar \
--label bot-bottle.spike=transparent-egress \
--network bb-spike-230t-egress \
--network bb-spike-230t-agent \
--dns 1.1.1.1 \
--detach docker.io/alpine:latest sleep 1800
```
Agent, host-only network:
```bash
container run --name bb-spike-230t-agent \
--label bot-bottle.spike=transparent-egress \
--network bb-spike-230t-agent \
--detach docker.io/alpine:latest sleep 1800
```
Observed sidecar addresses:
```console
eth0 192.168.66.2/24 # NAT egress network
eth1 192.168.128.2/24 # host-only agent network
default via 192.168.66.1 dev eth0
nameserver 1.1.1.1
```
Observed agent baseline:
```console
eth0 192.168.128.3/24
default via 192.168.128.1 dev eth0
nameserver 192.168.128.1
wget: bad address 'pypi.org'
```
That confirms the previous spike's baseline: sidecar can egress, agent
cannot egress directly.
## Plain NAT Test
Relaunch sidecar and agent with `CAP_NET_ADMIN`:
```bash
container run --name bb-spike-230t-sidecar \
--label bot-bottle.spike=transparent-egress \
--network bb-spike-230t-egress \
--network bb-spike-230t-agent \
--dns 1.1.1.1 \
--cap-add CAP_NET_ADMIN \
--detach docker.io/alpine:latest sleep 1800
container run --name bb-spike-230t-agent \
--label bot-bottle.spike=transparent-egress \
--network bb-spike-230t-agent \
--cap-add CAP_NET_ADMIN \
--detach docker.io/alpine:latest sleep 1800
```
Configure sidecar forwarding:
```bash
container exec bb-spike-230t-sidecar sh -c '
apk add --no-cache iptables iproute2
sysctl -w net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -s 192.168.128.0/24 -o eth0 -j MASQUERADE
iptables -A FORWARD -i eth1 -o eth0 -j ACCEPT
iptables -A FORWARD -i eth0 -o eth1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
'
```
Point the agent at the sidecar:
```bash
container exec bb-spike-230t-agent sh -c '
ip route replace default via 192.168.128.4 dev eth0
printf "nameserver 1.1.1.1\n" > /etc/resolv.conf
'
```
Normal direct PyPI fetch from the agent, with no proxy variables set:
```bash
container exec bb-spike-230t-agent sh -c '
for v in HTTP_PROXY HTTPS_PROXY http_proxy https_proxy ALL_PROXY all_proxy; do
if [ -n "$(printenv "$v")" ]; then echo "$v=SET"; fi
done
wget -T 10 -O- https://pypi.org/simple/pip/ | head -c 120
'
```
Observed:
```console
Connecting to pypi.org (151.101.0.223:443)
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="pypi:repository-version" content="1.4">
```
Sidecar NAT counters increased:
```console
POSTROUTING MASQUERADE 3 packets / 168 bytes
FORWARD eth1 -> eth0 22 packets / 2806 bytes
FORWARD eth0 -> eth1 29 packets / 54781 bytes
```
Verdict: plain transparent routing through the sidecar works, but this
is only NAT. It does not apply bot-bottle's existing route allowlist,
authorization stripping/injection, or DLP logic.
## Transparent Mitmproxy Test
The current sidecar launcher uses explicit proxy mode:
```sh
MODE="--mode regular@9099"
exec mitmdump $CONFDIR_FLAG $MODE $LISTEN_HOST_FLAG $TRUST_FLAG -s /app/egress_addon.py
```
So transparent egress needs a launcher mode change plus iptables
redirects.
Run a test mitmproxy container:
```bash
container run --name bb-spike-230t-mitm \
--label bot-bottle.spike=transparent-egress \
--network bb-spike-230t-egress \
--network bb-spike-230t-agent \
--dns 1.1.1.1 \
--cap-add CAP_NET_ADMIN \
--detach mitmproxy/mitmproxy:11.1.3 \
sh -c 'apt-get update >/tmp/apt.log &&
apt-get install -y --no-install-recommends iptables iproute2 >>/tmp/apt.log &&
echo 1 > /proc/sys/net/ipv4/ip_forward &&
iptables -t nat -A PREROUTING -i eth1 -p tcp --dport 80 -j REDIRECT --to-port 8080 &&
iptables -t nat -A PREROUTING -i eth1 -p tcp --dport 443 -j REDIRECT --to-port 8080 &&
mitmdump --mode transparent@8080 --set showhost=true --set ssl_insecure=true --set confdir=/tmp/mitm -v'
```
The container listened successfully:
```console
Transparent Proxy listening at *:8080.
```
It had an agent-facing address of `192.168.128.7`. Point the agent at
it and set DNS:
```bash
container exec bb-spike-230t-agent sh -c '
ip route replace default via 192.168.128.7 dev eth0
printf "nameserver 1.1.1.1\n" > /etc/resolv.conf
'
```
DNS also needs NAT/forwarding because only TCP 80/443 is redirected:
```bash
container exec bb-spike-230t-mitm sh -c '
iptables -t nat -A POSTROUTING -s 192.168.128.0/24 -o eth0 -j MASQUERADE
iptables -A FORWARD -i eth1 -o eth0 -j ACCEPT
iptables -A FORWARD -i eth0 -o eth1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
'
```
An alternative, and likely better, DNS shape is to run a DNS forwarder on
the sidecar's host-only IP and point the agent at it. This was tested
with `dnsmasq`:
```bash
container exec bb-spike-230t-mitm sh -c '
apt-get install -y --no-install-recommends dnsmasq
cat >/tmp/dnsmasq.conf <<EOF
no-daemon
listen-address=192.168.128.7
bind-interfaces
server=1.1.1.1
log-queries
log-facility=-
EOF
(dnsmasq --conf-file=/tmp/dnsmasq.conf >/tmp/dnsmasq.log 2>&1 &)
sleep 1
ss -lunp | grep :53
'
```
Observed:
```console
UNCONN 0 0 192.168.128.7:53 0.0.0.0:* users:(("dnsmasq",pid=515,fd=4))
```
Point the agent to sidecar DNS:
```bash
container exec bb-spike-230t-agent sh -c '
printf "nameserver 192.168.128.7\n" > /etc/resolv.conf
nslookup pypi.org
'
```
Observed:
```console
Server: 192.168.128.7
Address: 192.168.128.7:53
Non-authoritative answer:
Name: pypi.org
Address: 151.101.128.223
Name: pypi.org
Address: 151.101.192.223
Name: pypi.org
Address: 151.101.64.223
Name: pypi.org
Address: 151.101.0.223
```
Direct HTTP from the agent worked and mitmproxy logged the request:
```console
$ container exec bb-spike-230t-agent sh -c \
'wget -T 10 -O- http://example.com | head -c 100'
Connecting to example.com (172.66.147.243:80)
<!doctype html><html lang="en"><head><title>Example Domain</title>
```
Mitmproxy log:
```console
192.168.128.5:39742: GET http://example.com/
Host: example.com
User-Agent: Wget
<< 200 OK 559b
```
After switching the agent to sidecar DNS, direct HTTP still hit
mitmproxy:
```console
192.168.128.5:50784: GET http://example.com/
Host: example.com
User-Agent: Wget
<< 200 OK 559b
```
Direct HTTPS from the agent reached mitmproxy but failed certificate
verification, as expected when the client does not trust the mitmproxy
CA:
```console
$ container exec bb-spike-230t-agent sh -c \
'wget -T 10 -O- https://pypi.org/simple/pip/ | head -c 100'
Connecting to pypi.org (151.101.128.223:443)
... certificate verify failed ...
```
Mitmproxy log:
```console
Client TLS handshake failed. The client does not trust the proxy's
certificate for pypi.org (tlsv1 alert unknown ca)
```
With verification disabled, the same direct URL succeeded and mitmproxy
logged the full HTTPS request:
```console
$ container exec bb-spike-230t-agent sh -c \
'wget --no-check-certificate -T 10 -O- https://pypi.org/simple/pip/ | head -c 100'
Connecting to pypi.org (151.101.128.223:443)
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="pypi:repository-version" content="1.4">
```
Mitmproxy log:
```console
192.168.128.5:32802: GET https://pypi.org/simple/pip/
Host: pypi.org
User-Agent: Wget
<< 200 OK 103k
```
After switching the agent to sidecar DNS, direct HTTPS still hit
mitmproxy:
```console
192.168.128.5:50254: GET https://pypi.org/simple/pip/
Host: pypi.org
User-Agent: Wget
<< 200 OK 103k
```
Verdict: transparent mitmproxy mode works in this topology. The bot
agent would still need the egress CA installed, which bot-bottle already
does for explicit proxy mode.
## Answers
### Can the sidecar become the agent network's default gateway?
Not directly through Apple Container's documented CLI. The installed
`container run --help` documents `--network
<name>[,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE]`; it does not document a
gateway option.
The route can be changed after container start only if the agent has
`CAP_NET_ADMIN`. Without it, `ip route replace default via <sidecar>`
fails with `Operation not permitted`.
### Can Apple Container support sidecar forwarding/NAT/transparent proxying?
Yes. A dual-homed sidecar with `CAP_NET_ADMIN` can enable IP forwarding,
set iptables NAT/forwarding rules, and route agent traffic out through
the NAT network.
Transparent mitmproxy interception also works with `PREROUTING`
redirects to `mitmdump --mode transparent`.
### What capabilities/custom image are required?
At minimum:
- sidecar needs `CAP_NET_ADMIN`;
- sidecar image needs `iptables`/`iproute2` or equivalent nftables
tooling;
- sidecar should run a DNS listener on its host-only IP, or otherwise
provide a controlled resolver path for the agent;
- sidecar launcher needs a transparent mode variant;
- agent route must be changed to the sidecar's host-only IP;
- agent DNS should point to the sidecar DNS listener;
- agent must trust the sidecar CA for HTTPS interception.
The tested agent route mutation required agent `CAP_NET_ADMIN`, which
should not be accepted as the final design without a privilege-dropping
init/shim story.
### Can host-level `pf` or vmnet rules replace agent route mutation?
Not tested. The successful transparent paths did not use host `pf`;
they used container-local routing and iptables. Host-level `pf` remains
a possible escape hatch if Apple Container cannot set a custom gateway
and we reject agent `CAP_NET_ADMIN`.
### Can existing route policy and DLP semantics be preserved?
Likely, but not fully validated in this spike. Mitmproxy transparent
mode produced normal HTTP flows with correct `Host` values for both
HTTP and HTTPS. The existing `egress_addon.py` hooks should still see
`flow.request.pretty_host`, method, path, headers, and response bodies.
But the current sidecar entrypoint only starts `mitmdump` in regular
explicit-proxy mode. A real implementation must add a transparent mode
launcher and then run the existing egress addon test suite against
transparent flows.
## Recommendation
Do not switch `macos-container` to transparent egress yet, but keep it
as a plausible implementation path.
The next implementation spike should focus on removing the agent
`CAP_NET_ADMIN` requirement. Acceptable options:
- find or add an Apple Container-supported default-gateway setting;
- start the agent through a tiny root init that sets route/DNS, drops
capabilities, and then execs the agent as the normal user;
- include a sidecar DNS service and set the agent resolver to the
sidecar's host-only IP as part of that init/setup path;
- avoid routing mutation by using host/vmnet-level packet redirection;
- explicitly decide that route mutation is only a convenience layer and
keep explicit proxy env vars for v1.
Bluntly: transparent egress is feasible, but not production-ready until
the agent route can be controlled without leaving network-admin power in
the agent runtime.
+8 -8
View File
@@ -1,14 +1,14 @@
---
agent_provider:
template: claude
egress:
routes:
- host: api.anthropic.com
role: claude_code_oauth
auth:
scheme: Bearer
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
# auth_token names the host env var holding the Claude OAuth token. The
# provider injects a provider-owned api.anthropic.com egress route that
# re-injects this token as the Bearer header; the agent only ever sees a
# placeholder CLAUDE_CODE_OAUTH_TOKEN. DLP defaults (token_patterns,
# known_secrets outbound; naive_injection_detection inbound) apply to
# that route. To scan additional hosts, declare them under egress.routes
# with per-route matches/dlp (see README "Egress route fields").
auth_token: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
---
Common Claude provider boundary. Drop this file into
Executable
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh
set -eu
PACKAGE_SPEC="${BOT_BOTTLE_INSTALL_SPEC:-git+https://gitea.dideric.is/didericis/bot-bottle.git}"
MIN_PYTHON="3.11"
say() {
printf 'bot-bottle install: %s\n' "$*" >&2
}
die() {
say "error: $*"
exit 1
}
command -v python3 >/dev/null 2>&1 || die "python3 is required (version ${MIN_PYTHON} or newer)"
python3 - <<'PY' || die "python3 3.11 or newer is required"
import sys
raise SystemExit(0 if sys.version_info >= (3, 11) else 1)
PY
command -v docker >/dev/null 2>&1 || die "Docker is required; install Docker and start the daemon, then re-run this script"
docker info >/dev/null 2>&1 || die "Docker is installed but the daemon is not reachable; start Docker and re-run this script"
mkdir -p \
"${HOME}/.bot-bottle/agents" \
"${HOME}/.bot-bottle/bottles" \
"${HOME}/.bot-bottle/contrib"
if command -v pipx >/dev/null 2>&1; then
say "installing with pipx"
pipx install --force "${PACKAGE_SPEC}"
else
say "pipx not found; installing with python3 -m pip --user"
python3 -m pip install --user --upgrade "${PACKAGE_SPEC}"
fi
if command -v bot-bottle >/dev/null 2>&1; then
BOT_BOTTLE_BIN="bot-bottle"
elif [ -x "${HOME}/.local/bin/bot-bottle" ]; then
BOT_BOTTLE_BIN="${HOME}/.local/bin/bot-bottle"
say "using ${BOT_BOTTLE_BIN}; add ${HOME}/.local/bin to PATH for future shells"
else
die "bot-bottle was installed but is not on PATH"
fi
say "running bot-bottle doctor"
"${BOT_BOTTLE_BIN}" doctor
+27
View File
@@ -0,0 +1,27 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "bot-bottle"
version = "0.1.0"
description = "Self-hosted sandbox for AI coding agents with egress controls"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "Apache-2.0" }
dependencies = []
[project.scripts]
bot-bottle = "bot_bottle.cli:main"
[tool.setuptools.packages.find]
include = ["bot_bottle*"]
[tool.setuptools.package-data]
bot_bottle = [
"Dockerfile.sidecars",
"egress_entrypoint.sh",
"contrib/claude/Dockerfile",
"contrib/codex/Dockerfile",
"contrib/pi/Dockerfile",
]
+9 -9
View File
@@ -10,7 +10,7 @@ import tempfile
from pathlib import Path
from typing import Any, Callable
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
def fixture_minimal_dict() -> dict[str, Any]:
@@ -46,12 +46,12 @@ def fixture_with_git_dict() -> dict[str, Any]:
"repos": {
"bot-bottle": {
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
"identity": "/dev/null",
"key": {"provider": "static", "path": "/dev/null"},
"host_key": "ssh-ed25519 AAAA...",
},
"foo": {
"url": "ssh://git@github.com/didericis/foo.git",
"identity": "/dev/null",
"key": {"provider": "static", "path": "/dev/null"},
"host_key": "ssh-ed25519 BBBB...",
},
},
@@ -62,16 +62,16 @@ def fixture_with_git_dict() -> dict[str, Any]:
}
def fixture_minimal() -> Manifest:
return Manifest.from_json_obj(fixture_minimal_dict())
def fixture_minimal() -> ManifestIndex:
return ManifestIndex.from_json_obj(fixture_minimal_dict())
def fixture_with_egress() -> Manifest:
return Manifest.from_json_obj(fixture_with_egress_dict())
def fixture_with_egress() -> ManifestIndex:
return ManifestIndex.from_json_obj(fixture_with_egress_dict())
def fixture_with_git() -> Manifest:
return Manifest.from_json_obj(fixture_with_git_dict())
def fixture_with_git() -> ManifestIndex:
return ManifestIndex.from_json_obj(fixture_with_git_dict())
def write_fixture(fn: Callable[[], dict[str, Any]]) -> Path:
@@ -29,7 +29,7 @@ from bot_bottle.backend.macos_container.util import (
dns_server as _container_dns_server,
is_available as _container_available,
)
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
_AGENT_PROMPT = "You are a launch smoke-test agent. Be brief."
@@ -52,8 +52,8 @@ def _minimal_agent_dockerfile(path: Path) -> None:
)
def _minimal_manifest(dockerfile: Path) -> Manifest:
return Manifest.from_json_obj({
def _minimal_manifest(dockerfile: Path) -> ManifestIndex:
return ManifestIndex.from_json_obj({
"bottles": {
"dev": {
"agent_provider": {
+5 -6
View File
@@ -31,7 +31,7 @@ from pathlib import Path
from bot_bottle.backend import BottleSpec, get_bottle_backend
from bot_bottle.bottle_state import cleanup_state
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
from tests._docker import skip_unless_docker
@@ -92,17 +92,16 @@ class TestSandboxEscape(unittest.TestCase):
"on PATH: curl -sSL https://smolmachines.com/install.sh | sh"
)
# Throwaway "identity file" so the manifest's _validate_git_entries
# passes (it only checks `os.path.isfile`, not that the content is
# a real SSH key). Test 5 reaches gitleaks before any SSH attempt
# anyway.
# Throwaway "identity file" for the git-gate's `identity` field.
# It need not be a real SSH key: test 5 reaches gitleaks before
# any SSH attempt anyway.
fd, kp = tempfile.mkstemp(prefix="sandbox-test-key.")
os.close(fd)
cls._key_path = Path(kp)
cls._key_path.write_text("placeholder\n")
cls._key_path.chmod(0o600)
manifest = Manifest.from_json_obj({
manifest = ManifestIndex.from_json_obj({
"bottles": {
"dev": {
# Three fake secrets — different shapes — land
@@ -22,15 +22,15 @@ from pathlib import Path
from unittest.mock import patch
from bot_bottle.backend import BottleSpec, get_bottle_backend
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
from tests._docker import skip_unless_docker
def _manifest() -> Manifest:
def _manifest() -> ManifestIndex:
"""Bottle with supervise on so the bundle exercises egress +
supervise. Git is off because a meaningful git-gate test needs
a real upstream and SSH keys out of scope for a bundle smoke."""
return Manifest.from_json_obj({
return ManifestIndex.from_json_obj({
"bottles": {
"dev": {
"supervise": True,
@@ -35,15 +35,15 @@ from pathlib import Path
from bot_bottle.backend import BottleSpec, get_bottle_backend
from bot_bottle.backend.smolmachines.smolvm import is_available as _smolvm_available
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
from tests._docker import skip_unless_docker
_AGENT_PROMPT = "You are demo. Be brief."
def _minimal_manifest() -> Manifest:
return Manifest.from_json_obj({
def _minimal_manifest() -> ManifestIndex:
return ManifestIndex.from_json_obj({
"bottles": {
"dev": {
"egress": {
+1 -1
View File
@@ -74,7 +74,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
instance_name="bot-bottle-test",
prompt_file=prompt_file,
label="review-api",
color="bright-cyan",
color="cyan",
)
prompt = prompt_file.read_text()
config = Path(tmp, "codex-config.toml").read_text()
+216
View File
@@ -0,0 +1,216 @@
"""Unit: Freezer class hierarchy."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from bot_bottle import supervise, bottle_state
from bot_bottle.backend import ActiveAgent
from bot_bottle.backend.freeze import get_freezer
from bot_bottle.backend.docker.freezer import DockerFreezer
from bot_bottle.backend.macos_container.freezer import MacosContainerFreezer
from bot_bottle.backend.smolmachines.freezer import SmolmachinesFreezer
class _FakeHomeMixin:
def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="freezer-test.")
original = supervise.bot_bottle_root
def fake_root() -> Path:
return Path(self._tmp.name) / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
self._restore = lambda: setattr(supervise, "bot_bottle_root", original)
def _teardown_fake_home(self):
self._restore()
self._tmp.cleanup()
def _make_agent(slug: str, backend: str = "docker") -> ActiveAgent:
return ActiveAgent(
backend_name=backend,
slug=slug,
agent_name="dev",
started_at="t",
services=(),
)
class TestGetFreezer(unittest.TestCase):
def test_docker(self):
self.assertIsInstance(get_freezer("docker"), DockerFreezer)
def test_empty_backend_gives_docker(self):
self.assertIsInstance(get_freezer(""), DockerFreezer)
def test_macos_container(self):
self.assertIsInstance(get_freezer("macos-container"), MacosContainerFreezer)
def test_smolmachines(self):
self.assertIsInstance(get_freezer("smolmachines"), SmolmachinesFreezer)
def test_unknown_backend_dies(self):
with patch("bot_bottle.backend.freeze.die", side_effect=SystemExit("die")):
with self.assertRaises(SystemExit):
get_freezer("unknown-backend")
class TestFreezerBaseCommit(_FakeHomeMixin, unittest.TestCase):
"""The base Freezer.commit() owns the shared post-freeze steps."""
def setUp(self):
self._setup_fake_home()
def tearDown(self):
self._teardown_fake_home()
def test_writes_committed_image_and_marks_preserved(self):
slug = "dev-abc12"
bottle_state.write_metadata(bottle_state.BottleMetadata(
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
started_at="t", backend="docker",
))
freezer = get_freezer("docker")
agent = _make_agent(slug)
with patch.object(freezer, "_freeze", return_value="bot-bottle-committed-dev-abc12:latest"), \
patch("bot_bottle.backend.freeze.info"):
freezer.commit(agent)
self.assertEqual(
"bot-bottle-committed-dev-abc12:latest",
bottle_state.read_committed_image(slug),
)
self.assertTrue(bottle_state.is_preserved(slug))
def test_commit_slug_passes_correct_slug_to_freeze(self):
slug = "dev-abc12"
bottle_state.write_metadata(bottle_state.BottleMetadata(
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
started_at="t", backend="docker",
))
freezer = get_freezer("docker")
captured = {}
def capture_freeze(agent: ActiveAgent) -> str:
captured["slug"] = agent.slug
return "some-ref"
with patch.object(freezer, "_freeze", side_effect=capture_freeze), \
patch("bot_bottle.backend.freeze.info"):
freezer.commit_slug(slug)
self.assertEqual(slug, captured["slug"])
class TestDockerFreezer(_FakeHomeMixin, unittest.TestCase):
def setUp(self):
self._setup_fake_home()
def tearDown(self):
self._teardown_fake_home()
def test_commits_container_and_records_image(self):
slug = "dev-abc12"
bottle_state.write_metadata(bottle_state.BottleMetadata(
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
started_at="t", backend="docker",
))
freezer = DockerFreezer()
agent = _make_agent(slug)
with patch("bot_bottle.backend.docker.freezer.commit_container") as mock_commit, \
patch("bot_bottle.backend.freeze.info"), \
patch("bot_bottle.backend.docker.freezer.info"):
freezer.commit(agent)
mock_commit.assert_called_once_with(
f"bot-bottle-{slug}",
f"bot-bottle-committed-{slug}:latest",
)
self.assertEqual(
f"bot-bottle-committed-{slug}:latest",
bottle_state.read_committed_image(slug),
)
self.assertTrue(bottle_state.is_preserved(slug))
class TestMacosContainerFreezer(_FakeHomeMixin, unittest.TestCase):
def setUp(self):
self._setup_fake_home()
def tearDown(self):
self._teardown_fake_home()
def _write_meta(self, slug: str) -> None:
bottle_state.write_metadata(bottle_state.BottleMetadata(
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
started_at="t", backend="macos-container",
))
def test_commits_running_container_without_stopping(self):
"""Commit should exec-tar the running container, not stop it."""
slug = "dev-abc12"
self._write_meta(slug)
freezer = MacosContainerFreezer()
agent = _make_agent(slug, "macos-container")
with patch("bot_bottle.backend.macos_container.freezer.commit_container") as mock_commit, \
patch("bot_bottle.backend.freeze.info"), \
patch("bot_bottle.backend.macos_container.freezer.info"):
freezer.commit(agent)
mock_commit.assert_called_once_with(
f"bot-bottle-{slug}",
f"bot-bottle-committed-{slug}:latest",
)
self.assertEqual(
f"bot-bottle-committed-{slug}:latest",
bottle_state.read_committed_image(slug),
)
self.assertTrue(bottle_state.is_preserved(slug))
class TestSmolmachinesFreezer(_FakeHomeMixin, unittest.TestCase):
def setUp(self):
self._setup_fake_home()
def tearDown(self):
self._teardown_fake_home()
def _write_meta(self, slug: str) -> None:
bottle_state.write_metadata(bottle_state.BottleMetadata(
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
started_at="t", backend="smolmachines",
))
def test_snapshots_running_vm_without_stopping(self):
"""Commit should exec-tar the running VM, not stop it."""
slug = "dev-abc12"
self._write_meta(slug)
freezer = SmolmachinesFreezer()
agent = _make_agent(slug, "smolmachines")
with patch("bot_bottle.backend.smolmachines.freezer._snapshot_running_vm") as mock_snap, \
patch("bot_bottle.backend.freeze.info"), \
patch("bot_bottle.backend.smolmachines.freezer.info"):
freezer.commit(agent)
expected_binary = bottle_state.bottle_state_dir(slug) / "committed-smolmachine"
mock_snap.assert_called_once_with(
f"bot-bottle-{slug}",
f"bot-bottle-committed-{slug}:latest",
expected_binary,
)
expected_sidecar = str(expected_binary.with_suffix(".smolmachine"))
self.assertEqual(expected_sidecar, bottle_state.read_committed_image(slug))
self.assertTrue(bottle_state.is_preserved(slug))
if __name__ == "__main__":
unittest.main()
+35 -3
View File
@@ -16,12 +16,13 @@ from bot_bottle import bottle_state
from bot_bottle import supervise
from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker import DockerBottleBackend
from bot_bottle.backend.resolve_common import mint_slug
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
def _manifest() -> Manifest:
return Manifest.from_json_obj({
def _manifest() -> ManifestIndex:
return ManifestIndex.from_json_obj({
"bottles": {
"dev": {
"env": {
@@ -115,5 +116,36 @@ class TestSmolmachinesPrepare(_FakeStateMixin, unittest.TestCase):
)
class TestMintSlug(unittest.TestCase):
def _spec(self, *, label: str = "", identity: str = "") -> BottleSpec:
manifest = _manifest()
return BottleSpec(
manifest=manifest,
agent_name="demo",
copy_cwd=False,
user_cwd="/tmp",
label=label,
identity=identity,
)
def test_no_label_uses_agent_name_with_random_suffix(self) -> None:
slug = mint_slug(self._spec(label=""))
self.assertTrue(slug.startswith("demo-"), slug)
# random suffix present — slug is longer than just "demo"
self.assertGreater(len(slug), len("demo-"))
def test_label_becomes_exact_slug(self) -> None:
slug = mint_slug(self._spec(label="my-run"))
self.assertEqual("my-run", slug)
def test_label_with_spaces_slugified_no_suffix(self) -> None:
slug = mint_slug(self._spec(label="My Feature Run"))
self.assertEqual("my-feature-run", slug)
def test_identity_takes_precedence_over_label(self) -> None:
slug = mint_slug(self._spec(label="my-run", identity="fixed-id"))
self.assertEqual("fixed-id", slug)
if __name__ == "__main__":
unittest.main()
+8 -11
View File
@@ -11,14 +11,14 @@ class TestPalettePrintf(unittest.TestCase):
def test_known_color_returns_printf(self):
cmd = palette_printf("red")
self.assertTrue(cmd.startswith("printf '"))
self.assertIn("\\033]4;1;", cmd) # normal red
self.assertIn("\\033]4;9;", cmd) # bright red
self.assertIn("\\033]4;9;", cmd) # bright-red slot
self.assertIn("\\033]4;1;", cmd) # normal-red slot
self.assertIn("\\033]11;", cmd) # default background tint
def test_bright_variant_sets_both_slots(self):
cmd = palette_printf("bright-blue")
self.assertIn("\\033]4;12;", cmd) # bright-blue
self.assertIn("\\033]4;4;", cmd) # blue
def test_color_sets_both_palette_slots(self):
cmd = palette_printf("blue")
self.assertIn("\\033]4;12;", cmd) # bright-blue slot
self.assertIn("\\033]4;4;", cmd) # normal-blue slot
def test_unknown_color_returns_empty(self):
self.assertEqual("", palette_printf(""))
@@ -26,10 +26,7 @@ class TestPalettePrintf(unittest.TestCase):
def test_all_named_colors_produce_output(self):
colors = [
"black", "red", "green", "yellow",
"blue", "magenta", "cyan", "white",
"bright-black", "bright-red", "bright-green", "bright-yellow",
"bright-blue", "bright-magenta", "bright-cyan", "bright-white",
"red", "green", "yellow", "blue", "magenta",
]
for color in colors:
with self.subTest(color=color):
@@ -65,7 +62,7 @@ class TestExecShellScript(unittest.TestCase):
self.assertFalse(agent_part.startswith("exec "))
def test_title_and_color_both_appear(self):
script = exec_shell_script(self._ARGV, terminal_title="bot", terminal_color="cyan")
script = exec_shell_script(self._ARGV, terminal_title="bot", terminal_color="magenta")
assert script is not None
self.assertIn("bot", script)
self.assertIn("\\033]4;", script)
+3 -3
View File
@@ -17,11 +17,11 @@ from bot_bottle import supervise
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker import DockerBottleBackend
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
def _manifest() -> Manifest:
return Manifest.from_json_obj({
def _manifest() -> ManifestIndex:
return ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {
"demo": {
+51
View File
@@ -277,5 +277,56 @@ class TestBottleMetadataBackend(_FakeHomeMixin, unittest.TestCase):
self.assertEqual("", loaded.backend)
class TestCommittedImage(_FakeHomeMixin, unittest.TestCase):
"""write_committed_image / read_committed_image round-trip."""
def setUp(self):
self._setup_fake_home()
def tearDown(self):
self._teardown_fake_home()
def test_returns_none_when_absent(self):
self.assertIsNone(bottle_state.read_committed_image("dev"))
def test_write_then_read_roundtrip(self):
bottle_state.write_committed_image("dev", "bot-bottle-committed-dev:latest")
self.assertEqual(
"bot-bottle-committed-dev:latest",
bottle_state.read_committed_image("dev"),
)
def test_strips_trailing_newline_on_read(self):
path = bottle_state.committed_image_path("dev")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("bot-bottle-committed-dev:latest\n\n")
self.assertEqual(
"bot-bottle-committed-dev:latest",
bottle_state.read_committed_image("dev"),
)
def test_isolated_per_slug(self):
bottle_state.write_committed_image("dev", "bot-bottle-committed-dev:latest")
bottle_state.write_committed_image("api", "bot-bottle-committed-api:latest")
self.assertEqual(
"bot-bottle-committed-dev:latest",
bottle_state.read_committed_image("dev"),
)
self.assertEqual(
"bot-bottle-committed-api:latest",
bottle_state.read_committed_image("api"),
)
def test_path_under_state_dir(self):
path = bottle_state.committed_image_path("dev")
self.assertTrue(str(path).endswith("/.bot-bottle/state/dev/committed-image"))
def test_empty_content_returns_none(self):
path = bottle_state.committed_image_path("dev")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(" \n")
self.assertIsNone(bottle_state.read_committed_image("dev"))
if __name__ == "__main__":
unittest.main()
+143
View File
@@ -0,0 +1,143 @@
"""Unit: cli.py commit command."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from bot_bottle.cli.commit import cmd_commit
from bot_bottle import supervise
from bot_bottle import bottle_state
from bot_bottle.backend.freeze import CommitCancelled
class _FakeHomeMixin:
def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cli-commit-test.")
original = supervise.bot_bottle_root
def fake_root() -> Path:
return Path(self._tmp.name) / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
self._restore = lambda: setattr(supervise, "bot_bottle_root", original)
def _teardown_fake_home(self):
self._restore()
self._tmp.cleanup()
class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
"""cmd_commit with an explicit slug delegates to get_freezer."""
def setUp(self):
self._setup_fake_home()
def tearDown(self):
self._teardown_fake_home()
def _write_meta(self, slug: str, backend: str) -> None:
bottle_state.write_metadata(bottle_state.BottleMetadata(
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
started_at="t", backend=backend,
))
def test_commits_docker_bottle(self):
slug = "dev-abc12"
self._write_meta(slug, "docker")
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
mock_freezer = MagicMock()
mock_gf.return_value = mock_freezer
rc = cmd_commit([slug])
self.assertEqual(0, rc)
mock_gf.assert_called_once_with("docker")
mock_freezer.commit_slug.assert_called_once_with(slug)
def test_empty_backend_passed_to_get_freezer(self):
"""Old state dirs without a backend field pass '' to get_freezer."""
slug = "dev-abc12"
self._write_meta(slug, "")
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
mock_freezer = MagicMock()
mock_gf.return_value = mock_freezer
rc = cmd_commit([slug])
self.assertEqual(0, rc)
mock_gf.assert_called_once_with("")
def test_commits_macos_container_bottle(self):
slug = "dev-abc12"
self._write_meta(slug, "macos-container")
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
mock_freezer = MagicMock()
mock_gf.return_value = mock_freezer
rc = cmd_commit([slug])
self.assertEqual(0, rc)
mock_gf.assert_called_once_with("macos-container")
mock_freezer.commit_slug.assert_called_once_with(slug)
def test_commits_smolmachines_bottle(self):
slug = "dev-abc12"
self._write_meta(slug, "smolmachines")
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
mock_freezer = MagicMock()
mock_gf.return_value = mock_freezer
rc = cmd_commit([slug])
self.assertEqual(0, rc)
mock_gf.assert_called_once_with("smolmachines")
def test_returns_zero_on_commit_cancelled(self):
slug = "dev-abc12"
self._write_meta(slug, "macos-container")
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
mock_freezer = MagicMock()
mock_freezer.commit_slug.side_effect = CommitCancelled
mock_gf.return_value = mock_freezer
rc = cmd_commit([slug])
self.assertEqual(0, rc)
class TestCmdCommitNoActiveBottles(_FakeHomeMixin, unittest.TestCase):
def setUp(self):
self._setup_fake_home()
def tearDown(self):
self._teardown_fake_home()
def test_dies_when_no_active_bottles_and_no_slug(self):
with patch(
"bot_bottle.cli.commit.enumerate_active_agents", return_value=[],
), patch(
"bot_bottle.cli.commit.die", side_effect=SystemExit("die"),
) as mock_die:
with self.assertRaises(SystemExit):
cmd_commit([])
mock_die.assert_called_once()
def test_returns_zero_when_picker_cancelled(self):
active = MagicMock()
active.slug = "dev-abc12"
with patch(
"bot_bottle.cli.commit.enumerate_active_agents", return_value=[active],
), patch(
"bot_bottle.cli.commit.tui.filter_select", return_value=None,
):
rc = cmd_commit([])
self.assertEqual(0, rc)
if __name__ == "__main__":
unittest.main()
+51
View File
@@ -0,0 +1,51 @@
"""Unit: `bot-bottle doctor` host prerequisite checks."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from bot_bottle.cli import doctor
class TestDoctor(unittest.TestCase):
def test_success_when_prerequisites_present(self):
with tempfile.TemporaryDirectory() as tmp, patch.object(
doctor.Path, "home", return_value=Path(tmp),
), patch.object(
doctor.shutil, "which", return_value="/usr/bin/docker",
), patch.object(
doctor.subprocess, "run",
return_value=MagicMock(returncode=0),
):
Path(tmp, ".bot-bottle").mkdir()
self.assertEqual(0, doctor.cmd_doctor([]))
def test_missing_config_fails(self):
with tempfile.TemporaryDirectory() as tmp, patch.object(
doctor.Path, "home", return_value=Path(tmp),
), patch.object(
doctor.shutil, "which", return_value="/usr/bin/docker",
), patch.object(
doctor.subprocess, "run",
return_value=MagicMock(returncode=0),
):
self.assertEqual(1, doctor.cmd_doctor([]))
def test_missing_docker_fails_before_daemon_check(self):
with tempfile.TemporaryDirectory() as tmp, patch.object(
doctor.Path, "home", return_value=Path(tmp),
), patch.object(
doctor.shutil, "which", return_value=None,
), patch.object(
doctor.subprocess, "run",
) as run:
Path(tmp, ".bot-bottle").mkdir()
self.assertEqual(1, doctor.cmd_doctor([]))
run.assert_not_called()
if __name__ == "__main__":
unittest.main()
+61 -1
View File
@@ -14,11 +14,13 @@ from unittest.mock import MagicMock, patch
import bot_bottle.cli.start as start_mod
import bot_bottle.cli.tui as tui_mod
from bot_bottle.backend import ActiveAgent
def _make_manifest(agent_names: list[str]):
manifest = MagicMock()
manifest.agents = {name: MagicMock() for name in agent_names}
manifest.all_agent_names = sorted(agent_names)
return manifest
@@ -29,7 +31,7 @@ class TestCmdStartSelector(unittest.TestCase):
# Stub Manifest.resolve so no on-disk manifest is needed.
self._manifest = _make_manifest(["researcher", "implementer"])
self._resolve_patch = patch(
"bot_bottle.cli.start.Manifest.resolve",
"bot_bottle.cli.start.ManifestIndex.resolve",
return_value=self._manifest,
)
self._resolve_patch.start()
@@ -133,5 +135,63 @@ class TestCmdStartSelector(unittest.TestCase):
self._launch_mock.assert_not_called()
def _active_agent(slug: str) -> ActiveAgent:
return ActiveAgent(
backend_name="docker",
slug=slug,
agent_name="demo",
started_at="2026-01-01T00:00:00+00:00",
services=(),
)
class TestCmdStartLabelCollision(unittest.TestCase):
"""cmd_start re-prompts when the label's slug is already running."""
def setUp(self):
self._manifest = _make_manifest(["researcher"])
patch("bot_bottle.cli.start.ManifestIndex.resolve", return_value=self._manifest).start()
self._launch_mock = patch(
"bot_bottle.cli.start._launch_bottle", return_value=0,
).start()
self.addCleanup(patch.stopall)
def test_no_collision_proceeds_without_reprompt(self):
with (
patch.object(tui_mod, "name_color_modal", return_value=("researcher", "")) as modal,
patch("bot_bottle.cli.start.enumerate_active_agents", return_value=[]),
):
rc = start_mod.cmd_start(["researcher"])
self.assertEqual(0, rc)
modal.assert_called_once()
self._launch_mock.assert_called_once()
def test_collision_reprompts_with_disclaimer(self):
collision_agent = _active_agent("researcher")
call_count = 0
def _modal(default_label: str, *, disclaimer: str = "", **_kw: object) -> tuple[str, str]:
nonlocal call_count
call_count += 1
if call_count == 1:
return "researcher", ""
return "researcher-2", ""
with (
patch.object(tui_mod, "name_color_modal", side_effect=_modal) as modal,
patch(
"bot_bottle.cli.start.enumerate_active_agents",
side_effect=[[collision_agent], []],
),
):
rc = start_mod.cmd_start(["researcher"])
self.assertEqual(0, rc)
self.assertEqual(2, modal.call_count)
second_call_kwargs = modal.call_args_list[1][1]
self.assertIn("researcher", second_call_kwargs.get("disclaimer", ""))
self.assertIn("already in use", second_call_kwargs.get("disclaimer", ""))
if __name__ == "__main__":
unittest.main()
+26 -16
View File
@@ -31,7 +31,7 @@ from bot_bottle.egress import (
EgressRoute,
)
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
from bot_bottle.supervise import SupervisePlan
@@ -40,7 +40,7 @@ STAGE = Path("/tmp/cb-stage")
STATE = Path("/tmp/cb-state")
def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest:
def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> ManifestIndex:
"""Minimal manifest with the toggles the chunk-1 matrix needs.
The renderer only reads from the plan, not the manifest, so this
is just here to back BottleSpec."""
@@ -51,7 +51,7 @@ def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest
bottle["git-gate"] = {"repos": {
"upstream": {
"url": "ssh://git@example.com:22/x/y.git",
"identity": "/etc/hostname", # any existing file
"key": {"provider": "static", "path": "/etc/hostname"},
},
}}
if with_egress:
@@ -61,22 +61,12 @@ def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest
"auth": {"scheme": "Bearer", "token_ref": "TOK"},
}],
}
return Manifest.from_json_obj({
return ManifestIndex.from_json_obj({
"bottles": {"dev": bottle},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
def _spec(*, supervise: bool, with_git: bool, with_egress: bool) -> BottleSpec:
return BottleSpec(
manifest=_manifest(
supervise=supervise, with_git=with_git, with_egress=with_egress,
),
agent_name="demo",
copy_cwd=False,
user_cwd="/tmp/x",
)
def _git_gate_plan(upstreams: tuple[GitGateUpstream, ...] = ()) -> GitGatePlan:
return GitGatePlan(
@@ -146,9 +136,16 @@ def _plan(
roles=(),
),)
spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress)
index = _manifest(supervise=supervise, with_git=with_git, with_egress=with_egress)
spec = BottleSpec(
manifest=index,
agent_name="demo",
copy_cwd=False,
user_cwd="/tmp/x",
)
return DockerBottlePlan(
spec=spec,
manifest=index.load_for_agent("demo"),
stage_dir=STAGE,
slug=SLUG,
forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
@@ -304,6 +301,19 @@ class TestSidecarBundleShape(unittest.TestCase):
self.assertEqual("bot-bottle-sidecars:latest", sc["image"])
self.assertEqual("Dockerfile.sidecars", sc["build"]["dockerfile"])
def test_bundle_uses_packaged_dockerfile_when_root_missing(self):
from bot_bottle.backend.docker import compose as compose_mod
original = compose_mod._REPO_DIR
try:
compose_mod._REPO_DIR = "/tmp/does-not-exist"
self.assertEqual(
"bot_bottle/Dockerfile.sidecars",
compose_mod._sidecar_bundle_dockerfile(),
)
finally:
compose_mod._REPO_DIR = original
def test_bundle_container_name_uses_sidecars_prefix(self):
sc = self._render()["services"]["sidecars"]
self.assertEqual(f"bot-bottle-sidecars-{SLUG}", sc["container_name"])
@@ -395,7 +405,7 @@ class TestSidecarBundleShape(unittest.TestCase):
"services"]["sidecars"]
targets = {v["target"] for v in sc["volumes"]}
self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets)
self.assertIn("/etc/egress/routes.yaml", targets)
self.assertIn("/etc/egress", targets)
self.assertIn("/git-gate-entrypoint.sh", targets)
self.assertIn("/git-gate/creds/upstream-known_hosts", targets)
self.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise")
+8 -6
View File
@@ -24,7 +24,7 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.contrib.claude.agent_provider import ClaudeAgentProvider
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
from bot_bottle.supervise import SupervisePlan
@@ -55,7 +55,7 @@ def _plan(
bottle_json: dict = {"agent_provider": {"template": "claude"}} # type: ignore
if supervise:
bottle_json["supervise"] = True
manifest = Manifest.from_json_obj({
index = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {
"demo": {
@@ -65,8 +65,9 @@ def _plan(
},
},
})
manifest = index.load_for_agent("demo")
spec = BottleSpec(
manifest=manifest, agent_name="demo",
manifest=index, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x",
)
supervise_plan = None
@@ -78,6 +79,7 @@ def _plan(
)
return DockerBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=Path("/tmp/stage"),
slug="demo-abc12",
forwarded_env={},
@@ -276,7 +278,7 @@ class TestClaudeUiProvision(unittest.TestCase):
instance_name="bot-bottle-demo-abc12",
prompt_file=prompt_file,
label="research-ui",
color="bright-cyan",
color="blue",
)
settings = json.loads((state_dir / "claude-settings.json").read_text())
statusline = (state_dir / "claude-statusline.sh").read_text()
@@ -288,9 +290,9 @@ class TestClaudeUiProvision(unittest.TestCase):
self.assertEqual("~/.claude/statusline.sh", settings["statusLine"]["command"])
self.assertEqual("custom:bot-bottle-research-ui", settings["theme"])
self.assertIn("research-ui", statusline)
self.assertIn("\x1b[96m", statusline)
self.assertIn("\x1b[94m", statusline)
self.assertEqual("dark", theme["base"])
self.assertEqual("ansi:cyanBright", theme["overrides"]["claude"])
self.assertEqual("ansi:blueBright", theme["overrides"]["claude"])
def test_runs_verify_commands(self):
provision = AgentProvisionPlan(
+10 -8
View File
@@ -24,7 +24,7 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.contrib.codex.agent_provider import CodexAgentProvider
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
from bot_bottle.supervise import SupervisePlan
@@ -55,7 +55,7 @@ def _plan(
bottle_json: dict = {"agent_provider": {"template": "codex"}} # type: ignore
if supervise:
bottle_json["supervise"] = True
manifest = Manifest.from_json_obj({
index = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {
"demo": {
@@ -65,8 +65,9 @@ def _plan(
},
},
})
manifest = index.load_for_agent("demo")
spec = BottleSpec(
manifest=manifest, agent_name="demo",
manifest=index, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x",
)
supervise_plan = None
@@ -78,6 +79,7 @@ def _plan(
)
return DockerBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=Path("/tmp/stage"),
slug="demo-abc12",
forwarded_env={},
@@ -158,7 +160,7 @@ class TestCodexProvisionPrompt(unittest.TestCase):
instance_name="bot-bottle-demo-abc12",
prompt_file=prompt_file,
label="research-ui",
color="bright-cyan",
color="cyan",
)
config = (state_dir / "codex-config.toml").read_text()
prompt_text = prompt_file.read_text()
@@ -290,10 +292,10 @@ class TestCodexSuperviseMcp(unittest.TestCase):
bottle.exec.assert_called_once()
script = bottle.exec.call_args.args[0]
self.assertEqual("node", bottle.exec.call_args.kwargs.get("user"))
self.assertIn("codex mcp add", script)
self.assertIn("--transport http", script)
self.assertIn("supervise", script)
self.assertIn(_URL, script)
self.assertEqual(
f"codex mcp add supervise --url {_URL}",
script,
)
def test_logs_warning_on_failure_but_does_not_raise(self):
bottle = _make_bottle(
+5 -3
View File
@@ -16,7 +16,7 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.contrib.pi.agent_provider import PiAgentProvider
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
_URL = "http://supervise:9100/"
@@ -43,7 +43,7 @@ def _plan(
skills: list[str] | None = None,
agent_provision: AgentProvisionPlan | None = None,
) -> DockerBottlePlan:
manifest = Manifest.from_json_obj({
index = ManifestIndex.from_json_obj({
"bottles": {"dev": {"agent_provider": {"template": "pi"}}},
"agents": {
"demo": {
@@ -53,12 +53,14 @@ def _plan(
},
},
})
manifest = index.load_for_agent("demo")
spec = BottleSpec(
manifest=manifest, agent_name="demo",
manifest=index, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x",
)
return DockerBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=Path("/tmp/stage"),
slug="demo-abc12",
forwarded_env={},
@@ -0,0 +1,192 @@
"""Unit: Docker launch step uses committed image when available."""
from __future__ import annotations
import contextlib
import io
import tempfile
import unittest
from pathlib import Path
from typing import Any
from unittest import mock
from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker import launch as launch_mod
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import ManifestIndex
_SLUG = "dev-abc12"
_COMMITTED_TAG = f"bot-bottle-committed-{_SLUG}:latest"
_DEFAULT_IMAGE = "bot-bottle-claude:latest"
_IDX = ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
def _plan(tmp: str) -> DockerBottlePlan:
stage = Path(tmp)
spec = BottleSpec(
manifest=_IDX,
agent_name="demo",
copy_cwd=False,
user_cwd=tmp,
identity=_SLUG,
)
return DockerBottlePlan(
spec=spec,
manifest=_IDX.load_for_agent("demo"),
stage_dir=stage,
git_gate_plan=GitGatePlan(
slug=_SLUG,
entrypoint_script=stage / "entrypoint.sh",
hook_script=stage / "hook.sh",
access_hook_script=stage / "access-hook.sh",
upstreams=(),
),
egress_plan=EgressPlan(
slug=_SLUG,
routes_path=stage / "egress.yaml",
routes=(),
token_env_map={},
),
supervise_plan=None,
agent_provision=AgentProvisionPlan(
template="claude",
command="claude",
prompt_mode="append_file",
image=_DEFAULT_IMAGE,
dockerfile="",
guest_home="/home/node",
instance_name=f"bot-bottle-{_SLUG}",
prompt_file=stage / "prompt.txt",
guest_env={},
),
slug=_SLUG,
forwarded_env={},
use_runsc=False,
)
class TestLaunchCommittedImage(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.mkdtemp(prefix="launch-committed-test.")
def tearDown(self) -> None:
import shutil
shutil.rmtree(self._tmp, ignore_errors=True)
def _run_launch(
self,
plan: DockerBottlePlan,
*,
committed_tag: str | None = None,
image_present: bool = True,
) -> list[str]:
"""Drive launch() through its full sequence with the committed-image
behaviour controlled by the arguments. Returns the images that were
passed to `build_image` (empty list if it was never called)."""
built: list[str] = []
def fake_build(image: str, ctx: str, *, dockerfile: str = "") -> None:
del ctx, dockerfile
built.append(image)
with mock.patch.object(
launch_mod, "read_committed_image", return_value=committed_tag,
), mock.patch.object(
launch_mod.docker_mod, "image_exists", return_value=image_present,
), mock.patch.object(
launch_mod.docker_mod, "build_image", side_effect=fake_build,
), mock.patch.object(
launch_mod, "egress_tls_init",
return_value=(Path("/egress_ca"), Path("/egress_cert")),
), mock.patch.object(
launch_mod.network_mod, "network_name_for_slug",
return_value="bb-internal",
), mock.patch.object(
launch_mod.network_mod, "network_egress_name_for_slug",
return_value="bb-egress",
), mock.patch.object(
launch_mod, "bottle_plan_to_compose",
return_value={"services": {"agent": {}}},
), mock.patch.object(
launch_mod, "write_compose_file",
return_value=Path("/tmp/compose.yml"),
), mock.patch.object(launch_mod, "compose_up"), \
mock.patch.object(launch_mod, "compose_dump_logs"), \
mock.patch.object(launch_mod, "compose_down"), \
contextlib.redirect_stderr(io.StringIO()):
provision = mock.Mock(return_value=None)
with launch_mod.launch(plan, provision=provision):
pass
return built
def test_skips_build_when_committed_image_present(self) -> None:
plan = _plan(self._tmp)
built = self._run_launch(plan, committed_tag=_COMMITTED_TAG, image_present=True)
self.assertEqual([], built, "build_image should not be called when committed image exists")
def test_uses_committed_image_in_compose_spec(self) -> None:
"""The compose spec renderer receives the committed image tag via
plan.image captured here by checking what bottle_plan_to_compose
was called with."""
plan = _plan(self._tmp)
captured_plans: list[DockerBottlePlan] = []
def fake_compose(p: DockerBottlePlan) -> dict[str, Any]:
captured_plans.append(p)
return {"services": {"agent": {}}}
with mock.patch.object(
launch_mod, "read_committed_image", return_value=_COMMITTED_TAG,
), mock.patch.object(
launch_mod.docker_mod, "image_exists", return_value=True,
), mock.patch.object(
launch_mod.docker_mod, "build_image",
), mock.patch.object(
launch_mod, "egress_tls_init",
return_value=(Path("/egress_ca"), Path("/egress_cert")),
), mock.patch.object(
launch_mod.network_mod, "network_name_for_slug",
return_value="bb-internal",
), mock.patch.object(
launch_mod.network_mod, "network_egress_name_for_slug",
return_value="bb-egress",
), mock.patch.object(
launch_mod, "bottle_plan_to_compose", side_effect=fake_compose,
), mock.patch.object(
launch_mod, "write_compose_file",
return_value=Path("/tmp/compose.yml"),
), mock.patch.object(launch_mod, "compose_up"), \
mock.patch.object(launch_mod, "compose_dump_logs"), \
mock.patch.object(launch_mod, "compose_down"), \
contextlib.redirect_stderr(io.StringIO()):
provision = mock.Mock(return_value=None)
with launch_mod.launch(plan, provision=provision):
pass
self.assertEqual(1, len(captured_plans))
self.assertEqual(_COMMITTED_TAG, captured_plans[0].image)
def test_falls_back_to_build_when_no_committed_image(self) -> None:
plan = _plan(self._tmp)
built = self._run_launch(plan, committed_tag=None)
self.assertEqual([_DEFAULT_IMAGE], built)
def test_falls_back_to_build_when_committed_image_missing_from_daemon(self) -> None:
plan = _plan(self._tmp)
built = self._run_launch(
plan, committed_tag=_COMMITTED_TAG, image_present=False,
)
self.assertEqual([_DEFAULT_IMAGE], built)
if __name__ == "__main__":
unittest.main()
+8 -9
View File
@@ -21,21 +21,19 @@ from bot_bottle.backend.docker import launch as launch_mod
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
def _manifest() -> Manifest:
return Manifest.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
_INDEX = ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
def _plan(tmp: str) -> DockerBottlePlan:
stage = Path(tmp)
manifest = _manifest()
manifest = _INDEX.load_for_agent("demo")
spec = BottleSpec(
manifest=manifest,
manifest=_INDEX,
agent_name="demo",
copy_cwd=False,
user_cwd=tmp,
@@ -43,6 +41,7 @@ def _plan(tmp: str) -> DockerBottlePlan:
)
return DockerBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=stage,
git_gate_plan=GitGatePlan(
slug="test-teardown-00001",
+5 -3
View File
@@ -21,7 +21,7 @@ from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
class _Provider(AgentProvider):
@@ -51,16 +51,18 @@ def _plan(*, git_user: dict | None = None, # type: ignore
bottle_json: dict = {} # type: ignore
if git_user is not None:
bottle_json["git-gate"] = {"user": git_user}
manifest = Manifest.from_json_obj({
index = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
manifest = index.load_for_agent("demo")
spec = BottleSpec(
manifest=manifest, agent_name="demo",
manifest=index, agent_name="demo",
copy_cwd=copy_cwd, user_cwd=user_cwd,
)
return DockerBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=stage_dir or Path("/tmp/stage"),
slug="demo-abc12",
forwarded_env={},
+41
View File
@@ -67,5 +67,46 @@ class TestSave(unittest.TestCase):
)
class TestCommitContainer(unittest.TestCase):
def test_runs_docker_commit(self):
with patch.object(
docker_mod.subprocess, "run", return_value=_ok(),
) as run, patch.object(docker_mod, "info"):
docker_mod.commit_container(
"bot-bottle-dev-abc12",
"bot-bottle-committed-dev-abc12:latest",
)
argv = run.call_args.args[0]
self.assertEqual(
[
"docker", "commit",
"bot-bottle-dev-abc12",
"bot-bottle-committed-dev-abc12:latest",
],
argv,
)
def test_dies_on_docker_commit_failure(self):
with patch.object(
docker_mod.subprocess, "run", return_value=_fail("No such container"),
), patch.object(
docker_mod, "die", side_effect=SystemExit("die"),
) as die:
with self.assertRaises(SystemExit):
docker_mod.commit_container("missing-container", "some:tag")
die.assert_called_once()
self.assertIn("missing-container", die.call_args.args[0])
def test_die_message_includes_image_tag(self):
with patch.object(
docker_mod.subprocess, "run", return_value=_fail("boom"),
), patch.object(
docker_mod, "die", side_effect=SystemExit("die"),
) as die:
with self.assertRaises(SystemExit):
docker_mod.commit_container("ctr", "my-tag:v1")
self.assertIn("my-tag:v1", die.call_args.args[0])
if __name__ == "__main__":
unittest.main()
+4 -4
View File
@@ -13,12 +13,12 @@ from bot_bottle.egress import (
egress_token_env_map,
)
from bot_bottle.log import Die
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
from bot_bottle.yaml_subset import parse_yaml_subset
def _bottle(routes): # type: ignore
return Manifest.from_json_obj({
return ManifestIndex.from_json_obj({
"bottles": {"dev": {"egress": {"routes": routes}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
@@ -362,9 +362,9 @@ class TestRenderRoutes(unittest.TestCase):
self.assertEqual("x.example", cfg.routes[0].host)
def test_log_via_manifest_flows_to_render(self):
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
from bot_bottle.egress_addon_core import load_config, LOG_BLOCKS
m = Manifest.from_json_obj({
m = ManifestIndex.from_json_obj({
"bottles": {"dev": {"egress": {
"log": 1,
"routes": [{"host": "x.example"}],
+54 -11
View File
@@ -2,12 +2,15 @@
add_route removed; docker exec / cp / kill paths are covered by the
integration test)."""
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from bot_bottle.backend.docker.egress_apply import (
EgressApplyError,
validate_routes_content,
)
from bot_bottle import supervise
from bot_bottle.backend.egress_apply import EgressApplyError
from bot_bottle.backend.docker.egress_apply import applicator
_ROUTES_EMPTY = "routes: []\n"
@@ -16,11 +19,11 @@ _ROUTES_ONE = 'routes:\n - host: "api.anthropic.com"\n'
class TestValidateRoutesContent(unittest.TestCase):
def test_accepts_minimal_route_table(self):
validate_routes_content(_ROUTES_EMPTY)
validate_routes_content(_ROUTES_ONE)
applicator.validate_routes_content(_ROUTES_EMPTY)
applicator.validate_routes_content(_ROUTES_ONE)
def test_accepts_full_route_with_matches(self):
validate_routes_content(
applicator.validate_routes_content(
'routes:\n'
' - host: "api.github.com"\n'
' auth_scheme: "Bearer"\n'
@@ -32,25 +35,65 @@ class TestValidateRoutesContent(unittest.TestCase):
def test_rejects_bad_yaml(self):
with self.assertRaises(EgressApplyError) as cm:
validate_routes_content("routes:\n\t- host: x\n")
applicator.validate_routes_content("routes:\n\t- host: x\n")
self.assertIn("not valid", str(cm.exception))
def test_rejects_missing_routes_key(self):
with self.assertRaises(EgressApplyError):
validate_routes_content("other: []\n")
applicator.validate_routes_content("other: []\n")
def test_rejects_non_list_routes(self):
with self.assertRaises(EgressApplyError):
validate_routes_content('routes: "not a list"\n')
applicator.validate_routes_content('routes: "not a list"\n')
def test_rejects_partial_auth_pair(self):
with self.assertRaises(EgressApplyError):
validate_routes_content(
applicator.validate_routes_content(
'routes:\n'
' - host: "x.example"\n'
' auth_scheme: "Bearer"\n'
)
class TestApplyRoutesChange(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="egress-apply-test.")
original = supervise.bot_bottle_root
def fake_root() -> Path:
return Path(self._tmp.name) / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
self.addCleanup(lambda: setattr(supervise, "bot_bottle_root", original))
self.addCleanup(self._tmp.cleanup)
def test_writes_live_routes_and_signals_reload(self):
calls: list[list[str]] = []
def fake_run(argv: list[str], **kwargs: object) -> SimpleNamespace:
calls.append(list(argv))
return SimpleNamespace(returncode=0, stdout="", stderr="")
with patch(
"bot_bottle.backend.docker.egress_apply.subprocess.run",
side_effect=fake_run,
):
before, after = applicator.apply_routes_change(
"dev",
"routes:\n - host: google.com\n",
)
self.assertEqual("", before)
self.assertEqual("routes:\n - host: google.com\n", after)
self.assertEqual(
"routes:\n - host: google.com\n",
(Path(self._tmp.name) / ".bot-bottle/state/dev/egress/routes.yaml").read_text(encoding="utf-8"),
)
self.assertEqual(
["docker", "kill", "--signal", "HUP", "bot-bottle-sidecars-dev"],
calls[0],
)
if __name__ == "__main__":
unittest.main()
+34 -3
View File
@@ -15,7 +15,7 @@ from bot_bottle.git_gate import (
git_gate_render_hook,
git_gate_upstreams_for_bottle,
)
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
from tests.fixtures import fixture_minimal, fixture_with_git
@@ -181,6 +181,13 @@ class TestHookRender(unittest.TestCase):
self.assertIn("BatchMode=yes", hook)
self.assertIn("ConnectTimeout=", hook)
def test_force_push_uses_plus_refspec(self):
# A non-fast-forward push (old != zero, new not a descendant of old)
# must forward +$new:$ref so the upstream accepts the force push.
hook = git_gate_render_hook()
self.assertIn('git merge-base --is-ancestor "$old" "$new"', hook)
self.assertIn('refspec="+$new:$ref"', hook)
def test_forward_preserves_push_options(self):
# Git exposes push options to pre-receive hooks as
# GIT_PUSH_OPTION_COUNT + indexed GIT_PUSH_OPTION_N variables.
@@ -192,6 +199,30 @@ class TestHookRender(unittest.TestCase):
self.assertIn('set -- "$@" --push-option="$opt"', hook)
self.assertIn('git push "$@" origin "$refspec"', hook)
def test_inline_gitleaks_allow_routes_to_supervisor(self):
hook = git_gate_render_hook()
# First gitleaks runs normally; only if that passes does the
# hook ask gitleaks to ignore inline allow comments and report
# the suppressed findings for human approval.
self.assertIn("--ignore-gitleaks-allow", hook)
self.assertIn("--report-format=json", hook)
self.assertIn('"tool": "gitleaks-allow"', hook)
self.assertIn("SUPERVISE_QUEUE_DIR", hook)
self.assertIn("SUPERVISE_BOTTLE_SLUG", hook)
self.assertIn("supervisor approved # gitleaks:allow", hook)
self.assertIn("supervisor rejected # gitleaks:allow", hook)
def test_inline_gitleaks_allow_fails_closed_without_supervisor(self):
hook = git_gate_render_hook()
self.assertIn(
"cannot route # gitleaks:allow finding to supervisor; refusing push",
hook,
)
self.assertIn(
"supervisor approval timed out for # gitleaks:allow; refusing push",
hook,
)
class TestAccessHookRender(unittest.TestCase):
def test_access_hook_refreshes_origin_on_upload_pack(self):
@@ -273,11 +304,11 @@ class TestPrepare(unittest.TestCase):
self.assertEqual(0o600, os.stat(upstream.known_hosts_file).st_mode & 0o777)
def test_prepare_skips_known_hosts_file_when_key_missing(self):
manifest = Manifest.from_json_obj({
manifest = ManifestIndex.from_json_obj({
"bottles": {"dev": {"git-gate": {"repos": {
"foo": {
"url": "ssh://git@github.com/didericis/foo.git",
"identity": "/dev/null",
"key": {"provider": "static", "path": "/dev/null"},
},
}}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
+34
View File
@@ -0,0 +1,34 @@
"""Unit: install.sh static contract checks."""
from __future__ import annotations
import subprocess
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
class TestInstallScript(unittest.TestCase):
def test_shell_syntax(self):
result = subprocess.run(
["sh", "-n", str(ROOT / "install.sh")],
check=False,
capture_output=True,
text=True,
)
self.assertEqual("", result.stderr)
self.assertEqual(0, result.returncode)
def test_contract_phrases(self):
script = (ROOT / "install.sh").read_text(encoding="utf-8")
self.assertIn("python3", script)
self.assertIn("docker info", script)
self.assertIn("pipx install --force", script)
self.assertIn("pip install --user --upgrade", script)
self.assertIn('"${BOT_BOTTLE_BIN}" doctor', script)
if __name__ == "__main__":
unittest.main()
+49 -4
View File
@@ -2,26 +2,32 @@
from __future__ import annotations
import sys
import unittest
from unittest.mock import patch
from bot_bottle.backend.macos_container.bottle import MacosContainerBottle
from bot_bottle.backend.macos_container import bottle as bottle_mod
from bot_bottle.backend.macos_container.bottle import MacosContainerBottle, _PTY_FORWARD_SCRIPT
class TestMacosContainerBottle(unittest.TestCase):
def test_agent_argv_uses_container_exec(self):
def test_agent_argv_uses_pty_forward_and_container_exec(self):
bottle = MacosContainerBottle(
"bot-bottle-dev-abc",
lambda: None,
None,
agent_command="codex",
)
with patch.dict(bottle_mod.os.environ, {}, clear=True):
argv = bottle.agent_argv(["run"])
self.assertEqual(
[
sys.executable, _PTY_FORWARD_SCRIPT, "--",
"container", "exec", "--interactive", "--tty",
"--env", "TERM",
"bot-bottle-dev-abc", "codex", "run",
],
bottle.agent_argv(["run"]),
argv,
)
def test_agent_argv_includes_workdir(self):
@@ -31,15 +37,54 @@ class TestMacosContainerBottle(unittest.TestCase):
None,
agent_workdir="/home/node/workspace",
)
with patch.dict(bottle_mod.os.environ, {}, clear=True):
argv = bottle.agent_argv([])
self.assertEqual(
[
sys.executable, _PTY_FORWARD_SCRIPT, "--",
"container", "exec", "--interactive", "--tty",
"--env", "TERM",
"--workdir", "/home/node/workspace",
"bot-bottle-dev-abc", "claude",
],
bottle.agent_argv([]),
argv,
)
def test_agent_argv_forwards_terminal_env_names_without_values(self):
bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None)
with patch.dict(
bottle_mod.os.environ,
{
"TERM": "screen-256color",
"TERM_PROGRAM": "WezTerm",
"WEZTERM_PANE": "pane-id",
"SHELL": "/bin/zsh",
},
clear=True,
):
argv = bottle.agent_argv([])
self.assertIn("TERM", argv)
self.assertIn("TERM_PROGRAM", argv)
self.assertIn("WEZTERM_PANE", argv)
self.assertNotIn("SHELL", argv)
self.assertNotIn("TERM=screen-256color", argv)
self.assertNotIn("TERM_PROGRAM=WezTerm", argv)
self.assertNotIn("WEZTERM_PANE=pane-id", argv)
def test_agent_argv_always_forwards_term_name(self):
bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None)
with patch.dict(bottle_mod.os.environ, {}, clear=True):
argv = bottle.agent_argv([])
self.assertIn("TERM", argv)
def test_agent_argv_no_tty_omits_wrapper_and_tty_flags(self):
bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None)
argv = bottle.agent_argv([], tty=False)
self.assertNotIn("--tty", argv)
self.assertNotIn("--env", argv)
self.assertNotIn(_PTY_FORWARD_SCRIPT, argv)
self.assertEqual(["container", "exec", "bot-bottle-dev-abc", "claude"], argv)
def test_exec_pipes_script_to_shell(self):
bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None)
with patch("bot_bottle.backend.macos_container.bottle.subprocess.run") as run:
+89 -7
View File
@@ -9,8 +9,18 @@ from types import SimpleNamespace
from typing import cast
from unittest.mock import patch
from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.backend import BottleSpec
from bot_bottle.backend.macos_container import launch
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import ManifestIndex
_MANIFEST = ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).load_for_agent("demo")
def _plan(
@@ -21,7 +31,7 @@ def _plan(
agent_git_gate_url: str = "",
agent_supervise_url: str = "",
) -> MacosContainerBottlePlan:
routes_path = stage_dir / "source-routes.yaml"
routes_path = stage_dir / "routes.yaml"
routes_path.write_text("routes: []\n", encoding="utf-8")
ca_dir = stage_dir / "egress-ca"
ca_dir.mkdir(exist_ok=True)
@@ -67,6 +77,7 @@ def _plan(
)
return cast(MacosContainerBottlePlan, SimpleNamespace(
spec=SimpleNamespace(),
manifest=_MANIFEST,
stage_dir=stage_dir,
slug="dev-abc",
container_name="bot-bottle-dev-abc",
@@ -118,15 +129,10 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
f"type=bind,source={self.stage_dir / 'egress-ca'},target=/home/mitmproxy/.mitmproxy",
argv,
)
routes_dir = self.stage_dir / "macos-container-egress"
self.assertIn(
f"type=bind,source={routes_dir},target=/etc/egress,readonly",
f"type=bind,source={self.stage_dir},target=/etc/egress,readonly",
argv,
)
self.assertEqual(
"routes: []\n",
(routes_dir / "routes.yaml").read_text(encoding="utf-8"),
)
self.assertIn(
"type=bind,source=/state/supervise/queue,target=/run/supervise/queue",
argv,
@@ -193,6 +199,7 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
)
plan = MacosContainerBottlePlan(
spec=base.spec,
manifest=base.manifest,
stage_dir=base.stage_dir,
git_gate_plan=base.git_gate_plan,
egress_plan=base.egress_plan,
@@ -258,5 +265,80 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
)
def _build_plan(stage_dir: Path) -> MacosContainerBottlePlan:
return MacosContainerBottlePlan(
spec=cast(BottleSpec, SimpleNamespace()),
manifest=_MANIFEST,
stage_dir=stage_dir,
git_gate_plan=cast(GitGatePlan, SimpleNamespace(upstreams=())),
egress_plan=cast(EgressPlan, SimpleNamespace()),
supervise_plan=None,
agent_provision=AgentProvisionPlan(
template="claude",
command="claude",
prompt_mode="append_file",
image="bot-bottle-agent:latest",
dockerfile="/repo/Dockerfile",
guest_home="/home/node",
instance_name="bot-bottle-dev-abc",
prompt_file=stage_dir / "prompt.txt",
guest_env={},
),
slug="dev-abc",
forwarded_env={},
)
class TestMacosContainerLaunchCommittedImage(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.stage_dir = Path(self._tmp.name)
def tearDown(self):
self._tmp.cleanup()
def test_build_images_uses_committed_image_when_present(self):
plan = _build_plan(self.stage_dir)
calls = []
def fake_build(image: str, context: str, *, dockerfile: str = "") -> None:
calls.append((image, context, dockerfile))
with patch.object(
launch, "read_committed_image",
return_value="bot-bottle-committed-dev-abc:latest",
), patch.object(
launch.container_mod, "image_exists", return_value=True,
), patch.object(
launch.container_mod, "build_image", side_effect=fake_build,
), patch.object(launch, "info"):
updated = launch._build_images(plan)
self.assertEqual("bot-bottle-committed-dev-abc:latest", updated.image)
self.assertEqual(1, len(calls))
self.assertEqual(launch.SIDECAR_BUNDLE_IMAGE, calls[0][0])
def test_build_images_builds_agent_when_committed_image_missing(self):
plan = _build_plan(self.stage_dir)
calls = []
def fake_build(image: str, context: str, *, dockerfile: str = "") -> None:
calls.append((image, context, dockerfile))
with patch.object(
launch, "read_committed_image",
return_value="bot-bottle-committed-dev-abc:latest",
), patch.object(
launch.container_mod, "image_exists", return_value=False,
), patch.object(
launch.container_mod, "build_image", side_effect=fake_build,
):
updated = launch._build_images(plan)
self.assertEqual("bot-bottle-agent:latest", updated.image)
self.assertEqual(2, len(calls))
self.assertEqual("bot-bottle-agent:latest", calls[1][0])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,159 @@
"""Unit: macos-container pty_forward raw-mode wrapper (issue #245).
Tests argument parsing, non-TTY fallback, and the raw-mode
setup/restore sequence without requiring a real terminal.
"""
from __future__ import annotations
import io
import termios
import unittest
from unittest.mock import ANY, MagicMock, patch
from bot_bottle.backend.macos_container import pty_forward
def _fake_stdin(fd: int = 0) -> MagicMock:
"""Return a mock stdin whose fileno() returns *fd*."""
m = MagicMock()
m.fileno.return_value = fd
return m
class TestArgvParsing(unittest.TestCase):
def test_missing_separator_returns_error_exit_code(self):
with patch.object(pty_forward.sys, "stderr", new=io.StringIO()) as err:
rc = pty_forward.main(["container", "exec"])
self.assertEqual(2, rc)
self.assertIn("usage:", err.getvalue())
def test_too_few_args_returns_error_exit_code(self):
with patch.object(pty_forward.sys, "stderr", new=io.StringIO()):
self.assertEqual(2, pty_forward.main([]))
self.assertEqual(2, pty_forward.main(["--"]))
def test_separator_at_start_with_inner_is_valid(self):
with (
patch.object(pty_forward.sys, "stdin", _fake_stdin()),
patch.object(pty_forward.os, "isatty", return_value=False),
patch.object(pty_forward.subprocess, "run") as run,
):
run.return_value.returncode = 0
rc = pty_forward.main(["--", "container", "exec"])
self.assertEqual(0, rc)
run.assert_called_once()
self.assertEqual(["container", "exec"], run.call_args.args[0])
self.assertFalse(run.call_args.kwargs["check"])
class TestNonTtyFallback(unittest.TestCase):
def test_non_tty_stdin_runs_inner_directly(self):
with (
patch.object(pty_forward.sys, "stdin", _fake_stdin()),
patch.object(pty_forward.os, "isatty", return_value=False),
patch.object(pty_forward.subprocess, "run") as run,
):
run.return_value.returncode = 42
rc = pty_forward.main(
["--", "container", "exec", "--interactive", "--tty", "c", "claude"]
)
self.assertEqual(42, rc)
run.assert_called_once()
self.assertEqual(
["container", "exec", "--interactive", "--tty", "c", "claude"],
run.call_args.args[0],
)
self.assertFalse(run.call_args.kwargs["check"])
def test_fileno_error_runs_inner_directly(self):
bad_stdin = MagicMock()
bad_stdin.fileno.side_effect = OSError("pseudofile")
with (
patch.object(pty_forward.sys, "stdin", bad_stdin),
patch.object(pty_forward.subprocess, "run") as run,
):
run.return_value.returncode = 0
rc = pty_forward.main(["--", "container", "exec"])
run.assert_called_once()
self.assertEqual(["container", "exec"], run.call_args.args[0])
self.assertFalse(run.call_args.kwargs["check"])
self.assertEqual(0, rc)
class TestRawModeSetupAndRestore(unittest.TestCase):
def test_tty_stdin_sets_raw_mode_and_restores_on_exit(self):
saved_attrs = object()
with (
patch.object(pty_forward.sys, "stdin", _fake_stdin()),
patch.object(pty_forward.os, "isatty", return_value=True),
patch.object(pty_forward.termios, "tcgetattr", return_value=saved_attrs),
patch.object(pty_forward.tty, "setraw") as setraw,
patch.object(pty_forward.termios, "tcsetattr") as tcsetattr,
patch.object(pty_forward.subprocess, "run") as run,
):
run.return_value.returncode = 0
rc = pty_forward.main(["--", "container", "exec"])
self.assertEqual(0, rc)
setraw.assert_called_once()
tcsetattr.assert_called_once_with(
ANY, termios.TCSADRAIN, saved_attrs,
)
def test_tty_restores_on_subprocess_nonzero_exit(self):
saved_attrs = object()
with (
patch.object(pty_forward.sys, "stdin", _fake_stdin()),
patch.object(pty_forward.os, "isatty", return_value=True),
patch.object(pty_forward.termios, "tcgetattr", return_value=saved_attrs),
patch.object(pty_forward.tty, "setraw"),
patch.object(pty_forward.termios, "tcsetattr") as tcsetattr,
patch.object(pty_forward.subprocess, "run") as run,
):
run.return_value.returncode = 1
rc = pty_forward.main(["--", "container", "exec"])
self.assertEqual(1, rc)
tcsetattr.assert_called_once_with(
ANY, termios.TCSADRAIN, saved_attrs,
)
def test_tcgetattr_error_falls_back_to_bare_run(self):
with (
patch.object(pty_forward.sys, "stdin", _fake_stdin()),
patch.object(pty_forward.os, "isatty", return_value=True),
patch.object(
pty_forward.termios, "tcgetattr",
side_effect=termios.error("not a tty"),
),
patch.object(pty_forward.tty, "setraw") as setraw,
patch.object(pty_forward.subprocess, "run") as run,
):
run.return_value.returncode = 0
rc = pty_forward.main(["--", "container", "exec"])
setraw.assert_not_called()
run.assert_called_once()
self.assertEqual(["container", "exec"], run.call_args.args[0])
self.assertFalse(run.call_args.kwargs["check"])
self.assertEqual(0, rc)
def test_inner_run_sets_term_default_without_mutating_process_env(self):
with (
patch.dict(pty_forward.os.environ, {}, clear=True),
patch.object(pty_forward.subprocess, "run") as run,
):
run.return_value.returncode = 0
rc = pty_forward._run_inner(["container", "exec"])
self.assertNotIn("TERM", pty_forward.os.environ)
self.assertEqual(0, rc)
child_env = run.call_args.kwargs["env"]
self.assertEqual(["TERM"], sorted(child_env.keys()))
self.assertEqual("xterm-256color", child_env["TERM"])
if __name__ == "__main__":
unittest.main()
+47
View File
@@ -73,6 +73,53 @@ resolver #2
)
self.assertTrue(run.call_args_list[-1].kwargs["check"])
def test_commit_container_execs_tar_and_builds_image(self):
# stderr is bytes because subprocess.run uses stderr=PIPE without text=True
completed = util.subprocess.CompletedProcess(
args=[], returncode=0, stdout=b"", stderr=b"",
)
dockerfile_text = ""
def fake_build_image(image_tag: str, context: str, *, dockerfile: str = "") -> None:
nonlocal dockerfile_text
with open(dockerfile, encoding="utf-8") as f:
dockerfile_text = f.read()
with patch.object(util.subprocess, "run", return_value=completed) as run, \
patch.object(util, "build_image", side_effect=fake_build_image) as build_image, \
patch.object(util, "info"):
util.commit_container(
"bot-bottle-dev-abc12",
"bot-bottle-committed-dev-abc12:latest",
)
argv = run.call_args.args[0]
self.assertEqual("container", argv[0])
self.assertEqual("exec", argv[1])
self.assertIn("bot-bottle-dev-abc12", argv)
self.assertIn("tar", argv)
self.assertIn("--directory=/", argv)
build_image.assert_called_once()
self.assertEqual(
"bot-bottle-committed-dev-abc12:latest",
build_image.call_args.args[0],
)
self.assertIn("ADD rootfs.tar /\n", dockerfile_text)
self.assertIn("USER node\n", dockerfile_text)
self.assertIn("WORKDIR /home/node\n", dockerfile_text)
def test_commit_container_dies_on_exec_tar_failure(self):
failed = util.subprocess.CompletedProcess(
args=[], returncode=1, stdout=b"", stderr=b"No such container",
)
with patch.object(util.subprocess, "run", return_value=failed), \
patch.object(util, "die", side_effect=SystemExit("die")) as die:
with self.assertRaises(SystemExit):
util.commit_container("missing-container", "some:tag")
die.assert_called_once()
self.assertIn("missing-container", die.call_args.args[0])
def test_build_image_restarts_builder_when_dns_mismatches(self):
status = util.subprocess.CompletedProcess(
args=[],
+59 -31
View File
@@ -1,14 +1,14 @@
"""Unit: agent-level git-gate.user overlay + provenance (PRD 0027, PRD 0047).
An agent file may declare `git-gate.user` (name/email). At
`Manifest.bottle_for()` it overlays the referenced bottle's
`ManifestIndex.load_for_agent()` it overlays the referenced bottle's
`git-gate.user` per-field, agent-wins-on-non-empty. `git-gate.repos` is
rejected on agents. `Manifest.git_identity_summary()` reports the
effective identity with per-field `(agent)`/`(bottle)` provenance.
The `from_json_obj` path drives `Agent.from_dict` + `bottle_for`;
a temp-dir case locks the md loader (the `_AGENT_KEYS` allow + the
`git-gate` threading into `agent_dict`)."""
The `from_json_obj` path drives `Agent.from_dict` + the overlay in
load_for_agent; a temp-dir case locks the md loader (the `_AGENT_KEYS`
allow + the `git-gate` threading into `agent_dict`)."""
from __future__ import annotations
@@ -19,7 +19,7 @@ import textwrap
import unittest
from pathlib import Path
from bot_bottle.manifest import ManifestError, Manifest
from bot_bottle.manifest import ManifestError, Manifest, ManifestIndex
def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
@@ -32,13 +32,28 @@ def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
def _manifest(*, bottle_user=None, agent_git=None) -> Manifest: # type: ignore
"""Build an index with one agent 'impl' and load it, returning a Manifest."""
bottle: dict = {} # type: ignore
if bottle_user is not None:
bottle = {"git-gate": {"user": bottle_user}}
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
if agent_git is not None:
agent["git-gate"] = agent_git
return Manifest.from_json_obj({
return ManifestIndex.from_json_obj({
"bottles": {"dev": bottle},
"agents": {"impl": agent},
}).load_for_agent("impl")
def _index(*, bottle_user: dict[str, object] | None = None, agent_git: dict[str, object] | None = None) -> ManifestIndex:
"""Build an index with one agent 'impl' without loading it."""
bottle: dict = {} # type: ignore
if bottle_user is not None:
bottle = {"git-gate": {"user": bottle_user}}
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
if agent_git is not None:
agent["git-gate"] = agent_git
return ManifestIndex.from_json_obj({
"bottles": {"dev": bottle},
"agents": {"impl": agent},
})
@@ -47,7 +62,7 @@ def _manifest(*, bottle_user=None, agent_git=None) -> Manifest: # type: ignore
class TestAgentGitUserOverlay(unittest.TestCase):
def test_agent_supplies_both_fields(self):
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
u = m.bottle_for("impl").git_user
u = m.bottle.git_user
self.assertEqual("a", u.name)
self.assertEqual("a@b", u.email)
@@ -56,7 +71,7 @@ class TestAgentGitUserOverlay(unittest.TestCase):
bottle_user={"name": "B", "email": "b@c"},
agent_git={"user": {"name": "a"}},
)
u = m.bottle_for("impl").git_user
u = m.bottle.git_user
self.assertEqual("a", u.name) # agent wins
self.assertEqual("b@c", u.email) # bottle falls through
@@ -65,34 +80,40 @@ class TestAgentGitUserOverlay(unittest.TestCase):
bottle_user={"name": "B", "email": "b@c"},
agent_git={"user": {"email": "a@b"}},
)
u = m.bottle_for("impl").git_user
u = m.bottle.git_user
self.assertEqual("B", u.name)
self.assertEqual("a@b", u.email)
def test_agent_identity_with_bottle_declaring_none(self):
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
self.assertTrue(m.bottles["dev"].git_user.is_empty())
self.assertFalse(m.bottle_for("impl").git_user.is_empty())
idx = _index(agent_git={"user": {"name": "a", "email": "a@b"}})
# Raw bottle has no git_user; loaded manifest has merged git_user from agent
self.assertTrue(idx.bottles["dev"].git_user.is_empty())
m = idx.load_for_agent("impl")
self.assertFalse(m.bottle.git_user.is_empty())
def test_bottle_only_identity_preserved_when_agent_silent(self):
m = _manifest(bottle_user={"name": "B", "email": "b@c"})
u = m.bottle_for("impl").git_user
u = m.bottle.git_user
self.assertEqual("B", u.name)
self.assertEqual("b@c", u.email)
def test_bottle_for_returns_same_instance_when_no_overlay(self):
m = _manifest(bottle_user={"name": "B"})
self.assertIs(m.bottles["dev"], m.bottle_for("impl"))
def test_no_overlay_uses_bottle_instance_directly(self):
idx = _index(bottle_user={"name": "B"})
m = idx.load_for_agent("impl")
# Agent has no git_user — bottle instance should be the same object
self.assertIs(idx.bottles["dev"], m.bottle)
def test_bottle_for_returns_same_instance_when_overlay_is_noop(self):
m = _manifest(
def test_noop_overlay_uses_bottle_instance_directly(self):
idx = _index(
bottle_user={"name": "B", "email": "b@c"},
agent_git={"user": {"name": "B", "email": "b@c"}},
)
self.assertIs(m.bottles["dev"], m.bottle_for("impl"))
m = idx.load_for_agent("impl")
# Agent git_user == bottle git_user — no replace needed
self.assertEqual(idx.bottles["dev"].git_user, m.bottle.git_user)
def test_other_bottle_fields_untouched_by_overlay(self):
m = Manifest.from_json_obj({
idx = ManifestIndex.from_json_obj({
"bottles": {"dev": {
"env": {"FOO": "bar"},
"supervise": True,
@@ -103,7 +124,7 @@ class TestAgentGitUserOverlay(unittest.TestCase):
"git-gate": {"user": {"name": "a"}},
}},
})
b = m.bottle_for("impl")
b = idx.load_for_agent("impl").bottle
self.assertEqual("a", b.git_user.name)
self.assertEqual({"FOO": "bar"}, dict(b.env))
self.assertTrue(b.supervise)
@@ -112,7 +133,7 @@ class TestAgentGitUserOverlay(unittest.TestCase):
class TestAgentGitUserRejections(unittest.TestCase):
def test_agent_repos_dies_bottle_only(self):
msg = _error_message(_manifest, agent_git={
"repos": {"r": {"url": "ssh://git@x/y.git", "identity": "/dev/null"}},
"repos": {"r": {"url": "ssh://git@x/y.git", "key": {"provider": "static", "path": "/dev/null"}}},
})
self.assertIn("git-gate.repos", msg)
self.assertIn("bottle-only", msg)
@@ -131,7 +152,7 @@ class TestGitIdentitySummary(unittest.TestCase):
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
self.assertEqual(
"name=a (agent), email=a@b (agent)",
m.git_identity_summary("impl"),
m.git_identity_summary(),
)
def test_mixed_provenance(self):
@@ -141,19 +162,19 @@ class TestGitIdentitySummary(unittest.TestCase):
)
self.assertEqual(
"name=a (agent), email=b@c (bottle)",
m.git_identity_summary("impl"),
m.git_identity_summary(),
)
def test_bottle_only(self):
m = _manifest(bottle_user={"name": "B", "email": "b@c"})
self.assertEqual(
"name=B (bottle), email=b@c (bottle)",
m.git_identity_summary("impl"),
m.git_identity_summary(),
)
def test_none_when_unset_anywhere(self):
m = _manifest()
self.assertIsNone(m.git_identity_summary("impl"))
self.assertIsNone(m.git_identity_summary())
_BOTTLE_DEV = """
@@ -217,19 +238,26 @@ class TestAgentGitUserMdLoader(unittest.TestCase):
def test_md_agent_git_user_overlays_bottle(self):
self._write("bottles/dev.md", _BOTTLE_DEV)
self._write("agents/impl.md", _AGENT_WITH_GIT)
m = Manifest.resolve(str(self.home))
u = m.bottle_for("impl").git_user
m = ManifestIndex.resolve(str(self.home)).load_for_agent("impl")
u = m.bottle.git_user
self.assertEqual("agent-name", u.name)
self.assertEqual("bottle@example.com", u.email)
self.assertEqual(
"name=agent-name (agent), email=bottle@example.com (bottle)",
m.git_identity_summary("impl"),
m.git_identity_summary(),
)
def test_md_agent_repos_dies(self):
def test_md_agent_repos_fails_at_preflight(self):
"""git-gate.repos on an agent is an error; resolve() still succeeds
so other agents remain accessible, but load_for_agent raises."""
self._write("bottles/dev.md", _BOTTLE_DEV)
self._write("agents/impl.md", _AGENT_WITH_REPOS)
msg = _error_message(Manifest.resolve, str(self.home))
from bot_bottle.manifest import ManifestError
names = ManifestIndex.resolve(str(self.home))
self.assertIn("impl", names.all_agent_names)
with self.assertRaises(ManifestError) as ctx:
names.load_for_agent("impl")
msg = str(ctx.exception)
self.assertIn("git-gate.repos", msg)
self.assertIn("bottle-only", msg)
+11 -11
View File
@@ -9,18 +9,18 @@ partial `auth` is an error, auth omission means unauthenticated."""
import unittest
from bot_bottle.manifest import ManifestError, Manifest
from bot_bottle.manifest import ManifestError, ManifestIndex
def _bottle(routes): # type: ignore
return Manifest.from_json_obj({
return ManifestIndex.from_json_obj({
"bottles": {"dev": {"egress": {"routes": routes}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
def _provider_bottle(provider, routes): # type: ignore
return Manifest.from_json_obj({
return ManifestIndex.from_json_obj({
"bottles": {
"dev": {
"agent_provider": {"template": provider},
@@ -32,7 +32,7 @@ def _provider_bottle(provider, routes): # type: ignore
def _provider_config_bottle(agent_provider): # type: ignore
return Manifest.from_json_obj({
return ManifestIndex.from_json_obj({
"bottles": {"dev": {"agent_provider": agent_provider}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
@@ -433,7 +433,7 @@ class TestRouteValidation(unittest.TestCase):
self.assertEqual((), b.egress.routes)
def test_no_egress_block_means_empty(self):
b = Manifest.from_json_obj({
b = ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
@@ -443,7 +443,7 @@ class TestRouteValidation(unittest.TestCase):
class TestConfigShape(unittest.TestCase):
def test_unknown_egress_key_rejected(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj({
ManifestIndex.from_json_obj({
"bottles": {"dev": {"egress": {"wat": []}}},
"agents": {"demo": {"skills": [], "prompt": "",
"bottle": "dev"}},
@@ -454,14 +454,14 @@ class TestConfigShape(unittest.TestCase):
self.assertEqual(0, b.egress.Log)
def test_log_level_1_accepted(self):
b = Manifest.from_json_obj({
b = ManifestIndex.from_json_obj({
"bottles": {"dev": {"egress": {"log": 1, "routes": []}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
self.assertEqual(1, b.egress.Log)
def test_log_level_2_accepted(self):
b = Manifest.from_json_obj({
b = ManifestIndex.from_json_obj({
"bottles": {"dev": {"egress": {"log": 2, "routes": []}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
@@ -469,7 +469,7 @@ class TestConfigShape(unittest.TestCase):
def test_log_invalid_level_rejected(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj({
ManifestIndex.from_json_obj({
"bottles": {"dev": {"egress": {"log": 3}}},
"agents": {"demo": {"skills": [], "prompt": "",
"bottle": "dev"}},
@@ -477,7 +477,7 @@ class TestConfigShape(unittest.TestCase):
def test_log_bool_rejected(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj({
ManifestIndex.from_json_obj({
"bottles": {"dev": {"egress": {"log": True}}},
"agents": {"demo": {"skills": [], "prompt": "",
"bottle": "dev"}},
@@ -485,7 +485,7 @@ class TestConfigShape(unittest.TestCase):
def test_log_string_rejected(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj({
ManifestIndex.from_json_obj({
"bottles": {"dev": {"egress": {"log": "full"}}},
"agents": {"demo": {"skills": [], "prompt": "",
"bottle": "dev"}},
+85 -12
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import unittest
from bot_bottle.manifest import ManifestError, Manifest
from bot_bottle.manifest import ManifestError, ManifestIndex
def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
@@ -28,7 +28,7 @@ def _build(**bottles) -> Manifest: # type: ignore
"""Build a manifest with the given bottles and one trivial agent
referencing the first bottle (so the manifest is valid)."""
first = next(iter(bottles))
return Manifest.from_json_obj({
return ManifestIndex.from_json_obj({
"bottles": bottles,
"agents": {
"demo": {"skills": [], "prompt": "", "bottle": first},
@@ -113,11 +113,11 @@ class TestExtendsEnvMerge(unittest.TestCase):
class TestExtendsGitMerge(unittest.TestCase):
"""git-gate.user overlays by field; git-gate.repos merges by upstream
host, with child entries replacing duplicate hosts."""
"""git-gate.user overlays by field; git-gate.repos merges by name,
with same-name child entries merging field-by-field (child wins)."""
_GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "identity": "/dev/null"}
_GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "identity": "/dev/null"}
_GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "key": {"provider": "static", "path": "/dev/null"}}
_GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "key": {"provider": "static", "path": "/dev/null"}}
def test_child_git_repos_merge_with_parent(self):
m = _build(
@@ -130,19 +130,21 @@ class TestExtendsGitMerge(unittest.TestCase):
names = [e.Name for e in m.bottles["child"].git]
self.assertEqual(["a", "b"], names)
def test_child_git_repo_replaces_same_host(self):
replacement = {"url": "ssh://git@host-a/replacement.git", "identity": "/dev/null"}
def test_child_git_repo_different_name_same_host_coexists(self):
# Repos are keyed by Name, not UpstreamHost: two repos with
# different names on the same host both survive the merge.
same_host_b = {"url": "ssh://git@host-a/b.git", "key": {"provider": "static", "path": "/dev/null"}}
m = _build(
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
child={
"extends": "base",
"git-gate": {"repos": {"a2": replacement}},
"git-gate": {"repos": {"a2": same_host_b}},
},
)
entries = m.bottles["child"].git
self.assertEqual(1, len(entries))
self.assertEqual("a2", entries[0].Name)
self.assertEqual("replacement.git", entries[0].UpstreamPath)
self.assertEqual(2, len(entries))
names = {e.Name for e in entries}
self.assertEqual({"a", "a2"}, names)
def test_child_omits_git_gate_inherits_full_list(self):
m = _build(
@@ -164,6 +166,77 @@ class TestExtendsGitMerge(unittest.TestCase):
)
self.assertEqual((), m.bottles["child"].git)
def test_child_same_name_repo_merges_key_field(self):
# Issue #237: child repo with same name as parent should merge
# field-by-field. Child overrides only `key`; parent's url and
# host_key are preserved.
parent_entry = {
"url": "ssh://git@host-a/repo.git",
"host_key": "ecdsa-sha2-nistp256 AAAA",
"key": {"provider": "static", "path": "/keys/id_rsa"},
}
m = _build(
base={"git-gate": {"repos": {"repo": parent_entry}}},
child={
"extends": "base",
"git-gate": {"repos": {"repo": {
"key": {"provider": "gitea", "forge_token_env": "GITEA_TOKEN"},
}}},
},
)
entries = m.bottles["child"].git
self.assertEqual(1, len(entries))
e = entries[0]
self.assertEqual("repo", e.Name)
self.assertEqual("ssh://git@host-a/repo.git", e.Upstream)
self.assertEqual("ecdsa-sha2-nistp256 AAAA", e.KnownHostKey)
self.assertEqual("gitea", e.Key.provider)
self.assertEqual("GITEA_TOKEN", e.Key.forge_token_env)
def test_child_same_name_repo_overrides_url(self):
# Child can override url on a same-name repo; other parent fields
# fall through.
parent_entry = {
"url": "ssh://git@host-a/old.git",
"key": {"provider": "static", "path": "/keys/id_rsa"},
}
m = _build(
base={"git-gate": {"repos": {"repo": parent_entry}}},
child={
"extends": "base",
"git-gate": {"repos": {"repo": {
"url": "ssh://git@host-b/new.git",
"key": {"provider": "static", "path": "/keys/id_rsa"},
}}},
},
)
entries = m.bottles["child"].git
self.assertEqual(1, len(entries))
self.assertEqual("ssh://git@host-b/new.git", entries[0].Upstream)
def test_child_same_name_plus_new_repo(self):
# Same-name repo is field-merged; a distinct new name in child
# is appended.
parent_entry = {
"url": "ssh://git@host-a/repo.git",
"key": {"provider": "static", "path": "/keys/id_rsa"},
}
m = _build(
base={"git-gate": {"repos": {"repo": parent_entry}}},
child={
"extends": "base",
"git-gate": {"repos": {
"repo": {"key": {"provider": "gitea", "forge_token_env": "TOK"}},
"other": self._GIT_ENTRY_B,
}},
},
)
child = m.bottles["child"]
names = {e.Name for e in child.git}
self.assertEqual({"repo", "other"}, names)
repo_entry = next(e for e in child.git if e.Name == "repo")
self.assertEqual("gitea", repo_entry.Key.provider)
def test_child_git_user_inherits_parent_repos(self):
m = _build(
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},

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