diff --git a/CHANGELOG.md b/CHANGELOG.md index ee8168c..06b47d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ 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 +- `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. + ## [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..f31555d 100755 --- a/bin/browser-service +++ b/bin/browser-service @@ -48,10 +48,51 @@ 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 + 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 )" + if [[ -n "${resolved}" ]] && [[ -x "${resolved}" ]]; then + mkdir -p "$(dirname "${PLAYWRIGHT_CHROMIUM_CACHE}")" + printf '%s' "${resolved}" > "${PLAYWRIGHT_CHROMIUM_CACHE}" + fi + printf '%s' "${resolved}" +} + 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/bin/outreach-upgrade b/bin/outreach-upgrade index 564d062..4934fcc 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 failed (see warning above)" >&2 fi fi diff --git a/install.sh b/install.sh index 4dd7fdb..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 @@ -628,6 +631,20 @@ 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 + # 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 +} + 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 +654,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}" diff --git a/outreach/browser.py b/outreach/browser.py index f373d21..fab4c27 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, @@ -528,6 +531,31 @@ 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). + + 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() + 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() + class LinkedInBrowser: """ @@ -624,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. @@ -638,15 +694,11 @@ 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) + self._browser = await self._connect_with_zero_tab_retry() self._is_attached = True # 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/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/testing/tests/test_browser_attach.py b/testing/tests/test_browser_attach.py new file mode 100644 index 0000000..8b8a8ed --- /dev/null +++ b/testing/tests/test_browser_attach.py @@ -0,0 +1,133 @@ +"""Unit tests for LinkedInBrowser._attach's no-open-tabs fallback.""" + +from __future__ import annotations + +import urllib.error +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from playwright.async_api import Error + +from outreach.browser import LinkedInBrowser, _open_blank_tab + + +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() + + +@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_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() + 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( + "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() 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" },