From 8b85e11fb417792113b396b0f3d754e1f54f642b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Fri, 25 Sep 2026 10:30:49 +0200 Subject: [PATCH] fix(browser): skip the Chrome sweep when the host has no procfs _terminate_owned_chrome() walked Path("/proc") unconditionally, so on macOS and Windows iterdir() raised FileNotFoundError out of private-browser session shutdown. src/tools/cookbook.py already guards the same kind of scan with os.path.isdir("/proc"). The sweep only reclaims Chrome trees that agent-browser reparented, so it is an optimisation rather than a correctness requirement: degrade to a no-op rather than failing the whole shutdown path. _PROC_ROOT is a module attribute so both branches are testable on either kind of host. The procfs-present path had no coverage at all before this. --- src/agent_tools/web_tools.py | 14 +++++++++- tests/test_private_browser_tool.py | 44 ++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/agent_tools/web_tools.py b/src/agent_tools/web_tools.py index 4bd36d96d..84bce8d5c 100644 --- a/src/agent_tools/web_tools.py +++ b/src/agent_tools/web_tools.py @@ -119,6 +119,12 @@ def _browser_pid_file_candidates( ) return list(dict.fromkeys(candidates)) + +# Linux exposes one command line per pid under /proc; macOS and Windows do not. +# Kept as a module attribute so the procfs-dependent paths stay testable on a +# host that has no procfs, and on one that does. +_PROC_ROOT = Path("/proc") + _SCHOLARLY_METADATA_CUE_RE = re.compile( r"\b(?:accept(?:ed|ance)?|publish(?:ed|ing|cation)?|venue|conference|" r"journal|proceedings|doi)\b", @@ -2316,8 +2322,14 @@ class PrivateBrowserTool: except OSError: return profile_prefix = str(tmpdir / "agent-browser-chrome-") + if not _PROC_ROOT.is_dir(): + # Without procfs there is no way to match a reparented Chrome by + # its command line, and the sweep is an optimisation rather than a + # correctness requirement. Leave those trees to the daemon's own + # lifecycle instead of failing the whole shutdown path. + return pids: list[int] = [] - for entry in Path("/proc").iterdir(): + for entry in _PROC_ROOT.iterdir(): if not entry.name.isdigit(): continue try: diff --git a/tests/test_private_browser_tool.py b/tests/test_private_browser_tool.py index 8e279b837..34fbce363 100644 --- a/tests/test_private_browser_tool.py +++ b/tests/test_private_browser_tool.py @@ -1801,3 +1801,47 @@ def test_generic_go_to_phrase_is_browser_interaction() -> None: 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(web_tools, "_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(web_tools, "_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]