223 lines
7.8 KiB
Python
223 lines
7.8 KiB
Python
|
|
"""Build browser HUX scope only from an authenticated server session."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import hashlib
|
||
|
|
import hmac
|
||
|
|
import os
|
||
|
|
from pathlib import Path
|
||
|
|
import re
|
||
|
|
import secrets
|
||
|
|
import stat
|
||
|
|
import threading
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
|
||
|
|
_RAW_ID = re.compile(r"^[A-Za-z0-9._:@+-]{1,200}$")
|
||
|
|
_SLOT = re.compile(r"^slot-[0-9]{1,3}$")
|
||
|
|
_SUBJECT = re.compile(r"^usr_[0-9a-f]{64}$")
|
||
|
|
_KEY_BYTES = 32
|
||
|
|
_KEY_NAME = ".hux-context-key"
|
||
|
|
_DEFAULT_PROJECT_SOURCE = "profile:default"
|
||
|
|
_ID_PURPOSES = {
|
||
|
|
"ses": "session",
|
||
|
|
"conv": "conversation",
|
||
|
|
"prj": "project",
|
||
|
|
"run": "run",
|
||
|
|
"msg": "message",
|
||
|
|
}
|
||
|
|
_KEY_LOCK = threading.Lock()
|
||
|
|
_KEY_CACHE: tuple[Path, bytes] | None = None
|
||
|
|
|
||
|
|
|
||
|
|
class HuxContextUnavailable(Exception):
|
||
|
|
"""Signal that trusted context cannot be produced for this response."""
|
||
|
|
|
||
|
|
|
||
|
|
def _safe_value(value: Any, fallback: str | None = None) -> str:
|
||
|
|
candidate = value if isinstance(value, str) else ""
|
||
|
|
if _RAW_ID.fullmatch(candidate):
|
||
|
|
return candidate
|
||
|
|
if fallback is not None:
|
||
|
|
return fallback
|
||
|
|
raise HuxContextUnavailable("WebUI session identity is unavailable")
|
||
|
|
|
||
|
|
|
||
|
|
def _key_path() -> Path:
|
||
|
|
from api.config import STATE_DIR
|
||
|
|
|
||
|
|
configured = os.environ.get("HUX_CONTEXT_KEY_FILE", "").strip()
|
||
|
|
path = Path(configured) if configured else Path(STATE_DIR) / _KEY_NAME
|
||
|
|
if not path.is_absolute() or ".." in path.parts:
|
||
|
|
raise HuxContextUnavailable("HUX context key path is invalid")
|
||
|
|
root = path.parent
|
||
|
|
try:
|
||
|
|
info = root.stat(follow_symlinks=False)
|
||
|
|
except OSError as exc:
|
||
|
|
raise HuxContextUnavailable("HUX context state is unavailable") from exc
|
||
|
|
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid():
|
||
|
|
raise HuxContextUnavailable("HUX context state is unavailable")
|
||
|
|
return path
|
||
|
|
|
||
|
|
|
||
|
|
def _read_key(path: Path) -> bytes:
|
||
|
|
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||
|
|
try:
|
||
|
|
descriptor = os.open(path, flags)
|
||
|
|
try:
|
||
|
|
info = os.fstat(descriptor)
|
||
|
|
if (
|
||
|
|
not stat.S_ISREG(info.st_mode)
|
||
|
|
or info.st_uid != os.geteuid()
|
||
|
|
or stat.S_IMODE(info.st_mode) != 0o600
|
||
|
|
or info.st_nlink != 1
|
||
|
|
or info.st_size != _KEY_BYTES
|
||
|
|
):
|
||
|
|
raise HuxContextUnavailable("HUX context key is unsafe")
|
||
|
|
value = os.read(descriptor, _KEY_BYTES + 1)
|
||
|
|
finally:
|
||
|
|
os.close(descriptor)
|
||
|
|
except HuxContextUnavailable:
|
||
|
|
raise
|
||
|
|
except OSError as exc:
|
||
|
|
raise HuxContextUnavailable("HUX context key is unavailable") from exc
|
||
|
|
if len(value) != _KEY_BYTES:
|
||
|
|
raise HuxContextUnavailable("HUX context key is invalid")
|
||
|
|
return value
|
||
|
|
|
||
|
|
|
||
|
|
def _create_key(path: Path) -> None:
|
||
|
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0)
|
||
|
|
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||
|
|
value = secrets.token_bytes(_KEY_BYTES)
|
||
|
|
try:
|
||
|
|
descriptor = os.open(path, flags, 0o600)
|
||
|
|
except FileExistsError:
|
||
|
|
return
|
||
|
|
except OSError as exc:
|
||
|
|
raise HuxContextUnavailable("HUX context key cannot be created") from exc
|
||
|
|
try:
|
||
|
|
written = os.write(descriptor, value)
|
||
|
|
if written != len(value):
|
||
|
|
raise HuxContextUnavailable("HUX context key write was incomplete")
|
||
|
|
os.fsync(descriptor)
|
||
|
|
finally:
|
||
|
|
os.close(descriptor)
|
||
|
|
|
||
|
|
|
||
|
|
def _context_key() -> bytes:
|
||
|
|
global _KEY_CACHE
|
||
|
|
path = _key_path()
|
||
|
|
with _KEY_LOCK:
|
||
|
|
if _KEY_CACHE and _KEY_CACHE[0] == path:
|
||
|
|
return _KEY_CACHE[1]
|
||
|
|
_create_key(path)
|
||
|
|
value = _read_key(path)
|
||
|
|
_KEY_CACHE = (path, value)
|
||
|
|
return value
|
||
|
|
|
||
|
|
|
||
|
|
def _project_source() -> str:
|
||
|
|
"""Return the bounded server-owned source for the default HUX project."""
|
||
|
|
configured = os.environ.get("HUX_PROJECT_SOURCE", _DEFAULT_PROJECT_SOURCE).strip()
|
||
|
|
return _safe_value(configured)
|
||
|
|
|
||
|
|
|
||
|
|
def _authoritative_scalar(value: Any) -> str | None:
|
||
|
|
"""Return one bounded scalar already present in a trusted server payload."""
|
||
|
|
if isinstance(value, bool):
|
||
|
|
return None
|
||
|
|
candidate = str(value) if isinstance(value, (str, int)) else ""
|
||
|
|
return candidate if _RAW_ID.fullmatch(candidate) else None
|
||
|
|
|
||
|
|
|
||
|
|
def _latest_message_source(session: dict[str, Any]) -> str | None:
|
||
|
|
"""Find the newest server-stamped message identifier without inventing one."""
|
||
|
|
messages = session.get("messages")
|
||
|
|
if not isinstance(messages, list):
|
||
|
|
return None
|
||
|
|
for message in reversed(messages):
|
||
|
|
if not isinstance(message, dict):
|
||
|
|
continue
|
||
|
|
for field in ("id", "message_id"):
|
||
|
|
candidate = _authoritative_scalar(message.get(field))
|
||
|
|
if candidate is not None:
|
||
|
|
return candidate
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def derive_hux_id(key: bytes, prefix: str, purpose: str, slot: str, subject: str, raw: str) -> str:
|
||
|
|
"""Derive one cross-surface opaque ID from the shared server key."""
|
||
|
|
if (
|
||
|
|
not isinstance(key, bytes)
|
||
|
|
or len(key) != _KEY_BYTES
|
||
|
|
or _ID_PURPOSES.get(prefix) != purpose
|
||
|
|
or not _SLOT.fullmatch(slot)
|
||
|
|
or not _SUBJECT.fullmatch(subject)
|
||
|
|
or not _RAW_ID.fullmatch(raw)
|
||
|
|
):
|
||
|
|
raise HuxContextUnavailable("HUX context key is invalid")
|
||
|
|
message = "\0".join(("hux.context.id.v1", purpose, slot, subject, raw))
|
||
|
|
digest = hmac.new(key, message.encode("utf-8"), hashlib.sha256).hexdigest()[:32]
|
||
|
|
return f"{prefix}_{digest}"
|
||
|
|
|
||
|
|
|
||
|
|
def build_hux_context(handler: Any, session: dict[str, Any]) -> dict[str, Any]:
|
||
|
|
"""Return the exact context for one already-authorized server session."""
|
||
|
|
from api.hux_bff import _trusted_identity
|
||
|
|
|
||
|
|
if not isinstance(session, dict):
|
||
|
|
raise HuxContextUnavailable("WebUI session payload is unavailable")
|
||
|
|
raw_session = _safe_value(session.get("session_id"))
|
||
|
|
slot, subject, surface = _trusted_identity(handler)
|
||
|
|
raw_project = _project_source()
|
||
|
|
key = _context_key()
|
||
|
|
context = {
|
||
|
|
"schema": "hux.webui_context.v1",
|
||
|
|
"webui_session_id": raw_session,
|
||
|
|
"session_id": derive_hux_id(key, "ses", "session", slot, subject, raw_session),
|
||
|
|
"conversation_id": derive_hux_id(
|
||
|
|
key, "conv", "conversation", slot, subject, raw_session
|
||
|
|
),
|
||
|
|
"project_id": derive_hux_id(key, "prj", "project", slot, subject, raw_project),
|
||
|
|
"project_source": raw_project,
|
||
|
|
"identity": {
|
||
|
|
"tenant_slot": slot,
|
||
|
|
"subject": subject,
|
||
|
|
"surface": surface,
|
||
|
|
"trust": "relay",
|
||
|
|
},
|
||
|
|
}
|
||
|
|
# These optional values exist only when the upstream server has already
|
||
|
|
# minted them. A stream id is deliberately never treated as an agent run.
|
||
|
|
optional_sources = {
|
||
|
|
"run_id": ("run", "run", _authoritative_scalar(session.get("turn_id"))),
|
||
|
|
"message_id": ("msg", "message", _latest_message_source(session)),
|
||
|
|
"branch_point_message_id": (
|
||
|
|
"msg",
|
||
|
|
"message",
|
||
|
|
_authoritative_scalar(session.get("branch_point_message_id")),
|
||
|
|
),
|
||
|
|
}
|
||
|
|
for field, (prefix, purpose, source) in optional_sources.items():
|
||
|
|
if source is not None:
|
||
|
|
context[field] = derive_hux_id(key, prefix, purpose, slot, subject, source)
|
||
|
|
notebook = session.get("notebook_id")
|
||
|
|
if isinstance(notebook, str) and re.fullmatch(r"nb_[A-Za-z0-9._-]{4,80}", notebook):
|
||
|
|
context["notebook_id"] = notebook
|
||
|
|
return context
|
||
|
|
|
||
|
|
|
||
|
|
def attach_hux_context(handler: Any, session: Any) -> Any:
|
||
|
|
"""Attach trusted context or return a clean, feature-disabled payload."""
|
||
|
|
if not isinstance(session, dict):
|
||
|
|
return session
|
||
|
|
clean = dict(session)
|
||
|
|
clean.pop("hux_context", None)
|
||
|
|
try:
|
||
|
|
clean["hux_context"] = build_hux_context(handler, clean)
|
||
|
|
except Exception:
|
||
|
|
# Chat remains available when auth, state, or the optional HUX path fails.
|
||
|
|
clean.pop("hux_context", None)
|
||
|
|
return clean
|