mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
159 lines
7.4 KiB
Python
159 lines
7.4 KiB
Python
import asyncio
|
|
import json
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from src import browser_identity as browser
|
|
from src.agent_runtime.resources import ResourceIdentityError
|
|
from tests.test_browser_resource_identity import producer, observed, authority
|
|
from tests.test_runtime_resource_integration import approval_for, dispatch
|
|
|
|
|
|
@pytest.mark.parametrize("phase", ["timeout", "cancel", "spawn_cancel"])
|
|
async def test_client_is_killed_before_resend_deadline_without_retry(monkeypatch, phase):
|
|
calls = []
|
|
class Child:
|
|
returncode = None
|
|
killed = False
|
|
async def wait(self):
|
|
if self.killed:
|
|
self.returncode = -9
|
|
return -9
|
|
await asyncio.Future()
|
|
def kill(self): self.killed = True
|
|
child = Child()
|
|
started, release = asyncio.Event(), asyncio.Event()
|
|
async def spawn(*args, **kwargs):
|
|
calls.append(args); started.set()
|
|
if phase == "spawn_cancel": await release.wait()
|
|
return child
|
|
monkeypatch.setattr(browser.asyncio, "create_subprocess_exec", spawn)
|
|
original = asyncio.wait_for
|
|
async def bounded(awaitable, timeout):
|
|
assert timeout == browser.CLIENT_DEADLINE_S and timeout < 30
|
|
return await original(awaitable, .01 if phase == "timeout" else timeout)
|
|
monkeypatch.setattr(browser.asyncio, "wait_for", bounded)
|
|
task = asyncio.create_task(browser.run_client(["trusted-producer", "session", "info"], env={}, cwd="/"))
|
|
await started.wait()
|
|
if phase != "timeout": task.cancel()
|
|
release.set()
|
|
with pytest.raises((asyncio.TimeoutError, asyncio.CancelledError)): await task
|
|
assert child.killed and len(calls) == 1
|
|
|
|
|
|
async def test_sidecar_allowlist_has_no_enable_mutation_or_arbitrary_cdp():
|
|
client = browser.CDPSidecar("ws://127.0.0.1:1234/devtools/browser/12345678-1234-1234-1234-123456789abc")
|
|
for method in ("Page.enable", "Runtime.evaluate", "Page.navigate", "Target.closeTarget", "Browser.close"):
|
|
with pytest.raises(ValueError): await client.call(method)
|
|
|
|
|
|
@pytest.mark.parametrize("envelope", [[], None, {"id": True, "result": {}}, {"id": "1", "result": {}}, {"id": 1, "error": {}, "result": {}}])
|
|
async def test_sidecar_rejects_malformed_identity_envelopes(monkeypatch, envelope):
|
|
client = browser.CDPSidecar("ws://127.0.0.1:1234/devtools/browser/12345678-1234-1234-1234-123456789abc")
|
|
async def send(*args): pass
|
|
async def receive(): return envelope
|
|
monkeypatch.setattr(client, "_send", send)
|
|
monkeypatch.setattr(client, "_message", receive)
|
|
with pytest.raises(ResourceIdentityError):
|
|
await client.call("Target.getTargets")
|
|
|
|
|
|
@pytest.mark.parametrize("field", ["namespace", "runtimeError", "restoreKey"])
|
|
async def test_missing_nullable_lifecycle_fields_are_not_valid_observations(producer, field):
|
|
record = await observed(producer)
|
|
info = await record.command("session", "info")
|
|
del (info["runtime"] if field == "restoreKey" else info)[field]
|
|
with pytest.raises(ResourceIdentityError): browser.daemon_observation(record, info)
|
|
|
|
|
|
async def test_observation_cancellation_while_waiting_for_lock_invalidates_session(producer):
|
|
record = await observed(producer)
|
|
await record.lock.acquire()
|
|
task = asyncio.create_task(browser.observe_registered(record))
|
|
await asyncio.sleep(0)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError): await task
|
|
record.lock.release()
|
|
assert record.session is None and record.pages == ()
|
|
|
|
|
|
@pytest.mark.parametrize("args", [{"action": "click", "page": "t1"}, {"action": "batch", "commands": [["click", "e1"]]}])
|
|
async def test_central_dispatch_cannot_bypass_page_denial(producer, args):
|
|
await observed(producer)
|
|
current = authority()
|
|
producer.calls.clear(); producer.cdp_calls.clear()
|
|
_, result = await dispatch(current, "private_browser", json.dumps(args))
|
|
assert result["failure_kind"] == browser.PAGE_FAILURE and result["executed"] is False
|
|
assert not producer.calls and not producer.cdp_calls
|
|
|
|
|
|
@pytest.mark.parametrize("replacement", ["browser", "daemon"])
|
|
async def test_exact_approval_revalidates_before_claim(producer, replacement):
|
|
record = await observed(producer)
|
|
current = authority()
|
|
content = '{"action":"session_info"}'
|
|
approval = approval_for(current, "private_browser", content)
|
|
if replacement == "daemon":
|
|
producer.pid += 1
|
|
else:
|
|
record._endpoint = "ws://127.0.0.1:1234/devtools/browser/87654321-1234-1234-1234-123456789abc"
|
|
_, result = await dispatch(current, "private_browser", content, approval)
|
|
assert result["exit_code"] == 1 and not approval._claimed
|
|
assert record.session is None and record.pages == ()
|
|
|
|
|
|
async def test_metadata_revalidation_never_auto_launches_or_calls_get_cdp_url(producer):
|
|
await observed(producer)
|
|
current = authority()
|
|
producer.calls.clear()
|
|
_, result = await dispatch(current, "private_browser", '{"action":"session_info"}')
|
|
assert result["exit_code"] == 0
|
|
assert producer.calls and all(command == ("session", "info") for command in producer.calls)
|
|
|
|
|
|
@pytest.mark.parametrize("status", ["EOF", "connection reset", "EAGAIN", "read timeout"])
|
|
async def test_page_failures_never_enter_producer_internal_retry_path(producer, status, monkeypatch):
|
|
async def forbidden(*args, **kwargs):
|
|
pytest.fail("Producer retry hazard reached: " + status)
|
|
monkeypatch.setattr(browser, "run_client", forbidden)
|
|
producer.calls.clear()
|
|
from src.agent_tools.web_tools import PrivateBrowserTool
|
|
result = await PrivateBrowserTool().execute('{"action":"wait","page":"t1","timeout_ms":120000}', {})
|
|
assert result["executed"] is False and result["retryable"] is False
|
|
assert producer.calls == []
|
|
|
|
|
|
async def test_page_scoped_child_still_cannot_execute_even_matching_observation(producer):
|
|
await observed(producer)
|
|
from dataclasses import replace
|
|
parent = replace(authority(), browser_sessions=())
|
|
child = parent.intersect(authority())
|
|
_, result = await dispatch(child, "private_browser", '{"action":"click","page":"t1","ref":"e1"}')
|
|
assert result["failure_kind"] == browser.PAGE_FAILURE and result["executed"] is False
|
|
|
|
|
|
async def test_observed_url_or_alias_change_is_not_resource_authority(producer):
|
|
record = await observed(producer)
|
|
from dataclasses import replace
|
|
original = record.pages[0]
|
|
metadata = replace(original, resolved_alias="t99", observed_url="https://different.example")
|
|
assert original.authority_key() == metadata.authority_key()
|
|
metadata.validate()
|
|
|
|
|
|
def test_raw_global_playwright_and_native_backend_are_not_substitutable():
|
|
from src.agent_runtime.resources import ExternalResource
|
|
from src.agent_runtime.authority import ExactOperation
|
|
assert not browser.native_browser(ExactOperation.normalize("private_browser", '{"action":"session_info"}'),
|
|
ExternalResource("mcp", "endpoint", "server", "tool", "epoch"))
|
|
|
|
|
|
@pytest.mark.parametrize("tool", ["browser_click", "browser_snapshot", "browser_evaluate", "browser_navigate", "browser_run_code"])
|
|
async def test_raw_mcp_browser_execution_cannot_evade_disabled_page_contract(tool):
|
|
from src.agent_runtime.authority import RequestAuthority, OperationGrant
|
|
name = "mcp__builtin_browser__" + tool
|
|
current = RequestAuthority("request", "alice", "thread", "", (OperationGrant(name),))
|
|
_, result = await dispatch(current, name, '{}')
|
|
assert result["failure_kind"] == browser.PAGE_FAILURE and result["executed"] is False
|