fix(smolmachines): docker push fails on Docker Desktop — daemon-side route differs from host loopback #74
@@ -75,6 +75,17 @@ class SmolmachinesBottlePlan(BottlePlan):
|
|||||||
# None when bottle.supervise is False, matching the docker
|
# None when bottle.supervise is False, matching the docker
|
||||||
# backend's convention.
|
# backend's convention.
|
||||||
supervise_plan: SupervisePlan | None
|
supervise_plan: SupervisePlan | None
|
||||||
|
# Agent-side endpoints. On Docker Desktop the docker bridge
|
||||||
|
# IPs aren't reachable from the smolvm guest (TSI uses macOS
|
||||||
|
# networking; docker container IPs live in the daemon's VM),
|
||||||
|
# so the agent dials the bundle via host loopback +
|
||||||
|
# docker-published random ports. Empty at prepare time;
|
||||||
|
# launch populates these after bundle bringup via
|
||||||
|
# `dataclasses.replace`. Format: a `host:port` for git-gate
|
||||||
|
# (insteadOf URL prefix) + full URLs for proxy / supervise.
|
||||||
|
agent_proxy_url: str = ""
|
||||||
|
agent_git_gate_host: str = ""
|
||||||
|
agent_supervise_url: str = ""
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -41,14 +41,28 @@ from ..docker.git_gate import (
|
|||||||
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
||||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
||||||
GIT_GATE_HOOK_IN_CONTAINER,
|
GIT_GATE_HOOK_IN_CONTAINER,
|
||||||
|
GIT_GATE_PORT as _GIT_GATE_PORT,
|
||||||
|
)
|
||||||
|
from ..docker.pipelock import (
|
||||||
|
BUNDLE_LOCAL_PIPELOCK_URL,
|
||||||
|
PIPELOCK_PORT as _PIPELOCK_PORT_STR,
|
||||||
|
pipelock_tls_init,
|
||||||
)
|
)
|
||||||
from ..docker.pipelock import BUNDLE_LOCAL_PIPELOCK_URL, pipelock_tls_init
|
|
||||||
from . import sidecar_bundle as _bundle
|
from . import sidecar_bundle as _bundle
|
||||||
from . import smolvm as _smolvm
|
from . import smolvm as _smolvm
|
||||||
from .bottle import SmolmachinesBottle
|
from .bottle import SmolmachinesBottle
|
||||||
from .bottle_plan import SmolmachinesBottlePlan
|
from .bottle_plan import SmolmachinesBottlePlan
|
||||||
|
|
||||||
|
|
||||||
|
# Container-internal listening ports for each bundle daemon. The
|
||||||
|
# bundle publishes each one on a random host loopback port (see
|
||||||
|
# `_bundle.start_bundle`), and `_bundle.bundle_host_port` looks
|
||||||
|
# them up post-start. Pipelock's port is an env-overridable string
|
||||||
|
# in docker.pipelock; coerce to int here.
|
||||||
|
_PIPELOCK_PORT = int(_PIPELOCK_PORT_STR)
|
||||||
|
_SUPERVISE_PORT = SUPERVISE_PORT
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def launch(
|
def launch(
|
||||||
plan: SmolmachinesBottlePlan,
|
plan: SmolmachinesBottlePlan,
|
||||||
@@ -96,28 +110,74 @@ def launch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 3. Build the BundleLaunchSpec from the (now-resolved)
|
# 3. Build the BundleLaunchSpec from the (now-resolved)
|
||||||
# inner Plans: daemon subset, env, bind-mounts.
|
# inner Plans: daemon subset, env, bind-mounts. The spec's
|
||||||
|
# ports_to_publish list expands depending on which daemons
|
||||||
|
# the agent needs to reach from the smolvm guest.
|
||||||
bundle_spec = _bundle_launch_spec(plan, network)
|
bundle_spec = _bundle_launch_spec(plan, network)
|
||||||
token_env = _resolve_token_env(plan, os.environ)
|
token_env = _resolve_token_env(plan, os.environ)
|
||||||
_bundle.start_bundle(bundle_spec, env={**os.environ, **token_env})
|
_bundle.start_bundle(bundle_spec, env={**os.environ, **token_env})
|
||||||
stack.callback(_bundle.stop_bundle, plan.slug)
|
stack.callback(_bundle.stop_bundle, plan.slug)
|
||||||
|
|
||||||
# 4. smolvm VM. --from carries the pre-packed .smolmachine
|
# 4. Discover the host-side ports docker assigned for the
|
||||||
|
# bundle's published container ports, and bind the
|
||||||
|
# agent's URLs to `127.0.0.1:<host port>`. Docker container
|
||||||
|
# IPs (192.168.x.x in the daemon's bridge) aren't
|
||||||
|
# reachable from the smolvm guest on macOS — TSI uses
|
||||||
|
# macOS networking, and macOS sees the daemon's bridge
|
||||||
|
# via the published-port loopback forward only.
|
||||||
|
pipelock_host_port = _bundle.bundle_host_port(plan.slug, _PIPELOCK_PORT)
|
||||||
|
agent_proxy_url = f"http://127.0.0.1:{pipelock_host_port}"
|
||||||
|
agent_git_gate_host = ""
|
||||||
|
if plan.git_gate_plan.upstreams:
|
||||||
|
git_gate_host_port = _bundle.bundle_host_port(
|
||||||
|
plan.slug, _GIT_GATE_PORT,
|
||||||
|
)
|
||||||
|
agent_git_gate_host = f"127.0.0.1:{git_gate_host_port}"
|
||||||
|
agent_supervise_url = ""
|
||||||
|
if plan.supervise_plan is not None:
|
||||||
|
supervise_host_port = _bundle.bundle_host_port(
|
||||||
|
plan.slug, _SUPERVISE_PORT,
|
||||||
|
)
|
||||||
|
agent_supervise_url = f"http://127.0.0.1:{supervise_host_port}/"
|
||||||
|
|
||||||
|
# Stamp the URLs onto the plan + guest_env. provision_git
|
||||||
|
# and provision_supervise read the plan fields; the agent
|
||||||
|
# reads guest_env on every exec_claude.
|
||||||
|
guest_env = {
|
||||||
|
**plan.guest_env,
|
||||||
|
"HTTPS_PROXY": agent_proxy_url,
|
||||||
|
"HTTP_PROXY": agent_proxy_url,
|
||||||
|
}
|
||||||
|
if agent_git_gate_host:
|
||||||
|
guest_env["GIT_GATE_URL"] = f"git://{agent_git_gate_host}"
|
||||||
|
if agent_supervise_url:
|
||||||
|
guest_env["MCP_SUPERVISE_URL"] = agent_supervise_url
|
||||||
|
plan = dataclasses.replace(
|
||||||
|
plan,
|
||||||
|
guest_env=guest_env,
|
||||||
|
agent_proxy_url=agent_proxy_url,
|
||||||
|
agent_git_gate_host=agent_git_gate_host,
|
||||||
|
agent_supervise_url=agent_supervise_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. smolvm VM. --from carries the pre-packed .smolmachine
|
||||||
# artifact (built by prepare); --allow-cidr + -e carry the
|
# artifact (built by prepare); --allow-cidr + -e carry the
|
||||||
# per-bottle TSI allowlist + env. Smolfile isn't usable
|
# per-bottle TSI allowlist + env. The allowlist is
|
||||||
# here — smolvm 0.8.0 makes `--from` and `--smolfile`
|
# `127.0.0.1/32` because every bundle daemon the agent
|
||||||
# mutually exclusive.
|
# reaches is fronted by a host loopback port-forward.
|
||||||
|
# Smolfile isn't usable here — smolvm 0.8.0 makes `--from`
|
||||||
|
# and `--smolfile` 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,
|
||||||
allow_cidrs=[f"{plan.bundle_ip}/32"],
|
allow_cidrs=["127.0.0.1/32"],
|
||||||
env=plan.guest_env,
|
env=plan.guest_env,
|
||||||
)
|
)
|
||||||
stack.callback(_smolvm.machine_delete, plan.machine_name)
|
stack.callback(_smolvm.machine_delete, plan.machine_name)
|
||||||
_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)
|
||||||
|
|
||||||
# 5. Reclaim /home/node for the node user. smolvm's pack
|
# 6. Reclaim /home/node for the node user. smolvm's pack
|
||||||
# process remaps OCI-layer ownership to the host invoker's
|
# process remaps OCI-layer ownership to the host invoker's
|
||||||
# uid (501 on macOS) rather than preserving the image's
|
# uid (501 on macOS) rather than preserving the image's
|
||||||
# uid 1000 — so without this chown, node can't write its
|
# uid 1000 — so without this chown, node can't write its
|
||||||
@@ -129,7 +189,7 @@ def launch(
|
|||||||
["chown", "-R", "node:node", "/home/node"],
|
["chown", "-R", "node:node", "/home/node"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# 6. Provision (CA / prompt / skills / git / supervise).
|
# 7. Provision (CA / prompt / skills / git / supervise).
|
||||||
prompt_path = provision(plan, plan.machine_name)
|
prompt_path = provision(plan, plan.machine_name)
|
||||||
|
|
||||||
yield SmolmachinesBottle(
|
yield SmolmachinesBottle(
|
||||||
@@ -217,6 +277,16 @@ def _bundle_launch_spec(
|
|||||||
]
|
]
|
||||||
volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
|
volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
|
||||||
|
|
||||||
|
# Container ports the agent reaches from the smolvm guest —
|
||||||
|
# published on host loopback so the guest can dial via TSI +
|
||||||
|
# macOS networking. Egress is bundle-internal and never
|
||||||
|
# published.
|
||||||
|
ports_to_publish: list[int] = [_PIPELOCK_PORT]
|
||||||
|
if gp.upstreams:
|
||||||
|
ports_to_publish.append(_GIT_GATE_PORT)
|
||||||
|
if sp is not None:
|
||||||
|
ports_to_publish.append(_SUPERVISE_PORT)
|
||||||
|
|
||||||
return _bundle.BundleLaunchSpec(
|
return _bundle.BundleLaunchSpec(
|
||||||
slug=plan.slug,
|
slug=plan.slug,
|
||||||
network_name=network,
|
network_name=network,
|
||||||
@@ -226,6 +296,7 @@ def _bundle_launch_spec(
|
|||||||
daemons_csv=",".join(daemons),
|
daemons_csv=",".join(daemons),
|
||||||
environment=tuple(env),
|
environment=tuple(env),
|
||||||
volumes=tuple(volumes),
|
volumes=tuple(volumes),
|
||||||
|
ports_to_publish=tuple(ports_to_publish),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -89,29 +89,23 @@ def resolve_plan(
|
|||||||
|
|
||||||
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
|
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
|
||||||
|
|
||||||
# Agent's env. IP literals; no DNS resolution inside the guest
|
# Agent's env: the prepare-time view doesn't yet know the
|
||||||
# (TSI allowlist contains only `<bundle_ip>/32` — no resolver).
|
# host loopback ports the bundle's daemons get published on
|
||||||
# TLS trust env trio (NODE_EXTRA_CA_CERTS / SSL_CERT_FILE /
|
# (those come from docker AFTER `docker run` returns), so
|
||||||
# REQUESTS_CA_BUNDLE) points at Debian's
|
# HTTPS_PROXY / GIT_GATE_URL / MCP_SUPERVISE_URL are
|
||||||
# update-ca-certificates output bundle — provision_ca writes
|
# populated in launch.py and stamped onto guest_env there.
|
||||||
# the per-bottle MITM CA there at launch time.
|
# What we set here is the part that doesn't depend on
|
||||||
|
# bundle bringup — bottle.env literals, the empty-NO_PROXY
|
||||||
|
# safe default, and the TLS trust env trio
|
||||||
|
# (NODE_EXTRA_CA_CERTS / SSL_CERT_FILE / REQUESTS_CA_BUNDLE)
|
||||||
|
# pointing at Debian's update-ca-certificates output bundle.
|
||||||
guest_env: dict[str, str] = {
|
guest_env: dict[str, str] = {
|
||||||
**bottle.env,
|
**bottle.env,
|
||||||
"HTTPS_PROXY": f"http://{bundle_ip}:{_BUNDLE_PIPELOCK_PORT}",
|
|
||||||
"HTTP_PROXY": f"http://{bundle_ip}:{_BUNDLE_PIPELOCK_PORT}",
|
|
||||||
"NO_PROXY": "localhost,127.0.0.1",
|
"NO_PROXY": "localhost,127.0.0.1",
|
||||||
"NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt",
|
"NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt",
|
||||||
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
|
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
|
||||||
"REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt",
|
"REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt",
|
||||||
}
|
}
|
||||||
if bottle.git:
|
|
||||||
guest_env["GIT_GATE_URL"] = (
|
|
||||||
f"git://{bundle_ip}:{_BUNDLE_GIT_GATE_PORT}"
|
|
||||||
)
|
|
||||||
if bottle.supervise:
|
|
||||||
guest_env["MCP_SUPERVISE_URL"] = (
|
|
||||||
f"http://{bundle_ip}:{_BUNDLE_SUPERVISE_PORT}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Inner Plans for the four bundle daemons. The ABCs are
|
# Inner Plans for the four bundle daemons. The ABCs are
|
||||||
# platform-neutral — `.prepare()` writes config files + returns
|
# platform-neutral — `.prepare()` writes config files + returns
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
from ....git_gate import git_gate_render_gitconfig
|
from ....git_gate import git_gate_render_gitconfig
|
||||||
from ....log import info
|
from ....log import info
|
||||||
from ...docker.git_gate import GIT_GATE_PORT
|
|
||||||
from .. import smolvm as _smolvm
|
from .. import smolvm as _smolvm
|
||||||
from ..bottle_plan import SmolmachinesBottlePlan
|
from ..bottle_plan import SmolmachinesBottlePlan
|
||||||
|
|
||||||
@@ -79,10 +78,12 @@ def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> Non
|
|||||||
if not bottle.git:
|
if not bottle.git:
|
||||||
return
|
return
|
||||||
|
|
||||||
# IP-literal form: the TSI allowlist passes <bundle_ip>/32 and
|
# `127.0.0.1:<host port>` form: the bundle's git-gate port
|
||||||
# nothing else, so the agent has to dial the gate by IP+port.
|
# is published on host loopback at launch time so the
|
||||||
gate_host = f"{plan.bundle_ip}:{GIT_GATE_PORT}"
|
# smolvm guest (which can only reach macOS networking via
|
||||||
content = git_gate_render_gitconfig(bottle.git, gate_host)
|
# TSI, not the docker bridge IP) can dial it. launch.py
|
||||||
|
# populates `plan.agent_git_gate_host` after bundle bringup.
|
||||||
|
content = git_gate_render_gitconfig(bottle.git, plan.agent_git_gate_host)
|
||||||
|
|
||||||
guest_gitconfig = f"{_guest_home()}/.gitconfig"
|
guest_gitconfig = f"{_guest_home()}/.gitconfig"
|
||||||
# Stage the file under the plan's stage_dir so `machine cp`
|
# Stage the file under the plan's stage_dir so `machine cp`
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ short `supervise` alias (no DNS in the TSI-allowlisted guest)."""
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ....log import info, warn
|
from ....log import info, warn
|
||||||
from ....supervise import SUPERVISE_PORT
|
|
||||||
from .. import smolvm as _smolvm
|
from .. import smolvm as _smolvm
|
||||||
from ..bottle_plan import SmolmachinesBottlePlan
|
from ..bottle_plan import SmolmachinesBottlePlan
|
||||||
|
|
||||||
@@ -22,21 +21,22 @@ from ..bottle_plan import SmolmachinesBottlePlan
|
|||||||
_SUPERVISE_MCP_NAME = "supervise"
|
_SUPERVISE_MCP_NAME = "supervise"
|
||||||
|
|
||||||
|
|
||||||
def supervise_mcp_url(bundle_ip: str) -> str:
|
|
||||||
return f"http://{bundle_ip}:{SUPERVISE_PORT}/"
|
|
||||||
|
|
||||||
|
|
||||||
def provision_supervise(plan: SmolmachinesBottlePlan, target: str) -> None:
|
def provision_supervise(plan: SmolmachinesBottlePlan, target: str) -> None:
|
||||||
"""Run `claude mcp add` inside the guest to register the
|
"""Run `claude mcp add` inside the guest to register the
|
||||||
supervise sidecar in claude-code's user config. No-op when
|
supervise sidecar in claude-code's user config. No-op when
|
||||||
bottle.supervise is False.
|
bottle.supervise is False.
|
||||||
|
|
||||||
|
The URL is the agent-side endpoint launch.py populated after
|
||||||
|
bundle bringup — `http://127.0.0.1:<host port>/` rather than
|
||||||
|
the bundle's docker bridge IP, because that bridge isn't
|
||||||
|
reachable from the smolvm guest on macOS.
|
||||||
|
|
||||||
Failure is logged but not fatal: the bottle still works (you
|
Failure is logged but not fatal: the bottle still works (you
|
||||||
just can't call supervise tools from the agent until the entry
|
just can't call supervise tools from the agent until the entry
|
||||||
is added manually). The operator sees the warning at launch."""
|
is added manually). The operator sees the warning at launch."""
|
||||||
if plan.supervise_plan is None:
|
if plan.supervise_plan is None:
|
||||||
return
|
return
|
||||||
url = supervise_mcp_url(plan.bundle_ip)
|
url = plan.agent_supervise_url
|
||||||
info(f"registering supervise MCP server in agent claude config → {url}")
|
info(f"registering supervise MCP server in agent claude config → {url}")
|
||||||
r = _smolvm.machine_exec(
|
r = _smolvm.machine_exec(
|
||||||
target,
|
target,
|
||||||
@@ -57,4 +57,4 @@ def provision_supervise(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["provision_supervise", "supervise_mcp_url"]
|
__all__ = ["provision_supervise"]
|
||||||
|
|||||||
@@ -70,6 +70,13 @@ class BundleLaunchSpec:
|
|||||||
environment: Sequence[str] = field(default_factory=tuple)
|
environment: Sequence[str] = field(default_factory=tuple)
|
||||||
# (host_path, container_path, read_only) bind mounts.
|
# (host_path, container_path, read_only) bind mounts.
|
||||||
volumes: Sequence[tuple[str, str, bool]] = field(default_factory=tuple)
|
volumes: Sequence[tuple[str, str, bool]] = field(default_factory=tuple)
|
||||||
|
# Container ports to publish on the host's 127.0.0.1, random
|
||||||
|
# host-side port per entry. The smolvm guest's TSI talks via
|
||||||
|
# macOS networking, so docker container IPs (192.168.x.x in
|
||||||
|
# the daemon's bridge) aren't directly reachable from the
|
||||||
|
# guest — host-loopback port-forwards are. Egress's port
|
||||||
|
# is bundle-internal and never published.
|
||||||
|
ports_to_publish: Sequence[int] = field(default_factory=tuple)
|
||||||
|
|
||||||
|
|
||||||
def create_bundle_network(network_name: str, subnet: str, gateway: str) -> None:
|
def create_bundle_network(network_name: str, subnet: str, gateway: str) -> None:
|
||||||
@@ -135,6 +142,11 @@ def start_bundle(spec: BundleLaunchSpec, *,
|
|||||||
for host_path, container_path, read_only in spec.volumes:
|
for host_path, container_path, read_only in spec.volumes:
|
||||||
suffix = ":ro" if read_only else ""
|
suffix = ":ro" if read_only else ""
|
||||||
argv += ["-v", f"{host_path}:{container_path}{suffix}"]
|
argv += ["-v", f"{host_path}:{container_path}{suffix}"]
|
||||||
|
# Loopback-only host port-forwards — the smolvm guest's TSI
|
||||||
|
# uses macOS networking, and macOS loopback is the only host
|
||||||
|
# surface that round-trips into Docker Desktop's daemon VM.
|
||||||
|
for port in spec.ports_to_publish:
|
||||||
|
argv += ["-p", f"127.0.0.1::{port}"]
|
||||||
argv.append(spec.image)
|
argv.append(spec.image)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
argv, capture_output=True, text=True,
|
argv, capture_output=True, text=True,
|
||||||
@@ -147,6 +159,33 @@ def start_bundle(spec: BundleLaunchSpec, *,
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bundle_host_port(slug: str, container_port: int) -> int:
|
||||||
|
"""`docker port <bundle> <container_port>/tcp` → the random
|
||||||
|
host-side port docker assigned. Called after `start_bundle`
|
||||||
|
on each container port listed in `BundleLaunchSpec
|
||||||
|
.ports_to_publish` so the launch step can build the agent's
|
||||||
|
HTTPS_PROXY / GIT_GATE / SUPERVISE URLs in
|
||||||
|
`127.0.0.1:<host port>` form."""
|
||||||
|
container = bundle_container_name(slug)
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "port", container, f"{container_port}/tcp"],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
die(
|
||||||
|
f"docker port {container} {container_port}/tcp failed: "
|
||||||
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||||
|
)
|
||||||
|
# `127.0.0.1:54321\n` — rpartition on last colon gives the port.
|
||||||
|
line = (result.stdout or "").splitlines()[0].strip()
|
||||||
|
_, _, port_str = line.rpartition(":")
|
||||||
|
try:
|
||||||
|
return int(port_str)
|
||||||
|
except ValueError:
|
||||||
|
die(f"unexpected `docker port` output: {line!r}")
|
||||||
|
return -1 # unreachable; die() never returns
|
||||||
|
|
||||||
|
|
||||||
def stop_bundle(slug: str) -> None:
|
def stop_bundle(slug: str) -> None:
|
||||||
"""Idempotent: a missing container returns success."""
|
"""Idempotent: a missing container returns success."""
|
||||||
container = bundle_container_name(slug)
|
container = bundle_container_name(slug)
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ def _plan(
|
|||||||
pipelock_ca_path: Path = Path(),
|
pipelock_ca_path: Path = Path(),
|
||||||
supervise: bool = False,
|
supervise: bool = False,
|
||||||
bundle_ip: str = "192.168.50.2",
|
bundle_ip: str = "192.168.50.2",
|
||||||
|
agent_git_gate_host: str = "127.0.0.1:55555",
|
||||||
|
agent_supervise_url: str = "http://127.0.0.1:55556/",
|
||||||
) -> SmolmachinesBottlePlan:
|
) -> SmolmachinesBottlePlan:
|
||||||
bottle_json: dict = {}
|
bottle_json: dict = {}
|
||||||
if git:
|
if git:
|
||||||
@@ -111,6 +113,8 @@ def _plan(
|
|||||||
mitmproxy_ca_cert_only_host_path=egress_ca_path,
|
mitmproxy_ca_cert_only_host_path=egress_ca_path,
|
||||||
),
|
),
|
||||||
supervise_plan=supervise_plan,
|
supervise_plan=supervise_plan,
|
||||||
|
agent_git_gate_host=agent_git_gate_host,
|
||||||
|
agent_supervise_url=agent_supervise_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -412,9 +416,10 @@ class TestProvisionGit(unittest.TestCase):
|
|||||||
cp.assert_not_called()
|
cp.assert_not_called()
|
||||||
|
|
||||||
def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self):
|
def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self):
|
||||||
# Smolmachines's TSI-allowlisted guest has no DNS resolver,
|
# Smolmachines's TSI-allowlisted guest dials git-gate via
|
||||||
# so the insteadOf URL has to be IP+port rather than the
|
# `127.0.0.1:<host port>` — the bundle's git-gate port is
|
||||||
# docker backend's `git-gate` short alias.
|
# published on host loopback at launch time, and the plan
|
||||||
|
# carries the discovered host port (here mocked to 9418).
|
||||||
plan = _plan(
|
plan = _plan(
|
||||||
git=[GitEntry(
|
git=[GitEntry(
|
||||||
Name="claude-bottle",
|
Name="claude-bottle",
|
||||||
@@ -422,7 +427,7 @@ class TestProvisionGit(unittest.TestCase):
|
|||||||
IdentityFile="~/.ssh/id_ed25519",
|
IdentityFile="~/.ssh/id_ed25519",
|
||||||
)],
|
)],
|
||||||
stage_dir=self.stage,
|
stage_dir=self.stage,
|
||||||
bundle_ip="192.168.99.2",
|
agent_git_gate_host="127.0.0.1:9418",
|
||||||
)
|
)
|
||||||
with patch(
|
with patch(
|
||||||
"claude_bottle.backend.smolmachines.provision.git._smolvm.machine_cp"
|
"claude_bottle.backend.smolmachines.provision.git._smolvm.machine_cp"
|
||||||
@@ -437,7 +442,7 @@ class TestProvisionGit(unittest.TestCase):
|
|||||||
self.assertEqual(self.stage, staged_path.parent)
|
self.assertEqual(self.stage, staged_path.parent)
|
||||||
content = staged_path.read_text()
|
content = staged_path.read_text()
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
'[url "git://192.168.99.2:9418/claude-bottle.git"]', content,
|
'[url "git://127.0.0.1:9418/claude-bottle.git"]', content,
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"\tinsteadOf = ssh://git@host/repo.git", content,
|
"\tinsteadOf = ssh://git@host/repo.git", content,
|
||||||
@@ -453,7 +458,10 @@ class TestProvisionSupervise(unittest.TestCase):
|
|||||||
ex.assert_not_called()
|
ex.assert_not_called()
|
||||||
|
|
||||||
def test_calls_claude_mcp_add_when_supervise_enabled(self):
|
def test_calls_claude_mcp_add_when_supervise_enabled(self):
|
||||||
plan = _plan(supervise=True, bundle_ip="192.168.50.2")
|
plan = _plan(
|
||||||
|
supervise=True,
|
||||||
|
agent_supervise_url="http://127.0.0.1:9100/",
|
||||||
|
)
|
||||||
with patch(
|
with patch(
|
||||||
"claude_bottle.backend.smolmachines.provision.supervise._smolvm.machine_exec",
|
"claude_bottle.backend.smolmachines.provision.supervise._smolvm.machine_exec",
|
||||||
return_value=SmolvmRunResult(returncode=0, stdout="", stderr=""),
|
return_value=SmolvmRunResult(returncode=0, stdout="", stderr=""),
|
||||||
@@ -462,13 +470,15 @@ class TestProvisionSupervise(unittest.TestCase):
|
|||||||
ex.assert_called_once()
|
ex.assert_called_once()
|
||||||
argv = ex.call_args.args[1]
|
argv = ex.call_args.args[1]
|
||||||
# claude mcp add --scope user --transport http supervise <url>
|
# claude mcp add --scope user --transport http supervise <url>
|
||||||
|
# — URL is the agent-side endpoint (host loopback +
|
||||||
|
# discovered port), not the docker bridge IP.
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[
|
[
|
||||||
"claude", "mcp", "add",
|
"claude", "mcp", "add",
|
||||||
"--scope", "user",
|
"--scope", "user",
|
||||||
"--transport", "http",
|
"--transport", "http",
|
||||||
"supervise",
|
"supervise",
|
||||||
"http://192.168.50.2:9100/",
|
"http://127.0.0.1:9100/",
|
||||||
],
|
],
|
||||||
argv,
|
argv,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user