Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.0.6
1.0.0.7
41 changes: 41 additions & 0 deletions bin/browser-service
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolve_playwright_chromium spawns a full uv run + Python + Playwright-import subprocess with no caching.

This runs on every resolve_chrome call, i.e. every install/daemon/start invocation — including launchd/systemd KeepAlive respawns. A Chrome crash-loop now also crash-loops this subprocess each cycle. Since the resolved path only changes when Playwright's browser is (re)installed, worth caching it (e.g. a file next to REPO_ROOT invalidated by playwright version, or just resolving once at install time and relying on the CHROME_BIN already baked into the plist/unit).

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}"
Expand Down
24 changes: 7 additions & 17 deletions bin/outreach-upgrade
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed guard for "no Chrome binary found at all" changes the failure message, not just the Chrome-choice logic.

The old code only called browser-service install when a chrome_bin was actually found; now it's called unconditionally. On a machine with no system Chrome and no Playwright Chromium installed yet, resolve_chrome fails inside browser-service, this if takes the else branch, and prints "skipped (no launchd/systemd)" (line 169) — which is the wrong diagnostic; the real cause is "no Chrome binary found," unrelated to launchd/systemd, and will misdirect anyone troubleshooting from this log line.

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

Expand Down
40 changes: 39 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No corruption/validity check before keeping an existing persona.json.

persona_already_configured only compares against persona.json.example via cmp. If a previous install was interrupted (Ctrl-C, disk full) leaving a truncated/invalid JSON file, this still returns "configured" (it differs from the example), and a non-interactive reinstall now permanently keeps the corrupt file (return 0 at line 666) instead of the old behavior of always re-syncing. Might be worth a quick validity check (e.g. python -c "import json; json.load(open(...))") before treating it as configured.

# 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}'"
Expand All @@ -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}"
Expand Down
64 changes: 58 additions & 6 deletions outreach/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -79,6 +81,7 @@
from playwright.async_api import (
Browser,
BrowserContext,
Error,
Locator,
Page,
Playwright,
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reimplements a workaround that already exists in install.sh.

install.sh's open_linkedin_tab_in_cdp (install.sh:498-503) already does the identical PUT-then-GET-fallback to {cdp}/json/new to work around the same Chrome CDP quirk. Now there are two independent implementations (bash + Python) of the same workaround to keep in sync if Chrome's CDP behavior changes again.

"""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:
"""
Expand Down Expand Up @@ -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.
Expand All @@ -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]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible IndexError if contexts is still empty after the retry.

self._browser.contexts[0] has no guard. Trace: if connect_over_cdp throws (line 656), we open a blank tab and reconnect (661-662); we then fall into if not self._browser.contexts (665) unconditionally and, if still empty, open another blank tab and reconnect again (671-672) — but there's no check after that. If the CDP HTTP /json/new call races with Playwright's context enumeration (tab created but not yet visible to the fresh CDP connection, or something closes it in between), contexts can still be [] here and this raises IndexError, whereas the old ... else await self._browser.new_context() fallback would have degraded gracefully instead of crashing.


page = self._pick_tab(self._ctx.pages)
if page is not None:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading
Loading