feat(ssh-gate)!: remove ssh-gate sidecar and provisioner (PRD 0009)

Delete claude_bottle/ssh_gate.py, the DockerSSHGate sidecar,
and the provision_ssh provisioner (~/.ssh/config + ssh-agent
wiring). Unwire the gate from the abstract BottleBackend
(provision orchestration drops the ssh step,
_validate_ssh_entries goes away) and from the Docker backend
(prepare/launch lose the `gate` kwarg, bottle_plan drops the
gate_plan field, dry-run JSON drops the ssh_hosts / ssh_gate
keys, y/N preflight drops the ssh-hosts block). cli/info now
prints declared git remotes instead of ssh hosts. pipelock's
docstring picks up the git-gate framing now that there's no
PRD-0007 boundary to call out.

BREAKING (dry-run JSON): the `ssh_hosts` and `ssh_gate` keys
are gone from `start --dry-run --format=json`. Consumers should
read `git_remotes` / `git_gate` instead.
This commit is contained in:
2026-05-12 23:49:58 -04:00
parent c403d137b6
commit 3d66ad2a86
10 changed files with 23 additions and 595 deletions
+13 -30
View File
@@ -37,7 +37,7 @@ from pathlib import Path
from typing import Any, Generic, Sequence, TypeVar
from ..log import die
from ..manifest import GitEntry, Manifest, SshEntry
from ..manifest import GitEntry, Manifest
from ..util import expand_tilde
from .util import host_skill_dir
@@ -162,7 +162,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
def _validate(self, spec: BottleSpec) -> None:
"""Cross-backend pre-launch checks. Confirms the agent exists,
the named skills are present on the host, and every SSH
the named skills are present on the host, and every git
IdentityFile resolves. Subclasses with additional preconditions
should override and call `super()._validate(spec)` first."""
manifest = spec.manifest
@@ -170,7 +170,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
self._validate_skills(agent.skills)
self._validate_ssh_entries(bottle.ssh)
self._validate_git_entries(bottle.git)
def _validate_skills(self, skills: Sequence[str]) -> None:
@@ -185,15 +184,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
f"Create it under ~/.claude/skills/, then re-run."
)
def _validate_ssh_entries(self, entries: Sequence[SshEntry]) -> None:
"""Each entry's IdentityFile must exist on the host (after
expanding leading ~). Shape is already enforced by Manifest
validation; this only checks file presence."""
for entry in entries:
key = expand_tilde(entry.IdentityFile)
if not os.path.isfile(key):
die(f"ssh key file not found for host '{entry.Host}': {key}")
def _validate_git_entries(self, entries: Sequence[GitEntry]) -> None:
"""Each entry's IdentityFile must exist on the host (after
expanding leading ~) — the git-gate copies it in at start time
@@ -215,24 +205,23 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""Build/run the bottle and yield a handle; tear down on exit."""
def provision(self, plan: PlanT, target: str) -> str | None:
"""Copy host-side files (CA cert, prompt, skills, SSH keys,
.git) into the running bottle. Called from `launch` after the
container/machine is up. `target` identifies the running
instance in backend-specific terms (Docker: resolved
container name; fly: machine id). Returns the in-container
prompt path if a prompt was provisioned, else None — the
Bottle handle uses it to decide whether to add
--append-system-prompt-file to claude's argv.
"""Copy host-side files (CA cert, prompt, skills, .git) into
the running bottle. Called from `launch` after the container
/ machine is up. `target` identifies the running instance in
backend-specific terms (Docker: resolved container name; fly:
machine id). Returns the in-container prompt path if a prompt
was provisioned, else None — the Bottle handle uses it to
decide whether to add --append-system-prompt-file to claude's
argv.
Default orchestration: ca → prompt → skills → ssh → git.
CA install runs first so the agent's trust store is rebuilt
before anything inside the agent makes a TLS call. Subclasses
Default orchestration: ca → prompt → skills → git. CA install
runs first so the agent's trust store is rebuilt before
anything inside the agent makes a TLS call. Subclasses
typically don't override this; they implement the sub-methods
below."""
self.provision_ca(plan, target)
prompt_path = self.provision_prompt(plan, target)
self.provision_skills(plan, target)
self.provision_ssh(plan, target)
self.provision_git(plan, target)
return prompt_path
@@ -257,12 +246,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""Copy the agent's named skills from the host into the
running bottle. No-op when the agent has no skills."""
@abstractmethod
def provision_ssh(self, plan: PlanT, target: str) -> None:
"""Set up SSH in the running bottle (config, agent, keys)
so the bottle can reach the manifest's declared SSH hosts.
No-op when the bottle has no SSH entries."""
@abstractmethod
def provision_git(self, plan: PlanT, target: str) -> None:
"""Copy the host's cwd `.git` directory into the running
-8
View File
@@ -29,8 +29,6 @@ from .provision import ca as _ca
from .provision import git as _git
from .provision import prompt as _prompt
from .provision import skills as _skills
from .provision import ssh as _ssh
from .ssh_gate import DockerSSHGate
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
@@ -41,7 +39,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
def __init__(self) -> None:
self._proxy = DockerPipelockProxy()
self._gate = DockerSSHGate()
self._git_gate = DockerGitGate()
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
@@ -49,7 +46,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
spec,
stage_dir=stage_dir,
proxy=self._proxy,
gate=self._gate,
git_gate=self._git_gate,
)
@@ -58,7 +54,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
with _launch.launch(
plan,
proxy=self._proxy,
gate=self._gate,
git_gate=self._git_gate,
provision=self.provision,
) as bottle:
@@ -73,9 +68,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
def provision_skills(self, plan: DockerBottlePlan, target: str) -> None:
_skills.provision_skills(plan, target)
def provision_ssh(self, plan: DockerBottlePlan, target: str) -> None:
_ssh.provision_ssh(plan, target)
def provision_git(self, plan: DockerBottlePlan, target: str) -> None:
_git.provision_git(plan, target)
@@ -15,7 +15,6 @@ from ...git_gate import GitGatePlan
from ...log import info
from ...manifest import Agent, Bottle
from ...pipelock import PipelockProxyPlan, pipelock_effective_allowlist
from ...ssh_gate import SSHGatePlan
from .. import BottlePlan
@@ -27,7 +26,6 @@ class _PlanView:
agent: Agent
bottle: Bottle
env_names: list[str]
ssh_hosts: list[str]
git_names: list[str]
prompt_first_line: str
@@ -52,7 +50,6 @@ class DockerBottlePlan(BottlePlan):
forwarded_env: dict[str, str] = field(repr=False)
prompt_file: Path
proxy_plan: PipelockProxyPlan
gate_plan: SSHGatePlan
git_gate_plan: GitGatePlan
allowlist_summary: str
use_runsc: bool
@@ -69,7 +66,6 @@ class DockerBottlePlan(BottlePlan):
agent=agent,
bottle=bottle,
env_names=env_names,
ssh_hosts=[e.Host for e in bottle.ssh],
git_names=[e.Name for e in bottle.git],
prompt_first_line=agent.prompt.splitlines()[0] if agent.prompt else "",
)
@@ -94,16 +90,6 @@ class DockerBottlePlan(BottlePlan):
info("skills : " + (" ".join(v.agent.skills) if v.agent.skills else "(none)"))
info(f"docker runtime : {runtime_label}")
info(f"bottle : {v.agent.bottle}")
if v.ssh_hosts:
info(f" ssh hosts : {', '.join(v.ssh_hosts)}")
gate_lines = [
f"{u.bottle_host_alias} -> {u.upstream_host}:{u.upstream_port} "
f"(listen {u.listen_port})"
for u in self.gate_plan.upstreams
]
info(f" ssh gate : {'; '.join(gate_lines)}")
else:
info(" ssh hosts : (none)")
if v.git_names:
info(f" git remotes : {', '.join(v.git_names)}")
git_lines = [
@@ -136,15 +122,6 @@ class DockerBottlePlan(BottlePlan):
"runtime": "runsc" if self.use_runsc else "runc",
"env_names": v.env_names,
"skills": list(v.agent.skills),
"ssh_hosts": v.ssh_hosts,
"ssh_gate": [
{
"host": u.bottle_host_alias,
"upstream": f"{u.upstream_host}:{u.upstream_port}",
"listen_port": u.listen_port,
}
for u in self.gate_plan.upstreams
],
"git_remotes": v.git_names,
"git_gate": [
{
-17
View File
@@ -25,7 +25,6 @@ from .bottle_plan import DockerBottlePlan
from .git_gate import DockerGitGate
from .pipelock import DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
from .ssh_gate import DockerSSHGate
# Where the repo root lives, for `docker build` context. Computed once.
@@ -37,7 +36,6 @@ def launch(
plan: DockerBottlePlan,
*,
proxy: DockerPipelockProxy,
gate: DockerSSHGate,
git_gate: DockerGitGate,
provision: Callable[[DockerBottlePlan, str], str | None],
) -> Generator[DockerBottle, None, None]:
@@ -89,21 +87,6 @@ def launch(
pipelock_name = proxy.start(plan.proxy_plan)
stack.callback(proxy.stop, pipelock_name)
# SSH egress gate (PRD 0007). One sidecar per agent, only
# brought up when the bottle has ssh entries. Lives on the
# same internal + egress networks pipelock straddles; the
# agent dials it by container name (DNS works on --internal,
# confirmed by the PRD 0007 spike).
if plan.gate_plan.upstreams:
gate_plan = dataclasses.replace(
plan.gate_plan,
internal_network=internal_network,
egress_network=egress_network,
)
plan = dataclasses.replace(plan, gate_plan=gate_plan)
gate_name = gate.start(plan.gate_plan)
stack.callback(gate.stop, gate_name)
# Git gate (PRD 0008). One sidecar per agent, only brought up
# when the bottle has git entries. Same internal + egress
# network attachment as the other sidecars; agent dials it as
+2 -6
View File
@@ -21,7 +21,6 @@ from . import util as docker_mod
from .bottle_plan import DockerBottlePlan
from .git_gate import DockerGitGate
from .pipelock import DockerPipelockProxy
from .ssh_gate import DockerSSHGate
def resolve_plan(
@@ -29,12 +28,11 @@ def resolve_plan(
*,
stage_dir: Path,
proxy: DockerPipelockProxy,
gate: DockerSSHGate,
git_gate: DockerGitGate,
) -> DockerBottlePlan:
"""Resolve Docker-specific names and write scratch files. Trusts
that the agent and its skills/SSH keys are present — validation
already ran in the base class."""
that the agent and its skills/git-gate keys are present —
validation already ran in the base class."""
docker_mod.require_docker()
manifest = spec.manifest
@@ -82,7 +80,6 @@ def resolve_plan(
prompt_file.chmod(0o600)
proxy_plan = proxy.prepare(bottle, slug, stage_dir)
gate_plan = gate.prepare(bottle, slug, stage_dir)
git_gate_plan = git_gate.prepare(bottle, slug, stage_dir)
resolved = resolve_env(manifest, spec.agent_name)
# Everything that should reach the bottle by-name (so its value
@@ -111,7 +108,6 @@ def resolve_plan(
forwarded_env=forwarded_env,
prompt_file=prompt_file,
proxy_plan=proxy_plan,
gate_plan=gate_plan,
git_gate_plan=git_gate_plan,
allowlist_summary=allowlist_summary,
use_runsc=use_runsc,
@@ -1,199 +0,0 @@
"""Set up SSH inside a running Docker bottle.
This is the most involved provisioner. The end state in the container:
- ~/.ssh/config + ~/.ssh/known_hosts owned by node, mode 600
- ssh-agent running as root with each key loaded; agent socket at
/run/claude-bottle-agent.sock
- socat forwarder (also root) bridging the agent socket to
/run/claude-bottle-agent-public.sock (mode 666) so node can talk
to the agent despite ssh-agent's SO_PEERCRED UID match
- on-disk key files deleted after `ssh-add`; the bytes only live in
the agent process's memory thereafter
See the `provision_ssh` docstring for the full isolation rationale."""
from __future__ import annotations
import os
import subprocess
from ....log import die, info
from ....util import expand_tilde
from .. import util as docker_mod
from ..bottle_plan import DockerBottlePlan
from ..ssh_gate import ssh_gate_host
def provision_ssh(plan: DockerBottlePlan, target: str) -> None:
"""Set up SSH in the container so node can authenticate using
each entry's key without the key file being readable by node.
No-op when the bottle has no SSH entries.
Isolation strategy:
- Keys live at /root/.claude-bottle-keys/ (mode 700,
root-owned). /root is mode 700 in node:22-slim, so node
(uid 1000) can't even traverse in.
- ssh-agent runs as root, listening on
/run/claude-bottle-agent.sock. Each key is loaded with
ssh-add, then deleted; the bytes now live only in the
agent process's memory.
- ssh-agent's SO_PEERCRED-based UID match rejects every
connection whose peer euid is neither 0 nor the agent's.
To bridge that, a root-owned socat forwarder listens on
/run/claude-bottle-agent-public.sock (mode 666) and
proxies bytes to the real agent socket.
- node can't ptrace root-owned agent or socat, so
/proc/<pid>/mem is off-limits and key bytes never leave
root-owned memory.
- ~/.ssh/config in node's home points each Host at the
public socket via IdentityAgent.
Why an in-container agent (not bind-mounted from host):
Docker Desktop on macOS does not forward Unix-domain socket
connect() across the VM boundary — connect() returns
ENOTSUP. Running ssh-agent inside the container sidesteps
that entirely.
Limitation: keys must be passphrase-less. ssh-add prompts on
/dev/tty for passphrases, but our docker exec has no TTY."""
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
if not bottle.ssh:
return
container = target
gate_target = ssh_gate_host(plan.slug)
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
container_ssh = f"{container_home}/.ssh"
agent_socket = "/run/claude-bottle-agent.sock"
public_socket = "/run/claude-bottle-agent-public.sock"
keys_dir = "/root/.claude-bottle-keys"
# Per-entry listen ports come off the gate plan (PRD 0007).
# Indexed by the bottle.ssh entry's Host alias so each ssh_config
# block knows which port its forwarder lives on.
upstreams_by_alias = {u.bottle_host_alias: u for u in plan.gate_plan.upstreams}
if set(upstreams_by_alias) != {e.Host for e in bottle.ssh}:
die(
"ssh-gate upstream table is out of sync with bottle.ssh; "
"this is an internal bug"
)
# ~/.ssh for node (700, owned by node).
docker_mod.docker_exec_root(container, ["mkdir", "-p", container_ssh])
docker_mod.docker_exec_root(container, ["chown", "node:node", container_ssh])
docker_mod.docker_exec_root(container, ["chmod", "700", container_ssh])
# /root/.claude-bottle-keys for root (700, root-owned).
docker_mod.docker_exec_root(container, ["mkdir", "-p", keys_dir])
docker_mod.docker_exec_root(container, ["chown", "root:root", keys_dir])
docker_mod.docker_exec_root(container, ["chmod", "700", keys_dir])
config_file = plan.stage_dir / "ssh_config"
known_hosts_file = plan.stage_dir / "ssh_known_hosts"
config_file.write_text("")
config_file.chmod(0o600)
known_hosts_file.write_text("")
known_hosts_file.chmod(0o600)
container_key_paths: list[str] = []
for entry in bottle.ssh:
name = entry.Host
key = expand_tilde(entry.IdentityFile)
hostname = entry.Hostname
user = entry.User
known_host_key = entry.KnownHostKey
upstream = upstreams_by_alias[name]
listen_port = upstream.listen_port
key_basename = os.path.basename(key)
container_key_path = f"{keys_dir}/{key_basename}"
info(f"copying ssh key for '{name}' -> {container} (root-only staging)")
subprocess.run(
["docker", "cp", key, f"{container}:{container_key_path}"],
stdout=subprocess.DEVNULL,
check=True,
)
docker_mod.docker_exec_root(container, ["chown", "root:root", container_key_path])
docker_mod.docker_exec_root(container, ["chmod", "600", container_key_path])
container_key_paths.append(container_key_path)
# Each Host block points at the gate container + its
# per-entry listen port. HostKeyAlias makes ssh validate
# the host key against `hostname` (the real upstream
# name) instead of the gate container; CheckHostIP=no
# skips the resolved-IP lookup, which would also point at
# the gate.
block = (
f"Host {name}\n"
f" HostName {gate_target}\n"
f" User {user}\n"
f" Port {listen_port}\n"
f" IdentityAgent {public_socket}\n"
f" HostKeyAlias {hostname}\n"
f" CheckHostIP no\n"
f"\n"
)
with config_file.open("a") as f:
f.write(block)
if known_host_key:
# HostKeyAlias makes ssh look up known_hosts under
# `hostname` (the upstream's real name / IP literal),
# not the gate container. One unambiguous entry per
# ssh entry.
with known_hosts_file.open("a") as f:
f.write(f"{hostname} {known_host_key}\n")
# Boot the agent, load each key, delete the key files, then
# start the root-owned socat forwarder. One docker exec so the
# whole sequence is atomic.
info(f"starting in-container ssh-agent at {agent_socket} (forwarded via {public_socket})")
setup_lines = [
"set -eu",
f"ssh-agent -a {agent_socket} >/dev/null",
]
for kp in container_key_paths:
setup_lines.append(f"SSH_AUTH_SOCK={agent_socket} ssh-add {kp}")
setup_lines.append(f"rm -f {kp}")
setup_lines.append(f"rmdir {keys_dir} 2>/dev/null || true")
# Forwarder: socat (uid 0) connects to the agent on node's behalf.
setup_lines.append(
f"nohup socat UNIX-LISTEN:{public_socket},fork,reuseaddr,mode=666 "
f"UNIX-CONNECT:{agent_socket} </dev/null >/dev/null 2>&1 &"
)
# Wait briefly for the forwarder to bind.
setup_lines.extend([
"i=0",
"while [ $i -lt 20 ]; do",
f" [ -S {public_socket} ] && break",
" i=$((i + 1))",
" sleep 0.1",
"done",
f"[ -S {public_socket} ] || {{ echo 'claude-bottle: socat forwarder failed to bind {public_socket}' >&2; exit 1; }}",
])
setup_script = "\n".join(setup_lines) + "\n"
subprocess.run(
["docker", "exec", "-u", "0", container, "sh", "-c", setup_script],
check=True,
)
info(f"writing {container_ssh}/config")
subprocess.run(
["docker", "cp", str(config_file), f"{container}:{container_ssh}/config"],
stdout=subprocess.DEVNULL,
check=True,
)
docker_mod.docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/config"])
docker_mod.docker_exec_root(container, ["chmod", "600", f"{container_ssh}/config"])
if known_hosts_file.stat().st_size > 0:
info(f"writing {container_ssh}/known_hosts")
subprocess.run(
["docker", "cp", str(known_hosts_file), f"{container}:{container_ssh}/known_hosts"],
stdout=subprocess.DEVNULL,
check=True,
)
docker_mod.docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/known_hosts"])
docker_mod.docker_exec_root(container, ["chmod", "600", f"{container_ssh}/known_hosts"])
-159
View File
@@ -1,159 +0,0 @@
"""DockerSSHGate — the Docker-specific lifecycle for the per-agent
SSH egress gate sidecar (PRD 0007). Inherits the platform-agnostic
prepare step (upstream allocation + entrypoint render) from
`SSHGate`."""
from __future__ import annotations
import os
import subprocess
from ...log import die, info, warn
from ...ssh_gate import SSHGate, SSHGatePlan
# alpine/socat pinned by digest. The image is `alpine` + `socat`
# pre-installed; PRD 0007 requires the gate image to be
# self-sufficient at boot (no apk pulls) because the agent-facing
# leg sits on the `--internal` network.
SSH_GATE_IMAGE = os.environ.get(
"CLAUDE_BOTTLE_SSH_GATE_IMAGE",
"alpine/socat@sha256:a26f4bcee25ad4a4096ce91e596c0a2fffcbb51f7fd198dd87a5c86eae66f0e1",
)
# In-container path the entrypoint script lands at after `docker cp`.
# Root path keeps the cp simple — no intermediate directories to
# create.
SSH_GATE_ENTRYPOINT_IN_CONTAINER = "/ssh-gate-entrypoint.sh"
def ssh_gate_container_name(slug: str) -> str:
return f"claude-bottle-ssh-gate-{slug}"
def ssh_gate_host(slug: str) -> str:
"""The hostname the agent's ssh client should connect to. Same as
the container name — Docker's embedded DNS resolves it on the
`--internal` network (verified by the PRD 0007 DNS spike)."""
return ssh_gate_container_name(slug)
class DockerSSHGate(SSHGate):
"""Brings the SSH gate sidecar up and down via Docker."""
def start(self, plan: SSHGatePlan) -> str:
"""Boot the gate sidecar:
1. `docker create` on the internal network with the
canonical name, `--entrypoint /bin/sh`, and the
in-container entrypoint path as the CMD.
2. `docker cp` the entrypoint script in.
3. Attach to the per-agent egress network so socat can dial
upstream.
4. `docker start`.
Returns the container name (the target passed to `.stop`)."""
if not plan.upstreams:
die("DockerSSHGate.start called with no upstreams; caller should skip")
if not plan.internal_network or not plan.egress_network:
die(
"DockerSSHGate.start: internal_network / egress_network must be "
"populated on the plan before start"
)
if not plan.entrypoint_script.is_file():
die(
f"ssh-gate entrypoint script missing at {plan.entrypoint_script}; "
f"SSHGate.prepare must run first"
)
name = ssh_gate_container_name(plan.slug)
info(f"starting ssh-gate sidecar {name} on network {plan.internal_network}")
create_args = [
"docker", "create",
"--name", name,
"--network", plan.internal_network,
"--entrypoint", "/bin/sh",
SSH_GATE_IMAGE,
SSH_GATE_ENTRYPOINT_IN_CONTAINER,
]
if subprocess.run(
create_args,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode != 0:
die(f"failed to create ssh-gate sidecar {name}")
cp_result = subprocess.run(
[
"docker", "cp",
str(plan.entrypoint_script),
f"{name}:{SSH_GATE_ENTRYPOINT_IN_CONTAINER}",
],
capture_output=True,
text=True,
check=False,
)
if cp_result.returncode != 0:
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
die(
f"failed to copy ssh-gate entrypoint into {name}: "
f"{cp_result.stderr.strip()}"
)
if subprocess.run(
["docker", "network", "connect", plan.egress_network, name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode != 0:
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
die(
f"failed to attach ssh-gate sidecar {name} to egress network "
f"{plan.egress_network}"
)
if subprocess.run(
["docker", "start", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode != 0:
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
die(f"failed to start ssh-gate sidecar {name}")
return name
def stop(self, target: str) -> None:
"""Idempotent: missing container is success. `target` is the
container name returned by `.start`."""
if subprocess.run(
["docker", "inspect", target],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode == 0:
if subprocess.run(
["docker", "rm", "-f", target],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode != 0:
warn(
f"failed to remove ssh-gate sidecar {target}; "
f"clean up with 'docker rm -f {target}'"
)
+5 -6
View File
@@ -31,16 +31,15 @@ def cmd_info(argv: list[str]) -> int:
f"first line: {prompt_first_line or '(empty)'}"
)
info(f"bottle : {agent.bottle}")
if bottle.ssh:
for e in bottle.ssh:
if bottle.git:
for e in bottle.git:
info(
f" ssh host : {e.Host} "
f"(Hostname={e.Hostname}, User={e.User}, "
f"Port={e.Port}, IdentityFile={e.IdentityFile})"
f" git remote : {e.Name} -> {e.Upstream} "
f"(IdentityFile={e.IdentityFile})"
)
if e.KnownHostKey:
info(f" KnownHostKey: {e.KnownHostKey}")
else:
info(" ssh hosts : (none)")
info(" git remotes : (none)")
print()
return 0
+3 -3
View File
@@ -57,9 +57,9 @@ def pipelock_bottle_allowlist(bottle: Bottle) -> list[str]:
def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
"""Deduplicated union of: baked-in defaults, bottle.egress.allowlist.
Sorted for stability. Per PRD 0007, bottle.ssh entries do NOT
contribute here SSH traffic flows through the per-agent ssh-gate
sidecar, not pipelock."""
Sorted for stability. Git upstreams declared in `bottle.git` do NOT
contribute here git traffic flows through the per-agent git-gate
sidecar (PRD 0008), not pipelock."""
seen: dict[str, None] = {}
for h in DEFAULT_ALLOWLIST:
seen.setdefault(h, None)
-144
View File
@@ -1,144 +0,0 @@
"""Per-agent SSH egress gate (PRD 0007).
A second per-agent sidecar that does plain TCP forwarding from a set
of static listen ports to the SSH hosts declared in `bottle.ssh`.
The agent's ssh client points each `Host` block at the gate
container + a per-entry listen port; pipelock stops seeing SSH
traffic entirely.
This module defines the abstract gate (`SSHGate`) and the plan
dataclass (`SSHGatePlan`) consumed by its `start`. The sidecar's
start/stop lifecycle is backend-specific and lives on concrete
subclasses (see `claude_bottle/backend/docker/ssh_gate.py`)."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
from .log import die
from .manifest import Bottle
# Default port when an ssh entry has no `Port` field. Matches OpenSSH.
_DEFAULT_SSH_PORT = 22
@dataclass(frozen=True)
class SSHGateUpstream:
"""One forwarder rule on the gate: listen locally on `listen_port`,
forward each connection to `upstream_host:upstream_port`. The
`bottle_host_alias` is the `Host` value from the manifest entry,
kept for diagnostics + so the ssh provisioner can correlate
upstreams with their alias.
`listen_port` mirrors the upstream port. That choice lets git
URLs that bake the upstream port into the remote (e.g.
`ssh://git@host:30009/repo.git`) work without rewriting: OpenSSH
treats a URL-supplied port as overriding the config's `Port`
directive, so the gate must be reachable on the same port the URL
names. Two ssh entries that share an upstream port are a config
error and rejected at prepare time."""
listen_port: int
upstream_host: str
upstream_port: str
bottle_host_alias: str
@dataclass(frozen=True)
class SSHGatePlan:
"""Output of SSHGate.prepare; consumed by .start when the sidecar
needs to be brought up.
`upstreams` + `slug` + `entrypoint_script` are filled in at
prepare time (host-side, side-effect-free on docker). The network
fields are populated by the backend's launch step via
`dataclasses.replace` once those networks exist. Empty defaults
are sentinels meaning "not yet set"; `.start` validates that
they are populated."""
slug: str
entrypoint_script: Path
upstreams: tuple[SSHGateUpstream, ...]
internal_network: str = ""
egress_network: str = ""
def ssh_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[SSHGateUpstream, ...]:
"""Build the gate's upstream table. Each ssh entry's listen port
equals its upstream port so URL-supplied ports (which override
`~/.ssh/config`'s `Port` directive) still reach the gate.
Dies on two entries sharing an upstream port the gate is a
single container with a flat port space, so each listener has to
be unique."""
seen_ports: dict[int, str] = {}
upstreams: list[SSHGateUpstream] = []
for e in bottle.ssh:
port = int(e.Port) if e.Port else _DEFAULT_SSH_PORT
if port in seen_ports:
die(
f"ssh entries '{seen_ports[port]}' and '{e.Host}' share upstream port "
f"{port}; the per-agent ssh gate can only forward one upstream "
f"per port. Change one of the upstream Ports in claude-bottle.json."
)
seen_ports[port] = e.Host
upstreams.append(
SSHGateUpstream(
listen_port=port,
upstream_host=e.Hostname,
upstream_port=e.Port,
bottle_host_alias=e.Host,
)
)
return tuple(upstreams)
def ssh_gate_render_entrypoint(upstreams: tuple[SSHGateUpstream, ...]) -> str:
"""Render the gate's entrypoint script: one `socat TCP-LISTEN`
per upstream, all backgrounded, then `wait`. Posix sh, no bash-isms
(alpine's sh is busybox ash). If any one socat dies, the others
keep running until the container is removed matches the v1
no-restart policy from the PRD."""
lines = ["#!/bin/sh", "set -eu"]
for u in upstreams:
lines.append(
f"socat TCP-LISTEN:{u.listen_port},reuseaddr,fork "
f"TCP:{u.upstream_host}:{u.upstream_port} &"
)
lines.append("wait")
return "\n".join(lines) + "\n"
class SSHGate(ABC):
"""The per-agent SSH egress gate. Encapsulates the host-side
prepare step (upstream allocation + entrypoint render); the
sidecar's start/stop lifecycle is backend-specific and lives on
concrete subclasses."""
def prepare(self, bottle: Bottle, slug: str, stage_dir: Path) -> SSHGatePlan:
"""Compute the upstream table from `bottle.ssh` and write the
entrypoint script (mode 600) under `stage_dir`. Pure host-side,
no docker subprocess.
Returned plan is incomplete: the launch step must fill
`internal_network` / `egress_network` via `dataclasses.replace`
before passing the plan to `.start`."""
upstreams = ssh_gate_upstreams_for_bottle(bottle)
script = stage_dir / "ssh_gate_entrypoint.sh"
script.write_text(ssh_gate_render_entrypoint(upstreams))
script.chmod(0o600)
return SSHGatePlan(slug=slug, entrypoint_script=script, upstreams=upstreams)
@abstractmethod
def start(self, plan: SSHGatePlan) -> str:
"""Bring up the gate sidecar according to `plan`. Returns the
target string identifying the running instance the same
value to pass to `.stop`. Backend-specific."""
@abstractmethod
def stop(self, target: str) -> None:
"""Tear down the gate sidecar identified by `target` (the
value `.start` returned). Idempotent: a missing target is
success. Backend-specific."""