From 307400f08a48799a52e765b38d7cfc6440c334bd Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 25 May 2026 07:36:27 -0400 Subject: [PATCH] =?UTF-8?q?fix(supervise):=20bypass=20pipelock=20for=20age?= =?UTF-8?q?nt=20=E2=86=92=20supervise=20MCP=20traffic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/mcp` showed the supervise server as ✔ connected (initialize is fast), but any actual tool call failed because the supervise MCP design is long-poll — the sidecar holds the HTTP request open until the operator approves in the dashboard (potentially minutes) and only then returns the response. Pipelock is a forward proxy with idle timeouts; it cut the long- polled HTTPS-style request well before the operator could act, and claude-code reported the tool as ✘ failed. Fix: add `supervise` to the agent's NO_PROXY when bottle.supervise is true. The supervise sidecar is on the bottle's internal network with the `supervise` network-alias, so the agent can dial it directly via docker DNS — no proxy, no idle timeout. Body-scanning supervise traffic isn't critical because the operator reviews every proposal in the TUI before approving. The earlier pipelock allowlist auto-add for `supervise` stays as belt-and- braces (handles any proxy-respecting client other than claude-code that might dial supervise). Existing bottles need a restart to pick up the new NO_PROXY value (env can't be changed on a running container). The dashboard's pipelock-edit workaround from PR #25 unblocks short-running tool calls in the meantime but won't survive the pipelock idle timeout on a long-polled call. Co-Authored-By: Claude Opus 4.7 --- claude_bottle/backend/docker/launch.py | 27 +++++++++++++++-- tests/unit/test_agent_no_proxy.py | 42 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_agent_no_proxy.py diff --git a/claude_bottle/backend/docker/launch.py b/claude_bottle/backend/docker/launch.py index b2da057..fda19ce 100644 --- a/claude_bottle/backend/docker/launch.py +++ b/claude_bottle/backend/docker/launch.py @@ -19,7 +19,7 @@ from typing import Callable, Generator from ...log import die, info from ...pipelock import pipelock_build_config, pipelock_render_yaml -from ...supervise import CURRENT_CONFIG_DIR_IN_AGENT +from ...supervise import CURRENT_CONFIG_DIR_IN_AGENT, SUPERVISE_HOSTNAME from . import network as network_mod from . import util as docker_mod from .bottle import DockerBottle @@ -185,6 +185,29 @@ def launch( teardown() +def _agent_no_proxy(plan: DockerBottlePlan) -> str: + """NO_PROXY value for the agent container. Standard loopback + + `supervise` when the supervise sidecar is enabled. + + Supervise needs to bypass pipelock because the MCP tool-call + pattern is long-poll: claude-code opens an HTTPS-style request to + http://supervise:9100/, the sidecar holds it open until the + operator approves (potentially minutes), then returns the + response. Pipelock is a forward proxy with idle timeouts; + pipelock cuts the long-polled connection well before the operator + can act, and claude-code reports the tool as ✘ failed even + though /mcp shows ✔ connected. + + The supervise sidecar is on the bottle's internal network with + the `supervise` network-alias, so the agent can dial it + directly via docker DNS. Body-scanning the supervise traffic + isn't critical — the operator reviews every proposal in the TUI.""" + hosts = ["localhost", "127.0.0.1"] + if plan.supervise_plan is not None: + hosts.append(SUPERVISE_HOSTNAME) + return ",".join(hosts) + + def _run_agent_container(plan: DockerBottlePlan, internal_network: str) -> str: """Build the `docker run` argv and execute it, handling name- conflict races by incrementing the suffix (unless the name was @@ -196,7 +219,7 @@ def _run_agent_container(plan: DockerBottlePlan, internal_network: str) -> str: "--network", internal_network, "-e", f"HTTPS_PROXY={proxy_url}", "-e", f"HTTP_PROXY={proxy_url}", - "-e", "NO_PROXY=localhost,127.0.0.1", + "-e", f"NO_PROXY={_agent_no_proxy(plan)}", # CA trust trio for the agent process. Docker propagates # run-time env into `docker exec`, so `claude` sees these # without per-exec threading. NODE_EXTRA_CA_CERTS points at diff --git a/tests/unit/test_agent_no_proxy.py b/tests/unit/test_agent_no_proxy.py new file mode 100644 index 0000000..d72a2b3 --- /dev/null +++ b/tests/unit/test_agent_no_proxy.py @@ -0,0 +1,42 @@ +"""Unit: agent NO_PROXY value builder (PR #25 follow-up). + +claude-code's HTTP MCP client must bypass pipelock for the supervise +sidecar — long-poll tool calls would hit pipelock's idle timeout +otherwise. This test pins the rule: localhost always; supervise iff +the supervise sidecar is in the plan.""" + +import unittest +from pathlib import Path + +from claude_bottle.backend.docker.launch import _agent_no_proxy + + +class _FakePlan: + """Just enough plan shape for the helper — no full DockerBottlePlan + construction needed.""" + + def __init__(self, supervise_plan): + self.supervise_plan = supervise_plan + + +class _SentinelSupervisePlan: + """The helper only checks `supervise_plan is not None`; any object + is fine.""" + + +class TestAgentNoProxy(unittest.TestCase): + def test_loopback_only_when_no_supervise(self): + self.assertEqual( + "localhost,127.0.0.1", + _agent_no_proxy(_FakePlan(supervise_plan=None)), + ) + + def test_supervise_appended_when_enabled(self): + self.assertEqual( + "localhost,127.0.0.1,supervise", + _agent_no_proxy(_FakePlan(supervise_plan=_SentinelSupervisePlan())), + ) + + +if __name__ == "__main__": + unittest.main()