Compare commits

..

13 Commits

Author SHA1 Message Date
didericis ae1531835d docs: drop "forge" jargon for concrete Gitea wording
test / integration (pull_request) Successful in 53s
test / integration (push) Successful in 57s
test / unit (pull_request) Successful in 33s
test / unit (push) Successful in 36s
We use Gitea, not an abstract forge. Reword the docs added in this
branch: "forge thread" -> "Gitea thread", and the research note's
generic "forge" -> "Gitea" / "hosting provider" as context demands,
keeping its portability argument coherent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:05:02 -04:00
didericis 5c5f576df0 docs(research): add README describing research notes
Document what research notes are (opinionated investigations of a
question/design space), their unnumbered kebab-case naming, and their
loose verdict-first shape — explicitly freeform, not a template. Point
the AGENTS.md research line at it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:05:02 -04:00
didericis d329e511fd docs: drop docs/INDEX.md, add PRD README with format
Remove the one-line docs/INDEX.md (its directory pointers are covered
by docs/README.md's "when to write which document" table). Add
docs/prds/README.md documenting the PRD naming, Status lifecycle, and
section format. Repoint the AGENTS.md repository-layout list at the
new READMEs and add the decisions/ dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:05:02 -04:00
didericis 1308e61c7e docs: hoist "when to write which document" to docs/README.md
Move the document-type comparison out of docs/decisions/README.md
(where it only surfaced if you were already in the decisions dir) up
to a new docs/README.md, renamed "When to write which document".
Leave a pointer from the decisions README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:05:02 -04:00
didericis 2141a85884 docs(decisions): drop hand-maintained index from README
Per review on PR #97: an index that lists every ADR is a sync
burden. The files in docs/decisions/ are the index.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:05:02 -04:00
didericis ccbed97776 docs(prd): inline #88 rationale into PRD 0025
Add an "Alternatives considered" section enumerating the design
options from issue #88 (duplicate bottles / agent-side bottle_config
/ bottle-side extends) and why extends won, so the PRD stands without
the forge thread. Repoint the two phrases that depended on the #88
comment thread at the new section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:05:02 -04:00
didericis 1df78ee77f docs(decisions): add ADR-lite decision log
Add docs/decisions/ with a convention README and back-fill two
decisions that previously had no in-repo home: merging PRs with
rebase (ADR 0001) and the agent-identity claimed-not-vouched trust
posture from PRD 0027 (ADR 0002). Point docs/INDEX.md at it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:05:02 -04:00
didericis c840182d12 docs(research): issue tracking vs in-repo decision history
Analyze tracking feature requests in Gitea against the project's
in-repo PRDs/research notes, given the goal of keeping decision
history portable and not provider-locked. Recommends demoting issues
to an ephemeral inbox and reifying durable rationale into the repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:05:02 -04:00
didericis 7b4c1cd091 docs: drop "forge" jargon for concrete wording
test / unit (push) Successful in 28s
test / integration (push) Successful in 42s
test / unit (pull_request) Successful in 26s
test / integration (pull_request) Successful in 43s
We use Gitea, not an abstract forge. Reword the pre-existing research
and PRD docs: the generic "Forge-API gate"/"forge tokens" become
"Git-host-API gate"/"Git-host tokens" (the gate still spans Gitea /
GitHub / GitLab), "Git/forge history" -> "Git/Gitea history", and the
KNOWN_FORGE_HOSTS / forge: manifest-field examples -> KNOWN_GIT_HOSTS
/ git_host:. Meaning preserved; only the word "forge" is dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 22:57:20 -04:00
didericis 47c3ba63f8 docs(prd): mark merged PRDs as Active
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 58s
test / integration (push) Successful in 54s
test / unit (push) Successful in 32s
Flip Status: Draft -> Active for the 23 PRDs whose work has shipped to
main (including 0027, now that PR #95 has merged). Leaves the
terminal-status PRDs unchanged: 0007 and 0010 (Superseded) and 0014
(Retargeted) were replaced, not shipped as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 22:12:03 -04:00
didericis dcd90cd45e docs(manifest): document + demo agent-level git.user
test / unit (pull_request) Successful in 35s
test / integration (pull_request) Successful in 57s
test / unit (push) Successful in 32s
test / integration (push) Successful in 44s
README manifest section documents the agent git.user overlay, the
bottle-only git.remotes boundary, and the claimed-not-vouched trust
note. Collapses the example: implementer carries its own identity
against the shared dev bottle instead of an identity-only bottle.

Refs #94

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 21:10:47 -04:00
didericis 0708e99e4e feat(manifest): lift git.user to the agent layer
Agents may declare git.user (name/email); it overlays the referenced
bottle's git.user per-field at Manifest.bottle_for (agent wins on
non-empty), mirroring the extends: merge. git.remotes is rejected on
agents — it carries credentials and host trust and stays bottle-only.

The overlay lives at bottle_for, the single chokepoint both backends
use, so the docker/smolmachines git provisioners are unchanged. Adds
Manifest.git_identity_summary with per-field (agent)/(bottle)
provenance, printed in both preflights and `info`.

Refs #94

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 21:10:47 -04:00
didericis f9e3b6adda docs(prd): add PRD 0027 agent-level git user identity
test / unit (pull_request) Successful in 27s
test / integration (pull_request) Successful in 43s
Lift git.user (name/email) to the agent layer with a per-field
overlay onto the referenced bottle, mirroring the extends: merge.
git.remotes stays bottle-only. Includes identity provenance in
preflight/info and an example collapse.

Refs #94

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 20:58:00 -04:00
32 changed files with 605 additions and 36 deletions
+17
View File
@@ -362,6 +362,10 @@ Dockerfile while keeping the bot-bottle sidecars in place.
bottle: gitea-dev bottle: gitea-dev
skills: skills:
- init-prd - init-prd
git:
user:
name: gitea-helper
email: eric+gitea-helper@dideric.is
--- ---
You help maintain Gitea-hosted projects. You help maintain Gitea-hosted projects.
@@ -375,6 +379,19 @@ frontmatter — bot-bottle ignores them at launch but doesn't
reject them, so the same file can drop into `~/.claude/agents/` as a reject them, so the same file can drop into `~/.claude/agents/` as a
Claude Code subagent. Claude Code subagent.
An agent may also declare `git.user` (`name` / `email`). It overlays
the referenced bottle's `git.user` per-field — the agent's non-empty
fields win, the rest fall through to the bottle — so two agents can
share one bottle and still commit under distinct identities without
an identity-only bottle (PRD 0027). Only `git.user` is allowed at the
agent level; `git.remotes` stays bottle-only because it carries
credentials and host trust. The launch preflight and `cli.py info`
print the effective identity annotated `(agent)` / `(bottle)` so you
can see where each field came from. Git authorship is not a
credential — push auth is the bottle's remote key/token — so a
repo-shipped agent setting its own identity grants no access; treat
an agent identity as *claimed, not vouched*.
Unknown top-level frontmatter keys die at load with a "did you mean" Unknown top-level frontmatter keys die at load with a "did you mean"
pointer; typos don't silently ghost into an empty config. pointer; typos don't silently ghost into an empty config.
+4
View File
@@ -85,6 +85,10 @@ class DockerBottlePlan(BottlePlan):
print_multi("skills ", list(agent.skills)) print_multi("skills ", list(agent.skills))
info(f"bottle : {agent.bottle}") info(f"bottle : {agent.bottle}")
identity = manifest.git_identity_summary(spec.agent_name)
if identity:
info(f" git identity : {identity}")
git_lines = [ git_lines = [
f"{u.upstream_host}:{u.upstream_port}" f"{u.upstream_host}:{u.upstream_port}"
for u in self.git_gate_plan.upstreams for u in self.git_gate_plan.upstreams
@@ -125,6 +125,9 @@ class SmolmachinesBottlePlan(BottlePlan):
print_multi("env ", env_names) print_multi("env ", env_names)
print_multi("skills ", list(agent.skills)) print_multi("skills ", list(agent.skills))
info(f"bottle : {agent.bottle}") info(f"bottle : {agent.bottle}")
identity = manifest.git_identity_summary(spec.agent_name)
if identity:
info(f" git identity : {identity}")
if upstreams: if upstreams:
print_multi(" git gate ", upstreams) print_multi(" git gate ", upstreams)
if routes: if routes:
+3
View File
@@ -31,6 +31,9 @@ def cmd_info(argv: list[str]) -> int:
f"first line: {prompt_first_line or '(empty)'}" f"first line: {prompt_first_line or '(empty)'}"
) )
info(f"bottle : {agent.bottle}") info(f"bottle : {agent.bottle}")
identity = manifest.git_identity_summary(args.name)
if identity:
info(f" git identity : {identity}")
if bottle.git: if bottle.git:
for e in bottle.git: for e in bottle.git:
info( info(
+72 -8
View File
@@ -47,7 +47,7 @@ import ipaddress
import json import json
import os import os
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field, replace
from pathlib import Path from pathlib import Path
from typing import Mapping, cast from typing import Mapping, cast
@@ -692,6 +692,11 @@ class Agent:
bottle: str bottle: str
skills: tuple[str, ...] = () skills: tuple[str, ...] = ()
prompt: str = "" prompt: str = ""
# Per-agent git identity (issue #94). Overlays the referenced
# bottle's git.user per-field at `Manifest.bottle_for`. Only the
# `user` block is allowed at the agent level; `git.remotes` stays
# bottle-only because it carries credentials and host trust.
git_user: GitUser = GitUser()
@classmethod @classmethod
def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "Agent": def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "Agent":
@@ -731,7 +736,25 @@ class Agent:
else: else:
die(f"agent '{name}' prompt must be a string (was {type(prompt_raw).__name__})") die(f"agent '{name}' prompt must be a string (was {type(prompt_raw).__name__})")
return cls(bottle=bottle, skills=skills, prompt=prompt) # git: agents may declare only `git.user` (name/email). Any
# other git key — notably `remotes` — is rejected: remotes
# carry credentials and host trust and stay bottle-only.
git_user = GitUser()
git_raw = d.get("git")
if git_raw is not None:
gd = _as_json_object(git_raw, f"agent '{name}' git")
for k in gd.keys():
if k != "user":
die(
f"agent '{name}' git.{k} is not allowed at the "
f"agent level; only git.user (name/email) may be "
f"set on an agent. git.remotes is bottle-only "
f"(it carries credentials and host trust)."
)
if "user" in gd:
git_user = GitUser.from_dict(name, gd["user"])
return cls(bottle=bottle, skills=skills, prompt=prompt, git_user=git_user)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -874,11 +897,50 @@ class Manifest:
) )
die(f"bottle '{name}' not defined in bot-bottle.json (no bottles defined).") die(f"bottle '{name}' not defined in bot-bottle.json (no bottles defined).")
def _effective_git_user(self, agent_name: str) -> GitUser:
"""Merge the agent's git.user over the referenced bottle's,
per-field, agent-wins-on-non-empty (issue #94). Same overlay
the `extends:` resolver applies between bottles
(`_merge_bottles`)."""
agent = self.agents[agent_name]
base = self.bottles[agent.bottle].git_user
over = agent.git_user
if over.is_empty():
return base
return GitUser(
name=over.name or base.name,
email=over.email or base.email,
)
def bottle_for(self, agent_name: str) -> Bottle: def bottle_for(self, agent_name: str) -> Bottle:
"""Resolve the Bottle the named agent references. The validator """Resolve the Bottle the named agent references, with the
guarantees both lookups succeed for a manifest built via agent's git.user overlaid on top. The validator guarantees both
from_json_obj.""" lookups succeed for a manifest built via from_json_obj.
return self.bottles[self.agents[agent_name].bottle]
The overlay lives here, the single point both backends call to
resolve an agent's bottle, so the docker / smolmachines git
provisioners pick up the merged identity unchanged."""
bottle = self.bottles[self.agents[agent_name].bottle]
merged = self._effective_git_user(agent_name)
if merged == bottle.git_user:
return bottle
return replace(bottle, git_user=merged)
def git_identity_summary(self, agent_name: str) -> str | None:
"""One-line effective git identity with per-field provenance
for launch summaries, e.g.
`name=claude (agent), email=eric@dideric.is (bottle)`.
Returns None when neither agent nor bottle sets an identity."""
over = self.agents[agent_name].git_user
merged = self._effective_git_user(agent_name)
if merged.is_empty():
return None
parts: list[str] = []
if merged.name:
parts.append(f"name={merged.name} ({'agent' if over.name else 'bottle'})")
if merged.email:
parts.append(f"email={merged.email} ({'agent' if over.email else 'bottle'})")
return ", ".join(parts)
def _as_json_object(value: object, label: str) -> dict[str, object]: def _as_json_object(value: object, label: str) -> dict[str, object]:
@@ -1053,7 +1115,7 @@ _BOTTLE_KEYS = frozenset(
{"env", "extends", "agent_provider", "git", "egress", "supervise"} {"env", "extends", "agent_provider", "git", "egress", "supervise"}
) )
_AGENT_KEYS_REQUIRED = frozenset({"bottle"}) _AGENT_KEYS_REQUIRED = frozenset({"bottle"})
_AGENT_KEYS_OPTIONAL = frozenset({"skills"}) _AGENT_KEYS_OPTIONAL = frozenset({"skills", "git"})
# Claude Code subagent fields bot-bottle ignores at launch but # Claude Code subagent fields bot-bottle ignores at launch but
# doesn't reject — lets the same file double as `~/.claude/agents/*.md`. # doesn't reject — lets the same file double as `~/.claude/agents/*.md`.
_AGENT_KEYS_CC_PASSTHROUGH = frozenset({ _AGENT_KEYS_CC_PASSTHROUGH = frozenset({
@@ -1301,11 +1363,13 @@ def _load_agents_from_dir(
) )
# Build the dict Agent.from_dict expects. The body becomes # Build the dict Agent.from_dict expects. The body becomes
# prompt; CC passthrough fields stay in fm and get ignored # prompt; CC passthrough fields stay in fm and get ignored
# by from_dict (which only reads bottle/skills/prompt). # by from_dict (which reads bottle/skills/git/prompt).
agent_dict: dict[str, object] = { agent_dict: dict[str, object] = {
"bottle": fm.get("bottle"), "bottle": fm.get("bottle"),
"skills": fm.get("skills", []), "skills": fm.get("skills", []),
"prompt": body.strip(), "prompt": body.strip(),
} }
if "git" in fm:
agent_dict["git"] = fm["git"]
out[name] = Agent.from_dict(name, agent_dict, bottle_names) out[name] = Agent.from_dict(name, agent_dict, bottle_names)
return out return out
@@ -1,6 +1,6 @@
# PRD 0001: Per-agent egress proxy via pipelock # PRD 0001: Per-agent egress proxy via pipelock
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-08 - **Created:** 2026-05-08
@@ -1,6 +1,6 @@
# PRD 0002: Test pipeline on Gitea Actions # PRD 0002: Test pipeline on Gitea Actions
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-08 - **Created:** 2026-05-08
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0003: Bottle Backend abstraction # PRD 0003: Bottle Backend abstraction
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-10 - **Created:** 2026-05-10
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0004: Split out provisioners # PRD 0004: Split out provisioners
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-11 - **Created:** 2026-05-11
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0006: pipelock native TLS interception # PRD 0006: pipelock native TLS interception
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-12 - **Created:** 2026-05-12
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0008: Git gate # PRD 0008: Git gate
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-12 - **Created:** 2026-05-12
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0009: Remove ssh-gate and bottle.ssh # PRD 0009: Remove ssh-gate and bottle.ssh
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-13 - **Created:** 2026-05-13
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0011: Per-file Markdown manifest # PRD 0011: Per-file Markdown manifest
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-24 - **Created:** 2026-05-24
+2 -2
View File
@@ -1,6 +1,6 @@
# PRD 0012: Stuck-agent recovery flow # PRD 0012: Stuck-agent recovery flow
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-24 - **Created:** 2026-05-24
@@ -22,7 +22,7 @@ A real stuck agent recovers end-to-end in each of the three categories: a **cred
- Live attach or in-place mutation of running containers. The whole design exists to avoid this. - Live attach or in-place mutation of running containers. The whole design exists to avoid this.
- Agent-to-agent communication. Re-stated from the project's existing non-goals; the recovery flow is human→agent only. - Agent-to-agent communication. Re-stated from the project's existing non-goals; the recovery flow is human→agent only.
- Auditing or forensic replay of agent runs. Git/forge history is the audit log; this PRD does not add a separate run log. - Auditing or forensic replay of agent runs. Git/Gitea history is the audit log; this PRD does not add a separate run log.
- Reducing time-to-unstuck below some target. Faster than kill-and-restart is implicit, but no specific SLO is in scope. - Reducing time-to-unstuck below some target. Faster than kill-and-restart is implicit, but no specific SLO is in scope.
## Stuck categories ## Stuck categories
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0013: Supervise plane foundation # PRD 0013: Supervise plane foundation
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-25 - **Created:** 2026-05-25
- **Parent:** PRD 0012 - **Parent:** PRD 0012
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0015: pipelock block remediation # PRD 0015: pipelock block remediation
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-25 - **Created:** 2026-05-25
- **Parent:** PRD 0012 - **Parent:** PRD 0012
@@ -1,6 +1,6 @@
# PRD 0016: capability block remediation # PRD 0016: capability block remediation
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-25 - **Created:** 2026-05-25
- **Parent:** PRD 0012 - **Parent:** PRD 0012
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0017: Egress-proxy — universal MITM with path filtering + auth injection # PRD 0017: Egress-proxy — universal MITM with path filtering + auth injection
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-25 - **Created:** 2026-05-25
- **Supersedes:** the cred-proxy sidecar (PRD 0010) — hard cutover. - **Supersedes:** the cred-proxy sidecar (PRD 0010) — hard cutover.
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0018: One Compose project per bottle instance # PRD 0018: One Compose project per bottle instance
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-25 - **Created:** 2026-05-25
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0019: Active agents in the dashboard, agent-scoped edit verbs # PRD 0019: Active agents in the dashboard, agent-scoped edit verbs
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-26 - **Created:** 2026-05-26
@@ -1,6 +1,6 @@
# PRD 0020: Start and attach to agents from inside the dashboard # PRD 0020: Start and attach to agents from inside the dashboard
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-26 - **Created:** 2026-05-26
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0021: Dashboard as left tmux pane, selected agent as right pane # PRD 0021: Dashboard as left tmux pane, selected agent as right pane
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-26 - **Created:** 2026-05-26
@@ -1,6 +1,6 @@
# PRD 0022: End-to-end sandbox-escape integration test # PRD 0022: End-to-end sandbox-escape integration test
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-26 - **Created:** 2026-05-26
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0023: smolmachines bottle backend # PRD 0023: smolmachines bottle backend
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-26 - **Created:** 2026-05-26
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0024: Consolidate per-bottle sidecars into a single bundle # PRD 0024: Consolidate per-bottle sidecars into a single bundle
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-26 - **Created:** 2026-05-26
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0025: Bottle composition via `extends:` # PRD 0025: Bottle composition via `extends:`
- **Status:** Draft - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-27 - **Created:** 2026-05-27
- **Issue:** #88 - **Issue:** #88
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0026: Agent Provider Templates # PRD 0026: Agent Provider Templates
- **Status:** Draft - **Status:** Active
- **Author:** codex - **Author:** codex
- **Created:** 2026-05-28 - **Created:** 2026-05-28
+226
View File
@@ -0,0 +1,226 @@
# PRD 0027: Agent-level git user identity
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-28
- **Issue:** #94
## Summary
Let an **agent** file declare `git.user` (name / email). At launch
the agent's `git.user` overlays the referenced bottle's `git.user`
per-field (agent wins on non-empty fields), mirroring the
`extends:` overlay from PRD 0025. `git.remotes` stays bottle-only.
Solves the "I need a whole separate bottle just to change the commit
name" coupling: today commit attribution — a purpose/presentation
concern — can only be varied by authoring a new security boundary
(a bottle). After this change, two agents (e.g. `implementer` and
`reviewer`) can share one bottle and still commit under distinct
identities.
## Problem
`git.user.name` / `git.user.email` is a bottle-only field. Agent
frontmatter is validated against a strict allowlist (`_AGENT_KEYS`
in `manifest.py`); a `git:` block in an agent file dies at parse
with "unknown frontmatter key(s)". So the only way to give an agent
a distinct commit identity is to give it a distinct *bottle*.
That couples identity to the trust boundary. Bottles are home-only
and define security (egress, credentials, provider). Forcing a new
bottle per commit-name means either (a) duplicating a bottle just to
flip one string, or (b) leaning on PRD 0025 `extends:` to make a
near-clone whose only delta is `git.user`. Both are heavier than the
concern deserves.
## Why this is safe (trust analysis)
Allowing agent files — which can live in `$CWD/.bot-bottle/agents/`
and therefore be supplied by a cloned repo — to set `git.user` does
**not** weaken the security model, because git author identity is
not a credential or a capability:
- **Push auth is separate.** Authentication to a remote is the
bottle's `git.remotes` IdentityFile / token. `user.name` /
`user.email` grant zero access.
- **It's already forgeable.** The agent inside the bottle can run
`git config user.email <anything>` or `git commit --author=...`
at runtime regardless of the manifest. The manifest field is only
a *default*; allowing agents to set it adds no capability that
isn't already reachable one layer down.
- **Authorship was never a trust anchor.** If attribution integrity
matters, that is a commit-*signing* concern (SSH/GPG), which a
name/email field cannot provide either way.
The one residual is cosmetic impersonation (a cloned repo's agent
file could set an identity that reads like a real person's). Commits
still push under the bottle's credentials, and the author string was
never vouched-for, so this is presentation, not escalation. We
document that an agent identity is *claimed, not vouched*.
`git.remotes` is explicitly **not** lifted to the agent layer — that
block carries credentials and host trust (IdentityFile, KnownHostKey)
and stays a bottle-only, home-only concern.
## Goals / Success Criteria
- Add an optional `git.user` block to the agent frontmatter schema
(name and/or email), reusing the existing `GitUser` validator.
- An agent's declared `git.user` overlays the referenced bottle's
`git.user` per-field; each non-empty agent field wins, empties
fall through to the bottle. Identical overlay semantics to the
bottle `extends:` merge (PRD 0025).
- `git.remotes` in an agent file is rejected at parse with a clear
"bottle-only" pointer.
- The overlay is applied at `Manifest.bottle_for()` — the single
point both backends call to resolve an agent's bottle — so the
docker and smolmachines provisioners need no changes.
- Existing agent files continue to parse identically — `git.user`
is opt-in.
- **Identity provenance is surfaced.** The y/N preflight (both
backends) and `cli.py info <agent>` print the effective git
identity with a per-field `(agent)` / `(bottle)` annotation so the
operator can see which level each field came from.
- **The example collapses to demonstrate the pattern.** The bundled
example replaces the identity-only-bottle shape with one shared
bottle + per-agent `git.user`, showing the intended usage.
## Non-goals
- **No agent-level `git.remotes`.** Credentials and host trust stay
bottle-only and home-only.
- **No other agent-level bottle fields.** This PRD lifts `git.user`
and nothing else; agents do not gain `egress`, `env`,
`agent_provider`, etc. (that is the issue #88 design PRD 0025
deliberately rejected).
- **No commit signing.** Attribution integrity via SSH/GPG is a
separate concern, out of scope here.
- **No CWD-vs-HOME identity gating.** Because the field is
non-enforcing (see trust analysis), CWD and HOME agents are
treated the same. If a future change makes identity load-bearing,
revisit.
## Design
### Schema
A new optional `git:` block on agent files, carrying only `user`:
```yaml
---
bottle: claude-dev
git:
user:
name: claude-implementer
email: eric+claude-implementer@dideric.is
---
You are a feature-implementation agent ...
```
- `git.user` accepts `name` and/or `email` (string-or-die; at least
one non-empty), validated by the **existing** `GitUser.from_dict`.
- `git.remotes` (or any `git` key other than `user`) in an agent
file dies at parse: "git.remotes is bottle-only".
- `git` is added to `_AGENT_KEYS`; the agent md-loader threads the
raw `git` block into the dict `Agent.from_dict` consumes.
### Merge rule
When `Manifest.bottle_for(agent)` resolves the agent's bottle, it
returns the bottle with `git_user` overlaid by the agent's
`git_user`:
| Field | Merge |
|-------------------|------------------------------------------------|
| `git_user.name` | agent's if non-empty, else bottle's |
| `git_user.email` | agent's if non-empty, else bottle's |
This is the same per-field overlay `_merge_bottles` already applies
for `extends:` (`manifest.py:1212`). Empty string = "not set", the
same predicate the provisioner's `is_empty()` uses. All other bottle
fields are returned unchanged.
### Where the overlay lives
```
def bottle_for(self, agent_name) -> Bottle:
agent = self.agents[agent_name]
bottle = self.bottles[agent.bottle]
if agent.git_user.is_empty():
return bottle
merged = GitUser(
name=agent.git_user.name or bottle.git_user.name,
email=agent.git_user.email or bottle.git_user.email,
)
return dataclasses.replace(bottle, git_user=merged)
```
Both `provision/git.py` paths (docker + smolmachines) already call
`bottle_for(agent_name)` and read `bottle.git_user`, so they pick up
the merged identity with no edits. The dashboard / `info` / preflight
surfaces that print bottle config go through the same resolution.
### Provenance display
A sibling `Manifest.git_identity_summary(agent_name) -> str | None`
returns the effective identity annotated per field, e.g.:
```
identity : name=claude-implementer (agent), email=eric+claude-implementer@dideric.is (bottle)
```
`(agent)` when the agent's `git.user` supplied that field, `(bottle)`
when it fell through to the referenced bottle. Returns `None` when no
identity is set at either level (callers omit the line). Both
`bottle_plan.py` preflights and `cli/info.py` print it; `info` today
prints `git remotes` but not the identity, so this adds the line.
### `Agent` dataclass
`Agent` gains `git_user: GitUser = GitUser()`. `Agent.from_dict`
parses the optional `git.user` and rejects non-`user` git keys.
## Implementation chunks
1. **PRD (this commit).** Sets the design.
2. **Schema + overlay + tests.**
- Add `"git"` to `_AGENT_KEYS`.
- Add `git_user: GitUser = GitUser()` to `Agent`; parse it in
`Agent.from_dict`, reusing `GitUser.from_dict`; reject any
`git` key other than `user` with a bottle-only message.
- Thread `fm.get("git")` through `_load_agents_from_dir`'s
`agent_dict`.
- Apply the per-field overlay in `Manifest.bottle_for()`.
- Add `Manifest.git_identity_summary()` and print it in both
`bottle_plan.py` preflights and `cli/info.py`.
- Unit tests: agent name+email overlay; agent name-only (email
falls through to bottle); agent email-only; agent identity with
a bottle that declares none; agent `git.remotes` dies; agent
with no `git` block unchanged; bottle-only behavior preserved;
provenance summary returns the right `(agent)`/`(bottle)` tags
and `None` when unset.
3. **Docs + example.** README manifest section: note `git.user` is
allowed on agents and overlays the bottle; `git.remotes` stays
bottle-only. Collapse the example — `examples/agents/implementer.md`
carries its own `git.user` against the shared `dev` bottle,
demonstrating per-agent identity without an identity-only bottle.
## Testing strategy
- **Unit (must):** the overlay matrix above, the parse-time reject
for agent `git.remotes`, and confirmation that `bottle_for` leaves
every non-`git_user` bottle field untouched.
- **No integration changes needed:** provisioners consume the
already-merged `Bottle` via `bottle_for`; existing docker /
smolmachines git-provisioning tests cover the consumption path.
## Open questions
- **Should a `git.user`-declaring agent be loadable as a Claude Code
subagent file too?** The CC-passthrough keys (`name`, `model`, …)
let one file double as `~/.claude/agents/*.md`; `git` is not a CC
key, so a file carrying it won't round-trip as a CC subagent. Not a
blocker (bot-bottle ignores unknown CC keys; CC ignores `git`), but
worth noting. Out of scope.
@@ -314,9 +314,9 @@ In priority order:
npm even if it captures something. Also disable Sentry error npm even if it captures something. Also disable Sentry error
reporting via `DISABLE_ERROR_REPORTING=1`. reporting via `DISABLE_ERROR_REPORTING=1`.
3. **Generalize the same proxy to forge tokens.** Add a manifest 3. **Generalize the same proxy to Git-host tokens.** Add a manifest
field along the lines of field along the lines of
`forge: { kind: "gitea", url, tokenRef }` so a per-bottle token `git_host: { kind: "gitea", url, tokenRef }` so a per-bottle token
reference resolves at launch, the proxy starts as root before reference resolves at launch, the proxy starts as root before
`node` is exec'd, and `tea` plus git HTTPS remotes are `node` is exec'd, and `tea` plus git HTTPS remotes are
pre-configured to point at the proxy. Use pre-configured to point at the proxy. Use
@@ -148,7 +148,7 @@ telemetry to `statsig.anthropic.com` — are documented in
[`agent-credential-proxy-landscape.md`](agent-credential-proxy-landscape.md) [`agent-credential-proxy-landscape.md`](agent-credential-proxy-landscape.md)
§Anthropic / Claude Code. §Anthropic / Claude Code.
**Forge-API gate (Gitea / GitHub / GitLab).** Holds the PAT; **Git-host-API gate (Gitea / GitHub / GitLab).** Holds the PAT;
exposes a narrow REST surface. Token auth on all three is exposes a narrow REST surface. Token auth on all three is
stateless `Authorization`-header injection — no CSRF, no request stateless `Authorization`-header injection — no CSRF, no request
signing, no per-request nonce — so one proxy generalizes by signing, no per-request nonce — so one proxy generalizes by
@@ -221,7 +221,7 @@ Add a `secret: true` flag (or a `secrets:` sibling of `env:`) that:
AWS_SECRET_ACCESS_KEY"). AWS_SECRET_ACCESS_KEY").
- Refuses to launch if `egress.allowlist` contains any host that - Refuses to launch if `egress.allowlist` contains any host that
is not source-controlled by the user (heuristic: not on a is not source-controlled by the user (heuristic: not on a
built-in `KNOWN_FORGE_HOSTS` list). built-in `KNOWN_GIT_HOSTS` list).
- Forces an explicit acknowledgement that a credential is being - Forces an explicit acknowledgement that a credential is being
placed into the bottle rather than behind a gate. placed into the bottle rather than behind a gate.
@@ -280,7 +280,7 @@ In priority order:
([`agent-credential-proxy-landscape.md`](agent-credential-proxy-landscape.md) ([`agent-credential-proxy-landscape.md`](agent-credential-proxy-landscape.md)
§Recommended). Removes the highest-value secret and closes the §Recommended). Removes the highest-value secret and closes the
passthrough hole as a side effect. passthrough hole as a side effect.
2. **Forge-API gate** (same doc, same section — one proxy 2. **Git-host-API gate** (same doc, same section — one proxy
generalizes across Gitea / GitHub / GitLab by config). generalizes across Gitea / GitHub / GitLab by config).
3. **Egress data budget** in pipelock — small lift, large damage 3. **Egress data budget** in pipelock — small lift, large damage
bound. bound.
+4
View File
@@ -5,6 +5,10 @@ model: opus
bottle: dev bottle: dev
skills: skills:
- init-prd - init-prd
git:
user:
name: implementer-bot
email: eric+implementer@dideric.is
--- ---
You are a feature-implementation agent running inside an ephemeral You are a feature-implementation agent running inside an ephemeral
+248
View File
@@ -0,0 +1,248 @@
"""Unit: agent-level git.user overlay + provenance (PRD 0027, issue #94).
An agent file may declare `git.user` (name/email). At
`Manifest.bottle_for()` it overlays the referenced bottle's
`git.user` per-field, agent-wins-on-non-empty. `git.remotes` is
rejected on agents. `Manifest.git_identity_summary()` reports the
effective identity with per-field `(agent)`/`(bottle)` provenance.
The `from_json_obj` path drives `Agent.from_dict` + `bottle_for`;
a temp-dir case locks the md loader (the `_AGENT_KEYS` allow + the
`git` threading into `agent_dict`)."""
from __future__ import annotations
import contextlib
import io
import os
import shutil
import tempfile
import textwrap
import unittest
from pathlib import Path
from bot_bottle.log import Die
from bot_bottle.manifest import Manifest
def _die_message(callable_, *args, **kwargs) -> str:
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
try:
callable_(*args, **kwargs)
except Die:
return buf.getvalue()
raise AssertionError("expected Die was not raised")
def _manifest(*, bottle_user=None, agent_git=None) -> Manifest:
bottle: dict = {}
if bottle_user is not None:
bottle = {"git": {"user": bottle_user}}
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"}
if agent_git is not None:
agent["git"] = agent_git
return Manifest.from_json_obj({
"bottles": {"dev": bottle},
"agents": {"impl": agent},
})
class TestAgentGitUserOverlay(unittest.TestCase):
def test_agent_supplies_both_fields(self):
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
u = m.bottle_for("impl").git_user
self.assertEqual("a", u.name)
self.assertEqual("a@b", u.email)
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_for("impl").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_for("impl").git_user
self.assertEqual("B", u.name)
self.assertEqual("a@b", u.email)
def test_agent_identity_with_bottle_declaring_none(self):
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
# The underlying bottle declares no identity; the merged one does.
self.assertTrue(m.bottles["dev"].git_user.is_empty())
self.assertFalse(m.bottle_for("impl").git_user.is_empty())
def test_bottle_only_identity_preserved_when_agent_silent(self):
m = _manifest(bottle_user={"name": "B", "email": "b@c"})
u = m.bottle_for("impl").git_user
self.assertEqual("B", u.name)
self.assertEqual("b@c", u.email)
def test_bottle_for_returns_same_instance_when_no_overlay(self):
# No agent git.user → no replace(); the cached Bottle is
# returned as-is (identity check guards against churn).
m = _manifest(bottle_user={"name": "B"})
self.assertIs(m.bottles["dev"], m.bottle_for("impl"))
def test_bottle_for_returns_same_instance_when_overlay_is_noop(self):
# Agent restates exactly what the bottle already has → merged
# == bottle.git_user → same instance, no replace().
m = _manifest(
bottle_user={"name": "B", "email": "b@c"},
agent_git={"user": {"name": "B", "email": "b@c"}},
)
self.assertIs(m.bottles["dev"], m.bottle_for("impl"))
def test_other_bottle_fields_untouched_by_overlay(self):
m = Manifest.from_json_obj({
"bottles": {"dev": {
"env": {"FOO": "bar"},
"supervise": True,
"git": {"user": {"name": "B"}},
}},
"agents": {"impl": {
"bottle": "dev", "skills": [], "prompt": "",
"git": {"user": {"name": "a"}},
}},
})
b = m.bottle_for("impl")
self.assertEqual("a", b.git_user.name)
self.assertEqual({"FOO": "bar"}, dict(b.env))
self.assertTrue(b.supervise)
class TestAgentGitUserRejections(unittest.TestCase):
def test_agent_remotes_dies_bottle_only(self):
msg = _die_message(_manifest, agent_git={
"remotes": {"h": {"Name": "r", "Upstream": "ssh://x/y.git"}},
})
self.assertIn("git.remotes", msg)
self.assertIn("bottle-only", msg)
def test_agent_unknown_git_subkey_dies(self):
msg = _die_message(_manifest, agent_git={"nope": {}})
self.assertIn("not allowed at the agent level", msg)
def test_agent_git_user_both_empty_dies(self):
# Reuses GitUser.from_dict validation.
msg = _die_message(_manifest, agent_git={"user": {"name": "", "email": ""}})
self.assertIn("neither name nor email", msg)
class TestGitIdentitySummary(unittest.TestCase):
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("impl"),
)
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("impl"),
)
def test_bottle_only(self):
m = _manifest(bottle_user={"name": "B", "email": "b@c"})
self.assertEqual(
"name=B (bottle), email=b@c (bottle)",
m.git_identity_summary("impl"),
)
def test_none_when_unset_anywhere(self):
m = _manifest()
self.assertIsNone(m.git_identity_summary("impl"))
_BOTTLE_DEV = """
---
git:
user:
name: bottle-name
email: bottle@example.com
---
dev bottle.
"""
_AGENT_WITH_GIT = """
---
bottle: dev
git:
user:
name: agent-name
---
impl agent.
"""
_AGENT_WITH_REMOTES = """
---
bottle: dev
git:
remotes:
h:
Name: r
Upstream: ssh://x/y.git
---
bad agent.
"""
class TestAgentGitUserMdLoader(unittest.TestCase):
"""Locks the md path: `git` is an accepted agent key and threads
into the parsed Agent (not rejected as an unknown frontmatter
key), and agent `git.remotes` dies through the same loader."""
def setUp(self) -> None:
self.home = Path(tempfile.mkdtemp(prefix="cb-home-"))
self._orig_home = os.environ.get("HOME")
os.environ["HOME"] = str(self.home)
def tearDown(self) -> None:
if self._orig_home is None:
os.environ.pop("HOME", None)
else:
os.environ["HOME"] = self._orig_home
shutil.rmtree(self.home, ignore_errors=True)
def _write(self, rel: str, text: str) -> None:
p = self.home / ".bot-bottle" / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(textwrap.dedent(text).lstrip("\n"))
def test_md_agent_git_user_overlays_bottle(self):
self._write("bottles/dev.md", _BOTTLE_DEV)
self._write("agents/impl.md", _AGENT_WITH_GIT)
m = Manifest.resolve(str(self.home))
u = m.bottle_for("impl").git_user
self.assertEqual("agent-name", u.name) # agent wins
self.assertEqual("bottle@example.com", u.email) # bottle falls through
self.assertEqual(
"name=agent-name (agent), email=bottle@example.com (bottle)",
m.git_identity_summary("impl"),
)
def test_md_agent_remotes_dies(self):
self._write("bottles/dev.md", _BOTTLE_DEV)
self._write("agents/impl.md", _AGENT_WITH_REMOTES)
msg = _die_message(Manifest.resolve, str(self.home))
self.assertIn("git.remotes", msg)
self.assertIn("bottle-only", msg)
if __name__ == "__main__":
unittest.main()