feat(docker): apply git_user via git config --global on provision (issue #86)
Add a third provisioning subcase to
`backend/docker/provision/git.py`:
_provision_git_user(plan, target)
Runs `docker exec -u node <container> git config --global
user.{name,email} <value>` for each field the bottle's
`git_user` declares. No-op when `git_user.is_empty()`.
`-u node` so `--global` lands in /home/node/.gitconfig (matching
the existing `_provision_git_gate_config` write location, so
agent-side `git` reads both configs from the same dotfile).
Name and email apply independently — a bottle declaring only
name runs just the user.name line, etc.
4 unit tests in `test_docker_provision_git_user.py`: no-op,
both-set, name-only, email-only. 657 unit tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"""Git provisioning inside a running Docker bottle.
|
"""Git provisioning inside a running Docker bottle.
|
||||||
|
|
||||||
Two concerns, both about git in the agent:
|
Three concerns, all about git in the agent:
|
||||||
|
|
||||||
1. If --cwd was passed AND the host cwd has a .git, copy that .git
|
1. If --cwd was passed AND the host cwd has a .git, copy that .git
|
||||||
into /home/node/workspace/.git so the agent operates on the
|
into /home/node/workspace/.git so the agent operates on the
|
||||||
@@ -11,6 +11,9 @@ Two concerns, both about git in the agent:
|
|||||||
ls-remote) transparently hits the per-agent git-gate. The
|
ls-remote) transparently hits the per-agent git-gate. The
|
||||||
gate mirrors the upstream in both directions, so URL
|
gate mirrors the upstream in both directions, so URL
|
||||||
rewriting is symmetric.
|
rewriting is symmetric.
|
||||||
|
3. If the bottle declares `git_user` (issue #86), set
|
||||||
|
`git config --global user.{name,email}` inside the bottle so
|
||||||
|
the agent's commits are attributed to that identity.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -26,10 +29,11 @@ from ..bottle_plan import DockerBottlePlan
|
|||||||
|
|
||||||
|
|
||||||
def provision_git(plan: DockerBottlePlan, target: str) -> None:
|
def provision_git(plan: DockerBottlePlan, target: str) -> None:
|
||||||
"""Set up git inside the bottle. Runs both subcases; each no-ops
|
"""Set up git inside the bottle. Runs all three subcases; each
|
||||||
when its condition isn't met."""
|
no-ops when its condition isn't met."""
|
||||||
_provision_cwd_git(plan, target)
|
_provision_cwd_git(plan, target)
|
||||||
_provision_git_gate_config(plan, target)
|
_provision_git_gate_config(plan, target)
|
||||||
|
_provision_git_user(plan, target)
|
||||||
|
|
||||||
|
|
||||||
def _provision_cwd_git(plan: DockerBottlePlan, target: str) -> None:
|
def _provision_cwd_git(plan: DockerBottlePlan, target: str) -> None:
|
||||||
@@ -78,3 +82,40 @@ def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None:
|
|||||||
)
|
)
|
||||||
docker_mod.docker_exec_root(container, ["chown", "node:node", container_gitconfig])
|
docker_mod.docker_exec_root(container, ["chown", "node:node", container_gitconfig])
|
||||||
docker_mod.docker_exec_root(container, ["chmod", "644", container_gitconfig])
|
docker_mod.docker_exec_root(container, ["chmod", "644", container_gitconfig])
|
||||||
|
|
||||||
|
|
||||||
|
def _provision_git_user(plan: DockerBottlePlan, target: str) -> None:
|
||||||
|
"""Apply `git config --global user.{name,email}` inside the
|
||||||
|
bottle so the agent's commits are attributed to the operator-
|
||||||
|
chosen identity instead of the agent image's default
|
||||||
|
(which is no user — git would refuse to commit at all
|
||||||
|
until the agent ran its own `git config`).
|
||||||
|
|
||||||
|
Runs as the `node` user so `--global` lands in
|
||||||
|
`/home/node/.gitconfig` (matching the existing
|
||||||
|
`_provision_git_gate_config` write location). No-op when the
|
||||||
|
bottle didn't declare `git_user`.
|
||||||
|
|
||||||
|
Each field set independently — name-only or email-only
|
||||||
|
configs only run the `git config` line for the field
|
||||||
|
present."""
|
||||||
|
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||||
|
gu = bottle.git_user
|
||||||
|
if gu.is_empty():
|
||||||
|
return
|
||||||
|
if gu.name:
|
||||||
|
info(f"git config --global user.name = {gu.name!r}")
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "exec", "-u", "node", target,
|
||||||
|
"git", "config", "--global", "user.name", gu.name],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
if gu.email:
|
||||||
|
info(f"git config --global user.email = {gu.email!r}")
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "exec", "-u", "node", target,
|
||||||
|
"git", "config", "--global", "user.email", gu.email],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Unit: docker backend `_provision_git_user` (issue #86).
|
||||||
|
|
||||||
|
Mocks `subprocess.run` and asserts the `docker exec -u node …
|
||||||
|
git config --global …` argv shape. The cwd + git-gate passes
|
||||||
|
are covered indirectly by the existing integration-shaped tests
|
||||||
|
in test_smolmachines_provision; this file targets just the new
|
||||||
|
git_user pass."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from claude_bottle.backend import BottleSpec
|
||||||
|
from claude_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||||
|
from claude_bottle.backend.docker.provision import git as _git
|
||||||
|
from claude_bottle.egress import EgressPlan
|
||||||
|
from claude_bottle.git_gate import GitGatePlan
|
||||||
|
from claude_bottle.manifest import Manifest
|
||||||
|
from claude_bottle.pipelock import PipelockProxyPlan
|
||||||
|
|
||||||
|
|
||||||
|
def _plan(*, git_user: dict | None = None,
|
||||||
|
stage_dir: Path | None = None) -> DockerBottlePlan:
|
||||||
|
bottle_json: dict = {}
|
||||||
|
if git_user is not None:
|
||||||
|
bottle_json["git_user"] = git_user
|
||||||
|
manifest = Manifest.from_json_obj({
|
||||||
|
"bottles": {"dev": bottle_json},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
})
|
||||||
|
spec = BottleSpec(
|
||||||
|
manifest=manifest, agent_name="demo",
|
||||||
|
copy_cwd=False, user_cwd="/tmp/x",
|
||||||
|
)
|
||||||
|
return DockerBottlePlan(
|
||||||
|
spec=spec,
|
||||||
|
stage_dir=stage_dir or Path("/tmp/stage"),
|
||||||
|
slug="demo-abc12",
|
||||||
|
container_name="claude-bottle-demo-abc12",
|
||||||
|
container_name_pinned=False,
|
||||||
|
image="claude-bottle:latest",
|
||||||
|
derived_image="",
|
||||||
|
runtime_image="claude-bottle:latest",
|
||||||
|
dockerfile_path="",
|
||||||
|
env_file=Path("/tmp/agent.env"),
|
||||||
|
forwarded_env={},
|
||||||
|
prompt_file=Path("/tmp/prompt.txt"),
|
||||||
|
proxy_plan=PipelockProxyPlan(
|
||||||
|
yaml_path=Path("/tmp/pipelock.yaml"), slug="demo-abc12",
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _git_config_calls(mock_run) -> list[list[str]]:
|
||||||
|
"""Filter `subprocess.run` calls down to the ones that run
|
||||||
|
`git config --global` inside the bottle, returning each argv."""
|
||||||
|
out: list[list[str]] = []
|
||||||
|
for call in mock_run.call_args_list:
|
||||||
|
argv = call.args[0]
|
||||||
|
if (len(argv) >= 5
|
||||||
|
and argv[0] == "docker" and argv[1] == "exec"
|
||||||
|
and "git" in argv and "config" in argv):
|
||||||
|
out.append(list(argv))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class TestProvisionGitUser(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-git-user.")
|
||||||
|
self.stage = Path(self._tmp.name)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def test_noop_when_no_git_user(self):
|
||||||
|
with patch.object(_git.subprocess, "run") as run:
|
||||||
|
_git._provision_git_user(
|
||||||
|
_plan(stage_dir=self.stage), "claude-bottle-demo-abc12",
|
||||||
|
)
|
||||||
|
self.assertEqual([], _git_config_calls(run))
|
||||||
|
|
||||||
|
def test_sets_name_and_email(self):
|
||||||
|
plan = _plan(
|
||||||
|
git_user={"name": "Eric Bauerfeld", "email": "eric@dideric.is"},
|
||||||
|
stage_dir=self.stage,
|
||||||
|
)
|
||||||
|
with patch.object(_git.subprocess, "run") as run:
|
||||||
|
_git._provision_git_user(plan, "claude-bottle-demo-abc12")
|
||||||
|
calls = _git_config_calls(run)
|
||||||
|
self.assertEqual(2, len(calls))
|
||||||
|
# All `docker exec` invocations run as `-u node` so the
|
||||||
|
# --global config lands in /home/node/.gitconfig.
|
||||||
|
for argv in calls:
|
||||||
|
self.assertEqual(
|
||||||
|
["docker", "exec", "-u", "node", "claude-bottle-demo-abc12",
|
||||||
|
"git", "config", "--global"],
|
||||||
|
argv[:8],
|
||||||
|
)
|
||||||
|
self.assertEqual(["user.name", "Eric Bauerfeld"], calls[0][8:])
|
||||||
|
self.assertEqual(["user.email", "eric@dideric.is"], calls[1][8:])
|
||||||
|
|
||||||
|
def test_name_only_sets_only_name(self):
|
||||||
|
plan = _plan(git_user={"name": "Bot"}, stage_dir=self.stage)
|
||||||
|
with patch.object(_git.subprocess, "run") as run:
|
||||||
|
_git._provision_git_user(plan, "claude-bottle-demo-abc12")
|
||||||
|
calls = _git_config_calls(run)
|
||||||
|
self.assertEqual(1, len(calls))
|
||||||
|
self.assertEqual(["user.name", "Bot"], calls[0][8:])
|
||||||
|
|
||||||
|
def test_email_only_sets_only_email(self):
|
||||||
|
plan = _plan(
|
||||||
|
git_user={"email": "bot@example.com"}, stage_dir=self.stage,
|
||||||
|
)
|
||||||
|
with patch.object(_git.subprocess, "run") as run:
|
||||||
|
_git._provision_git_user(plan, "claude-bottle-demo-abc12")
|
||||||
|
calls = _git_config_calls(run)
|
||||||
|
self.assertEqual(1, len(calls))
|
||||||
|
self.assertEqual(["user.email", "bot@example.com"], calls[0][8:])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user