feat(smolmachines): provision_prompt + provision_skills (PRD 0023 chunk 4a) #69
@@ -13,6 +13,8 @@ from . import prepare as _prepare
|
|||||||
from .bottle import SmolmachinesBottle
|
from .bottle import SmolmachinesBottle
|
||||||
from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
|
from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
|
||||||
from .bottle_plan import SmolmachinesBottlePlan
|
from .bottle_plan import SmolmachinesBottlePlan
|
||||||
|
from .provision import prompt as _prompt
|
||||||
|
from .provision import skills as _skills
|
||||||
|
|
||||||
|
|
||||||
class SmolmachinesBottleBackend(
|
class SmolmachinesBottleBackend(
|
||||||
@@ -32,26 +34,26 @@ class SmolmachinesBottleBackend(
|
|||||||
def launch(
|
def launch(
|
||||||
self, plan: SmolmachinesBottlePlan
|
self, plan: SmolmachinesBottlePlan
|
||||||
) -> Generator[SmolmachinesBottle, None, None]:
|
) -> Generator[SmolmachinesBottle, None, None]:
|
||||||
with _launch.launch(plan) as bottle:
|
with _launch.launch(plan, provision=self.provision) as bottle:
|
||||||
yield 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(
|
def provision_prompt(
|
||||||
self, plan: SmolmachinesBottlePlan, target: str
|
self, plan: SmolmachinesBottlePlan, target: str
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
raise NotImplementedError("smolmachines provision_prompt → chunk 4")
|
return _prompt.provision_prompt(plan, target)
|
||||||
|
|
||||||
def provision_skills(
|
def provision_skills(
|
||||||
self, plan: SmolmachinesBottlePlan, target: str
|
self, plan: SmolmachinesBottlePlan, target: str
|
||||||
) -> None:
|
) -> None:
|
||||||
raise NotImplementedError("smolmachines provision_skills → chunk 4")
|
_skills.provision_skills(plan, target)
|
||||||
|
|
||||||
def provision_git(
|
def provision_git(
|
||||||
self, plan: SmolmachinesBottlePlan, target: str
|
self, plan: SmolmachinesBottlePlan, target: str
|
||||||
) -> None:
|
) -> 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:
|
def prepare_cleanup(self) -> SmolmachinesBottleCleanupPlan:
|
||||||
return SmolmachinesBottleCleanupPlan()
|
return SmolmachinesBottleCleanupPlan()
|
||||||
|
|||||||
@@ -21,8 +21,12 @@ class SmolmachinesBottle(Bottle):
|
|||||||
on the launch ExitStack — this class only routes runtime
|
on the launch ExitStack — this class only routes runtime
|
||||||
operations to the right `smolvm machine ...` subcommand."""
|
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
|
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:
|
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
|
||||||
"""Run `claude` interactively inside the VM. Inherits the
|
"""Run `claude` interactively inside the VM. Inherits the
|
||||||
@@ -37,7 +41,10 @@ class SmolmachinesBottle(Bottle):
|
|||||||
flags = ["smolvm", "machine", "exec", "--name", self.name]
|
flags = ["smolvm", "machine", "exec", "--name", self.name]
|
||||||
if tty:
|
if tty:
|
||||||
flags += ["-i", "-t"]
|
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)
|
result = subprocess.run(flags, check=False)
|
||||||
return result.returncode
|
return result.returncode
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ class SmolmachinesBottlePlan(BottlePlan):
|
|||||||
# `--smolfile` is mutually exclusive with `--from`, and
|
# `--smolfile` is mutually exclusive with `--from`, and
|
||||||
# `--from` is the path that avoids the registry-pull race).
|
# `--from` is the path that avoids the registry-pull race).
|
||||||
guest_env: dict[str, str]
|
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:
|
def print(self, *, remote_control: bool) -> None:
|
||||||
"""Compact y/N preflight. Same shape as the Docker
|
"""Compact y/N preflight. Same shape as the Docker
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ install + the inner Plan plumbing land in chunk 4."""
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import ExitStack, contextmanager
|
from contextlib import ExitStack, contextmanager
|
||||||
from typing import Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
from . import smolvm as _smolvm
|
from . import smolvm as _smolvm
|
||||||
from . import sidecar_bundle as _bundle
|
from . import sidecar_bundle as _bundle
|
||||||
@@ -26,6 +26,8 @@ from .bottle_plan import SmolmachinesBottlePlan
|
|||||||
@contextmanager
|
@contextmanager
|
||||||
def launch(
|
def launch(
|
||||||
plan: SmolmachinesBottlePlan,
|
plan: SmolmachinesBottlePlan,
|
||||||
|
*,
|
||||||
|
provision: Callable[[SmolmachinesBottlePlan, str], str | None],
|
||||||
) -> Generator[SmolmachinesBottle, None, None]:
|
) -> Generator[SmolmachinesBottle, None, None]:
|
||||||
"""Build + run the bottle and yield a handle; tear everything
|
"""Build + run the bottle and yield a handle; tear everything
|
||||||
down on exit. Errors during bringup unwind any partial state
|
down on exit. Errors during bringup unwind any partial state
|
||||||
@@ -76,7 +78,14 @@ def launch(
|
|||||||
_smolvm.machine_start(plan.machine_name)
|
_smolvm.machine_start(plan.machine_name)
|
||||||
stack.callback(_smolvm.machine_stop, plan.machine_name)
|
stack.callback(_smolvm.machine_stop, plan.machine_name)
|
||||||
|
|
||||||
# 3. Yield the handle.
|
# 3. Provision (CA / prompt / skills / git / supervise).
|
||||||
yield SmolmachinesBottle(plan.machine_name)
|
# 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:
|
finally:
|
||||||
stack.close()
|
stack.close()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from pathlib import Path
|
|||||||
from ...backend import BottleSpec
|
from ...backend import BottleSpec
|
||||||
from ...backend.docker.bottle_state import (
|
from ...backend.docker.bottle_state import (
|
||||||
BottleMetadata,
|
BottleMetadata,
|
||||||
|
agent_state_dir,
|
||||||
bottle_identity,
|
bottle_identity,
|
||||||
write_metadata,
|
write_metadata,
|
||||||
)
|
)
|
||||||
@@ -85,8 +86,21 @@ def resolve_plan(
|
|||||||
f"http://{bundle_ip}:{_BUNDLE_SUPERVISE_PORT}"
|
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}"
|
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
|
# alpine pulls cleanly from docker.io via smolvm's crane
|
||||||
# backend; the real claude-bottle image lives in the local
|
# backend; the real claude-bottle image lives in the local
|
||||||
# docker daemon and isn't reachable that way.
|
# docker daemon and isn't reachable that way.
|
||||||
@@ -103,6 +117,7 @@ def resolve_plan(
|
|||||||
machine_name=machine_name,
|
machine_name=machine_name,
|
||||||
agent_from_path=agent_from_path,
|
agent_from_path=agent_from_path,
|
||||||
guest_env=guest_env,
|
guest_env=guest_env,
|
||||||
|
prompt_file=prompt_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
@@ -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
|
||||||
@@ -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/<name>/). 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/<name>/ 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}")
|
||||||
@@ -38,11 +38,18 @@ from claude_bottle.manifest import Manifest
|
|||||||
from tests._docker import skip_unless_docker
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
|
||||||
|
_AGENT_PROMPT = "You are demo. Be brief."
|
||||||
|
|
||||||
|
|
||||||
def _minimal_manifest() -> Manifest:
|
def _minimal_manifest() -> Manifest:
|
||||||
return Manifest.from_json_obj({
|
return Manifest.from_json_obj({
|
||||||
"bottles": {"dev": {}},
|
"bottles": {"dev": {}},
|
||||||
"agents": {
|
"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}",
|
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):
|
def test_egress_port_bypass_probe(self):
|
||||||
# Agent dials <bundle-ip>:9099 (egress's port). TSI
|
# Agent dials <bundle-ip>:9099 (egress's port). TSI
|
||||||
# permits the IP, but egress will bind 127.0.0.1:9099
|
# permits the IP, but egress will bind 127.0.0.1:9099
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user