from dataclasses import replace import asyncio import hashlib import json import os from pathlib import Path from types import SimpleNamespace import pytest from src import browser_identity as browser from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority from src.agent_runtime.resources import BrowserSessionResource, BrowserPageResource, ResourceIdentityError, FilesystemRoot, FilesystemResource from src.agent_tools.web_tools import PrivateBrowserTool from src.process_lifecycle import ProcessIdentity from tests.test_runtime_resource_integration import approval_for, dispatch @pytest.fixture def producer(tmp_path, monkeypatch): root = tmp_path / "release" root.mkdir() binary = root / "agent-browser-linux-x64" binary.write_bytes(b"explicit trusted fake producer") binary.chmod(0o755) checksum = hashlib.sha256(binary.read_bytes()).hexdigest() monkeypatch.setattr(browser, "PRODUCER_ROOT", root) monkeypatch.setattr(browser, "PRODUCER_HASHES", {"linux-x64": checksum}) monkeypatch.setattr(browser, "STATE_ROOT", tmp_path / "private") monkeypatch.setattr(browser, "_REGISTRY", {}) monkeypatch.setattr(ProcessIdentity, "owned", lambda self: True) state = SimpleNamespace(pid=4321, guid="12345678-1234-1234-1234-123456789abc", loader="loader-original", target="A" * 32, label=None, active=True, version="0.35.0", launches=False, calls=[], cdp_calls=[], raw_calls=[]) async def run(argv, **kwargs): state.raw_calls.append(argv) return "agent-browser " + state.version, "" monkeypatch.setattr(browser, "run_client", run) monkeypatch.setattr(browser.platform, "system", lambda: "Linux") monkeypatch.setattr(browser.platform, "machine", lambda: "x86_64") monkeypatch.setattr(browser, "observe", lambda pid, facts: SimpleNamespace( identity=ProcessIdentity(state.pid, "frozen:" + str(state.pid), state.pid), facts=binary)) class Sidecar: def __init__(self, url): browser.browser_digest(url) async def __aenter__(self): return self async def __aexit__(self, *a): pass async def call(self, method, params=None, session_id=None): assert method in browser.CDP_METHODS state.cdp_calls.append(method) if method == "Target.getTargets": return {"targetInfos": [{"targetId": state.target, "type": "page"}]} if method == "Target.getTargetInfo": return {"targetInfo": {"targetId": state.target, "type": "page"}} if method == "Target.attachToTarget": return {"sessionId": "observation-only"} if method == "Page.getFrameTree": return {"frameTree": {"frame": {"id": state.target, "loaderId": state.loader}}} return {} monkeypatch.setattr(browser, "CDPSidecar", Sidecar) async def command(record, *args): state.calls.append(args) lifecycle = {"launched": state.launches, "relaunchedBrowser": False, "restartedBackground": False} if args[:2] == ("session", "info"): return {"active": state.active, "version": state.version, "pid": state.pid, "session": record.key, "socketDir": record.env["AGENT_BROWSER_SOCKET_DIR"], "namespace": None, "runtimeError": None, "runtime": {"backgroundPid": state.pid, "session": record.key, "engine": "chrome", "browserLaunched": True, "compatibilityStatus": "current", "socketDir": record.env["AGENT_BROWSER_SOCKET_DIR"], "restoreKey": None}} if args == ("get", "cdp-url"): return {"cdpUrl": "ws://127.0.0.1:12345/devtools/browser/" + state.guid, "lifecycle": lifecycle} if args == ("tab", "list"): return {"tabs": [{"tabId": "t1", "targetId": state.target, "label": state.label, "title": "metadata", "url": "https://same.example", "type": "page", "active": True}]} pytest.fail("Page command reached the producer") monkeypatch.setattr(browser.RegisteredBrowser, "command", command) return state async def observed(producer): record = await browser.register_producer("alice", "thread") await browser.observe_registered(record) return record def authority(): return RequestAuthority("request", "alice", "thread", "", (OperationGrant("private_browser"),)) @pytest.mark.parametrize("action", sorted(browser.PAGE_ACTIONS | {"close"})) async def test_disabled_page_operations_never_observe_select_or_execute(producer, action): record = await observed(producer) old = record.pages[0] producer.target, producer.loader = "B" * 32, "replacement-document" record.pin_armed_for = record.session.observation.session_incarnation # Still not a producer capability. producer.calls.clear(); producer.cdp_calls.clear() result = await PrivateBrowserTool().execute(json.dumps({"action": action, "page": "t1"}), {"owner": "alice", "session_id": "thread"}) assert result["failure_kind"] == browser.PAGE_FAILURE assert result["executed"] is False and result["retryable"] is False assert producer.calls == producer.cdp_calls == [] assert old.target_id != producer.target @pytest.mark.parametrize("args", [{"action": "batch", "commands": [["click", "@e1"]]}, {"action": "tab"}, {"action": "window"}, {"action": "frame"}, {"action": "connect"}, {"action": "click", "target": "--new-tab"}, {"action": "evaluate", "--cdp": "endpoint"}, {"action": "click", "targetId": "A" * 32}, {"action": "open", "label": "unsafe"}, {"action": "open", "provider": "remote"}, {"action": "open", "profile": "private"}, {"action": "open", "state": "private"}, {"action": "open", "session-name": "other"}, {"action": "open", "config": "other"}]) async def test_raw_model_escapes_never_spawn(producer, args): result = await PrivateBrowserTool().execute(json.dumps(args), {}) assert result["executed"] is False assert producer.raw_calls == producer.calls == [] @pytest.mark.parametrize("page", ["t0", "t01", "t-1", "current", "title", "label", "A" * 32, 0, None]) def test_alias_validation(page): with pytest.raises(ValueError): browser.parse_operation(json.dumps({"action": "click", "page": page})) async def test_observation_serializes_no_guid_or_control_url(producer): record = await observed(producer) page = record.pages[0] payload = json.dumps(page.to_dict()) assert producer.guid not in payload and "devtools/browser" not in payload assert BrowserPageResource.from_dict(page.to_dict()) == page assert page.target_id == "A" * 32 and page.loader_id == producer.loader assert record.pin_armed_for is None assert not any("pin-tab" in str(c) for c in producer.calls) assert "Target.detachFromTarget" in producer.cdp_calls @pytest.mark.parametrize("field,value", [("pid", 5678), ("guid", "87654321-1234-1234-1234-123456789abc")]) async def test_session_replacement_invalidates_every_old_observation(producer, field, value): record = await observed(producer) old, page = record.session, record.pages[0] record.pin_armed_for = old.observation.session_incarnation setattr(producer, field, value) await browser.observe_registered(record) assert record.session != old and record.pin_armed_for is None with pytest.raises(ValueError): old.validate() with pytest.raises(ValueError): page.validate() @pytest.mark.parametrize("field,value", [("label", "A" * 32), ("loader", ""), ("active", False), ("version", "0.27.0"), ("version", "0.36.0"), ("launches", True)]) async def test_bad_producer_observation_fails_closed(producer, field, value): record = await observed(producer) setattr(producer, field, value) with pytest.raises(ValueError): await browser.observe_registered(record) assert record.session is None and record.pages == () async def test_replacing_same_url_page_or_loader_invalidates_document(producer): record = await observed(producer) old = record.pages[0] producer.loader = "new-loader" await browser.observe_registered(record) with pytest.raises(ValueError): old.validate() document = record.pages[0] producer.target = "C" * 32 await browser.observe_registered(record) with pytest.raises(ValueError): document.validate() @pytest.mark.parametrize("version", ["0.27.0", "0.36.0", "", "0.35.0-extra"]) async def test_exact_producer_version_gate(producer, version): producer.version = version with pytest.raises(ValueError): await browser.trusted_producer() async def test_binary_hash_gate_does_not_search_path_or_npx(producer): (browser.PRODUCER_ROOT / "agent-browser-linux-x64").write_bytes(b"replacement") with pytest.raises(ValueError): await browser.trusted_producer() assert producer.raw_calls == [] assert PrivateBrowserTool._local_agent_browser_binary() is None @pytest.mark.parametrize("raw", ['{}', '{"success":true}', '{"success":1,"data":{}}', '{"success":true,"data":{},"extra":1}', '{"success":true,"data":{},"success":false}', '{"success":true,"data":{},"error":"secret"}', 'not-json']) def test_strict_response_schema(raw): with pytest.raises(ValueError): browser.response(raw) @pytest.mark.parametrize("url", ["ws://127.0.0.1:123/devtools/browser", "ws://evil:123/devtools/browser/12345678-1234-1234-1234-123456789abc", "http://127.0.0.1:123/devtools/browser/12345678-1234-1234-1234-123456789abc", "ws://127.0.0.1:99999/devtools/browser/12345678-1234-1234-1234-123456789abc"]) def test_endpoint_validation_does_not_leak_capability(url): with pytest.raises(ValueError) as failure: browser.browser_digest(url) assert url not in str(failure.value) async def test_environment_config_and_cwd_are_server_owned(producer, monkeypatch): monkeypatch.setenv("AGENT_BROWSER_CDP", "untrusted") monkeypatch.setenv("AGENT_BROWSER_CONFIG", "untrusted") record = await observed(producer) assert record.env == browser.owned_environment(record.cwd, record.key) assert record.cwd.is_relative_to(browser.STATE_ROOT) assert record.config.read_text() == "{}" record.config.write_text('{"cdp":"remote"}') with pytest.raises(ValueError): record.validate_config() @pytest.mark.parametrize("alias", ["direct", "symlink", "hardlink"]) async def test_browser_control_state_is_not_user_filesystem(producer, tmp_path, alias): record = await observed(producer) target = record.config if alias != "direct": target = tmp_path / "alias" (os.link(record.config, target) if alias == "hardlink" else target.symlink_to(record.config)) with pytest.raises(ValueError): FilesystemResource.resolve(FilesystemRoot.seal(tmp_path), str(target)) from src.agent_runtime.process_resources import guard_launch_workspace with pytest.raises(ValueError): guard_launch_workspace(FilesystemRoot.seal(tmp_path)) async def test_session_metadata_exact_approval_first_use_and_replay(producer): await observed(producer) original = authority() content = '{"action":"session_info"}' approval = approval_for(original, "private_browser", content) assert approval.pending.browser_operation.session == original.browser_sessions[0] restored = replace(original, grants=(), browser_sessions=(), browser_pages=(), backend_resources=()) _, first = await dispatch(restored, "private_browser", content, approval) assert first["exit_code"] == 0 assert "https://same.example" not in first["output"] _, replay = await dispatch(restored, "private_browser", content, approval) assert replay["exit_code"] == 1 assert restored.browser_sessions == restored.browser_pages == () async def test_page_approval_cannot_enable_unsupported_operations(producer): await observed(producer) original = authority() content = '{"action":"click","page":"t1","ref":"e1"}' approval = approval_for(original, "private_browser", content) assert approval.pending.browser_operation.page.loader_id == producer.loader producer.calls.clear(); producer.cdp_calls.clear() restored = replace(original, grants=(), browser_sessions=(), browser_pages=()) _, denied = await dispatch(restored, "private_browser", content, approval) assert denied["failure_kind"] == browser.PAGE_FAILURE and denied["executed"] is False assert not approval._claimed and producer.calls == producer.cdp_calls == [] @pytest.mark.parametrize("field,value", [("owner", "bob"), ("request_id", "other"), ("session_id", "other")]) async def test_browser_approval_application_binding_is_exact(producer, field, value): await observed(producer) original = authority() content = '{"action":"session_info"}' approval = approval_for(original, "private_browser", content) changed = replace(original, **{field: value}, browser_sessions=(), browser_pages=()) _, result = await dispatch(changed, "private_browser", content, approval) assert result["exit_code"] == 1 and not approval._claimed async def test_page_child_cannot_acquire_session_scope_or_new_document(producer): record = await observed(producer) original = replace(authority(), browser_sessions=()) child = original.intersect(authority()) assert child.browser_sessions == () and child.browser_pages == original.browser_pages with pytest.raises(ValueError): browser.resolve_browser_operation(child, ExactOperation.normalize("private_browser", '{"action":"session_info"}')) producer.loader = "replacement" await browser.observe_registered(record) with pytest.raises(ValueError): original.intersect(authority()) @pytest.mark.parametrize("phase", ["success", "exception", "cancel", "nested"]) async def test_browser_context_restoration(producer, phase): await observed(producer) bound = browser.resolve_browser_operation(authority(), ExactOperation.normalize("private_browser", '{"action":"session_info"}')) try: with browser.bind_browser_operation(bound): if phase == "exception": raise RuntimeError() if phase == "cancel": raise asyncio.CancelledError() if phase == "nested": with browser.bind_browser_operation(None): assert browser._ACTIVE.get() is None assert browser._ACTIVE.get() is bound except (RuntimeError, asyncio.CancelledError): pass assert browser._ACTIVE.get() is None async def test_legacy_restoration_does_not_discover_browser_scopes(producer): await observed(producer) data = authority().to_dict() data["version"] = 4 del data["browser_sessions"], data["browser_pages"] restored = RequestAuthority.from_dict(data) assert restored.browser_sessions == restored.browser_pages == () def test_lookup_does_not_create_legacy_or_missing_session(producer): assert browser.registered("alice", "thread") is None assert authority().browser_sessions == () assert browser._REGISTRY == {} and producer.raw_calls == []