diff --git a/UPSTREAM.md b/UPSTREAM.md index 940ade3cb..4049407be 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -88,6 +88,7 @@ Each upstream has its own append-only table. Add a row every time you pull. | 2026-04-28 | `04f7716` | `2125cea` | bcode | 1 upstream commit (PR #243). `src/browser_harness/_ipc.py`: `_TMP.mkdir(parents=True, exist_ok=True)` at module load so a caller-supplied `BH_TMP_DIR` pointing at a non-existent directory no longer fails the first sock/port/pid/log/screenshot write. Prerequisite for browsercode's per-session scratch-dir use case. Protected zone — taken verbatim. Divergences touched: none. | | 2026-04-29 | `2125cea` | `997ee45` | bcode | 6 upstream commits (PRs #241, #244, #245). `src/browser_harness/_ipc.py`: when `BH_TMP_DIR` is set, drop the `bu-` filename prefix (caller-isolated dir means no shared-tmpdir disambiguation needed); without `BH_TMP_DIR` the original `bu-` scheme is unchanged. `src/browser_harness/admin.py`: `_daemon_endpoint_names` short-circuits to the local NAME when `BH_TMP_DIR` is set (no glob); plus catch `SystemError` from `os.kill` on Windows during `restart_daemon`. `src/browser_harness/daemon.py`: discover DevToolsActivePort in Comet and Arc profiles on macOS. `tests/unit/test_admin.py`: 2 new tests for the `BH_TMP_DIR` discovery path. All in protected `src/browser_harness/*.py` + tests — taken verbatim. Smoke test + 12 admin unit tests pass. The `_ipc` filename change pairs with our recent per-session BH_TMP_DIR work (browsercode PR #22) — caller isolation now extends to filenames as well as the dir. Divergences touched: none. | | 2026-04-30 | `997ee45` | `660827d` | bcode | 11 upstream commits (PRs #246, #247, #251, #254, #256, #260). `src/browser_harness/daemon.py`: resolve WS via `/json/version` to avoid stale `DevToolsActivePort` path (PR #260) + report `cdp_disconnected` on stale CDP probe in `connection_status` (PR #254) + cleanup remote browser when daemon startup fails (PR #251). `src/browser_harness/admin.py`: companion changes for the daemon fixes. `tests/unit/test_admin.py`: 7 new tests. New domain skills: `agent-workspace/domain-skills/xiaohongshu/scraping.md` (PR #246), and a top-level `domain-skills/shopify-admin/` tree (PR #247: README, embedded-apps, knowledge-base, polaris-inputs). Note: PR #247 added skills at the top-level `domain-skills/` path, not under `agent-workspace/domain-skills/` as the post-#229 layout would suggest — vendored verbatim to match upstream layout. Doc updates: README operator framing (PR #255), install.md heredoc → `-c` flag (PR #256), profile-sync.md same. All files outside divergences — taken verbatim. Smoke test + 19 admin unit tests pass. Divergences touched: none. | +| 2026-05-01 | `660827d` | `013097a` | bcode | 8 upstream commits (PRs #261, #265, #266). `src/browser_harness/daemon.py` (PR #265): split `DevToolsActivePort` into port + ws-path lines and fall back to `ws://127.0.0.1:` when `/json/version` returns 404 (Chrome 147+ disables `/json/*` HTTP discovery on the default user-data-dir). `src/browser_harness/run.py` (PR #266): when no daemon is alive, no local Chrome is listening on 9222/9223 (probed via `/json/version`, not bare TCP), and `BROWSER_USE_API_KEY` is set, auto-bootstrap a cloud daemon. `tests/unit/test_run.py`: 2 new tests for the cloud bootstrap path. PR #261 moved `domain-skills/shopify-admin/` → `agent-workspace/domain-skills/shopify-admin/` upstream — both paths are excluded from the vendored tree per §3, so this rename is a no-op for browsercode (`script/check-harness-diff.sh` filters both via `IGNORED_PATHS_REGEX`). All in protected `src/browser_harness/*.py` + tests — taken verbatim. Smoke test + 23 unit tests pass. Divergences touched: none. | --- diff --git a/packages/bcode-browser/harness/src/browser_harness/daemon.py b/packages/bcode-browser/harness/src/browser_harness/daemon.py index 4042519d7..a8631406d 100644 --- a/packages/bcode-browser/harness/src/browser_harness/daemon.py +++ b/packages/bcode-browser/harness/src/browser_harness/daemon.py @@ -1,5 +1,5 @@ """CDP WS holder + IPC relay (Unix socket on POSIX, TCP loopback on Windows). One daemon per BU_NAME.""" -import asyncio, json, os, socket, sys, time, urllib.request +import asyncio, json, os, socket, sys, time, urllib.error, urllib.request from collections import deque from pathlib import Path @@ -93,9 +93,13 @@ def get_ws_url(): raise RuntimeError(f"BU_CDP_URL={url} unreachable after 30s: {last_err} -- is the dedicated automation Chrome running?") for base in PROFILES: try: - port = (base / "DevToolsActivePort").read_text().strip().split("\n", 1)[0].strip() + active = (base / "DevToolsActivePort").read_text().splitlines() except (FileNotFoundError, NotADirectoryError): continue + port = active[0].strip() if active else "" + ws_path = active[1].strip() if len(active) > 1 else "" + if not port: + continue # Resolve the live WS URL via /json/version instead of trusting the path stored # alongside the port in DevToolsActivePort: if Chrome was previously launched # with a different --user-data-dir on the same port, that file is left behind @@ -104,6 +108,12 @@ def get_ws_url(): while time.time() < deadline: try: return json.loads(urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=1).read())["webSocketDebuggerUrl"] + except urllib.error.HTTPError as e: + # Chrome 147+ disables /json/* HTTP discovery on the default user-data-dir; + # the ws path Chrome wrote to DevToolsActivePort still works. + if e.code == 404 and ws_path: + return f"ws://127.0.0.1:{port}{ws_path}" + time.sleep(1) except (OSError, KeyError, ValueError): time.sleep(1) raise RuntimeError( diff --git a/packages/bcode-browser/harness/src/browser_harness/run.py b/packages/bcode-browser/harness/src/browser_harness/run.py index 6b88ed831..ba7b8309d 100644 --- a/packages/bcode-browser/harness/src/browser_harness/run.py +++ b/packages/bcode-browser/harness/src/browser_harness/run.py @@ -1,4 +1,4 @@ -import os, sys +import os, sys, urllib.request # Windows default stdout encoding is cp1252, which can't encode the 🟢 marker # helpers prepend to tab titles (or anything else outside Latin-1). Force UTF-8 @@ -9,6 +9,8 @@ from .admin import ( _version, + NAME, + daemon_alive, ensure_daemon, list_cloud_profiles, list_local_profiles, @@ -44,6 +46,18 @@ """ +# Probe /json/version (not a bare TCP connect) so a non-Chrome process bound to +# 9222/9223 doesn't masquerade as Chrome and skip the cloud bootstrap. Mirrors +# daemon.py's fallback probe. +def _local_chrome_listening(): + for port in (9222, 9223): + try: + urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=0.3).close() + return True + except OSError: pass + return False + + def main(): args = sys.argv[1:] if args and args[0] in {"-h", "--help"}: @@ -71,6 +85,8 @@ def main(): if len(args) < 2: sys.exit("Usage: browser-harness -c \"print(page_info())\"") print_update_banner() + if not daemon_alive() and not _local_chrome_listening() and os.environ.get("BROWSER_USE_API_KEY"): + start_remote_daemon(NAME) ensure_daemon() exec(args[1], globals()) diff --git a/packages/bcode-browser/harness/tests/unit/test_run.py b/packages/bcode-browser/harness/tests/unit/test_run.py index 7d75a1656..fcf2ed4a7 100644 --- a/packages/bcode-browser/harness/tests/unit/test_run.py +++ b/packages/bcode-browser/harness/tests/unit/test_run.py @@ -15,6 +15,29 @@ def test_c_flag_executes_code(): assert stdout.getvalue().strip() == "hello from -c" +def test_cloud_bootstrap_on_headless_server(monkeypatch): + """No daemon, no local Chrome, API key set -> auto-provision cloud daemon.""" + monkeypatch.setenv("BROWSER_USE_API_KEY", "test-key") + with patch.object(sys, "argv", ["browser-harness", "-c", "x = 1"]), \ + patch("browser_harness.run.daemon_alive", return_value=False), \ + patch("browser_harness.run._local_chrome_listening", return_value=False), \ + patch("browser_harness.run.start_remote_daemon") as mock_start, \ + patch("browser_harness.run.ensure_daemon"), \ + patch("browser_harness.run.print_update_banner"): + run.main() + mock_start.assert_called_once() + + +def test_local_chrome_listening_rejects_non_chrome(): + """A bare TCP listener on 9222/9223 must not fool the probe — only a real + /json/version response counts as Chrome.""" + with patch("browser_harness.run.urllib.request.urlopen", side_effect=OSError): + assert run._local_chrome_listening() is False + with patch("browser_harness.run.urllib.request.urlopen") as mock_open: + assert run._local_chrome_listening() is True + mock_open.assert_called_once() + + def test_c_flag_does_not_read_stdin(): stdin_read = [] fake_stdin = StringIO("should not be read")