feat(manifest): trusted agent forge identity and guidance
test / unit (pull_request) Successful in 49s
lint / lint (push) Failing after 1m3s
test / image-input-builds (pull_request) Successful in 1m3s
test / integration-docker (pull_request) Successful in 1m9s
refresh-image-locks / refresh (push) Successful in 1m26s
test / coverage (pull_request) Failing after 16s
tracker-policy-pr / check-pr (pull_request) Successful in 5s

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 db25564bd9
commit 2fafeef61c
25 changed files with 1237 additions and 439 deletions
+3
View File
@@ -32,6 +32,7 @@ if TYPE_CHECKING:
EGRESS_ROUTES_IN_CONTAINER,
Egress,
egress_agent_env_entries,
egress_forge_routes,
egress_gateway_env_entries,
egress_manifest_routes,
egress_render_routes,
@@ -51,6 +52,7 @@ _LAZY: dict[str, str] = {
"EGRESS_ROUTES_FILENAME": ".service",
"EGRESS_ROUTES_IN_CONTAINER": ".service",
"egress_agent_env_entries": ".service",
"egress_forge_routes": ".service",
"egress_gateway_env_entries": ".service",
"egress_manifest_routes": ".service",
"egress_render_routes": ".service",
@@ -80,6 +82,7 @@ __all__ = [
"Egress",
"EgressPlan",
"EgressRoute",
"egress_forge_routes",
"egress_manifest_routes",
"egress_render_routes",
"egress_resolve_token_values",
+48 -6
View File
@@ -26,7 +26,7 @@ from ..log import die
from .plan import EgressPlan, EgressRoute
if TYPE_CHECKING:
from ..manifest import ManifestBottle
from ..manifest import ManifestBottle, ResolvedForgeAssociation
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
@@ -119,15 +119,56 @@ def egress_manifest_routes(
return tuple(out)
def egress_forge_routes(
associations: "tuple[ResolvedForgeAssociation, ...]",
) -> tuple[EgressRoute, ...]:
"""Synthesize one inspected, token-authenticated egress route per distinct
forge alias referenced by a selected git-gate repo (PRD
prd-new-trusted-agent-forge-identity).
The route is scoped to the canonical forge origin (host) and API prefix
(`/api/v1`): the proxy injects the Gitea `token` scheme using the value of
the host env var named by the account's `token_secret`, resolved at launch
from the host environment — the token never enters the bottle. Aliases that
canonicalize to the same host share a single route (deduplicated)."""
out: list[EgressRoute] = []
seen_hosts: set[str] = set()
for assoc in associations:
acct = assoc.account
host_key = acct.host.lower()
if host_key in seen_hosts:
continue
seen_hosts.add(host_key)
out.append(EgressRoute(
host=acct.host,
matches=(CoreMatchEntry(
paths=(CorePathMatch(type="prefix", value=acct.api_prefix),),
),),
auth_scheme=acct.auth_type,
token_ref=acct.token_secret,
inspect=True,
))
return tuple(out)
def egress_routes_for_bottle(
bottle: ManifestBottle,
provider_routes: tuple[EgressRoute, ...] = (),
forge_routes: tuple[EgressRoute, ...] = (),
) -> tuple[EgressRoute, ...]:
manifest = egress_manifest_routes(bottle)
provisioned_hosts = {pr.host.lower() for pr in provider_routes}
merged = list(_default_provider_on_match(provider_routes)) + [
r for r in manifest if r.host.lower() not in provisioned_hosts
]
# Provider routes (LLM API) default to redact-on-match; forge routes are
# host-injected but keep the default DLP policy. Both take precedence over
# a manifest route to the same host.
reserved_hosts = (
{pr.host.lower() for pr in provider_routes}
| {fr.host.lower() for fr in forge_routes}
)
merged = (
list(_default_provider_on_match(provider_routes))
+ list(forge_routes)
+ [r for r in manifest if r.host.lower() not in reserved_hosts]
)
return _assign_token_slots(merged)
@@ -367,8 +408,9 @@ class Egress:
slug: str,
stage_dir: Path,
provider_routes: tuple[EgressRoute, ...] = (),
forge_routes: tuple[EgressRoute, ...] = (),
) -> EgressPlan:
routes = egress_routes_for_bottle(bottle, provider_routes)
routes = egress_routes_for_bottle(bottle, provider_routes, forge_routes)
log = bottle.egress.Log
routes_path = stage_dir / EGRESS_ROUTES_FILENAME
routes_path.write_text(egress_render_routes(routes, log=log))