From 4c6610e2220c93751b77b31aca6891efd7d5d4a8 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 12 May 2026 23:05:58 -0400 Subject: [PATCH 1/3] feat(manifest): add ExtraHosts to bottle.git entries Optional `ExtraHosts: { hostname: ip }` map per git entry. The docker backend will surface these to the gate sidecar via --add-host so the gate can resolve upstreams whose default container DNS doesn't point at the reachable IP (e.g. Tailscale-only hosts with a public DNS A record pointed elsewhere). The agent-side insteadOf rewrite still keys off the original hostname, so the manifest's Upstream URL stays human-readable. --- claude_bottle/manifest.py | 32 ++++++++++++++++++++++ tests/unit/test_manifest_git.py | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/claude_bottle/manifest.py b/claude_bottle/manifest.py index a7910b3..f5eb2f9 100644 --- a/claude_bottle/manifest.py +++ b/claude_bottle/manifest.py @@ -89,6 +89,13 @@ class GitEntry: upstream after gitleaks passes. The agent itself never holds the upstream credential. + `ExtraHosts` is an optional `{hostname: ip}` map injected into the + gate container's `/etc/hosts` via `--add-host`. Use it when the + Upstream's hostname isn't resolvable from the gate (e.g. a + Tailscale-only host whose public DNS A record points elsewhere): + the agent's `insteadOf` rewrite still matches the original + hostname, but the gate routes to the right IP. + The Upstream URL is parsed once at construction and the pieces are stashed in the `Upstream*` fields so the git-gate render step doesn't have to re-parse.""" @@ -97,6 +104,7 @@ class GitEntry: Upstream: str IdentityFile: str KnownHostKey: str = "" + ExtraHosts: Mapping[str, str] = field(default_factory=_empty_str_dict) UpstreamUser: str = "" UpstreamHost: str = "" UpstreamPort: str = "" @@ -124,6 +132,9 @@ class GitEntry: d.get("KnownHostKey"), f"bottle '{bottle_name}' git '{name}' KnownHostKey", ) + extra_hosts = _opt_extra_hosts( + d.get("ExtraHosts"), f"bottle '{bottle_name}' git '{name}' ExtraHosts" + ) user, host, port, path = _parse_git_upstream( upstream, f"bottle '{bottle_name}' git '{name}' Upstream" ) @@ -132,6 +143,7 @@ class GitEntry: Upstream=upstream, IdentityFile=ident, KnownHostKey=khk, + ExtraHosts=extra_hosts, UpstreamUser=user, UpstreamHost=host, UpstreamPort=port, @@ -435,6 +447,26 @@ def _opt_port(value: object, label: str) -> str: die(f"{label} must be a string or number (was {type(value).__name__})") +def _opt_extra_hosts(value: object, label: str) -> dict[str, str]: + """Validate a `{hostname: ip}` object and return a plain dict. None + yields an empty dict so callers can treat ExtraHosts as always + present. IP format is not checked here; docker validates at + `--add-host` time.""" + if value is None: + return {} + obj = _as_json_object(value, label) + out: dict[str, str] = {} + for host, ip in obj.items(): + if not host: + die(f"{label} contains an empty hostname key") + if not isinstance(ip, str): + die(f"{label}['{host}'] must be a string (was {type(ip).__name__})") + if not ip: + die(f"{label}['{host}'] must be a non-empty string") + out[host] = ip + return out + + def _parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]: """Parse `ssh://user@host[:port]/path` into (user, host, port, path). Dies if `url` doesn't match the ssh:// shape v1 supports. Default diff --git a/tests/unit/test_manifest_git.py b/tests/unit/test_manifest_git.py index 87cc1bc..cf22fc8 100644 --- a/tests/unit/test_manifest_git.py +++ b/tests/unit/test_manifest_git.py @@ -111,6 +111,53 @@ class TestGitEntryParsing(unittest.TestCase): }])) +class TestGitEntryExtraHosts(unittest.TestCase): + def test_extra_hosts_defaults_to_empty(self): + m = Manifest.from_json_obj(_manifest([{ + "Name": "foo", + "Upstream": "ssh://git@github.com/foo.git", + "IdentityFile": "/dev/null", + }])) + self.assertEqual({}, dict(m.bottles["dev"].git[0].ExtraHosts)) + + def test_extra_hosts_parses_host_to_ip_map(self): + m = Manifest.from_json_obj(_manifest([{ + "Name": "claude-bottle", + "Upstream": "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git", + "IdentityFile": "/dev/null", + "ExtraHosts": {"gitea.dideric.is": "100.78.141.42"}, + }])) + eh = dict(m.bottles["dev"].git[0].ExtraHosts) + self.assertEqual({"gitea.dideric.is": "100.78.141.42"}, eh) + + def test_extra_hosts_must_be_object(self): + with self.assertRaises(Die): + Manifest.from_json_obj(_manifest([{ + "Name": "foo", + "Upstream": "ssh://git@github.com/foo.git", + "IdentityFile": "/dev/null", + "ExtraHosts": ["gitea.dideric.is", "100.78.141.42"], + }])) + + def test_extra_hosts_ip_must_be_string(self): + with self.assertRaises(Die): + Manifest.from_json_obj(_manifest([{ + "Name": "foo", + "Upstream": "ssh://git@github.com/foo.git", + "IdentityFile": "/dev/null", + "ExtraHosts": {"gitea.dideric.is": 100}, + }])) + + def test_extra_hosts_empty_ip_dies(self): + with self.assertRaises(Die): + Manifest.from_json_obj(_manifest([{ + "Name": "foo", + "Upstream": "ssh://git@github.com/foo.git", + "IdentityFile": "/dev/null", + "ExtraHosts": {"gitea.dideric.is": ""}, + }])) + + class TestGitEntryCrossValidation(unittest.TestCase): def test_duplicate_name_dies(self): with self.assertRaises(Die): From 102e29ee774b54d23959d05a9265a3b32cb0dd2e Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 12 May 2026 23:06:08 -0400 Subject: [PATCH 2/3] feat(git-gate): plumb ExtraHosts through to docker --add-host GitGateUpstream carries each entry's extra_hosts; a new git_gate_aggregate_extra_hosts() merges them into one map for the gate container's /etc/hosts. Same host -> same IP is harmless duplication; same host -> different IPs is a manifest bug (/etc/hosts is per-container, not per-upstream) and dies with the conflicting upstream names. DockerGitGate.start passes one --add-host host:ip per merged entry on docker create. Empty map (the default) emits no flags and is a no-op for bottles that don't need DNS overrides. --- claude_bottle/backend/docker/git_gate.py | 11 +++- claude_bottle/git_gate.py | 43 ++++++++++++- tests/unit/test_git_gate.py | 81 ++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 4 deletions(-) diff --git a/claude_bottle/backend/docker/git_gate.py b/claude_bottle/backend/docker/git_gate.py index e3c11a3..5ad312c 100644 --- a/claude_bottle/backend/docker/git_gate.py +++ b/claude_bottle/backend/docker/git_gate.py @@ -8,7 +8,12 @@ import os import subprocess from pathlib import Path -from ...git_gate import GitGate, GitGatePlan, git_gate_known_hosts_line +from ...git_gate import ( + GitGate, + GitGatePlan, + git_gate_aggregate_extra_hosts, + git_gate_known_hosts_line, +) from ...log import die, info, warn from ...util import expand_tilde from . import util as docker_mod @@ -101,8 +106,10 @@ class DockerGitGate(GitGate): "docker", "create", "--name", name, "--network", plan.internal_network, - GIT_GATE_IMAGE, ] + for host, ip in git_gate_aggregate_extra_hosts(plan.upstreams).items(): + create_args.extend(["--add-host", f"{host}:{ip}"]) + create_args.append(GIT_GATE_IMAGE) if subprocess.run( create_args, stdout=subprocess.DEVNULL, diff --git a/claude_bottle/git_gate.py b/claude_bottle/git_gate.py index 6827178..e95c63c 100644 --- a/claude_bottle/git_gate.py +++ b/claude_bottle/git_gate.py @@ -30,12 +30,18 @@ backend-specific and lives on concrete subclasses (see from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path +from typing import Mapping +from .log import die from .manifest import Bottle +def _empty_str_map() -> dict[str, str]: + return {} + + @dataclass(frozen=True) class GitGateUpstream: """One bare repo on the gate. `name` drives the bare-repo path @@ -47,7 +53,12 @@ class GitGateUpstream: `identity_file` is the host-side absolute path the gate's start step will docker-cp into the container. `known_host_key` is the KnownHostKey string from the manifest; the gate's start step - materialises it into a known_hosts file if non-empty.""" + materialises it into a known_hosts file if non-empty. + + `extra_hosts` is a `{hostname: ip}` map the backend injects into + the gate container's `/etc/hosts` via `--add-host` so the gate + can resolve upstream hostnames that aren't reachable via the + container's default DNS (e.g. Tailscale-only hosts).""" name: str upstream_url: str @@ -55,6 +66,7 @@ class GitGateUpstream: upstream_port: str identity_file: str known_host_key: str + extra_hosts: Mapping[str, str] = field(default_factory=_empty_str_map) @dataclass(frozen=True) @@ -92,11 +104,38 @@ def git_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[GitGateUpstream, ...] upstream_port=e.UpstreamPort, identity_file=e.IdentityFile, known_host_key=e.KnownHostKey, + extra_hosts=dict(e.ExtraHosts), ) for e in bottle.git ) +def git_gate_aggregate_extra_hosts( + upstreams: tuple[GitGateUpstream, ...], +) -> dict[str, str]: + """Merge every upstream's `extra_hosts` into a single + `{hostname: ip}` map for `--add-host` on the gate container. Two + entries naming the same hostname with different IPs is a manifest + bug — the gate has one /etc/hosts — so die loudly with the + conflicting names rather than silently picking one.""" + merged: dict[str, str] = {} + source: dict[str, str] = {} + for u in upstreams: + for host, ip in u.extra_hosts.items(): + existing = merged.get(host) + if existing is None: + merged[host] = ip + source[host] = u.name + elif existing != ip: + die( + f"git-gate ExtraHosts conflict: '{host}' maps to " + f"'{existing}' in upstream '{source[host]}' and to " + f"'{ip}' in upstream '{u.name}'. The gate has one " + f"/etc/hosts; pick one IP." + ) + return merged + + def git_gate_known_hosts_line(host: str, port: str, key: str) -> str: """Format `host[:port] key` for OpenSSH's known_hosts. Non-default ports use the bracketed `[host]:port` form (the form OpenSSH writes diff --git a/tests/unit/test_git_gate.py b/tests/unit/test_git_gate.py index ff2d402..eabb66a 100644 --- a/tests/unit/test_git_gate.py +++ b/tests/unit/test_git_gate.py @@ -9,12 +9,15 @@ from claude_bottle.git_gate import ( GitGate, GitGatePlan, GitGateUpstream, + git_gate_aggregate_extra_hosts, git_gate_known_hosts_line, git_gate_render_access_hook, git_gate_render_entrypoint, git_gate_render_hook, git_gate_upstreams_for_bottle, ) +from claude_bottle.log import Die +from claude_bottle.manifest import Manifest from tests.fixtures import fixture_minimal, fixture_with_git @@ -43,6 +46,84 @@ class TestUpstreamsForBottle(unittest.TestCase): self.assertEqual((), git_gate_upstreams_for_bottle(bottle)) +class TestExtraHostsPlumbing(unittest.TestCase): + def test_upstream_carries_extra_hosts_from_manifest(self): + m = Manifest.from_json_obj({ + "bottles": { + "dev": { + "git": [{ + "Name": "claude-bottle", + "Upstream": "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git", + "IdentityFile": "/dev/null", + "ExtraHosts": {"gitea.dideric.is": "100.78.141.42"}, + }], + }, + }, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + }) + ups = git_gate_upstreams_for_bottle(m.bottles["dev"]) + self.assertEqual( + {"gitea.dideric.is": "100.78.141.42"}, dict(ups[0].extra_hosts) + ) + + def test_aggregator_merges_distinct_hostnames(self): + ups = ( + GitGateUpstream( + name="a", upstream_url="", upstream_host="", upstream_port="", + identity_file="", known_host_key="", + extra_hosts={"a.example": "10.0.0.1"}, + ), + GitGateUpstream( + name="b", upstream_url="", upstream_host="", upstream_port="", + identity_file="", known_host_key="", + extra_hosts={"b.example": "10.0.0.2"}, + ), + ) + self.assertEqual( + {"a.example": "10.0.0.1", "b.example": "10.0.0.2"}, + git_gate_aggregate_extra_hosts(ups), + ) + + def test_aggregator_allows_same_host_same_ip(self): + # Two entries listing the same host:ip is harmless duplication, + # not a conflict. The gate's /etc/hosts ends up with one line. + ups = ( + GitGateUpstream( + name="a", upstream_url="", upstream_host="", upstream_port="", + identity_file="", known_host_key="", + extra_hosts={"gitea.dideric.is": "100.78.141.42"}, + ), + GitGateUpstream( + name="b", upstream_url="", upstream_host="", upstream_port="", + identity_file="", known_host_key="", + extra_hosts={"gitea.dideric.is": "100.78.141.42"}, + ), + ) + self.assertEqual( + {"gitea.dideric.is": "100.78.141.42"}, + git_gate_aggregate_extra_hosts(ups), + ) + + def test_aggregator_rejects_conflicting_ips(self): + ups = ( + GitGateUpstream( + name="a", upstream_url="", upstream_host="", upstream_port="", + identity_file="", known_host_key="", + extra_hosts={"gitea.dideric.is": "100.78.141.42"}, + ), + GitGateUpstream( + name="b", upstream_url="", upstream_host="", upstream_port="", + identity_file="", known_host_key="", + extra_hosts={"gitea.dideric.is": "10.0.0.99"}, + ), + ) + with self.assertRaises(Die): + git_gate_aggregate_extra_hosts(ups) + + def test_aggregator_empty_is_empty(self): + self.assertEqual({}, git_gate_aggregate_extra_hosts(())) + + class TestKnownHostsLine(unittest.TestCase): def test_default_port_unbracketed(self): line = git_gate_known_hosts_line("github.com", "22", "ssh-ed25519 AAAA") From 9b7bcc01494ea52b0836deaa1401724146725c3b Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 12 May 2026 23:07:32 -0400 Subject: [PATCH 3/3] docs(git-gate): document ExtraHosts on bottle.git entries - example manifest swaps the gitea-dev bottle from ssh: to git: and shows ExtraHosts pinning gitea.dideric.is to its Tailscale IP - README's git-gate paragraph names the field and the case it solves (upstream resolvable on the host but not from the gate container's default DNS) - PRD 0008's manifest-field bullet mentions the field for parity --- README.md | 10 ++++++++-- claude-bottle.example.json | 11 +++++------ docs/prds/0008-git-gate.md | 7 ++++++- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e877274..56b8876 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,14 @@ that enforces the manifest before it leaves the host. upstream has *now* (fail-closed if unreachable). The agent's `~/.gitconfig` rewrites the real URL to the gate via `insteadOf`, so push, fetch, clone, and pull all route through. The agent - never sees the upstream credential. Brought up only when - `bottle.git` has entries. Design in `docs/prds/0008-git-gate.md`. + never sees the upstream credential. If the upstream's hostname + isn't resolvable from the gate container (e.g. a Tailscale-only + host whose public DNS points elsewhere), pin its IP via + `ExtraHosts: { "": "" }` on the `bottle.git` entry — + the gate's `/etc/hosts` gets the override while the agent's + `insteadOf` rewrite still keys off the original hostname. Brought + up only when `bottle.git` has entries. Design in + `docs/prds/0008-git-gate.md`. When the agent exits, `cli.py` tears down every sidecar that was brought up and the two networks; nothing about a bottle persists diff --git a/claude-bottle.example.json b/claude-bottle.example.json index dbfd93c..d288613 100644 --- a/claude-bottle.example.json +++ b/claude-bottle.example.json @@ -19,14 +19,13 @@ "GIT_AUTHOR_NAME": "Eric Diderich", "NODE_ENV": "development" }, - "ssh": [ + "git": [ { - "Host": "gitea", - "Hostname": "gitea.dideric.is", - "User": "git", - "Port": 30009, + "Name": "claude-bottle", + "Upstream": "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git", "IdentityFile": "/Users/didericis/.ssh/id_ed25519_gitea", - "KnownHostKey": "gitea.dideric.is ssh-ed25519 AAAA..." + "KnownHostKey": "ssh-ed25519 AAAA...", + "ExtraHosts": { "gitea.dideric.is": "100.78.141.42" } } ], "egress": { diff --git a/docs/prds/0008-git-gate.md b/docs/prds/0008-git-gate.md index 47033b5..d24e316 100644 --- a/docs/prds/0008-git-gate.md +++ b/docs/prds/0008-git-gate.md @@ -83,7 +83,12 @@ for a declared upstream: - **Manifest field.** `bottle.git` — a list of git remotes the bottle is allowed to talk to, each with the credential the gate uses to push upstream. The agent gets no parallel `bottle.ssh` - entry for those upstreams. + entry for those upstreams. Each entry may also carry an + `ExtraHosts: { hostname: ip }` map, surfaced to the gate as + `--add-host` so the gate can resolve upstreams whose public DNS + doesn't point at the reachable IP (e.g. Tailscale-only hosts). + The agent-side `insteadOf` rewrite keys off the original hostname, + so the manifest's `Upstream` URL stays human-readable. - **Agent-side URL rewrite.** Provisioner emits `~/.gitconfig` with `[url ""] insteadOf = ` so every git operation against the declared upstream (push, fetch, clone,