Files
bot-bottle/tests/unit/test_smolmachines_provision.py
T
didericis-claude 0efc07ba67
test / unit (pull_request) Successful in 50s
test / integration (pull_request) Successful in 59s
test / unit (push) Successful in 43s
test / integration (push) Successful in 1m3s
refactor(backend): pass Bottle to provisioners instead of target string
Closes #178.

The backend provision functions now receive a Bottle handle with
exec / cp_in methods instead of a raw target string. Provisioner
modules use bottle.exec and bottle.cp_in in place of inlined
subprocess.run(["docker", "exec"/"cp", ...]) and direct
_smolvm.machine_cp / machine_exec calls. This decouples the
provisioners from backend-specific runtime primitives so future
refactors (e.g. the supervise rework) can swap the bottle's exec
implementation without touching every provisioner.

Each launch.py constructs the Bottle handle before calling
provision so it can be passed in; provision_prompt's return value
is wired back onto the bottle's prompt path attribute after the
fact.
2026-06-03 20:47:37 +00:00

815 lines
29 KiB
Python

"""Unit: smolmachines provisioning helpers (PRD 0023 chunks 4a + 4d).
Tests mock `bottle.exec` / `bottle.cp_in` and assert on the
dispatched script shape. The real round-trip lives in the chunk-4
integration smoke."""
from __future__ import annotations
import subprocess
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from unittest.mock import MagicMock, patch
from bot_bottle.agent_provider import (
AgentProvisionCommand,
AgentProvisionDir,
AgentProvisionFile,
AgentProvisionPlan,
)
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.smolmachines.bottle_plan import (
SmolmachinesBottlePlan,
)
from bot_bottle.backend.smolmachines.provision import (
ca as _ca,
git as _git,
prompt as _prompt,
provider_auth as _provider_auth,
skills as _skills,
supervise as _supervise,
workspace as _workspace,
)
from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import GitEntry, Manifest
from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan
def _make_bottle(
name: str = "bot-bottle-demo-abc12",
exec_result: ExecResult | None = None,
) -> MagicMock:
bottle = MagicMock(spec=Bottle)
bottle.name = name
bottle.exec.return_value = (
exec_result if exec_result is not None
else ExecResult(returncode=0, stdout="", stderr="")
)
return bottle
def _exec_scripts(bottle: MagicMock) -> list[str]:
"""All script strings passed to bottle.exec, in call order."""
return [c.args[0] for c in bottle.exec.call_args_list]
def _exec_users(bottle: MagicMock) -> list[str]:
"""user= kwarg from each bottle.exec call, in order."""
return [c.kwargs.get("user", "node") for c in bottle.exec.call_args_list]
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,
egress_routes: tuple[EgressRoute, ...] = (),
egress_ca_path: Path = Path(),
pipelock_ca_path: Path = Path(),
supervise: bool = False,
bundle_ip: str = "192.168.50.2",
agent_git_gate_host: str = "127.0.0.1:55555",
agent_supervise_url: str = "http://127.0.0.1:55556/",
codex_auth_file: Path | None = None,
agent_provider_template: str = "claude",
guest_env: dict[str, str] | None = None,
) -> SmolmachinesBottlePlan:
bottle_json: dict = {}
git_gate_json: dict = {}
if git:
git_gate_json["repos"] = {
g.Name: {
"url": g.Upstream,
"identity": g.IdentityFile,
}
for g in git
}
if git_user is not None:
git_gate_json["user"] = git_user
if git_gate_json:
bottle_json["git-gate"] = git_gate_json
if supervise:
bottle_json["supervise"] = True
manifest = Manifest.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {
"demo": {
"skills": list(skills or []),
"prompt": agent_prompt,
"bottle": "dev",
},
},
})
spec = BottleSpec(
manifest=manifest,
agent_name="demo",
copy_cwd=copy_cwd,
user_cwd=user_cwd,
)
supervise_plan = None
if supervise:
supervise_plan = SupervisePlan(
slug="demo-abc12",
queue_dir=Path("/tmp/queue"),
current_config_dir=Path("/tmp/current-config"),
)
return SmolmachinesBottlePlan(
spec=spec,
stage_dir=stage_dir or Path("/tmp/stage"),
slug="demo-abc12",
bundle_subnet="192.168.50.0/24",
bundle_gateway="192.168.50.1",
bundle_ip=bundle_ip,
machine_name="bot-bottle-demo-abc12",
agent_image_ref="bot-bottle-claude:latest",
guest_env=dict(guest_env or {}),
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
proxy_plan=PipelockProxyPlan(
yaml_path=Path("/tmp/pipelock.yaml"),
slug="demo-abc12",
ca_cert_host_path=pipelock_ca_path,
),
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=egress_routes,
token_env_map={},
mitmproxy_ca_cert_only_host_path=egress_ca_path,
),
supervise_plan=supervise_plan,
agent_git_gate_host=agent_git_gate_host,
agent_supervise_url=agent_supervise_url,
agent_provision=_agent_provision(
agent_provider_template,
codex_auth_file=codex_auth_file,
guest_env=dict(guest_env or {}),
),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
)
def _agent_provision(
template: str,
*,
codex_auth_file: Path | None = None,
guest_env: dict[str, str] | None = None,
) -> AgentProvisionPlan:
if template != "codex":
return AgentProvisionPlan(
template=template,
command=template,
prompt_mode="append_file",
image="",
dockerfile="",
guest_env=dict(guest_env or {}),
)
auth_dir = (guest_env or {}).get("CODEX_HOME", "/home/node/.codex")
files = [
AgentProvisionFile(
Path("/tmp/codex-config.toml"),
f"{auth_dir}/config.toml",
),
]
pre_copy: tuple[AgentProvisionCommand, ...] = ()
verify: tuple[AgentProvisionCommand, ...] = ()
if codex_auth_file is not None:
files.append(AgentProvisionFile(codex_auth_file, f"{auth_dir}/auth.json"))
pre_copy = (AgentProvisionCommand((
"find", auth_dir,
"-maxdepth", "1",
"-type", "f",
"(",
"-name", "*.sqlite",
"-o", "-name", "*.sqlite-*",
"-o", "-name", "*.codex-repair-*.bak",
")",
"-delete",
), "codex host credentials: could not reset runtime db files"),)
verify = (AgentProvisionCommand((
"runuser", "-u", "node", "--",
"env",
"HOME=/home/node",
f"CODEX_HOME={auth_dir}",
"codex", "login", "status",
), "codex host credentials: dummy auth was copied into the guest"),)
return AgentProvisionPlan(
template="codex",
command="codex",
prompt_mode="read_prompt_file",
image="bot-bottle-codex:latest",
dockerfile="",
guest_env=dict(guest_env or {}),
dirs=(AgentProvisionDir(auth_dir),),
files=tuple(files),
pre_copy=pre_copy,
verify=verify,
)
class TestProvisionPrompt(unittest.TestCase):
def test_cp_uses_bottle_cp_in(self):
bottle = _make_bottle()
_prompt.provision_prompt(_plan(), bottle)
bottle.cp_in.assert_called_once_with(
"/tmp/state/demo-abc12/agent/prompt.txt",
"/home/node/.bot-bottle-prompt.txt",
)
def test_returns_path_when_agent_has_prompt(self):
bottle = _make_bottle()
r = _prompt.provision_prompt(
_plan(agent_prompt="You are a helpful assistant."),
bottle,
)
self.assertEqual("/home/node/.bot-bottle-prompt.txt", r)
def test_returns_none_when_agent_has_no_prompt(self):
# The file is still copied (path-must-exist contract);
# only the return value differs.
bottle = _make_bottle()
r = _prompt.provision_prompt(_plan(agent_prompt=""), bottle)
self.assertIsNone(r)
bottle.cp_in.assert_called_once()
def test_chowns_to_node_after_copy(self):
# cp_in lands as root; without the chown, the node user
# can't read its own mode-600 prompt.
bottle = _make_bottle()
_prompt.provision_prompt(_plan(), bottle)
scripts = _exec_scripts(bottle)
self.assertTrue(
any("chown node:node" in s and "/home/node/.bot-bottle-prompt.txt" in s
for s in scripts)
)
self.assertTrue(
any("chmod 600" in s and "/home/node/.bot-bottle-prompt.txt" in s
for s in scripts)
)
class TestProvisionProviderAuth(unittest.TestCase):
def test_noop_for_non_codex_provider(self):
bottle = _make_bottle()
_provider_auth.provision_provider_auth(_plan(), bottle)
self.assertEqual(0, bottle.cp_in.call_count)
self.assertEqual(0, bottle.exec.call_count)
def test_codex_provider_trusts_launch_dir_without_auth_file(self):
bottle = _make_bottle()
_provider_auth.provision_provider_auth(
_plan(agent_provider_template="codex"),
bottle,
)
bottle.cp_in.assert_called_once_with(
"/tmp/codex-config.toml",
"/home/node/.codex/config.toml",
)
scripts = _exec_scripts(bottle)
self.assertTrue(any("mkdir -p" in s and "/home/node/.codex" in s for s in scripts))
self.assertTrue(
any("chown" in s and "node:node" in s and "/home/node/.codex/config.toml" in s
for s in scripts)
)
self.assertTrue(
any("chmod" in s and "600" in s and "/home/node/.codex/config.toml" in s
for s in scripts)
)
def test_copies_dummy_auth_json_to_codex_home(self):
bottle = _make_bottle()
_provider_auth.provision_provider_auth(
_plan(
agent_provider_template="codex",
codex_auth_file=Path("/tmp/codex-auth.json"),
),
bottle,
)
cp_calls = [c.args for c in bottle.cp_in.call_args_list]
self.assertIn(
("/tmp/codex-config.toml", "/home/node/.codex/config.toml"),
cp_calls,
)
self.assertIn(
("/tmp/codex-auth.json", "/home/node/.codex/auth.json"),
cp_calls,
)
scripts = _exec_scripts(bottle)
self.assertTrue(any("mkdir -p" in s and "/home/node/.codex" in s for s in scripts))
self.assertTrue(
any("chown" in s and "node:node" in s and s.rstrip().endswith("/home/node/.codex")
for s in scripts)
)
self.assertTrue(
any("chmod" in s and "700" in s and s.rstrip().endswith("/home/node/.codex")
for s in scripts)
)
# The pre_copy `find ... -delete` script should be present
# (shlex.join properly quotes the `(`/`)`/`*.sqlite`).
self.assertTrue(
any("find" in s and "-delete" in s and "*.sqlite" in s for s in scripts)
)
self.assertTrue(
any("chown" in s and "/home/node/.codex/auth.json" in s for s in scripts)
)
self.assertTrue(
any("chmod" in s and "600" in s and "/home/node/.codex/auth.json" in s
for s in scripts)
)
# Verify command runs `codex login status` via runuser node.
self.assertTrue(
any("runuser" in s and "codex login status" in s for s in scripts)
)
def test_honors_codex_home_from_guest_env(self):
bottle = _make_bottle()
_provider_auth.provision_provider_auth(
_plan(
agent_provider_template="codex",
codex_auth_file=Path("/tmp/codex-auth.json"),
guest_env={"CODEX_HOME": "/run/codex-home"},
),
bottle,
)
cp_calls = [c.args for c in bottle.cp_in.call_args_list]
self.assertIn(
("/tmp/codex-config.toml", "/run/codex-home/config.toml"),
cp_calls,
)
self.assertIn(
("/tmp/codex-auth.json", "/run/codex-home/auth.json"),
cp_calls,
)
scripts = _exec_scripts(bottle)
self.assertTrue(
any("runuser" in s and "CODEX_HOME=/run/codex-home" in s and "codex login status" in s
for s in scripts)
)
def test_dies_when_codex_home_cannot_be_created(self):
bottle = _make_bottle(
exec_result=ExecResult(1, "", "mkdir: nope\n"),
)
with self.assertRaises(SystemExit):
_provider_auth.provision_provider_auth(
_plan(
agent_provider_template="codex",
codex_auth_file=Path("/tmp/codex-auth.json"),
),
bottle,
)
self.assertEqual(0, bottle.cp_in.call_count)
self.assertEqual(1, bottle.exec.call_count)
def test_dies_when_codex_rejects_dummy_auth(self):
# CODEX_HOME setup ok, but codex login status fails (last exec).
bottle = _make_bottle()
bottle.exec.side_effect = [
ExecResult(0, "", ""), # mkdir CODEX_HOME
ExecResult(0, "", ""), # chown CODEX_HOME
ExecResult(0, "", ""), # chmod CODEX_HOME
ExecResult(0, "", ""), # find ... -delete (pre_copy)
ExecResult(0, "", ""), # chown config.toml
ExecResult(0, "", ""), # chmod config.toml
ExecResult(0, "", ""), # chown auth.json
ExecResult(0, "", ""), # chmod auth.json
ExecResult(1, "Not logged in\n", ""), # login status (verify)
]
with self.assertRaises(SystemExit):
_provider_auth.provision_provider_auth(
_plan(
agent_provider_template="codex",
codex_auth_file=Path("/tmp/codex-auth.json"),
),
bottle,
)
class TestProvisionSkills(unittest.TestCase):
def _patch_host_skill_dir(self, returns: dict[str, str]):
return patch(
"bot_bottle.backend.smolmachines.provision.skills.host_skill_dir",
side_effect=lambda n: returns.get(n, f"/nope/{n}"),
)
def test_no_op_when_agent_has_no_skills(self):
bottle = _make_bottle()
_skills.provision_skills(_plan(skills=[]), bottle)
self.assertEqual(0, bottle.cp_in.call_count)
self.assertEqual(0, bottle.exec.call_count)
def test_mkdir_plus_cp_per_skill(self):
bottle = _make_bottle()
with self._patch_host_skill_dir({
"init-prd": "/host/skills/init-prd",
"verify": "/host/skills/verify",
}), patch(
"bot_bottle.backend.smolmachines.provision.skills.os.path.isdir",
return_value=True,
):
_skills.provision_skills(
_plan(skills=["init-prd", "verify"]),
bottle,
)
# mkdir skills_dir once + (rm -rf + chown) per skill = 5 exec calls.
self.assertEqual(5, bottle.exec.call_count)
scripts = _exec_scripts(bottle)
self.assertTrue(
any("mkdir -p" in s and "/home/node/.claude/skills" in s for s in scripts)
)
# Two cp calls, one per skill, into the per-skill subdir.
self.assertEqual(2, bottle.cp_in.call_count)
cp_targets = {call.args[1] for call in bottle.cp_in.call_args_list}
self.assertEqual(
{
"/home/node/.claude/skills/init-prd",
"/home/node/.claude/skills/verify",
},
cp_targets,
)
# Each skill gets a chown -R node:node so claude can read it.
chown_scripts = [s for s in scripts if "chown -R node:node" in s]
self.assertEqual(2, len(chown_scripts))
def test_skills_dir_overridable_via_env(self):
import os
bottle = _make_bottle()
with self._patch_host_skill_dir({"init-prd": "/host/skills/init-prd"}), \
patch(
"bot_bottle.backend.smolmachines.provision.skills.os.path.isdir",
return_value=True,
), \
patch.dict(os.environ, {"BOT_BOTTLE_GUEST_SKILLS_DIR": "/home/node/.claude/skills"}):
_skills.provision_skills(_plan(skills=["init-prd"]), bottle)
self.assertEqual(
"/home/node/.claude/skills/init-prd",
bottle.cp_in.call_args.args[1],
)
def test_missing_skill_dies(self):
bottle = _make_bottle()
with self._patch_host_skill_dir({"init-prd": "/host/skills/init-prd"}), \
patch(
"bot_bottle.backend.smolmachines.provision.skills.os.path.isdir",
return_value=False,
):
with self.assertRaises(SystemExit):
_skills.provision_skills(_plan(skills=["init-prd"]), bottle)
def _write_self_signed_cert(path: Path) -> None:
"""Drop a real self-signed PEM at `path` so provision_ca's
fingerprint computation (PEM_cert_to_DER_cert + sha256) has
actual bytes to chew on. Generated once per test via openssl."""
subprocess.run(
["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", "/dev/null",
"-out", str(path),
"-days", "1",
"-subj", "/CN=test"],
check=True, capture_output=True,
)
class TestProvisionCA(unittest.TestCase):
"""provision_ca selects the right CA cert (egress when the
bottle has routes, else pipelock) and dispatches
cp_in + exec in the right order."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-ca.")
self.tmp = Path(self._tmp.name)
self.pipelock_ca = self.tmp / "pipelock-ca.pem"
self.egress_ca = self.tmp / "egress-ca.pem"
_write_self_signed_cert(self.pipelock_ca)
_write_self_signed_cert(self.egress_ca)
def tearDown(self):
self._tmp.cleanup()
# provision_ca dies hard if update-ca-certificates' exit
# is non-zero; supply a stock success return so the bulk of
# the tests below exercise the happy path.
_UPDATE_OK = ExecResult(
returncode=0,
stdout="Updating certificates in /etc/ssl/certs...\n1 added, 0 removed; done.\n",
stderr="",
)
def test_pipelock_path_when_no_routes(self):
plan = _plan(pipelock_ca_path=self.pipelock_ca)
bottle = _make_bottle(exec_result=self._UPDATE_OK)
_ca.provision_ca(plan, bottle)
bottle.cp_in.assert_called_once_with(
str(self.pipelock_ca),
_ca.AGENT_CA_PATH,
)
# chmod + chown + update-ca-certificates are folded into
# one exec invocation; look at the single exec's script
# rather than expecting separate calls.
bottle.exec.assert_called_once()
script = bottle.exec.call_args.args[0]
self.assertIn("chmod 644", script)
self.assertIn("update-ca-certificates", script)
self.assertEqual("root", bottle.exec.call_args.kwargs.get("user"))
def test_egress_path_when_routes_declared(self):
plan = _plan(
egress_routes=(EgressRoute(host="api.anthropic.com"),),
egress_ca_path=self.egress_ca,
pipelock_ca_path=self.pipelock_ca,
)
bottle = _make_bottle(exec_result=self._UPDATE_OK)
_ca.provision_ca(plan, bottle)
# When routes are declared, egress is the agent's first hop,
# so egress's CA is the one that gets installed.
bottle.cp_in.assert_called_once_with(
str(self.egress_ca),
_ca.AGENT_CA_PATH,
)
def test_retries_smolvm_sigkill_during_update_ca(self):
plan = _plan(pipelock_ca_path=self.pipelock_ca)
killed = ExecResult(
returncode=137,
stdout="Updating certificates in /etc/ssl/certs...\n",
stderr="",
)
bottle = _make_bottle()
bottle.exec.side_effect = [killed, self._UPDATE_OK]
with patch(
"bot_bottle.backend.smolmachines.provision.ca.time.sleep"
) as sleep:
_ca.provision_ca(plan, bottle)
self.assertEqual(2, bottle.exec.call_count)
sleep.assert_called_once_with(1.0)
def test_dies_when_selected_cert_missing(self):
# Plan claims a pipelock cert at a path that doesn't exist —
# something went wrong in launch's pipelock_tls_init.
plan = _plan(pipelock_ca_path=self.tmp / "does-not-exist.pem")
bottle = _make_bottle()
with self.assertRaises(SystemExit):
_ca.provision_ca(plan, bottle)
class TestProvisionGit(unittest.TestCase):
"""provision_git dispatches two independent passes (cwd .git
copy + gitconfig insteadOf write); each no-ops on its own
when its condition doesn't hold."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-git.")
self.stage = Path(self._tmp.name)
def tearDown(self):
self._tmp.cleanup()
def test_noop_when_no_cwd_and_no_git_entries(self):
bottle = _make_bottle()
_git.provision_git(_plan(stage_dir=self.stage), bottle)
bottle.cp_in.assert_not_called()
bottle.exec.assert_not_called()
def test_copies_cwd_git_when_copy_cwd_and_git_present(self):
# Stage a fake host .git dir under user_cwd so the path-
# check in _provision_cwd_git fires.
cwd = self.stage / "cwd"
(cwd / ".git").mkdir(parents=True)
plan = _plan(
copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage,
)
bottle = _make_bottle()
_git.provision_git(plan, bottle)
bottle.cp_in.assert_called_once_with(
f"{cwd}/.git",
"/home/node/workspace/.git",
)
scripts = _exec_scripts(bottle)
self.assertTrue(any("mkdir -p" in s and "/home/node/workspace" in s for s in scripts))
# chown the workspace tree so the agent (node) owns it.
self.assertTrue(
any("chown -R" in s and "node:node" in s and "/home/node/workspace/.git" in s
for s in scripts)
)
def test_skips_cwd_when_copy_cwd_false(self):
plan = _plan(copy_cwd=False, stage_dir=self.stage)
bottle = _make_bottle()
_git.provision_git(plan, bottle)
bottle.cp_in.assert_not_called()
def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self):
# Smolmachines's TSI-allowlisted guest dials git-gate via
# smart HTTP at `127.0.0.1:<host port>` — the bundle's
# git HTTP port is published on host loopback at launch
# time, and the plan carries the discovered host port.
plan = _plan(
git=[GitEntry(
Name="bot-bottle",
Upstream="ssh://git@host/repo.git",
IdentityFile="~/.ssh/id_ed25519",
)],
stage_dir=self.stage,
agent_git_gate_host="127.0.0.1:9418",
)
bottle = _make_bottle()
_git.provision_git(plan, bottle)
# The staged gitconfig path is whatever NamedTemporaryFile
# picked; we read its contents.
cp_call = bottle.cp_in.call_args
staged_path = Path(cp_call.args[0])
self.assertEqual(self.stage, staged_path.parent)
content = staged_path.read_text()
self.assertIn(
'[url "http://127.0.0.1:9418/bot-bottle.git"]', content,
)
self.assertIn(
"\tinsteadOf = ssh://git@host/repo.git", content,
)
class TestBundleLaunchSpec(unittest.TestCase):
def test_git_gate_uses_http_daemon_for_smolmachines(self):
plan = _plan()
plan = replace(
plan,
git_gate_plan=replace(
plan.git_gate_plan,
upstreams=(GitGateUpstream(
name="bot-bottle",
upstream_url="ssh://git@host/repo.git",
upstream_host="host",
upstream_port="22",
identity_file="/tmp/key",
known_host_key="",
),),
),
)
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
self.assertEqual(
"egress,pipelock,git-gate,git-http",
spec.daemons_csv,
)
self.assertIn(9420, spec.ports_to_publish)
self.assertNotIn(9418, spec.ports_to_publish)
class TestProvisionGitUser(unittest.TestCase):
"""`_provision_git_user` runs `git config --global` inside the
guest as the node user. SmolmachinesBottle.exec sets HOME and
USER automatically for the requested user, so --global lands
in /home/node/.gitconfig. No-op when the bottle didn't declare
git_user (issue #86)."""
def _git_config_calls(self, bottle: MagicMock) -> list[tuple[str, str]]:
"""Filter bottle.exec calls down to git-config invocations,
return list of (script, user) tuples."""
out = []
for c in bottle.exec.call_args_list:
script = c.args[0] if c.args else ""
user = c.kwargs.get("user", "node")
if "git config" in script:
out.append((script, user))
return out
def test_noop_when_no_git_user(self):
bottle = _make_bottle()
_git._provision_git_user(_plan(), bottle)
self.assertEqual([], self._git_config_calls(bottle))
def test_sets_name_and_email_as_node(self):
plan = _plan(git_user={
"name": "Eric Bauerfeld",
"email": "eric@dideric.is",
})
bottle = _make_bottle()
_git._provision_git_user(plan, bottle)
calls = self._git_config_calls(bottle)
self.assertEqual(2, len(calls))
# Both run as node so SmolmachinesBottle.exec sets HOME=/home/node
# automatically, ensuring --global writes to /home/node/.gitconfig.
for script, user in calls:
self.assertEqual("node", user)
self.assertIn("git config --global", script)
self.assertIn("user.name", calls[0][0])
self.assertIn("Eric Bauerfeld", calls[0][0])
self.assertIn("user.email", calls[1][0])
self.assertIn("eric@dideric.is", calls[1][0])
def test_name_only(self):
plan = _plan(git_user={"name": "Bot"})
bottle = _make_bottle()
_git._provision_git_user(plan, bottle)
calls = self._git_config_calls(bottle)
self.assertEqual(1, len(calls))
self.assertIn("user.name", calls[0][0])
self.assertIn("Bot", calls[0][0])
def test_email_only(self):
plan = _plan(git_user={"email": "bot@example.com"})
bottle = _make_bottle()
_git._provision_git_user(plan, bottle)
calls = self._git_config_calls(bottle)
self.assertEqual(1, len(calls))
self.assertIn("user.email", calls[0][0])
self.assertIn("bot@example.com", calls[0][0])
class TestProvisionWorkspace(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-workspace.")
self.stage = Path(self._tmp.name)
def tearDown(self):
self._tmp.cleanup()
def test_noop_when_copy_cwd_false(self):
plan = _plan(copy_cwd=False, stage_dir=self.stage)
bottle = _make_bottle()
_workspace.provision_workspace(plan, bottle)
bottle.cp_in.assert_not_called()
bottle.exec.assert_not_called()
def test_copies_workspace_to_plan_path_and_chowns(self):
cwd = self.stage / "cwd"
cwd.mkdir()
plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage)
bottle = _make_bottle()
_workspace.provision_workspace(plan, bottle)
bottle.cp_in.assert_called_once_with(
str(cwd),
"/home/node/workspace",
)
scripts = _exec_scripts(bottle)
self.assertTrue(
any("rm -rf /home/node/workspace" in s and "mkdir -p /home/node" in s
for s in scripts)
)
self.assertTrue(
any("chown -R node:node /home/node/workspace" in s
and "chmod 755 /home/node/workspace" in s
for s in scripts)
)
class TestProvisionSupervise(unittest.TestCase):
def test_noop_when_supervise_not_enabled(self):
bottle = _make_bottle()
_supervise.provision_supervise(_plan(), bottle)
bottle.exec.assert_not_called()
def test_calls_claude_mcp_add_when_supervise_enabled(self):
plan = _plan(
supervise=True,
agent_supervise_url="http://127.0.0.1:9100/",
)
bottle = _make_bottle()
_supervise.provision_supervise(plan, bottle)
bottle.exec.assert_called_once()
script = bottle.exec.call_args.args[0]
user = bottle.exec.call_args.kwargs.get("user")
self.assertEqual("node", user)
# SmolmachinesBottle.exec(user="node") handles uid switch +
# HOME setup automatically — the script itself is just the
# claude command.
self.assertIn("claude mcp add", script)
self.assertIn("--scope user", script)
self.assertIn("--transport http", script)
self.assertIn("supervise", script)
self.assertIn("http://127.0.0.1:9100/", script)
def test_non_zero_exit_logs_warning_but_does_not_raise(self):
plan = _plan(supervise=True)
bottle = _make_bottle(
exec_result=ExecResult(returncode=1, stdout="", stderr="boom"),
)
# No raise — the bottle still works without the MCP
# entry, so we log and move on.
_supervise.provision_supervise(plan, bottle)
if __name__ == "__main__":
unittest.main()