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
@@ -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,
)
+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(