aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-14 12:08:17 +0100
committerIgor Soarez <igor@soarez.org>2026-10-14 12:16:02 +0100
commit7e2196bd1f046707d1930f8c3d9aa7e4cc390bd6 (patch)
tree5d9579b18364388b083a75745b718c76383247d8
parentf2e93bba02734461b4e40a1a2684372e6f2437c7 (diff)
feat(remote): drive a companion extension on another host through a broker
PI_CHROME_REMOTE=host:port sends every command to a broker/broker.mjs there, with its bearer token, instead of binding a bridge on this machine. Unset, the extension behaves as upstream. A remote session never binds or takes over a port: when the broker is unreachable the tools fail rather than drive some other browser. A pi-chrome-remote prompt section says which browser chrome_* drives and how files move to and from its host; chrome_upload_file wants absolute paths there. PI_CHROME_AUTHORIZE grants every session up front.
-rw-r--r--extensions/chrome-profile-bridge/index.ts135
1 files changed, 127 insertions, 8 deletions
diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts
index fd12dac..e518833 100644
--- a/extensions/chrome-profile-bridge/index.ts
+++ b/extensions/chrome-profile-bridge/index.ts
@@ -4,7 +4,8 @@ import { Type } from "typebox";
import { existsSync, readFileSync, statSync } from "node:fs";
import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
-import { dirname, join, resolve } from "node:path";
+import { execSync } from "node:child_process";
+import { dirname, isAbsolute, join, resolve } from "node:path";
/**
* Existing-profile Chrome bridge for pi.
@@ -60,13 +61,73 @@ const PI_CHROME_GLOBAL_KEY = "__piChromeProfileBridgeLoaded__";
// reload) so a /reload — which tears down and re-evaluates the module — does not silently drop
// an active /chrome authorize grant.
const PI_CHROME_AUTH_KEY = "__piChromeProfileBridgeAuth__";
-const DEFAULT_HOST = process.env.PI_CHROME_BRIDGE_HOST ?? "127.0.0.1";
-const DEFAULT_PORT = Number(process.env.PI_CHROME_BRIDGE_PORT ?? "17318");
+// FORK: drive a companion extension on another host through its broker (broker/broker.mjs), so
+// every machine on a private network can share one signed-in browser. See FORK.md.
+// PI_CHROME_REMOTE unset or local -> upstream behaviour: this machine's browser, bridge on loopback
+// PI_CHROME_REMOTE=host:port -> the broker there
+// A remote bridge is never bound here and never taken over: when the broker is unreachable the
+// tools fail, rather than quietly serve whatever browser happens to be on this machine.
+const REMOTE_BRIDGE = resolveRemoteBridge(process.env.PI_CHROME_REMOTE);
+const DEFAULT_HOST = REMOTE_BRIDGE?.host ?? process.env.PI_CHROME_BRIDGE_HOST ?? "127.0.0.1";
+const DEFAULT_PORT = REMOTE_BRIDGE?.port ?? Number(process.env.PI_CHROME_BRIDGE_PORT ?? "17318");
const DEFAULT_TIMEOUT_MS = 30_000;
const MAX_TEXT_CHARS = 30_000;
const MAX_ELEMENTS = 80;
const BACKGROUND_PARAM_DESCRIPTION = "If true, avoid explicit Chrome focus/tab activation for this call. /chrome background on (default) enforces this for every call and ignores false. Ask the user to run /chrome background off to allow foreground work.";
+function resolveRemoteBridge(raw: string | undefined): { host: string; port: number } | undefined {
+ const value = raw?.trim();
+ if (!value || ["local", "localhost", "off", "none", "0", "no"].includes(value.toLowerCase())) return undefined;
+ const match = /^([^:\s]+):(\d{1,5})$/.exec(value);
+ if (!match) throw new Error(`PI_CHROME_REMOTE must be host:port or "local"; got "${value}"`);
+ return { host: match[1], port: Number(match[2]) };
+}
+
+// The broker's bearer token: PI_CHROME_TOKEN, or the output of PI_CHROME_TOKEN_COMMAND for a
+// secret store. Read on first use rather than at load, so a session that never touches chrome_*
+// never runs the command, and a broken store surfaces as a tool error instead of a failed pi startup.
+let remoteBridgeToken: string | undefined;
+function remoteBridgeHeaders(): Record<string, string> {
+ if (!REMOTE_BRIDGE) return {};
+ remoteBridgeToken ??= process.env.PI_CHROME_TOKEN?.trim() || undefined;
+ if (!remoteBridgeToken) {
+ const command = process.env.PI_CHROME_TOKEN_COMMAND?.trim();
+ if (!command) throw new Error("A remote pi-chrome broker needs a token: set PI_CHROME_TOKEN, or PI_CHROME_TOKEN_COMMAND to read one from a secret store.");
+ try {
+ remoteBridgeToken = execSync(command, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 }).trim() || undefined;
+ } catch (error) {
+ const reason = String((error as Error).message).split("\n")[0];
+ throw new Error(`Cannot read the pi-chrome broker token: PI_CHROME_TOKEN_COMMAND failed (${reason}).`);
+ }
+ if (!remoteBridgeToken) throw new Error("PI_CHROME_TOKEN_COMMAND produced nothing.");
+ }
+ return { authorization: `Bearer ${remoteBridgeToken}` };
+}
+
+// Added beside upstream's primer, which describes "the user's normal Chrome profile". With a remote
+// bridge that is wrong twice over - another browser, on another machine - and the model needs both
+// facts: the first to pick between browsers, the second to move files. When
+// PI_CHROME_OTHER_BROWSER_TOOLS names another browser, the signed-in one is kept for the tasks
+// that need it.
+function remoteBridgePrimer(): string {
+ if (!REMOTE_BRIDGE) return "";
+ const label = process.env.PI_CHROME_REMOTE_LABEL?.trim() || `the user's own browser on ${REMOTE_BRIDGE.host}`;
+ const ssh = process.env.PI_CHROME_REMOTE_SSH?.trim() || REMOTE_BRIDGE.host;
+ // Another browser's tools go by different names per harness, e.g. browser_* in pi
+ // (pi-browser-harness), mcp__<server>__* in an MCP client.
+ const other = process.env.PI_CHROME_OTHER_BROWSER_TOOLS?.trim();
+ const stage = "/tmp/pi-chrome-uploads";
+ return `Which browser: in this setup the chrome_* tools do NOT drive a browser on this machine. They drive ${label}, through a broker on that host; the "chrome" in the tool names is historical. It is signed in to the user's own accounts, so every chrome_* action acts as the user.${other ? `
+- Another browser is available through ${other}. Use chrome_* only when the task needs the user's logged-in sessions, or when the user asks for this browser.
+- If it is unclear which browser the user means, ask. Always say which browser you used.` : ""}
+
+Files: the browser runs on ${ssh}, not on this machine.
+- Uploads: chrome_upload_file takes absolute paths on ${ssh}. Stage the file first with \`ssh ${ssh} mkdir -p ${stage} && scp <file> ${ssh}:${stage}/\`, then pass ${stage}/<name>.
+- Downloads land in ~/Downloads on ${ssh}. Copy them back with \`scp '${ssh}:Downloads/<name>' <dest>\`.
+- If this machine is ${ssh} itself, use cp instead of scp.
+- chrome_screenshot already writes its image on this machine; nothing to copy.`;
+}
+
function truncateText(text: string, maxChars = MAX_TEXT_CHARS): string {
if (text.length <= maxChars) return text;
return `${text.slice(0, maxChars)}\n\n[truncated ${text.length - maxChars} characters]`;
@@ -434,9 +495,15 @@ class ChromeProfileBridge {
return this.lastSeenAt !== undefined && Date.now() - this.lastSeenAt < 5 * 60_000;
}
+ // FORK: true when commands go to a broker on another host (PI_CHROME_REMOTE).
+ get remote(): boolean {
+ return REMOTE_BRIDGE !== undefined;
+ }
+
status(): Record<string, unknown> {
return {
url: this.url,
+ remote: this.remote,
mode: this.mode ?? "starting",
connected: this.connected,
lastSeenAt: this.lastSeenAt,
@@ -459,7 +526,8 @@ class ChromeProfileBridge {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1_000);
try {
- const response = await fetch(`${this.url}/status`, { signal: controller.signal });
+ // FORK: the broker's peer listener answers /status only with the bearer token.
+ const response = await fetch(`${this.url}/status`, { headers: remoteBridgeHeaders(), signal: controller.signal });
if (!response.ok) return local;
const owner = (await response.json()) as Record<string, unknown>;
return {
@@ -479,6 +547,11 @@ class ChromeProfileBridge {
// Try to own the bridge port. On success we are the server; on EADDRINUSE another Pi
// session owns it and we run as a client that forwards commands to that owner.
private async bindServerOrClient(): Promise<void> {
+ // FORK: a remote broker's port is never ours to bind, so this is always a client of it.
+ if (REMOTE_BRIDGE) {
+ this.mode = "client";
+ return;
+ }
const server = createServer((request, response) => {
void this.handle(request, response).catch((error) => {
sendJson(response, 500, { error: (error as Error).message });
@@ -598,7 +671,7 @@ class ChromeProfileBridge {
try {
const response = await fetch(`${this.url}/command`, {
method: "POST",
- headers: { "content-type": "application/json" },
+ headers: { "content-type": "application/json", ...remoteBridgeHeaders() },
body: JSON.stringify({ action, params, timeoutMs }),
signal: controller.signal,
});
@@ -619,6 +692,11 @@ class ChromeProfileBridge {
// Try to take over the port ourselves and re-run the command locally instead of staying
// stuck as a client pointed at a dead owner.
if (this.isOwnerUnreachable(error)) {
+ if (REMOTE_BRIDGE) {
+ throw new Error(
+ `The pi-chrome broker at ${this.url} is unreachable. It runs on the browser's host: check this machine can reach that host and that the broker is up.`,
+ );
+ }
const promoted = await this.tryPromoteToServer().catch(() => false);
if (promoted) return this.sendLocal(action, params, timeoutMs, signal);
throw new Error(
@@ -1082,6 +1160,19 @@ export default function (pi: ExtensionAPI): void {
pi.on("session_start", async (_event, ctx) => {
sessionCtx = ctx;
await bridge.start();
+ // FORK: PI_CHROME_AUTHORIZE grants every session up front, for setups that want chrome_*
+ // present from the start like any other tool. Same values as /chrome authorize; a grant
+ // that survived /reload is left alone.
+ const autoGrant = process.env.PI_CHROME_AUTHORIZE?.trim();
+ if (autoGrant && chromeAuthorizedUntil === undefined) {
+ const grant = parseAuthorizeArg(autoGrant);
+ if (grant) {
+ chromeAuthorizedUntil = grant.until;
+ persistAuth();
+ } else {
+ ctx.ui.notify(`Ignoring PI_CHROME_AUTHORIZE="${autoGrant}": use minutes (15m, 30, 45) or indefinite.`, "warning");
+ }
+ }
// Reestablish in-memory state after a /reload restored chromeAuthorizedUntil from globalThis.
if (chromeControlAuthorized()) {
activateChromeTools();
@@ -1113,6 +1204,22 @@ export default function (pi: ExtensionAPI): void {
}
});
+ // FORK: with a remote bridge, a <pi-chrome-remote> prompt section sits beside upstream's primer,
+ // shown and withdrawn with it. A handler of its own, so upstream's below stays as shipped.
+ const REMOTE_PRIMER_SECTION = "pi-chrome-remote";
+ pi.on("before_agent_start", (event) => {
+ if (!REMOTE_BRIDGE) return undefined;
+ const active = chromeToolsRegistered && chromeControlAuthorized();
+ const sections = (event as { systemPromptOptions?: { sections?: Record<string, string> } }).systemPromptOptions?.sections;
+ if (sections) {
+ if (active) sections[REMOTE_PRIMER_SECTION] = remoteBridgePrimer();
+ else delete sections[REMOTE_PRIMER_SECTION];
+ return undefined;
+ }
+ if (!active) return undefined;
+ return { systemPrompt: `${event.systemPrompt}\n<${REMOTE_PRIMER_SECTION}>\n${remoteBridgePrimer()}\n</${REMOTE_PRIMER_SECTION}>` };
+ });
+
// Contribute the primer as a named prompt section instead of returning `systemPrompt`.
// Returning `systemPrompt` forces a whole-prompt override, which makes pi collapse every system
// message into a new leading prompt each run (busting the provider prompt cache and disabling
@@ -1167,7 +1274,9 @@ Usage rules:
`• Background: ${backgroundEnabled ? "on (hard)" : "off (foreground/watch mode)"}.`,
];
const status = bridge.status();
- const roleLabel = status.mode === "client" ? "sharing another pi session's connection" : "running the Chrome connection for this machine";
+ const roleLabel = status.remote
+ ? `using the pi-chrome broker at ${status.url}`
+ : status.mode === "client" ? "sharing another pi session's connection" : "running the Chrome connection for this machine";
lines.push(`• This pi session is ${roleLabel}.`);
let extensionAlive = false;
let versionMismatch = false;
@@ -1196,6 +1305,8 @@ Usage rules:
lines.push(`✗ Chrome isn't responding: ${message}`);
if (message.includes("older pi-chrome without multi-session")) {
lines.push(" Fix: quit and restart the pi session that first opened the Chrome connection (it was on an older pi-chrome).");
+ } else if (status.remote) {
+ lines.push(" Fix: check the broker on the browser's host is up, and that 'Pi Chrome Connector' is loaded in the browser there (see FORK.md).");
} else {
lines.push(" Fix: run /chrome onboard to install the Chrome companion extension, then keep that Chrome window open.");
}
@@ -2092,7 +2203,7 @@ Usage rules:
parameters: Type.Object({
uid: Type.Optional(Type.String()),
selector: Type.Optional(Type.String()),
- paths: Type.Array(Type.String(), { description: "Local absolute file paths to upload." }),
+ paths: Type.Array(Type.String(), { description: "Absolute file paths to upload. With a remote browser these are paths on the browser's host; copy the file there first." }),
domFallback: Type.Optional(Type.Boolean({ description: "Upload buttons need a click to open the (intercepted) file chooser. If true (default) and the tab is hidden, use a page-level element.click() with a user gesture instead of trusted input. Set false to require trusted input." })),
targetId: Type.Optional(Type.String()),
urlIncludes: Type.Optional(Type.String()),
@@ -2100,8 +2211,16 @@ Usage rules:
background: Type.Optional(Type.Boolean({ description: BACKGROUND_PARAM_DESCRIPTION })),
}),
async execute(_id, params, signal, _onUpdate, ctx): Promise<ToolTextResult> {
+ // FORK: a remote browser opens these paths on its own host, so they must already name files
+ // there; resolving them against this machine's cwd would point at nothing.
+ if (bridge.remote && params.paths.some((p) => !isAbsolute(p))) {
+ const host = process.env.PI_CHROME_REMOTE_SSH?.trim() || REMOTE_BRIDGE?.host;
+ throw new Error(
+ `With a remote browser, chrome_upload_file needs absolute paths on the browser's host (${host}). Copy the file there first: \`ssh ${host} mkdir -p /tmp/pi-chrome-uploads && scp <file> ${host}:/tmp/pi-chrome-uploads/\`, then pass /tmp/pi-chrome-uploads/<name>.`,
+ );
+ }
const cwd = workspaceCwd(ctx);
- const paths = params.paths.map((p) => resolve(cwd, p));
+ const paths = bridge.remote ? params.paths : params.paths.map((p) => resolve(cwd, p));
const result = (await authorizedBridgeSend("page.upload", { ...params, paths }, DEFAULT_TIMEOUT_MS, signal)) as { mode?: string; trigger?: string; inFrame?: boolean } | undefined;
const via = result?.mode === "file-chooser"
? ` via intercepted file chooser (${result.trigger === "activation-click" ? "page-level click because the tab is hidden" : "trusted click"})`