9e3b7e441e
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>
92 lines
3.9 KiB
Python
92 lines
3.9 KiB
Python
"""End-to-end launch flow for the smolmachines backend
|
|
(PRD 0023 chunk 2d).
|
|
|
|
Brings up the per-bottle docker bridge + sidecar bundle, creates
|
|
+ starts the smolvm guest pointed at the bundle's pinned IP via
|
|
the Smolfile's TSI allowlist, yields a `SmolmachinesBottle`
|
|
handle, tears everything down on context exit.
|
|
|
|
Chunk-2d scope: smoke-test plumbing for the launch + exec round
|
|
trip. The bundle daemons aren't supplied with config files yet
|
|
(pipelock.yaml, routes.yaml, etc.); the bundle's init supervisor
|
|
exits cleanly when nothing is configured. Real provisioning + CA
|
|
install + the inner Plan plumbing land in chunk 4."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import ExitStack, contextmanager
|
|
from typing import Callable, Generator
|
|
|
|
from . import smolvm as _smolvm
|
|
from . import sidecar_bundle as _bundle
|
|
from .bottle import SmolmachinesBottle
|
|
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
|
|
via the ExitStack."""
|
|
stack = ExitStack()
|
|
try:
|
|
# 1. Per-bottle docker bridge + bundle container.
|
|
network = _bundle.bundle_network_name(plan.slug)
|
|
_bundle.create_bundle_network(network, plan.bundle_subnet, plan.bundle_gateway)
|
|
stack.callback(_bundle.remove_bundle_network, network)
|
|
|
|
bundle_spec = _bundle.BundleLaunchSpec(
|
|
slug=plan.slug,
|
|
network_name=network,
|
|
subnet=plan.bundle_subnet,
|
|
gateway=plan.bundle_gateway,
|
|
bundle_ip=plan.bundle_ip,
|
|
# Chunk 2d: empty daemon set — the init supervisor
|
|
# logs "no daemons selected" and idles. Real daemon
|
|
# bringup with inner-Plan-driven env + volumes lands
|
|
# in chunk 4 alongside provisioning.
|
|
daemons_csv="",
|
|
# PRD 0023 chunk 3: pin egress to localhost INSIDE the
|
|
# bundle so the agent's TSI-permitted `<bundle-ip>:*`
|
|
# connect to :9099 refuses at the socket level. Always
|
|
# set in smolmachines mode — agent dials pipelock, not
|
|
# egress, so egress is bundle-internal regardless of
|
|
# whether routes are declared. The docker backend
|
|
# doesn't set this env (egress on 0.0.0.0 by default)
|
|
# since the docker agent goes via the egress alias.
|
|
environment=("EGRESS_LISTEN_HOST=127.0.0.1",),
|
|
)
|
|
_bundle.start_bundle(bundle_spec)
|
|
stack.callback(_bundle.stop_bundle, plan.slug)
|
|
|
|
# 2. smolvm VM. --from carries the pre-packed
|
|
# .smolmachine artifact (built by prepare); --allow-cidr
|
|
# + -e carry the per-bottle TSI allowlist + env. Smolfile
|
|
# isn't usable here — smolvm 0.8.0 makes `--from` and
|
|
# `--smolfile` mutually exclusive.
|
|
_smolvm.machine_create(
|
|
plan.machine_name,
|
|
from_path=plan.agent_from_path,
|
|
allow_cidrs=[f"{plan.bundle_ip}/32"],
|
|
env=plan.guest_env,
|
|
)
|
|
stack.callback(_smolvm.machine_delete, plan.machine_name)
|
|
_smolvm.machine_start(plan.machine_name)
|
|
stack.callback(_smolvm.machine_stop, 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()
|