diff --git a/README.md b/README.md index 1bb74a74..59db25aa 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,9 @@ BOT_BOTTLE_BACKEND=firecracker ./cli.py start ## Manifest -Bottles and agents are Markdown files with YAML frontmatter under `~/.bot-bottle/`. The Markdown body is the system prompt. Bottles live in `~/.bot-bottle/bottles/`; agents may also be shipped by a repo at `/.bot-bottle/agents/.md`. +Bottles and agents are Markdown files with YAML frontmatter under `~/.bot-bottle/`. The Markdown body is the system prompt. Both bottles and agents are **home-only**: they live under `~/.bot-bottle/bottles/` and `~/.bot-bottle/agents/`. A `/.bot-bottle/agents/.md` shipped by a workspace is ignored with a warning — since an agent may select a host identity and forge secret, checked-out content must not define one (PRD prd-new-trusted-agent-forge-identity). Keep repo-specific behavioral instructions in `AGENTS.md` instead. + +Identity is **agent-owned**: the author name/email and named forge accounts live on the agent, not under `git-gate` (which now carries only Git transport policy). A bottle repo may optionally name one of the selected agent's forge aliases via `forge:`. **Bottle** (`~/.bot-bottle/bottles/gitea-dev.md`): @@ -188,37 +190,19 @@ Bottles and agents are Markdown files with YAML frontmatter under `~/.bot-bottle --- extends: claude # inherit the Claude provider boundary -env: - GIT_AUTHOR_NAME: didericis - -git: - user: - name: "Eric Bauerfeld" - email: "eric+claude@dideric.is" - remotes: - gitea.dideric.is: - Name: bot-bottle - Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git - IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea - KnownHostKey: ssh-ed25519 AAAA... - -egress: - routes: - - host: gitea.dideric.is - inspect: - auth: - scheme: token # Bearer | token - token_ref: BOT_BOTTLE_GITEA_TOKEN - matches: # optional — restrict to specific paths/methods/headers - - paths: - - {type: prefix, value: /api/v1/} - methods: [GET, POST, PATCH, DELETE] - outbound_detectors: [token_patterns, known_secrets] - inbound_detectors: false # disable response scanning for this host +git-gate: + repos: + bot-bottle: + url: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git + key: + provider: gitea + forge_token_env: GITEA_DEPLOY_TOKEN # deploy-key admin (push), PRD 0048 + host_key: "ssh-ed25519 AAAA..." + forge: didericis-gitea # ← selects the agent's forge alias --- -The `gitea-dev` bottle. Provider auth via the inherited Claude route; -gitea over SSH for push, token over HTTPS for the API. +The `gitea-dev` bottle. Gitea over SSH for push; the API credential and +workflow guidance come from the agent's `forge: didericis-gitea` association. ```` **Agent** (`~/.bot-bottle/agents/gitea-helper.md`): @@ -228,11 +212,27 @@ gitea over SSH for push, token over HTTPS for the API. bottle: gitea-dev skills: - init-prd +author: + name: didericis-claude + email: eric+claude@dideric.is +forge-accounts: + didericis-gitea: + url: https://gitea.dideric.is/api/v1 + auth: + type: token + token_secret: GITEA_CLAUDE_TOKEN # host env var; value never enters the bottle --- You help maintain Gitea-hosted projects. ```` +`author` populates the bottle's `git config user.name/user.email`. When a +selected repo names a `forge` alias, bot-bottle resolves the alias's +`token_secret` from the host env into the egress proxy only (never the bottle), +adds a scoped, proxy-authenticated route to the Gitea API origin, and appends +non-secret forge workflow guidance to the agent's system prompt. Neither the +token value nor its `token_secret` name appears in the bottle env or prompt. + **Egress route fields:** | Field | Required | Description | diff --git a/bot_bottle/backend/base.py b/bot_bottle/backend/base.py index 02e821f2..8346acee 100644 --- a/bot_bottle/backend/base.py +++ b/bot_bottle/backend/base.py @@ -359,7 +359,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): provider_settings=manifest_agent_provider.settings, ) agent_provision_plan = merge_provision_env_vars(agent_provision_plan) - egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan) + egress_plan = prepare_egress(manifest, slug, agent_provision_plan) supervise_plan = prepare_supervise(manifest_bottle, slug) git_gate_plan = prepare_git_gate(manifest_bottle, slug) diff --git a/bot_bottle/backend/resolve_common.py b/bot_bottle/backend/resolve_common.py index 7ea9c145..a62c474f 100644 --- a/bot_bottle/backend/resolve_common.py +++ b/bot_bottle/backend/resolve_common.py @@ -24,10 +24,11 @@ from ..bottle_state import ( supervise_state_dir, write_metadata, ) -from ..egress import Egress, EgressPlan +from ..egress import Egress, EgressPlan, egress_forge_routes from ..git_gate import GitGate, GitGatePlan from ..log import die from ..manifest import Manifest, ManifestBottle +from ..manifest.forge import render_forge_guidance from ..supervisor.plan import SupervisePlan from ..orchestrator.supervisor import Supervisor from . import BottleSpec @@ -71,12 +72,21 @@ def write_launch_metadata( def prepare_agent_state_dir(slug: str, manifest: Manifest) -> tuple[Path, Path]: """Create the agent state subdir, write the prompt file. - Returns (agent_dir, prompt_file).""" + Returns (agent_dir, prompt_file). + + For repositories associated with a forge, appends generated, non-secret + provider-specific workflow guidance to the prompt (PRD + prd-new-trusted-agent-forge-identity). The guidance carries neither the + token value nor its `token_secret` name.""" agent = manifest.agent agent_dir = agent_state_dir(slug) agent_dir.mkdir(parents=True, exist_ok=True) prompt_file = agent_dir / "prompt.txt" - prompt_file.write_text(agent.prompt or "") + prompt = agent.prompt or "" + guidance = render_forge_guidance(manifest.forge_associations) + if guidance: + prompt = f"{prompt.rstrip()}\n\n{guidance}" if prompt.strip() else guidance + prompt_file.write_text(prompt) prompt_file.chmod(0o600) return agent_dir, prompt_file @@ -88,11 +98,18 @@ def prepare_git_gate(bottle: ManifestBottle, slug: str) -> GitGatePlan: def prepare_egress( - bottle: ManifestBottle, slug: str, provision: AgentProvisionPlan, + manifest: Manifest, slug: str, provision: AgentProvisionPlan, ) -> EgressPlan: + """Build the egress plan, adding a scoped, proxy-held Gitea API route for + each forge alias referenced by a selected git-gate repo (PRD + prd-new-trusted-agent-forge-identity). The token is resolved from the host + env at launch and never enters the bottle.""" egress_dir = egress_state_dir(slug) egress_dir.mkdir(parents=True, exist_ok=True) - return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes) + forge_routes = egress_forge_routes(manifest.forge_associations) + return Egress().prepare( + manifest.bottle, slug, egress_dir, provision.egress_routes, forge_routes, + ) def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None: diff --git a/bot_bottle/cli/commands/start.py b/bot_bottle/cli/commands/start.py index 2082f928..25193552 100644 --- a/bot_bottle/cli/commands/start.py +++ b/bot_bottle/cli/commands/start.py @@ -129,7 +129,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 @@ -384,12 +384,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: @@ -489,13 +486,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:") @@ -511,20 +514,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:") diff --git a/bot_bottle/egress/__init__.py b/bot_bottle/egress/__init__.py index 37756ca0..c5062d1d 100644 --- a/bot_bottle/egress/__init__.py +++ b/bot_bottle/egress/__init__.py @@ -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", diff --git a/bot_bottle/egress/service.py b/bot_bottle/egress/service.py index 76a88298..1d3f029c 100644 --- a/bot_bottle/egress/service.py +++ b/bot_bottle/egress/service.py @@ -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)) diff --git a/bot_bottle/manifest/__init__.py b/bot_bottle/manifest/__init__.py index 1dde0285..e26d1a6a 100644 --- a/bot_bottle/manifest/__init__.py +++ b/bot_bottle/manifest/__init__.py @@ -1,10 +1,10 @@ """Manifest dataclasses (PRD 0011 layout). -Reads the per-file manifest tree: +Reads the per-file manifest tree (home-only — +PRD prd-new-trusted-agent-forge-identity): $HOME/.bot-bottle/bottles/.md — one bottle per file - $HOME/.bot-bottle/agents/.md — home-resident agents - $CWD/.bot-bottle/agents/.md — cwd-supplied agents + $HOME/.bot-bottle/agents/.md — agents Each file is Markdown with YAML frontmatter. The frontmatter holds the structured config (see schema below); for agents the body is @@ -15,27 +15,38 @@ Bottle schema (frontmatter): extends: # optional (PRD 0025) env: { : , ... } git-gate: # optional (PRD 0047) - user: { name: , email: } # optional repos: { : , ... } # optional + # git-gate-entry keys: url, key, host_key, forge + # `forge`: optional alias into the selected agent's forge-accounts egress: { routes: [ , ... ] } # route keys: host, matches, auth, role, dlp supervise: # optional (default true) nested_containers: # optional (default false) Agent schema (frontmatter): - bottle: # required + bottle: # optional skills: [ , ... ] # optional - git-gate: - user: { name: , email: } # optional; overlays bottle + author: # optional; agent git identity + name: # required when author is present + email: # required when author is present + forge-accounts: # optional; alias -> forge account + : + url: + auth: { type: token, token_secret: } # Claude Code subagent passthrough fields — accepted, ignored: name, description, model, color, memory +`author` populates the bottle's git user.name/user.email; `forge-accounts` +maps a forge alias to a Gitea API origin plus a host token reference. Identity +is agent-owned — `git-gate` is no longer accepted on an agent (git-gate.user +moved to `author`; git-gate.repos is bottle-only). + The agent file's Markdown body is the system prompt (stripped). Unknown top-level frontmatter keys raise ManifestError with a hint. -Bottles can ONLY live under $HOME. A bottles/ dir under $CWD is a -warn at load time and contributes nothing. The trust boundary is -expressed as filesystem layout rather than resolver logic. +Both bottles and agents can ONLY live under $HOME. An agents/ or bottles/ +dir under $CWD is a warn at load time and contributes nothing. The trust +boundary is expressed as filesystem layout rather than resolver logic. Two types are exported: @@ -66,6 +77,11 @@ if TYPE_CHECKING: from .agent import ManifestAgent, ManifestAgentProvider from .bottle import ManifestBottle from .egress import EGRESS_AUTH_SCHEMES, ManifestEgressConfig, ManifestEgressRoute + from .forge import ( + ManifestAuthor, + ManifestForgeAccount, + ResolvedForgeAssociation, + ) from .git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig @@ -81,6 +97,9 @@ _LAZY_MODULES: dict[str, str] = { "EGRESS_AUTH_SCHEMES": "egress", "ManifestEgressRoute": "egress", "ManifestEgressConfig": "egress", + "ManifestAuthor": "forge", + "ManifestForgeAccount": "forge", + "ResolvedForgeAssociation": "forge", "ManifestGitEntry": "git", "ManifestGitUser": "git", "ManifestKeyConfig": "git", @@ -115,4 +134,7 @@ __all__ = [ "EGRESS_AUTH_SCHEMES", "ManifestEgressRoute", "ManifestEgressConfig", + "ManifestAuthor", + "ManifestForgeAccount", + "ResolvedForgeAssociation", ] diff --git a/bot_bottle/manifest/agent.py b/bot_bottle/manifest/agent.py index f5d76ceb..8bb913c0 100644 --- a/bot_bottle/manifest/agent.py +++ b/bot_bottle/manifest/agent.py @@ -3,11 +3,11 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import cast +from typing import Mapping, cast from ..agent_provider import PROVIDER_TEMPLATES from .util import ManifestError, as_json_object -from .git import ManifestGitUser +from .forge import ManifestAuthor, ManifestForgeAccount from .schema import AGENT_MODEL_KEYS, is_valid_entity_name @@ -119,15 +119,29 @@ class ManifestAgent: bottle: str = "" skills: tuple[str, ...] = () prompt: str = "" - # Per-agent git identity (issue #94). Overlays the referenced - # bottle's git-gate.user per-field at `Manifest.bottle_for`. Only - # `user` is allowed at the agent level; `repos` stays bottle-only - # because it carries credentials and host trust. - git_user: ManifestGitUser = ManifestGitUser() + # Agent-owned identity (PRD prd-new-trusted-agent-forge-identity). + # `author` populates the bottle's git user.name/user.email; + # `forge_accounts` maps a forge alias to a canonical Gitea API origin and + # a host token reference. Both live only on the agent — never under + # `git-gate`, which is bottle-only transport policy. + author: ManifestAuthor | None = None + forge_accounts: Mapping[str, ManifestForgeAccount] = field( + default_factory=dict + ) @classmethod def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "ManifestAgent": d = as_json_object(raw, f"agent '{name}'") + # git-gate is no longer accepted on an agent (checked before the + # generic unknown-key error so the migration pointer is surfaced): + # identity moved to `author`, and git-gate.repos is bottle-only. + if "git-gate" in d: + raise ManifestError( + f"agent '{name}' has a 'git-gate' block, which is no longer " + f"accepted on an agent (PRD prd-new-trusted-agent-forge-identity). " + f"Move git-gate.user name/email into the 'author' block; " + f"git-gate.repos stays on the bottle." + ) unknown = set(d.keys()) - AGENT_MODEL_KEYS if unknown: allowed = ", ".join(sorted(AGENT_MODEL_KEYS)) @@ -191,24 +205,30 @@ class ManifestAgent: f"(was {type(prompt_raw).__name__})" ) - # git-gate: agents may declare only `git-gate.user` (name/email). - # `git-gate.repos` is bottle-only — it carries credentials and host trust. - git_user = ManifestGitUser() - git_raw = d.get("git-gate") - if git_raw is not None: - gd = as_json_object(git_raw, f"agent '{name}' git-gate") - for k in gd: - if k != "user": - raise ManifestError( - f"agent '{name}' git-gate.{k} is not allowed at the " - f"agent level; only git-gate.user (name/email) may be " - f"set on an agent. git-gate.repos is bottle-only " - f"(it carries credentials and host trust)." - ) - if "user" in gd: - git_user = ManifestGitUser.from_dict(name, gd["user"]) + # author: agent-owned git identity (optional; both fields required + # when present). Populates the bottle's user.name/user.email. + author = ( + ManifestAuthor.from_dict(name, d["author"]) + if "author" in d else None + ) - return cls(bottle=bottle, skills=skills, prompt=prompt, git_user=git_user) + # forge-accounts: alias -> Gitea API origin + host token reference. + forge_accounts: dict[str, ManifestForgeAccount] = {} + forge_raw = d.get("forge-accounts") + if forge_raw is not None: + forge_d = as_json_object(forge_raw, f"agent '{name}' forge-accounts") + for alias, entry in forge_d.items(): + forge_accounts[alias] = ManifestForgeAccount.from_dict( + name, alias, entry, + ) + + return cls( + bottle=bottle, + skills=skills, + prompt=prompt, + author=author, + forge_accounts=forge_accounts, + ) def _parse_provider_settings( diff --git a/bot_bottle/manifest/bottle.py b/bot_bottle/manifest/bottle.py index ae76d7e3..fcf2eef7 100644 --- a/bot_bottle/manifest/bottle.py +++ b/bot_bottle/manifest/bottle.py @@ -107,11 +107,14 @@ class ManifestBottle: ) env[var] = value + # `git_user` is now an internal resolved carrier populated from the + # selected agent's `author` at composition time — it is never parsed + # from the bottle manifest (PRD prd-new-trusted-agent-forge-identity). git: tuple[ManifestGitEntry, ...] = () git_user = ManifestGitUser() git_raw = d.get("git-gate") if git_raw is not None: - git, git_user = parse_git_gate_config(name, git_raw) + git = parse_git_gate_config(name, git_raw) agent_provider = ( ManifestAgentProvider.from_dict(name, d["agent_provider"]) diff --git a/bot_bottle/manifest/extends.py b/bot_bottle/manifest/extends.py index c96b032c..b0d755d3 100644 --- a/bot_bottle/manifest/extends.py +++ b/bot_bottle/manifest/extends.py @@ -210,7 +210,7 @@ def _fold_two_bottles( for n in names } if merged_repos_raw: - merged_git, _ = parse_git_gate_config("_fold", {"repos": merged_repos_raw}) + merged_git = parse_git_gate_config("_fold", {"repos": merged_repos_raw}) else: merged_git = () diff --git a/bot_bottle/manifest/forge.py b/bot_bottle/manifest/forge.py new file mode 100644 index 00000000..ae6fca20 --- /dev/null +++ b/bot_bottle/manifest/forge.py @@ -0,0 +1,285 @@ +"""Agent-owned author identity and forge accounts (PRD prd-new-trusted-agent-forge-identity). + +`ManifestAuthor` is the agent's git author identity (name/email) — it moved off +`git-gate.user` (PRD 0027 / ADR 0002) and onto the trusted agent definition. + +`ManifestForgeAccount` maps a **forge alias** to a canonical HTTPS Gitea API +origin plus the *name* of a host env var holding the operator-provided API +token. The token value never lives on the manifest; the host resolves it only +when a selected bottle repository references the alias, and hands it to the +egress proxy — never to the bottle. + +`ResolvedForgeAssociation` is the composed view: one distinct forge alias that +at least one selected git-gate repository points at, with the repository names +that reference it. The proxy-provisioning and prompt-guidance steps consume it. +""" + +from __future__ import annotations + +import urllib.parse +from dataclasses import dataclass + +from .schema import is_valid_entity_name +from .util import ManifestError, as_json_object + +# Only the Gitea `/api/v1` base is supported today. A future provider adds a +# validator + auth scheme + guidance renderer in code rather than accepting a +# repository-supplied prompt or path (PRD non-goal: provider-generic prompts). +GITEA_API_PREFIX = "/api/v1" + +# Auth schemes an agent forge account may declare. Gitea uses `token`. +FORGE_AUTH_TYPES = ("token",) + + +def canonicalize_forge_url( + agent_name: str, alias: str, url: object, +) -> tuple[str, str, str, str]: + """Validate and canonicalize a forge account's API base URL. + + Returns `(canonical, origin, host, api_prefix)` where `origin` is + `https://host[:port]` and `canonical` is `origin + api_prefix`. + + Fails closed on: non-string, non-`https`, embedded userinfo, query, or + fragment, a missing host, or any path other than the supported Gitea API + base (`/api/v1`, trailing slash tolerated).""" + label = f"agent '{agent_name}' forge-accounts.{alias}.url" + if not isinstance(url, str) or not url: + raise ManifestError(f"{label} is required (non-empty string)") + try: + parts = urllib.parse.urlsplit(url) + except ValueError as e: + raise ManifestError(f"{label} is not a valid URL ({e}): {url!r}") from e + + if parts.scheme != "https": + raise ManifestError( + f"{label} must use https (got scheme {parts.scheme!r} in {url!r})" + ) + if parts.username or parts.password: + raise ManifestError( + f"{label} must not embed userinfo (user:pass@); the token is held " + f"host-side via auth.token_secret. Got {url!r}" + ) + if parts.query: + raise ManifestError(f"{label} must not contain a query string: {url!r}") + if parts.fragment: + raise ManifestError(f"{label} must not contain a fragment: {url!r}") + + host = parts.hostname + if not host: + raise ManifestError(f"{label} must include a hostname: {url!r}") + + path = parts.path.rstrip("/") + if path != GITEA_API_PREFIX: + raise ManifestError( + f"{label} path must be the Gitea API base '{GITEA_API_PREFIX}' " + f"(got {parts.path!r} in {url!r}); other providers and API paths " + f"are not yet supported." + ) + + host = host.lower() + netloc = host if parts.port is None else f"{host}:{parts.port}" + origin = f"https://{netloc}" + return f"{origin}{path}", origin, host, path + + +@dataclass(frozen=True) +class ManifestAuthor: + """The agent's git author identity. Both fields are required and + non-empty — an author that is declared must fully identify the agent. + Populates `user.name` / `user.email` inside the bottle.""" + + name: str + email: str + + @classmethod + def from_dict(cls, agent_name: str, raw: object) -> "ManifestAuthor": + d = as_json_object(raw, f"agent '{agent_name}' author") + for k in d: + if k not in {"name", "email"}: + raise ManifestError( + f"agent '{agent_name}' author has unknown key {k!r}; " + f"allowed: name, email" + ) + name = d.get("name") + if not isinstance(name, str) or not name: + raise ManifestError( + f"agent '{agent_name}' author.name must be a non-empty string" + ) + email = d.get("email") + if not isinstance(email, str) or not email: + raise ManifestError( + f"agent '{agent_name}' author.email must be a non-empty string" + ) + # Git identity validation: reject values that would break the + # `git config user.email` line or smuggle a second config directive. + if any(c in email for c in ("\n", "\r", " ", "\t")): + raise ManifestError( + f"agent '{agent_name}' author.email must not contain whitespace " + f"or newlines (got {email!r})" + ) + if any(c in name for c in ("\n", "\r")): + raise ManifestError( + f"agent '{agent_name}' author.name must not contain newlines " + f"(got {name!r})" + ) + return cls(name=name, email=email) + + +@dataclass(frozen=True) +class ManifestForgeAccount: + """One forge alias on an agent: a canonical Gitea API origin plus the + host env var name that holds the agent-specific API token. The + authenticated account is whoever owns that token — there is no separate + account-name field.""" + + alias: str + url: str # canonical https base incl. /api/v1, no trailing slash + origin: str # https://host[:port] + host: str # lowercased hostname + api_prefix: str # /api/v1 + auth_type: str # "token" + token_secret: str # host env var name holding the API token + + @classmethod + def from_dict( + cls, agent_name: str, alias: str, raw: object, + ) -> "ManifestForgeAccount": + if not is_valid_entity_name(alias): + raise ManifestError( + f"agent '{agent_name}' forge-accounts key {alias!r} is not a " + f"valid alias; must match [a-z][a-z0-9-]*" + ) + label = f"agent '{agent_name}' forge-accounts.{alias}" + d = as_json_object(raw, label) + for k in d: + if k not in {"url", "auth"}: + raise ManifestError( + f"{label} has unknown key {k!r}; allowed: url, auth" + ) + + canonical, origin, host, api_prefix = canonicalize_forge_url( + agent_name, alias, d.get("url"), + ) + + if "auth" not in d: + raise ManifestError(f"{label} missing required 'auth' block") + auth = as_json_object(d.get("auth"), f"{label}.auth") + for k in auth: + if k not in {"type", "token_secret"}: + raise ManifestError( + f"{label}.auth has unknown key {k!r}; allowed: type, " + f"token_secret" + ) + auth_type = auth.get("type") + if auth_type not in FORGE_AUTH_TYPES: + raise ManifestError( + f"{label}.auth.type must be one of " + f"{', '.join(FORGE_AUTH_TYPES)} (got {auth_type!r})" + ) + token_secret = auth.get("token_secret") + if not isinstance(token_secret, str) or not token_secret: + raise ManifestError( + f"{label}.auth.token_secret must be a non-empty string (the " + f"name of a host env var holding the API token)" + ) + return cls( + alias=alias, + url=canonical, + origin=origin, + host=host, + api_prefix=api_prefix, + auth_type=str(auth_type), + token_secret=token_secret, + ) + + +@dataclass(frozen=True) +class ResolvedForgeAssociation: + """A distinct forge alias referenced by one or more selected git-gate + repositories. `repo_names` lists the git-gate repo names pointing at + `account.alias` (sorted, deduplicated).""" + + account: ManifestForgeAccount + repo_names: tuple[str, ...] + + @property + def alias(self) -> str: + return self.account.alias + + +def resolve_forge_associations( + agent_name: str, + forge_accounts: "dict[str, ManifestForgeAccount]", + git_entries: "tuple[object, ...]", +) -> tuple[ResolvedForgeAssociation, ...]: + """Compose the agent's forge accounts with the effective bottle's + git-gate repos. Each git entry that declares a `forge` alias must match a + forge account on the selected agent — otherwise fail closed before launch. + + Returns one association per distinct referenced alias, carrying the repo + names that reference it. Unreferenced accounts produce nothing (no token + is resolved and no route is created for them).""" + by_alias: dict[str, list[str]] = {} + for entry in git_entries: + alias = getattr(entry, "Forge", "") + if not alias: + continue + if alias not in forge_accounts: + available = ", ".join(sorted(forge_accounts)) or "(none)" + raise ManifestError( + f"git-gate.repos['{getattr(entry, 'Name', '?')}'].forge " + f"references forge alias {alias!r}, which is not defined on " + f"agent '{agent_name}'. Available forge-accounts: {available}" + ) + by_alias.setdefault(alias, []).append(getattr(entry, "Name", "")) + + return tuple( + ResolvedForgeAssociation( + account=forge_accounts[alias], + repo_names=tuple(sorted(set(names))), + ) + for alias, names in sorted(by_alias.items()) + ) + + +def render_forge_guidance( + associations: tuple[ResolvedForgeAssociation, ...], +) -> str: + """Render the non-secret, provider-specific forge workflow guidance + appended to the agent's system prompt for associated repositories only. + + Derived entirely from validated typed fields — never repository-supplied + Markdown. Contains neither the token value nor its `token_secret` name. + Returns "" when there are no associations (no section is generated).""" + if not associations: + return "" + lines: list[str] = [ + "## Forge access (managed by bot-bottle)", + "", + "bot-bottle authenticates the forge API requests below for you " + "through the egress proxy. Do not read, print, or manually attach " + "an authorization token — the proxy injects it. Never place a token " + "in a request.", + ] + for assoc in associations: + acct = assoc.account + for repo in assoc.repo_names: + lines.extend([ + "", + f"### Repository `{repo}` (forge alias `{acct.alias}`)", + f"- Forge API base URL: `{acct.url}`.", + f"- This git-gate repository (`{repo}`) is tied to forge " + f"`{acct.alias}`; use its API for forge actions on this repo.", + f"- Call the API at `{acct.url}` over HTTPS through the proxy. " + "The proxy adds authentication; you never supply a token " + "yourself.", + "- Git pushes still use the git-gate remote, not the API.", + "- To propose changes: push a normal branch " + "(`refs/heads/`) through the git-gate remote, then open " + "a branch-backed pull request through the API.", + "- Do NOT push AGit review refs (`refs/for/*`, `refs/draft/*`, " + "`refs/for-review/*`); they are prohibited here.", + "- Use the API for reviews and comments, and verify the " + "returned object state before claiming the task is complete.", + ]) + return "\n".join(lines) + "\n" diff --git a/bot_bottle/manifest/git.py b/bot_bottle/manifest/git.py index 44c87b68..bf9b2bff 100644 --- a/bot_bottle/manifest/git.py +++ b/bot_bottle/manifest/git.py @@ -117,6 +117,11 @@ class ManifestGitEntry: UpstreamHost: str = "" UpstreamPort: str = "" UpstreamPath: str = "" + # Optional forge alias (PRD prd-new-trusted-agent-forge-identity). When + # set, it must match a `forge-accounts` alias on the selected agent; the + # composition enables a scoped proxy-held API credential and forge + # workflow guidance for this repo. Empty = no forge association. + Forge: str = "" @classmethod def from_repos_entry( @@ -139,10 +144,10 @@ class ManifestGitEntry: label = f"git-gate.repos[{repo_name!r}]" d = as_json_object(raw, f"bottle '{bottle_name}' {label}") for k in d: - if k not in {"url", "key", "host_key"}: + if k not in {"url", "key", "host_key", "forge"}: raise ManifestError( f"bottle '{bottle_name}' {label} has unknown key {k!r}; " - f"allowed: url, key, host_key" + f"allowed: url, key, host_key, forge" ) upstream = d.get("url") if not isinstance(upstream, str) or not upstream: @@ -150,6 +155,21 @@ class ManifestGitEntry: f"bottle '{bottle_name}' {label} missing required string field 'url'" ) + forge = d.get("forge", "") + if not isinstance(forge, str): + raise ManifestError( + f"bottle '{bottle_name}' {label} forge must be a string " + f"(was {type(forge).__name__})" + ) + if forge and not _GIT_NAME_RE.match(forge): + # forge aliases follow the kebab-case identifier grammar; the + # cross-check against the agent's forge-accounts happens at + # composition time (it needs the resolved agent). + raise ManifestError( + f"bottle '{bottle_name}' {label} forge {forge!r} is not a " + f"valid forge alias; allowed characters: A-Z a-z 0-9 . _ -" + ) + if "key" not in d: raise ManifestError( f"bottle '{bottle_name}' {label} missing required 'key' block" @@ -176,6 +196,7 @@ class ManifestGitEntry: UpstreamHost=host, UpstreamPort=port, UpstreamPath=path, + Forge=forge, ) @@ -286,21 +307,26 @@ class ManifestGitUser: def parse_git_gate_config( bottle_name: str, raw: object, -) -> tuple[tuple[ManifestGitEntry, ...], ManifestGitUser]: +) -> tuple[ManifestGitEntry, ...]: + """Parse `git-gate` on a bottle. Only `repos` is accepted; `git-gate.user` + moved to the agent's `author` block (PRD prd-new-trusted-agent-forge-identity).""" d = as_json_object(raw, f"bottle '{bottle_name}' git-gate") + if "user" in d: + raise ManifestError( + f"bottle '{bottle_name}' git-gate.user is no longer supported " + f"(PRD prd-new-trusted-agent-forge-identity). Move name/email into " + f"the selected home agent's 'author' block:\n" + f" author:\n name: \n email: \n" + f"Identity is agent-owned; git-gate now carries only transport " + f"policy (repos)." + ) for k in d: - if k not in {"user", "repos"}: + if k != "repos": raise ManifestError( f"bottle '{bottle_name}' git-gate has unknown key {k!r}; " - f"allowed: user, repos" + f"allowed: repos" ) - git_user = ( - ManifestGitUser.from_dict(bottle_name, d["user"]) - if "user" in d - else ManifestGitUser() - ) - git: tuple[ManifestGitEntry, ...] = () repos_raw = d.get("repos") if repos_raw is not None: @@ -311,4 +337,4 @@ def parse_git_gate_config( ) validate_unique_git_names(bottle_name, git) - return git, git_user + return git diff --git a/bot_bottle/manifest/index.py b/bot_bottle/manifest/index.py index 8996560d..6d572d18 100644 --- a/bot_bottle/manifest/index.py +++ b/bot_bottle/manifest/index.py @@ -19,6 +19,7 @@ from .util import ManifestError, as_json_object from .agent import ManifestAgent from .bottle import ManifestBottle from .extends import merge_bottles_runtime, resolve_bottles +from .forge import ResolvedForgeAssociation, resolve_forge_associations from .git import ManifestGitUser from .loader import ( check_stale_json, @@ -37,30 +38,50 @@ def _section_dict(value: object, label: str) -> dict[str, object]: return as_json_object(value, label) -def _merge_git_user( - agent_user: ManifestGitUser, base_user: ManifestGitUser -) -> ManifestGitUser: - """Merge the agent's git.user over the bottle's, agent-wins-on-non-empty.""" - if agent_user.is_empty(): - return base_user - return ManifestGitUser( - name=agent_user.name or base_user.name, - email=agent_user.email or base_user.email, +def _warn_ignored_cwd_dir(cwd_dir: Path, kind: str, home_path: str) -> None: + """Warn (once) that manifest files of `kind` under `$CWD/.bot-bottle/` + are ignored — the filesystem layout IS the trust boundary. `kind` is the + subdir name (`bottles`/`agents`); `home_path` is where they belong.""" + stale = cwd_dir / kind + if not stale.is_dir(): + return + files = sorted(stale.glob("*.md")) + if not files: + return + names = ", ".join(p.name for p in files) + warn( + f"ignoring {kind[:-1]} file(s) under {stale}: {names}. " + f"{kind.capitalize()} can only live under {home_path} " + f"(PRD prd-new-trusted-agent-forge-identity). Move them or delete." ) -def _manifest_with_merged_git_user( - agent: "ManifestAgent", raw_bottle: "ManifestBottle" +def _compose_manifest( + agent_name: str, + agent: "ManifestAgent", + raw_bottle: "ManifestBottle", ) -> "Manifest": - """Build the single-value Manifest, overlaying the agent's git-gate.user - onto the bottle (agent wins on non-empty, per-field). Shared by the eager - and lazy load_for_agent paths.""" - merged = _merge_git_user(agent.git_user, raw_bottle.git_user) - bottle = ( - raw_bottle if merged == raw_bottle.git_user - else replace(raw_bottle, git_user=merged) + """Build the single-value Manifest from the selected agent and its + effective bottle (PRD prd-new-trusted-agent-forge-identity): + + - the agent's `author` populates the bottle's git user.name/user.email; + - each git-gate repo's `forge` alias is resolved against the agent's + `forge-accounts` (failing closed on an unknown alias) into the + Manifest's forge associations. + + Shared by the eager (from_json_obj) and lazy (from_md_dirs) paths.""" + identity = ( + ManifestGitUser(name=agent.author.name, email=agent.author.email) + if agent.author is not None else ManifestGitUser() ) - return Manifest(agent=agent, bottle=bottle) + bottle = ( + raw_bottle if identity == raw_bottle.git_user + else replace(raw_bottle, git_user=identity) + ) + associations = resolve_forge_associations( + agent_name, dict(agent.forge_accounts), bottle.git, + ) + return Manifest(agent=agent, bottle=bottle, forge_associations=associations) def _resolve_effective_bottle_eager( @@ -121,26 +142,28 @@ def _resolve_effective_bottle_lazy( class Manifest: """Single-agent/bottle value type. Returned by ManifestIndex.load_for_agent(). - `bottle` is the effective bottle with the agent's git-gate.user already - overlaid per-field (agent wins on non-empty). Backends and provisioners - use this directly — no agent_name lookup needed.""" + `bottle` is the effective bottle with the agent's `author` already + populated into its git identity. `forge_associations` holds the distinct + forge aliases referenced by the effective bottle's git-gate repos, resolved + against the agent's `forge-accounts`. Backends and provisioners use this + directly — no agent_name lookup needed.""" agent: ManifestAgent bottle: ManifestBottle + forge_associations: tuple[ResolvedForgeAssociation, ...] = () def git_identity_summary(self) -> str | None: - """One-line effective git identity with per-field provenance, e.g. - `name=claude (agent), email=eric@dideric.is (bottle)`. - Returns None when neither agent nor bottle sets an identity.""" - over = self.agent.git_user # agent's declared git_user (pre-merge) - merged = self.bottle.git_user # effective git_user (post-merge) - if merged.is_empty(): + """One-line effective git identity, e.g. + `name=claude, email=eric@dideric.is`. Sourced from the agent's + `author` block. Returns None when the agent declares no author.""" + gu = self.bottle.git_user + if gu.is_empty(): return None parts: list[str] = [] - if merged.name: - parts.append(f"name={merged.name} ({'agent' if over.name else 'bottle'})") - if merged.email: - parts.append(f"email={merged.email} ({'agent' if over.email else 'bottle'})") + if gu.name: + parts.append(f"name={gu.name}") + if gu.email: + parts.append(f"email={gu.email}") return ", ".join(parts) @@ -164,15 +187,15 @@ class ManifestIndex: def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "ManifestIndex": """Walk the per-file manifest tree and build a ManifestIndex. - Layout (PRD 0011): + Layout: $HOME/.bot-bottle/bottles/.md — bottles (home-only) - $HOME/.bot-bottle/agents/.md — home agents - $CWD/.bot-bottle/agents/.md — cwd agents + $HOME/.bot-bottle/agents/.md — agents (home-only) - Cwd agents merge into the home agents on the same name - (cwd wins). A bottles/ subdir under $CWD is logged as a - warning and ignored — the filesystem layout IS the trust - boundary. + Both agents and bottles are home-only + (PRD prd-new-trusted-agent-forge-identity): a `bottles/` or `agents/` + subdir under $CWD is logged as a warning and ignored — the filesystem + layout IS the trust boundary, since an agent may now select a host + identity and forge secret. If `missing_ok` is true, a missing `$HOME/.bot-bottle/` returns an empty index instead of dying. This is for @@ -223,17 +246,12 @@ class ManifestIndex: Used by tests to build a ManifestIndex from fixture directories without touching `os.environ`.""" if cwd_dir is not None: - stale_bottles = cwd_dir / "bottles" - if stale_bottles.is_dir(): - files = sorted(stale_bottles.glob("*.md")) - if files: - names = ", ".join(p.name for p in files) - warn( - f"ignoring bottle file(s) under " - f"{stale_bottles}: {names}. Bottles can only " - f"live under $HOME/.bot-bottle/bottles/ " - f"(PRD 0011). Move them or delete." - ) + _warn_ignored_cwd_dir(cwd_dir, "bottles", "$HOME/.bot-bottle/bottles/") + # Agents became home-only in + # PRD prd-new-trusted-agent-forge-identity: a cwd agent file that + # once shadowed a home agent could select a host identity/secret, + # so it is now ignored with a migration pointer. + _warn_ignored_cwd_dir(cwd_dir, "agents", "$HOME/.bot-bottle/agents/") return cls(bottles={}, agents={}, home_md=home_dir, cwd_md=cwd_dir) @classmethod @@ -275,13 +293,12 @@ class ManifestIndex: In names-only mode (from resolve/from_md_dirs) this scans agent filenames without reading their content. In eager mode (from - from_json_obj) it returns the pre-parsed agents' names.""" + from_json_obj) it returns the pre-parsed agents' names. + + Agents are home-only (PRD prd-new-trusted-agent-forge-identity): cwd + agent files never contribute names.""" if self.home_md is not None: - home_names = set(scan_agent_names(self.home_md / "agents").keys()) - cwd_names: set[str] = set() - if self.cwd_md is not None: - cwd_names = set(scan_agent_names(self.cwd_md / "agents").keys()) - return sorted(home_names | cwd_names) + return sorted(scan_agent_names(self.home_md / "agents").keys()) return sorted(self.agents.keys()) def load_for_agent( @@ -326,7 +343,7 @@ class ManifestIndex: raw_bottle = _resolve_effective_bottle_eager( agent_name, agent, bottle_names, self.bottles ) - return _manifest_with_merged_git_user(agent, raw_bottle) + return _compose_manifest(agent_name, agent, raw_bottle) def _load_for_agent_lazy( self, agent_name: str, bottle_names: tuple[str, ...] @@ -334,20 +351,17 @@ class ManifestIndex: """Lazy path (resolve/from_md_dirs): read and parse the agent file and its bottle chain from disk for the first time here.""" assert self.home_md is not None # guaranteed by load_for_agent dispatch - # Locate the agent file; cwd wins over home on name collision. + # Agents are home-only (PRD prd-new-trusted-agent-forge-identity): + # a cwd agent file must not select a host identity or forge secret. home_agents = scan_agent_names(self.home_md / "agents") - cwd_agents: dict[str, Path] = {} - if self.cwd_md is not None: - cwd_agents = scan_agent_names(self.cwd_md / "agents") - merged_agents = {**home_agents, **cwd_agents} - if agent_name not in merged_agents: - available = ", ".join(sorted(merged_agents.keys())) or "(none)" + if agent_name not in home_agents: + available = ", ".join(sorted(home_agents.keys())) or "(none)" raise ManifestError( f"agent '{agent_name}' not defined. Available: {available}" ) - agent_path = merged_agents[agent_name] + agent_path = home_agents[agent_name] try: fm, body = parse_frontmatter(agent_path.read_text()) except OSError as e: @@ -374,15 +388,18 @@ class ManifestIndex: } if agent_bottle: agent_dict["bottle"] = agent_bottle - if "git-gate" in fm: - agent_dict["git-gate"] = fm["git-gate"] + # Surface agent-owned identity keys (and any stale git-gate, so + # ManifestAgent.from_dict raises the migration error). + for key in ("author", "forge-accounts", "git-gate"): + if key in fm: + agent_dict[key] = fm[key] # Pass the effective bottle name as the known-bottles set so agents # that have bottle: set are validated; agents without bottle: pass {} # since bottle_names were already resolved above. known = {effective_bottle_name} if effective_bottle_name else set() agent = ManifestAgent.from_dict(agent_name, agent_dict, known) - return _manifest_with_merged_git_user(agent, raw_bottle) + return _compose_manifest(agent_name, agent, raw_bottle) def has_agent(self, name: str) -> bool: return name in self.agents @@ -394,13 +411,9 @@ class ManifestIndex: if self.has_agent(name): return if self.home_md is not None: - # Names-only mode: check file existence without parsing. - home_path = self.home_md / "agents" / f"{name}.md" - cwd_path = ( - self.cwd_md / "agents" / f"{name}.md" - if self.cwd_md else None - ) - if home_path.is_file() or (cwd_path and cwd_path.is_file()): + # Names-only mode: check home file existence without parsing. + # Agents are home-only; a cwd agent file is never selectable. + if (self.home_md / "agents" / f"{name}.md").is_file(): return available = ", ".join(self.all_agent_names) or "(none)" raise ManifestError( diff --git a/bot_bottle/manifest/schema.py b/bot_bottle/manifest/schema.py index 512231a6..87c3bc88 100644 --- a/bot_bottle/manifest/schema.py +++ b/bot_bottle/manifest/schema.py @@ -22,7 +22,10 @@ BOTTLE_KEYS = frozenset( } ) AGENT_KEYS_REQUIRED: frozenset[str] = frozenset() -AGENT_KEYS_OPTIONAL = frozenset({"bottle", "skills", "git-gate"}) +# `author` / `forge-accounts` are agent-owned identity (PRD +# prd-new-trusted-agent-forge-identity). `git-gate` is no longer accepted on an +# agent: `git-gate.user` moved to `author`, and `git-gate.repos` is bottle-only. +AGENT_KEYS_OPTIONAL = frozenset({"bottle", "skills", "author", "forge-accounts"}) # Claude Code subagent fields bot-bottle ignores at launch but does # not reject. This lets the same file double as diff --git a/docs/prds/0011-per-file-md-manifest.md b/docs/prds/0011-per-file-md-manifest.md index ae1c3722..d47e3f1d 100644 --- a/docs/prds/0011-per-file-md-manifest.md +++ b/docs/prds/0011-per-file-md-manifest.md @@ -1,9 +1,19 @@ # PRD 0011: Per-file Markdown manifest -- **Status:** Active +- **Status:** Active (agent cwd-discovery superseded) - **Author:** didericis - **Created:** 2026-05-24 +> **Superseded in part by PRD prd-new-trusted-agent-forge-identity.** +> The `$CWD/.bot-bottle/agents/.md` discovery/override path described +> below is removed: agents are now **home-only**, like bottles. Once an agent +> definition can select a host identity (`author`) and a host forge secret +> (`forge-accounts`), letting checked-out workspace content define or override +> an agent would let untrusted content select host credentials. A cwd +> `agents/` (or `bottles/`) directory is now warned-about and ignored. The +> filesystem-layout trust boundary still holds — it just admits nothing from +> `$CWD`. + ## Summary Replace the single-file `bot-bottle.json` manifest with a diff --git a/docs/prds/prd-new-trusted-agent-forge-identity.md b/docs/prds/prd-new-trusted-agent-forge-identity.md index 60ce086b..5d4f96b2 100644 --- a/docs/prds/prd-new-trusted-agent-forge-identity.md +++ b/docs/prds/prd-new-trusted-agent-forge-identity.md @@ -1,6 +1,6 @@ # PRD prd-new: Trusted agent forge identity and guidance -- **Status:** Draft +- **Status:** Accepted - **Author:** didericis-claude - **Created:** 2026-07-25 - **Issue:** #423 diff --git a/examples/agents/implementer.md b/examples/agents/implementer.md index 15ae1090..3a1d8cf6 100644 --- a/examples/agents/implementer.md +++ b/examples/agents/implementer.md @@ -5,10 +5,9 @@ model: opus bottle: dev skills: - init-prd -git-gate: - user: - name: implementer-bot - email: eric+implementer@dideric.is +author: + name: implementer-bot + email: eric+implementer@dideric.is --- You are a feature-implementation agent running inside an ephemeral diff --git a/tests/unit/test_docker_provision_git_user.py b/tests/unit/test_docker_provision_git_user.py index d193e9f5..858eec8e 100644 --- a/tests/unit/test_docker_provision_git_user.py +++ b/tests/unit/test_docker_provision_git_user.py @@ -9,6 +9,7 @@ from __future__ import annotations import tempfile import unittest +from dataclasses import replace from pathlib import Path from unittest.mock import MagicMock @@ -21,7 +22,7 @@ from bot_bottle.backend import Bottle, BottleSpec, ExecResult from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan -from bot_bottle.manifest import ManifestIndex +from bot_bottle.manifest import ManifestGitUser, ManifestIndex class _Provider(AgentProvider): @@ -50,15 +51,39 @@ def _plan(*, git_user: dict | None = None, # type: ignore user_cwd: str = "/tmp/x", stage_dir: Path | None = None) -> DockerBottlePlan: bottle_json: dict = {} # type: ignore - if git_user is not None: - bottle_json["git-gate"] = {"user": git_user} if git_repos is not None: bottle_json.setdefault("git-gate", {})["repos"] = git_repos + # Identity now lives on the agent's `author` block; at composition it + # populates manifest.bottle.git_user, which provision_git reads + # (production unchanged). When the caller passes a full name+email we + # route it through `author`; the name-only / email-only cases (which + # exercise provision_git emitting a single `git config` line) can't be + # expressed via `author` (both fields required), so we inject the + # partial ManifestGitUser onto the composed bottle directly. + agent_json: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore + full_author = ( + git_user + if git_user and git_user.get("name") and git_user.get("email") + else None + ) + if full_author is not None: + agent_json["author"] = full_author index = ManifestIndex.from_json_obj({ "bottles": {"dev": bottle_json}, - "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + "agents": {"demo": agent_json}, }) manifest = index.load_for_agent("demo") + if git_user is not None and full_author is None: + manifest = replace( + manifest, + bottle=replace( + manifest.bottle, + git_user=ManifestGitUser( + name=git_user.get("name", ""), + email=git_user.get("email", ""), + ), + ), + ) spec = BottleSpec( manifest=index, agent_name="demo", copy_cwd=copy_cwd, user_cwd=user_cwd, diff --git a/tests/unit/test_forge_identity.py b/tests/unit/test_forge_identity.py new file mode 100644 index 00000000..8cac60d6 --- /dev/null +++ b/tests/unit/test_forge_identity.py @@ -0,0 +1,364 @@ +"""Unit: trusted agent forge identity & guidance +(PRD prd-new-trusted-agent-forge-identity). + +Covers the net-new surface: agent `author`, agent `forge-accounts` (Gitea API +URL validation + host token reference), the repo→forge association resolved at +composition, the synthesized proxy-held egress route, and the generated, +non-secret prompt guidance. Legacy git-gate.user / cwd-agent behavior lives in +the manifest test modules. +""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from bot_bottle.egress import ( + egress_forge_routes, + egress_render_routes, + egress_resolve_token_values, + egress_routes_for_bottle, + egress_token_env_map, +) +from bot_bottle.manifest import ManifestError, ManifestIndex +from bot_bottle.manifest.forge import ( + ManifestForgeAccount, + canonicalize_forge_url, + render_forge_guidance, +) + + +GITEA = { + "url": "https://gitea.dideric.is/api/v1", + "auth": {"type": "token", "token_secret": "GITEA_CLAUDE_TOKEN"}, +} + + +def _repo(*, forge: str | None = None) -> dict: + entry: dict = { + "url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git", + "key": {"provider": "static", "path": "/k"}, + } + if forge is not None: + entry["forge"] = forge + return entry + + +def _index(*, author=None, forge_accounts=None, repo_forge=None) -> ManifestIndex: + """Build an eager index with one agent 'claude' + bottle 'bb'.""" + agent: dict = {"bottle": "bb", "prompt": ""} + if author is not None: + agent["author"] = author + if forge_accounts is not None: + agent["forge-accounts"] = forge_accounts + bottle: dict = { + "git-gate": {"repos": {"bot-bottle": _repo(forge=repo_forge)}} + } + return ManifestIndex.from_json_obj( + {"bottles": {"bb": bottle}, "agents": {"claude": agent}} + ) + + +def _error(callable_, *args, **kwargs) -> str: + try: + callable_(*args, **kwargs) + except ManifestError as e: + return str(e) + raise AssertionError("expected ManifestError was not raised") + + +# --------------------------------------------------------------------------- +# author +# --------------------------------------------------------------------------- + + +class TestAuthor(unittest.TestCase): + def test_populates_git_identity(self) -> None: + idx = _index(author={"name": "didericis-claude", "email": "e+c@x.is"}) + m = idx.load_for_agent("claude", ()) + self.assertEqual("didericis-claude", m.bottle.git_user.name) + self.assertEqual("e+c@x.is", m.bottle.git_user.email) + self.assertEqual( + "name=didericis-claude, email=e+c@x.is", + m.git_identity_summary(), + ) + + def test_absent_author_no_identity(self) -> None: + m = _index().load_for_agent("claude", ()) + self.assertTrue(m.bottle.git_user.is_empty()) + self.assertIsNone(m.git_identity_summary()) + + def test_name_required(self) -> None: + msg = _error(_index, author={"email": "e@x.is"}) + self.assertIn("author.name must be a non-empty string", msg) + + def test_email_required(self) -> None: + msg = _error(_index, author={"name": "n"}) + self.assertIn("author.email must be a non-empty string", msg) + + def test_email_rejects_whitespace(self) -> None: + msg = _error(_index, author={"name": "n", "email": "a b@x.is"}) + self.assertIn("must not contain whitespace", msg) + + def test_unknown_key(self) -> None: + msg = _error( + _index, author={"name": "n", "email": "e@x.is", "role": "x"} + ) + self.assertIn("author has unknown key", msg) + + +# --------------------------------------------------------------------------- +# forge-accounts URL validation +# --------------------------------------------------------------------------- + + +class TestForgeUrl(unittest.TestCase): + def test_canonical_ok(self) -> None: + canonical, origin, host, prefix = canonicalize_forge_url( + "a", "g", "https://Gitea.Dideric.is/api/v1/" + ) + # trailing slash normalized; host lowercased. + self.assertEqual("https://gitea.dideric.is/api/v1", canonical) + self.assertEqual("https://gitea.dideric.is", origin) + self.assertEqual("gitea.dideric.is", host) + self.assertEqual("/api/v1", prefix) + + def test_port_preserved(self) -> None: + canonical, origin, host, _ = canonicalize_forge_url( + "a", "g", "https://gitea.local:3000/api/v1" + ) + self.assertEqual("https://gitea.local:3000/api/v1", canonical) + self.assertEqual("https://gitea.local:3000", origin) + + def test_http_fails(self) -> None: + self.assertIn("must use https", _error( + canonicalize_forge_url, "a", "g", "http://gitea/api/v1")) + + def test_userinfo_fails(self) -> None: + self.assertIn("userinfo", _error( + canonicalize_forge_url, "a", "g", "https://u:p@gitea/api/v1")) + + def test_query_fails(self) -> None: + self.assertIn("query string", _error( + canonicalize_forge_url, "a", "g", "https://gitea/api/v1?x=1")) + + def test_fragment_fails(self) -> None: + self.assertIn("fragment", _error( + canonicalize_forge_url, "a", "g", "https://gitea/api/v1#x")) + + def test_missing_host_fails(self) -> None: + self.assertIn("hostname", _error( + canonicalize_forge_url, "a", "g", "https:///api/v1")) + + def test_bad_path_fails(self) -> None: + self.assertIn("Gitea API base", _error( + canonicalize_forge_url, "a", "g", "https://gitea/api/v2")) + + def test_non_string_fails(self) -> None: + self.assertIn("required", _error( + canonicalize_forge_url, "a", "g", None)) + + +class TestForgeAccount(unittest.TestCase): + def test_parses(self) -> None: + acct = ManifestForgeAccount.from_dict("a", "didericis-gitea", GITEA) + self.assertEqual("didericis-gitea", acct.alias) + self.assertEqual("gitea.dideric.is", acct.host) + self.assertEqual("token", acct.auth_type) + self.assertEqual("GITEA_CLAUDE_TOKEN", acct.token_secret) + + def test_non_kebab_alias_fails(self) -> None: + self.assertIn("valid alias", _error( + ManifestForgeAccount.from_dict, "a", "Bad_Alias", GITEA)) + + def test_auth_required(self) -> None: + self.assertIn("missing required 'auth'", _error( + ManifestForgeAccount.from_dict, "a", "g", + {"url": "https://gitea/api/v1"})) + + def test_bad_auth_type_fails(self) -> None: + self.assertIn("auth.type must be", _error( + ManifestForgeAccount.from_dict, "a", "g", + {"url": "https://gitea/api/v1", + "auth": {"type": "basic", "token_secret": "T"}})) + + def test_token_secret_required(self) -> None: + self.assertIn("token_secret must be", _error( + ManifestForgeAccount.from_dict, "a", "g", + {"url": "https://gitea/api/v1", "auth": {"type": "token"}})) + + def test_unknown_key_fails(self) -> None: + self.assertIn("unknown key", _error( + ManifestForgeAccount.from_dict, "a", "g", + {**GITEA, "bogus": 1})) + + +# --------------------------------------------------------------------------- +# repo -> forge association (composition) +# --------------------------------------------------------------------------- + + +class TestForgeAssociations(unittest.TestCase): + def test_resolves_when_repo_references_alias(self) -> None: + idx = _index( + forge_accounts={"didericis-gitea": GITEA}, + repo_forge="didericis-gitea", + ) + m = idx.load_for_agent("claude", ()) + self.assertEqual(1, len(m.forge_associations)) + assoc = m.forge_associations[0] + self.assertEqual("didericis-gitea", assoc.alias) + self.assertEqual(("bot-bottle",), assoc.repo_names) + + def test_unreferenced_account_yields_no_association(self) -> None: + idx = _index(forge_accounts={"didericis-gitea": GITEA}) # repo has no forge + m = idx.load_for_agent("claude", ()) + self.assertEqual((), m.forge_associations) + + def test_unknown_alias_fails_closed(self) -> None: + idx = _index( + forge_accounts={"didericis-gitea": GITEA}, repo_forge="nope" + ) + msg = _error(idx.load_for_agent, "claude", ()) + self.assertIn("references forge alias 'nope'", msg) + self.assertIn("not defined on agent", msg) + + def test_forge_without_account_fails_closed(self) -> None: + idx = _index(repo_forge="didericis-gitea") # no forge-accounts at all + self.assertIn("(none)", _error(idx.load_for_agent, "claude", ())) + + +# --------------------------------------------------------------------------- +# synthesized egress route (proxy-held credential) +# --------------------------------------------------------------------------- + + +class TestForgeEgressRoutes(unittest.TestCase): + def _assoc(self): + idx = _index( + forge_accounts={"didericis-gitea": GITEA}, + repo_forge="didericis-gitea", + ) + return idx.load_for_agent("claude", ()) + + def test_route_shape(self) -> None: + routes = egress_forge_routes(self._assoc().forge_associations) + self.assertEqual(1, len(routes)) + r = routes[0] + self.assertEqual("gitea.dideric.is", r.host) + self.assertEqual("token", r.auth_scheme) + self.assertEqual("GITEA_CLAUDE_TOKEN", r.token_ref) + self.assertTrue(r.inspect) + # scoped to the API prefix. + self.assertEqual("/api/v1", r.matches[0].paths[0].value) + + def test_token_slot_and_resolution(self) -> None: + m = self._assoc() + forge_routes = egress_forge_routes(m.forge_associations) + routes = egress_routes_for_bottle(m.bottle, (), forge_routes) + token_map = egress_token_env_map(routes) + # one slot mapping the proxy env slot -> host env var name. + self.assertEqual({"EGRESS_TOKEN_0": "GITEA_CLAUDE_TOKEN"}, token_map) + resolved = egress_resolve_token_values( + token_map, {"GITEA_CLAUDE_TOKEN": "sekret-value"} + ) + self.assertEqual({"EGRESS_TOKEN_0": "sekret-value"}, resolved) + + def test_rendered_routes_hide_secret_name_and_value(self) -> None: + m = self._assoc() + routes = egress_routes_for_bottle( + m.bottle, (), egress_forge_routes(m.forge_associations) + ) + rendered = egress_render_routes(routes) + self.assertIn("gitea.dideric.is", rendered) + self.assertIn("EGRESS_TOKEN_0", rendered) # slot, not the host var name + self.assertNotIn("GITEA_CLAUDE_TOKEN", rendered) + self.assertNotIn("sekret-value", rendered) + + def test_dedup_by_host(self) -> None: + # Two repos referencing the same alias share one route/credential. + idx = ManifestIndex.from_json_obj({ + "bottles": {"bb": {"git-gate": {"repos": { + "r1": _repo(forge="g"), + "r2": { + "url": "ssh://git@h/x/other.git", + "key": {"provider": "static", "path": "/k"}, + "forge": "g", + }, + }}}}, + "agents": {"claude": {"bottle": "bb", "prompt": "", + "forge-accounts": {"g": GITEA}}}, + }) + m = idx.load_for_agent("claude", ()) + routes = egress_forge_routes(m.forge_associations) + self.assertEqual(1, len(routes)) + + +# --------------------------------------------------------------------------- +# generated prompt guidance +# --------------------------------------------------------------------------- + + +class TestForgeGuidance(unittest.TestCase): + def _assoc(self): + idx = _index( + forge_accounts={"didericis-gitea": GITEA}, + repo_forge="didericis-gitea", + ) + return idx.load_for_agent("claude", ()).forge_associations + + def test_empty_when_no_associations(self) -> None: + self.assertEqual("", render_forge_guidance(())) + + def test_names_account_repo_and_workflow(self) -> None: + text = render_forge_guidance(self._assoc()) + self.assertIn("didericis-gitea", text) + self.assertIn("https://gitea.dideric.is/api/v1", text) + self.assertIn("bot-bottle", text) + # branch-backed PR workflow + AGit prohibition. + self.assertIn("refs/heads/", text) + self.assertIn("refs/for/*", text) + self.assertIn("pull request", text) + + def test_no_token_secret_name_or_value(self) -> None: + text = render_forge_guidance(self._assoc()) + self.assertNotIn("GITEA_CLAUDE_TOKEN", text) + + def test_prompt_file_appends_guidance(self) -> None: + from bot_bottle.backend.resolve_common import prepare_agent_state_dir + + idx = _index( + author={"name": "n", "email": "e@x.is"}, + forge_accounts={"didericis-gitea": GITEA}, + repo_forge="didericis-gitea", + ) + # Give the agent a real prompt body. + m = idx.load_for_agent("claude", ()) + from dataclasses import replace + m = replace(m, agent=replace(m.agent, prompt="BASE PROMPT")) + with tempfile.TemporaryDirectory() as td: + with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": td}): + _dir, prompt_file = prepare_agent_state_dir("slug1", m) + body = Path(prompt_file).read_text() + self.assertIn("BASE PROMPT", body) + self.assertIn("Forge access", body) + self.assertIn("https://gitea.dideric.is/api/v1", body) + + def test_prompt_file_no_guidance_without_forge(self) -> None: + from bot_bottle.backend.resolve_common import prepare_agent_state_dir + + m = _index(author={"name": "n", "email": "e@x.is"}).load_for_agent( + "claude", () + ) + with tempfile.TemporaryDirectory() as td: + with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": td}): + _dir, prompt_file = prepare_agent_state_dir("slug2", m) + body = Path(prompt_file).read_text() + self.assertNotIn("Forge access", body) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_manifest_agent_git_user.py b/tests/unit/test_manifest_agent_git_user.py index c9ccd268..9fb09915 100644 --- a/tests/unit/test_manifest_agent_git_user.py +++ b/tests/unit/test_manifest_agent_git_user.py @@ -1,14 +1,17 @@ -"""Unit: agent-level git-gate.user overlay + provenance (PRD 0027, PRD 0047). +"""Unit: agent-owned identity via `author` (PRD +prd-new-trusted-agent-forge-identity). -An agent file may declare `git-gate.user` (name/email). At -`ManifestIndex.load_for_agent()` it overlays the referenced bottle's -`git-gate.user` per-field, agent-wins-on-non-empty. `git-gate.repos` is -rejected on agents. `Manifest.git_identity_summary()` reports the -effective identity with per-field `(agent)`/`(bottle)` provenance. +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 `Agent.from_dict` + the overlay in -load_for_agent; a temp-dir case locks the md loader (the `_AGENT_KEYS` -allow + the `git-gate` threading into `agent_dict`).""" +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 @@ -31,97 +34,61 @@ def _error_message(callable_, *args, **kwargs) -> str: # type: ignore raise AssertionError("expected ManifestError was not raised") -def _manifest(*, bottle_user=None, agent_git=None) -> Manifest: # type: ignore +def _manifest(*, author=None, agent_git=None) -> Manifest: # type: ignore """Build an index with one agent 'impl' and load it, returning a Manifest.""" - bottle: dict = {} # type: ignore - if bottle_user is not None: - bottle = {"git-gate": {"user": bottle_user}} 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": bottle}, + "bottles": {"dev": {}}, "agents": {"impl": agent}, }).load_for_agent("impl") -def _index(*, bottle_user: dict[str, object] | None = None, agent_git: dict[str, object] | None = None) -> ManifestIndex: +def _index(*, author: dict[str, object] | None = None) -> ManifestIndex: """Build an index with one agent 'impl' without loading it.""" - bottle: dict = {} # type: ignore - if bottle_user is not None: - bottle = {"git-gate": {"user": bottle_user}} agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore - if agent_git is not None: - agent["git-gate"] = agent_git + if author is not None: + agent["author"] = author return ManifestIndex.from_json_obj({ - "bottles": {"dev": bottle}, + "bottles": {"dev": {}}, "agents": {"impl": agent}, }) -class TestAgentGitUserOverlay(unittest.TestCase): - def test_agent_supplies_both_fields(self): - m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}}) +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_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.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.git_user - self.assertEqual("B", u.name) - self.assertEqual("a@b", u.email) - - def test_agent_identity_with_bottle_declaring_none(self): - idx = _index(agent_git={"user": {"name": "a", "email": "a@b"}}) - # Raw bottle has no git_user; loaded manifest has merged git_user from agent + 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_bottle_only_identity_preserved_when_agent_silent(self): - m = _manifest(bottle_user={"name": "B", "email": "b@c"}) - u = m.bottle.git_user - self.assertEqual("B", u.name) - self.assertEqual("b@c", u.email) - - def test_no_overlay_uses_bottle_instance_directly(self): - idx = _index(bottle_user={"name": "B"}) + def test_agent_silent_leaves_bottle_identity_empty(self): + idx = _index() m = idx.load_for_agent("impl") - # Agent has no git_user — bottle instance should be the same object + # 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_noop_overlay_uses_bottle_instance_directly(self): - idx = _index( - bottle_user={"name": "B", "email": "b@c"}, - agent_git={"user": {"name": "B", "email": "b@c"}}, - ) - m = idx.load_for_agent("impl") - # Agent git_user == bottle git_user — no replace needed - self.assertEqual(idx.bottles["dev"].git_user, m.bottle.git_user) - - def test_other_bottle_fields_untouched_by_overlay(self): + def test_other_bottle_fields_untouched_by_identity(self): idx = ManifestIndex.from_json_obj({ "bottles": {"dev": { "env": {"FOO": "bar"}, "supervise": True, - "git-gate": {"user": {"name": "B"}}, }}, "agents": {"impl": { "bottle": "dev", "skills": [], "prompt": "", - "git-gate": {"user": {"name": "a"}}, + "author": {"name": "a", "email": "a@b"}, }}, }) b = idx.load_for_agent("impl").bottle @@ -130,93 +97,77 @@ class TestAgentGitUserOverlay(unittest.TestCase): self.assertTrue(b.supervise) -class TestAgentGitUserRejections(unittest.TestCase): - def test_agent_repos_dies_bottle_only(self): +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("git-gate.repos", msg) - self.assertIn("bottle-only", msg) - - def test_agent_unknown_git_subkey_dies(self): - msg = _error_message(_manifest, agent_git={"nope": {}}) - self.assertIn("not allowed at the agent level", msg) - - def test_agent_git_user_both_empty_dies(self): - msg = _error_message(_manifest, agent_git={"user": {"name": "", "email": ""}}) - self.assertIn("neither name nor email", msg) + self.assertIn("no longer", msg) + self.assertIn("author", 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(), - ) + """Summary reports the effective identity (from the agent's author) + with no per-field provenance annotation.""" - 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(), - ) + 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_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(), - ) - - def test_none_when_unset_anywhere(self): + def test_none_when_no_author(self): m = _manifest() self.assertIsNone(m.git_identity_summary()) _BOTTLE_DEV = """ --- - git-gate: - user: - name: bottle-name - email: bottle@example.com + egress: + routes: + - host: example.com --- dev bottle. """ -_AGENT_WITH_GIT = """ +_AGENT_WITH_AUTHOR = """ --- bottle: dev - git-gate: - user: - name: agent-name + author: + name: agent-name + email: agent@example.com --- impl agent. """ -_AGENT_WITH_REPOS = """ +_AGENT_WITH_GIT_GATE = """ --- bottle: dev git-gate: repos: r: url: ssh://git@x/y.git - identity: /dev/null + key: + provider: static + path: /dev/null --- bad agent. """ -class TestAgentGitUserMdLoader(unittest.TestCase): - """Locks the md path: `git-gate` is an accepted agent key and threads - into the parsed Agent (not rejected as an unknown frontmatter key), - and agent `git-gate.repos` dies through the same loader.""" +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-")) @@ -235,31 +186,30 @@ class TestAgentGitUserMdLoader(unittest.TestCase): 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): + def test_md_agent_author_populates_identity(self): self._write("bottles/dev.md", _BOTTLE_DEV) - self._write("agents/impl.md", _AGENT_WITH_GIT) + 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("bottle@example.com", u.email) + self.assertEqual("agent@example.com", u.email) self.assertEqual( - "name=agent-name (agent), email=bottle@example.com (bottle)", + "name=agent-name, email=agent@example.com", m.git_identity_summary(), ) - def test_md_agent_repos_fails_at_preflight(self): - """git-gate.repos on an agent is an error; resolve() still succeeds - so other agents remain accessible, but load_for_agent raises.""" + 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_REPOS) - from bot_bottle.manifest import ManifestError + 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") - msg = str(ctx.exception) - self.assertIn("git-gate.repos", msg) - self.assertIn("bottle-only", msg) + self.assertIn("git-gate", str(ctx.exception)) if __name__ == "__main__": diff --git a/tests/unit/test_manifest_extends.py b/tests/unit/test_manifest_extends.py index 92b4588f..afaebb10 100644 --- a/tests/unit/test_manifest_extends.py +++ b/tests/unit/test_manifest_extends.py @@ -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): diff --git a/tests/unit/test_manifest_git_user.py b/tests/unit/test_manifest_git_user.py index 074a12d0..a3559ea2 100644 --- a/tests/unit/test_manifest_git_user.py +++ b/tests/unit/test_manifest_git_user.py @@ -1,4 +1,11 @@ -"""Unit: Bottle git-gate.user manifest parsing + validation (issue #86, PRD 0047).""" +"""Unit: agent `author` identity -> bottle.git_user (PRD +prd-new-trusted-agent-forge-identity). + +Identity moved off `git-gate.user` (bottle) onto the trusted agent's +`author` block. At `load_for_agent` the agent's author populates the +effective bottle's `git_user` (a `ManifestGitUser`). This locks the +`author` validation/rejection paths and the bottle `git-gate.user` +migration die.""" import unittest @@ -14,89 +21,92 @@ def _error_message(callable_, *args, **kwargs) -> str: # type: ignore raise AssertionError("expected ManifestError was not raised") -def _manifest(git_user): # type: ignore - return { - "bottles": {"dev": {"git-gate": {"user": git_user}}}, - "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, - } +def _manifest(author): # type: ignore + """Build an index with one agent 'demo' carrying the given `author` + block, then load it, returning the composed Manifest.""" + agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore + if author is not None: + agent["author"] = author + return ManifestIndex.from_json_obj({ + "bottles": {"dev": {}}, + "agents": {"demo": agent}, + }).load_for_agent("demo") -class TestGitUserParsing(unittest.TestCase): +class TestAuthorIdentity(unittest.TestCase): + """The agent's `author` block populates bottle.git_user.""" + def test_parses_both_fields(self): - m = ManifestIndex.from_json_obj(_manifest({ + m = _manifest({ "name": "Eric Bauerfeld", "email": "eric+claude@dideric.is", - })) - u = m.bottles["dev"].git_user + }) + u = m.bottle.git_user self.assertEqual("Eric Bauerfeld", u.name) self.assertEqual("eric+claude@dideric.is", u.email) self.assertFalse(u.is_empty()) - def test_name_only(self): - m = ManifestIndex.from_json_obj(_manifest({"name": "Bot"})) - u = m.bottles["dev"].git_user - self.assertEqual("Bot", u.name) - self.assertEqual("", u.email) - - def test_email_only(self): - m = ManifestIndex.from_json_obj(_manifest({"email": "bot@example.com"})) - u = m.bottles["dev"].git_user - self.assertEqual("", u.name) - self.assertEqual("bot@example.com", u.email) - - def test_omitted_defaults_to_empty(self): - # No git.user block at all → empty GitUser, is_empty True → + def test_omitted_author_defaults_to_empty(self): + # No author block at all -> empty git_user, is_empty True -> # provisioner skips the `git config` step entirely. - m = ManifestIndex.from_json_obj({ - "bottles": {"dev": {}}, - "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, - }) - u = m.bottles["dev"].git_user - self.assertTrue(u.is_empty()) + m = _manifest(None) + self.assertTrue(m.bottle.git_user.is_empty()) + + def test_missing_name_dies(self): + # `author` is present but name is absent -> both fields required. + msg = _error_message(_manifest, {"email": "bot@example.com"}) + self.assertIn("author.name must be a non-empty string", msg) + + def test_missing_email_dies(self): + msg = _error_message(_manifest, {"name": "Bot"}) + self.assertIn("author.email must be a non-empty string", msg) def test_both_empty_strings_dies(self): - # An explicit `git.user: {name: "", email: ""}` is a typo - # / half-finished edit; fail loudly rather than silently - # no-op (the operator clearly meant to configure something). - msg = _error_message( - ManifestIndex.from_json_obj, _manifest({"name": "", "email": ""}), - ) - self.assertIn("neither name nor email", msg) + # An explicit `author: {name: "", email: ""}` is a typo / + # half-finished edit; fail loudly rather than silently no-op. + msg = _error_message(_manifest, {"name": "", "email": ""}) + self.assertIn("author.name must be a non-empty string", msg) def test_unknown_key_dies(self): msg = _error_message( - ManifestIndex.from_json_obj, - _manifest({"name": "Bot", "username": "bot"}), + _manifest, + {"name": "Bot", "email": "b@x", "username": "bot"}, ) self.assertIn("unknown key", msg) self.assertIn("username", msg) def test_non_string_name_dies(self): - msg = _error_message( - ManifestIndex.from_json_obj, _manifest({"name": 42}), - ) - self.assertIn("git-gate.user.name must be a string", msg) + msg = _error_message(_manifest, {"name": 42, "email": "b@x"}) + self.assertIn("author.name must be a non-empty string", msg) def test_non_string_email_dies(self): - msg = _error_message( - ManifestIndex.from_json_obj, _manifest({"email": ["x@y.z"]}), - ) - self.assertIn("git-gate.user.email must be a string", msg) + msg = _error_message(_manifest, {"name": "Bot", "email": ["x@y.z"]}) + self.assertIn("author.email must be a non-empty string", msg) - def test_legacy_top_level_git_user_dies(self): + def test_email_with_whitespace_dies(self): + msg = _error_message(_manifest, {"name": "Bot", "email": "a @b"}) + self.assertIn("author.email must not contain whitespace", msg) + + +class TestBottleGitGateUserMigration(unittest.TestCase): + """`git-gate.user` on a bottle is no longer supported: it raises a + ManifestError pointing at the agent's `author` block.""" + + def test_bottle_git_gate_user_dies(self): msg = _error_message( ManifestIndex.from_json_obj, { - "bottles": {"dev": {"git_user": {"name": "Bot"}}}, + "bottles": {"dev": {"git-gate": {"user": {"name": "Bot"}}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }, ) - self.assertIn("git_user", msg) - self.assertIn("git-gate.user", msg) + self.assertIn("git-gate.user is no longer supported", msg) + self.assertIn("author", msg) class TestGitUserDirect(unittest.TestCase): - """Direct GitUser dataclass exercises (no manifest wrapper).""" + """Direct GitUser dataclass exercises (no manifest wrapper). The + dataclass is still the runtime carrier on ManifestBottle.git_user.""" def test_is_empty_default(self): self.assertTrue(ManifestGitUser().is_empty()) diff --git a/tests/unit/test_manifest_lazy_loader.py b/tests/unit/test_manifest_lazy_loader.py index 868c434e..c42992c4 100644 --- a/tests/unit/test_manifest_lazy_loader.py +++ b/tests/unit/test_manifest_lazy_loader.py @@ -71,11 +71,14 @@ class _LazyCase(unittest.TestCase): class TestAllAgentNamesLazy(_LazyCase): - def test_merges_home_and_cwd_agents(self) -> None: + def test_cwd_agents_ignored_home_only(self) -> None: + # Agents are home-only (PRD prd-new-trusted-agent-forge-identity): + # a cwd agents/ dir is warned-and-ignored, so only the home agent + # appears in all_agent_names. _write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV) _write(self.home_cb / "agents" / "alpha.md", _AGENT) _write(self.cwd_cb / "agents" / "beta.md", _AGENT) - self.assertEqual(["alpha", "beta"], self.resolve().all_agent_names) + self.assertEqual(["alpha"], self.resolve().all_agent_names) class TestLoadForAgentLazy(_LazyCase): @@ -96,11 +99,13 @@ class TestRequireAgentLazy(_LazyCase): _write(self.home_cb / "agents" / "alpha.md", _AGENT) self.resolve().require_agent("alpha") # no raise - def test_existing_cwd_agent_ok(self) -> None: - # File only under cwd -> require_agent's cwd_path branch. + def test_cwd_only_agent_not_selectable(self) -> None: + # Agents are home-only (PRD prd-new-trusted-agent-forge-identity): + # a cwd-only agent file is never selectable, so require_agent raises. _write(self.home_cb / "agents" / "alpha.md", _AGENT) _write(self.cwd_cb / "agents" / "beta.md", _AGENT) - self.resolve().require_agent("beta") # no raise + with self.assertRaises(ManifestError): + self.resolve().require_agent("beta") def test_unknown_agent_raises(self) -> None: _write(self.home_cb / "agents" / "alpha.md", _AGENT) diff --git a/tests/unit/test_manifest_md_load.py b/tests/unit/test_manifest_md_load.py index 41ef52da..3e657e24 100644 --- a/tests/unit/test_manifest_md_load.py +++ b/tests/unit/test_manifest_md_load.py @@ -110,14 +110,16 @@ class TestAgentFileParses(_ResolveCase): self.assertFalse(a.prompt.endswith("\n")) -class TestCwdAgentOverridesHome(_ResolveCase): - """SC #3: a cwd agent file with the same name as a home agent - wins. The home bottle stays intact.""" +class TestCwdAgentIgnoredHomeWins(_ResolveCase): + """SC #3 (revised, PRD prd-new-trusted-agent-forge-identity): agents + are home-only. A cwd agent file with the same name as a home agent no + longer wins — it is warned-and-ignored, so the HOME agent's prompt is + used and the home bottle stays intact.""" - def test_cwd_wins(self): + def test_home_wins_cwd_ignored(self): _write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV) _write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL) - # Cwd overrides with a different prompt + # Cwd agent with a different prompt is ignored entirely. _write( self.cwd_cb / "agents" / "implementer.md", """ @@ -129,14 +131,19 @@ class TestCwdAgentOverridesHome(_ResolveCase): """, ) m = self.resolve().load_for_agent("implementer") - self.assertIn("CWD-OVERRIDE-PROMPT", m.agent.prompt) + # Home agent's body is used; the cwd override never applies. + self.assertIn("feature implementation agent", m.agent.prompt) + self.assertNotIn("CWD-OVERRIDE-PROMPT", m.agent.prompt) # Home bottle still present with its two egress routes self.assertEqual(2, len(m.bottle.egress.routes)) class TestCwdBottlesIgnored(_ResolveCase): """SC #4: a bottles/ dir under $CWD is ignored (with a warn). - The home bottle still wins; cwd contributes only agents.""" + The home bottle still wins. Under + PRD prd-new-trusted-agent-forge-identity a cwd agents/ dir is also + ignored, so $CWD contributes nothing — the filesystem layout is the + trust boundary.""" def test_ignored(self): _write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV) diff --git a/tests/unit/test_manifest_validation.py b/tests/unit/test_manifest_validation.py index b762c2a0..843f1f81 100644 --- a/tests/unit/test_manifest_validation.py +++ b/tests/unit/test_manifest_validation.py @@ -212,13 +212,21 @@ class TestAgentValidation(unittest.TestCase): with self.assertRaises(ManifestError): ManifestAgent.from_dict("a", {"prompt": 5}, set()) - def test_git_gate_repos_rejected_at_agent_level(self) -> None: + def test_git_gate_rejected_at_agent_level(self) -> None: + # `git-gate` (user or repos) is no longer accepted on an agent; + # identity moved to `author`, repos stays bottle-only. with self.assertRaises(ManifestError): ManifestAgent.from_dict("a", {"git-gate": {"repos": {}}}, set()) + with self.assertRaises(ManifestError): + ManifestAgent.from_dict( + "a", {"git-gate": {"user": {"name": "x"}}}, set() + ) - def test_git_gate_empty_is_allowed(self) -> None: - agent = ManifestAgent.from_dict("a", {"git-gate": {}}, set()) - self.assertTrue(agent.git_user.is_empty()) + def test_bottle_empty_git_gate_is_allowed(self) -> None: + # An empty `git-gate: {}` on a bottle is still allowed (only the + # optional `repos` subkey exists now); it contributes no git repos. + bottle = ManifestBottle.from_dict("b", {"git-gate": {}}) + self.assertEqual((), bottle.git) # --------------------------------------------------------------------------- @@ -228,9 +236,12 @@ class TestAgentValidation(unittest.TestCase): class TestEagerIndexLookups(unittest.TestCase): def _idx(self) -> ManifestIndex: + # Identity lives on the agent's `author` block now; at composition + # it populates the effective bottle's git_user. return _idx({ - "bottles": {"b": {"git-gate": {"user": {"name": "Bot", "email": "b@x"}}}}, - "agents": {"a": {"bottle": "b"}}, + "bottles": {"b": {}}, + "agents": {"a": {"bottle": "b", + "author": {"name": "Bot", "email": "b@x"}}}, }) def test_unknown_bottle_section_is_empty(self) -> None: