Compare commits

..

1 Commits

Author SHA1 Message Date
didericis 6a4f8c8e52 docs(prd): mark merged PRDs as Active
test / unit (pull_request) Successful in 35s
test / integration (pull_request) Successful in 1m0s
Flip Status: Draft -> Active for the 22 PRDs whose work has shipped to
main. Leaves the terminal-status PRDs unchanged: 0007 and 0010
(Superseded) and 0014 (Retargeted) were replaced, not shipped as-is.
0027 stays Draft — its PR (#95) is not merged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 22:07:19 -04:00
8 changed files with 8 additions and 577 deletions
-17
View File
@@ -362,10 +362,6 @@ 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.
@@ -379,19 +375,6 @@ 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,10 +85,6 @@ 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,9 +125,6 @@ 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,9 +31,6 @@ 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(
+8 -72
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, replace from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Mapping, cast from typing import Mapping, cast
@@ -692,11 +692,6 @@ 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":
@@ -736,25 +731,7 @@ 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__})")
# git: agents may declare only `git.user` (name/email). Any return cls(bottle=bottle, skills=skills, prompt=prompt)
# 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)
@@ -897,50 +874,11 @@ 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, with the """Resolve the Bottle the named agent references. The validator
agent's git.user overlaid on top. The validator guarantees both guarantees both lookups succeed for a manifest built via
lookups succeed for a manifest built via from_json_obj. 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]:
@@ -1115,7 +1053,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", "git"}) _AGENT_KEYS_OPTIONAL = frozenset({"skills"})
# 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({
@@ -1363,13 +1301,11 @@ 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 reads bottle/skills/git/prompt). # by from_dict (which only reads bottle/skills/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
-226
View File
@@ -1,226 +0,0 @@
# 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.
-4
View File
@@ -5,10 +5,6 @@ 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
@@ -1,248 +0,0 @@
"""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()