diff --git a/claude_bottle/backend/docker/compose.py b/claude_bottle/backend/docker/compose.py index cc3c047..e09614c 100644 --- a/claude_bottle/backend/docker/compose.py +++ b/claude_bottle/backend/docker/compose.py @@ -49,8 +49,9 @@ from ...egress import ( EGRESS_HOSTNAME, EGRESS_ROUTES_IN_CONTAINER, ) +from ...git_gate import GIT_GATE_HOSTNAME, git_gate_aggregate_extra_hosts from ...log import die, warn -from ...git_gate import git_gate_aggregate_extra_hosts +from ...pipelock import PIPELOCK_HOSTNAME from ...supervise import ( CURRENT_CONFIG_DIR_IN_AGENT, QUEUE_DIR_IN_CONTAINER, @@ -62,20 +63,17 @@ from .bottle_plan import DockerBottlePlan from .egress import ( EGRESS_CA_IN_CONTAINER, EGRESS_PIPELOCK_CA_IN_CONTAINER, - egress_container_name, ) from .git_gate import ( GIT_GATE_ACCESS_HOOK_IN_CONTAINER, GIT_GATE_CREDS_DIR_IN_CONTAINER, GIT_GATE_ENTRYPOINT_IN_CONTAINER, GIT_GATE_HOOK_IN_CONTAINER, - git_gate_container_name, ) from .pipelock import ( PIPELOCK_CA_CERT_IN_CONTAINER, PIPELOCK_CA_KEY_IN_CONTAINER, PIPELOCK_PORT, - pipelock_container_name, ) from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH from .sidecar_bundle import ( @@ -232,17 +230,15 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]: "read_only": False, }) - # Internal-network aliases: every shortname + long-form legacy - # name routes to the bundle so the agent's HTTPS_PROXY URL - # (which references either `pipelock` or `egress`) keeps - # resolving without an agent-side change. + # Internal-network aliases: the agent reaches each daemon through + # its short name (pipelock / egress / git-gate / supervise) which + # the bundle answers as if it were the daemon itself. internal_aliases = [ - pipelock_container_name(plan.slug), + PIPELOCK_HOSTNAME, EGRESS_HOSTNAME, - egress_container_name(plan.slug), ] if gp.upstreams: - internal_aliases.append(git_gate_container_name(plan.slug)) + internal_aliases.append(GIT_GATE_HOSTNAME) if sp is not None: internal_aliases.append(SUPERVISE_HOSTNAME) @@ -328,7 +324,7 @@ def _agent_proxy_url(plan: DockerBottlePlan) -> str: if plan.egress_plan.routes: from .egress import EGRESS_PORT return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}" - return f"http://{pipelock_container_name(plan.slug)}:{PIPELOCK_PORT}" + return f"http://{PIPELOCK_HOSTNAME}:{PIPELOCK_PORT}" def _agent_no_proxy(plan: DockerBottlePlan) -> str: diff --git a/claude_bottle/backend/docker/egress.py b/claude_bottle/backend/docker/egress.py index 925161b..beeb47c 100644 --- a/claude_bottle/backend/docker/egress.py +++ b/claude_bottle/backend/docker/egress.py @@ -32,14 +32,6 @@ EGRESS_PIPELOCK_CA_IN_CONTAINER = ( ) -def egress_container_name(slug: str) -> str: - """The legacy per-sidecar container name. Kept as a function so - the renderer can register it as a docker-network alias on the - bundle — any code still referring to `claude-bottle-egress-` - resolves to the bundle's IP.""" - return f"claude-bottle-egress-{slug}" - - def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]: """Mint the per-bottle egress MITM CA via host `openssl req`. diff --git a/claude_bottle/backend/docker/git_gate.py b/claude_bottle/backend/docker/git_gate.py index 227b524..19e397b 100644 --- a/claude_bottle/backend/docker/git_gate.py +++ b/claude_bottle/backend/docker/git_gate.py @@ -1,9 +1,8 @@ -"""Docker-side git-gate helpers: in-container paths the renderer's -bind-mounts target, port pin, and container naming. The -prepare-time entrypoint/hook render lives on the platform-neutral -`GitGate` ABC — backends instantiate it directly. The git-gate -daemon's container lifecycle is owned by the sidecar bundle -(PRD 0024).""" +"""Docker-side git-gate constants: in-container paths the renderer's +bind-mounts target + the listening port. The prepare-time entrypoint +/ hook render lives on the platform-neutral `GitGate` ABC — backends +instantiate it directly. The git-gate daemon's container lifecycle +is owned by the sidecar bundle (PRD 0024).""" from __future__ import annotations @@ -15,18 +14,3 @@ GIT_GATE_CREDS_DIR_IN_CONTAINER = "/git-gate/creds" # git daemon's default listening port. GIT_GATE_PORT = 9418 - - -def git_gate_container_name(slug: str) -> str: - """The legacy per-sidecar container name. Kept as a function so - the renderer can register it as a docker-network alias on the - bundle — any code still dialing `claude-bottle-git-gate-` - resolves to the bundle's IP.""" - return f"claude-bottle-git-gate-{slug}" - - -def git_gate_host(slug: str) -> str: - """The hostname the agent's git client connects to. Resolves via - the bundle's network alias to the bundle container, where the - git-gate daemon listens on GIT_GATE_PORT.""" - return git_gate_container_name(slug) diff --git a/claude_bottle/backend/docker/launch.py b/claude_bottle/backend/docker/launch.py index 5b48711..14b3442 100644 --- a/claude_bottle/backend/docker/launch.py +++ b/claude_bottle/backend/docker/launch.py @@ -65,7 +65,7 @@ from .compose import ( ) from .egress import egress_tls_init from .pipelock import ( - pipelock_proxy_url, + BUNDLE_LOCAL_PIPELOCK_URL, pipelock_tls_init, ) @@ -149,7 +149,7 @@ def launch( mitmproxy_ca_host_path=egress_ca_host, mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, pipelock_ca_host_path=ca_cert_host, - pipelock_proxy_url=pipelock_proxy_url(plan.slug), + pipelock_proxy_url=BUNDLE_LOCAL_PIPELOCK_URL, ) supervise_plan = plan.supervise_plan if supervise_plan is not None: diff --git a/claude_bottle/backend/docker/pipelock.py b/claude_bottle/backend/docker/pipelock.py index 3479f84..c0a9821 100644 --- a/claude_bottle/backend/docker/pipelock.py +++ b/claude_bottle/backend/docker/pipelock.py @@ -35,12 +35,11 @@ PIPELOCK_IMAGE = os.environ.get( PIPELOCK_PORT = os.environ.get("CLAUDE_BOTTLE_PIPELOCK_PORT", "8888") -def pipelock_container_name(slug: str) -> str: - return f"claude-bottle-pipelock-{slug}" - - -def pipelock_proxy_url(slug: str) -> str: - return f"http://{pipelock_container_name(slug)}:{PIPELOCK_PORT}" +# The URL egress dials for its upstream HTTPS_PROXY. egress and +# pipelock share the same container's network namespace inside the +# sidecar bundle, so loopback reaches pipelock directly — no docker +# DNS aliases involved. +BUNDLE_LOCAL_PIPELOCK_URL = f"http://127.0.0.1:{PIPELOCK_PORT}" def pipelock_tls_init(stage_dir: Path) -> tuple[Path, Path]: diff --git a/claude_bottle/backend/docker/prepare.py b/claude_bottle/backend/docker/prepare.py index 0d2314c..1738fd0 100644 --- a/claude_bottle/backend/docker/prepare.py +++ b/claude_bottle/backend/docker/prepare.py @@ -23,8 +23,6 @@ from ...supervise import Supervise from .. import BottleSpec from . import util as docker_mod from .bottle_plan import DockerBottlePlan -from .egress import egress_container_name -from .git_gate import git_gate_container_name from .bottle_state import ( BottleMetadata, agent_state_dir, @@ -39,7 +37,7 @@ from .bottle_state import ( supervise_state_dir, write_metadata, ) -from .pipelock import pipelock_container_name +from .sidecar_bundle import sidecar_bundle_container_name def resolve_plan( @@ -126,28 +124,18 @@ def resolve_plan( f"clean up old containers with 'docker rm -f '" ) - # Probe sidecar container names for orphans from a previous run. - # Sidecar names are deterministic from the slug; an orphan would - # surface as a docker-create conflict deep inside launch() with no - # actionable hint. Fail fast here with a cleanup pointer instead. - # Only probe sidecars this launch will actually try to create: - # pipelock always; git-gate when bottle.git is non-empty; - # egress when bottle.egress.routes is non-empty. - sidecar_probes: list[tuple[str, str]] = [ - ("pipelock", pipelock_container_name(slug)), - ] - if bottle.git: - sidecar_probes.append(("git-gate", git_gate_container_name(slug))) - if bottle.egress.routes: - sidecar_probes.append(("egress", egress_container_name(slug))) - for label, sidecar_name in sidecar_probes: - if docker_mod.container_exists(sidecar_name): - die( - f"{label} sidecar container '{sidecar_name}' already exists. " - f"This is an orphan from a previous run; clean it up with " - f"'./cli.py cleanup' (or 'docker rm -f {sidecar_name}') and " - f"retry." - ) + # Probe the sidecar-bundle container name for an orphan from a + # previous run. Otherwise a stale bundle surfaces as a + # docker-create conflict deep inside launch() with no actionable + # hint; failing fast here points at the cleanup command. + bundle_name = sidecar_bundle_container_name(slug) + if docker_mod.container_exists(bundle_name): + die( + f"sidecar bundle container '{bundle_name}' already exists. " + f"This is an orphan from a previous run; clean it up with " + f"'./cli.py cleanup' (or 'docker rm -f {bundle_name}') and " + f"retry." + ) # PRD 0018 chunk 2: prepare-time scratch files live under # ~/.claude-bottle/state/// so chunk 3's compose diff --git a/claude_bottle/backend/docker/provision/git.py b/claude_bottle/backend/docker/provision/git.py index 7dc91e0..b9fca81 100644 --- a/claude_bottle/backend/docker/provision/git.py +++ b/claude_bottle/backend/docker/provision/git.py @@ -19,11 +19,11 @@ import os import subprocess from pathlib import Path +from ....git_gate import GIT_GATE_HOSTNAME from ....log import info from ....manifest import GitEntry from .. import util as docker_mod from ..bottle_plan import DockerBottlePlan -from ..git_gate import git_gate_host def provision_git(plan: DockerBottlePlan, target: str) -> None: @@ -56,7 +56,7 @@ def _provision_cwd_git(plan: DockerBottlePlan, target: str) -> None: ) -def render_git_gate_gitconfig(slug: str, entries: tuple[GitEntry, ...]) -> str: +def render_git_gate_gitconfig(entries: tuple[GitEntry, ...]) -> str: """Render the ~/.gitconfig content for git-gate `insteadOf` rewrites. Pure host-side, no docker; exposed for tests. @@ -64,7 +64,6 @@ def render_git_gate_gitconfig(slug: str, entries: tuple[GitEntry, ...]) -> str: cleanly without conditional formatting at the call site.""" if not entries: return "" - gate = git_gate_host(slug) out = [ "# claude-bottle git-gate (PRD 0008): every git operation against\n", "# a declared upstream routes through the gate, which mirrors\n", @@ -72,7 +71,7 @@ def render_git_gate_gitconfig(slug: str, entries: tuple[GitEntry, ...]) -> str: "# fetch-from-upstream-before-every-upload-pack via access-hook).\n", ] for entry in entries: - out.append(f'[url "git://{gate}/{entry.Name}.git"]\n') + out.append(f'[url "git://{GIT_GATE_HOSTNAME}/{entry.Name}.git"]\n') out.append(f"\tinsteadOf = {entry.Upstream}\n") return "".join(out) @@ -87,7 +86,7 @@ def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None: container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") container_gitconfig = f"{container_home}/.gitconfig" - content = render_git_gate_gitconfig(plan.slug, bottle.git) + content = render_git_gate_gitconfig(bottle.git) config_file = plan.stage_dir / "agent_gitconfig" config_file.write_text(content) config_file.chmod(0o600) diff --git a/claude_bottle/backend/smolmachines/launch.py b/claude_bottle/backend/smolmachines/launch.py index 3c6998e..1f55792 100644 --- a/claude_bottle/backend/smolmachines/launch.py +++ b/claude_bottle/backend/smolmachines/launch.py @@ -42,19 +42,13 @@ from ..docker.git_gate import ( GIT_GATE_ENTRYPOINT_IN_CONTAINER, GIT_GATE_HOOK_IN_CONTAINER, ) -from ..docker.pipelock import pipelock_tls_init +from ..docker.pipelock import BUNDLE_LOCAL_PIPELOCK_URL, pipelock_tls_init from . import sidecar_bundle as _bundle from . import smolvm as _smolvm from .bottle import SmolmachinesBottle from .bottle_plan import SmolmachinesBottlePlan -# Pipelock's upstream when egress is in the bundle: localhost on -# the bundle's own loopback. No docker DNS aliases involved — -# pipelock + egress share the same container's network namespace. -_BUNDLE_LOCAL_PIPELOCK_URL = "http://127.0.0.1:8888" - - @contextmanager def launch( plan: SmolmachinesBottlePlan, @@ -95,7 +89,7 @@ def launch( # On smolmachines, egress's upstream is pipelock # on the bundle's localhost — they're in the same # container's network namespace. - pipelock_proxy_url=_BUNDLE_LOCAL_PIPELOCK_URL, + pipelock_proxy_url=BUNDLE_LOCAL_PIPELOCK_URL, ) plan = dataclasses.replace( plan, proxy_plan=proxy_plan, egress_plan=egress_plan, diff --git a/claude_bottle/git_gate.py b/claude_bottle/git_gate.py index ae24fe9..95d2a1d 100644 --- a/claude_bottle/git_gate.py +++ b/claude_bottle/git_gate.py @@ -38,6 +38,11 @@ from .log import die from .manifest import Bottle +# Short network alias for git-gate inside the sidecar bundle. The +# agent's `.gitconfig` insteadOf rewrites resolve through this name. +GIT_GATE_HOSTNAME = "git-gate" + + def _empty_str_map() -> dict[str, str]: return {} diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index 64587ae..96c779f 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -55,6 +55,12 @@ PIPELOCK_CA_CERT_IN_CONTAINER = "/etc/pipelock-ca.pem" PIPELOCK_CA_KEY_IN_CONTAINER = "/etc/pipelock-ca-key.pem" +# Short network alias for pipelock inside the sidecar bundle. The +# agent's HTTP_PROXY (when no egress is declared) and any in-bundle +# consumer's URL both reference this name. +PIPELOCK_HOSTNAME = "pipelock" + + # --- Allowlist resolution -------------------------------------------------- @@ -329,10 +335,10 @@ class PipelockProxy: `slug` is the agent-derived identifier (lowercased, hyphen-normalized) used as the suffix in every per-agent - resource name — the agent container, the pipelock container - (`claude-bottle-pipelock-`), the internal/egress - networks. It's stored on the returned plan so the backend's - launch step can derive the sidecar's container name. + resource name — the agent container, the sidecar bundle + container, the internal/egress networks. It's stored on the + returned plan so the backend's launch step can derive those + names. The CA paths the YAML references are the module-level in-container constants. The host-side counterparts are diff --git a/tests/integration/test_sandbox_escape.py b/tests/integration/test_sandbox_escape.py index bd6c8ed..12f218d 100644 --- a/tests/integration/test_sandbox_escape.py +++ b/tests/integration/test_sandbox_escape.py @@ -402,7 +402,7 @@ class TestSandboxEscape(unittest.TestCase): ("aws", "TEST_SECRET_AWS"), ("generic", "TEST_SECRET_GENERIC"), ] - gate_host = f"claude-bottle-git-gate-{self._identity}" + gate_host = "git-gate" for name, var in shapes: with self.subTest(secret=name): diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index e5af704..2a0ee95 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -112,7 +112,7 @@ def _egress_plan(routes: tuple[EgressRoute, ...] = ()) -> EgressPlan: mitmproxy_ca_host_path=STATE / "egress-ca" / "mitmproxy-ca.pem", mitmproxy_ca_cert_only_host_path=STATE / "egress-ca" / "ca.pem", pipelock_ca_host_path=STATE / "pipelock-ca" / "ca.pem", - pipelock_proxy_url=f"http://claude-bottle-pipelock-{SLUG}:8888", + pipelock_proxy_url="http://127.0.0.1:8888", ) @@ -221,7 +221,7 @@ class TestAgentAlwaysPresent(unittest.TestCase): proxy_lines = [e for e in env if e.startswith("HTTPS_PROXY=")] self.assertEqual(1, len(proxy_lines)) self.assertEqual( - f"HTTPS_PROXY=http://claude-bottle-pipelock-{SLUG}:8888", + "HTTPS_PROXY=http://pipelock:8888", proxy_lines[0], ) @@ -304,12 +304,11 @@ class TestSidecarBundleShape(unittest.TestCase): def test_internal_aliases_cover_pipelock_and_egress_shortnames(self): # The agent's HTTPS_PROXY url references either `egress` or - # `pipelock` (long form). Both must resolve to the bundle. + # `pipelock`. Both must resolve to the bundle. sc = self._render()["services"]["sidecars"] aliases = set(sc["networks"]["internal"]["aliases"]) self.assertIn("egress", aliases) - self.assertIn(f"claude-bottle-pipelock-{SLUG}", aliases) - self.assertIn(f"claude-bottle-egress-{SLUG}", aliases) + self.assertIn("pipelock", aliases) def test_internal_aliases_omit_inactive_sidecars(self): # With no git-gate / supervise, those names are NOT aliased @@ -317,13 +316,13 @@ class TestSidecarBundleShape(unittest.TestCase): # listening inside the bundle. sc = self._render()["services"]["sidecars"] aliases = set(sc["networks"]["internal"]["aliases"]) - self.assertNotIn(f"claude-bottle-git-gate-{SLUG}", aliases) + self.assertNotIn("git-gate", aliases) self.assertNotIn("supervise", aliases) def test_internal_aliases_include_active_sidecars(self): sc = self._render(with_git=True, supervise=True)["services"]["sidecars"] aliases = set(sc["networks"]["internal"]["aliases"]) - self.assertIn(f"claude-bottle-git-gate-{SLUG}", aliases) + self.assertIn("git-gate", aliases) self.assertIn("supervise", aliases) def test_daemons_csv_lists_only_active(self): diff --git a/tests/unit/test_provision_git.py b/tests/unit/test_provision_git.py index 8c2f6af..59c60df 100644 --- a/tests/unit/test_provision_git.py +++ b/tests/unit/test_provision_git.py @@ -9,15 +9,15 @@ from tests.fixtures import fixture_minimal, fixture_with_git class TestGitGateGitconfigRender(unittest.TestCase): def test_empty_entries_renders_nothing(self): bottle = fixture_minimal().bottles["dev"] - self.assertEqual("", render_git_gate_gitconfig("demo", bottle.git)) + self.assertEqual("", render_git_gate_gitconfig(bottle.git)) def test_one_block_per_entry(self): bottle = fixture_with_git().bottles["dev"] - out = render_git_gate_gitconfig("demo", bottle.git) + out = render_git_gate_gitconfig(bottle.git) # Both entries map to a [url ...] block keyed on the gate's - # container hostname (claude-bottle-git-gate-). + # short network alias (`git-gate`) inside the sidecar bundle. self.assertIn( - '[url "git://claude-bottle-git-gate-demo/claude-bottle.git"]', + '[url "git://git-gate/claude-bottle.git"]', out, ) self.assertIn( @@ -25,7 +25,7 @@ class TestGitGateGitconfigRender(unittest.TestCase): "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git", out, ) - self.assertIn('[url "git://claude-bottle-git-gate-demo/foo.git"]', out) + self.assertIn('[url "git://git-gate/foo.git"]', out) self.assertIn( "\tinsteadOf = ssh://git@github.com/didericis/foo.git", out, @@ -37,7 +37,7 @@ class TestGitGateGitconfigRender(unittest.TestCase): # gate push and leave fetch on the original URL — exactly the # v1 design we've moved past. bottle = fixture_with_git().bottles["dev"] - out = render_git_gate_gitconfig("demo", bottle.git) + out = render_git_gate_gitconfig(bottle.git) self.assertIn("\tinsteadOf", out) self.assertNotIn("pushInsteadOf", out)