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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user