diff --git a/backend/apps/agents/combined_meta_mcp_server.py b/backend/apps/agents/combined_meta_mcp_server.py index 5c7825d4..3dcd8a01 100644 --- a/backend/apps/agents/combined_meta_mcp_server.py +++ b/backend/apps/agents/combined_meta_mcp_server.py @@ -33,6 +33,7 @@ P_MODULE_FILES = { "web": "web_mcp_server", "browser": "browser_agent_mcp_server", "canvas": "canvas_mcp_server", + "ptc": "ptc_mcp_server", } P_ENABLED = [m.strip() for m in os.environ.get("OSW_MCP_MODULES", "meta,settings,apps").split(",") if m.strip()] diff --git a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py index 8df9f26e..a83e38c6 100644 --- a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py +++ b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py @@ -110,6 +110,12 @@ def build_effective_tool_lists( effective_allowed.append("mcp__openswarm-core__CanvasCommand") elif policy == "deny": effective_disallowed.append("mcp__openswarm-core__CanvasCommand") + if "ptc" in p_modules: + policy = builtin_perms.get("RunToolScript", "always_allow") + if policy == "always_allow": + effective_allowed.append("mcp__openswarm-core__RunToolScript") + elif policy == "deny": + effective_disallowed.append("mcp__openswarm-core__RunToolScript") if "web" in p_modules: # Honor existing WebSearch/WebFetch permission policy, if the user disabled them in Settings, don't offer the MCP variants either. for wt in ("WebSearch", "WebFetch"): diff --git a/backend/apps/agents/manager/register_builtin_mcp_servers.py b/backend/apps/agents/manager/register_builtin_mcp_servers.py index 6df65801..bb2102c2 100644 --- a/backend/apps/agents/manager/register_builtin_mcp_servers.py +++ b/backend/apps/agents/manager/register_builtin_mcp_servers.py @@ -75,6 +75,10 @@ def register_builtin_mcp_servers( # Schedule module: ScheduleWorkflow + CRUD + step editing so the agent (and the workflow Edit Agent) can build and schedule recurring work via the native scheduler. The 4 scheduling tools are force-gated in path_gate; Cron* is denied in build_effective_tool_lists. modules.append("schedule") + # RunToolScript (PTC): script-chained tool calls whose intermediates never enter context; inner calls are allowlisted in the server itself. + if builtin_perms.get("RunToolScript", "always_allow") != "deny": + modules.append("ptc") + # Canvas control after spawn (ENG-334): move/collapse/tile/close/tidy; close is scoped server-side to the caller's own cards. if builtin_perms.get("CanvasCommand", "always_allow") != "deny": modules.append("canvas") diff --git a/backend/apps/agents/manager/streaming/unwedge_sidecar.py b/backend/apps/agents/manager/streaming/unwedge_sidecar.py index 4e37f6a2..ff5ff245 100644 --- a/backend/apps/agents/manager/streaming/unwedge_sidecar.py +++ b/backend/apps/agents/manager/streaming/unwedge_sidecar.py @@ -40,6 +40,7 @@ P_BLOCKING_TOOLS: Set[str] = { "CreateBrowserAgent", "BrowserAgent", "AppAgent", "SpawnAgent", "InvokeAgent", "RequestHumanIntervention", "MCPSearch", "MCPActivate", + "RunToolScript", } diff --git a/backend/apps/agents/ptc_mcp_server.py b/backend/apps/agents/ptc_mcp_server.py new file mode 100644 index 00000000..d4192ba6 --- /dev/null +++ b/backend/apps/agents/ptc_mcp_server.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""RunToolScript: the model writes a short Python script that chains builtin tools, and only +the script's printed output enters context (PTC, lifted from hermes-agent's code_execution_tool, +MIT). A 10-page research sweep costs the tokens of its summary instead of ten raw page dumps. + +Safety shape: the script subprocess gets a minimal env with NO auth token; every tool call is +brokered here against an explicit ALLOWLIST (never the deny-list's guess), so the gated surfaces +(MCP activation, delegation, HITL, canvas, schedules, SettingsWrite) are unreachable from scripts +by construction. Agents already hold always_allow Bash, so this adds reach for no new privilege.""" + +import json +import os +import subprocess +import sys +import threading +import time + +SCRIPT_TIMEOUT_S = 300.0 +MAX_TOOL_CALLS = 50 +MAX_STDOUT_BYTES = 50_000 + +# Read/write-safe, ungated tools only. Everything else is invisible to scripts on purpose. +SCRIPT_ALLOWED_TOOLS = ("WebSearch", "WebFetch", "MemoryRead", "MemoryWrite", "SettingsRead", "Skill") + +TOOLS = [ + { + "name": "RunToolScript", + "description": ( + "Run a short Python script that chains multiple tool calls in ONE turn; only what " + "the script print()s comes back to you, so bulky intermediate results never enter " + "your context. Use this whenever a task needs 3+ tool calls whose raw outputs you " + "would only aggregate anyway (fetch N pages and extract one fact each, search then " + "fetch the top hits, sweep memory). The script gets one function: " + "call_tool(name, args_dict) -> str, valid names: " + + ", ".join(SCRIPT_ALLOWED_TOOLS) + ". A failed tool raises PtcToolError (catchable). " + "Print ONLY your distilled findings. Budget: " + f"{MAX_TOOL_CALLS} tool calls, {SCRIPT_TIMEOUT_S:.0f}s, printed output capped at 50KB." + ), + "inputSchema": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "Python source. Example: results = call_tool('WebSearch', {'query': 'x'}) then loop call_tool('WebFetch', {'url': u}) and print the aggregate.", + }, + }, + "required": ["script"], + }, + }, +] + + +def p_core(): + """The running combined sidecar (spawned as __main__), which owns tool routing. Tests + inject a stand-in via set_core_for_tests.""" + if p_core_override is not None: + return p_core_override + main_mod = sys.modules.get("__main__") + if main_mod is not None and hasattr(main_mod, "P_ROUTE") and hasattr(main_mod, "p_call"): + return main_mod + return None + + +p_core_override = None + + +def set_core_for_tests(core) -> None: + global p_core_override + p_core_override = core + + +def p_result_text(result: dict) -> str: + parts = [] + for c in result.get("content", []) or []: + if isinstance(c, dict) and c.get("type") == "text": + parts.append(str(c.get("text", ""))) + return "\n".join(parts) + + +def p_dispatch(name: str, args: dict) -> dict: + """One brokered inner call: allowlist first, then the sidecar's own routing.""" + if name not in SCRIPT_ALLOWED_TOOLS: + return {"text": f"tool '{name}' is not callable from scripts; allowed: {', '.join(SCRIPT_ALLOWED_TOOLS)}", "is_error": True} + core = p_core() + if core is None: + return {"text": "tool routing unavailable", "is_error": True} + mod = core.P_ROUTE.get(name) + if mod is None: + return {"text": f"tool '{name}' is not loaded in this session", "is_error": True} + try: + result = core.p_call(mod, name, args) + except Exception as e: + return {"text": f"tool '{name}' raised: {e}", "is_error": True} + return {"text": p_result_text(result), "is_error": bool(result.get("isError"))} + + +def p_elide(text: str, cap: int = MAX_STDOUT_BYTES) -> str: + raw = text.encode("utf-8", errors="replace") + if len(raw) <= cap: + return text + head = int(cap * 0.4) + tail = cap - head + return ( + raw[:head].decode("utf-8", errors="replace") + + f"\n\n[... output elided: {len(raw)} bytes total, cap {cap} ...]\n\n" + + raw[-tail:].decode("utf-8", errors="replace") + ) + + +def p_runner_env() -> dict: + # Minimal on purpose: the child needs no secrets because the parent brokers every call. + keep = ("PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "SYSTEMROOT", "TEMP", "TMP") + env = {k: os.environ[k] for k in keep if k in os.environ} + env["PYTHONUNBUFFERED"] = "1" + env["PYTHONDONTWRITEBYTECODE"] = "1" + return env + + +def p_mcp_text(text: str, is_error: bool = False) -> dict: + out = {"content": [{"type": "text", "text": text}]} + if is_error: + out["isError"] = True + return out + + +def handle_tool_call(tool_name: str, arguments: dict) -> dict: + if tool_name != "RunToolScript": + return p_mcp_text(f"Unknown tool: {tool_name}", is_error=True) + script = str(arguments.get("script", "")).strip() + if not script: + return p_mcp_text("script is required", is_error=True) + runner = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ptc_script_runner.py") + try: + proc = subprocess.Popen( + [sys.executable, "-u", runner], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + env=p_runner_env(), text=True, + ) + except Exception as e: + return p_mcp_text(f"could not start script runner: {e}", is_error=True) + deadline = time.monotonic() + SCRIPT_TIMEOUT_S + # The read loop blocks in readline, so the deadline needs teeth of its own: the timer kills the child, which turns the block into a clean EOF. + p_reaper = threading.Timer(SCRIPT_TIMEOUT_S, lambda: proc.poll() is None and proc.kill()) + p_reaper.daemon = True + p_reaper.start() + calls_used = 0 + try: + proc.stdin.write(json.dumps({"script": script}) + "\n") + proc.stdin.flush() + while True: + if time.monotonic() > deadline: + proc.kill() + return p_mcp_text( + f"script exceeded the {SCRIPT_TIMEOUT_S:.0f}s budget after {calls_used} tool calls; nothing was returned. Break the work into smaller scripts.", + is_error=True, + ) + line = proc.stdout.readline() + if not line: + if time.monotonic() > deadline: + return p_mcp_text( + f"script exceeded the {SCRIPT_TIMEOUT_S:.0f}s budget after {calls_used} tool calls; nothing was returned. Break the work into smaller scripts.", + is_error=True, + ) + return p_mcp_text(f"script runner exited unexpectedly after {calls_used} tool calls", is_error=True) + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + if msg.get("done"): + out = p_elide(str(msg.get("stdout", ""))) + err = msg.get("error") + footer = f"\n\n[script ran {int(msg.get('calls', 0))} tool call(s)]" + if err: + return p_mcp_text(f"script raised {err}\n\npartial output:\n{out}{footer}", is_error=True) + if not out.strip(): + return p_mcp_text(f"script printed nothing; print your findings next time.{footer}", is_error=True) + return p_mcp_text(out + footer) + call = msg.get("call") + if not isinstance(call, dict): + continue + calls_used += 1 + if calls_used > MAX_TOOL_CALLS: + reply = {"seq": msg.get("seq"), "text": f"tool call budget ({MAX_TOOL_CALLS}) exhausted; print what you have", "is_error": True} + else: + reply = {"seq": msg.get("seq"), **p_dispatch(str(call.get("name", "")), dict(call.get("args") or {}))} + proc.stdin.write(json.dumps(reply) + "\n") + proc.stdin.flush() + except BrokenPipeError: + return p_mcp_text(f"script runner pipe broke after {calls_used} tool calls", is_error=True) + finally: + p_reaper.cancel() + try: + if proc.poll() is None: + proc.kill() + except Exception: + pass diff --git a/backend/apps/agents/ptc_script_runner.py b/backend/apps/agents/ptc_script_runner.py new file mode 100644 index 00000000..3aa2cb7e --- /dev/null +++ b/backend/apps/agents/ptc_script_runner.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Subprocess half of RunToolScript (PTC, the hermes code_execution lift): executes ONE +model-written Python script whose tool calls ride JSON lines back to the sidecar, so +intermediate tool results never enter the model's context window; only what the script +prints returns. + +Protocol, all newline-delimited JSON over the real stdin/stdout: + parent -> child {"script": ""} + child -> parent {"call": {"name": str, "args": {}}, "seq": int} + parent -> child {"seq": int, "text": str, "is_error": bool} + child -> parent {"done": true, "stdout": str, "calls": int, "error": str|null} + +The script's own print() goes to an in-memory buffer (the real stdout is the RPC channel), +capped so a runaway loop can't balloon the process. Runs with a scrubbed env and no auth +token: every tool call is brokered by the parent sidecar, which owns the allowlist.""" + +import io +import json +import sys + +P_STDOUT_CAP_BYTES = 5_000_000 + +p_rpc_out = sys.stdout +p_rpc_in = sys.stdin +p_seq = 0 +p_calls = 0 + + +class PtcToolError(Exception): + pass + + +def p_send(obj): + p_rpc_out.write(json.dumps(obj) + "\n") + p_rpc_out.flush() + + +def p_recv(): + line = p_rpc_in.readline() + if not line: + raise PtcToolError("sidecar closed the pipe") + return json.loads(line) + + +def call_tool(name, args=None): + """The one function scripts get: run a builtin tool, return its text result. + + Raises PtcToolError when the tool itself errored, so a script can try/except + around a flaky fetch instead of parsing error prose.""" + global p_seq, p_calls + if not isinstance(name, str) or not name: + raise PtcToolError("call_tool needs a tool name string") + p_seq += 1 + p_calls += 1 + p_send({"call": {"name": name, "args": dict(args or {})}, "seq": p_seq}) + reply = p_recv() + if reply.get("is_error"): + raise PtcToolError(str(reply.get("text", "tool failed"))) + return str(reply.get("text", "")) + + +class P_CappedBuffer(io.StringIO): + def write(self, s): + if self.tell() < P_STDOUT_CAP_BYTES: + return super().write(s) + return len(s) + + +def main(): + first = p_recv() + script = str(first.get("script", "")) + buf = P_CappedBuffer() + sys.stdout = buf + error = None + scope = {"call_tool": call_tool, "PtcToolError": PtcToolError, "__name__": "__ptc_script__"} + try: + exec(compile(script, "", "exec"), scope) + except BaseException as e: + error = f"{type(e).__name__}: {e}" + finally: + sys.stdout = p_rpc_out + p_send({"done": True, "stdout": buf.getvalue(), "calls": p_calls, "error": error}) + + +if __name__ == "__main__": + main() diff --git a/backend/apps/tools_lib/models.py b/backend/apps/tools_lib/models.py index f862d56e..e3f39454 100644 --- a/backend/apps/tools_lib/models.py +++ b/backend/apps/tools_lib/models.py @@ -39,6 +39,7 @@ BUILTIN_TOOLS: list[BuiltinTool] = [ BuiltinTool(name="Agent", display_name="CreateAgent", description="Spawn a sub-agent to handle a complex subtask", category="agents"), BuiltinTool(name="InvokeAgent", description="Invoke a copy of an existing agent with a new message, preserving full conversation context", category="agents"), BuiltinTool(name="CanvasCommand", display_name="Canvas control", description="Move, collapse, tile, close, or tidy cards on the canvas after spawn", category="agents"), + BuiltinTool(name="RunToolScript", display_name="Tool scripting", description="Chain many tool calls in one scripted step; only the distilled output enters the conversation", category="agents"), # These two always had real deny-gates at dispatch (register_builtin_mcp_servers), but were missing here, so the permissions API refused to store a policy for them and the gates were unreachable (ENG-284). BuiltinTool(name="ShowUI", display_name="Rich UI", description="Render rich inline components in chat, and ask questions through interactive UI", category="interaction"), BuiltinTool(name="Skill", description="Load an installed skill's instructions into the conversation", category="skills"), diff --git a/backend/tests/test_ptc_run_tool_script.py b/backend/tests/test_ptc_run_tool_script.py new file mode 100644 index 00000000..57f52801 --- /dev/null +++ b/backend/tests/test_ptc_run_tool_script.py @@ -0,0 +1,137 @@ +"""Pins the RunToolScript (PTC) contract: scripts chain allowlisted tools through the broker, +only printed output returns, and every guardrail (allowlist, call cap, timeout, stdout cap, +secret-free child env) actually bites. The whole point is intermediates never reach context, +so the strongest assertion here is what the RESULT does NOT contain.""" + +import sys + +from backend.apps.agents import ptc_mcp_server as ptc + + +class P_FakeCore: + """Stands in for the combined sidecar: routes every allowlisted name to a canned handler.""" + + def __init__(self): + self.calls = [] + self.P_ROUTE = {name: self for name in ptc.SCRIPT_ALLOWED_TOOLS} + + def p_call(self, mod, name, args): + self.calls.append((name, args)) + if name == "WebFetch": + return {"content": [{"type": "text", "text": "PAGE-BODY " + ("x" * 2000) + " NEEDLE:" + str(args.get("url"))}]} + if name == "WebSearch": + return {"content": [{"type": "text", "text": "r1 http://a\nr2 http://b"}]} + if name == "MemoryWrite": + return {"content": [{"type": "text", "text": "saved"}]} + return {"content": [{"type": "text", "text": f"ok:{name}"}]} + + +def p_run(script: str) -> dict: + return ptc.handle_tool_call("RunToolScript", {"script": script}) + + +def p_text(result: dict) -> str: + return result["content"][0]["text"] + + +def setup_function(fn): + ptc.set_core_for_tests(P_FakeCore()) + + +def teardown_function(fn): + ptc.set_core_for_tests(None) + + +def test_chained_calls_return_only_printed_output(): + core = P_FakeCore() + ptc.set_core_for_tests(core) + r = p_run( + "urls = [u.split()[1] for u in call_tool('WebSearch', {'query': 'q'}).splitlines()]\n" + "needles = [call_tool('WebFetch', {'url': u}).split('NEEDLE:')[1] for u in urls]\n" + "print('needles: ' + ', '.join(needles))\n" + ) + text = p_text(r) + assert "needles: http://a, http://b" in text + assert "PAGE-BODY" not in text, "intermediate tool output leaked into context" + assert len(core.calls) == 3 + assert "[script ran 3 tool call(s)]" in text + assert not r.get("isError") + + +def test_non_allowlisted_tool_is_refused_but_catchable(): + r = p_run( + "try:\n" + " call_tool('MCPActivate', {'server_name': 'x'})\n" + " print('ESCAPED')\n" + "except PtcToolError as e:\n" + " print('blocked: ' + str(e)[:40])\n" + ) + text = p_text(r) + assert "blocked:" in text + assert "ESCAPED" not in text + + +def test_call_cap_enforced(): + r = p_run( + "hits = 0\n" + "for i in range(60):\n" + " try:\n" + " call_tool('MemoryWrite', {'ops': []})\n" + " hits += 1\n" + " except PtcToolError:\n" + " break\n" + "print('completed ' + str(hits))\n" + ) + assert f"completed {ptc.MAX_TOOL_CALLS}" in p_text(r) + + +def test_script_exception_reports_partial_output(): + r = p_run("print('got this far')\nraise ValueError('boom')\n") + text = p_text(r) + assert r.get("isError") is True + assert "ValueError: boom" in text + assert "got this far" in text + + +def test_empty_print_is_an_error_nudge(): + r = p_run("x = 1 + 1\n") + assert r.get("isError") is True + assert "printed nothing" in p_text(r) + + +def test_stdout_capped_with_elide(): + r = p_run("print('A' * 200_000)") + text = p_text(r) + assert len(text.encode()) < ptc.MAX_STDOUT_BYTES + 500 + assert "output elided" in text + + +def test_timeout_kills_hung_script(monkeypatch): + monkeypatch.setattr(ptc, "SCRIPT_TIMEOUT_S", 3.0) + r = p_run("import time\ntime.sleep(60)\nprint('never')\n") + assert r.get("isError") is True + assert "exceeded" in p_text(r) + + +def test_child_env_carries_no_secrets(monkeypatch): + monkeypatch.setenv("OPENSWARM_AUTH_TOKEN", "sekrit-token") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-nope") + env = ptc.p_runner_env() + joined = " ".join(f"{k}={v}" for k, v in env.items()) + assert "sekrit-token" not in joined + assert "sk-ant-nope" not in joined + assert "PATH" in env + + +def test_negative_control_no_core_routing(): + ptc.set_core_for_tests(None) + # No __main__ sidecar in pytest, so dispatch must fail closed, not crash. + out = ptc.p_dispatch("WebFetch", {"url": "http://x"}) + assert out["is_error"] is True + assert "unavailable" in out["text"] + + +def test_runner_importable_and_single_purpose(): + import backend.apps.agents.ptc_script_runner as runner + assert callable(runner.call_tool) + assert sys.modules["backend.apps.agents.ptc_script_runner"] is runner