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
|
# Fork notes
**sz-chrome-remote-extension**: driving a signed-in browser through its
companion extension, from other machines. A fork of
[tianrendong/pi-chrome](https://github.com/tianrendong/pi-chrome) that lets pi
on **other machines** drive one signed-in browser. It adds a remote mode to the
pi extension, a standalone broker for the browser's host, and `mcp/`, an MCP
server for the same tools. Without `PI_CHROME_REMOTE` set, the pi extension
behaves exactly like upstream. Until 2026-10 this repository was named
pi-chrome.
Not published to npm and not proposed upstream. The package keeps upstream's
name, `pi-chrome`, in `package.json`: renaming it would conflict on every
rebase.
```
origin https://git.soarez.net/sz/sz-chrome-remote-extension.git
upstream https://github.com/tianrendong/pi-chrome.git
```
## Why the patch exists
Upstream only works on the machine running the browser:
- the companion extension long-polls a bridge at `127.0.0.1:17318`, hardcoded
in `browser-extension/service_worker.js`;
- that bridge lives inside whichever pi session started first on the machine,
and goes away when that session exits;
- a pi session on another host cannot join it. `PI_CHROME_BRIDGE_HOST` set to a
remote address makes `listen()` fail with `EADDRNOTAVAIL`, and only
`EADDRINUSE` switches a session into client mode, so the session fails at
startup.
Upstream tried a standalone daemon in 0.16–0.17 (`docs/DESIGN-0.17-daemon.md` in
the v0.17.3 tag) and then dropped it; main is back on the 0.15 line. This fork
is based on main.
## What the fork changes
**`broker/broker.mjs`** runs on the browser's host in place of upstream's
in-session bridge. It speaks upstream's HTTP contract to the extension, so the
extension is loaded exactly as shipped. Node builtins only, no install step. It
has two listeners:
| listener | address | routes |
|---|---|---|
| extension | `127.0.0.1:17318` | `/next`, `/result`, `/status`, `/command` |
| peer | `PI_CHROME_BROKER_PEER_HOST`, same port | `/command`, `/status` |
Keeping `/next` and `/result` off the peer listener means nothing on the network
can drain the extension's queue or forge results. `/command` needs a bearer
token on both listeners, because the browser is signed into real accounts. Bind
the peer listener to an address on a network you trust (a VPN, say), never to
`0.0.0.0`.
| Broker setting | Effect |
|---|---|
| `PI_CHROME_BROKER_PORT` | both listeners' port; default `17318`, which the extension expects |
| `PI_CHROME_BROKER_PEER_HOST` | the peer listener's address; unset, there is no peer listener |
| `PI_CHROME_TOKEN` | the bearer token |
| `PI_CHROME_TOKEN_COMMAND` | a command whose output is the token, for a secret store |
**`extensions/chrome-profile-bridge/index.ts`**: every change is marked
`FORK:`.
| Setting | Effect |
|---|---|
| `PI_CHROME_REMOTE` unset, or `local` (also `off`, `none`, `0`, `no`, `localhost`) | upstream behaviour |
| `PI_CHROME_REMOTE=host:port` | the broker there |
| `PI_CHROME_TOKEN`, `PI_CHROME_TOKEN_COMMAND` | the bearer token, as for the broker; read on first use |
| `PI_CHROME_AUTHORIZE=indefinite` (or `30m`, `45`…) | skip `/chrome authorize`: every session starts authorized |
| `PI_CHROME_REMOTE_LABEL` | how the prompt names the browser; default "the user's own browser on *host*" |
| `PI_CHROME_REMOTE_SSH` | the ssh target the prompt gives for file transfer; default the broker's host |
| `PI_CHROME_OTHER_BROWSER_TOOLS` | another browser's tools (e.g. `browser_*`), for the prompt to route between |
In remote mode the session never binds a port and never promotes itself to
bridge. If the broker is unreachable the tools fail. They do not silently drive
some other browser on this machine.
The system prompt gains a `pi-chrome-remote` section beside upstream's
`chrome-profile-bridge` primer, present while `chrome_*` is authorized. It is
added by a `before_agent_start` handler of its own, so upstream's handler stays
as shipped. It says `chrome_*` drives
a signed-in browser on another host, and, when another browser's tools are
named, to keep this one for tasks that need the user's sessions. It also covers
moving files: uploads are staged on the browser's host with scp, downloads land
in its `~/Downloads`, and screenshots are already local. `chrome_upload_file`
refuses relative paths in remote mode, because the browser opens them on its own
host.
Pi sessions on different hosts each get their own automation window. The
extension keys windows by pi session id, which is globally unique. Background
mode, which is upstream's default, keeps them from focusing the browser or
switching its tabs.
## MCP clients
`mcp/` serves these tools to Claude Code, Claude Desktop and other MCP clients;
its README has the setup. It loads the pi extension from this repository,
`../extensions/chrome-profile-bridge/index.ts`, so the server, the extension and
the broker always come from one commit.
It was its own repository, pi-chrome-mcp, from 2026-09-25 (when it moved out of
this fork to stand alone, loading pi-chrome from npm) until 2026-10, when it
came back to keep everything that drives the browser through its extension in
one place. Its commits were replayed here with `git am --directory=mcp`, linear,
so they rebase like any fork commit. It touches no upstream file, so it never
conflicts. It keeps its own `package.json` and lockfile; `mcp/run.sh` installs
them into `mcp/node_modules`.
Run it from a checkout of its own, not from pi's package clone
(`~/.pi/agent/git/...`): pi runs `git clean -fdx` there whenever it updates the
package, which deletes `mcp/node_modules`.
## Setup
On the browser's host:
1. Clone this repository somewhere it will stay. The browser disables an
unpacked extension whose folder goes missing, so not a package manager's
cache that it may replace.
2. Load the extension once: `chrome://extensions` (or `brave://extensions`) ->
Developer mode -> Load unpacked ->
`extensions/chrome-profile-bridge/browser-extension`.
3. Run `node broker/broker.mjs` under a supervisor (launchd, systemd), with
`PI_CHROME_BROKER_PEER_HOST` and a token set. No pi session on that machine
may be running stock pi-chrome, which would take the port first.
On every other machine, install the fork as a pi package and set
`PI_CHROME_REMOTE=<browser host>:17318` and the token.
## Taking upstream releases
```
git fetch upstream
git rebase upstream/main
git push --force-with-lease origin master
```
Then update the broker's checkout, restart it, and reload the extension in the
browser. `/chrome doctor` reports a version mismatch until you do.
Commits under `broker/` and `mcp/` never conflict. Expect conflicts in the
places marked `FORK:` in `index.ts`: the bridge class's
bind, forward and status code (including `connectionStatus`, whose `/status`
fetch carries the token), `session_start`, the remote prompt-section handler
just ahead of upstream's primer, doctor, and `chrome_upload_file`. If upstream changes the extension↔bridge HTTP contract
(`/next`, `/result`, the `x-pi-chrome-version` header), port the change to
`broker/broker.mjs`. Its request handling is a line-by-line port of the server
half of `ChromeProfileBridge`.
## Verifying
`/chrome doctor` in a pi session should report "using the pi-chrome broker at
http://*host*:17318" and a connected extension. From outside pi:
```
curl -s -H "Authorization: Bearer $PI_CHROME_TOKEN" http://<host>:17318/status
```
`connected: true` with a recent `lastSeenAt` means the extension is polling.
|