mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-09 00:12:21 +02:00
feat(runtime): bind browser resources to authority
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
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
|
||||
@@ -244,177 +244,6 @@ def _run(payload, ctx):
|
||||
return asyncio.run(PrivateBrowserTool().execute(json.dumps(payload), ctx))
|
||||
|
||||
|
||||
def test_timeout_cleans_only_this_sessions_browser(browser_env) -> None:
|
||||
state, calls, cleaned, swept = browser_env
|
||||
|
||||
async def _hang(command):
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
state["behaviour"] = _hang
|
||||
result = _run({"action": "open", "url": "https://example.com"}, {"session_id": "s-timeout"})
|
||||
|
||||
assert result["exit_code"] == 1 and "timed out" in result["error"]
|
||||
assert cleaned == ["s-timeout"]
|
||||
assert swept == [], "a per-session timeout must not sweep other sessions' Chrome"
|
||||
lifecycle = result["browser_lifecycle"]
|
||||
assert lifecycle["state"] == "timed_out"
|
||||
assert lifecycle["cleanup"]["verified"] is True
|
||||
assert [stage["stage"] for stage in lifecycle["stages"]] == ["open", "forced_cleanup"]
|
||||
assert sum(1 for call in calls if "open" in call) == 1, "remote opens are never retried"
|
||||
|
||||
|
||||
def test_launch_failure_is_reported_and_cleaned(browser_env) -> None:
|
||||
state, _, cleaned, _ = browser_env
|
||||
|
||||
async def _no_sandbox(command):
|
||||
return 1, ("Chrome exited early (exit code: unknown) without writing DevToolsActivePort\n"
|
||||
"FATAL: No usable sandbox!")
|
||||
|
||||
state["behaviour"] = _no_sandbox
|
||||
result = _run({"action": "open", "url": "https://example.com"}, {"session_id": "s-launch"})
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "could not launch the browser" in result["error"]
|
||||
assert cleaned == ["s-launch"]
|
||||
assert result["browser_lifecycle"]["state"] == "launch_failed"
|
||||
assert result["browser_lifecycle"]["navigation_generation"] == 0
|
||||
|
||||
|
||||
def test_observation_after_failed_navigation_is_marked_stale(browser_env) -> None:
|
||||
state, _, _, _ = browser_env
|
||||
|
||||
async def _behaviour(command):
|
||||
if command[-2:] == ["open", "https://good.example/"]:
|
||||
return 0, "✓ Good\n https://good.example/\n"
|
||||
if "open" in command:
|
||||
return 1, "net::ERR_NAME_NOT_RESOLVED"
|
||||
return 0, '- heading "Good page" [ref=e1]'
|
||||
|
||||
state["behaviour"] = _behaviour
|
||||
ctx = {"session_id": "s-stale"}
|
||||
opened = _run({"action": "open", "url": "https://good.example/"}, ctx)
|
||||
assert opened["browser_lifecycle"]["navigation_generation"] == 1
|
||||
assert opened["browser_lifecycle"]["page_url"] == "https://good.example/"
|
||||
|
||||
failed = _run({"action": "open", "url": "https://bad.example/"}, ctx)
|
||||
assert failed["exit_code"] == 1
|
||||
assert failed["browser_lifecycle"]["state"] == "navigation_failed"
|
||||
|
||||
observed = _run({"action": "snapshot"}, ctx)
|
||||
assert observed["output"].startswith("[Browser lifecycle: the most recent navigation to https://bad.example/ failed")
|
||||
assert "shows https://good.example/ (navigation #1)" in observed["output"]
|
||||
assert observed["browser_lifecycle"]["stale_observation"] is True
|
||||
|
||||
_run({"action": "open", "url": "https://good.example/"}, ctx)
|
||||
fresh = _run({"action": "snapshot"}, ctx)
|
||||
assert not fresh["output"].startswith("[Browser lifecycle")
|
||||
assert "stale_observation" not in fresh["browser_lifecycle"]
|
||||
|
||||
|
||||
def test_sessionless_call_gets_its_own_browser_and_closes_it(browser_env, monkeypatch) -> None:
|
||||
state, calls, cleaned, _ = browser_env
|
||||
monkeypatch.setattr(PrivateBrowserTool, "_owned_daemon_exists", staticmethod(lambda env, session: True))
|
||||
|
||||
async def _ok(command):
|
||||
return 0, "✓ T\n https://example.com/\n"
|
||||
|
||||
state["behaviour"] = _ok
|
||||
first = _run({"action": "open", "url": "https://example.com/"}, {})
|
||||
second = _run({"action": "open", "url": "https://example.com/"}, {})
|
||||
|
||||
sessions = [call[call.index("--session") + 1] for call in calls if "--session" in call]
|
||||
assert all(session.startswith("ody-") for session in sessions)
|
||||
assert len({sessions[0], sessions[-1]}) == 2, "sessionless calls must not share a browser"
|
||||
assert any(call[-1] == "close" for call in calls)
|
||||
assert first["browser_lifecycle"]["ownership"] == "ephemeral"
|
||||
assert first["browser_lifecycle"]["cleanup"]["graceful_close"] is True
|
||||
assert first["browser_lifecycle"]["state"] == "closed"
|
||||
assert len(cleaned) == 2
|
||||
assert not web_tools._ACTIVE_BROWSER_SESSIONS.intersection(sessions)
|
||||
assert not any(browser_lifecycle.registered(s) for s in sessions)
|
||||
assert second["exit_code"] == 0
|
||||
|
||||
|
||||
def test_actions_on_one_session_are_serialized(browser_env) -> None:
|
||||
state, _, _, _ = browser_env
|
||||
active = {"now": 0, "peak": 0}
|
||||
|
||||
async def _slow(command):
|
||||
active["now"] += 1
|
||||
active["peak"] = max(active["peak"], active["now"])
|
||||
await asyncio.sleep(0.02)
|
||||
active["now"] -= 1
|
||||
return 0, '- heading "x"'
|
||||
|
||||
state["behaviour"] = _slow
|
||||
|
||||
async def _both():
|
||||
tool = PrivateBrowserTool()
|
||||
await asyncio.gather(
|
||||
tool.execute(json.dumps({"action": "snapshot"}), {"session_id": "s-lock"}),
|
||||
tool.execute(json.dumps({"action": "snapshot"}), {"session_id": "s-lock"}),
|
||||
)
|
||||
|
||||
asyncio.run(_both())
|
||||
assert active["peak"] == 1
|
||||
|
||||
|
||||
def test_cancellation_stops_clients_and_cleans_the_session(browser_env, monkeypatch) -> None:
|
||||
state, calls, cleaned, _ = browser_env
|
||||
terminated = []
|
||||
|
||||
async def _forever(command):
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
state["behaviour"] = _forever
|
||||
monkeypatch.setattr(
|
||||
PrivateBrowserTool, "_terminate_subprocess",
|
||||
staticmethod(lambda proc: terminated.append(proc.command)),
|
||||
)
|
||||
|
||||
async def _cancel():
|
||||
task = asyncio.create_task(PrivateBrowserTool().execute(
|
||||
json.dumps({"action": "open", "url": "https://example.com"}),
|
||||
{"session_id": "s-cancel"},
|
||||
))
|
||||
while not calls:
|
||||
await asyncio.sleep(0.01)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
asyncio.run(_cancel())
|
||||
|
||||
assert terminated and terminated[0][-1] == "https://example.com"
|
||||
assert cleaned == ["s-cancel"]
|
||||
key = web_tools._scoped_browser_session("odysseus-ui", "s-cancel")
|
||||
assert browser_lifecycle.registered(key).state == "cancelled"
|
||||
|
||||
|
||||
def test_local_open_recovery_is_single_and_inside_the_deadline(browser_env, monkeypatch, tmp_path) -> None:
|
||||
state, calls, cleaned, _ = browser_env
|
||||
page = tmp_path / "page.html"
|
||||
page.write_text("<title>x</title>")
|
||||
|
||||
async def _hang(command):
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
state["behaviour"] = _hang
|
||||
payload = {"action": "open", "url": "/workspace/page.html", "_odysseus_browser_retry": True}
|
||||
result = _run(payload, {"session_id": "s-retry"})
|
||||
|
||||
opens = [call for call in calls if call[-1] == page.as_uri()]
|
||||
assert len(opens) == 2, "a model-supplied retry flag must not change recovery"
|
||||
assert result["browser_lifecycle"]["recovery_attempts"] == 1
|
||||
assert cleaned == ["s-retry", "s-retry"]
|
||||
|
||||
calls.clear()
|
||||
monkeypatch.setattr(PrivateBrowserTool, "_RECOVERY_BUDGET_S", 0)
|
||||
exhausted = _run({"action": "open", "url": "/workspace/page.html", "timeout_ms": 1000}, {"session_id": "s-budget"})
|
||||
assert len([call for call in calls if call[-1] == page.as_uri()]) == 1
|
||||
assert "recovery_attempts" not in exhausted["browser_lifecycle"]
|
||||
|
||||
|
||||
def test_research_reader_passes_its_timeout_to_the_browser(monkeypatch) -> None:
|
||||
from src.research_navigator import ResearchNavigator
|
||||
|
||||
@@ -486,76 +315,6 @@ def _owned_processes(runtime: Path) -> list[int]:
|
||||
return owned
|
||||
|
||||
|
||||
@real_browser
|
||||
def test_real_local_page_open_extract_and_ephemeral_cleanup(real_runtime) -> None:
|
||||
workspace, runtime, env = real_runtime
|
||||
(workspace / "page.html").write_text(
|
||||
"<html><head><title>Lifecycle</title></head><body><h1>Fresh heading</h1></body></html>"
|
||||
)
|
||||
|
||||
result = _run(
|
||||
{"action": "batch", "commands": [["open", "/workspace/page.html"], ["snapshot"]]},
|
||||
{"subproc_env": env},
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 0, result
|
||||
assert "Fresh heading" in result["output"]
|
||||
lifecycle = result["browser_lifecycle"]
|
||||
assert lifecycle["ownership"] == "ephemeral"
|
||||
assert lifecycle["navigation_generation"] == 1
|
||||
assert lifecycle["state"] == "closed" and lifecycle["page_url"] == ""
|
||||
assert lifecycle["closed_page_url"].endswith("/page.html")
|
||||
assert lifecycle["cleanup"]["verified"] is True
|
||||
assert [stage["stage"] for stage in lifecycle["stages"]] == ["batch", "close"]
|
||||
time.sleep(0.5)
|
||||
assert _owned_processes(runtime) == []
|
||||
assert list((runtime / "agent-browser").glob("ody-*")) == []
|
||||
assert list((runtime / "tmp").glob("agent-browser-chrome-*")) == []
|
||||
|
||||
|
||||
@real_browser
|
||||
def test_real_retained_session_survives_then_forced_cleanup_leaves_nothing(real_runtime) -> None:
|
||||
workspace, runtime, env = real_runtime
|
||||
(workspace / "a.html").write_text("<title>A</title><h1>Alpha</h1>")
|
||||
ctx = {"session_id": "retained", "subproc_env": env}
|
||||
|
||||
opened = _run({"action": "open", "url": "/workspace/a.html"}, ctx)
|
||||
assert opened["exit_code"] == 0, opened
|
||||
observed = _run({"action": "snapshot"}, ctx)
|
||||
assert "Alpha" in observed["output"]
|
||||
assert observed["browser_lifecycle"]["ownership"] == "retained"
|
||||
assert _owned_processes(runtime), "a retained session keeps its browser"
|
||||
|
||||
receipt = PrivateBrowserTool._terminate_owned_daemon(dict(os.environ, **env), "retained")
|
||||
|
||||
assert receipt["verified"] is True and receipt["killed"] >= 2
|
||||
assert receipt["removed_profiles"] == 1
|
||||
assert _owned_processes(runtime) == []
|
||||
assert list((runtime / "agent-browser").glob("ody-*")) == []
|
||||
|
||||
|
||||
@real_browser
|
||||
def test_real_cancellation_leaves_no_browser(real_runtime) -> None:
|
||||
workspace, runtime, env = real_runtime
|
||||
(workspace / "slow.html").write_text("<title>S</title><h1>Slow</h1>")
|
||||
ctx = {"session_id": "cancelled", "subproc_env": env}
|
||||
assert _run({"action": "open", "url": "/workspace/slow.html"}, ctx)["exit_code"] == 0
|
||||
|
||||
async def _cancel_wait():
|
||||
task = asyncio.create_task(PrivateBrowserTool().execute(
|
||||
json.dumps({"action": "wait", "timeout_ms": 30000}), ctx,
|
||||
))
|
||||
await asyncio.sleep(1.5)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
asyncio.run(_cancel_wait())
|
||||
time.sleep(0.5)
|
||||
assert _owned_processes(runtime) == []
|
||||
assert list((runtime / "agent-browser").glob("ody-*")) == []
|
||||
|
||||
|
||||
def test_browser_mcp_call_is_bounded_and_never_replayed(monkeypatch) -> None:
|
||||
from src.mcp_manager import McpManager
|
||||
|
||||
@@ -577,115 +336,3 @@ def test_browser_mcp_call_is_bounded_and_never_replayed(monkeypatch) -> None:
|
||||
assert result["exit_code"] == 1
|
||||
assert "timed out after 0.05s and was not retried" in result["error"]
|
||||
assert calls == ["browser_navigate"]
|
||||
|
||||
|
||||
def test_read_url_navigates_and_extracts_in_one_observation(browser_env) -> None:
|
||||
state, calls, _, _ = browser_env
|
||||
|
||||
async def _batch(command):
|
||||
return 0, json.dumps([
|
||||
{"command": ["open", "https://example.com/"], "success": True,
|
||||
"result": {"title": "Example", "url": "https://example.com/final"}},
|
||||
{"command": ["get", "text", "body"], "success": True,
|
||||
"result": {"text": "Example body"}},
|
||||
])
|
||||
|
||||
state["behaviour"] = _batch
|
||||
result = _run({"action": "read", "url": "https://example.com/"}, {"session_id": "s-read"})
|
||||
|
||||
assert calls[-1][-2:] == ["batch", "--json"]
|
||||
assert result["exit_code"] == 0
|
||||
assert result["output"] == "Example\nhttps://example.com/final\n\nExample body"
|
||||
assert result["browser_lifecycle"]["page_url"] == "https://example.com/final"
|
||||
|
||||
|
||||
def test_read_url_without_extracted_text_is_a_failure(browser_env) -> None:
|
||||
state, _, _, _ = browser_env
|
||||
|
||||
async def _no_text(command):
|
||||
return 0, json.dumps([
|
||||
{"success": True, "result": {"url": "https://example.com/"}},
|
||||
{"success": False, "error": "Timeout waiting for body", "result": None},
|
||||
])
|
||||
|
||||
state["behaviour"] = _no_text
|
||||
result = _run({"action": "read", "url": "https://example.com/"}, {"session_id": "s-read-fail"})
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Timeout waiting for body" in result["error"]
|
||||
assert result["browser_lifecycle"]["state"] == "navigation_failed"
|
||||
|
||||
|
||||
@real_browser
|
||||
def test_real_read_url_extracts_text_after_navigation(real_runtime) -> None:
|
||||
import functools
|
||||
import http.server
|
||||
import threading
|
||||
|
||||
workspace, runtime, env = real_runtime
|
||||
(workspace / "doc.html").write_text("<title>Doc</title><h1>Served heading</h1><p>Body text</p>")
|
||||
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(workspace))
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
url = f"http://127.0.0.1:{server.server_address[1]}/doc.html"
|
||||
result = _run({"action": "read", "url": url}, {"subproc_env": env})
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
assert result["exit_code"] == 0, result
|
||||
assert result["output"].startswith(f"Doc\n{url}")
|
||||
assert "Served heading" in result["output"] and "Body text" in result["output"]
|
||||
assert result["browser_lifecycle"]["closed_page_url"] == url
|
||||
assert result["browser_lifecycle"]["cleanup"]["verified"] is True
|
||||
time.sleep(0.5)
|
||||
assert _owned_processes(runtime) == []
|
||||
|
||||
|
||||
def test_selector_read_is_an_observation_not_a_navigation() -> None:
|
||||
assert PrivateBrowserTool._navigation_target(
|
||||
"read", {"selector": "#main", "url": "https://elsewhere.example/"}
|
||||
) == ""
|
||||
assert PrivateBrowserTool._navigation_target(
|
||||
"batch", {"commands": [["open", "file:///a.html"], ["snapshot"], ["open", "file:///b.html"]]}
|
||||
) == "file:///b.html"
|
||||
|
||||
|
||||
def test_batch_navigation_outcome_comes_from_its_rows(browser_env) -> None:
|
||||
state, _, _, _ = browser_env
|
||||
responses = {}
|
||||
|
||||
async def _batch(command):
|
||||
if command[-2:] == ["batch", "--json"]:
|
||||
return responses["batch"]
|
||||
return 0, '- heading "x"'
|
||||
|
||||
state["behaviour"] = _batch
|
||||
ctx = {"session_id": "s-batch"}
|
||||
|
||||
# The open succeeded; a later click failing must not mark it failed.
|
||||
responses["batch"] = (1, json.dumps([
|
||||
{"command": ["open", "https://a.example/"], "success": True,
|
||||
"result": {"url": "https://a.example/landing"}},
|
||||
{"command": ["click", "@e9"], "success": False, "error": "no element"},
|
||||
]))
|
||||
result = _run({"action": "batch", "commands": [["open", "https://a.example/"], ["click", "@e9"]]}, ctx)
|
||||
assert result["browser_lifecycle"]["page_url"] == "https://a.example/landing"
|
||||
assert result["browser_lifecycle"]["state"] == "ready"
|
||||
assert "stale_observation" not in _run({"action": "snapshot"}, ctx)["browser_lifecycle"]
|
||||
|
||||
responses["batch"] = (1, json.dumps([
|
||||
{"command": ["open", "https://b.example/"], "success": False, "error": "net::ERR"},
|
||||
]))
|
||||
failed = _run({"action": "batch", "commands": [["open", "https://b.example/"]]}, ctx)
|
||||
assert failed["browser_lifecycle"]["state"] == "navigation_failed"
|
||||
note = _run({"action": "snapshot"}, ctx)["output"]
|
||||
assert "shows https://a.example/landing (navigation #1), not https://b.example/" in note
|
||||
|
||||
responses["batch"] = (1, "daemon connection lost")
|
||||
_run({"action": "batch", "commands": [["open", "https://c.example/"]]}, ctx)
|
||||
unknown = _run({"action": "snapshot"}, ctx)
|
||||
assert "outcome of the most recent navigation to https://c.example/ is unknown" in unknown["output"]
|
||||
assert unknown["browser_lifecycle"]["page_url"] == ""
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Release-only probes, isolated owned sessions; no model page authorization.
|
||||
|
||||
Run in the actual release image with ODYSSEUS_BROWSER_LIVE_CONTRACT=1. Without
|
||||
that explicit gate these are reported as skips, not producer-contract passes.
|
||||
The pin test asserts the known 0.35.0 defect, never enables page operations.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import pytest
|
||||
|
||||
from src import browser_identity as browser
|
||||
from src.agent_tools.web_tools import PrivateBrowserTool
|
||||
from src import browser_lifecycle
|
||||
|
||||
pytestmark = pytest.mark.skipif(os.environ.get("ODYSSEUS_BROWSER_LIVE_CONTRACT") != "1",
|
||||
reason="requires explicit live contract gate in the allowlisted 0.35.0 release Docker image")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def live(tmp_path, monkeypatch):
|
||||
from pathlib import Path
|
||||
# Unix-domain sockets have a strict path-length limit. Match the release's
|
||||
# short owned runtime instead of pytest's long per-test directory name.
|
||||
directory = tempfile.TemporaryDirectory(prefix="w3-live-")
|
||||
monkeypatch.setattr(browser, "STATE_ROOT", Path(directory.name))
|
||||
monkeypatch.setattr(browser, "_REGISTRY", {})
|
||||
record = await browser.register_producer("live-contract", "thread")
|
||||
# Test setup only. Exercise the source-audited first-pin local launch case.
|
||||
await record.command("get", "cdp-url", "--pin-tab")
|
||||
try:
|
||||
yield record
|
||||
finally:
|
||||
try:
|
||||
await record.command("close")
|
||||
finally:
|
||||
browser_lifecycle.force_cleanup(record.cwd / "runtime", record.key)
|
||||
directory.cleanup()
|
||||
|
||||
|
||||
async def test_live_exact_schema_target_loader_and_observation_stability(live):
|
||||
first = await browser.observe_registered(live, "t1")
|
||||
second = await browser.observe_registered(live, "t1")
|
||||
assert first.authority_key() == second.authority_key()
|
||||
assert first.loader_id and first.target_id
|
||||
assert live.pin_armed_for is None
|
||||
assert "devtools/browser" not in json.dumps(first.to_dict())
|
||||
|
||||
|
||||
async def test_live_document_navigation_reload_hash_and_identical_tabs(live):
|
||||
await live.command("open", "data:text/html,<title>fixture</title><p>content</p>", "--pin-tab")
|
||||
first = await browser.observe_registered(live, "t1")
|
||||
await live.command("eval", "history.replaceState(null,'','#same')", "--pin-tab")
|
||||
same = await browser.observe_registered(live, "t1")
|
||||
assert same.loader_id == first.loader_id
|
||||
await live.command("reload", "--pin-tab")
|
||||
reloaded = await browser.observe_registered(live, "t1")
|
||||
assert reloaded.loader_id != first.loader_id
|
||||
await live.command("open", "data:text/html,<title>replacement</title>", "--pin-tab")
|
||||
navigated = await browser.observe_registered(live, "t1")
|
||||
assert navigated.loader_id != reloaded.loader_id
|
||||
await live.command("tab", "new", "data:text/html,<title>replacement</title>", "--pin-tab")
|
||||
await browser.observe_registered(live)
|
||||
assert len({p.target_id for p in live.pages}) == 2
|
||||
assert len({p.loader_id for p in live.pages}) == 2
|
||||
|
||||
|
||||
async def test_live_local_launch_rearm_drops_flags_and_retargets_destroyed_page(live):
|
||||
await live.command("tab", "new", "about:blank", "--pin-tab")
|
||||
await browser.observe_registered(live)
|
||||
# Digit-leading target avoids the distinct producer label-parser hazard.
|
||||
captured = next((p for p in live.pages if p.target_id[0].isdigit()), None)
|
||||
for _ in range(8):
|
||||
if captured is not None:
|
||||
break
|
||||
await live.command("tab", "new", "about:blank", "--pin-tab")
|
||||
await browser.observe_registered(live)
|
||||
captured = next((p for p in live.pages if p.target_id[0].isdigit()), None)
|
||||
assert captured is not None, "could not obtain a digit-leading target for the pin probe"
|
||||
switched = await live.command("tab", captured.target_id, "--pin-tab")
|
||||
assert switched["targetId"] == captured.target_id
|
||||
await live.command("session", "info", "--no-pin-tab")
|
||||
await live.command("session", "info", "--pin-tab")
|
||||
endpoint = urlsplit(live._endpoint)
|
||||
# External destruction is TEST FIXTURE ONLY, outside the identity sidecar.
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{endpoint.port}/json/close/{captured.target_id}", timeout=3) as response:
|
||||
assert response.status == 200
|
||||
result = await live.command("snapshot", "--pin-tab")
|
||||
active = [t for t in browser.tabs_schema(await live.command("tab", "list")) if t["active"]]
|
||||
assert active and active[0]["targetId"] != captured.target_id
|
||||
assert "tab_gone" not in json.dumps(result)
|
||||
assert result["lifecycle"]["relaunchedBrowser"] is False
|
||||
assert live.pin_armed_for is None
|
||||
# Actual Odysseus refuses before any page command, even with this observation.
|
||||
denied = await PrivateBrowserTool().execute('{"action":"snapshot","page":"t1"}',
|
||||
{"owner": "live-contract", "session_id": "thread"})
|
||||
assert denied["failure_kind"] == browser.PAGE_FAILURE and denied["executed"] is False
|
||||
|
||||
|
||||
async def test_live_af_target_switch_is_exact_but_never_grants_page_execution(live):
|
||||
await browser.observe_registered(live)
|
||||
captured = live.pages[0]
|
||||
switched = await live.command("tab", captured.target_id, "--pin-tab")
|
||||
assert switched["targetId"] == captured.target_id
|
||||
denied = await PrivateBrowserTool().execute('{"action":"click","page":"t1","ref":"e1"}', {})
|
||||
assert denied["executed"] is False
|
||||
@@ -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 == []
|
||||
@@ -21,5 +21,6 @@ def test_screenshot_cannot_overwrite_nonimage_artifact(monkeypatch, tmp_path, na
|
||||
{"session_id": "artifact-safety"},
|
||||
))
|
||||
assert result["exit_code"] == 1
|
||||
assert "OUTPUT destination" in result["error"]
|
||||
assert result["failure_kind"] == "browser_page_authority_unavailable"
|
||||
assert result["executed"] is False
|
||||
assert source.read_bytes() == b"original artifact"
|
||||
|
||||
@@ -59,7 +59,7 @@ async def test_stream_recovers_navigation_then_fetch_without_email_classifier(mo
|
||||
return {'tool_calls': [{'index': 0, 'id': name, 'type': 'function',
|
||||
'function': {'name': name, 'arguments': json.dumps(args)}}]}
|
||||
responses = iter([
|
||||
call('private_browser', {'action': 'batch', 'commands': [['open', URL], ['find', 'wardrobe'], ['snapshot']]}),
|
||||
call('private_browser', {'action': 'open', 'url': URL}),
|
||||
call('web_fetch', {'url': URL}),
|
||||
call('web_search', {'query': 'wardrobe'}),
|
||||
{'content': 'The site could not be read and no usable product evidence was found.'},
|
||||
|
||||
@@ -3392,7 +3392,7 @@ def test_skill_update_alias_normalizes_to_edit_before_policy():
|
||||
assert args['action'] == 'edit'
|
||||
|
||||
|
||||
def test_private_browser_open_normalizes_to_atomic_snapshot_batch():
|
||||
def test_private_browser_open_never_creates_an_internal_batch():
|
||||
tool, args = normalize_preview_function_args(
|
||||
'private_browser',
|
||||
{'action': 'open', 'url': 'https://example.com', 'timeout_ms': 12000},
|
||||
@@ -3400,8 +3400,8 @@ def test_private_browser_open_normalizes_to_atomic_snapshot_batch():
|
||||
|
||||
assert tool == 'private_browser'
|
||||
assert args == {
|
||||
'action': 'batch',
|
||||
'commands': [['open', 'https://example.com'], ['snapshot']],
|
||||
'action': 'open',
|
||||
'url': 'https://example.com',
|
||||
'timeout_ms': 12000,
|
||||
}
|
||||
|
||||
@@ -3495,7 +3495,7 @@ def test_every_compactly_offered_preview_tool_has_valid_policy_permitted_call():
|
||||
'chat_with_model': ({'model': 'qwen', 'message': 'hello'}, 'ask model qwen to answer hello'),
|
||||
'pipeline': ({'steps': [{'model': 'qwen', 'instruction': 'draft'}]}, 'run a model pipeline to draft'),
|
||||
'pdf_extract': ({'url': 'https://example.com/x.pdf', 'query': 'metric'}, 'read this pdf'),
|
||||
'private_browser': ({'action': 'batch', 'commands': [['open', 'https://example.com'], ['snapshot']]}, 'use the private browser'),
|
||||
'private_browser': ({'action': 'session_info'}, 'use the private browser'),
|
||||
'read_email': ({'uid': '1'}, 'read my email'),
|
||||
'reply_to_email': ({'uid': '1', 'body': 'Thanks'}, 'reply to email UID 1 saying Thanks'),
|
||||
'search_chats': ({'query': 'project'}, 'search my chats'),
|
||||
@@ -4322,23 +4322,19 @@ def test_compact_browser_distinguishes_element_refs_from_keyboard_keys():
|
||||
original = next(s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'private_browser')
|
||||
browser = compact_schemas([original])[0]['function']
|
||||
assert 'fill/click/press' not in browser['description']
|
||||
assert 'key' in browser['description'] and 'Enter' in browser['description']
|
||||
assert 'focused' in browser['parameters']['properties']['key']['description']
|
||||
assert 'unavailable' in browser['description']
|
||||
assert 'commands' not in browser['parameters']['properties']
|
||||
assert set(browser['parameters']['properties']) == set(original['function']['parameters']['properties'])
|
||||
|
||||
|
||||
def test_v3_browser_batch_schema_matches_executor_sequence_contract():
|
||||
def test_v3_browser_schema_does_not_offer_batch_or_current_tab_authority():
|
||||
browser = next(
|
||||
schema for schema in compact_schemas(FUNCTION_TOOL_SCHEMAS)
|
||||
if schema['function']['name'] == 'private_browser'
|
||||
)['function']
|
||||
commands = browser['parameters']['properties']['commands']
|
||||
|
||||
assert commands['items']['type'] == 'array'
|
||||
assert commands['items']['items'] == {'type': 'string'}
|
||||
assert '[["open"' in commands['description']
|
||||
assert 'snapshot' in browser['description']
|
||||
assert 'does not search the site' in browser['description']
|
||||
assert 'commands' not in browser['parameters']['properties']
|
||||
assert 'batch' not in browser['parameters']['properties']['action']['enum']
|
||||
assert 'unavailable' in browser['description']
|
||||
|
||||
|
||||
def test_v3_browser_target_fields_preserve_selector_semantics():
|
||||
|
||||
@@ -32,8 +32,8 @@ def test_registry_dispatch_preserves_session_id_for_native_handlers(monkeypatch)
|
||||
monkeypatch.setattr(tool_execution, "_direct_fallback", fallback)
|
||||
|
||||
async def invoke():
|
||||
block = Block('{"action":"snapshot"}')
|
||||
block.tool_type = "private_browser"
|
||||
block = Block('{"location":"Lisbon"}')
|
||||
block.tool_type = "get_weather"
|
||||
return await execute_tool_block(
|
||||
block,
|
||||
session_id="runtime-session",
|
||||
@@ -41,7 +41,7 @@ def test_registry_dispatch_preserves_session_id_for_native_handlers(monkeypatch)
|
||||
)
|
||||
|
||||
description, result = asyncio.run(invoke())
|
||||
assert description.startswith("registry: private_browser")
|
||||
assert description.startswith("registry: get_weather")
|
||||
assert result["exit_code"] == 0
|
||||
assert seen["session_id"] == "runtime-session"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ from src.agent_runtime.resource_binding import (
|
||||
bind_resource_operation, resolve_filesystem_operation,
|
||||
)
|
||||
from src.agent_runtime.resources import (
|
||||
BrowserPageResource, BrowserProducer, ExternalResource, FileObjectIdentity,
|
||||
ExternalResource, FileObjectIdentity,
|
||||
FilesystemResource, FilesystemRoot, FilesystemScope, OwnedResource, ProcessResource,
|
||||
)
|
||||
from src.tool_approvals import ToolApprovalStore
|
||||
@@ -706,10 +706,6 @@ async def test_resource_identity_never_expands_narrow_request_classes(tmp_path,
|
||||
|
||||
|
||||
def test_nonfilesystem_identities_are_inert_and_distinguish_producers_from_pages():
|
||||
producer = BrowserProducer("browser", "alice", "thread", "session", "incarnation-1")
|
||||
page = BrowserPageResource(producer, "page-1", 2, "https://example.test")
|
||||
assert replace(producer, incarnation="incarnation-2") != producer
|
||||
assert replace(page, navigation_generation=3) != page
|
||||
from src.process_lifecycle import ProcessIdentity
|
||||
ProcessResource("native:containment", "alice", "request", "thread", ProcessIdentity(123, "boot:start"), "leader", "job", "receipt")
|
||||
OwnedResource("documents", "alice", "thread", "documents", "document", "revision")
|
||||
|
||||
Reference in New Issue
Block a user