@miller-tech/uap 1.119.3 → 1.120.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1275,6 +1275,7 @@ class SessionMonitor:
1275
1275
  and enable proactive context management before overflow occurs."""
1276
1276
 
1277
1277
  context_window: int = 0 # Auto-detected or configured
1278
+ sandboxed: bool = False # True when the client is a `uap sandbox` (bwrap) session (X-Uap-Sandbox header)
1278
1279
  total_requests: int = 0
1279
1280
  last_input_tokens: int = 0 # Estimated input tokens of last request
1280
1281
  last_output_tokens: int = 0 # Actual output tokens of last response
@@ -4366,6 +4367,46 @@ def _writes_are_gated(openai_body: dict) -> bool:
4366
4367
  return False
4367
4368
 
4368
4369
 
4370
+ # MCP tool prefixes a bubblewrap sandbox (`uap sandbox`) cannot reach: the
4371
+ # claude-in-chrome browser extension talks over a socket that is not bound into
4372
+ # the sandbox mount namespace, so every browser_batch call dead-ends. Offering
4373
+ # these tools to a sandboxed session guarantees a fetch/tool-selection loop.
4374
+ # Env-configurable (colon-separated) so a deployment that DOES bind the browser
4375
+ # extension socket into the sandbox can disable stripping (set empty), or extend
4376
+ # it to other sandbox-unreachable MCP servers. Default: the claude-in-chrome
4377
+ # browser bridge, empirically unreachable in the bwrap namespace (observed live:
4378
+ # browser_batch dead-ended repeatedly and the model reported it unavailable).
4379
+ _SANDBOX_UNREACHABLE_TOOL_PREFIXES = tuple(
4380
+ p
4381
+ for p in os.environ.get(
4382
+ "PROXY_SANDBOX_UNREACHABLE_PREFIXES", "mcp__claude-in-chrome__"
4383
+ ).split(":")
4384
+ if p
4385
+ )
4386
+
4387
+
4388
+ def _strip_sandbox_unreachable_tools(body: dict) -> int:
4389
+ """Drop browser/extension MCP tools a bwrap sandbox can't reach from the
4390
+ Anthropic-format tool list in ``body['tools']``. Returns the count removed
4391
+ so the caller can log it. The model then falls back to WebFetch / local
4392
+ file reads instead of looping on an unreachable browser_batch."""
4393
+ tools = body.get("tools")
4394
+ if not isinstance(tools, list) or not _SANDBOX_UNREACHABLE_TOOL_PREFIXES:
4395
+ return 0
4396
+ kept = [
4397
+ t
4398
+ for t in tools
4399
+ if not (
4400
+ isinstance(t, dict)
4401
+ and str(t.get("name", "")).startswith(_SANDBOX_UNREACHABLE_TOOL_PREFIXES)
4402
+ )
4403
+ ]
4404
+ removed = len(tools) - len(kept)
4405
+ if removed:
4406
+ body["tools"] = kept
4407
+ return removed
4408
+
4409
+
4369
4410
  def _maybe_inject_stuck_break(openai_body: dict, monitor: "SessionMonitor") -> None:
4370
4411
  """Force a terminal turn when the model is looping self-awarely or hammering
4371
4412
  a rate-limited API. Unlike the cycle-breaker (which narrows tools), this
@@ -4379,11 +4420,18 @@ def _maybe_inject_stuck_break(openai_body: dict, monitor: "SessionMonitor") -> N
4379
4420
  # Release the tool-choice coercion so a plain text turn is allowed.
4380
4421
  if openai_body.get("tool_choice") == "required":
4381
4422
  openai_body["tool_choice"] = "auto"
4423
+ # Sandboxed sessions can't reach the browser extension, so don't steer them
4424
+ # to it (that is the very dead-end this guard is trying to break).
4425
+ channel_hint = (
4426
+ "use `git clone` (git protocol) or WebFetch"
4427
+ if monitor.sandboxed
4428
+ else "use the browser tool or `git clone` (git protocol)"
4429
+ )
4382
4430
  directive = (
4383
4431
  "\n\nSTOP — you are repeating a failing action (" + reason + "). Do NOT "
4384
4432
  "retry the same tool or fetch again. If a resource is unreachable (e.g. a "
4385
- "rate-limited GitHub REST API), switch channel: use the browser tool or "
4386
- "`git clone` (git protocol), NOT api.github.com. If it is still "
4433
+ "rate-limited GitHub REST API), switch channel: " + channel_hint + ", NOT "
4434
+ "api.github.com. If it is still "
4387
4435
  "unavailable, proceed WITHOUT it using what you already have, or ask the "
4388
4436
  "operator the single blocking question in one sentence. Take a DIFFERENT "
4389
4437
  "action now."
@@ -9278,6 +9326,21 @@ async def messages(request: Request):
9278
9326
  if request.headers.get("x-uap-json-response"):
9279
9327
  body["_uap_json_response"] = True
9280
9328
 
9329
+ # Sandboxed sessions (`uap sandbox` / bwrap) set X-Uap-Sandbox:1 via
9330
+ # ANTHROPIC_CUSTOM_HEADERS. The sandbox can't reach the claude-in-chrome
9331
+ # browser extension, so strip its MCP tools here (before passthrough, so it
9332
+ # applies to local AND cloud sessions) — the model then uses WebFetch or
9333
+ # local file reads instead of looping on an unreachable browser_batch.
9334
+ sandboxed = (request.headers.get("x-uap-sandbox") or "").strip() == "1"
9335
+ if sandboxed:
9336
+ _stripped = _strip_sandbox_unreachable_tools(body)
9337
+ if _stripped:
9338
+ logger.warning(
9339
+ "SANDBOX: stripped %d unreachable browser MCP tool(s) "
9340
+ "(bwrap cannot reach the claude-in-chrome extension)",
9341
+ _stripped,
9342
+ )
9343
+
9281
9344
  # Periodically re-detect context window from upstream (handles server restarts)
9282
9345
  await _maybe_recheck_context_window()
9283
9346
 
@@ -9286,6 +9349,7 @@ async def messages(request: Request):
9286
9349
  return await _passthrough_anthropic_request(request, body, is_stream)
9287
9350
  session_id = resolve_session_id(request, body)
9288
9351
  monitor = get_session_monitor(session_id)
9352
+ monitor.sandboxed = sandboxed
9289
9353
  # Per-turn flag: only the turn whose breaker strips tools suppresses the
9290
9354
  # response-side prose->tool_call resurrection. Clear it at request entry so a
9291
9355
  # prior finalize turn never bleeds into the next turn's normal extraction.
@@ -0,0 +1,36 @@
1
+ """Tests for sandbox browser-tool stripping (bwrap can't reach the extension)."""
2
+ import importlib.util
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ proxy_path = Path(__file__).resolve().parents[3] / "tools" / "agents" / "scripts" / "anthropic_proxy.py"
7
+ spec = importlib.util.spec_from_file_location("anthropic_proxy", proxy_path)
8
+ ap = importlib.util.module_from_spec(spec)
9
+ spec.loader.exec_module(ap)
10
+
11
+
12
+ class StripSandboxToolsTest(unittest.TestCase):
13
+ def test_strips_browser_mcp_tools_keeps_the_rest(self):
14
+ body = {"tools": [
15
+ {"name": "Bash"},
16
+ {"name": "mcp__claude-in-chrome__browser_batch"},
17
+ {"name": "mcp__claude-in-chrome__navigate"},
18
+ {"name": "WebFetch"},
19
+ ]}
20
+ removed = ap._strip_sandbox_unreachable_tools(body)
21
+ self.assertEqual(removed, 2)
22
+ self.assertEqual([t["name"] for t in body["tools"]], ["Bash", "WebFetch"])
23
+
24
+ def test_keeps_all_when_no_browser_tools(self):
25
+ body = {"tools": [{"name": "Bash"}, {"name": "WebFetch"}]}
26
+ self.assertEqual(ap._strip_sandbox_unreachable_tools(body), 0)
27
+ self.assertEqual(len(body["tools"]), 2)
28
+
29
+ def test_missing_or_null_tools_is_safe(self):
30
+ self.assertEqual(ap._strip_sandbox_unreachable_tools({}), 0)
31
+ self.assertEqual(ap._strip_sandbox_unreachable_tools({"tools": None}), 0)
32
+ self.assertEqual(ap._strip_sandbox_unreachable_tools({"tools": []}), 0)
33
+
34
+
35
+ if __name__ == "__main__":
36
+ unittest.main()