feat(manifest): trusted agent forge identity and guidance

Implements PRD prd-new-trusted-agent-forge-identity. Moves author identity
and named forge configurations onto the trusted, host-only agent definition,
and lets a bottle repository optionally associate a git-gate repo with one of
the agent's forge aliases.

- Agent-owned identity: new `author` (name/email) and `forge-accounts`
  (alias -> canonical Gitea /api/v1 origin + host `token_secret` ref) on the
  agent manifest. `author` populates the bottle's git user.name/user.email.
- Remove `git-gate.user` from both agents and bottles; fail with a migration
  pointer to `author`. `git-gate` is no longer accepted on an agent.
- Bottle git-gate repos gain optional `forge: <alias>`, resolved against the
  selected agent's forge-accounts at composition (fail-closed on unknown).
- Fail-closed Gitea API URL validation (https, no userinfo/query/fragment,
  /api/v1 base, host lowercased, trailing slash normalized, host dedup).
- Proxy-held credential: synthesize one inspected, token-authenticated egress
  route per referenced forge alias, scoped to the origin + API prefix. The
  token is resolved from the host env at launch into the egress proxy only —
  never the bottle env, prompt, gitconfig, or workspace.
- Generated, non-secret forge workflow guidance appended to the agent prompt
  for associated repos (API base, branch-backed PR flow, AGit-ref prohibition,
  mutation verification); omitted when no repo declares a forge.
- Agents become home-only: cwd `.bot-bottle/agents` no longer contributes,
  overrides, or is selectable; warned-and-ignored like cwd bottles.
- Docs: README examples, PRD 0011 supersession note, manifest schema docstring.
- Tests: new test_forge_identity suite; legacy git-gate.user/cwd tests updated
  to the agent-owned identity model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 00:20:14 +00:00
committed by didericis
parent 5cb1c190f4
commit cf15500fb8
26 changed files with 1261 additions and 439 deletions
+26 -40
View File
@@ -130,8 +130,10 @@ class TestExtendsEnvMerge(unittest.TestCase):
class TestExtendsGitMerge(unittest.TestCase):
"""git-gate.user overlays by field; git-gate.repos merges by name,
with same-name child entries merging field-by-field (child wins)."""
"""git-gate.repos merges by name, with same-name child entries
merging field-by-field (child wins). Bottles no longer carry a user
identity (PRD prd-new-trusted-agent-forge-identity), so only repos
merging is meaningful across extends chains."""
_GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "key": {"provider": "static", "path": "/dev/null"}}
_GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "key": {"provider": "static", "path": "/dev/null"}}
@@ -254,13 +256,16 @@ class TestExtendsGitMerge(unittest.TestCase):
repo_entry = next(e for e in child.git if e.Name == "repo")
self.assertEqual("gitea", repo_entry.Key.provider)
def test_child_git_user_inherits_parent_repos(self):
def test_child_inherits_parent_repos_no_user_identity(self):
# Child omits git-gate entirely -> inherits the parent's repos.
# Bottles carry no user identity anymore, so git_user stays empty
# across the extends chain.
m = _build(
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
child={"extends": "base", "git-gate": {"user": {"name": "Child"}}},
child={"extends": "base"},
)
self.assertEqual(["a"], [e.Name for e in m.bottles["child"].git])
self.assertEqual("Child", m.bottles["child"].git_user.name)
self.assertTrue(m.bottles["child"].git_user.is_empty())
class TestExtendsEgressMerge(unittest.TestCase):
@@ -332,48 +337,29 @@ class TestExtendsEgressMerge(unittest.TestCase):
self.assertIn("A.EXAMPLE.COM", msg)
class TestExtendsGitUserOverlay(unittest.TestCase):
"""git-gate.user: per-field overlay. Each non-empty field on child
wins; empties fall through to parent."""
class TestExtendsNoBottleUserIdentity(unittest.TestCase):
"""Bottles no longer carry a user identity (PRD
prd-new-trusted-agent-forge-identity): identity moved to the agent's
`author` block. `git-gate.user` on a bottle is rejected outright, and
`git_user` is always empty across an extends chain."""
def test_parent_full_child_omits(self):
m = _build(
def test_bottle_git_gate_user_dies(self):
# A stale `git-gate.user` on a bottle fails with the migration die
# even inside an extends chain.
msg = _error_message(
_build,
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
child={"extends": "base"},
)
u = m.bottles["child"].git_user
self.assertEqual("Parent", u.name)
self.assertEqual("p@x", u.email)
self.assertIn("git-gate.user is no longer supported", msg)
def test_child_overrides_both(self):
def test_git_user_empty_across_chain(self):
m = _build(
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
child={
"extends": "base",
"git-gate": {"user": {"name": "Child", "email": "c@x"}},
},
base={"git-gate": {"repos": {}}},
child={"extends": "base"},
)
u = m.bottles["child"].git_user
self.assertEqual("Child", u.name)
self.assertEqual("c@x", u.email)
def test_child_adds_email_inherits_name(self):
m = _build(
base={"git-gate": {"user": {"name": "Parent"}}},
child={"extends": "base", "git-gate": {"user": {"email": "c@x"}}},
)
u = m.bottles["child"].git_user
self.assertEqual("Parent", u.name)
self.assertEqual("c@x", u.email)
def test_child_overrides_only_email(self):
m = _build(
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
child={"extends": "base", "git-gate": {"user": {"email": "c@x"}}},
)
u = m.bottles["child"].git_user
self.assertEqual("Parent", u.name)
self.assertEqual("c@x", u.email)
self.assertTrue(m.bottles["base"].git_user.is_empty())
self.assertTrue(m.bottles["child"].git_user.is_empty())
class TestExtendsChain(unittest.TestCase):