feat(smolmachines): thread inner Plans + bundle daemons run (PRD 0023 chunk 4b) #70
@@ -43,6 +43,7 @@ from .bottle_state import (
|
|||||||
transcript_snapshot_dir,
|
transcript_snapshot_dir,
|
||||||
write_per_bottle_dockerfile,
|
write_per_bottle_dockerfile,
|
||||||
)
|
)
|
||||||
|
from .sidecar_bundle import sidecar_bundle_container_name
|
||||||
|
|
||||||
|
|
||||||
# Agent home inside the container (per the repo Dockerfile's
|
# Agent home inside the container (per the repo Dockerfile's
|
||||||
@@ -52,9 +53,7 @@ _AGENT_HOME_IN_CONTAINER = "/home/node"
|
|||||||
_AGENT_TRANSCRIPT_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/.claude"
|
_AGENT_TRANSCRIPT_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/.claude"
|
||||||
_AGENT_WORKSPACE_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/workspace"
|
_AGENT_WORKSPACE_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/workspace"
|
||||||
|
|
||||||
# Per-bottle resource name patterns (mirroring prepare.py /
|
# Per-bottle resource name patterns (mirroring prepare.py).
|
||||||
# the various sidecar modules). The agent container's name is the
|
|
||||||
# slug with no infix; sidecars carry an infix like cred-proxy.
|
|
||||||
def _agent_container_name(slug: str) -> str:
|
def _agent_container_name(slug: str) -> str:
|
||||||
return f"claude-bottle-{slug}"
|
return f"claude-bottle-{slug}"
|
||||||
|
|
||||||
@@ -65,10 +64,7 @@ def _per_bottle_container_names(slug: str) -> list[str]:
|
|||||||
fine to include names that don't exist for a given bottle."""
|
fine to include names that don't exist for a given bottle."""
|
||||||
return [
|
return [
|
||||||
_agent_container_name(slug),
|
_agent_container_name(slug),
|
||||||
f"claude-bottle-cred-proxy-{slug}",
|
sidecar_bundle_container_name(slug),
|
||||||
f"claude-bottle-pipelock-{slug}",
|
|
||||||
f"claude-bottle-git-gate-{slug}",
|
|
||||||
f"claude-bottle-supervise-{slug}",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ from ...egress import (
|
|||||||
EGRESS_HOSTNAME,
|
EGRESS_HOSTNAME,
|
||||||
EGRESS_ROUTES_IN_CONTAINER,
|
EGRESS_ROUTES_IN_CONTAINER,
|
||||||
)
|
)
|
||||||
|
from ...git_gate import GIT_GATE_HOSTNAME, git_gate_aggregate_extra_hosts
|
||||||
from ...log import die, warn
|
from ...log import die, warn
|
||||||
from ...git_gate import git_gate_aggregate_extra_hosts
|
from ...pipelock import PIPELOCK_HOSTNAME
|
||||||
from ...supervise import (
|
from ...supervise import (
|
||||||
CURRENT_CONFIG_DIR_IN_AGENT,
|
CURRENT_CONFIG_DIR_IN_AGENT,
|
||||||
QUEUE_DIR_IN_CONTAINER,
|
QUEUE_DIR_IN_CONTAINER,
|
||||||
@@ -62,20 +63,17 @@ from .bottle_plan import DockerBottlePlan
|
|||||||
from .egress import (
|
from .egress import (
|
||||||
EGRESS_CA_IN_CONTAINER,
|
EGRESS_CA_IN_CONTAINER,
|
||||||
EGRESS_PIPELOCK_CA_IN_CONTAINER,
|
EGRESS_PIPELOCK_CA_IN_CONTAINER,
|
||||||
egress_container_name,
|
|
||||||
)
|
)
|
||||||
from .git_gate import (
|
from .git_gate import (
|
||||||
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
|
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
|
||||||
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_container_name,
|
|
||||||
)
|
)
|
||||||
from .pipelock import (
|
from .pipelock import (
|
||||||
PIPELOCK_CA_CERT_IN_CONTAINER,
|
PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||||
PIPELOCK_CA_KEY_IN_CONTAINER,
|
PIPELOCK_CA_KEY_IN_CONTAINER,
|
||||||
PIPELOCK_PORT,
|
PIPELOCK_PORT,
|
||||||
pipelock_container_name,
|
|
||||||
)
|
)
|
||||||
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||||
from .sidecar_bundle import (
|
from .sidecar_bundle import (
|
||||||
@@ -83,7 +81,6 @@ from .sidecar_bundle import (
|
|||||||
SIDECAR_BUNDLE_IMAGE,
|
SIDECAR_BUNDLE_IMAGE,
|
||||||
sidecar_bundle_container_name,
|
sidecar_bundle_container_name,
|
||||||
)
|
)
|
||||||
from .supervise import supervise_container_name
|
|
||||||
|
|
||||||
|
|
||||||
# Repo root, used as the build context for the bundle Dockerfile.
|
# Repo root, used as the build context for the bundle Dockerfile.
|
||||||
@@ -233,20 +230,17 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
"read_only": False,
|
"read_only": False,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Internal-network aliases: every shortname + long-form legacy
|
# Internal-network aliases: the agent reaches each daemon through
|
||||||
# name routes to the bundle so the agent's HTTPS_PROXY URL
|
# its short name (pipelock / egress / git-gate / supervise) which
|
||||||
# (which references either `pipelock` or `egress`) keeps
|
# the bundle answers as if it were the daemon itself.
|
||||||
# resolving without an agent-side change.
|
|
||||||
internal_aliases = [
|
internal_aliases = [
|
||||||
pipelock_container_name(plan.slug),
|
PIPELOCK_HOSTNAME,
|
||||||
EGRESS_HOSTNAME,
|
EGRESS_HOSTNAME,
|
||||||
egress_container_name(plan.slug),
|
|
||||||
]
|
]
|
||||||
if gp.upstreams:
|
if gp.upstreams:
|
||||||
internal_aliases.append(git_gate_container_name(plan.slug))
|
internal_aliases.append(GIT_GATE_HOSTNAME)
|
||||||
if sp is not None:
|
if sp is not None:
|
||||||
internal_aliases.append(SUPERVISE_HOSTNAME)
|
internal_aliases.append(SUPERVISE_HOSTNAME)
|
||||||
internal_aliases.append(supervise_container_name(plan.slug))
|
|
||||||
|
|
||||||
service: dict[str, Any] = {
|
service: dict[str, Any] = {
|
||||||
"image": SIDECAR_BUNDLE_IMAGE,
|
"image": SIDECAR_BUNDLE_IMAGE,
|
||||||
@@ -330,7 +324,7 @@ def _agent_proxy_url(plan: DockerBottlePlan) -> str:
|
|||||||
if plan.egress_plan.routes:
|
if plan.egress_plan.routes:
|
||||||
from .egress import EGRESS_PORT
|
from .egress import EGRESS_PORT
|
||||||
return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}"
|
return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}"
|
||||||
return f"http://{pipelock_container_name(plan.slug)}:{PIPELOCK_PORT}"
|
return f"http://{PIPELOCK_HOSTNAME}:{PIPELOCK_PORT}"
|
||||||
|
|
||||||
|
|
||||||
def _agent_no_proxy(plan: DockerBottlePlan) -> str:
|
def _agent_no_proxy(plan: DockerBottlePlan) -> str:
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""DockerEgress — the Docker-specific lifecycle for the
|
"""Docker-side egress helpers: port pin, in-container CA paths,
|
||||||
per-bottle egress sidecar (PRD 0017). Inherits the platform-
|
container naming, and the host-side mitmproxy CA mint. The
|
||||||
agnostic prepare step (route lift + routes.yaml render + token-env
|
prepare-time routes-yaml rendering itself lives on the
|
||||||
map derivation) from `Egress`.
|
platform-neutral `Egress` ABC — backends instantiate it directly.
|
||||||
|
|
||||||
Chunks 1+2 of the PRD: the lifecycle is implemented and wired into
|
The per-container `.start()` / `.stop()` lifecycle was removed in
|
||||||
launch.py — cred-proxy is gone. Chunk 3 retargets the cred-proxy-
|
PRD 0024 chunk 3; the sidecar bundle (PRD 0024) runs egress
|
||||||
block remediation flow (PRD 0014)."""
|
under its python init supervisor."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -13,7 +13,6 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ...egress import Egress
|
|
||||||
from ...log import die
|
from ...log import die
|
||||||
|
|
||||||
|
|
||||||
@@ -33,14 +32,6 @@ EGRESS_PIPELOCK_CA_IN_CONTAINER = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def egress_container_name(slug: str) -> str:
|
|
||||||
"""The legacy per-sidecar container name. Kept as a function so
|
|
||||||
the renderer can register it as a docker-network alias on the
|
|
||||||
bundle — any code still referring to `claude-bottle-egress-<slug>`
|
|
||||||
resolves to the bundle's IP."""
|
|
||||||
return f"claude-bottle-egress-{slug}"
|
|
||||||
|
|
||||||
|
|
||||||
def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
||||||
"""Mint the per-bottle egress MITM CA via host `openssl req`.
|
"""Mint the per-bottle egress MITM CA via host `openssl req`.
|
||||||
|
|
||||||
@@ -130,9 +121,3 @@ def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
|||||||
mitm.write_bytes(cert_path.read_bytes() + key_path.read_bytes())
|
mitm.write_bytes(cert_path.read_bytes() + key_path.read_bytes())
|
||||||
mitm.chmod(0o644)
|
mitm.chmod(0o644)
|
||||||
return (mitm, cert_path)
|
return (mitm, cert_path)
|
||||||
|
|
||||||
|
|
||||||
class DockerEgress(Egress):
|
|
||||||
"""Docker-flavored Egress: inherits `.prepare()` from the base.
|
|
||||||
Container lifecycle is owned by compose; per-container
|
|
||||||
`.start()` / `.stop()` were removed in PRD 0024 chunk 3."""
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
"""DockerGitGate — Docker-flavored git-gate config (PRD 0008).
|
"""Docker-side git-gate constants: in-container paths the renderer's
|
||||||
Inherits the platform-agnostic prepare step (upstream lift +
|
bind-mounts target + the listening port. The prepare-time entrypoint
|
||||||
entrypoint/hook render) from `GitGate`. The git-gate daemon runs
|
/ hook render lives on the platform-neutral `GitGate` ABC — backends
|
||||||
inside the sidecar bundle (PRD 0024); this module just holds the
|
instantiate it directly. The git-gate daemon's container lifecycle
|
||||||
in-container paths the renderer's bind-mounts target."""
|
is owned by the sidecar bundle (PRD 0024)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ...git_gate import GitGate
|
|
||||||
|
|
||||||
|
|
||||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER = "/git-gate-entrypoint.sh"
|
GIT_GATE_ENTRYPOINT_IN_CONTAINER = "/git-gate-entrypoint.sh"
|
||||||
GIT_GATE_HOOK_IN_CONTAINER = "/etc/git-gate/pre-receive"
|
GIT_GATE_HOOK_IN_CONTAINER = "/etc/git-gate/pre-receive"
|
||||||
@@ -16,24 +14,3 @@ GIT_GATE_CREDS_DIR_IN_CONTAINER = "/git-gate/creds"
|
|||||||
|
|
||||||
# git daemon's default listening port.
|
# git daemon's default listening port.
|
||||||
GIT_GATE_PORT = 9418
|
GIT_GATE_PORT = 9418
|
||||||
|
|
||||||
|
|
||||||
def git_gate_container_name(slug: str) -> str:
|
|
||||||
"""The legacy per-sidecar container name. Kept as a function so
|
|
||||||
the renderer can register it as a docker-network alias on the
|
|
||||||
bundle — any code still dialing `claude-bottle-git-gate-<slug>`
|
|
||||||
resolves to the bundle's IP."""
|
|
||||||
return f"claude-bottle-git-gate-{slug}"
|
|
||||||
|
|
||||||
|
|
||||||
def git_gate_host(slug: str) -> str:
|
|
||||||
"""The hostname the agent's git client connects to. Resolves via
|
|
||||||
the bundle's network alias to the bundle container, where the
|
|
||||||
git-gate daemon listens on GIT_GATE_PORT."""
|
|
||||||
return git_gate_container_name(slug)
|
|
||||||
|
|
||||||
|
|
||||||
class DockerGitGate(GitGate):
|
|
||||||
"""Docker-flavored GitGate: inherits `.prepare()` from the base.
|
|
||||||
The git-gate daemon's container lifecycle is owned by the
|
|
||||||
sidecar bundle (PRD 0024)."""
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ from .compose import (
|
|||||||
)
|
)
|
||||||
from .egress import egress_tls_init
|
from .egress import egress_tls_init
|
||||||
from .pipelock import (
|
from .pipelock import (
|
||||||
pipelock_proxy_url,
|
BUNDLE_LOCAL_PIPELOCK_URL,
|
||||||
pipelock_tls_init,
|
pipelock_tls_init,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ def launch(
|
|||||||
mitmproxy_ca_host_path=egress_ca_host,
|
mitmproxy_ca_host_path=egress_ca_host,
|
||||||
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
|
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
|
||||||
pipelock_ca_host_path=ca_cert_host,
|
pipelock_ca_host_path=ca_cert_host,
|
||||||
pipelock_proxy_url=pipelock_proxy_url(plan.slug),
|
pipelock_proxy_url=BUNDLE_LOCAL_PIPELOCK_URL,
|
||||||
)
|
)
|
||||||
supervise_plan = plan.supervise_plan
|
supervise_plan = plan.supervise_plan
|
||||||
if supervise_plan is not None:
|
if supervise_plan is not None:
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
"""DockerPipelockProxy — the Docker-specific implementation of the
|
"""Docker-side pipelock helpers: image pin, container naming, and
|
||||||
sidecar's `.prepare()` step + in-container CA path constants.
|
the one-shot `pipelock tls init` host-side CA mint. The
|
||||||
Inherits the platform-agnostic YAML-config generation from
|
prepare-time YAML rendering itself lives on the platform-neutral
|
||||||
PipelockProxy.
|
`PipelockProxy` ABC — backends instantiate it directly.
|
||||||
|
|
||||||
The per-container `.start()` / `.stop()` lifecycle was deleted in
|
The per-container `.start()` / `.stop()` lifecycle was deleted in
|
||||||
PRD 0024 chunk 3 — compose-up owns the container lifecycle (PRD
|
PRD 0024 chunk 3; compose-up owns the container lifecycle (PRD
|
||||||
0018) and the bundle path (PRD 0024) collapses pipelock + egress
|
0018) and the bundle path (PRD 0024) collapses pipelock + egress
|
||||||
+ git-gate + supervise into one container. What remains here is
|
+ git-gate + supervise into one container."""
|
||||||
the prepare-time YAML rendering + the CA path constants the
|
|
||||||
compose renderer reads."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -17,7 +15,13 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ...log import die
|
from ...log import die
|
||||||
from ...pipelock import PipelockProxy
|
# Re-exported for the compose renderer + smolmachines launch step
|
||||||
|
# (they used to import these from this module before they moved to
|
||||||
|
# the platform-neutral pipelock module).
|
||||||
|
from ...pipelock import ( # noqa: F401
|
||||||
|
PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||||
|
PIPELOCK_CA_KEY_IN_CONTAINER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Pipelock image, pinned by digest. The digest is the multi-arch image
|
# Pipelock image, pinned by digest. The digest is the multi-arch image
|
||||||
@@ -30,19 +34,12 @@ PIPELOCK_IMAGE = os.environ.get(
|
|||||||
# Listening port for pipelock's forward proxy.
|
# Listening port for pipelock's forward proxy.
|
||||||
PIPELOCK_PORT = os.environ.get("CLAUDE_BOTTLE_PIPELOCK_PORT", "8888")
|
PIPELOCK_PORT = os.environ.get("CLAUDE_BOTTLE_PIPELOCK_PORT", "8888")
|
||||||
|
|
||||||
# In-container paths where the per-bottle CA cert + key land via
|
|
||||||
# the compose renderer's bind-mounts. Pipelock's rendered YAML
|
|
||||||
# references these paths under `tls_interception`.
|
|
||||||
PIPELOCK_CA_CERT_IN_CONTAINER = "/etc/pipelock-ca.pem"
|
|
||||||
PIPELOCK_CA_KEY_IN_CONTAINER = "/etc/pipelock-ca-key.pem"
|
|
||||||
|
|
||||||
|
# The URL egress dials for its upstream HTTPS_PROXY. egress and
|
||||||
def pipelock_container_name(slug: str) -> str:
|
# pipelock share the same container's network namespace inside the
|
||||||
return f"claude-bottle-pipelock-{slug}"
|
# sidecar bundle, so loopback reaches pipelock directly — no docker
|
||||||
|
# DNS aliases involved.
|
||||||
|
BUNDLE_LOCAL_PIPELOCK_URL = f"http://127.0.0.1:{PIPELOCK_PORT}"
|
||||||
def pipelock_proxy_url(slug: str) -> str:
|
|
||||||
return f"http://{pipelock_container_name(slug)}:{PIPELOCK_PORT}"
|
|
||||||
|
|
||||||
|
|
||||||
def pipelock_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
def pipelock_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
||||||
@@ -82,13 +79,3 @@ def pipelock_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
|||||||
key.chmod(0o600)
|
key.chmod(0o600)
|
||||||
cert.chmod(0o644)
|
cert.chmod(0o644)
|
||||||
return (cert, key)
|
return (cert, key)
|
||||||
|
|
||||||
|
|
||||||
class DockerPipelockProxy(PipelockProxy):
|
|
||||||
"""Docker-flavored PipelockProxy: inherits `.prepare()` from the
|
|
||||||
base, exposes the in-container CA paths the renderer reads.
|
|
||||||
Container lifecycle is owned by compose."""
|
|
||||||
|
|
||||||
CA_CERT_IN_CONTAINER = PIPELOCK_CA_CERT_IN_CONTAINER
|
|
||||||
CA_KEY_IN_CONTAINER = PIPELOCK_CA_KEY_IN_CONTAINER
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,15 @@ import os
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...egress import Egress
|
||||||
from ...env import ResolvedEnv, resolve_env
|
from ...env import ResolvedEnv, resolve_env
|
||||||
|
from ...git_gate import GitGate
|
||||||
from ...log import die
|
from ...log import die
|
||||||
|
from ...pipelock import PipelockProxy
|
||||||
|
from ...supervise import Supervise
|
||||||
from .. import BottleSpec
|
from .. import BottleSpec
|
||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
from .egress import DockerEgress, egress_container_name
|
|
||||||
from .git_gate import DockerGitGate, git_gate_container_name
|
|
||||||
from .bottle_state import (
|
from .bottle_state import (
|
||||||
BottleMetadata,
|
BottleMetadata,
|
||||||
agent_state_dir,
|
agent_state_dir,
|
||||||
@@ -35,8 +37,7 @@ from .bottle_state import (
|
|||||||
supervise_state_dir,
|
supervise_state_dir,
|
||||||
write_metadata,
|
write_metadata,
|
||||||
)
|
)
|
||||||
from .pipelock import DockerPipelockProxy, pipelock_container_name
|
from .sidecar_bundle import sidecar_bundle_container_name
|
||||||
from .supervise import DockerSupervise, supervise_container_name
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_plan(
|
def resolve_plan(
|
||||||
@@ -49,10 +50,10 @@ def resolve_plan(
|
|||||||
validation already ran in the base class."""
|
validation already ran in the base class."""
|
||||||
docker_mod.require_docker()
|
docker_mod.require_docker()
|
||||||
|
|
||||||
proxy = DockerPipelockProxy()
|
proxy = PipelockProxy()
|
||||||
git_gate = DockerGitGate()
|
git_gate = GitGate()
|
||||||
egress = DockerEgress()
|
egress = Egress()
|
||||||
supervise = DockerSupervise()
|
supervise = Supervise()
|
||||||
|
|
||||||
manifest = spec.manifest
|
manifest = spec.manifest
|
||||||
agent = manifest.agents[spec.agent_name]
|
agent = manifest.agents[spec.agent_name]
|
||||||
@@ -123,30 +124,18 @@ def resolve_plan(
|
|||||||
f"clean up old containers with 'docker rm -f <name>'"
|
f"clean up old containers with 'docker rm -f <name>'"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Probe sidecar container names for orphans from a previous run.
|
# Probe the sidecar-bundle container name for an orphan from a
|
||||||
# Sidecar names are deterministic from the slug; an orphan would
|
# previous run. Otherwise a stale bundle surfaces as a
|
||||||
# surface as a docker-create conflict deep inside launch() with no
|
# docker-create conflict deep inside launch() with no actionable
|
||||||
# actionable hint. Fail fast here with a cleanup pointer instead.
|
# hint; failing fast here points at the cleanup command.
|
||||||
# Only probe sidecars this launch will actually try to create:
|
bundle_name = sidecar_bundle_container_name(slug)
|
||||||
# pipelock always; git-gate when bottle.git is non-empty;
|
if docker_mod.container_exists(bundle_name):
|
||||||
# egress when bottle.egress.routes is non-empty.
|
die(
|
||||||
sidecar_probes: list[tuple[str, str]] = [
|
f"sidecar bundle container '{bundle_name}' already exists. "
|
||||||
("pipelock", pipelock_container_name(slug)),
|
f"This is an orphan from a previous run; clean it up with "
|
||||||
]
|
f"'./cli.py cleanup' (or 'docker rm -f {bundle_name}') and "
|
||||||
if bottle.git:
|
f"retry."
|
||||||
sidecar_probes.append(("git-gate", git_gate_container_name(slug)))
|
)
|
||||||
if bottle.egress.routes:
|
|
||||||
sidecar_probes.append(("egress", egress_container_name(slug)))
|
|
||||||
if bottle.supervise:
|
|
||||||
sidecar_probes.append(("supervise", supervise_container_name(slug)))
|
|
||||||
for label, sidecar_name in sidecar_probes:
|
|
||||||
if docker_mod.container_exists(sidecar_name):
|
|
||||||
die(
|
|
||||||
f"{label} sidecar container '{sidecar_name}' already exists. "
|
|
||||||
f"This is an orphan from a previous run; clean it up with "
|
|
||||||
f"'./cli.py cleanup' (or 'docker rm -f {sidecar_name}') and "
|
|
||||||
f"retry."
|
|
||||||
)
|
|
||||||
|
|
||||||
# PRD 0018 chunk 2: prepare-time scratch files live under
|
# PRD 0018 chunk 2: prepare-time scratch files live under
|
||||||
# ~/.claude-bottle/state/<slug>/<service>/ so chunk 3's compose
|
# ~/.claude-bottle/state/<slug>/<service>/ so chunk 3's compose
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ....git_gate import GIT_GATE_HOSTNAME
|
||||||
from ....log import info
|
from ....log import info
|
||||||
from ....manifest import GitEntry
|
from ....manifest import GitEntry
|
||||||
from .. import util as docker_mod
|
from .. import util as docker_mod
|
||||||
from ..bottle_plan import DockerBottlePlan
|
from ..bottle_plan import DockerBottlePlan
|
||||||
from ..git_gate import git_gate_host
|
|
||||||
|
|
||||||
|
|
||||||
def provision_git(plan: DockerBottlePlan, target: str) -> None:
|
def provision_git(plan: DockerBottlePlan, target: str) -> None:
|
||||||
@@ -56,7 +56,7 @@ def _provision_cwd_git(plan: DockerBottlePlan, target: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def render_git_gate_gitconfig(slug: str, entries: tuple[GitEntry, ...]) -> str:
|
def render_git_gate_gitconfig(entries: tuple[GitEntry, ...]) -> str:
|
||||||
"""Render the ~/.gitconfig content for git-gate `insteadOf`
|
"""Render the ~/.gitconfig content for git-gate `insteadOf`
|
||||||
rewrites. Pure host-side, no docker; exposed for tests.
|
rewrites. Pure host-side, no docker; exposed for tests.
|
||||||
|
|
||||||
@@ -64,7 +64,6 @@ def render_git_gate_gitconfig(slug: str, entries: tuple[GitEntry, ...]) -> str:
|
|||||||
cleanly without conditional formatting at the call site."""
|
cleanly without conditional formatting at the call site."""
|
||||||
if not entries:
|
if not entries:
|
||||||
return ""
|
return ""
|
||||||
gate = git_gate_host(slug)
|
|
||||||
out = [
|
out = [
|
||||||
"# claude-bottle git-gate (PRD 0008): every git operation against\n",
|
"# claude-bottle git-gate (PRD 0008): every git operation against\n",
|
||||||
"# a declared upstream routes through the gate, which mirrors\n",
|
"# a declared upstream routes through the gate, which mirrors\n",
|
||||||
@@ -72,7 +71,7 @@ def render_git_gate_gitconfig(slug: str, entries: tuple[GitEntry, ...]) -> str:
|
|||||||
"# fetch-from-upstream-before-every-upload-pack via access-hook).\n",
|
"# fetch-from-upstream-before-every-upload-pack via access-hook).\n",
|
||||||
]
|
]
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
out.append(f'[url "git://{gate}/{entry.Name}.git"]\n')
|
out.append(f'[url "git://{GIT_GATE_HOSTNAME}/{entry.Name}.git"]\n')
|
||||||
out.append(f"\tinsteadOf = {entry.Upstream}\n")
|
out.append(f"\tinsteadOf = {entry.Upstream}\n")
|
||||||
return "".join(out)
|
return "".join(out)
|
||||||
|
|
||||||
@@ -87,7 +86,7 @@ def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None:
|
|||||||
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||||
container_gitconfig = f"{container_home}/.gitconfig"
|
container_gitconfig = f"{container_home}/.gitconfig"
|
||||||
|
|
||||||
content = render_git_gate_gitconfig(plan.slug, bottle.git)
|
content = render_git_gate_gitconfig(bottle.git)
|
||||||
config_file = plan.stage_dir / "agent_gitconfig"
|
config_file = plan.stage_dir / "agent_gitconfig"
|
||||||
config_file.write_text(content)
|
config_file.write_text(content)
|
||||||
config_file.chmod(0o600)
|
config_file.chmod(0o600)
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
"""DockerSupervise — Docker-flavored supervise config (PRD 0013).
|
|
||||||
Inherits the platform-agnostic prepare step (queue dir +
|
|
||||||
current-config staging) from `Supervise`. The supervise daemon
|
|
||||||
runs inside the sidecar bundle (PRD 0024); this module just holds
|
|
||||||
the container-name helper the renderer's network alias targets."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from ...supervise import Supervise
|
|
||||||
|
|
||||||
|
|
||||||
def supervise_container_name(slug: str) -> str:
|
|
||||||
"""The legacy per-sidecar container name. Kept as a function so
|
|
||||||
the renderer can register it as a docker-network alias on the
|
|
||||||
bundle — any code still referring to
|
|
||||||
`claude-bottle-supervise-<slug>` resolves to the bundle's IP."""
|
|
||||||
return f"claude-bottle-supervise-{slug}"
|
|
||||||
|
|
||||||
|
|
||||||
class DockerSupervise(Supervise):
|
|
||||||
"""Docker-flavored Supervise: inherits `.prepare()` from the base.
|
|
||||||
The supervise daemon's container lifecycle is owned by the
|
|
||||||
sidecar bundle (PRD 0024)."""
|
|
||||||
@@ -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,24 +1,50 @@
|
|||||||
"""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 ...pipelock import (
|
||||||
|
PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||||
|
PIPELOCK_CA_KEY_IN_CONTAINER,
|
||||||
|
)
|
||||||
|
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 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 .bottle import SmolmachinesBottle
|
from .bottle import SmolmachinesBottle
|
||||||
from .bottle_plan import SmolmachinesBottlePlan
|
from .bottle_plan import SmolmachinesBottlePlan
|
||||||
|
|
||||||
@@ -34,40 +60,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 +117,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 ...egress import Egress
|
||||||
|
from ...git_gate import GitGate
|
||||||
|
from ...pipelock import PipelockProxy
|
||||||
|
from ...supervise import Supervise
|
||||||
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,30 @@ def resolve_plan(
|
|||||||
f"http://{bundle_ip}:{_BUNDLE_SUPERVISE_PORT}"
|
f"http://{bundle_ip}:{_BUNDLE_SUPERVISE_PORT}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Inner Plans for the four bundle daemons. The ABCs are
|
||||||
|
# platform-neutral — `.prepare()` writes config files + returns
|
||||||
|
# a Plan dataclass with no backend-specific assumptions. State
|
||||||
|
# dirs are still keyed by slug under the docker backend's
|
||||||
|
# bottle_state layout (shared on-host convention; not a docker
|
||||||
|
didericis marked this conversation as resolved
Outdated
|
|||||||
|
# dependency).
|
||||||
|
pipelock_dir = pipelock_state_dir(slug)
|
||||||
|
pipelock_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
proxy_plan = PipelockProxy().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 = GitGate().prepare(bottle, slug, git_gate_dir)
|
||||||
|
|
||||||
|
egress_dir = egress_state_dir(slug)
|
||||||
|
egress_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
egress_plan = Egress().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 = Supervise().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 +150,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ from .log import die
|
|||||||
from .manifest import Bottle
|
from .manifest import Bottle
|
||||||
|
|
||||||
|
|
||||||
|
# Short network alias for git-gate inside the sidecar bundle. The
|
||||||
|
# agent's `.gitconfig` insteadOf rewrites resolve through this name.
|
||||||
|
GIT_GATE_HOSTNAME = "git-gate"
|
||||||
|
|
||||||
|
|
||||||
def _empty_str_map() -> dict[str, str]:
|
def _empty_str_map() -> dict[str, str]:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|||||||
+35
-24
@@ -17,7 +17,6 @@ Image pin: ghcr.io/luckypipewrench/pipelock@sha256:<digest> for tag 2.3.0.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import cast
|
from typing import cast
|
||||||
@@ -47,6 +46,21 @@ DEFAULT_TLS_PASSTHROUGH: tuple[str, ...] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# In-container paths the rendered pipelock YAML references under
|
||||||
|
# `tls_interception`. The pipelock binary expects the per-bottle CA
|
||||||
|
# cert + key at these exact paths inside its container — independent
|
||||||
|
# of how the daemon is wrapped (own container, sidecar bundle, etc.),
|
||||||
|
# which is why they live in the platform-neutral module.
|
||||||
|
PIPELOCK_CA_CERT_IN_CONTAINER = "/etc/pipelock-ca.pem"
|
||||||
|
PIPELOCK_CA_KEY_IN_CONTAINER = "/etc/pipelock-ca-key.pem"
|
||||||
|
|
||||||
|
|
||||||
|
# Short network alias for pipelock inside the sidecar bundle. The
|
||||||
|
# agent's HTTP_PROXY (when no egress is declared) and any in-bundle
|
||||||
|
# consumer's URL both reference this name.
|
||||||
|
PIPELOCK_HOSTNAME = "pipelock"
|
||||||
|
|
||||||
|
|
||||||
# --- Allowlist resolution --------------------------------------------------
|
# --- Allowlist resolution --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -301,44 +315,41 @@ class PipelockProxyPlan:
|
|||||||
ca_key_host_path: Path = Path()
|
ca_key_host_path: Path = Path()
|
||||||
|
|
||||||
|
|
||||||
class PipelockProxy(ABC):
|
class PipelockProxy:
|
||||||
"""The pipelock egress proxy. Encapsulates the YAML-config
|
"""The pipelock egress proxy. Encapsulates the YAML-config
|
||||||
generation; the sidecar's start/stop lifecycle is backend-specific
|
generation; the container lifecycle is owned by whatever
|
||||||
and lives on concrete subclasses.
|
wraps the daemon (compose-managed pipelock container on docker,
|
||||||
|
sidecar-bundle PID 1 on smolmachines).
|
||||||
|
|
||||||
The class-level constants `CA_CERT_IN_CONTAINER` /
|
Backends instantiate the class directly — there are no
|
||||||
`CA_KEY_IN_CONTAINER` are the in-container paths the YAML config
|
platform-specific subclasses; the in-container CA paths are
|
||||||
references — they correspond to wherever the backend's `.start`
|
universal module-level constants
|
||||||
places the CA cert and key inside the sidecar. Subclasses
|
(`PIPELOCK_CA_CERT_IN_CONTAINER` / `PIPELOCK_CA_KEY_IN_CONTAINER`)."""
|
||||||
override the constants."""
|
|
||||||
|
|
||||||
CA_CERT_IN_CONTAINER: str = ""
|
|
||||||
CA_KEY_IN_CONTAINER: str = ""
|
|
||||||
|
|
||||||
def prepare(
|
def prepare(
|
||||||
self, bottle: Bottle, slug: str, stage_dir: Path
|
self, bottle: Bottle, slug: str, stage_dir: Path
|
||||||
) -> PipelockProxyPlan:
|
) -> PipelockProxyPlan:
|
||||||
"""Write the pipelock yaml config (mode 600) under `stage_dir`
|
"""Write the pipelock yaml config (mode 600) under `stage_dir`
|
||||||
and return the plan for `.start`. Pure host-side, no docker
|
and return the plan for launch. Pure host-side, no docker
|
||||||
subprocess.
|
subprocess.
|
||||||
|
|
||||||
`slug` is the agent-derived identifier (lowercased,
|
`slug` is the agent-derived identifier (lowercased,
|
||||||
hyphen-normalized) used as the suffix in every per-agent
|
hyphen-normalized) used as the suffix in every per-agent
|
||||||
resource name — the agent container, the pipelock container
|
resource name — the agent container, the sidecar bundle
|
||||||
(`claude-bottle-pipelock-<slug>`), the internal/egress
|
container, the internal/egress networks. It's stored on the
|
||||||
networks. It's stored on the returned plan so the backend's
|
returned plan so the backend's launch step can derive those
|
||||||
start step can derive the sidecar's container name.
|
names.
|
||||||
|
|
||||||
The CA paths the YAML references are the in-container paths
|
The CA paths the YAML references are the module-level
|
||||||
from the concrete subclass's class-level constants. The
|
in-container constants. The host-side counterparts are
|
||||||
host-side counterparts are generated by the launch step
|
generated by the launch step (not here, so prepare stays
|
||||||
(not here, so prepare stays side-effect-free on docker) and
|
side-effect-free on docker) and added to the plan via
|
||||||
added to the plan via `dataclasses.replace` before `.start`."""
|
`dataclasses.replace` before the daemon starts."""
|
||||||
yaml_path = stage_dir / "pipelock.yaml"
|
yaml_path = stage_dir / "pipelock.yaml"
|
||||||
cfg = pipelock_build_config(
|
cfg = pipelock_build_config(
|
||||||
bottle,
|
bottle,
|
||||||
ca_cert_path=self.CA_CERT_IN_CONTAINER,
|
ca_cert_path=PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||||
ca_key_path=self.CA_KEY_IN_CONTAINER,
|
ca_key_path=PIPELOCK_CA_KEY_IN_CONTAINER,
|
||||||
)
|
)
|
||||||
yaml_path.write_text(pipelock_render_yaml(cfg))
|
yaml_path.write_text(pipelock_render_yaml(cfg))
|
||||||
yaml_path.chmod(0o600)
|
yaml_path.chmod(0o600)
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ to the agent.
|
|||||||
This module defines the host-side library: dataclasses for the queue
|
This module defines the host-side library: dataclasses for the queue
|
||||||
file shapes, queue read/write helpers, the audit log writer, and the
|
file shapes, queue read/write helpers, the audit log writer, and the
|
||||||
diff renderer. The in-container sidecar lives in
|
diff renderer. The in-container sidecar lives in
|
||||||
claude_bottle/supervise_server.py; the Docker lifecycle in
|
claude_bottle/supervise_server.py; the supervise daemon's container
|
||||||
claude_bottle/backend/docker/supervise.py.
|
lifecycle is owned by the sidecar bundle (PRD 0024).
|
||||||
|
|
||||||
For 0013 the supervisor's approval handlers are deliberately no-ops:
|
For 0013 the supervisor's approval handlers are deliberately no-ops:
|
||||||
on approval the audit log is written and the response file is
|
on approval the audit log is written and the response file is
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ from claude_bottle.backend.docker.network import (
|
|||||||
network_create_internal,
|
network_create_internal,
|
||||||
network_remove,
|
network_remove,
|
||||||
)
|
)
|
||||||
|
from claude_bottle.backend.docker.sidecar_bundle import (
|
||||||
|
sidecar_bundle_container_name,
|
||||||
|
)
|
||||||
from tests._docker import skip_unless_docker
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
|
||||||
@@ -101,9 +104,9 @@ class TestCapabilityApply(unittest.TestCase):
|
|||||||
capture_output=True, text=True, check=False,
|
capture_output=True, text=True, check=False,
|
||||||
)
|
)
|
||||||
self.assertEqual(0, r.returncode, r.stderr)
|
self.assertEqual(0, r.returncode, r.stderr)
|
||||||
# Also start a fake supervise sidecar so teardown has something
|
# Also start a fake sidecar bundle so teardown has something
|
||||||
# extra to clean up (mirrors a real bottle's container set).
|
# extra to clean up (mirrors a real bottle's container set).
|
||||||
sidecar = f"claude-bottle-supervise-{self.slug}"
|
sidecar = sidecar_bundle_container_name(self.slug)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
[
|
||||||
"docker", "run", "-d",
|
"docker", "run", "-d",
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ from claude_bottle.backend.docker.network import (
|
|||||||
from claude_bottle.backend.docker.pipelock import (
|
from claude_bottle.backend.docker.pipelock import (
|
||||||
PIPELOCK_CA_CERT_IN_CONTAINER,
|
PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||||
PIPELOCK_CA_KEY_IN_CONTAINER,
|
PIPELOCK_CA_KEY_IN_CONTAINER,
|
||||||
DockerPipelockProxy,
|
|
||||||
pipelock_tls_init,
|
pipelock_tls_init,
|
||||||
)
|
)
|
||||||
|
from claude_bottle.pipelock import PipelockProxy
|
||||||
from claude_bottle.backend.docker.pipelock_apply import (
|
from claude_bottle.backend.docker.pipelock_apply import (
|
||||||
PipelockApplyError,
|
PipelockApplyError,
|
||||||
apply_allowlist_change,
|
apply_allowlist_change,
|
||||||
@@ -99,7 +99,7 @@ class TestPipelockApply(unittest.TestCase):
|
|||||||
the updated config."""
|
the updated config."""
|
||||||
state_dir = pipelock_state_dir(self.slug)
|
state_dir = pipelock_state_dir(self.slug)
|
||||||
state_dir.mkdir(parents=True, exist_ok=True)
|
state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
prep = DockerPipelockProxy().prepare(
|
prep = PipelockProxy().prepare(
|
||||||
fixture_minimal().bottles["dev"], self.slug, state_dir,
|
fixture_minimal().bottles["dev"], self.slug, state_dir,
|
||||||
)
|
)
|
||||||
self.internal_net = network_create_internal(self.slug)
|
self.internal_net = network_create_internal(self.slug)
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ class TestSandboxEscape(unittest.TestCase):
|
|||||||
("aws", "TEST_SECRET_AWS"),
|
("aws", "TEST_SECRET_AWS"),
|
||||||
("generic", "TEST_SECRET_GENERIC"),
|
("generic", "TEST_SECRET_GENERIC"),
|
||||||
]
|
]
|
||||||
gate_host = f"claude-bottle-git-gate-{self._identity}"
|
gate_host = "git-gate"
|
||||||
|
|
||||||
for name, var in shapes:
|
for name, var in shapes:
|
||||||
with self.subTest(secret=name):
|
with self.subTest(secret=name):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ def _egress_plan(routes: tuple[EgressRoute, ...] = ()) -> EgressPlan:
|
|||||||
mitmproxy_ca_host_path=STATE / "egress-ca" / "mitmproxy-ca.pem",
|
mitmproxy_ca_host_path=STATE / "egress-ca" / "mitmproxy-ca.pem",
|
||||||
mitmproxy_ca_cert_only_host_path=STATE / "egress-ca" / "ca.pem",
|
mitmproxy_ca_cert_only_host_path=STATE / "egress-ca" / "ca.pem",
|
||||||
pipelock_ca_host_path=STATE / "pipelock-ca" / "ca.pem",
|
pipelock_ca_host_path=STATE / "pipelock-ca" / "ca.pem",
|
||||||
pipelock_proxy_url=f"http://claude-bottle-pipelock-{SLUG}:8888",
|
pipelock_proxy_url="http://127.0.0.1:8888",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ class TestAgentAlwaysPresent(unittest.TestCase):
|
|||||||
proxy_lines = [e for e in env if e.startswith("HTTPS_PROXY=")]
|
proxy_lines = [e for e in env if e.startswith("HTTPS_PROXY=")]
|
||||||
self.assertEqual(1, len(proxy_lines))
|
self.assertEqual(1, len(proxy_lines))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
f"HTTPS_PROXY=http://claude-bottle-pipelock-{SLUG}:8888",
|
"HTTPS_PROXY=http://pipelock:8888",
|
||||||
proxy_lines[0],
|
proxy_lines[0],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -304,12 +304,11 @@ class TestSidecarBundleShape(unittest.TestCase):
|
|||||||
|
|
||||||
def test_internal_aliases_cover_pipelock_and_egress_shortnames(self):
|
def test_internal_aliases_cover_pipelock_and_egress_shortnames(self):
|
||||||
# The agent's HTTPS_PROXY url references either `egress` or
|
# The agent's HTTPS_PROXY url references either `egress` or
|
||||||
# `pipelock` (long form). Both must resolve to the bundle.
|
# `pipelock`. Both must resolve to the bundle.
|
||||||
sc = self._render()["services"]["sidecars"]
|
sc = self._render()["services"]["sidecars"]
|
||||||
aliases = set(sc["networks"]["internal"]["aliases"])
|
aliases = set(sc["networks"]["internal"]["aliases"])
|
||||||
self.assertIn("egress", aliases)
|
self.assertIn("egress", aliases)
|
||||||
self.assertIn(f"claude-bottle-pipelock-{SLUG}", aliases)
|
self.assertIn("pipelock", aliases)
|
||||||
self.assertIn(f"claude-bottle-egress-{SLUG}", aliases)
|
|
||||||
|
|
||||||
def test_internal_aliases_omit_inactive_sidecars(self):
|
def test_internal_aliases_omit_inactive_sidecars(self):
|
||||||
# With no git-gate / supervise, those names are NOT aliased
|
# With no git-gate / supervise, those names are NOT aliased
|
||||||
@@ -317,15 +316,14 @@ class TestSidecarBundleShape(unittest.TestCase):
|
|||||||
# listening inside the bundle.
|
# listening inside the bundle.
|
||||||
sc = self._render()["services"]["sidecars"]
|
sc = self._render()["services"]["sidecars"]
|
||||||
aliases = set(sc["networks"]["internal"]["aliases"])
|
aliases = set(sc["networks"]["internal"]["aliases"])
|
||||||
self.assertNotIn(f"claude-bottle-git-gate-{SLUG}", aliases)
|
self.assertNotIn("git-gate", aliases)
|
||||||
self.assertNotIn("supervise", aliases)
|
self.assertNotIn("supervise", aliases)
|
||||||
|
|
||||||
def test_internal_aliases_include_active_sidecars(self):
|
def test_internal_aliases_include_active_sidecars(self):
|
||||||
sc = self._render(with_git=True, supervise=True)["services"]["sidecars"]
|
sc = self._render(with_git=True, supervise=True)["services"]["sidecars"]
|
||||||
aliases = set(sc["networks"]["internal"]["aliases"])
|
aliases = set(sc["networks"]["internal"]["aliases"])
|
||||||
self.assertIn(f"claude-bottle-git-gate-{SLUG}", aliases)
|
self.assertIn("git-gate", aliases)
|
||||||
self.assertIn("supervise", aliases)
|
self.assertIn("supervise", aliases)
|
||||||
self.assertIn(f"claude-bottle-supervise-{SLUG}", aliases)
|
|
||||||
|
|
||||||
def test_daemons_csv_lists_only_active(self):
|
def test_daemons_csv_lists_only_active(self):
|
||||||
# Egress + pipelock are always in the daemon set even when
|
# Egress + pipelock are always in the daemon set even when
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from claude_bottle.backend.docker.pipelock import DockerPipelockProxy
|
|
||||||
from claude_bottle.manifest import Manifest
|
from claude_bottle.manifest import Manifest
|
||||||
from claude_bottle.pipelock import (
|
from claude_bottle.pipelock import (
|
||||||
DEFAULT_TLS_PASSTHROUGH,
|
DEFAULT_TLS_PASSTHROUGH,
|
||||||
|
PipelockProxy,
|
||||||
pipelock_build_config,
|
pipelock_build_config,
|
||||||
pipelock_render_yaml,
|
pipelock_render_yaml,
|
||||||
)
|
)
|
||||||
@@ -54,7 +54,7 @@ class TestBuildConfig(unittest.TestCase):
|
|||||||
self.assertNotIn("tls_interception", cfg)
|
self.assertNotIn("tls_interception", cfg)
|
||||||
|
|
||||||
def test_tls_interception_block_emitted_when_paths_supplied(self):
|
def test_tls_interception_block_emitted_when_paths_supplied(self):
|
||||||
# PRD 0006: paths flow in via DockerPipelockProxy's in-container
|
# PRD 0006: paths flow in via the platform-neutral in-container
|
||||||
# constants; this directly pins the dict shape. passthrough_domains
|
# constants; this directly pins the dict shape. passthrough_domains
|
||||||
# is baked in so LLM provider endpoints (api.anthropic.com) skip
|
# is baked in so LLM provider endpoints (api.anthropic.com) skip
|
||||||
# MITM — pipelock's docs explicitly recommend this for LLM hosts,
|
# MITM — pipelock's docs explicitly recommend this for LLM hosts,
|
||||||
@@ -152,7 +152,7 @@ class TestRenderAndWrite(unittest.TestCase):
|
|||||||
self.assertNotIn("ssrf:", text)
|
self.assertNotIn("ssrf:", text)
|
||||||
|
|
||||||
def test_prepare_writes_file_at_mode_600(self):
|
def test_prepare_writes_file_at_mode_600(self):
|
||||||
plan = DockerPipelockProxy().prepare(
|
plan = PipelockProxy().prepare(
|
||||||
fixture_minimal().bottles["dev"], "demo", self.out_dir
|
fixture_minimal().bottles["dev"], "demo", self.out_dir
|
||||||
)
|
)
|
||||||
self.assertEqual(0o600, os.stat(plan.yaml_path).st_mode & 0o777)
|
self.assertEqual(0o600, os.stat(plan.yaml_path).st_mode & 0o777)
|
||||||
@@ -170,7 +170,7 @@ class TestRenderAndWrite(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
})
|
})
|
||||||
plan = DockerPipelockProxy().prepare(
|
plan = PipelockProxy().prepare(
|
||||||
manifest.bottles["dev"], "demo", self.out_dir
|
manifest.bottles["dev"], "demo", self.out_dir
|
||||||
)
|
)
|
||||||
content = plan.yaml_path.read_text()
|
content = plan.yaml_path.read_text()
|
||||||
@@ -179,13 +179,13 @@ class TestRenderAndWrite(unittest.TestCase):
|
|||||||
self.assertNotIn("prompt-message", content)
|
self.assertNotIn("prompt-message", content)
|
||||||
|
|
||||||
def test_render_emits_tls_interception_via_prepare(self):
|
def test_render_emits_tls_interception_via_prepare(self):
|
||||||
"""`DockerPipelockProxy.prepare` plumbs its in-container CA
|
"""`PipelockProxy.prepare` plumbs the module-level in-container
|
||||||
constants through to the YAML. The block should land in the
|
CA constants through to the YAML. The block should land in the
|
||||||
rendered output with `enabled: true`, the configured paths,
|
rendered output with `enabled: true`, the configured paths,
|
||||||
and the baked LLM-provider passthrough list. The actual
|
and the baked LLM-provider passthrough list. The actual
|
||||||
host-side CA generation happens in launch (not prepare), so
|
host-side CA generation happens in launch (not prepare), so
|
||||||
this test exercises only the YAML rendering."""
|
this test exercises only the YAML rendering."""
|
||||||
plan = DockerPipelockProxy().prepare(
|
plan = PipelockProxy().prepare(
|
||||||
fixture_minimal().bottles["dev"], "demo", self.out_dir
|
fixture_minimal().bottles["dev"], "demo", self.out_dir
|
||||||
)
|
)
|
||||||
content = plan.yaml_path.read_text()
|
content = plan.yaml_path.read_text()
|
||||||
|
|||||||
@@ -9,15 +9,15 @@ from tests.fixtures import fixture_minimal, fixture_with_git
|
|||||||
class TestGitGateGitconfigRender(unittest.TestCase):
|
class TestGitGateGitconfigRender(unittest.TestCase):
|
||||||
def test_empty_entries_renders_nothing(self):
|
def test_empty_entries_renders_nothing(self):
|
||||||
bottle = fixture_minimal().bottles["dev"]
|
bottle = fixture_minimal().bottles["dev"]
|
||||||
self.assertEqual("", render_git_gate_gitconfig("demo", bottle.git))
|
self.assertEqual("", render_git_gate_gitconfig(bottle.git))
|
||||||
|
|
||||||
def test_one_block_per_entry(self):
|
def test_one_block_per_entry(self):
|
||||||
bottle = fixture_with_git().bottles["dev"]
|
bottle = fixture_with_git().bottles["dev"]
|
||||||
out = render_git_gate_gitconfig("demo", bottle.git)
|
out = render_git_gate_gitconfig(bottle.git)
|
||||||
# Both entries map to a [url ...] block keyed on the gate's
|
# Both entries map to a [url ...] block keyed on the gate's
|
||||||
# container hostname (claude-bottle-git-gate-<slug>).
|
# short network alias (`git-gate`) inside the sidecar bundle.
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
'[url "git://claude-bottle-git-gate-demo/claude-bottle.git"]',
|
'[url "git://git-gate/claude-bottle.git"]',
|
||||||
out,
|
out,
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
@@ -25,7 +25,7 @@ class TestGitGateGitconfigRender(unittest.TestCase):
|
|||||||
"ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
|
"ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
|
||||||
out,
|
out,
|
||||||
)
|
)
|
||||||
self.assertIn('[url "git://claude-bottle-git-gate-demo/foo.git"]', out)
|
self.assertIn('[url "git://git-gate/foo.git"]', out)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"\tinsteadOf = ssh://git@github.com/didericis/foo.git",
|
"\tinsteadOf = ssh://git@github.com/didericis/foo.git",
|
||||||
out,
|
out,
|
||||||
@@ -37,7 +37,7 @@ class TestGitGateGitconfigRender(unittest.TestCase):
|
|||||||
# gate push and leave fetch on the original URL — exactly the
|
# gate push and leave fetch on the original URL — exactly the
|
||||||
# v1 design we've moved past.
|
# v1 design we've moved past.
|
||||||
bottle = fixture_with_git().bottles["dev"]
|
bottle = fixture_with_git().bottles["dev"]
|
||||||
out = render_git_gate_gitconfig("demo", bottle.git)
|
out = render_git_gate_gitconfig(bottle.git)
|
||||||
self.assertIn("\tinsteadOf", out)
|
self.assertIn("\tinsteadOf", out)
|
||||||
self.assertNotIn("pushInsteadOf", out)
|
self.assertNotIn("pushInsteadOf", out)
|
||||||
|
|
||||||
|
|||||||
@@ -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
Do this refactor now
Done in
73dc0d4— moved CA in-container path constants up toclaude_bottle/pipelock.py, madePipelockProxya regular class, and deleted the four emptyDocker*sidecar subclasses. Both backends now instantiate the platform-neutral ABCs directly.