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
|
# Fork notes
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**.
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/pi-browser-harness.git (canonical)
upstream https://github.com/amankumarsingh77/pi-browser-harness.git
```
## 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`, the documented remote escape hatch, is read only by the
in-process client (`src/client.ts`) — which that path never touches, so it has
no effect on the extension
Without the patch the only option is 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/pi-browser-harness.git
```
Clones to `~/.pi/agent/git/git.soarez.net/sz/pi-browser-harness`. 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`).
## 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.
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.
`/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.
## Taking upstream releases
The patch is a single commit on top of an upstream tag, 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 confined to `src/cdp/discovery.ts` (the
`discoverEndpoint()` short-circuit and the `host` parameter on
`queryLiveWsUrl`) and `src/setup.ts` (the skipped local-process precondition).
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"'
```
|