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

pi-chrome, patched to drive a signed-in browser on another host through a broker, with an MCP server

mcp/pi/browser-gate.ts (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
/**
 * One grant per pi session for a browser MCP server.
 *
 * pi reaches this server through its built-in MCP support, which has no permission prompts of its
 * own: every MCP call goes through pi's tool pipeline, and an extension's `tool_call` handler is
 * the only place to stop one. This asks once, and the answer covers the session.
 *
 * It gates the tools of one server only, mcp__<server>__*, whether the model calls them directly
 * or a codemode script does:
 *   authorized               -> allow
 *   not yet, interactive     -> ask once: for this session, for 15 minutes, or deny
 *   not yet, headless (-p)   -> deny, unless PI_BROWSER_AUTHORIZE granted it
 * Calls to any other tool are left alone.
 *
 * Nothing backs this up: if this extension fails to load, browser calls run unchecked. `/browser`
 * answering is how to tell that it loaded.
 *
 * Install: copy this file into ~/.pi/agent/extensions/ (or list it in settings.json's
 * "extensions"), and set PI_BROWSER_GATE_SERVER if your server is not named my-chrome.
 *
 *   /browser                                    show the grant
 *   /browser authorize [15m|<minutes>|indefinite]
 *   /browser revoke
 *   PI_BROWSER_AUTHORIZE=indefinite|<minutes>   grant every session up front, headless included
 */

import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";

// The MCP server this gates, as named in mcp.json.
const SERVER = process.env.PI_BROWSER_GATE_SERVER?.trim() || "my-chrome";
// pi names a server's tools mcp__<server>__<tool>, with "-" in the server name turned into "_"
// (server names may hold only letters, digits, "_" and "-"). A tool whose name collides gets a
// hash suffix, never a different prefix.
const TOOL_PREFIX = `mcp__${SERVER.replace(/-/g, "_")}__`;
const DEFAULT_MINUTES = 15;

// Epoch ms it runs out at, or the rest of this pi process.
type Grant = number | "indefinite" | undefined;

function parseGrant(raw: string | undefined): Grant {
	const value = raw?.trim().toLowerCase();
	if (!value) return undefined;
	if (value === "indefinite" || value === "session" || value === "forever") return "indefinite";
	const minutes = Number(value.endsWith("m") ? value.slice(0, -1) : value);
	return Number.isFinite(minutes) && minutes > 0 ? Date.now() + minutes * 60_000 : undefined;
}

export default function browserGate(pi: ExtensionAPI): void {
	let grant: Grant = parseGrant(process.env.PI_BROWSER_AUTHORIZE);
	let ctx: ExtensionContext | undefined;
	let asking: Promise<boolean> | undefined;

	const authorized = (): boolean => grant === "indefinite" || (typeof grant === "number" && grant > Date.now());

	const summary = (): string => {
		if (grant === "indefinite") return `${SERVER}: authorized for this session`;
		if (typeof grant === "number" && grant > Date.now()) {
			return `${SERVER}: authorized for ~${Math.ceil((grant - Date.now()) / 60_000)} more minutes`;
		}
		return `${SERVER}: locked; the next call asks (or run /browser authorize)`;
	};

	const showStatus = (): void => {
		ctx?.ui.setStatus("browser-gate", authorized() ? `● ${SERVER}` : undefined);
	};

	// One question at a time: calls made in parallel wait on the same answer rather than each
	// raising a prompt of its own.
	const ask = (toolName: string): Promise<boolean> => {
		asking ??= (async () => {
			try {
				const session = "For this session";
				const timed = `For ${DEFAULT_MINUTES} minutes`;
				const choice = await ctx!.ui.select(
					`${SERVER}: let this pi session drive your browser? (first call: ${toolName})`,
					[session, timed, "Deny"],
				);
				if (choice === session) grant = "indefinite";
				else if (choice === timed) grant = Date.now() + DEFAULT_MINUTES * 60_000;
				showStatus();
				return authorized();
			} finally {
				asking = undefined;
			}
		})();
		return asking;
	};

	pi.on("tool_call", async (event, callCtx) => {
		if (!event.toolName.startsWith(TOOL_PREFIX)) return;
		ctx = callCtx;
		const tool = event.toolName.slice(TOOL_PREFIX.length);
		try {
			if (authorized()) return;
			if (ctx?.hasUI && (await ask(tool))) return;
			return {
				block: true,
				reason: ctx?.hasUI
					? `${SERVER}: the user did not allow this pi session to drive the browser`
					: `${SERVER}: browser access is not authorized in a headless session (set PI_BROWSER_AUTHORIZE)`,
			};
		} catch (error) {
			// Nothing else asks, so a gate that cannot decide denies rather than leaving the call to
			// whatever pi does with a hook that threw.
			return {
				block: true,
				reason: `${SERVER}: the gate could not decide (${error instanceof Error ? error.message : String(error)})`,
			};
		}
	});

	pi.on("session_start", async (_event, sessionCtx) => {
		ctx = sessionCtx;
		showStatus();
	});

	pi.registerCommand("browser", {
		description:
			`Browser access for this pi session (${SERVER}).\n  /browser                 show the grant\n  /browser authorize [15m|<minutes>|indefinite]\n  /browser revoke`,
		handler: async (args, commandCtx) => {
			ctx = commandCtx;
			const [action, value] = (args ?? "").trim().split(/\s+/);
			if (action === "authorize") {
				const next = parseGrant(value || `${DEFAULT_MINUTES}m`);
				if (!next) {
					commandCtx.ui.notify("Use /browser authorize 15m, /browser authorize 45, or /browser authorize indefinite.", "warning");
					return;
				}
				grant = next;
			} else if (action === "revoke") {
				grant = undefined;
			} else if (action) {
				commandCtx.ui.notify(`Unknown /browser action "${action}": use authorize or revoke.`, "warning");
				return;
			}
			showStatus();
			commandCtx.ui.notify(summary(), "info");
		},
	});
}