From ff99a4432a3feedfac10d9514ce661a9cb95004c Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Fri, 6 Nov 2026 13:49:39 +0200 Subject: 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. --- extensions/chrome-profile-bridge/index.ts | 6 ++ package.json | 2 +- test-suite/unit/grant-scope.test.mjs | 121 ++++++++++++++++++++++++++++++ test-suite/unit/session-cleanup.test.mjs | 14 +++- 4 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 test-suite/unit/grant-scope.test.mjs diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts index 6156e81..158584d 100644 --- a/extensions/chrome-profile-bridge/index.ts +++ b/extensions/chrome-profile-bridge/index.ts @@ -1204,6 +1204,12 @@ export default function (pi: ExtensionAPI): void { // /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]; diff --git a/package.json b/package.json index 97ac4ea..527b255 100644 --- a/package.json +++ b/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" }, diff --git a/test-suite/unit/grant-scope.test.mjs b/test-suite/unit/grant-scope.test.mjs new file mode 100644 index 0000000..e7ce044 --- /dev/null +++ b/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); +}); diff --git a/test-suite/unit/session-cleanup.test.mjs b/test-suite/unit/session-cleanup.test.mjs index 7e3285f..604e691 100644 --- a/test-suite/unit/session-cleanup.test.mjs +++ b/test-suite/unit/session-cleanup.test.mjs @@ -19,7 +19,7 @@ function harness(send, sessionKey = "session:test") { 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 @@ function harness(send, sessionKey = "session:test") { 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 @@ for (const send of [() => Promise.reject(new Error("offline")), () => { throw ne 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"); -- cgit v1.2.3