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
parent 14f19247c0
commit a2edaf8694
26 changed files with 1261 additions and 439 deletions
+23 -26
View File
@@ -128,7 +128,7 @@ def cmd_start(argv: list[str]) -> int:
if not manifest.all_agent_names:
print(
"bot-bottle: no agents defined. "
"Add an agent to ~/.bot-bottle/agents/ or ./bot-bottle/agents/ to get started.",
"Add an agent to ~/.bot-bottle/agents/ to get started.",
file=sys.stderr,
)
return 1
@@ -383,12 +383,9 @@ def _peek_agent_bottle(manifest: ManifestIndex, agent_name: str) -> str:
from ...manifest.loader import scan_agent_names
from ...yaml_subset import YamlSubsetError, parse_frontmatter
# Agents are home-only (PRD prd-new-trusted-agent-forge-identity).
home_agents = scan_agent_names(manifest.home_md / "agents")
cwd_agents: dict[str, Path] = {}
if manifest.cwd_md is not None:
cwd_agents = scan_agent_names(manifest.cwd_md / "agents")
merged = {**home_agents, **cwd_agents}
path = merged.get(agent_name)
path = home_agents.get(agent_name)
if path is None:
return ""
try:
@@ -488,13 +485,19 @@ def _manifest_to_yaml(manifest: Manifest) -> str:
lines.append(" skills:")
for s in agent.skills:
lines.append(f" - {s}")
if not agent.git_user.is_empty():
lines.append(" git-gate:")
lines.append(" user:")
if agent.git_user.name:
lines.append(f" name: {agent.git_user.name}")
if agent.git_user.email:
lines.append(f" email: {agent.git_user.email}")
if agent.author is not None:
lines.append(" author:")
lines.append(f" name: {agent.author.name}")
lines.append(f" email: {agent.author.email}")
if agent.forge_accounts:
lines.append(" forge-accounts:")
for alias, acct in sorted(agent.forge_accounts.items()):
lines.append(f" {alias}:")
lines.append(f" url: {acct.url}")
lines.append(" auth:")
lines.append(f" type: {acct.auth_type}")
# token_secret name is host config; show the name, never a value.
lines.append(f" token_secret: {acct.token_secret}")
bottle = manifest.bottle
lines.append("bottle:")
@@ -510,20 +513,14 @@ def _manifest_to_yaml(manifest: Manifest) -> str:
for k, v in sorted(bottle.env.items()):
lines.append(f" {k}: {v}")
has_git_gate = not bottle.git_user.is_empty() or bottle.git
if has_git_gate:
if bottle.git:
lines.append(" git-gate:")
if not bottle.git_user.is_empty():
lines.append(" user:")
if bottle.git_user.name:
lines.append(f" name: {bottle.git_user.name}")
if bottle.git_user.email:
lines.append(f" email: {bottle.git_user.email}")
if bottle.git:
lines.append(" repos:")
for entry in bottle.git:
lines.append(f" {entry.Name}:")
lines.append(f" url: {entry.Upstream}")
lines.append(" repos:")
for entry in bottle.git:
lines.append(f" {entry.Name}:")
lines.append(f" url: {entry.Upstream}")
if entry.Forge:
lines.append(f" forge: {entry.Forge}")
if bottle.egress.routes:
lines.append(" egress:")