mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 16:21:50 +03:00
feat(tui): run bang commands through the gateway
This commit is contained in:
+55
-1
@@ -44,7 +44,7 @@ from nanobot.agent.turn_delivery import (
|
|||||||
)
|
)
|
||||||
from nanobot.agent.turn_delivery import TurnRoute as TurnRoute
|
from nanobot.agent.turn_delivery import TurnRoute as TurnRoute
|
||||||
from nanobot.agent.turn_hooks import AgentTurnHookSpec, build_agent_turn_hook
|
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.outbound_events import StreamedResponseEvent
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.bus.runtime_events import RuntimeEventBus
|
from nanobot.bus.runtime_events import RuntimeEventBus
|
||||||
@@ -802,6 +802,7 @@ class AgentLoop:
|
|||||||
dispatch_fn: Callable[[CommandContext], Awaitable[OutboundMessage | None]],
|
dispatch_fn: Callable[[CommandContext], Awaitable[OutboundMessage | None]],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Dispatch a command directly from the run() loop and publish the result."""
|
"""Dispatch a command directly from the run() loop and publish the result."""
|
||||||
|
async def dispatch_and_publish() -> None:
|
||||||
ctx = CommandContext(msg=msg, session=None, key=key, raw=raw, loop=self)
|
ctx = CommandContext(msg=msg, session=None, key=key, raw=raw, loop=self)
|
||||||
result = await dispatch_fn(ctx)
|
result = await dispatch_fn(ctx)
|
||||||
if result:
|
if result:
|
||||||
@@ -809,6 +810,59 @@ class AgentLoop:
|
|||||||
else:
|
else:
|
||||||
logger.warning("Command '{}' matched but dispatch returned None", raw)
|
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:
|
||||||
|
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:
|
async def _cancel_active_tasks(self, key: str) -> int:
|
||||||
"""Cancel and await all active work for *key*.
|
"""Cancel and await all active work for *key*.
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ if TYPE_CHECKING:
|
|||||||
# render it and other channels may ignore unknown keys.
|
# render it and other channels may ignore unknown keys.
|
||||||
OUTBOUND_META_AGENT_UI = "_agent_ui"
|
OUTBOUND_META_AGENT_UI = "_agent_ui"
|
||||||
|
|
||||||
# Internal-only inbound metadata used by in-process channels to ask the agent
|
# Internal-only inbound metadata minted by trusted transports and runtime
|
||||||
# loop to update runtime state without going through a user session.
|
# services. Never accept these keys verbatim from an untrusted client.
|
||||||
INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
|
INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
|
||||||
|
INBOUND_META_USER_SHELL = "_user_shell"
|
||||||
RUNTIME_CONTROL_ACK = "_ack"
|
RUNTIME_CONTROL_ACK = "_ack"
|
||||||
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
|
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
|
||||||
RUNTIME_CONTROL_SESSION_DISCARD = "session_discard"
|
RUNTIME_CONTROL_SESSION_DISCARD = "session_discard"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from websockets.exceptions import ConnectionClosed
|
|||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
|
|
||||||
from nanobot.bus.events import (
|
from nanobot.bus.events import (
|
||||||
|
INBOUND_META_USER_SHELL,
|
||||||
OUTBOUND_META_AGENT_UI,
|
OUTBOUND_META_AGENT_UI,
|
||||||
OutboundMessage,
|
OutboundMessage,
|
||||||
)
|
)
|
||||||
@@ -39,7 +40,7 @@ from nanobot.bus.outbound_events import (
|
|||||||
)
|
)
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
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.config.schema import Base
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
RUNTIME_CONTEXT_INPUT_META,
|
RUNTIME_CONTEXT_INPUT_META,
|
||||||
@@ -1172,6 +1173,18 @@ class WebSocketChannel(BaseChannel):
|
|||||||
metadata["webui"] = True
|
metadata["webui"] = True
|
||||||
metadata.update(self._transcripts.client_turn_metadata(envelope.get("turn_id")))
|
metadata.update(self._transcripts.client_turn_metadata(envelope.get("turn_id")))
|
||||||
trusted_webui = metadata.get("webui") is True and connection in self._webui_connections
|
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"))
|
cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
|
||||||
if cli_apps:
|
if cli_apps:
|
||||||
metadata["cli_apps"] = cli_apps
|
metadata["cli_apps"] = cli_apps
|
||||||
@@ -1197,7 +1210,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._workspaces.persist_scope(cid, scope)
|
self._workspaces.persist_scope(cid, scope)
|
||||||
is_webui = metadata.get("webui") is True
|
is_webui = metadata.get("webui") is True
|
||||||
queued_owner = None
|
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)
|
queued_owner = register_queued_websocket_turn_if_idle(cid, turn_id)
|
||||||
if queued_owner is not None:
|
if queued_owner is not None:
|
||||||
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner
|
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner
|
||||||
@@ -1234,7 +1247,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
sender_id=client_id,
|
sender_id=client_id,
|
||||||
chat_id=cid,
|
chat_id=cid,
|
||||||
content=content,
|
content=dispatch_content,
|
||||||
media=media_paths or None,
|
media=media_paths or None,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
is_dm=False,
|
is_dm=False,
|
||||||
@@ -1742,6 +1755,9 @@ class WebSocketChannel(BaseChannel):
|
|||||||
"chat_id": msg.chat_id,
|
"chat_id": msg.chat_id,
|
||||||
"text": wire_text,
|
"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:
|
if msg.media:
|
||||||
payload["media"] = msg.media
|
payload["media"] = msg.media
|
||||||
urls: list[dict[str, str]] = []
|
urls: list[dict[str, str]] = []
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from websockets.frames import Close
|
|||||||
|
|
||||||
from nanobot.bus.events import (
|
from nanobot.bus.events import (
|
||||||
INBOUND_META_RUNTIME_CONTROL,
|
INBOUND_META_RUNTIME_CONTROL,
|
||||||
|
INBOUND_META_USER_SHELL,
|
||||||
OUTBOUND_META_AGENT_UI,
|
OUTBOUND_META_AGENT_UI,
|
||||||
RUNTIME_CONTROL_SESSION_DISCARD,
|
RUNTIME_CONTROL_SESSION_DISCARD,
|
||||||
OutboundMessage,
|
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)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_webui_message_envelope_persists_user_transcript_for_refresh(
|
async def test_webui_message_envelope_persists_user_transcript_for_refresh(
|
||||||
bus: MagicMock,
|
bus: MagicMock,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from dataclasses import dataclass
|
|||||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||||
|
|
||||||
from nanobot import __version__
|
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.command.router import CommandContext, CommandRouter, normalize_command_text
|
||||||
from nanobot.utils.helpers import build_status_content
|
from nanobot.utils.helpers import build_status_content
|
||||||
from nanobot.utils.restart import set_restart_notice_to_env
|
from nanobot.utils.restart import set_restart_notice_to_env
|
||||||
@@ -37,6 +37,8 @@ CommandLifecycle = Literal[
|
|||||||
"agent_turn_with_args",
|
"agent_turn_with_args",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
USER_SHELL_COMMAND = "/__shell"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BuiltinCommandSpec:
|
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:
|
def build_help_text() -> str:
|
||||||
"""Build canonical help text shared across channels."""
|
"""Build canonical help text shared across channels."""
|
||||||
lines = ["🐈 nanobot commands:"]
|
lines = ["🐈 nanobot commands:"]
|
||||||
@@ -1038,3 +1064,5 @@ def register_builtin_commands(router: CommandRouter) -> None:
|
|||||||
router.exact("/help", cmd_help)
|
router.exact("/help", cmd_help)
|
||||||
router.exact("/pairing", cmd_pairing)
|
router.exact("/pairing", cmd_pairing)
|
||||||
router.prefix("/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)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class TestIsDispatchableCommand:
|
|||||||
assert router.is_dispatchable_command("/goal migrate the database")
|
assert router.is_dispatchable_command("/goal migrate the database")
|
||||||
assert router.is_dispatchable_command("/pairing list")
|
assert router.is_dispatchable_command("/pairing list")
|
||||||
assert router.is_dispatchable_command("/pairing approve CODE")
|
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:
|
def test_priority_commands_not_matched(self, router: CommandRouter) -> None:
|
||||||
# Priority commands are NOT in the dispatchable tiers — they are
|
# 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:
|
def test_regular_text_not_matched(self, router: CommandRouter) -> None:
|
||||||
assert not router.is_dispatchable_command("hello")
|
assert not router.is_dispatchable_command("hello")
|
||||||
assert not router.is_dispatchable_command("what is 2+2?")
|
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("")
|
assert not router.is_dispatchable_command("")
|
||||||
|
|
||||||
def test_case_insensitive(self, router: CommandRouter) -> None:
|
def test_case_insensitive(self, router: CommandRouter) -> None:
|
||||||
@@ -86,6 +88,7 @@ class TestIsDispatchableCommand:
|
|||||||
("/goal", False),
|
("/goal", False),
|
||||||
("/goal migrate the database", True),
|
("/goal migrate the database", True),
|
||||||
("regular prompt", True),
|
("regular prompt", True),
|
||||||
|
("!pwd", True),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_builtin_command_agent_turn_lifecycle(content: str, expected: bool) -> None:
|
def test_builtin_command_agent_turn_lifecycle(content: str, expected: bool) -> None:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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"]
|
assert "Current model: `custom/smoke-model`" in answer["text"]
|
||||||
await _recv_until(ws, "turn_end")
|
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"]
|
api_token = _wait_for_bootstrap(base_url, process, log_path)["api_token"]
|
||||||
sessions = _get_json(f"{base_url}/api/sessions", token=api_token)
|
sessions = _get_json(f"{base_url}/api/sessions", token=api_token)
|
||||||
key = f"websocket:{chat_id}"
|
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"]]
|
contents = [str(message.get("content") or "") for message in thread["messages"]]
|
||||||
assert "/model" in contents
|
assert "/model" in contents
|
||||||
assert any("Current model: `custom/smoke-model`" in text for text 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:
|
finally:
|
||||||
_stop_gateway(process)
|
_stop_gateway(process)
|
||||||
|
|||||||
@@ -454,6 +454,35 @@ describe("NanobotTui layout", () => {
|
|||||||
expect(sent).toEqual([])
|
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 () => {
|
test("switches and creates gateway chats without replacing core slash commands", async () => {
|
||||||
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
||||||
const original = globalThis.fetch
|
const original = globalThis.fetch
|
||||||
|
|||||||
+6
-1
@@ -752,6 +752,10 @@ export class NanobotTui {
|
|||||||
if (lifecycle) this.sendGatewayCommand(visibleContent, lifecycle)
|
if (lifecycle) this.sendGatewayCommand(visibleContent, lifecycle)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (visibleContent.startsWith("!")) {
|
||||||
|
this.sendGatewayCommand(visibleContent, "side_channel", false, { userShell: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!this.ready) {
|
if (!this.ready) {
|
||||||
this.status.content = "Preparing chat…"
|
this.status.content = "Preparing chat…"
|
||||||
return
|
return
|
||||||
@@ -1812,6 +1816,7 @@ export class NanobotTui {
|
|||||||
content: string,
|
content: string,
|
||||||
lifecycle: ResolvedSlashCommandLifecycle,
|
lifecycle: ResolvedSlashCommandLifecycle,
|
||||||
silent = false,
|
silent = false,
|
||||||
|
options: MessageOptions = {},
|
||||||
): void {
|
): void {
|
||||||
if (!this.ready) {
|
if (!this.ready) {
|
||||||
this.status.content = "Preparing chat…"
|
this.status.content = "Preparing chat…"
|
||||||
@@ -1823,7 +1828,7 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
let turnId: string
|
let turnId: string
|
||||||
try {
|
try {
|
||||||
turnId = this.client.send(content)
|
turnId = this.client.send(content, options)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.status.content = error instanceof Error ? error.message : String(error)
|
this.status.content = error instanceof Error ? error.message : String(error)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ describe("gateway protocol", () => {
|
|||||||
client.send("hello", {
|
client.send("hello", {
|
||||||
cliApps: [{ name: "github" }],
|
cliApps: [{ name: "github" }],
|
||||||
sessionMentions: [{ name: "plan", session_key: "websocket:plan" }],
|
sessionMentions: [{ name: "plan", session_key: "websocket:plan" }],
|
||||||
|
userShell: true,
|
||||||
})
|
})
|
||||||
client.attach("other-chat")
|
client.attach("other-chat")
|
||||||
client.newChat()
|
client.newChat()
|
||||||
@@ -110,6 +111,7 @@ describe("gateway protocol", () => {
|
|||||||
expect(outbound[1]?.type).toBe("message")
|
expect(outbound[1]?.type).toBe("message")
|
||||||
expect(outbound[1]?.chat_id).toBe("terminal")
|
expect(outbound[1]?.chat_id).toBe("terminal")
|
||||||
expect(outbound[1]?.content).toBe("hello")
|
expect(outbound[1]?.content).toBe("hello")
|
||||||
|
expect(outbound[1]?.user_shell).toBe(true)
|
||||||
expect(outbound[1]?.cli_apps).toEqual([{ name: "github" }])
|
expect(outbound[1]?.cli_apps).toEqual([{ name: "github" }])
|
||||||
expect(outbound[1]?.session_mentions).toEqual([
|
expect(outbound[1]?.session_mentions).toEqual([
|
||||||
{ name: "plan", session_key: "websocket:plan" },
|
{ name: "plan", session_key: "websocket:plan" },
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ export interface MessageOptions {
|
|||||||
cliApps?: Array<{ name: string }>
|
cliApps?: Array<{ name: string }>
|
||||||
mcpPresets?: Array<{ name: string }>
|
mcpPresets?: Array<{ name: string }>
|
||||||
sessionMentions?: SessionMention[]
|
sessionMentions?: SessionMention[]
|
||||||
|
userShell?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
@@ -799,6 +800,7 @@ export class NanobotClient {
|
|||||||
content,
|
content,
|
||||||
turn_id: turnId,
|
turn_id: turnId,
|
||||||
webui: true,
|
webui: true,
|
||||||
|
...(options.userShell ? { user_shell: true } : {}),
|
||||||
...(options.cliApps?.length ? { cli_apps: options.cliApps } : {}),
|
...(options.cliApps?.length ? { cli_apps: options.cliApps } : {}),
|
||||||
...(options.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
|
...(options.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
|
||||||
...(options.sessionMentions?.length
|
...(options.sessionMentions?.length
|
||||||
|
|||||||
Reference in New Issue
Block a user