Add admin inject endpoint: cross-session inbound message injection
Test Suite / Detect changes (push) Canceled after 0s
Test Suite / Python (Windows, 3.14) (push) Canceled after 0s
Test Suite / Python (minimum, 3.11) (push) Canceled after 0s
Test Suite / Python (latest, 3.14 + coverage) (push) Canceled after 0s
Test Suite / webui (push) Canceled after 0s
Test Suite / docker (push) Canceled after 0s

POST /admin/inject?chat_id=...&content=... (auth: token_issue_secret,
same as /webui/bootstrap) publishes an InboundMessage into the target
session's queue, triggering a user turn in that chat. First piece of
the cross-session admin project.
This commit is contained in:
nanobot
2026-08-03 23:09:03 +08:00
parent 298b7ac6f6
commit 0afdd6cf09
2 changed files with 90 additions and 0 deletions
@@ -2838,6 +2838,48 @@ def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None:
assert body["api_token"] != body["token"]
async def test_admin_inject_requires_secret(bus: MagicMock) -> None:
"""Admin inject rejects requests without the token_issue_secret."""
channel = _ch(bus, tokenIssueSecret="s3cret")
resp = await channel.gateway.http._handle_admin_inject(
_FakeReq({}, path="/admin/inject")
)
assert resp.status_code == 401
bus.publish_inbound.assert_not_called()
async def test_admin_inject_publishes_inbound_message(bus: MagicMock) -> None:
"""Admin inject enqueues an InboundMessage for the target chat."""
channel = _ch(bus, tokenIssueSecret="s3cret")
path = (
"/admin/inject?chat_id=16765ad8-7222-488f-be1c-44680f4c06e2"
"&content=screen%20is%20black&sender_id=tester"
)
resp = await channel.gateway.http._handle_admin_inject(
_FakeReq({"Authorization": "Bearer s3cret"}, path=path)
)
assert resp.status_code == 200
body = json.loads(resp.body)
assert body["ok"] is True
assert body["chat_id"] == "16765ad8-7222-488f-be1c-44680f4c06e2"
bus.publish_inbound.assert_awaited_once()
msg = bus.publish_inbound.await_args.args[0]
assert msg.channel == "websocket"
assert msg.chat_id == "16765ad8-7222-488f-be1c-44680f4c06e2"
assert msg.content == "screen is black"
assert msg.sender_id == "tester"
async def test_admin_inject_requires_chat_id_and_content(bus: MagicMock) -> None:
"""Admin inject rejects missing chat_id or empty content."""
channel = _ch(bus, tokenIssueSecret="s3cret")
resp = await channel.gateway.http._handle_admin_inject(
_FakeReq({"Authorization": "Bearer s3cret"}, path="/admin/inject?chat_id=x")
)
assert resp.status_code == 400
bus.publish_inbound.assert_not_called()
def test_bootstrap_ws_url_uses_forwarded_https_host(bus: MagicMock) -> None:
channel = _ch(bus, host="127.0.0.1", port=29931, tokenIssueSecret="s3cret")
resp = channel.gateway.http._handle_bootstrap(
+48
View File
@@ -101,6 +101,8 @@ if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
from nanobot.triggers.local_store import LocalTriggerStore
from nanobot.bus.events import InboundMessage
def _decode_api_key(raw_key: str) -> str | None:
key = unquote(raw_key)
@@ -245,6 +247,10 @@ class GatewayHTTPHandler:
if got == "/webui/bootstrap":
return self._handle_bootstrap(connection, request)
# Admin: cross-session message injection
if got == "/admin/inject":
return await self._handle_admin_inject(request)
# Settings routes (delegated)
response = await self.settings_routes.dispatch(connection, request, got)
if response is not None:
@@ -372,6 +378,48 @@ class GatewayHTTPHandler:
expected_path = _normalize_config_path(self.config.path)
return f"{scheme}://{host}{expected_path}"
# -- Admin routes --------------------------------------------------------
async def _handle_admin_inject(self, request: WsRequest) -> Response:
"""Inject a user message into another session's turn queue (admin).
Auth: ``Authorization: Bearer <secret>`` or ``X-Nanobot-Auth: <secret>``
with the websocket ``token_issue_secret`` (or static token), same as
``/webui/bootstrap``. Query params: ``chat_id`` (required), ``content``
(required), ``sender_id`` (optional, default ``admin-inject``),
``channel`` (optional, default ``websocket``).
"""
secret = self.config.token_issue_secret.strip() or self.config.token.strip()
if not secret or not _issue_route_secret_matches(request.headers, secret):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
chat_id = (_query_first(query, "chat_id") or "").strip()
content = _query_first(query, "content")
if not chat_id or not content or not content.strip():
return _http_error(400, "chat_id and non-empty content are required")
if len(content) > 4000:
return _http_error(400, "content too long (max 4000 chars)")
sender_id = (_query_first(query, "sender_id") or "").strip() or "admin-inject"
channel = (_query_first(query, "channel") or "").strip() or "websocket"
msg = InboundMessage(
channel=channel,
sender_id=sender_id,
chat_id=chat_id,
content=content,
metadata={"admin_inject": True},
)
await self.bus.publish_inbound(msg)
self._log.info(
"Admin inject: {}:{} <- {} ({})",
channel,
chat_id,
sender_id,
content[:80],
)
return _http_json_response(
{"ok": True, "channel": channel, "chat_id": chat_id, "sender_id": sender_id}
)
# -- Session routes -----------------------------------------------------
async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None: