fix(supervise): bypass pipelock for agent → supervise MCP traffic
`/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 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ from typing import Callable, Generator
|
|||||||
|
|
||||||
from ...log import die, info
|
from ...log import die, info
|
||||||
from ...pipelock import pipelock_build_config, pipelock_render_yaml
|
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 network as network_mod
|
||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
from .bottle import DockerBottle
|
from .bottle import DockerBottle
|
||||||
@@ -185,6 +185,29 @@ def launch(
|
|||||||
teardown()
|
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:
|
def _run_agent_container(plan: DockerBottlePlan, internal_network: str) -> str:
|
||||||
"""Build the `docker run` argv and execute it, handling name-
|
"""Build the `docker run` argv and execute it, handling name-
|
||||||
conflict races by incrementing the suffix (unless the name was
|
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,
|
"--network", internal_network,
|
||||||
"-e", f"HTTPS_PROXY={proxy_url}",
|
"-e", f"HTTPS_PROXY={proxy_url}",
|
||||||
"-e", f"HTTP_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
|
# CA trust trio for the agent process. Docker propagates
|
||||||
# run-time env into `docker exec`, so `claude` sees these
|
# run-time env into `docker exec`, so `claude` sees these
|
||||||
# without per-exec threading. NODE_EXTRA_CA_CERTS points at
|
# without per-exec threading. NODE_EXTRA_CA_CERTS points at
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user