diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-14 12:08:17 +0100 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-14 12:16:02 +0100 |
| commit | 7e2196bd1f046707d1930f8c3d9aa7e4cc390bd6 (patch) | |
| tree | 5d9579b18364388b083a75745b718c76383247d8 | |
| parent | f2e93bba02734461b4e40a1a2684372e6f2437c7 (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.ts | 135 |
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"})` |
