From c320d08dfec8debf2baa25f4611520d9e9b8eeba Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:37:16 +0800 Subject: [PATCH] feat(tui): run bang commands through the gateway --- nanobot/agent/loop.py | 66 ++++++++++++++-- nanobot/bus/events.py | 5 +- nanobot/channels/websocket/runtime.py | 22 +++++- .../websocket/tests/test_websocket_channel.py | 56 ++++++++++++++ nanobot/command/builtin.py | 30 +++++++- tests/command/test_router_dispatchable.py | 3 + tests/command/test_user_shell_command.py | 75 +++++++++++++++++++ tests/webui/test_gateway_webui_smoke.py | 15 ++++ tui/src/app.test.ts | 29 +++++++ tui/src/app.ts | 7 +- tui/src/protocol.test.ts | 2 + tui/src/protocol.ts | 2 + 12 files changed, 299 insertions(+), 13 deletions(-) create mode 100644 tests/command/test_user_shell_command.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 1c99679ef..324414a3d 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -44,7 +44,7 @@ from nanobot.agent.turn_delivery import ( ) from nanobot.agent.turn_delivery import TurnRoute as TurnRoute from nanobot.agent.turn_hooks import AgentTurnHookSpec, build_agent_turn_hook -from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.bus.events import INBOUND_META_USER_SHELL, InboundMessage, OutboundMessage from nanobot.bus.outbound_events import StreamedResponseEvent from nanobot.bus.queue import MessageBus from nanobot.bus.runtime_events import RuntimeEventBus @@ -802,12 +802,66 @@ class AgentLoop: dispatch_fn: Callable[[CommandContext], Awaitable[OutboundMessage | None]], ) -> None: """Dispatch a command directly from the run() loop and publish the result.""" - ctx = CommandContext(msg=msg, session=None, key=key, raw=raw, loop=self) - result = await dispatch_fn(ctx) - if result: - await self.bus.publish_outbound(result) + async def dispatch_and_publish() -> None: + ctx = CommandContext(msg=msg, session=None, key=key, raw=raw, loop=self) + result = await dispatch_fn(ctx) + if result: + await self.bus.publish_outbound(result) + else: + logger.warning("Command '{}' matched but dispatch returned None", raw) + + # A shell command may run for up to the configured exec timeout. Keep + # the inbound consumer responsive when it runs beside an active turn. + if (msg.metadata or {}).get(INBOUND_META_USER_SHELL) is True: + self.schedule_background(dispatch_and_publish()) + return + await dispatch_and_publish() + + async def execute_user_shell_command(self, ctx: CommandContext) -> OutboundMessage: + """Execute one trusted user command with the active workspace policy.""" + metadata = dict(ctx.msg.metadata or {}) + tool = self.tools.get("exec") + if tool is None: + content = "Shell execution is disabled in this nanobot configuration." else: - logger.warning("Command '{}' matched but dispatch returned None", raw) + session = ctx.session or self.sessions.get_or_create(ctx.key) + scope = self.workspace_scopes.for_turn( + channel=ctx.msg.channel, + message_metadata=metadata, + session_metadata=session.metadata, + ) + request_token = bind_request_context(RequestContext( + channel=ctx.msg.channel, + chat_id=ctx.msg.chat_id, + message_id=metadata.get("message_id"), + session_key=ctx.key, + original_user_text=f"!{ctx.args.strip()}", + runtime=ctx.runtime, + metadata=metadata, + sender_id=ctx.msg.sender_id, + turn_id=metadata.get("webui_turn_id"), + workspace=scope.project_path, + )) + workspace_token = bind_workspace_scope(scope) + turn_scope_stack = ExitStack() + try: + for turn_scope in ctx.turn_scopes: + turn_scope_stack.enter_context(turn_scope) + result = await tool.execute( + command=ctx.args.strip(), + working_dir=str(scope.project_path), + ) + content = str(result) + finally: + turn_scope_stack.close() + reset_workspace_scope(workspace_token) + reset_request_context(request_token) + return OutboundMessage( + channel=ctx.msg.channel, + chat_id=ctx.msg.chat_id, + content=content, + metadata={**metadata, "render_as": "text"}, + ) async def _cancel_active_tasks(self, key: str) -> int: """Cancel and await all active work for *key*. diff --git a/nanobot/bus/events.py b/nanobot/bus/events.py index 28ada6b78..2725a359c 100644 --- a/nanobot/bus/events.py +++ b/nanobot/bus/events.py @@ -12,9 +12,10 @@ if TYPE_CHECKING: # render it and other channels may ignore unknown keys. OUTBOUND_META_AGENT_UI = "_agent_ui" -# Internal-only inbound metadata used by in-process channels to ask the agent -# loop to update runtime state without going through a user session. +# Internal-only inbound metadata minted by trusted transports and runtime +# services. Never accept these keys verbatim from an untrusted client. INBOUND_META_RUNTIME_CONTROL = "_runtime_control" +INBOUND_META_USER_SHELL = "_user_shell" RUNTIME_CONTROL_ACK = "_ack" RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload" RUNTIME_CONTROL_SESSION_DISCARD = "session_discard" diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index 58ebd826f..64e5dfb25 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -24,6 +24,7 @@ from websockets.exceptions import ConnectionClosed from websockets.http11 import Request as WsRequest from nanobot.bus.events import ( + INBOUND_META_USER_SHELL, OUTBOUND_META_AGENT_UI, OutboundMessage, ) @@ -39,7 +40,7 @@ from nanobot.bus.outbound_events import ( ) from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel -from nanobot.command.builtin import builtin_command_starts_agent_turn +from nanobot.command.builtin import USER_SHELL_COMMAND, builtin_command_starts_agent_turn from nanobot.config.schema import Base from nanobot.runtime_context import ( RUNTIME_CONTEXT_INPUT_META, @@ -1172,6 +1173,18 @@ class WebSocketChannel(BaseChannel): metadata["webui"] = True metadata.update(self._transcripts.client_turn_metadata(envelope.get("turn_id"))) trusted_webui = metadata.get("webui") is True and connection in self._webui_connections + is_user_shell = ( + trusted_webui + and envelope.get("user_shell") is True + and content.startswith("!") + ) + if is_user_shell: + metadata[INBOUND_META_USER_SHELL] = True + dispatch_content = ( + f"{USER_SHELL_COMMAND} {content[1:].lstrip()}" + if is_user_shell + else content + ) cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps")) if cli_apps: metadata["cli_apps"] = cli_apps @@ -1197,7 +1210,7 @@ class WebSocketChannel(BaseChannel): self._workspaces.persist_scope(cid, scope) is_webui = metadata.get("webui") is True queued_owner = None - if is_webui and builtin_command_starts_agent_turn(content): + if is_webui and not is_user_shell and builtin_command_starts_agent_turn(content): queued_owner = register_queued_websocket_turn_if_idle(cid, turn_id) if queued_owner is not None: metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner @@ -1234,7 +1247,7 @@ class WebSocketChannel(BaseChannel): await self._handle_message( sender_id=client_id, chat_id=cid, - content=content, + content=dispatch_content, media=media_paths or None, metadata=metadata, is_dm=False, @@ -1742,6 +1755,9 @@ class WebSocketChannel(BaseChannel): "chat_id": msg.chat_id, "text": wire_text, } + turn_id = msg.metadata.get(WEBUI_TURN_METADATA_KEY) + if isinstance(turn_id, str) and turn_id: + payload["turn_id"] = turn_id if msg.media: payload["media"] = msg.media urls: list[dict[str, str]] = [] diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index d24338920..9f264bf31 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -18,6 +18,7 @@ from websockets.frames import Close from nanobot.bus.events import ( INBOUND_META_RUNTIME_CONTROL, + INBOUND_META_USER_SHELL, OUTBOUND_META_AGENT_UI, RUNTIME_CONTROL_SESSION_DISCARD, OutboundMessage, @@ -814,6 +815,61 @@ async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) -> assert isinstance(lines[0].get("created_at_ms"), int) +@pytest.mark.asyncio +async def test_trusted_webui_shell_preserves_display_text_and_hides_dispatch_command( + bus: MagicMock, +) -> None: + from nanobot.webui.transcript import read_transcript_lines + + channel = _ch(bus) + conn = MagicMock() + conn.remote_address = ("127.0.0.1", 50123) + channel._webui_connections.add(conn) + + await channel._dispatch_envelope( + conn, + "webui-client", + { + "type": "message", + "chat_id": "shell-chat", + "content": "!printf ok", + "webui": True, + "user_shell": True, + "turn_id": "shell-turn", + }, + ) + + msg = bus.publish_inbound.await_args.args[0] + assert msg.content == "/__shell printf ok" + assert msg.metadata[INBOUND_META_USER_SHELL] is True + assert msg.metadata["webui_turn_id"] == "shell-turn" + assert read_transcript_lines("websocket:shell-chat")[0]["text"] == "!printf ok" + + +@pytest.mark.asyncio +async def test_untrusted_websocket_cannot_enable_user_shell(bus: MagicMock) -> None: + channel = _ch(bus) + conn = MagicMock() + conn.remote_address = ("127.0.0.1", 50123) + + await channel._dispatch_envelope( + conn, + "plain-client", + { + "type": "message", + "chat_id": "plain-chat", + "content": "!printf nope", + "webui": True, + "user_shell": True, + "turn_id": "plain-turn", + }, + ) + + msg = bus.publish_inbound.await_args.args[0] + assert msg.content == "!printf nope" + assert INBOUND_META_USER_SHELL not in msg.metadata + + @pytest.mark.asyncio async def test_webui_message_envelope_persists_user_transcript_for_refresh( bus: MagicMock, diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 5e5c92e26..13b7d18c7 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -12,7 +12,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal, cast from nanobot import __version__ -from nanobot.bus.events import OutboundMessage +from nanobot.bus.events import INBOUND_META_USER_SHELL, OutboundMessage from nanobot.command.router import CommandContext, CommandRouter, normalize_command_text from nanobot.utils.helpers import build_status_content from nanobot.utils.restart import set_restart_notice_to_env @@ -37,6 +37,8 @@ CommandLifecycle = Literal[ "agent_turn_with_args", ] +USER_SHELL_COMMAND = "/__shell" + @dataclass(frozen=True) class BuiltinCommandSpec: @@ -999,6 +1001,30 @@ async def cmd_help(ctx: CommandContext) -> OutboundMessage: ) +async def cmd_user_shell(ctx: CommandContext) -> OutboundMessage: + """Run a trusted local ``!command`` through nanobot's exec policy.""" + metadata = dict(ctx.msg.metadata or {}) + if ( + ctx.msg.channel != "websocket" + or metadata.get("webui") is not True + or metadata.get(INBOUND_META_USER_SHELL) is not True + ): + return OutboundMessage( + channel=ctx.msg.channel, + chat_id=ctx.msg.chat_id, + content="Shell commands are only available from a trusted local client.", + metadata={**metadata, "render_as": "text"}, + ) + if not ctx.args.strip(): + return OutboundMessage( + channel=ctx.msg.channel, + chat_id=ctx.msg.chat_id, + content="Type a command after `!`, for example `!pwd`.", + metadata={**metadata, "render_as": "text"}, + ) + return await ctx.loop.execute_user_shell_command(ctx) + + def build_help_text() -> str: """Build canonical help text shared across channels.""" lines = ["🐈 nanobot commands:"] @@ -1038,3 +1064,5 @@ def register_builtin_commands(router: CommandRouter) -> None: router.exact("/help", cmd_help) router.exact("/pairing", cmd_pairing) router.prefix("/pairing ", cmd_pairing) + router.exact(USER_SHELL_COMMAND, cmd_user_shell) + router.prefix(f"{USER_SHELL_COMMAND} ", cmd_user_shell) diff --git a/tests/command/test_router_dispatchable.py b/tests/command/test_router_dispatchable.py index 697673ffe..ef745f979 100644 --- a/tests/command/test_router_dispatchable.py +++ b/tests/command/test_router_dispatchable.py @@ -49,6 +49,7 @@ class TestIsDispatchableCommand: assert router.is_dispatchable_command("/goal migrate the database") assert router.is_dispatchable_command("/pairing list") assert router.is_dispatchable_command("/pairing approve CODE") + assert router.is_dispatchable_command("/__shell pwd") def test_priority_commands_not_matched(self, router: CommandRouter) -> None: # Priority commands are NOT in the dispatchable tiers — they are @@ -59,6 +60,7 @@ class TestIsDispatchableCommand: def test_regular_text_not_matched(self, router: CommandRouter) -> None: assert not router.is_dispatchable_command("hello") assert not router.is_dispatchable_command("what is 2+2?") + assert not router.is_dispatchable_command("!important is still ordinary text") assert not router.is_dispatchable_command("") def test_case_insensitive(self, router: CommandRouter) -> None: @@ -86,6 +88,7 @@ class TestIsDispatchableCommand: ("/goal", False), ("/goal migrate the database", True), ("regular prompt", True), + ("!pwd", True), ], ) def test_builtin_command_agent_turn_lifecycle(content: str, expected: bool) -> None: diff --git a/tests/command/test_user_shell_command.py b/tests/command/test_user_shell_command.py new file mode 100644 index 000000000..2037b3612 --- /dev/null +++ b/tests/command/test_user_shell_command.py @@ -0,0 +1,75 @@ +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from nanobot.agent.loop import AgentLoop +from nanobot.bus.events import INBOUND_META_USER_SHELL, InboundMessage, OutboundMessage +from nanobot.command.builtin import cmd_user_shell +from nanobot.command.router import CommandContext + + +def _context(loop: MagicMock, *, trusted: bool, command: str = "pwd") -> CommandContext: + metadata = { + "webui": True, + **({INBOUND_META_USER_SHELL: True} if trusted else {}), + } + msg = InboundMessage( + channel="websocket", + sender_id="local-user", + chat_id="chat", + content=f"!{command}", + metadata=metadata, + ) + return CommandContext( + msg=msg, + session=None, + key=msg.session_key, + raw=msg.content, + args=command, + loop=loop, + ) + + +@pytest.mark.asyncio +async def test_user_shell_rejects_untrusted_transport_metadata() -> None: + loop = MagicMock() + loop.execute_user_shell_command = AsyncMock() + + response = await cmd_user_shell(_context(loop, trusted=False)) + + assert "trusted local client" in response.content + loop.execute_user_shell_command.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_user_shell_uses_exec_tool_with_workspace_scope(tmp_path: Path) -> None: + tool = MagicMock() + tool.execute = AsyncMock(return_value=f"{tmp_path}\n\nExit code: 0") + session = SimpleNamespace(metadata={}) + scope = SimpleNamespace(project_path=tmp_path) + loop = MagicMock() + loop.tools.get.return_value = tool + loop.sessions.get_or_create.return_value = session + loop.workspace_scopes.for_turn.return_value = scope + ctx = _context(loop, trusted=True) + + response = await AgentLoop.execute_user_shell_command(loop, ctx) + + tool.execute.assert_awaited_once_with(command="pwd", working_dir=str(tmp_path)) + assert response.content.endswith("Exit code: 0") + assert response.metadata["render_as"] == "text" + + +@pytest.mark.asyncio +async def test_user_shell_delegates_trusted_request_to_agent_loop() -> None: + loop = MagicMock() + expected = OutboundMessage(channel="websocket", chat_id="chat", content="ok") + loop.execute_user_shell_command = AsyncMock(return_value=expected) + ctx = _context(loop, trusted=True, command="printf ok") + + response = await cmd_user_shell(ctx) + + assert response is expected + loop.execute_user_shell_command.assert_awaited_once_with(ctx) diff --git a/tests/webui/test_gateway_webui_smoke.py b/tests/webui/test_gateway_webui_smoke.py index e0cf16147..535abb939 100644 --- a/tests/webui/test_gateway_webui_smoke.py +++ b/tests/webui/test_gateway_webui_smoke.py @@ -176,6 +176,19 @@ async def test_gateway_webui_bootstrap_message_and_thread_hydration(tmp_path: Pa assert "Current model: `custom/smoke-model`" in answer["text"] await _recv_until(ws, "turn_end") + await ws.send(json.dumps({ + "type": "message", + "chat_id": chat_id, + "content": "!printf shell-ok", + "webui": True, + "user_shell": True, + "turn_id": "shell-turn", + })) + shell = await _recv_until(ws, "message") + assert "shell-ok" in shell["text"] + assert shell["turn_id"] == "shell-turn" + await _recv_until(ws, "turn_end") + api_token = _wait_for_bootstrap(base_url, process, log_path)["api_token"] sessions = _get_json(f"{base_url}/api/sessions", token=api_token) key = f"websocket:{chat_id}" @@ -189,5 +202,7 @@ async def test_gateway_webui_bootstrap_message_and_thread_hydration(tmp_path: Pa contents = [str(message.get("content") or "") for message in thread["messages"]] assert "/model" in contents assert any("Current model: `custom/smoke-model`" in text for text in contents) + assert "!printf shell-ok" in contents + assert any("shell-ok" in text for text in contents) finally: _stop_gateway(process) diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index c32739269..fc7ae2026 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -454,6 +454,35 @@ describe("NanobotTui layout", () => { expect(sent).toEqual([]) }) + test("runs bang commands through the gateway without steering the agent", async () => { + setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) + const sent: string[] = [] + const sentOptions: MessageOptions[] = [] + const app = NanobotTui.mount( + setup.renderer, + options, + client(sent, [], [], sentOptions), + new MockTreeSitterClient({ autoResolveTimeout: 0 }), + ) + app.accept({ event: "attached", chat_id: "chat" }) + const ui = app as unknown as { ready: boolean; composer: TextareaRenderable; activeTurn: boolean } + await waitUntil(() => ui.ready) + + app.accept({ event: "goal_status", chat_id: "chat", status: "running" }) + ui.composer.setText("!pwd") + ui.composer.submit() + + await waitUntil(() => sent.length === 1) + expect(sent).toEqual(["!pwd"]) + expect(sentOptions).toEqual([{ userShell: true }]) + expect(ui.activeTurn).toBe(true) + + app.accept({ event: "message", chat_id: "chat", text: "/tmp/project", turn_id: "turn" }) + await setup.flush() + expect(setup.captureCharFrame()).toContain("/tmp/project") + expect(ui.activeTurn).toBe(true) + }) + test("switches and creates gateway chats without replacing core slash commands", async () => { setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) const original = globalThis.fetch diff --git a/tui/src/app.ts b/tui/src/app.ts index 5a99a8532..eec5ea74e 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -752,6 +752,10 @@ export class NanobotTui { if (lifecycle) this.sendGatewayCommand(visibleContent, lifecycle) return } + if (visibleContent.startsWith("!")) { + this.sendGatewayCommand(visibleContent, "side_channel", false, { userShell: true }) + return + } if (!this.ready) { this.status.content = "Preparing chat…" return @@ -1812,6 +1816,7 @@ export class NanobotTui { content: string, lifecycle: ResolvedSlashCommandLifecycle, silent = false, + options: MessageOptions = {}, ): void { if (!this.ready) { this.status.content = "Preparing chat…" @@ -1823,7 +1828,7 @@ export class NanobotTui { } let turnId: string try { - turnId = this.client.send(content) + turnId = this.client.send(content, options) } catch (error) { this.status.content = error instanceof Error ? error.message : String(error) return diff --git a/tui/src/protocol.test.ts b/tui/src/protocol.test.ts index 03c1d693f..709bedf4c 100644 --- a/tui/src/protocol.test.ts +++ b/tui/src/protocol.test.ts @@ -96,6 +96,7 @@ describe("gateway protocol", () => { client.send("hello", { cliApps: [{ name: "github" }], sessionMentions: [{ name: "plan", session_key: "websocket:plan" }], + userShell: true, }) client.attach("other-chat") client.newChat() @@ -110,6 +111,7 @@ describe("gateway protocol", () => { expect(outbound[1]?.type).toBe("message") expect(outbound[1]?.chat_id).toBe("terminal") expect(outbound[1]?.content).toBe("hello") + expect(outbound[1]?.user_shell).toBe(true) expect(outbound[1]?.cli_apps).toEqual([{ name: "github" }]) expect(outbound[1]?.session_mentions).toEqual([ { name: "plan", session_key: "websocket:plan" }, diff --git a/tui/src/protocol.ts b/tui/src/protocol.ts index abd79bb74..32fd5bc99 100644 --- a/tui/src/protocol.ts +++ b/tui/src/protocol.ts @@ -214,6 +214,7 @@ export interface MessageOptions { cliApps?: Array<{ name: string }> mcpPresets?: Array<{ name: string }> sessionMentions?: SessionMention[] + userShell?: boolean } export interface SlashCommand { @@ -799,6 +800,7 @@ export class NanobotClient { content, turn_id: turnId, webui: true, + ...(options.userShell ? { user_shell: true } : {}), ...(options.cliApps?.length ? { cli_apps: options.cliApps } : {}), ...(options.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}), ...(options.sessionMentions?.length