From 12fc078260c9f17b85eede972a0ac8007a35aaea Mon Sep 17 00:00:00 2001 From: Ruoqi Huang Date: Sun, 5 Jul 2026 18:07:38 -0700 Subject: [PATCH 01/13] Fix CDP reconnect when Chrome has zero open tabs; bump to 1.0.0.7 connect_over_cdp throws Browser.setDownloadBehavior instead of returning an empty contexts list when no tabs are open, so the empty-contexts fallback never ran. Wrap the first connect attempt too, and prefer Playwright's bundled Chromium in browser-service since it supports the CDP multi-context calls system Chrome rejects. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 +++ VERSION | 2 +- bin/browser-service | 19 ++++++++++ outreach/browser.py | 38 ++++++++++++++++--- testing/tests/test_browser_attach.py | 56 ++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 testing/tests/test_browser_attach.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ee8168c..c46bb68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## [1.0.0.7] - 2026-07-05 + +### Fixed +- `LinkedInBrowser._attach` no longer fails to reconnect when the managed Chrome has zero open tabs — `connect_over_cdp` itself throws `Browser.setDownloadBehavior: Browser context management is not supported` in that state (rather than returning an empty `contexts` list), so the first connect attempt is now wrapped and falls back to opening a blank tab via the CDP HTTP endpoint before retrying +- `bin/browser-service` now prefers Playwright's bundled Chromium ("Chrome for Testing") over system Chrome/Chromium when resolving the browser binary, since it supports the CDP multi-context calls system Chrome rejects + ## [1.0.0.6] - 2026-07-04 ### Added diff --git a/VERSION b/VERSION index b658d66..417ec1b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0.6 +1.0.0.7 diff --git a/bin/browser-service b/bin/browser-service index abe1a14..01d55b5 100755 --- a/bin/browser-service +++ b/bin/browser-service @@ -48,10 +48,29 @@ resolve_repo() { fi } +resolve_playwright_chromium() { + # Playwright's bundled Chromium ("Chrome for Testing") supports the CDP + # multi-context calls (Browser.setDownloadBehavior, Target.createBrowserContext) + # that a real Google Chrome / system Chromium install rejects — using it here + # avoids that class of CDP error entirely instead of working around it. + command -v uv >/dev/null 2>&1 || return 1 + ( cd "${REPO_ROOT}" && uv run --project "${REPO_ROOT}" python -c ' +from playwright.sync_api import sync_playwright +with sync_playwright() as p: + print(p.chromium.executable_path) +' 2>/dev/null ) | tail -1 +} + resolve_chrome() { if [[ -n "${CHROME_BIN}" ]] && [[ -x "${CHROME_BIN}" ]]; then return 0 fi + local pw_chromium + pw_chromium="$(resolve_playwright_chromium || true)" + if [[ -n "${pw_chromium}" ]] && [[ -x "${pw_chromium}" ]]; then + CHROME_BIN="${pw_chromium}" + return 0 + fi local mac_chrome="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" if [[ "$(uname -s)" == "Darwin" ]] && [[ -x "${mac_chrome}" ]]; then CHROME_BIN="${mac_chrome}" diff --git a/outreach/browser.py b/outreach/browser.py index f373d21..3037f0d 100644 --- a/outreach/browser.py +++ b/outreach/browser.py @@ -69,6 +69,8 @@ import random import re import uuid +import urllib.error +import urllib.request from urllib.parse import urlparse from datetime import datetime, timezone from pathlib import Path @@ -79,6 +81,7 @@ from playwright.async_api import ( Browser, BrowserContext, + Error, Locator, Page, Playwright, @@ -529,6 +532,16 @@ def _pp_structure_activity_update(index: int, post: dict[str, Any]) -> dict[str, # ── Browser wrapper ─────────────────────────────────────────────────────────── +def _open_blank_tab(cdp_url: str) -> None: + """Ask Chrome's CDP HTTP endpoint to open a tab (bypasses Playwright context creation).""" + req = urllib.request.Request(f"{cdp_url}/json/new", method="PUT") + try: + urllib.request.urlopen(req, timeout=5).close() + except urllib.error.URLError: + # Older Chrome builds only accept GET on this endpoint. + urllib.request.urlopen(f"{cdp_url}/json/new", timeout=5).close() + + class LinkedInBrowser: """ Async context manager wrapping a Playwright Chromium session. @@ -638,15 +651,28 @@ async def _attach(self) -> None: We never close a tab we didn't open, so the user's browsing is undisturbed. """ - self._browser = await self._pw.chromium.connect_over_cdp(self.cdp_url) + try: + self._browser = await self._pw.chromium.connect_over_cdp(self.cdp_url) + except Error: + # With zero open tabs, connect_over_cdp itself fails (it has no + # default context to attach to) instead of returning a browser + # with an empty contexts list. Ask Chrome's own CDP HTTP endpoint + # to open a tab first, then connect. + await asyncio.to_thread(_open_blank_tab, self.cdp_url) + self._browser = await self._pw.chromium.connect_over_cdp(self.cdp_url) self._is_attached = True + if not self._browser.contexts: + # Real Chrome (unlike Playwright's bundled Chromium) rejects + # browser.new_context() over CDP — it has no open tabs to report + # a context for (e.g. the user closed every window). Ask Chrome's + # own CDP HTTP endpoint to open a tab instead, then reconnect so + # Playwright picks up the resulting default context. + await asyncio.to_thread(_open_blank_tab, self.cdp_url) + self._browser = await self._pw.chromium.connect_over_cdp(self.cdp_url) + # Inherit the real user session (cookies, localStorage, etc.). - self._ctx = ( - self._browser.contexts[0] - if self._browser.contexts - else await self._browser.new_context() - ) + self._ctx = self._browser.contexts[0] page = self._pick_tab(self._ctx.pages) if page is not None: diff --git a/testing/tests/test_browser_attach.py b/testing/tests/test_browser_attach.py new file mode 100644 index 0000000..d4147f6 --- /dev/null +++ b/testing/tests/test_browser_attach.py @@ -0,0 +1,56 @@ +"""Unit tests for LinkedInBrowser._attach's no-open-tabs fallback.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from outreach.browser import LinkedInBrowser + + +def _browser_with_pw(pw_chromium: MagicMock) -> LinkedInBrowser: + li = object.__new__(LinkedInBrowser) + li.cdp_url = "http://localhost:9222" + li._pw = MagicMock() + li._pw.chromium = pw_chromium + return li + + +@pytest.mark.asyncio +async def test_attach_reuses_default_context_when_tabs_open() -> None: + ctx = MagicMock(pages=[]) + fake_browser = MagicMock(contexts=[ctx]) + pw_chromium = MagicMock() + pw_chromium.connect_over_cdp = AsyncMock(return_value=fake_browser) + ctx.new_page = AsyncMock(return_value=MagicMock()) + + li = _browser_with_pw(pw_chromium) + with patch("outreach.browser._open_blank_tab") as open_tab: + await li._attach() + + open_tab.assert_not_called() + pw_chromium.connect_over_cdp.assert_awaited_once() + assert li._ctx is ctx + + +@pytest.mark.asyncio +async def test_attach_opens_tab_via_cdp_when_no_contexts() -> None: + ctx = MagicMock(pages=[]) + ctx.new_page = AsyncMock(return_value=MagicMock()) + empty_browser = MagicMock(contexts=[]) + populated_browser = MagicMock(contexts=[ctx]) + pw_chromium = MagicMock() + pw_chromium.connect_over_cdp = AsyncMock( + side_effect=[empty_browser, populated_browser] + ) + + li = _browser_with_pw(pw_chromium) + with patch("outreach.browser._open_blank_tab") as open_tab: + await li._attach() + + open_tab.assert_called_once_with("http://localhost:9222") + assert pw_chromium.connect_over_cdp.await_count == 2 + assert li._ctx is ctx + # The buggy path this replaces — new_context() is unsupported on real Chrome. + empty_browser.new_context.assert_not_called() From 8fd0fc9c9bfc132551aafe10b29841dd41baba52 Mon Sep 17 00:00:00 2001 From: Ruoqi Huang Date: Sun, 5 Jul 2026 18:08:34 -0700 Subject: [PATCH 02/13] Note the required LinkedIn relogin after the browser-binary switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reusing the same profile dir doesn't carry the session over when bin/browser-service switches CHROME_BIN to Playwright's Chromium — operators hit "not logged in" until they log in again in the new window. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c46bb68..d93b100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ All notable changes to this project will be documented in this file. - `LinkedInBrowser._attach` no longer fails to reconnect when the managed Chrome has zero open tabs — `connect_over_cdp` itself throws `Browser.setDownloadBehavior: Browser context management is not supported` in that state (rather than returning an empty `contexts` list), so the first connect attempt is now wrapped and falls back to opening a blank tab via the CDP HTTP endpoint before retrying - `bin/browser-service` now prefers Playwright's bundled Chromium ("Chrome for Testing") over system Chrome/Chromium when resolving the browser binary, since it supports the CDP multi-context calls system Chrome rejects +### Upgrade note +- Existing installs: after upgrading, run `bin/browser-service install` to relaunch the managed browser under Playwright's Chromium. This reuses the same `--user-data-dir` profile, but the switch to a new browser binary drops the existing LinkedIn session — **you'll need to log in to LinkedIn again** in the relaunched window (or run `/setup-outreach` to restore it) before scraping/messaging tools will work. + ## [1.0.0.6] - 2026-07-04 ### Added From 5e110cadfdf6f3f8b1ee00cdbc94f15fe58a1e45 Mon Sep 17 00:00:00 2001 From: Ruoqi Huang Date: Sun, 5 Jul 2026 18:11:18 -0700 Subject: [PATCH 03/13] Preserve existing setup on reinstall/upgrade install.sh's persona sync unconditionally re-ran on every reinstall (defaulting yes interactively, always running non-interactively), overwriting an already-configured persona.json. Now it detects an existing, non-example persona.json and keeps it by default. outreach-upgrade also hardcoded CHROME_BIN to system Chrome before calling browser-service install, undoing this branch's preference for Playwright's Chromium on every upgrade. It now lets resolve_chrome pick. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 ++ bin/outreach-upgrade | 24 +++++++----------------- install.sh | 29 +++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d93b100..06b47d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ All notable changes to this project will be documented in this file. ### Fixed - `LinkedInBrowser._attach` no longer fails to reconnect when the managed Chrome has zero open tabs — `connect_over_cdp` itself throws `Browser.setDownloadBehavior: Browser context management is not supported` in that state (rather than returning an empty `contexts` list), so the first connect attempt is now wrapped and falls back to opening a blank tab via the CDP HTTP endpoint before retrying - `bin/browser-service` now prefers Playwright's bundled Chromium ("Chrome for Testing") over system Chrome/Chromium when resolving the browser binary, since it supports the CDP multi-context calls system Chrome rejects +- `install.sh` no longer overwrites an already-configured `outreach/config/persona.json` on reinstall — it now keeps the existing file by default (interactive: prompts before overwriting; non-interactive: always keeps it), matching the existing email-setup behavior +- `bin/outreach-upgrade` no longer forces `CHROME_BIN` to system Chrome before running `bin/browser-service install` — it now lets `resolve_chrome` pick Playwright's Chromium first, so upgrades don't silently regress back to the CDP bug above ### Upgrade note - Existing installs: after upgrading, run `bin/browser-service install` to relaunch the managed browser under Playwright's Chromium. This reuses the same `--user-data-dir` profile, but the switch to a new browser binary drops the existing LinkedIn session — **you'll need to log in to LinkedIn again** in the relaunched window (or run `/setup-outreach` to restore it) before scraping/messaging tools will work. diff --git a/bin/outreach-upgrade b/bin/outreach-upgrade index 564d062..f225d70 100755 --- a/bin/outreach-upgrade +++ b/bin/outreach-upgrade @@ -160,23 +160,13 @@ fi BROWSER_SVC="${REPO_ROOT}/bin/browser-service" if [[ -f "$BROWSER_SVC" ]]; then chmod +x "$BROWSER_SVC" 2>/dev/null || true - chrome_bin="" - mac_chrome="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" - if [[ -x "$mac_chrome" ]]; then - chrome_bin="$mac_chrome" - elif command -v google-chrome >/dev/null 2>&1; then - chrome_bin="$(command -v google-chrome)" - elif command -v chromium >/dev/null 2>&1; then - chrome_bin="$(command -v chromium)" - elif command -v chromium-browser >/dev/null 2>&1; then - chrome_bin="$(command -v chromium-browser)" - fi - if [[ -n "$chrome_bin" ]]; then - if OUTREACH_REPO_ROOT="$REPO_ROOT" CHROME_BIN="$chrome_bin" "$BROWSER_SVC" install; then - echo "[outreach-upgrade] Refreshed browser auto-start (bin/browser-service install)" - else - echo "[outreach-upgrade] bin/browser-service install skipped (no launchd/systemd)" >&2 - fi + # Let browser-service resolve_chrome pick the binary (Playwright's + # Chromium first, then system Chrome) instead of forcing system Chrome + # here — forcing it would undo that preference on every upgrade. + if OUTREACH_REPO_ROOT="$REPO_ROOT" "$BROWSER_SVC" install; then + echo "[outreach-upgrade] Refreshed browser auto-start (bin/browser-service install)" + else + echo "[outreach-upgrade] bin/browser-service install skipped (no launchd/systemd)" >&2 fi fi diff --git a/install.sh b/install.sh index 4dd7fdb..5dcf4a9 100755 --- a/install.sh +++ b/install.sh @@ -628,6 +628,14 @@ prompt_linkedin_login() { fi } +persona_already_configured() { + local persona="${REPO_ROOT}/outreach/config/persona.json" + local example="${REPO_ROOT}/outreach/config/persona.json.example" + [[ -f "${persona}" ]] || return 1 + [[ -f "${example}" ]] && cmp -s "${persona}" "${example}" && return 1 + return 0 +} + run_sync_planner_persona_skill() { local prompt="Run the sync-planner-persona-from-linkedin skill for profile ${PERSONA_PROFILE_URL}" local rerun_cmd="cd \"${REPO_ROOT}\" && claude -p '${prompt}'" @@ -637,6 +645,27 @@ run_sync_planner_persona_skill() { note "skip: planner persona sync (--skip-persona-sync)" return 0 fi + + if persona_already_configured; then + if [[ -t 0 ]]; then + local reply="" + info "outreach/config/persona.json is already configured (reinstall detected)." + read -r -p "[install] Re-sync persona from LinkedIn and overwrite it? [y/N] " reply || reply="" + case "${reply}" in + y|Y|yes|YES) ;; + *) + info "Keeping existing outreach/config/persona.json." + note "ok: existing persona.json kept (reinstall)" + return 0 + ;; + esac + else + info "Non-interactive reinstall — keeping existing outreach/config/persona.json." + info "To re-sync: ${rerun_cmd}" + note "ok: existing persona.json kept (non-interactive reinstall)" + return 0 + fi + fi if ! command -v claude >/dev/null 2>&1; then info "claude CLI not on PATH — skipping planner persona sync." info "After installing Claude Code, run: ${rerun_cmd}" From 5dfd43781bb34bc6d94d3fcd706bce4bcda0bcd1 Mon Sep 17 00:00:00 2001 From: Ruoqi Huang Date: Sun, 5 Jul 2026 18:13:19 -0700 Subject: [PATCH 04/13] version --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3eb6022..c140007 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ebase" -version = "1.0.0.6" +version = "1.0.0.7" description = "LinkedIn recruiting outreach that runs inside Claude Code" readme = "README.md" license = "MIT" diff --git a/uv.lock b/uv.lock index 36fca5d..82dad76 100644 --- a/uv.lock +++ b/uv.lock @@ -245,7 +245,7 @@ wheels = [ [[package]] name = "ebase" -version = "1.0.0.6" +version = "1.0.0.7" source = { virtual = "." } dependencies = [ { name = "certifi" }, From df285187ab52f47b6b0b773dcc69b7bd5b9588eb Mon Sep 17 00:00:00 2001 From: Ruoqi Huang Date: Sun, 5 Jul 2026 19:09:03 -0700 Subject: [PATCH 05/13] Fix misleading skip message when browser-service install fails resolve_chrome already warns "Chrome / Chromium not found" when no binary is available; the old message here blamed missing launchd/ systemd regardless of the actual cause, misdirecting troubleshooting. --- bin/outreach-upgrade | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/outreach-upgrade b/bin/outreach-upgrade index f225d70..4934fcc 100755 --- a/bin/outreach-upgrade +++ b/bin/outreach-upgrade @@ -166,7 +166,7 @@ if [[ -f "$BROWSER_SVC" ]]; then if OUTREACH_REPO_ROOT="$REPO_ROOT" "$BROWSER_SVC" install; then echo "[outreach-upgrade] Refreshed browser auto-start (bin/browser-service install)" else - echo "[outreach-upgrade] bin/browser-service install skipped (no launchd/systemd)" >&2 + echo "[outreach-upgrade] bin/browser-service install failed (see warning above)" >&2 fi fi From 38a0b03645d2686cea0a36845aaef850ba9005c8 Mon Sep 17 00:00:00 2001 From: Ruoqi Huang Date: Sun, 5 Jul 2026 19:17:11 -0700 Subject: [PATCH 06/13] Re-sync persona.json if the existing file is corrupt persona_already_configured only compared against the example template, so a truncated/invalid file left by an interrupted prior install would be kept forever on a non-interactive reinstall instead of re-synced. --- install.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/install.sh b/install.sh index 5dcf4a9..e0249d1 100755 --- a/install.sh +++ b/install.sh @@ -633,6 +633,12 @@ persona_already_configured() { local example="${REPO_ROOT}/outreach/config/persona.json.example" [[ -f "${persona}" ]] || return 1 [[ -f "${example}" ]] && cmp -s "${persona}" "${example}" && return 1 + # ponytail: treat unparseable JSON (e.g. truncated by an interrupted prior + # install) as unconfigured so it gets re-synced instead of kept forever. + # Skips the check if python3 isn't on PATH yet. + if command -v python3 >/dev/null 2>&1; then + python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "${persona}" >/dev/null 2>&1 || return 1 + fi return 0 } From 499f31281200eed806fc8f3bb2e84f75b6832b61 Mon Sep 17 00:00:00 2001 From: Ruoqi Huang Date: Sun, 5 Jul 2026 19:25:58 -0700 Subject: [PATCH 07/13] Let fresh installs prefer Playwright's Chromium too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit launch_chrome_cdp force-exported CHROME_BIN to system Chrome before calling bin/browser-service install, so resolve_chrome's new Playwright-Chromium preference (added for this same CDP bug) never took effect on a first install — only on bin/outreach-upgrade, which already dropped this forcing. Fresh installs now get the same preference upgrades do. --- install.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index e0249d1..72c2676 100755 --- a/install.sh +++ b/install.sh @@ -537,7 +537,10 @@ launch_chrome_cdp() { export OUTREACH_REPO_ROOT="${REPO_ROOT}" export CDP_PORT CHROME_PROFILE - export CHROME_BIN="${chrome}" + # Don't export CHROME_BIN here — resolve_chrome (bin/browser-service) picks + # Playwright's bundled Chromium first, falling back to this same system + # Chrome if that's unavailable. Forcing it here would skip that preference + # on every fresh install, not just reintroduce it on upgrades. info "Running bin/browser-service install…" if ! "${svc}" install; then From c8ab0ada20daef1b9cb227c4109169b38a24fda1 Mon Sep 17 00:00:00 2001 From: Ruoqi Huang Date: Sun, 5 Jul 2026 19:34:05 -0700 Subject: [PATCH 08/13] Cache the resolved Playwright Chromium path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_chrome runs on every install/daemon/start invocation, including launchd KeepAlive respawns, and resolve_playwright_chromium was paying a full `uv run` + Playwright-import subprocess each time with no caching — turning a Chrome crash-loop into a subprocess crash-loop too. Cache the resolved path in ~/.ebase and revalidate with -x so a Playwright upgrade (new versioned install dir) invalidates it naturally. --- bin/browser-service | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/bin/browser-service b/bin/browser-service index 01d55b5..f31555d 100755 --- a/bin/browser-service +++ b/bin/browser-service @@ -48,17 +48,39 @@ resolve_repo() { fi } +PLAYWRIGHT_CHROMIUM_CACHE="${HOME}/.ebase/playwright-chromium-path" + resolve_playwright_chromium() { # Playwright's bundled Chromium ("Chrome for Testing") supports the CDP # multi-context calls (Browser.setDownloadBehavior, Target.createBrowserContext) # that a real Google Chrome / system Chromium install rejects — using it here # avoids that class of CDP error entirely instead of working around it. + # + # Cached because this is called on every resolve_chrome (install/daemon/ + # start, including launchd KeepAlive respawns) and `uv run` here pays a + # full interpreter + Playwright import per call. Cache is invalidated by + # simply checking the cached path is still executable (a Playwright + # upgrade moves it to a new versioned directory). + if [[ -f "${PLAYWRIGHT_CHROMIUM_CACHE}" ]]; then + local cached + cached="$(<"${PLAYWRIGHT_CHROMIUM_CACHE}")" + if [[ -n "${cached}" ]] && [[ -x "${cached}" ]]; then + printf '%s' "${cached}" + return 0 + fi + fi command -v uv >/dev/null 2>&1 || return 1 - ( cd "${REPO_ROOT}" && uv run --project "${REPO_ROOT}" python -c ' + local resolved + resolved="$( ( cd "${REPO_ROOT}" && uv run --project "${REPO_ROOT}" python -c ' from playwright.sync_api import sync_playwright with sync_playwright() as p: print(p.chromium.executable_path) -' 2>/dev/null ) | tail -1 +' 2>/dev/null ) | tail -1 )" + if [[ -n "${resolved}" ]] && [[ -x "${resolved}" ]]; then + mkdir -p "$(dirname "${PLAYWRIGHT_CHROMIUM_CACHE}")" + printf '%s' "${resolved}" > "${PLAYWRIGHT_CHROMIUM_CACHE}" + fi + printf '%s' "${resolved}" } resolve_chrome() { From 5f1f991333d2f3b8a605f74e69695694f36e6471 Mon Sep 17 00:00:00 2001 From: Ruoqi Huang Date: Sun, 5 Jul 2026 19:34:55 -0700 Subject: [PATCH 09/13] Only retry _open_blank_tab's PUT with GET on a real HTTP error Catching the broad URLError meant a slow-but-successful PUT (timeout reading the response) also triggered the GET fallback, opening a second blank tab. Narrow the fallback to HTTPError (an actual "this doesn't work" response, e.g. 404/405 on older Chrome) so a timeout or connection failure propagates instead of risking a duplicate tab. --- outreach/browser.py | 9 +++++++-- testing/tests/test_browser_attach.py | 27 ++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/outreach/browser.py b/outreach/browser.py index 3037f0d..ef51c9f 100644 --- a/outreach/browser.py +++ b/outreach/browser.py @@ -537,8 +537,13 @@ def _open_blank_tab(cdp_url: str) -> None: req = urllib.request.Request(f"{cdp_url}/json/new", method="PUT") try: urllib.request.urlopen(req, timeout=5).close() - except urllib.error.URLError: - # Older Chrome builds only accept GET on this endpoint. + except urllib.error.HTTPError: + # Older Chrome builds reject PUT on this endpoint (e.g. 404/405) but + # accept GET. A plain URLError (timeout, connection refused) means + # the request may already have gone through server-side, or Chrome + # isn't reachable at all — retrying here would risk opening a + # second tab, so only HTTPError (a real "this doesn't work" reply) + # triggers the GET fallback. urllib.request.urlopen(f"{cdp_url}/json/new", timeout=5).close() diff --git a/testing/tests/test_browser_attach.py b/testing/tests/test_browser_attach.py index d4147f6..e7e8a77 100644 --- a/testing/tests/test_browser_attach.py +++ b/testing/tests/test_browser_attach.py @@ -2,11 +2,12 @@ from __future__ import annotations +import urllib.error from unittest.mock import AsyncMock, MagicMock, patch import pytest -from outreach.browser import LinkedInBrowser +from outreach.browser import LinkedInBrowser, _open_blank_tab def _browser_with_pw(pw_chromium: MagicMock) -> LinkedInBrowser: @@ -54,3 +55,27 @@ async def test_attach_opens_tab_via_cdp_when_no_contexts() -> None: assert li._ctx is ctx # The buggy path this replaces — new_context() is unsupported on real Chrome. empty_browser.new_context.assert_not_called() + + +def test_open_blank_tab_retries_with_get_on_http_error() -> None: + put_response = MagicMock() + with patch( + "urllib.request.urlopen", + side_effect=[urllib.error.HTTPError("url", 405, "Method Not Allowed", {}, None), put_response], + ) as urlopen: + _open_blank_tab("http://localhost:9222") + + assert urlopen.call_count == 2 + + +def test_open_blank_tab_does_not_retry_on_timeout() -> None: + with patch( + "urllib.request.urlopen", side_effect=urllib.error.URLError("timed out") + ) as urlopen: + with pytest.raises(urllib.error.URLError): + _open_blank_tab("http://localhost:9222") + + # A bare URLError (timeout, connection refused) might mean the PUT + # already succeeded server-side — retrying with GET risks opening a + # second tab, so it must not be attempted. + urlopen.assert_called_once() From 27d443075f59cf6abf58c9767013dfef34554ec5 Mon Sep 17 00:00:00 2001 From: Ruoqi Huang Date: Sun, 5 Jul 2026 19:35:09 -0700 Subject: [PATCH 10/13] Cross-reference install.sh's duplicate CDP-tab-open workaround _open_blank_tab and install.sh's open_linkedin_tab_in_cdp both PUT-then- GET-fallback to Chrome's /json/new endpoint. Can't share the implementation across bash and Python (install.sh runs before there's a project env to call into), so just flag the duplication for future maintainers touching either one. --- outreach/browser.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/outreach/browser.py b/outreach/browser.py index ef51c9f..c9d47d4 100644 --- a/outreach/browser.py +++ b/outreach/browser.py @@ -533,7 +533,12 @@ def _pp_structure_activity_update(index: int, post: dict[str, Any]) -> dict[str, def _open_blank_tab(cdp_url: str) -> None: - """Ask Chrome's CDP HTTP endpoint to open a tab (bypasses Playwright context creation).""" + """Ask Chrome's CDP HTTP endpoint to open a tab (bypasses Playwright context creation). + + install.sh's open_linkedin_tab_in_cdp() does the same PUT-then-GET + workaround in bash for the pre-attach case (no Python env to call into + yet) — keep both in sync if Chrome's CDP behavior here changes. + """ req = urllib.request.Request(f"{cdp_url}/json/new", method="PUT") try: urllib.request.urlopen(req, timeout=5).close() From 75f1da6709e462163a600a77bb48df93e817e88e Mon Sep 17 00:00:00 2001 From: Ruoqi Huang Date: Sun, 5 Jul 2026 19:36:09 -0700 Subject: [PATCH 11/13] Narrow _attach's except Error to the actual zero-tab CDP failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catching the base playwright.Error swallowed every connect_over_cdp failure — wrong port, Chrome not running, auth issues — and silently padded each with a doomed retry (open a tab, reconnect, fail again) before the real error surfaced. Match on the known zero-tab error text instead and re-raise anything else immediately. Also closes the test-coverage gap this left: no test exercised the except branch itself (connect_over_cdp raising), only the separate empty-contexts branch. --- outreach/browser.py | 19 ++++++++++---- testing/tests/test_browser_attach.py | 38 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/outreach/browser.py b/outreach/browser.py index c9d47d4..97d1dc0 100644 --- a/outreach/browser.py +++ b/outreach/browser.py @@ -531,6 +531,11 @@ def _pp_structure_activity_update(index: int, post: dict[str, Any]) -> dict[str, # ── Browser wrapper ─────────────────────────────────────────────────────────── +# ponytail: matched by substring against the real Chrome zero-open-tabs CDP +# error; if Chrome's wording changes, this stops matching and _attach falls +# back to re-raising instead of retrying — update the substring then. +_ZERO_TAB_CDP_ERROR = "setDownloadBehavior" + def _open_blank_tab(cdp_url: str) -> None: """Ask Chrome's CDP HTTP endpoint to open a tab (bypasses Playwright context creation). @@ -663,11 +668,15 @@ async def _attach(self) -> None: """ try: self._browser = await self._pw.chromium.connect_over_cdp(self.cdp_url) - except Error: - # With zero open tabs, connect_over_cdp itself fails (it has no - # default context to attach to) instead of returning a browser - # with an empty contexts list. Ask Chrome's own CDP HTTP endpoint - # to open a tab first, then connect. + except Error as e: + # With zero open tabs, connect_over_cdp itself fails with this + # specific CDP error (it has no default context to attach to) + # instead of returning a browser with an empty contexts list. + # Anything else (wrong port, Chrome not running, auth failure) + # is a genuine connection failure — re-raise so it surfaces + # immediately instead of being delayed by a doomed retry. + if _ZERO_TAB_CDP_ERROR not in str(e): + raise await asyncio.to_thread(_open_blank_tab, self.cdp_url) self._browser = await self._pw.chromium.connect_over_cdp(self.cdp_url) self._is_attached = True diff --git a/testing/tests/test_browser_attach.py b/testing/tests/test_browser_attach.py index e7e8a77..0880163 100644 --- a/testing/tests/test_browser_attach.py +++ b/testing/tests/test_browser_attach.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from playwright.async_api import Error from outreach.browser import LinkedInBrowser, _open_blank_tab @@ -57,6 +58,43 @@ async def test_attach_opens_tab_via_cdp_when_no_contexts() -> None: empty_browser.new_context.assert_not_called() +@pytest.mark.asyncio +async def test_attach_recovers_when_connect_over_cdp_raises_zero_tab_error() -> None: + """The actual root cause per CHANGELOG: connect_over_cdp itself throws + with zero open tabs, rather than returning an empty contexts list.""" + ctx = MagicMock(pages=[]) + ctx.new_page = AsyncMock(return_value=MagicMock()) + populated_browser = MagicMock(contexts=[ctx]) + pw_chromium = MagicMock() + pw_chromium.connect_over_cdp = AsyncMock( + side_effect=[ + Error("Browser.setDownloadBehavior: Browser context management is not supported"), + populated_browser, + ] + ) + + li = _browser_with_pw(pw_chromium) + with patch("outreach.browser._open_blank_tab") as open_tab: + await li._attach() + + open_tab.assert_called_once_with("http://localhost:9222") + assert pw_chromium.connect_over_cdp.await_count == 2 + assert li._ctx is ctx + + +@pytest.mark.asyncio +async def test_attach_reraises_unrelated_connect_errors() -> None: + pw_chromium = MagicMock() + pw_chromium.connect_over_cdp = AsyncMock(side_effect=Error("net::ERR_CONNECTION_REFUSED")) + + li = _browser_with_pw(pw_chromium) + with patch("outreach.browser._open_blank_tab") as open_tab: + with pytest.raises(Error, match="ERR_CONNECTION_REFUSED"): + await li._attach() + + open_tab.assert_not_called() + + def test_open_blank_tab_retries_with_get_on_http_error() -> None: put_response = MagicMock() with patch( From 2241937202a227afa1df7211080158593216970f Mon Sep 17 00:00:00 2001 From: Ruoqi Huang Date: Sun, 5 Jul 2026 19:36:37 -0700 Subject: [PATCH 12/13] Raise a clear error instead of crashing on contexts[0] If opening a blank tab via CDP and reconnecting still left contexts empty (a plausible race between the HTTP tab-open and Playwright's context enumeration), _attach indexed into an empty list and crashed with a bare IndexError. Raise a RuntimeError that names the actual failure instead. --- outreach/browser.py | 10 ++++++++++ testing/tests/test_browser_attach.py | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/outreach/browser.py b/outreach/browser.py index 97d1dc0..8bfc7cf 100644 --- a/outreach/browser.py +++ b/outreach/browser.py @@ -690,6 +690,16 @@ async def _attach(self) -> None: await asyncio.to_thread(_open_blank_tab, self.cdp_url) self._browser = await self._pw.chromium.connect_over_cdp(self.cdp_url) + if not self._browser.contexts: + # Opening a tab via the CDP HTTP endpoint and reconnecting still + # didn't produce a context (e.g. the new tab hadn't registered + # yet, or something closed it in between) — fail clearly instead + # of crashing on contexts[0] below. + raise RuntimeError( + f"Chrome at {self.cdp_url} reported no browser context even " + "after opening a tab via its CDP HTTP endpoint." + ) + # Inherit the real user session (cookies, localStorage, etc.). self._ctx = self._browser.contexts[0] diff --git a/testing/tests/test_browser_attach.py b/testing/tests/test_browser_attach.py index 0880163..8b8a8ed 100644 --- a/testing/tests/test_browser_attach.py +++ b/testing/tests/test_browser_attach.py @@ -82,6 +82,20 @@ async def test_attach_recovers_when_connect_over_cdp_raises_zero_tab_error() -> assert li._ctx is ctx +@pytest.mark.asyncio +async def test_attach_raises_clear_error_when_still_no_contexts() -> None: + empty_browser = MagicMock(contexts=[]) + pw_chromium = MagicMock() + pw_chromium.connect_over_cdp = AsyncMock(return_value=empty_browser) + + li = _browser_with_pw(pw_chromium) + with patch("outreach.browser._open_blank_tab") as open_tab: + with pytest.raises(RuntimeError, match="no browser context"): + await li._attach() + + open_tab.assert_called_once_with("http://localhost:9222") + + @pytest.mark.asyncio async def test_attach_reraises_unrelated_connect_errors() -> None: pw_chromium = MagicMock() From 7c12eb01efd5b2b3ea2d47948acfa73610d39bcd Mon Sep 17 00:00:00 2001 From: Ruoqi Huang Date: Sun, 5 Jul 2026 19:37:41 -0700 Subject: [PATCH 13/13] Collapse _attach's duplicated retry logic into one loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The except-Error branch and the empty-contexts branch each did the same "open a blank tab via CDP, then reconnect" recovery, just on different triggers — two copies to keep in sync. Extract _connect_with_zero_tab_retry() so both paths share one retry loop with one attempt limit and one error message. --- outreach/browser.py | 61 +++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/outreach/browser.py b/outreach/browser.py index 8bfc7cf..fab4c27 100644 --- a/outreach/browser.py +++ b/outreach/browser.py @@ -652,6 +652,34 @@ async def _launch(self) -> None: # Inject stealth overrides on every new page / frame. await self._ctx.add_init_script(_STEALTH_SCRIPT) + async def _connect_with_zero_tab_retry(self) -> Browser: + """ + Connect over CDP, working around Chrome's zero-open-tabs quirks. + + With zero open tabs, real Chrome's connect_over_cdp either throws + _ZERO_TAB_CDP_ERROR outright (it has no default context to attach + to), or — on some builds — succeeds but reports an empty contexts + list. Either way, asking Chrome's own CDP HTTP endpoint to open a + blank tab and reconnecting fixes it, so both cases retry the same + way. + """ + for attempt in (1, 2): + try: + browser = await self._pw.chromium.connect_over_cdp(self.cdp_url) + except Error as e: + if attempt == 2 or _ZERO_TAB_CDP_ERROR not in str(e): + raise + browser = None + if browser is not None and browser.contexts: + return browser + if attempt == 2: + raise RuntimeError( + f"Chrome at {self.cdp_url} reported no browser context " + "even after opening a tab via its CDP HTTP endpoint." + ) + await asyncio.to_thread(_open_blank_tab, self.cdp_url) + raise AssertionError("unreachable") # loop always returns or raises + async def _attach(self) -> None: """ Attach to an already-running Chrome via CDP and pick the best tab to use. @@ -666,40 +694,9 @@ async def _attach(self) -> None: We never close a tab we didn't open, so the user's browsing is undisturbed. """ - try: - self._browser = await self._pw.chromium.connect_over_cdp(self.cdp_url) - except Error as e: - # With zero open tabs, connect_over_cdp itself fails with this - # specific CDP error (it has no default context to attach to) - # instead of returning a browser with an empty contexts list. - # Anything else (wrong port, Chrome not running, auth failure) - # is a genuine connection failure — re-raise so it surfaces - # immediately instead of being delayed by a doomed retry. - if _ZERO_TAB_CDP_ERROR not in str(e): - raise - await asyncio.to_thread(_open_blank_tab, self.cdp_url) - self._browser = await self._pw.chromium.connect_over_cdp(self.cdp_url) + self._browser = await self._connect_with_zero_tab_retry() self._is_attached = True - if not self._browser.contexts: - # Real Chrome (unlike Playwright's bundled Chromium) rejects - # browser.new_context() over CDP — it has no open tabs to report - # a context for (e.g. the user closed every window). Ask Chrome's - # own CDP HTTP endpoint to open a tab instead, then reconnect so - # Playwright picks up the resulting default context. - await asyncio.to_thread(_open_blank_tab, self.cdp_url) - self._browser = await self._pw.chromium.connect_over_cdp(self.cdp_url) - - if not self._browser.contexts: - # Opening a tab via the CDP HTTP endpoint and reconnecting still - # didn't produce a context (e.g. the new tab hadn't registered - # yet, or something closed it in between) — fail clearly instead - # of crashing on contexts[0] below. - raise RuntimeError( - f"Chrome at {self.cdp_url} reported no browser context even " - "after opening a tab via its CDP HTTP endpoint." - ) - # Inherit the real user session (cookies, localStorage, etc.). self._ctx = self._browser.contexts[0]