Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions raven/tui_rpc/methods/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,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
Expand Down Expand Up @@ -133,6 +134,10 @@ def register_aligned_methods_except_system(
# come AFTER register_stub_methods so session.status's real handler
# supersedes the (now-removed) hermes-only stub entry.
register_slash_routing_methods(dispatcher, confirm_broker=confirm_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
Expand Down Expand Up @@ -173,6 +178,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_confirm_methods",
Expand Down
58 changes: 58 additions & 0 deletions raven/tui_rpc/methods/shell_exec.py
Original file line number Diff line number Diff line change
@@ -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"]
66 changes: 66 additions & 0 deletions tests/test_tui_rpc_shell_exec.py
Original file line number Diff line number Diff line change
@@ -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"] == ""