From 08977064b91295b4cd16c5ec6bb1b5bd3dd955da Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Sat, 25 Jul 2026 14:19:42 +0000 Subject: [PATCH] fix(tui-rpc): register the missing shell.exec handler `!`-prefixed bang commands in the TUI always failed with `[rpc -32601] method_not_found`: useSubmission.ts's shellExec/interpolate call `gw.request('shell.exec', {command})`, but the backend never registered a shell.exec handler, and shell.exec is absent everywhere from raven/**/*.py. bundle, which called a method named command.dispatch. That name is gone from the current frontend source; only shell.exec is called on this path now (useSubmission.ts:180,212). command.dispatch survives only as dead code in createSlashHandler.ts's unreachable .catch() fallback, since slash.exec never rejects. On current HEAD, shell.exec is the missing method, so the suggested command.dispatch registration would not have fixed the bug. Add register_shell_methods (raven/tui_rpc/methods/shell_exec.py), wired into the umbrella in methods/__init__.py the same way slash_routing is. shell.exec carries no session_id (both call sites pass only {command}), so there is no session-scoped sandbox executor to route through; it runs via DirectExecutor, matching a plain host shell escape. Co-authored-by: Claude (claude-sonnet-5) --- raven/tui_rpc/methods/__init__.py | 6 +++ raven/tui_rpc/methods/shell_exec.py | 58 +++++++++++++++++++++++++ tests/test_tui_rpc_shell_exec.py | 66 +++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 raven/tui_rpc/methods/shell_exec.py create mode 100644 tests/test_tui_rpc_shell_exec.py diff --git a/raven/tui_rpc/methods/__init__.py b/raven/tui_rpc/methods/__init__.py index 3b503523..90522871 100644 --- a/raven/tui_rpc/methods/__init__.py +++ b/raven/tui_rpc/methods/__init__.py @@ -39,6 +39,7 @@ from raven.tui_rpc.methods.reload import register_reload_methods from raven.tui_rpc.methods.session import register_session_methods from raven.tui_rpc.methods.setup import register_setup_methods +from raven.tui_rpc.methods.shell_exec import register_shell_methods from raven.tui_rpc.methods.slash_routing import register_slash_routing_methods from raven.tui_rpc.methods.system import register_system_methods from raven.tui_rpc.methods.terminal import register_terminal_methods @@ -144,6 +145,10 @@ def register_aligned_methods_except_system( # Register it only when this gateway owns an interactive approval broker. if approval_broker is not None: register_approval_methods(dispatcher, approval_broker=approval_broker) + # shell.exec — the `!`-prefixed bang-shell escape (useSubmission.ts's + # shellExec/interpolate). Independent of the slash pipeline above: it + # carries no session_id and always runs on the host (#171). + register_shell_methods(dispatcher) # turn.{send,subscribe,unsubscribe,cancel}. The handlers # need a SubscriptionEmitter to push streaming events; when the caller # has not built one (demo runner / production path pre-wire) we skip @@ -184,6 +189,7 @@ def register_aligned_methods_except_system( "register_terminal_methods", "register_stub_methods", "register_model_methods", + "register_shell_methods", "register_slash_routing_methods", "register_turn_methods", "register_approval_methods", diff --git a/raven/tui_rpc/methods/shell_exec.py b/raven/tui_rpc/methods/shell_exec.py new file mode 100644 index 00000000..e373da2e --- /dev/null +++ b/raven/tui_rpc/methods/shell_exec.py @@ -0,0 +1,58 @@ +"""``shell.exec`` RPC method handler. + +Wires the RPC method the TUI's `!`-prefixed bang-shell escape calls +(``ui-tui/src/app/useSubmission.ts``'s ``shellExec`` / ``interpolate``) but +which the backend never registered, so every bang command failed with +``[rpc -32601] method_not_found`` (EverMind-AI/Raven#171). + +The issue's own root-cause evidence was gathered against the released +v0.1.6 bundle, which called a method named ``command.dispatch``; that name +is gone from the current frontend source, which calls ``shell.exec`` +instead (``useSubmission.ts:180,212``) — ``command.dispatch`` only survives +as dead code in ``createSlashHandler.ts``'s unreachable ``.catch()`` +fallback (``slash.exec`` never rejects, so that branch cannot fire). On +current HEAD ``shell.exec`` is the method that is actually missing. + +``shell.exec`` carries no ``session_id`` (both call sites pass only +``{command}``), so there is no session-scoped sandbox executor to route +through; it always runs on the host via :class:`DirectExecutor`, same as a +plain shell escape. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from raven.sandbox import DirectExecutor + +if TYPE_CHECKING: + from raven.tui_rpc.dispatcher import Dispatcher + +_TIMEOUT_S = 60 + + +async def shell_exec(params: dict[str, Any]) -> dict[str, Any]: + """Run a bang-shell command on the host; return ``ShellExecResponse``. + + Never raises -32xxx: ``useSubmission.ts`` reads ``{code, stdout, stderr}`` + off a resolved promise, so an empty command or a executor failure + degrades to a non-zero ``code`` instead of an RPC error. + """ + command = str(params.get("command", "")).strip() + if not command: + return {"code": 1, "stdout": "", "stderr": "empty command"} + + try: + result = await DirectExecutor().exec(command, timeout=_TIMEOUT_S) + except Exception as exc: # noqa: BLE001 — surface as stderr, not an RPC error + return {"code": 1, "stdout": "", "stderr": f"{type(exc).__name__}: {exc}"} + + return {"code": result.exit_code, "stdout": result.stdout, "stderr": result.stderr} + + +def register_shell_methods(dispatcher: "Dispatcher") -> None: + """Register ``shell.exec`` on a dispatcher instance.""" + dispatcher.register("shell.exec", shell_exec) + + +__all__ = ["register_shell_methods", "shell_exec"] diff --git a/tests/test_tui_rpc_shell_exec.py b/tests/test_tui_rpc_shell_exec.py new file mode 100644 index 00000000..ac70f7a7 --- /dev/null +++ b/tests/test_tui_rpc_shell_exec.py @@ -0,0 +1,66 @@ +"""Tests for the ``shell.exec`` RPC method. + +Covers EverMind-AI/Raven#171: every `!`-prefixed bang command in the TUI +failed with ``[rpc -32601] method_not_found``. ``useSubmission.ts``'s +``shellExec`` / ``interpolate`` call ``gw.request('shell.exec', {command})``, +but the backend never registered a ``shell.exec`` handler. +""" + +from __future__ import annotations + +import pytest + +from raven.tui_rpc.dispatcher import Dispatcher +from raven.tui_rpc.methods.shell_exec import register_shell_methods, shell_exec + + +@pytest.fixture +def dispatcher() -> Dispatcher: + d = Dispatcher() + register_shell_methods(d) + return d + + +async def test_shell_exec_registered_on_umbrella_dispatcher() -> None: + """The production umbrella (register_aligned_methods_except_system) must + expose ``shell.exec`` — the same registration-drift bug the + slash_routing methods were added to prevent.""" + from raven.tui_rpc.methods import register_aligned_methods_except_system + + d = Dispatcher() + register_aligned_methods_except_system(d) + assert "shell.exec" in d.methods() + + +async def test_shell_exec_runs_command_and_returns_stdout(dispatcher: Dispatcher) -> None: + frame = { + "jsonrpc": "2.0", + "id": 1, + "method": "shell.exec", + "params": {"command": "echo hello"}, + } + response = await dispatcher.dispatch(frame) + + assert "error" not in response + result = response["result"] + assert result["code"] == 0 + assert result["stdout"].strip() == "hello" + + +async def test_shell_exec_reports_nonzero_exit_code(dispatcher: Dispatcher) -> None: + frame = { + "jsonrpc": "2.0", + "id": 2, + "method": "shell.exec", + "params": {"command": "exit 3"}, + } + response = await dispatcher.dispatch(frame) + + assert response["result"]["code"] == 3 + + +async def test_shell_exec_empty_command_does_not_raise() -> None: + result = await shell_exec({"command": " "}) + + assert result["code"] != 0 + assert result["stdout"] == ""