feat(runtime): bind browser resources to authority

This commit is contained in:
Alexandre Teixeira
2026-10-02 18:54:09 +01:00
parent b648f9ddbe
commit e175bea752
27 changed files with 2120 additions and 3153 deletions
+291
View File
@@ -0,0 +1,291 @@
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 == []