diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index c12670e8..582a1c5b 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -754,6 +754,11 @@ class WebSocketChannel(BaseChannel): if not content.strip() and not media_paths: await self._send_event(connection, "error", detail="missing content") return + # Auto-attach on first use so clients can one-shot without a separate attach. + self._attach(connection, cid) + await self._hydrate_after_subscribe(cid) + + # Resolve after hydration so a concurrent downgrade cannot be overwritten. scope = await self._workspace_scope_or_error( connection, lambda: self._workspaces.scope_for_message( @@ -767,9 +772,6 @@ class WebSocketChannel(BaseChannel): if scope is None: return - # Auto-attach on first use so clients can one-shot without a separate attach. - self._attach(connection, cid) - await self._hydrate_after_subscribe(cid) metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)} if envelope.get("webui") is True: metadata["webui"] = True diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 3c379dec..c5ec0a84 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -786,6 +786,76 @@ async def test_remote_webui_scope_allows_access_reduction( } +@pytest.mark.asyncio +async def test_remote_access_reduction_rejects_stale_in_flight_message_scope( + bus: MagicMock, + tmp_path, + monkeypatch, +) -> None: + monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui") + default_workspace = tmp_path / "default" + default_workspace.mkdir() + sessions = SessionManager(tmp_path / "sessions") + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"}, + bus, + gateway=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace), + ) + hydrate_started = asyncio.Event() + release_hydrate = asyncio.Event() + + async def blocked_hydrate(_chat_id: str) -> None: + hydrate_started.set() + await release_hydrate.wait() + + channel._hydrate_after_subscribe = blocked_hydrate + message_conn = AsyncMock() + message_conn.remote_address = ("203.0.113.8", 50123) + settings_conn = AsyncMock() + settings_conn.remote_address = ("203.0.113.8", 50124) + chat_id = "race-chat" + + message_task = asyncio.create_task( + channel._dispatch_envelope( + message_conn, + "remote-message", + { + "type": "message", + "chat_id": chat_id, + "content": "hello", + "webui": True, + "workspace_scope": { + "project_path": str(default_workspace), + "access_mode": "full", + }, + }, + ) + ) + await hydrate_started.wait() + + await channel._dispatch_envelope( + settings_conn, + "remote-settings", + { + "type": "set_workspace_scope", + "chat_id": chat_id, + "workspace_scope": { + "project_path": str(default_workspace), + "access_mode": "restricted", + }, + }, + ) + release_hydrate.set() + await message_task + + saved = sessions.read_session_file(f"websocket:{chat_id}") + assert saved["metadata"]["workspace_scope"]["access_mode"] == "restricted" + payload = json.loads(message_conn.send.await_args.args[0]) + assert payload["event"] == "error" + assert payload["detail"] == "workspace_scope_rejected" + bus.publish_inbound.assert_not_awaited() + + @pytest.mark.asyncio async def test_webui_scope_rejects_non_loopback_custom_scope(bus: MagicMock, tmp_path) -> None: default_workspace = tmp_path / "default"