diff --git a/claude_bottle/backend/__init__.py b/claude_bottle/backend/__init__.py index 1436bf0..ba677b6 100644 --- a/claude_bottle/backend/__init__.py +++ b/claude_bottle/backend/__init__.py @@ -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 diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index dbc0ea5..55baa8b 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -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) diff --git a/claude_bottle/backend/docker/bottle_plan.py b/claude_bottle/backend/docker/bottle_plan.py index d031b23..af635de 100644 --- a/claude_bottle/backend/docker/bottle_plan.py +++ b/claude_bottle/backend/docker/bottle_plan.py @@ -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": [ { diff --git a/claude_bottle/backend/docker/launch.py b/claude_bottle/backend/docker/launch.py index 006a719..c1575bc 100644 --- a/claude_bottle/backend/docker/launch.py +++ b/claude_bottle/backend/docker/launch.py @@ -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 diff --git a/claude_bottle/backend/docker/prepare.py b/claude_bottle/backend/docker/prepare.py index ce08cba..074d8d7 100644 --- a/claude_bottle/backend/docker/prepare.py +++ b/claude_bottle/backend/docker/prepare.py @@ -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, diff --git a/claude_bottle/backend/docker/provision/ssh.py b/claude_bottle/backend/docker/provision/ssh.py deleted file mode 100644 index a63053d..0000000 --- a/claude_bottle/backend/docker/provision/ssh.py +++ /dev/null @@ -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//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 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"]) diff --git a/claude_bottle/backend/docker/ssh_gate.py b/claude_bottle/backend/docker/ssh_gate.py deleted file mode 100644 index c5d82d1..0000000 --- a/claude_bottle/backend/docker/ssh_gate.py +++ /dev/null @@ -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}'" - ) diff --git a/claude_bottle/cli/info.py b/claude_bottle/cli/info.py index b479bd0..228ffbc 100644 --- a/claude_bottle/cli/info.py +++ b/claude_bottle/cli/info.py @@ -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 diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index 1b92b40..867d54f 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -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) diff --git a/claude_bottle/ssh_gate.py b/claude_bottle/ssh_gate.py deleted file mode 100644 index 9b73fee..0000000 --- a/claude_bottle/ssh_gate.py +++ /dev/null @@ -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."""