Files
odysseus/tests/test_private_browser_tool.py
T
Alexandre Teixeira 576abb012d feat(browser): deterministic private_browser lifecycle (Wave 5A)
Own each agent-browser session as a browser tree: the daemon's POSIX
session, its runtime files and its Chrome profile. Timeouts, launch
failures, bootstrap recovery, cancellation and shutdown clean that tree
and verify nothing survives, instead of killing only the daemon and
orphaning Chrome. Per-call cleanup no longer sweeps every Chrome under
the runtime TMPDIR.

Sessionless calls get an ephemeral browser closed before returning.
Actions on one session are serialized. Recovery is bounded by one
deadline with at most one retry for local HTML open, and the retry flag
is no longer model-visible. Observations after a failed navigation are
marked stale. read URL navigates and extracts in one batch because
agent-browser has no read command. Results carry a browser_lifecycle
receipt with stages, timings, ownership and cleanup evidence.

Playwright MCP tool calls are bounded by
ODYSSEUS_BROWSER_MCP_CALL_TIMEOUT_S and are not retried. research_navigator
now passes timeout_ms.
2026-10-01 20:59:27 +01:00

2026 lines
74 KiB
Python

import asyncio
import pytest
import base64
import json
from pathlib import Path
from core import platform_compat
import src.agent_tools.web_tools as web_tools
from src.agent_tools.web_tools import (
PrivateBrowserTool,
YouTubeTool,
shutdown_private_browser_sessions,
)
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
@pytest.mark.parametrize('snapshot,empty', [
('- generic\n - generic\n - generic', True),
('(empty page)', True),
('- heading "No results found"', False),
('- button "Accept cookies" [ref=e1]', False),
('- generic "GameStop"', False),
])
def test_browser_distinguishes_loading_scaffolding_from_content(snapshot, empty):
observation = json.dumps([{'success': True, 'result': {'snapshot': snapshot}}])
assert PrivateBrowserTool._empty_dom_observation(observation) is empty
def test_open_snapshot_batch_waits_for_loading_scaffolding(monkeypatch):
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set())
batches = []
class Proc:
returncode = 0
def __init__(self, kwargs):
self.kwargs = kwargs
async def communicate(self, stdin=None):
batches.append(json.loads(stdin))
snapshot = '- generic\n - generic' if len(batches) == 1 else '- heading "Loaded results"'
output = json.dumps([{'success': True, 'result': {'snapshot': snapshot}}]).encode()
if self.kwargs['stdout'] != asyncio.subprocess.PIPE:
self.kwargs['stdout'].write(output)
return b'', b''
return output, b''
async def spawn(*command, **kwargs):
return Proc(kwargs)
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
result = asyncio.run(PrivateBrowserTool().execute(json.dumps({
'action': 'batch', 'commands': [['open', 'https://example.com'], ['snapshot']],
}), {'session_id': 'loading-scaffolding'}))
assert 'Loaded results' in result['output']
assert len(batches) == 2
assert batches[1] == [['wait', '1000'], ['snapshot']]
def test_private_browser_plain_url_defaults_to_read() -> None:
args, err = PrivateBrowserTool()._parse_args("https://example.com")
assert err is None
assert args == {"action": "read", "url": "https://example.com"}
def test_private_browser_rejects_snapshot_path_as_stale_page_risk(monkeypatch) -> None:
"""A snapshot has no target path; local media needs inspect_media."""
called = False
async def _unexpected_subprocess(*args, **kwargs):
nonlocal called
called = True
raise AssertionError("snapshot path must be rejected before browser launch")
monkeypatch.setattr(asyncio, "create_subprocess_exec", _unexpected_subprocess)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({"action": "snapshot", "path": "/workspace/fixture.png"}),
{"session_id": "snapshot-path"},
))
assert result["exit_code"] == 1
assert "inspect_media" in result["error"]
assert not called
def test_private_browser_maps_workspace_file_urls_and_screenshot_paths(monkeypatch, tmp_path) -> None:
monkeypatch.setattr("src.tool_execution.get_active_workspace", lambda: str(tmp_path))
source = tmp_path / "task browser.html"
source.write_text("<html></html>")
resolved_url = PrivateBrowserTool._resolve_local_file_url(
"file:///workspace/task%20browser.html"
)
resolved_path = PrivateBrowserTool._resolve_workspace_path(
"/workspace/output.png"
)
assert resolved_url == source.as_uri()
assert resolved_path == tmp_path / "output.png"
def test_private_browser_maps_bare_workspace_page_for_direct_and_batch_open(
monkeypatch, tmp_path
) -> None:
monkeypatch.setattr("src.tool_execution.get_active_workspace", lambda: str(tmp_path))
page = tmp_path / "output.html"
page.write_text("<title>local</title>")
assert PrivateBrowserTool._resolve_local_file_url(
"/workspace/output.html"
) == page.as_uri()
commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([
["open", "/workspace/output.html"],
{"action": "read", "url": "file:///workspace/output.html"},
])
assert paths == []
assert commands == [
["open", page.as_uri()],
["open", page.as_uri()],
]
def test_generate_image_has_stable_native_schema() -> None:
names = {
schema.get("function", {}).get("name")
for schema in FUNCTION_TOOL_SCHEMAS
}
assert "generate_image" in names
def test_private_browser_batch_schema_declares_array_items() -> None:
schema = next(
schema["function"]
for schema in FUNCTION_TOOL_SCHEMAS
if schema.get("function", {}).get("name") == "private_browser"
)
commands = schema["parameters"]["properties"]["commands"]
# Providers such as Gemini reject an array property without `items` before
# generation starts. Keep both supported batch command representations
# explicit in the native JSON schema.
assert commands["items"]["oneOf"] == [
{"type": "array", "items": {"type": "string"}},
{"type": "object"},
]
def test_all_native_array_schemas_declare_items() -> None:
"""Provider APIs reject an array schema without an item schema."""
def walk(value, path="schema"):
if isinstance(value, dict):
if value.get("type") == "array":
assert "items" in value, f"missing items at {path}"
for key, child in value.items():
yield from walk(child, f"{path}.{key}")
elif isinstance(value, list):
for index, child in enumerate(value):
yield from walk(child, f"{path}[{index}]")
# Consume the generator so assertions execute for every schema node.
list(walk(FUNCTION_TOOL_SCHEMAS, "FUNCTION_TOOL_SCHEMAS"))
def test_private_browser_builds_fill_command_without_shell() -> None:
command, stdin_data, err = PrivateBrowserTool()._command_for_action(
["agent-browser"],
"fill",
{"selector": "@e1", "text": "hello"},
)
assert err is None
assert stdin_data is None
assert command == ["agent-browser", "fill", "@e1", "hello"]
def test_private_browser_builds_visible_text_find_command() -> None:
command, stdin_data, err = PrivateBrowserTool()._command_for_action(
["agent-browser"],
"find",
{"find": "Learn more"},
)
assert err is None
assert stdin_data is None
assert command == ["agent-browser", "find", "text", "Learn more", "text"]
def test_private_browser_click_accepts_role_and_accessible_name_fields() -> None:
command, stdin_data, err = PrivateBrowserTool()._command_for_action(
["agent-browser"],
"click",
{"target": "link", "text": "Learn more"},
)
assert err is None
assert stdin_data is None
assert command == [
"agent-browser", "find", "role", "link", "click", "--name", "Learn more",
]
def test_private_browser_click_accepts_quoted_role_target() -> None:
command, stdin_data, err = PrivateBrowserTool()._command_for_action(
["agent-browser"],
"click",
{"target": 'link "Learn more"'},
)
assert err is None
assert stdin_data is None
assert command == [
"agent-browser", "find", "role", "link", "click", "--name", "Learn more",
]
def test_private_browser_batch_normalizes_stable_inspection_action_names() -> None:
commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([
["open", "https://example.com"],
["evaluate", "document.title"],
["find", "Learn more"],
])
assert paths == []
assert commands == [
["open", "https://example.com"],
["eval", "document.title"],
["find", "text", "Learn more", "text"],
]
def test_private_browser_batch_read_selector_matches_top_level_read_semantics() -> None:
commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([
["open", "https://example.com"],
["read", "h1"],
])
assert paths == []
assert commands == [
["open", "https://example.com"],
["get", "text", "h1"],
]
def test_private_browser_batch_recovers_omitted_wait_selector_with_timeout() -> None:
commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([
["fill", "@e2", "orange"],
["wait", None, 2500],
["snapshot"],
])
assert paths == []
assert commands == [
["fill", "@e2", "orange"],
["wait", "2500"],
["snapshot"],
]
def test_private_browser_batch_normalizes_object_commands_to_cli_arrays() -> None:
commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([
{"action": "open", "url": "https://example.com"},
{"action": "snapshot"},
{"action": "find", "find": "Contact"},
])
assert paths == []
assert commands == [
["open", "https://example.com"],
["snapshot"],
["find", "text", "Contact", "text"],
]
def test_private_browser_batch_stops_guessed_interaction_after_open_at_snapshot() -> None:
commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([
["open", "https://example.com"],
["fill", "search input", "chair"],
["press", "Enter"],
])
assert paths == []
assert commands == [["open", "https://example.com"], ["snapshot"]]
def test_private_browser_batch_preserves_explicit_css_after_open() -> None:
commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([
["open", "https://example.com"],
["fill", "#search", "chair"],
["press", "Enter"],
])
assert paths == []
assert commands[1] == ["fill", "#search", "chair"]
def test_private_browser_exposes_global_store_landing_link_ref() -> None:
output = '''
- heading "Welcome to IKEA Global!"
- link "Go shopping at IKEA dot j p(English), or use the store selector to search for another store" [ref=e172]
'''
hint = PrivateBrowserTool._shopping_landing_hint(output)
assert "global store-selector landing page" in hint
assert "@e172" in hint
def test_private_browser_builds_evaluate_command() -> None:
command, stdin_data, err = PrivateBrowserTool()._command_for_action(
["agent-browser"],
"evaluate",
{"script": "document.location.hostname"},
)
assert err is None
assert stdin_data is None
assert command == ["agent-browser", "eval", "document.location.hostname"]
def test_private_browser_executes_scroll_with_native_browser_command(monkeypatch) -> None:
monkeypatch.setattr(
web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser"
)
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
calls = []
class _FakeProc:
returncode = 0
async def communicate(self, stdin=None):
return b"scrolled", b""
async def _fake_create_subprocess_exec(*command, **kwargs):
calls.append(list(command))
return _FakeProc()
monkeypatch.setattr(
asyncio, "create_subprocess_exec", _fake_create_subprocess_exec
)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({"action": "scroll", "direction": "down", "amount": 500}),
{"session_id": "scroll-session"},
))
assert result["exit_code"] == 0
assert calls[0][-3:] == ["scroll", "down", "500"]
def test_private_browser_expands_tiny_scroll_steps_to_pixels() -> None:
command, stdin_data, err = PrivateBrowserTool()._command_for_action(
["agent-browser"], "scroll", {"direction": "down", "amount": 5},
)
assert err is None
assert stdin_data is None
assert command[-3:] == ["scroll", "down", "1500"]
def test_private_browser_reads_element_from_current_page_without_url(monkeypatch) -> None:
monkeypatch.setattr(
web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser"
)
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
calls = []
class _FakeProc:
returncode = 0
async def communicate(self, stdin=None):
return b"Play Animation", b""
async def _fake_create_subprocess_exec(*command, **kwargs):
calls.append(list(command))
return _FakeProc()
monkeypatch.setattr(
asyncio, "create_subprocess_exec", _fake_create_subprocess_exec
)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({"action": "read", "selector": "#playBtn"}),
{"session_id": "read-session"},
))
assert result["exit_code"] == 0
assert calls[0][-3:] == ["get", "text", "#playBtn"]
@pytest.mark.parametrize('mode,observed', [('recent_model_choice', True), ('baseline', False)])
def test_keyboard_submit_returns_new_page_state_without_repeating_key(monkeypatch, mode, observed):
from types import SimpleNamespace
import src.turn_contract as turn_contract
monkeypatch.setattr(turn_contract, 'active_turn_contract',
lambda: SimpleNamespace(routing_experiment=mode))
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
commands = []
class Proc:
returncode = 0
def __init__(self, kwargs): self.kwargs = kwargs
async def communicate(self, stdin=None):
if stdin:
assert all(command[0] in {'wait', 'snapshot'} for command in json.loads(stdin))
return json.dumps([{'success': True, 'result': {
'origin': 'https://example.org/results',
'snapshot': '- heading "Search results" [ref=e4]'}}]).encode(), b''
self.kwargs['stdout'].write(b'Done')
return b'', b''
async def spawn(*command, **kwargs):
commands.append(command)
return Proc(kwargs)
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({'action': 'press', 'key': 'Enter'}), {'session_id': 'keyboard-submit'}))
assert result['exit_code'] == 0
assert ('Search results' in result['output']) is observed
assert sum('press' in command for command in commands) == 1
assert commands[0][-2:] == ('press', 'Enter')
def test_private_browser_accepts_visible_text_button_selector(monkeypatch) -> None:
monkeypatch.setattr(
web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser"
)
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
calls = []
class _FakeProc:
returncode = 0
async def communicate(self, stdin=None):
return b"clicked", b""
async def _fake_create_subprocess_exec(*command, **kwargs):
calls.append(list(command))
return _FakeProc()
monkeypatch.setattr(
asyncio, "create_subprocess_exec", _fake_create_subprocess_exec
)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({
"action": "click",
"selector": 'button:has-text("Play Animation")',
}),
{"session_id": "click-session"},
))
assert result["exit_code"] == 0
assert calls[0][-6:] == [
"find", "role", "button", "click", "--name", "Play Animation",
]
def test_private_browser_successful_click_returns_settled_snapshot(monkeypatch) -> None:
monkeypatch.setattr(
web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser"
)
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
calls = []
class _FakeProc:
returncode = 0
async def communicate(self, stdin=None):
if stdin:
return b'[{"command":["snapshot"],"result":{"snapshot":"heading Example"},"success":true}]', b""
return b"clicked", b""
async def _fake_create_subprocess_exec(*command, **kwargs):
calls.append(list(command))
return _FakeProc()
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({"action": "click", "target": "@e2"}),
{"session_id": "click-settled-session"},
))
assert result["exit_code"] == 0
assert "post-click page state" in result["output"]
assert "heading Example" in result["output"]
assert calls[1][-2:] == ["batch", "--json"]
@pytest.mark.parametrize('action', ['fill', 'click', 'read', 'wait'])
@pytest.mark.parametrize('ref', ['e2', '@e2'])
def test_browser_accepts_explicit_snapshot_ref_at_execution_boundary(monkeypatch, action, ref):
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set())
commands = []
class Proc:
returncode = 0
async def communicate(self, stdin=None):
return b'[]', b''
async def spawn(*command, **kwargs):
commands.append(command)
return Proc()
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({'action': action, 'ref': ref, 'text': 'orange'}),
{'session_id': 'snapshot-ref-alias-test'}))
assert result['exit_code'] == 0, result
suffix = {'fill': ('fill', '@e2', 'orange'), 'click': ('click', '@e2'),
'read': ('get', 'text', '@e2'), 'wait': ('wait', '@e2')}[action]
assert commands[0][-len(suffix):] == suffix
@pytest.mark.parametrize('ref', ['button', '[ref=e2]', '--help', 'e2;click e3'])
def test_browser_rejects_malformed_ref_without_launching(monkeypatch, ref):
async def unexpected(*args, **kwargs):
raise AssertionError('invalid reference reached browser')
monkeypatch.setattr(asyncio, 'create_subprocess_exec', unexpected)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({'action': 'fill', 'ref': ref, 'text': 'orange'}), {}))
assert result['exit_code'] == 1
assert 'ref' in result['error']
@pytest.mark.parametrize('field', ['selector', 'target'])
def test_browser_rejects_conflicting_ref_targets_without_launching(monkeypatch, field):
async def unexpected(*args, **kwargs):
raise AssertionError('conflicting targets reached browser')
monkeypatch.setattr(asyncio, 'create_subprocess_exec', unexpected)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({'action': 'click', 'ref': 'e2', field: '@e3'}), {}))
assert result['exit_code'] == 1
assert 'conflicts' in result['error']
def test_model_choice_open_returns_page_refs_without_rewriting_requested_url(monkeypatch):
from types import SimpleNamespace
import src.turn_contract as turn_contract
monkeypatch.setattr(turn_contract, 'active_turn_contract',
lambda: SimpleNamespace(routing_experiment='recent_model_choice'))
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set())
calls = []
class Proc:
returncode = 0
async def communicate(self, stdin=None):
return (b'[{"result":{"snapshot":"textbox Search [ref=e1]"},"success":true}]', b'') if stdin else (b'opened', b'')
async def spawn(*command, **kwargs):
calls.append(command)
return Proc()
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({'action': 'open', 'url': 'https://example.org/catalog'}),
{'session_id': 'open-observation-test'}))
assert result['exit_code'] == 0
assert 'textbox Search [ref=e1]' in result['output']
assert 'https://example.org/catalog' in calls[0]
assert len(calls) == 2
@pytest.mark.parametrize('mode,observed', [('recent_model_choice', True), ('baseline', False)])
def test_successful_fill_observes_script_driven_dialog_without_retry(monkeypatch, mode, observed):
from types import SimpleNamespace
import src.turn_contract as turn_contract
monkeypatch.setattr(turn_contract, 'active_turn_contract',
lambda: SimpleNamespace(routing_experiment=mode))
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set())
commands = []
class Proc:
returncode = 0
async def communicate(self, stdin=None):
if stdin:
return json.dumps([
{'command': ['get', 'value', '@e2'], 'success': True, 'result': {'value': 'orange'}},
{'result': {'snapshot': 'dialog Preferences\nbutton Close [ref=e2]'}, 'success': True},
]).encode(), b''
return b'', b''
async def spawn(*command, **kwargs):
commands.append(command)
return Proc()
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({'action': 'fill', 'target': '@e2', 'text': 'orange'}),
{'session_id': 'post-fill-observation'}))
assert result['exit_code'] == 0
assert ('dialog Preferences' in result['output']) is observed
assert len(commands) == (2 if observed else 1)
assert sum('fill' in command for command in commands) == 1
@pytest.mark.parametrize('mode,outcome', [
('recent_model_choice', 'populated'), ('recent_model_choice', 'empty'),
('recent_model_choice', 'timeout'), ('recent_model_choice', 'invalid'),
('baseline', 'populated'),
])
def test_click_observes_empty_destination_with_bounded_read_only_retry(monkeypatch, mode, outcome):
from types import SimpleNamespace
import src.turn_contract as turn_contract
monkeypatch.setattr(turn_contract, 'active_turn_contract',
lambda: SimpleNamespace(routing_experiment=mode))
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set())
commands, batches, deadlines, killed = [], [], [], []
real_timeout = asyncio.timeout
def timed_observation(delay):
deadlines.append(delay)
return real_timeout(delay)
monkeypatch.setattr(asyncio, 'timeout', timed_observation)
class Proc:
returncode = 0
def __init__(self, kwargs):
self.kwargs = kwargs
def kill(self):
killed.append(True)
async def communicate(self, stdin=None):
if stdin:
batches.append(json.loads(stdin))
if len(batches) == 1:
await asyncio.sleep(0.01)
elif outcome == 'timeout':
raise asyncio.TimeoutError()
elif outcome == 'invalid':
return b'[{"success": false, "error": "snapshot unavailable"}]', b''
snapshot = '(empty page)' if len(batches) == 1 or outcome == 'empty' else '- heading "Destination" [ref=e7]'
return json.dumps([{'command': ['snapshot'], 'success': True,
'result': {'origin': 'https://example.org/destination', 'snapshot': snapshot}}]).encode(), b''
self.kwargs['stdout'].write('✓ Done'.encode())
return b'', b''
async def spawn(*command, **kwargs):
commands.append(command)
return Proc(kwargs)
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({'action': 'click', 'target': '@e2'}), {'session_id': 'empty-destination'}))
assert result['exit_code'] == 0, result
if outcome == 'populated':
assert 'Destination' in result['output'] and '[ref=e7]' in result['output']
assert '(empty page)' not in result['output']
else:
assert '(empty page)' in result['output']
assert '[ref=' not in result['output']
if outcome in {'timeout', 'invalid'}:
assert 'fresh page snapshot could not be obtained' in result['output']
assert bool(killed) is (outcome == 'timeout')
assert len(batches) == 2
assert 0 < deadlines[1] < deadlines[0] <= 20
assert sum('click' in command for command in commands) == 1
assert all(command[0] in {'wait', 'snapshot'} for batch in batches for command in batch)
@pytest.mark.parametrize('retained', [True, False])
def test_empty_snapshot_retry_preserves_fill_verification_without_reusing_old_refs(monkeypatch, retained):
from types import SimpleNamespace
import src.turn_contract as turn_contract
monkeypatch.setattr(turn_contract, 'active_turn_contract',
lambda: SimpleNamespace(routing_experiment='recent_model_choice'))
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
commands, batches = [], []
class Proc:
returncode = 0
def __init__(self, kwargs):
self.kwargs = kwargs
async def communicate(self, stdin=None):
if stdin:
batches.append(json.loads(stdin))
rows = [{'command': ['snapshot'], 'success': True, 'result': {
'snapshot': '(empty page)' if len(batches) == 1 else '- textbox Search [ref=e9]'}}]
if len(batches) == 1:
rows.insert(0, {'command': ['get', 'value', '@e2'], 'success': True,
'result': {'value': 'private-sentinel' if retained else ''}})
return json.dumps(rows).encode(), b''
self.kwargs['stdout'].write('✓ Done'.encode())
return b'', b''
async def spawn(*command, **kwargs):
commands.append(command)
return Proc(kwargs)
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({'action': 'fill', 'target': '@e2', 'text': 'private-sentinel'}),
{'session_id': 'fill-empty-snapshot'}))
assert result['exit_code'] == (0 if retained else 1), result
assert 'textbox Search [ref=e9]' in result['output']
assert 'private-sentinel' not in json.dumps(result)
assert len(batches) == 2
assert batches[0].index(['get', 'value', '@e2']) < batches[0].index(['snapshot'])
assert all(command[0] in {'wait', 'snapshot'} for command in batches[1])
assert sum('fill' in command for command in commands) == 1
def test_snapshot_observation_preserves_dom_refs_without_duplicate_metadata():
snapshot = '- searchbox "Search catalog" [ref=e2]\n- button "Search" [ref=e3]'
raw = json.dumps([{'success': True, 'result': {
'lifecycle': {'noise': 'x' * 9000}, 'refs': {'e2': {'role': 'searchbox'}},
'origin': 'https://example.org', 'snapshot': snapshot,
}}])
assert PrivateBrowserTool._snapshot_observation(raw) == 'https://example.org\n' + snapshot
assert PrivateBrowserTool._snapshot_observation('unparsed failure') == 'unparsed failure'
errors = json.dumps([{'error': 'snapshot failed'}])
assert PrivateBrowserTool._snapshot_observation(errors) == errors
@pytest.mark.parametrize('action', ['open', 'snapshot'])
def test_large_page_dialog_controls_survive_both_browser_observation_budgets(monkeypatch, action):
from types import SimpleNamespace
import src.turn_contract as turn_contract
from src.clean_agent_preview import preview_tool_result_text
monkeypatch.setattr(turn_contract, 'active_turn_contract',
lambda: SimpleNamespace(routing_experiment='recent_model_choice'))
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
snapshot = '- main\n' + ' - paragraph "Catalog item description"\n' * 1000 + (
'- region "Preferences"\n'
' - dialog "Choose preferences"\n'
' - paragraph "Some choices are optional."\n'
' - button "Only necessary" [ref=e901]\n'
' - button "All options" [ref=e902]\n'
'- contentinfo\n'
)
commands = []
class Proc:
returncode = 0
def __init__(self, command, kwargs):
self.command, self.kwargs = command, kwargs
async def communicate(self, stdin=None):
if not stdin and self.command[-1] == 'snapshot':
self.kwargs['stdout'].write(snapshot.encode())
return (json.dumps([{'success': True, 'result': {
'origin': 'https://example.org/catalog', 'snapshot': snapshot,
}}]).encode(), b'') if stdin else (b'', b'')
async def spawn(*command, **kwargs):
commands.append(command)
return Proc(command, kwargs)
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
args = {'action': action}
if action == 'open':
args['url'] = 'https://example.org/catalog'
result = asyncio.run(PrivateBrowserTool().execute(json.dumps(args), {'session_id': 'large-dialog'}))
observation = preview_tool_result_text(result, 'private_browser', args)
assert result['exit_code'] == 0
assert '- dialog "Choose preferences"' in observation
assert observation.count('button "Only necessary" [ref=e901]') == 1
assert observation.count('button "All options" [ref=e902]') == 1
if action == 'open':
assert 'https://example.org/catalog' in observation
assert len(observation) < 8100
assert not any('click' in command or 'fill' in command for command in commands)
def test_fill_reports_incomplete_when_browser_success_did_not_retain_text(monkeypatch):
from types import SimpleNamespace
import src.turn_contract as turn_contract
monkeypatch.setattr(turn_contract, 'active_turn_contract',
lambda: SimpleNamespace(routing_experiment='recent_model_choice'))
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set())
commands = []
batches = []
class Proc:
returncode = 0
def __init__(self, kwargs):
self.kwargs = kwargs
async def communicate(self, stdin=None):
if stdin:
batches.append(json.loads(stdin))
return json.dumps([
{'command': ['get', 'value', '@e2'], 'success': True, 'result': {'value': ''}},
{'command': ['snapshot'], 'success': True,
'result': {'snapshot': 'dialog Preferences\nbutton Close [ref=e2]'}},
]).encode(), b''
self.kwargs['stdout'].write('✓ Done'.encode())
return b'', b''
async def spawn(*command, **kwargs):
commands.append(command)
return Proc(kwargs)
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({'action': 'fill', 'ref': 'e2', 'text': 'orange'}),
{'session_id': 'incomplete-fill'}))
assert result['exit_code'] == 1, result
assert 'did not retain' in result['error']
assert 'dialog Preferences' in result['output']
assert '✓ Done' not in result['output']
assert sum('fill' in command for command in commands) == 1
assert batches[0].index(['get', 'value', '@e2']) < batches[0].index(['snapshot'])
@pytest.mark.parametrize('raw,expected_exit', [
([{'command': ['get', 'value', '@e2'], 'success': True,
'result': {'value': 'sentinel-private-input'}}], 0),
([{'command': ['get', 'value', '@e2'], 'success': False,
'result': {'value': 'sentinel-private-input'}}], 1),
([], 1),
('malformed sentinel-private-input', 1),
])
def test_fill_verification_is_truthful_without_dumping_input_values(monkeypatch, raw, expected_exit):
from types import SimpleNamespace
import src.turn_contract as turn_contract
monkeypatch.setattr(turn_contract, 'active_turn_contract',
lambda: SimpleNamespace(routing_experiment='recent_model_choice'))
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set())
class Proc:
returncode = 0
async def communicate(self, stdin=None):
return (json.dumps(raw).encode(), b'') if stdin else (b'', b'')
async def spawn(*command, **kwargs):
return Proc()
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({'action': 'fill', 'target': '@e2', 'text': 'sentinel-private-input'}),
{'session_id': 'private-fill-verification'}))
assert result['exit_code'] == expected_exit
assert 'sentinel-private-input' not in json.dumps(result)
if expected_exit:
assert 'could not be verified' in result['error']
@pytest.mark.parametrize('mode,observed', [('recent_model_choice', True), ('baseline', True)])
@pytest.mark.parametrize('action', ['click', 'fill'])
def test_failed_interaction_returns_current_refs_without_retrying_action(monkeypatch, mode, observed, action):
from types import SimpleNamespace
import src.turn_contract as turn_contract
monkeypatch.setattr(turn_contract, 'active_turn_contract',
lambda: SimpleNamespace(routing_experiment=mode))
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set())
commands = []
class Proc:
def __init__(self, kwargs, failed):
self.kwargs, self.failed = kwargs, failed
self.returncode = 1 if failed else 0
async def communicate(self, stdin=None):
if self.failed:
self.kwargs['stderr'].write(b'Element is covered by a dialog')
return b'', b''
return b'[{"result":{"snapshot":"dialog Cookie choices\\nbutton Reject optional [ref=e9]"},"success":true}]', b''
async def spawn(*command, **kwargs):
commands.append(command)
return Proc(kwargs, len(commands) == 1)
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({'action': action, 'target': '@e2', 'text': 'orange'}), {'session_id': 'failed-interaction-observation'}))
assert result['exit_code'] == 1
assert 'Element is covered' in result['output']
assert ('Reject optional [ref=e9]' in result['output']) is observed
assert len(commands) == (2 if observed else 1)
assert sum(action in command for command in commands) == 1
if observed:
assert commands[1][-2:] == ('batch', '--json')
def test_private_browser_bare_wait_uses_timeout_as_duration_with_process_headroom() -> None:
tool = PrivateBrowserTool()
command, stdin_data, err = tool._command_for_action(
["agent-browser"],
"wait",
{"timeout_ms": 2000},
)
assert err is None
assert stdin_data is None
assert command == ["agent-browser", "wait", "2000"]
assert tool._timeout_seconds({"timeout_ms": 2000}, action="wait") >= 7
def test_private_browser_prefix_is_scoped_to_odysseus_session() -> None:
prefix = PrivateBrowserTool()._with_session_args(
["agent-browser"],
{"session_id": "f42b1fb9-3747-44f0-bf64-61e7b3b14faa"},
)
assert prefix == [
"agent-browser",
"--session",
"ody-" + __import__('hashlib').sha256(
b'odysseus-ui\0f42b1fb9-3747-44f0-bf64-61e7b3b14faa'
).hexdigest()[:20],
]
def test_private_browser_namespace_can_isolate_parallel_runtimes(monkeypatch) -> None:
monkeypatch.setenv("ODYSSEUS_BROWSER_NAMESPACE", "clawmm-run/abc")
prefix = PrivateBrowserTool()._with_session_args(
["agent-browser"],
{"session_id": "session-1"},
)
assert prefix == [
"agent-browser",
"--session",
"ody-" + __import__('hashlib').sha256(
b'clawmm-run/abc\0session-1'
).hexdigest()[:20],
]
def test_private_browser_namespace_uses_effective_task_environment(monkeypatch) -> None:
monkeypatch.delenv("ODYSSEUS_BROWSER_NAMESPACE", raising=False)
prefix = PrivateBrowserTool()._with_session_args(
["agent-browser"],
{
"session_id": "session-1",
"subproc_env": {"ODYSSEUS_BROWSER_NAMESPACE": "clawmm-task-abc"},
},
)
assert prefix == [
"agent-browser",
"--session",
"ody-" + __import__('hashlib').sha256(
b'clawmm-task-abc\0session-1'
).hexdigest()[:20],
]
def test_private_browser_long_session_and_namespace_are_bounded(monkeypatch):
monkeypatch.setenv('ODYSSEUS_BROWSER_NAMESPACE', 'runtime-' + 'n'*100)
prefix = PrivateBrowserTool()._with_session_args(['agent-browser'], {'session_id':'x'*200})
assert len(prefix[prefix.index('--session')+1]) <= 24
def test_private_browser_hashes_preserve_session_isolation():
tool=PrivateBrowserTool()
names=[tool._with_session_args(['agent-browser'], {'session_id':s})[-1]
for s in ['x'*100+'a', 'x'*100+'b', 'a/b', 'a?b']]
assert len(set(names)) == 4
assert tool._with_session_args(['agent-browser'], {'session_id':'x'*100+'a'})[-1] == names[0]
def test_private_browser_reuses_host_npx_cache_when_task_home_is_isolated(
monkeypatch, tmp_path
) -> None:
host_home = tmp_path / "host-home"
task_home = tmp_path / "task-home"
host_home.mkdir()
task_home.mkdir()
monkeypatch.setenv("HOME", str(host_home))
monkeypatch.setattr(web_tools, "_service_home", lambda: host_home)
monkeypatch.setattr(web_tools, "_host_npm_roots", lambda: [host_home / ".npm"])
monkeypatch.delenv("npm_config_cache", raising=False)
monkeypatch.delenv("NPM_CONFIG_CACHE", raising=False)
def _which(name: str):
return "/usr/bin/npx" if name == "npx" else None
monkeypatch.setattr(web_tools.shutil, "which", _which)
calls = {}
class _FakeProc:
returncode = 0
def __init__(self, stdout):
self.stdout = stdout
async def communicate(self, stdin=None):
self.stdout.write(json.dumps([
{"success": True, "result": {"title": "T", "url": "https://example.com/"}},
{"success": True, "result": {"text": "page text"}},
]).encode())
return None, None
async def _fake_create_subprocess_exec(*command, **kwargs):
calls["env"] = kwargs["env"]
return _FakeProc(kwargs["stdout"])
monkeypatch.setattr(
asyncio, "create_subprocess_exec", _fake_create_subprocess_exec
)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({"action": "read", "url": "https://example.com"}),
{"subproc_env": {"HOME": str(task_home)}},
))
assert result["exit_code"] == 0
assert calls["env"]["HOME"] == str(host_home)
assert calls["env"]["npm_config_cache"] == str(host_home / ".npm")
assert calls["env"]["NPM_CONFIG_CACHE"] == str(host_home / ".npm")
assert calls["env"]["AGENT_BROWSER_IDLE_TIMEOUT_MS"] == "300000"
def test_private_browser_prefers_installed_npx_binary(monkeypatch, tmp_path) -> None:
package_bin = (
tmp_path
/ ".npm"
/ "_npx"
/ "abc"
/ "node_modules"
/ "agent-browser"
/ "bin"
)
package_bin.mkdir(parents=True)
binary = package_bin / "agent-browser-linux-x64"
binary.write_text("#!/bin/sh\n")
binary.chmod(0o755)
monkeypatch.setattr(web_tools, "_service_home", lambda: tmp_path)
monkeypatch.setattr(web_tools, "_host_npm_roots", lambda: [tmp_path / ".npm"])
assert PrivateBrowserTool._local_agent_browser_binary() == str(binary)
def test_private_browser_skips_unreadable_host_cache(monkeypatch, tmp_path) -> None:
blocked = tmp_path / "blocked"
usable = tmp_path / "usable"
binary = (
usable
/ "_npx"
/ "abc"
/ "node_modules"
/ "agent-browser"
/ "bin"
/ "agent-browser-linux-x64"
)
binary.parent.mkdir(parents=True)
binary.write_text("#!/bin/sh\n")
binary.chmod(0o755)
real_glob = Path.glob
def _glob(path, pattern):
if blocked in path.parents or path == blocked:
raise PermissionError(path)
return real_glob(path, pattern)
monkeypatch.setattr(web_tools, "_host_npm_roots", lambda: [blocked, usable])
monkeypatch.setattr(Path, "glob", _glob)
assert PrivateBrowserTool._local_agent_browser_binary() == str(binary)
def test_browser_executable_discovery_supports_chromium_snapshot_cache(
monkeypatch, tmp_path
) -> None:
chrome = (
tmp_path
/ ".chromium-browser-snapshots"
/ "chromium"
/ "linux-123"
/ "chrome-linux"
/ "chrome"
)
chrome.parent.mkdir(parents=True)
chrome.write_text("#!/bin/sh\n")
chrome.chmod(0o755)
monkeypatch.setattr(web_tools, "_service_home", lambda: tmp_path)
real_glob = Path.glob
def _glob(path, pattern):
if path == Path("/home") or path == Path("/root"):
return iter(())
return real_glob(path, pattern)
monkeypatch.setattr(Path, "glob", _glob)
assert web_tools._browser_executable_candidates() == [chrome]
def test_private_browser_shutdown_is_namespace_scoped(monkeypatch, tmp_path) -> None:
calls = {}
class _FakeProc:
async def communicate(self):
return b"", b""
monkeypatch.setattr(web_tools.shutil, "which", lambda name: "/bin/agent-browser")
monkeypatch.setenv("ODYSSEUS_BROWSER_NAMESPACE", "clawmm-test")
monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path))
monkeypatch.delenv("AGENT_BROWSER_SOCKET_DIR", raising=False)
web_tools._ACTIVE_BROWSER_SESSIONS.clear()
session = web_tools._scoped_browser_session("clawmm-test", "session-1")
web_tools._ACTIVE_BROWSER_SESSIONS.add(session)
(tmp_path / "agent-browser").mkdir()
(tmp_path / "agent-browser" / f"{session}.pid").write_text("7001")
proc = tmp_path / "proc"
(proc / "7001").mkdir(parents=True)
(proc / "7001" / "cmdline").write_bytes(b"agent-browser-linux-x64\0")
monkeypatch.setattr(platform_compat, "PROC_ROOT", proc)
monkeypatch.setattr(web_tools.os, "kill", lambda pid, sig: None)
async def _fake_create_subprocess_exec(*command, **kwargs):
calls["command"] = command
calls["env"] = kwargs["env"]
return _FakeProc()
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
asyncio.run(shutdown_private_browser_sessions())
assert calls["command"] == (
"/bin/agent-browser", "--session", session, "close"
)
assert not web_tools._ACTIVE_BROWSER_SESSIONS
def test_private_browser_shutdown_never_bootstraps_a_missing_daemon(
monkeypatch, tmp_path
) -> None:
monkeypatch.setattr(web_tools.shutil, "which", lambda name: "/bin/agent-browser")
monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path))
monkeypatch.setattr(platform_compat, "PROC_ROOT", tmp_path / "proc")
(tmp_path / "proc").mkdir()
web_tools._ACTIVE_BROWSER_SESSIONS.clear()
web_tools._ACTIVE_BROWSER_SESSIONS.add(
web_tools._scoped_browser_session("odysseus-ui", "never-started")
)
async def _no_spawn(*command, **kwargs):
pytest.fail(f"close would start a fresh daemon: {command}")
monkeypatch.setattr(asyncio, "create_subprocess_exec", _no_spawn)
asyncio.run(shutdown_private_browser_sessions())
assert not web_tools._ACTIVE_BROWSER_SESSIONS
def test_browser_pid_candidates_include_upstream_root_session() -> None:
runtime = Path("/run/user/1000")
namespace = "clawmm-test"
session = "session-1"
candidates = web_tools._browser_pid_file_candidates(runtime, namespace, session)
assert candidates[0] == runtime / "agent-browser" / (
web_tools._scoped_browser_session(namespace, session) + ".pid"
)
assert all("session-1" not in str(path) for path in candidates)
def test_browser_pid_candidates_do_not_sweep_shared_root_without_session(
tmp_path,
) -> None:
legacy = tmp_path / "agent-browser" / "namespaces" / "legacy" / "run"
legacy.mkdir(parents=True)
(legacy / "ody-old.pid").write_text("1")
candidates = web_tools._browser_pid_file_candidates(tmp_path, "legacy", None)
assert candidates == [legacy / "ody-old.pid"]
def test_private_browser_local_file_read_uses_supported_open_action(
monkeypatch, tmp_path
) -> None:
page = tmp_path / "output.html"
page.write_text("<title>local</title>")
monkeypatch.setattr(
"src.tool_execution.get_active_workspace", lambda: str(tmp_path)
)
monkeypatch.setattr(
web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser"
)
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
monkeypatch.setattr(PrivateBrowserTool, "_owned_daemon_exists", staticmethod(lambda env, session: True))
calls = []
class _FakeProc:
returncode = 0
def __init__(self, command):
self.command = list(command)
async def communicate(self, stdin=None):
if self.command[-1] == "errors":
return b"No page errors found", b""
return b"opened local page", b""
async def _fake_create_subprocess_exec(*command, **kwargs):
calls.append(list(command))
return _FakeProc(command)
monkeypatch.setattr(
asyncio, "create_subprocess_exec", _fake_create_subprocess_exec
)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({"action": "read", "url": "/workspace/output.html"}),
{"session_id": "local-read"},
))
assert result["exit_code"] == 0
assert calls[0][-1] == "close"
assert calls[1][-2:] == ["open", page.as_uri()]
assert calls[2][-1] == "errors"
def test_private_browser_new_local_session_skips_reset_close(
monkeypatch, tmp_path
) -> None:
page = tmp_path / "new.html"
page.write_text("<title>new</title>")
monkeypatch.setattr(
"src.tool_execution.get_active_workspace", lambda: str(tmp_path)
)
monkeypatch.setattr(
web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser"
)
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
calls = []
class _FakeProc:
returncode = 0
def __init__(self, command):
self.command = list(command)
async def communicate(self, stdin=None):
if self.command[-1] == "errors":
return b"No page errors found", b""
return b"opened new local page", b""
async def _fake_create_subprocess_exec(*command, **kwargs):
calls.append(list(command))
return _FakeProc(command)
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({"action": "open", "url": "/workspace/new.html"}),
{"session_id": "never-started"},
))
assert result["exit_code"] == 0
session = web_tools._scoped_browser_session("odysseus-ui", "never-started")
assert calls == [
["/usr/bin/agent-browser", "--session", session, "open", page.as_uri()],
["/usr/bin/agent-browser", "--session", session, "errors"],
]
def test_private_browser_local_html_ignores_stale_page_errors(
monkeypatch, tmp_path
) -> None:
page = tmp_path / "clean.html"
page.write_text("<title>clean</title>")
monkeypatch.setattr(
"src.tool_execution.get_active_workspace", lambda: str(tmp_path)
)
monkeypatch.setattr(
web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser"
)
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
monkeypatch.setattr(PrivateBrowserTool, "_owned_daemon_exists", staticmethod(lambda env, session: True))
calls = []
class _FakeProc:
returncode = 0
def __init__(self, command):
self.command = list(command)
async def communicate(self, stdin=None):
if self.command[-1] == "close":
return b"closed stale browser session", b""
if self.command[-1] == "errors":
return b"No page errors found", b""
return b"opened clean local page", b""
async def _fake_create_subprocess_exec(*command, **kwargs):
calls.append(list(command))
return _FakeProc(command)
monkeypatch.setattr(
asyncio, "create_subprocess_exec", _fake_create_subprocess_exec
)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({"action": "open", "url": "/workspace/clean.html"}),
{"session_id": "reused-session"},
))
assert result["exit_code"] == 0
assert "stale error" not in result["output"]
assert calls[0][-1] == "close"
assert calls[2][-1] == "errors"
def test_private_browser_local_html_surfaces_page_errors(monkeypatch, tmp_path) -> None:
page = tmp_path / "broken.html"
page.write_text("<script>missingFunction()</script>")
monkeypatch.setattr(
"src.tool_execution.get_active_workspace", lambda: str(tmp_path)
)
monkeypatch.setattr(
web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser"
)
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
class _FakeProc:
returncode = 0
def __init__(self, command):
self.command = list(command)
async def communicate(self, stdin=None):
if self.command[-1] == "errors":
return b"ReferenceError: missingFunction is not defined", b""
return b"opened local page", b""
async def _fake_create_subprocess_exec(*command, **kwargs):
return _FakeProc(command)
monkeypatch.setattr(
asyncio, "create_subprocess_exec", _fake_create_subprocess_exec
)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({"action": "open", "url": "/workspace/broken.html"}),
{"session_id": "broken-local-page"},
))
assert result["exit_code"] == 1
assert "[page errors]" in result["output"]
assert "ReferenceError" in result["output"]
assert "Fix the artifact and reopen" in result["error"]
def test_private_browser_batch_uses_json_stdin() -> None:
command, stdin_data, err = PrivateBrowserTool()._command_for_action(
[
"agent-browser",
"--namespace",
"odysseus-ui",
"--session",
"ody-session-a",
],
"batch",
{"commands": [["open", "https://example.com"], ["snapshot"]]},
)
assert err is None
assert command == [
"agent-browser",
"--namespace",
"odysseus-ui",
"--session",
"ody-session-a",
"batch",
"--json",
]
assert stdin_data == '[["open", "https://example.com"], ["snapshot"]]'
def test_private_browser_batch_screenshot_gets_writable_path(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(web_tools.tempfile, "gettempdir", lambda: str(tmp_path))
commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([
["open", "https://example.com"],
["screenshot"],
])
assert len(paths) == 1
assert commands[0] == ["open", "https://example.com"]
assert commands[1][0] == "screenshot"
assert commands[1][1].endswith(".png")
assert Path(commands[1][1]).parent == tmp_path / "odysseus-private-browser"
def test_private_browser_batch_screenshot_ignores_model_chosen_path(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(web_tools.tempfile, "gettempdir", lambda: str(tmp_path))
commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([
["open", "https://example.com"],
["screenshot", "/tmp/example_com.png"],
["screenshot", {"path": "/tmp/also_bad.png"}],
])
assert len(paths) == 2
assert commands[1] == ["screenshot", str(paths[0])]
assert commands[2] == ["screenshot", str(paths[1])]
assert all(path.parent == tmp_path / "odysseus-private-browser" for path in paths)
def test_private_browser_empty_batch_recovers_as_snapshot() -> None:
command, stdin_data, err = PrivateBrowserTool()._command_for_action(
[
"agent-browser",
"--namespace",
"odysseus-ui",
"--session",
"ody-session-a",
],
"batch",
{"commands": []},
)
assert err is None
assert command == [
"agent-browser",
"--namespace",
"odysseus-ui",
"--session",
"ody-session-a",
"snapshot",
]
assert stdin_data is None
def test_private_browser_screenshot_without_path_returns_image_payload(monkeypatch, tmp_path) -> None:
png_bytes = b"\x89PNG\r\n\x1a\nbrowser"
monkeypatch.setattr(web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser")
monkeypatch.setattr(web_tools.tempfile, "gettempdir", lambda: str(tmp_path))
class _FakeProc:
returncode = 0
async def communicate(self, stdin=None):
screenshot_path = Path(calls["command"][-1])
screenshot_path.write_bytes(png_bytes)
return b"saved screenshot", b""
calls = {}
async def _fake_create_subprocess_exec(*command, **kwargs):
calls["command"] = list(command)
return _FakeProc()
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({"action": "screenshot"}),
{"session_id": "abc"},
))
assert result["exit_code"] == 0
assert calls["command"][:4] == [
"/usr/bin/agent-browser",
"--session",
web_tools._scoped_browser_session("odysseus-ui", "abc"),
"screenshot",
]
assert calls["command"][-1].endswith(".png")
assert result["images"] == [{
"data": base64.b64encode(png_bytes).decode("ascii"),
"mimeType": "image/png",
}]
def test_private_browser_timeout_terminates_the_process_group(monkeypatch) -> None:
calls = []
class _Proc:
pid = 1234
def kill(self):
calls.append("fallback-kill")
monkeypatch.setattr(web_tools.os, "getpgid", lambda pid: pid)
monkeypatch.setattr(
web_tools.os,
"killpg",
lambda pgid, signum: calls.append((pgid, signum)),
)
PrivateBrowserTool._terminate_subprocess(_Proc())
assert calls == [(1234, web_tools.signal.SIGKILL), "fallback-kill"]
def test_private_browser_retries_one_timed_out_local_open(monkeypatch, tmp_path) -> None:
page = tmp_path / "output.html"
page.write_text("<html><title>retry</title></html>")
monkeypatch.setattr("src.tool_execution.get_active_workspace", lambda: str(tmp_path))
monkeypatch.setattr(web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser")
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
monkeypatch.setattr(PrivateBrowserTool, "_capture_page_errors", lambda *args: _no_page_errors())
calls = []
class _Proc:
returncode = 0
def __init__(self, command):
self.command = list(command)
async def communicate(self, stdin=None):
if self.command[-1] == "open" or (
len(self.command) > 1 and self.command[-2] == "open"
):
if sum(1 for call in calls if call[-1] == page.as_uri()) == 1:
raise asyncio.TimeoutError()
return b"opened", b""
def kill(self):
return None
async def _no_page_errors():
return ""
async def _fake_create_subprocess_exec(*command, **kwargs):
calls.append(list(command))
return _Proc(command)
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({"action": "open", "url": "file:///workspace/output.html"}),
{"session_id": "retry-local-open"},
))
assert result["exit_code"] == 0
assert sum(1 for call in calls if call[-1] == page.as_uri()) == 2
def test_private_browser_retries_transient_local_browser_bootstrap_failure(
monkeypatch, tmp_path
) -> None:
page = tmp_path / "output.html"
page.write_text("<html><title>bootstrap retry</title></html>")
monkeypatch.setattr("src.tool_execution.get_active_workspace", lambda: str(tmp_path))
monkeypatch.setattr(web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser")
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
async def _no_page_errors():
return ""
monkeypatch.setattr(PrivateBrowserTool, "_capture_page_errors", lambda *args: _no_page_errors())
monkeypatch.setattr(PrivateBrowserTool, "_terminate_owned_chrome", lambda *args: None)
monkeypatch.setattr(PrivateBrowserTool, "_terminate_owned_daemon", lambda *args: None)
calls = []
class _Proc:
def __init__(self, command, attempt, kwargs):
self.command = list(command)
self.returncode = 1 if attempt == 1 else 0
self._stdout = kwargs.get("stdout")
self._stderr = kwargs.get("stderr")
async def communicate(self, stdin=None):
if self.returncode:
self._stderr.write(
b"Could not configure browser: Failed to connect: "
b"No such file or directory (os error 2)"
)
else:
self._stdout.write(b"opened")
return b"", b""
async def _fake_create_subprocess_exec(*command, **kwargs):
calls.append(list(command))
return _Proc(
command,
sum(1 for call in calls if call[-1] == page.as_uri()),
kwargs,
)
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({"action": "open", "url": "file:///workspace/output.html"}),
{"session_id": "bootstrap-retry"},
))
assert result["exit_code"] == 0, result
assert sum(1 for call in calls if call[-1] == page.as_uri()) == 2
def test_private_browser_open_captures_visual_preview(monkeypatch, tmp_path) -> None:
png_bytes = b"\x89PNG\r\n\x1a\nauto-browser"
monkeypatch.setattr(web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser")
monkeypatch.setattr(web_tools.tempfile, "gettempdir", lambda: str(tmp_path))
class _FakeProc:
returncode = 0
def __init__(self, command):
self.command = list(command)
async def communicate(self, stdin=None):
if "screenshot" in self.command:
Path(self.command[-1]).write_bytes(png_bytes)
return b"saved screenshot", b""
return b"opened", b""
calls = []
async def _fake_create_subprocess_exec(*command, **kwargs):
calls.append(list(command))
return _FakeProc(command)
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({"action": "open", "url": "https://example.com"}),
{"session_id": "abc"},
))
assert result["exit_code"] == 0
assert calls[0] == [
"/usr/bin/agent-browser",
"--session",
web_tools._scoped_browser_session("odysseus-ui", "abc"),
"open",
"https://example.com",
]
assert len(calls) == 2
assert calls[1][-2] == "screenshot"
assert result["images"] == [{
"data": base64.b64encode(png_bytes).decode("ascii"),
"mimeType": "image/png",
}]
def test_private_browser_visual_preview_covers_state_changing_actions() -> None:
assert {'open', 'batch', 'snapshot', 'click', 'fill', 'press', 'scroll'} <= (
PrivateBrowserTool._AUTO_SCREENSHOT_ACTIONS
)
assert {'read', 'find', 'evaluate', 'wait'} - PrivateBrowserTool._AUTO_SCREENSHOT_ACTIONS
def test_youtube_tool_comments_falls_back_to_ytdlp(monkeypatch) -> None:
from services.youtube import youtube_handler
async def _fake_comments(video_id, max_comments=25, timeout=30):
return {
"success": True,
"title": "Example Video",
"channel": "Example Channel",
"comments": [
{"author": "Alice", "text": "Useful demo.", "likes": 12},
{"author": "Bob", "text": "Loved the walkthrough.", "likes": 3},
],
}
monkeypatch.delenv("YOUTUBE_API_KEY", raising=False)
monkeypatch.setattr(youtube_handler, "fetch_youtube_comments", _fake_comments)
result = asyncio.run(YouTubeTool().execute(
json.dumps({
"action": "comments",
"url": "https://www.youtube.com/watch?v=abc123DEF45",
"max_results": 2,
}),
{},
))
assert result["exit_code"] == 0
assert "Example Video" in result["output"]
assert "@Alice [12 likes]: Useful demo." in result["output"]
def test_youtube_tool_latest_channel_parses_playlist_json() -> None:
parsed = YouTubeTool._parse_ytdlp_json_output(json.dumps({
"entries": [
{"id": "vid123", "title": "Latest upload"},
{"id": "vid122", "title": "Previous upload"},
]
}))
assert parsed["entries"][0]["title"] == "Latest upload"
def test_youtube_tool_latest_channel_parses_json_lines() -> None:
parsed = YouTubeTool._parse_ytdlp_json_output(
'{"id":"vid123","title":"Latest upload"}\n'
'{"id":"vid122","title":"Previous upload"}\n'
)
assert [entry["id"] for entry in parsed["entries"]] == ["vid123", "vid122"]
def test_youtube_tool_latest_channel_formats_requested_upload_count(monkeypatch) -> None:
async def _fake_ytdlp_json(self, url, *, timeout, flat_playlist=False, playlist_end=5):
assert flat_playlist is True
assert playlist_end == 5
return {
"success": True,
"data": {
"entries": [
{"id": f"vid{i}", "title": f"Upload {i}", "duration": 60 + i}
for i in range(1, 8)
]
},
}
monkeypatch.setattr(YouTubeTool, "_ytdlp_json", _fake_ytdlp_json)
result = asyncio.run(YouTubeTool().execute(
json.dumps({
"action": "latest_channel_video",
"channel_url": "https://www.youtube.com/c/RAINBOLTGEO/videos",
"max_results": 5,
}),
{},
))
assert result["exit_code"] == 0
assert "Latest 5 channel videos" in result["output"]
assert "1. Upload 1" in result["output"]
assert "5. Upload 5" in result["output"]
assert "6. Upload 6" not in result["output"]
def test_youtube_tool_latest_channel_resolves_bad_handle(monkeypatch) -> None:
calls = []
async def _fake_ytdlp_json(self, url, *, timeout, flat_playlist=False, playlist_end=5):
calls.append(url)
if url == "https://www.youtube.com/@rainbolt/videos":
return {"success": False, "error": "HTTP Error 404: Not Found"}
if url.startswith("ytsearch10:"):
return {
"success": True,
"data": {
"entries": [
{
"title": "rainbolt clips",
"channel": "rainbolt clips",
"uploader_id": "@rainboltshorts",
"channel_url": "https://www.youtube.com/channel/clips",
"view_count": 1000000,
},
{
"title": "geoguessr pro reacts",
"channel": "RAINBOLT",
"uploader_id": "@georainbolt",
"channel_url": "https://www.youtube.com/channel/official",
"view_count": 100,
},
]
},
}
if url == "https://www.youtube.com/channel/official/videos":
return {
"success": True,
"data": {"entries": [{"id": "vid123", "title": "Official latest upload"}]},
}
return {"success": False, "error": f"unexpected url {url}"}
monkeypatch.setattr(YouTubeTool, "_ytdlp_json", _fake_ytdlp_json)
result = asyncio.run(YouTubeTool().execute(
json.dumps({
"action": "latest_channel_video",
"handle": "@rainbolt",
"max_results": 5,
}),
{},
))
assert result["exit_code"] == 0
assert "Official latest upload" in result["output"]
assert calls == [
"https://www.youtube.com/@rainbolt/videos",
"ytsearch10:rainbolt official YouTube channel",
"https://www.youtube.com/channel/official/videos",
]
def test_youtube_tool_channel_search_query_handles_urls_and_handles() -> None:
assert YouTubeTool._channel_search_query("@rainbolt") == "rainbolt"
assert YouTubeTool._channel_search_query("https://www.youtube.com/@rainbolt/videos") == "rainbolt"
assert YouTubeTool._channel_search_query("https://www.youtube.com/c/RainboltGeo") == "RainboltGeo"
def test_youtube_tool_metadata_with_channel_url_returns_channel_uploads(monkeypatch) -> None:
async def _fake_ytdlp_json(self, url, *, timeout, flat_playlist=False, playlist_end=5):
assert flat_playlist is True
return {
"success": True,
"data": {"entries": [{"id": "vid123", "title": "Latest upload"}]},
}
monkeypatch.setattr(YouTubeTool, "_ytdlp_json", _fake_ytdlp_json)
result = asyncio.run(YouTubeTool().execute(
json.dumps({
"action": "metadata",
"channel_url": "https://www.youtube.com/@example/videos",
"max_results": 1,
}),
{},
))
assert result["exit_code"] == 0
assert "Latest channel video" in result["output"]
assert "Latest upload" in result["output"]
def test_youtube_tool_comment_author_keeps_single_at_prefix() -> None:
output = YouTubeTool()._format_comments(
{
"comments": [
{"author": "@AlreadyPrefixed", "text": "Looks good.", "likes": 1}
]
},
"https://www.youtube.com/watch?v=abc123DEF45",
)
assert "@AlreadyPrefixed [1 likes]: Looks good." in output
assert "@@AlreadyPrefixed" not in output
class _FakeBrowserMcp:
def get_all_tools(self, disabled_map=None):
disabled = (disabled_map or {}).get("builtin_browser", set())
return [
{
"server_id": "builtin_browser",
"server_name": "Built-in: Browser",
"name": "browser_navigate",
"qualified_name": "mcp__builtin_browser__browser_navigate",
"description": "Navigate",
"input_schema": {"type": "object", "properties": {}},
"is_disabled": "browser_navigate" in disabled,
}
]
def get_all_openai_schemas(self, disabled_map=None):
return [
{
"type": "function",
"function": {
"name": tool["qualified_name"],
"description": tool["description"],
"parameters": tool["input_schema"],
},
}
for tool in self.get_all_tools(disabled_map)
if not tool["is_disabled"]
]
def get_tool_descriptions_for_prompt(self, disabled_map=None, allowed_names=None):
lines = []
for tool in self.get_all_tools(disabled_map):
if tool["is_disabled"]:
continue
if allowed_names is not None and tool["qualified_name"] not in allowed_names:
continue
lines.append(tool["qualified_name"])
return "\n".join(lines)
def test_raw_browser_mcp_is_hidden_when_private_browser_is_available() -> None:
from src.agent_loop import _build_system_prompt
messages, schemas = _build_system_prompt(
[{"role": "user", "content": "open https://example.com and take a screenshot"}],
"gpt-5.5",
None,
_FakeBrowserMcp(),
disabled_tools=set(),
relevant_tools={
"private_browser",
"builtin_browser",
"mcp__builtin_browser__browser_navigate",
},
compact=True,
)
prompt_text = "\n".join(str(message.get("content") or "") for message in messages)
schema_names = {
schema.get("function", {}).get("name")
for schema in schemas
}
assert "native tool schemas provided for this turn" in prompt_text
assert "mcp__builtin_browser__browser_navigate" not in prompt_text
assert "mcp__builtin_browser__browser_navigate" not in schema_names
def test_generic_go_to_phrase_is_browser_interaction() -> None:
from src.agent_loop import _looks_like_explicit_browser_interaction
assert _looks_like_explicit_browser_interaction(
"Go to Airbnb and find stays in Tokyo for next weekend under $150/night."
)
assert _looks_like_explicit_browser_interaction(
"Navigate to the vendor portal and check the pricing table."
)
assert _looks_like_explicit_browser_interaction(
"Open Spotify's web player and search for Bach cello suites."
)
assert not _looks_like_explicit_browser_interaction(
"Find rules for getting rid of garbage in Setagaya-ku."
)
def test_terminate_owned_chrome_skips_the_sweep_without_procfs(
monkeypatch, tmp_path
) -> None:
"""macOS and Windows have no /proc; shutdown must degrade, not raise."""
missing = tmp_path / "no-procfs"
monkeypatch.setattr(platform_compat, "PROC_ROOT", missing)
def _unexpected_iterdir(*args, **kwargs):
raise AssertionError("the pid sweep must not run without procfs")
monkeypatch.setattr(Path, "iterdir", _unexpected_iterdir)
PrivateBrowserTool._terminate_owned_chrome({"TMPDIR": str(tmp_path)})
def test_terminate_owned_chrome_kills_only_this_runtimes_profile(
monkeypatch, tmp_path
) -> None:
"""With procfs present, match on the runtime-owned profile prefix alone."""
proc = tmp_path / "proc"
tmpdir = tmp_path / "runtime-tmp"
tmpdir.mkdir()
profile_prefix = str(tmpdir.resolve() / "agent-browser-chrome-")
def _write_pid(pid: str, cmdline: str) -> None:
entry = proc / pid
entry.mkdir(parents=True)
(entry / "cmdline").write_bytes(cmdline.replace(" ", "\0").encode())
_write_pid("101", f"chrome --user-data-dir={profile_prefix}abc")
_write_pid("202", "chrome --user-data-dir=/Users/someone/Library/Chrome")
(proc / "self").mkdir()
monkeypatch.setattr(platform_compat, "PROC_ROOT", proc)
killed: list[int] = []
monkeypatch.setattr(web_tools.os, "kill", lambda pid, sig: killed.append(pid))
PrivateBrowserTool._terminate_owned_chrome({"TMPDIR": str(tmpdir)})
assert killed == [101]
def _pid_file_for(tmp_path, monkeypatch, namespace, session, pid):
"""Write a pid file where the daemon helpers will look for it."""
monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path))
monkeypatch.setenv("ODYSSEUS_BROWSER_NAMESPACE", namespace)
candidates = web_tools._browser_pid_file_candidates(tmp_path, namespace, session)
target = candidates[0]
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(str(pid))
return target
def test_live_daemon_pid_file_survives_a_host_without_procfs(
monkeypatch, tmp_path
) -> None:
"""Off Linux a missing cmdline is not evidence the daemon exited."""
monkeypatch.setattr(platform_compat, "PROC_ROOT", tmp_path / "no-procfs")
monkeypatch.setattr(web_tools, "_process_is_alive", lambda pid: True)
killed: list[int] = []
monkeypatch.setattr(web_tools.os, "kill", lambda pid, sig: killed.append(pid))
pid_file = _pid_file_for(tmp_path, monkeypatch, "clawmm-test", "session-1", 4321)
PrivateBrowserTool._terminate_owned_daemon({}, "session-1")
assert pid_file.exists(), "a live daemon's pid file must not be removed"
assert killed == [], "an unverified process must not be killed"
def test_dead_daemon_pid_file_is_removed_without_procfs(monkeypatch, tmp_path) -> None:
"""A pid that no longer exists is the one case that justifies forgetting it."""
monkeypatch.setattr(platform_compat, "PROC_ROOT", tmp_path / "no-procfs")
monkeypatch.setattr(web_tools, "_process_is_alive", lambda pid: False)
pid_file = _pid_file_for(tmp_path, monkeypatch, "clawmm-test", "session-2", 4322)
PrivateBrowserTool._terminate_owned_daemon({}, "session-2")
assert not pid_file.exists()
def test_owned_daemon_is_detected_from_a_live_pid_without_procfs(
monkeypatch, tmp_path
) -> None:
"""Answering "no daemon" here is what lets close bootstrap a fresh one."""
monkeypatch.setattr(platform_compat, "PROC_ROOT", tmp_path / "no-procfs")
monkeypatch.setattr(web_tools, "_process_is_alive", lambda pid: True)
_pid_file_for(tmp_path, monkeypatch, "clawmm-test", "session-3", 4323)
assert PrivateBrowserTool._owned_daemon_exists({}, "session-3") is True
def test_owned_daemon_absent_when_the_pid_is_gone(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(platform_compat, "PROC_ROOT", tmp_path / "no-procfs")
monkeypatch.setattr(web_tools, "_process_is_alive", lambda pid: False)
_pid_file_for(tmp_path, monkeypatch, "clawmm-test", "session-4", 4324)
assert PrivateBrowserTool._owned_daemon_exists({}, "session-4") is False
def test_procfs_host_still_matches_on_the_command_line(monkeypatch, tmp_path) -> None:
"""With procfs present the identity check stays exact, not pid-liveness."""
proc = tmp_path / "proc"
(proc / "5555").mkdir(parents=True)
(proc / "5555" / "cmdline").write_bytes(b"node\0agent-browser\0--serve")
(proc / "6666").mkdir(parents=True)
(proc / "6666" / "cmdline").write_bytes(b"some\0other\0process")
monkeypatch.setattr(platform_compat, "PROC_ROOT", proc)
monkeypatch.setattr(web_tools, "_process_is_alive", lambda pid: True)
_pid_file_for(tmp_path, monkeypatch, "clawmm-test", "session-5", 5555)
assert PrivateBrowserTool._owned_daemon_exists({}, "session-5") is True
_pid_file_for(tmp_path, monkeypatch, "clawmm-test", "session-6", 6666)
assert PrivateBrowserTool._owned_daemon_exists({}, "session-6") is False
def test_liveness_probe_goes_through_the_platform_safe_helper(monkeypatch) -> None:
"""The no-procfs path must not reach a bare ``os.kill(pid, 0)``.
CPython's Windows ``os.kill`` calls ``TerminateProcess(handle, sig)`` for
any signal other than CTRL_C / CTRL_BREAK, so probing liveness with signal
0 terminates the process it asks about — and the only hosts that reach this
probe are the ones with no procfs, Windows among them.
``core.platform_compat.pid_alive`` is the tree's platform-safe answer.
"""
asked: list[int] = []
monkeypatch.setattr(
platform_compat, "pid_alive", lambda pid: asked.append(pid) or True
)
monkeypatch.setattr(
web_tools.os,
"kill",
lambda *a, **kw: pytest.fail("os.kill must not be used to probe liveness"),
)
assert web_tools._process_is_alive(4242) is True
assert asked == [4242]