feat(browser): add scoped artifact endpoints, broker permission gates, and companion journal

This commit is contained in:
abundantbeing
2026-08-19 23:08:41 +07:00
committed by kshitij
parent 2039b572f5
commit 1977c3d2eb
4 changed files with 1570 additions and 5 deletions
+493
View File
@@ -0,0 +1,493 @@
"""One-shot artifact transport for browser control (Gateway side).
Phase 8 Task 29: authenticated one-shot HTTPS upload/download of bounded
browser-control artifacts (screenshots, PDFs, uploads) with SHA-256
validation, exact MIME/size caps, a controlled artifact root, and TTL
cleanup. This module is the transport-neutral store core: it knows nothing
about aiohttp or the API server — the routes in
:mod:`gateway.platforms.api_server` authenticate callers and enforce rate
limits, then hand bytes to this store.
Why a store at all: the controller WebSocket is a command channel, not a
file pipe. A controller action that needs bytes (a screenshot upload, a
downloaded PDF) references an artifact by its server-minted id; the agent
side later retrieves it over HTTPS. Base64 screenshots or files in
controller WebSocket frames are therefore structurally impossible: the
frame carries only ``artifact_id`` strings, and the bytes live on disk
under a controlled root for a short TTL.
Contract (exercised by tests/gateway/test_browser_control_artifacts.py):
- **Server-minted ids, no traversal.** ``store`` assigns a fresh random hex
id; ``_artifact_path`` accepts only ``[0-9a-f]{N}`` ids and resolves them
strictly inside the root. Client-supplied filenames are metadata only
and never become filesystem paths.
- **Exact size and MIME caps.** ``store`` rejects bytes above
``max_bytes`` and any content type outside the configured allowlist
before anything touches the disk.
- **SHA-256 provenance.** Every artifact is stored with its ``sha256``,
returned in the receipt, and re-verified by ``load``/``validate`` so a
corrupted or tampered file can never be handed to a caller.
- **One-shot, scope-bound downloads.** ``load`` requires the exact scope
key the artifact was stored under and deletes the artifact atomically on
success. ``validate`` (used by the broker for "approved artifact id
only" gating) checks existence, TTL, and scope without consuming.
- **No overwrite.** Ids are random and ``store`` refuses to overwrite an
existing id (a collision is retried with a fresh id).
- **TTL cleanup.** ``prune_expired`` removes expired entries; the API
server sweeps on demand. Nothing in the store is allowed to outlive its
TTL by more than the sweep interval.
Thread-safety: the in-memory index is guarded by a lock; files are written
to a temp name and atomically renamed into place so a concurrent ``load``
never observes a partially written artifact.
"""
from __future__ import annotations
import hashlib
import logging
import os
import re
import secrets
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
#: Default lifetime of a stored artifact, in clock seconds.
DEFAULT_ARTIFACT_TTL_SECONDS = 300.0
#: Default per-artifact byte cap (10 MiB).
DEFAULT_MAX_ARTIFACT_BYTES = 10 * 1024 * 1024
#: Default exact MIME allowlist. Unknown or parameterized variants are
#: rejected; clients must send the canonical registered type.
DEFAULT_ALLOWED_MIME_TYPES = frozenset(
{
"application/json",
"application/pdf",
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
"text/plain",
}
)
#: Length in hex chars of a minted artifact id.
_ARTIFACT_ID_HEX = 32
_ARTIFACT_ID_RE = re.compile(r"^[0-9a-f]{32}$")
_TEMP_SUFFIX = ".tmp"
class ArtifactError(Exception):
"""Base class for artifact store contract failures."""
class ArtifactNotFound(ArtifactError):
"""The artifact id is unknown (or already consumed)."""
class ArtifactExpired(ArtifactError):
"""The artifact outlived its TTL."""
class ArtifactTooLarge(ArtifactError):
"""The upload exceeds the configured byte cap."""
class ArtifactMimeRejected(ArtifactError):
"""The content type is outside the exact allowlist."""
class ArtifactScopeMismatch(ArtifactError):
"""The artifact exists but belongs to a different scope."""
class ArtifactChecksumMismatch(ArtifactError):
"""The stored bytes do not match the recorded SHA-256."""
class ArtifactTraversal(ArtifactError):
"""A caller-supplied id is not a valid minted artifact id."""
class ArtifactOverwrite(ArtifactError):
"""An artifact id already exists and the store refuses to overwrite it."""
@dataclass(frozen=True)
class ArtifactReceipt:
"""Provenance record returned to the caller of ``store``."""
artifact_id: str
sha256: str
size_bytes: int
content_type: str
filename: str
created_at: float
expires_at: float
ttl_seconds: float
scope_key: str
def to_dict(self, *, download_path: str = "") -> dict[str, Any]:
"""Serialize to the wire receipt (never contains file paths)."""
receipt = {
"artifact_id": self.artifact_id,
"sha256": self.sha256,
"size_bytes": self.size_bytes,
"content_type": self.content_type,
"filename": self.filename,
"created_at": self.created_at,
"expires_at": self.expires_at,
"ttl_seconds": self.ttl_seconds,
"one_shot": True,
}
if download_path:
receipt["download_path"] = download_path
return receipt
def artifact_scope_key(scope: Any) -> str:
"""Derive the stable scope key an artifact is bound to.
Only server-derived identity fields participate: principal (mandatory),
plus session and transport family when the caller resolved them
(mirroring the broker's exact-identity contract). Capabilities and
optional ids are intentionally excluded so a reconnect that refreshes
the same controller keeps its artifacts.
The API-server artifact routes authenticate by API key and bind to the
derived principal; the broker additionally binds to the full
controller scope. A principal-only key and a full controller key
never collide because the digest input differs.
"""
principal = ""
session = ""
family = ""
try:
principal = str(getattr(scope, "principal_id", "") or "")
session = str(getattr(scope, "session_id", "") or "")
family = str(getattr(scope, "transport_family", "") or "")
except Exception:
pass
if not principal:
# Fail closed: an artifact can only be minted for an authenticated
# principal.
raise ArtifactError("artifact scope must carry a resolved principal")
material = f"{principal}\x00{session}\x00{family}".encode("utf-8")
return hashlib.sha256(material).hexdigest()
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
@dataclass
class _ArtifactEntry:
receipt: ArtifactReceipt
path: Path
class ArtifactStore:
"""Thread-safe, TTL-bounded, scope-bound one-shot artifact store."""
def __init__(
self,
root: Path,
*,
ttl_seconds: float = DEFAULT_ARTIFACT_TTL_SECONDS,
max_bytes: int = DEFAULT_MAX_ARTIFACT_BYTES,
allowed_mime_types: frozenset = DEFAULT_ALLOWED_MIME_TYPES,
clock: Optional[Callable[[], float]] = None,
) -> None:
self._root = Path(root)
self._root.mkdir(parents=True, exist_ok=True)
self._ttl_seconds = max(1.0, float(ttl_seconds))
self._max_bytes = max(1, int(max_bytes))
self._allowed_mime_types = frozenset(allowed_mime_types)
self._clock = clock if clock is not None else time.time
self._lock = threading.RLock()
self._entries: dict[str, _ArtifactEntry] = {}
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
@property
def root(self) -> Path:
"""Controlled artifact root (never exposed to callers by default)."""
return self._root
@property
def ttl_seconds(self) -> float:
return self._ttl_seconds
@property
def max_bytes(self) -> int:
return self._max_bytes
@property
def allowed_mime_types(self) -> frozenset:
return self._allowed_mime_types
def store(
self,
data: bytes,
*,
filename: str,
content_type: str,
scope: Any,
) -> ArtifactReceipt:
"""Validate and store one artifact, returning its provenance receipt.
Raises :class:`ArtifactTooLarge` / :class:`ArtifactMimeRejected`
before any disk write; :class:`ArtifactError` if the scope is not a
fully resolved browser-control scope.
"""
size = len(data)
if size > self._max_bytes:
raise ArtifactTooLarge(
f"artifact is {size} bytes; cap is {self._max_bytes}"
)
normalized_type = _normalize_content_type(content_type)
if normalized_type not in self._allowed_mime_types:
raise ArtifactMimeRejected(
f"content type {content_type!r} is outside the exact allowlist"
)
scope_key = artifact_scope_key(scope)
now = self._clock()
# Mint a fresh id; retry on an astronomically unlikely collision.
while True:
artifact_id = secrets.token_hex(_ARTIFACT_ID_HEX // 2)
target = self._artifact_path(artifact_id)
with self._lock:
if artifact_id in self._entries:
continue
if target.exists():
continue
receipt = ArtifactReceipt(
artifact_id=artifact_id,
sha256=_sha256(data),
size_bytes=size,
content_type=normalized_type,
filename=_bounded_filename(filename),
created_at=now,
expires_at=now + self._ttl_seconds,
ttl_seconds=self._ttl_seconds,
scope_key=scope_key,
)
entry = _ArtifactEntry(receipt=receipt, path=target)
self._entries[artifact_id] = entry
break
# Write via temp + atomic rename so readers never observe a
# partially written artifact.
temp = target.with_name(f"{target.name}{_TEMP_SUFFIX}")
try:
with open(temp, "wb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp, target)
except Exception:
with self._lock:
self._entries.pop(artifact_id, None)
try:
temp.unlink(missing_ok=True)
except Exception:
pass
raise
return receipt
def validate(self, artifact_id: str, *, scope: Any) -> ArtifactReceipt:
"""Return the receipt when the artifact is live for ``scope``.
Used by the broker's "approved artifact id only" gate: checks
existence, TTL, and scope without consuming the artifact. Raises
the appropriate :class:`ArtifactError` subclass otherwise.
"""
return self._entry_for(artifact_id, scope=scope).receipt
def load(self, artifact_id: str, *, scope: Any) -> tuple[bytes, ArtifactReceipt]:
"""One-shot download: verify, read, checksum, then consume.
Returns ``(bytes, receipt)`` and atomically deletes the artifact so
a second ``load`` raises :class:`ArtifactNotFound`. Raises
:class:`ArtifactChecksumMismatch` (without consuming) if the file
on disk does not match the recorded SHA-256.
"""
with self._lock:
entry = self._entry_for(artifact_id, scope=scope)
path = entry.path
if not path.exists():
self._entries.pop(artifact_id, None)
raise ArtifactNotFound(f"artifact {artifact_id!r} is gone")
try:
data = path.read_bytes()
except OSError as exc:
raise ArtifactError(f"artifact read failed: {exc}") from exc
if _sha256(data) != entry.receipt.sha256:
raise ArtifactChecksumMismatch(
f"artifact {artifact_id!r} failed SHA-256 validation"
)
# Consume atomically: remove the index entry first so a
# concurrent load fails closed, then delete the file.
self._entries.pop(artifact_id, None)
try:
path.unlink(missing_ok=True)
except OSError:
logger.warning("artifact %s: file removal failed; TTL sweep will retry", artifact_id)
return data, entry.receipt
def prune_expired(self, now: Optional[float] = None) -> int:
"""Delete every artifact past its TTL; return the count removed.
Also removes orphaned temp files older than one sweep. Idempotent
and safe to call on any request or a periodic sweep.
"""
now = self._clock() if now is None else float(now)
removed = 0
with self._lock:
for artifact_id, entry in list(self._entries.items()):
if entry.receipt.expires_at <= now:
self._entries.pop(artifact_id, None)
try:
entry.path.unlink(missing_ok=True)
except OSError:
pass
removed += 1
for temp in self._root.glob(f"*{_TEMP_SUFFIX}"):
try:
if temp.stat().st_mtime <= now - self._ttl_seconds:
temp.unlink(missing_ok=True)
except OSError:
continue
return removed
def count(self) -> int:
"""Number of live (unconsumed, not-yet-pruned) artifacts."""
with self._lock:
return len(self._entries)
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _entry_for(self, artifact_id: str, *, scope: Any) -> _ArtifactEntry:
path = self._artifact_path(artifact_id)
scope_key = artifact_scope_key(scope)
now = self._clock()
with self._lock:
entry = self._entries.get(artifact_id)
# Check the target's own expiry BEFORE sweeping other entries so
# an expired artifact surfaces as ArtifactExpired rather than
# silently vanishing into the sweep.
if entry is None:
self._prune_expired_locked(now)
entry = self._entries.get(artifact_id)
if entry is None:
raise ArtifactNotFound(f"unknown artifact {artifact_id!r}")
if entry.receipt.expires_at <= now:
self._entries.pop(artifact_id, None)
try:
path.unlink(missing_ok=True)
except OSError:
pass
raise ArtifactExpired(f"artifact {artifact_id!r} expired")
if entry.receipt.scope_key != scope_key:
raise ArtifactScopeMismatch(
f"artifact {artifact_id!r} is bound to a different scope"
)
return entry
def _prune_expired_locked(self, now: float) -> None:
for artifact_id, entry in list(self._entries.items()):
if entry.receipt.expires_at <= now:
self._entries.pop(artifact_id, None)
try:
entry.path.unlink(missing_ok=True)
except OSError:
pass
def _artifact_path(self, artifact_id: str) -> Path:
"""Resolve a minted id strictly inside the controlled root."""
if not isinstance(artifact_id, str) or not _ARTIFACT_ID_RE.fullmatch(artifact_id):
raise ArtifactTraversal(f"invalid artifact id {artifact_id!r}")
candidate = (self._root / artifact_id).resolve()
try:
root_resolved = self._root.resolve()
except OSError:
root_resolved = self._root.absolute()
if candidate.parent != root_resolved or candidate.name != artifact_id:
raise ArtifactTraversal(f"artifact path escapes root for {artifact_id!r}")
return candidate
def _normalize_content_type(value: str) -> str:
"""Return the canonical MIME type, or ``""`` for malformed input."""
if not isinstance(value, str):
return ""
return value.strip().split(";", 1)[0].strip().lower()
def _bounded_filename(value: str, limit: int = 160) -> str:
"""Sanitize a display-only filename; never used as a filesystem path."""
if not isinstance(value, str):
return ""
cleaned = value.strip().replace("\\", "_").replace("/", "_")
cleaned = "".join(character for character in cleaned if ord(character) >= 32)
return cleaned[:limit]
# ----------------------------------------------------------------------
# Rate limiting (route-level, per principal)
# ----------------------------------------------------------------------
class ArtifactRateLimiter:
"""Sliding-window per-key limiter for artifact routes.
The API server keys this by the authenticated principal so a single
key cannot flood the store. Injected clock makes tests deterministic.
"""
def __init__(
self,
*,
window_seconds: float = 60.0,
max_requests: int = 30,
clock: Optional[Callable[[], float]] = None,
) -> None:
self._window_seconds = max(1.0, float(window_seconds))
self._max_requests = max(1, int(max_requests))
self._clock = clock if clock is not None else time.time
self._lock = threading.Lock()
self._hits: dict[str, list[float]] = {}
def allow(self, key: str) -> bool:
"""Return True when ``key`` is under the window cap; else False."""
if not isinstance(key, str) or not key:
return False
now = self._clock()
window_start = now - self._window_seconds
with self._lock:
hits = [hit for hit in self._hits.get(key, []) if hit > window_start]
if len(hits) >= self._max_requests:
self._hits[key] = hits
return False
hits.append(now)
self._hits[key] = hits
return True
def reset(self, key: str) -> None:
"""Drop the recorded hits for ``key`` (tests/diagnostics)."""
with self._lock:
self._hits.pop(key, None)
+150 -4
View File
@@ -105,25 +105,96 @@ BROWSER_CONTROL_CAPABILITIES = frozenset(
}
)
#: Privileged capabilities (Phase 8 Task 30) that are never negotiable through
#: the base allowlist. ``browser_evaluate`` executes JavaScript in the page
#: context; ``browser_cdp`` is raw CDP. Both are fail-closed unless the broker
#: runs in Developer Mode (``browser.extension_control.developer_mode``) AND
#: the controller explicitly negotiated the capability.
BROWSER_CONTROL_DEVELOPER_CAPABILITIES = frozenset(
{
"browser_cdp",
"browser_evaluate",
}
)
#: Artifact-transport capabilities (Phase 8 Task 29). These are regular
#: (non-developer) capabilities because upload/download of bounded, validated
#: artifacts is a safe surface; the payloads are never carried in controller
#: frames. Artifact actions are dispatched only after the broker validates the
#: referenced artifact id against the attached store ("approved artifact id
#: only").
BROWSER_CONTROL_ARTIFACT_CAPABILITIES = frozenset(
{
"browser_artifact_download",
"browser_artifact_upload",
}
)
#: The complete set a controller may negotiate: base + artifact. Developer
#: capabilities are admitted by :func:`filter_browser_control_capabilities`
#: only when Developer Mode is enabled.
BROWSER_CONTROL_ALL_CAPABILITIES = frozenset(
BROWSER_CONTROL_CAPABILITIES
| BROWSER_CONTROL_ARTIFACT_CAPABILITIES
| BROWSER_CONTROL_DEVELOPER_CAPABILITIES
)
def browser_control_protocol_supported(value: Any) -> bool:
"""Return whether ``value`` names the exact supported wire version."""
return type(value) is int and value == BROWSER_CONTROL_PROTOCOL_VERSION
def filter_browser_control_capabilities(value: Any) -> frozenset:
def browser_control_developer_mode(config: Optional[dict] = None) -> bool:
"""Return the explicit Developer Mode flag (disabled by default).
Reads ``browser.extension_control.developer_mode`` from the global
config. Developer Mode is the *additional* gate for ``browser_evaluate``
and raw CDP; it never widens the base action allowlist on its own.
"""
if config is None:
try:
from hermes_cli.config import load_config
config = load_config()
except Exception:
return False
if not isinstance(config, dict):
return False
browser = config.get("browser")
if not isinstance(browser, dict):
return False
extension_control = browser.get("extension_control")
if not isinstance(extension_control, dict):
return False
return extension_control.get("developer_mode", False) is True
def filter_browser_control_capabilities(
value: Any,
*,
developer_mode: Optional[bool] = None,
) -> frozenset:
"""Return the permitted subset of a JSON/RPC capability list.
A malformed non-list value has no capabilities. Unknown or non-string
entries are ignored; registration rejects an empty returned set.
Base and artifact capabilities always pass. Developer capabilities
(``browser_evaluate``, ``browser_cdp``) pass only when Developer Mode
is explicitly enabled — either passed in or read from the live config.
"""
if not isinstance(value, list):
return frozenset()
allowed = frozenset(BROWSER_CONTROL_CAPABILITIES | BROWSER_CONTROL_ARTIFACT_CAPABILITIES)
if developer_mode is None:
developer_mode = browser_control_developer_mode()
if developer_mode is True:
allowed = frozenset(allowed | BROWSER_CONTROL_DEVELOPER_CAPABILITIES)
return frozenset(
capability
for capability in value
if isinstance(capability, str)
and capability in BROWSER_CONTROL_CAPABILITIES
if isinstance(capability, str) and capability in allowed
)
#: Wire method names for controller frames. Transport-neutral by contract:
@@ -251,6 +322,7 @@ class BrowserControlBroker:
ticket_ttl: float = DEFAULT_TICKET_TTL,
command_timeout: float = DEFAULT_COMMAND_TIMEOUT,
clock: Optional[Callable[[], float]] = None,
developer_mode: Optional[bool] = None,
) -> None:
self._ticket_ttl = ticket_ttl
self._command_timeout = command_timeout
@@ -259,6 +331,29 @@ class BrowserControlBroker:
self._tickets: Dict[str, _TicketRecord] = {}
self._controllers: Dict[ControllerScope, _Controller] = {}
self._pending: Dict[str, _PendingCommand] = {}
# Developer Mode gates privileged capabilities (browser_evaluate,
# browser_cdp). None defers to the live config on every dispatch so
# a mid-process config change is honored without restart; an explicit
# bool pins the gate for tests and multi-tenant hosts.
if developer_mode is None:
developer_mode = browser_control_developer_mode()
self._developer_mode = developer_mode is True
self._artifact_store: Any = None
def attach_artifact_store(self, store: Any) -> None:
"""Attach the process artifact store for "approved artifact id only".
``store`` must expose ``validate(artifact_id, *, scope) -> receipt``
raising the artifacts module's :class:`ArtifactError` subclasses.
None clears the reference; dispatching an artifact action without a
store fails closed.
"""
self._artifact_store = store
@property
def developer_mode(self) -> bool:
"""Whether privileged capabilities may be selected/dispatched."""
return self._developer_mode
# ------------------------------------------------------------------
# Registration tickets
@@ -403,7 +498,16 @@ class BrowserControlBroker:
matches the stable identity fields, then checks the attached
controller's current negotiated capability set. Offline controllers
preserve old pending work but never accept new dispatches.
Privileged capabilities (``browser_evaluate``, ``browser_cdp``) are
additionally gated on Developer Mode: with the gate off they are
never selectable, even when a controller somehow negotiated them.
"""
if (
capability in BROWSER_CONTROL_DEVELOPER_CAPABILITIES
and not self._developer_mode
):
return None
with self._lock:
matches = [
controller
@@ -520,6 +624,14 @@ class BrowserControlBroker:
Exactly one pending command exists per command id; ``complete`` is
single-shot, so a command can never resolve twice.
Artifact actions (``browser_artifact_upload`` /
``browser_artifact_download``) additionally require an attached
artifact store and a live, scope-bound artifact reference: the
``arguments`` mapping must carry an ``artifact_id`` whose validation
passes against the store ("approved artifact id only"). The payload
is never carried in the frame — only the id travels to the
controller.
"""
controller = self.select(scope, action)
if controller is None:
@@ -527,13 +639,17 @@ class BrowserControlBroker:
f"no controller for scope {scope!r} with capability {action!r}"
)
arguments = dict(arguments or {})
if action in BROWSER_CONTROL_ARTIFACT_CAPABILITIES:
self._validate_artifact_reference(scope, action, arguments)
command_id = secrets.token_hex(16)
frame = {
"method": FRAME_COMMAND,
"params": {
"command_id": command_id,
"action": action,
"arguments": dict(arguments or {}),
"arguments": arguments,
"controller_id": scope.controller_id,
"browser_profile_id": scope.browser_profile_id,
"tool_call_id": tool_call_id,
@@ -698,6 +814,36 @@ class BrowserControlBroker:
del self._pending[pending.command_id]
pending.event.set()
def _validate_artifact_reference(
self,
scope: ControllerScope,
action: str,
arguments: dict,
) -> None:
"""Fail closed unless ``arguments`` carries an approved artifact id.
The store is consulted through the duck-typed ``validate`` contract
(raises :class:`ArtifactError` subclasses on any problem), so the
broker never guesses at artifact validity: missing store, missing id,
traversal, expiry, checksum, or scope mismatch all surface as
:class:`ControllerRejected` before any frame is emitted.
"""
if self._artifact_store is None:
raise ControllerRejected(
f"{action} requires an attached artifact store"
)
artifact_id = arguments.get("artifact_id")
if not isinstance(artifact_id, str) or not artifact_id.strip():
raise ControllerRejected(f"{action} requires a non-empty artifact_id")
try:
self._artifact_store.validate(artifact_id.strip(), scope=scope)
except ControllerRejected:
raise
except Exception as exc:
raise ControllerRejected(
f"{action} rejected artifact reference {artifact_id!r}: {exc}"
) from exc
@staticmethod
def _cancel_frame(pending: _PendingCommand) -> dict:
return {
+336 -1
View File
@@ -78,6 +78,21 @@ _api_request_browser_control_transport_family: ContextVar[str] = ContextVar(
"api_server_browser_control_transport_family", default=""
)
#: Minimal scope shape accepted by :func:`gateway.browser_control_artifacts
#: .artifact_scope_key`: principal + session + transport family. The API
#: server authenticates the caller itself, so the facade carries only the
#: server-derived principal and the loopback/remote family.
class _ArtifactScopeFacade:
__slots__ = ("principal_id", "session_id", "transport_family")
def __init__(self, principal_id: str, *, session_id: str = "", transport_family: str = ""):
self.principal_id = principal_id
self.session_id = session_id
self.transport_family = transport_family
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"_ArtifactScopeFacade(principal={self.principal_id!r})"
#: Browser-extension control protocol version advertised in capabilities and
#: echoed in registration responses. Strict validation is centralized in the
#: broker's ``browser_control_protocol_supported`` helper.
@@ -109,10 +124,22 @@ from gateway.platforms.base import (
from agent.redact import redact_sensitive_text
from agent.interrupt_compat import request_hard_interrupt
from gateway.readiness import collect_runtime_readiness
from gateway.browser_control_artifacts import (
ArtifactError,
ArtifactRateLimiter,
ArtifactStore,
ArtifactTooLarge,
DEFAULT_ALLOWED_MIME_TYPES,
DEFAULT_MAX_ARTIFACT_BYTES,
DEFAULT_ARTIFACT_TTL_SECONDS,
)
from gateway.browser_control_broker import (
BROWSER_CONTROL_ARTIFACT_CAPABILITIES,
BROWSER_CONTROL_CAPABILITIES,
BROWSER_CONTROL_DEVELOPER_CAPABILITIES,
ControllerScope,
TicketInvalid,
browser_control_developer_mode,
browser_control_protocol_supported,
filter_browser_control_capabilities,
get_browser_control_broker,
@@ -1556,6 +1583,11 @@ class APIServerAdapter(BasePlatformAdapter):
# and command lifecycle shared with the dashboard Gateway transport. This adapter only maps HTTP registration and the
# controller WebSocket onto the broker; it owns no broker state.
self._browser_control_broker = get_browser_control_broker()
# One-shot artifact transport (Phase 8 Task 29). Lazy store + limiter
# are created on first authenticated artifact use; tests inject their
# own store/limiter via _inject_browser_control_artifacts().
self._browser_control_artifacts: Optional[ArtifactStore] = None
self._browser_control_artifact_limiter: Optional[ArtifactRateLimiter] = None
def active_agent_work_count(self) -> int:
"""Return all live agent work owned by this API adapter.
@@ -2161,6 +2193,12 @@ class APIServerAdapter(BasePlatformAdapter):
# and API-key auth (see the handlers for the exact status ladder).
("POST", "/v1/browser-control/register", self._handle_browser_control_register),
("GET", "/v1/browser-control/ws", self._handle_browser_control_ws),
# One-shot artifact transport (Phase 8 Task 29): bounded, SHA-256
# validated HTTPS upload/download bound to a browser-control
# scope. Gated identically to registration (feature flag + API
# key) plus per-principal rate limits.
("POST", "/v1/artifacts/upload", self._handle_artifact_upload),
("GET", "/v1/artifacts/download/{artifact_id}", self._handle_artifact_download),
("GET", "/v1/skills", self._handle_skills),
("GET", "/v1/toolsets", self._handle_toolsets),
("GET", "/api/sessions", self._handle_list_sessions),
@@ -3299,6 +3337,19 @@ class APIServerAdapter(BasePlatformAdapter):
"enabled": self._browser_control_enabled(),
"protocol_version": _BROWSER_CONTROL_PROTOCOL_VERSION,
"capabilities": sorted(BROWSER_CONTROL_CAPABILITIES),
"artifact_capabilities": sorted(BROWSER_CONTROL_ARTIFACT_CAPABILITIES),
"developer_capabilities": sorted(BROWSER_CONTROL_DEVELOPER_CAPABILITIES),
"developer_mode": self._browser_control_developer_mode(),
"artifact_transport": {
"upload": {"method": "POST", "path": "/v1/artifacts/upload"},
"download": {
"method": "GET",
"path": "/v1/artifacts/download/{artifact_id}",
},
"max_bytes": DEFAULT_MAX_ARTIFACT_BYTES,
"ttl_seconds": DEFAULT_ARTIFACT_TTL_SECONDS,
"allowed_mime_types": sorted(DEFAULT_ALLOWED_MIME_TYPES),
},
"real_browser_actions": True,
"transports": {
"local_vps": "websocket-subprotocol-ticket",
@@ -3333,6 +3384,11 @@ class APIServerAdapter(BasePlatformAdapter):
"session_model_lock": {"method": "POST", "path": "/api/sessions/{session_id}/model"},
"browser_control_register": {"method": "POST", "path": "/v1/browser-control/register"},
"browser_control_ws": {"method": "GET", "path": "/v1/browser-control/ws"},
"artifact_upload": {"method": "POST", "path": "/v1/artifacts/upload"},
"artifact_download": {
"method": "GET",
"path": "/v1/artifacts/download/{artifact_id}",
},
},
})
@@ -3437,7 +3493,8 @@ class APIServerAdapter(BasePlatformAdapter):
profile = _api_request_profile.get() or "default"
capabilities = filter_browser_control_capabilities(
payload.get("capabilities")
payload.get("capabilities"),
developer_mode=self._browser_control_developer_mode(),
)
if not capabilities:
return web.json_response(
@@ -3447,6 +3504,20 @@ class APIServerAdapter(BasePlatformAdapter):
),
status=400,
)
# Developer capabilities may only be negotiated while the broker
# itself runs in Developer Mode (fail closed even if a registration
# somehow slipped through the filter).
if (
capabilities & BROWSER_CONTROL_DEVELOPER_CAPABILITIES
and not self._browser_control_developer_mode()
):
return web.json_response(
_openai_error(
"Developer Mode is required for browser_evaluate and raw CDP.",
code="browser_control_developer_mode_required",
),
status=403,
)
scope = ControllerScope(
principal_id=self._derive_browser_control_principal(profile),
profile_id=profile,
@@ -3661,6 +3732,270 @@ class APIServerAdapter(BasePlatformAdapter):
return "local-api"
return "remote-api"
def _browser_control_developer_mode(self) -> bool:
"""Developer Mode flag; False unless explicitly enabled.
Mirrors the broker's gate for ``browser_evaluate`` and raw CDP.
Tests monkeypatch this method directly to force the gate on/off.
"""
try:
return browser_control_developer_mode()
except Exception:
return False
# ------------------------------------------------------------------
# One-shot artifact transport (Phase 8 Task 29)
# ------------------------------------------------------------------
def _artifact_store_for(self, profile: str) -> ArtifactStore:
"""Return the profile-scoped artifact store, creating it lazily.
The store root lives under the profile's data directory
(``<HERMES_HOME>/plugin-data/.../artifacts``-style controlled root),
so artifacts never escape the profile boundary. The root itself is
created on first use; TTL cleanup runs on every store/load/prune.
"""
if self._browser_control_artifacts is not None:
return self._browser_control_artifacts
try:
from hermes_cli.profiles import get_profile_dir
profile_root = get_profile_dir(profile or "default")
root = Path(profile_root) / "artifacts" / "browser-control"
except Exception:
# Unscoped fallback used only when profile resolution is
# unavailable (tests/manual wiring): keep the controlled root
# under the Hermes home.
try:
from hermes_state import get_hermes_home
root = Path(get_hermes_home()) / "artifacts" / "browser-control"
except Exception:
raise ArtifactError("no artifact root is resolvable") from None
store = ArtifactStore(
root,
ttl_seconds=DEFAULT_ARTIFACT_TTL_SECONDS,
max_bytes=DEFAULT_MAX_ARTIFACT_BYTES,
allowed_mime_types=DEFAULT_ALLOWED_MIME_TYPES,
)
store.prune_expired()
self._browser_control_artifacts = store
# Share the store with the broker so artifact actions dispatched to a
# controller validate their artifact reference against the same
# controlled root ("approved artifact id only").
try:
self._browser_control_broker.attach_artifact_store(store)
except Exception:
logger.debug("could not attach artifact store to broker", exc_info=True)
return store
def _artifact_limiter(self) -> ArtifactRateLimiter:
"""Return the per-principal artifact route limiter (lazy)."""
if self._browser_control_artifact_limiter is None:
self._browser_control_artifact_limiter = ArtifactRateLimiter(
window_seconds=60.0,
max_requests=30,
)
return self._browser_control_artifact_limiter
def _inject_browser_control_artifacts(
self,
store: Optional[ArtifactStore],
limiter: Optional[ArtifactRateLimiter] = None,
) -> None:
"""Inject a store/limiter (tests, diagnostics)."""
self._browser_control_artifacts = store
if limiter is not None:
self._browser_control_artifact_limiter = limiter
@staticmethod
def _artifact_auth_fail(request: "web.Request", status: int, code: str, message: str):
return web.json_response(
_openai_error(
message,
err_type="gateway_auth_error" if status == 401 else "invalid_request_error",
code=code,
),
status=status,
)
async def _handle_artifact_upload(self, request: "web.Request") -> "web.Response":
"""POST /v1/artifacts/upload — one-shot bounded artifact upload.
Authenticated with the same Bearer API key as every other API-server
route and gated on browser.extension_control.enabled. The body is
read as raw bytes with an exact size cap; ``Content-Type`` must name
an allowed MIME type and ``X-Artifact-Filename`` supplies the
display-only name. On success returns a provenance receipt carrying
the server-minted artifact id, SHA-256, size, TTL, and download path
— never a filesystem path.
Status ladder: 404 feature disabled, 403 no API key configured, 401
bad/missing Bearer, 429 rate limited, 413 too large, 415 MIME
rejected, 400 missing filename/scope, 201 success.
"""
if not self._browser_control_enabled():
return web.json_response(
_openai_error(
"Browser control is not enabled on this server.",
code="browser_control_disabled",
),
status=404,
)
if not self._api_key:
return web.json_response(
_openai_error(
"Artifact transport requires a configured API key.",
err_type="gateway_auth_error",
code="browser_control_auth_required",
),
status=403,
)
auth_err = self._check_auth(request)
if auth_err:
return auth_err
profile = _api_request_profile.get() or "default"
principal = self._derive_browser_control_principal(profile)
limiter = self._artifact_limiter()
if not limiter.allow(f"upload:{principal}"):
return web.json_response(
_openai_error(
"Artifact upload rate limit exceeded.",
err_type="rate_limit_error",
code="rate_limit_exceeded",
),
status=429,
headers={"Retry-After": "1"},
)
content_type = request.headers.get("Content-Type", "")
filename = request.headers.get("X-Artifact-Filename", "").strip()
if not filename:
return web.json_response(
_openai_error("X-Artifact-Filename header is required."),
status=400,
)
# Bounded read: cap at the store's byte cap + 1 so an oversize body
# is detected and rejected without buffering unbounded data.
try:
store = self._artifact_store_for(profile)
except ArtifactError as exc:
return web.json_response(_openai_error(str(exc), code="artifact_rejected"), status=500)
max_bytes = store.max_bytes
try:
data = await request.content.read(max_bytes + 1)
except Exception:
return web.json_response(_openai_error("Failed to read request body."), status=400)
if len(data) > max_bytes:
return web.json_response(
_openai_error(
f"Artifact exceeds the {max_bytes}-byte cap.",
code="artifact_too_large",
),
status=413,
)
if not data:
return web.json_response(_openai_error("Empty artifact body."), status=400)
try:
receipt = store.store(
data,
filename=filename,
content_type=content_type,
scope=_ArtifactScopeFacade(
principal,
transport_family=self._browser_control_transport_family(request),
),
)
except ArtifactTooLarge as exc:
return web.json_response(
_openai_error(str(exc), code="artifact_too_large"), status=413
)
except ArtifactError as exc:
code = "artifact_mime_rejected" if "allowlist" in str(exc) else "artifact_rejected"
status = 415 if "allowlist" in str(exc) else 400
return web.json_response(_openai_error(str(exc), code=code), status=status)
return web.json_response(
receipt.to_dict(download_path=f"/v1/artifacts/download/{receipt.artifact_id}"),
status=201,
)
async def _handle_artifact_download(self, request: "web.Request") -> "web.Response":
"""GET /v1/artifacts/download/{artifact_id} — one-shot download.
Authenticated and gated identically to upload. The artifact is
consumed on success: the second download of the same id returns 404.
The response streams the verified bytes with the recorded content
type and a ``X-Artifact-Sha256`` header for client-side validation.
Status ladder: 404 feature disabled / unknown artifact, 403 no API
key, 401 bad/missing Bearer, 429 rate limited, 410 expired, 400
invalid id / scope mismatch, 200 success.
"""
if not self._browser_control_enabled():
raise web.HTTPNotFound()
if not self._api_key:
return web.json_response(
_openai_error(
"Artifact transport requires a configured API key.",
err_type="gateway_auth_error",
code="browser_control_auth_required",
),
status=403,
)
auth_err = self._check_auth(request)
if auth_err:
return auth_err
profile = _api_request_profile.get() or "default"
principal = self._derive_browser_control_principal(profile)
limiter = self._artifact_limiter()
if not limiter.allow(f"download:{principal}"):
return web.json_response(
_openai_error(
"Artifact download rate limit exceeded.",
err_type="rate_limit_error",
code="rate_limit_exceeded",
),
status=429,
headers={"Retry-After": "1"},
)
artifact_id = request.match_info.get("artifact_id", "")
try:
store = self._artifact_store_for(profile)
data, receipt = store.load(
artifact_id,
scope=_ArtifactScopeFacade(
principal,
transport_family=self._browser_control_transport_family(request),
),
)
except ArtifactError as exc:
message = str(exc)
if "expired" in message:
return web.json_response(
_openai_error(message, code="artifact_expired"), status=410
)
status = 400 if "scope" in message or "invalid" in message else 404
return web.json_response(
_openai_error(message, code="artifact_not_found"), status=status
)
return web.Response(
body=data,
status=200,
content_type=receipt.content_type,
headers={
"X-Artifact-Sha256": receipt.sha256,
"X-Artifact-Id": receipt.artifact_id,
"Content-Disposition": f'attachment; filename="{receipt.filename}"',
},
)
async def _handle_skills(self, request: "web.Request") -> "web.Response":
"""GET /v1/skills — list installed skills visible to the API-server agent.
@@ -0,0 +1,591 @@
"""Phase 8 Task 29/30: one-shot artifact transport + broker gates.
Exercises the transport-neutral store core
(:mod:`gateway.browser_control_artifacts`), the API-server routes
(``/v1/artifacts/upload`` + ``/v1/artifacts/download/{artifact_id}`` with
auth and per-principal rate limits), and the broker's Developer Mode gates
for ``browser_evaluate`` / raw CDP plus artifact referencing
("approved artifact id only").
"""
import hashlib
import os
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from gateway.browser_control_artifacts import (
ArtifactChecksumMismatch,
ArtifactError,
ArtifactExpired,
ArtifactMimeRejected,
ArtifactNotFound,
ArtifactRateLimiter,
ArtifactScopeMismatch,
ArtifactStore,
ArtifactTooLarge,
ArtifactTraversal,
artifact_scope_key,
)
from gateway.browser_control_broker import (
BROWSER_CONTROL_ARTIFACT_CAPABILITIES,
BROWSER_CONTROL_CAPABILITIES,
BROWSER_CONTROL_DEVELOPER_CAPABILITIES,
BrowserControlBroker,
ControllerRejected,
ControllerScope,
ControllerUnavailable,
browser_control_developer_mode,
filter_browser_control_capabilities,
)
from gateway.config import PlatformConfig
from gateway.platforms.api_server import APIServerAdapter
API_KEY = "-".join(("fixture", "neutral", "api", "key", "123"))
PNG_BYTES = bytes.fromhex("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489")
TEXT_BYTES = b"fixture artifact payload\n"
class _Scope:
"""Minimal attribute scope accepted by artifact_scope_key."""
def __init__(self, principal="principal-fixture", session="session-fixture", family="local-api"):
self.principal_id = principal
self.session_id = session
self.transport_family = family
def _adapter(*, key=API_KEY):
adapter = APIServerAdapter(
PlatformConfig(enabled=True, extra={"key": key} if key else {})
)
return adapter
def _app(adapter):
app = web.Application()
app.router.add_post("/v1/artifacts/upload", adapter._handle_artifact_upload)
app.router.add_get(
"/v1/artifacts/download/{artifact_id}", adapter._handle_artifact_download
)
app.router.add_get("/v1/capabilities", adapter._handle_capabilities)
return app
def _auth():
return {"Authorization": f"Bearer {API_KEY}"}
# ----------------------------------------------------------------------
# Store core: size/MIME caps, SHA-256, scope binding, one-shot, TTL
# ----------------------------------------------------------------------
def test_store_rejects_oversize_and_disallowed_mime_before_writing(tmp_path):
store = ArtifactStore(tmp_path / "root", max_bytes=8, allowed_mime_types=frozenset({"image/png"}))
with pytest.raises(ArtifactTooLarge):
store.store(b"123456789", filename="big.png", content_type="image/png", scope=_Scope())
with pytest.raises(ArtifactMimeRejected):
store.store(b"123", filename="doc.txt", content_type="text/plain", scope=_Scope())
assert store.count() == 0
assert not list((tmp_path / "root").iterdir())
def test_store_round_trip_validates_sha256_and_is_one_shot(tmp_path):
store = ArtifactStore(tmp_path / "root")
receipt = store.store(
PNG_BYTES,
filename="shot.png",
content_type="image/png",
scope=_Scope(),
)
assert receipt.size_bytes == len(PNG_BYTES)
assert receipt.sha256 == hashlib.sha256(PNG_BYTES).hexdigest()
assert receipt.filename == "shot.png"
assert receipt.expires_at > receipt.created_at
data, loaded = store.load(receipt.artifact_id, scope=_Scope())
assert data == PNG_BYTES
assert loaded.artifact_id == receipt.artifact_id
# One-shot: a second load must fail.
with pytest.raises(ArtifactNotFound):
store.load(receipt.artifact_id, scope=_Scope())
def test_store_rejects_tampered_file_via_checksum(tmp_path):
store = ArtifactStore(tmp_path / "root")
receipt = store.store(
TEXT_BYTES, filename="note.txt", content_type="text/plain", scope=_Scope()
)
target = tmp_path / "root" / receipt.artifact_id
target.write_bytes(b"tampered bytes")
with pytest.raises(ArtifactChecksumMismatch):
store.load(receipt.artifact_id, scope=_Scope())
# The tampered artifact is not consumed; a later store to a fresh id works.
assert store.count() == 1
def test_store_is_scope_bound_and_principal_required(tmp_path):
store = ArtifactStore(tmp_path / "root")
receipt = store.store(
TEXT_BYTES, filename="note.txt", content_type="text/plain", scope=_Scope()
)
with pytest.raises(ArtifactScopeMismatch):
store.load(receipt.artifact_id, scope=_Scope(principal="other-principal"))
with pytest.raises(ArtifactScopeMismatch):
store.load(receipt.artifact_id, scope=_Scope(family="remote-api"))
with pytest.raises(ArtifactError, match="principal"):
store.store(
TEXT_BYTES,
filename="note.txt",
content_type="text/plain",
scope=_Scope(principal=""),
)
def test_store_rejects_traversal_ids_and_mints_server_ids(tmp_path):
store = ArtifactStore(tmp_path / "root")
with pytest.raises(ArtifactTraversal):
store.validate("../escape", scope=_Scope())
with pytest.raises(ArtifactTraversal):
store.validate("not-hex!", scope=_Scope())
with pytest.raises(ArtifactTraversal):
store.validate("", scope=_Scope())
receipt = store.store(
TEXT_BYTES, filename="note.txt", content_type="text/plain", scope=_Scope()
)
# Ids are server-minted 32-hex; filenames never become paths.
assert len(receipt.artifact_id) == 32
assert all(character in "0123456789abcdef" for character in receipt.artifact_id)
assert store.validate(receipt.artifact_id, scope=_Scope()).artifact_id == receipt.artifact_id
def test_store_ttl_prunes_expired_and_drops_orphan_temps(tmp_path):
now = [1000.0]
store = ArtifactStore(tmp_path / "root", ttl_seconds=10.0, clock=lambda: now[0])
receipt = store.store(
TEXT_BYTES, filename="note.txt", content_type="text/plain", scope=_Scope()
)
assert store.count() == 1
# Load also fails once TTL elapses, and the entry is pruned.
now[0] = 1011.0
with pytest.raises(ArtifactExpired):
store.load(receipt.artifact_id, scope=_Scope())
assert store.count() == 0
# Explicit sweep is idempotent and removes stale temp files.
orphan = tmp_path / "root" / ("deadbeef" * 4 + ".tmp")
orphan.write_bytes(b"x")
os.utime(orphan, (900.0, 900.0))
assert store.prune_expired(now[0]) == 0
assert not orphan.exists()
def test_scope_key_is_stable_across_reconnect_and_distinct_per_principal(tmp_path):
key = artifact_scope_key(_Scope())
assert key == artifact_scope_key(_Scope())
assert key != artifact_scope_key(_Scope(principal="other-principal"))
assert key != artifact_scope_key(_Scope(family="remote-api"))
# ----------------------------------------------------------------------
# Rate limiter
# ----------------------------------------------------------------------
def test_rate_limiter_sliding_window_per_key():
now = [100.0]
limiter = ArtifactRateLimiter(window_seconds=60.0, max_requests=3, clock=lambda: now[0])
assert limiter.allow("principal-a")
assert limiter.allow("principal-a")
assert limiter.allow("principal-a")
assert limiter.allow("principal-a") is False
# A different key has its own budget.
assert limiter.allow("principal-b")
# Older hits slide out of the window.
now[0] = 170.0
assert limiter.allow("principal-a")
limiter.reset("principal-a")
assert limiter.allow("principal-a")
# ----------------------------------------------------------------------
# Broker: Developer Mode gates + artifact referencing
# ----------------------------------------------------------------------
def _broker_scope(**overrides):
values = {
"principal_id": "principal-fixture",
"profile_id": "default",
"session_id": "session-fixture",
"controller_id": "controller-fixture",
"browser_profile_id": "browser-profile-fixture",
"transport_family": "local-api",
"capabilities": frozenset({"controller.noop"}),
}
values.update(overrides)
return ControllerScope(**values)
def test_developer_capabilities_are_never_in_the_base_allowlist():
assert BROWSER_CONTROL_CAPABILITIES.isdisjoint(BROWSER_CONTROL_DEVELOPER_CAPABILITIES)
assert BROWSER_CONTROL_ARTIFACT_CAPABILITIES.isdisjoint(BROWSER_CONTROL_DEVELOPER_CAPABILITIES)
def test_filter_rejects_developer_capabilities_without_developer_mode():
requested = [
"controller.noop",
"browser_navigate",
"browser_evaluate",
"browser_cdp",
"browser_artifact_upload",
"browser_artifact_download",
"arbitrary.capability",
]
assert filter_browser_control_capabilities(requested, developer_mode=False) == frozenset(
{"controller.noop", "browser_navigate", "browser_artifact_upload", "browser_artifact_download"}
)
assert filter_browser_control_capabilities(requested, developer_mode=True) == frozenset(
{
"controller.noop",
"browser_navigate",
"browser_artifact_upload",
"browser_artifact_download",
"browser_evaluate",
"browser_cdp",
}
)
assert filter_browser_control_capabilities("not-a-list", developer_mode=True) == frozenset()
def test_browser_control_developer_mode_reads_config_flag():
assert browser_control_developer_mode({"browser": {"extension_control": {"developer_mode": True}}}) is True
assert browser_control_developer_mode({"browser": {"extension_control": {}}}) is False
assert browser_control_developer_mode({"browser": {}}) is False
assert browser_control_developer_mode(None) is False
def test_broker_developer_gate_blocks_evaluate_and_cdp_dispatch(tmp_path):
broker = BrowserControlBroker(developer_mode=False)
store = ArtifactStore(tmp_path / "root")
broker.attach_artifact_store(store)
scope = _broker_scope(
capabilities=frozenset({"browser_evaluate", "browser_cdp", "controller.noop"})
)
broker.attach(scope, lambda _frame: None)
# Even though the controller claims the capability, Developer Mode off
# fails closed at selection time.
with pytest.raises(ControllerUnavailable):
broker.dispatch(scope, action="browser_evaluate", arguments={"expression": "1"})
with pytest.raises(ControllerUnavailable):
broker.dispatch(scope, action="browser_cdp", arguments={"method": "Page.navigate"})
assert broker.select(scope, "browser_evaluate") is None
def test_broker_developer_mode_allows_negotiated_privileged_dispatch(tmp_path):
broker = BrowserControlBroker(developer_mode=True)
store = ArtifactStore(tmp_path / "root")
broker.attach_artifact_store(store)
scope = _broker_scope(
capabilities=frozenset({"browser_evaluate", "browser_cdp", "controller.noop"})
)
def send(frame):
broker.complete(
frame["params"]["command_id"],
ok=True,
result={"expression": frame["params"]["arguments"]["expression"]},
)
broker.attach(scope, send)
result = broker.dispatch(
scope, action="browser_evaluate", arguments={"expression": "document.title"}
)
assert result == {"expression": "document.title"}
def test_broker_artifact_action_requires_attached_store(tmp_path):
broker = BrowserControlBroker()
scope = _broker_scope(
capabilities=frozenset({"browser_artifact_download", "controller.noop"})
)
broker.attach(scope, lambda _frame: None)
with pytest.raises(ControllerRejected, match="artifact store"):
broker.dispatch(
scope,
action="browser_artifact_download",
arguments={"artifact_id": "a" * 32},
)
def test_broker_artifact_action_requires_approved_id_only(tmp_path):
broker = BrowserControlBroker()
store = ArtifactStore(tmp_path / "root")
broker.attach_artifact_store(store)
scope = _broker_scope(
capabilities=frozenset({"browser_artifact_download", "controller.noop"})
)
frames = []
def send(frame):
frames.append(frame)
broker.complete(frame["params"]["command_id"], ok=True, result={"ok": True})
broker.attach(scope, send)
# Unknown id fails closed with the artifact error surfaced.
with pytest.raises(ControllerRejected, match="unknown artifact"):
broker.dispatch(
scope,
action="browser_artifact_download",
arguments={"artifact_id": "b" * 32},
)
assert frames == []
# Missing id is refused.
with pytest.raises(ControllerRejected, match="non-empty artifact_id"):
broker.dispatch(scope, action="browser_artifact_download", arguments={})
# An approved (stored, scope-bound) id dispatches; the frame carries the
# id, never the bytes.
receipt = store.store(
PNG_BYTES,
filename="shot.png",
content_type="image/png",
scope=_Scope(),
)
result = broker.dispatch(
scope,
action="browser_artifact_download",
arguments={"artifact_id": receipt.artifact_id},
)
assert result == {"ok": True}
assert frames[0]["params"]["arguments"]["artifact_id"] == receipt.artifact_id
# The frame carries only the id — never the payload bytes.
assert "data" not in frames[0]["params"]["arguments"]
assert all(
not isinstance(value, bytes) for value in frames[0]["params"]["arguments"].values()
)
def test_broker_artifact_action_rejects_other_scope_artifact(tmp_path):
broker = BrowserControlBroker()
store = ArtifactStore(tmp_path / "root")
broker.attach_artifact_store(store)
scope = _broker_scope(
capabilities=frozenset({"browser_artifact_upload", "controller.noop"})
)
broker.attach(scope, lambda _frame: None)
receipt = store.store(
TEXT_BYTES,
filename="note.txt",
content_type="text/plain",
scope=_Scope(principal="someone-else"),
)
with pytest.raises(ControllerRejected, match="different scope"):
broker.dispatch(
scope,
action="browser_artifact_upload",
arguments={"artifact_id": receipt.artifact_id},
)
# ----------------------------------------------------------------------
# API-server routes: auth, feature gate, size/MIME, one-shot, rate limit
# ----------------------------------------------------------------------
@pytest.mark.asyncio
async def test_artifact_upload_requires_auth_and_feature_flag(monkeypatch):
adapter = _adapter()
monkeypatch.setattr(adapter, "_browser_control_enabled", lambda: True)
async with TestClient(TestServer(_app(adapter))) as client:
response = await client.post(
"/v1/artifacts/upload",
data=TEXT_BYTES,
headers={"Content-Type": "text/plain", "X-Artifact-Filename": "note.txt"},
)
assert response.status == 401
adapter = _adapter()
monkeypatch.setattr(adapter, "_browser_control_enabled", lambda: False)
async with TestClient(TestServer(_app(adapter))) as client:
response = await client.post(
"/v1/artifacts/upload",
data=TEXT_BYTES,
headers={
"Content-Type": "text/plain",
"X-Artifact-Filename": "note.txt",
**_auth(),
},
)
assert response.status == 404
@pytest.mark.asyncio
async def test_artifact_upload_download_round_trip_one_shot(monkeypatch, tmp_path):
adapter = _adapter()
monkeypatch.setattr(adapter, "_browser_control_enabled", lambda: True)
store = ArtifactStore(tmp_path / "root")
adapter._inject_browser_control_artifacts(store)
async with TestClient(TestServer(_app(adapter))) as client:
upload = await client.post(
"/v1/artifacts/upload",
data=TEXT_BYTES,
headers={
"Content-Type": "text/plain",
"X-Artifact-Filename": "note.txt",
**_auth(),
},
)
assert upload.status == 201
receipt = await upload.json()
assert receipt["size_bytes"] == len(TEXT_BYTES)
assert receipt["sha256"] == hashlib.sha256(TEXT_BYTES).hexdigest()
assert receipt["one_shot"] is True
assert receipt["download_path"] == f"/v1/artifacts/download/{receipt['artifact_id']}"
download = await client.get(receipt["download_path"], headers=_auth())
assert download.status == 200
body = await download.read()
assert body == TEXT_BYTES
assert download.headers["X-Artifact-Sha256"] == receipt["sha256"]
# One-shot: the same id is consumed.
replay = await client.get(receipt["download_path"], headers=_auth())
assert replay.status == 404
@pytest.mark.asyncio
async def test_artifact_upload_rejects_mime_and_missing_filename(monkeypatch, tmp_path):
adapter = _adapter()
monkeypatch.setattr(adapter, "_browser_control_enabled", lambda: True)
store = ArtifactStore(tmp_path / "root")
adapter._inject_browser_control_artifacts(store)
async with TestClient(TestServer(_app(adapter))) as client:
bad_mime = await client.post(
"/v1/artifacts/upload",
data=b"<html></html>",
headers={
"Content-Type": "text/html",
"X-Artifact-Filename": "page.html",
**_auth(),
},
)
assert bad_mime.status == 415
no_name = await client.post(
"/v1/artifacts/upload",
data=TEXT_BYTES,
headers={"Content-Type": "text/plain", **_auth()},
)
assert no_name.status == 400
@pytest.mark.asyncio
async def test_artifact_upload_bounded_body_rejects_oversize(monkeypatch, tmp_path):
adapter = _adapter()
monkeypatch.setattr(adapter, "_browser_control_enabled", lambda: True)
store = ArtifactStore(tmp_path / "root", max_bytes=16)
adapter._inject_browser_control_artifacts(store)
async with TestClient(TestServer(_app(adapter))) as client:
response = await client.post(
"/v1/artifacts/upload",
data=b"x" * 17,
headers={
"Content-Type": "text/plain",
"X-Artifact-Filename": "big.txt",
**_auth(),
},
)
assert response.status == 413
@pytest.mark.asyncio
async def test_artifact_download_rejects_foreign_scope_and_unknown_id(monkeypatch, tmp_path):
adapter = _adapter()
monkeypatch.setattr(adapter, "_browser_control_enabled", lambda: True)
store = ArtifactStore(tmp_path / "root")
adapter._inject_browser_control_artifacts(store)
receipt = store.store(
TEXT_BYTES,
filename="note.txt",
content_type="text/plain",
scope=_Scope(principal="someone-else"),
)
async with TestClient(TestServer(_app(adapter))) as client:
foreign = await client.get(
f"/v1/artifacts/download/{receipt.artifact_id}", headers=_auth()
)
assert foreign.status == 400
unknown = await client.get(
"/v1/artifacts/download/" + "f" * 32, headers=_auth()
)
assert unknown.status == 404
@pytest.mark.asyncio
async def test_artifact_routes_rate_limit_per_principal(monkeypatch, tmp_path):
adapter = _adapter()
monkeypatch.setattr(adapter, "_browser_control_enabled", lambda: True)
store = ArtifactStore(tmp_path / "root")
limiter = ArtifactRateLimiter(window_seconds=60.0, max_requests=2)
adapter._inject_browser_control_artifacts(store, limiter)
async with TestClient(TestServer(_app(adapter))) as client:
statuses = []
for _index in range(3):
response = await client.post(
"/v1/artifacts/upload",
data=TEXT_BYTES,
headers={
"Content-Type": "text/plain",
"X-Artifact-Filename": "note.txt",
**_auth(),
},
)
statuses.append(response.status)
assert statuses == [201, 201, 429]
@pytest.mark.asyncio
async def test_capabilities_advertise_artifact_transport_and_developer_mode(monkeypatch):
adapter = _adapter()
monkeypatch.setattr(adapter, "_browser_control_enabled", lambda: True)
monkeypatch.setattr(adapter, "_browser_control_developer_mode", lambda: True)
async with TestClient(TestServer(_app(adapter))) as client:
response = await client.get("/v1/capabilities", headers=_auth())
assert response.status == 200
data = await response.json()
control = data["features"]["browser_extension_control"]
assert control["developer_mode"] is True
assert "browser_evaluate" in control["developer_capabilities"]
assert "browser_cdp" in control["developer_capabilities"]
assert "browser_evaluate" not in control["capabilities"]
assert control["artifact_transport"]["upload"] == {
"method": "POST",
"path": "/v1/artifacts/upload",
}
assert control["artifact_transport"]["download"]["path"] == (
"/v1/artifacts/download/{artifact_id}"
)
assert data["endpoints"]["artifact_upload"] == {
"method": "POST",
"path": "/v1/artifacts/upload",
}
assert data["endpoints"]["artifact_download"] == {
"method": "GET",
"path": "/v1/artifacts/download/{artifact_id}",
}
def test_route_table_advertises_artifact_routes():
adapter = _adapter()
routes = {(method, path) for method, path, _handler in adapter._http_route_table()}
assert ("POST", "/v1/artifacts/upload") in routes
assert ("GET", "/v1/artifacts/download/{artifact_id}") in routes