Files
bot-bottle/tests/unit/test_manifest_agent_git_user.py
T
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

249 lines
8.1 KiB
Python

"""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()