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
|
/**
* 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");
},
});
}
|