feat(tui): run bang commands through the gateway

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent c5d2e0ddf1
commit c320d08dfe
12 changed files with 299 additions and 13 deletions
+19 -3
View File
@@ -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]] = []
@@ -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,