Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cf57f55bb | |||
| 25113fae92 | |||
| a2edaf8694 | |||
| 14f19247c0 | |||
| 300b878288 | |||
| cbd92347d3 | |||
| 23e794f273 | |||
| f31be349ef | |||
| 4ba26b8813 | |||
| f600180861 |
@@ -182,7 +182,9 @@ BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent>
|
|||||||
|
|
||||||
## Manifest
|
## 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 `<repo>/.bot-bottle/agents/<name>.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 `<repo>/.bot-bottle/agents/<name>.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 0082). 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`):
|
**Bottle** (`~/.bot-bottle/bottles/gitea-dev.md`):
|
||||||
|
|
||||||
@@ -190,37 +192,19 @@ Bottles and agents are Markdown files with YAML frontmatter under `~/.bot-bottle
|
|||||||
---
|
---
|
||||||
extends: claude # inherit the Claude provider boundary
|
extends: claude # inherit the Claude provider boundary
|
||||||
|
|
||||||
env:
|
git-gate:
|
||||||
GIT_AUTHOR_NAME: didericis
|
repos:
|
||||||
|
bot-bottle:
|
||||||
git:
|
url: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
|
||||||
user:
|
key:
|
||||||
name: "Eric Bauerfeld"
|
provider: gitea
|
||||||
email: "eric+claude@dideric.is"
|
forge_token_env: GITEA_DEPLOY_TOKEN # deploy-key admin (push), PRD 0048
|
||||||
remotes:
|
host_key: "ssh-ed25519 AAAA..."
|
||||||
gitea.dideric.is:
|
forge: didericis-gitea # ← selects the agent's forge alias
|
||||||
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
|
|
||||||
---
|
---
|
||||||
|
|
||||||
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
|
The `gitea-dev` bottle. Gitea over SSH for push; the API credential and
|
||||||
gitea over SSH for push, token over HTTPS for the API.
|
workflow guidance come from the agent's `forge: didericis-gitea` association.
|
||||||
````
|
````
|
||||||
|
|
||||||
**Agent** (`~/.bot-bottle/agents/gitea-helper.md`):
|
**Agent** (`~/.bot-bottle/agents/gitea-helper.md`):
|
||||||
@@ -230,11 +214,27 @@ gitea over SSH for push, token over HTTPS for the API.
|
|||||||
bottle: gitea-dev
|
bottle: gitea-dev
|
||||||
skills:
|
skills:
|
||||||
- init-prd
|
- 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.
|
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:**
|
**Egress route fields:**
|
||||||
|
|
||||||
| Field | Required | Description |
|
| Field | Required | Description |
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ class BottlePreparationPlanner:
|
|||||||
slug=slug,
|
slug=slug,
|
||||||
resolved_env=resolved_env,
|
resolved_env=resolved_env,
|
||||||
agent_provision_plan=provision,
|
agent_provision_plan=provision,
|
||||||
egress_plan=prepare_egress(bottle, slug, provision),
|
egress_plan=prepare_egress(manifest, slug, provision),
|
||||||
git_gate_plan=prepare_git_gate(bottle, slug),
|
git_gate_plan=prepare_git_gate(bottle, slug),
|
||||||
supervise_plan=prepare_supervise(bottle, slug),
|
supervise_plan=prepare_supervise(bottle, slug),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ from ..bottle_state import (
|
|||||||
supervise_state_dir,
|
supervise_state_dir,
|
||||||
write_metadata,
|
write_metadata,
|
||||||
)
|
)
|
||||||
from ..egress import Egress, EgressPlan
|
from ..egress import Egress, EgressPlan, egress_forge_routes
|
||||||
from ..git_gate import GitGate, GitGatePlan
|
from ..git_gate import GitGate, GitGatePlan
|
||||||
from ..log import die
|
from ..log import die
|
||||||
from ..manifest import Manifest, ManifestBottle
|
from ..manifest import Manifest, ManifestBottle
|
||||||
|
from ..manifest.forge import render_forge_guidance
|
||||||
from ..supervisor.plan import SupervisePlan
|
from ..supervisor.plan import SupervisePlan
|
||||||
from ..orchestrator.supervisor import Supervisor
|
from ..orchestrator.supervisor import Supervisor
|
||||||
from ..util import slugify
|
from ..util import slugify
|
||||||
@@ -71,12 +72,21 @@ def write_launch_metadata(
|
|||||||
|
|
||||||
def prepare_agent_state_dir(slug: str, manifest: Manifest) -> tuple[Path, Path]:
|
def prepare_agent_state_dir(slug: str, manifest: Manifest) -> tuple[Path, Path]:
|
||||||
"""Create the agent state subdir, write the prompt file.
|
"""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
|
||||||
|
0082). The guidance carries neither the
|
||||||
|
token value nor its `token_secret` name."""
|
||||||
agent = manifest.agent
|
agent = manifest.agent
|
||||||
agent_dir = agent_state_dir(slug)
|
agent_dir = agent_state_dir(slug)
|
||||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||||
prompt_file = agent_dir / "prompt.txt"
|
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)
|
prompt_file.chmod(0o600)
|
||||||
return agent_dir, prompt_file
|
return agent_dir, prompt_file
|
||||||
|
|
||||||
@@ -88,11 +98,18 @@ def prepare_git_gate(bottle: ManifestBottle, slug: str) -> GitGatePlan:
|
|||||||
|
|
||||||
|
|
||||||
def prepare_egress(
|
def prepare_egress(
|
||||||
bottle: ManifestBottle, slug: str, provision: AgentProvisionPlan,
|
manifest: Manifest, slug: str, provision: AgentProvisionPlan,
|
||||||
) -> EgressPlan:
|
) -> 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
|
||||||
|
0082). The token is resolved from the host
|
||||||
|
env at launch and never enters the bottle."""
|
||||||
egress_dir = egress_state_dir(slug)
|
egress_dir = egress_state_dir(slug)
|
||||||
egress_dir.mkdir(parents=True, exist_ok=True)
|
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:
|
def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None:
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
if not manifest.all_agent_names:
|
if not manifest.all_agent_names:
|
||||||
print(
|
print(
|
||||||
"bot-bottle: no agents defined. "
|
"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,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
@@ -383,12 +383,9 @@ def _peek_agent_bottle(manifest: ManifestIndex, agent_name: str) -> str:
|
|||||||
from ...manifest.loader import scan_agent_names
|
from ...manifest.loader import scan_agent_names
|
||||||
from ...yaml_subset import YamlSubsetError, parse_frontmatter
|
from ...yaml_subset import YamlSubsetError, parse_frontmatter
|
||||||
|
|
||||||
|
# Agents are home-only (PRD 0082).
|
||||||
home_agents = scan_agent_names(manifest.home_md / "agents")
|
home_agents = scan_agent_names(manifest.home_md / "agents")
|
||||||
cwd_agents: dict[str, Path] = {}
|
path = home_agents.get(agent_name)
|
||||||
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)
|
|
||||||
if path is None:
|
if path is None:
|
||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
@@ -488,13 +485,19 @@ def _manifest_to_yaml(manifest: Manifest) -> str:
|
|||||||
lines.append(" skills:")
|
lines.append(" skills:")
|
||||||
for s in agent.skills:
|
for s in agent.skills:
|
||||||
lines.append(f" - {s}")
|
lines.append(f" - {s}")
|
||||||
if not agent.git_user.is_empty():
|
if agent.author is not None:
|
||||||
lines.append(" git-gate:")
|
lines.append(" author:")
|
||||||
lines.append(" user:")
|
lines.append(f" name: {agent.author.name}")
|
||||||
if agent.git_user.name:
|
lines.append(f" email: {agent.author.email}")
|
||||||
lines.append(f" name: {agent.git_user.name}")
|
if agent.forge_accounts:
|
||||||
if agent.git_user.email:
|
lines.append(" forge-accounts:")
|
||||||
lines.append(f" email: {agent.git_user.email}")
|
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
|
bottle = manifest.bottle
|
||||||
lines.append("bottle:")
|
lines.append("bottle:")
|
||||||
@@ -510,20 +513,14 @@ def _manifest_to_yaml(manifest: Manifest) -> str:
|
|||||||
for k, v in sorted(bottle.env.items()):
|
for k, v in sorted(bottle.env.items()):
|
||||||
lines.append(f" {k}: {v}")
|
lines.append(f" {k}: {v}")
|
||||||
|
|
||||||
has_git_gate = not bottle.git_user.is_empty() or bottle.git
|
if bottle.git:
|
||||||
if has_git_gate:
|
|
||||||
lines.append(" git-gate:")
|
lines.append(" git-gate:")
|
||||||
if not bottle.git_user.is_empty():
|
lines.append(" repos:")
|
||||||
lines.append(" user:")
|
for entry in bottle.git:
|
||||||
if bottle.git_user.name:
|
lines.append(f" {entry.Name}:")
|
||||||
lines.append(f" name: {bottle.git_user.name}")
|
lines.append(f" url: {entry.Upstream}")
|
||||||
if bottle.git_user.email:
|
if entry.Forge:
|
||||||
lines.append(f" email: {bottle.git_user.email}")
|
lines.append(f" forge: {entry.Forge}")
|
||||||
if bottle.git:
|
|
||||||
lines.append(" repos:")
|
|
||||||
for entry in bottle.git:
|
|
||||||
lines.append(f" {entry.Name}:")
|
|
||||||
lines.append(f" url: {entry.Upstream}")
|
|
||||||
|
|
||||||
if bottle.egress.routes:
|
if bottle.egress.routes:
|
||||||
lines.append(" egress:")
|
lines.append(" egress:")
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ if TYPE_CHECKING:
|
|||||||
EGRESS_ROUTES_IN_CONTAINER,
|
EGRESS_ROUTES_IN_CONTAINER,
|
||||||
Egress,
|
Egress,
|
||||||
egress_agent_env_entries,
|
egress_agent_env_entries,
|
||||||
|
egress_forge_routes,
|
||||||
egress_gateway_env_entries,
|
egress_gateway_env_entries,
|
||||||
egress_manifest_routes,
|
egress_manifest_routes,
|
||||||
egress_render_routes,
|
egress_render_routes,
|
||||||
@@ -51,6 +52,7 @@ _LAZY: dict[str, str] = {
|
|||||||
"EGRESS_ROUTES_FILENAME": ".service",
|
"EGRESS_ROUTES_FILENAME": ".service",
|
||||||
"EGRESS_ROUTES_IN_CONTAINER": ".service",
|
"EGRESS_ROUTES_IN_CONTAINER": ".service",
|
||||||
"egress_agent_env_entries": ".service",
|
"egress_agent_env_entries": ".service",
|
||||||
|
"egress_forge_routes": ".service",
|
||||||
"egress_gateway_env_entries": ".service",
|
"egress_gateway_env_entries": ".service",
|
||||||
"egress_manifest_routes": ".service",
|
"egress_manifest_routes": ".service",
|
||||||
"egress_render_routes": ".service",
|
"egress_render_routes": ".service",
|
||||||
@@ -80,6 +82,7 @@ __all__ = [
|
|||||||
"Egress",
|
"Egress",
|
||||||
"EgressPlan",
|
"EgressPlan",
|
||||||
"EgressRoute",
|
"EgressRoute",
|
||||||
|
"egress_forge_routes",
|
||||||
"egress_manifest_routes",
|
"egress_manifest_routes",
|
||||||
"egress_render_routes",
|
"egress_render_routes",
|
||||||
"egress_resolve_token_values",
|
"egress_resolve_token_values",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from ..log import die
|
|||||||
from .plan import EgressPlan, EgressRoute
|
from .plan import EgressPlan, EgressRoute
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..manifest import ManifestBottle
|
from ..manifest import ManifestBottle, ResolvedForgeAssociation
|
||||||
|
|
||||||
|
|
||||||
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
|
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
|
||||||
@@ -119,15 +119,61 @@ def egress_manifest_routes(
|
|||||||
return tuple(out)
|
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
|
||||||
|
0082).
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
Composition (`resolve_forge_associations`) has already rejected referenced
|
||||||
|
aliases that share a host but disagree on origin/auth/token_secret, so this
|
||||||
|
host-dedup only ever collapses genuinely identical credentials — it never
|
||||||
|
silently discards a distinct one."""
|
||||||
|
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(
|
def egress_routes_for_bottle(
|
||||||
bottle: ManifestBottle,
|
bottle: ManifestBottle,
|
||||||
provider_routes: tuple[EgressRoute, ...] = (),
|
provider_routes: tuple[EgressRoute, ...] = (),
|
||||||
|
forge_routes: tuple[EgressRoute, ...] = (),
|
||||||
) -> tuple[EgressRoute, ...]:
|
) -> tuple[EgressRoute, ...]:
|
||||||
manifest = egress_manifest_routes(bottle)
|
manifest = egress_manifest_routes(bottle)
|
||||||
provisioned_hosts = {pr.host.lower() for pr in provider_routes}
|
# Provider routes (LLM API) default to redact-on-match; forge routes are
|
||||||
merged = list(_default_provider_on_match(provider_routes)) + [
|
# host-injected but keep the default DLP policy. Both take precedence over
|
||||||
r for r in manifest if r.host.lower() not in provisioned_hosts
|
# 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)
|
return _assign_token_slots(merged)
|
||||||
|
|
||||||
|
|
||||||
@@ -367,8 +413,9 @@ class Egress:
|
|||||||
slug: str,
|
slug: str,
|
||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
provider_routes: tuple[EgressRoute, ...] = (),
|
provider_routes: tuple[EgressRoute, ...] = (),
|
||||||
|
forge_routes: tuple[EgressRoute, ...] = (),
|
||||||
) -> EgressPlan:
|
) -> EgressPlan:
|
||||||
routes = egress_routes_for_bottle(bottle, provider_routes)
|
routes = egress_routes_for_bottle(bottle, provider_routes, forge_routes)
|
||||||
log = bottle.egress.Log
|
log = bottle.egress.Log
|
||||||
routes_path = stage_dir / EGRESS_ROUTES_FILENAME
|
routes_path = stage_dir / EGRESS_ROUTES_FILENAME
|
||||||
routes_path.write_text(egress_render_routes(routes, log=log))
|
routes_path.write_text(egress_render_routes(routes, log=log))
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""Manifest dataclasses (PRD 0011 layout).
|
"""Manifest dataclasses (PRD 0011 layout).
|
||||||
|
|
||||||
Reads the per-file manifest tree:
|
Reads the per-file manifest tree (home-only —
|
||||||
|
PRD 0082):
|
||||||
|
|
||||||
$HOME/.bot-bottle/bottles/<name>.md — one bottle per file
|
$HOME/.bot-bottle/bottles/<name>.md — one bottle per file
|
||||||
$HOME/.bot-bottle/agents/<name>.md — home-resident agents
|
$HOME/.bot-bottle/agents/<name>.md — agents
|
||||||
$CWD/.bot-bottle/agents/<name>.md — cwd-supplied agents
|
|
||||||
|
|
||||||
Each file is Markdown with YAML frontmatter. The frontmatter holds
|
Each file is Markdown with YAML frontmatter. The frontmatter holds
|
||||||
the structured config (see schema below); for agents the body is
|
the structured config (see schema below); for agents the body is
|
||||||
@@ -15,27 +15,38 @@ Bottle schema (frontmatter):
|
|||||||
extends: <bottle-name> # optional (PRD 0025)
|
extends: <bottle-name> # optional (PRD 0025)
|
||||||
env: { <NAME>: <env-entry>, ... }
|
env: { <NAME>: <env-entry>, ... }
|
||||||
git-gate: # optional (PRD 0047)
|
git-gate: # optional (PRD 0047)
|
||||||
user: { name: <str>, email: <str> } # optional
|
|
||||||
repos: { <name>: <git-gate-entry>, ... } # optional
|
repos: { <name>: <git-gate-entry>, ... } # optional
|
||||||
|
# git-gate-entry keys: url, key, host_key, forge
|
||||||
|
# `forge`: optional alias into the selected agent's forge-accounts
|
||||||
egress: { routes: [ <egress-route>, ... ] }
|
egress: { routes: [ <egress-route>, ... ] }
|
||||||
# route keys: host, matches, auth, role, dlp
|
# route keys: host, matches, auth, role, dlp
|
||||||
supervise: <bool> # optional (default true)
|
supervise: <bool> # optional (default true)
|
||||||
nested_containers: <bool> # optional (default false)
|
nested_containers: <bool> # optional (default false)
|
||||||
|
|
||||||
Agent schema (frontmatter):
|
Agent schema (frontmatter):
|
||||||
bottle: <bottle-name> # required
|
bottle: <bottle-name> # optional
|
||||||
skills: [ <skill-name>, ... ] # optional
|
skills: [ <skill-name>, ... ] # optional
|
||||||
git-gate:
|
author: # optional; agent git identity
|
||||||
user: { name: <str>, email: <str> } # optional; overlays bottle
|
name: <str> # required when author is present
|
||||||
|
email: <str> # required when author is present
|
||||||
|
forge-accounts: # optional; alias -> forge account
|
||||||
|
<alias>:
|
||||||
|
url: <https Gitea /api/v1 base>
|
||||||
|
auth: { type: token, token_secret: <host env var name> }
|
||||||
# Claude Code subagent passthrough fields — accepted, ignored:
|
# Claude Code subagent passthrough fields — accepted, ignored:
|
||||||
name, description, model, color, memory
|
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).
|
The agent file's Markdown body is the system prompt (stripped).
|
||||||
Unknown top-level frontmatter keys raise ManifestError with a hint.
|
Unknown top-level frontmatter keys raise ManifestError with a hint.
|
||||||
|
|
||||||
Bottles can ONLY live under $HOME. A bottles/ dir under $CWD is a
|
Both bottles and agents can ONLY live under $HOME. An agents/ or bottles/
|
||||||
warn at load time and contributes nothing. The trust boundary is
|
dir under $CWD is a warn at load time and contributes nothing. The trust
|
||||||
expressed as filesystem layout rather than resolver logic.
|
boundary is expressed as filesystem layout rather than resolver logic.
|
||||||
|
|
||||||
Two types are exported:
|
Two types are exported:
|
||||||
|
|
||||||
@@ -66,6 +77,11 @@ if TYPE_CHECKING:
|
|||||||
from .agent import ManifestAgent, ManifestAgentProvider
|
from .agent import ManifestAgent, ManifestAgentProvider
|
||||||
from .bottle import ManifestBottle
|
from .bottle import ManifestBottle
|
||||||
from .egress import EGRESS_AUTH_SCHEMES, ManifestEgressConfig, ManifestEgressRoute
|
from .egress import EGRESS_AUTH_SCHEMES, ManifestEgressConfig, ManifestEgressRoute
|
||||||
|
from .forge import (
|
||||||
|
ManifestAuthor,
|
||||||
|
ManifestForgeAccount,
|
||||||
|
ResolvedForgeAssociation,
|
||||||
|
)
|
||||||
from .git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig
|
from .git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig
|
||||||
|
|
||||||
|
|
||||||
@@ -81,6 +97,9 @@ _LAZY_MODULES: dict[str, str] = {
|
|||||||
"EGRESS_AUTH_SCHEMES": "egress",
|
"EGRESS_AUTH_SCHEMES": "egress",
|
||||||
"ManifestEgressRoute": "egress",
|
"ManifestEgressRoute": "egress",
|
||||||
"ManifestEgressConfig": "egress",
|
"ManifestEgressConfig": "egress",
|
||||||
|
"ManifestAuthor": "forge",
|
||||||
|
"ManifestForgeAccount": "forge",
|
||||||
|
"ResolvedForgeAssociation": "forge",
|
||||||
"ManifestGitEntry": "git",
|
"ManifestGitEntry": "git",
|
||||||
"ManifestGitUser": "git",
|
"ManifestGitUser": "git",
|
||||||
"ManifestKeyConfig": "git",
|
"ManifestKeyConfig": "git",
|
||||||
@@ -115,4 +134,7 @@ __all__ = [
|
|||||||
"EGRESS_AUTH_SCHEMES",
|
"EGRESS_AUTH_SCHEMES",
|
||||||
"ManifestEgressRoute",
|
"ManifestEgressRoute",
|
||||||
"ManifestEgressConfig",
|
"ManifestEgressConfig",
|
||||||
|
"ManifestAuthor",
|
||||||
|
"ManifestForgeAccount",
|
||||||
|
"ResolvedForgeAssociation",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import cast
|
from typing import Mapping, cast
|
||||||
|
|
||||||
from ..agent_provider import PROVIDER_TEMPLATES
|
from ..agent_provider import PROVIDER_TEMPLATES
|
||||||
from .util import ManifestError, as_json_object
|
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
|
from .schema import AGENT_MODEL_KEYS, is_valid_entity_name
|
||||||
|
|
||||||
|
|
||||||
@@ -119,15 +119,29 @@ class ManifestAgent:
|
|||||||
bottle: str = ""
|
bottle: str = ""
|
||||||
skills: tuple[str, ...] = ()
|
skills: tuple[str, ...] = ()
|
||||||
prompt: str = ""
|
prompt: str = ""
|
||||||
# Per-agent git identity (issue #94). Overlays the referenced
|
# Agent-owned identity (PRD 0082).
|
||||||
# bottle's git-gate.user per-field at `Manifest.bottle_for`. Only
|
# `author` populates the bottle's git user.name/user.email;
|
||||||
# `user` is allowed at the agent level; `repos` stays bottle-only
|
# `forge_accounts` maps a forge alias to a canonical Gitea API origin and
|
||||||
# because it carries credentials and host trust.
|
# a host token reference. Both live only on the agent — never under
|
||||||
git_user: ManifestGitUser = ManifestGitUser()
|
# `git-gate`, which is bottle-only transport policy.
|
||||||
|
author: ManifestAuthor | None = None
|
||||||
|
forge_accounts: Mapping[str, ManifestForgeAccount] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "ManifestAgent":
|
def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "ManifestAgent":
|
||||||
d = as_json_object(raw, f"agent '{name}'")
|
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 0082). "
|
||||||
|
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
|
unknown = set(d.keys()) - AGENT_MODEL_KEYS
|
||||||
if unknown:
|
if unknown:
|
||||||
allowed = ", ".join(sorted(AGENT_MODEL_KEYS))
|
allowed = ", ".join(sorted(AGENT_MODEL_KEYS))
|
||||||
@@ -191,24 +205,30 @@ class ManifestAgent:
|
|||||||
f"(was {type(prompt_raw).__name__})"
|
f"(was {type(prompt_raw).__name__})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# git-gate: agents may declare only `git-gate.user` (name/email).
|
# author: agent-owned git identity (optional; both fields required
|
||||||
# `git-gate.repos` is bottle-only — it carries credentials and host trust.
|
# when present). Populates the bottle's user.name/user.email.
|
||||||
git_user = ManifestGitUser()
|
author = (
|
||||||
git_raw = d.get("git-gate")
|
ManifestAuthor.from_dict(name, d["author"])
|
||||||
if git_raw is not None:
|
if "author" in d else 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"])
|
|
||||||
|
|
||||||
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(
|
def _parse_provider_settings(
|
||||||
|
|||||||
@@ -107,11 +107,14 @@ class ManifestBottle:
|
|||||||
)
|
)
|
||||||
env[var] = value
|
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 0082).
|
||||||
git: tuple[ManifestGitEntry, ...] = ()
|
git: tuple[ManifestGitEntry, ...] = ()
|
||||||
git_user = ManifestGitUser()
|
git_user = ManifestGitUser()
|
||||||
git_raw = d.get("git-gate")
|
git_raw = d.get("git-gate")
|
||||||
if git_raw is not None:
|
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 = (
|
agent_provider = (
|
||||||
ManifestAgentProvider.from_dict(name, d["agent_provider"])
|
ManifestAgentProvider.from_dict(name, d["agent_provider"])
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ def _fold_two_bottles(
|
|||||||
for n in names
|
for n in names
|
||||||
}
|
}
|
||||||
if merged_repos_raw:
|
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:
|
else:
|
||||||
merged_git = ()
|
merged_git = ()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
"""Agent-owned author identity and forge accounts (PRD 0082).
|
||||||
|
|
||||||
|
`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", ""))
|
||||||
|
|
||||||
|
associations = tuple(
|
||||||
|
ResolvedForgeAssociation(
|
||||||
|
account=forge_accounts[alias],
|
||||||
|
repo_names=tuple(sorted(set(names))),
|
||||||
|
)
|
||||||
|
for alias, names in sorted(by_alias.items())
|
||||||
|
)
|
||||||
|
|
||||||
|
# The egress proxy routes by host, so two referenced aliases that resolve
|
||||||
|
# to the same host must agree on the credential and target — otherwise the
|
||||||
|
# generated plan would authenticate every call to that host as whichever
|
||||||
|
# alias happened to win, silently acting as the wrong forge account. Fail
|
||||||
|
# closed here (before the bottle is created) rather than dropping a
|
||||||
|
# credential at route-synthesis time.
|
||||||
|
by_host: dict[str, ManifestForgeAccount] = {}
|
||||||
|
for assoc in associations:
|
||||||
|
acct = assoc.account
|
||||||
|
prev = by_host.get(acct.host)
|
||||||
|
if prev is None:
|
||||||
|
by_host[acct.host] = acct
|
||||||
|
continue
|
||||||
|
if (prev.origin, prev.api_prefix, prev.auth_type, prev.token_secret) != (
|
||||||
|
acct.origin, acct.api_prefix, acct.auth_type, acct.token_secret
|
||||||
|
):
|
||||||
|
raise ManifestError(
|
||||||
|
f"agent '{agent_name}' forge-accounts '{prev.alias}' and "
|
||||||
|
f"'{acct.alias}' both resolve to host '{acct.host}' but differ "
|
||||||
|
f"in origin/auth/token_secret. The egress proxy routes by host, "
|
||||||
|
f"so the intended credential would be ambiguous — every request "
|
||||||
|
f"to '{acct.host}' would authenticate as one account. Give the "
|
||||||
|
f"aliases distinct hosts, or make their url/auth identical."
|
||||||
|
)
|
||||||
|
|
||||||
|
return associations
|
||||||
|
|
||||||
|
|
||||||
|
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/<branch>`) 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"
|
||||||
+38
-12
@@ -117,6 +117,11 @@ class ManifestGitEntry:
|
|||||||
UpstreamHost: str = ""
|
UpstreamHost: str = ""
|
||||||
UpstreamPort: str = ""
|
UpstreamPort: str = ""
|
||||||
UpstreamPath: str = ""
|
UpstreamPath: str = ""
|
||||||
|
# Optional forge alias (PRD 0082). 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
|
@classmethod
|
||||||
def from_repos_entry(
|
def from_repos_entry(
|
||||||
@@ -139,10 +144,10 @@ class ManifestGitEntry:
|
|||||||
label = f"git-gate.repos[{repo_name!r}]"
|
label = f"git-gate.repos[{repo_name!r}]"
|
||||||
d = as_json_object(raw, f"bottle '{bottle_name}' {label}")
|
d = as_json_object(raw, f"bottle '{bottle_name}' {label}")
|
||||||
for k in d:
|
for k in d:
|
||||||
if k not in {"url", "key", "host_key"}:
|
if k not in {"url", "key", "host_key", "forge"}:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' {label} has unknown key {k!r}; "
|
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")
|
upstream = d.get("url")
|
||||||
if not isinstance(upstream, str) or not upstream:
|
if not isinstance(upstream, str) or not upstream:
|
||||||
@@ -150,6 +155,21 @@ class ManifestGitEntry:
|
|||||||
f"bottle '{bottle_name}' {label} missing required string field 'url'"
|
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:
|
if "key" not in d:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' {label} missing required 'key' block"
|
f"bottle '{bottle_name}' {label} missing required 'key' block"
|
||||||
@@ -176,6 +196,7 @@ class ManifestGitEntry:
|
|||||||
UpstreamHost=host,
|
UpstreamHost=host,
|
||||||
UpstreamPort=port,
|
UpstreamPort=port,
|
||||||
UpstreamPath=path,
|
UpstreamPath=path,
|
||||||
|
Forge=forge,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -286,21 +307,26 @@ class ManifestGitUser:
|
|||||||
def parse_git_gate_config(
|
def parse_git_gate_config(
|
||||||
bottle_name: str,
|
bottle_name: str,
|
||||||
raw: object,
|
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 0082)."""
|
||||||
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate")
|
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 0082). Move name/email into "
|
||||||
|
f"the selected home agent's 'author' block:\n"
|
||||||
|
f" author:\n name: <name>\n email: <email>\n"
|
||||||
|
f"Identity is agent-owned; git-gate now carries only transport "
|
||||||
|
f"policy (repos)."
|
||||||
|
)
|
||||||
for k in d:
|
for k in d:
|
||||||
if k not in {"user", "repos"}:
|
if k != "repos":
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' git-gate has unknown key {k!r}; "
|
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, ...] = ()
|
git: tuple[ManifestGitEntry, ...] = ()
|
||||||
repos_raw = d.get("repos")
|
repos_raw = d.get("repos")
|
||||||
if repos_raw is not None:
|
if repos_raw is not None:
|
||||||
@@ -311,4 +337,4 @@ def parse_git_gate_config(
|
|||||||
)
|
)
|
||||||
validate_unique_git_names(bottle_name, git)
|
validate_unique_git_names(bottle_name, git)
|
||||||
|
|
||||||
return git, git_user
|
return git
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from .util import ManifestError, as_json_object
|
|||||||
from .agent import ManifestAgent
|
from .agent import ManifestAgent
|
||||||
from .bottle import ManifestBottle
|
from .bottle import ManifestBottle
|
||||||
from .extends import merge_bottles_runtime, resolve_bottles
|
from .extends import merge_bottles_runtime, resolve_bottles
|
||||||
|
from .forge import ResolvedForgeAssociation, resolve_forge_associations
|
||||||
from .git import ManifestGitUser
|
from .git import ManifestGitUser
|
||||||
from .loader import (
|
from .loader import (
|
||||||
check_stale_json,
|
check_stale_json,
|
||||||
@@ -37,30 +38,50 @@ def _section_dict(value: object, label: str) -> dict[str, object]:
|
|||||||
return as_json_object(value, label)
|
return as_json_object(value, label)
|
||||||
|
|
||||||
|
|
||||||
def _merge_git_user(
|
def _warn_ignored_cwd_dir(cwd_dir: Path, kind: str, home_path: str) -> None:
|
||||||
agent_user: ManifestGitUser, base_user: ManifestGitUser
|
"""Warn (once) that manifest files of `kind` under `$CWD/.bot-bottle/`
|
||||||
) -> ManifestGitUser:
|
are ignored — the filesystem layout IS the trust boundary. `kind` is the
|
||||||
"""Merge the agent's git.user over the bottle's, agent-wins-on-non-empty."""
|
subdir name (`bottles`/`agents`); `home_path` is where they belong."""
|
||||||
if agent_user.is_empty():
|
stale = cwd_dir / kind
|
||||||
return base_user
|
if not stale.is_dir():
|
||||||
return ManifestGitUser(
|
return
|
||||||
name=agent_user.name or base_user.name,
|
files = sorted(stale.glob("*.md"))
|
||||||
email=agent_user.email or base_user.email,
|
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 0082). Move them or delete."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _manifest_with_merged_git_user(
|
def _compose_manifest(
|
||||||
agent: "ManifestAgent", raw_bottle: "ManifestBottle"
|
agent_name: str,
|
||||||
|
agent: "ManifestAgent",
|
||||||
|
raw_bottle: "ManifestBottle",
|
||||||
) -> "Manifest":
|
) -> "Manifest":
|
||||||
"""Build the single-value Manifest, overlaying the agent's git-gate.user
|
"""Build the single-value Manifest from the selected agent and its
|
||||||
onto the bottle (agent wins on non-empty, per-field). Shared by the eager
|
effective bottle (PRD 0082):
|
||||||
and lazy load_for_agent paths."""
|
|
||||||
merged = _merge_git_user(agent.git_user, raw_bottle.git_user)
|
- the agent's `author` populates the bottle's git user.name/user.email;
|
||||||
bottle = (
|
- each git-gate repo's `forge` alias is resolved against the agent's
|
||||||
raw_bottle if merged == raw_bottle.git_user
|
`forge-accounts` (failing closed on an unknown alias) into the
|
||||||
else replace(raw_bottle, git_user=merged)
|
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(
|
def _resolve_effective_bottle_eager(
|
||||||
@@ -121,26 +142,28 @@ def _resolve_effective_bottle_lazy(
|
|||||||
class Manifest:
|
class Manifest:
|
||||||
"""Single-agent/bottle value type. Returned by ManifestIndex.load_for_agent().
|
"""Single-agent/bottle value type. Returned by ManifestIndex.load_for_agent().
|
||||||
|
|
||||||
`bottle` is the effective bottle with the agent's git-gate.user already
|
`bottle` is the effective bottle with the agent's `author` already
|
||||||
overlaid per-field (agent wins on non-empty). Backends and provisioners
|
populated into its git identity. `forge_associations` holds the distinct
|
||||||
use this directly — no agent_name lookup needed."""
|
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
|
agent: ManifestAgent
|
||||||
bottle: ManifestBottle
|
bottle: ManifestBottle
|
||||||
|
forge_associations: tuple[ResolvedForgeAssociation, ...] = ()
|
||||||
|
|
||||||
def git_identity_summary(self) -> str | None:
|
def git_identity_summary(self) -> str | None:
|
||||||
"""One-line effective git identity with per-field provenance, e.g.
|
"""One-line effective git identity, e.g.
|
||||||
`name=claude (agent), email=eric@dideric.is (bottle)`.
|
`name=claude, email=eric@dideric.is`. Sourced from the agent's
|
||||||
Returns None when neither agent nor bottle sets an identity."""
|
`author` block. Returns None when the agent declares no author."""
|
||||||
over = self.agent.git_user # agent's declared git_user (pre-merge)
|
gu = self.bottle.git_user
|
||||||
merged = self.bottle.git_user # effective git_user (post-merge)
|
if gu.is_empty():
|
||||||
if merged.is_empty():
|
|
||||||
return None
|
return None
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
if merged.name:
|
if gu.name:
|
||||||
parts.append(f"name={merged.name} ({'agent' if over.name else 'bottle'})")
|
parts.append(f"name={gu.name}")
|
||||||
if merged.email:
|
if gu.email:
|
||||||
parts.append(f"email={merged.email} ({'agent' if over.email else 'bottle'})")
|
parts.append(f"email={gu.email}")
|
||||||
return ", ".join(parts)
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
@@ -164,15 +187,15 @@ class ManifestIndex:
|
|||||||
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "ManifestIndex":
|
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "ManifestIndex":
|
||||||
"""Walk the per-file manifest tree and build a ManifestIndex.
|
"""Walk the per-file manifest tree and build a ManifestIndex.
|
||||||
|
|
||||||
Layout (PRD 0011):
|
Layout:
|
||||||
$HOME/.bot-bottle/bottles/<name>.md — bottles (home-only)
|
$HOME/.bot-bottle/bottles/<name>.md — bottles (home-only)
|
||||||
$HOME/.bot-bottle/agents/<name>.md — home agents
|
$HOME/.bot-bottle/agents/<name>.md — agents (home-only)
|
||||||
$CWD/.bot-bottle/agents/<name>.md — cwd agents
|
|
||||||
|
|
||||||
Cwd agents merge into the home agents on the same name
|
Both agents and bottles are home-only
|
||||||
(cwd wins). A bottles/ subdir under $CWD is logged as a
|
(PRD 0082): a `bottles/` or `agents/`
|
||||||
warning and ignored — the filesystem layout IS the trust
|
subdir under $CWD is logged as a warning and ignored — the filesystem
|
||||||
boundary.
|
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/`
|
If `missing_ok` is true, a missing `$HOME/.bot-bottle/`
|
||||||
returns an empty index instead of dying. This is for
|
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
|
Used by tests to build a ManifestIndex from fixture directories
|
||||||
without touching `os.environ`."""
|
without touching `os.environ`."""
|
||||||
if cwd_dir is not None:
|
if cwd_dir is not None:
|
||||||
stale_bottles = cwd_dir / "bottles"
|
_warn_ignored_cwd_dir(cwd_dir, "bottles", "$HOME/.bot-bottle/bottles/")
|
||||||
if stale_bottles.is_dir():
|
# Agents became home-only in
|
||||||
files = sorted(stale_bottles.glob("*.md"))
|
# PRD 0082: a cwd agent file that
|
||||||
if files:
|
# once shadowed a home agent could select a host identity/secret,
|
||||||
names = ", ".join(p.name for p in files)
|
# so it is now ignored with a migration pointer.
|
||||||
warn(
|
_warn_ignored_cwd_dir(cwd_dir, "agents", "$HOME/.bot-bottle/agents/")
|
||||||
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."
|
|
||||||
)
|
|
||||||
return cls(bottles={}, agents={}, home_md=home_dir, cwd_md=cwd_dir)
|
return cls(bottles={}, agents={}, home_md=home_dir, cwd_md=cwd_dir)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -275,13 +293,12 @@ class ManifestIndex:
|
|||||||
|
|
||||||
In names-only mode (from resolve/from_md_dirs) this scans agent
|
In names-only mode (from resolve/from_md_dirs) this scans agent
|
||||||
filenames without reading their content. In eager mode (from
|
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 0082): cwd
|
||||||
|
agent files never contribute names."""
|
||||||
if self.home_md is not None:
|
if self.home_md is not None:
|
||||||
home_names = set(scan_agent_names(self.home_md / "agents").keys())
|
return sorted(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(self.agents.keys())
|
return sorted(self.agents.keys())
|
||||||
|
|
||||||
def load_for_agent(
|
def load_for_agent(
|
||||||
@@ -326,7 +343,7 @@ class ManifestIndex:
|
|||||||
raw_bottle = _resolve_effective_bottle_eager(
|
raw_bottle = _resolve_effective_bottle_eager(
|
||||||
agent_name, agent, bottle_names, self.bottles
|
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(
|
def _load_for_agent_lazy(
|
||||||
self, agent_name: str, bottle_names: tuple[str, ...]
|
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
|
"""Lazy path (resolve/from_md_dirs): read and parse the agent file and
|
||||||
its bottle chain from disk for the first time here."""
|
its bottle chain from disk for the first time here."""
|
||||||
assert self.home_md is not None # guaranteed by load_for_agent dispatch
|
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 0082):
|
||||||
|
# a cwd agent file must not select a host identity or forge secret.
|
||||||
home_agents = scan_agent_names(self.home_md / "agents")
|
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:
|
if agent_name not in home_agents:
|
||||||
available = ", ".join(sorted(merged_agents.keys())) or "(none)"
|
available = ", ".join(sorted(home_agents.keys())) or "(none)"
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"agent '{agent_name}' not defined. Available: {available}"
|
f"agent '{agent_name}' not defined. Available: {available}"
|
||||||
)
|
)
|
||||||
|
|
||||||
agent_path = merged_agents[agent_name]
|
agent_path = home_agents[agent_name]
|
||||||
try:
|
try:
|
||||||
fm, body = parse_frontmatter(agent_path.read_text())
|
fm, body = parse_frontmatter(agent_path.read_text())
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
@@ -374,15 +388,18 @@ class ManifestIndex:
|
|||||||
}
|
}
|
||||||
if agent_bottle:
|
if agent_bottle:
|
||||||
agent_dict["bottle"] = agent_bottle
|
agent_dict["bottle"] = agent_bottle
|
||||||
if "git-gate" in fm:
|
# Surface agent-owned identity keys (and any stale git-gate, so
|
||||||
agent_dict["git-gate"] = fm["git-gate"]
|
# 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
|
# Pass the effective bottle name as the known-bottles set so agents
|
||||||
# that have bottle: set are validated; agents without bottle: pass {}
|
# that have bottle: set are validated; agents without bottle: pass {}
|
||||||
# since bottle_names were already resolved above.
|
# since bottle_names were already resolved above.
|
||||||
known = {effective_bottle_name} if effective_bottle_name else set()
|
known = {effective_bottle_name} if effective_bottle_name else set()
|
||||||
agent = ManifestAgent.from_dict(agent_name, agent_dict, known)
|
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:
|
def has_agent(self, name: str) -> bool:
|
||||||
return name in self.agents
|
return name in self.agents
|
||||||
@@ -394,13 +411,9 @@ class ManifestIndex:
|
|||||||
if self.has_agent(name):
|
if self.has_agent(name):
|
||||||
return
|
return
|
||||||
if self.home_md is not None:
|
if self.home_md is not None:
|
||||||
# Names-only mode: check file existence without parsing.
|
# Names-only mode: check home file existence without parsing.
|
||||||
home_path = self.home_md / "agents" / f"{name}.md"
|
# Agents are home-only; a cwd agent file is never selectable.
|
||||||
cwd_path = (
|
if (self.home_md / "agents" / f"{name}.md").is_file():
|
||||||
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()):
|
|
||||||
return
|
return
|
||||||
available = ", ".join(self.all_agent_names) or "(none)"
|
available = ", ".join(self.all_agent_names) or "(none)"
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ BOTTLE_KEYS = frozenset(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
AGENT_KEYS_REQUIRED: frozenset[str] = frozenset()
|
AGENT_KEYS_REQUIRED: frozenset[str] = frozenset()
|
||||||
AGENT_KEYS_OPTIONAL = frozenset({"bottle", "skills", "git-gate"})
|
# `author` / `forge-accounts` are agent-owned identity (PRD
|
||||||
|
# 0082). `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
|
# Claude Code subagent fields bot-bottle ignores at launch but does
|
||||||
# not reject. This lets the same file double as
|
# not reject. This lets the same file double as
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
# PRD 0011: Per-file Markdown manifest
|
# PRD 0011: Per-file Markdown manifest
|
||||||
|
|
||||||
- **Status:** Active
|
- **Status:** Active (agent cwd-discovery superseded)
|
||||||
- **Author:** didericis
|
- **Author:** didericis
|
||||||
- **Created:** 2026-05-24
|
- **Created:** 2026-05-24
|
||||||
|
|
||||||
|
> **Superseded in part by PRD 0082.**
|
||||||
|
> The `$CWD/.bot-bottle/agents/<name>.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
|
## Summary
|
||||||
|
|
||||||
Replace the single-file `bot-bottle.json` manifest with a
|
Replace the single-file `bot-bottle.json` manifest with a
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
# PRD 0082: Trusted agent forge identity and guidance
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Author:** didericis-claude
|
||||||
|
- **Created:** 2026-07-25
|
||||||
|
- **Issue:** #423
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Move author identity and named forge configurations into host-trusted agent
|
||||||
|
definitions. A bottle may optionally associate a git-gate repository with one
|
||||||
|
of the selected agent's forge aliases. When associated, bot-bottle gives the
|
||||||
|
agent non-secret, provider-specific system guidance for that repository and
|
||||||
|
routes authenticated forge API calls through the egress proxy without exposing
|
||||||
|
the forge token to the bottle. The authenticated account is inferred from the
|
||||||
|
identity that owns that token; it is not separately declared in the manifest.
|
||||||
|
|
||||||
|
Repository-local agent and bottle definitions are no longer discovered. Once
|
||||||
|
agent definitions can select a host forge credential, allowing checked-out
|
||||||
|
repository content to define or override an agent would let untrusted workspace
|
||||||
|
content select host identities and secrets.
|
||||||
|
|
||||||
|
This PRD is deliberately limited to identity ownership, manifest trust, forge
|
||||||
|
API access, and generated guidance. Per-activation signing, signature
|
||||||
|
enforcement, commit attribution, and the commit audit model move to a follow-up
|
||||||
|
PRD.
|
||||||
|
|
||||||
|
Successor to:
|
||||||
|
|
||||||
|
- **PRD 0011 (per-file manifests)** — allowed repository-local agent files to
|
||||||
|
override home agents. This PRD removes that trust path: agents and bottles are
|
||||||
|
loaded only from the host-owned `~/.bot-bottle` tree.
|
||||||
|
- **PRD 0027 (agent git identity, #94)** / **ADR 0002** — put name/email in
|
||||||
|
`git-gate.user` while keeping them claimed rather than vouched. This PRD moves
|
||||||
|
those agent properties out of git-gate and into the trusted agent definition.
|
||||||
|
- **PRD 0048 (deploy-key provisioning, #169)** — remains the Git push
|
||||||
|
capability. A forge actor token is a separate API credential and never
|
||||||
|
replaces a deploy key.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
### Forge workflow context is missing
|
||||||
|
|
||||||
|
The agent prompt does not know which forge backs a git-gate repository, which
|
||||||
|
API base URL to use, or that authenticated requests must go through the egress
|
||||||
|
proxy. Bespoke prompt text has drifted between agents. Missing guidance has
|
||||||
|
already caused incorrect PR behavior, including attempts to use Gitea AGit
|
||||||
|
review refs instead of a normal branch-backed pull request.
|
||||||
|
|
||||||
|
### Identity is owned by the wrong layer
|
||||||
|
|
||||||
|
`git-gate.user` puts an agent property on a repository transport component. An
|
||||||
|
author identity and set of forge credentials should follow the agent across
|
||||||
|
bottles and repositories. Git-gate should own Git transport policy, not decide
|
||||||
|
who the agent is.
|
||||||
|
|
||||||
|
### Repository-local agents become a credential-selection path
|
||||||
|
|
||||||
|
Today `$CWD/.bot-bottle/agents/*.md` can define new agents and override
|
||||||
|
home-resident agents. If an agent definition may reference an operator-provided
|
||||||
|
forge token, a malicious repository could select a host credential merely by
|
||||||
|
being the current workspace. Repository-local bottles are already ignored;
|
||||||
|
agents need the same host-only boundary.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- **Agent-owned identity.** Author name/email and named forge configurations
|
||||||
|
live on the agent definition, not under `git-gate`.
|
||||||
|
- **Trusted definitions only.** Agent and bottle files are discovered only
|
||||||
|
under `~/.bot-bottle/{agents,bottles}`. Repository-local definitions never
|
||||||
|
contribute names, defaults, or overrides.
|
||||||
|
- **Optional repository association.** A bottle repository may name one forge
|
||||||
|
alias from the selected agent. Repositories without `forge` retain current
|
||||||
|
behavior and generate no forge guidance or credential route.
|
||||||
|
- **Proxy-held forge credential.** The host resolves the selected forge
|
||||||
|
alias's token reference and gives the value only to the egress proxy. The
|
||||||
|
bottle receives neither the token nor a credential file containing it.
|
||||||
|
- **Forge-aware system guidance.** For associated repositories, bot-bottle
|
||||||
|
generates provider-specific instructions describing the API base URL,
|
||||||
|
repository/account association, proxy-authenticated access, and correct PR
|
||||||
|
workflow.
|
||||||
|
- **No secret prompt material.** Neither token values nor token
|
||||||
|
environment-variable names appear in the prompt or bottle environment.
|
||||||
|
- **Fail closed.** Unknown account references, unsupported/non-HTTPS URLs,
|
||||||
|
missing host secrets, and misplaced agent/bottle fields fail before creating
|
||||||
|
the bottle.
|
||||||
|
- **Push capability unchanged.** Git transport remains PRD 0048 deploy keys.
|
||||||
|
The forge actor token is only for API actions such as opening, reviewing, and
|
||||||
|
commenting on pull requests.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- **Commit signing or signature enforcement.** No signing key is minted and
|
||||||
|
git-gate does not verify commit signatures in this PRD.
|
||||||
|
- **Commit attribution or audit tables.** There is no trustworthy commit
|
||||||
|
observation event in this slice. A follow-up signing PRD owns activation keys,
|
||||||
|
control-plane verification, and any configured-author-versus-claimed-author
|
||||||
|
audit model.
|
||||||
|
- **Cryptographically vouched author identity.** `author` configures Git and
|
||||||
|
identifies the agent actor, but commit author/committer fields remain claims
|
||||||
|
under ADR 0002.
|
||||||
|
- **Forge account or token minting.** The operator creates the agent-specific
|
||||||
|
account/token out of band. Bot-bottle references an existing host secret; it
|
||||||
|
does not create, rotate, or revoke that credential.
|
||||||
|
- **Forge-side commit attribution surfaces.** No commit status, signing-key
|
||||||
|
registration, or "Verified" badge.
|
||||||
|
- **Provider-generic arbitrary prompts.** Gitea is the first supported forge.
|
||||||
|
A future provider adds typed validation and generated guidance in code rather
|
||||||
|
than accepting repository-supplied prompt text.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Ownership model
|
||||||
|
|
||||||
|
The resolved bottled agent is an agent definition composed with a bottle:
|
||||||
|
|
||||||
|
| Part | Source | Role |
|
||||||
|
|------|--------|------|
|
||||||
|
| Author identity | agent `author` | configures Git name/email and identifies the agent's claimed author |
|
||||||
|
| Forge configurations | agent `forge-accounts` | maps forge aliases to API origins and host token references available to this agent; token ownership determines account identity |
|
||||||
|
| Git repository | bottle `git-gate.repos` | configures Git transport, host verification, and push capability |
|
||||||
|
| Repository/forge association | bottle repo `forge` | optionally selects an agent forge alias for guidance and API access |
|
||||||
|
|
||||||
|
This boundary keeps identity on the agent, capability/policy on the bottle, and
|
||||||
|
transport enforcement in git-gate.
|
||||||
|
|
||||||
|
### Agent manifest
|
||||||
|
|
||||||
|
Agent identity and forge accounts live only in
|
||||||
|
`~/.bot-bottle/agents/<name>.md`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
author:
|
||||||
|
name: didericis-claude
|
||||||
|
email: eric+claude@dideric.is
|
||||||
|
forge-accounts:
|
||||||
|
didericis-gitea:
|
||||||
|
auth:
|
||||||
|
type: token
|
||||||
|
token_secret: GITEA_CLAUDE_TOKEN
|
||||||
|
url: https://gitea.dideric.is/api/v1
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
`author` contains:
|
||||||
|
|
||||||
|
- `name`: non-empty string;
|
||||||
|
- `email`: non-empty string passing the existing Git identity validation.
|
||||||
|
|
||||||
|
The resolved values populate `user.name` and `user.email`. Existing
|
||||||
|
`git-gate.user` fields fail with migration guidance to move the values into the
|
||||||
|
selected home agent's `author` block. There is no compatibility period where a
|
||||||
|
bottle identity silently overrides the agent identity.
|
||||||
|
|
||||||
|
`forge-accounts` is a map whose keys are **forge aliases** and follow the
|
||||||
|
manifest's existing kebab-case identifier grammar
|
||||||
|
(`[a-z][a-z0-9-]*`). Each forge entry contains:
|
||||||
|
|
||||||
|
- `url`: a canonical HTTPS Gitea API base URL;
|
||||||
|
- `auth.type`: `token` in this slice;
|
||||||
|
- `auth.token_secret`: the name of a host environment variable containing the
|
||||||
|
operator-provided, agent-specific API token.
|
||||||
|
|
||||||
|
The token's owner determines the authenticated forge account; there is no
|
||||||
|
separate account-name field. The token secret name is host configuration, not
|
||||||
|
bottle configuration. The token value is resolved only if a selected bottle
|
||||||
|
repository references the forge alias.
|
||||||
|
|
||||||
|
### Bottle manifest
|
||||||
|
|
||||||
|
Repository policy remains in `~/.bot-bottle/bottles/<name>.md`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
git-gate:
|
||||||
|
repos:
|
||||||
|
bot-bottle:
|
||||||
|
url: ssh://git@100.78.141.42:30009/didericis/bot-bottle.git
|
||||||
|
provisioned_key:
|
||||||
|
provider: gitea
|
||||||
|
token_env: GITEA_DEPLOY_TOKEN
|
||||||
|
host_key: "ssh-ed25519 AAAA..."
|
||||||
|
forge: didericis-gitea
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
`git-gate.repos.<name>.forge` is optional:
|
||||||
|
|
||||||
|
- When absent, the repository behaves exactly as it does today. Bot-bottle does
|
||||||
|
not resolve a forge actor token and does not add forge-specific instructions
|
||||||
|
for that repository.
|
||||||
|
- When present, it must match a forge alias in `forge-accounts` on the selected
|
||||||
|
agent.
|
||||||
|
The resolved association enables a scoped proxy credential route and adds the
|
||||||
|
repository/forge relationship to generated system guidance.
|
||||||
|
|
||||||
|
`provisioned_key.token_env` remains the deploy-key administration credential
|
||||||
|
from PRD 0048. It is separate from `forge-accounts.*.auth.token_secret`: the
|
||||||
|
former provisions Git push capability, while the latter performs API actions as
|
||||||
|
the agent.
|
||||||
|
|
||||||
|
`author` and `forge-accounts` are agent-only. `git-gate.repos`, including
|
||||||
|
`forge`, is bottle-only. Validation errors point to the correct file and trust
|
||||||
|
domain rather than ignoring misplaced keys.
|
||||||
|
|
||||||
|
### Definition trust and discovery
|
||||||
|
|
||||||
|
Only the host-owned manifest tree is authoritative:
|
||||||
|
|
||||||
|
- Agents: `~/.bot-bottle/agents/*.md`
|
||||||
|
- Bottles: `~/.bot-bottle/bottles/*.md`
|
||||||
|
|
||||||
|
`$CWD/.bot-bottle/agents/*.md` no longer contributes new agents and no longer
|
||||||
|
overrides a home agent. `$CWD/.bot-bottle/bottles/*.md` remains unusable. If
|
||||||
|
either repository-local directory contains manifest files, bot-bottle emits a
|
||||||
|
warning that they are ignored and points to the corresponding home path.
|
||||||
|
|
||||||
|
Every discovery and resolution surface uses the same home-only index:
|
||||||
|
|
||||||
|
- agent enumeration and selectors;
|
||||||
|
- `require_agent`;
|
||||||
|
- lazy `load_for_agent`;
|
||||||
|
- default-agent/default-bottle resolution;
|
||||||
|
- dashboard and headless launch paths.
|
||||||
|
|
||||||
|
There must be no alternate direct-path load that can still select a workspace
|
||||||
|
definition.
|
||||||
|
|
||||||
|
Workspace instructions remain repository content (for example `AGENTS.md`),
|
||||||
|
but runtime policy, host secret references, and actor identity do not.
|
||||||
|
Programmatic in-memory manifests remain available for tests and trusted internal
|
||||||
|
composition; they are not a filesystem discovery path.
|
||||||
|
|
||||||
|
### Forge URL validation
|
||||||
|
|
||||||
|
The host parses and canonicalizes each referenced forge alias's URL before
|
||||||
|
creating any runtime resources:
|
||||||
|
|
||||||
|
- scheme must be `https`;
|
||||||
|
- userinfo, query, and fragment are forbidden;
|
||||||
|
- hostname must be present;
|
||||||
|
- the path must be a supported Gitea API base (initially `/api/v1`, with
|
||||||
|
normalization of a trailing slash);
|
||||||
|
- visually different inputs that canonicalize to the same origin/prefix are
|
||||||
|
deduplicated;
|
||||||
|
- unsupported providers or path shapes fail closed.
|
||||||
|
|
||||||
|
The provider is determined by typed support in bot-bottle, not by prompt text
|
||||||
|
from a repository. Adding another provider requires a validator, auth scheme,
|
||||||
|
and guidance renderer.
|
||||||
|
|
||||||
|
### Proxy credential provisioning
|
||||||
|
|
||||||
|
For each distinct forge alias referenced by at least one selected bottle
|
||||||
|
repository, the host:
|
||||||
|
|
||||||
|
1. Resolves `auth.token_secret` from the host environment and rejects a missing
|
||||||
|
or empty value.
|
||||||
|
2. Copies the token value only into the egress proxy's credential environment.
|
||||||
|
3. Adds an inspected route scoped to the canonical forge origin and API prefix.
|
||||||
|
4. Configures the provider authentication scheme (`token` for Gitea) so the
|
||||||
|
proxy injects authentication.
|
||||||
|
|
||||||
|
The bottle makes an unauthenticated request to the configured HTTPS API URL.
|
||||||
|
The token is not copied into the bottle, `.gitconfig`, generated prompt,
|
||||||
|
workspace, or process environment visible to the agent.
|
||||||
|
|
||||||
|
If several repositories reference the same forge alias, they share one
|
||||||
|
credential route. Unreferenced forge aliases resolve no secret and create no
|
||||||
|
route.
|
||||||
|
|
||||||
|
### Generated system guidance
|
||||||
|
|
||||||
|
Bot-bottle appends a generated, non-secret section to its existing system
|
||||||
|
prompt file. It is derived from validated typed fields, not copied Markdown from
|
||||||
|
a repository.
|
||||||
|
|
||||||
|
For each associated repository, Gitea guidance includes:
|
||||||
|
|
||||||
|
- forge alias (for example `didericis-gitea`);
|
||||||
|
- API base URL (for example `https://gitea.dideric.is/api/v1`);
|
||||||
|
- the git-gate repository name tied to that forge;
|
||||||
|
- the instruction to call the configured HTTPS API through the proxy without
|
||||||
|
reading, printing, or manually attaching an authorization token;
|
||||||
|
- the distinction that Git pushes still use the git-gate remote;
|
||||||
|
- the requirement to create/update a normal `refs/heads/<branch>` and open a
|
||||||
|
branch-backed pull request through the API;
|
||||||
|
- the prohibition on pushing `refs/for/*`, `refs/draft/*`, or
|
||||||
|
`refs/for-review/*`;
|
||||||
|
- the instruction to use the API for reviews/comments and verify returned
|
||||||
|
object state before claiming completion.
|
||||||
|
|
||||||
|
The prompt contains neither the token value nor its `token_secret` name.
|
||||||
|
Repositories without `forge` are omitted from this section. If no selected
|
||||||
|
repository has a forge association, no forge guidance section is generated.
|
||||||
|
|
||||||
|
### Failure and lifecycle behavior
|
||||||
|
|
||||||
|
Forge configuration is validated before bottle creation. A bad association or
|
||||||
|
credential must not leave a partially-created bottle or proxy.
|
||||||
|
|
||||||
|
The operator-owned token is not minted, rotated, or revoked by bot-bottle. On
|
||||||
|
teardown, stopping the egress proxy discards the activation's in-memory/runtime
|
||||||
|
copy. Later activations resolve the current host secret again.
|
||||||
|
|
||||||
|
Logs may include the forge alias, canonical API origin, and repository name.
|
||||||
|
They must never contain the token value. Errors for missing secrets name the
|
||||||
|
configuration field and host environment variable, but do not print any value.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
This change is intentionally breaking at the manifest trust boundary:
|
||||||
|
|
||||||
|
1. Move each home bottle/agent `git-gate.user.name` and `.email` into the
|
||||||
|
corresponding home agent's `author`.
|
||||||
|
2. Add agent-specific `forge-accounts` only to home agent definitions.
|
||||||
|
3. Add optional `forge` associations to home bottle repository entries.
|
||||||
|
4. Move any `$CWD/.bot-bottle/agents/*.md` that should remain selectable into
|
||||||
|
`~/.bot-bottle/agents/`. Repository copies are ignored thereafter.
|
||||||
|
5. Keep repository-specific behavioral instructions in `AGENTS.md` or another
|
||||||
|
workspace instruction file; do not put runtime identity or secret references
|
||||||
|
there.
|
||||||
|
|
||||||
|
Errors and warnings link to this migration rather than silently changing which
|
||||||
|
identity or definition is active.
|
||||||
|
|
||||||
|
## Follow-up: signed commits and attribution
|
||||||
|
|
||||||
|
A separate PRD will consume the trusted agent `author` introduced here and own:
|
||||||
|
|
||||||
|
- per-activation signing key minting and sidecar isolation;
|
||||||
|
- commit-time signing through a forwarded signing capability;
|
||||||
|
- git-gate rejection of unsigned/wrong-key new commits;
|
||||||
|
- independent control-plane object-ID recomputation and signature verification;
|
||||||
|
- activation and attributed-commit audit tables;
|
||||||
|
- storage of configured agent author separately from each commit's unenforced
|
||||||
|
claimed author/committer;
|
||||||
|
- post-teardown verification and key-retention policy.
|
||||||
|
|
||||||
|
That PRD must not reintroduce identity under git-gate or expand repository-local
|
||||||
|
manifest trust.
|
||||||
|
|
||||||
|
## Implementation chunks
|
||||||
|
|
||||||
|
1. **This PRD.** Establish the identity, forge, and filesystem trust boundary.
|
||||||
|
2. **Home-only definitions.** Remove cwd agents from discovery, override,
|
||||||
|
enumeration, lazy loading, defaults, and selectors. Warn on ignored cwd
|
||||||
|
agent/bottle files and provide migration guidance.
|
||||||
|
3. **Manifest schema.** Add agent-only `author` and `forge-accounts`; remove
|
||||||
|
`git-gate.user`; add optional bottle-only
|
||||||
|
`git-gate.repos.<name>.forge`. Validate account names, composition
|
||||||
|
references, field placement, and Gitea API URLs.
|
||||||
|
4. **Proxy provisioning.** Lazily resolve only referenced token secrets and
|
||||||
|
create scoped authenticated Gitea API routes without putting credentials in
|
||||||
|
the bottle.
|
||||||
|
5. **Prompt generation.** Render typed Gitea/repository workflow guidance into
|
||||||
|
the existing bot-bottle prompt path for associated repositories only.
|
||||||
|
6. **Docs and migration.** Update README examples, PRD 0011-facing discovery
|
||||||
|
documentation, agent/bottle schema docs, and error guidance.
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
- **Trust boundary:** cwd agent files are ignored with a warning, cannot
|
||||||
|
override a home agent, are absent from enumeration/selectors/defaults, and
|
||||||
|
cannot be loaded by name. Cwd bottle behavior remains home-only.
|
||||||
|
- **Agent schema:** `author` and `forge-accounts` parse; malformed identities,
|
||||||
|
non-kebab account names, unknown fields, invalid auth types, and misplaced
|
||||||
|
git-gate fields fail clearly.
|
||||||
|
- **Bottle schema:** `forge` is optional; absent associations preserve current
|
||||||
|
behavior; present associations resolve against the selected agent; unknown or
|
||||||
|
misplaced associations fail before launch.
|
||||||
|
- **URL validation:** HTTPS Gitea API bases pass and canonicalize; HTTP,
|
||||||
|
userinfo, query, fragment, missing host, unsupported paths, and unsupported
|
||||||
|
providers fail closed.
|
||||||
|
- **Secret handling:** only referenced accounts resolve environment secrets;
|
||||||
|
missing/empty secrets fail before runtime creation; token values are absent
|
||||||
|
from bottle env, prompt, generated config, logs, and workspace; token secret
|
||||||
|
names are absent from the bottle and prompt.
|
||||||
|
- **Proxy behavior:** associated API requests receive proxy-injected Gitea
|
||||||
|
authentication scoped to the configured origin/prefix; unrelated hosts and
|
||||||
|
paths receive no credential.
|
||||||
|
- **Prompt behavior:** guidance names account/API/repository associations,
|
||||||
|
branch-backed PR workflow, prohibited AGit refs, and mutation verification;
|
||||||
|
repositories without `forge` are omitted; no associations means no section.
|
||||||
|
- **Migration:** legacy `git-gate.user` fails with an `author` migration pointer;
|
||||||
|
ignored cwd definitions warn with the target home path.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- None.
|
||||||
@@ -5,10 +5,9 @@ model: opus
|
|||||||
bottle: dev
|
bottle: dev
|
||||||
skills:
|
skills:
|
||||||
- init-prd
|
- init-prd
|
||||||
git-gate:
|
author:
|
||||||
user:
|
name: implementer-bot
|
||||||
name: implementer-bot
|
email: eric+implementer@dideric.is
|
||||||
email: eric+implementer@dideric.is
|
|
||||||
---
|
---
|
||||||
|
|
||||||
You are a feature-implementation agent running inside an ephemeral
|
You are a feature-implementation agent running inside an ephemeral
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ bot_bottle/manifest/agent.py
|
|||||||
bot_bottle/manifest/bottle.py
|
bot_bottle/manifest/bottle.py
|
||||||
bot_bottle/manifest/egress.py
|
bot_bottle/manifest/egress.py
|
||||||
bot_bottle/manifest/extends.py
|
bot_bottle/manifest/extends.py
|
||||||
|
bot_bottle/manifest/forge.py
|
||||||
bot_bottle/manifest/git.py
|
bot_bottle/manifest/git.py
|
||||||
bot_bottle/manifest/index.py
|
bot_bottle/manifest/index.py
|
||||||
bot_bottle/manifest/loader.py
|
bot_bottle/manifest/loader.py
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
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.backend.docker.bottle_plan import DockerBottlePlan
|
||||||
from bot_bottle.egress import EgressPlan
|
from bot_bottle.egress import EgressPlan
|
||||||
from bot_bottle.git_gate import GitGatePlan
|
from bot_bottle.git_gate import GitGatePlan
|
||||||
from bot_bottle.manifest import ManifestIndex
|
from bot_bottle.manifest import ManifestGitUser, ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
class _Provider(AgentProvider):
|
class _Provider(AgentProvider):
|
||||||
@@ -50,15 +51,39 @@ def _plan(*, git_user: dict | None = None, # type: ignore
|
|||||||
user_cwd: str = "/tmp/x",
|
user_cwd: str = "/tmp/x",
|
||||||
stage_dir: Path | None = None) -> DockerBottlePlan:
|
stage_dir: Path | None = None) -> DockerBottlePlan:
|
||||||
bottle_json: dict = {} # type: ignore
|
bottle_json: dict = {} # type: ignore
|
||||||
if git_user is not None:
|
|
||||||
bottle_json["git-gate"] = {"user": git_user}
|
|
||||||
if git_repos is not None:
|
if git_repos is not None:
|
||||||
bottle_json.setdefault("git-gate", {})["repos"] = git_repos
|
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({
|
index = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": bottle_json},
|
"bottles": {"dev": bottle_json},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": agent_json},
|
||||||
})
|
})
|
||||||
manifest = index.load_for_agent("demo")
|
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(
|
spec = BottleSpec(
|
||||||
manifest=index, agent_name="demo",
|
manifest=index, agent_name="demo",
|
||||||
copy_cwd=copy_cwd, user_cwd=user_cwd,
|
copy_cwd=copy_cwd, user_cwd=user_cwd,
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
"""Unit: trusted agent forge identity & guidance
|
||||||
|
(PRD 0082).
|
||||||
|
|
||||||
|
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 typing import Callable
|
||||||
|
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[str, object]:
|
||||||
|
entry: dict[str, object] = {
|
||||||
|
"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: dict[str, object] | None = None,
|
||||||
|
forge_accounts: dict[str, object] | None = None,
|
||||||
|
repo_forge: str | None = None,
|
||||||
|
) -> ManifestIndex:
|
||||||
|
"""Build an eager index with one agent 'claude' + bottle 'bb'."""
|
||||||
|
agent: dict[str, object] = {"bottle": "bb", "prompt": ""}
|
||||||
|
if author is not None:
|
||||||
|
agent["author"] = author
|
||||||
|
if forge_accounts is not None:
|
||||||
|
agent["forge-accounts"] = forge_accounts
|
||||||
|
bottle: dict[str, object] = {
|
||||||
|
"git-gate": {"repos": {"bot-bottle": _repo(forge=repo_forge)}}
|
||||||
|
}
|
||||||
|
return ManifestIndex.from_json_obj(
|
||||||
|
{"bottles": {"bb": bottle}, "agents": {"claude": agent}}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _error(
|
||||||
|
callable_: Callable[..., object], *args: object, **kwargs: object,
|
||||||
|
) -> 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_name_rejects_newline(self) -> None:
|
||||||
|
msg = _error(_index, author={"name": "a\nb", "email": "e@x.is"})
|
||||||
|
self.assertIn("author.name must not contain newlines", 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))
|
||||||
|
|
||||||
|
def test_malformed_url_fails(self) -> None:
|
||||||
|
# An unparseable URL (invalid IPv6 literal) trips urlsplit's ValueError.
|
||||||
|
self.assertIn("not a valid URL", _error(
|
||||||
|
canonicalize_forge_url, "a", "g", "https://[/api/v1"))
|
||||||
|
|
||||||
|
|
||||||
|
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}))
|
||||||
|
|
||||||
|
def test_unknown_auth_key_fails(self) -> None:
|
||||||
|
self.assertIn("auth has unknown key", _error(
|
||||||
|
ManifestForgeAccount.from_dict, "a", "g",
|
||||||
|
{"url": "https://gitea/api/v1",
|
||||||
|
"auth": {"type": "token", "token_secret": "T", "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", ()))
|
||||||
|
|
||||||
|
def _two_alias_index(self, t1: str, t2: str) -> ManifestIndex:
|
||||||
|
"""Two aliases on the SAME host, each referenced by a distinct repo."""
|
||||||
|
def acct(token: str) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"url": "https://same.example/api/v1",
|
||||||
|
"auth": {"type": "token", "token_secret": token},
|
||||||
|
}
|
||||||
|
return ManifestIndex.from_json_obj({
|
||||||
|
"bottles": {"bb": {"git-gate": {"repos": {
|
||||||
|
"r1": {"url": "ssh://git@h/x/r1.git",
|
||||||
|
"key": {"provider": "static", "path": "/k"},
|
||||||
|
"forge": "g1"},
|
||||||
|
"r2": {"url": "ssh://git@h/x/r2.git",
|
||||||
|
"key": {"provider": "static", "path": "/k"},
|
||||||
|
"forge": "g2"},
|
||||||
|
}}}},
|
||||||
|
"agents": {"claude": {"bottle": "bb", "prompt": "",
|
||||||
|
"forge-accounts": {"g1": acct(t1),
|
||||||
|
"g2": acct(t2)}}},
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_conflicting_aliases_same_host_fail_closed(self) -> None:
|
||||||
|
# Two aliases on the same host with DIFFERENT tokens is ambiguous —
|
||||||
|
# the proxy routes by host, so it must fail before launch rather than
|
||||||
|
# silently authenticate every call as one account.
|
||||||
|
idx = self._two_alias_index("FIRST_TOKEN", "SECOND_TOKEN")
|
||||||
|
msg = _error(idx.load_for_agent, "claude", ())
|
||||||
|
self.assertIn("both resolve to host 'same.example'", msg)
|
||||||
|
self.assertIn("ambiguous", msg)
|
||||||
|
|
||||||
|
def test_matching_aliases_same_host_ok(self) -> None:
|
||||||
|
# Identical url/auth under two aliases is unambiguous: one route.
|
||||||
|
idx = self._two_alias_index("SAME_TOKEN", "SAME_TOKEN")
|
||||||
|
m = idx.load_for_agent("claude", ())
|
||||||
|
self.assertEqual(2, len(m.forge_associations)) # both referenced
|
||||||
|
routes = egress_forge_routes(m.forge_associations)
|
||||||
|
self.assertEqual(1, len(routes)) # deduped to a single proxy route
|
||||||
|
self.assertEqual("SAME_TOKEN", routes[0].token_ref)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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()
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
"""Unit: agent-level git-gate.user overlay + provenance (PRD 0027, PRD 0047).
|
"""Unit: agent-owned identity via `author` (PRD
|
||||||
|
0082).
|
||||||
|
|
||||||
An agent file may declare `git-gate.user` (name/email). At
|
Identity is agent-only now: an agent file declares an `author` block
|
||||||
`ManifestIndex.load_for_agent()` it overlays the referenced bottle's
|
(name + email, both required) and at `ManifestIndex.load_for_agent()`
|
||||||
`git-gate.user` per-field, agent-wins-on-non-empty. `git-gate.repos` is
|
it populates the effective bottle's `git_user`. There is no per-field
|
||||||
rejected on agents. `Manifest.git_identity_summary()` reports the
|
overlay against the bottle anymore — the bottle no longer carries a
|
||||||
effective identity with per-field `(agent)`/`(bottle)` provenance.
|
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
|
The `from_json_obj` path drives `ManifestAgent.from_dict` + the
|
||||||
load_for_agent; a temp-dir case locks the md loader (the `_AGENT_KEYS`
|
composition in load_for_agent; a temp-dir case locks the md loader
|
||||||
allow + the `git-gate` threading into `agent_dict`)."""
|
(the agent `author` frontmatter key threads into the parsed agent)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -31,97 +34,61 @@ def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
|
|||||||
raise AssertionError("expected ManifestError was not raised")
|
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."""
|
"""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
|
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
|
||||||
|
if author is not None:
|
||||||
|
agent["author"] = author
|
||||||
if agent_git is not None:
|
if agent_git is not None:
|
||||||
agent["git-gate"] = agent_git
|
agent["git-gate"] = agent_git
|
||||||
return ManifestIndex.from_json_obj({
|
return ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": bottle},
|
"bottles": {"dev": {}},
|
||||||
"agents": {"impl": agent},
|
"agents": {"impl": agent},
|
||||||
}).load_for_agent("impl")
|
}).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."""
|
"""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
|
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
|
||||||
if agent_git is not None:
|
if author is not None:
|
||||||
agent["git-gate"] = agent_git
|
agent["author"] = author
|
||||||
return ManifestIndex.from_json_obj({
|
return ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": bottle},
|
"bottles": {"dev": {}},
|
||||||
"agents": {"impl": agent},
|
"agents": {"impl": agent},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
class TestAgentGitUserOverlay(unittest.TestCase):
|
class TestAgentAuthorPopulatesBottle(unittest.TestCase):
|
||||||
def test_agent_supplies_both_fields(self):
|
def test_agent_author_supplies_both_fields(self):
|
||||||
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
|
m = _manifest(author={"name": "a", "email": "a@b"})
|
||||||
u = m.bottle.git_user
|
u = m.bottle.git_user
|
||||||
self.assertEqual("a", u.name)
|
self.assertEqual("a", u.name)
|
||||||
self.assertEqual("a@b", u.email)
|
self.assertEqual("a@b", u.email)
|
||||||
|
|
||||||
def test_agent_name_only_email_falls_through_to_bottle(self):
|
def test_bottle_has_no_identity_until_agent_composed(self):
|
||||||
m = _manifest(
|
idx = _index(author={"name": "a", "email": "a@b"})
|
||||||
bottle_user={"name": "B", "email": "b@c"},
|
# Raw bottle has no git_user; loaded manifest has it from the agent.
|
||||||
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
|
|
||||||
self.assertTrue(idx.bottles["dev"].git_user.is_empty())
|
self.assertTrue(idx.bottles["dev"].git_user.is_empty())
|
||||||
m = idx.load_for_agent("impl")
|
m = idx.load_for_agent("impl")
|
||||||
self.assertFalse(m.bottle.git_user.is_empty())
|
self.assertFalse(m.bottle.git_user.is_empty())
|
||||||
|
|
||||||
def test_bottle_only_identity_preserved_when_agent_silent(self):
|
def test_agent_silent_leaves_bottle_identity_empty(self):
|
||||||
m = _manifest(bottle_user={"name": "B", "email": "b@c"})
|
idx = _index()
|
||||||
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"})
|
|
||||||
m = idx.load_for_agent("impl")
|
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)
|
self.assertIs(idx.bottles["dev"], m.bottle)
|
||||||
|
|
||||||
def test_noop_overlay_uses_bottle_instance_directly(self):
|
def test_other_bottle_fields_untouched_by_identity(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):
|
|
||||||
idx = ManifestIndex.from_json_obj({
|
idx = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {
|
"bottles": {"dev": {
|
||||||
"env": {"FOO": "bar"},
|
"env": {"FOO": "bar"},
|
||||||
"supervise": True,
|
"supervise": True,
|
||||||
"git-gate": {"user": {"name": "B"}},
|
|
||||||
}},
|
}},
|
||||||
"agents": {"impl": {
|
"agents": {"impl": {
|
||||||
"bottle": "dev", "skills": [], "prompt": "",
|
"bottle": "dev", "skills": [], "prompt": "",
|
||||||
"git-gate": {"user": {"name": "a"}},
|
"author": {"name": "a", "email": "a@b"},
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
b = idx.load_for_agent("impl").bottle
|
b = idx.load_for_agent("impl").bottle
|
||||||
@@ -130,93 +97,77 @@ class TestAgentGitUserOverlay(unittest.TestCase):
|
|||||||
self.assertTrue(b.supervise)
|
self.assertTrue(b.supervise)
|
||||||
|
|
||||||
|
|
||||||
class TestAgentGitUserRejections(unittest.TestCase):
|
class TestAgentGitGateRejections(unittest.TestCase):
|
||||||
def test_agent_repos_dies_bottle_only(self):
|
"""`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={
|
msg = _error_message(_manifest, agent_git={
|
||||||
"repos": {"r": {"url": "ssh://git@x/y.git", "key": {"provider": "static", "path": "/dev/null"}}},
|
"repos": {"r": {"url": "ssh://git@x/y.git", "key": {"provider": "static", "path": "/dev/null"}}},
|
||||||
})
|
})
|
||||||
self.assertIn("git-gate.repos", msg)
|
self.assertIn("no longer", msg)
|
||||||
self.assertIn("bottle-only", msg)
|
self.assertIn("author", 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)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGitIdentitySummary(unittest.TestCase):
|
class TestGitIdentitySummary(unittest.TestCase):
|
||||||
def test_both_from_agent(self):
|
"""Summary reports the effective identity (from the agent's author)
|
||||||
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
|
with no per-field provenance annotation."""
|
||||||
self.assertEqual(
|
|
||||||
"name=a (agent), email=a@b (agent)",
|
|
||||||
m.git_identity_summary(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_mixed_provenance(self):
|
def test_summary_from_author(self):
|
||||||
m = _manifest(
|
m = _manifest(author={"name": "a", "email": "a@b"})
|
||||||
bottle_user={"name": "B", "email": "b@c"},
|
self.assertEqual("name=a, email=a@b", m.git_identity_summary())
|
||||||
agent_git={"user": {"name": "a"}},
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
"name=a (agent), email=b@c (bottle)",
|
|
||||||
m.git_identity_summary(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_bottle_only(self):
|
def test_none_when_no_author(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):
|
|
||||||
m = _manifest()
|
m = _manifest()
|
||||||
self.assertIsNone(m.git_identity_summary())
|
self.assertIsNone(m.git_identity_summary())
|
||||||
|
|
||||||
|
|
||||||
_BOTTLE_DEV = """
|
_BOTTLE_DEV = """
|
||||||
---
|
---
|
||||||
git-gate:
|
egress:
|
||||||
user:
|
routes:
|
||||||
name: bottle-name
|
- host: example.com
|
||||||
email: bottle@example.com
|
|
||||||
---
|
---
|
||||||
|
|
||||||
dev bottle.
|
dev bottle.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_AGENT_WITH_GIT = """
|
_AGENT_WITH_AUTHOR = """
|
||||||
---
|
---
|
||||||
bottle: dev
|
bottle: dev
|
||||||
git-gate:
|
author:
|
||||||
user:
|
name: agent-name
|
||||||
name: agent-name
|
email: agent@example.com
|
||||||
---
|
---
|
||||||
|
|
||||||
impl agent.
|
impl agent.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_AGENT_WITH_REPOS = """
|
_AGENT_WITH_GIT_GATE = """
|
||||||
---
|
---
|
||||||
bottle: dev
|
bottle: dev
|
||||||
git-gate:
|
git-gate:
|
||||||
repos:
|
repos:
|
||||||
r:
|
r:
|
||||||
url: ssh://git@x/y.git
|
url: ssh://git@x/y.git
|
||||||
identity: /dev/null
|
key:
|
||||||
|
provider: static
|
||||||
|
path: /dev/null
|
||||||
---
|
---
|
||||||
|
|
||||||
bad agent.
|
bad agent.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class TestAgentGitUserMdLoader(unittest.TestCase):
|
class TestAgentAuthorMdLoader(unittest.TestCase):
|
||||||
"""Locks the md path: `git-gate` is an accepted agent key and threads
|
"""Locks the md path: `author` is an accepted agent frontmatter key
|
||||||
into the parsed Agent (not rejected as an unknown frontmatter key),
|
and threads into the parsed agent, populating identity; a stale
|
||||||
and agent `git-gate.repos` dies through the same loader."""
|
agent `git-gate` block dies through the same loader."""
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.home = Path(tempfile.mkdtemp(prefix="cb-home-"))
|
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.parent.mkdir(parents=True, exist_ok=True)
|
||||||
p.write_text(textwrap.dedent(text).lstrip("\n"))
|
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("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")
|
m = ManifestIndex.resolve(str(self.home)).load_for_agent("impl")
|
||||||
u = m.bottle.git_user
|
u = m.bottle.git_user
|
||||||
self.assertEqual("agent-name", u.name)
|
self.assertEqual("agent-name", u.name)
|
||||||
self.assertEqual("bottle@example.com", u.email)
|
self.assertEqual("agent@example.com", u.email)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"name=agent-name (agent), email=bottle@example.com (bottle)",
|
"name=agent-name, email=agent@example.com",
|
||||||
m.git_identity_summary(),
|
m.git_identity_summary(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_md_agent_repos_fails_at_preflight(self):
|
def test_md_agent_git_gate_fails_at_preflight(self):
|
||||||
"""git-gate.repos on an agent is an error; resolve() still succeeds
|
"""A stale agent `git-gate` block is an error; resolve() still
|
||||||
so other agents remain accessible, but load_for_agent raises."""
|
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("bottles/dev.md", _BOTTLE_DEV)
|
||||||
self._write("agents/impl.md", _AGENT_WITH_REPOS)
|
self._write("agents/impl.md", _AGENT_WITH_GIT_GATE)
|
||||||
from bot_bottle.manifest import ManifestError
|
|
||||||
names = ManifestIndex.resolve(str(self.home))
|
names = ManifestIndex.resolve(str(self.home))
|
||||||
self.assertIn("impl", names.all_agent_names)
|
self.assertIn("impl", names.all_agent_names)
|
||||||
with self.assertRaises(ManifestError) as ctx:
|
with self.assertRaises(ManifestError) as ctx:
|
||||||
names.load_for_agent("impl")
|
names.load_for_agent("impl")
|
||||||
msg = str(ctx.exception)
|
self.assertIn("git-gate", str(ctx.exception))
|
||||||
self.assertIn("git-gate.repos", msg)
|
|
||||||
self.assertIn("bottle-only", msg)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -130,8 +130,10 @@ class TestExtendsEnvMerge(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestExtendsGitMerge(unittest.TestCase):
|
class TestExtendsGitMerge(unittest.TestCase):
|
||||||
"""git-gate.user overlays by field; git-gate.repos merges by name,
|
"""git-gate.repos merges by name, with same-name child entries
|
||||||
with same-name child entries merging field-by-field (child wins)."""
|
merging field-by-field (child wins). Bottles no longer carry a user
|
||||||
|
identity (PRD 0082), 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_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"}}
|
_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")
|
repo_entry = next(e for e in child.git if e.Name == "repo")
|
||||||
self.assertEqual("gitea", repo_entry.Key.provider)
|
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(
|
m = _build(
|
||||||
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
|
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(["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):
|
class TestExtendsEgressMerge(unittest.TestCase):
|
||||||
@@ -332,48 +337,29 @@ class TestExtendsEgressMerge(unittest.TestCase):
|
|||||||
self.assertIn("A.EXAMPLE.COM", msg)
|
self.assertIn("A.EXAMPLE.COM", msg)
|
||||||
|
|
||||||
|
|
||||||
class TestExtendsGitUserOverlay(unittest.TestCase):
|
class TestExtendsNoBottleUserIdentity(unittest.TestCase):
|
||||||
"""git-gate.user: per-field overlay. Each non-empty field on child
|
"""Bottles no longer carry a user identity (PRD
|
||||||
wins; empties fall through to parent."""
|
0082): 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):
|
def test_bottle_git_gate_user_dies(self):
|
||||||
m = _build(
|
# 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"}}},
|
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
|
||||||
child={"extends": "base"},
|
child={"extends": "base"},
|
||||||
)
|
)
|
||||||
u = m.bottles["child"].git_user
|
self.assertIn("git-gate.user is no longer supported", msg)
|
||||||
self.assertEqual("Parent", u.name)
|
|
||||||
self.assertEqual("p@x", u.email)
|
|
||||||
|
|
||||||
def test_child_overrides_both(self):
|
def test_git_user_empty_across_chain(self):
|
||||||
m = _build(
|
m = _build(
|
||||||
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
|
base={"git-gate": {"repos": {}}},
|
||||||
child={
|
child={"extends": "base"},
|
||||||
"extends": "base",
|
|
||||||
"git-gate": {"user": {"name": "Child", "email": "c@x"}},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
u = m.bottles["child"].git_user
|
self.assertTrue(m.bottles["base"].git_user.is_empty())
|
||||||
self.assertEqual("Child", u.name)
|
self.assertTrue(m.bottles["child"].git_user.is_empty())
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class TestExtendsChain(unittest.TestCase):
|
class TestExtendsChain(unittest.TestCase):
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
"""Unit: Bottle git-gate.user manifest parsing + validation (issue #86, PRD 0047)."""
|
"""Unit: agent `author` identity -> bottle.git_user (PRD
|
||||||
|
0082).
|
||||||
|
|
||||||
|
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
|
import unittest
|
||||||
|
|
||||||
@@ -14,89 +21,92 @@ def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
|
|||||||
raise AssertionError("expected ManifestError was not raised")
|
raise AssertionError("expected ManifestError was not raised")
|
||||||
|
|
||||||
|
|
||||||
def _manifest(git_user): # type: ignore
|
def _manifest(author): # type: ignore
|
||||||
return {
|
"""Build an index with one agent 'demo' carrying the given `author`
|
||||||
"bottles": {"dev": {"git-gate": {"user": git_user}}},
|
block, then load it, returning the composed Manifest."""
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
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):
|
def test_parses_both_fields(self):
|
||||||
m = ManifestIndex.from_json_obj(_manifest({
|
m = _manifest({
|
||||||
"name": "Eric Bauerfeld",
|
"name": "Eric Bauerfeld",
|
||||||
"email": "eric+claude@dideric.is",
|
"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 Bauerfeld", u.name)
|
||||||
self.assertEqual("eric+claude@dideric.is", u.email)
|
self.assertEqual("eric+claude@dideric.is", u.email)
|
||||||
self.assertFalse(u.is_empty())
|
self.assertFalse(u.is_empty())
|
||||||
|
|
||||||
def test_name_only(self):
|
def test_omitted_author_defaults_to_empty(self):
|
||||||
m = ManifestIndex.from_json_obj(_manifest({"name": "Bot"}))
|
# No author block at all -> empty git_user, is_empty True ->
|
||||||
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 →
|
|
||||||
# provisioner skips the `git config` step entirely.
|
# provisioner skips the `git config` step entirely.
|
||||||
m = ManifestIndex.from_json_obj({
|
m = _manifest(None)
|
||||||
"bottles": {"dev": {}},
|
self.assertTrue(m.bottle.git_user.is_empty())
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
||||||
})
|
def test_missing_name_dies(self):
|
||||||
u = m.bottles["dev"].git_user
|
# `author` is present but name is absent -> both fields required.
|
||||||
self.assertTrue(u.is_empty())
|
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):
|
def test_both_empty_strings_dies(self):
|
||||||
# An explicit `git.user: {name: "", email: ""}` is a typo
|
# An explicit `author: {name: "", email: ""}` is a typo /
|
||||||
# / half-finished edit; fail loudly rather than silently
|
# half-finished edit; fail loudly rather than silently no-op.
|
||||||
# no-op (the operator clearly meant to configure something).
|
msg = _error_message(_manifest, {"name": "", "email": ""})
|
||||||
msg = _error_message(
|
self.assertIn("author.name must be a non-empty string", msg)
|
||||||
ManifestIndex.from_json_obj, _manifest({"name": "", "email": ""}),
|
|
||||||
)
|
|
||||||
self.assertIn("neither name nor email", msg)
|
|
||||||
|
|
||||||
def test_unknown_key_dies(self):
|
def test_unknown_key_dies(self):
|
||||||
msg = _error_message(
|
msg = _error_message(
|
||||||
ManifestIndex.from_json_obj,
|
_manifest,
|
||||||
_manifest({"name": "Bot", "username": "bot"}),
|
{"name": "Bot", "email": "b@x", "username": "bot"},
|
||||||
)
|
)
|
||||||
self.assertIn("unknown key", msg)
|
self.assertIn("unknown key", msg)
|
||||||
self.assertIn("username", msg)
|
self.assertIn("username", msg)
|
||||||
|
|
||||||
def test_non_string_name_dies(self):
|
def test_non_string_name_dies(self):
|
||||||
msg = _error_message(
|
msg = _error_message(_manifest, {"name": 42, "email": "b@x"})
|
||||||
ManifestIndex.from_json_obj, _manifest({"name": 42}),
|
self.assertIn("author.name must be a non-empty string", msg)
|
||||||
)
|
|
||||||
self.assertIn("git-gate.user.name must be a string", msg)
|
|
||||||
|
|
||||||
def test_non_string_email_dies(self):
|
def test_non_string_email_dies(self):
|
||||||
msg = _error_message(
|
msg = _error_message(_manifest, {"name": "Bot", "email": ["x@y.z"]})
|
||||||
ManifestIndex.from_json_obj, _manifest({"email": ["x@y.z"]}),
|
self.assertIn("author.email must be a non-empty string", msg)
|
||||||
)
|
|
||||||
self.assertIn("git-gate.user.email must be a 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(
|
msg = _error_message(
|
||||||
ManifestIndex.from_json_obj,
|
ManifestIndex.from_json_obj,
|
||||||
{
|
{
|
||||||
"bottles": {"dev": {"git_user": {"name": "Bot"}}},
|
"bottles": {"dev": {"git-gate": {"user": {"name": "Bot"}}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertIn("git_user", msg)
|
self.assertIn("git-gate.user is no longer supported", msg)
|
||||||
self.assertIn("git-gate.user", msg)
|
self.assertIn("author", msg)
|
||||||
|
|
||||||
|
|
||||||
class TestGitUserDirect(unittest.TestCase):
|
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):
|
def test_is_empty_default(self):
|
||||||
self.assertTrue(ManifestGitUser().is_empty())
|
self.assertTrue(ManifestGitUser().is_empty())
|
||||||
|
|||||||
@@ -71,11 +71,14 @@ class _LazyCase(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestAllAgentNamesLazy(_LazyCase):
|
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 0082):
|
||||||
|
# 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 / "bottles" / "dev.md", _BOTTLE_DEV)
|
||||||
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
|
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
|
||||||
_write(self.cwd_cb / "agents" / "beta.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):
|
class TestLoadForAgentLazy(_LazyCase):
|
||||||
@@ -96,11 +99,13 @@ class TestRequireAgentLazy(_LazyCase):
|
|||||||
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
|
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
|
||||||
self.resolve().require_agent("alpha") # no raise
|
self.resolve().require_agent("alpha") # no raise
|
||||||
|
|
||||||
def test_existing_cwd_agent_ok(self) -> None:
|
def test_cwd_only_agent_not_selectable(self) -> None:
|
||||||
# File only under cwd -> require_agent's cwd_path branch.
|
# Agents are home-only (PRD 0082):
|
||||||
|
# a cwd-only agent file is never selectable, so require_agent raises.
|
||||||
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
|
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
|
||||||
_write(self.cwd_cb / "agents" / "beta.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:
|
def test_unknown_agent_raises(self) -> None:
|
||||||
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
|
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
|
||||||
|
|||||||
@@ -110,14 +110,16 @@ class TestAgentFileParses(_ResolveCase):
|
|||||||
self.assertFalse(a.prompt.endswith("\n"))
|
self.assertFalse(a.prompt.endswith("\n"))
|
||||||
|
|
||||||
|
|
||||||
class TestCwdAgentOverridesHome(_ResolveCase):
|
class TestCwdAgentIgnoredHomeWins(_ResolveCase):
|
||||||
"""SC #3: a cwd agent file with the same name as a home agent
|
"""SC #3 (revised, PRD 0082): agents
|
||||||
wins. The home bottle stays intact."""
|
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 / "bottles" / "dev.md", _BOTTLE_DEV)
|
||||||
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
|
_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(
|
_write(
|
||||||
self.cwd_cb / "agents" / "implementer.md",
|
self.cwd_cb / "agents" / "implementer.md",
|
||||||
"""
|
"""
|
||||||
@@ -129,14 +131,19 @@ class TestCwdAgentOverridesHome(_ResolveCase):
|
|||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
m = self.resolve().load_for_agent("implementer")
|
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
|
# Home bottle still present with its two egress routes
|
||||||
self.assertEqual(2, len(m.bottle.egress.routes))
|
self.assertEqual(2, len(m.bottle.egress.routes))
|
||||||
|
|
||||||
|
|
||||||
class TestCwdBottlesIgnored(_ResolveCase):
|
class TestCwdBottlesIgnored(_ResolveCase):
|
||||||
"""SC #4: a bottles/ dir under $CWD is ignored (with a warn).
|
"""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 0082 a cwd agents/ dir is also
|
||||||
|
ignored, so $CWD contributes nothing — the filesystem layout is the
|
||||||
|
trust boundary."""
|
||||||
|
|
||||||
def test_ignored(self):
|
def test_ignored(self):
|
||||||
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
||||||
|
|||||||
@@ -212,13 +212,21 @@ class TestAgentValidation(unittest.TestCase):
|
|||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
ManifestAgent.from_dict("a", {"prompt": 5}, set())
|
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):
|
with self.assertRaises(ManifestError):
|
||||||
ManifestAgent.from_dict("a", {"git-gate": {"repos": {}}}, set())
|
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:
|
def test_bottle_empty_git_gate_is_allowed(self) -> None:
|
||||||
agent = ManifestAgent.from_dict("a", {"git-gate": {}}, set())
|
# An empty `git-gate: {}` on a bottle is still allowed (only the
|
||||||
self.assertTrue(agent.git_user.is_empty())
|
# 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):
|
class TestEagerIndexLookups(unittest.TestCase):
|
||||||
def _idx(self) -> ManifestIndex:
|
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({
|
return _idx({
|
||||||
"bottles": {"b": {"git-gate": {"user": {"name": "Bot", "email": "b@x"}}}},
|
"bottles": {"b": {}},
|
||||||
"agents": {"a": {"bottle": "b"}},
|
"agents": {"a": {"bottle": "b",
|
||||||
|
"author": {"name": "Bot", "email": "b@x"}}},
|
||||||
})
|
})
|
||||||
|
|
||||||
def test_unknown_bottle_section_is_empty(self) -> None:
|
def test_unknown_bottle_section_is_empty(self) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user