Files
bot-bottle/tests/unit/test_docker_provision_git_user.py
T
didericis-claude 2fafeef61c
test / unit (pull_request) Successful in 49s
lint / lint (push) Failing after 1m3s
test / image-input-builds (pull_request) Successful in 1m3s
test / integration-docker (pull_request) Successful in 1m9s
refresh-image-locks / refresh (push) Successful in 1m26s
test / coverage (pull_request) Failing after 16s
tracker-policy-pr / check-pr (pull_request) Successful in 5s
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>
2026-07-27 00:39:55 +00:00

252 lines
9.2 KiB
Python

"""Unit: AgentProvider.provision_git — git-user and cwd .git copy (issue #86).
Mocks bottle.exec / bottle.cp_in and asserts on the dispatched script
shape. provision_git is now a method on AgentProvider (default impl);
the internal passes (_provision_cwd_git, _provision_git_gate_config,
_provision_git_user) are no longer exposed as separate helpers."""
from __future__ import annotations
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from unittest.mock import MagicMock
from bot_bottle.agent_provider import (
AgentProvider,
AgentProvisionPlan,
AgentProviderRuntime,
)
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 ManifestGitUser, ManifestIndex
class _Provider(AgentProvider):
"""Minimal concrete subclass for testing the default provision_git."""
@property
def runtime(self) -> AgentProviderRuntime:
return AgentProviderRuntime(
template="test", command="test", image="",
prompt_mode="append_file", bypass_args=(), resume_args=(),
)
def provision_plan(self, **kwargs): # type: ignore[override]
raise NotImplementedError
def provision_skills(self, plan, bottle): ... # type: ignore[override]
def provision_prompt(self, plan, bottle): ... # type: ignore[override]
def provision(self, plan, bottle): ... # type: ignore[override]
def provision_supervise_mcp(self, plan, bottle, supervise_url): ... # type: ignore[override]
def headless_prompt(self, prompt): return [] # type: ignore[override]
_PROVIDER = _Provider()
def _plan(*, git_user: dict | None = None, # type: ignore
git_repos: dict | None = None, # type: ignore
copy_cwd: bool = False,
user_cwd: str = "/tmp/x",
stage_dir: Path | None = None) -> DockerBottlePlan:
bottle_json: dict = {} # type: ignore
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": 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,
)
return DockerBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=stage_dir or Path("/tmp/stage"),
slug="demo-abc12",
forwarded_env={},
git_gate_plan=GitGatePlan(
slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
hook_script=Path("/tmp/git-gate-hook"),
access_hook_script=Path("/tmp/git-gate-access-hook"),
upstreams=(),
),
egress_plan=EgressPlan(
slug="demo-abc12",
routes_path=Path("/tmp/routes.yaml"),
routes=(),
token_env_map={},
),
supervise_plan=None,
use_runsc=False,
agent_provision=AgentProvisionPlan(
template="claude",
command="claude",
prompt_mode="append_file",
image="bot-bottle-claude:latest",
dockerfile="",
guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/prompt.txt"),
guest_env={},
),
)
def _make_bottle(name: str = "bot-bottle-demo-abc12") -> MagicMock:
bottle = MagicMock(spec=Bottle)
bottle.name = name
bottle.exec.return_value = ExecResult(returncode=0, stdout="", stderr="")
return bottle
def _git_config_exec_calls(bottle: MagicMock) -> list[tuple[str, str]]:
out = []
for c in bottle.exec.call_args_list:
script = c.args[0] if c.args else c.kwargs.get("script", "")
user = c.kwargs.get("user", c.args[1] if len(c.args) > 1 else "node")
if "git config" in script:
out.append((script, user))
return out
class TestProvisionGitUser(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-git-user.") # pylint: disable=consider-using-with
self.stage = Path(self._tmp.name)
def tearDown(self):
self._tmp.cleanup()
def test_noop_when_no_git_user(self):
bottle = _make_bottle()
_PROVIDER.provision_git(bottle, _plan(stage_dir=self.stage))
self.assertEqual([], _git_config_exec_calls(bottle))
def test_repairs_git_xdg_directory_for_runtime_user(self):
bottle = _make_bottle()
_PROVIDER.provision_git(bottle, _plan(stage_dir=self.stage))
script, user = next(
(call.args[0], call.kwargs.get("user", "node"))
for call in bottle.exec.call_args_list
if "/home/node/.config/git" in call.args[0]
)
self.assertEqual("root", user)
self.assertIn("chown node:node /home/node", script)
self.assertIn("chmod 755 /home/node", script)
self.assertIn("mkdir -p /home/node/.config/git", script)
self.assertIn("chown -R node:node /home/node/.config", script)
self.assertIn("chmod -R u+rwX,go+rX /home/node/.config", script)
def test_fails_closed_when_home_permissions_cannot_be_repaired(self):
bottle = _make_bottle()
bottle.exec.return_value = ExecResult(1, "", "read-only filesystem")
with self.assertRaises(SystemExit):
_PROVIDER.provision_git(bottle, _plan(stage_dir=self.stage))
def _git_plan(self) -> DockerBottlePlan:
return _plan(
git_repos={
"repo": {
"url": "ssh://git@example.com/repo.git",
"key": {"provider": "static", "path": "/dev/null"},
"host_key": "ssh-ed25519 AAAA",
},
},
stage_dir=self.stage,
)
def test_fails_closed_when_gitconfig_permissions_cannot_be_set(self):
bottle = _make_bottle()
bottle.exec.side_effect = [
ExecResult(0, "", ""),
ExecResult(1, "", "chown failed"),
]
with self.assertRaises(SystemExit):
_PROVIDER.provision_git(bottle, self._git_plan())
def test_fails_closed_when_runtime_user_cannot_read_gitconfig(self):
bottle = _make_bottle()
bottle.exec.side_effect = [
ExecResult(0, "", ""),
ExecResult(0, "", ""),
ExecResult(1, "", "permission denied"),
]
with self.assertRaises(SystemExit):
_PROVIDER.provision_git(bottle, self._git_plan())
def test_sets_name_and_email(self):
plan = _plan(
git_user={"name": "Eric Bauerfeld", "email": "eric@dideric.is"},
stage_dir=self.stage,
)
bottle = _make_bottle()
_PROVIDER.provision_git(bottle, plan)
calls = _git_config_exec_calls(bottle)
self.assertEqual(2, len(calls))
for script, user in calls:
self.assertEqual("node", user)
self.assertIn("git config --global", script)
self.assertIn("user.name", calls[0][0])
self.assertIn("Eric Bauerfeld", calls[0][0])
self.assertIn("user.email", calls[1][0])
self.assertIn("eric@dideric.is", calls[1][0])
def test_name_only_sets_only_name(self):
plan = _plan(git_user={"name": "Bot"}, stage_dir=self.stage)
bottle = _make_bottle()
_PROVIDER.provision_git(bottle, plan)
calls = _git_config_exec_calls(bottle)
self.assertEqual(1, len(calls))
self.assertIn("user.name", calls[0][0])
self.assertIn("Bot", calls[0][0])
def test_email_only_sets_only_email(self):
plan = _plan(
git_user={"email": "bot@example.com"}, stage_dir=self.stage,
)
bottle = _make_bottle()
_PROVIDER.provision_git(bottle, plan)
calls = _git_config_exec_calls(bottle)
self.assertEqual(1, len(calls))
self.assertIn("user.email", calls[0][0])
self.assertIn("bot@example.com", calls[0][0])
if __name__ == "__main__":
unittest.main()