From 701b4b127dd97ff45ff8abf49fed254cb709faef Mon Sep 17 00:00:00 2001 From: "nekomimi.pet" Date: Fri, 16 Oct 2026 22:02:24 -0400 Subject: [PATCH] browser: keep the Chrome we launch off the macOS login keychain Chrome resolved no default keychain for its own process, so before it could store its cookie key it put up a blocking "Keychain Not Found" panel with Cancel and Reset To Defaults, leaving the browser albedo drives stuck behind a dialog nobody asked for. A profile albedo owns has no business in the login keychain, and --use-mock-keychain keeps it out, on darwin only, so the automation browser cannot raise the panel at all. The owned argv moves into a module-level _command(), so a test reads the flags without starting a browser. browser_test.py asserts the darwin flag next to the rest of the owned options (profile, loopback debugging, headless, sandbox, trailing args, about:blank). --- priv/python/albedo_plugins/browser/launch.py | 59 ++++++++++++++------ test/harness/browser_test.py | 31 ++++++++++ 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/priv/python/albedo_plugins/browser/launch.py b/priv/python/albedo_plugins/browser/launch.py index 9c548d02..db1ea85b 100644 --- a/priv/python/albedo_plugins/browser/launch.py +++ b/priv/python/albedo_plugins/browser/launch.py @@ -11,6 +11,7 @@ import re import shutil import signal import subprocess +import sys import tempfile from typing import BinaryIO, TYPE_CHECKING, TypeVar @@ -146,6 +147,37 @@ def _extra_args(args: Sequence[str]) -> list[str]: return result +def _command( + executable: str, + profile: Path, + *, + headless: bool, + sandbox: bool, + args: Sequence[str], +) -> list[str]: + """The exact argv for one owned Chrome, kept apart so tests can read it.""" + command = [ + executable, + f"--user-data-dir={profile}", + "--remote-debugging-address=127.0.0.1", + "--remote-debugging-port=0", + "--no-first-run", + "--no-default-browser-check", + ] + if sys.platform == "darwin": + # Otherwise Chrome resolves no default keychain for its own process and + # puts up a blocking "Keychain Not Found" panel before it can store its + # cookie key. A profile albedo owns has no business in the keychain. + command.append("--use-mock-keychain") + if headless: + command.extend(["--headless=new", "--disable-dev-shm-usage"]) + if not sandbox: + command.append("--no-sandbox") + command.extend(args) + command.append("about:blank") + return command + + class OwnedChrome: """Internal lifecycle object; Browser delegates to it only after successful spawn.""" @@ -207,20 +239,9 @@ class OwnedChrome: def start( self, executable: str, *, headless: bool, sandbox: bool, args: list[str] ) -> None: - command = [ - executable, - f"--user-data-dir={self.profile}", - "--remote-debugging-address=127.0.0.1", - "--remote-debugging-port=0", - "--no-first-run", - "--no-default-browser-check", - ] - if headless: - command.extend(["--headless=new", "--disable-dev-shm-usage"]) - if not sandbox: - command.append("--no-sandbox") - command.extend(args) - command.append("about:blank") + command = _command( + executable, self.profile, headless=headless, sandbox=sandbox, args=args + ) # Intentionally no await between process creation and taking ownership. A # cancelled create_subprocess_exec() can otherwise lose its returned handle. # Output goes to a temporary file: no pipe backpressure or kernel-stdout noise. @@ -380,10 +401,12 @@ async def spawn( Auto-detects an installed executable; headless launches prefer an installed chrome-headless-shell to avoid macOS Dock tiles. Uses a temporary profile, loopback CDP and an OS-assigned port by default; never downloads a browser or - silently disables its sandbox. A supplied profile must be empty or previously - created by albedo and is preserved on close. ``Browser.close()`` or - ``async with await spawn()`` stops owned Chrome and removes temporary data. - ``disconnect()`` deliberately leaves it running until close or normal exit. + silently disables its sandbox. On macOS it is kept off the login keychain + entirely, so it cannot raise the system Keychain panel. A supplied profile + must be empty or previously created by albedo and is preserved on close. + ``Browser.close()`` or ``async with await spawn()`` stops owned Chrome and + removes temporary data. ``disconnect()`` deliberately leaves it running until + close or normal exit. timeout bounds startup and CDP initialization; bounded cleanup may take extra time. Exceptions include a diagnostic log tail. Cancelled startup is cleaned diff --git a/test/harness/browser_test.py b/test/harness/browser_test.py index 57de38cf..7e4ef716 100644 --- a/test/harness/browser_test.py +++ b/test/harness/browser_test.py @@ -11,6 +11,7 @@ ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "priv" / "python")) from albedo_plugins.browser import spawn, render, one # noqa: E402 +from albedo_plugins.browser.launch import _command # noqa: E402 class BrowserIntegrationTest(unittest.IsolatedAsyncioTestCase): @@ -74,5 +75,35 @@ class BrowserIntegrationTest(unittest.IsolatedAsyncioTestCase): await b.close() +PROFILE = Path("/tmp/profile") + + +class LaunchCommandTest(unittest.TestCase): + """The argv albedo hands Chrome, read without starting one.""" + + def test_macos_launches_stay_out_of_the_login_keychain(self): + # Chrome raises a blocking "Keychain Not Found" panel when it cannot + # resolve a default keychain for its own process. + command = _command( + "/opt/chrome", PROFILE, headless=False, sandbox=True, args=[] + ) + self.assertEqual("--use-mock-keychain" in command, sys.platform == "darwin") + + def test_the_owned_options_are_the_ones_asked_for(self): + command = _command( + "/opt/chrome", PROFILE, headless=False, sandbox=False, args=["--lang=en"] + ) + self.assertEqual(command[0], "/opt/chrome") + self.assertIn("--user-data-dir=/tmp/profile", command) + self.assertIn("--remote-debugging-port=0", command) + self.assertNotIn("--headless=new", command) + self.assertIn("--no-sandbox", command) + self.assertEqual(command[-2:], ["--lang=en", "about:blank"]) + headless = _command( + "/opt/chrome", PROFILE, headless=True, sandbox=True, args=[] + ) + self.assertIn("--headless=new", headless) + + if __name__ == "__main__": unittest.main() -- knotmirror 0.0.1