feat(smolmachines): thread inner Plans + bundle daemons run (PRD 0023 chunk 4b)
test / unit (pull_request) Successful in 21s
test / integration (pull_request) Successful in 42s

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:
2026-05-27 05:29:02 -04:00
parent 085a0c1923
commit 1dfc359141
5 changed files with 287 additions and 44 deletions
@@ -124,6 +124,32 @@ class TestSmolmachinesLaunch(unittest.TestCase):
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):
# provision_prompt copies the host-side prompt.txt into the
# guest at /root/.claude-bottle-prompt.txt. The content
+21
View File
@@ -18,7 +18,10 @@ from claude_bottle.backend.smolmachines.provision import (
prompt as _prompt,
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.pipelock import PipelockProxyPlan
def _plan(
@@ -53,6 +56,24 @@ def _plan(
agent_from_path=Path("/tmp/agent.smolmachine"),
guest_env={},
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,
)