PRD 0027: Agent-level git user identity #95
@@ -362,6 +362,10 @@ Dockerfile while keeping the bot-bottle sidecars in place.
|
||||
bottle: gitea-dev
|
||||
skills:
|
||||
- init-prd
|
||||
git:
|
||||
user:
|
||||
name: gitea-helper
|
||||
email: eric+gitea-helper@dideric.is
|
||||
---
|
||||
|
||||
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
|
||||
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"
|
||||
pointer; typos don't silently ghost into an empty config.
|
||||
|
||||
|
||||
@@ -85,6 +85,10 @@ class DockerBottlePlan(BottlePlan):
|
||||
print_multi("skills ", list(agent.skills))
|
||||
info(f"bottle : {agent.bottle}")
|
||||
|
||||
identity = manifest.git_identity_summary(spec.agent_name)
|
||||
if identity:
|
||||
info(f" git identity : {identity}")
|
||||
|
||||
git_lines = [
|
||||
f"{u.upstream_host}:{u.upstream_port}"
|
||||
for u in self.git_gate_plan.upstreams
|
||||
|
||||
@@ -125,6 +125,9 @@ class SmolmachinesBottlePlan(BottlePlan):
|
||||
print_multi("env ", env_names)
|
||||
print_multi("skills ", list(agent.skills))
|
||||
info(f"bottle : {agent.bottle}")
|
||||
identity = manifest.git_identity_summary(spec.agent_name)
|
||||
if identity:
|
||||
info(f" git identity : {identity}")
|
||||
if upstreams:
|
||||
print_multi(" git gate ", upstreams)
|
||||
if routes:
|
||||
|
||||
@@ -31,6 +31,9 @@ def cmd_info(argv: list[str]) -> int:
|
||||
f"first line: {prompt_first_line or '(empty)'}"
|
||||
)
|
||||
info(f"bottle : {agent.bottle}")
|
||||
identity = manifest.git_identity_summary(args.name)
|
||||
if identity:
|
||||
info(f" git identity : {identity}")
|
||||
if bottle.git:
|
||||
for e in bottle.git:
|
||||
info(
|
||||
|
||||
+72
-8
@@ -47,7 +47,7 @@ import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from pathlib import Path
|
||||
from typing import Mapping, cast
|
||||
|
||||
@@ -692,6 +692,11 @@ class Agent:
|
||||
bottle: str
|
||||
skills: tuple[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
|
||||
def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "Agent":
|
||||
@@ -731,7 +736,25 @@ class Agent:
|
||||
else:
|
||||
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)
|
||||
@@ -874,11 +897,50 @@ class Manifest:
|
||||
)
|
||||
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:
|
||||
"""Resolve the Bottle the named agent references. The validator
|
||||
guarantees both lookups succeed for a manifest built via
|
||||
from_json_obj."""
|
||||
return self.bottles[self.agents[agent_name].bottle]
|
||||
"""Resolve the Bottle the named agent references, with the
|
||||
agent's git.user overlaid on top. The validator guarantees both
|
||||
lookups succeed for a manifest built via from_json_obj.
|
||||
|
||||
The overlay lives here, the single point both backends call to
|
||||
resolve an agent's bottle, so the docker / smolmachines git
|
||||
provisioners pick up the merged identity unchanged."""
|
||||
bottle = self.bottles[self.agents[agent_name].bottle]
|
||||
merged = self._effective_git_user(agent_name)
|
||||
if merged == bottle.git_user:
|
||||
return bottle
|
||||
return replace(bottle, git_user=merged)
|
||||
|
||||
def git_identity_summary(self, agent_name: str) -> str | None:
|
||||
"""One-line effective git identity with per-field provenance
|
||||
for launch summaries, e.g.
|
||||
`name=claude (agent), email=eric@dideric.is (bottle)`.
|
||||
Returns None when neither agent nor bottle sets an identity."""
|
||||
over = self.agents[agent_name].git_user
|
||||
merged = self._effective_git_user(agent_name)
|
||||
if merged.is_empty():
|
||||
return None
|
||||
parts: list[str] = []
|
||||
if merged.name:
|
||||
parts.append(f"name={merged.name} ({'agent' if over.name else 'bottle'})")
|
||||
if merged.email:
|
||||
parts.append(f"email={merged.email} ({'agent' if over.email else 'bottle'})")
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
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"}
|
||||
)
|
||||
_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
|
||||
# doesn't reject — lets the same file double as `~/.claude/agents/*.md`.
|
||||
_AGENT_KEYS_CC_PASSTHROUGH = frozenset({
|
||||
@@ -1301,11 +1363,13 @@ def _load_agents_from_dir(
|
||||
)
|
||||
# Build the dict Agent.from_dict expects. The body becomes
|
||||
# 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] = {
|
||||
"bottle": fm.get("bottle"),
|
||||
"skills": fm.get("skills", []),
|
||||
"prompt": body.strip(),
|
||||
}
|
||||
if "git" in fm:
|
||||
agent_dict["git"] = fm["git"]
|
||||
out[name] = Agent.from_dict(name, agent_dict, bottle_names)
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
# PRD 0027: Agent-level git user identity
|
||||
|
||||
- **Status:** Draft
|
||||
- **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.
|
||||
@@ -5,6 +5,10 @@ model: opus
|
||||
bottle: dev
|
||||
skills:
|
||||
- init-prd
|
||||
git:
|
||||
user:
|
||||
name: implementer-bot
|
||||
email: eric+implementer@dideric.is
|
||||
---
|
||||
|
||||
You are a feature-implementation agent running inside an ephemeral
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user