fix(webui): prevent stale workspace scope restore
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user