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):