diff --git a/claude_bottle/backend/smolmachines/backend.py b/claude_bottle/backend/smolmachines/backend.py index eb57b02..094e9dd 100644 --- a/claude_bottle/backend/smolmachines/backend.py +++ b/claude_bottle/backend/smolmachines/backend.py @@ -13,6 +13,8 @@ from . import prepare as _prepare from .bottle import SmolmachinesBottle from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan from .bottle_plan import SmolmachinesBottlePlan +from .provision import prompt as _prompt +from .provision import skills as _skills class SmolmachinesBottleBackend( @@ -32,26 +34,26 @@ class SmolmachinesBottleBackend( def launch( self, plan: SmolmachinesBottlePlan ) -> Generator[SmolmachinesBottle, None, None]: - with _launch.launch(plan) as bottle: + with _launch.launch(plan, provision=self.provision) as bottle: yield bottle - # The four `provision_*` methods land in chunk 4 alongside the - # `smolvm machine cp`-based copy-in flow. Stubs raise so any - # caller that reaches them before chunk 4 gets a clear pointer. def provision_prompt( self, plan: SmolmachinesBottlePlan, target: str ) -> str | None: - raise NotImplementedError("smolmachines provision_prompt → chunk 4") + return _prompt.provision_prompt(plan, target) def provision_skills( self, plan: SmolmachinesBottlePlan, target: str ) -> None: - raise NotImplementedError("smolmachines provision_skills → chunk 4") + _skills.provision_skills(plan, target) def provision_git( self, plan: SmolmachinesBottlePlan, target: str ) -> None: - raise NotImplementedError("smolmachines provision_git → chunk 4") + # Chunk 4 follow-on: needs the git-gate inner Plan (so the + # gitconfig insteadOf URL points at the gate's host) and + # the agent image must contain `git`. Stub for chunk 4a. + del plan, target def prepare_cleanup(self) -> SmolmachinesBottleCleanupPlan: return SmolmachinesBottleCleanupPlan() diff --git a/claude_bottle/backend/smolmachines/bottle.py b/claude_bottle/backend/smolmachines/bottle.py index e8ae2c5..efa0aa1 100644 --- a/claude_bottle/backend/smolmachines/bottle.py +++ b/claude_bottle/backend/smolmachines/bottle.py @@ -21,8 +21,12 @@ class SmolmachinesBottle(Bottle): on the launch ExitStack — this class only routes runtime operations to the right `smolvm machine ...` subcommand.""" - def __init__(self, machine_name: str) -> None: + def __init__(self, machine_name: str, *, prompt_path: str | None = None) -> None: self.name = machine_name + # In-VM path to the agent's prompt file. None when the + # agent declared no prompt (file still exists; we just + # don't pass --append-system-prompt-file). + self._prompt_path = prompt_path def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: """Run `claude` interactively inside the VM. Inherits the @@ -37,7 +41,10 @@ class SmolmachinesBottle(Bottle): flags = ["smolvm", "machine", "exec", "--name", self.name] if tty: flags += ["-i", "-t"] - flags += ["--", "claude", *argv] + claude_argv = ["claude"] + if self._prompt_path: + claude_argv += ["--append-system-prompt-file", self._prompt_path] + flags += ["--", *claude_argv, *argv] result = subprocess.run(flags, check=False) return result.returncode diff --git a/claude_bottle/backend/smolmachines/bottle_plan.py b/claude_bottle/backend/smolmachines/bottle_plan.py index 943ab3c..1213553 100644 --- a/claude_bottle/backend/smolmachines/bottle_plan.py +++ b/claude_bottle/backend/smolmachines/bottle_plan.py @@ -52,6 +52,11 @@ class SmolmachinesBottlePlan(BottlePlan): # `--smolfile` is mutually exclusive with `--from`, and # `--from` is the path that avoids the registry-pull race). guest_env: dict[str, str] + # Path to the agent's prompt file on the host. Always written + # (mode 0o600) so the in-VM path always exists; the file is + # empty when the agent has no prompt — claude-code reads it + # via --append-system-prompt-file only when non-empty. + prompt_file: Path def print(self, *, remote_control: bool) -> None: """Compact y/N preflight. Same shape as the Docker diff --git a/claude_bottle/backend/smolmachines/launch.py b/claude_bottle/backend/smolmachines/launch.py index 8a2845d..c0aa550 100644 --- a/claude_bottle/backend/smolmachines/launch.py +++ b/claude_bottle/backend/smolmachines/launch.py @@ -15,7 +15,7 @@ install + the inner Plan plumbing land in chunk 4.""" from __future__ import annotations from contextlib import ExitStack, contextmanager -from typing import Generator +from typing import Callable, Generator from . import smolvm as _smolvm from . import sidecar_bundle as _bundle @@ -26,6 +26,8 @@ from .bottle_plan import SmolmachinesBottlePlan @contextmanager def launch( plan: SmolmachinesBottlePlan, + *, + provision: Callable[[SmolmachinesBottlePlan, str], str | None], ) -> Generator[SmolmachinesBottle, None, None]: """Build + run the bottle and yield a handle; tear everything down on exit. Errors during bringup unwind any partial state @@ -76,7 +78,14 @@ def launch( _smolvm.machine_start(plan.machine_name) stack.callback(_smolvm.machine_stop, plan.machine_name) - # 3. Yield the handle. - yield SmolmachinesBottle(plan.machine_name) + # 3. Provision (CA / prompt / skills / git / supervise). + # The orchestrator runs each one in order; provision_* + # methods left as stubs (chunk 4 follow-ons) are no-ops. + prompt_path = provision(plan, plan.machine_name) + + # 4. Yield the handle. The prompt_path drives whether + # exec_claude adds --append-system-prompt-file to claude's + # argv (None → no flag). + yield SmolmachinesBottle(plan.machine_name, prompt_path=prompt_path) finally: stack.close() diff --git a/claude_bottle/backend/smolmachines/prepare.py b/claude_bottle/backend/smolmachines/prepare.py index 92ab9c0..0aee9ba 100644 --- a/claude_bottle/backend/smolmachines/prepare.py +++ b/claude_bottle/backend/smolmachines/prepare.py @@ -13,6 +13,7 @@ from pathlib import Path from ...backend import BottleSpec from ...backend.docker.bottle_state import ( BottleMetadata, + agent_state_dir, bottle_identity, write_metadata, ) @@ -85,8 +86,21 @@ def resolve_plan( f"http://{bundle_ip}:{_BUNDLE_SUPERVISE_PORT}" ) + # Prompt file is always written (mode 0o600) so the in-VM + # path always exists. Content is the agent's `prompt` + # field (markdown body) — empty for agents with no prompt. + # claude-code reads it via --append-system-prompt-file only + # when non-empty, but the file must exist either way to + # match the docker backend's contract. + agent_dir = agent_state_dir(slug) + agent_dir.mkdir(parents=True, exist_ok=True) + prompt_file = agent_dir / "prompt.txt" + agent = manifest.agents[spec.agent_name] + prompt_file.write_text(agent.prompt or "") + prompt_file.chmod(0o600) + machine_name = f"claude-bottle-{slug}" - # Chunk 2d placeholder until chunk 4's agent-image work lands. + # Chunk 2d placeholder until the agent-image work lands. # alpine pulls cleanly from docker.io via smolvm's crane # backend; the real claude-bottle image lives in the local # docker daemon and isn't reachable that way. @@ -103,6 +117,7 @@ def resolve_plan( machine_name=machine_name, agent_from_path=agent_from_path, guest_env=guest_env, + prompt_file=prompt_file, ) diff --git a/claude_bottle/backend/smolmachines/provision/__init__.py b/claude_bottle/backend/smolmachines/provision/__init__.py new file mode 100644 index 0000000..cc202c0 --- /dev/null +++ b/claude_bottle/backend/smolmachines/provision/__init__.py @@ -0,0 +1,14 @@ +"""Provisioning helpers for the smolmachines backend (PRD 0023 +chunk 4). + +Each method maps onto one of `BottleBackend`'s `provision_*` +overrides. They run after the VM is up + the bundle is reachable +and copy host-side state (prompt, skills, .git, CA cert, +supervise MCP config) into the guest via `smolvm machine cp` / +`smolvm machine exec`. + +Chunk 4a ships `provision_prompt` and `provision_skills` — the +two that don't depend on agent-image tooling (claude-code, +update-ca-certificates) beyond `cp` and `mkdir`. provision_ca / +provision_git / provision_supervise land once the agent-image +gap is solved.""" diff --git a/claude_bottle/backend/smolmachines/provision/prompt.py b/claude_bottle/backend/smolmachines/provision/prompt.py new file mode 100644 index 0000000..62dc22b --- /dev/null +++ b/claude_bottle/backend/smolmachines/provision/prompt.py @@ -0,0 +1,32 @@ +"""Copy the agent prompt into a running smolmachines bottle. + +The prompt file is always copied (so the in-guest path always +exists) but `--append-system-prompt-file` only fires when the +agent actually has a prompt — the return value signals which +case, mirroring the docker backend's contract.""" + +from __future__ import annotations + +from .. import smolvm as _smolvm +from ..bottle_plan import SmolmachinesBottlePlan + + +# In-guest path for the prompt. Smolvm's default agent image +# (alpine for now; the real claude-bottle image later) runs as +# root with $HOME=/root. The path is also surfaced as the return +# value so the caller can pass it via --append-system-prompt-file. +_IN_GUEST_PROMPT_PATH = "/root/.claude-bottle-prompt.txt" + + +def provision_prompt(plan: SmolmachinesBottlePlan, target: str) -> str | None: + """Copy the prompt file into the running smolvm guest. Returns + the in-guest path if the agent has a non-empty prompt (drives + --append-system-prompt-file), else None. The file is copied + either way so the path always exists — mirrors the docker + backend's behavior.""" + _smolvm.machine_cp( + str(plan.prompt_file), + f"{target}:{_IN_GUEST_PROMPT_PATH}", + ) + agent = plan.spec.manifest.agents[plan.spec.agent_name] + return _IN_GUEST_PROMPT_PATH if agent.prompt else None diff --git a/claude_bottle/backend/smolmachines/provision/skills.py b/claude_bottle/backend/smolmachines/provision/skills.py new file mode 100644 index 0000000..bd9404c --- /dev/null +++ b/claude_bottle/backend/smolmachines/provision/skills.py @@ -0,0 +1,59 @@ +"""Copy host-side skill directories into a running smolmachines +bottle. + +Skills are validated on the host before launch by +`BottleBackend._validate_skills`; this module assumes that +validation has already run. A skill that disappears between +validation and copy still dies loudly rather than silently +producing a partial guest.""" + +from __future__ import annotations + +import os + +from ....log import die, info +from ...util import host_skill_dir +from .. import smolvm as _smolvm +from ..bottle_plan import SmolmachinesBottlePlan + + +# In-guest path mirrors the docker backend's claude-skills +# convention (~/.claude/skills//). For smolmachines the +# agent is root by default; chunk 5+ may swap to a node user +# in the real claude-bottle image, at which point this path +# follows /home/node/ — the env knob below provides the override. +_DEFAULT_SKILLS_DIR = "/root/.claude/skills" + + +def provision_skills(plan: SmolmachinesBottlePlan, target: str) -> None: + """Copy each of the agent's named skills from the host's + ~/.claude/skills// into the guest's equivalent path. + For each skill: `mkdir -p` the destination, then + `smolvm machine cp` the host source dir over. No-op when the + agent has no skills. + + smolvm machine cp on a directory copies recursively (same + semantics as `cp -r`); unlike docker cp's trailing-slash + convention, smolvm doesn't need the `/.` suffix dance.""" + agent = plan.spec.manifest.agents[plan.spec.agent_name] + if not agent.skills: + return + + skills_dir = os.environ.get( + "CLAUDE_BOTTLE_GUEST_SKILLS_DIR", _DEFAULT_SKILLS_DIR, + ) + + _smolvm.machine_exec(target, ["mkdir", "-p", skills_dir]) + + for name in agent.skills: + src = host_skill_dir(name) + if not os.path.isdir(src): + die( + f"skill {name!r} disappeared from host between " + f"validation and copy at {src}." + ) + dst = f"{skills_dir}/{name}" + info(f"copying skill {name} into {target}:{dst}") + # Wipe any prior copy so re-runs don't accumulate. + _smolvm.machine_exec(target, ["rm", "-rf", dst]) + _smolvm.machine_cp(src, f"{target}:{dst}") diff --git a/tests/integration/test_smolmachines_launch.py b/tests/integration/test_smolmachines_launch.py index 6bd00bb..7c9dd35 100644 --- a/tests/integration/test_smolmachines_launch.py +++ b/tests/integration/test_smolmachines_launch.py @@ -38,11 +38,18 @@ from claude_bottle.manifest import Manifest from tests._docker import skip_unless_docker +_AGENT_PROMPT = "You are demo. Be brief." + + def _minimal_manifest() -> Manifest: return Manifest.from_json_obj({ "bottles": {"dev": {}}, "agents": { - "demo": {"skills": [], "prompt": "", "bottle": "dev"}, + "demo": { + "skills": [], + "prompt": _AGENT_PROMPT, + "bottle": "dev", + }, }, }) @@ -117,6 +124,15 @@ class TestSmolmachinesLaunch(unittest.TestCase): f"expected a connect-refusal message; got: {r.stdout!r}", ) + def test_prompt_file_lands_in_guest(self): + # provision_prompt copies the host-side prompt.txt into the + # guest at /root/.claude-bottle-prompt.txt. The content + # must match what the manifest declared so claude-code's + # --append-system-prompt-file reads the right text. + r = self.bottle.exec("cat /root/.claude-bottle-prompt.txt") + self.assertEqual(0, r.returncode, msg=r.stderr) + self.assertEqual(_AGENT_PROMPT, r.stdout.rstrip("\n")) + def test_egress_port_bypass_probe(self): # Agent dials :9099 (egress's port). TSI # permits the IP, but egress will bind 127.0.0.1:9099 diff --git a/tests/unit/test_smolmachines_provision.py b/tests/unit/test_smolmachines_provision.py new file mode 100644 index 0000000..49e02e2 --- /dev/null +++ b/tests/unit/test_smolmachines_provision.py @@ -0,0 +1,180 @@ +"""Unit: smolmachines provisioning helpers (PRD 0023 chunk 4a). + +Tests mock `smolvm.machine_cp` / `smolvm.machine_exec` and assert +on the dispatched call shape. The real round-trip lives in the +chunk-4 integration smoke.""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from unittest.mock import patch + +from claude_bottle.backend import BottleSpec +from claude_bottle.backend.smolmachines.bottle_plan import ( + SmolmachinesBottlePlan, +) +from claude_bottle.backend.smolmachines.provision import ( + prompt as _prompt, + skills as _skills, +) +from claude_bottle.manifest import Manifest + + +def _plan( + *, + agent_prompt: str = "", + skills: list[str] | None = None, +) -> SmolmachinesBottlePlan: + manifest = Manifest.from_json_obj({ + "bottles": {"dev": {}}, + "agents": { + "demo": { + "skills": list(skills or []), + "prompt": agent_prompt, + "bottle": "dev", + }, + }, + }) + spec = BottleSpec( + manifest=manifest, + agent_name="demo", + copy_cwd=False, + user_cwd="/tmp/x", + ) + return SmolmachinesBottlePlan( + spec=spec, + stage_dir=Path("/tmp/stage"), + slug="demo-abc12", + bundle_subnet="192.168.50.0/24", + bundle_gateway="192.168.50.1", + bundle_ip="192.168.50.2", + machine_name="claude-bottle-demo-abc12", + agent_from_path=Path("/tmp/agent.smolmachine"), + guest_env={}, + prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), + ) + + +class TestProvisionPrompt(unittest.TestCase): + def test_cp_uses_smolvm_machine_cp_with_machine_path_syntax(self): + with patch( + "claude_bottle.backend.smolmachines.provision.prompt._smolvm.machine_cp" + ) as cp: + _prompt.provision_prompt(_plan(), "claude-bottle-demo-abc12") + cp.assert_called_once_with( + "/tmp/state/demo-abc12/agent/prompt.txt", + "claude-bottle-demo-abc12:/root/.claude-bottle-prompt.txt", + ) + + def test_returns_path_when_agent_has_prompt(self): + with patch( + "claude_bottle.backend.smolmachines.provision.prompt._smolvm.machine_cp" + ): + r = _prompt.provision_prompt( + _plan(agent_prompt="You are a helpful assistant."), + "claude-bottle-demo-abc12", + ) + self.assertEqual("/root/.claude-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. + with patch( + "claude_bottle.backend.smolmachines.provision.prompt._smolvm.machine_cp" + ) as cp: + r = _prompt.provision_prompt(_plan(agent_prompt=""), "claude-bottle-demo-abc12") + self.assertIsNone(r) + cp.assert_called_once() + + +class TestProvisionSkills(unittest.TestCase): + def _patch_host_skill_dir(self, returns: dict[str, str]): + return patch( + "claude_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): + with patch( + "claude_bottle.backend.smolmachines.provision.skills._smolvm.machine_cp" + ) as cp, patch( + "claude_bottle.backend.smolmachines.provision.skills._smolvm.machine_exec" + ) as ex: + _skills.provision_skills(_plan(skills=[]), "claude-bottle-demo-abc12") + self.assertEqual(0, cp.call_count) + self.assertEqual(0, ex.call_count) + + def test_mkdir_plus_cp_per_skill(self): + with self._patch_host_skill_dir({ + "init-prd": "/host/skills/init-prd", + "verify": "/host/skills/verify", + }), patch( + "claude_bottle.backend.smolmachines.provision.skills.os.path.isdir", + return_value=True, + ), patch( + "claude_bottle.backend.smolmachines.provision.skills._smolvm.machine_cp" + ) as cp, patch( + "claude_bottle.backend.smolmachines.provision.skills._smolvm.machine_exec" + ) as ex: + _skills.provision_skills( + _plan(skills=["init-prd", "verify"]), + "claude-bottle-demo-abc12", + ) + + # mkdir -p the skills dir once + rm -rf per skill = 3 exec calls. + self.assertEqual(3, ex.call_count) + mkdir_call = ex.call_args_list[0] + self.assertEqual( + ("claude-bottle-demo-abc12", ["mkdir", "-p", "/root/.claude/skills"]), + mkdir_call.args, + ) + # Two cp calls, one per skill, into the per-skill subdir. + self.assertEqual(2, cp.call_count) + cp_targets = {call.args[1] for call in cp.call_args_list} + self.assertEqual( + { + "claude-bottle-demo-abc12:/root/.claude/skills/init-prd", + "claude-bottle-demo-abc12:/root/.claude/skills/verify", + }, + cp_targets, + ) + + def test_skills_dir_overridable_via_env(self): + import os + with self._patch_host_skill_dir({"init-prd": "/host/skills/init-prd"}), \ + patch( + "claude_bottle.backend.smolmachines.provision.skills.os.path.isdir", + return_value=True, + ), \ + patch.dict(os.environ, {"CLAUDE_BOTTLE_GUEST_SKILLS_DIR": "/home/node/.claude/skills"}), \ + patch( + "claude_bottle.backend.smolmachines.provision.skills._smolvm.machine_cp" + ) as cp, \ + patch( + "claude_bottle.backend.smolmachines.provision.skills._smolvm.machine_exec" + ): + _skills.provision_skills(_plan(skills=["init-prd"]), "claude-bottle-demo-abc12") + self.assertEqual( + "claude-bottle-demo-abc12:/home/node/.claude/skills/init-prd", + cp.call_args.args[1], + ) + + def test_missing_skill_dies(self): + with self._patch_host_skill_dir({"init-prd": "/host/skills/init-prd"}), \ + patch( + "claude_bottle.backend.smolmachines.provision.skills.os.path.isdir", + return_value=False, + ), \ + patch( + "claude_bottle.backend.smolmachines.provision.skills._smolvm.machine_cp" + ), \ + patch( + "claude_bottle.backend.smolmachines.provision.skills._smolvm.machine_exec" + ): + with self.assertRaises(SystemExit): + _skills.provision_skills(_plan(skills=["init-prd"]), "claude-bottle-demo-abc12") + + +if __name__ == "__main__": + unittest.main()