feat(manifest): trusted agent forge identity and guidance
Implements PRD prd-new-trusted-agent-forge-identity. Moves author identity and named forge configurations onto the trusted, host-only agent definition, and lets a bottle repository optionally associate a git-gate repo with one of the agent's forge aliases. - Agent-owned identity: new `author` (name/email) and `forge-accounts` (alias -> canonical Gitea /api/v1 origin + host `token_secret` ref) on the agent manifest. `author` populates the bottle's git user.name/user.email. - Remove `git-gate.user` from both agents and bottles; fail with a migration pointer to `author`. `git-gate` is no longer accepted on an agent. - Bottle git-gate repos gain optional `forge: <alias>`, resolved against the selected agent's forge-accounts at composition (fail-closed on unknown). - Fail-closed Gitea API URL validation (https, no userinfo/query/fragment, /api/v1 base, host lowercased, trailing slash normalized, host dedup). - Proxy-held credential: synthesize one inspected, token-authenticated egress route per referenced forge alias, scoped to the origin + API prefix. The token is resolved from the host env at launch into the egress proxy only — never the bottle env, prompt, gitconfig, or workspace. - Generated, non-secret forge workflow guidance appended to the agent prompt for associated repos (API base, branch-backed PR flow, AGit-ref prohibition, mutation verification); omitted when no repo declares a forge. - Agents become home-only: cwd `.bot-bottle/agents` no longer contributes, overrides, or is selectable; warned-and-ignored like cwd bottles. - Docs: README examples, PRD 0011 supersession note, manifest schema docstring. - Tests: new test_forge_identity suite; legacy git-gate.user/cwd tests updated to the agent-owned identity model. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -21,7 +22,7 @@ from bot_bottle.backend import Bottle, BottleSpec, ExecResult
|
||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||
from bot_bottle.egress import EgressPlan
|
||||
from bot_bottle.git_gate import GitGatePlan
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
from bot_bottle.manifest import ManifestGitUser, ManifestIndex
|
||||
|
||||
|
||||
class _Provider(AgentProvider):
|
||||
@@ -50,15 +51,39 @@ def _plan(*, git_user: dict | None = None, # type: ignore
|
||||
user_cwd: str = "/tmp/x",
|
||||
stage_dir: Path | None = None) -> DockerBottlePlan:
|
||||
bottle_json: dict = {} # type: ignore
|
||||
if git_user is not None:
|
||||
bottle_json["git-gate"] = {"user": git_user}
|
||||
if git_repos is not None:
|
||||
bottle_json.setdefault("git-gate", {})["repos"] = git_repos
|
||||
# Identity now lives on the agent's `author` block; at composition it
|
||||
# populates manifest.bottle.git_user, which provision_git reads
|
||||
# (production unchanged). When the caller passes a full name+email we
|
||||
# route it through `author`; the name-only / email-only cases (which
|
||||
# exercise provision_git emitting a single `git config` line) can't be
|
||||
# expressed via `author` (both fields required), so we inject the
|
||||
# partial ManifestGitUser onto the composed bottle directly.
|
||||
agent_json: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
|
||||
full_author = (
|
||||
git_user
|
||||
if git_user and git_user.get("name") and git_user.get("email")
|
||||
else None
|
||||
)
|
||||
if full_author is not None:
|
||||
agent_json["author"] = full_author
|
||||
index = ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": bottle_json},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
"agents": {"demo": agent_json},
|
||||
})
|
||||
manifest = index.load_for_agent("demo")
|
||||
if git_user is not None and full_author is None:
|
||||
manifest = replace(
|
||||
manifest,
|
||||
bottle=replace(
|
||||
manifest.bottle,
|
||||
git_user=ManifestGitUser(
|
||||
name=git_user.get("name", ""),
|
||||
email=git_user.get("email", ""),
|
||||
),
|
||||
),
|
||||
)
|
||||
spec = BottleSpec(
|
||||
manifest=index, agent_name="demo",
|
||||
copy_cwd=copy_cwd, user_cwd=user_cwd,
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Unit: trusted agent forge identity & guidance
|
||||
(PRD prd-new-trusted-agent-forge-identity).
|
||||
|
||||
Covers the net-new surface: agent `author`, agent `forge-accounts` (Gitea API
|
||||
URL validation + host token reference), the repo→forge association resolved at
|
||||
composition, the synthesized proxy-held egress route, and the generated,
|
||||
non-secret prompt guidance. Legacy git-gate.user / cwd-agent behavior lives in
|
||||
the manifest test modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from 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", ()))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
prd-new-trusted-agent-forge-identity).
|
||||
|
||||
An agent file may declare `git-gate.user` (name/email). At
|
||||
`ManifestIndex.load_for_agent()` it overlays the referenced bottle's
|
||||
`git-gate.user` per-field, agent-wins-on-non-empty. `git-gate.repos` is
|
||||
rejected on agents. `Manifest.git_identity_summary()` reports the
|
||||
effective identity with per-field `(agent)`/`(bottle)` provenance.
|
||||
Identity is agent-only now: an agent file declares an `author` block
|
||||
(name + email, both required) and at `ManifestIndex.load_for_agent()`
|
||||
it populates the effective bottle's `git_user`. There is no per-field
|
||||
overlay against the bottle anymore — the bottle no longer carries a
|
||||
user identity. `git-gate` (user or repos) is rejected on an agent with
|
||||
a migration message. `Manifest.git_identity_summary()` reports the
|
||||
effective identity with no provenance annotation.
|
||||
|
||||
The `from_json_obj` path drives `Agent.from_dict` + the overlay in
|
||||
load_for_agent; a temp-dir case locks the md loader (the `_AGENT_KEYS`
|
||||
allow + the `git-gate` threading into `agent_dict`)."""
|
||||
The `from_json_obj` path drives `ManifestAgent.from_dict` + the
|
||||
composition in load_for_agent; a temp-dir case locks the md loader
|
||||
(the agent `author` frontmatter key threads into the parsed agent)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -31,97 +34,61 @@ def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
|
||||
raise AssertionError("expected ManifestError was not raised")
|
||||
|
||||
|
||||
def _manifest(*, bottle_user=None, agent_git=None) -> Manifest: # type: ignore
|
||||
def _manifest(*, author=None, agent_git=None) -> Manifest: # type: ignore
|
||||
"""Build an index with one agent 'impl' and load it, returning a Manifest."""
|
||||
bottle: dict = {} # type: ignore
|
||||
if bottle_user is not None:
|
||||
bottle = {"git-gate": {"user": bottle_user}}
|
||||
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
|
||||
if author is not None:
|
||||
agent["author"] = author
|
||||
if agent_git is not None:
|
||||
agent["git-gate"] = agent_git
|
||||
return ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": bottle},
|
||||
"bottles": {"dev": {}},
|
||||
"agents": {"impl": agent},
|
||||
}).load_for_agent("impl")
|
||||
|
||||
|
||||
def _index(*, bottle_user: dict[str, object] | None = None, agent_git: dict[str, object] | None = None) -> ManifestIndex:
|
||||
def _index(*, author: dict[str, object] | None = None) -> ManifestIndex:
|
||||
"""Build an index with one agent 'impl' without loading it."""
|
||||
bottle: dict = {} # type: ignore
|
||||
if bottle_user is not None:
|
||||
bottle = {"git-gate": {"user": bottle_user}}
|
||||
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
|
||||
if agent_git is not None:
|
||||
agent["git-gate"] = agent_git
|
||||
if author is not None:
|
||||
agent["author"] = author
|
||||
return ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": bottle},
|
||||
"bottles": {"dev": {}},
|
||||
"agents": {"impl": agent},
|
||||
})
|
||||
|
||||
|
||||
class TestAgentGitUserOverlay(unittest.TestCase):
|
||||
def test_agent_supplies_both_fields(self):
|
||||
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
|
||||
class TestAgentAuthorPopulatesBottle(unittest.TestCase):
|
||||
def test_agent_author_supplies_both_fields(self):
|
||||
m = _manifest(author={"name": "a", "email": "a@b"})
|
||||
u = m.bottle.git_user
|
||||
self.assertEqual("a", u.name)
|
||||
self.assertEqual("a@b", u.email)
|
||||
|
||||
def test_agent_name_only_email_falls_through_to_bottle(self):
|
||||
m = _manifest(
|
||||
bottle_user={"name": "B", "email": "b@c"},
|
||||
agent_git={"user": {"name": "a"}},
|
||||
)
|
||||
u = m.bottle.git_user
|
||||
self.assertEqual("a", u.name) # agent wins
|
||||
self.assertEqual("b@c", u.email) # bottle falls through
|
||||
|
||||
def test_agent_email_only_name_falls_through_to_bottle(self):
|
||||
m = _manifest(
|
||||
bottle_user={"name": "B", "email": "b@c"},
|
||||
agent_git={"user": {"email": "a@b"}},
|
||||
)
|
||||
u = m.bottle.git_user
|
||||
self.assertEqual("B", u.name)
|
||||
self.assertEqual("a@b", u.email)
|
||||
|
||||
def test_agent_identity_with_bottle_declaring_none(self):
|
||||
idx = _index(agent_git={"user": {"name": "a", "email": "a@b"}})
|
||||
# Raw bottle has no git_user; loaded manifest has merged git_user from agent
|
||||
def test_bottle_has_no_identity_until_agent_composed(self):
|
||||
idx = _index(author={"name": "a", "email": "a@b"})
|
||||
# Raw bottle has no git_user; loaded manifest has it from the agent.
|
||||
self.assertTrue(idx.bottles["dev"].git_user.is_empty())
|
||||
m = idx.load_for_agent("impl")
|
||||
self.assertFalse(m.bottle.git_user.is_empty())
|
||||
|
||||
def test_bottle_only_identity_preserved_when_agent_silent(self):
|
||||
m = _manifest(bottle_user={"name": "B", "email": "b@c"})
|
||||
u = m.bottle.git_user
|
||||
self.assertEqual("B", u.name)
|
||||
self.assertEqual("b@c", u.email)
|
||||
|
||||
def test_no_overlay_uses_bottle_instance_directly(self):
|
||||
idx = _index(bottle_user={"name": "B"})
|
||||
def test_agent_silent_leaves_bottle_identity_empty(self):
|
||||
idx = _index()
|
||||
m = idx.load_for_agent("impl")
|
||||
# Agent has no git_user — bottle instance should be the same object
|
||||
# No author -> git_user stays empty; the bottle instance is reused
|
||||
# directly (no replace needed).
|
||||
self.assertTrue(m.bottle.git_user.is_empty())
|
||||
self.assertIs(idx.bottles["dev"], m.bottle)
|
||||
|
||||
def test_noop_overlay_uses_bottle_instance_directly(self):
|
||||
idx = _index(
|
||||
bottle_user={"name": "B", "email": "b@c"},
|
||||
agent_git={"user": {"name": "B", "email": "b@c"}},
|
||||
)
|
||||
m = idx.load_for_agent("impl")
|
||||
# Agent git_user == bottle git_user — no replace needed
|
||||
self.assertEqual(idx.bottles["dev"].git_user, m.bottle.git_user)
|
||||
|
||||
def test_other_bottle_fields_untouched_by_overlay(self):
|
||||
def test_other_bottle_fields_untouched_by_identity(self):
|
||||
idx = ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": {
|
||||
"env": {"FOO": "bar"},
|
||||
"supervise": True,
|
||||
"git-gate": {"user": {"name": "B"}},
|
||||
}},
|
||||
"agents": {"impl": {
|
||||
"bottle": "dev", "skills": [], "prompt": "",
|
||||
"git-gate": {"user": {"name": "a"}},
|
||||
"author": {"name": "a", "email": "a@b"},
|
||||
}},
|
||||
})
|
||||
b = idx.load_for_agent("impl").bottle
|
||||
@@ -130,93 +97,77 @@ class TestAgentGitUserOverlay(unittest.TestCase):
|
||||
self.assertTrue(b.supervise)
|
||||
|
||||
|
||||
class TestAgentGitUserRejections(unittest.TestCase):
|
||||
def test_agent_repos_dies_bottle_only(self):
|
||||
class TestAgentGitGateRejections(unittest.TestCase):
|
||||
"""`git-gate` is no longer accepted on an agent (user or repos):
|
||||
identity moved to `author`, repos stays bottle-only."""
|
||||
|
||||
def test_agent_git_gate_user_dies(self):
|
||||
msg = _error_message(_manifest, agent_git={"user": {"name": "a", "email": "a@b"}})
|
||||
self.assertIn("no longer", msg)
|
||||
self.assertIn("author", msg)
|
||||
|
||||
def test_agent_git_gate_repos_dies(self):
|
||||
msg = _error_message(_manifest, agent_git={
|
||||
"repos": {"r": {"url": "ssh://git@x/y.git", "key": {"provider": "static", "path": "/dev/null"}}},
|
||||
})
|
||||
self.assertIn("git-gate.repos", msg)
|
||||
self.assertIn("bottle-only", msg)
|
||||
|
||||
def test_agent_unknown_git_subkey_dies(self):
|
||||
msg = _error_message(_manifest, agent_git={"nope": {}})
|
||||
self.assertIn("not allowed at the agent level", msg)
|
||||
|
||||
def test_agent_git_user_both_empty_dies(self):
|
||||
msg = _error_message(_manifest, agent_git={"user": {"name": "", "email": ""}})
|
||||
self.assertIn("neither name nor email", msg)
|
||||
self.assertIn("no longer", msg)
|
||||
self.assertIn("author", msg)
|
||||
|
||||
|
||||
class TestGitIdentitySummary(unittest.TestCase):
|
||||
def test_both_from_agent(self):
|
||||
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
|
||||
self.assertEqual(
|
||||
"name=a (agent), email=a@b (agent)",
|
||||
m.git_identity_summary(),
|
||||
)
|
||||
"""Summary reports the effective identity (from the agent's author)
|
||||
with no per-field provenance annotation."""
|
||||
|
||||
def test_mixed_provenance(self):
|
||||
m = _manifest(
|
||||
bottle_user={"name": "B", "email": "b@c"},
|
||||
agent_git={"user": {"name": "a"}},
|
||||
)
|
||||
self.assertEqual(
|
||||
"name=a (agent), email=b@c (bottle)",
|
||||
m.git_identity_summary(),
|
||||
)
|
||||
def test_summary_from_author(self):
|
||||
m = _manifest(author={"name": "a", "email": "a@b"})
|
||||
self.assertEqual("name=a, email=a@b", m.git_identity_summary())
|
||||
|
||||
def test_bottle_only(self):
|
||||
m = _manifest(bottle_user={"name": "B", "email": "b@c"})
|
||||
self.assertEqual(
|
||||
"name=B (bottle), email=b@c (bottle)",
|
||||
m.git_identity_summary(),
|
||||
)
|
||||
|
||||
def test_none_when_unset_anywhere(self):
|
||||
def test_none_when_no_author(self):
|
||||
m = _manifest()
|
||||
self.assertIsNone(m.git_identity_summary())
|
||||
|
||||
|
||||
_BOTTLE_DEV = """
|
||||
---
|
||||
git-gate:
|
||||
user:
|
||||
name: bottle-name
|
||||
email: bottle@example.com
|
||||
egress:
|
||||
routes:
|
||||
- host: example.com
|
||||
---
|
||||
|
||||
dev bottle.
|
||||
"""
|
||||
|
||||
_AGENT_WITH_GIT = """
|
||||
_AGENT_WITH_AUTHOR = """
|
||||
---
|
||||
bottle: dev
|
||||
git-gate:
|
||||
user:
|
||||
name: agent-name
|
||||
author:
|
||||
name: agent-name
|
||||
email: agent@example.com
|
||||
---
|
||||
|
||||
impl agent.
|
||||
"""
|
||||
|
||||
_AGENT_WITH_REPOS = """
|
||||
_AGENT_WITH_GIT_GATE = """
|
||||
---
|
||||
bottle: dev
|
||||
git-gate:
|
||||
repos:
|
||||
r:
|
||||
url: ssh://git@x/y.git
|
||||
identity: /dev/null
|
||||
key:
|
||||
provider: static
|
||||
path: /dev/null
|
||||
---
|
||||
|
||||
bad agent.
|
||||
"""
|
||||
|
||||
|
||||
class TestAgentGitUserMdLoader(unittest.TestCase):
|
||||
"""Locks the md path: `git-gate` is an accepted agent key and threads
|
||||
into the parsed Agent (not rejected as an unknown frontmatter key),
|
||||
and agent `git-gate.repos` dies through the same loader."""
|
||||
class TestAgentAuthorMdLoader(unittest.TestCase):
|
||||
"""Locks the md path: `author` is an accepted agent frontmatter key
|
||||
and threads into the parsed agent, populating identity; a stale
|
||||
agent `git-gate` block dies through the same loader."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.home = Path(tempfile.mkdtemp(prefix="cb-home-"))
|
||||
@@ -235,31 +186,30 @@ class TestAgentGitUserMdLoader(unittest.TestCase):
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(textwrap.dedent(text).lstrip("\n"))
|
||||
|
||||
def test_md_agent_git_user_overlays_bottle(self):
|
||||
def test_md_agent_author_populates_identity(self):
|
||||
self._write("bottles/dev.md", _BOTTLE_DEV)
|
||||
self._write("agents/impl.md", _AGENT_WITH_GIT)
|
||||
self._write("agents/impl.md", _AGENT_WITH_AUTHOR)
|
||||
m = ManifestIndex.resolve(str(self.home)).load_for_agent("impl")
|
||||
u = m.bottle.git_user
|
||||
self.assertEqual("agent-name", u.name)
|
||||
self.assertEqual("bottle@example.com", u.email)
|
||||
self.assertEqual("agent@example.com", u.email)
|
||||
self.assertEqual(
|
||||
"name=agent-name (agent), email=bottle@example.com (bottle)",
|
||||
"name=agent-name, email=agent@example.com",
|
||||
m.git_identity_summary(),
|
||||
)
|
||||
|
||||
def test_md_agent_repos_fails_at_preflight(self):
|
||||
"""git-gate.repos on an agent is an error; resolve() still succeeds
|
||||
so other agents remain accessible, but load_for_agent raises."""
|
||||
def test_md_agent_git_gate_fails_at_preflight(self):
|
||||
"""A stale agent `git-gate` block is an error; resolve() still
|
||||
succeeds so other agents remain accessible, but load_for_agent
|
||||
raises. The lazy loader's frontmatter-key validator rejects the
|
||||
unknown `git-gate` key first."""
|
||||
self._write("bottles/dev.md", _BOTTLE_DEV)
|
||||
self._write("agents/impl.md", _AGENT_WITH_REPOS)
|
||||
from bot_bottle.manifest import ManifestError
|
||||
self._write("agents/impl.md", _AGENT_WITH_GIT_GATE)
|
||||
names = ManifestIndex.resolve(str(self.home))
|
||||
self.assertIn("impl", names.all_agent_names)
|
||||
with self.assertRaises(ManifestError) as ctx:
|
||||
names.load_for_agent("impl")
|
||||
msg = str(ctx.exception)
|
||||
self.assertIn("git-gate.repos", msg)
|
||||
self.assertIn("bottle-only", msg)
|
||||
self.assertIn("git-gate", str(ctx.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -130,8 +130,10 @@ class TestExtendsEnvMerge(unittest.TestCase):
|
||||
|
||||
|
||||
class TestExtendsGitMerge(unittest.TestCase):
|
||||
"""git-gate.user overlays by field; git-gate.repos merges by name,
|
||||
with same-name child entries merging field-by-field (child wins)."""
|
||||
"""git-gate.repos merges by name, with same-name child entries
|
||||
merging field-by-field (child wins). Bottles no longer carry a user
|
||||
identity (PRD prd-new-trusted-agent-forge-identity), so only repos
|
||||
merging is meaningful across extends chains."""
|
||||
|
||||
_GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "key": {"provider": "static", "path": "/dev/null"}}
|
||||
_GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "key": {"provider": "static", "path": "/dev/null"}}
|
||||
@@ -254,13 +256,16 @@ class TestExtendsGitMerge(unittest.TestCase):
|
||||
repo_entry = next(e for e in child.git if e.Name == "repo")
|
||||
self.assertEqual("gitea", repo_entry.Key.provider)
|
||||
|
||||
def test_child_git_user_inherits_parent_repos(self):
|
||||
def test_child_inherits_parent_repos_no_user_identity(self):
|
||||
# Child omits git-gate entirely -> inherits the parent's repos.
|
||||
# Bottles carry no user identity anymore, so git_user stays empty
|
||||
# across the extends chain.
|
||||
m = _build(
|
||||
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
|
||||
child={"extends": "base", "git-gate": {"user": {"name": "Child"}}},
|
||||
child={"extends": "base"},
|
||||
)
|
||||
self.assertEqual(["a"], [e.Name for e in m.bottles["child"].git])
|
||||
self.assertEqual("Child", m.bottles["child"].git_user.name)
|
||||
self.assertTrue(m.bottles["child"].git_user.is_empty())
|
||||
|
||||
|
||||
class TestExtendsEgressMerge(unittest.TestCase):
|
||||
@@ -332,48 +337,29 @@ class TestExtendsEgressMerge(unittest.TestCase):
|
||||
self.assertIn("A.EXAMPLE.COM", msg)
|
||||
|
||||
|
||||
class TestExtendsGitUserOverlay(unittest.TestCase):
|
||||
"""git-gate.user: per-field overlay. Each non-empty field on child
|
||||
wins; empties fall through to parent."""
|
||||
class TestExtendsNoBottleUserIdentity(unittest.TestCase):
|
||||
"""Bottles no longer carry a user identity (PRD
|
||||
prd-new-trusted-agent-forge-identity): identity moved to the agent's
|
||||
`author` block. `git-gate.user` on a bottle is rejected outright, and
|
||||
`git_user` is always empty across an extends chain."""
|
||||
|
||||
def test_parent_full_child_omits(self):
|
||||
m = _build(
|
||||
def test_bottle_git_gate_user_dies(self):
|
||||
# A stale `git-gate.user` on a bottle fails with the migration die
|
||||
# even inside an extends chain.
|
||||
msg = _error_message(
|
||||
_build,
|
||||
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
|
||||
child={"extends": "base"},
|
||||
)
|
||||
u = m.bottles["child"].git_user
|
||||
self.assertEqual("Parent", u.name)
|
||||
self.assertEqual("p@x", u.email)
|
||||
self.assertIn("git-gate.user is no longer supported", msg)
|
||||
|
||||
def test_child_overrides_both(self):
|
||||
def test_git_user_empty_across_chain(self):
|
||||
m = _build(
|
||||
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
|
||||
child={
|
||||
"extends": "base",
|
||||
"git-gate": {"user": {"name": "Child", "email": "c@x"}},
|
||||
},
|
||||
base={"git-gate": {"repos": {}}},
|
||||
child={"extends": "base"},
|
||||
)
|
||||
u = m.bottles["child"].git_user
|
||||
self.assertEqual("Child", u.name)
|
||||
self.assertEqual("c@x", u.email)
|
||||
|
||||
def test_child_adds_email_inherits_name(self):
|
||||
m = _build(
|
||||
base={"git-gate": {"user": {"name": "Parent"}}},
|
||||
child={"extends": "base", "git-gate": {"user": {"email": "c@x"}}},
|
||||
)
|
||||
u = m.bottles["child"].git_user
|
||||
self.assertEqual("Parent", u.name)
|
||||
self.assertEqual("c@x", u.email)
|
||||
|
||||
def test_child_overrides_only_email(self):
|
||||
m = _build(
|
||||
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
|
||||
child={"extends": "base", "git-gate": {"user": {"email": "c@x"}}},
|
||||
)
|
||||
u = m.bottles["child"].git_user
|
||||
self.assertEqual("Parent", u.name)
|
||||
self.assertEqual("c@x", u.email)
|
||||
self.assertTrue(m.bottles["base"].git_user.is_empty())
|
||||
self.assertTrue(m.bottles["child"].git_user.is_empty())
|
||||
|
||||
|
||||
class TestExtendsChain(unittest.TestCase):
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
"""Unit: Bottle git-gate.user manifest parsing + validation (issue #86, PRD 0047)."""
|
||||
"""Unit: agent `author` identity -> bottle.git_user (PRD
|
||||
prd-new-trusted-agent-forge-identity).
|
||||
|
||||
Identity moved off `git-gate.user` (bottle) onto the trusted agent's
|
||||
`author` block. At `load_for_agent` the agent's author populates the
|
||||
effective bottle's `git_user` (a `ManifestGitUser`). This locks the
|
||||
`author` validation/rejection paths and the bottle `git-gate.user`
|
||||
migration die."""
|
||||
|
||||
import unittest
|
||||
|
||||
@@ -14,89 +21,92 @@ def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
|
||||
raise AssertionError("expected ManifestError was not raised")
|
||||
|
||||
|
||||
def _manifest(git_user): # type: ignore
|
||||
return {
|
||||
"bottles": {"dev": {"git-gate": {"user": git_user}}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
}
|
||||
def _manifest(author): # type: ignore
|
||||
"""Build an index with one agent 'demo' carrying the given `author`
|
||||
block, then load it, returning the composed Manifest."""
|
||||
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
|
||||
if author is not None:
|
||||
agent["author"] = author
|
||||
return ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": {}},
|
||||
"agents": {"demo": agent},
|
||||
}).load_for_agent("demo")
|
||||
|
||||
|
||||
class TestGitUserParsing(unittest.TestCase):
|
||||
class TestAuthorIdentity(unittest.TestCase):
|
||||
"""The agent's `author` block populates bottle.git_user."""
|
||||
|
||||
def test_parses_both_fields(self):
|
||||
m = ManifestIndex.from_json_obj(_manifest({
|
||||
m = _manifest({
|
||||
"name": "Eric Bauerfeld",
|
||||
"email": "eric+claude@dideric.is",
|
||||
}))
|
||||
u = m.bottles["dev"].git_user
|
||||
})
|
||||
u = m.bottle.git_user
|
||||
self.assertEqual("Eric Bauerfeld", u.name)
|
||||
self.assertEqual("eric+claude@dideric.is", u.email)
|
||||
self.assertFalse(u.is_empty())
|
||||
|
||||
def test_name_only(self):
|
||||
m = ManifestIndex.from_json_obj(_manifest({"name": "Bot"}))
|
||||
u = m.bottles["dev"].git_user
|
||||
self.assertEqual("Bot", u.name)
|
||||
self.assertEqual("", u.email)
|
||||
|
||||
def test_email_only(self):
|
||||
m = ManifestIndex.from_json_obj(_manifest({"email": "bot@example.com"}))
|
||||
u = m.bottles["dev"].git_user
|
||||
self.assertEqual("", u.name)
|
||||
self.assertEqual("bot@example.com", u.email)
|
||||
|
||||
def test_omitted_defaults_to_empty(self):
|
||||
# No git.user block at all → empty GitUser, is_empty True →
|
||||
def test_omitted_author_defaults_to_empty(self):
|
||||
# No author block at all -> empty git_user, is_empty True ->
|
||||
# provisioner skips the `git config` step entirely.
|
||||
m = ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": {}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
u = m.bottles["dev"].git_user
|
||||
self.assertTrue(u.is_empty())
|
||||
m = _manifest(None)
|
||||
self.assertTrue(m.bottle.git_user.is_empty())
|
||||
|
||||
def test_missing_name_dies(self):
|
||||
# `author` is present but name is absent -> both fields required.
|
||||
msg = _error_message(_manifest, {"email": "bot@example.com"})
|
||||
self.assertIn("author.name must be a non-empty string", msg)
|
||||
|
||||
def test_missing_email_dies(self):
|
||||
msg = _error_message(_manifest, {"name": "Bot"})
|
||||
self.assertIn("author.email must be a non-empty string", msg)
|
||||
|
||||
def test_both_empty_strings_dies(self):
|
||||
# An explicit `git.user: {name: "", email: ""}` is a typo
|
||||
# / half-finished edit; fail loudly rather than silently
|
||||
# no-op (the operator clearly meant to configure something).
|
||||
msg = _error_message(
|
||||
ManifestIndex.from_json_obj, _manifest({"name": "", "email": ""}),
|
||||
)
|
||||
self.assertIn("neither name nor email", msg)
|
||||
# An explicit `author: {name: "", email: ""}` is a typo /
|
||||
# half-finished edit; fail loudly rather than silently no-op.
|
||||
msg = _error_message(_manifest, {"name": "", "email": ""})
|
||||
self.assertIn("author.name must be a non-empty string", msg)
|
||||
|
||||
def test_unknown_key_dies(self):
|
||||
msg = _error_message(
|
||||
ManifestIndex.from_json_obj,
|
||||
_manifest({"name": "Bot", "username": "bot"}),
|
||||
_manifest,
|
||||
{"name": "Bot", "email": "b@x", "username": "bot"},
|
||||
)
|
||||
self.assertIn("unknown key", msg)
|
||||
self.assertIn("username", msg)
|
||||
|
||||
def test_non_string_name_dies(self):
|
||||
msg = _error_message(
|
||||
ManifestIndex.from_json_obj, _manifest({"name": 42}),
|
||||
)
|
||||
self.assertIn("git-gate.user.name must be a string", msg)
|
||||
msg = _error_message(_manifest, {"name": 42, "email": "b@x"})
|
||||
self.assertIn("author.name must be a non-empty string", msg)
|
||||
|
||||
def test_non_string_email_dies(self):
|
||||
msg = _error_message(
|
||||
ManifestIndex.from_json_obj, _manifest({"email": ["x@y.z"]}),
|
||||
)
|
||||
self.assertIn("git-gate.user.email must be a string", msg)
|
||||
msg = _error_message(_manifest, {"name": "Bot", "email": ["x@y.z"]})
|
||||
self.assertIn("author.email must be a non-empty string", msg)
|
||||
|
||||
def test_legacy_top_level_git_user_dies(self):
|
||||
def test_email_with_whitespace_dies(self):
|
||||
msg = _error_message(_manifest, {"name": "Bot", "email": "a @b"})
|
||||
self.assertIn("author.email must not contain whitespace", msg)
|
||||
|
||||
|
||||
class TestBottleGitGateUserMigration(unittest.TestCase):
|
||||
"""`git-gate.user` on a bottle is no longer supported: it raises a
|
||||
ManifestError pointing at the agent's `author` block."""
|
||||
|
||||
def test_bottle_git_gate_user_dies(self):
|
||||
msg = _error_message(
|
||||
ManifestIndex.from_json_obj,
|
||||
{
|
||||
"bottles": {"dev": {"git_user": {"name": "Bot"}}},
|
||||
"bottles": {"dev": {"git-gate": {"user": {"name": "Bot"}}}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
},
|
||||
)
|
||||
self.assertIn("git_user", msg)
|
||||
self.assertIn("git-gate.user", msg)
|
||||
self.assertIn("git-gate.user is no longer supported", msg)
|
||||
self.assertIn("author", msg)
|
||||
|
||||
|
||||
class TestGitUserDirect(unittest.TestCase):
|
||||
"""Direct GitUser dataclass exercises (no manifest wrapper)."""
|
||||
"""Direct GitUser dataclass exercises (no manifest wrapper). The
|
||||
dataclass is still the runtime carrier on ManifestBottle.git_user."""
|
||||
|
||||
def test_is_empty_default(self):
|
||||
self.assertTrue(ManifestGitUser().is_empty())
|
||||
|
||||
@@ -71,11 +71,14 @@ class _LazyCase(unittest.TestCase):
|
||||
|
||||
|
||||
class TestAllAgentNamesLazy(_LazyCase):
|
||||
def test_merges_home_and_cwd_agents(self) -> None:
|
||||
def test_cwd_agents_ignored_home_only(self) -> None:
|
||||
# Agents are home-only (PRD prd-new-trusted-agent-forge-identity):
|
||||
# a cwd agents/ dir is warned-and-ignored, so only the home agent
|
||||
# appears in all_agent_names.
|
||||
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
||||
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
|
||||
_write(self.cwd_cb / "agents" / "beta.md", _AGENT)
|
||||
self.assertEqual(["alpha", "beta"], self.resolve().all_agent_names)
|
||||
self.assertEqual(["alpha"], self.resolve().all_agent_names)
|
||||
|
||||
|
||||
class TestLoadForAgentLazy(_LazyCase):
|
||||
@@ -96,11 +99,13 @@ class TestRequireAgentLazy(_LazyCase):
|
||||
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
|
||||
self.resolve().require_agent("alpha") # no raise
|
||||
|
||||
def test_existing_cwd_agent_ok(self) -> None:
|
||||
# File only under cwd -> require_agent's cwd_path branch.
|
||||
def test_cwd_only_agent_not_selectable(self) -> None:
|
||||
# Agents are home-only (PRD prd-new-trusted-agent-forge-identity):
|
||||
# a cwd-only agent file is never selectable, so require_agent raises.
|
||||
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
|
||||
_write(self.cwd_cb / "agents" / "beta.md", _AGENT)
|
||||
self.resolve().require_agent("beta") # no raise
|
||||
with self.assertRaises(ManifestError):
|
||||
self.resolve().require_agent("beta")
|
||||
|
||||
def test_unknown_agent_raises(self) -> None:
|
||||
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
|
||||
|
||||
@@ -110,14 +110,16 @@ class TestAgentFileParses(_ResolveCase):
|
||||
self.assertFalse(a.prompt.endswith("\n"))
|
||||
|
||||
|
||||
class TestCwdAgentOverridesHome(_ResolveCase):
|
||||
"""SC #3: a cwd agent file with the same name as a home agent
|
||||
wins. The home bottle stays intact."""
|
||||
class TestCwdAgentIgnoredHomeWins(_ResolveCase):
|
||||
"""SC #3 (revised, PRD prd-new-trusted-agent-forge-identity): agents
|
||||
are home-only. A cwd agent file with the same name as a home agent no
|
||||
longer wins — it is warned-and-ignored, so the HOME agent's prompt is
|
||||
used and the home bottle stays intact."""
|
||||
|
||||
def test_cwd_wins(self):
|
||||
def test_home_wins_cwd_ignored(self):
|
||||
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
||||
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
|
||||
# Cwd overrides with a different prompt
|
||||
# Cwd agent with a different prompt is ignored entirely.
|
||||
_write(
|
||||
self.cwd_cb / "agents" / "implementer.md",
|
||||
"""
|
||||
@@ -129,14 +131,19 @@ class TestCwdAgentOverridesHome(_ResolveCase):
|
||||
""",
|
||||
)
|
||||
m = self.resolve().load_for_agent("implementer")
|
||||
self.assertIn("CWD-OVERRIDE-PROMPT", m.agent.prompt)
|
||||
# Home agent's body is used; the cwd override never applies.
|
||||
self.assertIn("feature implementation agent", m.agent.prompt)
|
||||
self.assertNotIn("CWD-OVERRIDE-PROMPT", m.agent.prompt)
|
||||
# Home bottle still present with its two egress routes
|
||||
self.assertEqual(2, len(m.bottle.egress.routes))
|
||||
|
||||
|
||||
class TestCwdBottlesIgnored(_ResolveCase):
|
||||
"""SC #4: a bottles/ dir under $CWD is ignored (with a warn).
|
||||
The home bottle still wins; cwd contributes only agents."""
|
||||
The home bottle still wins. Under
|
||||
PRD prd-new-trusted-agent-forge-identity a cwd agents/ dir is also
|
||||
ignored, so $CWD contributes nothing — the filesystem layout is the
|
||||
trust boundary."""
|
||||
|
||||
def test_ignored(self):
|
||||
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
||||
|
||||
@@ -212,13 +212,21 @@ class TestAgentValidation(unittest.TestCase):
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgent.from_dict("a", {"prompt": 5}, set())
|
||||
|
||||
def test_git_gate_repos_rejected_at_agent_level(self) -> None:
|
||||
def test_git_gate_rejected_at_agent_level(self) -> None:
|
||||
# `git-gate` (user or repos) is no longer accepted on an agent;
|
||||
# identity moved to `author`, repos stays bottle-only.
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgent.from_dict("a", {"git-gate": {"repos": {}}}, set())
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgent.from_dict(
|
||||
"a", {"git-gate": {"user": {"name": "x"}}}, set()
|
||||
)
|
||||
|
||||
def test_git_gate_empty_is_allowed(self) -> None:
|
||||
agent = ManifestAgent.from_dict("a", {"git-gate": {}}, set())
|
||||
self.assertTrue(agent.git_user.is_empty())
|
||||
def test_bottle_empty_git_gate_is_allowed(self) -> None:
|
||||
# An empty `git-gate: {}` on a bottle is still allowed (only the
|
||||
# optional `repos` subkey exists now); it contributes no git repos.
|
||||
bottle = ManifestBottle.from_dict("b", {"git-gate": {}})
|
||||
self.assertEqual((), bottle.git)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -228,9 +236,12 @@ class TestAgentValidation(unittest.TestCase):
|
||||
|
||||
class TestEagerIndexLookups(unittest.TestCase):
|
||||
def _idx(self) -> ManifestIndex:
|
||||
# Identity lives on the agent's `author` block now; at composition
|
||||
# it populates the effective bottle's git_user.
|
||||
return _idx({
|
||||
"bottles": {"b": {"git-gate": {"user": {"name": "Bot", "email": "b@x"}}}},
|
||||
"agents": {"a": {"bottle": "b"}},
|
||||
"bottles": {"b": {}},
|
||||
"agents": {"a": {"bottle": "b",
|
||||
"author": {"name": "Bot", "email": "b@x"}}},
|
||||
})
|
||||
|
||||
def test_unknown_bottle_section_is_empty(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user