feat(tui): run bang commands through the gateway
This commit is contained in:
+60
-6
@@ -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*.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user