diff options
| author | TJ Dong <122852285+tj-dong@users.noreply.github.com> | 2026-06-22 16:55:20 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-22 19:55:20 -0400 |
| commit | b8165967408be47e93b6a566270988177f8f6866 (patch) | |
| tree | 58cb4999702566a24a0384a35c0b9aab1db3c121 /extensions | |
| parent | dce772f330db12a73e85297918882c0c4f28f030 (diff) | |
0.15.39: dedicated pi-chrome automation tab/window isolation (#6)
Stop pi-chrome from hijacking the user's active tab. Page/navigation actions
with no explicit target now route to a dedicated automation window owned by the
calling Pi session (falling back to a dedicated tab if a window can't be made),
instead of overwriting whatever the user has open.
- Session-scoped ownership: Map<sessionKey, target> in the service worker, keyed
by a sessionKey forwarded from index.ts, so concurrent sessions (one shared
extension) never fight over a window or close each other's tabs.
- Persistence: ownership mirrored to chrome.storage.session so a service-worker
restart re-hydrates and reuses the window instead of orphaning it. Cleared on
full browser restart; restored windows are then left alone (never reclaimed).
- getTabByParams default branch resolves the owned target; tab.activate/close/
group/ungroup pass createOwnedTarget:false so management never auto-creates a
throwaway tab or falls back to the user's active tab.
- Session-group integration: the dedicated-window tab still joins this session's
tab group, in its own window; grouping stays best-effort.
- Lifecycle: cleanup on /chrome revoke and real session end (not /reload);
fire-and-forget so it never blocks /quit, /reload, or shutdown.
- Tests: new test-suite/unit/automation-target.test.mjs (isolation, reuse,
concurrency, restart/reconnect re-hydration, guarded tab.*, group integration,
window->tab fallback, robust cleanup). Docs + CHANGELOG updated.
Diffstat (limited to 'extensions')
3 files changed, 241 insertions, 16 deletions
diff --git a/extensions/chrome-profile-bridge/browser-extension/manifest.json b/extensions/chrome-profile-bridge/browser-extension/manifest.json index b4ef2e3..7d16294 100644 --- a/extensions/chrome-profile-bridge/browser-extension/manifest.json +++ b/extensions/chrome-profile-bridge/browser-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Pi Chrome Connector", - "version": "0.15.38", + "version": "0.15.39", "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.", "permissions": [ "tabs", diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js index 55de152..c393090 100644 --- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js +++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js @@ -10,6 +10,151 @@ const SCRIPTING_TIMEOUT_MS = 8_000; const ATTACH_TIMEOUT_MS = 3_000; let polling = false; +// =================== pi-chrome automation target ownership =================== +// pi-chrome must never hijack the user's active tab. When a page/navigation action runs without +// an explicit target (targetId/urlIncludes/titleIncludes), we route it to a dedicated automation +// target that pi-chrome created and owns. We prefer a separate Chrome window so the user's +// windows are left untouched; if the windows API is unavailable we fall back to a dedicated tab. +// +// Ownership is SESSION-SCOPED, keyed by the calling Pi session's `sessionKey` (forwarded on the +// wire). One Chrome extension / service worker brokers commands for *all* Pi sessions (see the +// client/server bridge in index.ts), so a single global target would make concurrent sessions +// fight over one window. A per-session map gives each session its own isolated window and lets +// cleanup close exactly that session's target — never another session's, never a user's. +// +// State is mirrored to chrome.storage.session so a service-worker restart (MV3 can suspend the +// worker at any time) re-hydrates ownership instead of orphaning the window it already created. +// storage.session is cleared on browser restart; any window restored by Chrome's session-restore +// is then untracked and simply left alone (we only ever close ids we still recognize as ours). +const automationTargets = new Map(); // sessionKey -> { windowId?: number, tabId: number } +const DEFAULT_SESSION_KEY = "__default__"; +const AUTOMATION_STORAGE_KEY = "piChromeAutomationTargets"; +let automationHydrated = false; + +function sessionKeyOf(params) { + return params && typeof params.sessionKey === "string" && params.sessionKey + ? params.sessionKey + : DEFAULT_SESSION_KEY; +} + +// Re-hydrate the in-memory ownership map from storage.session once per worker lifetime. Best +// effort: storage may be unavailable on old Chrome, and a failure just means we may create a +// fresh window (a harmless orphan) rather than reusing one. +async function hydrateAutomationTargets() { + if (automationHydrated) return; + automationHydrated = true; + try { + const stored = await chrome.storage?.session?.get?.(AUTOMATION_STORAGE_KEY); + const saved = stored && stored[AUTOMATION_STORAGE_KEY]; + if (saved && typeof saved === "object") { + for (const [key, value] of Object.entries(saved)) { + if (value && typeof value.tabId === "number") { + automationTargets.set(key, { + windowId: typeof value.windowId === "number" ? value.windowId : undefined, + tabId: value.tabId, + }); + } + } + } + } catch { + // Ignore: treat as "no persisted state". + } +} + +async function persistAutomationTargets() { + try { + const obj = {}; + for (const [key, value] of automationTargets) { + obj[key] = { windowId: typeof value.windowId === "number" ? value.windowId : null, tabId: value.tabId }; + } + await chrome.storage?.session?.set?.({ [AUTOMATION_STORAGE_KEY]: obj }); + } catch { + // Ignore: persistence is an optimization, not a correctness requirement. + } +} + +// True if `tabId` is a pi-chrome-owned automation tab. Pass `sessionKey` to check a specific +// session; omit it to check ownership across *any* session (used as a safety predicate so we +// never operate on a user-created tab). Never infers ownership from "active". +function isPiChromeOwnedTarget(tabId, sessionKey) { + if (typeof tabId !== "number") return false; + if (sessionKey !== undefined) { + const t = automationTargets.get(sessionKey); + return !!t && t.tabId === tabId; + } + for (const t of automationTargets.values()) if (t.tabId === tabId) return true; + return false; +} + +// Create a fresh dedicated automation target for `sessionKey`. Prefer an isolated window; fall +// back to a tab. Structured so a window can be the default while tab-fallback stays a one-liner. +async function createAutomationTarget(sessionKey) { + if (chrome.windows && typeof chrome.windows.create === "function") { + try { + const win = await chrome.windows.create({ url: "about:blank", focused: false }); + const created = win && Array.isArray(win.tabs) ? win.tabs[0] : undefined; + if (created && typeof created.id === "number") { + automationTargets.set(sessionKey, { windowId: typeof win.id === "number" ? win.id : undefined, tabId: created.id }); + await persistAutomationTargets(); + return created; + } + } catch { + // Window creation can fail (policy, headless, etc.); fall back to a dedicated tab below. + } + } + // Tab fallback: the tab lives in a pre-existing (user/shared) window we did NOT create, so we + // must leave windowId unset — cleanup then closes only our tab, never the user's window. + const tab = await chrome.tabs.create({ url: "about:blank", active: false }); + automationTargets.set(sessionKey, { windowId: undefined, tabId: typeof tab.id === "number" ? tab.id : undefined }); + await persistAutomationTargets(); + return tab; +} + +// Return the session's owned automation target if it still exists, else null. Robust to the user +// (or Chrome) having closed it: a stale entry is forgotten so callers can recreate cleanly. +async function resolveOwnedAutomationTarget(sessionKey) { + await hydrateAutomationTargets(); + const t = automationTargets.get(sessionKey); + if (!t || typeof t.tabId !== "number") return null; + const existing = await chrome.tabs.get(t.tabId).catch(() => null); + if (existing && typeof existing.id === "number") return existing; + automationTargets.delete(sessionKey); + await persistAutomationTargets(); + return null; +} + +// Return the session's dedicated automation target, creating it on first use (or after the user +// closed it). Used by page/navigation actions that need a live surface to drive. +async function getOrCreateAutomationTarget(sessionKey) { + return (await resolveOwnedAutomationTarget(sessionKey)) || createAutomationTarget(sessionKey); +} + +// Close only the session's pi-chrome-owned window/tab, and only if it still exists. Never touches +// user tabs/windows or other sessions' targets. Safe to call repeatedly and when nothing exists. +async function cleanupAutomationTarget(sessionKey) { + await hydrateAutomationTargets(); + const t = automationTargets.get(sessionKey); + automationTargets.delete(sessionKey); + await persistAutomationTargets(); + if (!t) return { closedWindowId: null, closedTabId: null }; + const { windowId, tabId } = t; + if (typeof windowId === "number" && chrome.windows && typeof chrome.windows.remove === "function") { + const win = await chrome.windows.get(windowId).catch(() => null); + if (win) { + await chrome.windows.remove(windowId).catch(() => {}); + return { closedWindowId: windowId, closedTabId: typeof tabId === "number" ? tabId : null }; + } + } + if (typeof tabId === "number") { + const tab = await chrome.tabs.get(tabId).catch(() => null); + if (tab) { + await chrome.tabs.remove(tabId).catch(() => {}); + return { closedWindowId: null, closedTabId: tabId }; + } + } + return { closedWindowId: null, closedTabId: null }; +} + function withTimeout(promise, ms, label, onTimeout) { let timer; return Promise.race([ @@ -977,21 +1122,24 @@ async function dispatch(action, params) { return groupTab(tab, params.groupTitle || "Pi", params.groupColor); } case "tab.activate": { - const tab = await getTabByParams(params); + // Management actions never auto-create an automation target (createOwnedTarget:false): with + // no explicit target they act on an owned target if one exists, else error — they must never + // fall back to (or spawn a tab just to touch) the user's active tab. + const tab = await getTabByParams(params, { createOwnedTarget: false }); await chrome.windows.update(tab.windowId, { focused: true }); return formatTab(await chrome.tabs.update(tab.id, { active: true })); } case "tab.group": { - const tab = await getTabByParams(params); + const tab = await getTabByParams(params, { createOwnedTarget: false }); return groupTab(tab, params.groupTitle || "Pi", params.groupColor); } case "tab.ungroup": { - const tab = await getTabByParams(params); + const tab = await getTabByParams(params, { createOwnedTarget: false }); if (typeof tab.groupId === "number" && tab.groupId >= 0) await chrome.tabs.ungroup(tab.id); return formatTab(await chrome.tabs.get(tab.id)); } case "tab.close": { - const tab = await getTabByParams(params); + const tab = await getTabByParams(params, { createOwnedTarget: false }); await chrome.tabs.remove(tab.id); return { closed: tab.id }; } @@ -1073,6 +1221,16 @@ async function dispatch(action, params) { } case "page.screenshot": return takeScreenshot(params); + case "automation.status": { + // Report this session's owned automation target (ids only). Used for diagnostics/tests. + await hydrateAutomationTargets(); + const t = automationTargets.get(sessionKeyOf(params)); + return { windowId: t?.windowId ?? null, tabId: t?.tabId ?? null }; + } + case "automation.cleanup": + // Close only THIS session's pi-chrome-owned window/tab. Never touches user tabs/windows or + // another Pi session's target. + return cleanupAutomationTarget(sessionKeyOf(params)); default: throw new Error(`Unknown action: ${action}`); } @@ -1094,7 +1252,23 @@ async function formatTab(tab) { }; } -async function getTabByParams(params) { +// Resolve which Chrome tab an action targets. +// +// Explicit targeting (targetId / urlIncludes / titleIncludes) is unchanged: callers can still act +// on any existing tab, including a user tab, when they ask for it by name. Only the implicit +// "no target given" case changed — it used to grab the user's *active* tab (and page.navigate +// would then overwrite it); it now resolves to this Pi session's dedicated automation target. +// +// `createOwnedTarget` controls the implicit case: +// - true (default): create the automation target on first use. Used by every page/content +// action — page.navigate, click/type/fill/key/hover/drag/scroll/tap/upload, snapshot, +// inspect, evaluate, screenshot, waitFor, console/network list, probe. These need a live +// surface to drive, so auto-creating is correct and they no longer touch the user's tab. +// - false: do NOT create. Used by tab.activate/close/group/ungroup (tab *management*): with no +// explicit target they operate on an already-owned automation target if one exists, else +// throw asking for an explicit target — so e.g. `chrome_tab close` can never silently close +// the user's active tab the way it used to, and never spawns a throwaway tab just to close it. +async function getTabByParams(params, { createOwnedTarget = true } = {}) { const tabs = await chrome.tabs.query({}); let tab; if (params.targetId !== undefined) { @@ -1119,8 +1293,20 @@ async function getTabByParams(params) { } else if (params.titleIncludes) { tab = tabs.find((candidate) => (candidate.title || "").includes(params.titleIncludes)); } else { - const active = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); - tab = active[0] || tabs.find((candidate) => candidate.active) || tabs[0]; + // No explicit target: use this session's dedicated automation target instead of hijacking the + // user's active tab. This keeps human browsing and Pi automation separated — navigating here + // never replaces whatever the user currently has open. Callers that *want* a specific + // existing tab pass targetId/urlIncludes/titleIncludes above. + const sessionKey = sessionKeyOf(params); + tab = createOwnedTarget + ? await getOrCreateAutomationTarget(sessionKey) + : await resolveOwnedAutomationTarget(sessionKey); + if (!tab) { + throw new Error( + "No target tab specified and this Pi session has no automation tab yet. " + + "Pass targetId/urlIncludes/titleIncludes, or run chrome_navigate first.", + ); + } } if (!tab?.id) throw new Error("No matching Chrome tab found"); const url = tab.url || ""; diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts index 51d8e26..7690f25 100644 --- a/extensions/chrome-profile-bridge/index.ts +++ b/extensions/chrome-profile-bridge/index.ts @@ -729,12 +729,23 @@ export default function (pi: ExtensionAPI): void { pi.setActiveTools(activeToolNamesWithoutChrome()); }; + // Close THIS session's dedicated automation window/tab. Fire-and-forget and best-effort: it + // must never block /quit, /reload, revoke, or session end, and the service-worker side only + // ever closes targets this session created itself (never user tabs/windows, never another + // session's target). Errors (bridge down, target already closed) are intentionally swallowed. + const cleanupAutomationTargetBestEffort = (): void => { + const sessionKey = sessionKeyFor(sessionCtx); + void bridge.send("automation.cleanup", sessionKey !== undefined ? { sessionKey } : {}, 3_000).catch(() => undefined); + }; + const lockChromeControl = (): void => { clearAuthExpiryTimer(); clearCountdownInterval(); chromeAuthorizedUntil = undefined; persistAuth(); deactivateChromeTools(); + // Revoking control ends pi-chrome's automation for this session; tidy up the target we own. + cleanupAutomationTargetBestEffort(); }; const authSummary = (): string => { @@ -779,6 +790,15 @@ export default function (pi: ExtensionAPI): void { return ""; }; + // Stable per-session key the service worker uses to scope its dedicated automation tab/window + // to *this* session (one extension brokers all sessions). The session id is stable across + // /reload, so the automation target is reused rather than orphaned. Returns undefined only + // before session_start, in which case the worker uses its default bucket. + const sessionKeyFor = (ctx: ExtensionContext | undefined): string | undefined => { + const id = ctx?.sessionManager?.getSessionId?.(); + return typeof id === "string" && id ? `session:${id}` : undefined; + }; + const updateChromeStatus = (ctx: ExtensionContext): void => { if (chromeControlAuthorized()) { ctx.ui.setStatus("chrome", ctx.ui.theme.fg("success", "●") + " Chrome Bridge" + authCountdownLabel()); @@ -818,14 +838,20 @@ export default function (pi: ExtensionAPI): void { const authorizedBridgeSend = (action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> => { requireChromeControlAuthorized(); + // Scope the service worker's dedicated automation tab/window to this session. Forwarded on + // every action so tab resolution, navigation, and cleanup all agree on which target is ours. + const sessionKey = sessionKeyFor(sessionCtx); + let wireParams: Record<string, unknown> = sessionKey !== undefined && params.sessionKey === undefined + ? { ...params, sessionKey } + : params; // Any tab Pi *uses* (page.* interactions) should join this session's group, mirroring the // auto-grouping that tab.new already does. Tagging the wire params lets getTabByParams pull - // the resolved (e.g. active) tab into the session group on the service-worker side. We skip - // tab.* actions: tab.new/group group explicitly, and activate/close/ungroup/list must not. + // the resolved tab into the session group on the service-worker side. We skip tab.* actions: + // tab.new/group group explicitly, and activate/close/ungroup/list must not. const shouldJoinGroup = action.startsWith("page.") && sessionCtx !== undefined && params.sessionGroupTitle === undefined; - const wireParams = shouldJoinGroup - ? { ...params, sessionGroupTitle: sessionGroupTitle(sessionCtx as ExtensionContext), joinSessionGroup: true } - : params; + if (shouldJoinGroup) { + wireParams = { ...wireParams, sessionGroupTitle: sessionGroupTitle(sessionCtx as ExtensionContext), joinSessionGroup: true }; + } return bridge.send(action, wireParams, timeoutMs, signal); }; @@ -855,9 +881,17 @@ export default function (pi: ExtensionAPI): void { updateChromeStatus(ctx); }); - pi.on("session_shutdown", () => { + pi.on("session_shutdown", (event) => { clearAuthExpiryTimer(); clearCountdownInterval(); + // Tidy up this session's dedicated automation window on real session end, but NOT on + // "reload": /reload tears down and re-evaluates this module while the *same* session + // (same sessionKey) continues, so we keep the window so it is reused, not churned. The + // call is fire-and-forget and runs before bridge.stop() so it never blocks shutdown. + // (Owner-session quit may not deliver in time since stop() closes the bridge server; + // that only ever leaves a clearly pi-chrome window for the user to close — never a user + // tab — and /chrome revoke remains the reliable, bridge-alive cleanup path.) + if (event?.reason !== "reload") cleanupAutomationTargetBestEffort(); bridge.stop(); if (globalState[PI_CHROME_GLOBAL_KEY]?.token === instanceToken) { delete globalState[PI_CHROME_GLOBAL_KEY]; @@ -872,6 +906,11 @@ export default function (pi: ExtensionAPI): void { <chrome-profile-bridge> Chrome control is available through the chrome_* tools via a companion Chrome extension installed in the user's normal Chrome profile. Tools target the existing signed-in profile: no remote-debug port, no throwaway profile. +Tab/window isolation (important): +- pi-chrome owns a dedicated automation window/tab. When a chrome_* tool runs with no explicit target, it acts on that pi-chrome-owned target — it never reuses or overwrites the user's currently active tab. The dedicated target is created on first use and reused afterward. +- To act on a specific *existing* tab (e.g. one the user asks you to use), pass targetId/urlIncludes/titleIncludes. Without one of those, assume you are working in pi-chrome's own automation target. +- pi-chrome's automation target may be closed automatically when Chrome control is revoked; user tabs/windows are never closed by pi-chrome. + Capability model (important): - Interactive controls (click/type/fill/key/hover/drag/scroll/tap) use Chrome's real input layer via chrome.debugger / CDP. Events satisfy normal user-activation gates. - Input bypasses page CSP because it is injected at browser input layer, not page JavaScript. Chrome may show the “Pi Chrome Connector started debugging this browser” banner while attached. @@ -1240,7 +1279,7 @@ Usage rules: pi.registerTool({ name: "chrome_tab", label: "Chrome Tab", - description: "List, create, activate, close, group, ungroup, or inspect tabs in the user's existing Chrome profile via the companion extension.", + description: "List, create, activate, close, group, ungroup, or inspect tabs in the user's existing Chrome profile via the companion extension. activate/close/group/ungroup require a target (targetId/urlIncludes/titleIncludes); with no target they act on this session's pi-chrome automation tab if one exists, and otherwise error rather than touching the user's active tab.", promptSnippet: "List/open/activate/close/group existing Chrome tabs through the companion extension.", parameters: Type.Object({ action: StringEnum(tabActionValues), @@ -1387,7 +1426,7 @@ Usage rules: name: "chrome_navigate", label: "Chrome Navigate", description: - "Navigate an existing Chrome tab to a URL via the companion extension. Runs in the background by default; pass background=false to focus Chrome and activate the tab so the user can watch. Optionally waits for load completion.", + "Navigate a Chrome tab to a URL via the companion extension. With no target, navigation goes to pi-chrome's own dedicated automation window/tab — it never replaces the user's active tab. Pass targetId/urlIncludes/titleIncludes only to act on a specific existing tab. Runs in the background by default; pass background=false to focus Chrome and activate the tab so the user can watch. Optionally waits for load completion.", promptSnippet: "Navigate a Chrome tab in the user's existing profile.", parameters: Type.Object({ url: Type.String(), |
