all repos — sz-chrome-remote-cdp @ master

pi-browser-harness, patched to drive a Chrome on another host over CDP, with castle_cdp_search web search

FORK.md (view raw)

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
# Fork notes

**sz-chrome-remote-cdp**: driving a Chrome on another host over its DevTools
port. A fork of [amankumarsingh77/pi-browser-harness](https://github.com/amankumarsingh77/pi-browser-harness),
carrying one patch, `BU_CDP_HTTP`, which lets pi drive a browser on **another
host**, plus `castle-search/`, a web-search tool over the same route (below).
Until 2026-10 this repository was named pi-browser-harness.

Hosted on the fleet git host rather than a public forge — deliberately not
published to npm and not proposed upstream.

```
origin    git@git.soarez.net:sz/sz-chrome-remote-cdp.git   (canonical)
upstream  https://github.com/amankumarsingh77/pi-browser-harness.git
```

The package keeps upstream's name, `pi-browser-harness`, in `package.json`:
renaming it would conflict on every rebase, and pi identifies a git package by
its URL, not its name.

## Why the patch exists

Upstream can only reach a browser on localhost:

- the pi extension always goes through the daemon (`src/index.ts` uses
  `createDaemonTransport`)
- the daemon resolves Chrome with `discoverWsUrl()` (`src/daemon/bridge.ts`)
- that probe is hardcoded to `127.0.0.1` (`src/cdp/discovery.ts`)
- `BU_CDP_WS`, upstream's remote escape hatch, pins a full WebSocket URL

Before upstream 0.11.0, `BU_CDP_WS` was read only by the in-process client and
so did nothing for the extension. 0.11.0 (`6d30009`) moved it into
`discoverEndpoint()`, so the daemon now honours it too. It still does not cover
this fork's use: the URL embeds the browser UUID, which Chrome re-mints on every
launch, and it is read once when the daemon starts. There is also no default,
no fallback and no way to switch from inside pi.

Without the patch the options are pinning `BU_CDP_WS` and re-setting it after
every remote restart, or relaying the remote browser onto localhost (ssh tunnel
or socat), which means a local service on every client machine.

`BU_CDP_HTTP="host:port"` is resolved inside `discoverEndpoint()`, so daemon and
client both honour it. It names an **HTTP** endpoint, not a WebSocket URL,
because it is re-resolved on every discovery call — the browser UUID is re-read
after the remote browser restarts. Chrome re-mints that UUID on every launch, so
a pinned WebSocket URL breaks as soon as the remote browser restarts (castle's
watchdog restarts Chrome whenever CDP stops answering, plus once a day; see
`cloud-setup/macos/chrome-cdp.sh`).

## Install

```
pi install git:git@git.soarez.net:sz/sz-chrome-remote-cdp.git
```

Clones to `~/.pi/agent/git/git.soarez.net/sz/sz-chrome-remote-cdp`. The same
identifier works on macOS and Linux, which a local-path install could not do —
pi records those by resolved absolute path, and `settings.json` is shared across
hosts via the dotfiles symlink.

Nothing else to configure: castle is the built-in default
(`DEFAULT_REMOTE_CDP` in `src/cdp/endpoint.ts`).

## castle-search

`castle-search/` is `castle_cdp_search`, web search run in the same remote
Chrome: it opens a background tab on an engine's results page, reads it and
closes it. It was its own repository, pi-castle-cdp-search, until 2026-10; its
commits were replayed here with `git am --directory=castle-search`, linear, so
they rebase like any fork commit. Its README has the details.

It touches no upstream file except the root `package.json`, where it is listed
in `pi.extensions` and added to `check`. It keeps its own `package.json`,
`tsconfig.json` and tests, and its own copy of the endpoint logic rather than
importing the harness's `src/cdp/endpoint.ts`: that would tie it to upstream's
layout, which rebases move. Both read `~/.pi/agent/browser-target.json`.

The root `.gitignore` ignores `docs/` at any depth, so a new file under
`castle-search/docs/` needs `git add -f`.

## Choosing a browser

From inside a pi session:

```
/browser-target                 show the current target
/browser-target local           this machine's own browser
/browser-target default         back to castle
/browser-target host:port       somewhere else
```

The choice persists in `~/.pi/agent/browser-target.json` and applies to every
session on the machine, because the browser daemon is machine-wide. Switching
replaces that daemon process — it resolves its target once at startup and holds
the WebSocket to the old browser, so nothing short of a restart actually moves
you.

Or set it per-process:

| `BU_CDP_HTTP` | behaviour |
|---|---|
| unset | the stored choice, else castle, falling back to this machine's browser if unreachable |
| `host:port` | that endpoint; **unreachable is a hard error** |
| `local` (also `localhost`, `off`, `none`, `0`, `no`) | upstream behaviour — this machine's own browser |

Precedence is `BU_CDP_HTTP` > `/browser-target` > built-in default, so a one-off
`BU_CDP_HTTP=… pi` is never silently overridden by a choice made days ago. When
the variable is set, `/browser-target` says so instead of pretending to obey.

Upstream's `BU_CDP_WS` sits above all of these: `discoverEndpoint()` returns it
before consulting the fork's target, so an explicit WebSocket URL still wins.
`resolveCdpTarget()` mirrors that, so `/browser-status` and the system prompt
name the pinned endpoint and `/browser-target` warns that it takes precedence,
as it does for `BU_CDP_HTTP`. They show only its host:port: the path carries the
browser id, and a proxied URL may carry a token. `/browser-status` does not
probe a pinned URL, because `/json/version` answering says nothing about whether
the pinned browser id is still current. A pinned URL counts as a remote target,
like any `BU_CDP_HTTP` value, so profile selection is refused while it is set,
even for a loopback URL.

The asymmetry is deliberate. An explicit endpoint is an instruction, so failing
to reach it must not quietly automate a different browser — that would drive
your own signed-in Chrome instead of the shared one. The default is only a
preference, so a laptop away from the wire network still gets the local browser.
The default is given 1.5s to answer, explicit targets 5s: upstream 0.11 starts
the bridge's 10s connect timer before discovery, and off wg8 the castle address
times out rather than being refused, so a 5s probe left a local Chrome only
about 5s for its "Allow remote debugging?" prompt.

`/browser-status` names the resolved target and whether it is reachable. Note
the connection is **lazy**: nothing dials the browser until the first `browser_*`
tool runs, so a fresh session reports "not connected yet" rather than an error.

`pi update` reconciles the clone to the configured ref rather than pulling stock
from npm, so the patch cannot be silently reverted — which is exactly what the
previous approach (patching `node_modules` in place) suffered from.

## Launching a local Chrome

With an explicit `local` target (`/browser-target local` or `BU_CDP_HTTP=local`) and no browser to
attach to, discovery starts one (`src/cdp/local-launch.ts`) and waits for `127.0.0.1:9222` to answer.
It never starts a browser for the castle default, and never when something already answers.

It always uses a dedicated profile, `~/.chrome-cdp-profile`, never the user's own: Chrome ignores
`--remote-debugging-port` on its default user-data-dir, and opening the signed-in profile unasked
would be a surprise. macOS only; elsewhere discovery fails as before.

| Variable | Effect |
|---|---|
| `BU_LOCAL_CHROME_LAUNCH=off` (also `0`, `no`, `false`, `none`) | never launch |
| `BU_LOCAL_CHROME_PROFILE` | profile directory, default `~/.chrome-cdp-profile` |
| `BU_LOCAL_CHROME_EXE` | Chrome binary, default the one in `/Applications` |

There is no watchdog: if the launched Chrome dies, the next discovery starts another.

## Taking upstream releases

The patch is a short series of fork commits on top of an upstream commit, so
rebasing keeps it that way:

```
git fetch upstream
git rebase upstream/main          # or: upstream/main --onto a specific tag
git push --force-with-lease origin master
```

Conflicts to expect are mostly in `src/cdp/discovery.ts` (the
`discoverEndpoint()` short-circuit, placed after upstream's `BU_CDP_WS` check,
and the `host` parameter on `queryLiveWsUrl`) and `src/setup.ts` (the skipped
local-process precondition). Upstream refactors also touch the fork's hunks in
`src/index.ts` (imports, `/browser-status`, `before_agent_start`),
`src/prompt.ts`, `src/daemon/spawn.ts` (`stopDaemon`), `src/profile/*` and
`CHANGELOG.md`, and `package.json` (castle-search's entries in `pi.extensions`
and `check`). Commits under `castle-search/` never conflict.

Verify with `npm run check` (typecheck, the boundary scanner and the tests,
then castle-search's typecheck and tests; `npm --prefix castle-search install`
first).
The boundary scanner rejects unguarded casts, so parse JSON with
`src/util/guards.ts`. `test/daemon/entry-imports-test.ts` walks the daemon's
import graph, which includes `endpoint.ts` and `target-store.ts`, and fails if
it reaches a non-runtime dependency such as `typebox`. The fork's own tests
(`test/cdp/endpoint-test.ts`, `test/cdp/target-store-test.ts`) use a
hand-rolled `check()` rather than `node:test`; the test glob still runs them and
a failure exits non-zero.

The setup precondition skips `isBrowserRunning()` whenever the resolved target
is remote — by env var, `/browser-target` choice, or the built-in default — not
merely when `BU_CDP_HTTP` is set. A machine with no local browser (any host
relying on the castle default) would otherwise fail `/browser-setup` with "No
browser instance running" before discovery ever dialed the remote.
If upstream ever reworks daemon discovery, re-check whether the patch is still
needed at all.

Note the branch here is `master` (matching the other repos on castle) while
upstream uses `main`.

## Browser profiles are local-only

Upstream's `/browser-profile` does not work against a remote target, and this
fork now says so instead of guessing.

Choosing a profile is not a CDP operation. `Target.createTarget` rejects another
profile's `browserContextId`, and a bare createTarget lands in the focus-derived
default context, so the only way in is the browser's own command line —
`--profile-directory`, delegated to the running browser by Chromium's
ProcessSingleton (`src/profile/launch.ts`). That is a local-process mechanism
with no remote equivalent.

`discoverEndpoint()` already returns no `userDataDir` for a remote target, but
`resolveUserDataDir()` used to fall through to `userDataDirCandidates()` — the
Chromium user-data-dirs on *this* disk. On castle that meant the picker offering
Brave's profiles while the harness was connected to castle's Chrome on :9223,
and `seedProfileWindow()` could not catch it: its mismatch guard is
`connectedDir && connectedDir !== pin.userDataDir`, and `connectedDir` is
undefined on the remote path. Pinning launched a local Brave window and then
polled the remote browser for a sentinel that could never appear there, failing
only after the 15s seed deadline.

So, for a remote target:

- **setup never prompts.** `promptForProfileIfUnset()` returns early. A prompt
  with no actionable answer is bad enough; because cancelling stores nothing, it
  also came back every single session.
- **`/browser-profile` refuses**, naming the target and pointing at
  `/browser-target local`.
- **`seedProfileWindow()` refuses**, which catches a pin left over from a local
  session rather than letting it launch a browser on the wrong machine.

The default against a remote browser is therefore unpinned — whichever window is
focused. castle's `~/.chrome-cdp-profile` has exactly one profile (`Default`),
so there is nothing to disambiguate.

Making profiles selectable on castle needs the `--profile-directory` launch to
happen *on castle*, next to the browser — not a patch to this path, a separate
piece of machinery. Unbuilt so far.

## Verifying after a merge

With a browser exposed at `$BU_CDP_HTTP`:

```
pi -p --tools browser_setup,browser_navigate,browser_page_info \
  "Call browser_setup, then browser_navigate to https://example.com/ and browser_page_info."
```

Confirm from outside that the remote browser really moved, rather than trusting
the agent's own report:

```
curl -s http://10.88.0.25:9223/json/list | grep '"url"'
```