feat(smolmachines): apply git_user via git config --global on provision (issue #86)

Mirror the docker backend's third provisioning subcase in
`backend/smolmachines/provision/git.py`:

  _provision_git_user(plan, target)

Runs `smolvm machine exec --name <M> -e HOME=/home/node -e
USER=node -- runuser -u node -- git config --global user.<X>
<value>` for each git_user field. No-op when
`git_user.is_empty()`.

`runuser -u node --` switches the UID without invoking a login
shell (matching the existing `Bottle.exec_claude` pattern).
HOME / USER are forced via `smolvm -e` because bare runuser
inherits root's HOME=/root, which would put --global in
/root/.gitconfig instead of /home/node/.gitconfig (where the
existing `_provision_git_gate_config` writes).

4 unit tests in test_smolmachines_provision.TestProvisionGitUser:
no-op, both-set (asserts runuser prefix + HOME/USER env),
name-only, email-only. 661 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 23:00:21 -04:00
parent 9e69aaa99a
commit c9cdd41110
2 changed files with 112 additions and 2 deletions
+72
View File
@@ -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(