diff --git a/raven/tui_rpc/methods/__init__.py b/raven/tui_rpc/methods/__init__.py index 3b50352..9052287 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 0000000..e373da2 --- /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 0000000..ac70f7a --- /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"] == ""