main
py 510 lines 18.9 KB
Raw
1 from __future__ import annotations
2
3 import asyncio
4 import importlib
5 import sys
6 from pathlib import Path
7 from types import SimpleNamespace
8
9 import pytest
10
11 PROJECT_ROOT = Path(__file__).resolve().parents[1]
12 if str(PROJECT_ROOT) not in sys.path:
13 sys.path.insert(0, str(PROJECT_ROOT))
14
15 from plugins._a0_connector.helpers import ws_runtime
16 from plugins._browser.helpers import connector_runtime as connector_runtime_module
17 from plugins._browser.helpers.connector_runtime import (
18 ConnectorBrowserRuntime,
19 _agent_uses_local_chat_model,
20 _stable_host_browser_selection,
21 )
22
23
24 def _agent(context_id: str = "ctx-host"):
25 return SimpleNamespace(context=SimpleNamespace(id=context_id))
26
27
28 def test_host_required_runtime_error_is_repairable(monkeypatch):
29 from plugins._browser.helpers import selector as browser_selector
30
31 monkeypatch.setattr(
32 browser_selector,
33 "get_browser_config",
34 lambda agent=None: {"runtime_backend": "host_required"},
35 )
36
37 with pytest.raises(browser_selector.RepairableException, match="Bring Your Own Browser") as exc_info:
38 asyncio.run(browser_selector.get_tool_runtime(_agent("ctx-host-required-missing")))
39
40 message = str(exc_info.value)
41 assert "Internal Docker browser" in message
42 assert "/browser container" in message
43
44
45 def test_host_browser_metadata_selection_is_context_scoped():
46 sid = "sid-host-browser"
47 context_id = "ctx-host-browser"
48 ws_runtime.register_sid(sid)
49 ws_runtime.subscribe_sid_to_context(sid, context_id)
50 try:
51 ws_runtime.store_sid_host_browser_metadata(
52 sid,
53 {
54 "supported": True,
55 "enabled": True,
56 "status": "ready",
57 "browser_family": "chrome",
58 "profile_label": "Default",
59 "content_helper_sha256": "abc123",
60 "features": ["open", "content"],
61 },
62 )
63
64 assert ws_runtime.select_host_browser_target_sid(context_id) == sid
65 rows = ws_runtime.host_browser_metadata_for_context(context_id)
66 assert rows[0]["browser_family"] == "chrome"
67 assert rows[0]["enabled"] is True
68 assert rows[0]["content_helper_sha256"] == "abc123"
69 finally:
70 ws_runtime.unregister_sid(sid)
71
72
73 def test_connector_runtime_uses_stable_id_for_advertised_legacy_endpoint(monkeypatch):
74 sid = "sid-host-browser-stable"
75 context_id = "ctx-host-browser-stable"
76 endpoint = "ws://localhost:9222/devtools/browser/old-guid"
77 metadata = {
78 "supported": True,
79 "enabled": True,
80 "status": "ready",
81 "browser_family": "chrome-cdp",
82 "browser_id": "chrome-cdp",
83 "cdp_endpoint": endpoint,
84 "available_browsers": [
85 {
86 "id": "chrome-cdp",
87 "family": "chrome-cdp",
88 "label": "Chrome (allowed)",
89 "cdp_endpoint": endpoint,
90 }
91 ],
92 "features": ["ensure", "open"],
93 }
94 monkeypatch.setattr(
95 connector_runtime_module,
96 "get_browser_config",
97 lambda agent=None: {
98 "host_browser_profile_mode": "existing",
99 "host_browser_selection": endpoint,
100 },
101 )
102 ws_runtime.register_sid(sid)
103 ws_runtime.subscribe_sid_to_context(sid, context_id)
104 try:
105 ws_runtime.store_sid_host_browser_metadata(sid, metadata)
106 runtime = ConnectorBrowserRuntime(context_id, _agent(context_id))
107
108 assert runtime._payload_for_call("open", "example.com")["browser_selection"] == "chrome-cdp"
109 assert _stable_host_browser_selection(
110 "ws://localhost:9333/devtools/browser/custom",
111 metadata,
112 ) == "ws://localhost:9333/devtools/browser/custom"
113 finally:
114 ws_runtime.unregister_sid(sid)
115
116
117 def test_host_browser_candidate_selection_allows_disabled_supported_cli():
118 sid = "sid-host-browser-disabled"
119 context_id = "ctx-host-browser-disabled"
120 ws_runtime.register_sid(sid)
121 ws_runtime.subscribe_sid_to_context(sid, context_id)
122 try:
123 ws_runtime.store_sid_host_browser_metadata(
124 sid,
125 {
126 "supported": True,
127 "enabled": False,
128 "status": "disabled",
129 "browser_family": "chrome-a0",
130 "profile_label": "Default",
131 "features": ["ensure", "open"],
132 },
133 )
134
135 assert ws_runtime.select_host_browser_target_sid(context_id) is None
136 assert ws_runtime.select_host_browser_candidate_sid(context_id) == sid
137 finally:
138 ws_runtime.unregister_sid(sid)
139
140
141 def test_host_browser_candidate_selection_allows_preparable_cli():
142 sid = "sid-host-browser-preparable"
143 context_id = "ctx-host-browser-preparable"
144 ws_runtime.register_sid(sid)
145 ws_runtime.subscribe_sid_to_context(sid, context_id)
146 try:
147 ws_runtime.store_sid_host_browser_metadata(
148 sid,
149 {
150 "supported": False,
151 "can_prepare": True,
152 "enabled": False,
153 "status": "unsupported",
154 "browser_family": "chrome-a0",
155 "profile_label": "Default",
156 "features": ["ensure", "open"],
157 "support_reason": "Python Playwright is not installed.",
158 },
159 )
160
161 assert ws_runtime.select_host_browser_target_sid(context_id) is None
162 assert ws_runtime.select_host_browser_candidate_sid(context_id) == sid
163 rows = ws_runtime.host_browser_metadata_for_context(context_id)
164 assert rows[0]["can_prepare"] is True
165 finally:
166 ws_runtime.unregister_sid(sid)
167
168
169 def test_host_browser_metadata_infers_preparable_legacy_cli():
170 sid = "sid-host-browser-legacy-preparable"
171 context_id = "ctx-host-browser-legacy-preparable"
172 ws_runtime.register_sid(sid)
173 ws_runtime.subscribe_sid_to_context(sid, context_id)
174 try:
175 ws_runtime.store_sid_host_browser_metadata(
176 sid,
177 {
178 "supported": False,
179 "enabled": False,
180 "status": "unsupported",
181 "browser_family": "chrome-a0",
182 "profile_label": "Default",
183 "features": ["ensure", "open"],
184 "support_reason": "Python Playwright is not installed.",
185 },
186 )
187
188 rows = ws_runtime.host_browser_metadata_for_context(context_id)
189 assert rows[0]["can_prepare"] is True
190 assert ws_runtime.select_host_browser_candidate_sid(context_id) == sid
191 finally:
192 ws_runtime.unregister_sid(sid)
193
194
195 def test_pending_browser_op_resolves_and_disconnect_fails():
196 async def run() -> None:
197 sid = "sid-browser-pending"
198 loop = asyncio.get_running_loop()
199 future: asyncio.Future[dict[str, object]] = loop.create_future()
200 ws_runtime.store_pending_browser_op(
201 "op-browser",
202 sid=sid,
203 future=future,
204 loop=loop,
205 context_id="ctx",
206 )
207
208 assert ws_runtime.resolve_pending_browser_op(
209 "op-browser",
210 sid=sid,
211 payload={"op_id": "op-browser", "ok": True, "result": {"id": 1}},
212 )
213 assert await future == {"op_id": "op-browser", "ok": True, "result": {"id": 1}}
214
215 future2: asyncio.Future[dict[str, object]] = loop.create_future()
216 ws_runtime.store_pending_browser_op(
217 "op-browser-2",
218 sid=sid,
219 future=future2,
220 loop=loop,
221 context_id="ctx",
222 )
223 ws_runtime.fail_pending_browser_ops_for_sid(sid, error="gone")
224 assert await future2 == {"op_id": "op-browser-2", "ok": False, "error": "gone"}
225
226 asyncio.run(run())
227
228
229 def test_host_browser_privacy_detects_local_model(monkeypatch):
230 from plugins._model_config.helpers import model_config
231
232 monkeypatch.setattr(
233 model_config,
234 "get_chat_model_config",
235 lambda agent=None: {"provider": "openai", "name": "local", "api_base": "http://127.0.0.1:11434/v1"},
236 )
237
238 assert _agent_uses_local_chat_model(_agent()) is True
239
240
241 def test_connector_runtime_tolerates_legacy_config_module(monkeypatch):
242 import plugins._browser.helpers.config as browser_config
243 import plugins._browser.helpers.connector_runtime as connector_runtime_module
244
245 original = getattr(browser_config, "HOST_BROWSER_PROFILE_MODE_KEY", None)
246 monkeypatch.delattr(browser_config, "HOST_BROWSER_PROFILE_MODE_KEY", raising=False)
247
248 reloaded = importlib.reload(connector_runtime_module)
249
250 assert reloaded.HOST_BROWSER_PROFILE_MODE_KEY == "host_browser_profile_mode"
251
252 if original is not None:
253 monkeypatch.setattr(browser_config, "HOST_BROWSER_PROFILE_MODE_KEY", original, raising=False)
254 importlib.reload(connector_runtime_module)
255
256
257 def test_host_browser_privacy_blocks_cloud_content(monkeypatch):
258 import plugins._browser.helpers.connector_runtime as connector_runtime_module
259 from plugins._model_config.helpers import model_config
260
261 monkeypatch.setattr(
262 model_config,
263 "get_chat_model_config",
264 lambda agent=None: {"provider": "openrouter", "name": "cloud/model", "api_base": ""},
265 )
266 monkeypatch.setattr(
267 connector_runtime_module,
268 "get_browser_config",
269 lambda agent=None: {
270 "host_browser_privacy_policy": "enforce_local",
271 },
272 )
273 runtime = ConnectorBrowserRuntime("ctx-host", _agent("ctx-host"))
274
275 with pytest.raises(RuntimeError, match="blocked by Browser privacy policy"):
276 runtime._enforce_privacy({"action": "content"})
277
278
279 def test_connector_runtime_normalizes_host_navigation_payloads(monkeypatch):
280 import plugins._browser.helpers.connector_runtime as connector_runtime_module
281
282 monkeypatch.setattr(
283 connector_runtime_module,
284 "get_browser_config",
285 lambda agent=None: {"host_browser_profile_mode": "existing"},
286 )
287 runtime = ConnectorBrowserRuntime("ctx-host", _agent("ctx-host"))
288
289 open_payload = runtime._payload_for_call("open", "localhost:3000")
290 empty_open_payload = runtime._payload_for_call("open", "")
291 navigate_payload = runtime._payload_for_call("navigate", 7, "novinky.cz")
292 multi_payload = runtime._payload_for_call(
293 "multi",
294 [
295 {"action": "open", "url": "example.com"},
296 {"action": "navigate", "browser_id": 1, "url": "127.0.0.1:8000/path"},
297 {"action": "click", "browser_id": 1, "x": 12, "y": 34},
298 {"action": "type", "browser_id": 1, "text": "agent-zero.ai"},
299 {"action": "key_chord", "browser_id": 1, "keys": "CTRL+A"},
300 {
301 "action": "multi",
302 "calls": [{"action": "open", "url": "nested.example"}],
303 },
304 {"action": "content", "browser_id": 1},
305 ],
306 )
307
308 assert open_payload["url"] == "http://localhost:3000/"
309 assert empty_open_payload["url"] == ""
310 assert navigate_payload["url"] == "https://novinky.cz/"
311 assert multi_payload["calls"][0]["url"] == "https://example.com/"
312 assert multi_payload["calls"][1]["url"] == "http://127.0.0.1:8000/path"
313 assert multi_payload["calls"][2] == {
314 "action": "mouse",
315 "browser_id": 1,
316 "x": 12,
317 "y": 34,
318 "event_type": "click",
319 "button": "left",
320 }
321 assert multi_payload["calls"][3] == {
322 "action": "keyboard",
323 "browser_id": 1,
324 "text": "agent-zero.ai",
325 "key": "",
326 }
327 assert multi_payload["calls"][4]["keys"] == ["Control", "A"]
328 assert multi_payload["calls"][5]["calls"][0]["url"] == "https://nested.example/"
329 assert multi_payload["calls"][6] == {"action": "content", "browser_id": 1}
330 assert open_payload["profile_mode"] == "existing"
331 assert runtime._payload_for_call("key_chord", 1, "CTRL+A")["keys"] == ["Control", "A"]
332
333
334 def test_connector_runtime_forwards_host_profile_mode(monkeypatch):
335 import plugins._browser.helpers.connector_runtime as connector_runtime_module
336
337 monkeypatch.setattr(
338 connector_runtime_module,
339 "get_browser_config",
340 lambda agent=None: {"host_browser_profile_mode": "agent"},
341 )
342 runtime = ConnectorBrowserRuntime("ctx-host", _agent("ctx-host"))
343
344 assert runtime._payload_for_call("open", "example.com")["profile_mode"] == "agent"
345
346
347 def test_connector_runtime_adds_remote_debugging_help_to_cdp_errors():
348 runtime = ConnectorBrowserRuntime("ctx-host", _agent("ctx-host"))
349
350 message = runtime._host_browser_error_message(
351 "Cannot connect to the host browser remote-debugging endpoint "
352 "ws://127.0.0.1:9222/devtools/browser/test. Original error: refused"
353 )
354
355 assert "chrome://inspect/#remote-debugging" in message
356 assert "opera://inspect/#remote-debugging" in message
357 assert "Allow remote debugging for this browser instance" in message
358 assert "/browser host on" in message
359 assert "Internal Docker browser" in message
360 assert "/browser container" in message
361 already_helpful = (
362 "Open chrome://inspect/#remote-debugging and enable "
363 '"Allow remote debugging for this browser instance".'
364 )
365 already_helpful_message = runtime._host_browser_error_message(already_helpful)
366 assert already_helpful in already_helpful_message
367 assert "/browser container" in already_helpful_message
368
369
370 def test_connector_runtime_adds_docker_recovery_to_host_errors():
371 runtime = ConnectorBrowserRuntime("ctx-host", _agent("ctx-host"))
372
373 message = runtime._host_browser_error_message("Host browser operation failed")
374
375 assert "Internal Docker browser" in message
376 assert "/browser container" in message
377
378
379 def test_host_browser_artifacts_become_chat_scoped_files(monkeypatch, tmp_path):
380 def fake_get_abs_path(*parts):
381 return str(tmp_path.joinpath(*parts))
382
383 def fake_normalize_a0_path(path):
384 return "/a0/" + str(Path(path).relative_to(tmp_path)).replace("\\", "/")
385
386 monkeypatch.setattr(connector_runtime_module.chat_media.files, "get_abs_path", fake_get_abs_path)
387 monkeypatch.setattr(connector_runtime_module.chat_media.files, "normalize_a0_path", fake_normalize_a0_path)
388 runtime = ConnectorBrowserRuntime("ctx-host", _agent("ctx-host"))
389
390 result = runtime._materialize_artifact(
391 [
392 {
393 "ok": True,
394 "result": {
395 "browser_id": 1,
396 "artifact": {
397 "filename": "shot.jpg",
398 "mime": "image/jpeg",
399 "encoding": "base64",
400 "data": "ZmFrZQ==",
401 },
402 },
403 }
404 ]
405 )
406
407 inner = result[0]["result"]
408 assert "artifact" not in inner
409 assert Path(inner["path"]).read_bytes() == b"fake"
410 assert inner["a0_path"].startswith("/a0/usr/chats/ctx-host/screenshots/browser/shot-")
411 assert inner["context_id"] == "ctx-host"
412 assert inner["ephemeral"] is False
413 assert inner["chat_scoped"] is True
414 assert inner["vision_load"]["tool_args"]["paths"] == [inner["a0_path"]]
415
416
417 def test_host_browser_artifact_materialization_rejects_oversized_payload(monkeypatch, tmp_path):
418 monkeypatch.setattr(connector_runtime_module, "MAX_ARTIFACT_SIZE_BYTES", 2)
419 runtime = ConnectorBrowserRuntime("ctx-host", _agent("ctx-host"))
420
421 with pytest.raises(RuntimeError, match="too large"):
422 runtime._materialize_artifact(
423 {
424 "artifact": {
425 "filename": "shot.jpg",
426 "mime": "image/jpeg",
427 "encoding": "base64",
428 "data": "ZmFrZQ==",
429 },
430 }
431 )
432
433 assert not list(tmp_path.rglob("shot.jpg"))
434
435
436 def test_connector_runtime_ensures_preparable_host_browser_before_action(monkeypatch):
437 async def run() -> None:
438 import plugins._browser.helpers.connector_runtime as connector_runtime_module
439
440 sid = "sid-host-browser-ensure"
441 context_id = "ctx-host-browser-ensure"
442 emitted: list[dict[str, object]] = []
443
444 class FakeWsManager:
445 async def emit_to(self, namespace, target_sid, event, payload, handler_id=""):
446 del namespace, event, handler_id
447 emitted.append(dict(payload))
448 assert target_sid == sid
449 if payload["action"] == "ensure":
450 ws_runtime.store_sid_host_browser_metadata(
451 sid,
452 {
453 "supported": True,
454 "enabled": True,
455 "status": "active",
456 "browser_family": "chrome-a0",
457 "profile_label": "Default",
458 "features": ["ensure", "open"],
459 },
460 )
461 response = {"op_id": payload["op_id"], "ok": True, "result": {"status": "active"}}
462 else:
463 response = {
464 "op_id": payload["op_id"],
465 "ok": True,
466 "result": {"id": 1, "state": {"runtime": "host"}},
467 }
468 ws_runtime.resolve_pending_browser_op(payload["op_id"], sid=target_sid, payload=response)
469
470 monkeypatch.setattr(connector_runtime_module, "get_shared_ws_manager", lambda: FakeWsManager())
471 monkeypatch.setattr(
472 connector_runtime_module,
473 "get_browser_config",
474 lambda agent=None: {"host_browser_privacy_policy": "allow"},
475 )
476 ws_runtime.register_sid(sid)
477 ws_runtime.subscribe_sid_to_context(sid, context_id)
478 try:
479 ws_runtime.store_sid_host_browser_metadata(
480 sid,
481 {
482 "supported": False,
483 "can_prepare": True,
484 "enabled": False,
485 "status": "unsupported",
486 "browser_family": "chrome-a0",
487 "profile_label": "Default",
488 "features": ["ensure", "open"],
489 "support_reason": "Python Playwright is not installed.",
490 },
491 )
492 runtime = ConnectorBrowserRuntime(context_id, _agent(context_id))
493
494 result = await runtime._dispatch(
495 {"op_id": "op-open", "context_id": context_id, "action": "open", "url": "https://example.com"}
496 )
497
498 assert result == {"id": 1, "state": {"runtime": "host"}}
499 assert [payload["action"] for payload in emitted] == ["ensure", "open"]
500 assert [payload["profile_mode"] for payload in emitted] == ["existing", "existing"]
501 assert "__spaceBrowserDomHelper__" in emitted[0]["dom_helper"]["source"]
502 assert "captureDocument" in emitted[0]["dom_helper"]["required_apis"]
503 assert emitted[0]["dom_helper"]["sha256"]
504 assert "__spaceBrowserPageContent__" in emitted[0]["content_helper"]["source"]
505 assert "capture" in emitted[0]["content_helper"]["required_apis"]
506 assert emitted[0]["content_helper"]["sha256"]
507 finally:
508 ws_runtime.unregister_sid(sid)
509
510 asyncio.run(run())