Compare commits

..

8 Commits

Author SHA1 Message Date
didericis 400f173b0b fix(doctor): survive a PATH entry the user can't execute
prd-number-check / require-numbered-prds (pull_request) Successful in 10s
lint / lint (push) Successful in 57s
tracker-policy-pr / check-pr (pull_request) Failing after 14m34s
test / coverage (pull_request) Blocked by required conditions
test / integration-docker (pull_request) Blocked by required conditions
test / unit (pull_request) Has started running
test / image-input-builds (pull_request) Has started running
The install itself now works end to end on a fresh macOS account — venv built,
package installed from git, entry point linked — and `doctor` then died with an
unhandled traceback:

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 01:20:19 -04:00
didericis 3d354465f0 fix(install): name the profile the user's shell actually reads
prd-number-check / require-numbered-prds (pull_request) Successful in 11s
tracker-policy-pr / check-pr (pull_request) Successful in 9s
test / image-input-builds (pull_request) Successful in 38s
test / unit (pull_request) Successful in 43s
test / integration-docker (pull_request) Successful in 59s
test / coverage (pull_request) Successful in 16s
The PATH hint hardcoded ~/.zprofile, which is right for macOS's zsh and wrong
for the Linux users this installer also serves. Pick from $SHELL instead.
~/.profile is the default for non-zsh rather than ~/.bash_profile, because
creating the latter would shadow an existing ~/.profile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 01:14:49 -04:00
didericis 90d6104e17 fix(install): install into a private venv when pipx is absent
prd-number-check / require-numbered-prds (pull_request) Successful in 5s
test / image-input-builds (pull_request) Successful in 38s
test / unit (pull_request) Successful in 44s
tracker-policy-pr / check-pr (pull_request) Successful in 4s
lint / lint (push) Successful in 53s
test / integration-docker (pull_request) Successful in 58s
test / coverage (pull_request) Successful in 18s
The harness's second run hit the wall the first one predicted: a fresh account
has no pipx, so install.sh fell to `pip install --user`, and every Python a Mac
offers — Homebrew and python.org alike — is externally managed, so PEP 668
blocked it. That fallback was never a fallback on macOS; it was a dead end that
printed instructions.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 01:10:06 -04:00
didericis bb6f081b66 docs: correct two claims the live harness run disproved
prd-number-check / require-numbered-prds (pull_request) Successful in 11s
tracker-policy-pr / check-pr (pull_request) Successful in 15s
test / unit (pull_request) Successful in 2m24s
test / image-input-builds (pull_request) Successful in 45s
test / integration-docker (pull_request) Successful in 1m1s
test / coverage (pull_request) Successful in 24s
The PATH story was wrong in an instructive way. I said a Homebrew Python is on
PATH only because of a shell-profile line; on this host /etc/paths.d/homebrew
puts /opt/homebrew/bin on every login shell's PATH, fresh accounts included.
The stub still wins, because path_helper appends /etc/paths.d/* *after*
/etc/paths and /usr/bin is in the latter. Ordering, not absence, is what makes
bare `python3` the 3.9.6 stub — which is also why the versioned `python3.14`
candidate is the one that matched during the real run, rather than the
/opt/homebrew/bin/python3 fallback I expected.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 00:59:09 -04:00
didericis c62aa7b805 fix(install): find a usable python instead of dead-ending on PATH's
prd-number-check / require-numbered-prds (pull_request) Successful in 10s
tracker-policy-pr / check-pr (pull_request) Successful in 13s
test / image-input-builds (pull_request) Successful in 43s
test / unit (pull_request) Successful in 51s
test / integration-docker (pull_request) Successful in 59s
test / coverage (pull_request) Successful in 15s
lint / lint (push) Failing after 14m25s
The macOS install harness caught this on its first real run: a throwaway
account gets `bot-bottle install: error: python3 3.11 or newer is required`
and stops. A fresh account's PATH is just /etc/paths, which excludes
/opt/homebrew/bin, so `python3` resolves to the Command Line Tools stub —
still 3.9.6 on macOS 26. Homebrew's shellenv line lives in the *installing*
user's ~/.zprofile and is inherited by nobody. The documented `curl … | sh`
path therefore dead-ends for anyone whose profile isn't already set up, which
is every new user, launchd job, and CI runner.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 00:50:21 -04:00
didericis e847d51a71 feat: add a one-shot test cycle to the macOS install harness
prd-number-check / require-numbered-prds (pull_request) Successful in 6s
tracker-policy-pr / check-pr (pull_request) Successful in 10s
`test` chains up -> run -> status -> down, which is the loop you actually want
when verifying a clean install. Three things make it more than a convenience
wrapper:

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 00:43:12 -04:00
didericis-claude fdec887beb feat: add macOS clean-install test harness
prd-number-check / require-numbered-prds (pull_request) Successful in 14s
tracker-policy-pr / check-pr (pull_request) Successful in 10s
Add scripts/macos-install-test.sh, a throwaway-user harness for exercising
install.sh the way a brand-new user would on macOS, plus the research note
that motivates the approach.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 03:48:39 +00:00
didericis-codex a6e1aebda1 docs: update agent sandbox competitor landscape 2026-07-27 03:44:41 +00:00
36 changed files with 1554 additions and 1829 deletions
+45 -31
View File
@@ -71,7 +71,21 @@ When the agent exits, `cli.py` tears down every gateway and both networks; nothi
## Quickstart
On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The Firecracker backend (Linux) requires Docker on the host for the gateway plus the `firecracker` binary and KVM. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
```sh
curl -fsSL https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh | sh
```
The installer is a bootstrapper: it finds a suitable Python, installs bot-bottle with `pipx` (falling back to `pip --user`), creates `~/.bot-bottle`, and runs `bot-bottle doctor`. It is idempotent and never uses `sudo`. Python-native users can skip it entirely with `pipx install bot-bottle` or `uv tool install bot-bottle`.
### Requirements
**Python ≥ 3.11**, and this is the one that trips people up on macOS: the `python3` Apple ships at `/usr/bin/python3` is **3.9.6**, which is too old. Bare `python3` resolves to that stub far more often than people expect. `path_helper` builds a login shell's `PATH` from `/etc/paths` and then appends `/etc/paths.d/*`, and `/usr/bin` sits in the former — so even when `/opt/homebrew/bin` *is* on the `PATH` (via `/etc/paths.d/homebrew`), it comes after `/usr/bin` and loses. Prepending a newer Python is something your shell profile does, and a fresh account, a launchd job, or a CI runner has no such profile. So the installer looks past bare `python3` before giving up: it tries `python3`, then the versioned `python3.11``python3.14` names, then `/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, and python.org framework builds — and tells you which one it picked when it isn't the obvious one. Point it somewhere specific with `BOT_BOTTLE_PYTHON=/path/to/python3`.
**No `pipx` required.** If `pipx` is present the installer uses it and stays out of the way. If it isn't, bot-bottle installs into a private venv at `~/.bot-bottle/venv` (override with `BOT_BOTTLE_VENV`) and symlinks the entry point into `~/.local/bin`. There is deliberately no `pip install --user` path: Homebrew, python.org and Debian/Ubuntu interpreters are all externally managed (PEP 668), which blocks `--user` outright — so on a Mac it is never the fallback it appears to be. A venv is exempt from PEP 668, and `venv` is stdlib, so unlike `pipx` there is nothing to bootstrap first.
**`git`**, because the default install spec is a `git+` URL. Set `BOT_BOTTLE_INSTALL_SPEC` to a wheel path or index name to avoid it.
**A backend**, which the installer deliberately does *not* install for you — `doctor` reports what's missing afterwards. On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The Firecracker backend (Linux) requires Docker on the host for the gateway plus the `firecracker` binary and KVM. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend.
@@ -182,9 +196,7 @@ BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent>
## Manifest
Bottles and agents are Markdown files with YAML frontmatter under `~/.bot-bottle/`. The Markdown body is the system prompt. Both bottles and agents are **home-only**: they live under `~/.bot-bottle/bottles/` and `~/.bot-bottle/agents/`. A `<repo>/.bot-bottle/agents/<name>.md` shipped by a workspace is ignored with a warning — since an agent may select a host identity and forge secret, checked-out content must not define one (PRD 0082). Keep repo-specific behavioral instructions in `AGENTS.md` instead.
Identity is **agent-owned**: the author name/email and named forge accounts live on the agent, not under `git-gate` (which now carries only Git transport policy). A bottle repo may optionally name one of the selected agent's forge aliases via `forge:`.
Bottles and agents are Markdown files with YAML frontmatter under `~/.bot-bottle/`. The Markdown body is the system prompt. Bottles live in `~/.bot-bottle/bottles/`; agents may also be shipped by a repo at `<repo>/.bot-bottle/agents/<name>.md`.
**Bottle** (`~/.bot-bottle/bottles/gitea-dev.md`):
@@ -192,19 +204,37 @@ Identity is **agent-owned**: the author name/email and named forge accounts live
---
extends: claude # inherit the Claude provider boundary
git-gate:
repos:
bot-bottle:
url: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
key:
provider: gitea
forge_token_env: GITEA_DEPLOY_TOKEN # deploy-key admin (push), PRD 0048
host_key: "ssh-ed25519 AAAA..."
forge: didericis-gitea # ← selects the agent's forge alias
env:
GIT_AUTHOR_NAME: didericis
git:
user:
name: "Eric Bauerfeld"
email: "eric+claude@dideric.is"
remotes:
gitea.dideric.is:
Name: bot-bottle
Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea
KnownHostKey: ssh-ed25519 AAAA...
egress:
routes:
- host: gitea.dideric.is
inspect:
auth:
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]
outbound_detectors: [token_patterns, known_secrets]
inbound_detectors: false # disable response scanning for this host
---
The `gitea-dev` bottle. Gitea over SSH for push; the API credential and
workflow guidance come from the agent's `forge: didericis-gitea` association.
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
gitea over SSH for push, token over HTTPS for the API.
````
**Agent** (`~/.bot-bottle/agents/gitea-helper.md`):
@@ -214,27 +244,11 @@ workflow guidance come from the agent's `forge: didericis-gitea` association.
bottle: gitea-dev
skills:
- init-prd
author:
name: didericis-claude
email: eric+claude@dideric.is
forge-accounts:
didericis-gitea:
url: https://gitea.dideric.is/api/v1
auth:
type: token
token_secret: GITEA_CLAUDE_TOKEN # host env var; value never enters the bottle
---
You help maintain Gitea-hosted projects.
````
`author` populates the bottle's `git config user.name/user.email`. When a
selected repo names a `forge` alias, bot-bottle resolves the alias's
`token_secret` from the host env into the egress proxy only (never the bottle),
adds a scoped, proxy-authenticated route to the Gitea API origin, and appends
non-secret forge workflow guidance to the agent's system prompt. Neither the
token value nor its `token_secret` name appears in the bottle env or prompt.
**Egress route fields:**
| Field | Required | Description |
+4 -3
View File
@@ -72,9 +72,10 @@ def list_compose_projects(
result = subprocess.run(
argv, capture_output=True, text=True, check=False,
)
except FileNotFoundError:
# docker binary not on PATH — same shape as a daemon-down
# error from the caller's POV: no projects discoverable.
except OSError:
# docker unavailable — not on PATH, or on it but not executable by
# this user. Same shape as a daemon-down error from the caller's
# POV: no projects discoverable.
return []
if result.returncode != 0:
if warn_on_error:
+2 -1
View File
@@ -74,7 +74,8 @@ def _query_services_by_project() -> dict[str, set[str]]:
],
capture_output=True, text=True, check=False,
)
except FileNotFoundError:
except OSError:
# docker missing, or on PATH but not executable by this user.
return {}
if r.returncode != 0:
return {}
+11 -3
View File
@@ -177,14 +177,20 @@ def gw_slot() -> Slot:
# --- fail-closed verification ---------------------------------------
def _run_ok(argv: list[str]) -> bool:
"""Run a probe command, treating a missing binary as failure
"""Run a probe command, treating an unavailable binary as failure
(rather than crashing) so callers can stay fail-closed."""
try:
return subprocess.run(
argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False,
).returncode == 0
except FileNotFoundError:
except OSError:
# Not only "missing". A name on PATH that isn't executable by this
# user raises PermissionError, and CPython reports that EACCES in
# preference to the ENOENT from the other PATH entries — which is
# how `doctor` came to die with a traceback on a fresh macOS
# account. Any OSError means the probe couldn't run, which for a
# fail-closed check is indistinguishable from "not present".
return False
@@ -244,7 +250,9 @@ def overlapping_routes() -> list[RouteConflict]:
["ip", "-json", "route", "show", "table", "all"],
capture_output=True, text=True, check=False,
)
except FileNotFoundError:
except OSError:
# Missing, or present-but-not-executable for this user; either way
# there are no routes we can enumerate. See _run_ok.
return []
if proc.returncode != 0 or not proc.stdout.strip():
return []
+1 -1
View File
@@ -118,7 +118,7 @@ class BottlePreparationPlanner:
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=provision,
egress_plan=prepare_egress(manifest, slug, provision),
egress_plan=prepare_egress(bottle, slug, provision),
git_gate_plan=prepare_git_gate(bottle, slug),
supervise_plan=prepare_supervise(bottle, slug),
)
+5 -22
View File
@@ -24,11 +24,10 @@ from ..bottle_state import (
supervise_state_dir,
write_metadata,
)
from ..egress import Egress, EgressPlan, egress_forge_routes
from ..egress import Egress, EgressPlan
from ..git_gate import GitGate, GitGatePlan
from ..log import die
from ..manifest import Manifest, ManifestBottle
from ..manifest.forge import render_forge_guidance
from ..supervisor.plan import SupervisePlan
from ..orchestrator.supervisor import Supervisor
from ..util import slugify
@@ -72,21 +71,12 @@ def write_launch_metadata(
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).
For repositories associated with a forge, appends generated, non-secret
provider-specific workflow guidance to the prompt (PRD
0082). The guidance carries neither the
token value nor its `token_secret` name."""
Returns (agent_dir, prompt_file)."""
agent = manifest.agent
agent_dir = agent_state_dir(slug)
agent_dir.mkdir(parents=True, exist_ok=True)
prompt_file = agent_dir / "prompt.txt"
prompt = agent.prompt or ""
guidance = render_forge_guidance(manifest.forge_associations)
if guidance:
prompt = f"{prompt.rstrip()}\n\n{guidance}" if prompt.strip() else guidance
prompt_file.write_text(prompt)
prompt_file.write_text(agent.prompt or "")
prompt_file.chmod(0o600)
return agent_dir, prompt_file
@@ -98,18 +88,11 @@ def prepare_git_gate(bottle: ManifestBottle, slug: str) -> GitGatePlan:
def prepare_egress(
manifest: Manifest, slug: str, provision: AgentProvisionPlan,
bottle: ManifestBottle, slug: str, provision: AgentProvisionPlan,
) -> EgressPlan:
"""Build the egress plan, adding a scoped, proxy-held Gitea API route for
each forge alias referenced by a selected git-gate repo (PRD
0082). The token is resolved from the host
env at launch and never enters the bottle."""
egress_dir = egress_state_dir(slug)
egress_dir.mkdir(parents=True, exist_ok=True)
forge_routes = egress_forge_routes(manifest.forge_associations)
return Egress().prepare(
manifest.bottle, slug, egress_dir, provision.egress_routes, forge_routes,
)
return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes)
def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None:
+26 -23
View File
@@ -128,7 +128,7 @@ def cmd_start(argv: list[str]) -> int:
if not manifest.all_agent_names:
print(
"bot-bottle: no agents defined. "
"Add an agent to ~/.bot-bottle/agents/ to get started.",
"Add an agent to ~/.bot-bottle/agents/ or ./bot-bottle/agents/ to get started.",
file=sys.stderr,
)
return 1
@@ -383,9 +383,12 @@ def _peek_agent_bottle(manifest: ManifestIndex, agent_name: str) -> str:
from ...manifest.loader import scan_agent_names
from ...yaml_subset import YamlSubsetError, parse_frontmatter
# Agents are home-only (PRD 0082).
home_agents = scan_agent_names(manifest.home_md / "agents")
path = home_agents.get(agent_name)
cwd_agents: dict[str, Path] = {}
if manifest.cwd_md is not None:
cwd_agents = scan_agent_names(manifest.cwd_md / "agents")
merged = {**home_agents, **cwd_agents}
path = merged.get(agent_name)
if path is None:
return ""
try:
@@ -485,19 +488,13 @@ def _manifest_to_yaml(manifest: Manifest) -> str:
lines.append(" skills:")
for s in agent.skills:
lines.append(f" - {s}")
if agent.author is not None:
lines.append(" author:")
lines.append(f" name: {agent.author.name}")
lines.append(f" email: {agent.author.email}")
if agent.forge_accounts:
lines.append(" forge-accounts:")
for alias, acct in sorted(agent.forge_accounts.items()):
lines.append(f" {alias}:")
lines.append(f" url: {acct.url}")
lines.append(" auth:")
lines.append(f" type: {acct.auth_type}")
# token_secret name is host config; show the name, never a value.
lines.append(f" token_secret: {acct.token_secret}")
if not agent.git_user.is_empty():
lines.append(" git-gate:")
lines.append(" user:")
if agent.git_user.name:
lines.append(f" name: {agent.git_user.name}")
if agent.git_user.email:
lines.append(f" email: {agent.git_user.email}")
bottle = manifest.bottle
lines.append("bottle:")
@@ -513,14 +510,20 @@ def _manifest_to_yaml(manifest: Manifest) -> str:
for k, v in sorted(bottle.env.items()):
lines.append(f" {k}: {v}")
if bottle.git:
has_git_gate = not bottle.git_user.is_empty() or bottle.git
if has_git_gate:
lines.append(" git-gate:")
lines.append(" repos:")
for entry in bottle.git:
lines.append(f" {entry.Name}:")
lines.append(f" url: {entry.Upstream}")
if entry.Forge:
lines.append(f" forge: {entry.Forge}")
if not bottle.git_user.is_empty():
lines.append(" user:")
if bottle.git_user.name:
lines.append(f" name: {bottle.git_user.name}")
if bottle.git_user.email:
lines.append(f" email: {bottle.git_user.email}")
if bottle.git:
lines.append(" repos:")
for entry in bottle.git:
lines.append(f" {entry.Name}:")
lines.append(f" url: {entry.Upstream}")
if bottle.egress.routes:
lines.append(" egress:")
-3
View File
@@ -32,7 +32,6 @@ if TYPE_CHECKING:
EGRESS_ROUTES_IN_CONTAINER,
Egress,
egress_agent_env_entries,
egress_forge_routes,
egress_gateway_env_entries,
egress_manifest_routes,
egress_render_routes,
@@ -52,7 +51,6 @@ _LAZY: dict[str, str] = {
"EGRESS_ROUTES_FILENAME": ".service",
"EGRESS_ROUTES_IN_CONTAINER": ".service",
"egress_agent_env_entries": ".service",
"egress_forge_routes": ".service",
"egress_gateway_env_entries": ".service",
"egress_manifest_routes": ".service",
"egress_render_routes": ".service",
@@ -82,7 +80,6 @@ __all__ = [
"Egress",
"EgressPlan",
"EgressRoute",
"egress_forge_routes",
"egress_manifest_routes",
"egress_render_routes",
"egress_resolve_token_values",
+6 -53
View File
@@ -26,7 +26,7 @@ from ..log import die
from .plan import EgressPlan, EgressRoute
if TYPE_CHECKING:
from ..manifest import ManifestBottle, ResolvedForgeAssociation
from ..manifest import ManifestBottle
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
@@ -119,61 +119,15 @@ def egress_manifest_routes(
return tuple(out)
def egress_forge_routes(
associations: "tuple[ResolvedForgeAssociation, ...]",
) -> tuple[EgressRoute, ...]:
"""Synthesize one inspected, token-authenticated egress route per distinct
forge alias referenced by a selected git-gate repo (PRD
0082).
The route is scoped to the canonical forge origin (host) and API prefix
(`/api/v1`): the proxy injects the Gitea `token` scheme using the value of
the host env var named by the account's `token_secret`, resolved at launch
from the host environment — the token never enters the bottle. Aliases that
canonicalize to the same host share a single route (deduplicated).
Composition (`resolve_forge_associations`) has already rejected referenced
aliases that share a host but disagree on origin/auth/token_secret, so this
host-dedup only ever collapses genuinely identical credentials — it never
silently discards a distinct one."""
out: list[EgressRoute] = []
seen_hosts: set[str] = set()
for assoc in associations:
acct = assoc.account
host_key = acct.host.lower()
if host_key in seen_hosts:
continue
seen_hosts.add(host_key)
out.append(EgressRoute(
host=acct.host,
matches=(CoreMatchEntry(
paths=(CorePathMatch(type="prefix", value=acct.api_prefix),),
),),
auth_scheme=acct.auth_type,
token_ref=acct.token_secret,
inspect=True,
))
return tuple(out)
def egress_routes_for_bottle(
bottle: ManifestBottle,
provider_routes: tuple[EgressRoute, ...] = (),
forge_routes: tuple[EgressRoute, ...] = (),
) -> tuple[EgressRoute, ...]:
manifest = egress_manifest_routes(bottle)
# Provider routes (LLM API) default to redact-on-match; forge routes are
# host-injected but keep the default DLP policy. Both take precedence over
# a manifest route to the same host.
reserved_hosts = (
{pr.host.lower() for pr in provider_routes}
| {fr.host.lower() for fr in forge_routes}
)
merged = (
list(_default_provider_on_match(provider_routes))
+ list(forge_routes)
+ [r for r in manifest if r.host.lower() not in reserved_hosts]
)
provisioned_hosts = {pr.host.lower() for pr in provider_routes}
merged = list(_default_provider_on_match(provider_routes)) + [
r for r in manifest if r.host.lower() not in provisioned_hosts
]
return _assign_token_slots(merged)
@@ -413,9 +367,8 @@ class Egress:
slug: str,
stage_dir: Path,
provider_routes: tuple[EgressRoute, ...] = (),
forge_routes: tuple[EgressRoute, ...] = (),
) -> EgressPlan:
routes = egress_routes_for_bottle(bottle, provider_routes, forge_routes)
routes = egress_routes_for_bottle(bottle, provider_routes)
log = bottle.egress.Log
routes_path = stage_dir / EGRESS_ROUTES_FILENAME
routes_path.write_text(egress_render_routes(routes, log=log))
+10 -32
View File
@@ -1,10 +1,10 @@
"""Manifest dataclasses (PRD 0011 layout).
Reads the per-file manifest tree (home-only —
PRD 0082):
Reads the per-file manifest tree:
$HOME/.bot-bottle/bottles/<name>.md — one bottle per file
$HOME/.bot-bottle/agents/<name>.md — agents
$HOME/.bot-bottle/agents/<name>.md — home-resident agents
$CWD/.bot-bottle/agents/<name>.md — cwd-supplied agents
Each file is Markdown with YAML frontmatter. The frontmatter holds
the structured config (see schema below); for agents the body is
@@ -15,38 +15,27 @@ Bottle schema (frontmatter):
extends: <bottle-name> # optional (PRD 0025)
env: { <NAME>: <env-entry>, ... }
git-gate: # optional (PRD 0047)
user: { name: <str>, email: <str> } # optional
repos: { <name>: <git-gate-entry>, ... } # optional
# git-gate-entry keys: url, key, host_key, forge
# `forge`: optional alias into the selected agent's forge-accounts
egress: { routes: [ <egress-route>, ... ] }
# route keys: host, matches, auth, role, dlp
supervise: <bool> # optional (default true)
nested_containers: <bool> # optional (default false)
Agent schema (frontmatter):
bottle: <bottle-name> # optional
bottle: <bottle-name> # required
skills: [ <skill-name>, ... ] # optional
author: # optional; agent git identity
name: <str> # required when author is present
email: <str> # required when author is present
forge-accounts: # optional; alias -> forge account
<alias>:
url: <https Gitea /api/v1 base>
auth: { type: token, token_secret: <host env var name> }
git-gate:
user: { name: <str>, email: <str> } # optional; overlays bottle
# Claude Code subagent passthrough fields — accepted, ignored:
name, description, model, color, memory
`author` populates the bottle's git user.name/user.email; `forge-accounts`
maps a forge alias to a Gitea API origin plus a host token reference. Identity
is agent-owned — `git-gate` is no longer accepted on an agent (git-gate.user
moved to `author`; git-gate.repos is bottle-only).
The agent file's Markdown body is the system prompt (stripped).
Unknown top-level frontmatter keys raise ManifestError with a hint.
Both bottles and agents can ONLY live under $HOME. An agents/ or 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.
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.
Two types are exported:
@@ -77,11 +66,6 @@ if TYPE_CHECKING:
from .agent import ManifestAgent, ManifestAgentProvider
from .bottle import ManifestBottle
from .egress import EGRESS_AUTH_SCHEMES, ManifestEgressConfig, ManifestEgressRoute
from .forge import (
ManifestAuthor,
ManifestForgeAccount,
ResolvedForgeAssociation,
)
from .git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig
@@ -97,9 +81,6 @@ _LAZY_MODULES: dict[str, str] = {
"EGRESS_AUTH_SCHEMES": "egress",
"ManifestEgressRoute": "egress",
"ManifestEgressConfig": "egress",
"ManifestAuthor": "forge",
"ManifestForgeAccount": "forge",
"ResolvedForgeAssociation": "forge",
"ManifestGitEntry": "git",
"ManifestGitUser": "git",
"ManifestKeyConfig": "git",
@@ -134,7 +115,4 @@ __all__ = [
"EGRESS_AUTH_SCHEMES",
"ManifestEgressRoute",
"ManifestEgressConfig",
"ManifestAuthor",
"ManifestForgeAccount",
"ResolvedForgeAssociation",
]
+24 -44
View File
@@ -3,11 +3,11 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Mapping, cast
from typing import cast
from ..agent_provider import PROVIDER_TEMPLATES
from .util import ManifestError, as_json_object
from .forge import ManifestAuthor, ManifestForgeAccount
from .git import ManifestGitUser
from .schema import AGENT_MODEL_KEYS, is_valid_entity_name
@@ -119,29 +119,15 @@ class ManifestAgent:
bottle: str = ""
skills: tuple[str, ...] = ()
prompt: str = ""
# Agent-owned identity (PRD 0082).
# `author` populates the bottle's git user.name/user.email;
# `forge_accounts` maps a forge alias to a canonical Gitea API origin and
# a host token reference. Both live only on the agent — never under
# `git-gate`, which is bottle-only transport policy.
author: ManifestAuthor | None = None
forge_accounts: Mapping[str, ManifestForgeAccount] = field(
default_factory=dict
)
# Per-agent git identity (issue #94). Overlays the referenced
# bottle's git-gate.user per-field at `Manifest.bottle_for`. Only
# `user` is allowed at the agent level; `repos` stays bottle-only
# because it carries credentials and host trust.
git_user: ManifestGitUser = ManifestGitUser()
@classmethod
def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "ManifestAgent":
d = as_json_object(raw, f"agent '{name}'")
# git-gate is no longer accepted on an agent (checked before the
# generic unknown-key error so the migration pointer is surfaced):
# identity moved to `author`, and git-gate.repos is bottle-only.
if "git-gate" in d:
raise ManifestError(
f"agent '{name}' has a 'git-gate' block, which is no longer "
f"accepted on an agent (PRD 0082). "
f"Move git-gate.user name/email into the 'author' block; "
f"git-gate.repos stays on the bottle."
)
unknown = set(d.keys()) - AGENT_MODEL_KEYS
if unknown:
allowed = ", ".join(sorted(AGENT_MODEL_KEYS))
@@ -205,30 +191,24 @@ class ManifestAgent:
f"(was {type(prompt_raw).__name__})"
)
# author: agent-owned git identity (optional; both fields required
# when present). Populates the bottle's user.name/user.email.
author = (
ManifestAuthor.from_dict(name, d["author"])
if "author" in d else None
)
# git-gate: agents may declare only `git-gate.user` (name/email).
# `git-gate.repos` is bottle-only — it carries credentials and host trust.
git_user = ManifestGitUser()
git_raw = d.get("git-gate")
if git_raw is not None:
gd = as_json_object(git_raw, f"agent '{name}' git-gate")
for k in gd:
if k != "user":
raise ManifestError(
f"agent '{name}' git-gate.{k} is not allowed at the "
f"agent level; only git-gate.user (name/email) may be "
f"set on an agent. git-gate.repos is bottle-only "
f"(it carries credentials and host trust)."
)
if "user" in gd:
git_user = ManifestGitUser.from_dict(name, gd["user"])
# forge-accounts: alias -> Gitea API origin + host token reference.
forge_accounts: dict[str, ManifestForgeAccount] = {}
forge_raw = d.get("forge-accounts")
if forge_raw is not None:
forge_d = as_json_object(forge_raw, f"agent '{name}' forge-accounts")
for alias, entry in forge_d.items():
forge_accounts[alias] = ManifestForgeAccount.from_dict(
name, alias, entry,
)
return cls(
bottle=bottle,
skills=skills,
prompt=prompt,
author=author,
forge_accounts=forge_accounts,
)
return cls(bottle=bottle, skills=skills, prompt=prompt, git_user=git_user)
def _parse_provider_settings(
+1 -4
View File
@@ -107,14 +107,11 @@ class ManifestBottle:
)
env[var] = value
# `git_user` is now an internal resolved carrier populated from the
# selected agent's `author` at composition time — it is never parsed
# from the bottle manifest (PRD 0082).
git: tuple[ManifestGitEntry, ...] = ()
git_user = ManifestGitUser()
git_raw = d.get("git-gate")
if git_raw is not None:
git = parse_git_gate_config(name, git_raw)
git, git_user = parse_git_gate_config(name, git_raw)
agent_provider = (
ManifestAgentProvider.from_dict(name, d["agent_provider"])
+1 -1
View File
@@ -210,7 +210,7 @@ def _fold_two_bottles(
for n in names
}
if merged_repos_raw:
merged_git = parse_git_gate_config("_fold", {"repos": merged_repos_raw})
merged_git, _ = parse_git_gate_config("_fold", {"repos": merged_repos_raw})
else:
merged_git = ()
-312
View File
@@ -1,312 +0,0 @@
"""Agent-owned author identity and forge accounts (PRD 0082).
`ManifestAuthor` is the agent's git author identity (name/email) — it moved off
`git-gate.user` (PRD 0027 / ADR 0002) and onto the trusted agent definition.
`ManifestForgeAccount` maps a **forge alias** to a canonical HTTPS Gitea API
origin plus the *name* of a host env var holding the operator-provided API
token. The token value never lives on the manifest; the host resolves it only
when a selected bottle repository references the alias, and hands it to the
egress proxy — never to the bottle.
`ResolvedForgeAssociation` is the composed view: one distinct forge alias that
at least one selected git-gate repository points at, with the repository names
that reference it. The proxy-provisioning and prompt-guidance steps consume it.
"""
from __future__ import annotations
import urllib.parse
from dataclasses import dataclass
from .schema import is_valid_entity_name
from .util import ManifestError, as_json_object
# Only the Gitea `/api/v1` base is supported today. A future provider adds a
# validator + auth scheme + guidance renderer in code rather than accepting a
# repository-supplied prompt or path (PRD non-goal: provider-generic prompts).
GITEA_API_PREFIX = "/api/v1"
# Auth schemes an agent forge account may declare. Gitea uses `token`.
FORGE_AUTH_TYPES = ("token",)
def canonicalize_forge_url(
agent_name: str, alias: str, url: object,
) -> tuple[str, str, str, str]:
"""Validate and canonicalize a forge account's API base URL.
Returns `(canonical, origin, host, api_prefix)` where `origin` is
`https://host[:port]` and `canonical` is `origin + api_prefix`.
Fails closed on: non-string, non-`https`, embedded userinfo, query, or
fragment, a missing host, or any path other than the supported Gitea API
base (`/api/v1`, trailing slash tolerated)."""
label = f"agent '{agent_name}' forge-accounts.{alias}.url"
if not isinstance(url, str) or not url:
raise ManifestError(f"{label} is required (non-empty string)")
try:
parts = urllib.parse.urlsplit(url)
except ValueError as e:
raise ManifestError(f"{label} is not a valid URL ({e}): {url!r}") from e
if parts.scheme != "https":
raise ManifestError(
f"{label} must use https (got scheme {parts.scheme!r} in {url!r})"
)
if parts.username or parts.password:
raise ManifestError(
f"{label} must not embed userinfo (user:pass@); the token is held "
f"host-side via auth.token_secret. Got {url!r}"
)
if parts.query:
raise ManifestError(f"{label} must not contain a query string: {url!r}")
if parts.fragment:
raise ManifestError(f"{label} must not contain a fragment: {url!r}")
host = parts.hostname
if not host:
raise ManifestError(f"{label} must include a hostname: {url!r}")
path = parts.path.rstrip("/")
if path != GITEA_API_PREFIX:
raise ManifestError(
f"{label} path must be the Gitea API base '{GITEA_API_PREFIX}' "
f"(got {parts.path!r} in {url!r}); other providers and API paths "
f"are not yet supported."
)
host = host.lower()
netloc = host if parts.port is None else f"{host}:{parts.port}"
origin = f"https://{netloc}"
return f"{origin}{path}", origin, host, path
@dataclass(frozen=True)
class ManifestAuthor:
"""The agent's git author identity. Both fields are required and
non-empty — an author that is declared must fully identify the agent.
Populates `user.name` / `user.email` inside the bottle."""
name: str
email: str
@classmethod
def from_dict(cls, agent_name: str, raw: object) -> "ManifestAuthor":
d = as_json_object(raw, f"agent '{agent_name}' author")
for k in d:
if k not in {"name", "email"}:
raise ManifestError(
f"agent '{agent_name}' author has unknown key {k!r}; "
f"allowed: name, email"
)
name = d.get("name")
if not isinstance(name, str) or not name:
raise ManifestError(
f"agent '{agent_name}' author.name must be a non-empty string"
)
email = d.get("email")
if not isinstance(email, str) or not email:
raise ManifestError(
f"agent '{agent_name}' author.email must be a non-empty string"
)
# Git identity validation: reject values that would break the
# `git config user.email` line or smuggle a second config directive.
if any(c in email for c in ("\n", "\r", " ", "\t")):
raise ManifestError(
f"agent '{agent_name}' author.email must not contain whitespace "
f"or newlines (got {email!r})"
)
if any(c in name for c in ("\n", "\r")):
raise ManifestError(
f"agent '{agent_name}' author.name must not contain newlines "
f"(got {name!r})"
)
return cls(name=name, email=email)
@dataclass(frozen=True)
class ManifestForgeAccount:
"""One forge alias on an agent: a canonical Gitea API origin plus the
host env var name that holds the agent-specific API token. The
authenticated account is whoever owns that token — there is no separate
account-name field."""
alias: str
url: str # canonical https base incl. /api/v1, no trailing slash
origin: str # https://host[:port]
host: str # lowercased hostname
api_prefix: str # /api/v1
auth_type: str # "token"
token_secret: str # host env var name holding the API token
@classmethod
def from_dict(
cls, agent_name: str, alias: str, raw: object,
) -> "ManifestForgeAccount":
if not is_valid_entity_name(alias):
raise ManifestError(
f"agent '{agent_name}' forge-accounts key {alias!r} is not a "
f"valid alias; must match [a-z][a-z0-9-]*"
)
label = f"agent '{agent_name}' forge-accounts.{alias}"
d = as_json_object(raw, label)
for k in d:
if k not in {"url", "auth"}:
raise ManifestError(
f"{label} has unknown key {k!r}; allowed: url, auth"
)
canonical, origin, host, api_prefix = canonicalize_forge_url(
agent_name, alias, d.get("url"),
)
if "auth" not in d:
raise ManifestError(f"{label} missing required 'auth' block")
auth = as_json_object(d.get("auth"), f"{label}.auth")
for k in auth:
if k not in {"type", "token_secret"}:
raise ManifestError(
f"{label}.auth has unknown key {k!r}; allowed: type, "
f"token_secret"
)
auth_type = auth.get("type")
if auth_type not in FORGE_AUTH_TYPES:
raise ManifestError(
f"{label}.auth.type must be one of "
f"{', '.join(FORGE_AUTH_TYPES)} (got {auth_type!r})"
)
token_secret = auth.get("token_secret")
if not isinstance(token_secret, str) or not token_secret:
raise ManifestError(
f"{label}.auth.token_secret must be a non-empty string (the "
f"name of a host env var holding the API token)"
)
return cls(
alias=alias,
url=canonical,
origin=origin,
host=host,
api_prefix=api_prefix,
auth_type=str(auth_type),
token_secret=token_secret,
)
@dataclass(frozen=True)
class ResolvedForgeAssociation:
"""A distinct forge alias referenced by one or more selected git-gate
repositories. `repo_names` lists the git-gate repo names pointing at
`account.alias` (sorted, deduplicated)."""
account: ManifestForgeAccount
repo_names: tuple[str, ...]
@property
def alias(self) -> str:
return self.account.alias
def resolve_forge_associations(
agent_name: str,
forge_accounts: "dict[str, ManifestForgeAccount]",
git_entries: "tuple[object, ...]",
) -> tuple[ResolvedForgeAssociation, ...]:
"""Compose the agent's forge accounts with the effective bottle's
git-gate repos. Each git entry that declares a `forge` alias must match a
forge account on the selected agent — otherwise fail closed before launch.
Returns one association per distinct referenced alias, carrying the repo
names that reference it. Unreferenced accounts produce nothing (no token
is resolved and no route is created for them)."""
by_alias: dict[str, list[str]] = {}
for entry in git_entries:
alias = getattr(entry, "Forge", "")
if not alias:
continue
if alias not in forge_accounts:
available = ", ".join(sorted(forge_accounts)) or "(none)"
raise ManifestError(
f"git-gate.repos['{getattr(entry, 'Name', '?')}'].forge "
f"references forge alias {alias!r}, which is not defined on "
f"agent '{agent_name}'. Available forge-accounts: {available}"
)
by_alias.setdefault(alias, []).append(getattr(entry, "Name", ""))
associations = tuple(
ResolvedForgeAssociation(
account=forge_accounts[alias],
repo_names=tuple(sorted(set(names))),
)
for alias, names in sorted(by_alias.items())
)
# The egress proxy routes by host, so two referenced aliases that resolve
# to the same host must agree on the credential and target — otherwise the
# generated plan would authenticate every call to that host as whichever
# alias happened to win, silently acting as the wrong forge account. Fail
# closed here (before the bottle is created) rather than dropping a
# credential at route-synthesis time.
by_host: dict[str, ManifestForgeAccount] = {}
for assoc in associations:
acct = assoc.account
prev = by_host.get(acct.host)
if prev is None:
by_host[acct.host] = acct
continue
if (prev.origin, prev.api_prefix, prev.auth_type, prev.token_secret) != (
acct.origin, acct.api_prefix, acct.auth_type, acct.token_secret
):
raise ManifestError(
f"agent '{agent_name}' forge-accounts '{prev.alias}' and "
f"'{acct.alias}' both resolve to host '{acct.host}' but differ "
f"in origin/auth/token_secret. The egress proxy routes by host, "
f"so the intended credential would be ambiguous — every request "
f"to '{acct.host}' would authenticate as one account. Give the "
f"aliases distinct hosts, or make their url/auth identical."
)
return associations
def render_forge_guidance(
associations: tuple[ResolvedForgeAssociation, ...],
) -> str:
"""Render the non-secret, provider-specific forge workflow guidance
appended to the agent's system prompt for associated repositories only.
Derived entirely from validated typed fields — never repository-supplied
Markdown. Contains neither the token value nor its `token_secret` name.
Returns "" when there are no associations (no section is generated)."""
if not associations:
return ""
lines: list[str] = [
"## Forge access (managed by bot-bottle)",
"",
"bot-bottle authenticates the forge API requests below for you "
"through the egress proxy. Do not read, print, or manually attach "
"an authorization token — the proxy injects it. Never place a token "
"in a request.",
]
for assoc in associations:
acct = assoc.account
for repo in assoc.repo_names:
lines.extend([
"",
f"### Repository `{repo}` (forge alias `{acct.alias}`)",
f"- Forge API base URL: `{acct.url}`.",
f"- This git-gate repository (`{repo}`) is tied to forge "
f"`{acct.alias}`; use its API for forge actions on this repo.",
f"- Call the API at `{acct.url}` over HTTPS through the proxy. "
"The proxy adds authentication; you never supply a token "
"yourself.",
"- Git pushes still use the git-gate remote, not the API.",
"- To propose changes: push a normal branch "
"(`refs/heads/<branch>`) through the git-gate remote, then open "
"a branch-backed pull request through the API.",
"- Do NOT push AGit review refs (`refs/for/*`, `refs/draft/*`, "
"`refs/for-review/*`); they are prohibited here.",
"- Use the API for reviews and comments, and verify the "
"returned object state before claiming the task is complete.",
])
return "\n".join(lines) + "\n"
+12 -38
View File
@@ -117,11 +117,6 @@ class ManifestGitEntry:
UpstreamHost: str = ""
UpstreamPort: str = ""
UpstreamPath: str = ""
# Optional forge alias (PRD 0082). When
# set, it must match a `forge-accounts` alias on the selected agent; the
# composition enables a scoped proxy-held API credential and forge
# workflow guidance for this repo. Empty = no forge association.
Forge: str = ""
@classmethod
def from_repos_entry(
@@ -144,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", "key", "host_key", "forge"}:
if k not in {"url", "key", "host_key"}:
raise ManifestError(
f"bottle '{bottle_name}' {label} has unknown key {k!r}; "
f"allowed: url, key, host_key, forge"
f"allowed: url, key, host_key"
)
upstream = d.get("url")
if not isinstance(upstream, str) or not upstream:
@@ -155,21 +150,6 @@ class ManifestGitEntry:
f"bottle '{bottle_name}' {label} missing required string field 'url'"
)
forge = d.get("forge", "")
if not isinstance(forge, str):
raise ManifestError(
f"bottle '{bottle_name}' {label} forge must be a string "
f"(was {type(forge).__name__})"
)
if forge and not _GIT_NAME_RE.match(forge):
# forge aliases follow the kebab-case identifier grammar; the
# cross-check against the agent's forge-accounts happens at
# composition time (it needs the resolved agent).
raise ManifestError(
f"bottle '{bottle_name}' {label} forge {forge!r} is not a "
f"valid forge alias; allowed characters: A-Z a-z 0-9 . _ -"
)
if "key" not in d:
raise ManifestError(
f"bottle '{bottle_name}' {label} missing required 'key' block"
@@ -196,7 +176,6 @@ class ManifestGitEntry:
UpstreamHost=host,
UpstreamPort=port,
UpstreamPath=path,
Forge=forge,
)
@@ -307,26 +286,21 @@ class ManifestGitUser:
def parse_git_gate_config(
bottle_name: str,
raw: object,
) -> tuple[ManifestGitEntry, ...]:
"""Parse `git-gate` on a bottle. Only `repos` is accepted; `git-gate.user`
moved to the agent's `author` block (PRD 0082)."""
) -> tuple[tuple[ManifestGitEntry, ...], ManifestGitUser]:
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate")
if "user" in d:
raise ManifestError(
f"bottle '{bottle_name}' git-gate.user is no longer supported "
f"(PRD 0082). Move name/email into "
f"the selected home agent's 'author' block:\n"
f" author:\n name: <name>\n email: <email>\n"
f"Identity is agent-owned; git-gate now carries only transport "
f"policy (repos)."
)
for k in d:
if k != "repos":
if k not in {"user", "repos"}:
raise ManifestError(
f"bottle '{bottle_name}' git-gate has unknown key {k!r}; "
f"allowed: repos"
f"allowed: user, repos"
)
git_user = (
ManifestGitUser.from_dict(bottle_name, d["user"])
if "user" in d
else ManifestGitUser()
)
git: tuple[ManifestGitEntry, ...] = ()
repos_raw = d.get("repos")
if repos_raw is not None:
@@ -337,4 +311,4 @@ def parse_git_gate_config(
)
validate_unique_git_names(bottle_name, git)
return git
return git, git_user
+74 -87
View File
@@ -19,7 +19,6 @@ from .util import ManifestError, as_json_object
from .agent import ManifestAgent
from .bottle import ManifestBottle
from .extends import merge_bottles_runtime, resolve_bottles
from .forge import ResolvedForgeAssociation, resolve_forge_associations
from .git import ManifestGitUser
from .loader import (
check_stale_json,
@@ -38,50 +37,30 @@ def _section_dict(value: object, label: str) -> dict[str, object]:
return as_json_object(value, label)
def _warn_ignored_cwd_dir(cwd_dir: Path, kind: str, home_path: str) -> None:
"""Warn (once) that manifest files of `kind` under `$CWD/.bot-bottle/`
are ignored — the filesystem layout IS the trust boundary. `kind` is the
subdir name (`bottles`/`agents`); `home_path` is where they belong."""
stale = cwd_dir / kind
if not stale.is_dir():
return
files = sorted(stale.glob("*.md"))
if not files:
return
names = ", ".join(p.name for p in files)
warn(
f"ignoring {kind[:-1]} file(s) under {stale}: {names}. "
f"{kind.capitalize()} can only live under {home_path} "
f"(PRD 0082). Move them or delete."
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,
)
def _compose_manifest(
agent_name: str,
agent: "ManifestAgent",
raw_bottle: "ManifestBottle",
def _manifest_with_merged_git_user(
agent: "ManifestAgent", raw_bottle: "ManifestBottle"
) -> "Manifest":
"""Build the single-value Manifest from the selected agent and its
effective bottle (PRD 0082):
- the agent's `author` populates the bottle's git user.name/user.email;
- each git-gate repo's `forge` alias is resolved against the agent's
`forge-accounts` (failing closed on an unknown alias) into the
Manifest's forge associations.
Shared by the eager (from_json_obj) and lazy (from_md_dirs) paths."""
identity = (
ManifestGitUser(name=agent.author.name, email=agent.author.email)
if agent.author is not None else ManifestGitUser()
)
"""Build the single-value Manifest, overlaying the agent's git-gate.user
onto the bottle (agent wins on non-empty, per-field). Shared by the eager
and lazy load_for_agent paths."""
merged = _merge_git_user(agent.git_user, raw_bottle.git_user)
bottle = (
raw_bottle if identity == raw_bottle.git_user
else replace(raw_bottle, git_user=identity)
raw_bottle if merged == raw_bottle.git_user
else replace(raw_bottle, git_user=merged)
)
associations = resolve_forge_associations(
agent_name, dict(agent.forge_accounts), bottle.git,
)
return Manifest(agent=agent, bottle=bottle, forge_associations=associations)
return Manifest(agent=agent, bottle=bottle)
def _resolve_effective_bottle_eager(
@@ -142,28 +121,26 @@ def _resolve_effective_bottle_lazy(
class Manifest:
"""Single-agent/bottle value type. Returned by ManifestIndex.load_for_agent().
`bottle` is the effective bottle with the agent's `author` already
populated into its git identity. `forge_associations` holds the distinct
forge aliases referenced by the effective bottle's git-gate repos, resolved
against the agent's `forge-accounts`. Backends and provisioners use this
directly — no agent_name lookup needed."""
`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
forge_associations: tuple[ResolvedForgeAssociation, ...] = ()
def git_identity_summary(self) -> str | None:
"""One-line effective git identity, e.g.
`name=claude, email=eric@dideric.is`. Sourced from the agent's
`author` block. Returns None when the agent declares no author."""
gu = self.bottle.git_user
if gu.is_empty():
"""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 gu.name:
parts.append(f"name={gu.name}")
if gu.email:
parts.append(f"email={gu.email}")
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)
@@ -187,15 +164,15 @@ class ManifestIndex:
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "ManifestIndex":
"""Walk the per-file manifest tree and build a ManifestIndex.
Layout:
Layout (PRD 0011):
$HOME/.bot-bottle/bottles/<name>.md — bottles (home-only)
$HOME/.bot-bottle/agents/<name>.md — agents (home-only)
$HOME/.bot-bottle/agents/<name>.md — home agents
$CWD/.bot-bottle/agents/<name>.md — cwd agents
Both agents and bottles are home-only
(PRD 0082): a `bottles/` or `agents/`
subdir under $CWD is logged as a warning and ignored — the filesystem
layout IS the trust boundary, since an agent may now select a host
identity and forge secret.
Cwd agents merge into the home agents on the same name
(cwd wins). A bottles/ subdir under $CWD is logged as a
warning and ignored — the filesystem layout IS the trust
boundary.
If `missing_ok` is true, a missing `$HOME/.bot-bottle/`
returns an empty index instead of dying. This is for
@@ -246,12 +223,17 @@ class ManifestIndex:
Used by tests to build a ManifestIndex from fixture directories
without touching `os.environ`."""
if cwd_dir is not None:
_warn_ignored_cwd_dir(cwd_dir, "bottles", "$HOME/.bot-bottle/bottles/")
# Agents became home-only in
# PRD 0082: a cwd agent file that
# once shadowed a home agent could select a host identity/secret,
# so it is now ignored with a migration pointer.
_warn_ignored_cwd_dir(cwd_dir, "agents", "$HOME/.bot-bottle/agents/")
stale_bottles = cwd_dir / "bottles"
if stale_bottles.is_dir():
files = sorted(stale_bottles.glob("*.md"))
if files:
names = ", ".join(p.name for p in files)
warn(
f"ignoring bottle file(s) under "
f"{stale_bottles}: {names}. Bottles can only "
f"live under $HOME/.bot-bottle/bottles/ "
f"(PRD 0011). Move them or delete."
)
return cls(bottles={}, agents={}, home_md=home_dir, cwd_md=cwd_dir)
@classmethod
@@ -293,12 +275,13 @@ class ManifestIndex:
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.
Agents are home-only (PRD 0082): cwd
agent files never contribute names."""
from_json_obj) it returns the pre-parsed agents' names."""
if self.home_md is not None:
return sorted(scan_agent_names(self.home_md / "agents").keys())
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(
@@ -343,7 +326,7 @@ class ManifestIndex:
raw_bottle = _resolve_effective_bottle_eager(
agent_name, agent, bottle_names, self.bottles
)
return _compose_manifest(agent_name, agent, raw_bottle)
return _manifest_with_merged_git_user(agent, raw_bottle)
def _load_for_agent_lazy(
self, agent_name: str, bottle_names: tuple[str, ...]
@@ -351,17 +334,20 @@ class ManifestIndex:
"""Lazy path (resolve/from_md_dirs): read and parse the agent file and
its bottle chain from disk for the first time here."""
assert self.home_md is not None # guaranteed by load_for_agent dispatch
# Agents are home-only (PRD 0082):
# a cwd agent file must not select a host identity or forge secret.
# 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 home_agents:
available = ", ".join(sorted(home_agents.keys())) or "(none)"
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 = home_agents[agent_name]
agent_path = merged_agents[agent_name]
try:
fm, body = parse_frontmatter(agent_path.read_text())
except OSError as e:
@@ -388,18 +374,15 @@ class ManifestIndex:
}
if agent_bottle:
agent_dict["bottle"] = agent_bottle
# Surface agent-owned identity keys (and any stale git-gate, so
# ManifestAgent.from_dict raises the migration error).
for key in ("author", "forge-accounts", "git-gate"):
if key in fm:
agent_dict[key] = fm[key]
if "git-gate" in fm:
agent_dict["git-gate"] = fm["git-gate"]
# Pass the effective bottle name as the known-bottles set so agents
# that have bottle: set are validated; agents without bottle: pass {}
# since bottle_names were already resolved above.
known = {effective_bottle_name} if effective_bottle_name else set()
agent = ManifestAgent.from_dict(agent_name, agent_dict, known)
return _compose_manifest(agent_name, agent, raw_bottle)
return _manifest_with_merged_git_user(agent, raw_bottle)
def has_agent(self, name: str) -> bool:
return name in self.agents
@@ -411,9 +394,13 @@ class ManifestIndex:
if self.has_agent(name):
return
if self.home_md is not None:
# Names-only mode: check home file existence without parsing.
# Agents are home-only; a cwd agent file is never selectable.
if (self.home_md / "agents" / f"{name}.md").is_file():
# 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
)
if home_path.is_file() or (cwd_path and cwd_path.is_file()):
return
available = ", ".join(self.all_agent_names) or "(none)"
raise ManifestError(
+1 -4
View File
@@ -22,10 +22,7 @@ BOTTLE_KEYS = frozenset(
}
)
AGENT_KEYS_REQUIRED: frozenset[str] = frozenset()
# `author` / `forge-accounts` are agent-owned identity (PRD
# 0082). `git-gate` is no longer accepted on an
# agent: `git-gate.user` moved to `author`, and `git-gate.repos` is bottle-only.
AGENT_KEYS_OPTIONAL = frozenset({"bottle", "skills", "author", "forge-accounts"})
AGENT_KEYS_OPTIONAL = frozenset({"bottle", "skills", "git-gate"})
# Claude Code subagent fields bot-bottle ignores at launch but does
# not reject. This lets the same file double as
+1 -11
View File
@@ -1,19 +1,9 @@
# PRD 0011: Per-file Markdown manifest
- **Status:** Active (agent cwd-discovery superseded)
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-24
> **Superseded in part by PRD 0082.**
> The `$CWD/.bot-bottle/agents/<name>.md` discovery/override path described
> below is removed: agents are now **home-only**, like bottles. Once an agent
> definition can select a host identity (`author`) and a host forge secret
> (`forge-accounts`), letting checked-out workspace content define or override
> an agent would let untrusted content select host credentials. A cwd
> `agents/` (or `bottles/`) directory is now warned-about and ignored. The
> filesystem-layout trust boundary still holds — it just admits nothing from
> `$CWD`.
## Summary
Replace the single-file `bot-bottle.json` manifest with a
@@ -1,391 +0,0 @@
# PRD 0082: Trusted agent forge identity and guidance
- **Status:** Accepted
- **Author:** didericis-claude
- **Created:** 2026-07-25
- **Issue:** #423
## Summary
Move author identity and named forge configurations into host-trusted agent
definitions. A bottle may optionally associate a git-gate repository with one
of the selected agent's forge aliases. When associated, bot-bottle gives the
agent non-secret, provider-specific system guidance for that repository and
routes authenticated forge API calls through the egress proxy without exposing
the forge token to the bottle. The authenticated account is inferred from the
identity that owns that token; it is not separately declared in the manifest.
Repository-local agent and bottle definitions are no longer discovered. Once
agent definitions can select a host forge credential, allowing checked-out
repository content to define or override an agent would let untrusted workspace
content select host identities and secrets.
This PRD is deliberately limited to identity ownership, manifest trust, forge
API access, and generated guidance. Per-activation signing, signature
enforcement, commit attribution, and the commit audit model move to a follow-up
PRD.
Successor to:
- **PRD 0011 (per-file manifests)** — allowed repository-local agent files to
override home agents. This PRD removes that trust path: agents and bottles are
loaded only from the host-owned `~/.bot-bottle` tree.
- **PRD 0027 (agent git identity, #94)** / **ADR 0002** — put name/email in
`git-gate.user` while keeping them claimed rather than vouched. This PRD moves
those agent properties out of git-gate and into the trusted agent definition.
- **PRD 0048 (deploy-key provisioning, #169)** — remains the Git push
capability. A forge actor token is a separate API credential and never
replaces a deploy key.
## Problem
### Forge workflow context is missing
The agent prompt does not know which forge backs a git-gate repository, which
API base URL to use, or that authenticated requests must go through the egress
proxy. Bespoke prompt text has drifted between agents. Missing guidance has
already caused incorrect PR behavior, including attempts to use Gitea AGit
review refs instead of a normal branch-backed pull request.
### Identity is owned by the wrong layer
`git-gate.user` puts an agent property on a repository transport component. An
author identity and set of forge credentials should follow the agent across
bottles and repositories. Git-gate should own Git transport policy, not decide
who the agent is.
### Repository-local agents become a credential-selection path
Today `$CWD/.bot-bottle/agents/*.md` can define new agents and override
home-resident agents. If an agent definition may reference an operator-provided
forge token, a malicious repository could select a host credential merely by
being the current workspace. Repository-local bottles are already ignored;
agents need the same host-only boundary.
## Goals / Success Criteria
- **Agent-owned identity.** Author name/email and named forge configurations
live on the agent definition, not under `git-gate`.
- **Trusted definitions only.** Agent and bottle files are discovered only
under `~/.bot-bottle/{agents,bottles}`. Repository-local definitions never
contribute names, defaults, or overrides.
- **Optional repository association.** A bottle repository may name one forge
alias from the selected agent. Repositories without `forge` retain current
behavior and generate no forge guidance or credential route.
- **Proxy-held forge credential.** The host resolves the selected forge
alias's token reference and gives the value only to the egress proxy. The
bottle receives neither the token nor a credential file containing it.
- **Forge-aware system guidance.** For associated repositories, bot-bottle
generates provider-specific instructions describing the API base URL,
repository/account association, proxy-authenticated access, and correct PR
workflow.
- **No secret prompt material.** Neither token values nor token
environment-variable names appear in the prompt or bottle environment.
- **Fail closed.** Unknown account references, unsupported/non-HTTPS URLs,
missing host secrets, and misplaced agent/bottle fields fail before creating
the bottle.
- **Push capability unchanged.** Git transport remains PRD 0048 deploy keys.
The forge actor token is only for API actions such as opening, reviewing, and
commenting on pull requests.
## Non-goals
- **Commit signing or signature enforcement.** No signing key is minted and
git-gate does not verify commit signatures in this PRD.
- **Commit attribution or audit tables.** There is no trustworthy commit
observation event in this slice. A follow-up signing PRD owns activation keys,
control-plane verification, and any configured-author-versus-claimed-author
audit model.
- **Cryptographically vouched author identity.** `author` configures Git and
identifies the agent actor, but commit author/committer fields remain claims
under ADR 0002.
- **Forge account or token minting.** The operator creates the agent-specific
account/token out of band. Bot-bottle references an existing host secret; it
does not create, rotate, or revoke that credential.
- **Forge-side commit attribution surfaces.** No commit status, signing-key
registration, or "Verified" badge.
- **Provider-generic arbitrary prompts.** Gitea is the first supported forge.
A future provider adds typed validation and generated guidance in code rather
than accepting repository-supplied prompt text.
## Design
### Ownership model
The resolved bottled agent is an agent definition composed with a bottle:
| Part | Source | Role |
|------|--------|------|
| Author identity | agent `author` | configures Git name/email and identifies the agent's claimed author |
| Forge configurations | agent `forge-accounts` | maps forge aliases to API origins and host token references available to this agent; token ownership determines account identity |
| Git repository | bottle `git-gate.repos` | configures Git transport, host verification, and push capability |
| Repository/forge association | bottle repo `forge` | optionally selects an agent forge alias for guidance and API access |
This boundary keeps identity on the agent, capability/policy on the bottle, and
transport enforcement in git-gate.
### Agent manifest
Agent identity and forge accounts live only in
`~/.bot-bottle/agents/<name>.md`:
```yaml
---
author:
name: didericis-claude
email: eric+claude@dideric.is
forge-accounts:
didericis-gitea:
auth:
type: token
token_secret: GITEA_CLAUDE_TOKEN
url: https://gitea.dideric.is/api/v1
---
```
`author` contains:
- `name`: non-empty string;
- `email`: non-empty string passing the existing Git identity validation.
The resolved values populate `user.name` and `user.email`. Existing
`git-gate.user` fields fail with migration guidance to move the values into the
selected home agent's `author` block. There is no compatibility period where a
bottle identity silently overrides the agent identity.
`forge-accounts` is a map whose keys are **forge aliases** and follow the
manifest's existing kebab-case identifier grammar
(`[a-z][a-z0-9-]*`). Each forge entry contains:
- `url`: a canonical HTTPS Gitea API base URL;
- `auth.type`: `token` in this slice;
- `auth.token_secret`: the name of a host environment variable containing the
operator-provided, agent-specific API token.
The token's owner determines the authenticated forge account; there is no
separate account-name field. The token secret name is host configuration, not
bottle configuration. The token value is resolved only if a selected bottle
repository references the forge alias.
### Bottle manifest
Repository policy remains in `~/.bot-bottle/bottles/<name>.md`:
```yaml
---
git-gate:
repos:
bot-bottle:
url: ssh://git@100.78.141.42:30009/didericis/bot-bottle.git
provisioned_key:
provider: gitea
token_env: GITEA_DEPLOY_TOKEN
host_key: "ssh-ed25519 AAAA..."
forge: didericis-gitea
---
```
`git-gate.repos.<name>.forge` is optional:
- When absent, the repository behaves exactly as it does today. Bot-bottle does
not resolve a forge actor token and does not add forge-specific instructions
for that repository.
- When present, it must match a forge alias in `forge-accounts` on the selected
agent.
The resolved association enables a scoped proxy credential route and adds the
repository/forge relationship to generated system guidance.
`provisioned_key.token_env` remains the deploy-key administration credential
from PRD 0048. It is separate from `forge-accounts.*.auth.token_secret`: the
former provisions Git push capability, while the latter performs API actions as
the agent.
`author` and `forge-accounts` are agent-only. `git-gate.repos`, including
`forge`, is bottle-only. Validation errors point to the correct file and trust
domain rather than ignoring misplaced keys.
### Definition trust and discovery
Only the host-owned manifest tree is authoritative:
- Agents: `~/.bot-bottle/agents/*.md`
- Bottles: `~/.bot-bottle/bottles/*.md`
`$CWD/.bot-bottle/agents/*.md` no longer contributes new agents and no longer
overrides a home agent. `$CWD/.bot-bottle/bottles/*.md` remains unusable. If
either repository-local directory contains manifest files, bot-bottle emits a
warning that they are ignored and points to the corresponding home path.
Every discovery and resolution surface uses the same home-only index:
- agent enumeration and selectors;
- `require_agent`;
- lazy `load_for_agent`;
- default-agent/default-bottle resolution;
- dashboard and headless launch paths.
There must be no alternate direct-path load that can still select a workspace
definition.
Workspace instructions remain repository content (for example `AGENTS.md`),
but runtime policy, host secret references, and actor identity do not.
Programmatic in-memory manifests remain available for tests and trusted internal
composition; they are not a filesystem discovery path.
### Forge URL validation
The host parses and canonicalizes each referenced forge alias's URL before
creating any runtime resources:
- scheme must be `https`;
- userinfo, query, and fragment are forbidden;
- hostname must be present;
- the path must be a supported Gitea API base (initially `/api/v1`, with
normalization of a trailing slash);
- visually different inputs that canonicalize to the same origin/prefix are
deduplicated;
- unsupported providers or path shapes fail closed.
The provider is determined by typed support in bot-bottle, not by prompt text
from a repository. Adding another provider requires a validator, auth scheme,
and guidance renderer.
### Proxy credential provisioning
For each distinct forge alias referenced by at least one selected bottle
repository, the host:
1. Resolves `auth.token_secret` from the host environment and rejects a missing
or empty value.
2. Copies the token value only into the egress proxy's credential environment.
3. Adds an inspected route scoped to the canonical forge origin and API prefix.
4. Configures the provider authentication scheme (`token` for Gitea) so the
proxy injects authentication.
The bottle makes an unauthenticated request to the configured HTTPS API URL.
The token is not copied into the bottle, `.gitconfig`, generated prompt,
workspace, or process environment visible to the agent.
If several repositories reference the same forge alias, they share one
credential route. Unreferenced forge aliases resolve no secret and create no
route.
### Generated system guidance
Bot-bottle appends a generated, non-secret section to its existing system
prompt file. It is derived from validated typed fields, not copied Markdown from
a repository.
For each associated repository, Gitea guidance includes:
- forge alias (for example `didericis-gitea`);
- API base URL (for example `https://gitea.dideric.is/api/v1`);
- the git-gate repository name tied to that forge;
- the instruction to call the configured HTTPS API through the proxy without
reading, printing, or manually attaching an authorization token;
- the distinction that Git pushes still use the git-gate remote;
- the requirement to create/update a normal `refs/heads/<branch>` and open a
branch-backed pull request through the API;
- the prohibition on pushing `refs/for/*`, `refs/draft/*`, or
`refs/for-review/*`;
- the instruction to use the API for reviews/comments and verify returned
object state before claiming completion.
The prompt contains neither the token value nor its `token_secret` name.
Repositories without `forge` are omitted from this section. If no selected
repository has a forge association, no forge guidance section is generated.
### Failure and lifecycle behavior
Forge configuration is validated before bottle creation. A bad association or
credential must not leave a partially-created bottle or proxy.
The operator-owned token is not minted, rotated, or revoked by bot-bottle. On
teardown, stopping the egress proxy discards the activation's in-memory/runtime
copy. Later activations resolve the current host secret again.
Logs may include the forge alias, canonical API origin, and repository name.
They must never contain the token value. Errors for missing secrets name the
configuration field and host environment variable, but do not print any value.
## Migration
This change is intentionally breaking at the manifest trust boundary:
1. Move each home bottle/agent `git-gate.user.name` and `.email` into the
corresponding home agent's `author`.
2. Add agent-specific `forge-accounts` only to home agent definitions.
3. Add optional `forge` associations to home bottle repository entries.
4. Move any `$CWD/.bot-bottle/agents/*.md` that should remain selectable into
`~/.bot-bottle/agents/`. Repository copies are ignored thereafter.
5. Keep repository-specific behavioral instructions in `AGENTS.md` or another
workspace instruction file; do not put runtime identity or secret references
there.
Errors and warnings link to this migration rather than silently changing which
identity or definition is active.
## Follow-up: signed commits and attribution
A separate PRD will consume the trusted agent `author` introduced here and own:
- per-activation signing key minting and sidecar isolation;
- commit-time signing through a forwarded signing capability;
- git-gate rejection of unsigned/wrong-key new commits;
- independent control-plane object-ID recomputation and signature verification;
- activation and attributed-commit audit tables;
- storage of configured agent author separately from each commit's unenforced
claimed author/committer;
- post-teardown verification and key-retention policy.
That PRD must not reintroduce identity under git-gate or expand repository-local
manifest trust.
## Implementation chunks
1. **This PRD.** Establish the identity, forge, and filesystem trust boundary.
2. **Home-only definitions.** Remove cwd agents from discovery, override,
enumeration, lazy loading, defaults, and selectors. Warn on ignored cwd
agent/bottle files and provide migration guidance.
3. **Manifest schema.** Add agent-only `author` and `forge-accounts`; remove
`git-gate.user`; add optional bottle-only
`git-gate.repos.<name>.forge`. Validate account names, composition
references, field placement, and Gitea API URLs.
4. **Proxy provisioning.** Lazily resolve only referenced token secrets and
create scoped authenticated Gitea API routes without putting credentials in
the bottle.
5. **Prompt generation.** Render typed Gitea/repository workflow guidance into
the existing bot-bottle prompt path for associated repositories only.
6. **Docs and migration.** Update README examples, PRD 0011-facing discovery
documentation, agent/bottle schema docs, and error guidance.
## Testing strategy
- **Trust boundary:** cwd agent files are ignored with a warning, cannot
override a home agent, are absent from enumeration/selectors/defaults, and
cannot be loaded by name. Cwd bottle behavior remains home-only.
- **Agent schema:** `author` and `forge-accounts` parse; malformed identities,
non-kebab account names, unknown fields, invalid auth types, and misplaced
git-gate fields fail clearly.
- **Bottle schema:** `forge` is optional; absent associations preserve current
behavior; present associations resolve against the selected agent; unknown or
misplaced associations fail before launch.
- **URL validation:** HTTPS Gitea API bases pass and canonicalize; HTTP,
userinfo, query, fragment, missing host, unsupported paths, and unsupported
providers fail closed.
- **Secret handling:** only referenced accounts resolve environment secrets;
missing/empty secrets fail before runtime creation; token values are absent
from bottle env, prompt, generated config, logs, and workspace; token secret
names are absent from the bottle and prompt.
- **Proxy behavior:** associated API requests receive proxy-injected Gitea
authentication scoped to the configured origin/prefix; unrelated hosts and
paths receive no credential.
- **Prompt behavior:** guidance names account/API/repository associations,
branch-backed PR workflow, prohibited AGit refs, and mutation verification;
repositories without `forge` are omitted; no associations means no section.
- **Migration:** legacy `git-gate.user` fails with an `author` migration pointer;
ignored cwd definitions warn with the target home path.
## Open questions
- None.
+293 -8
View File
@@ -32,9 +32,17 @@ not a principled scope exclusion: both are major hosted sandbox platforms and
belong in this landscape even though they target platform builders rather than
bot-bottle's local single-operator workflow.
Updated 2026-07-27 after a scan of recent Show HN launches: **Black LLAB,
Eve, CloudRouter, Nucleus, yolo-cage, and Sandbox Agent SDK** added as a
dated entrant cohort. They sharpen the comparison on three axes the original
table underweighted: the browser/preview loop, parallel-agent operator UX, and
a provider-neutral automation/session API.
## Summary
The main table compares bot-bottle against fifteen isolation/sandbox tools.
The main table compares bot-bottle against fifteen canonical
isolation/sandbox tools; a later section evaluates six recent HN entrants
without widening an already unwieldy table.
Governance/pre-action authorization and credential-only layers are covered
separately because they don't provide VM or container isolation. None
duplicate bot-bottle's combination of local
@@ -542,6 +550,199 @@ them.
framework runtime is not compromised.
- **Maturity**: Specification + reference implementation, 2026.
## Recent HN entrants (added 2026-07-27)
These are grouped by launch date rather than promoted into the main table.
Several are young or sparsely documented, and putting them beside mature
runtime platforms with false precision would obscure the useful comparison.
The HN launch posts are the evidence snapshot; feature claims should be
rechecked against their repositories before relying on them for a security
decision.
### Black LLAB
- **Source**: https://github.com/isaacdear/black-llab ;
HN launch https://news.ycombinator.com/item?id=47402394
- **Isolation/locality**: Local Docker environment, with an isolated container
created for each agent task. Shared host kernel; no stronger boundary is
claimed.
- **Agent integration**: General local/cloud model workspace. Its headline is
dynamic routing of simple prompts to local models and complex prompts to
hosted models, with code execution and web scraping inside the task
container.
- **Network/credentials**: No default-deny egress, payload inspection, or
host-side credential injection documented in the launch.
- **Competitive read**: Superficial overlap ("a container per agent task"),
but not a direct security-policy competitor. Its useful challenge is the
integrated model-selection UX, which bot-bottle intentionally leaves to the
selected agent provider.
- **Maturity**: Early solo project; HN launch received 1 point.
### Eve
- **Source**: https://eve.new/ ;
HN launch https://news.ycombinator.com/item?id=47721255
- **Isolation/locality**: Managed, hosted Linux sandbox per user/session
(claimed 2 vCPU, 4 GB RAM, 10 GB disk), with filesystem, code execution,
headless Chromium, and service connectors.
- **Agent integration**: End-user OpenClaw-style agent product. An orchestrator
routes subtasks to specialist models and can run parallel subagents that
coordinate through a shared filesystem. Web UI and iMessage are primary
interaction surfaces.
- **Network/credentials**: Broad connectors are a product feature; the launch
does not document bot-bottle-style default-deny route policy, content DLP,
or credentials held outside the sandbox.
- **Competitive read**: Adjacent, not direct. Eve sells a managed colleague;
bot-bottle lets an operator run existing coding-agent CLIs under local
containment. Eve nevertheless demonstrates the appeal of background work,
live progress, browser capability, and mobile notification.
- **Maturity**: Commercial hosted product; HN launch received 71 points and
39 comments.
### CloudRouter
- **Source**: https://github.com/manaflow-ai/manaflow/tree/main/packages/cloudrouter ;
HN launch https://news.ycombinator.com/item?id=47006393
- **Isolation/locality**: Claude Code or Codex runs locally and provisions
remote cloud VMs/GPUs for execution. Project files are uploaded to the VM;
each machine exposes auth-protected VNC, VS Code, and Jupyter surfaces.
- **Agent integration**: A skill plus CLI lets the coding agent itself start,
command, inspect, and tear down machines. Browser automation is integrated,
including snapshots and screenshots. Parallel disposable compute is the
central workflow.
- **Network/credentials**: The launch emphasizes remote resource isolation and
authenticated UI endpoints, not default-deny guest egress, payload DLP, or
proxy-held application credentials.
- **Competitive read**: The closest recent workflow competitor. It directly
addresses parallel coding agents, environmental conflict, and closing the
browser/test loop, but trades local custody for elastic cloud compute.
Cloud VMs and GPUs could be a future bot-bottle backend; they do not replace
its manifest/policy layer.
- **Maturity**: Active open-source monorepo project; HN launch received
138 points and 36 comments.
### Nucleus
- **Source**: https://github.com/coproduct-opensource/nucleus ;
HN launch https://news.ycombinator.com/item?id=46855770
- **Isolation/locality**: Firecracker microVM with an enforcing MCP tool proxy.
- **Agent integration/config**: Compositional permission envelope for
read/write/run actions. The envelope is non-escalating and can tighten or
terminate, with scoped approval tokens for gated operations.
- **Network/credentials**: Default-deny egress, DNS allowlist, iptables drift
detection, time/budget caps, and hash-chained audit logging are claimed.
Remote append-only audit storage and attestation were roadmap items at
launch.
- **Competitive read**: Direct on security architecture, especially
non-escalating policy and tamper-evident audit. It is an early execution/tool
proxy rather than a provider-neutral, one-command coding-agent product. Its
tool-level action envelope is semantically finer than bot-bottle's network
boundary; bot-bottle is stronger on turnkey agent/provider integration,
credential custody, Git mediation, and long-running operator workflow.
- **Maturity**: Early OSS experiment; HN launch received 3 points.
### yolo-cage
- **Source**: https://github.com/borenstein/yolo-cage ;
HN launch https://news.ycombinator.com/item?id=46706796
- **Isolation/locality**: Local sandbox for running multiple coding agents in
YOLO mode. The launch discussion describes a VM boundary.
- **Agent integration**: Built around the native Claude Code experience and
motivated by running many agents in parallel without permission-prompt
fatigue.
- **Network/Git/credentials**: Strict egress filtering, configurable HTTP
middleware, and mediated `git`/`gh` dispatch are the main value. The launch
discussion explicitly identifies provider credential handling as unfinished
and difficult because Claude state spans multiple host paths.
- **Competitive read**: The closest new threat-model competitor. It shares
bot-bottle's premise that filesystem isolation alone is insufficient and
that Git plus authorized HTTP channels need mediation. bot-bottle currently
leads on cross-provider support, proxy-held Claude/Codex/forge credentials,
typed per-role manifests, content DLP, and supervision. yolo-cage's simpler
pitch and narrower Claude-first setup may be easier to explain.
- **Maturity**: Early local tool; HN launch received 60 points and 76 comments.
### Sandbox Agent SDK
- **Source**: https://github.com/rivet-dev/sandbox-agent ;
HN launch https://news.ycombinator.com/item?id=46795584
- **Isolation/locality**: Does not provide the isolation primitive. It runs
inside E2B, Daytona, Modal, Cloudflare Containers, Agent Computer, BoxLite,
Docker, or another sandbox provider. Embedded mode can also run locally
without a sandbox.
- **Agent integration**: Provider-neutral Rust server/SDK exposing a common
HTTP/SSE/OpenAPI interface across Claude Code, Codex, OpenCode, Cursor, Amp,
and Pi, plus a universal event/session schema for external storage and
replay. It also exposes filesystem, managed-process, terminal, MCP, skills,
custom-tool, and computer-use APIs. TypeScript is the primary SDK surface.
- **Network/credentials**: Delegated to the chosen sandbox provider.
- **Credential posture**: Its documented convenience command extracts real
OpenAI/Anthropic credentials from local agent configuration and passes them
as environment variables into the sandbox. That is materially weaker than
bot-bottle's host-side credential custody, but it is an integration choice,
not a structural limitation: a sandbox provider could put a credential
proxy underneath the same SDK.
- **Competitive read**: A serious architectural threat despite not supplying
isolation. Sandbox Agent is trying to standardize the boundary *above* the
sandbox: one client protocol, session model, and UI/control surface across
every coding agent and runtime. If that boundary becomes the ecosystem
standard, users and application builders may choose a sandbox provider plus
Sandbox Agent rather than a vertically integrated launcher. bot-bottle's
manifests would then be valuable chiefly as a local policy/backend
implementation unless they expose an equally usable control contract.
- **Maturity**: Apache 2.0, ~1.5k stars and 426 commits at the 2026-07-27
check; HN launch received 41 points.
#### Why the Sandbox Agent architecture is strategically different
The manifest and the universal control protocol solve different layers:
- A bot-bottle manifest is a **trusted launch-time policy composition**. It
selects the agent role, isolation backend, image, skills, egress routes,
credentials, Git mediation, and supervision policy. Crucially, identity and
secret references live on the host side of the trust boundary.
- Sandbox Agent is a **runtime control and observation protocol**. A remote
client creates sessions, sends messages, handles permissions, configures
skills/MCP, manipulates files/processes/desktops, and streams normalized
events. It deliberately delegates sandbox lifecycle, Git management,
storage, network policy, and credential security to other products.
That makes it complementary in a component diagram but competitive in product
architecture. The layer that becomes the stable integration point tends to own
the ecosystem. Three plausible threat paths matter:
1. **Standard control plane, interchangeable runtimes.** Applications integrate
once with Sandbox Agent and treat E2B, Daytona, BoxLite, Docker, or a future
local microVM as replaceable compute. A provider that bundles adequate
egress and credential custody makes bot-bottle's end-to-end launcher less
necessary.
2. **Policy grows upward.** Sandbox Agent already configures permissions,
skills, MCP, custom tools, filesystem/process access, and computer use. If
it adds a declarative, host-verifiable policy document, the overlap with
agent/bottle manifests becomes substantial even if enforcement remains
delegated.
3. **UI and session ownership.** Its universal transcript schema, Inspector,
React components, event replay, and remote terminal/computer APIs can become
the natural basis for desktop, web, and mobile agent managers. bot-bottle's
security layer could remain stronger while losing the operator surface and
distribution channel.
The counter-position is not to claim that manifests and an API are mutually
exclusive. The defensible split is:
- bot-bottle owns the trusted policy and enforcement plane outside the agent;
- a provider-neutral protocol owns agent process control and normalized
events; and
- the operator UI consumes both.
This suggests an explicit compatibility decision rather than parallel,
accidental protocol design: evaluate running Sandbox Agent inside a bottle and
exposing it only through the authenticated bot-bottle control plane. If its
schema is suitable, adopting it could turn a threat into an integration while
keeping manifests as the higher-trust policy source. If it is unsuitable,
bot-bottle should still publish a stable provider-neutral session/event API so
frontends do not depend on Claude/Codex/Pi-specific process behavior.
## Comparison table
*Isolation/sandbox tools only. AGT and OAP are governance layers — see their per-project notes above.*
@@ -616,6 +817,70 @@ would be a *backend* bot-bottle could call, not a competitor to its
manifest layer. endo-familiar is in a different paradigm entirely:
capability passing rather than kernel boundaries.
**Recent entrants change two parts of this read.** yolo-cage is closer to the
actual threat model than agent-safehouse or litterbox: it combines a VM-style
boundary with mediated Git and filtered HTTP specifically for parallel coding
agents. Sandbox Agent SDK is the more important strategic entrant even though
it supplies no isolation. It can become the standard agent-control layer above
all of these runtimes, including a future bot-bottle backend. CloudRouter is
the clearest workflow challenge because its browser/desktop/GPU loop makes
parallel agents visibly more capable, not merely safer.
## Gap evaluation after the 2026-07-27 entrant scan
### Material gaps
1. **A stable provider-neutral control and event protocol.** This is the
largest newly visible gap. bot-bottle normalizes launch/provisioning across
providers, but an external UI or orchestrator still lacks one documented
contract for creating a Claude/Codex/Pi session, sending input, handling
permission/supervision events, streaming normalized output, reconnecting,
and replaying history. Sandbox Agent SDK addresses exactly this layer and
is already portable across many sandbox providers.
2. **Browser/preview closure.** CloudRouter and Eve make a browser or desktop
part of the standard agent environment and expose screenshots/live viewing
to the operator. bot-bottle can run dev servers and supports nested
containers, but it does not present a first-class browser/computer-use
primitive or an auth-protected preview surface. For coding agents expected
to verify UI work, this is a real product gap.
3. **Unified parallel-session operator UX.** Named persistent bottles and
supervision provide the substrate, but the recent products make task
switching, live progress, notifications, terminal attach, diffs, and
session history the product. Security depth will not compensate for a
visibly rougher daily loop.
4. **Normalized transcript persistence and replay.** bot-bottle preserves
provider-specific state for resume; it does not expose a provider-neutral
event record suitable for audit, replay, analytics, or a web/mobile client.
This is both a UX gap and an audit gap.
### Important, but not necessarily bot-bottle features
- **Cloud VM/GPU provisioning.** Valuable for elastic workloads and could be a
backend, but it conflicts with the local-custody default and should not
displace core policy work.
- **Automatic model routing.** Black LLAB and Eve sell task-to-model routing.
bot-bottle's provider-template boundary can host that choice without making
it part of the trusted sandbox policy.
- **A thousand SaaS connectors.** This broadens capability and blast radius.
The bot-bottle-native answer should remain explicit, scoped forge/egress
associations rather than connector count as a goal.
- **SDK-driven sandbox lifecycle as the primary configuration model.** Useful
for platform builders, but not a replacement for reviewable, host-owned
manifests. A control API and a declarative policy source are compatible;
neither should silently become the other.
### Areas where bot-bottle remains ahead
- real provider and forge credentials remain outside the agent process rather
than being extracted into its environment;
- authorized HTTP payloads are scanned, not merely destination-filtered;
- Git writes traverse a distinct gate with secret scanning and host-held
upstream credentials;
- role policy is host-owned, composable, and separate from untrusted repo
content; and
- local Firecracker/Apple Container execution preserves operator custody
without requiring a hosted sandbox platform.
## Borrowable ideas
### Already shipped or otherwise addressed
@@ -642,6 +907,19 @@ capability passing rather than kernel boundaries.
### Still worth considering
- **Sandbox Agent compatibility or an equivalent stable protocol (highest
priority):** spike running its server inside a bottle behind bot-bottle's
authenticated control plane. Compare its session/event schema, permission
model, restore semantics, and provider coverage with current provider
adapters. Adopt compatibility if it preserves the host-owned trust boundary;
otherwise specify bot-bottle's own stable API before building another UI.
- **First-class browser/preview loop** (from CloudRouter and Eve): give a
bottle an optional browser/computer-use capability plus an operator-visible,
authenticated preview/screenshot surface. Treat its network access as part
of the bottle policy, not an implicit bypass.
- **Provider-neutral transcript/event persistence** (from Sandbox Agent SDK):
retain enough normalized structure for replay and audit while preserving the
provider-native state needed for exact resume.
- **Live network activity in the supervisor TUI** (from Docker sbx): show
allowed and blocked connections and let the operator propose policy changes
from the existing supervision surface.
@@ -652,10 +930,11 @@ capability passing rather than kernel boundaries.
closer review. This needs a carefully specified trust model before it can be
more than a heuristic.
Not worth borrowing: the SDK-first programmatic API style of boxlite /
microsandbox (cuts against the declarative-manifest stance), and the
hosted-SaaS dashboard model of tilde.run (cuts against the
"infrastructure I control" goal).
Not worth borrowing: SDK-first *policy configuration* as used by boxlite /
microsandbox (cuts against the reviewable declarative-manifest stance), and
the hosted-SaaS custody model of tilde.run (cuts against the "infrastructure I
control" goal). A provider-neutral runtime-control API is a separate concern
and is worth borrowing.
## Publishing and positioning verdict
@@ -679,9 +958,15 @@ bot-bottle remains unusual in combining:
The practical wedge is “as easy as native yolo, with declarative role policy
and self-hosted custody,” including scoped access to private LAN/Tailnet
services that cloud-first runtimes cannot provide without additional network
plumbing. The main competitive risks are a local wrapper such as claudebox or
Docker sbx growing a role-manifest layer, and GUI products such as SuperHQ
adding equivalent policy and audit depth.
plumbing. The main competitive risks are now:
- a local wrapper such as yolo-cage, claudebox, or Docker sbx growing a
role-manifest and credential-custody layer;
- Sandbox Agent SDK becoming the standard control/session boundary and making
the runtime beneath it interchangeable; and
- GUI products such as SuperHQ or CloudRouter adding equivalent policy and
audit depth before bot-bottle closes the browser/preview and
parallel-session UX gaps.
## Caveats
@@ -0,0 +1,258 @@
# Testing a clean bot-bottle install on macOS
How do you exercise `install.sh` (and, ideally, a first `bot-bottle start`)
the way a brand-new user would — on a pristine macOS environment you can
throw away afterward — *without* permanently polluting your daily-driver
Mac? The user's framing: is there a VM or boundary that avoids creating a
separate account, or is spinning up and tearing down a throwaway macOS
user on the CLI easy enough to just do that?
## Summary
There is no lightweight, in-place macOS sandbox that hands you a clean home
directory and wipeable system state without *either* a VM or a separate
user account. `sandbox-exec` (Seatbelt) is deprecated and confines a
process, not an environment; App Sandbox is for shipping apps, not for
provisioning a fresh dev host. So the real choice is exactly the two the
user named: **a disposable macOS VM** or **a throwaway user account**
and which one is right turns on a detail specific to *this* project.
bot-bottle's default macOS backend is Apple's `container`, which runs each
container in its own lightweight VM via `Virtualization.framework`
([`README.md:27`](../README.md), [`apple-container-backend.md`](apple-container-backend.md)).
That means a full end-to-end test — install *and* `bot-bottle start`
needs virtualization to work wherever bot-bottle runs. Inside a macOS guest
VM that requires **nested virtualization, which Apple gates to M3 or newer
chips on macOS 15+**. On M1/M2 you cannot run the Apple Container backend
(or Docker Desktop, same reason) inside a macOS VM at all.
The recommendation splits on what you're testing and what silicon you have:
- **Install-script correctness only** (does `curl | sh` → pipx → config dir
`doctor`'s Python/config checks pass?): a **disposable Tart VM** is the
cleanest boundary and works on any Apple Silicon Mac. `doctor` will report
the backend as not-ready inside the VM on M1/M2, which is fine — you're
testing the installer, not the runtime.
- **Full runtime** (actually launch a bottle) on **M3/M4**: a **disposable
Tart VM from a golden base image, cloned per run** is the gold standard —
a genuine kernel/state boundary that wipes to nothing.
- **Full runtime** on **M1/M2**, or when you'd rather not fight nested virt:
a **throwaway admin user via `sysadminctl`** is the pragmatic pick. It
tests the real backend because the backend runs on the host hypervisor —
but it is a *hygiene* boundary, not a security one, and it does **not**
clean the system-level footprint (see below).
Prefer the VM. Reach for the throwaway user only when nested virt is off the
table and you accept an imperfect wipe.
## Why "a boundary without a separate user" doesn't really exist on macOS
macOS has no namespace/overlay story like Linux `unshare` + tmpfs. The
options that sound like in-place sandboxes don't fit:
| Mechanism | Why it doesn't give you a clean, wipeable env |
|---|---|
| `sandbox-exec` / Seatbelt | Officially deprecated; confines *one process's* syscalls against a profile. It cannot present a fresh `$HOME` or a pristine `/usr/local`, and it won't let the Apple Container system service work. |
| App Sandbox | Entitlement-based confinement for signed `.app` bundles, not a provisioning tool for a CLI dev environment. |
| A second `$HOME` via `HOME=/tmp/foo` | Redirects only what honors `$HOME`. `install.sh` mostly does (it writes `~/.bot-bottle` and pipx/pip `--user` paths), but the Apple `container` install lands in `/usr/local` + a **system service**, and Homebrew lands in `/opt/homebrew` — all outside any `$HOME` you set. You'd get a false sense of "clean." |
| APFS snapshot rollback (`tmutil localsnapshot`) | You can't roll the live boot volume back to a local snapshot without booting to Recovery; it's not a per-run userspace undo. |
So the honest answer to "is there some boundary that avoids a separate
user?": yes — a **VM** — and it's the *stronger* boundary anyway. The only
lighter-weight option is the separate user, with the caveats below.
## What a clean install actually touches (the footprint that decides "wipeable")
Grounding the teardown story in what `install.sh` and the backend create:
| Artifact | Location | In `$HOME`? | Survives user deletion? |
|---|---|---|---|
| Config / state / db | `~/.bot-bottle/{agents,bottles,contrib,state,db}` ([`install.sh:80-83`](../install.sh), [`bot_bottle/paths.py:59`](../bot_bottle/paths.py)) | ✅ | ❌ removed with home |
| pipx venv + shim | `~/.local/pipx/venvs/bot-bottle`, shim in `~/.local/bin` ([`install.sh:87-89`](../install.sh)) | ✅ | ❌ removed with home |
| private venv fallback (no pipx) | `~/.bot-bottle/venv` + symlink in `~/.local/bin` ([`install.sh`](../install.sh)) | ✅ | ❌ removed with home |
| PATH / token exports | shell profile (`~/.zprofile`, etc.); `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` ([`README.md:74`](../README.md)) | ✅ | ❌ removed with home |
| **Apple `container` install** | `/usr/local/...` + notarized `.pkg` receipts | ❌ | ✅ **stays** |
| **Apple `container` system service** | launchd system service (`container system start`) | ❌ | ✅ **stays** |
| **Homebrew** (if used for `container`/python) | `/opt/homebrew` | ❌ | ✅ **stays** |
| Rosetta 2 (needed for image builds) | system | ❌ | ✅ **stays** |
The three bold rows are the crux: **deleting the throwaway user does not
uninstall the Apple Container runtime, its system service, Homebrew, or
Rosetta.** A VM, by contrast, wipes 100% of the above by definition —
that's its entire advantage for this task.
## Option A — Disposable Tart VM (recommended)
[Tart](https://tart.run) is a CLI-first macOS/Linux VM manager built on
`Virtualization.framework`, purpose-built for exactly this "does it work on
a clean macOS, without my settings/permissions/data" workflow. Keep one
pristine *golden* image, clone a throwaway per run, delete it after.
```sh
brew install cirruslabs/cli/tart
# One-time: build a golden base (either a prebuilt image or a vanilla IPSW).
tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest golden # ~25 GB pull
# — or a truly vanilla install you click through once —
# tart create golden --from-ipsw latest --disk-size 60
# Per test run: clone → boot → test → destroy.
tart clone golden test-run
tart run test-run &
ssh admin@"$(tart ip test-run)"
# inside the guest:
# curl -fsSL https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh | sh
# bot-bottle doctor
tart stop test-run
tart delete test-run # back to pristine; golden is untouched
```
Cloning is cheap (sparse files), so the golden image is your reset button —
every `tart clone` is a fresh macOS. This is the closest thing to a Linux
`docker run --rm` for a whole Mac.
**The nested-virt caveat (read before relying on it for runtime tests).**
The Apple Container backend inside the guest needs
`Virtualization.framework` to work *inside* the VM. Apple enables nested
virtualization only on **M3 or newer**, on **macOS 15 (Sequoia) or later**;
M2 and earlier are excluded by Apple, confirmed by Apple DTS. Consequences:
- **M3/M4 host:** full runtime works in the guest. `bot-bottle doctor`
reports the backend ready and `start` can launch a bottle. Gold standard.
- **M1/M2 host:** the guest can install bot-bottle and pass the Python /
config-dir checks, but `doctor`'s backend check will fail and you cannot
launch a bottle in the VM. Still perfectly good for testing *the
installer*; not for the runtime.
- **M4-specific:** a known bug blocks pre-Ventura guests on M4; use a
current macOS guest (which you want anyway, since Apple `container`
targets macOS 26 Tahoe).
UTM is the GUI equivalent on the same framework (and was first to expose
nested virt) if you'd rather click; Tart wins for a scriptable
spin-up/tear-down loop.
## Option B — Throwaway user via `sysadminctl` (pragmatic fallback)
Creating and deleting a user from the CLI is genuinely a two-liner, and it
tests the **real** backend on any Apple Silicon Mac because the backend runs
on the host hypervisor — no nested virt needed.
```sh
# Create a self-contained admin user (admin needed for the container service).
sudo sysadminctl -addUser bbtest -fullName "bot-bottle test" \
-password 'throwaway' -admin
# Log into that account (fast-user-switch or the login window), then run the
# installer as bbtest exactly as a new user would. When done:
sudo sysadminctl -deleteUser bbtest -secure # -secure erases the home dir
```
Honest accounting of what this does and doesn't buy you:
- **Boundary strength:** it's a *hygiene / fresh-`$HOME`* boundary, **not a
security boundary.** Same kernel, same admin group; an admin test user can
touch system state. If the point is "clean environment," fine. If the point
is "contain something untrusted," this is the wrong tool — use a VM.
- **Wipe completeness:** `-secure` erases the home dir (so `~/.bot-bottle`,
the pipx venv, and profile exports go away), but as the footprint table
shows, the **Apple Container runtime, its launchd system service,
Homebrew, and Rosetta persist.** For a truly repeatable "did a *system with
nothing installed* work?" test, that residue defeats the purpose — the
second run isn't clean.
- **Operational gotchas:** don't pass real passwords on the command line (they
land in `ps` and history — this is a throwaway credential, so it's
tolerable here). Deletion must run as root from a normally-booted, admin-
logged-in session; the Terminal needs **Full Disk Access** or you'll hit
error `-14120` and a half-deleted account. Prefer letting the system place
the home dir (don't pass `-home`), or deletion can orphan it.
Use this when you're on M1/M2, you specifically want to exercise the live
backend, and you can tolerate the system-level runtime staying installed
between runs (or you uninstall Apple `container` / brew by hand to reset).
## Honorable mentions
- **External bootable macOS volume.** A fresh macOS on an external SSD (or a
separate APFS volume) is bare-metal disposable: no nested-virt limit, real
backend works, and you `diskutil` the volume away to reset. Cost is reboot
friction per run — good for an occasional thorough pass, poor for a tight
loop.
- **Rented / cloud Mac.** AWS EC2 Mac (dedicated Mac minis), Scaleway Apple
silicon, or MacStadium give a genuinely throwaway host you release when
done. Overkill for local iteration, but this is essentially what the
project's own advisory `integration-macos` CI job needs — a self-hosted
Apple Silicon runner with the `container` CLI, Python ≥ 3.11, and coverage
on the launchd service's PATH ([`README.md:78`](../README.md)). If you end
up standing up a cloud Mac for install testing, it doubles as that runner.
## Recommendation
Default to a **disposable Tart VM** — it's the only option that wipes the
*entire* footprint (including the Apple Container system service that a user
deletion leaves behind), it's a real boundary, and the spin-up/tear-down
loop is a two-command `tart clone` / `tart delete`. Confirm your chip first:
on **M3/M4** it tests install *and* runtime end-to-end; on **M1/M2** it still
cleanly tests `install.sh` + `doctor`'s Python/config path, and you fall back
to a **throwaway `sysadminctl` admin user** for live-backend testing —
accepting that it's a hygiene boundary and that you'll manually uninstall the
Apple Container runtime / Homebrew between runs to get back to truly clean.
There is no third, lighter-weight "in-place boundary without a user" that
actually delivers a clean, wipeable macOS — the VM *is* that answer, and it's
the better one.
## Harness
The throwaway-user loop is scripted in
[`scripts/macos-install-test.sh`](../../scripts/macos-install-test.sh):
`up` creates the account, `run` pipes *this checkout's* `install.sh` into it
headlessly (so a PR is verifiable before it lands) and lets the installer run
`doctor`, `down` deletes the account and its home (the full reset), and
`deep-reset` additionally uninstalls the host `container` runtime. It leans on
the footprint analysis above — the reset is just user deletion because
everything `install.sh` writes is user-home-local.
`test` chains `up → run → status → down` into the one-shot cycle you normally
want:
```sh
sudo ./scripts/macos-install-test.sh test
```
It refuses to start against an existing account (a reused home is not a clean
install), and it tears the account down from an `EXIT`/`INT` trap armed the
moment the account exists, so a failed or Ctrl-C'd run still leaves the machine
clean. Its verdict is deliberately stricter than the installer's own: note that
`install.sh` exits **0** when it finishes but `doctor` reports unmet
prerequisites, so "the installer succeeded" is not the assertion — `test` fails
if the install fails, if `bot-bottle` never reached the new user's `PATH`, or if
`doctor` is unhappy. `BB_TEST_KEEP=1` skips the teardown to poke at a failure.
### What a fresh account actually inherits
Expect the first honest run on a developer Mac to fail at the *Python* gate,
and expect that to be correct. A new account's `PATH` is just `/etc/paths`
(`/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin`),
which notably does **not** include `/opt/homebrew/bin`. Homebrew's `shellenv`
line lives in the *installing* user's `~/.zprofile` and is not inherited, so a
throwaway user resolves `python3` to `/usr/bin/python3` — the Command Line
Tools stub, still **3.9.6** on macOS 26 — and `install.sh` correctly dies on its
`3.11+` requirement. Your own shell resolving `python3` to a 3.14 Homebrew
build says nothing about what a new user sees; that gap is exactly what this
harness exists to expose.
## Sources
- [Apple Containers on macOS: technical comparison with Docker — The New Stack](https://thenewstack.io/apple-containers-on-macos-a-technical-comparison-with-docker/)
- [How to Set Up Apple Containerization on macOS 26 — Stéphane Paquet](https://spaquet.medium.com/how-to-set-up-apple-containerization-on-macos-26-f870cc8c26cd)
- [Install Apple Container CLI (macOS 15/26) — 4sysops](https://4sysops.com/archives/install-apple-container-cli-running-containers-natively-on-macos-15-sequoia-and-macos-26-tahoe/)
- [Nested virtualization on Apple Silicon (M3+, macOS 15) — UTM issue #6700](https://github.com/utmapp/UTM/issues/6700)
- [macOS 15 Sequoia nested virtualization for M3+ — Parallels Forums](https://forum.parallels.com/threads/macos-15-sequoia-nested-virtualization-for-m3-macs.364397/)
- [M2 nested virtualization restriction (Apple DTS) — Apple Developer Forums](https://developer.apple.com/forums/thread/756723)
- [M4 can't virtualize older macOS — Yahoo/Tech](https://tech.yahoo.com/computing/articles/m4-mac-computers-cant-virtualize-175122301.html)
- [Tart — macOS/Linux VMs on Apple Silicon (Cirrus Labs)](https://tart.run/quick-start/)
- [Tart GitHub](https://github.com/cirruslabs/tart)
- [macOS VMs in a single command — frr.dev](https://www.frr.dev/posts/tart-macos-vms-from-terminal/)
- [sysadminctl reference — SS64](https://ss64.com/mac/sysadminctl.html)
- [User management from the macOS command line — macnotes](https://macnotes.wordpress.com/2019/03/28/user-management-create-remove-change-password-secure-token-from-macos-command-line/)
+4 -3
View File
@@ -5,9 +5,10 @@ model: opus
bottle: dev
skills:
- init-prd
author:
name: implementer-bot
email: eric+implementer@dideric.is
git-gate:
user:
name: implementer-bot
email: eric+implementer@dideric.is
---
You are a feature-implementation agent running inside an ephemeral
+131 -51
View File
@@ -8,14 +8,20 @@
# pipx install bot-bottle # from a checkout or a published index
# uv tool install bot-bottle
#
# This script is a thin bootstrapper: it checks prerequisites, installs the
# package with pipx (falling back to pip --user), creates the config dir, and
# runs `bot-bottle doctor`. It is idempotent (safe to re-run) and never uses
# sudo. It does NOT install Docker or a VM backend for you — `doctor` reports
# what's missing after install.
# This script is a thin bootstrapper: it finds a Python 3.11+ interpreter,
# installs the package with pipx (falling back to a private venv), creates the
# config dir, and runs `bot-bottle doctor`. It is idempotent (safe to re-run)
# and never uses sudo. It does NOT install Docker or a VM backend for you —
# `doctor` reports what's missing after install.
#
# Env:
# BOT_BOTTLE_PYTHON interpreter to install with (skips the search)
# BOT_BOTTLE_INSTALL_SPEC pip/git spec to install instead of the default
# BOT_BOTTLE_VENV where the non-pipx install lives (~/.bot-bottle/venv)
set -eu
PACKAGE_SPEC="${BOT_BOTTLE_INSTALL_SPEC:-git+https://gitea.dideric.is/didericis/bot-bottle.git}"
VENV="${BOT_BOTTLE_VENV:-${HOME}/.bot-bottle/venv}"
MIN_PYTHON_MAJOR=3
MIN_PYTHON_MINOR=11
@@ -28,17 +34,97 @@ die() {
exit 1
}
# --- prerequisites -----------------------------------------------------------
# --- prerequisites: find an interpreter new enough ----------------------------
command -v python3 >/dev/null 2>&1 \
|| die "python3 ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+ is required but was not found"
python3 - "$MIN_PYTHON_MAJOR" "$MIN_PYTHON_MINOR" <<'PY' || die "python3 ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR} or newer is required"
# Is $1 an interpreter that exists and meets the floor?
python_ok() {
[ -n "${1:-}" ] || return 1
command -v "$1" >/dev/null 2>&1 || return 1
"$1" - "$MIN_PYTHON_MAJOR" "$MIN_PYTHON_MINOR" <<'PY' >/dev/null 2>&1
import sys
want = (int(sys.argv[1]), int(sys.argv[2]))
raise SystemExit(0 if sys.version_info[:2] >= want else 1)
PY
}
python_version() {
"$1" -c 'import sys; print("%d.%d.%d" % sys.version_info[:3])' 2>/dev/null
}
# `python3` on PATH is often NOT the newest interpreter installed, and on macOS
# it is usually the oldest: a fresh login shell's PATH is just /etc/paths, so
# python3 resolves to the Command Line Tools stub (3.9.x) while the usable
# 3.11+ build sits in /opt/homebrew/bin or a python.org framework directory,
# reachable only via a line in the *installing* user's shell profile. A new
# account inherits none of that. Look past PATH before giving up, so the common
# case installs instead of dead-ending on a version error.
find_python() {
for candidate in \
"${BOT_BOTTLE_PYTHON:-}" \
python3 \
python3.14 python3.13 python3.12 python3.11 \
/opt/homebrew/bin/python3 \
/usr/local/bin/python3 \
"${HOME}/.local/bin/python3" \
/Library/Frameworks/Python.framework/Versions/*/bin/python3
do
# An unmatched glob arrives here literally; python_ok rejects it.
if python_ok "$candidate"; then
command -v "$candidate"
return 0
fi
done
return 1
}
# An explicit choice that doesn't work is an error, not a reason to quietly
# search elsewhere and install somewhere the caller didn't ask for.
if [ -n "${BOT_BOTTLE_PYTHON:-}" ] && ! python_ok "${BOT_BOTTLE_PYTHON}"; then
if command -v "${BOT_BOTTLE_PYTHON}" >/dev/null 2>&1; then
die "BOT_BOTTLE_PYTHON=${BOT_BOTTLE_PYTHON} is $(python_version "${BOT_BOTTLE_PYTHON}"), "\
"below the ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR} floor. Unset it to search for a newer one."
fi
die "BOT_BOTTLE_PYTHON=${BOT_BOTTLE_PYTHON} is not an executable interpreter."
fi
PYTHON="$(find_python || true)"
if [ -z "${PYTHON}" ]; then
path_python="$(command -v python3 2>/dev/null || true)"
if [ -n "${path_python}" ]; then
found="the python3 on your PATH is ${path_python} ($(python_version "${path_python}")), which is too old"
else
found="no python3 was found on your PATH"
fi
case "$(uname -s)" in
Darwin) fix=" brew install python@3.12
# or install from https://www.python.org/downloads/macos/
# macOS itself ships only /usr/bin/python3, which is too old" ;;
*) fix=" sudo apt install python3.12 # Debian/Ubuntu
sudo dnf install python3.12 # Fedora/RHEL" ;;
esac
die "bot-bottle needs python3 ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR} or newer, and none was found.
${found}.
Also checked: python3.11-3.14, /opt/homebrew/bin, /usr/local/bin,
~/.local/bin, and python.org framework builds.
Install a newer Python, then re-run this installer:
${fix}
Already have one somewhere? Point at it directly:
BOT_BOTTLE_PYTHON=/path/to/python3 sh install.sh"
fi
# Be explicit when the interpreter isn't the obvious one, so nobody is left
# wondering which Python their install ended up on.
path_python="$(command -v python3 2>/dev/null || true)"
if [ "${PYTHON}" != "${path_python}" ]; then
say "using ${PYTHON} ($(python_version "${PYTHON}"))"
if [ -n "${path_python}" ]; then
say "note: 'python3' on your PATH is ${path_python} ($(python_version "${path_python}")), which is below the ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR} floor"
fi
fi
# Installing a `git+` spec (the default) shells out to git under the hood,
# whether via pipx or pip. Fail early with a clear message rather than deep
@@ -51,30 +137,6 @@ case "${PACKAGE_SPEC}" in
;;
esac
# The pip fallback needs a usable pip. Externally-managed interpreters
# (PEP 668, common on Debian/Ubuntu/Homebrew) reject `pip install --user`;
# pipx sidesteps that, so recommend it when pip can't be used.
if ! command -v pipx >/dev/null 2>&1; then
python3 -m pip --version >/dev/null 2>&1 || die \
"neither pipx nor a usable 'python3 -m pip' was found. Install pipx "\
"(recommended): 'python3 -m pip install --user pipx' or your OS package manager."
if python3 - <<'PY'
import os
import sys
import sysconfig
# PEP 668: an EXTERNALLY-MANAGED marker in the stdlib dir means pip refuses
# to install into this interpreter without --break-system-packages.
marker = os.path.join(sysconfig.get_path("stdlib"), "EXTERNALLY-MANAGED")
raise SystemExit(0 if os.path.exists(marker) else 1)
PY
then
die "this Python is externally managed (PEP 668), so 'pip install --user' is "\
"blocked. Install pipx and re-run: 'python3 -m pip install --user --break-system-packages pipx', "\
"then 'pipx ensurepath'."
fi
fi
# --- config directories ------------------------------------------------------
mkdir -p \
@@ -84,32 +146,50 @@ mkdir -p \
# --- install -----------------------------------------------------------------
BIN_DIR="${HOME}/.local/bin"
if command -v pipx >/dev/null 2>&1; then
say "installing with pipx"
pipx install --force "${PACKAGE_SPEC}"
# --python pins the venv to the interpreter we vetted. Without it pipx uses
# whichever Python it was itself installed with, which is not necessarily
# the one that passed the version check above.
say "installing with pipx (python: ${PYTHON})"
pipx install --python "${PYTHON}" --force "${PACKAGE_SPEC}"
# Ask pipx where it puts entry points rather than assuming ~/.local/bin.
pipx_bin="$(pipx environment --value PIPX_BIN_DIR 2>/dev/null || true)"
[ -n "${pipx_bin}" ] && BIN_DIR="${pipx_bin}"
else
say "pipx not found; installing with 'python3 -m pip install --user'"
python3 -m pip install --user --upgrade "${PACKAGE_SPEC}"
# No `pip install --user` fallback: PEP 668 makes it unusable on nearly
# every interpreter a Mac offers (Homebrew and python.org are both
# externally managed), and on Debian/Ubuntu too. A private venv sidesteps
# that entirely — PEP 668 does not apply inside a venv — and `venv` is
# stdlib, so unlike pipx there is nothing to bootstrap first.
say "pipx not found; installing into a managed venv at ${VENV}"
"${PYTHON}" -m venv --clear "${VENV}" || die \
"could not create a virtualenv at ${VENV} using ${PYTHON}. On Debian/Ubuntu "\
"the venv module ships separately: 'sudo apt install python3-venv'."
"${VENV}/bin/python" -m pip install --upgrade "${PACKAGE_SPEC}"
# Expose the entry point outside the venv, the way pipx would.
mkdir -p "${BIN_DIR}"
ln -sf "${VENV}/bin/bot-bottle" "${BIN_DIR}/bot-bottle"
fi
# --- locate the entry point --------------------------------------------------
# The pip --user scripts directory is platform-specific: ~/.local/bin on Linux,
# but ~/Library/Python/<X.Y>/bin on a python.org macOS interpreter. Ask the
# interpreter for its own user-scheme scripts dir instead of hardcoding.
USER_SCRIPTS="$(python3 - <<'PY'
import sysconfig
print(sysconfig.get_path("scripts", sysconfig.get_preferred_scheme("user")))
PY
)"
if command -v bot-bottle >/dev/null 2>&1; then
BOT_BOTTLE_BIN="bot-bottle"
elif [ -n "${USER_SCRIPTS}" ] && [ -x "${USER_SCRIPTS}/bot-bottle" ]; then
BOT_BOTTLE_BIN="${USER_SCRIPTS}/bot-bottle"
say "note: add ${USER_SCRIPTS} to your PATH to run 'bot-bottle' directly"
elif [ -x "${BIN_DIR}/bot-bottle" ]; then
BOT_BOTTLE_BIN="${BIN_DIR}/bot-bottle"
# Name the file the user's own login shell actually reads. ~/.profile is
# the safe default for non-zsh: bash falls back to it, and suggesting
# ~/.bash_profile could shadow an existing ~/.profile.
case "${SHELL:-}" in
*/zsh) profile="~/.zprofile" ;;
*) profile="~/.profile" ;;
esac
say "note: add ${BIN_DIR} to your PATH to run 'bot-bottle' directly:"
say " echo 'export PATH=\"${BIN_DIR}:\$PATH\"' >> ${profile}"
else
die "bot-bottle was installed but is not on PATH; add ${USER_SCRIPTS:-your user scripts dir} to PATH and re-run"
die "bot-bottle was installed but no entry point turned up in ${BIN_DIR}"
fi
# --- verify ------------------------------------------------------------------
-1
View File
@@ -30,7 +30,6 @@ bot_bottle/manifest/agent.py
bot_bottle/manifest/bottle.py
bot_bottle/manifest/egress.py
bot_bottle/manifest/extends.py
bot_bottle/manifest/forge.py
bot_bottle/manifest/git.py
bot_bottle/manifest/index.py
bot_bottle/manifest/loader.py
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env bash
# Clean-install test harness for the macOS (Apple `container`) path.
#
# Exercises install.sh the way a brand-new user would, inside a throwaway
# macOS account you create and delete from the CLI. install.sh's entire
# footprint is user-home-local — the pipx venv under ~/.local, or the private
# venv at ~/.bot-bottle/venv plus a ~/.local/bin symlink, and the ~/.bot-bottle
# config dir. It writes no shell-profile PATH line, and never installs the
# backend (see
# the header of install.sh), so deleting the user is a complete,
# deterministic reset of everything the installer touched. The Apple
# `container` runtime is a HOST prerequisite installed once and kept;
# `deep-reset` is the rare escape hatch that also removes it.
#
# Why a throwaway user and not a disposable VM: bot-bottle's default macOS
# backend is Apple `container`, which runs each container in its own
# Virtualization.framework microVM. Running that backend inside a macOS
# guest VM needs nested virtualization, which Apple gates to M3+ silicon.
# On M1/M2 a separate user account is the only way to get a clean $HOME
# while still reaching the real host backend. Full rationale in
# docs/research/testing-clean-install-on-macos.md.
#
# Usage:
# sudo ./scripts/macos-install-test.sh test # up -> run -> status -> down
# sudo ./scripts/macos-install-test.sh up # create the throwaway user
# sudo ./scripts/macos-install-test.sh run # run install.sh (+doctor) as it
# ./scripts/macos-install-test.sh status # user present? backend ready?
# sudo ./scripts/macos-install-test.sh down # delete user + home (the reset)
# sudo ./scripts/macos-install-test.sh deep-reset # ALSO uninstall host `container`
#
# `test` is the one-shot clean cycle and the command you normally want: it
# refuses to start if the account already exists (a reused home is not a clean
# install), and it tears the account down on the way out however it exits, so
# a failed run never leaves an orphan behind. It exits non-zero if the install
# fails, if `bot-bottle` is missing from the new user's PATH, or if `doctor`
# reports unmet prerequisites — note install.sh itself exits 0 in that last
# case, so `test` is a stricter gate than running the installer by hand.
#
# Config via env:
# BB_TEST_USER account short name (default: bbtest)
# BB_TEST_FULLNAME account full name (default: "bot-bottle install test")
# BB_TEST_ADMIN 1=admin (reach container svc), 0=standard (default: 1)
# BB_TEST_INSTALL_URL curl this install.sh instead of piping the local checkout
# BB_TEST_KEEP 1=`test` skips its teardown, to poke at a failure
# BOT_BOTTLE_INSTALL_SPEC passed through to install.sh (pip / git spec)
#
# Notes:
# * Run from a normally-booted admin session. Grant Terminal *Full Disk
# Access* (System Settings -> Privacy & Security) or `down` half-fails
# with error -14120 and leaves an orphaned account.
# * `sysadminctl` always exits 0 even on failure, so `up`/`down` verify
# the result with `dscl` and fail loudly on a mismatch.
# * The account is created without a password: `run` drives it headlessly
# via `sudo -u`, which never needs the target's password. The account
# cannot GUI-login, which this harness does not require.
# * `run` covers the installer + `bot-bottle doctor`. Actually launching a
# bottle from the throwaway user may need a full launchd user session
# (`launchctl asuser`); on M1/M2 the backend can't run under nested virt
# anyway, so this harness stops at install + doctor.
set -euo pipefail
USER_NAME="${BB_TEST_USER:-bbtest}"
FULL_NAME="${BB_TEST_FULLNAME:-bot-bottle install test}"
ADMIN="${BB_TEST_ADMIN:-1}"
_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_REPO_ROOT="$(cd "$_SCRIPT_DIR/.." && pwd)"
# Set by `test`, which chains the steps itself and so suppresses the
# "here's the next command to run" hints the individual steps print.
IN_TEST=0
# --- guards ----------------------------------------------------------
require_macos() {
[ "$(uname -s)" = "Darwin" ] \
|| { echo "error: this harness is macOS-only (uname is $(uname -s))" >&2; exit 1; }
}
require_root() {
if [ "$(id -u)" -ne 0 ]; then
echo "error: '$1' needs root; re-run under sudo" >&2
exit 1
fi
}
user_exists() { dscl . -read "/Users/$USER_NAME" >/dev/null 2>&1; }
# Run a shell snippet as the throwaway user in a fresh login shell.
run_as_user() { sudo -u "$USER_NAME" -i sh -c "$1"; }
# `bot-bottle doctor` as the throwaway user. Non-zero when no entry point was
# installed at all, or when doctor itself is unhappy.
#
# Deliberately does NOT require `bot-bottle` on PATH: install.sh prints the
# PATH line rather than editing a shell profile, so on a fresh account the
# entry point is installed and working but not on PATH. Demanding PATH here
# would fail every run for a reason the installer intends.
doctor_as_user() {
# shellcheck disable=SC2016 # $HOME/$bb must expand in the *target* user's
# shell, not in this one — that's the whole point of the single quotes.
run_as_user '
for bb in "$HOME/.local/bin/bot-bottle" "$HOME/.bot-bottle/venv/bin/bot-bottle"; do
if [ -x "$bb" ]; then
command -v bot-bottle >/dev/null 2>&1 \
|| echo " (not on PATH — running $bb directly, as install.sh advises)"
exec "$bb" doctor
fi
done
command -v bot-bottle >/dev/null 2>&1 && exec bot-bottle doctor
echo " no bot-bottle entry point found for this user" >&2
exit 1
'
}
# --- commands --------------------------------------------------------
cmd_up() {
require_macos
require_root up
if user_exists; then
echo "$USER_NAME already exists; nothing to do (run 'down' first to reset)"
return 0
fi
local admin_flag=()
[ "$ADMIN" = "1" ] && admin_flag=(-admin)
# No -password: the account is only ever driven headlessly via `sudo -u`,
# which doesn't need one. sysadminctl warns about FileVault here; that's
# irrelevant to a headless test account.
sysadminctl -addUser "$USER_NAME" -fullName "$FULL_NAME" "${admin_flag[@]}" || true
# sysadminctl exits 0 regardless of outcome, so confirm the account landed.
user_exists || { echo "error: failed to create $USER_NAME" >&2; return 1; }
if [ "$IN_TEST" = 1 ]; then
echo "created $USER_NAME (admin=$ADMIN)"
else
echo "created $USER_NAME (admin=$ADMIN). Install into it with: sudo $0 run"
fi
}
cmd_run() {
require_macos
require_root run
user_exists || { echo "error: $USER_NAME does not exist; run 'sudo $0 up' first" >&2; return 1; }
local spec_env=""
[ -n "${BOT_BOTTLE_INSTALL_SPEC:-}" ] \
&& spec_env="BOT_BOTTLE_INSTALL_SPEC='$BOT_BOTTLE_INSTALL_SPEC' "
echo "== installing bot-bottle as $USER_NAME =="
if [ -n "${BB_TEST_INSTALL_URL:-}" ]; then
run_as_user "curl -fsSL '$BB_TEST_INSTALL_URL' | ${spec_env}sh"
else
# Test THIS checkout's install.sh, not the published one, so a PR is
# verifiable before it lands. Feed it in on stdin rather than staging a
# copy somewhere the throwaway user can read: the redirect is opened by
# root before sudo drops privileges, so the tester's mode-700 home is a
# non-issue, there's no temp file to leak if the run is interrupted, and
# `sh -s` is the same shape as the documented `curl … | sh` install.
run_as_user "${spec_env}sh -s" < "$_REPO_ROOT/install.sh"
fi
[ "$IN_TEST" = 1 ] \
|| echo "== install.sh runs 'doctor' itself; re-check anytime with: $0 status =="
}
# Informational, with one teeth-bearing case: when it can actually reach
# doctor (root, account present) its exit status is doctor's, so `test` and
# any other caller can use it as the post-install assertion.
cmd_status() {
require_macos
local rc=0
if user_exists; then
echo "user: $USER_NAME present"
if [ "$(id -u)" -eq 0 ]; then
echo "doctor (as $USER_NAME):"
doctor_as_user || rc=1
else
echo " (re-run under sudo to run 'bot-bottle doctor' as $USER_NAME)"
fi
else
echo "user: $USER_NAME absent"
fi
if command -v container >/dev/null 2>&1; then
echo "backend: apple 'container' present ($(container --version 2>/dev/null | head -1))"
else
echo "backend: apple 'container' NOT on PATH (host prerequisite; install once)"
fi
return "$rc"
}
cmd_down() {
require_macos
require_root down
if ! user_exists; then
echo "$USER_NAME not present; nothing to remove"
return 0
fi
# A plain -deleteUser removes the home dir, which is the whole reset.
# -secure is a no-op on modern macOS (secure erase of the home folder
# was removed in Sierra), so it buys nothing here.
sysadminctl -deleteUser "$USER_NAME" || true
if user_exists; then
echo "error: $USER_NAME still present after delete." >&2
echo " - grant Terminal Full Disk Access (System Settings > Privacy & Security), or" >&2
echo " - it may hold the last Secure Token (won't happen while another admin exists)" >&2
return 1
fi
echo "removed $USER_NAME and its home — install surface is clean."
}
# Teardown half of `test`, installed as an EXIT trap the moment the account
# exists so that a failure — or a Ctrl-C — still leaves the machine clean.
_test_teardown() {
local rc=$?
trap - EXIT INT TERM
if [ "${BB_TEST_KEEP:-0}" = "1" ]; then
echo
echo "== [4/4] down: SKIPPED (BB_TEST_KEEP=1) =="
echo " $USER_NAME is still around; remove it with: sudo $0 down"
exit "$rc"
fi
echo
echo "== [4/4] down =="
cmd_down || rc=1
if [ "$rc" -eq 0 ]; then
echo
echo "PASS: a brand-new user can install bot-bottle and pass doctor."
else
echo
echo "FAIL: see above (the throwaway account was torn down regardless)." >&2
fi
exit "$rc"
}
cmd_test() {
require_macos
require_root test
# A pre-existing account means a pre-existing home, which is the one thing
# this harness exists to rule out. Don't silently test a dirty install.
if user_exists; then
echo "error: $USER_NAME already exists, so this would not be a clean install." >&2
echo " reset first: sudo $0 down" >&2
return 1
fi
IN_TEST=1
echo "== [1/4] up =="
cmd_up
trap _test_teardown EXIT INT TERM
echo
echo "== [2/4] run =="
cmd_run
echo
echo "== [3/4] status =="
# install.sh exits 0 even when doctor reports unmet prerequisites, so the
# install succeeding is not the verdict — this is.
cmd_status || {
echo "error: doctor is unhappy for a freshly installed user (see above)." >&2
echo " re-run with BB_TEST_KEEP=1 to keep $USER_NAME around and dig in." >&2
return 1
}
}
cmd_deep_reset() {
require_macos
require_root deep-reset
# Remove the user first (idempotent), then the HOST-level container
# runtime that a user deletion leaves behind under /usr/local + launchd.
cmd_down || true
if command -v container >/dev/null 2>&1; then
# The service can run in more than one launchd context (the invoking
# user's and root's), so stop both, best-effort.
[ -n "${SUDO_USER:-}" ] && sudo -u "$SUDO_USER" container system stop 2>/dev/null || true
container system stop 2>/dev/null || true
if [ -x /usr/local/bin/uninstall-container.sh ]; then
/usr/local/bin/uninstall-container.sh -d || true
echo "uninstalled the host Apple 'container' runtime"
else
echo "note: /usr/local/bin/uninstall-container.sh not found; runtime left as-is" >&2
fi
else
echo "no 'container' runtime on PATH; nothing further to remove"
fi
}
case "${1:-}" in
test) cmd_test ;;
up) cmd_up ;;
run) cmd_run ;;
status) cmd_status ;;
down) cmd_down ;;
deep-reset) cmd_deep_reset ;;
*) echo "usage: $0 {test|up|run|status|down|deep-reset}" >&2 ; exit 2 ;;
esac
+11
View File
@@ -43,6 +43,17 @@ class TestProjectNaming(unittest.TestCase):
class TestComposeProjectListing(unittest.TestCase):
def test_compose_ls_empty_when_docker_unusable(self):
# Missing is the obvious case; present-but-not-executable raises
# PermissionError instead, which must not escape as a crash.
for exc in (FileNotFoundError, PermissionError(13, "Permission denied", "docker")):
with self.subTest(exc=type(exc).__name__):
with mock.patch(
"bot_bottle.backend.docker.compose.subprocess.run",
side_effect=exc,
):
self.assertEqual([], list_compose_projects())
def test_compose_ls_error_warns_by_default(self):
with (
mock.patch(
+4 -29
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from unittest.mock import MagicMock
@@ -22,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 ManifestGitUser, ManifestIndex
from bot_bottle.manifest import ManifestIndex
class _Provider(AgentProvider):
@@ -51,39 +50,15 @@ def _plan(*, git_user: dict | None = None, # type: ignore
user_cwd: str = "/tmp/x",
stage_dir: Path | None = None) -> DockerBottlePlan:
bottle_json: dict = {} # type: ignore
if git_user is not None:
bottle_json["git-gate"] = {"user": git_user}
if git_repos is not None:
bottle_json.setdefault("git-gate", {})["repos"] = git_repos
# Identity now lives on the agent's `author` block; at composition it
# populates manifest.bottle.git_user, which provision_git reads
# (production unchanged). When the caller passes a full name+email we
# route it through `author`; the name-only / email-only cases (which
# exercise provision_git emitting a single `git config` line) can't be
# expressed via `author` (both fields required), so we inject the
# partial ManifestGitUser onto the composed bottle directly.
agent_json: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
full_author = (
git_user
if git_user and git_user.get("name") and git_user.get("email")
else None
)
if full_author is not None:
agent_json["author"] = full_author
index = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {"demo": agent_json},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
manifest = index.load_for_agent("demo")
if git_user is not None and full_author is None:
manifest = replace(
manifest,
bottle=replace(
manifest.bottle,
git_user=ManifestGitUser(
name=git_user.get("name", ""),
email=git_user.get("email", ""),
),
),
)
spec = BottleSpec(
manifest=index, agent_name="demo",
copy_cwd=copy_cwd, user_cwd=user_cwd,
+15
View File
@@ -56,6 +56,21 @@ class TestNetpoolProbes(unittest.TestCase):
with patch.object(netpool.subprocess, "run", side_effect=FileNotFoundError):
self.assertFalse(netpool._run_ok(["nft"]))
def test_run_ok_false_on_unexecutable_binary(self):
# A name on PATH that this user can't execute raises PermissionError,
# not FileNotFoundError — CPython reports that EACCES in preference to
# the ENOENT from the other PATH entries. Catching only the latter made
# `doctor` die with a traceback on a fresh macOS account.
with patch.object(netpool.subprocess, "run",
side_effect=PermissionError(13, "Permission denied", "ip")):
self.assertFalse(netpool._run_ok(["ip", "link", "show", "bbfc0"]))
def test_overlapping_routes_empty_when_ip_unusable(self):
for exc in (FileNotFoundError, PermissionError(13, "Permission denied", "ip")):
with self.subTest(exc=type(exc).__name__), \
patch.object(netpool.subprocess, "run", side_effect=exc):
self.assertEqual([], netpool.overlapping_routes())
def test_tap_and_nft_probes(self):
with patch.object(netpool, "_run_ok", return_value=True) as ok:
self.assertTrue(netpool.tap_present("bbfc0"))
-426
View File
@@ -1,426 +0,0 @@
"""Unit: trusted agent forge identity & guidance
(PRD 0082).
Covers the net-new surface: agent `author`, agent `forge-accounts` (Gitea API
URL validation + host token reference), the repoforge association resolved at
composition, the synthesized proxy-held egress route, and the generated,
non-secret prompt guidance. Legacy git-gate.user / cwd-agent behavior lives in
the manifest test modules.
"""
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from typing import Callable
from unittest.mock import patch
from bot_bottle.egress import (
egress_forge_routes,
egress_render_routes,
egress_resolve_token_values,
egress_routes_for_bottle,
egress_token_env_map,
)
from bot_bottle.manifest import ManifestError, ManifestIndex
from bot_bottle.manifest.forge import (
ManifestForgeAccount,
canonicalize_forge_url,
render_forge_guidance,
)
GITEA = {
"url": "https://gitea.dideric.is/api/v1",
"auth": {"type": "token", "token_secret": "GITEA_CLAUDE_TOKEN"},
}
def _repo(*, forge: str | None = None) -> dict[str, object]:
entry: dict[str, object] = {
"url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
"key": {"provider": "static", "path": "/k"},
}
if forge is not None:
entry["forge"] = forge
return entry
def _index(
*,
author: dict[str, object] | None = None,
forge_accounts: dict[str, object] | None = None,
repo_forge: str | None = None,
) -> ManifestIndex:
"""Build an eager index with one agent 'claude' + bottle 'bb'."""
agent: dict[str, object] = {"bottle": "bb", "prompt": ""}
if author is not None:
agent["author"] = author
if forge_accounts is not None:
agent["forge-accounts"] = forge_accounts
bottle: dict[str, object] = {
"git-gate": {"repos": {"bot-bottle": _repo(forge=repo_forge)}}
}
return ManifestIndex.from_json_obj(
{"bottles": {"bb": bottle}, "agents": {"claude": agent}}
)
def _error(
callable_: Callable[..., object], *args: object, **kwargs: object,
) -> str:
try:
callable_(*args, **kwargs)
except ManifestError as e:
return str(e)
raise AssertionError("expected ManifestError was not raised")
# ---------------------------------------------------------------------------
# author
# ---------------------------------------------------------------------------
class TestAuthor(unittest.TestCase):
def test_populates_git_identity(self) -> None:
idx = _index(author={"name": "didericis-claude", "email": "e+c@x.is"})
m = idx.load_for_agent("claude", ())
self.assertEqual("didericis-claude", m.bottle.git_user.name)
self.assertEqual("e+c@x.is", m.bottle.git_user.email)
self.assertEqual(
"name=didericis-claude, email=e+c@x.is",
m.git_identity_summary(),
)
def test_absent_author_no_identity(self) -> None:
m = _index().load_for_agent("claude", ())
self.assertTrue(m.bottle.git_user.is_empty())
self.assertIsNone(m.git_identity_summary())
def test_name_required(self) -> None:
msg = _error(_index, author={"email": "e@x.is"})
self.assertIn("author.name must be a non-empty string", msg)
def test_email_required(self) -> None:
msg = _error(_index, author={"name": "n"})
self.assertIn("author.email must be a non-empty string", msg)
def test_email_rejects_whitespace(self) -> None:
msg = _error(_index, author={"name": "n", "email": "a b@x.is"})
self.assertIn("must not contain whitespace", msg)
def test_name_rejects_newline(self) -> None:
msg = _error(_index, author={"name": "a\nb", "email": "e@x.is"})
self.assertIn("author.name must not contain newlines", msg)
def test_unknown_key(self) -> None:
msg = _error(
_index, author={"name": "n", "email": "e@x.is", "role": "x"}
)
self.assertIn("author has unknown key", msg)
# ---------------------------------------------------------------------------
# forge-accounts URL validation
# ---------------------------------------------------------------------------
class TestForgeUrl(unittest.TestCase):
def test_canonical_ok(self) -> None:
canonical, origin, host, prefix = canonicalize_forge_url(
"a", "g", "https://Gitea.Dideric.is/api/v1/"
)
# trailing slash normalized; host lowercased.
self.assertEqual("https://gitea.dideric.is/api/v1", canonical)
self.assertEqual("https://gitea.dideric.is", origin)
self.assertEqual("gitea.dideric.is", host)
self.assertEqual("/api/v1", prefix)
def test_port_preserved(self) -> None:
canonical, origin, _host, _ = canonicalize_forge_url(
"a", "g", "https://gitea.local:3000/api/v1"
)
self.assertEqual("https://gitea.local:3000/api/v1", canonical)
self.assertEqual("https://gitea.local:3000", origin)
def test_http_fails(self) -> None:
self.assertIn("must use https", _error(
canonicalize_forge_url, "a", "g", "http://gitea/api/v1"))
def test_userinfo_fails(self) -> None:
self.assertIn("userinfo", _error(
canonicalize_forge_url, "a", "g", "https://u:p@gitea/api/v1"))
def test_query_fails(self) -> None:
self.assertIn("query string", _error(
canonicalize_forge_url, "a", "g", "https://gitea/api/v1?x=1"))
def test_fragment_fails(self) -> None:
self.assertIn("fragment", _error(
canonicalize_forge_url, "a", "g", "https://gitea/api/v1#x"))
def test_missing_host_fails(self) -> None:
self.assertIn("hostname", _error(
canonicalize_forge_url, "a", "g", "https:///api/v1"))
def test_bad_path_fails(self) -> None:
self.assertIn("Gitea API base", _error(
canonicalize_forge_url, "a", "g", "https://gitea/api/v2"))
def test_non_string_fails(self) -> None:
self.assertIn("required", _error(
canonicalize_forge_url, "a", "g", None))
def test_malformed_url_fails(self) -> None:
# An unparseable URL (invalid IPv6 literal) trips urlsplit's ValueError.
self.assertIn("not a valid URL", _error(
canonicalize_forge_url, "a", "g", "https://[/api/v1"))
class TestForgeAccount(unittest.TestCase):
def test_parses(self) -> None:
acct = ManifestForgeAccount.from_dict("a", "didericis-gitea", GITEA)
self.assertEqual("didericis-gitea", acct.alias)
self.assertEqual("gitea.dideric.is", acct.host)
self.assertEqual("token", acct.auth_type)
self.assertEqual("GITEA_CLAUDE_TOKEN", acct.token_secret)
def test_non_kebab_alias_fails(self) -> None:
self.assertIn("valid alias", _error(
ManifestForgeAccount.from_dict, "a", "Bad_Alias", GITEA))
def test_auth_required(self) -> None:
self.assertIn("missing required 'auth'", _error(
ManifestForgeAccount.from_dict, "a", "g",
{"url": "https://gitea/api/v1"}))
def test_bad_auth_type_fails(self) -> None:
self.assertIn("auth.type must be", _error(
ManifestForgeAccount.from_dict, "a", "g",
{"url": "https://gitea/api/v1",
"auth": {"type": "basic", "token_secret": "T"}}))
def test_token_secret_required(self) -> None:
self.assertIn("token_secret must be", _error(
ManifestForgeAccount.from_dict, "a", "g",
{"url": "https://gitea/api/v1", "auth": {"type": "token"}}))
def test_unknown_key_fails(self) -> None:
self.assertIn("unknown key", _error(
ManifestForgeAccount.from_dict, "a", "g",
{**GITEA, "bogus": 1}))
def test_unknown_auth_key_fails(self) -> None:
self.assertIn("auth has unknown key", _error(
ManifestForgeAccount.from_dict, "a", "g",
{"url": "https://gitea/api/v1",
"auth": {"type": "token", "token_secret": "T", "bogus": 1}}))
# ---------------------------------------------------------------------------
# repo -> forge association (composition)
# ---------------------------------------------------------------------------
class TestForgeAssociations(unittest.TestCase):
def test_resolves_when_repo_references_alias(self) -> None:
idx = _index(
forge_accounts={"didericis-gitea": GITEA},
repo_forge="didericis-gitea",
)
m = idx.load_for_agent("claude", ())
self.assertEqual(1, len(m.forge_associations))
assoc = m.forge_associations[0]
self.assertEqual("didericis-gitea", assoc.alias)
self.assertEqual(("bot-bottle",), assoc.repo_names)
def test_unreferenced_account_yields_no_association(self) -> None:
idx = _index(forge_accounts={"didericis-gitea": GITEA}) # repo has no forge
m = idx.load_for_agent("claude", ())
self.assertEqual((), m.forge_associations)
def test_unknown_alias_fails_closed(self) -> None:
idx = _index(
forge_accounts={"didericis-gitea": GITEA}, repo_forge="nope"
)
msg = _error(idx.load_for_agent, "claude", ())
self.assertIn("references forge alias 'nope'", msg)
self.assertIn("not defined on agent", msg)
def test_forge_without_account_fails_closed(self) -> None:
idx = _index(repo_forge="didericis-gitea") # no forge-accounts at all
self.assertIn("(none)", _error(idx.load_for_agent, "claude", ()))
def _two_alias_index(self, t1: str, t2: str) -> ManifestIndex:
"""Two aliases on the SAME host, each referenced by a distinct repo."""
def acct(token: str) -> dict[str, object]:
return {
"url": "https://same.example/api/v1",
"auth": {"type": "token", "token_secret": token},
}
return ManifestIndex.from_json_obj({
"bottles": {"bb": {"git-gate": {"repos": {
"r1": {"url": "ssh://git@h/x/r1.git",
"key": {"provider": "static", "path": "/k"},
"forge": "g1"},
"r2": {"url": "ssh://git@h/x/r2.git",
"key": {"provider": "static", "path": "/k"},
"forge": "g2"},
}}}},
"agents": {"claude": {"bottle": "bb", "prompt": "",
"forge-accounts": {"g1": acct(t1),
"g2": acct(t2)}}},
})
def test_conflicting_aliases_same_host_fail_closed(self) -> None:
# Two aliases on the same host with DIFFERENT tokens is ambiguous —
# the proxy routes by host, so it must fail before launch rather than
# silently authenticate every call as one account.
idx = self._two_alias_index("FIRST_TOKEN", "SECOND_TOKEN")
msg = _error(idx.load_for_agent, "claude", ())
self.assertIn("both resolve to host 'same.example'", msg)
self.assertIn("ambiguous", msg)
def test_matching_aliases_same_host_ok(self) -> None:
# Identical url/auth under two aliases is unambiguous: one route.
idx = self._two_alias_index("SAME_TOKEN", "SAME_TOKEN")
m = idx.load_for_agent("claude", ())
self.assertEqual(2, len(m.forge_associations)) # both referenced
routes = egress_forge_routes(m.forge_associations)
self.assertEqual(1, len(routes)) # deduped to a single proxy route
self.assertEqual("SAME_TOKEN", routes[0].token_ref)
# ---------------------------------------------------------------------------
# synthesized egress route (proxy-held credential)
# ---------------------------------------------------------------------------
class TestForgeEgressRoutes(unittest.TestCase):
def _assoc(self):
idx = _index(
forge_accounts={"didericis-gitea": GITEA},
repo_forge="didericis-gitea",
)
return idx.load_for_agent("claude", ())
def test_route_shape(self) -> None:
routes = egress_forge_routes(self._assoc().forge_associations)
self.assertEqual(1, len(routes))
r = routes[0]
self.assertEqual("gitea.dideric.is", r.host)
self.assertEqual("token", r.auth_scheme)
self.assertEqual("GITEA_CLAUDE_TOKEN", r.token_ref)
self.assertTrue(r.inspect)
# scoped to the API prefix.
self.assertEqual("/api/v1", r.matches[0].paths[0].value)
def test_token_slot_and_resolution(self) -> None:
m = self._assoc()
forge_routes = egress_forge_routes(m.forge_associations)
routes = egress_routes_for_bottle(m.bottle, (), forge_routes)
token_map = egress_token_env_map(routes)
# one slot mapping the proxy env slot -> host env var name.
self.assertEqual({"EGRESS_TOKEN_0": "GITEA_CLAUDE_TOKEN"}, token_map)
resolved = egress_resolve_token_values(
token_map, {"GITEA_CLAUDE_TOKEN": "sekret-value"}
)
self.assertEqual({"EGRESS_TOKEN_0": "sekret-value"}, resolved)
def test_rendered_routes_hide_secret_name_and_value(self) -> None:
m = self._assoc()
routes = egress_routes_for_bottle(
m.bottle, (), egress_forge_routes(m.forge_associations)
)
rendered = egress_render_routes(routes)
self.assertIn("gitea.dideric.is", rendered)
self.assertIn("EGRESS_TOKEN_0", rendered) # slot, not the host var name
self.assertNotIn("GITEA_CLAUDE_TOKEN", rendered)
self.assertNotIn("sekret-value", rendered)
def test_dedup_by_host(self) -> None:
# Two repos referencing the same alias share one route/credential.
idx = ManifestIndex.from_json_obj({
"bottles": {"bb": {"git-gate": {"repos": {
"r1": _repo(forge="g"),
"r2": {
"url": "ssh://git@h/x/other.git",
"key": {"provider": "static", "path": "/k"},
"forge": "g",
},
}}}},
"agents": {"claude": {"bottle": "bb", "prompt": "",
"forge-accounts": {"g": GITEA}}},
})
m = idx.load_for_agent("claude", ())
routes = egress_forge_routes(m.forge_associations)
self.assertEqual(1, len(routes))
# ---------------------------------------------------------------------------
# generated prompt guidance
# ---------------------------------------------------------------------------
class TestForgeGuidance(unittest.TestCase):
def _assoc(self):
idx = _index(
forge_accounts={"didericis-gitea": GITEA},
repo_forge="didericis-gitea",
)
return idx.load_for_agent("claude", ()).forge_associations
def test_empty_when_no_associations(self) -> None:
self.assertEqual("", render_forge_guidance(()))
def test_names_account_repo_and_workflow(self) -> None:
text = render_forge_guidance(self._assoc())
self.assertIn("didericis-gitea", text)
self.assertIn("https://gitea.dideric.is/api/v1", text)
self.assertIn("bot-bottle", text)
# branch-backed PR workflow + AGit prohibition.
self.assertIn("refs/heads/", text)
self.assertIn("refs/for/*", text)
self.assertIn("pull request", text)
def test_no_token_secret_name_or_value(self) -> None:
text = render_forge_guidance(self._assoc())
self.assertNotIn("GITEA_CLAUDE_TOKEN", text)
def test_prompt_file_appends_guidance(self) -> None:
from bot_bottle.backend.resolve_common import prepare_agent_state_dir
idx = _index(
author={"name": "n", "email": "e@x.is"},
forge_accounts={"didericis-gitea": GITEA},
repo_forge="didericis-gitea",
)
# Give the agent a real prompt body.
m = idx.load_for_agent("claude", ())
from dataclasses import replace
m = replace(m, agent=replace(m.agent, prompt="BASE PROMPT"))
with tempfile.TemporaryDirectory() as td:
with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": td}):
_dir, prompt_file = prepare_agent_state_dir("slug1", m)
body = Path(prompt_file).read_text()
self.assertIn("BASE PROMPT", body)
self.assertIn("Forge access", body)
self.assertIn("https://gitea.dideric.is/api/v1", body)
def test_prompt_file_no_guidance_without_forge(self) -> None:
from bot_bottle.backend.resolve_common import prepare_agent_state_dir
m = _index(author={"name": "n", "email": "e@x.is"}).load_for_agent(
"claude", ()
)
with tempfile.TemporaryDirectory() as td:
with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": td}):
_dir, prompt_file = prepare_agent_state_dir("slug2", m)
body = Path(prompt_file).read_text()
self.assertNotIn("Forge access", body)
if __name__ == "__main__":
unittest.main()
+83 -41
View File
@@ -9,7 +9,7 @@ create the config tree, install the package, and verify with `doctor`.
from __future__ import annotations
import os
import sysconfig
import re
import unittest
from pathlib import Path
@@ -17,6 +17,22 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
INSTALL_SH = REPO_ROOT / "install.sh"
def code_only(text: str) -> str:
"""Script text with string literals and comments removed.
Both are places the script *talks about* commands rather than running
them remediation advice quite reasonably says "sudo apt install …"
so assertions about what the script actually executes must not see them.
Strings are stripped before comments because a '#' inside a quoted string
is not a comment, and several literals here span multiple lines.
"""
without_strings = re.sub(r"\"(?:[^\"\\]|\\.)*\"|'[^']*'", "", text)
return "\n".join(
ln for ln in without_strings.splitlines()
if ln.strip() and not ln.lstrip().startswith("#")
)
class TestInstallScript(unittest.TestCase):
@classmethod
def setUpClass(cls):
@@ -32,20 +48,45 @@ class TestInstallScript(unittest.TestCase):
self.assertIn("set -eu", self.text)
def test_never_uses_sudo(self):
# Only executable lines matter; the header comment may mention sudo.
code = [
ln for ln in self.text.splitlines()
if ln.strip() and not ln.lstrip().startswith("#")
]
self.assertNotIn("sudo", "\n".join(code))
# The installer must never *invoke* sudo. It may print it: the "no
# usable python" error suggests 'sudo apt install python3.12'.
self.assertNotIn("sudo", code_only(self.text))
def test_creates_config_tree(self):
self.assertIn(".bot-bottle/agents", self.text)
self.assertIn(".bot-bottle/bottles", self.text)
def test_installs_via_pipx_with_pip_fallback(self):
def test_installs_via_pipx_with_venv_fallback(self):
self.assertIn("pipx install", self.text)
self.assertIn("pip install --user", self.text)
self.assertIn("-m venv", self.text)
def test_no_pip_user_fallback(self):
# `pip install --user` is not a fallback, it's a dead end: PEP 668
# blocks it on Homebrew, python.org and Debian/Ubuntu interpreters,
# which is every Python a Mac realistically offers. A private venv is
# exempt from PEP 668 and needs no bootstrap, since venv is stdlib.
# code_only, because the comment explaining the absence says the words.
code = code_only(self.text)
self.assertNotIn("pip install --user", code)
self.assertNotIn("--break-system-packages", code)
def test_venv_lives_under_the_config_dir(self):
# Keeps the whole install footprint inside ~/.bot-bottle (plus the
# entry-point symlink), which is what makes deleting a throwaway
# account a complete reset in scripts/macos-install-test.sh.
self.assertIn(".bot-bottle/venv", self.text)
self.assertIn("BOT_BOTTLE_VENV", self.text)
def test_venv_failure_is_actionable(self):
# Debian/Ubuntu ship venv separately; failing there must say so rather
# than dumping ensurepip's error.
self.assertIn("python3-venv", self.text)
def test_entry_point_is_exposed_outside_the_venv(self):
# A venv's bin dir is never on PATH, so the console script has to be
# linked somewhere conventional or `bot-bottle` is unreachable.
self.assertIn(".local/bin", self.text)
self.assertIn("ln -sf", self.text)
def test_runs_doctor_after_install(self):
self.assertIn("doctor", self.text)
@@ -60,42 +101,43 @@ class TestInstallScript(unittest.TestCase):
self.assertIn("command -v git", self.text)
self.assertIn("git+*|*.git", self.text)
def test_checks_pip_usable_before_fallback(self):
self.assertIn("python3 -m pip --version", self.text)
def test_installs_into_the_venv_with_its_own_pip(self):
# The venv's pip, not the base interpreter's — the base one may not
# exist, and using it would install outside the venv.
self.assertIn("${VENV}/bin/python\" -m pip install", self.text)
def test_detects_externally_managed_python(self):
# PEP 668: 'pip install --user' is blocked on externally-managed
# interpreters; the script must detect this and point at pipx.
self.assertIn("EXTERNALLY-MANAGED", self.text)
self.assertIn("pipx", self.text)
def test_pipx_is_preferred_when_present(self):
# The venv is a fallback, not a takeover: someone who already manages
# their Python apps with pipx keeps doing so.
self.assertIn("command -v pipx", self.text)
def test_resolves_user_scripts_dir_not_hardcoded(self):
# The pip --user scripts dir differs by platform; the script must ask
# the interpreter (sysconfig + the preferred *user* scheme) rather than
# hardcoding Linux's ~/.local/bin (which is wrong on macOS python.org).
self.assertIn("get_preferred_scheme", self.text)
self.assertIn("sysconfig", self.text)
# No hardcoded Linux path in executable lines (a comment may mention it).
code = "\n".join(
ln for ln in self.text.splitlines()
if ln.strip() and not ln.lstrip().startswith("#")
)
self.assertNotIn(".local/bin", code)
def test_asks_pipx_where_its_bin_dir_is(self):
# PIPX_BIN_DIR is configurable, so the post-install "is it on PATH?"
# check must ask rather than assume ~/.local/bin.
self.assertIn("PIPX_BIN_DIR", self.text)
def test_macos_user_scheme_is_not_dot_local_bin(self):
# The case the fix exists for: a python.org macOS interpreter uses the
# osx_framework_user scheme, whose scripts land under
# ~/Library/Python/<X.Y>/bin — NOT ~/.local/bin. Drive the same
# sysconfig lookup install.sh uses, with a mac-like userbase, to prove
# it resolves a non-~/.local/bin directory.
self.assertIn("osx_framework_user", sysconfig.get_scheme_names())
scripts = sysconfig.get_path(
"scripts", "osx_framework_user",
vars={"userbase": "/Users/dev/Library/Python/3.11"},
)
self.assertEqual("/Users/dev/Library/Python/3.11/bin", scripts)
self.assertNotIn("/.local/bin", scripts)
def test_searches_beyond_path_for_an_interpreter(self):
# `python3` on PATH is the *oldest* interpreter on a stock Mac: a fresh
# account's PATH is /etc/paths, so python3 is the 3.9.6 CLT stub while
# the usable build sits somewhere only a shell profile puts on PATH.
# Giving up at that point dead-ends every new macOS user.
for candidate in ("python3.11", "/opt/homebrew/bin", "Python.framework"):
self.assertIn(candidate, self.text)
def test_interpreter_is_overridable(self):
self.assertIn("BOT_BOTTLE_PYTHON", self.text)
def test_pipx_is_pinned_to_the_vetted_interpreter(self):
# Without --python, pipx builds the venv with whichever interpreter
# pipx itself was installed with, which need not be the one that
# passed the version check.
self.assertIn("pipx install --python", self.text)
def test_version_failure_is_actionable(self):
# The failure a new macOS user actually hits must say what to do about
# it, not just state the requirement.
self.assertIn("brew install python@", self.text)
self.assertIn("BOT_BOTTLE_PYTHON=/path/to/python3", self.text)
if __name__ == "__main__":
unittest.main()
+127 -77
View File
@@ -1,17 +1,14 @@
"""Unit: agent-owned identity via `author` (PRD
0082).
"""Unit: agent-level git-gate.user overlay + provenance (PRD 0027, PRD 0047).
Identity is agent-only now: an agent file declares an `author` block
(name + email, both required) and at `ManifestIndex.load_for_agent()`
it populates the effective bottle's `git_user`. There is no per-field
overlay against the bottle anymore the bottle no longer carries a
user identity. `git-gate` (user or repos) is rejected on an agent with
a migration message. `Manifest.git_identity_summary()` reports the
effective identity with no provenance annotation.
An agent file may declare `git-gate.user` (name/email). At
`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 `ManifestAgent.from_dict` + the
composition in load_for_agent; a temp-dir case locks the md loader
(the agent `author` frontmatter key threads into the parsed agent)."""
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
@@ -34,61 +31,97 @@ def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
raise AssertionError("expected ManifestError was not raised")
def _manifest(*, author=None, agent_git=None) -> Manifest: # 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 author is not None:
agent["author"] = author
if agent_git is not None:
agent["git-gate"] = agent_git
return ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"bottles": {"dev": bottle},
"agents": {"impl": agent},
}).load_for_agent("impl")
def _index(*, author: dict[str, object] | None = None) -> ManifestIndex:
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 author is not None:
agent["author"] = author
if agent_git is not None:
agent["git-gate"] = agent_git
return ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"bottles": {"dev": bottle},
"agents": {"impl": agent},
})
class TestAgentAuthorPopulatesBottle(unittest.TestCase):
def test_agent_author_supplies_both_fields(self):
m = _manifest(author={"name": "a", "email": "a@b"})
class TestAgentGitUserOverlay(unittest.TestCase):
def test_agent_supplies_both_fields(self):
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
u = m.bottle.git_user
self.assertEqual("a", u.name)
self.assertEqual("a@b", u.email)
def test_bottle_has_no_identity_until_agent_composed(self):
idx = _index(author={"name": "a", "email": "a@b"})
# Raw bottle has no git_user; loaded manifest has it from the agent.
def test_agent_name_only_email_falls_through_to_bottle(self):
m = _manifest(
bottle_user={"name": "B", "email": "b@c"},
agent_git={"user": {"name": "a"}},
)
u = m.bottle.git_user
self.assertEqual("a", u.name) # agent wins
self.assertEqual("b@c", u.email) # bottle falls through
def test_agent_email_only_name_falls_through_to_bottle(self):
m = _manifest(
bottle_user={"name": "B", "email": "b@c"},
agent_git={"user": {"email": "a@b"}},
)
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):
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_agent_silent_leaves_bottle_identity_empty(self):
idx = _index()
def test_bottle_only_identity_preserved_when_agent_silent(self):
m = _manifest(bottle_user={"name": "B", "email": "b@c"})
u = m.bottle.git_user
self.assertEqual("B", u.name)
self.assertEqual("b@c", u.email)
def test_no_overlay_uses_bottle_instance_directly(self):
idx = _index(bottle_user={"name": "B"})
m = idx.load_for_agent("impl")
# No author -> git_user stays empty; the bottle instance is reused
# directly (no replace needed).
self.assertTrue(m.bottle.git_user.is_empty())
# Agent has no git_user — bottle instance should be the same object
self.assertIs(idx.bottles["dev"], m.bottle)
def test_other_bottle_fields_untouched_by_identity(self):
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"}},
)
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):
idx = ManifestIndex.from_json_obj({
"bottles": {"dev": {
"env": {"FOO": "bar"},
"supervise": True,
"git-gate": {"user": {"name": "B"}},
}},
"agents": {"impl": {
"bottle": "dev", "skills": [], "prompt": "",
"author": {"name": "a", "email": "a@b"},
"git-gate": {"user": {"name": "a"}},
}},
})
b = idx.load_for_agent("impl").bottle
@@ -97,77 +130,93 @@ class TestAgentAuthorPopulatesBottle(unittest.TestCase):
self.assertTrue(b.supervise)
class TestAgentGitGateRejections(unittest.TestCase):
"""`git-gate` is no longer accepted on an agent (user or repos):
identity moved to `author`, repos stays bottle-only."""
def test_agent_git_gate_user_dies(self):
msg = _error_message(_manifest, agent_git={"user": {"name": "a", "email": "a@b"}})
self.assertIn("no longer", msg)
self.assertIn("author", msg)
def test_agent_git_gate_repos_dies(self):
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", "key": {"provider": "static", "path": "/dev/null"}}},
})
self.assertIn("no longer", msg)
self.assertIn("author", msg)
self.assertIn("git-gate.repos", msg)
self.assertIn("bottle-only", msg)
def test_agent_unknown_git_subkey_dies(self):
msg = _error_message(_manifest, agent_git={"nope": {}})
self.assertIn("not allowed at the agent level", msg)
def test_agent_git_user_both_empty_dies(self):
msg = _error_message(_manifest, agent_git={"user": {"name": "", "email": ""}})
self.assertIn("neither name nor email", msg)
class TestGitIdentitySummary(unittest.TestCase):
"""Summary reports the effective identity (from the agent's author)
with no per-field provenance annotation."""
def test_both_from_agent(self):
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
self.assertEqual(
"name=a (agent), email=a@b (agent)",
m.git_identity_summary(),
)
def test_summary_from_author(self):
m = _manifest(author={"name": "a", "email": "a@b"})
self.assertEqual("name=a, email=a@b", m.git_identity_summary())
def test_mixed_provenance(self):
m = _manifest(
bottle_user={"name": "B", "email": "b@c"},
agent_git={"user": {"name": "a"}},
)
self.assertEqual(
"name=a (agent), email=b@c (bottle)",
m.git_identity_summary(),
)
def test_none_when_no_author(self):
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(),
)
def test_none_when_unset_anywhere(self):
m = _manifest()
self.assertIsNone(m.git_identity_summary())
_BOTTLE_DEV = """
---
egress:
routes:
- host: example.com
git-gate:
user:
name: bottle-name
email: bottle@example.com
---
dev bottle.
"""
_AGENT_WITH_AUTHOR = """
_AGENT_WITH_GIT = """
---
bottle: dev
author:
name: agent-name
email: agent@example.com
git-gate:
user:
name: agent-name
---
impl agent.
"""
_AGENT_WITH_GIT_GATE = """
_AGENT_WITH_REPOS = """
---
bottle: dev
git-gate:
repos:
r:
url: ssh://git@x/y.git
key:
provider: static
path: /dev/null
identity: /dev/null
---
bad agent.
"""
class TestAgentAuthorMdLoader(unittest.TestCase):
"""Locks the md path: `author` is an accepted agent frontmatter key
and threads into the parsed agent, populating identity; a stale
agent `git-gate` block dies through the same loader."""
class TestAgentGitUserMdLoader(unittest.TestCase):
"""Locks the md path: `git-gate` is an accepted agent key and threads
into the parsed Agent (not rejected as an unknown frontmatter key),
and agent `git-gate.repos` dies through the same loader."""
def setUp(self) -> None:
self.home = Path(tempfile.mkdtemp(prefix="cb-home-"))
@@ -186,30 +235,31 @@ class TestAgentAuthorMdLoader(unittest.TestCase):
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(textwrap.dedent(text).lstrip("\n"))
def test_md_agent_author_populates_identity(self):
def test_md_agent_git_user_overlays_bottle(self):
self._write("bottles/dev.md", _BOTTLE_DEV)
self._write("agents/impl.md", _AGENT_WITH_AUTHOR)
self._write("agents/impl.md", _AGENT_WITH_GIT)
m = ManifestIndex.resolve(str(self.home)).load_for_agent("impl")
u = m.bottle.git_user
self.assertEqual("agent-name", u.name)
self.assertEqual("agent@example.com", u.email)
self.assertEqual("bottle@example.com", u.email)
self.assertEqual(
"name=agent-name, email=agent@example.com",
"name=agent-name (agent), email=bottle@example.com (bottle)",
m.git_identity_summary(),
)
def test_md_agent_git_gate_fails_at_preflight(self):
"""A stale agent `git-gate` block is an error; resolve() still
succeeds so other agents remain accessible, but load_for_agent
raises. The lazy loader's frontmatter-key validator rejects the
unknown `git-gate` key first."""
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_GIT_GATE)
self._write("agents/impl.md", _AGENT_WITH_REPOS)
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")
self.assertIn("git-gate", str(ctx.exception))
msg = str(ctx.exception)
self.assertIn("git-gate.repos", msg)
self.assertIn("bottle-only", msg)
if __name__ == "__main__":
+40 -26
View File
@@ -130,10 +130,8 @@ class TestExtendsEnvMerge(unittest.TestCase):
class TestExtendsGitMerge(unittest.TestCase):
"""git-gate.repos merges by name, with same-name child entries
merging field-by-field (child wins). Bottles no longer carry a user
identity (PRD 0082), so only repos
merging is meaningful across extends chains."""
"""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", "key": {"provider": "static", "path": "/dev/null"}}
_GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "key": {"provider": "static", "path": "/dev/null"}}
@@ -256,16 +254,13 @@ class TestExtendsGitMerge(unittest.TestCase):
repo_entry = next(e for e in child.git if e.Name == "repo")
self.assertEqual("gitea", repo_entry.Key.provider)
def test_child_inherits_parent_repos_no_user_identity(self):
# Child omits git-gate entirely -> inherits the parent's repos.
# Bottles carry no user identity anymore, so git_user stays empty
# across the extends chain.
def test_child_git_user_inherits_parent_repos(self):
m = _build(
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
child={"extends": "base"},
child={"extends": "base", "git-gate": {"user": {"name": "Child"}}},
)
self.assertEqual(["a"], [e.Name for e in m.bottles["child"].git])
self.assertTrue(m.bottles["child"].git_user.is_empty())
self.assertEqual("Child", m.bottles["child"].git_user.name)
class TestExtendsEgressMerge(unittest.TestCase):
@@ -337,29 +332,48 @@ class TestExtendsEgressMerge(unittest.TestCase):
self.assertIn("A.EXAMPLE.COM", msg)
class TestExtendsNoBottleUserIdentity(unittest.TestCase):
"""Bottles no longer carry a user identity (PRD
0082): identity moved to the agent's
`author` block. `git-gate.user` on a bottle is rejected outright, and
`git_user` is always empty across an extends chain."""
class TestExtendsGitUserOverlay(unittest.TestCase):
"""git-gate.user: per-field overlay. Each non-empty field on child
wins; empties fall through to parent."""
def test_bottle_git_gate_user_dies(self):
# A stale `git-gate.user` on a bottle fails with the migration die
# even inside an extends chain.
msg = _error_message(
_build,
def test_parent_full_child_omits(self):
m = _build(
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
child={"extends": "base"},
)
self.assertIn("git-gate.user is no longer supported", msg)
u = m.bottles["child"].git_user
self.assertEqual("Parent", u.name)
self.assertEqual("p@x", u.email)
def test_git_user_empty_across_chain(self):
def test_child_overrides_both(self):
m = _build(
base={"git-gate": {"repos": {}}},
child={"extends": "base"},
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
child={
"extends": "base",
"git-gate": {"user": {"name": "Child", "email": "c@x"}},
},
)
self.assertTrue(m.bottles["base"].git_user.is_empty())
self.assertTrue(m.bottles["child"].git_user.is_empty())
u = m.bottles["child"].git_user
self.assertEqual("Child", u.name)
self.assertEqual("c@x", u.email)
def test_child_adds_email_inherits_name(self):
m = _build(
base={"git-gate": {"user": {"name": "Parent"}}},
child={"extends": "base", "git-gate": {"user": {"email": "c@x"}}},
)
u = m.bottles["child"].git_user
self.assertEqual("Parent", u.name)
self.assertEqual("c@x", u.email)
def test_child_overrides_only_email(self):
m = _build(
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
child={"extends": "base", "git-gate": {"user": {"email": "c@x"}}},
)
u = m.bottles["child"].git_user
self.assertEqual("Parent", u.name)
self.assertEqual("c@x", u.email)
class TestExtendsChain(unittest.TestCase):
+52 -62
View File
@@ -1,11 +1,4 @@
"""Unit: agent `author` identity -> bottle.git_user (PRD
0082).
Identity moved off `git-gate.user` (bottle) onto the trusted agent's
`author` block. At `load_for_agent` the agent's author populates the
effective bottle's `git_user` (a `ManifestGitUser`). This locks the
`author` validation/rejection paths and the bottle `git-gate.user`
migration die."""
"""Unit: Bottle git-gate.user manifest parsing + validation (issue #86, PRD 0047)."""
import unittest
@@ -21,92 +14,89 @@ def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
raise AssertionError("expected ManifestError was not raised")
def _manifest(author): # type: ignore
"""Build an index with one agent 'demo' carrying the given `author`
block, then load it, returning the composed Manifest."""
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
if author is not None:
agent["author"] = author
return ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": agent},
}).load_for_agent("demo")
def _manifest(git_user): # type: ignore
return {
"bottles": {"dev": {"git-gate": {"user": git_user}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}
class TestAuthorIdentity(unittest.TestCase):
"""The agent's `author` block populates bottle.git_user."""
class TestGitUserParsing(unittest.TestCase):
def test_parses_both_fields(self):
m = _manifest({
m = ManifestIndex.from_json_obj(_manifest({
"name": "Eric Bauerfeld",
"email": "eric+claude@dideric.is",
})
u = m.bottle.git_user
}))
u = m.bottles["dev"].git_user
self.assertEqual("Eric Bauerfeld", u.name)
self.assertEqual("eric+claude@dideric.is", u.email)
self.assertFalse(u.is_empty())
def test_omitted_author_defaults_to_empty(self):
# No author block at all -> empty git_user, is_empty True ->
def test_name_only(self):
m = ManifestIndex.from_json_obj(_manifest({"name": "Bot"}))
u = m.bottles["dev"].git_user
self.assertEqual("Bot", u.name)
self.assertEqual("", u.email)
def test_email_only(self):
m = ManifestIndex.from_json_obj(_manifest({"email": "bot@example.com"}))
u = m.bottles["dev"].git_user
self.assertEqual("", u.name)
self.assertEqual("bot@example.com", u.email)
def test_omitted_defaults_to_empty(self):
# No git.user block at all → empty GitUser, is_empty True →
# provisioner skips the `git config` step entirely.
m = _manifest(None)
self.assertTrue(m.bottle.git_user.is_empty())
def test_missing_name_dies(self):
# `author` is present but name is absent -> both fields required.
msg = _error_message(_manifest, {"email": "bot@example.com"})
self.assertIn("author.name must be a non-empty string", msg)
def test_missing_email_dies(self):
msg = _error_message(_manifest, {"name": "Bot"})
self.assertIn("author.email must be a non-empty string", msg)
m = ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
u = m.bottles["dev"].git_user
self.assertTrue(u.is_empty())
def test_both_empty_strings_dies(self):
# An explicit `author: {name: "", email: ""}` is a typo /
# half-finished edit; fail loudly rather than silently no-op.
msg = _error_message(_manifest, {"name": "", "email": ""})
self.assertIn("author.name must be a non-empty string", msg)
# An explicit `git.user: {name: "", email: ""}` is a typo
# / half-finished edit; fail loudly rather than silently
# no-op (the operator clearly meant to configure something).
msg = _error_message(
ManifestIndex.from_json_obj, _manifest({"name": "", "email": ""}),
)
self.assertIn("neither name nor email", msg)
def test_unknown_key_dies(self):
msg = _error_message(
_manifest,
{"name": "Bot", "email": "b@x", "username": "bot"},
ManifestIndex.from_json_obj,
_manifest({"name": "Bot", "username": "bot"}),
)
self.assertIn("unknown key", msg)
self.assertIn("username", msg)
def test_non_string_name_dies(self):
msg = _error_message(_manifest, {"name": 42, "email": "b@x"})
self.assertIn("author.name must be a non-empty string", msg)
msg = _error_message(
ManifestIndex.from_json_obj, _manifest({"name": 42}),
)
self.assertIn("git-gate.user.name must be a string", msg)
def test_non_string_email_dies(self):
msg = _error_message(_manifest, {"name": "Bot", "email": ["x@y.z"]})
self.assertIn("author.email must be a non-empty string", msg)
msg = _error_message(
ManifestIndex.from_json_obj, _manifest({"email": ["x@y.z"]}),
)
self.assertIn("git-gate.user.email must be a string", msg)
def test_email_with_whitespace_dies(self):
msg = _error_message(_manifest, {"name": "Bot", "email": "a @b"})
self.assertIn("author.email must not contain whitespace", msg)
class TestBottleGitGateUserMigration(unittest.TestCase):
"""`git-gate.user` on a bottle is no longer supported: it raises a
ManifestError pointing at the agent's `author` block."""
def test_bottle_git_gate_user_dies(self):
def test_legacy_top_level_git_user_dies(self):
msg = _error_message(
ManifestIndex.from_json_obj,
{
"bottles": {"dev": {"git-gate": {"user": {"name": "Bot"}}}},
"bottles": {"dev": {"git_user": {"name": "Bot"}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
},
)
self.assertIn("git-gate.user is no longer supported", msg)
self.assertIn("author", msg)
self.assertIn("git_user", msg)
self.assertIn("git-gate.user", msg)
class TestGitUserDirect(unittest.TestCase):
"""Direct GitUser dataclass exercises (no manifest wrapper). The
dataclass is still the runtime carrier on ManifestBottle.git_user."""
"""Direct GitUser dataclass exercises (no manifest wrapper)."""
def test_is_empty_default(self):
self.assertTrue(ManifestGitUser().is_empty())
+5 -10
View File
@@ -71,14 +71,11 @@ class _LazyCase(unittest.TestCase):
class TestAllAgentNamesLazy(_LazyCase):
def test_cwd_agents_ignored_home_only(self) -> None:
# Agents are home-only (PRD 0082):
# a cwd agents/ dir is warned-and-ignored, so only the home agent
# appears in all_agent_names.
def test_merges_home_and_cwd_agents(self) -> None:
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
_write(self.cwd_cb / "agents" / "beta.md", _AGENT)
self.assertEqual(["alpha"], self.resolve().all_agent_names)
self.assertEqual(["alpha", "beta"], self.resolve().all_agent_names)
class TestLoadForAgentLazy(_LazyCase):
@@ -99,13 +96,11 @@ class TestRequireAgentLazy(_LazyCase):
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
self.resolve().require_agent("alpha") # no raise
def test_cwd_only_agent_not_selectable(self) -> None:
# Agents are home-only (PRD 0082):
# a cwd-only agent file is never selectable, so require_agent raises.
def test_existing_cwd_agent_ok(self) -> None:
# File only under cwd -> require_agent's cwd_path branch.
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
_write(self.cwd_cb / "agents" / "beta.md", _AGENT)
with self.assertRaises(ManifestError):
self.resolve().require_agent("beta")
self.resolve().require_agent("beta") # no raise
def test_unknown_agent_raises(self) -> None:
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
+7 -14
View File
@@ -110,16 +110,14 @@ class TestAgentFileParses(_ResolveCase):
self.assertFalse(a.prompt.endswith("\n"))
class TestCwdAgentIgnoredHomeWins(_ResolveCase):
"""SC #3 (revised, PRD 0082): agents
are home-only. A cwd agent file with the same name as a home agent no
longer wins it is warned-and-ignored, so the HOME agent's prompt is
used and the home bottle stays intact."""
class TestCwdAgentOverridesHome(_ResolveCase):
"""SC #3: a cwd agent file with the same name as a home agent
wins. The home bottle stays intact."""
def test_home_wins_cwd_ignored(self):
def test_cwd_wins(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
# Cwd agent with a different prompt is ignored entirely.
# Cwd overrides with a different prompt
_write(
self.cwd_cb / "agents" / "implementer.md",
"""
@@ -131,19 +129,14 @@ class TestCwdAgentIgnoredHomeWins(_ResolveCase):
""",
)
m = self.resolve().load_for_agent("implementer")
# Home agent's body is used; the cwd override never applies.
self.assertIn("feature implementation agent", m.agent.prompt)
self.assertNotIn("CWD-OVERRIDE-PROMPT", m.agent.prompt)
self.assertIn("CWD-OVERRIDE-PROMPT", m.agent.prompt)
# Home bottle still present with its two egress routes
self.assertEqual(2, len(m.bottle.egress.routes))
class TestCwdBottlesIgnored(_ResolveCase):
"""SC #4: a bottles/ dir under $CWD is ignored (with a warn).
The home bottle still wins. Under
PRD 0082 a cwd agents/ dir is also
ignored, so $CWD contributes nothing the filesystem layout is the
trust boundary."""
The home bottle still wins; cwd contributes only agents."""
def test_ignored(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
+6 -17
View File
@@ -212,21 +212,13 @@ class TestAgentValidation(unittest.TestCase):
with self.assertRaises(ManifestError):
ManifestAgent.from_dict("a", {"prompt": 5}, set())
def test_git_gate_rejected_at_agent_level(self) -> None:
# `git-gate` (user or repos) is no longer accepted on an agent;
# identity moved to `author`, repos stays bottle-only.
def test_git_gate_repos_rejected_at_agent_level(self) -> None:
with self.assertRaises(ManifestError):
ManifestAgent.from_dict("a", {"git-gate": {"repos": {}}}, set())
with self.assertRaises(ManifestError):
ManifestAgent.from_dict(
"a", {"git-gate": {"user": {"name": "x"}}}, set()
)
def test_bottle_empty_git_gate_is_allowed(self) -> None:
# An empty `git-gate: {}` on a bottle is still allowed (only the
# optional `repos` subkey exists now); it contributes no git repos.
bottle = ManifestBottle.from_dict("b", {"git-gate": {}})
self.assertEqual((), bottle.git)
def test_git_gate_empty_is_allowed(self) -> None:
agent = ManifestAgent.from_dict("a", {"git-gate": {}}, set())
self.assertTrue(agent.git_user.is_empty())
# ---------------------------------------------------------------------------
@@ -236,12 +228,9 @@ class TestAgentValidation(unittest.TestCase):
class TestEagerIndexLookups(unittest.TestCase):
def _idx(self) -> ManifestIndex:
# Identity lives on the agent's `author` block now; at composition
# it populates the effective bottle's git_user.
return _idx({
"bottles": {"b": {}},
"agents": {"a": {"bottle": "b",
"author": {"name": "Bot", "email": "b@x"}}},
"bottles": {"b": {"git-gate": {"user": {"name": "Bot", "email": "b@x"}}}},
"agents": {"a": {"bottle": "b"}},
})
def test_unknown_bottle_section_is_empty(self) -> None: