Files
2026-07-13 13:12:00 +08:00

480 lines
20 KiB
Python

"""
Integration tests for the embedded-browser action bridge routes.
Covers the risk-critical server-side routes that carry a runner ``browser_*``
tool call to the desktop renderer and back:
- ``POST /sessions/{id}/browser/action_request`` — parks a Future,
publishes ``browser.action_request``, awaits, and returns the renderer
result or a clean timeout error.
- ``POST /sessions/{id}/browser/action_claim/{action_id}`` — the atomic
one-winner claim lease that prevents double execution when several
renderers are subscribed to the same session stream.
- ``POST /sessions/{id}/browser/action_result/{action_id}`` — the
claim-token + owner guarded resolution.
These are concurrency- and security-sensitive: a future refactor could
reintroduce an ``await`` in the claim critical section or weaken a
guard, so the guarantees are pinned here — two concurrent claims yield
exactly one winner, tokenless / wrong-token / wrong-session results are
all rejected (403), a post-``future.done()`` result is a graceful
no-op, and the timeout path leaves the registry clean.
Uses the shared ``client`` fixture (real stores + mock LLM); the
``auth_client`` cross-user gate mirrors the elicitation-resolve tests.
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
import httpx
import pytest
import pytest_asyncio
from fastapi import FastAPI
from omnigent.runtime import session_stream
from omnigent.runtime.agent_cache import AgentCache
from omnigent.server.app import create_app
from omnigent.server.routes import sessions as sessions_routes
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
from omnigent.stores.artifact_store.local import LocalArtifactStore
from omnigent.stores.comment_store.sqlalchemy_store import SqlAlchemyCommentStore
from omnigent.stores.conversation_store.sqlalchemy_store import (
SqlAlchemyConversationStore,
)
from omnigent.stores.file_store.sqlalchemy_store import SqlAlchemyFileStore
from omnigent.stores.permission_store.sqlalchemy_store import (
SqlAlchemyPermissionStore,
)
from tests.server.conftest import ControllableMockClient
from tests.server.helpers import create_test_agent
pytestmark = pytest.mark.asyncio
# ── Auth-enabled fixtures (mirroring test_sessions_elicitation_resolve_url) ──
@pytest.fixture()
def auth_app(runtime_init: None, db_uri: str, tmp_path: Path) -> FastAPI:
"""App fixture with a permission store + auth provider enabled."""
from omnigent.server.auth import UnifiedAuthProvider
artifact_store = LocalArtifactStore(str(tmp_path / "artifacts"))
return create_app(
agent_store=SqlAlchemyAgentStore(db_uri),
file_store=SqlAlchemyFileStore(db_uri),
conversation_store=SqlAlchemyConversationStore(db_uri),
artifact_store=artifact_store,
agent_cache=AgentCache(
artifact_store=artifact_store,
cache_dir=tmp_path / "cache",
),
comment_store=SqlAlchemyCommentStore(db_uri),
permission_store=SqlAlchemyPermissionStore(db_uri),
auth_provider=UnifiedAuthProvider(source="header"),
)
@pytest_asyncio.fixture()
async def auth_client(
auth_app: FastAPI,
mock_llm: ControllableMockClient,
tmp_path: Path,
) -> AsyncIterator[httpx.AsyncClient]:
"""HTTP client wired to the auth-enabled app."""
from omnigent.runtime import set_harness_process_manager
from omnigent.runtime.harnesses.process_manager import HarnessProcessManager
pm = HarnessProcessManager(tmp_parent=tmp_path / "harness_pm")
await pm.start()
set_harness_process_manager(pm)
transport = httpx.ASGITransport(app=auth_app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
yield c
mock_llm.release_all()
set_harness_process_manager(None)
await pm.shutdown()
# ── Helpers ──────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _clean_browser_registries() -> Any:
"""
Clear the module-global browser registries around each test.
They are keyed by ``action_id`` and process-global, so a leaked
Future / owner / claim from one test would be visible to the next.
"""
sessions_routes._browser_action_registry.clear()
sessions_routes._browser_action_owners.clear()
sessions_routes._browser_action_claims.clear()
yield
sessions_routes._browser_action_registry.clear()
sessions_routes._browser_action_owners.clear()
sessions_routes._browser_action_claims.clear()
async def _create_session(
client: httpx.AsyncClient,
agent_id: str,
*,
user: str | None = None,
) -> str:
"""Create a minimal session and return its id."""
headers = {"X-Forwarded-Email": user} if user is not None else None
resp = await client.post("/v1/sessions", json={"agent_id": agent_id}, headers=headers)
assert resp.status_code == 201, f"create failed: {resp.status_code} {resp.text}"
return resp.json()["id"]
async def _drain_until_action_request(
session_id: str,
*,
subscribed: asyncio.Event | None = None,
timeout_s: float = 5.0,
) -> dict[str, Any]:
"""
Block on a session stream until a ``browser.action_request`` arrives.
The request route publishes the SSE event before awaiting the
Future, so subscribing is how a test learns the minted ``action_id``
without monkey-patching ``secrets``.
:param session_id: Session to subscribe to.
:param subscribed: Optional event set once the subscriber registers.
:param timeout_s: Max seconds before failing the test.
:returns: The full ``browser.action_request`` event.
"""
async def _on_subscribed() -> tuple[dict[str, Any], ...]:
if subscribed is not None:
subscribed.set()
return ()
async with asyncio.timeout(timeout_s):
async for event in session_stream.subscribe(session_id, on_subscribed=_on_subscribed):
if event.get("type") == "browser.action_request":
return event
raise AssertionError("subscribe loop ended without a browser.action_request event")
async def _park_action_request(
client: httpx.AsyncClient,
session_id: str,
*,
action: str = "navigate",
args: dict[str, Any] | None = None,
) -> tuple[asyncio.Task[httpx.Response], str]:
"""
Fire the action_request route and capture its minted ``action_id``.
Starts the request POST as a background task (it blocks on the
server-side Future until a result arrives or the await elapses) and
subscribes to learn the ``action_id``.
:returns: The in-flight request-POST task and the ``action_id``.
"""
subscribed = asyncio.Event()
drain = asyncio.create_task(_drain_until_action_request(session_id, subscribed=subscribed))
await subscribed.wait()
request_task = asyncio.create_task(
client.post(
f"/v1/sessions/{session_id}/browser/action_request",
json={"action": action, "args": args or {"url": "https://example.com"}},
)
)
event = await drain
action_id = event["action_id"]
assert isinstance(action_id, str) and action_id.startswith("baction_")
assert event["action"] == action
return request_task, action_id
# ── request/result round-trip ────────────────────────────────────
async def test_action_request_resolves_on_result(client: httpx.AsyncClient) -> None:
"""
A claimed result resolves the parked Future: the request POST returns
the renderer's result JSON — the core happy path.
"""
agent = await create_test_agent(client, "test-browser-roundtrip")
session_id = await _create_session(client, agent["id"])
request_task, action_id = await _park_action_request(client, session_id, action="navigate")
claim = await client.post(f"/v1/sessions/{session_id}/browser/action_claim/{action_id}")
assert claim.status_code == 200, claim.text
token = claim.json()["claim_token"]
result = await client.post(
f"/v1/sessions/{session_id}/browser/action_result/{action_id}",
json={"result": {"final_url": "https://example.com/landed"}, "claim_token": token},
)
assert result.status_code == 202, result.text
assert result.json() == {"resolved": True}
resp = await request_task
assert resp.status_code == 200, resp.text
assert resp.json() == {"final_url": "https://example.com/landed"}
# ── atomic claim lease ───────────────────────────────────────────
async def test_two_concurrent_claims_exactly_one_winner(client: httpx.AsyncClient) -> None:
"""
Two renderers claiming the same action concurrently → exactly one
``{claimed: true}``. Guards the atomic check-and-set against double
execution via session-stream fan-out.
"""
agent = await create_test_agent(client, "test-browser-claim-race")
session_id = await _create_session(client, agent["id"])
request_task, action_id = await _park_action_request(client, session_id)
try:
claim_a, claim_b = await asyncio.gather(
client.post(f"/v1/sessions/{session_id}/browser/action_claim/{action_id}"),
client.post(f"/v1/sessions/{session_id}/browser/action_claim/{action_id}"),
)
assert claim_a.status_code == 200 and claim_b.status_code == 200
winners = [c.json() for c in (claim_a, claim_b) if c.json().get("claimed")]
losers = [c.json() for c in (claim_a, claim_b) if not c.json().get("claimed")]
assert len(winners) == 1, f"expected exactly one winner, got {winners}"
assert len(losers) == 1
assert winners[0]["claim_token"]
assert losers[0] == {"claimed": False}
finally:
request_task.cancel()
# Await the cancellation so no task is left pending; gather with
# return_exceptions swallows the CancelledError (and gives the await an
# observable result, unlike a bare `await` the linter flags as no-op).
await asyncio.gather(request_task, return_exceptions=True)
async def test_concurrent_claims_winner_token_is_the_stored_token(
client: httpx.AsyncClient,
) -> None:
"""
Two concurrent claims on one action → exactly one winner, and the
token the winner receives is precisely the one persisted in
``_browser_action_claims``. Pins the atomic ``setdefault`` lease: the
loser must see the winner's stored token (not its own) and bail, so
the winner's token can never be clobbered by the racer.
"""
agent = await create_test_agent(client, "test-browser-claim-stored-token")
session_id = await _create_session(client, agent["id"])
request_task, action_id = await _park_action_request(client, session_id)
try:
claim_a, claim_b = await asyncio.gather(
client.post(f"/v1/sessions/{session_id}/browser/action_claim/{action_id}"),
client.post(f"/v1/sessions/{session_id}/browser/action_claim/{action_id}"),
)
assert claim_a.status_code == 200 and claim_b.status_code == 200
winners = [c.json() for c in (claim_a, claim_b) if c.json().get("claimed")]
losers = [c.json() for c in (claim_a, claim_b) if not c.json().get("claimed")]
assert len(winners) == 1, f"expected exactly one winner, got {winners}"
assert len(losers) == 1
assert losers[0] == {"claimed": False}
# The winner's token is exactly what's stored in the registry.
assert sessions_routes._browser_action_claims[action_id] == winners[0]["claim_token"], (
"stored claim token must equal the winner's token"
)
finally:
request_task.cancel()
# Await the cancellation so no task is left pending; gather with
# return_exceptions swallows the CancelledError (and gives the await an
# observable result, unlike a bare `await` the linter flags as no-op).
await asyncio.gather(request_task, return_exceptions=True)
async def test_claim_unknown_action_returns_not_claimed(client: httpx.AsyncClient) -> None:
"""Claiming an unknown / already-resolved action → ``{claimed: false}``."""
agent = await create_test_agent(client, "test-browser-claim-unknown")
session_id = await _create_session(client, agent["id"])
claim = await client.post(
f"/v1/sessions/{session_id}/browser/action_claim/baction_does_not_exist"
)
assert claim.status_code == 200, claim.text
assert claim.json() == {"claimed": False}
# ── result-route guards ──────────────────────────────────────────
async def test_result_without_token_rejected(client: httpx.AsyncClient) -> None:
"""A result with no claim_token is rejected (403); Future stays parked."""
agent = await create_test_agent(client, "test-browser-no-token")
session_id = await _create_session(client, agent["id"])
request_task, action_id = await _park_action_request(client, session_id)
try:
# Claim first so a token EXISTS — proving it's the *missing body*
# token, not the absence of any claim, that triggers the 403.
await client.post(f"/v1/sessions/{session_id}/browser/action_claim/{action_id}")
resp = await client.post(
f"/v1/sessions/{session_id}/browser/action_result/{action_id}",
json={"result": {"ok": True}},
)
assert resp.status_code == 403, resp.text
assert not request_task.done(), "tokenless result wrongly resolved the Future"
finally:
request_task.cancel()
# Await the cancellation so no task is left pending; gather with
# return_exceptions swallows the CancelledError (and gives the await an
# observable result, unlike a bare `await` the linter flags as no-op).
await asyncio.gather(request_task, return_exceptions=True)
async def test_result_wrong_token_rejected(client: httpx.AsyncClient) -> None:
"""A result with a mismatched claim_token is rejected (403)."""
agent = await create_test_agent(client, "test-browser-wrong-token")
session_id = await _create_session(client, agent["id"])
request_task, action_id = await _park_action_request(client, session_id)
try:
await client.post(f"/v1/sessions/{session_id}/browser/action_claim/{action_id}")
resp = await client.post(
f"/v1/sessions/{session_id}/browser/action_result/{action_id}",
json={"result": {"ok": True}, "claim_token": "not_the_real_token"},
)
assert resp.status_code == 403, resp.text
assert not request_task.done()
finally:
request_task.cancel()
# Await the cancellation so no task is left pending; gather with
# return_exceptions swallows the CancelledError (and gives the await an
# observable result, unlike a bare `await` the linter flags as no-op).
await asyncio.gather(request_task, return_exceptions=True)
async def test_result_wrong_session_rejected(client: httpx.AsyncClient) -> None:
"""
A result delivered under a DIFFERENT session is rejected even with a
valid token minted under the owning session — the owner guard.
"""
agent = await create_test_agent(client, "test-browser-wrong-session")
session_a = await _create_session(client, agent["id"])
session_b = await _create_session(client, agent["id"])
request_task, action_id = await _park_action_request(client, session_a)
try:
claim = await client.post(f"/v1/sessions/{session_a}/browser/action_claim/{action_id}")
token = claim.json()["claim_token"]
# Post the result under session B with A's real token.
resp = await client.post(
f"/v1/sessions/{session_b}/browser/action_result/{action_id}",
json={"result": {"ok": True}, "claim_token": token},
)
assert resp.status_code == 403, resp.text
assert not request_task.done(), "cross-session result wrongly resolved A's Future"
finally:
request_task.cancel()
# Await the cancellation so no task is left pending; gather with
# return_exceptions swallows the CancelledError (and gives the await an
# observable result, unlike a bare `await` the linter flags as no-op).
await asyncio.gather(request_task, return_exceptions=True)
async def test_second_result_after_done_is_noop(client: httpx.AsyncClient) -> None:
"""
A second result after the Future is already resolved → ``{resolved:
false}`` and no exception (the ``future.done()`` guard).
"""
agent = await create_test_agent(client, "test-browser-double-result")
session_id = await _create_session(client, agent["id"])
request_task, action_id = await _park_action_request(client, session_id)
claim = await client.post(f"/v1/sessions/{session_id}/browser/action_claim/{action_id}")
token = claim.json()["claim_token"]
first = await client.post(
f"/v1/sessions/{session_id}/browser/action_result/{action_id}",
json={"result": {"n": 1}, "claim_token": token},
)
assert first.json() == {"resolved": True}
# The request POST has now returned and the registry entry is gone.
resp = await request_task
assert resp.status_code == 200
assert resp.json() == {"n": 1}
# A late duplicate must not raise; the action is no longer registered
# (owner entry cleaned up), so it's rejected by the owner guard (403)
# rather than resolving anything a second time.
second = await client.post(
f"/v1/sessions/{session_id}/browser/action_result/{action_id}",
json={"result": {"n": 2}, "claim_token": token},
)
assert second.status_code == 403, second.text
# ── timeout / cleanup ────────────────────────────────────────────
async def test_timeout_returns_clean_json_and_cleans_registry(
client: httpx.AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
With no renderer result, the await elapses and the route returns the
clean timeout JSON — and the registry, owner, and claim entries are
all removed in the ``finally`` (no leak).
"""
# Shrink the await so the test doesn't wait 30s.
monkeypatch.setattr(sessions_routes, "_BROWSER_ACTION_AWAIT_S", 0.2)
agent = await create_test_agent(client, "test-browser-timeout")
session_id = await _create_session(client, agent["id"])
resp = await client.post(
f"/v1/sessions/{session_id}/browser/action_request",
json={"action": "snapshot", "args": {}},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert "timed out" in body["error"]
assert "Omnigent desktop app" in body["error"]
# Registry fully cleaned — no leaked Future / owner / claim.
assert sessions_routes._browser_action_registry == {}
assert sessions_routes._browser_action_owners == {}
assert sessions_routes._browser_action_claims == {}
async def test_action_request_rejects_empty_action(client: httpx.AsyncClient) -> None:
"""A request with a missing / empty ``action`` is rejected (400)."""
agent = await create_test_agent(client, "test-browser-empty-action")
session_id = await _create_session(client, agent["id"])
resp = await client.post(
f"/v1/sessions/{session_id}/browser/action_request",
json={"action": "", "args": {}},
)
assert resp.status_code == 400, resp.text
# ── cross-user gate ──────────────────────────────────────────────
async def test_action_request_cross_user_forbidden(auth_client: httpx.AsyncClient) -> None:
"""
A non-owner cannot reach the action_request route when auth is active
— the ``LEVEL_EDIT`` gate fences the bridge just like elicitation.
"""
agent = await create_test_agent(auth_client, user="alice@example.com")
session_id = await _create_session(auth_client, agent["id"], user="alice@example.com")
resp = await auth_client.post(
f"/v1/sessions/{session_id}/browser/action_request",
json={"action": "navigate", "args": {"url": "https://x"}},
headers={"X-Forwarded-Email": "bob@example.com"},
)
assert resp.status_code in (403, 404), resp.text