diff --git a/claude_bottle/backend/smolmachines/provision/git.py b/claude_bottle/backend/smolmachines/provision/git.py index dc3fefb..42bdb39 100644 --- a/claude_bottle/backend/smolmachines/provision/git.py +++ b/claude_bottle/backend/smolmachines/provision/git.py @@ -1,7 +1,7 @@ """Git provisioning inside a running smolmachines bottle (PRD 0023 chunk 4d). -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 into /home/node/workspace/.git so the agent operates on @@ -11,6 +11,9 @@ Two concerns, both about git in the agent: against a declared upstream transparently hits the per-bottle git-gate. The gate mirrors the upstream in both directions, so URL rewriting is symmetric. + 3. If the bottle declares `git_user` (issue #86), set + `git config --global user.{name,email}` inside the guest so + the agent's commits are attributed to that identity. Differs from `backend.docker.provision.git` in one address detail: the TSI-allowlisted guest can only reach the bundle's pinned IP @@ -44,10 +47,11 @@ def _guest_home() -> str: def provision_git(plan: SmolmachinesBottlePlan, target: str) -> None: - """Set up git inside the guest. Runs both subcases; each + """Set up git inside the guest. Runs all three subcases; each no-ops when its condition isn't met.""" _provision_cwd_git(plan, target) _provision_git_gate_config(plan, target) + _provision_git_user(plan, target) def _provision_cwd_git(plan: SmolmachinesBottlePlan, target: str) -> None: @@ -101,3 +105,37 @@ def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> Non _smolvm.machine_cp(str(config_file), f"{target}:{guest_gitconfig}") _smolvm.machine_exec(target, ["chown", "node:node", guest_gitconfig]) _smolvm.machine_exec(target, ["chmod", "644", guest_gitconfig]) + + +def _provision_git_user( + plan: SmolmachinesBottlePlan, target: str, +) -> None: + """Apply `git config --global user.{name,email}` inside the + guest as the node user so --global lands in the same + `/home/node/.gitconfig` that `_provision_git_gate_config` + writes to. No-op when the bottle didn't declare `git_user`. + + Runs via `runuser -u node --`; HOME is forced via smolvm's + `-e` flag because runuser (without -l) inherits root's + HOME=/root, which would put --global in the wrong file.""" + bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) + gu = bottle.git_user + if gu.is_empty(): + return + env = {"HOME": _guest_home(), "USER": "node"} + if gu.name: + info(f"git config --global user.name = {gu.name!r}") + _smolvm.machine_exec( + target, + ["runuser", "-u", "node", "--", + "git", "config", "--global", "user.name", gu.name], + env=env, + ) + if gu.email: + info(f"git config --global user.email = {gu.email!r}") + _smolvm.machine_exec( + target, + ["runuser", "-u", "node", "--", + "git", "config", "--global", "user.email", gu.email], + env=env, + ) diff --git a/tests/unit/test_smolmachines_provision.py b/tests/unit/test_smolmachines_provision.py index d738363..9cfa6dc 100644 --- a/tests/unit/test_smolmachines_provision.py +++ b/tests/unit/test_smolmachines_provision.py @@ -36,6 +36,7 @@ def _plan( agent_prompt: str = "", skills: list[str] | None = None, git: list[GitEntry] = (), + git_user: dict | None = None, copy_cwd: bool = False, user_cwd: str = "/tmp/x", stage_dir: Path | None = None, @@ -57,6 +58,8 @@ def _plan( } for g in git ] + if git_user is not None: + bottle_json["git_user"] = git_user if supervise: bottle_json["supervise"] = True manifest = Manifest.from_json_obj({ @@ -467,6 +470,75 @@ class TestProvisionGit(unittest.TestCase): ) +class TestProvisionGitUser(unittest.TestCase): + """`_provision_git_user` runs `git config --global` inside the + guest as the node user with HOME forced via `smolvm -e` + (otherwise --global lands in /root/.gitconfig). No-op when the + bottle didn't declare git_user (issue #86).""" + + def _git_config_calls(self, mock_exec): + """Filter machine_exec calls down to git-config invocations, + return list of (argv, env-dict) tuples.""" + out = [] + for c in mock_exec.call_args_list: + argv = c.args[1] if len(c.args) > 1 else c.kwargs.get("argv", []) + if "git" in argv and "config" in argv: + out.append((argv, c.kwargs.get("env") or {})) + return out + + def test_noop_when_no_git_user(self): + with patch( + "claude_bottle.backend.smolmachines.provision.git._smolvm.machine_exec" + ) as ex: + _git._provision_git_user(_plan(), "claude-bottle-demo-abc12") + self.assertEqual([], self._git_config_calls(ex)) + + def test_sets_name_and_email_as_node(self): + plan = _plan(git_user={ + "name": "Eric Bauerfeld", + "email": "eric@dideric.is", + }) + with patch( + "claude_bottle.backend.smolmachines.provision.git._smolvm.machine_exec" + ) as ex: + _git._provision_git_user(plan, "claude-bottle-demo-abc12") + calls = self._git_config_calls(ex) + self.assertEqual(2, len(calls)) + # Both go through `runuser -u node --` so they run as node; + # HOME is forced via smolvm -e so --global writes to + # /home/node/.gitconfig and not /root/.gitconfig. + for argv, env in calls: + self.assertEqual( + ["runuser", "-u", "node", "--", + "git", "config", "--global"], + argv[:7], + ) + self.assertEqual("/home/node", env.get("HOME")) + self.assertEqual("node", env.get("USER")) + self.assertEqual(["user.name", "Eric Bauerfeld"], calls[0][0][7:]) + self.assertEqual(["user.email", "eric@dideric.is"], calls[1][0][7:]) + + def test_name_only(self): + plan = _plan(git_user={"name": "Bot"}) + with patch( + "claude_bottle.backend.smolmachines.provision.git._smolvm.machine_exec" + ) as ex: + _git._provision_git_user(plan, "claude-bottle-demo-abc12") + calls = self._git_config_calls(ex) + self.assertEqual(1, len(calls)) + self.assertEqual(["user.name", "Bot"], calls[0][0][7:]) + + def test_email_only(self): + plan = _plan(git_user={"email": "bot@example.com"}) + with patch( + "claude_bottle.backend.smolmachines.provision.git._smolvm.machine_exec" + ) as ex: + _git._provision_git_user(plan, "claude-bottle-demo-abc12") + calls = self._git_config_calls(ex) + self.assertEqual(1, len(calls)) + self.assertEqual(["user.email", "bot@example.com"], calls[0][0][7:]) + + class TestProvisionSupervise(unittest.TestCase): def test_noop_when_supervise_not_enabled(self): with patch(