Files
odysseus/tests/test_private_browser_tool.py
T

749 lines
28 KiB
Python

"""Pure browser formatting/path and Wave 5B cleanup regressions.
Legacy successful page-command/batch/recovery tests have been superseded by
failed-before-dispatch resource tests in test_browser_resource_identity.py.
"""
import asyncio
import pytest
import base64
import json
import shutil
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_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_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_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_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_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
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_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 _cli_proc(calls, identity=None):
class _Proc:
pid = 1234
returncode = None
_ody_identity = identity
def kill(self):
calls.append("fallback-kill")
return _Proc()
def test_private_browser_timeout_terminates_the_process_group(monkeypatch) -> None:
from src import process_lifecycle, process_ownership
calls = []
monkeypatch.setattr(web_tools.os, "getpgid", lambda pid: pid)
monkeypatch.setattr(process_ownership, "verify", lambda pid, token: process_ownership.OWNED)
monkeypatch.setattr(
web_tools.os,
"killpg",
lambda pgid, signum: calls.append((pgid, signum)),
)
PrivateBrowserTool._terminate_subprocess(
_cli_proc(calls, process_lifecycle.ProcessIdentity(1234, "spawned", pgid=1234)))
assert calls == [(1234, web_tools.signal.SIGKILL), "fallback-kill"]
@pytest.mark.parametrize("verdict", ["foreign", "unverifiable", "gone"])
def test_cli_group_is_not_signalled_once_its_identity_is_lost(monkeypatch, verdict) -> None:
"""A reaped CLI's pid may be reissued; its group is then not ours to kill."""
from src import process_lifecycle, process_ownership
calls = []
monkeypatch.setattr(web_tools.os, "getpgid", lambda pid: pid)
monkeypatch.setattr(process_ownership, "verify", lambda pid, token: verdict)
monkeypatch.setattr(web_tools.os, "killpg", lambda *a: pytest.fail("signalled an unowned group"))
PrivateBrowserTool._terminate_subprocess(
_cli_proc(calls, process_lifecycle.ProcessIdentity(1234, "spawned", pgid=1234)))
assert calls == ["fallback-kill"]
def test_cli_without_a_spawn_identity_is_never_group_signalled(monkeypatch) -> None:
calls = []
monkeypatch.setattr(web_tools.os, "getpgid", lambda pid: pid)
monkeypatch.setattr(web_tools.os, "killpg", lambda *a: pytest.fail("signalled a bare pid's group"))
PrivateBrowserTool._terminate_subprocess(_cli_proc(calls))
assert calls == ["fallback-kill"]
def test_cli_group_that_moved_is_not_signalled(monkeypatch) -> None:
"""The identity verifies but no longer leads the recorded group."""
from src import process_lifecycle, process_ownership
calls = []
monkeypatch.setattr(web_tools.os, "getpgid", lambda pid: 999)
monkeypatch.setattr(process_ownership, "verify", lambda pid, token: process_ownership.OWNED)
monkeypatch.setattr(web_tools.os, "killpg", lambda *a: pytest.fail("signalled a group it does not lead"))
PrivateBrowserTool._terminate_subprocess(
_cli_proc(calls, process_lifecycle.ProcessIdentity(1234, "spawned", pgid=1234)))
assert calls == ["fallback-kill"]
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 = _fake_procfs(monkeypatch, tmp_path)
tmpdir = tmp_path / "runtime-tmp"
tmpdir.mkdir()
profile_prefix = str(tmpdir.resolve() / "agent-browser-chrome-")
_fake_process(proc, 101, f"chrome --user-data-dir={profile_prefix}abc")
_fake_process(proc, 202, "chrome --user-data-dir=/Users/someone/Library/Chrome")
(proc / "self").mkdir()
killed = _install_lethal_kill(monkeypatch, proc)
PrivateBrowserTool._terminate_owned_chrome({"TMPDIR": str(tmpdir)})
assert killed == [101]
def _fake_procfs(monkeypatch, tmp_path):
from src import process_ownership
proc = tmp_path / "proc"
boot = proc / "sys/kernel/random/boot_id"
boot.parent.mkdir(parents=True)
boot.write_text("fake-boot\n")
monkeypatch.setattr(platform_compat, "PROC_ROOT", proc)
monkeypatch.setattr(process_ownership, "PROC_ROOT", proc)
return proc
def _fake_process(proc, pid: int, cmdline: str, *, starttime: int | None = None) -> None:
entry = proc / str(pid)
entry.mkdir(parents=True)
tail = " ".join(["0"] * 15 + [str(starttime if starttime is not None else 1000 + pid)])
(entry / "stat").write_text(f"{pid} (x) S 1 {pid} {pid} {tail}")
(entry / "cmdline").write_bytes(cmdline.replace(" ", "\0").encode())
def _install_lethal_kill(monkeypatch, proc, *, before_kill=None):
killed: list[int] = []
def _kill(pid, sig):
if before_kill is not None:
before_kill(pid)
entry = proc / str(pid)
if not entry.exists():
raise ProcessLookupError(pid)
killed.append(pid)
shutil.rmtree(entry)
monkeypatch.setattr(web_tools.os, "kill", _kill)
return killed
def test_owned_chrome_sweep_never_signals_a_reused_pid(monkeypatch, tmp_path) -> None:
"""Matched by profile, then reissued to a stranger before the signal."""
from src import process_lifecycle
proc = _fake_procfs(monkeypatch, tmp_path)
tmpdir = tmp_path / "runtime-tmp"
tmpdir.mkdir()
_fake_process(proc, 101, f"chrome --user-data-dir={tmpdir.resolve()}/agent-browser-chrome-x")
killed = _install_lethal_kill(monkeypatch, proc)
real_terminate = process_lifecycle.terminate_identities
def recycle_then_terminate(identities, **kwargs):
identities = list(identities)
shutil.rmtree(proc / "101")
_fake_process(proc, 101, "postgres", starttime=99999)
return real_terminate(identities, **kwargs)
monkeypatch.setattr(process_lifecycle, "terminate_identities", recycle_then_terminate)
PrivateBrowserTool._terminate_owned_chrome({"TMPDIR": str(tmpdir)})
assert killed == [] and (proc / "101").exists()
def _legacy_pid_file_for(tmp_path, monkeypatch, namespace, session, pid):
"""A pid file in the legacy namespace layout, which only the fallback loop reads."""
monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path))
monkeypatch.setenv("ODYSSEUS_BROWSER_NAMESPACE", namespace)
legacy_run = (tmp_path / "agent-browser" / "namespaces"
/ web_tools._bounded_browser_identity(namespace) / "run")
legacy_run.mkdir(parents=True, exist_ok=True)
target = legacy_run / f"ody-{web_tools._bounded_browser_identity(session)}.pid"
assert target in web_tools._browser_pid_file_candidates(tmp_path, namespace, session)
target.write_text(str(pid))
return target
def test_legacy_daemon_pid_file_kills_only_the_verified_daemon(monkeypatch, tmp_path) -> None:
proc = _fake_procfs(monkeypatch, tmp_path)
_fake_process(proc, 4401, "node agent-browser --serve")
killed = _install_lethal_kill(monkeypatch, proc)
pid_file = _legacy_pid_file_for(tmp_path, monkeypatch, "clawmm-test", "session-7", 4401)
PrivateBrowserTool._terminate_owned_daemon({}, "session-7")
assert killed == [4401] and not pid_file.exists()
def test_legacy_daemon_pid_reused_before_the_signal_is_spared(monkeypatch, tmp_path) -> None:
"""The pid file still names the slot; the daemon in it was replaced."""
from src import process_lifecycle
proc = _fake_procfs(monkeypatch, tmp_path)
_fake_process(proc, 4402, "node agent-browser --serve")
killed = _install_lethal_kill(monkeypatch, proc)
pid_file = _legacy_pid_file_for(tmp_path, monkeypatch, "clawmm-test", "session-8", 4402)
real_terminate = process_lifecycle.terminate_identities
def recycle_then_terminate(identities, **kwargs):
identities = list(identities)
shutil.rmtree(proc / "4402")
_fake_process(proc, 4402, "node agent-browser --serve", starttime=99999)
return real_terminate(identities, **kwargs)
monkeypatch.setattr(process_lifecycle, "terminate_identities", recycle_then_terminate)
PrivateBrowserTool._terminate_owned_daemon({}, "session-8")
# Even a lookalike command line is not the process the match was made on.
assert killed == [] and (proc / "4402").exists()
def test_legacy_daemon_without_identity_keeps_its_pid_file(monkeypatch, tmp_path) -> None:
proc = _fake_procfs(monkeypatch, tmp_path)
_fake_process(proc, 4403, "node agent-browser --serve")
(proc / "4403" / "stat").write_text("4403 (x) S 1") # no start time: unidentifiable
monkeypatch.setattr(web_tools.os, "kill", lambda *a: pytest.fail("signalled an unidentified pid"))
pid_file = _legacy_pid_file_for(tmp_path, monkeypatch, "clawmm-test", "session-9", 4403)
PrivateBrowserTool._terminate_owned_daemon({}, "session-9")
assert pid_file.exists()
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]