aboutsummaryrefslogtreecommitdiff
path: root/mcp/pi/browser-gate.ts
diff options
context:
space:
mode:
Diffstat (limited to 'mcp/pi/browser-gate.ts')
-rw-r--r--mcp/pi/browser-gate.ts134
1 files changed, 134 insertions, 0 deletions
diff --git a/mcp/pi/browser-gate.ts b/mcp/pi/browser-gate.ts
new file mode 100644
index 0000000..2ebe460
--- /dev/null
+++ b/mcp/pi/browser-gate.ts
@@ -0,0 +1,134 @@
+/**
+ * One grant per pi session for a browser MCP server.
+ *
+ * pi reaches this server through pi-mcp-adapter. pi has no permission prompts of its own, and the
+ * adapter's approval remembers a grant only for identical arguments - for a browser, where no two
+ * calls are alike, that is a prompt on nearly every call. This asks once instead, and the answer
+ * covers the session.
+ *
+ * It answers the adapter's approval broker for one server only:
+ * 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 MCP server are left to the adapter.
+ *
+ * Keep `approveTools: true` on the server in mcp.json as well. If this extension ever fails to
+ * load, the adapter then falls back to asking on every call, and to failing closed when headless -
+ * never to running unchecked.
+ *
+ * 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-mcp-adapter's MCP_TOOL_APPROVAL_REQUEST_EVENT. Named here rather than imported: this file is
+// loaded from ~/.pi/agent/extensions, where the adapter's package does not resolve.
+const APPROVAL_EVENT = "pi-mcp-adapter:tool-approval-request";
+const DEFAULT_MINUTES = 15;
+
+// Epoch ms it runs out at, or the rest of this pi process.
+type Grant = number | "indefinite" | undefined;
+
+interface ApprovalRequest {
+ serverName: string;
+ originalToolName: string;
+ claim(handler: () => Promise<"allow_once" | "deny">): boolean;
+}
+
+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.events.on(APPROVAL_EVENT, (data: unknown) => {
+ const request = data as ApprovalRequest;
+ if (request?.serverName !== SERVER) return;
+ // Claimed synchronously, as the adapter requires; the decision itself can take its time.
+ request.claim(async () => {
+ if (authorized()) return "allow_once";
+ if (!ctx?.hasUI) return "deny";
+ return (await ask(request.originalToolName)) ? "allow_once" : "deny";
+ });
+ });
+
+ 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");
+ },
+ });
+}