From ad100b8a8425a3f756dcecb7f6abcad2af7643ef Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 20 Jul 2026 22:40:38 -0400 Subject: [PATCH] fix(macos): supply proxy env only at exec, so Codex keeps its identity token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `container exec --env` does not override the run-time environment on Apple Container — it appends. The launch path baked a token-less `*_PROXY` into `container run` and relied on the exec-time, token-bearing value superseding it, so the agent's `environ` ended up with two `HTTPS_PROXY` entries, token-less first. Which entry a runtime reads is then luck. Node reads the last, so Claude bottles picked up the token and worked. Rust's `std::env::var` reads the first, so Codex proxied with no identity token at all; `/resolve` requires a matching (source_ip, identity_token) pair and fail-closes, so every request from a Codex bottle was denied — its model calls, its `wss://` reconnects, and its MCP servers alike. The registry row was correct the whole time, which is what made this read as a registration bug. Drop the run-time proxy vars entirely. A token-less proxy URL has no legitimate consumer: the init process is `sleep` and everything that egresses arrives by exec. Its only benefit was a tidy 403 for unattributed callers, which is not worth silently dropping attribution for — and a process that egresses before the exec-time env still fails closed, since the agent network is host-only and the gateway is the only route off it. Also corrects the two comments that asserted the false "exec --env wins" invariant, so the next reader doesn't rebuild the same assumption. Co-Authored-By: Claude Opus 4.8 --- bot_bottle/backend/macos_container/bottle.py | 11 ++++-- bot_bottle/backend/macos_container/launch.py | 35 +++++++++++++------ .../test_macos_container_launch_wiring.py | 28 ++++++++++----- 3 files changed, 52 insertions(+), 22 deletions(-) diff --git a/bot_bottle/backend/macos_container/bottle.py b/bot_bottle/backend/macos_container/bottle.py index bc1180d..2a9d026 100644 --- a/bot_bottle/backend/macos_container/bottle.py +++ b/bot_bottle/backend/macos_container/bottle.py @@ -68,9 +68,14 @@ class MacosContainerBottle(Bottle): # reaches the agent (PRD 0070): registration mints it *after* the # container exists — its source IP is the registration key and Apple # Container assigns that by DHCP — so it cannot be in the run-time env - # the way docker's compose spec does it. `container exec --env` wins - # over the run-time value, so the token-bearing proxy URL set here - # supersedes the token-less one baked in at launch. + # the way docker's compose spec does it. + # + # `container exec --env` does NOT override a run-time value — it + # appends, leaving duplicate entries in the agent's `environ` whose + # resolution is runtime-specific (Node last-wins, Rust first-wins). So + # nothing here may rely on superseding: the proxy vars are supplied + # *only* at exec time and are deliberately absent from the run-time + # env. See `launch._agent_env_entries`. self._exec_env = dict(exec_env or {}) self._closed = False diff --git a/bot_bottle/backend/macos_container/launch.py b/bot_bottle/backend/macos_container/launch.py index 97bef37..056ab78 100644 --- a/bot_bottle/backend/macos_container/launch.py +++ b/bot_bottle/backend/macos_container/launch.py @@ -265,9 +265,15 @@ def _no_proxy(gateway_ip: str) -> str: def _identity_proxy_env( endpoint: GatewayEndpoint, identity_token: str, ) -> dict[str, str]: - """The token-bearing proxy env applied at `container exec`. It supersedes - the token-less run-time value (exec `--env` wins), which is the only way to - get the token in: it does not exist until after the container runs.""" + """The token-bearing proxy env applied at `container exec` — the only way + to get the token in, since it does not exist until after the container + runs (registration keys on the DHCP-assigned address). + + This is the *sole* source of `*_PROXY` for the agent. It deliberately does + not rely on overriding a run-time value: `container exec --env` appends + rather than replaces, so a run-time `HTTPS_PROXY` would survive alongside + this one and first-wins runtimes would read the wrong entry. See + `_agent_env_entries`.""" if not identity_token: return {} url = _proxy_url(endpoint.gateway_ip, identity_token) @@ -317,16 +323,23 @@ def _agent_run_argv( def _agent_env_entries( plan: MacosContainerBottlePlan, endpoint: GatewayEndpoint, ) -> tuple[str, ...]: - # Token-less at run time — the token does not exist yet (see - # `_identity_proxy_env`). Anything egressing before the exec-time override - # is denied by `/resolve`, which is the safe direction. - proxy_url = _proxy_url(endpoint.gateway_ip) + # No `*_PROXY` here on purpose. The token-bearing URL is applied at + # `container exec` (`_identity_proxy_env`), and Apple's `container exec + # --env` **appends** to the run-time environment rather than replacing it: + # setting a token-less value here leaves two `HTTPS_PROXY` entries in the + # agent's `environ`, token-less first. Which one a runtime reads is then + # pure luck — Node takes the last (and worked), Rust's `std::env::var` + # takes the first, so Codex proxied without its identity token and + # `/resolve` fail-closed on every request. + # + # A token-less proxy URL has no legitimate consumer anyway: the init + # process is `sleep` and everything that egresses arrives via exec. Its + # only value was a tidy 403 for unattributed callers, which is not worth + # silently dropping attribution for. Without it a process that egresses + # before the exec-time env still fails closed — the agent network is + # host-only, so there is no route off it except the gateway. no_proxy = _no_proxy(endpoint.gateway_ip) env = [ - f"HTTPS_PROXY={proxy_url}", - f"HTTP_PROXY={proxy_url}", - f"https_proxy={proxy_url}", - f"http_proxy={proxy_url}", f"NO_PROXY={no_proxy}", f"no_proxy={no_proxy}", f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}", diff --git a/tests/unit/test_macos_container_launch_wiring.py b/tests/unit/test_macos_container_launch_wiring.py index a8cc720..f743ac5 100644 --- a/tests/unit/test_macos_container_launch_wiring.py +++ b/tests/unit/test_macos_container_launch_wiring.py @@ -20,7 +20,6 @@ from bot_bottle.backend.macos_container.consolidated_launch import GatewayEndpoi from bot_bottle.backend.macos_container.launch import ( _agent_run_argv, _identity_proxy_env, - _proxy_url, ) from bot_bottle.manifest import ManifestIndex @@ -104,13 +103,26 @@ class TestAgentRunArgv(unittest.TestCase): read back after start.""" self.assertNotIn("--ip", self.argv) - def test_run_time_proxy_carries_no_identity_token(self) -> None: - """The token is minted by registration, which happens after this run — - so it cannot be here. `/resolve` denies the token-less pair (#366), - which is the safe direction; the real value arrives at exec time.""" - joined = " ".join(self.argv) - self.assertIn(f"HTTP_PROXY={_proxy_url('192.168.128.3')}", joined) - self.assertNotIn("bottle:", joined) + def test_run_time_env_sets_no_proxy_vars_at_all(self) -> None: + """Regression: the proxy vars must exist *only* in the exec-time env. + + `container exec --env` appends rather than replaces, so a token-less + `HTTPS_PROXY` here would survive next to the token-bearing one and + leave two entries in the agent's `environ`. Resolution is then + runtime-specific — Node reads the last, Rust's `std::env::var` reads + the first — so Codex proxied without its identity token and every + request fail-closed at `/resolve`. + """ + for entry in self.argv: + self.assertFalse( + entry.upper().startswith(("HTTP_PROXY=", "HTTPS_PROXY=")), + f"run-time env must not set a proxy var, got {entry!r}", + ) + + def test_run_time_env_carries_no_identity_token(self) -> None: + """The token is minted by registration, which happens after this run, + so it cannot be here under any name.""" + self.assertNotIn("bottle:", " ".join(self.argv)) def test_gateway_bypasses_the_proxy(self) -> None: """git-http + supervise live on the gateway and must be reached