feat(smolmachines): backend skeleton + Smolfile/gvproxy renderers (PRD 0023 chunk 1) #62

Merged
didericis merged 2 commits from prd-0023-chunk-1-skeleton into main 2026-05-27 03:18:48 -04:00
Owner

Summary

PRD 0023 chunk 1: the smolmachines backend's prepare side. Subpackage layout, _BACKENDS registration under "smolmachines", smolvm + gvproxy preflight check, and the two config-file renderers (Smolfile TOML + gvproxy YAML). Launch / provisioning raise NotImplementedError until chunks 2 + 4.

CLAUDE_BOTTLE_BACKEND=smolmachines python3 cli.py start <agent> will now run through prepare, write the Smolfile + gvproxy.yaml to the stage dir, render the y/N preflight, and fail cleanly at launch with a chunk-2 pointer.

Module layout (mirrors backend/docker/)

claude_bottle/backend/smolmachines/
  __init__.py            re-exports SmolmachinesBottleBackend
  backend.py             SmolmachinesBottleBackend façade
  bottle.py              SmolmachinesBottle stub (NotImpl until ch2)
  bottle_plan.py         SmolmachinesBottlePlan + .print()
  bottle_cleanup_plan.py SmolmachinesBottleCleanupPlan stub
  prepare.py             resolve_plan: writes both config files
  smolfile.py            TOML renderer (stdlib, no tomli_w dep)
  gvproxy_config.py      YAML renderer (same shape as pipelock_yaml)
  util.py                preflight + per-slug subnet + loopback port

What resolve_plan does

  1. Runs preflight (smolvm, gvproxy on PATH or die with install pointers).
  2. Mints/resumes a slug + writes BottleMetadata (same schema as docker).
  3. Derives a per-slug gvproxy subnet from sha256(slug) % 254, skipping octet 17 (avoids the historic 192.168.17.x collision with docker's default bridge).
  4. Allocates one host-side loopback port per active sidecar (pipelock always; git-gate / supervise conditional on the manifest).
  5. Writes <stage>/gvproxy.yaml (subnet + DNS rule that resolves only proxy.internal + one port_forwards entry per active sidecar) and <stage>/smolfile.toml (guest command/env + virtio-net device wired to the gvproxy unixgram socket; no TSI flags).
  6. Returns a SmolmachinesBottlePlan carrying the slug, paths, subnet/gateway, and host port map.

Tests

29 new unit tests across three files:

  • test_smolfile.py — TOML renderer correctness, round-trip through stdlib tomllib, special-char escapes, and explicit assertion that no TSI primitives leak (--allow-cidr, --allow-host, --outbound-localhost-only).
  • test_smolmachines_gvproxy_config.py — subnet/gateway pass-through, DNS rule resolves ONLY proxy.internal (the PRD 0022 DNS-exfil regression check), port forwards render correctly, no TSI leakage.
  • test_smolmachines_util.py — subnet derivation stable for same slug + differs across slugs + avoids docker-default octet, loopback port allocation, preflight error paths (missing one binary / both / install pointers in message).

Full unit suite: 532 passing.

Out of scope for chunk 1

  • VM bringup (chunk 2): smolvm.py subprocess wrapper, OCI archive build, launch + teardown.
  • Host-side sidecar bringup (chunk 3): the bundle container per bottle, gvproxy port_forwards wired to the published host ports.
  • Provisioning (chunk 4): CA install / prompt / skills / .git / supervise config via smolvm machine exec.
  • PRD 0022 acceptance gate (chunk 5).
## Summary PRD 0023 chunk 1: the smolmachines backend's prepare side. Subpackage layout, `_BACKENDS` registration under `"smolmachines"`, `smolvm` + `gvproxy` preflight check, and the two config-file renderers (Smolfile TOML + gvproxy YAML). Launch / provisioning raise `NotImplementedError` until chunks 2 + 4. `CLAUDE_BOTTLE_BACKEND=smolmachines python3 cli.py start <agent>` will now run through `prepare`, write the Smolfile + gvproxy.yaml to the stage dir, render the y/N preflight, and fail cleanly at launch with a chunk-2 pointer. ## Module layout (mirrors backend/docker/) ``` claude_bottle/backend/smolmachines/ __init__.py re-exports SmolmachinesBottleBackend backend.py SmolmachinesBottleBackend façade bottle.py SmolmachinesBottle stub (NotImpl until ch2) bottle_plan.py SmolmachinesBottlePlan + .print() bottle_cleanup_plan.py SmolmachinesBottleCleanupPlan stub prepare.py resolve_plan: writes both config files smolfile.py TOML renderer (stdlib, no tomli_w dep) gvproxy_config.py YAML renderer (same shape as pipelock_yaml) util.py preflight + per-slug subnet + loopback port ``` ## What `resolve_plan` does 1. Runs preflight (`smolvm`, `gvproxy` on PATH or `die` with install pointers). 2. Mints/resumes a slug + writes `BottleMetadata` (same schema as docker). 3. Derives a per-slug gvproxy subnet from `sha256(slug) % 254`, skipping octet 17 (avoids the historic 192.168.17.x collision with docker's default bridge). 4. Allocates one host-side loopback port per active sidecar (pipelock always; git-gate / supervise conditional on the manifest). 5. Writes `<stage>/gvproxy.yaml` (subnet + DNS rule that resolves only `proxy.internal` + one `port_forwards` entry per active sidecar) and `<stage>/smolfile.toml` (guest command/env + virtio-net device wired to the gvproxy unixgram socket; **no TSI flags**). 6. Returns a `SmolmachinesBottlePlan` carrying the slug, paths, subnet/gateway, and host port map. ## Tests 29 new unit tests across three files: - `test_smolfile.py` — TOML renderer correctness, round-trip through stdlib `tomllib`, special-char escapes, and **explicit assertion that no TSI primitives leak** (`--allow-cidr`, `--allow-host`, `--outbound-localhost-only`). - `test_smolmachines_gvproxy_config.py` — subnet/gateway pass-through, DNS rule resolves ONLY `proxy.internal` (the PRD 0022 DNS-exfil regression check), port forwards render correctly, no TSI leakage. - `test_smolmachines_util.py` — subnet derivation stable for same slug + differs across slugs + avoids docker-default octet, loopback port allocation, preflight error paths (missing one binary / both / install pointers in message). Full unit suite: **532 passing.** ## Out of scope for chunk 1 - VM bringup (chunk 2): `smolvm.py` subprocess wrapper, OCI archive build, launch + teardown. - Host-side sidecar bringup (chunk 3): the bundle container per bottle, gvproxy `port_forwards` wired to the published host ports. - Provisioning (chunk 4): CA install / prompt / skills / .git / supervise config via `smolvm machine exec`. - PRD 0022 acceptance gate (chunk 5).
didericis added 1 commit 2026-05-27 02:22:31 -04:00
feat(smolmachines): backend skeleton + Smolfile/gvproxy renderers (PRD 0023 chunk 1)
test / unit (pull_request) Successful in 22s
test / integration (pull_request) Successful in 43s
20f411b22e
Ships the smolmachines backend's prepare side: subpackage layout,
`_BACKENDS` registration under "smolmachines", preflight check
for `smolvm` + `gvproxy` on PATH, and the two config-file
renderers (Smolfile TOML + gvproxy YAML). Launch raises
NotImplementedError until chunk 2.

New module layout (mirrors backend/docker/):
  claude_bottle/backend/smolmachines/
    __init__.py            re-exports SmolmachinesBottleBackend
    backend.py             SmolmachinesBottleBackend façade
    bottle.py              SmolmachinesBottle stub (NotImpl until ch2)
    bottle_plan.py         SmolmachinesBottlePlan + .print()
    bottle_cleanup_plan.py SmolmachinesBottleCleanupPlan stub
    prepare.py             resolve_plan: writes both config files
    smolfile.py            TOML renderer (stdlib, no tomli_w dep)
    gvproxy_config.py      YAML renderer (same shape as pipelock_yaml)
    util.py                preflight + per-slug subnet + loopback port

The renderers are pure functions. `resolve_plan` runs the
preflight, allocates one host-side loopback port per active
sidecar (pipelock always; git-gate / supervise conditional),
derives a per-slug gvproxy subnet (hash-mod-254, skipping the
docker-default 17), and writes:

  - <stage>/gvproxy.yaml: subnet + DNS rule resolving only
    `proxy.internal` + port_forwards (one per active sidecar).
  - <stage>/smolfile.toml: guest command/env + virtio-net device
    backed by gvproxy's unixgram socket. No TSI flags — see
    PRD 0023 "Why gvproxy, not TSI".

The agent's HTTPS_PROXY etc. point at `proxy.internal:<gateway-
port>` so the guest dials through gvproxy. gvproxy resolves only
`proxy.internal` → the gateway IP, and forwards exactly the
listed ports to the host-side sidecar bundle (PRD 0024); every
other destination — host LAN, host loopback, public internet
directly — is unreachable by construction.

29 new unit tests covering renderer correctness, subnet
derivation stability + collision-avoidance, loopback port
allocation, and preflight error paths. Full unit suite: 532
passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
didericis reviewed 2026-05-27 02:33:14 -04:00
didericis left a comment
Author
Owner

Respond to the changes I specified

Respond to the changes I specified
@@ -0,0 +46,4 @@
info(f"agent: {spec.agent_name}")
info(f"bottle: {agent.bottle}")
info(f"slug: {self.slug}")
info(f"gvproxy: {self.gvproxy_gateway} on {self.gvproxy_subnet}")
Author
Owner

remove this

remove this
didericis marked this conversation as resolved
@@ -0,0 +52,4 @@
skills = list(agent.skills)
upstreams = [g.Name for g in bottle.git]
routes = [r.host for r in bottle.egress.routes]
info(f"env: {', '.join(env_names) if env_names else '(none)'}")
Author
Owner

can you print this multiline like for docker? use a shared util method

can you print this multiline like for docker? use a shared util method
didericis marked this conversation as resolved
@@ -0,0 +55,4 @@
info(f"env: {', '.join(env_names) if env_names else '(none)'}")
info(f"skills: {', '.join(skills) if skills else '(none)'}")
info(f"git: {', '.join(upstreams) if upstreams else '(none)'}")
info(f"routes: {', '.join(routes) if routes else '(none)'}")
Author
Owner

Actually use the shared util method for all of these

Actually use the shared util method for all of these
didericis marked this conversation as resolved
@@ -0,0 +56,4 @@
info(f"skills: {', '.join(skills) if skills else '(none)'}")
info(f"git: {', '.join(upstreams) if upstreams else '(none)'}")
info(f"routes: {', '.join(routes) if routes else '(none)'}")
info(f"smolfile: {self.smolfile_path}")
Author
Owner

Remove this

Remove this
didericis marked this conversation as resolved
@@ -0,0 +57,4 @@
info(f"git: {', '.join(upstreams) if upstreams else '(none)'}")
info(f"routes: {', '.join(routes) if routes else '(none)'}")
info(f"smolfile: {self.smolfile_path}")
info(f"gvproxy config: {self.gvproxy_config_path}")
Author
Owner

Don't need this either

Don't need this either
didericis marked this conversation as resolved
@@ -0,0 +57,4 @@
agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
slug = spec.identity or bottle_identity(spec.agent_name)
Author
Owner

why not just use the bottle identity?

why not just use the bottle identity?
didericis added 1 commit 2026-05-27 02:36:06 -04:00
refactor(backend): extract shared print_multi for plan preflights
test / unit (pull_request) Successful in 21s
test / integration (pull_request) Successful in 42s
2aca9e609a
Addresses PR #62 review comments on
claude_bottle/backend/smolmachines/bottle_plan.py:

- Lift the multi-value label printer (was a nested helper inside
  DockerBottlePlan.print) into a new module
  claude_bottle/backend/print_util.py:print_multi. Both backends
  use it for env / skills / git / egress lines.

- Strip the three smolmachines-preflight lines the review flagged:
  the gvproxy subnet line, the smolfile path line, and the
  gvproxy-config path line. Internal detail — operators see the
  agent / env / skills / bottle / git / egress that already
  matter on the docker side, and nothing else.

- Add `git → upstream` to the smolmachines git output to match
  what's useful at preflight time (the docker version shows
  upstream_host:port; this is similar shape).

Leaves the slug=spec.identity-or-mint pattern alone pending a
reply on PR comment #432 — the docker backend uses the same
pattern to preserve identity across `resume`, so dropping it
would silently break the resume path once smolmachines launch
lands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
didericis reviewed 2026-05-27 02:36:20 -04:00
didericis left a comment
Author
Owner

Good

Good
didericis-claude reviewed 2026-05-27 03:16:24 -04:00
didericis-claude left a comment
Collaborator

Addressed in commit 2aca9e6: dropped the three internal-detail lines (gvproxy subnet, smolfile path, gvproxy-config path) and lifted the multi-value printer into claude_bottle/backend/print_util.py:print_multi — both backends share it now.

On the slug = spec.identity or bottle_identity(...) comment (#432): the docker backend uses the same pattern to preserve identity across resume (PRD 0016: cli.py resume sets spec.identity to the recorded slug so prepare reuses it instead of minting a fresh one). Dropping the or branch would silently break resume once smolmachines launch lands. Want me to:

(a) keep the pattern (matches docker, resume-ready), or
(b) drop the fallback for chunk 1 since launch/resume don't exist yet, and add it back when needed?

Leaning toward (a). Let me know if I should switch.

Addressed in commit 2aca9e6: dropped the three internal-detail lines (gvproxy subnet, smolfile path, gvproxy-config path) and lifted the multi-value printer into claude_bottle/backend/print_util.py:print_multi — both backends share it now. On the slug = spec.identity or bottle_identity(...) comment (#432): the docker backend uses the same pattern to preserve identity across `resume` (PRD 0016: cli.py resume <identity> sets spec.identity to the recorded slug so prepare reuses it instead of minting a fresh one). Dropping the `or` branch would silently break resume once smolmachines launch lands. Want me to: (a) keep the pattern (matches docker, resume-ready), or (b) drop the fallback for chunk 1 since launch/resume don't exist yet, and add it back when needed? Leaning toward (a). Let me know if I should switch.
Author
Owner

Addressed in commit 2aca9e6: dropped the three internal-detail lines (gvproxy subnet, smolfile path, gvproxy-config path) and lifted the multi-value printer into claude_bottle/backend/print_util.py:print_multi — both backends share it now.

On the slug = spec.identity or bottle_identity(...) comment (#432): the docker backend uses the same pattern to preserve identity across resume (PRD 0016: cli.py resume sets spec.identity to the recorded slug so prepare reuses it instead of minting a fresh one). Dropping the or branch would silently break resume once smolmachines launch lands. Want me to:

(a) keep the pattern (matches docker, resume-ready), or
(b) drop the fallback for chunk 1 since launch/resume don't exist yet, and add it back when needed?

Leaning toward (a). Let me know if I should switch.

keep it

> Addressed in commit 2aca9e6: dropped the three internal-detail lines (gvproxy subnet, smolfile path, gvproxy-config path) and lifted the multi-value printer into claude_bottle/backend/print_util.py:print_multi — both backends share it now. > > On the slug = spec.identity or bottle_identity(...) comment (#432): the docker backend uses the same pattern to preserve identity across `resume` (PRD 0016: cli.py resume <identity> sets spec.identity to the recorded slug so prepare reuses it instead of minting a fresh one). Dropping the `or` branch would silently break resume once smolmachines launch lands. Want me to: > > (a) keep the pattern (matches docker, resume-ready), or > (b) drop the fallback for chunk 1 since launch/resume don't exist yet, and add it back when needed? > > Leaning toward (a). Let me know if I should switch. keep it
didericis merged commit b1ad6295a4 into main 2026-05-27 03:18:48 -04:00
didericis deleted branch prd-0023-chunk-1-skeleton 2026-05-27 03:19:11 -04:00
Sign in to join this conversation.