"""Unit: agent-owned identity via `author` (PRD prd-new-trusted-agent-forge-identity). Identity is agent-only now: an agent file declares an `author` block (name + email, both required) and at `ManifestIndex.load_for_agent()` it populates the effective bottle's `git_user`. There is no per-field overlay against the bottle anymore — the bottle no longer carries a user identity. `git-gate` (user or repos) is rejected on an agent with a migration message. `Manifest.git_identity_summary()` reports the effective identity with no provenance annotation. The `from_json_obj` path drives `ManifestAgent.from_dict` + the composition in load_for_agent; a temp-dir case locks the md loader (the agent `author` frontmatter key threads into the parsed agent).""" from __future__ import annotations import os import shutil import tempfile import textwrap import unittest from pathlib import Path from bot_bottle.manifest import ManifestError, Manifest, ManifestIndex def _error_message(callable_, *args, **kwargs) -> str: # type: ignore """Run `callable_` expecting a ManifestError; return its message.""" try: callable_(*args, **kwargs) except ManifestError as e: return str(e) raise AssertionError("expected ManifestError was not raised") def _manifest(*, author=None, agent_git=None) -> Manifest: # type: ignore """Build an index with one agent 'impl' and load it, returning a Manifest.""" agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore if author is not None: agent["author"] = author if agent_git is not None: agent["git-gate"] = agent_git return ManifestIndex.from_json_obj({ "bottles": {"dev": {}}, "agents": {"impl": agent}, }).load_for_agent("impl") def _index(*, author: dict[str, object] | None = None) -> ManifestIndex: """Build an index with one agent 'impl' without loading it.""" agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore if author is not None: agent["author"] = author return ManifestIndex.from_json_obj({ "bottles": {"dev": {}}, "agents": {"impl": agent}, }) class TestAgentAuthorPopulatesBottle(unittest.TestCase): def test_agent_author_supplies_both_fields(self): m = _manifest(author={"name": "a", "email": "a@b"}) u = m.bottle.git_user self.assertEqual("a", u.name) self.assertEqual("a@b", u.email) def test_bottle_has_no_identity_until_agent_composed(self): idx = _index(author={"name": "a", "email": "a@b"}) # Raw bottle has no git_user; loaded manifest has it from the agent. self.assertTrue(idx.bottles["dev"].git_user.is_empty()) m = idx.load_for_agent("impl") self.assertFalse(m.bottle.git_user.is_empty()) def test_agent_silent_leaves_bottle_identity_empty(self): idx = _index() m = idx.load_for_agent("impl") # No author -> git_user stays empty; the bottle instance is reused # directly (no replace needed). self.assertTrue(m.bottle.git_user.is_empty()) self.assertIs(idx.bottles["dev"], m.bottle) def test_other_bottle_fields_untouched_by_identity(self): idx = ManifestIndex.from_json_obj({ "bottles": {"dev": { "env": {"FOO": "bar"}, "supervise": True, }}, "agents": {"impl": { "bottle": "dev", "skills": [], "prompt": "", "author": {"name": "a", "email": "a@b"}, }}, }) b = idx.load_for_agent("impl").bottle self.assertEqual("a", b.git_user.name) self.assertEqual({"FOO": "bar"}, dict(b.env)) self.assertTrue(b.supervise) class TestAgentGitGateRejections(unittest.TestCase): """`git-gate` is no longer accepted on an agent (user or repos): identity moved to `author`, repos stays bottle-only.""" def test_agent_git_gate_user_dies(self): msg = _error_message(_manifest, agent_git={"user": {"name": "a", "email": "a@b"}}) self.assertIn("no longer", msg) self.assertIn("author", msg) def test_agent_git_gate_repos_dies(self): msg = _error_message(_manifest, agent_git={ "repos": {"r": {"url": "ssh://git@x/y.git", "key": {"provider": "static", "path": "/dev/null"}}}, }) self.assertIn("no longer", msg) self.assertIn("author", msg) class TestGitIdentitySummary(unittest.TestCase): """Summary reports the effective identity (from the agent's author) with no per-field provenance annotation.""" def test_summary_from_author(self): m = _manifest(author={"name": "a", "email": "a@b"}) self.assertEqual("name=a, email=a@b", m.git_identity_summary()) def test_none_when_no_author(self): m = _manifest() self.assertIsNone(m.git_identity_summary()) _BOTTLE_DEV = """ --- egress: routes: - host: example.com --- dev bottle. """ _AGENT_WITH_AUTHOR = """ --- bottle: dev author: name: agent-name email: agent@example.com --- impl agent. """ _AGENT_WITH_GIT_GATE = """ --- bottle: dev git-gate: repos: r: url: ssh://git@x/y.git key: provider: static path: /dev/null --- bad agent. """ class TestAgentAuthorMdLoader(unittest.TestCase): """Locks the md path: `author` is an accepted agent frontmatter key and threads into the parsed agent, populating identity; a stale agent `git-gate` block dies through the same loader.""" 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_author_populates_identity(self): self._write("bottles/dev.md", _BOTTLE_DEV) self._write("agents/impl.md", _AGENT_WITH_AUTHOR) m = ManifestIndex.resolve(str(self.home)).load_for_agent("impl") u = m.bottle.git_user self.assertEqual("agent-name", u.name) self.assertEqual("agent@example.com", u.email) self.assertEqual( "name=agent-name, email=agent@example.com", m.git_identity_summary(), ) def test_md_agent_git_gate_fails_at_preflight(self): """A stale agent `git-gate` block is an error; resolve() still succeeds so other agents remain accessible, but load_for_agent raises. The lazy loader's frontmatter-key validator rejects the unknown `git-gate` key first.""" self._write("bottles/dev.md", _BOTTLE_DEV) self._write("agents/impl.md", _AGENT_WITH_GIT_GATE) names = ManifestIndex.resolve(str(self.home)) self.assertIn("impl", names.all_agent_names) with self.assertRaises(ManifestError) as ctx: names.load_for_agent("impl") self.assertIn("git-gate", str(ctx.exception)) if __name__ == "__main__": unittest.main()