extension: end a /chrome authorize grant with its session The grant lives on globalThis so /reload keeps it, but nothing cleared it when pi replaced the session in the same process. After /new, /resume or /fork the next session started authorized and announced it in the footer. Drop the saved grant on session_shutdown for new, resume and fork. Reload keeps it. Quit leaves it alone, so a subagent quitting in the same process does not take its parent's grant with it. grant-scope.test.mjs loads the shipped module once per runtime in one shared context, the way pi keeps globalThis across reloads and switches, and checks the tools and the footer after each. It fails on 85b4988, and its /chrome authorize cases fail with only the PI_CHROME_AUTHORIZE fix.
Igor Soarez igor@soarez.org
Fri, 06 Nov 2026 13:49:39 +0200
4 files changed,
139 insertions(+),
4 deletions(-)
M
extensions/chrome-profile-bridge/index.ts
→
extensions/chrome-profile-bridge/index.ts
@@ -1204,6 +1204,12 @@ clearCountdownInterval();
// /reload continues the same session. On exit, give Chrome a bounded opportunity to // close this session's created tabs and ungroup adopted tabs before stopping the broker. if (event?.reason !== "reload") await cleanupAutomationTargetBestEffort(); + // A /chrome authorize grant belongs to this session: /reload keeps it, while /new, /resume + // and /fork start a different session in this process, which must not inherit it. "quit" + // leaves it alone: a subagent quitting in this process must not drop its parent's grant. + if (event?.reason === "new" || event?.reason === "resume" || event?.reason === "fork") { + delete globalState[PI_CHROME_AUTH_KEY]; + } bridge.stop(); if (globalState[PI_CHROME_GLOBAL_KEY]?.token === instanceToken) { delete globalState[PI_CHROME_GLOBAL_KEY];
M
package.json
→
package.json
@@ -2,7 +2,7 @@ {
"name": "pi-chrome", "version": "0.15.57", "scripts": { - "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs && node test-suite/unit/hidden-input.test.mjs && node test-suite/unit/console-capture.test.mjs && node test-suite/unit/prompt-section.test.mjs && node test-suite/unit/upload-guard.test.mjs", + "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs && node test-suite/unit/hidden-input.test.mjs && node test-suite/unit/console-capture.test.mjs && node test-suite/unit/prompt-section.test.mjs && node test-suite/unit/upload-guard.test.mjs && node test-suite/unit/grant-scope.test.mjs", "version": "node scripts/sync-manifest-version.js", "prepublishOnly": "node scripts/sync-manifest-version.js" },
A
test-suite/unit/grant-scope.test.mjs
@@ -0,0 +1,121 @@
+// Which grant a session starts with, and whether the "● Chrome Bridge" footer announces it, across +// /reload and session switches. Pi re-evaluates the extension for each runtime but keeps globalThis +// for the life of the process, so each runtime here is a fresh evaluation of the shipped module in +// one shared context. The bridge binds an ephemeral loopback port; no Chrome is involved. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import vm from "node:vm"; +import { createRequire, stripTypeScriptTypes } from "node:module"; +import { test } from "node:test"; + +const indexUrl = new URL("../../extensions/chrome-profile-bridge/index.ts", import.meta.url); +const stubbed = stripTypeScriptTypes(fs.readFileSync(indexUrl, "utf8"), { mode: "transform" }) + .replace(/^import \{ Text \} from "@earendil-works\/pi-tui";$/m, "const Text = class {};") + .replace(/^import \{ Type \} from "typebox";$/m, "const Type = new Proxy({}, { get: () => (value = {}) => value });") + .replace(/^import \{([^}]*)\} from "(node:[^"]+)";$/gm, (_m, names, mod) => `const {${names.replace(/\btype\s+\w+\s*,?/g, "")}} = require("${mod}");`) + .replace(/^export default function/m, "module.exports.default = function"); +assert.doesNotMatch(stubbed, /^import /m, "every import must be stubbed"); + +// One pi process: a shared context, so globalThis outlives each runtime. +function piProcess(env) { + const fakeProcess = Object.create(process, { env: { value: { ...env, PI_CHROME_BRIDGE_PORT: "0" } } }); + const context = vm.createContext({ + process: fakeProcess, console, Buffer, URL, AbortController, fetch, + setTimeout, clearTimeout, setInterval, clearInterval, + }); + let runtime; + // Evaluate the module again and run its factory, as pi does at startup, on /reload and on a + // session switch. + const load = () => { + const module = { exports: {} }; + vm.runInContext(`(function (module, exports, require, __dirname) {\n${stubbed}\n})`, context)( + module, module.exports, createRequire(indexUrl), new URL(".", indexUrl).pathname, + ); + const handlers = {}, commands = {}, status = {}; + let active = []; + const pi = { + on: (name, fn) => { handlers[name] = fn; }, + registerTool() {}, registerEntryRenderer() {}, appendEntry() {}, + registerCommand: (name, command) => { commands[name] = command; }, + getActiveTools: () => active, + setActiveTools: (tools) => { active = tools; }, + }; + module.exports.default(pi); + const ctx = { + cwd: process.cwd(), + ui: { + setStatus: (key, value) => { status[key] = value; }, + notify() {}, confirm: async () => true, theme: { fg: (_color, text) => text }, + }, + }; + runtime = { + footer: () => status.chrome, + chromeTools: () => active.some((name) => name.startsWith("chrome_")), + start: () => handlers.session_start({ type: "session_start" }, ctx), + shutdown: (reason) => handlers.session_shutdown({ type: "session_shutdown", reason }), + chrome: (args) => commands.chrome.handler(args, ctx), + }; + return runtime; + }; + // End the current runtime for `reason`, then start the next one. + const replace = async (reason) => { + await runtime.shutdown(reason); + const next = load(); + await next.start(); + return next; + }; + // Stops the bridge and timers of the live runtime, so a failed assertion cannot hang the run. + return { load, replace, end: () => runtime?.shutdown("quit") }; +} + +test("PI_CHROME_AUTHORIZE grants every runtime without announcing it", async (t) => { + const pi = piProcess({ PI_CHROME_AUTHORIZE: "indefinite" }); + t.after(() => pi.end()); + const first = pi.load(); + await first.start(); + assert.equal(first.chromeTools(), true); + assert.equal(first.footer(), undefined); + for (const reason of ["reload", "new", "resume", "fork"]) { + const next = await pi.replace(reason); + assert.equal(next.chromeTools(), true, `tools after ${reason}`); + assert.equal(next.footer(), undefined, `footer after ${reason}`); + } +}); + +test("/chrome authorize survives /reload and ends with its session", async (t) => { + const pi = piProcess({}); + t.after(() => pi.end()); + const first = pi.load(); + await first.start(); + assert.equal(first.chromeTools(), false); + await first.chrome("authorize indefinite"); + assert.equal(first.chromeTools(), true); + assert.equal(first.footer(), "● Chrome Bridge (indefinite)"); + + const reloaded = await pi.replace("reload"); + assert.equal(reloaded.chromeTools(), true); + assert.equal(reloaded.footer(), "● Chrome Bridge (indefinite)"); + + for (const reason of ["new", "resume", "fork"]) { + await (await pi.replace("reload")).chrome("authorize indefinite"); + const next = await pi.replace(reason); + assert.equal(next.chromeTools(), false, `tools after ${reason}`); + assert.equal(next.footer(), undefined, `footer after ${reason}`); + } +}); + +test("/chrome authorize over PI_CHROME_AUTHORIZE is announced, and a switch falls back to the env grant", async (t) => { + const pi = piProcess({ PI_CHROME_AUTHORIZE: "indefinite" }); + t.after(() => pi.end()); + const first = pi.load(); + await first.start(); + await first.chrome("authorize 15m"); + assert.match(first.footer(), /^● Chrome Bridge \(\d+m\)$/); + + const reloaded = await pi.replace("reload"); + assert.match(reloaded.footer(), /^● Chrome Bridge \(\d+m\)$/); + + const next = await pi.replace("new"); + assert.equal(next.chromeTools(), true); + assert.equal(next.footer(), undefined); +});
M
test-suite/unit/session-cleanup.test.mjs
→
test-suite/unit/session-cleanup.test.mjs
@@ -19,7 +19,7 @@ const timers = new Map();
const events = []; let shutdown; const token = Symbol(); - const globalState = { loaded: { token } }; + const globalState = { loaded: { token }, auth: { until: "indefinite" } }; const context = { AbortController, Promise, setTimeout(fn, ms) { const key = Symbol(); timers.set(key, { fn, ms }); return key; },@@ -27,7 +27,7 @@ clearTimeout(key) { timers.delete(key); },
bridge: { send(...args) { events.push("send"); return send(...args); }, stop() { events.push("stop"); } }, sessionCtx: {}, sessionKeyFor: () => sessionKey ?? undefined, clearAuthExpiryTimer() {}, clearCountdownInterval() {}, - globalState, PI_CHROME_GLOBAL_KEY: "loaded", instanceToken: token, + globalState, PI_CHROME_GLOBAL_KEY: "loaded", PI_CHROME_AUTH_KEY: "auth", instanceToken: token, pi: { on(name, handler) { assert.equal(name, "session_shutdown"); shutdown = handler; } }, }; vm.runInNewContext(`${helper}\n${listener}`, context);@@ -78,6 +78,14 @@ for (const reason of ["quit", "new", "resume", "fork"]) {
const h = harness(() => Promise.resolve({})); await h.shutdown({ reason }); assert.deepEqual(h.events, ["send", "stop"]); + // A /chrome authorize grant stays with its session: a session switch drops it, quit leaves it + // for a parent session sharing this process. + assert.equal("auth" in h.globalState, reason === "quit", `grant after ${reason}`); +} +{ + const h = harness(() => Promise.resolve({})); + await h.shutdown({ reason: "reload" }); + assert.deepEqual(h.globalState.auth, { until: "indefinite" }, "grant after reload"); } for (const send of [() => Promise.reject(new Error("offline")), () => { throw new Error("offline"); }]) { const h = harness(send);@@ -90,4 +98,4 @@ const h = harness(() => { throw new Error("unscoped cleanup must not send"); }, null);
await h.shutdown({ reason: "exit" }); assert.deepEqual(h.events, ["stop"]); } -console.log("session-cleanup: shutdown ordering, reload, deadline, failures, and missing identity passed"); +console.log("session-cleanup: shutdown ordering, reload, deadline, failures, missing identity, and grant scope passed");