fix(webui): prevent stale workspace scope restore

This commit is contained in:
Xubin Ren
2026-07-12 22:08:27 +08:00
parent 89acea6fe1
commit 45d1caba1d
2 changed files with 75 additions and 3 deletions
+5 -3
View File
@@ -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
+70
View File
@@ -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"