From 6d7e001f481ce75c1d08c9ca9618480058490faa Mon Sep 17 00:00:00 2001 From: WorkerOS Date: Thu, 2 Jul 2026 20:24:42 +0200 Subject: [PATCH 1/3] fix(auth-portal): always inject VNC password into noVNC embed The auth portal (/auth/) only supplied the VNC password to noVNC when trusted_client was true, so the owner opening the portal from an untrusted device (their phone) got a blank viewer. The portal token is itself the secret, so always auto-supply the password. Co-Authored-By: Claude Opus 4.8 --- ax_browser_broker/api.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ax_browser_broker/api.py b/ax_browser_broker/api.py index 07a2747..2d83ac2 100644 --- a/ax_browser_broker/api.py +++ b/ax_browser_broker/api.py @@ -4135,7 +4135,11 @@ def _auth_portal_html( floating_auth = "" inline_password_controls = "" if vnc: - embed_url = _novnc_embed_url(vnc, trusted_client) + # The /auth/ portal URL is itself the secret, so always auto-supply + # the VNC password to noVNC (previously gated on trusted_client, which left + # the viewer blank for the primary use case: the owner opening the portal + # from an untrusted device like their phone). + embed_url = _novnc_embed_url(vnc, True) safe_embed_url = html.escape(embed_url, quote=True) safe_open_url = html.escape(embed_url, quote=True) if trusted_client: From 60f94055df2357aad3a275029bd4daff7bb67134 Mon Sep 17 00:00:00 2001 From: WorkerOS Date: Thu, 2 Jul 2026 20:38:42 +0200 Subject: [PATCH 2/3] fix(auth-vnc): clone identity profile per-session to avoid SingletonLock; fail loud if chrome window doesn't open Auth VNC sessions launched chrome on the shared identity profile which is SingletonLock'd by a live pool chrome, so the new chrome exited immediately and the display stayed black. Clone the profile (login state preserved) to a per-session temp dir, and raise if no visible window appears. Co-Authored-By: Claude Opus 4.8 --- ax_browser_broker/auth.py | 157 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 151 insertions(+), 6 deletions(-) diff --git a/ax_browser_broker/auth.py b/ax_browser_broker/auth.py index 1ff0fcb..7f96ae2 100644 --- a/ax_browser_broker/auth.py +++ b/ax_browser_broker/auth.py @@ -35,6 +35,27 @@ class AuthError(RuntimeError): pass +AUTH_PROFILE_EXCLUDES = [ + "Singleton*", + ".com.google.Chrome.*", + "Crashpad", + "BrowserMetrics*", + "Cache", + "Code Cache", + "GPUCache", + "ShaderCache", + "GrShaderCache", + "DawnCache", + "component_crx_cache", + "Default/Cache", + "Default/Code Cache", + "Default/GPUCache", + "Default/Service Worker/CacheStorage", + "*/LOCK", + "*.log", +] + + def _url_from_base(base_url: str, path: str) -> str: return f"{base_url.rstrip('/')}/{path.lstrip('/')}" @@ -89,6 +110,29 @@ def current_auth_vnc(token: str) -> dict[str, Any] | None: } +def _record_auth_start_error(token: str, error: str) -> None: + now = int(time.time()) + with locked_auth_state() as state: + request = state.get("requests", {}).get(token) + if request is None: + return + request["start_error"] = error + request["start_error_at"] = now + vnc = request.get("vnc") + if isinstance(vnc, dict): + vnc["start_error"] = error + vnc["start_error_at"] = now + + +def _clear_auth_start_error(token: str) -> None: + with locked_auth_state() as state: + request = state.get("requests", {}).get(token) + if request is None: + return + request.pop("start_error", None) + request.pop("start_error_at", None) + + @contextmanager def locked_auth_state() -> Any: ensure_dirs() @@ -129,6 +173,8 @@ def gc_auth_requests(state: dict[str, Any]) -> list[str]: vnc["stopped_pids"] = stopped maintenance_slots = [str(item) for item in vnc.get("maintenance_slots", [])] vnc["cleared_maintenance_slots"] = _clear_auth_maintenance(maintenance_slots) + if vnc.get("auth_profile_clone"): + _cleanup_auth_profile_clone(str(vnc.get("profile_dir") or "")) password_file = vnc.get("password_file") if password_file: try: @@ -293,6 +339,60 @@ def _find_free_tcp_port(start: int = 18900, end: int = 18999) -> int: raise AuthError("No free local proxy port found for identity auth") +def _auth_profile_root() -> Path: + return ROOT / "state" / "auth-profiles" + + +def _safe_profile_token(value: str) -> str: + return "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in value)[:96] or "auth" + + +def _sync_auth_profile_clone(source: Path, target: Path) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists(): + shutil.rmtree(target, ignore_errors=True) + if not source.exists(): + target.mkdir(parents=True, exist_ok=True) + return + target.mkdir(parents=True, exist_ok=True) + rsync = shutil.which("rsync") + if rsync: + cmd = [rsync, "-a", "--delete", "--delete-excluded"] + for pattern in AUTH_PROFILE_EXCLUDES: + cmd.extend(["--exclude", pattern]) + cmd.extend([str(source) + "/", str(target) + "/"]) + subprocess.run(cmd, check=True) + return + + def ignore(_dir: str, names: list[str]) -> set[str]: + import fnmatch + + ignored: set[str] = set() + for name in names: + if any(fnmatch.fnmatch(name, pattern) for pattern in AUTH_PROFILE_EXCLUDES if "/" not in pattern): + ignored.add(name) + return ignored + + shutil.copytree(source, target, dirs_exist_ok=True, ignore=ignore) + + +def _prepare_identity_auth_profile(identity_id: str, source_profile_dir: Path, token: str) -> Path: + clone_dir = _auth_profile_root() / _safe_profile_token(identity_id) / _safe_profile_token(token) + _sync_auth_profile_clone(source_profile_dir, clone_dir) + return clone_dir + + +def _cleanup_auth_profile_clone(profile_dir: str | None) -> None: + if not profile_dir: + return + path = Path(profile_dir) + try: + path.resolve().relative_to(_auth_profile_root().resolve()) + except ValueError: + return + shutil.rmtree(path, ignore_errors=True) + + def _terminate_tcp_listeners(port: int) -> list[int]: try: output = subprocess.check_output(["lsof", "-ti", f"TCP:{port}", "-sTCP:LISTEN"], text=True) @@ -459,8 +559,8 @@ def _start_identity_auth_vnc( auth_slots = _identity_auth_slots(identity.identity_id, identity.profile_dir, identity.slot) maintenance_slots = _write_auth_maintenance(auth_slots, request) identity.profile_dir.mkdir(parents=True, exist_ok=True) - _kill_identity_pool_processes(identity.identity_id, identity.profile_dir, maintenance_slots) - time.sleep(0.5) + token = str(request.get("token") or "manual") + auth_profile_dir = _prepare_identity_auth_profile(identity.identity_id, identity.profile_dir, token) display = _find_free_display() env = os.environ.copy() env["DISPLAY"] = display @@ -507,7 +607,7 @@ def _start_identity_auth_vnc( chrome_proc = subprocess.Popen( [ chrome, - f"--user-data-dir={identity.profile_dir}", + f"--user-data-dir={auth_profile_dir}", "--no-sandbox", "--disable-gpu", "--disable-gpu-sandbox", @@ -531,7 +631,8 @@ def _start_identity_auth_vnc( start_new_session=True, ) started_pids.append(chrome_proc.pid) - time.sleep(0.7) + _wait_for_process_port(chrome_proc, cdp_port, log_path, "Chrome remote debugging", timeout_seconds=8.0) + _wait_for_chrome_window(chrome_proc, display, log_path) x11vnc_proc = subprocess.Popen( [ x11vnc, @@ -563,10 +664,14 @@ def _start_identity_auth_vnc( env=env, start_new_session=True, ) + started_pids.append(websockify_proc.pid) + _wait_for_process_port(x11vnc_proc, vnc_port, log_path, "x11vnc") + _wait_for_process_port(websockify_proc, websocket_port, log_path, "websockify") except Exception: for pid in reversed(started_pids): _terminate_process_group(pid) _clear_auth_maintenance(maintenance_slots) + _cleanup_auth_profile_clone(str(auth_profile_dir)) raise return { "mode": "identity", @@ -585,7 +690,9 @@ def _start_identity_auth_vnc( "vnc_port": vnc_port, "password_file": str(password_file), "started_at": int(time.time()), - "profile_dir": str(identity.profile_dir), + "profile_dir": str(auth_profile_dir), + "source_profile_dir": str(identity.profile_dir), + "auth_profile_clone": True, } @@ -666,7 +773,8 @@ def start_auth_vnc(token: str, websocket_port: int = 6081, vnc_port: int = 5901) "password_file": str(password_file), "started_at": int(time.time()), } - except Exception: + except Exception as error: + _record_auth_start_error(token, str(error)) try: password_file.unlink(missing_ok=True) except OSError: @@ -678,6 +786,7 @@ def start_auth_vnc(token: str, websocket_port: int = 6081, vnc_port: int = 5901) state_request = state["requests"].get(token) if state_request is not None: state_request["vnc"] = vnc_state + _clear_auth_start_error(token) return { "token": token, "display": display, @@ -779,6 +888,40 @@ def _wait_for_process_port(proc: subprocess.Popen[Any], port: int, log_path: Pat raise AuthError(f"{label} failed to listen on 127.0.0.1:{port}: {error_tail}") +def _wait_for_chrome_window( + chrome_proc: subprocess.Popen[Any], + display: str, + log_path: Path, + timeout_seconds: float = 8.0, +) -> None: + xdotool = shutil.which("xdotool") + if not xdotool: + raise AuthError("xdotool is missing; cannot verify Chrome opened a visible auth window") + env = os.environ.copy() + env["DISPLAY"] = display + deadline = time.time() + timeout_seconds + last_error = "" + while time.time() < deadline: + if chrome_proc.poll() is not None: + break + for args in ( + [xdotool, "search", "--onlyvisible", "--class", "chrome"], + [xdotool, "search", "--onlyvisible", "--name", "."], + ): + result = subprocess.run(args, env=env, capture_output=True, text=True, check=False) + if result.returncode == 0 and result.stdout.strip(): + return + last_error = (result.stderr or result.stdout or "").strip() + time.sleep(0.2) + error_tail = "" + try: + error_tail = log_path.read_text(encoding="utf-8", errors="replace")[-1200:] + except OSError: + pass + details = f"{last_error}\n{error_tail}".strip() + raise AuthError(f"Chrome did not open a visible auth window on {display}: {details}") + + def _start_lease_vnc( request: dict[str, Any], websocket_port: int, @@ -887,6 +1030,8 @@ def stop_auth_vnc(token: str, missing_ok: bool = False) -> dict[str, Any]: pass maintenance_slots = [str(item) for item in vnc.get("maintenance_slots", [])] cleared_maintenance = _clear_auth_maintenance(maintenance_slots) + if vnc.get("auth_profile_clone"): + _cleanup_auth_profile_clone(str(vnc.get("profile_dir") or "")) with locked_auth_state() as state: state_request = state["requests"].get(token) if state_request is not None and "vnc" in state_request: From c79a1d28b579300010e01ef9af06659ee8abb752 Mon Sep 17 00:00:00 2001 From: Federico De Ponte Date: Thu, 2 Jul 2026 20:47:23 +0200 Subject: [PATCH 3/3] fix(auth-vnc): launch auth portals from cloned profiles --- ax_browser_broker/api.py | 3 +- ax_browser_broker/auth.py | 209 +++++++++++++++++++++++++++++--------- tests/test_api.py | 20 +++- tests/test_auth.py | 81 ++++++++++++++- 4 files changed, 260 insertions(+), 53 deletions(-) diff --git a/ax_browser_broker/api.py b/ax_browser_broker/api.py index 2d83ac2..b0c0db9 100644 --- a/ax_browser_broker/api.py +++ b/ax_browser_broker/api.py @@ -4760,10 +4760,11 @@ async def auth_portal(token: str, request: Request) -> Any: except AuthError as error: raise HTTPException(status_code=410 if "expired" in str(error) or "is expired" in str(error) else 404, detail=str(error)) from error vnc = current_auth_vnc(token) - start_error = None + start_error = str(auth_request_data.get("start_error") or "") or None if vnc is None and AUTH_PORTAL_AUTOSTART: try: vnc = start_auth_vnc(token) + start_error = None except AuthError as error: redirect = _active_identity_control_redirect(auth_request_data, error) if redirect: diff --git a/ax_browser_broker/auth.py b/ax_browser_broker/auth.py index 7f96ae2..79c5ce3 100644 --- a/ax_browser_broker/auth.py +++ b/ax_browser_broker/auth.py @@ -50,6 +50,12 @@ class AuthError(RuntimeError): "Default/Cache", "Default/Code Cache", "Default/GPUCache", + "Default/Sessions", + "Current Session", + "Current Tabs", + "Last Session", + "Last Tabs", + "Sessions", "Default/Service Worker/CacheStorage", "*/LOCK", "*.log", @@ -376,12 +382,48 @@ def ignore(_dir: str, names: list[str]) -> set[str]: shutil.copytree(source, target, dirs_exist_ok=True, ignore=ignore) +def _scrub_auth_profile_runtime_state(profile_dir: Path) -> None: + for relative in ( + "Default/Sessions", + "Default/Current Session", + "Default/Current Tabs", + "Default/Last Session", + "Default/Last Tabs", + ): + path = profile_dir / relative + if path.is_dir(): + shutil.rmtree(path, ignore_errors=True) + else: + try: + path.unlink(missing_ok=True) + except OSError: + pass + preferences = profile_dir / "Default" / "Preferences" + try: + data = json.loads(preferences.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return + profile = data.setdefault("profile", {}) + if isinstance(profile, dict): + profile["exit_type"] = "Normal" + profile["exited_cleanly"] = True + try: + preferences.write_text(json.dumps(data, separators=(",", ":")), encoding="utf-8") + except OSError: + pass + + def _prepare_identity_auth_profile(identity_id: str, source_profile_dir: Path, token: str) -> Path: clone_dir = _auth_profile_root() / _safe_profile_token(identity_id) / _safe_profile_token(token) _sync_auth_profile_clone(source_profile_dir, clone_dir) + _scrub_auth_profile_runtime_state(clone_dir) return clone_dir +def _prepare_generic_auth_profile(token: str) -> Path: + return _prepare_identity_auth_profile("authenticated-chrome", AUTHENTICATED_PROFILE_DIR, token) + + def _cleanup_auth_profile_clone(profile_dir: str | None) -> None: if not profile_dir: return @@ -696,6 +738,123 @@ def _start_identity_auth_vnc( } +def _start_generic_auth_vnc( + request: dict[str, Any], + websocket_port: int, + vnc_port: int, + password_file: Path, + log_path: Path, +) -> dict[str, Any]: + xvfb = shutil.which("Xvfb") + x11vnc = shutil.which("x11vnc") + websockify = shutil.which("websockify") + chrome = shutil.which("google-chrome-stable") or shutil.which("google-chrome") + if not xvfb or not x11vnc or not websockify or not chrome: + raise AuthError("Xvfb, Chrome, x11vnc, or websockify is missing") + token = str(request.get("token") or "manual") + auth_profile_dir = _prepare_generic_auth_profile(token) + display = _find_free_display() + env = os.environ.copy() + env["DISPLAY"] = display + started_pids: list[int] = [] + cdp_port = _find_free_tcp_port(19400, 19499) + with log_path.open("ab") as log: + try: + xvfb_proc = subprocess.Popen( + [xvfb, display, "-screen", "0", "1280x800x24", "-nolisten", "tcp"], + stdout=log, + stderr=log, + env=env, + start_new_session=True, + ) + started_pids.append(xvfb_proc.pid) + time.sleep(0.4) + chrome_proc = subprocess.Popen( + [ + chrome, + f"--user-data-dir={auth_profile_dir}", + "--no-sandbox", + "--disable-gpu", + "--disable-gpu-sandbox", + "--use-gl=swiftshader", + "--disable-dev-shm-usage", + "--disable-extensions", + "--disable-component-extensions-with-background-pages", + "--no-first-run", + f"--remote-debugging-port={cdp_port}", + "--remote-debugging-address=127.0.0.1", + "--remote-allow-origins=*", + "--lang=en-US", + "--window-size=1280,800", + "--window-position=0,0", + str(request["url"]), + ], + stdout=log, + stderr=log, + env=env, + start_new_session=True, + ) + started_pids.append(chrome_proc.pid) + _wait_for_process_port(chrome_proc, cdp_port, log_path, "Chrome remote debugging", timeout_seconds=8.0) + _wait_for_chrome_window(chrome_proc, display, log_path) + x11vnc_proc = subprocess.Popen( + [ + x11vnc, + "-display", + display, + "-rfbport", + str(vnc_port), + "-localhost", + "-forever", + "-shared", + "-passwdfile", + str(password_file), + ], + stdout=log, + stderr=log, + env=env, + start_new_session=True, + ) + started_pids.append(x11vnc_proc.pid) + websockify_proc = subprocess.Popen( + [ + websockify, + "--web=/usr/share/novnc", + f"127.0.0.1:{websocket_port}", + f"127.0.0.1:{vnc_port}", + ], + stdout=log, + stderr=log, + env=env, + start_new_session=True, + ) + started_pids.append(websockify_proc.pid) + _wait_for_process_port(x11vnc_proc, vnc_port, log_path, "x11vnc") + _wait_for_process_port(websockify_proc, websocket_port, log_path, "websockify") + except Exception: + for pid in reversed(started_pids): + _terminate_process_group(pid) + _cleanup_auth_profile_clone(str(auth_profile_dir)) + raise + return { + "mode": "authenticated-chrome", + "display": display, + "xvfb_pid": xvfb_proc.pid, + "chrome_pid": chrome_proc.pid, + "x11vnc_pid": x11vnc_proc.pid, + "websockify_pid": websockify_proc.pid, + "cdp_port": cdp_port, + "cdp": f"http://127.0.0.1:{cdp_port}", + "websocket_port": websocket_port, + "vnc_port": vnc_port, + "password_file": str(password_file), + "started_at": int(time.time()), + "profile_dir": str(auth_profile_dir), + "source_profile_dir": str(AUTHENTICATED_PROFILE_DIR), + "auth_profile_clone": True, + } + + def start_auth_vnc(token: str, websocket_port: int = 6081, vnc_port: int = 5901) -> dict[str, Any]: request = get_pending_auth_request(token) if request["status"] not in {"pending", "complete"}: @@ -705,9 +864,6 @@ def start_auth_vnc(token: str, websocket_port: int = 6081, vnc_port: int = 5901) if not x11vnc or not websockify: raise AuthError("x11vnc or websockify is missing") display = "" - auth_path = None - if not request.get("identity_id"): - display, auth_path = _authenticated_x_display() runtime_dir = ROOT / "state" / "vnc" runtime_dir.mkdir(parents=True, exist_ok=True) password_file = runtime_dir / f"{token}.passwd" @@ -728,51 +884,8 @@ def start_auth_vnc(token: str, websocket_port: int = 6081, vnc_port: int = 5901) vnc_state = _start_identity_auth_vnc(request, websocket_port, vnc_port, password_file, log_path) display = str(vnc_state["display"]) else: - env = os.environ.copy() - env["DISPLAY"] = display - if auth_path: - env["XAUTHORITY"] = auth_path - with log_path.open("ab") as log: - x11vnc_proc = subprocess.Popen( - [ - x11vnc, - "-display", - display, - "-rfbport", - str(vnc_port), - "-localhost", - "-forever", - "-shared", - "-passwdfile", - str(password_file), - ], - stdout=log, - stderr=log, - env=env, - start_new_session=True, - ) - websockify_proc = subprocess.Popen( - [ - websockify, - "--web=/usr/share/novnc", - f"127.0.0.1:{websocket_port}", - f"127.0.0.1:{vnc_port}", - ], - stdout=log, - stderr=log, - env=env, - start_new_session=True, - ) - vnc_state = { - "mode": "authenticated-chrome", - "x11vnc_pid": x11vnc_proc.pid, - "websockify_pid": websockify_proc.pid, - "websocket_port": websocket_port, - "vnc_port": vnc_port, - "display": display, - "password_file": str(password_file), - "started_at": int(time.time()), - } + vnc_state = _start_generic_auth_vnc(request, websocket_port, vnc_port, password_file, log_path) + display = str(vnc_state["display"]) except Exception as error: _record_auth_start_error(token, str(error)) try: diff --git a/tests/test_api.py b/tests/test_api.py index a431245..c24765d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -193,8 +193,7 @@ def test_auth_portal_keeps_password_prompt_for_untrusted_ip(tmp_path, monkeypatc assert "OpenBrowser Login Handoff" not in response.text assert "access code" in response.text assert "manual-pass" in response.text - assert response.text.count("manual-pass") == 1 - assert "#password=manual-pass" not in response.text + assert "#password=manual-pass" in response.text assert 'id="authCard"' in response.text assert 'id="minimizeAuth"' in response.text assert 'id="minimizeAuthSecondary"' in response.text @@ -252,6 +251,23 @@ def test_auth_portal_rejects_expired_requests_before_vnc(tmp_path, monkeypatch) assert "vnc.html" not in response.text +def test_auth_portal_shows_persisted_start_error(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(auth, "AUTH_STATE_FILE", tmp_path / "auth.json") + monkeypatch.setattr(api, "AUTH_PORTAL_AUTOSTART", False) + + request = auth.create_auth_request("tester", "https://example.com/login") + data = auth.json.loads((tmp_path / "auth.json").read_text(encoding="utf-8")) + data["requests"][request["token"]]["start_error"] = "Chrome did not open a visible auth window on :870" + (tmp_path / "auth.json").write_text(auth.json.dumps(data), encoding="utf-8") + + client = TestClient(api.app) + response = client.get("/auth/" + request["token"]) + + assert response.status_code == 200 + assert "Chrome did not open a visible auth window on :870" in response.text + assert "vnc.html" not in response.text + + def test_auth_complete_returns_gone_for_expired_request(tmp_path, monkeypatch) -> None: monkeypatch.setattr(auth, "AUTH_STATE_FILE", tmp_path / "auth.json") diff --git a/tests/test_auth.py b/tests/test_auth.py index 056b6b3..25391c8 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -114,6 +114,58 @@ def test_current_auth_vnc_rejects_expired_request_and_removes_password(tmp_path, assert terminated == [123, 456] +def test_sync_auth_profile_clone_preserves_cookies_and_excludes_locks(tmp_path, monkeypatch) -> None: + source = tmp_path / "source" + target = tmp_path / "target" + (source / "Default" / "Network").mkdir(parents=True) + (source / "Default" / "Network" / "Cookies").write_text("cookie-db", encoding="utf-8") + (source / "SingletonLock").write_text("locked", encoding="utf-8") + (source / "Default" / "Cache").mkdir(parents=True) + (source / "Default" / "Cache" / "blob").write_text("cache", encoding="utf-8") + (source / "Default" / "Sessions").mkdir(parents=True) + (source / "Default" / "Sessions" / "Session_1").write_text("restore", encoding="utf-8") + (source / "Default" / "Preferences").write_text( + json.dumps({"profile": {"exit_type": "Crashed", "exited_cleanly": False}}), + encoding="utf-8", + ) + monkeypatch.setattr(auth.shutil, "which", lambda name: None if name == "rsync" else f"/usr/bin/{name}") + + auth._sync_auth_profile_clone(source, target) + auth._scrub_auth_profile_runtime_state(target) + + assert (target / "Default" / "Network" / "Cookies").read_text(encoding="utf-8") == "cookie-db" + assert not (target / "SingletonLock").exists() + assert not (target / "Default" / "Cache").exists() + assert not (target / "Default" / "Sessions").exists() + preferences = json.loads((target / "Default" / "Preferences").read_text(encoding="utf-8")) + assert preferences["profile"]["exit_type"] == "Normal" + assert preferences["profile"]["exited_cleanly"] is True + + +def test_stop_auth_vnc_removes_identity_auth_profile_clone(tmp_path, monkeypatch) -> None: + state_file = tmp_path / "auth_requests.json" + clone_root = tmp_path / "auth-profiles" + clone_dir = clone_root / "chrome-openpaper" / "tok" + clone_dir.mkdir(parents=True) + (clone_dir / "marker").write_text("clone", encoding="utf-8") + monkeypatch.setattr(auth, "AUTH_STATE_FILE", state_file) + monkeypatch.setattr(auth, "_auth_profile_root", lambda: clone_root) + monkeypatch.setattr(auth, "_terminate_process_group", lambda _pid: True) + + request = auth.create_auth_request("tester", "https://example.com") + data = json.loads(state_file.read_text()) + data["requests"][request["token"]]["vnc"] = { + "chrome_pid": 789, + "profile_dir": str(clone_dir), + "auth_profile_clone": True, + } + state_file.write_text(json.dumps(data), encoding="utf-8") + + auth.stop_auth_vnc(request["token"]) + + assert not clone_dir.exists() + + def test_stop_auth_vnc_removes_password_file(tmp_path, monkeypatch) -> None: state_file = tmp_path / "auth_requests.json" password_file = tmp_path / "vnc.passwd" @@ -152,10 +204,14 @@ def test_start_auth_vnc_recreates_password_after_restart_cleanup(tmp_path, monke old_password_file = tmp_path / "old.passwd" old_password_file.write_text("old\n", encoding="utf-8") monkeypatch.setattr(auth, "AUTH_STATE_FILE", state_file) - monkeypatch.setattr(auth, "_authenticated_x_display", lambda: (":99", None)) + monkeypatch.setattr(auth, "_authenticated_x_display", lambda: (_ for _ in ()).throw(AssertionError("must not mirror existing display"))) monkeypatch.setattr(auth.shutil, "which", lambda name: f"/usr/bin/{name}") monkeypatch.setattr(auth.time, "sleep", lambda _seconds: None) monkeypatch.setattr(auth, "_terminate_process_group", lambda _pid: True) + monkeypatch.setattr(auth, "_find_free_display", lambda: ":870") + monkeypatch.setattr(auth, "_find_free_tcp_port", lambda *args, **kwargs: 19401) + monkeypatch.setattr(auth, "_prepare_generic_auth_profile", lambda token: tmp_path / "auth-clone") + monkeypatch.setattr(auth, "_wait_for_chrome_window", lambda *_args, **_kwargs: None) popen_calls = [] @@ -171,6 +227,9 @@ def __init__(self, args, **_kwargs): password_path = args[args.index("-passwdfile") + 1] assert auth.Path(password_path).exists() + def poll(self): + return None + monkeypatch.setattr(auth.subprocess, "Popen", FakePopen) monkeypatch.setattr(auth, "_wait_for_process_port", lambda *_args, **_kwargs: None) @@ -187,6 +246,14 @@ def __init__(self, args, **_kwargs): assert not old_password_file.exists() assert result["password"] + assert result["display"] == ":870" + assert result["cdp_port"] == 19401 + assert any(call[0] == "/usr/bin/Xvfb" and call[1] == ":870" for call in popen_calls) + chrome_call = next(call for call in popen_calls if call[0] == "/usr/bin/google-chrome-stable") + assert f"--user-data-dir={tmp_path / 'auth-clone'}" in chrome_call + assert "--use-gl=swiftshader" in chrome_call + assert "--remote-debugging-port=19401" in chrome_call + assert chrome_call[-1] == "https://example.com" assert any(call[0] == "/usr/bin/x11vnc" for call in popen_calls) assert any(call[0] == "/usr/bin/websockify" for call in popen_calls) @@ -370,6 +437,8 @@ def test_start_auth_vnc_removes_password_file_when_identity_start_fails(tmp_path raise AssertionError("expected identity start failure") assert not password_file.exists() + data = json.loads(state_file.read_text(encoding="utf-8")) + assert data["requests"][request["token"]]["start_error"] == "refused" def test_identity_auth_partial_start_failure_terminates_started_helpers(tmp_path, monkeypatch) -> None: @@ -407,6 +476,9 @@ def fake_popen(args, **_kwargs): ], ) monkeypatch.setattr(auth, "_terminate_pids", lambda pids: killed_pool_pids.extend(pids)) + monkeypatch.setattr(auth, "_prepare_identity_auth_profile", lambda identity_id, profile_dir, token: tmp_path / "auth-clone") + monkeypatch.setattr(auth, "_wait_for_process_port", lambda *_args, **_kwargs: None) + monkeypatch.setattr(auth, "_wait_for_chrome_window", lambda *_args, **_kwargs: None) monkeypatch.setattr(auth.subprocess, "Popen", fake_popen) monkeypatch.setattr(auth.time, "sleep", lambda _seconds: None) monkeypatch.setattr(auth, "_terminate_process_group", lambda pid: terminated.append(pid) or True) @@ -425,7 +497,7 @@ def fake_popen(args, **_kwargs): raise AssertionError("expected partial start failure") assert terminated == [333, 222, 111] - assert killed_pool_pids == [444, 444] + assert killed_pool_pids == [] assert not (tmp_path / "maintenance" / "pool-b.json").exists() @@ -461,6 +533,9 @@ def fake_popen(args, **_kwargs): monkeypatch.setattr(auth, "_find_free_tcp_port", lambda *args, **kwargs: 18901) monkeypatch.setattr(auth, "_process_rows", lambda: []) monkeypatch.setattr(auth, "_terminate_pids", lambda _pids: None) + monkeypatch.setattr(auth, "_prepare_identity_auth_profile", lambda identity_id, profile_dir, token: tmp_path / "auth-clone") + monkeypatch.setattr(auth, "_wait_for_process_port", lambda *_args, **_kwargs: None) + monkeypatch.setattr(auth, "_wait_for_chrome_window", lambda *_args, **_kwargs: None) monkeypatch.setattr(auth.subprocess, "Popen", fake_popen) monkeypatch.setattr(auth.time, "sleep", lambda _seconds: None) @@ -476,5 +551,7 @@ def fake_popen(args, **_kwargs): assert result["proxy_local_port"] == 18901 assert any(str(call[0]).endswith("/bin/ax-proxy-forwarder") for call in popen_calls) chrome_call = next(call for call in popen_calls if call[0] == "/usr/bin/google-chrome-stable") + assert f"--user-data-dir={tmp_path / 'auth-clone'}" in chrome_call + assert f"--user-data-dir={tmp_path / 'work-main'}" not in chrome_call assert "--proxy-server=http://127.0.0.1:18901" in chrome_call assert "--remote-allow-origins=*" in chrome_call