480 lines
20 KiB
Python
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
|