From dba48706de82165768cf61152581f7c65df01e47 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 21 Jul 2026 20:55:24 -0400 Subject: [PATCH] fix(macos): put the gateway address in the Docker CLI proxy config This is the layer that actually decides it, and it was in the script from the start: ~/.docker/config.json's proxies block is what the Docker CLI copies into every container it starts. Being client-side it beats the podman service outright, which is why containers.conf `env`, http_proxy=false, and the service environment each looked correct on disk and changed nothing a nested container saw. Also corrects the comment on the service-env substitution, which claimed to be the one place the address sticks. It is not; it covers podman's own pulls and non-CLI API clients. Co-Authored-By: Claude Opus 4.8 --- .../macos_container/nested-containers-init.sh | 31 +++++++++++++------ tests/unit/test_macos_nested_containers.py | 25 ++++++++++----- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/bot_bottle/backend/macos_container/nested-containers-init.sh b/bot_bottle/backend/macos_container/nested-containers-init.sh index 41f29d2..e4c3425 100755 --- a/bot_bottle/backend/macos_container/nested-containers-init.sh +++ b/bot_bottle/backend/macos_container/nested-containers-init.sh @@ -156,16 +156,28 @@ PY # Registry pulls egress through the bottle's proxy like everything else. The # token-bearing proxy URL is already in the agent's environment; persisting it # inside this disposable VM does not broaden its authority. -python3 - <<'PY' +# +# This file is also what the Docker CLI copies into every container it starts, +# and being client-side it beats anything the podman service does — it is why +# containers.conf `env`, `http_proxy=false`, and the service's own environment +# all failed to change what a nested container saw. The address goes in here +# for the same reason it goes everywhere else: `bot-bottle-gateway` resolves +# in the bottle, never inside a nested container. +GATEWAY_NAME="$GATEWAY_NAME" GATEWAY_IP="$gateway_ip" python3 - <<'PY' import json import os from pathlib import Path +name = os.environ["GATEWAY_NAME"] +ip = os.environ["GATEWAY_IP"] + proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy", "") +# NO_PROXY keeps the name: it is matched against what a client asks for, and +# code inside a nested container still says "bot-bottle-gateway". no_proxy = os.environ.get("NO_PROXY") or os.environ.get("no_proxy", "") config = {"proxies": {"default": { - "httpProxy": proxy, - "httpsProxy": proxy, + "httpProxy": proxy.replace(name, ip), + "httpsProxy": proxy.replace(name, ip), "noProxy": no_proxy, }}} path = Path.home() / ".docker" / "config.json" @@ -178,14 +190,13 @@ if docker info >/dev/null 2>&1; then exit 0 fi -# The service's own environment is the last word on what a nested container -# gets. podman's Docker-compatible API injects proxy settings from the daemon -# environment *after* containers.conf is applied, so neither the `env` list -# below nor `http_proxy=false` can override it — the same blind spot the -# compat path has for `hosts_file`. Substituting here, before the service -# starts, is therefore the only place the address actually sticks. +# Belt to the ~/.docker/config.json braces above, which is what actually +# decides this for `docker run`. The service environment is what podman falls +# back to for anything the CLI does not stamp — its own registry pulls, and +# containers created through the API by something other than the Docker CLI. +# Cheap, and it keeps the address consistent across both paths. # -# Assigned via command substitution, never echoed: these carry the bottle's +# Assigned via parameter expansion, never echoed: these carry the bottle's # identity token. for var in HTTP_PROXY HTTPS_PROXY http_proxy https_proxy; do eval "value=\${$var:-}" diff --git a/tests/unit/test_macos_nested_containers.py b/tests/unit/test_macos_nested_containers.py index e81fdad..c771500 100644 --- a/tests/unit/test_macos_nested_containers.py +++ b/tests/unit/test_macos_nested_containers.py @@ -174,12 +174,19 @@ class TestInitScript(unittest.TestCase): self.assertIn('$2 == name { print $1; exit }', self.script) self.assertIn('value.replace(name, ip)', self.script) + def test_docker_cli_proxy_config_uses_the_address(self) -> None: + """The Docker CLI copies ~/.docker/config.json's proxies block into + every container it starts. Being client-side it beats the podman + service, so this — not containers.conf — is what decides whether a + nested container can reach the proxy.""" + block = self.script[self.script.index('"proxies"'):] + self.assertIn('"httpProxy": proxy.replace(name, ip)', block) + self.assertIn('"httpsProxy": proxy.replace(name, ip)', block) + self.assertIn('"noProxy": no_proxy', block) + def test_substitutes_the_address_into_the_service_environment(self) -> None: - """The compat API injects proxy settings from the daemon environment - after containers.conf is applied, so neither the env list nor - http_proxy=false can override it — the same blind spot it has for - hosts_file. Substituting before the service starts is the only place - the address sticks.""" + """Covers what the Docker CLI does not stamp: podman's own registry + pulls, and containers created through the API by another client.""" launch = self.script[self.script.index("podman system service"):] self.assertNotIn("$GATEWAY_NAME", launch) # substitution precedes it setup = self.script[:self.script.index("podman system service")] @@ -196,9 +203,11 @@ class TestInitScript(unittest.TestCase): def test_keeps_the_gateway_name_in_no_proxy(self) -> None: """NO_PROXY is matched against what a client asks for, and code inside a nested container still says bot-bottle-gateway.""" - no_proxy = self.script[self.script.index('for var in ("NO_PROXY"'):] - self.assertIn('entries.append(f"{var}={value}")', no_proxy) - self.assertNotIn("replace(name, ip)", no_proxy) + start = self.script.index('for var in ("NO_PROXY"') + loop = self.script[start:self.script.index("path = Path(", start)] + self.assertIn('entries.append(f"{var}={value}")', loop) + self.assertNotIn("replace(name, ip)", loop) + self.assertIn('"noProxy": no_proxy', self.script) def test_fails_closed_without_a_gateway_address(self) -> None: self.assertIn('[ -n "$gateway_ip" ] || {', self.script)