feat(smolmachines): provision_prompt + provision_skills (PRD 0023 chunk 4a)
First slice of chunk 4: implement the two provisioning methods
that don't depend on agent-image tooling beyond `cp` and
`mkdir`. provision_ca / provision_git / provision_supervise
land once the agent-image gap is solved (chunk 4b+) — they need
update-ca-certificates, git, and the claude binary respectively,
none of which the chunk-2d alpine placeholder provides.
What this PR ships:
- `claude_bottle/backend/smolmachines/provision/` subpackage
with `prompt.py` + `skills.py`. Each routes through
`smolvm.machine_cp` / `machine_exec`. provision_prompt mirrors
the docker contract (file always copied; return value drives
--append-system-prompt-file iff the agent has a non-empty
prompt). provision_skills mkdir + cp per skill, matching
the docker backend's loop.
- prepare.py now writes the prompt file under
agent_state_dir(slug) with the agent's `prompt` body, mode
0o600. The in-guest path is `/root/.claude-bottle-prompt.txt`
(alpine has no `node` user; will become `/home/node/...` once
the real claude-bottle image lands).
- launch.py calls `provision(plan, machine_name)` after
machine_start. The returned prompt path threads to
SmolmachinesBottle so exec_claude can add
--append-system-prompt-file when the agent has a prompt.
- backend.py: provision_prompt / provision_skills now real;
provision_git is a deliberate stub (waiting on the git-gate
inner Plan + git in the agent image). provision_supervise
stays the chunk-2d stub.
Tests:
- 7 new unit cases (test_smolmachines_provision.py): argv
shape (mocked smolvm.machine_cp / .machine_exec),
prompt return-value contract, no-op-with-no-skills,
CLAUDE_BOTTLE_GUEST_SKILLS_DIR override, fail-on-missing-skill.
- 1 new integration case in test_smolmachines_launch.py:
end-to-end verification that the prompt file lands in the
alpine guest at /root/.claude-bottle-prompt.txt with the
expected content (via `bottle.exec("cat ...")`). The smoke +
the two TSI probes stay green.
552 unit + 4 integration (Darwin+smolvm+docker gated) passing.
What's left in chunk 4:
- 4b: thread the inner Plans (PipelockProxyPlan / EgressPlan /
GitGatePlan / SupervisePlan) through prepare + launch so the
bundle daemons actually run (currently daemons_csv="").
- 4c: the agent-image-conversion gap — get claude-code + git +
curl + ca-certificates into the guest image (build a
.smolmachine via `pack create --from-vm` after manual setup,
or push the docker image to a registry smolvm can pull).
- 4d: provision_ca + provision_git + provision_supervise once
4b + 4c land.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user