187 lines
6.8 KiB
Python
187 lines
6.8 KiB
Python
"""RoomLink dispatch validation and hidden member-session ownership."""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import hmac
|
|
import time
|
|
from typing import Any
|
|
|
|
try:
|
|
from aiohttp import web
|
|
except ImportError:
|
|
web = None # type: ignore[assignment]
|
|
|
|
|
|
async def _ensure_hosted_member_session(self, dispatch: Any) -> str:
|
|
"""Create or verify the target's canonical hidden group session.
|
|
|
|
Reusing the ``Group: <room_id>`` namespace is intentional: a room that
|
|
moves from Desktop-assisted to hosted execution keeps one transcript.
|
|
A conflicting title with a different session id fails closed instead
|
|
of merging unrelated conversations.
|
|
"""
|
|
db = await self._ensure_session_db_async()
|
|
if db is None:
|
|
raise RuntimeError("session database unavailable")
|
|
title = f"Group: {dispatch.room_id}"
|
|
seed = (
|
|
f"{dispatch.home_install_id}\0{dispatch.room_id}\0"
|
|
f"{dispatch.member_id}\0{dispatch.target_profile}"
|
|
)
|
|
session_id = f"room_{hashlib.sha256(seed.encode()).hexdigest()[:32]}"
|
|
|
|
def ensure() -> str:
|
|
def atomic(conn):
|
|
row = conn.execute(
|
|
"SELECT id, title, source FROM sessions WHERE id=?",
|
|
(session_id,),
|
|
).fetchone()
|
|
if row is not None:
|
|
if row["title"] != title or row["source"] != "bot_room":
|
|
raise RuntimeError("room session identity conflicts with existing data")
|
|
return session_id
|
|
clean_title = db.sanitize_title(title)
|
|
conflict = conn.execute(
|
|
"SELECT id FROM sessions WHERE title=? AND id!=?",
|
|
(clean_title, session_id),
|
|
).fetchone()
|
|
if conflict:
|
|
raise RuntimeError(
|
|
"Another group already uses this room title on the target gateway. "
|
|
"Rename or migrate that group before retrying."
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO sessions(id, source, title, hidden, started_at) "
|
|
"VALUES(?, 'bot_room', ?, 1, ?)",
|
|
(session_id, clean_title, time.time()),
|
|
)
|
|
return session_id
|
|
|
|
return db._execute_write(atomic)
|
|
|
|
return await asyncio.to_thread(ensure)
|
|
|
|
|
|
async def _normalize_room_dispatch(
|
|
self,
|
|
request: "web.Request",
|
|
body: Any,
|
|
*,
|
|
_api_server,
|
|
) -> tuple[Any, "web.Response | None"]:
|
|
"""Validate and normalize a scoped RoomLink dispatch request."""
|
|
_api_request_profile = _api_server._api_request_profile
|
|
_openai_error = _api_server._openai_error
|
|
|
|
room_token = self._room_grant_token(request)
|
|
if not room_token:
|
|
return body, None
|
|
|
|
allowed_room_fields = {"input", "hosted_room_dispatch"}
|
|
if not isinstance(body, dict) or set(body) - allowed_room_fields:
|
|
return body, web.json_response(
|
|
_openai_error(
|
|
"Room dispatch accepts only input and hosted_room_dispatch.",
|
|
code="invalid_room_dispatch",
|
|
),
|
|
status=400,
|
|
)
|
|
try:
|
|
from gateway import hosted_rooms
|
|
from gateway.hosted_room_peer import (
|
|
GatewayRoomCatalog,
|
|
HostedMemberDispatch,
|
|
PROTOCOL_VERSION as ROOM_LINK_PROTOCOL_VERSION,
|
|
catalog_mapping,
|
|
verify_room_grant,
|
|
)
|
|
from gateway.hosted_room_execution_policy import (
|
|
RoomExecutionPolicy,
|
|
execution_policy_mapping,
|
|
)
|
|
|
|
dispatch = HostedMemberDispatch.from_mapping(
|
|
body.get("hosted_room_dispatch")
|
|
)
|
|
verify_room_grant(
|
|
self._room_grant_secret(),
|
|
room_token,
|
|
dispatch,
|
|
permission="dispatch",
|
|
)
|
|
active_profile = _api_request_profile.get() or "default"
|
|
local_install = hosted_rooms.local_authority_gateway_id()
|
|
if (
|
|
dispatch.target_profile != active_profile
|
|
or dispatch.target_install_id != local_install
|
|
):
|
|
raise ValueError("room dispatch target does not match this profile")
|
|
with self._profile_scope(active_profile):
|
|
execution_policy = execution_policy_mapping(
|
|
target_profile=active_profile
|
|
)
|
|
catalog = GatewayRoomCatalog.from_mapping(
|
|
catalog_mapping(
|
|
installation_id=local_install,
|
|
protocol_versions=(ROOM_LINK_PROTOCOL_VERSION,),
|
|
link_modes=("direct",),
|
|
persistent_process=True,
|
|
text=True,
|
|
attachments=False,
|
|
target_profile=active_profile,
|
|
execution_policy=execution_policy,
|
|
)
|
|
)
|
|
policy = RoomExecutionPolicy.from_mapping(
|
|
catalog.execution_policy.as_mapping()
|
|
)
|
|
if not hmac.compare_digest(
|
|
policy.policy_digest,
|
|
dispatch.execution_policy_digest,
|
|
):
|
|
raise ValueError("room execution policy changed")
|
|
if not hmac.compare_digest(
|
|
catalog.catalog_digest,
|
|
dispatch.capability_digest,
|
|
):
|
|
raise ValueError("room capability catalog changed")
|
|
supplied_input = body.get("input")
|
|
if supplied_input not in {None, dispatch.prompt}:
|
|
raise ValueError("room dispatch input does not match its prompt")
|
|
expected_key = f"room:{dispatch.task_id}:{dispatch.execution_generation}"
|
|
if request.headers.get("Idempotency-Key", "").strip() != expected_key:
|
|
raise ValueError("room dispatch idempotency key is invalid")
|
|
session_id = await self._ensure_hosted_member_session(dispatch)
|
|
return {
|
|
"input": dispatch.prompt,
|
|
"session_id": session_id,
|
|
"hosted_room_dispatch": dispatch.as_mapping(),
|
|
"_room_execution_policy": policy.as_mapping(),
|
|
}, None
|
|
except Exception as exc:
|
|
message = str(exc)
|
|
lowered = message.lower()
|
|
policy_changed = (
|
|
"execution policy" in lowered
|
|
or "remote room execution requires" in lowered
|
|
)
|
|
return body, web.json_response(
|
|
_openai_error(
|
|
(
|
|
"Room execution policy changed; reauthorization is required."
|
|
if policy_changed
|
|
else "Room capability catalog changed; reauthorization is required."
|
|
if "capability catalog changed" in lowered
|
|
else message
|
|
),
|
|
code=(
|
|
"room_execution_policy_changed"
|
|
if policy_changed
|
|
else "room_capability_catalog_changed"
|
|
if "capability catalog changed" in lowered
|
|
else "invalid_room_dispatch"
|
|
),
|
|
),
|
|
status=403,
|
|
)
|