pi-chrome 0.15.4 → 0.15.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable user-facing changes to `pi-chrome`.
4
4
 
5
+ ## 0.15.5 — 2026-05-14
6
+
7
+ - **Chrome control authorization.** `chrome_*` tools are locked until the user runs `/chrome authorize` in the current Pi session. Grants can be one command, 15 minutes, 1 hour, or the session; `/chrome revoke` locks control again and `/chrome status` shows auth state.
8
+ - **Browser-origin bridge hardening.** The loopback bridge no longer sends wildcard CORS headers. `/command` accepts only local-process requests, while extension polling/result endpoints reject non-extension browser origins, blocking ordinary web pages from driving or draining the bridge through `127.0.0.1`.
9
+
10
+ ## 0.15.4 — 2026-05-14
11
+
12
+ - **Quiet renamed to background.** Public UX now uses `/chrome background [on|off|toggle|status]` and docs/status say “run in background” instead of “quiet mode”.
13
+
14
+ ## 0.15.3 — 2026-05-14
15
+
16
+ - **Chrome real input only.** Public trusted/synthetic mode controls were removed. Interactive tools now always use Chrome's real input layer; `/chrome clicks` and public `trusted` parameters are gone.
17
+
5
18
  ## 0.15.2 — 2026-05-13
6
19
 
7
20
  - **Recipe prompts rewritten in user-language.** Earlier recipes leaked tool names into the `You:` prompts ("Use `chrome_tab list` to find my GitHub notifications tab…"), implying users need to know the tool catalog before they can ask anything. Prompts now read as natural intent; the agent trace below each one still shows the `chrome_*` primitives the agent picked. Affects the 30-second try-this block, all 3 hero recipes (PR triage / Linear standup / Bug repro), and 3 of the 6 collapsed recipes (auth-only data pull, network forensics, file upload).
package/README.md CHANGED
@@ -34,10 +34,11 @@ Then in Pi:
34
34
 
35
35
  On macOS this opens `chrome://extensions`, reveals the bundled `browser-extension/` folder in Finder, and copies its path to your clipboard. In Chrome: **Developer mode** → **Load unpacked** → paste the path. Done.
36
36
 
37
- Verify:
37
+ Verify and authorize current Pi session:
38
38
 
39
39
  ```text
40
40
  /chrome doctor
41
+ /chrome authorize
41
42
  ```
42
43
 
43
44
  ```text
@@ -185,6 +186,20 @@ Each tool is documented inline in Pi — agents see the parameters and gotchas (
185
186
 
186
187
  `pi-chrome` drives interactive controls through Chrome's real input layer: clicks, typing, fill, keys, hover, drag, scroll, and touch. Under the hood it uses `chrome.debugger` / CDP, so input satisfies normal user-activation gates. Chrome may show the *"Pi Chrome Connector started debugging this browser"* banner while attached.
187
188
 
189
+ ### Authorization
190
+
191
+ Chrome control is locked by default. Before any agent can use `chrome_*` tools, explicitly authorize the current Pi session:
192
+
193
+ ```text
194
+ /chrome authorize # authorize until this Pi session exits
195
+ /chrome authorize once # allow one Chrome command
196
+ /chrome authorize 15m # allow for 15 minutes
197
+ /chrome revoke # lock again
198
+ /chrome status # shows connection + auth + background
199
+ ```
200
+
201
+ This protects your signed-in Chrome profile from accidental agent use. The loopback bridge also rejects browser-origin command requests so arbitrary web pages cannot call into `127.0.0.1:17318` through CORS.
202
+
188
203
  ### Run in background / watch modes
189
204
 
190
205
  By default, every `chrome_*` call focuses Chrome and activates the target tab so you can **watch the agent work** — invaluable for demos, debugging, and first-time confidence.
@@ -201,6 +216,7 @@ Per-call `background: true` wins over the session setting.
201
216
 
202
217
  - `/chrome doctor` — single command: connectivity, extension version, bridge owner, version drift, MAIN-world helper injection, `chrome_evaluate("1+1") === 2`, fingerprint flags.
203
218
  - `/chrome onboard` — guided first-time setup.
219
+ - `/chrome authorize status` — current Chrome-control authorization state.
204
220
  - `/chrome background status` — current watch/background setting.
205
221
 
206
222
  If the loaded Chrome extension is older than `pi-chrome` on disk, `/chrome doctor` tells you to reload it from `chrome://extensions`.
@@ -248,9 +264,9 @@ If you build a competing tool, please open a PR with your scores. We benchmark i
248
264
 
249
265
  **Unpacked on purpose.** A Web Store extension cannot talk to a local bridge controlled by another tool on the same machine — so pi-chrome ships its bridge as an inspectable, MIT-licensed folder you load once with Developer Mode. Every line is yours to read in [`extensions/chrome-profile-bridge/browser-extension/`](./extensions/chrome-profile-bridge/browser-extension). `/chrome doctor` reports the loaded extension version and warns when it drifts from your installed `pi-chrome`.
250
266
 
251
- The companion extension runs in the Chrome profile where you install it and has broad tab/scripting permissions. Only install it from a package source you trust.
267
+ The companion extension runs in the Chrome profile where you install it and has broad tab/scripting permissions. Only install it from a package source you trust. Even after install, `chrome_*` tools stay locked until you run `/chrome authorize` in Pi. Use `/chrome revoke` to lock them again.
252
268
 
253
- The Pi side listens on `127.0.0.1:17318` by default. Override before starting Pi:
269
+ The Pi side listens on `127.0.0.1:17318` by default and rejects browser-origin command requests; ordinary web pages cannot use CORS to drive the bridge. Override before starting Pi:
254
270
 
255
271
  ```bash
256
272
  PI_CHROME_BRIDGE_PORT=17319 pi
package/SECURITY.md CHANGED
@@ -9,13 +9,14 @@ Open a GitHub issue prefixed with `[security]` at https://github.com/tianrendong
9
9
  `pi-chrome` is a developer tool you install knowingly. It is **not** designed to defend against:
10
10
 
11
11
  - Hostile pages running in your Chrome trying to detect or escape automation. (Standard browser security boundaries still apply, but a hostile page that already runs in your tab can do anything that page can already do.)
12
- - Other processes on your local machine. The bridge binds to `127.0.0.1:17318` (loopback only) but **does not authenticate** local callers. Any process running as your user can issue commands. If your threat model includes hostile local processes, run pi-chrome on a separate user account.
12
+ - Other processes on your local machine. The bridge binds to `127.0.0.1:17318` (loopback only) and chrome_* tools require `/chrome authorize` inside Pi, but the bridge does not authenticate arbitrary non-browser local callers. If your threat model includes hostile local processes running as you, run pi-chrome on a separate user account.
13
13
 
14
14
  `pi-chrome` **is** designed to:
15
15
 
16
16
  - Never exfiltrate page state to the network. All communication is loopback (`127.0.0.1`).
17
17
  - Surface every action with an honest result envelope so the agent can't silently do the wrong thing.
18
- - Require explicit opt-in for trusted-input mode (`/chrome clicks on` or `trusted: true`), which uses `chrome.debugger` and shows Chrome's banner.
18
+ - Keep Chrome control locked until the user explicitly runs `/chrome authorize` in the current Pi session.
19
+ - Reject browser-origin command requests to the loopback bridge so ordinary web pages cannot use CORS to drive Chrome.
19
20
 
20
21
  ## The companion extension
21
22
 
@@ -24,8 +25,9 @@ The Chrome extension under `extensions/chrome-profile-bridge/browser-extension/`
24
25
  ## Defaults
25
26
 
26
27
  - Loopback bridge only. No remote port. No telemetry.
27
- - Synthetic events first; trusted CDP only when explicitly enabled.
28
- - Quiet mode optional; tab/window focus is observable (the user can see Pi acting).
28
+ - Chrome real input layer for interactive controls.
29
+ - Chrome control locked by default; `/chrome authorize` unlocks current Pi session, `/chrome revoke` locks it again.
30
+ - Run-in-background optional; tab/window focus is observable by default (the user can see Pi acting).
29
31
 
30
32
  ## Override the port
31
33
 
@@ -126,7 +126,7 @@ Yes — you can export cookies and replay them, or point Playwright at your exis
126
126
  Different security boundary, not strictly safer.
127
127
 
128
128
  - **CDP-based tools** require `chrome --remote-debugging-port=...`. That port is unauthenticated and exposes the whole browser to any local process. Easy to misconfigure.
129
- - **pi-chrome** runs through an extension you install yourself with broad permissions (tabs, scripting, debugger, webNavigation). The bridge listens on `127.0.0.1:17318` loopback only. **Only install the bundled extension if you trust the source you got the npm package from.**
129
+ - **pi-chrome** runs through an extension you install yourself with broad permissions (tabs, scripting, debugger, webNavigation). The bridge listens on `127.0.0.1:17318` loopback only, rejects browser-origin command requests, and keeps chrome_* tools locked until `/chrome authorize` is run in the current Pi session. **Only install the bundled extension if you trust the source you got the npm package from.**
130
130
 
131
131
  If your threat model excludes extensions with broad permissions, neither approach is a fit — you want a sandboxed CI runner.
132
132
 
package/docs/EXAMPLES.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-chrome examples
2
2
 
3
- Real, useful agent prompts. Drop any of these into Pi after running `/chrome onboard`. Each one uses Chrome tabs and accounts you already have.
3
+ Real, useful agent prompts. Drop any of these into Pi after running `/chrome onboard` and `/chrome authorize`. Each one uses Chrome tabs and accounts you already have.
4
4
 
5
5
  ## Daily workflow
6
6
 
package/docs/FAQ.md CHANGED
@@ -24,11 +24,13 @@ That's Chrome's built-in warning when an extension uses `chrome.debugger`. pi-ch
24
24
 
25
25
  ## Can a malicious page escape and access my other tabs?
26
26
 
27
- No — pages cannot directly talk to extensions. Commands flow agent → local bridge (`127.0.0.1:17318`) → extension → tab. The bridge binds to loopback only. The risk surface is **other local processes** that could connect to `127.0.0.1:17318` and impersonate Pi. If that's in your threat model, run pi-chrome on a separate user account.
27
+ No — pages cannot directly talk to extensions. Commands flow agent → local bridge (`127.0.0.1:17318`) → extension → tab. The bridge binds to loopback only and rejects browser-origin command requests, so ordinary web pages cannot use CORS to drive it.
28
+
29
+ Chrome control is also locked per Pi session until you run `/chrome authorize`; `/chrome revoke` locks it again. The remaining risk surface is **other local processes running as you** that can connect to loopback and imitate Pi. If that's in your threat model, run pi-chrome in a separate OS user account.
28
30
 
29
31
  ## Can multiple Pi sessions use it at once?
30
32
 
31
- Yes. The first session opens the local bridge; later sessions detect it and pipe their commands through the same bridge. Planner + worker + audit can all drive the same Chrome concurrently.
33
+ Yes. The first session opens the local bridge; later sessions detect it and pipe their commands through the same bridge. Each Pi session must be authorized with `/chrome authorize` before its chrome_* tools work.
32
34
 
33
35
  ## Why can't this be on the Chrome Web Store?
34
36
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Pi Chrome Connector",
4
- "version": "0.15.4",
4
+ "version": "0.15.5",
5
5
  "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
6
6
  "permissions": [
7
7
  "tabs",
@@ -127,12 +127,32 @@ function readRequestBody(request: IncomingMessage): Promise<string> {
127
127
  });
128
128
  }
129
129
 
130
+ function corsHeadersFor(request: IncomingMessage): Record<string, string> {
131
+ const origin = String(request.headers.origin ?? "");
132
+ if (!origin.startsWith("chrome-extension://")) return {};
133
+ return {
134
+ "access-control-allow-origin": origin,
135
+ "access-control-allow-methods": "GET,POST,OPTIONS",
136
+ "access-control-allow-headers": "content-type",
137
+ "access-control-expose-headers": "x-pi-chrome-version",
138
+ "vary": "origin",
139
+ };
140
+ }
141
+
142
+ function isBrowserOriginAllowed(request: IncomingMessage): boolean {
143
+ const origin = String(request.headers.origin ?? "");
144
+ if (origin) return origin.startsWith("chrome-extension://");
145
+ const secFetchSite = String(request.headers["sec-fetch-site"] ?? "");
146
+ return !secFetchSite || secFetchSite === "none" || secFetchSite === "same-origin";
147
+ }
148
+
149
+ function isLocalProcessRequest(request: IncomingMessage): boolean {
150
+ return !request.headers.origin && !request.headers["sec-fetch-site"];
151
+ }
152
+
130
153
  function sendJson(response: ServerResponse, status: number, body: unknown, extraHeaders?: Record<string, string>): void {
131
154
  response.writeHead(status, {
132
155
  "content-type": "application/json; charset=utf-8",
133
- "access-control-allow-origin": "*",
134
- "access-control-allow-methods": "GET,POST,OPTIONS",
135
- "access-control-allow-headers": "content-type",
136
156
  "cache-control": "no-store",
137
157
  ...(extraHeaders ?? {}),
138
158
  });
@@ -278,16 +298,25 @@ class ChromeProfileBridge {
278
298
  }
279
299
 
280
300
  private async handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
301
+ const url = new URL(request.url ?? "/", this.url);
302
+ const corsHeaders = corsHeadersFor(request);
281
303
  if (request.method === "OPTIONS") {
282
- sendJson(response, 200, { ok: true });
304
+ if (!isBrowserOriginAllowed(request)) {
305
+ sendJson(response, 403, { ok: false, error: "browser origin not allowed" });
306
+ return;
307
+ }
308
+ sendJson(response, 200, { ok: true }, corsHeaders);
283
309
  return;
284
310
  }
285
- const url = new URL(request.url ?? "/", this.url);
286
311
  if (request.method === "GET" && url.pathname === "/status") {
287
312
  sendJson(response, 200, this.status());
288
313
  return;
289
314
  }
290
315
  if (request.method === "POST" && url.pathname === "/command") {
316
+ if (!isLocalProcessRequest(request)) {
317
+ sendJson(response, 403, { ok: false, error: "Chrome commands are accepted only from local Pi processes" });
318
+ return;
319
+ }
291
320
  const body = JSON.parse(await readRequestBody(request)) as {
292
321
  action?: string;
293
322
  params?: Record<string, unknown>;
@@ -306,6 +335,10 @@ class ChromeProfileBridge {
306
335
  return;
307
336
  }
308
337
  if (request.method === "GET" && url.pathname === "/next") {
338
+ if (!isBrowserOriginAllowed(request)) {
339
+ sendJson(response, 403, { ok: false, error: "browser origin not allowed" });
340
+ return;
341
+ }
309
342
  this.lastSeenAt = Date.now();
310
343
  this.clientName = url.searchParams.get("name") ?? undefined;
311
344
  let aborted = false;
@@ -334,23 +367,27 @@ class ChromeProfileBridge {
334
367
  command
335
368
  ? { type: "command", command, expectedExtensionVersion: currentVersion }
336
369
  : { type: "none", expectedExtensionVersion: currentVersion },
337
- { "x-pi-chrome-version": currentVersion },
370
+ { ...corsHeaders, "x-pi-chrome-version": currentVersion },
338
371
  );
339
372
  return;
340
373
  }
341
374
  if (request.method === "POST" && url.pathname === "/result") {
375
+ if (!isBrowserOriginAllowed(request)) {
376
+ sendJson(response, 403, { ok: false, error: "browser origin not allowed" });
377
+ return;
378
+ }
342
379
  this.lastSeenAt = Date.now();
343
380
  const result = JSON.parse(await readRequestBody(request)) as BridgeResult;
344
381
  const pending = this.pending.get(result.id);
345
382
  if (!pending) {
346
- sendJson(response, 404, { ok: false, error: "unknown command id" });
383
+ sendJson(response, 404, { ok: false, error: "unknown command id" }, corsHeaders);
347
384
  return;
348
385
  }
349
386
  clearTimeout(pending.timer);
350
387
  this.pending.delete(result.id);
351
388
  if (result.ok) pending.resolve(result.result);
352
389
  else pending.reject(new Error(result.error ?? "Chrome extension command failed"));
353
- sendJson(response, 200, { ok: true });
390
+ sendJson(response, 200, { ok: true }, corsHeaders);
354
391
  return;
355
392
  }
356
393
  sendJson(response, 404, { error: "not found" });
@@ -399,6 +436,46 @@ export default function (pi: ExtensionAPI): void {
399
436
 
400
437
  const bridge = new ChromeProfileBridge(DEFAULT_HOST, DEFAULT_PORT);
401
438
  let backgroundDefault = false;
439
+ let chromeAuthorizedUntil: number | "session" | undefined;
440
+ let chromeAuthorizedCalls: number | undefined;
441
+
442
+ const authSummary = (): string => {
443
+ if (chromeAuthorizedUntil === "session") return "authorized for this session";
444
+ if (typeof chromeAuthorizedUntil === "number") {
445
+ const remainingMs = chromeAuthorizedUntil - Date.now();
446
+ if (remainingMs > 0) {
447
+ const minutes = Math.ceil(remainingMs / 60_000);
448
+ return `authorized for ${chromeAuthorizedCalls === 1 ? "one Chrome command" : `~${minutes}m`}`;
449
+ }
450
+ }
451
+ return "locked";
452
+ };
453
+
454
+ const chromeControlAuthorized = (): boolean => {
455
+ if (chromeAuthorizedUntil === "session") return true;
456
+ if (typeof chromeAuthorizedUntil === "number" && chromeAuthorizedUntil > Date.now()) return true;
457
+ chromeAuthorizedUntil = undefined;
458
+ chromeAuthorizedCalls = undefined;
459
+ return false;
460
+ };
461
+
462
+ const requireChromeControlAuthorized = (): void => {
463
+ if (!chromeControlAuthorized()) {
464
+ throw new Error("Chrome control locked. Ask the user to run /chrome authorize before using chrome_* tools.");
465
+ }
466
+ if (chromeAuthorizedCalls !== undefined) {
467
+ chromeAuthorizedCalls -= 1;
468
+ if (chromeAuthorizedCalls <= 0) {
469
+ chromeAuthorizedUntil = undefined;
470
+ chromeAuthorizedCalls = undefined;
471
+ }
472
+ }
473
+ };
474
+
475
+ const authorizedBridgeSend = (action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> => {
476
+ requireChromeControlAuthorized();
477
+ return bridge.send(action, params, timeoutMs);
478
+ };
402
479
 
403
480
  // Translate the public `background` parameter (default false = visible/foreground) into the
404
481
  // service worker's wire-level `foreground` flag, accepting legacy `foreground` as a fallback.
@@ -420,8 +497,8 @@ export default function (pi: ExtensionAPI): void {
420
497
  ctx.ui.setStatus("chrome", `Chrome bridge :${DEFAULT_PORT}`);
421
498
  ctx.ui.notify(
422
499
  status.mode === "client"
423
- ? `pi-chrome connected (sharing the Chrome connection an earlier pi session opened).`
424
- : `pi-chrome is ready and waiting for the Chrome companion to connect. Run /chrome onboard to install it.`,
500
+ ? `pi-chrome connected (sharing the Chrome connection an earlier pi session opened). Run /chrome authorize before using chrome_* tools.`
501
+ : `pi-chrome is ready and waiting for the Chrome companion to connect. Run /chrome onboard to install it, then /chrome authorize to allow chrome_* tools.`,
425
502
  "info",
426
503
  );
427
504
  });
@@ -442,13 +519,14 @@ Capability model (important):
442
519
  - Tool results include \`pageMutated\`, \`defaultPrevented\`, \`elementVisible\`, \`occludedBy\`, and (for type/fill) \`valueMatches\`. If an action result indicates no page change or occlusion, inspect current page state instead of repeating blindly.
443
520
 
444
521
  Usage rules:
445
- 1. \`chrome_snapshot\` before clicking/typing; pass \`uid\` over \`selector\`.
446
- 2. \`includeSnapshot=true\` on click/type/fill to verify in one round trip.
447
- 3. If \`chrome_evaluate\` returns null when you expected a value, the expression evaluated to null/undefined in the page; surface the value via \`JSON.stringify\` to confirm.
448
- 4. \`chrome_navigate\` supports an optional \`initScript\` that runs at document_start in MAIN world for the next navigation (good for seeding localStorage or stubbing Date.now).
449
- 5. By default chrome_* tools focus Chrome so the user can watch; pass \`background=true\` or run /chrome background on for session-wide background execution.
450
- 6. If you hit a native file-picker or privileged browser prompt gate, tell the user; generic clicks/typing/CSP gates are handled by Chrome input.
451
- 7. Run /chrome doctor when in doubt about connectivity or capabilities.
522
+ 1. If a chrome_* tool says Chrome control is locked, ask the user to run \`/chrome authorize\` before retrying.
523
+ 2. \`chrome_snapshot\` before clicking/typing; pass \`uid\` over \`selector\`.
524
+ 3. \`includeSnapshot=true\` on click/type/fill to verify in one round trip.
525
+ 4. If \`chrome_evaluate\` returns null when you expected a value, the expression evaluated to null/undefined in the page; surface the value via \`JSON.stringify\` to confirm.
526
+ 5. \`chrome_navigate\` supports an optional \`initScript\` that runs at document_start in MAIN world for the next navigation (good for seeding localStorage or stubbing Date.now).
527
+ 6. By default chrome_* tools focus Chrome so the user can watch; pass \`background=true\` or run /chrome background on for session-wide background execution.
528
+ 7. If you hit a native file-picker or privileged browser prompt gate, tell the user; generic clicks/typing/CSP gates are handled by Chrome input.
529
+ 8. Run /chrome doctor when in doubt about connectivity or capabilities.
452
530
  </chrome-profile-bridge>`;
453
531
  return { systemPrompt: event.systemPrompt + primer };
454
532
  });
@@ -544,6 +622,42 @@ Usage rules:
544
622
  ctx.ui.notify(`Run in background → ${nextLabel}. ${BACKGROUND_DESC[nextLabel]}`, "info");
545
623
  };
546
624
 
625
+ const authorizeHandler = async (ctx: ExtensionContext, args: string) => {
626
+ const arg = (args || "").trim().toLowerCase() || "session";
627
+ if (arg === "status") {
628
+ ctx.ui.notify(`Chrome control auth: ${authSummary()}.`, "info");
629
+ return;
630
+ }
631
+ const options: Record<string, { label: string; until: number | "session"; calls?: number }> = {
632
+ once: { label: "one Chrome command", until: Date.now() + 10 * 60_000, calls: 1 },
633
+ "15m": { label: "15 minutes", until: Date.now() + 15 * 60_000 },
634
+ "1h": { label: "1 hour", until: Date.now() + 60 * 60_000 },
635
+ session: { label: "this Pi session", until: "session" },
636
+ };
637
+ const grant = options[arg];
638
+ if (!grant) {
639
+ ctx.ui.notify("Unknown authorize duration. Pick one of: once | 15m | 1h | session | status.", "warning");
640
+ return;
641
+ }
642
+ const ok = await ctx.ui.confirm(
643
+ "Authorize pi-chrome control?",
644
+ `This Pi session will be allowed to inspect and control your existing Chrome profile for ${grant.label}.\n\nChrome actions use your signed-in browser state and real input. Only approve if you trust the current agent/task.`,
645
+ );
646
+ if (!ok) {
647
+ ctx.ui.notify("Chrome control remains locked.", "info");
648
+ return;
649
+ }
650
+ chromeAuthorizedUntil = grant.until;
651
+ chromeAuthorizedCalls = grant.calls;
652
+ ctx.ui.notify(`Chrome control authorized for ${grant.label}.`, "info");
653
+ };
654
+
655
+ const revokeHandler = (ctx: ExtensionContext) => {
656
+ chromeAuthorizedUntil = undefined;
657
+ chromeAuthorizedCalls = undefined;
658
+ ctx.ui.notify("Chrome control locked. Run /chrome authorize to allow chrome_* tools again.", "info");
659
+ };
660
+
547
661
  const onboardHandler = async (ctx: ExtensionContext) => {
548
662
  const extensionPath = browserExtensionPath();
549
663
  const proceed = await ctx.ui.confirm(
@@ -579,6 +693,7 @@ Usage rules:
579
693
  } catch {
580
694
  parts.push(`✗ Chrome not responding`);
581
695
  }
696
+ parts.push(`auth: ${authSummary()}`);
582
697
  parts.push(`background: ${backgroundDefault ? "on" : "off"}`);
583
698
  return parts.join(" · ");
584
699
  };
@@ -632,7 +747,7 @@ Usage rules:
632
747
 
633
748
  pi.registerCommand("chrome", {
634
749
  description:
635
- "All pi-chrome controls in one place.\n /chrome status — one-line snapshot of connection + background setting.\n /chrome doctor — full health check.\n /chrome onboard — install the Chrome companion extension.\n /chrome background [on|off|status|toggle] — whether pi-chrome runs without focusing Chrome.\nRun with no arguments for an interactive picker that shows current state.",
750
+ "All pi-chrome controls in one place.\n /chrome authorize [once|15m|1h|session|status] — allow this Pi session to use chrome_* tools.\n /chrome revokelock Chrome control.\n /chrome status — one-line snapshot of connection, auth, and background setting.\n /chrome doctor — full health check.\n /chrome onboard — install the Chrome companion extension.\n /chrome background [on|off|status|toggle] — whether pi-chrome runs without focusing Chrome.\nRun with no arguments for an interactive picker that shows current state.",
636
751
  getArgumentCompletions: (prefix) => {
637
752
  const raw = prefix;
638
753
  const trimmedRight = raw.replace(/\s+$/, "");
@@ -649,11 +764,21 @@ Usage rules:
649
764
  let candidates: Item[] = [];
650
765
  if (path.length === 0) {
651
766
  candidates = [
652
- { fullValue: "status", label: "status", description: "One-line summary: connection + background setting." },
767
+ { fullValue: "authorize", label: "authorize", description: "Allow this Pi session to use chrome_* tools." },
768
+ { fullValue: "revoke", label: "revoke", description: "Lock Chrome control for this Pi session." },
769
+ { fullValue: "status", label: "status", description: "One-line summary: connection, auth, and background setting." },
653
770
  { fullValue: "doctor", label: "doctor", description: "Full health check. Tells you if Chrome is connected and what's wrong if it isn't." },
654
771
  { fullValue: "onboard", label: "onboard", description: "Install the Chrome companion extension (first-time setup)." },
655
772
  { fullValue: "background", label: "background", description: "Run pi-chrome in the background without focusing Chrome?" },
656
773
  ];
774
+ } else if (path[0] === "authorize" && path.length === 1) {
775
+ candidates = [
776
+ { fullValue: "authorize once", label: "once", description: "Authorize one Chrome command." },
777
+ { fullValue: "authorize 15m", label: "15m", description: "Authorize Chrome control for 15 minutes." },
778
+ { fullValue: "authorize 1h", label: "1h", description: "Authorize Chrome control for 1 hour." },
779
+ { fullValue: "authorize session", label: "session", description: "Authorize Chrome control until this Pi session exits." },
780
+ { fullValue: "authorize status", label: "status", description: "Show Chrome control authorization state." },
781
+ ];
657
782
  } else if (path[0] === "background" && path.length === 1) {
658
783
  candidates = [
659
784
  { fullValue: "background on", label: "on", description: "Run in background. Chrome stays in the background. Your editor keeps focus." },
@@ -676,6 +801,8 @@ Usage rules:
676
801
  const [head, ...rest] = tokens;
677
802
  const subArgs = rest.join(" ");
678
803
  switch (head) {
804
+ case "authorize": return authorizeHandler(ctx, subArgs);
805
+ case "revoke": return revokeHandler(ctx);
679
806
  case "status": return statusHandler(ctx);
680
807
  case "doctor": return doctorHandler(ctx);
681
808
  case "onboard": return onboardHandler(ctx);
@@ -689,7 +816,7 @@ Usage rules:
689
816
  return;
690
817
  }
691
818
  default:
692
- ctx.ui.notify(`Unknown subcommand '${head}'. Try: /chrome status | doctor | onboard | background.`, "warning");
819
+ ctx.ui.notify(`Unknown subcommand '${head}'. Try: /chrome authorize | revoke | status | doctor | onboard | background.`, "warning");
693
820
  }
694
821
  },
695
822
  });
@@ -709,7 +836,7 @@ Usage rules:
709
836
  }),
710
837
  async execute(_id, params, _signal, _onUpdate, ctx): Promise<ToolTextResult> {
711
838
  if (params.url && bridge.connected) {
712
- const result = await bridge.send("tab.new", { url: params.url }, DEFAULT_TIMEOUT_MS);
839
+ const result = await authorizedBridgeSend("tab.new", { url: params.url }, DEFAULT_TIMEOUT_MS);
713
840
  return { content: [{ type: "text", text: `Chrome bridge connected; opened ${params.url}` }], details: { status: bridge.status(), result } };
714
841
  }
715
842
  return {
@@ -744,7 +871,7 @@ Usage rules:
744
871
  port: Type.Optional(Type.Number()),
745
872
  }),
746
873
  async execute(_id, params): Promise<ToolTextResult> {
747
- const result = await bridge.send(`tab.${params.action}`, params, DEFAULT_TIMEOUT_MS);
874
+ const result = await authorizedBridgeSend(`tab.${params.action}`, params, DEFAULT_TIMEOUT_MS);
748
875
  if (params.action === "list") {
749
876
  const tabs = result as Array<{ id: number; title: string; url: string; active: boolean; windowId: number }>;
750
877
  const text = tabs.map((tab) => `${tab.id}\t${tab.active ? "*" : " "}\t${tab.title || "(untitled)"}\t${tab.url}`).join("\n") || "No tabs.";
@@ -775,7 +902,7 @@ Usage rules:
775
902
  port: Type.Optional(Type.Number()),
776
903
  }),
777
904
  async execute(_id, params): Promise<ToolTextResult> {
778
- const snapshot = await bridge.send(
905
+ const snapshot = await authorizedBridgeSend(
779
906
  "page.snapshot",
780
907
  withBackground({ ...params, maxElements: params.maxElements ?? MAX_ELEMENTS }),
781
908
  DEFAULT_TIMEOUT_MS,
@@ -805,7 +932,7 @@ Usage rules:
805
932
  port: Type.Optional(Type.Number()),
806
933
  }),
807
934
  async execute(_id, params): Promise<ToolTextResult> {
808
- const result = await bridge.send("page.navigate", withBackground(params), (params.timeoutMs ?? 15_000) + 2_000);
935
+ const result = await authorizedBridgeSend("page.navigate", withBackground(params), (params.timeoutMs ?? 15_000) + 2_000);
809
936
  return { content: [{ type: "text", text: `Navigated to ${params.url}${params.initScript ? " (with initScript)" : ""}` }], details: { result: result as Json } };
810
937
  },
811
938
  });
@@ -829,7 +956,7 @@ Usage rules:
829
956
  port: Type.Optional(Type.Number()),
830
957
  }),
831
958
  async execute(_id, params): Promise<ToolTextResult> {
832
- const value = await bridge.send("page.evaluate", withBackground(params), DEFAULT_TIMEOUT_MS);
959
+ const value = await authorizedBridgeSend("page.evaluate", withBackground(params), DEFAULT_TIMEOUT_MS);
833
960
  const text = value === undefined
834
961
  ? "undefined"
835
962
  : typeof value === "string"
@@ -862,7 +989,7 @@ Usage rules:
862
989
  port: Type.Optional(Type.Number()),
863
990
  }),
864
991
  async execute(_id, params): Promise<ToolTextResult> {
865
- const raw = await bridge.send("page.click", withBackground(params), DEFAULT_TIMEOUT_MS);
992
+ const raw = await authorizedBridgeSend("page.click", withBackground(params), DEFAULT_TIMEOUT_MS);
866
993
  const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
867
994
  const summary = summarizeActionResult(result);
868
995
  const target = params.uid ?? params.selector ?? `${params.x},${params.y}`;
@@ -894,7 +1021,7 @@ Usage rules:
894
1021
  port: Type.Optional(Type.Number()),
895
1022
  }),
896
1023
  async execute(_id, params): Promise<ToolTextResult> {
897
- const raw = await bridge.send("page.type", withBackground(params), DEFAULT_TIMEOUT_MS);
1024
+ const raw = await authorizedBridgeSend("page.type", withBackground(params), DEFAULT_TIMEOUT_MS);
898
1025
  const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
899
1026
  const summary = summarizeActionResult(result);
900
1027
  const into = params.uid || params.selector ? ` into ${params.uid ?? params.selector}` : "";
@@ -926,7 +1053,7 @@ Usage rules:
926
1053
  port: Type.Optional(Type.Number()),
927
1054
  }),
928
1055
  async execute(_id, params): Promise<ToolTextResult> {
929
- const raw = await bridge.send("page.fill", withBackground(params), DEFAULT_TIMEOUT_MS);
1056
+ const raw = await authorizedBridgeSend("page.fill", withBackground(params), DEFAULT_TIMEOUT_MS);
930
1057
  const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
931
1058
  const summary = summarizeActionResult(result);
932
1059
  const into = params.uid || params.selector ? ` into ${params.uid ?? params.selector}` : "";
@@ -961,7 +1088,7 @@ Usage rules:
961
1088
  port: Type.Optional(Type.Number()),
962
1089
  }),
963
1090
  async execute(_id, params): Promise<ToolTextResult> {
964
- const raw = await bridge.send("page.key", withBackground(params), DEFAULT_TIMEOUT_MS);
1091
+ const raw = await authorizedBridgeSend("page.key", withBackground(params), DEFAULT_TIMEOUT_MS);
965
1092
  const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
966
1093
  const summary = summarizeActionResult(result);
967
1094
  const base = `Pressed ${params.key}.`;
@@ -986,7 +1113,7 @@ Usage rules:
986
1113
  port: Type.Optional(Type.Number()),
987
1114
  }),
988
1115
  async execute(_id, params): Promise<ToolTextResult> {
989
- const result = await bridge.send("page.waitFor", params, (params.timeoutMs ?? 10_000) + 2_000);
1116
+ const result = await authorizedBridgeSend("page.waitFor", params, (params.timeoutMs ?? 10_000) + 2_000);
990
1117
  return { content: [{ type: "text", text: `Observed ${params.kind}: ${params.value}` }], details: { result: result as Json } };
991
1118
  },
992
1119
  });
@@ -1007,7 +1134,7 @@ Usage rules:
1007
1134
  port: Type.Optional(Type.Number()),
1008
1135
  }),
1009
1136
  async execute(_id, params): Promise<ToolTextResult> {
1010
- const result = await bridge.send("page.console.list", withBackground(params), DEFAULT_TIMEOUT_MS);
1137
+ const result = await authorizedBridgeSend("page.console.list", withBackground(params), DEFAULT_TIMEOUT_MS);
1011
1138
  return { content: [{ type: "text", text: truncateText(safeJson(result)) }], details: { result: result as Json } };
1012
1139
  },
1013
1140
  });
@@ -1029,7 +1156,7 @@ Usage rules:
1029
1156
  port: Type.Optional(Type.Number()),
1030
1157
  }),
1031
1158
  async execute(_id, params): Promise<ToolTextResult> {
1032
- const result = await bridge.send("page.network.list", withBackground(params), DEFAULT_TIMEOUT_MS);
1159
+ const result = await authorizedBridgeSend("page.network.list", withBackground(params), DEFAULT_TIMEOUT_MS);
1033
1160
  return { content: [{ type: "text", text: truncateText(safeJson(result)) }], details: { result: result as Json } };
1034
1161
  },
1035
1162
  });
@@ -1049,7 +1176,7 @@ Usage rules:
1049
1176
  port: Type.Optional(Type.Number()),
1050
1177
  }),
1051
1178
  async execute(_id, params): Promise<ToolTextResult> {
1052
- const result = await bridge.send("page.network.get", withBackground(params), DEFAULT_TIMEOUT_MS);
1179
+ const result = await authorizedBridgeSend("page.network.get", withBackground(params), DEFAULT_TIMEOUT_MS);
1053
1180
  return { content: [{ type: "text", text: truncateText(safeJson(result)) }], details: { result: result as Json } };
1054
1181
  },
1055
1182
  });
@@ -1079,7 +1206,7 @@ Usage rules:
1079
1206
  const cwd = workspaceCwd(ctx);
1080
1207
  const defaultPath = join(cwd, ".pi", "chrome-screenshots", `${new Date().toISOString().replace(/[:.]/g, "-")}.${format}`);
1081
1208
  const outputPath = params.path ? resolve(cwd, params.path) : defaultPath;
1082
- const result = (await bridge.send("page.screenshot", withBackground(params), params.fullPage ? 120_000 : DEFAULT_TIMEOUT_MS)) as {
1209
+ const result = (await authorizedBridgeSend("page.screenshot", withBackground(params), params.fullPage ? 120_000 : DEFAULT_TIMEOUT_MS)) as {
1083
1210
  dataUrl?: string;
1084
1211
  tab?: unknown;
1085
1212
  fullPage?: boolean;
@@ -1129,7 +1256,7 @@ Usage rules:
1129
1256
  background: Type.Optional(Type.Boolean()),
1130
1257
  }),
1131
1258
  async execute(_id, params): Promise<ToolTextResult> {
1132
- const result = await bridge.send("page.hover", withBackground(params), DEFAULT_TIMEOUT_MS);
1259
+ const result = await authorizedBridgeSend("page.hover", withBackground(params), DEFAULT_TIMEOUT_MS);
1133
1260
  return { content: [{ type: "text", text: `Hovered ${params.uid ?? params.selector ?? `${params.x},${params.y}`}` }], details: { result: result as Json } };
1134
1261
  },
1135
1262
  });
@@ -1155,7 +1282,7 @@ Usage rules:
1155
1282
  background: Type.Optional(Type.Boolean()),
1156
1283
  }),
1157
1284
  async execute(_id, params): Promise<ToolTextResult> {
1158
- const result = await bridge.send("page.drag", withBackground(params), DEFAULT_TIMEOUT_MS);
1285
+ const result = await authorizedBridgeSend("page.drag", withBackground(params), DEFAULT_TIMEOUT_MS);
1159
1286
  return { content: [{ type: "text", text: `Dragged from ${params.fromUid ?? params.fromSelector} to ${params.toUid ?? params.toSelector}` }], details: { result: result as Json } };
1160
1287
  },
1161
1288
  });
@@ -1177,7 +1304,7 @@ Usage rules:
1177
1304
  background: Type.Optional(Type.Boolean()),
1178
1305
  }),
1179
1306
  async execute(_id, params): Promise<ToolTextResult> {
1180
- const result = await bridge.send("page.tap", withBackground(params), DEFAULT_TIMEOUT_MS);
1307
+ const result = await authorizedBridgeSend("page.tap", withBackground(params), DEFAULT_TIMEOUT_MS);
1181
1308
  const target = params.uid ?? params.selector ?? `${params.x},${params.y}`;
1182
1309
  return { content: [{ type: "text", text: `Tapped ${target} (touch)` }], details: { result: result as Json } };
1183
1310
  },
@@ -1200,7 +1327,7 @@ Usage rules:
1200
1327
  background: Type.Optional(Type.Boolean()),
1201
1328
  }),
1202
1329
  async execute(_id, params): Promise<ToolTextResult> {
1203
- const result = await bridge.send("page.scroll", withBackground(params), DEFAULT_TIMEOUT_MS);
1330
+ const result = await authorizedBridgeSend("page.scroll", withBackground(params), DEFAULT_TIMEOUT_MS);
1204
1331
  return { content: [{ type: "text", text: `Scrolled dy=${params.deltaY ?? 0} dx=${params.deltaX ?? 0}` }], details: { result: result as Json } };
1205
1332
  },
1206
1333
  });
@@ -1222,7 +1349,7 @@ Usage rules:
1222
1349
  async execute(_id, params, _signal, _onUpdate, ctx): Promise<ToolTextResult> {
1223
1350
  const cwd = workspaceCwd(ctx);
1224
1351
  const paths = params.paths.map((p) => resolve(cwd, p));
1225
- const result = await bridge.send("page.upload", withBackground({ ...params, paths }), DEFAULT_TIMEOUT_MS);
1352
+ const result = await authorizedBridgeSend("page.upload", withBackground({ ...params, paths }), DEFAULT_TIMEOUT_MS);
1226
1353
  return { content: [{ type: "text", text: `Uploaded ${paths.length} file(s) to ${params.uid ?? params.selector}` }], details: { result: result as Json } };
1227
1354
  },
1228
1355
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-chrome",
3
- "version": "0.15.4",
3
+ "version": "0.15.5",
4
4
  "scripts": {
5
5
  "version": "node scripts/sync-manifest-version.js",
6
6
  "prepublishOnly": "node scripts/sync-manifest-version.js"