Merge pull request 'PRD 0009: Remove ssh-gate and bottle.ssh' (#13) from deprecate-ssh-gate into main
This commit was merged in pull request #13.
This commit is contained in:
@@ -78,13 +78,7 @@ that enforces the manifest before it leaves the host.
|
|||||||
│ │ built locally) │ │ (TLS bump, DLP,│ │ hosts
|
│ │ built locally) │ │ (TLS bump, DLP,│ │ hosts
|
||||||
│ │ │ │ allowlist) │ │
|
│ │ │ │ allowlist) │ │
|
||||||
│ │ skills, env, │ └────────────────┘ │
|
│ │ skills, env, │ └────────────────┘ │
|
||||||
│ │ ~/.ssh/config, │ │
|
│ │ ~/.gitconfig │ │
|
||||||
│ │ ~/.gitconfig │ ssh ┌────────────────┐ │ TCP to
|
|
||||||
│ │ │ ───────────────► │ socat/ssh image│──┼──► bottle.ssh
|
|
||||||
│ │ │ │ (alpine/socat, │ │ upstreams
|
|
||||||
│ │ │ │ L4 forwarder) │ │
|
|
||||||
│ │ │ └────────────────┘ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ git ops ┌────────────────┐ │ SSH (push/
|
│ │ │ git ops ┌────────────────┐ │ SSH (push/
|
||||||
│ │ │ ───────────────► │ git-gate image │──┼──► fetch) to
|
│ │ │ ───────────────► │ git-gate image │──┼──► fetch) to
|
||||||
│ │ │ │ (gitleaks + │ │ bottle.git
|
│ │ │ │ (gitleaks + │ │ bottle.git
|
||||||
@@ -98,16 +92,12 @@ that enforces the manifest before it leaves the host.
|
|||||||
|
|
||||||
- **agent image** — built from the repo `Dockerfile` (`node:22-slim`
|
- **agent image** — built from the repo `Dockerfile` (`node:22-slim`
|
||||||
base) on first run; runs `claude` with the manifest-granted skills,
|
base) on first run; runs `claude` with the manifest-granted skills,
|
||||||
env vars, `~/.ssh/config`, and `~/.gitconfig` (the latter for the
|
env vars, and `~/.gitconfig` (the latter for the git-gate's
|
||||||
git-gate's `pushInsteadOf` rules when `bottle.git` is set).
|
`insteadOf` rules when `bottle.git` is set).
|
||||||
- **pipelock image** — per-agent sidecar. Terminates the agent's
|
- **pipelock image** — per-agent sidecar. Terminates the agent's
|
||||||
outbound HTTP/HTTPS, enforces the resolved allowlist, runs DLP
|
outbound HTTP/HTTPS, enforces the resolved allowlist, runs DLP
|
||||||
scanning. Design in `docs/prds/0001-per-agent-egress-proxy-via-pipelock.md`
|
scanning. Design in `docs/prds/0001-per-agent-egress-proxy-via-pipelock.md`
|
||||||
and `docs/prds/0006-pipelock-tls-interception.md`.
|
and `docs/prds/0006-pipelock-tls-interception.md`.
|
||||||
- **socat/ssh image** — per-agent sidecar built on `alpine/socat`.
|
|
||||||
One container, one socat listener per `bottle.ssh` entry, each
|
|
||||||
forwarding TCP to the upstream `Hostname:Port`. SSH does *not* go
|
|
||||||
through pipelock. Design in `docs/prds/0007-ssh-egress-gate.md`.
|
|
||||||
- **git-gate image** — per-agent sidecar built on `zricethezav/gitleaks`
|
- **git-gate image** — per-agent sidecar built on `zricethezav/gitleaks`
|
||||||
(alpine + gitleaks + git-daemon + openssh-client). Runs
|
(alpine + gitleaks + git-daemon + openssh-client). Runs
|
||||||
`git daemon` over `git://` as a bidirectional mirror of each
|
`git daemon` over `git://` as a bidirectional mirror of each
|
||||||
@@ -160,14 +150,12 @@ project entries overriding home entries on key conflict).
|
|||||||
"GIT_AUTHOR_NAME": "didericis"
|
"GIT_AUTHOR_NAME": "didericis"
|
||||||
},
|
},
|
||||||
|
|
||||||
"ssh": [
|
"git": [
|
||||||
{
|
{
|
||||||
"Host": "gitea",
|
"Name": "claude-bottle",
|
||||||
"Hostname": "gitea.dideric.is",
|
"Upstream": "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
|
||||||
"User": "git",
|
|
||||||
"Port": 30009,
|
|
||||||
"IdentityFile": "/Users/didericis/.ssh/id_ed25519_gitea",
|
"IdentityFile": "/Users/didericis/.ssh/id_ed25519_gitea",
|
||||||
"KnownHostKey": "gitea.dideric.is ssh-ed25519 AAAA..."
|
"KnownHostKey": "ssh-ed25519 AAAA..."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"bottles": {
|
"bottles": {
|
||||||
"default": {
|
"default": {
|
||||||
"env": {},
|
"env": {},
|
||||||
"ssh": [],
|
|
||||||
"egress": {
|
"egress": {
|
||||||
"allowlist": [
|
"allowlist": [
|
||||||
"github.com",
|
"github.com",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Generic, Sequence, TypeVar
|
from typing import Any, Generic, Sequence, TypeVar
|
||||||
|
|
||||||
from ..log import die
|
from ..log import die
|
||||||
from ..manifest import GitEntry, Manifest, SshEntry
|
from ..manifest import GitEntry, Manifest
|
||||||
from ..util import expand_tilde
|
from ..util import expand_tilde
|
||||||
from .util import host_skill_dir
|
from .util import host_skill_dir
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
|
|
||||||
def _validate(self, spec: BottleSpec) -> None:
|
def _validate(self, spec: BottleSpec) -> None:
|
||||||
"""Cross-backend pre-launch checks. Confirms the agent exists,
|
"""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
|
IdentityFile resolves. Subclasses with additional preconditions
|
||||||
should override and call `super()._validate(spec)` first."""
|
should override and call `super()._validate(spec)` first."""
|
||||||
manifest = spec.manifest
|
manifest = spec.manifest
|
||||||
@@ -170,7 +170,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
agent = manifest.agents[spec.agent_name]
|
agent = manifest.agents[spec.agent_name]
|
||||||
bottle = manifest.bottle_for(spec.agent_name)
|
bottle = manifest.bottle_for(spec.agent_name)
|
||||||
self._validate_skills(agent.skills)
|
self._validate_skills(agent.skills)
|
||||||
self._validate_ssh_entries(bottle.ssh)
|
|
||||||
self._validate_git_entries(bottle.git)
|
self._validate_git_entries(bottle.git)
|
||||||
|
|
||||||
def _validate_skills(self, skills: Sequence[str]) -> None:
|
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."
|
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:
|
def _validate_git_entries(self, entries: Sequence[GitEntry]) -> None:
|
||||||
"""Each entry's IdentityFile must exist on the host (after
|
"""Each entry's IdentityFile must exist on the host (after
|
||||||
expanding leading ~) — the git-gate copies it in at start time
|
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."""
|
"""Build/run the bottle and yield a handle; tear down on exit."""
|
||||||
|
|
||||||
def provision(self, plan: PlanT, target: str) -> str | None:
|
def provision(self, plan: PlanT, target: str) -> str | None:
|
||||||
"""Copy host-side files (CA cert, prompt, skills, SSH keys,
|
"""Copy host-side files (CA cert, prompt, skills, .git) into
|
||||||
.git) into the running bottle. Called from `launch` after the
|
the running bottle. Called from `launch` after the container
|
||||||
container/machine is up. `target` identifies the running
|
/ machine is up. `target` identifies the running instance in
|
||||||
instance in backend-specific terms (Docker: resolved
|
backend-specific terms (Docker: resolved container name; fly:
|
||||||
container name; fly: machine id). Returns the in-container
|
machine id). Returns the in-container prompt path if a prompt
|
||||||
prompt path if a prompt was provisioned, else None — the
|
was provisioned, else None — the Bottle handle uses it to
|
||||||
Bottle handle uses it to decide whether to add
|
decide whether to add --append-system-prompt-file to claude's
|
||||||
--append-system-prompt-file to claude's argv.
|
argv.
|
||||||
|
|
||||||
Default orchestration: ca → prompt → skills → ssh → git.
|
Default orchestration: ca → prompt → skills → git. CA install
|
||||||
CA install runs first so the agent's trust store is rebuilt
|
runs first so the agent's trust store is rebuilt before
|
||||||
before anything inside the agent makes a TLS call. Subclasses
|
anything inside the agent makes a TLS call. Subclasses
|
||||||
typically don't override this; they implement the sub-methods
|
typically don't override this; they implement the sub-methods
|
||||||
below."""
|
below."""
|
||||||
self.provision_ca(plan, target)
|
self.provision_ca(plan, target)
|
||||||
prompt_path = self.provision_prompt(plan, target)
|
prompt_path = self.provision_prompt(plan, target)
|
||||||
self.provision_skills(plan, target)
|
self.provision_skills(plan, target)
|
||||||
self.provision_ssh(plan, target)
|
|
||||||
self.provision_git(plan, target)
|
self.provision_git(plan, target)
|
||||||
return prompt_path
|
return prompt_path
|
||||||
|
|
||||||
@@ -257,12 +246,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
"""Copy the agent's named skills from the host into the
|
"""Copy the agent's named skills from the host into the
|
||||||
running bottle. No-op when the agent has no skills."""
|
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
|
@abstractmethod
|
||||||
def provision_git(self, plan: PlanT, target: str) -> None:
|
def provision_git(self, plan: PlanT, target: str) -> None:
|
||||||
"""Copy the host's cwd `.git` directory into the running
|
"""Copy the host's cwd `.git` directory into the running
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ from .provision import ca as _ca
|
|||||||
from .provision import git as _git
|
from .provision import git as _git
|
||||||
from .provision import prompt as _prompt
|
from .provision import prompt as _prompt
|
||||||
from .provision import skills as _skills
|
from .provision import skills as _skills
|
||||||
from .provision import ssh as _ssh
|
|
||||||
from .ssh_gate import DockerSSHGate
|
|
||||||
|
|
||||||
|
|
||||||
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
|
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
|
||||||
@@ -41,7 +39,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._proxy = DockerPipelockProxy()
|
self._proxy = DockerPipelockProxy()
|
||||||
self._gate = DockerSSHGate()
|
|
||||||
self._git_gate = DockerGitGate()
|
self._git_gate = DockerGitGate()
|
||||||
|
|
||||||
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
||||||
@@ -49,7 +46,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
spec,
|
spec,
|
||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
proxy=self._proxy,
|
proxy=self._proxy,
|
||||||
gate=self._gate,
|
|
||||||
git_gate=self._git_gate,
|
git_gate=self._git_gate,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,7 +54,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
with _launch.launch(
|
with _launch.launch(
|
||||||
plan,
|
plan,
|
||||||
proxy=self._proxy,
|
proxy=self._proxy,
|
||||||
gate=self._gate,
|
|
||||||
git_gate=self._git_gate,
|
git_gate=self._git_gate,
|
||||||
provision=self.provision,
|
provision=self.provision,
|
||||||
) as bottle:
|
) as bottle:
|
||||||
@@ -73,9 +68,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
def provision_skills(self, plan: DockerBottlePlan, target: str) -> None:
|
def provision_skills(self, plan: DockerBottlePlan, target: str) -> None:
|
||||||
_skills.provision_skills(plan, target)
|
_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:
|
def provision_git(self, plan: DockerBottlePlan, target: str) -> None:
|
||||||
_git.provision_git(plan, target)
|
_git.provision_git(plan, target)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from ...git_gate import GitGatePlan
|
|||||||
from ...log import info
|
from ...log import info
|
||||||
from ...manifest import Agent, Bottle
|
from ...manifest import Agent, Bottle
|
||||||
from ...pipelock import PipelockProxyPlan, pipelock_effective_allowlist
|
from ...pipelock import PipelockProxyPlan, pipelock_effective_allowlist
|
||||||
from ...ssh_gate import SSHGatePlan
|
|
||||||
from .. import BottlePlan
|
from .. import BottlePlan
|
||||||
|
|
||||||
|
|
||||||
@@ -27,7 +26,6 @@ class _PlanView:
|
|||||||
agent: Agent
|
agent: Agent
|
||||||
bottle: Bottle
|
bottle: Bottle
|
||||||
env_names: list[str]
|
env_names: list[str]
|
||||||
ssh_hosts: list[str]
|
|
||||||
git_names: list[str]
|
git_names: list[str]
|
||||||
prompt_first_line: str
|
prompt_first_line: str
|
||||||
|
|
||||||
@@ -52,7 +50,6 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
forwarded_env: dict[str, str] = field(repr=False)
|
forwarded_env: dict[str, str] = field(repr=False)
|
||||||
prompt_file: Path
|
prompt_file: Path
|
||||||
proxy_plan: PipelockProxyPlan
|
proxy_plan: PipelockProxyPlan
|
||||||
gate_plan: SSHGatePlan
|
|
||||||
git_gate_plan: GitGatePlan
|
git_gate_plan: GitGatePlan
|
||||||
allowlist_summary: str
|
allowlist_summary: str
|
||||||
use_runsc: bool
|
use_runsc: bool
|
||||||
@@ -69,7 +66,6 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
agent=agent,
|
agent=agent,
|
||||||
bottle=bottle,
|
bottle=bottle,
|
||||||
env_names=env_names,
|
env_names=env_names,
|
||||||
ssh_hosts=[e.Host for e in bottle.ssh],
|
|
||||||
git_names=[e.Name for e in bottle.git],
|
git_names=[e.Name for e in bottle.git],
|
||||||
prompt_first_line=agent.prompt.splitlines()[0] if agent.prompt else "",
|
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("skills : " + (" ".join(v.agent.skills) if v.agent.skills else "(none)"))
|
||||||
info(f"docker runtime : {runtime_label}")
|
info(f"docker runtime : {runtime_label}")
|
||||||
info(f"bottle : {v.agent.bottle}")
|
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:
|
if v.git_names:
|
||||||
info(f" git remotes : {', '.join(v.git_names)}")
|
info(f" git remotes : {', '.join(v.git_names)}")
|
||||||
git_lines = [
|
git_lines = [
|
||||||
@@ -136,15 +122,6 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
"runtime": "runsc" if self.use_runsc else "runc",
|
"runtime": "runsc" if self.use_runsc else "runc",
|
||||||
"env_names": v.env_names,
|
"env_names": v.env_names,
|
||||||
"skills": list(v.agent.skills),
|
"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_remotes": v.git_names,
|
||||||
"git_gate": [
|
"git_gate": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ from .bottle_plan import DockerBottlePlan
|
|||||||
from .git_gate import DockerGitGate
|
from .git_gate import DockerGitGate
|
||||||
from .pipelock import DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init
|
from .pipelock import DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init
|
||||||
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
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.
|
# Where the repo root lives, for `docker build` context. Computed once.
|
||||||
@@ -37,7 +36,6 @@ def launch(
|
|||||||
plan: DockerBottlePlan,
|
plan: DockerBottlePlan,
|
||||||
*,
|
*,
|
||||||
proxy: DockerPipelockProxy,
|
proxy: DockerPipelockProxy,
|
||||||
gate: DockerSSHGate,
|
|
||||||
git_gate: DockerGitGate,
|
git_gate: DockerGitGate,
|
||||||
provision: Callable[[DockerBottlePlan, str], str | None],
|
provision: Callable[[DockerBottlePlan, str], str | None],
|
||||||
) -> Generator[DockerBottle, None, None]:
|
) -> Generator[DockerBottle, None, None]:
|
||||||
@@ -89,21 +87,6 @@ def launch(
|
|||||||
pipelock_name = proxy.start(plan.proxy_plan)
|
pipelock_name = proxy.start(plan.proxy_plan)
|
||||||
stack.callback(proxy.stop, pipelock_name)
|
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
|
# Git gate (PRD 0008). One sidecar per agent, only brought up
|
||||||
# when the bottle has git entries. Same internal + egress
|
# when the bottle has git entries. Same internal + egress
|
||||||
# network attachment as the other sidecars; agent dials it as
|
# network attachment as the other sidecars; agent dials it as
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ from . import util as docker_mod
|
|||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
from .git_gate import DockerGitGate
|
from .git_gate import DockerGitGate
|
||||||
from .pipelock import DockerPipelockProxy
|
from .pipelock import DockerPipelockProxy
|
||||||
from .ssh_gate import DockerSSHGate
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_plan(
|
def resolve_plan(
|
||||||
@@ -29,12 +28,11 @@ def resolve_plan(
|
|||||||
*,
|
*,
|
||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
proxy: DockerPipelockProxy,
|
proxy: DockerPipelockProxy,
|
||||||
gate: DockerSSHGate,
|
|
||||||
git_gate: DockerGitGate,
|
git_gate: DockerGitGate,
|
||||||
) -> DockerBottlePlan:
|
) -> DockerBottlePlan:
|
||||||
"""Resolve Docker-specific names and write scratch files. Trusts
|
"""Resolve Docker-specific names and write scratch files. Trusts
|
||||||
that the agent and its skills/SSH keys are present — validation
|
that the agent and its skills/git-gate keys are present —
|
||||||
already ran in the base class."""
|
validation already ran in the base class."""
|
||||||
docker_mod.require_docker()
|
docker_mod.require_docker()
|
||||||
|
|
||||||
manifest = spec.manifest
|
manifest = spec.manifest
|
||||||
@@ -82,7 +80,6 @@ def resolve_plan(
|
|||||||
prompt_file.chmod(0o600)
|
prompt_file.chmod(0o600)
|
||||||
|
|
||||||
proxy_plan = proxy.prepare(bottle, slug, stage_dir)
|
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)
|
git_gate_plan = git_gate.prepare(bottle, slug, stage_dir)
|
||||||
resolved = resolve_env(manifest, spec.agent_name)
|
resolved = resolve_env(manifest, spec.agent_name)
|
||||||
# Everything that should reach the bottle by-name (so its value
|
# Everything that should reach the bottle by-name (so its value
|
||||||
@@ -111,7 +108,6 @@ def resolve_plan(
|
|||||||
forwarded_env=forwarded_env,
|
forwarded_env=forwarded_env,
|
||||||
prompt_file=prompt_file,
|
prompt_file=prompt_file,
|
||||||
proxy_plan=proxy_plan,
|
proxy_plan=proxy_plan,
|
||||||
gate_plan=gate_plan,
|
|
||||||
git_gate_plan=git_gate_plan,
|
git_gate_plan=git_gate_plan,
|
||||||
allowlist_summary=allowlist_summary,
|
allowlist_summary=allowlist_summary,
|
||||||
use_runsc=use_runsc,
|
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"])
|
|
||||||
@@ -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}'"
|
|
||||||
)
|
|
||||||
@@ -31,16 +31,15 @@ def cmd_info(argv: list[str]) -> int:
|
|||||||
f"first line: {prompt_first_line or '(empty)'}"
|
f"first line: {prompt_first_line or '(empty)'}"
|
||||||
)
|
)
|
||||||
info(f"bottle : {agent.bottle}")
|
info(f"bottle : {agent.bottle}")
|
||||||
if bottle.ssh:
|
if bottle.git:
|
||||||
for e in bottle.ssh:
|
for e in bottle.git:
|
||||||
info(
|
info(
|
||||||
f" ssh host : {e.Host} "
|
f" git remote : {e.Name} -> {e.Upstream} "
|
||||||
f"(Hostname={e.Hostname}, User={e.User}, "
|
f"(IdentityFile={e.IdentityFile})"
|
||||||
f"Port={e.Port}, IdentityFile={e.IdentityFile})"
|
|
||||||
)
|
)
|
||||||
if e.KnownHostKey:
|
if e.KnownHostKey:
|
||||||
info(f" KnownHostKey: {e.KnownHostKey}")
|
info(f" KnownHostKey: {e.KnownHostKey}")
|
||||||
else:
|
else:
|
||||||
info(" ssh hosts : (none)")
|
info(" git remotes : (none)")
|
||||||
print()
|
print()
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -93,9 +93,8 @@ class GitGatePlan:
|
|||||||
|
|
||||||
|
|
||||||
def git_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[GitGateUpstream, ...]:
|
def git_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[GitGateUpstream, ...]:
|
||||||
"""Lift each `bottle.git` entry into a GitGateUpstream. Cross-entry
|
"""Lift each `bottle.git` entry into a GitGateUpstream. Unique-Name
|
||||||
validation (unique Names, no shadow route with bottle.ssh) already
|
validation already ran in `manifest.Bottle.from_dict`."""
|
||||||
ran in `manifest.Bottle.from_dict`."""
|
|
||||||
return tuple(
|
return tuple(
|
||||||
GitGateUpstream(
|
GitGateUpstream(
|
||||||
name=e.Name,
|
name=e.Name,
|
||||||
|
|||||||
+13
-92
@@ -6,7 +6,6 @@ Schema (see CLAUDE.md "Intended design"):
|
|||||||
"bottles": {
|
"bottles": {
|
||||||
"<bottle-name>": {
|
"<bottle-name>": {
|
||||||
"env": { "<NAME>": <env-entry>, ... },
|
"env": { "<NAME>": <env-entry>, ... },
|
||||||
"ssh": [ <ssh-entry>, ... ],
|
|
||||||
"git": [ <git-entry>, ... ],
|
"git": [ <git-entry>, ... ],
|
||||||
"egress": { "allowlist": [ "<hostname>", ... ] }
|
"egress": { "allowlist": [ "<hostname>", ... ] }
|
||||||
}
|
}
|
||||||
@@ -20,8 +19,9 @@ Schema (see CLAUDE.md "Intended design"):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Bottles group shared infrastructure (SSH keys, known hosts, egress allowlist)
|
Bottles group shared infrastructure (git upstreams + their gate credentials,
|
||||||
that multiple agents can reference. Every agent must reference a bottle.
|
egress allowlist) that multiple agents can reference. Every agent must
|
||||||
|
reference a bottle.
|
||||||
|
|
||||||
Validation runs once at construction (Manifest.from_json_obj) so getters
|
Validation runs once at construction (Manifest.from_json_obj) so getters
|
||||||
can trust the shape.
|
can trust the shape.
|
||||||
@@ -42,44 +42,6 @@ def _empty_str_dict() -> dict[str, str]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SshEntry:
|
|
||||||
Host: str
|
|
||||||
IdentityFile: str
|
|
||||||
Hostname: str = ""
|
|
||||||
User: str = ""
|
|
||||||
Port: str = ""
|
|
||||||
KnownHostKey: str = ""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "SshEntry":
|
|
||||||
d = _as_json_object(raw, f"bottle '{bottle_name}' ssh[{idx}]")
|
|
||||||
host = d.get("Host")
|
|
||||||
if not isinstance(host, str) or not host:
|
|
||||||
die(f"bottle '{bottle_name}' ssh[{idx}] missing required string field 'Host'")
|
|
||||||
ident = d.get("IdentityFile")
|
|
||||||
if not isinstance(ident, str) or not ident:
|
|
||||||
die(
|
|
||||||
f"bottle '{bottle_name}' ssh '{host}' missing required string field "
|
|
||||||
f"'IdentityFile'"
|
|
||||||
)
|
|
||||||
hostname = _opt_str(d.get("Hostname"), f"bottle '{bottle_name}' ssh '{host}' Hostname")
|
|
||||||
user = _opt_str(d.get("User"), f"bottle '{bottle_name}' ssh '{host}' User")
|
|
||||||
port = _opt_port(d.get("Port"), f"bottle '{bottle_name}' ssh '{host}' Port")
|
|
||||||
khk = _opt_str(
|
|
||||||
d.get("KnownHostKey"),
|
|
||||||
f"bottle '{bottle_name}' ssh '{host}' KnownHostKey",
|
|
||||||
)
|
|
||||||
return cls(
|
|
||||||
Host=host,
|
|
||||||
IdentityFile=ident,
|
|
||||||
Hostname=hostname,
|
|
||||||
User=user,
|
|
||||||
Port=port,
|
|
||||||
KnownHostKey=khk,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class GitEntry:
|
class GitEntry:
|
||||||
"""One upstream the per-agent git-gate (PRD 0008) is allowed to
|
"""One upstream the per-agent git-gate (PRD 0008) is allowed to
|
||||||
@@ -205,7 +167,6 @@ class BottleEgress:
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Bottle:
|
class Bottle:
|
||||||
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
|
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
|
||||||
ssh: tuple[SshEntry, ...] = ()
|
|
||||||
git: tuple[GitEntry, ...] = ()
|
git: tuple[GitEntry, ...] = ()
|
||||||
egress: BottleEgress = field(default_factory=BottleEgress)
|
egress: BottleEgress = field(default_factory=BottleEgress)
|
||||||
|
|
||||||
@@ -221,6 +182,15 @@ class Bottle:
|
|||||||
f"definition."
|
f"definition."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if "ssh" in d:
|
||||||
|
die(
|
||||||
|
f"bottle '{name}' has an 'ssh' field, which has been removed "
|
||||||
|
f"(PRD 0009). Move each entry to 'git': declare the upstream "
|
||||||
|
f"as a git remote with Name + Upstream URL + IdentityFile, "
|
||||||
|
f"and the per-bottle git-gate (PRD 0008) will hold the "
|
||||||
|
f"credential and gitleaks-scan pushes."
|
||||||
|
)
|
||||||
|
|
||||||
env: dict[str, str] = {}
|
env: dict[str, str] = {}
|
||||||
env_raw = d.get("env")
|
env_raw = d.get("env")
|
||||||
if env_raw is not None:
|
if env_raw is not None:
|
||||||
@@ -233,17 +203,6 @@ class Bottle:
|
|||||||
)
|
)
|
||||||
env[var] = value
|
env[var] = value
|
||||||
|
|
||||||
ssh: tuple[SshEntry, ...] = ()
|
|
||||||
ssh_raw = d.get("ssh")
|
|
||||||
if ssh_raw is not None:
|
|
||||||
if not isinstance(ssh_raw, list):
|
|
||||||
die(f"bottle '{name}' ssh must be an array (was {type(ssh_raw).__name__})")
|
|
||||||
ssh_list = cast(list[object], ssh_raw)
|
|
||||||
ssh = tuple(
|
|
||||||
SshEntry.from_dict(name, i, entry)
|
|
||||||
for i, entry in enumerate(ssh_list)
|
|
||||||
)
|
|
||||||
|
|
||||||
git: tuple[GitEntry, ...] = ()
|
git: tuple[GitEntry, ...] = ()
|
||||||
git_raw = d.get("git")
|
git_raw = d.get("git")
|
||||||
if git_raw is not None:
|
if git_raw is not None:
|
||||||
@@ -255,7 +214,6 @@ class Bottle:
|
|||||||
for i, entry in enumerate(git_list)
|
for i, entry in enumerate(git_list)
|
||||||
)
|
)
|
||||||
_validate_unique_git_names(name, git)
|
_validate_unique_git_names(name, git)
|
||||||
_validate_no_shadow_route(name, ssh, git)
|
|
||||||
|
|
||||||
egress_raw = d.get("egress")
|
egress_raw = d.get("egress")
|
||||||
egress = (
|
egress = (
|
||||||
@@ -264,7 +222,7 @@ class Bottle:
|
|||||||
else BottleEgress()
|
else BottleEgress()
|
||||||
)
|
)
|
||||||
|
|
||||||
return cls(env=env, ssh=ssh, git=git, egress=egress)
|
return cls(env=env, git=git, egress=egress)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -434,19 +392,6 @@ def _opt_str(value: object, label: str) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _opt_port(value: object, label: str) -> str:
|
|
||||||
"""Port accepts string or int (JSON-friendly) and is normalized to str."""
|
|
||||||
if value is None:
|
|
||||||
return ""
|
|
||||||
if isinstance(value, bool):
|
|
||||||
die(f"{label} must be a string or number (was boolean)")
|
|
||||||
if isinstance(value, int):
|
|
||||||
return str(value)
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value
|
|
||||||
die(f"{label} must be a string or number (was {type(value).__name__})")
|
|
||||||
|
|
||||||
|
|
||||||
def _opt_extra_hosts(value: object, label: str) -> dict[str, str]:
|
def _opt_extra_hosts(value: object, label: str) -> dict[str, str]:
|
||||||
"""Validate a `{hostname: ip}` object and return a plain dict. None
|
"""Validate a `{hostname: ip}` object and return a plain dict. None
|
||||||
yields an empty dict so callers can treat ExtraHosts as always
|
yields an empty dict so callers can treat ExtraHosts as always
|
||||||
@@ -507,27 +452,3 @@ def _validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> N
|
|||||||
seen[g.Name] = None
|
seen[g.Name] = None
|
||||||
|
|
||||||
|
|
||||||
def _validate_no_shadow_route(
|
|
||||||
bottle_name: str,
|
|
||||||
ssh: tuple[SshEntry, ...],
|
|
||||||
git: tuple[GitEntry, ...],
|
|
||||||
) -> None:
|
|
||||||
"""Reject if any git entry's (host, port) matches an ssh entry's
|
|
||||||
(Hostname, Port). The same upstream reachable two ways — once through
|
|
||||||
the L4 ssh-gate, once through the gitleaks-bearing git-gate — defeats
|
|
||||||
the git-gate."""
|
|
||||||
ssh_targets: dict[tuple[str, str], str] = {}
|
|
||||||
for e in ssh:
|
|
||||||
if not e.Hostname:
|
|
||||||
continue
|
|
||||||
port = e.Port or "22"
|
|
||||||
ssh_targets[(e.Hostname, port)] = e.Host
|
|
||||||
for g in git:
|
|
||||||
ssh_host = ssh_targets.get((g.UpstreamHost, g.UpstreamPort))
|
|
||||||
if ssh_host is not None:
|
|
||||||
die(
|
|
||||||
f"bottle '{bottle_name}' has ssh entry '{ssh_host}' "
|
|
||||||
f"({g.UpstreamHost}:{g.UpstreamPort}) and git entry '{g.Name}' "
|
|
||||||
f"pointing at the same upstream. The same remote reachable two "
|
|
||||||
f"ways defeats the git-gate; remove one."
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ def pipelock_bottle_allowlist(bottle: Bottle) -> list[str]:
|
|||||||
|
|
||||||
def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
|
def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
|
||||||
"""Deduplicated union of: baked-in defaults, bottle.egress.allowlist.
|
"""Deduplicated union of: baked-in defaults, bottle.egress.allowlist.
|
||||||
Sorted for stability. Per PRD 0007, bottle.ssh entries do NOT
|
Sorted for stability. Git upstreams declared in `bottle.git` do NOT
|
||||||
contribute here — SSH traffic flows through the per-agent ssh-gate
|
contribute here — git traffic flows through the per-agent git-gate
|
||||||
sidecar, not pipelock."""
|
sidecar (PRD 0008), not pipelock."""
|
||||||
seen: dict[str, None] = {}
|
seen: dict[str, None] = {}
|
||||||
for h in DEFAULT_ALLOWLIST:
|
for h in DEFAULT_ALLOWLIST:
|
||||||
seen.setdefault(h, None)
|
seen.setdefault(h, None)
|
||||||
|
|||||||
@@ -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."""
|
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
# PRD 0007: SSH egress gate
|
# PRD 0007: SSH egress gate
|
||||||
|
|
||||||
- **Status:** Draft
|
- **Status:** Superseded by PRD 0009 (2026-05-13)
|
||||||
- **Author:** didericis
|
- **Author:** didericis
|
||||||
- **Created:** 2026-05-12
|
- **Created:** 2026-05-12
|
||||||
|
|
||||||
|
> **Superseded.** The ssh-gate sidecar and `bottle.ssh` manifest field
|
||||||
|
> described below were removed in PRD 0009. Every upstream this PRD
|
||||||
|
> targeted has since been folded into PRD 0008's git-gate, which
|
||||||
|
> covers the same use case with credential isolation and gitleaks
|
||||||
|
> scanning instead of bare L4 forwarding. Kept in-tree for the
|
||||||
|
> history of intent.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Per-agent TCP-forwarder sidecar built from `bottle.ssh` entries; SSH stops
|
Per-agent TCP-forwarder sidecar built from `bottle.ssh` entries; SSH stops
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# PRD 0009: Remove ssh-gate and bottle.ssh
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** didericis
|
||||||
|
- **Created:** 2026-05-13
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Delete the ssh-gate sidecar and the `bottle.ssh` manifest field.
|
||||||
|
Git-gate (PRD 0008) covers every current SSH use case in
|
||||||
|
claude-bottle: each declared upstream gets a per-bottle gate
|
||||||
|
with gitleaks scanning, an `insteadOf` rewrite that captures
|
||||||
|
push / fetch / clone / pull / ls-remote, and credential
|
||||||
|
isolation from the agent. ssh-gate is now redundant L4
|
||||||
|
forwarding with no gating value, and the only remaining users
|
||||||
|
of `bottle.ssh` were git remotes that git-gate handles better.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
PRD 0007 introduced ssh-gate as an L4 SSH forwarder so an agent
|
||||||
|
could reach declared SSH upstreams without a default route
|
||||||
|
off-box. At the time that meant git. With PRD 0008 every git
|
||||||
|
upstream now flows through git-gate, which:
|
||||||
|
|
||||||
|
- Holds the upstream credential in the gate, not in the agent
|
||||||
|
- Runs gitleaks against every incoming ref
|
||||||
|
- Refreshes from upstream on every fetch (fail-closed when the
|
||||||
|
upstream is unreachable)
|
||||||
|
- Rewrites the agent's URLs via `insteadOf`, so push, fetch,
|
||||||
|
clone, pull, and ls-remote all route through the gate
|
||||||
|
|
||||||
|
ssh-gate does none of those — it's transport-only — and offers
|
||||||
|
no benefit over git-gate for git, which is the only upstream
|
||||||
|
class currently used. Carrying it means a dead manifest field,
|
||||||
|
a redundant sidecar lifecycle, a shadow-route validator between
|
||||||
|
`bottle.ssh` and `bottle.git`, and a third place to keep an SSH
|
||||||
|
identity in sync. The project's stated goal is minimum
|
||||||
|
credentials and minimum egress; the simpler answer is to drop
|
||||||
|
the unused path.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- `bottle.ssh` is no longer a valid manifest field. Manifests
|
||||||
|
carrying it parse-fail with an error message pointing at
|
||||||
|
`bottle.git`.
|
||||||
|
- `SSHGate` / `DockerSSHGate`, `provision_ssh`, and the socat
|
||||||
|
sidecar lifecycle are gone from the codebase.
|
||||||
|
- Unit + integration tests pass without the ssh-gate suites;
|
||||||
|
no test references `SshEntry` or `bottle.ssh`.
|
||||||
|
- The y/N preflight no longer mentions the ssh sidecar; the
|
||||||
|
README's architecture diagram drops the socat box; the
|
||||||
|
example manifest drops `ssh:`.
|
||||||
|
- PRD 0007 carries a "Superseded by PRD 0009" header so the
|
||||||
|
history of intent stays in the tree.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Removing pipelock or git-gate. Both keep their current roles.
|
||||||
|
- Removing the SSH egress *capability* in general — git-gate
|
||||||
|
uses SSH inside the gate to reach the real upstream.
|
||||||
|
- Building a generic L4 egress proxy as a replacement. If
|
||||||
|
non-git SSH ever returns, design a fresh gate for the use
|
||||||
|
case; don't resurrect ssh-gate's L4-only design.
|
||||||
|
- Preserving a "ssh: []" compatibility shim. If the field is
|
||||||
|
dead it should error, not silently parse.
|
||||||
|
- Auto-migrating user manifests. The replacement (`bottle.git`)
|
||||||
|
needs a `Name` and full upstream URL that `bottle.ssh` does
|
||||||
|
not carry; a hard error with a migration hint is cleaner than
|
||||||
|
a partial rewrite.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- **Manifest.** Delete `SshEntry`, `Bottle.ssh`, and
|
||||||
|
`_validate_no_shadow_route`. Add an explicit branch in
|
||||||
|
`Bottle.from_dict` that dies on a `ssh` key with a one-line
|
||||||
|
"move this to `bottle.git` (see PRD 0008)" hint.
|
||||||
|
- **Sidecar.** Delete `claude_bottle/ssh_gate.py` and
|
||||||
|
`claude_bottle/backend/docker/ssh_gate.py`. Drop the socat
|
||||||
|
image build path.
|
||||||
|
- **Provisioner.** Delete
|
||||||
|
`claude_bottle/backend/docker/provision/ssh.py` and its
|
||||||
|
`~/.ssh/config` render.
|
||||||
|
- **Docker backend wiring.** Drop `DockerSSHGate` from
|
||||||
|
`backend.py`; drop its start / stop from `launch.py`'s
|
||||||
|
`ExitStack`; drop the plan field from `bottle_plan.py`.
|
||||||
|
- **Pipelock interaction.** Drop the SSH-derived branch from
|
||||||
|
pipelock's `ssrf.ip_allowlist` build. With no `bottle.ssh`
|
||||||
|
there is no per-upstream IP carve-out to render; git-gate
|
||||||
|
has its own egress network and pulls in upstream resolution
|
||||||
|
via `ExtraHosts` plus DNS.
|
||||||
|
- **Tests.** Delete the ssh-gate unit + integration suites,
|
||||||
|
the ssh fixtures in `tests/fixtures.py`, and the
|
||||||
|
shadow-route assertions in `test_manifest_git.py`. Adjust
|
||||||
|
any tests that asserted on a "git + ssh" combined manifest
|
||||||
|
to be git-only.
|
||||||
|
- **README.** Drop the socat / ssh image box from the
|
||||||
|
architecture diagram and its bullet; drop `ssh:` from the
|
||||||
|
manifest example.
|
||||||
|
- **Example manifest.** Drop `ssh:` from `claude-bottle.example.json`.
|
||||||
|
- **PRD 0007.** Add a `Status: Superseded by PRD 0009` header
|
||||||
|
at the top of the document. Do not delete the file; the
|
||||||
|
history of intent matters for the audit trail.
|
||||||
|
- **Migration note.** A short paragraph in the README explaining
|
||||||
|
the swap (drop the `ssh` entry, add a `git` entry pointing
|
||||||
|
at the same upstream with a `Name` and full URL).
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- A generic non-git SSH gate. If/when the use case returns,
|
||||||
|
design a fresh sidecar with its own threat model.
|
||||||
|
- Migrating user manifests automatically. Parse-fail with a
|
||||||
|
clear error is the path.
|
||||||
|
- Reworking git-gate to absorb non-git protocols. git-gate
|
||||||
|
remains git-only.
|
||||||
|
|
||||||
|
## Proposed Design
|
||||||
|
|
||||||
|
Most of the work is deletion. The substantive decisions are at
|
||||||
|
the seams between ssh-gate and the rest of the system:
|
||||||
|
|
||||||
|
1. **Manifest parse-error shape.** `Bottle.from_dict` adds an
|
||||||
|
explicit `if "ssh" in d:` branch that dies with a hint
|
||||||
|
naming `bottle.git` and PRD 0008. Better than silent ignore
|
||||||
|
— the migration is visible and one-shot — and better than a
|
||||||
|
warning, which agents wouldn't see anyway.
|
||||||
|
2. **Pipelock allowlist build.** Today pipelock pulls SSH
|
||||||
|
upstream IPs into its `ssrf.ip_allowlist` so ssh-gate can
|
||||||
|
dial out. With ssh-gate gone, that branch goes away; verify
|
||||||
|
nothing else in the pipelock render touches `bottle.ssh`.
|
||||||
|
3. **Plan rendering.** `bottle_plan.py` and the y/N preflight
|
||||||
|
currently list the socat sidecar. Remove the field and any
|
||||||
|
rendering branch; the dry-run output simplifies.
|
||||||
|
4. **PRD 0007 marker.** A header line at the top, not a delete.
|
||||||
|
The PRD's rationale (why ssh-gate was built) is still
|
||||||
|
useful context when reading PRD 0008 and PRD 0009.
|
||||||
|
|
||||||
|
### Existing code touched
|
||||||
|
|
||||||
|
- `claude_bottle/manifest.py` — delete `SshEntry`,
|
||||||
|
`Bottle.ssh`, `_validate_no_shadow_route`; add the
|
||||||
|
parse-fail branch.
|
||||||
|
- `claude_bottle/ssh_gate.py` — delete.
|
||||||
|
- `claude_bottle/backend/docker/ssh_gate.py` — delete.
|
||||||
|
- `claude_bottle/backend/docker/provision/ssh.py` — delete.
|
||||||
|
- `claude_bottle/backend/docker/backend.py` — drop
|
||||||
|
`DockerSSHGate` instantiation.
|
||||||
|
- `claude_bottle/backend/docker/launch.py` — drop the
|
||||||
|
ssh-gate start / stop from the `ExitStack`.
|
||||||
|
- `claude_bottle/backend/docker/bottle_plan.py` — drop the
|
||||||
|
ssh-gate plan field.
|
||||||
|
- `claude_bottle/pipelock.py` — drop the `bottle.ssh`-derived
|
||||||
|
branch in the allowlist render.
|
||||||
|
- `tests/unit/test_ssh_gate.py` — delete.
|
||||||
|
- `tests/integration/` — delete any ssh-gate-specific tests.
|
||||||
|
- `tests/unit/test_manifest_git.py` — drop the shadow-route
|
||||||
|
assertions.
|
||||||
|
- `tests/fixtures.py` — drop `fixture_with_ssh` and its dict
|
||||||
|
helper.
|
||||||
|
- `README.md` — drop the socat image box from the diagram and
|
||||||
|
the matching bullet; drop `ssh:` from the manifest example.
|
||||||
|
- `claude-bottle.example.json` — drop the `ssh` field.
|
||||||
|
- `docs/prds/0007-ssh-egress-gate.md` — add a
|
||||||
|
`Status: Superseded by PRD 0009` header at the top.
|
||||||
|
|
||||||
|
### Data model changes
|
||||||
|
|
||||||
|
- `SshEntry` removed.
|
||||||
|
- `Bottle.ssh: tuple[SshEntry, ...] = ()` removed.
|
||||||
|
- `_validate_no_shadow_route` removed.
|
||||||
|
|
||||||
|
### External dependencies
|
||||||
|
|
||||||
|
Nothing added. The `alpine/socat` image is no longer pulled
|
||||||
|
by claude-bottle; the cleanup of any existing local image is
|
||||||
|
the user's choice (a single `docker image rm` if they care).
|
||||||
|
|
||||||
|
## Future work
|
||||||
|
|
||||||
|
If a non-git SSH use case returns (deployment, rsync, remote
|
||||||
|
management), build it as its own gate with its own threat
|
||||||
|
model. Don't resurrect ssh-gate's L4-only design. The
|
||||||
|
git-gate pattern (gate holds credentials, agent gets a
|
||||||
|
rewritten URL, gate makes the upstream connection) is the
|
||||||
|
template.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **Error vs migrate on legacy `bottle.ssh`.** Default: hard
|
||||||
|
error with a one-line "move this to `bottle.git`" hint.
|
||||||
|
Migrating in-place is brittle — the entry has no `Name`
|
||||||
|
field and no upstream URL, so we'd be inventing both.
|
||||||
|
- **One PR or two.** The removal touches enough files that a
|
||||||
|
single big PR may be hard to review; splitting into
|
||||||
|
"deprecate manifest field" and "remove sidecar code" reads
|
||||||
|
cleaner, but the field can't be removed cleanly while the
|
||||||
|
sidecar still references it. Default: single PR, deletion
|
||||||
|
commit per layer (manifest, sidecar, provisioner, tests,
|
||||||
|
docs).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- PRD 0007: SSH egress gate — the design being superseded.
|
||||||
|
- PRD 0008: Git gate — the design that subsumes ssh-gate's
|
||||||
|
only current use case.
|
||||||
@@ -37,34 +37,6 @@ def fixture_with_egress_dict() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def fixture_with_ssh_dict() -> dict[str, Any]:
|
|
||||||
"""Bottle has both an IPv4-literal SSH host (CGNAT) and a hostname host,
|
|
||||||
exercising both ssrf.ip_allowlist and trusted_domains code paths. JSON shape."""
|
|
||||||
return {
|
|
||||||
"bottles": {
|
|
||||||
"dev": {
|
|
||||||
"ssh": [
|
|
||||||
{
|
|
||||||
"Host": "tailscale-gitea",
|
|
||||||
"IdentityFile": "/dev/null",
|
|
||||||
"Hostname": "100.78.141.42",
|
|
||||||
"User": "git",
|
|
||||||
"Port": 30009,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Host": "github",
|
|
||||||
"IdentityFile": "/dev/null",
|
|
||||||
"Hostname": "github.com",
|
|
||||||
"User": "git",
|
|
||||||
"Port": 22,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def fixture_with_git_dict() -> dict[str, Any]:
|
def fixture_with_git_dict() -> dict[str, Any]:
|
||||||
"""Bottle declares a git-gate upstream. JSON shape."""
|
"""Bottle declares a git-gate upstream. JSON shape."""
|
||||||
return {
|
return {
|
||||||
@@ -98,10 +70,6 @@ def fixture_with_egress() -> Manifest:
|
|||||||
return Manifest.from_json_obj(fixture_with_egress_dict())
|
return Manifest.from_json_obj(fixture_with_egress_dict())
|
||||||
|
|
||||||
|
|
||||||
def fixture_with_ssh() -> Manifest:
|
|
||||||
return Manifest.from_json_obj(fixture_with_ssh_dict())
|
|
||||||
|
|
||||||
|
|
||||||
def fixture_with_git() -> Manifest:
|
def fixture_with_git() -> Manifest:
|
||||||
return Manifest.from_json_obj(fixture_with_git_dict())
|
return Manifest.from_json_obj(fixture_with_git_dict())
|
||||||
|
|
||||||
|
|||||||
@@ -79,8 +79,6 @@ class TestDryRunPlan(unittest.TestCase):
|
|||||||
self.assertEqual("runc", plan["runtime"],
|
self.assertEqual("runc", plan["runtime"],
|
||||||
"runsc isn't available on the CI runner")
|
"runsc isn't available on the CI runner")
|
||||||
self.assertEqual([], plan["skills"])
|
self.assertEqual([], plan["skills"])
|
||||||
self.assertEqual([], plan["ssh_hosts"])
|
|
||||||
self.assertEqual([], plan["ssh_gate"])
|
|
||||||
self.assertEqual([], plan["git_remotes"])
|
self.assertEqual([], plan["git_remotes"])
|
||||||
self.assertEqual([], plan["git_gate"])
|
self.assertEqual([], plan["git_gate"])
|
||||||
self.assertEqual(False, plan["remote_control"])
|
self.assertEqual(False, plan["remote_control"])
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Integration: the cleanup primitives the start-flow trap depends on
|
"""Integration: the cleanup primitives the start-flow trap depends on
|
||||||
are idempotent. The original orphan-network bug was a trap-ordering
|
are idempotent. The original orphan-network bug was a trap-ordering
|
||||||
issue; the fix moved the install earlier. The trap is only safe if
|
issue; the fix moved the install earlier. The trap is only safe if
|
||||||
network_remove, PipelockProxy.stop, and SSHGate.stop are no-ops
|
network_remove and PipelockProxy.stop are no-ops against missing
|
||||||
against missing resources."""
|
resources."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -17,10 +17,6 @@ from claude_bottle.backend.docker.pipelock import (
|
|||||||
DockerPipelockProxy,
|
DockerPipelockProxy,
|
||||||
pipelock_container_name,
|
pipelock_container_name,
|
||||||
)
|
)
|
||||||
from claude_bottle.backend.docker.ssh_gate import (
|
|
||||||
DockerSSHGate,
|
|
||||||
ssh_gate_container_name,
|
|
||||||
)
|
|
||||||
from tests._docker import skip_unless_docker
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
|
||||||
@@ -79,13 +75,6 @@ class TestOrphanCleanup(unittest.TestCase):
|
|||||||
# Should not raise.
|
# Should not raise.
|
||||||
DockerPipelockProxy().stop(pipelock_container_name(f"missing-{self.slug}"))
|
DockerPipelockProxy().stop(pipelock_container_name(f"missing-{self.slug}"))
|
||||||
|
|
||||||
def test_ssh_gate_stop_missing_sidecar(self):
|
|
||||||
# Same trap-safety requirement for the gate (PRD 0007). The
|
|
||||||
# launch ExitStack calls gate.stop on every error path; if
|
|
||||||
# the container was never created (early failure), stop must
|
|
||||||
# still no-op.
|
|
||||||
DockerSSHGate().stop(ssh_gate_container_name(f"missing-{self.slug}"))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -168,11 +168,9 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
"IdentityFile": "/dev/null"},
|
"IdentityFile": "/dev/null"},
|
||||||
]))
|
]))
|
||||||
|
|
||||||
def test_shadow_route_with_ssh_entry_dies(self):
|
def test_legacy_ssh_field_dies_with_hint(self):
|
||||||
# An ssh entry pointing at gitea.dideric.is:30009 AND a git
|
# PRD 0009: bottle.ssh is removed; manifests carrying it must
|
||||||
# entry pointing at ssh://git@gitea.dideric.is:30009/... is a
|
# fail loudly with a hint pointing at bottle.git.
|
||||||
# bypass: agents could route around the gate by using the
|
|
||||||
# ssh-gate. Manifest construction must reject.
|
|
||||||
with self.assertRaises(Die):
|
with self.assertRaises(Die):
|
||||||
Manifest.from_json_obj({
|
Manifest.from_json_obj({
|
||||||
"bottles": {
|
"bottles": {
|
||||||
@@ -184,40 +182,11 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
"User": "git",
|
"User": "git",
|
||||||
"Port": 30009,
|
"Port": 30009,
|
||||||
}],
|
}],
|
||||||
"git": [{
|
|
||||||
"Name": "claude-bottle",
|
|
||||||
"Upstream": "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
|
|
||||||
"IdentityFile": "/dev/null",
|
|
||||||
}],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
})
|
})
|
||||||
|
|
||||||
def test_independent_ssh_and_git_targets_allowed(self):
|
|
||||||
# Same hostname but different ports are independent targets.
|
|
||||||
m = Manifest.from_json_obj({
|
|
||||||
"bottles": {
|
|
||||||
"dev": {
|
|
||||||
"ssh": [{
|
|
||||||
"Host": "gitea-ssh",
|
|
||||||
"IdentityFile": "/dev/null",
|
|
||||||
"Hostname": "gitea.dideric.is",
|
|
||||||
"User": "git",
|
|
||||||
"Port": 22,
|
|
||||||
}],
|
|
||||||
"git": [{
|
|
||||||
"Name": "claude-bottle",
|
|
||||||
"Upstream": "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
|
|
||||||
"IdentityFile": "/dev/null",
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
||||||
})
|
|
||||||
self.assertEqual(1, len(m.bottles["dev"].ssh))
|
|
||||||
self.assertEqual(1, len(m.bottles["dev"].git))
|
|
||||||
|
|
||||||
|
|
||||||
class TestEmptyGitField(unittest.TestCase):
|
class TestEmptyGitField(unittest.TestCase):
|
||||||
def test_no_git_field_yields_empty_tuple(self):
|
def test_no_git_field_yields_empty_tuple(self):
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Unit: pipelock_effective_allowlist — the union of baked-in defaults
|
"""Unit: pipelock_effective_allowlist — the union of baked-in defaults
|
||||||
and bottle.egress.allowlist. Per PRD 0007, bottle.ssh entries do NOT
|
and bottle.egress.allowlist. Git upstreams declared in bottle.git do not
|
||||||
contribute (SSH traffic goes through the per-agent ssh-gate, not
|
contribute here; they flow through the per-agent git-gate (PRD 0008)."""
|
||||||
pipelock)."""
|
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
@@ -14,25 +13,21 @@ class TestEffectiveAllowlist(unittest.TestCase):
|
|||||||
manifest = Manifest.from_json_obj({
|
manifest = Manifest.from_json_obj({
|
||||||
"bottles": {
|
"bottles": {
|
||||||
"dev": {
|
"dev": {
|
||||||
"egress": {"allowlist": ["registry.npmjs.org"]},
|
"egress": {
|
||||||
"ssh": [
|
"allowlist": [
|
||||||
{"Host": "ts", "IdentityFile": "/dev/null",
|
"registry.npmjs.org",
|
||||||
"Hostname": "100.78.141.42", "User": "git", "Port": 30009},
|
# Duplicate of a baked default; the union
|
||||||
{"Host": "gh", "IdentityFile": "/dev/null",
|
# must dedupe.
|
||||||
"Hostname": "github.com", "User": "git", "Port": 22},
|
"api.anthropic.com",
|
||||||
],
|
],
|
||||||
}
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
})
|
})
|
||||||
eff = pipelock_effective_allowlist(manifest.bottles["dev"])
|
eff = pipelock_effective_allowlist(manifest.bottles["dev"])
|
||||||
self.assertIn("api.anthropic.com", eff, "baked default present")
|
self.assertIn("api.anthropic.com", eff, "baked default present")
|
||||||
self.assertIn("registry.npmjs.org", eff, "egress.allowlist present")
|
self.assertIn("registry.npmjs.org", eff, "egress.allowlist present")
|
||||||
# PRD 0007: ssh hostnames must not contribute to pipelock's
|
|
||||||
# allowlist anymore — they're routed through the ssh-gate
|
|
||||||
# sidecar, which is on its own egress path.
|
|
||||||
self.assertNotIn("100.78.141.42", eff)
|
|
||||||
self.assertNotIn("github.com", eff)
|
|
||||||
self.assertEqual(len(eff), len(set(eff)), "deduplicated")
|
self.assertEqual(len(eff), len(set(eff)), "deduplicated")
|
||||||
self.assertEqual(eff, sorted(eff), "sorted")
|
self.assertEqual(eff, sorted(eff), "sorted")
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from claude_bottle.pipelock import (
|
|||||||
pipelock_build_config,
|
pipelock_build_config,
|
||||||
pipelock_render_yaml,
|
pipelock_render_yaml,
|
||||||
)
|
)
|
||||||
from tests.fixtures import fixture_minimal, fixture_with_ssh
|
from tests.fixtures import fixture_minimal
|
||||||
|
|
||||||
|
|
||||||
class TestBuildConfig(unittest.TestCase):
|
class TestBuildConfig(unittest.TestCase):
|
||||||
@@ -38,26 +38,14 @@ class TestBuildConfig(unittest.TestCase):
|
|||||||
# Baked defaults always present.
|
# Baked defaults always present.
|
||||||
self.assertIn("api.anthropic.com", cast(list[str], cfg["api_allowlist"]))
|
self.assertIn("api.anthropic.com", cast(list[str], cfg["api_allowlist"]))
|
||||||
self.assertIn("raw.githubusercontent.com", cast(list[str], cfg["api_allowlist"]))
|
self.assertIn("raw.githubusercontent.com", cast(list[str], cfg["api_allowlist"]))
|
||||||
# PRD 0007: pipelock has no SSH carve-outs at all — neither
|
# pipelock has no SSH carve-outs at all — neither
|
||||||
# trusted_domains nor ssrf are ever emitted from bottle data
|
# trusted_domains nor ssrf are emitted from bottle data.
|
||||||
# in v1.
|
|
||||||
self.assertNotIn("trusted_domains", cfg)
|
self.assertNotIn("trusted_domains", cfg)
|
||||||
self.assertNotIn("ssrf", cfg)
|
self.assertNotIn("ssrf", cfg)
|
||||||
# Without CA paths, the tls_interception block is omitted —
|
# Without CA paths, the tls_interception block is omitted —
|
||||||
# pipelock falls back to its built-in default of `enabled: false`.
|
# pipelock falls back to its built-in default of `enabled: false`.
|
||||||
self.assertNotIn("tls_interception", cfg)
|
self.assertNotIn("tls_interception", cfg)
|
||||||
|
|
||||||
def test_ssh_entries_do_not_leak_into_pipelock(self):
|
|
||||||
# PRD 0007: bottle.ssh routes through the ssh-gate sidecar,
|
|
||||||
# so pipelock's config must not reflect those hostnames or
|
|
||||||
# IPs in any of its blocks.
|
|
||||||
cfg = pipelock_build_config(fixture_with_ssh().bottles["dev"])
|
|
||||||
allow = cast(list[str], cfg["api_allowlist"])
|
|
||||||
self.assertNotIn("github.com", allow)
|
|
||||||
self.assertNotIn("100.78.141.42", allow)
|
|
||||||
self.assertNotIn("trusted_domains", cfg)
|
|
||||||
self.assertNotIn("ssrf", 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 DockerPipelockProxy's in-container
|
||||||
# constants; this directly pins the dict shape. passthrough_domains
|
# constants; this directly pins the dict shape. passthrough_domains
|
||||||
@@ -102,7 +90,7 @@ class TestRenderAndWrite(unittest.TestCase):
|
|||||||
"""One render-level smoke check: the serialized YAML is plausibly
|
"""One render-level smoke check: the serialized YAML is plausibly
|
||||||
the shape pipelock expects. We don't grep every key here — that's
|
the shape pipelock expects. We don't grep every key here — that's
|
||||||
what TestBuildConfig is for."""
|
what TestBuildConfig is for."""
|
||||||
cfg = pipelock_build_config(fixture_with_ssh().bottles["dev"])
|
cfg = pipelock_build_config(fixture_minimal().bottles["dev"])
|
||||||
text = pipelock_render_yaml(cfg)
|
text = pipelock_render_yaml(cfg)
|
||||||
for required in (
|
for required in (
|
||||||
"api_allowlist:",
|
"api_allowlist:",
|
||||||
@@ -111,7 +99,7 @@ class TestRenderAndWrite(unittest.TestCase):
|
|||||||
"request_body_scanning:",
|
"request_body_scanning:",
|
||||||
):
|
):
|
||||||
self.assertIn(required, text)
|
self.assertIn(required, text)
|
||||||
# PRD 0007: no ssh carve-outs in the rendered yaml.
|
# No ssh carve-outs in the rendered yaml.
|
||||||
self.assertNotIn("trusted_domains:", text)
|
self.assertNotIn("trusted_domains:", text)
|
||||||
self.assertNotIn("ssrf:", text)
|
self.assertNotIn("ssrf:", text)
|
||||||
|
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
"""Unit: SSHGate prepare shape + entrypoint render."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import stat
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from claude_bottle.manifest import Manifest
|
|
||||||
from claude_bottle.ssh_gate import (
|
|
||||||
SSHGate,
|
|
||||||
SSHGatePlan,
|
|
||||||
SSHGateUpstream,
|
|
||||||
ssh_gate_render_entrypoint,
|
|
||||||
ssh_gate_upstreams_for_bottle,
|
|
||||||
)
|
|
||||||
from tests.fixtures import fixture_minimal, fixture_with_ssh
|
|
||||||
|
|
||||||
|
|
||||||
class _StubGate(SSHGate):
|
|
||||||
"""Concrete subclass for testing the abstract `prepare`. The
|
|
||||||
backend-specific start/stop aren't exercised here."""
|
|
||||||
|
|
||||||
def start(self, plan: SSHGatePlan) -> str:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def stop(self, target: str) -> None:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpstreamAssignment(unittest.TestCase):
|
|
||||||
def test_listen_port_matches_upstream_port(self):
|
|
||||||
# Critical: URLs like ssh://git@host:30009/... override the
|
|
||||||
# config Port directive, so the gate must listen on the same
|
|
||||||
# port the URL names.
|
|
||||||
bottle = fixture_with_ssh().bottles["dev"]
|
|
||||||
upstreams = ssh_gate_upstreams_for_bottle(bottle)
|
|
||||||
self.assertEqual(2, len(upstreams))
|
|
||||||
# Fixture: tailscale-gitea -> 100.78.141.42:30009, github -> github.com:22.
|
|
||||||
self.assertEqual(30009, upstreams[0].listen_port)
|
|
||||||
self.assertEqual(22, upstreams[1].listen_port)
|
|
||||||
|
|
||||||
def test_upstream_fields_mirror_ssh_entry(self):
|
|
||||||
bottle = fixture_with_ssh().bottles["dev"]
|
|
||||||
first = ssh_gate_upstreams_for_bottle(bottle)[0]
|
|
||||||
self.assertEqual("tailscale-gitea", first.bottle_host_alias)
|
|
||||||
self.assertEqual("100.78.141.42", first.upstream_host)
|
|
||||||
self.assertEqual("30009", first.upstream_port)
|
|
||||||
|
|
||||||
def test_empty_bottle_yields_empty_upstreams(self):
|
|
||||||
bottle = fixture_minimal().bottles["dev"]
|
|
||||||
self.assertEqual((), ssh_gate_upstreams_for_bottle(bottle))
|
|
||||||
|
|
||||||
def test_duplicate_upstream_port_is_rejected(self):
|
|
||||||
# Two entries on the same upstream port can't both have a
|
|
||||||
# listener — the gate is one container with a flat port
|
|
||||||
# space. Surface as a clear config error.
|
|
||||||
manifest = Manifest.from_json_obj({
|
|
||||||
"bottles": {
|
|
||||||
"dev": {
|
|
||||||
"ssh": [
|
|
||||||
{"Host": "a", "IdentityFile": "/dev/null",
|
|
||||||
"Hostname": "host-a.example", "User": "git", "Port": 22},
|
|
||||||
{"Host": "b", "IdentityFile": "/dev/null",
|
|
||||||
"Hostname": "host-b.example", "User": "git", "Port": 22},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
||||||
})
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
ssh_gate_upstreams_for_bottle(manifest.bottles["dev"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestEntrypointRender(unittest.TestCase):
|
|
||||||
def test_one_socat_line_per_upstream(self):
|
|
||||||
upstreams = (
|
|
||||||
SSHGateUpstream(30009, "gitea.example", "30009", "gitea"),
|
|
||||||
SSHGateUpstream(22, "github.com", "22", "gh"),
|
|
||||||
)
|
|
||||||
script = ssh_gate_render_entrypoint(upstreams)
|
|
||||||
self.assertIn("#!/bin/sh", script)
|
|
||||||
self.assertIn(
|
|
||||||
"socat TCP-LISTEN:30009,reuseaddr,fork TCP:gitea.example:30009 &", script
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
"socat TCP-LISTEN:22,reuseaddr,fork TCP:github.com:22 &", script
|
|
||||||
)
|
|
||||||
# wait blocks the entrypoint so PID 1 stays alive while sockets
|
|
||||||
# are open.
|
|
||||||
self.assertTrue(script.rstrip().endswith("wait"))
|
|
||||||
|
|
||||||
def test_empty_upstreams_still_has_wait(self):
|
|
||||||
# Defensive: a no-upstream gate is a no-op, but render must
|
|
||||||
# still produce a valid shell script.
|
|
||||||
script = ssh_gate_render_entrypoint(())
|
|
||||||
self.assertIn("#!/bin/sh", script)
|
|
||||||
self.assertIn("wait", script)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPrepare(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.stage = Path(tempfile.mkdtemp())
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
shutil.rmtree(self.stage, ignore_errors=True)
|
|
||||||
|
|
||||||
def test_prepare_writes_entrypoint_mode_600(self):
|
|
||||||
plan = _StubGate().prepare(
|
|
||||||
fixture_with_ssh().bottles["dev"], "demo", self.stage
|
|
||||||
)
|
|
||||||
self.assertEqual(self.stage / "ssh_gate_entrypoint.sh", plan.entrypoint_script)
|
|
||||||
self.assertEqual(0o600, os.stat(plan.entrypoint_script).st_mode & 0o777)
|
|
||||||
|
|
||||||
def test_prepare_plan_carries_upstreams_and_slug(self):
|
|
||||||
plan = _StubGate().prepare(
|
|
||||||
fixture_with_ssh().bottles["dev"], "demo", self.stage
|
|
||||||
)
|
|
||||||
self.assertEqual("demo", plan.slug)
|
|
||||||
self.assertEqual(2, len(plan.upstreams))
|
|
||||||
self.assertEqual("", plan.internal_network)
|
|
||||||
self.assertEqual("", plan.egress_network)
|
|
||||||
|
|
||||||
def test_prepare_with_no_ssh_writes_minimal_script(self):
|
|
||||||
plan = _StubGate().prepare(
|
|
||||||
fixture_minimal().bottles["dev"], "demo", self.stage
|
|
||||||
)
|
|
||||||
self.assertEqual((), plan.upstreams)
|
|
||||||
content = plan.entrypoint_script.read_text()
|
|
||||||
self.assertNotIn("socat", content)
|
|
||||||
self.assertIn("wait", content)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
Reference in New Issue
Block a user