feat(smolmachines): thread inner Plans + bundle daemons run (PRD 0023 chunk 4b)
Bundle daemons (pipelock, egress, optionally git-gate + supervise)
now actually start with their config files bind-mounted from the
inner Plans the docker backend already produces. Chunks 2d + 3
ran with daemons_csv="" so the bundle's init supervisor idled;
chunk 4b wires up the real path: agent → pipelock → egress →
internet (when routes declared) is now functional, modulo agent-
image gaps (claude-code / TLS-trust-store / git in the guest)
that chunk 4c addresses.
bottle_plan.py — added the four inner Plan fields:
proxy_plan: PipelockProxyPlan
git_gate_plan: GitGatePlan
egress_plan: EgressPlan
supervise_plan: SupervisePlan | None
Same shape the docker backend's plan uses. Docker-network-only
fields (internal_network, egress_network) stay at dataclass
defaults — the smolmachines bundle is on a per-bottle bridge
with a pinned IP, not docker's --internal + egress topology.
prepare.py — instantiates DockerPipelockProxy / DockerEgress /
DockerGitGate / DockerSupervise and calls their .prepare()
methods to write the per-bottle config files (pipelock.yaml,
routes.yaml, git-gate entrypoint/hooks, supervise queue dir)
under the per-bottle state dir. (The "Docker" prefix on the
class names is a misnomer here — .prepare() is platform-neutral,
inherited from each sidecar's ABC. A future cleanup could factor
the prepare logic out of the docker subpackage.)
launch.py — major rewrite:
- pipelock_tls_init at launch (always); egress_tls_init only
when the bottle declares routes (otherwise the CA files
aren't bind-mounted and openssl runs would be wasted).
- Inner Plans updated in place with launch-time CA paths +
EGRESS_UPSTREAM_PROXY = http://127.0.0.1:8888 (egress's
upstream is pipelock on the bundle's own loopback; same
container's network namespace).
- BundleLaunchSpec env + volumes built from the inner Plans:
pipelock.yaml + CA + key (always); egress routes + CAs +
upstream env + token-slot bare names (when routes); git-gate
entrypoint + hooks + per-upstream identity files (when
upstreams); supervise queue dir + env (when enabled).
- daemons_csv = ["egress", "pipelock"] + ["git-gate"] (if
upstreams) + ["supervise"] (if enabled).
- Token env values resolved from host env via
`egress_resolve_token_values` and threaded into the
docker-run subprocess env (bare-name -e entries in spec
inherit from there — values never land on argv).
Tests:
- 552 unit passing (no new unit cases; fixture updated to
populate the new plan fields).
- 5 integration cases passing locally (Darwin + smolvm + docker
+ not GITEA_ACTIONS):
* test_smoke_exec_echo — still works.
* test_localhost_reach_probe — host loopback still refused.
* test_egress_port_bypass_probe — <bundle-ip>:9099 still
refused, NOW WITH EGRESS ACTUALLY RUNNING (chunk 3's
127.0.0.1 bind-address is doing its job).
* test_prompt_file_lands_in_guest — still works.
* test_pipelock_answers_on_bundle_ip — NEW. From inside the
guest, wget to <bundle-ip>:8888 gets an HTTP response
(not "connection refused") — proves pipelock is actually
listening and the bind-mount + CA generation path works.
What's left in chunk 4:
- 4c: agent-image-conversion (claude-code + git + curl +
ca-certificates in the guest). Chunk 2d's alpine placeholder
stays for now.
- 4d: provision_ca + provision_git + provision_supervise once
the agent image has the required tools.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,11 @@ import sys
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...egress import EgressPlan
|
||||||
|
from ...git_gate import GitGatePlan
|
||||||
from ...log import info
|
from ...log import info
|
||||||
|
from ...pipelock import PipelockProxyPlan
|
||||||
|
from ...supervise import SupervisePlan
|
||||||
from .. import BottlePlan
|
from .. import BottlePlan
|
||||||
from ..print_util import print_multi
|
from ..print_util import print_multi
|
||||||
|
|
||||||
@@ -57,6 +61,20 @@ class SmolmachinesBottlePlan(BottlePlan):
|
|||||||
# empty when the agent has no prompt — claude-code reads it
|
# empty when the agent has no prompt — claude-code reads it
|
||||||
# via --append-system-prompt-file only when non-empty.
|
# via --append-system-prompt-file only when non-empty.
|
||||||
prompt_file: Path
|
prompt_file: Path
|
||||||
|
# Inner Plans for the four bundle daemons. The same shape the
|
||||||
|
# docker backend uses — same `.prepare()` calls produced
|
||||||
|
# them — but our launch step doesn't populate the
|
||||||
|
# docker-specific network fields (internal_network,
|
||||||
|
# egress_network) because the smolmachines bundle isn't on
|
||||||
|
# docker's `--internal` + egress bridge topology; it's on a
|
||||||
|
# per-bottle bridge with a pinned IP. The unused fields stay
|
||||||
|
# at their dataclass defaults.
|
||||||
|
proxy_plan: PipelockProxyPlan
|
||||||
|
git_gate_plan: GitGatePlan
|
||||||
|
egress_plan: EgressPlan
|
||||||
|
# None when bottle.supervise is False, matching the docker
|
||||||
|
# backend's convention.
|
||||||
|
supervise_plan: SupervisePlan | None
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,28 +1,60 @@
|
|||||||
"""End-to-end launch flow for the smolmachines backend
|
"""End-to-end launch flow for the smolmachines backend
|
||||||
(PRD 0023 chunk 2d).
|
(PRD 0023 chunks 2d + 4b).
|
||||||
|
|
||||||
Brings up the per-bottle docker bridge + sidecar bundle, creates
|
Brings up the per-bottle docker bridge + sidecar bundle (with
|
||||||
+ starts the smolvm guest pointed at the bundle's pinned IP via
|
real daemons + their config files), creates + starts the smolvm
|
||||||
the Smolfile's TSI allowlist, yields a `SmolmachinesBottle`
|
guest pointed at the bundle's pinned IP via TSI's
|
||||||
handle, tears everything down on context exit.
|
`--allow-cidr <bundle-ip>/32` allowlist, yields a
|
||||||
|
`SmolmachinesBottle` handle, tears everything down on context
|
||||||
|
exit.
|
||||||
|
|
||||||
Chunk-2d scope: smoke-test plumbing for the launch + exec round
|
The bundle's daemons consume the inner Plans the docker backend
|
||||||
trip. The bundle daemons aren't supplied with config files yet
|
already produces: pipelock reads its yaml + CA from the
|
||||||
(pipelock.yaml, routes.yaml, etc.); the bundle's init supervisor
|
PipelockProxyPlan; egress reads routes + CAs from the EgressPlan
|
||||||
exits cleanly when nothing is configured. Real provisioning + CA
|
+ EGRESS_UPSTREAM_PROXY pointing at `127.0.0.1:8888` (bundle
|
||||||
install + the inner Plan plumbing land in chunk 4."""
|
local), since the agent dials pipelock first (not egress) on the
|
||||||
|
smolmachines path. Git-gate + supervise plumb through the same
|
||||||
|
plans the docker backend uses, minus the docker-network fields
|
||||||
|
that don't apply here."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import os
|
||||||
from contextlib import ExitStack, contextmanager
|
from contextlib import ExitStack, contextmanager
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
from . import smolvm as _smolvm
|
from ...egress import EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values
|
||||||
|
from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
|
||||||
|
from ...util import expand_tilde
|
||||||
|
from ..docker.egress import (
|
||||||
|
EGRESS_CA_IN_CONTAINER,
|
||||||
|
EGRESS_PIPELOCK_CA_IN_CONTAINER,
|
||||||
|
egress_tls_init,
|
||||||
|
)
|
||||||
|
from ..docker.git_gate import (
|
||||||
|
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
|
||||||
|
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
||||||
|
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
||||||
|
GIT_GATE_HOOK_IN_CONTAINER,
|
||||||
|
)
|
||||||
|
from ..docker.pipelock import (
|
||||||
|
PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||||
|
PIPELOCK_CA_KEY_IN_CONTAINER,
|
||||||
|
pipelock_tls_init,
|
||||||
|
)
|
||||||
from . import sidecar_bundle as _bundle
|
from . import sidecar_bundle as _bundle
|
||||||
|
from . import smolvm as _smolvm
|
||||||
from .bottle import SmolmachinesBottle
|
from .bottle import SmolmachinesBottle
|
||||||
from .bottle_plan import SmolmachinesBottlePlan
|
from .bottle_plan import SmolmachinesBottlePlan
|
||||||
|
|
||||||
|
|
||||||
|
# Pipelock's upstream when egress is in the bundle: localhost on
|
||||||
|
# the bundle's own loopback. No docker DNS aliases involved —
|
||||||
|
# pipelock + egress share the same container's network namespace.
|
||||||
|
_BUNDLE_LOCAL_PIPELOCK_URL = "http://127.0.0.1:8888"
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def launch(
|
def launch(
|
||||||
plan: SmolmachinesBottlePlan,
|
plan: SmolmachinesBottlePlan,
|
||||||
@@ -34,40 +66,53 @@ def launch(
|
|||||||
via the ExitStack."""
|
via the ExitStack."""
|
||||||
stack = ExitStack()
|
stack = ExitStack()
|
||||||
try:
|
try:
|
||||||
# 1. Per-bottle docker bridge + bundle container.
|
# 1. Per-bottle docker bridge.
|
||||||
network = _bundle.bundle_network_name(plan.slug)
|
network = _bundle.bundle_network_name(plan.slug)
|
||||||
_bundle.create_bundle_network(network, plan.bundle_subnet, plan.bundle_gateway)
|
_bundle.create_bundle_network(network, plan.bundle_subnet, plan.bundle_gateway)
|
||||||
stack.callback(_bundle.remove_bundle_network, network)
|
stack.callback(_bundle.remove_bundle_network, network)
|
||||||
|
|
||||||
bundle_spec = _bundle.BundleLaunchSpec(
|
# 2. Mint per-bottle CAs and update the inner Plans with
|
||||||
slug=plan.slug,
|
# their launch-time paths. pipelock always runs in the
|
||||||
network_name=network,
|
# bundle; egress's CA is only minted when the bottle
|
||||||
subnet=plan.bundle_subnet,
|
# declares routes (otherwise egress runs idle without
|
||||||
gateway=plan.bundle_gateway,
|
# MITM and the CA files would be unused).
|
||||||
bundle_ip=plan.bundle_ip,
|
ca_cert_host, ca_key_host = pipelock_tls_init(plan.proxy_plan.yaml_path.parent)
|
||||||
# Chunk 2d: empty daemon set — the init supervisor
|
proxy_plan = dataclasses.replace(
|
||||||
# logs "no daemons selected" and idles. Real daemon
|
plan.proxy_plan,
|
||||||
# bringup with inner-Plan-driven env + volumes lands
|
ca_cert_host_path=ca_cert_host,
|
||||||
# in chunk 4 alongside provisioning.
|
ca_key_host_path=ca_key_host,
|
||||||
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)
|
egress_plan = plan.egress_plan
|
||||||
|
if egress_plan.routes:
|
||||||
|
egress_ca_host, egress_ca_cert_only = egress_tls_init(
|
||||||
|
plan.egress_plan.routes_path.parent,
|
||||||
|
)
|
||||||
|
egress_plan = dataclasses.replace(
|
||||||
|
egress_plan,
|
||||||
|
mitmproxy_ca_host_path=egress_ca_host,
|
||||||
|
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
|
||||||
|
pipelock_ca_host_path=ca_cert_host,
|
||||||
|
# On smolmachines, egress's upstream is pipelock
|
||||||
|
# on the bundle's localhost — they're in the same
|
||||||
|
# container's network namespace.
|
||||||
|
pipelock_proxy_url=_BUNDLE_LOCAL_PIPELOCK_URL,
|
||||||
|
)
|
||||||
|
plan = dataclasses.replace(
|
||||||
|
plan, proxy_plan=proxy_plan, egress_plan=egress_plan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Build the BundleLaunchSpec from the (now-resolved)
|
||||||
|
# inner Plans: daemon subset, env, bind-mounts.
|
||||||
|
bundle_spec = _bundle_launch_spec(plan, network)
|
||||||
|
token_env = _resolve_token_env(plan, os.environ)
|
||||||
|
_bundle.start_bundle(bundle_spec, env={**os.environ, **token_env})
|
||||||
stack.callback(_bundle.stop_bundle, plan.slug)
|
stack.callback(_bundle.stop_bundle, plan.slug)
|
||||||
|
|
||||||
# 2. smolvm VM. --from carries the pre-packed
|
# 4. smolvm VM. --from carries the pre-packed .smolmachine
|
||||||
# .smolmachine artifact (built by prepare); --allow-cidr
|
# artifact (built by prepare); --allow-cidr + -e carry the
|
||||||
# + -e carry the per-bottle TSI allowlist + env. Smolfile
|
# per-bottle TSI allowlist + env. Smolfile isn't usable
|
||||||
# isn't usable here — smolvm 0.8.0 makes `--from` and
|
# here — smolvm 0.8.0 makes `--from` and `--smolfile`
|
||||||
# `--smolfile` mutually exclusive.
|
# mutually exclusive.
|
||||||
_smolvm.machine_create(
|
_smolvm.machine_create(
|
||||||
plan.machine_name,
|
plan.machine_name,
|
||||||
from_path=plan.agent_from_path,
|
from_path=plan.agent_from_path,
|
||||||
@@ -78,14 +123,109 @@ 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. Provision (CA / prompt / skills / git / supervise).
|
# 5. 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)
|
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)
|
yield SmolmachinesBottle(plan.machine_name, prompt_path=prompt_path)
|
||||||
finally:
|
finally:
|
||||||
stack.close()
|
stack.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _bundle_launch_spec(
|
||||||
|
plan: SmolmachinesBottlePlan, network: str
|
||||||
|
) -> _bundle.BundleLaunchSpec:
|
||||||
|
"""Build a BundleLaunchSpec from the resolved inner Plans.
|
||||||
|
|
||||||
|
Daemons in the CSV:
|
||||||
|
- egress + pipelock are always present (pipelock is the
|
||||||
|
agent's first hop; egress is its upstream).
|
||||||
|
- git-gate is conditional on plan.git_gate_plan.upstreams.
|
||||||
|
- supervise is conditional on plan.supervise_plan.
|
||||||
|
|
||||||
|
Env + volumes are the union of the four daemons' needs, with
|
||||||
|
daemon-private values only (HTTPS_PROXY is scoped to the
|
||||||
|
egress process by egress_entrypoint.sh — see PRD 0024's bundle
|
||||||
|
bind-address PR)."""
|
||||||
|
daemons: list[str] = ["egress", "pipelock"]
|
||||||
|
env: list[str] = []
|
||||||
|
volumes: list[tuple[str, str, bool]] = []
|
||||||
|
|
||||||
|
# PRD 0023 chunk 3: egress binds 127.0.0.1 inside the bundle
|
||||||
|
# so TSI's IP-only allowlist can't bypass pipelock.
|
||||||
|
env.append("EGRESS_LISTEN_HOST=127.0.0.1")
|
||||||
|
|
||||||
|
# --- pipelock ---------------------------------------------
|
||||||
|
pp = plan.proxy_plan
|
||||||
|
volumes += [
|
||||||
|
(str(pp.yaml_path), "/etc/pipelock.yaml", True),
|
||||||
|
(str(pp.ca_cert_host_path), PIPELOCK_CA_CERT_IN_CONTAINER, True),
|
||||||
|
(str(pp.ca_key_host_path), PIPELOCK_CA_KEY_IN_CONTAINER, True),
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- egress -----------------------------------------------
|
||||||
|
ep = plan.egress_plan
|
||||||
|
if ep.routes:
|
||||||
|
env.append(f"EGRESS_UPSTREAM_PROXY={ep.pipelock_proxy_url}")
|
||||||
|
env.append(f"EGRESS_UPSTREAM_CA={EGRESS_PIPELOCK_CA_IN_CONTAINER}")
|
||||||
|
volumes += [
|
||||||
|
(str(ep.routes_path), EGRESS_ROUTES_IN_CONTAINER, True),
|
||||||
|
(str(ep.mitmproxy_ca_host_path), EGRESS_CA_IN_CONTAINER, True),
|
||||||
|
(str(ep.pipelock_ca_host_path), EGRESS_PIPELOCK_CA_IN_CONTAINER, True),
|
||||||
|
]
|
||||||
|
# Bare-name entries for upstream-token slots. Their values
|
||||||
|
# come from the docker-run subprocess env (inherited from
|
||||||
|
# the operator's shell), never landing on argv.
|
||||||
|
for token_env in sorted(ep.token_env_map.keys()):
|
||||||
|
env.append(token_env)
|
||||||
|
|
||||||
|
# --- git-gate ---------------------------------------------
|
||||||
|
extra_hosts: list[str] = []
|
||||||
|
gp = plan.git_gate_plan
|
||||||
|
if gp.upstreams:
|
||||||
|
daemons.append("git-gate")
|
||||||
|
volumes += [
|
||||||
|
(str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER, True),
|
||||||
|
(str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER, True),
|
||||||
|
(str(gp.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER, True),
|
||||||
|
]
|
||||||
|
for u in gp.upstreams:
|
||||||
|
keypath = expand_tilde(u.identity_file)
|
||||||
|
volumes.append((
|
||||||
|
keypath,
|
||||||
|
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
|
||||||
|
True,
|
||||||
|
))
|
||||||
|
|
||||||
|
# --- supervise --------------------------------------------
|
||||||
|
sp = plan.supervise_plan
|
||||||
|
if sp is not None:
|
||||||
|
daemons.append("supervise")
|
||||||
|
env += [
|
||||||
|
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||||
|
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
||||||
|
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||||
|
]
|
||||||
|
volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
|
||||||
|
|
||||||
|
return _bundle.BundleLaunchSpec(
|
||||||
|
slug=plan.slug,
|
||||||
|
network_name=network,
|
||||||
|
subnet=plan.bundle_subnet,
|
||||||
|
gateway=plan.bundle_gateway,
|
||||||
|
bundle_ip=plan.bundle_ip,
|
||||||
|
daemons_csv=",".join(daemons),
|
||||||
|
environment=tuple(env),
|
||||||
|
volumes=tuple(volumes),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_token_env(
|
||||||
|
plan: SmolmachinesBottlePlan, host_env: object
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Resolve the egress token env-var values from the host's
|
||||||
|
environ so they reach the bundle's process env via docker's
|
||||||
|
`-e NAME` inheritance. Empty when no routes declare auth."""
|
||||||
|
ep = plan.egress_plan
|
||||||
|
if not ep.routes:
|
||||||
|
return {}
|
||||||
|
return egress_resolve_token_values(ep.token_env_map, dict(host_env))
|
||||||
|
|||||||
@@ -15,8 +15,16 @@ from ...backend.docker.bottle_state import (
|
|||||||
BottleMetadata,
|
BottleMetadata,
|
||||||
agent_state_dir,
|
agent_state_dir,
|
||||||
bottle_identity,
|
bottle_identity,
|
||||||
|
egress_state_dir,
|
||||||
|
git_gate_state_dir,
|
||||||
|
pipelock_state_dir,
|
||||||
|
supervise_state_dir,
|
||||||
write_metadata,
|
write_metadata,
|
||||||
)
|
)
|
||||||
|
from ...backend.docker.egress import DockerEgress
|
||||||
|
from ...backend.docker.git_gate import DockerGitGate
|
||||||
|
from ...backend.docker.pipelock import DockerPipelockProxy
|
||||||
|
from ...backend.docker.supervise import DockerSupervise
|
||||||
from . import smolvm as _smolvm
|
from . import smolvm as _smolvm
|
||||||
from .bottle_plan import SmolmachinesBottlePlan
|
from .bottle_plan import SmolmachinesBottlePlan
|
||||||
from .util import smolmachines_bundle_subnet, smolmachines_preflight
|
from .util import smolmachines_bundle_subnet, smolmachines_preflight
|
||||||
@@ -86,6 +94,32 @@ def resolve_plan(
|
|||||||
f"http://{bundle_ip}:{_BUNDLE_SUPERVISE_PORT}"
|
f"http://{bundle_ip}:{_BUNDLE_SUPERVISE_PORT}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Inner Plans for the four bundle daemons. Use the docker
|
||||||
|
# backend's concrete subclasses — the `.prepare()` method
|
||||||
|
# they inherit is platform-neutral (writes config files +
|
||||||
|
# returns a Plan dataclass); the docker-specific subclasses
|
||||||
|
# exist only to satisfy ABC instantiation. Future: factor
|
||||||
|
# the prepare logic out of the docker subpackage so
|
||||||
|
# smolmachines doesn't have to reach across the backend
|
||||||
|
# boundary.
|
||||||
|
pipelock_dir = pipelock_state_dir(slug)
|
||||||
|
pipelock_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
proxy_plan = DockerPipelockProxy().prepare(bottle, slug, pipelock_dir)
|
||||||
|
|
||||||
|
git_gate_dir = git_gate_state_dir(slug)
|
||||||
|
git_gate_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
git_gate_plan = DockerGitGate().prepare(bottle, slug, git_gate_dir)
|
||||||
|
|
||||||
|
egress_dir = egress_state_dir(slug)
|
||||||
|
egress_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
egress_plan = DockerEgress().prepare(bottle, slug, egress_dir)
|
||||||
|
|
||||||
|
supervise_plan = None
|
||||||
|
if bottle.supervise:
|
||||||
|
supervise_dir = supervise_state_dir(slug)
|
||||||
|
supervise_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
supervise_plan = DockerSupervise().prepare(slug, supervise_dir)
|
||||||
|
|
||||||
# Prompt file is always written (mode 0o600) so the in-VM
|
# Prompt file is always written (mode 0o600) so the in-VM
|
||||||
# path always exists. Content is the agent's `prompt`
|
# path always exists. Content is the agent's `prompt`
|
||||||
# field (markdown body) — empty for agents with no prompt.
|
# field (markdown body) — empty for agents with no prompt.
|
||||||
@@ -118,6 +152,10 @@ def resolve_plan(
|
|||||||
agent_from_path=agent_from_path,
|
agent_from_path=agent_from_path,
|
||||||
guest_env=guest_env,
|
guest_env=guest_env,
|
||||||
prompt_file=prompt_file,
|
prompt_file=prompt_file,
|
||||||
|
proxy_plan=proxy_plan,
|
||||||
|
git_gate_plan=git_gate_plan,
|
||||||
|
egress_plan=egress_plan,
|
||||||
|
supervise_plan=supervise_plan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,32 @@ 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_pipelock_answers_on_bundle_ip(self):
|
||||||
|
# Chunk 4b: the bundle's pipelock daemon is now actually
|
||||||
|
# running (was daemons_csv="" in chunks 2d/3). From inside
|
||||||
|
# the guest, a TCP connect to <bundle-ip>:8888 must succeed
|
||||||
|
# — distinct from the egress-port-bypass probe below where
|
||||||
|
# the connect must FAIL.
|
||||||
|
#
|
||||||
|
# We don't try to speak proxy protocol here — pipelock will
|
||||||
|
# 4xx a bare GET — we just verify the socket answers.
|
||||||
|
r = self.bottle.exec(
|
||||||
|
f"wget -T 5 -t 1 -O - http://{self.plan.bundle_ip}:8888/ "
|
||||||
|
"2>&1 || true"
|
||||||
|
)
|
||||||
|
# Any HTTP response (even a 4xx) proves pipelock is up.
|
||||||
|
# "connection refused" / "unable to connect" / "timed out"
|
||||||
|
# would mean it isn't.
|
||||||
|
msg = r.stdout.lower()
|
||||||
|
self.assertNotIn(
|
||||||
|
"connection refused", msg,
|
||||||
|
f"pipelock connect refused — daemon not listening? {r.stdout!r}",
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"timed out", msg,
|
||||||
|
f"pipelock connect timed out: {r.stdout!r}",
|
||||||
|
)
|
||||||
|
|
||||||
def test_prompt_file_lands_in_guest(self):
|
def test_prompt_file_lands_in_guest(self):
|
||||||
# provision_prompt copies the host-side prompt.txt into the
|
# provision_prompt copies the host-side prompt.txt into the
|
||||||
# guest at /root/.claude-bottle-prompt.txt. The content
|
# guest at /root/.claude-bottle-prompt.txt. The content
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ from claude_bottle.backend.smolmachines.provision import (
|
|||||||
prompt as _prompt,
|
prompt as _prompt,
|
||||||
skills as _skills,
|
skills as _skills,
|
||||||
)
|
)
|
||||||
|
from claude_bottle.egress import EgressPlan
|
||||||
|
from claude_bottle.git_gate import GitGatePlan
|
||||||
from claude_bottle.manifest import Manifest
|
from claude_bottle.manifest import Manifest
|
||||||
|
from claude_bottle.pipelock import PipelockProxyPlan
|
||||||
|
|
||||||
|
|
||||||
def _plan(
|
def _plan(
|
||||||
@@ -53,6 +56,24 @@ def _plan(
|
|||||||
agent_from_path=Path("/tmp/agent.smolmachine"),
|
agent_from_path=Path("/tmp/agent.smolmachine"),
|
||||||
guest_env={},
|
guest_env={},
|
||||||
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
|
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
|
||||||
|
proxy_plan=PipelockProxyPlan(
|
||||||
|
yaml_path=Path("/tmp/pipelock.yaml"),
|
||||||
|
slug="demo-abc12",
|
||||||
|
),
|
||||||
|
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=(),
|
||||||
|
token_env_map={},
|
||||||
|
),
|
||||||
|
supervise_plan=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user