aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTJ Dong <122852285+tj-dong@users.noreply.github.com>2026-06-22 16:55:20 -0700
committerGitHub <noreply@github.com>2026-06-22 19:55:20 -0400
commitb8165967408be47e93b6a566270988177f8f6866 (patch)
tree58cb4999702566a24a0384a35c0b9aab1db3c121
parentdce772f330db12a73e85297918882c0c4f28f030 (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.
-rw-r--r--CHANGELOG.md8
-rw-r--r--README.md11
-rw-r--r--docs/FAQ.md10
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/manifest.json2
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/service_worker.js200
-rw-r--r--extensions/chrome-profile-bridge/index.ts55
-rw-r--r--package.json4
-rw-r--r--test-suite/unit/automation-target.test.mjs344
8 files changed, 615 insertions, 19 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9685c21..0341dfd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
All notable user-facing changes to `pi-chrome`.
+## 0.15.39 — 2026-06-07
+
+- **Dedicated automation tab/window (no more hijacking your active tab).** When a chrome_* action runs without an explicit target, pi-chrome now opens and reuses a dedicated automation window it owns (falling back to a dedicated tab if a separate window can't be created) instead of navigating whatever tab you currently have open. Your existing tabs/windows are left untouched. Pass `targetId`/`urlIncludes`/`titleIncludes` to act on a specific existing tab.
+ - **Per session.** Ownership is scoped to the calling Pi session, so concurrent sessions sharing one companion extension each get their own automation window instead of fighting over a tab.
+ - **Survives `/reload` and Chrome service-worker restarts.** Ownership is tracked by id and mirrored to `chrome.storage.session`, so the window is reused rather than orphaned.
+ - **Guarded tab management.** `chrome_tab` `activate`/`close`/`group`/`ungroup` with no target now act on the session's automation tab if present, otherwise error — they no longer fall back to (or spawn a throwaway tab to touch) your active tab.
+ - **Safe, non-blocking cleanup.** The owned target is closed on `/chrome revoke` and on real session end (never on `/reload`); cleanup only ever closes the calling session's own window/tab — never user tabs/windows or another session's target — and is fire-and-forget so it never blocks `/quit`, `/reload`, or session end.
+
## 0.15.38 — 2026-06-07
- **Overlay-safe click/fill fallbacks.** `chrome_click` and `chrome_fill` now fall back to DOM-dispatched click/value events when Chrome's debugger input path is blocked by another extension overlay (for example password-manager/autofill UI), unless `domFallback:false` is passed.
diff --git a/README.md b/README.md
index 4a35f38..7b762ea 100644
--- a/README.md
+++ b/README.md
@@ -173,6 +173,17 @@ Agents can verify page state immediately instead of blindly retrying.
Each tool is documented inline in Pi — agents see the parameters and gotchas (Chrome input, CSP limits, file upload behavior) without trial-and-error.
+### Tab/window isolation
+
+pi-chrome never overwrites the tab you're currently looking at. The first time a chrome_* action runs without an explicit target, pi-chrome opens a **dedicated automation window** (falling back to a dedicated tab if a separate window can't be created) and reuses it for the rest of the session. Your existing tabs and windows are left untouched. To point pi-chrome at a specific tab you already have open, pass `targetId`/`urlIncludes`/`titleIncludes`.
+
+Details:
+
+- **Per session.** Each Pi session owns its *own* automation window, so concurrent sessions (which all share one companion extension) never fight over a tab.
+- **Survives `/reload` and Chrome service-worker restarts.** Ownership is tracked by id and mirrored to `chrome.storage.session`, so the window is reused rather than orphaned.
+- **Cleanup is safe.** The dedicated target is closed when you revoke Chrome control (`/chrome revoke`) and on real session end — never on `/reload`. Cleanup only ever closes the calling session's own automation window/tab; user tabs/windows and other sessions' targets are never closed. Cleanup is fire-and-forget so it never blocks `/quit`, `/reload`, or session end.
+- **Management actions are guarded.** `chrome_tab` `activate`/`close`/`group`/`ungroup` with no explicit target act on the session's automation tab if it exists, otherwise they error instead of touching your active tab.
+
### Known limits vs. human Chrome use
pi-chrome is strongest on web-page workflows exposed through DOM, screenshots, tabs, and Chrome input. It is not a full human/OS substitute. Current limitations include native Chrome/OS surfaces (print/save dialogs, permission bubbles, password-manager prompts), cross-origin iframe DOM access, rich multitouch/pinch/stylus gestures, visual CAPTCHA/bot challenges, hardware-backed auth (passkeys/security keys/biometrics), and arbitrary OS app interaction. For strict-CSP pages, use screenshot + coordinate input when `chrome_snapshot`/`chrome_evaluate` are blocked.
diff --git a/docs/FAQ.md b/docs/FAQ.md
index 3893575..a450a93 100644
--- a/docs/FAQ.md
+++ b/docs/FAQ.md
@@ -30,7 +30,15 @@ Chrome control is also locked per Pi session until you run `/chrome authorize`;
## Can multiple Pi sessions use it at once?
-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.
+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. Each session also owns its **own** dedicated automation window (ownership is keyed by session id inside the one extension), so concurrent sessions never navigate into or close each other's tabs.
+
+## Does pi-chrome navigate my current tab?
+
+No. The first chrome_* action that has no explicit target opens a **dedicated automation window** that pi-chrome owns (falling back to a dedicated tab only if a separate window can't be created), and reuses it for the rest of the session. Your existing tabs and windows are never reused or overwritten. Pass `targetId`/`urlIncludes`/`titleIncludes` to deliberately act on a tab you already have open.
+
+The window survives `/reload` and Chrome service-worker restarts because ownership is tracked by id and mirrored to `chrome.storage.session`. It is closed when you run `/chrome revoke` and on real session end (not on `/reload`); cleanup only ever closes that session's own window/tab.
+
+**After a full browser restart**, `chrome.storage.session` is cleared by Chrome. If Chrome's session-restore reopens the old automation window, pi-chrome no longer recognizes it (its tracking is gone), so it is left alone as an ordinary window — pi-chrome will open a fresh dedicated window for the new run rather than reclaim or close the restored one. pi-chrome never closes a window it can't positively identify as its own, so a user window is never at risk.
## Why ship as an unpacked extension?
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(),
diff --git a/package.json b/package.json
index 7a7f827..8f2b5fa 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,8 @@
{
"name": "pi-chrome",
- "version": "0.15.38",
+ "version": "0.15.39",
"scripts": {
- "test": "node test-suite/unit/csp-eval.test.mjs",
+ "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs",
"version": "node scripts/sync-manifest-version.js",
"prepublishOnly": "node scripts/sync-manifest-version.js"
},
diff --git a/test-suite/unit/automation-target.test.mjs b/test-suite/unit/automation-target.test.mjs
new file mode 100644
index 0000000..541e08a
--- /dev/null
+++ b/test-suite/unit/automation-target.test.mjs
@@ -0,0 +1,344 @@
+// Unit harness for pi-chrome's dedicated automation tab/window isolation in service_worker.js.
+//
+// Feature under test: pi-chrome must never navigate or replace the user's active tab. Page and
+// navigation actions without an explicit target are routed to a dedicated automation target that
+// the *calling Pi session* created and owns. Ownership is session-scoped (one extension brokers
+// every session) and mirrored to chrome.storage.session so a service-worker restart re-hydrates
+// it instead of orphaning the window. Cleanup closes only the calling session's owned target.
+//
+// Like csp-eval.test.mjs we load the *real* worker into a vm sandbox with a stateful chrome.*
+// mock, then exercise the real helpers and the real dispatch() paths. Chrome state (tabs/windows/
+// storage.session) can be shared across two sandbox loads to simulate a service-worker restart.
+
+import vm from "node:vm";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const workerPath = path.resolve(__dirname, "../../extensions/chrome-profile-bridge/browser-extension/service_worker.js");
+const src = fs.readFileSync(workerPath, "utf8");
+
+let failures = 0;
+let passes = 0;
+function ok(cond, msg) {
+ if (cond) { passes++; }
+ else { failures++; console.error(` ✗ ${msg}`); }
+}
+async function throwsWith(fn, re, msg) {
+ try { await fn(); ok(false, `${msg} (expected throw)`); }
+ catch (e) { ok(re.test(String(e.message || e)), `${msg} (got: ${e.message})`); }
+}
+
+// ---- stateful Chrome mock. `state` (tabs/windows/storage) can be shared to simulate a
+// service-worker restart: the browser keeps its tabs/windows/session-storage, the worker memory
+// is wiped (a fresh sandbox).
+function makeChromeState() {
+ const tabs = new Map(); // id -> { id, windowId, url, active, groupId }
+ const windows = new Map(); // id -> { id }
+ const groups = new Map(); // groupId -> { id, title, color, collapsed, windowId }
+ const storage = {}; // chrome.storage.session backing
+ let nextTabId = 1;
+ let nextWindowId = 1;
+ let nextGroupId = 1;
+ const alloc = { tab: () => nextTabId++, window: () => nextWindowId++, group: () => nextGroupId++ };
+
+ // Seed a user window with two real user tabs (Gmail + a research article, the active one).
+ const userWindowId = alloc.window();
+ windows.set(userWindowId, { id: userWindowId });
+ const userGmail = { id: alloc.tab(), windowId: userWindowId, url: "https://mail.google.com/", active: false, groupId: -1 };
+ const userArticle = { id: alloc.tab(), windowId: userWindowId, url: "https://example.com/research-article", active: true, groupId: -1 };
+ tabs.set(userGmail.id, userGmail);
+ tabs.set(userArticle.id, userArticle);
+
+ return { tabs, windows, groups, storage, alloc, userWindowId, userGmail, userArticle };
+}
+
+function makeChrome(state, { withWindows = true, withStorage = true, withTabGroups = false } = {}) {
+ const { tabs, windows, groups, storage, alloc, userWindowId } = state;
+ const noop = () => {};
+ const listener = { addListener: noop, removeListener: noop };
+
+ const chrome = {
+ runtime: { id: "unittestextension", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener, lastError: null },
+ alarms: { onAlarm: listener, create: noop, clear: noop, clearAll: noop },
+ action: { onClicked: listener },
+ debugger: { sendCommand: noop, attach: async () => {}, detach: async () => {}, getTargets: (cb) => cb([]), onDetach: listener },
+ scripting: { executeScript: async () => [{ result: undefined }], registerContentScripts: async () => {}, unregisterContentScripts: async () => {} },
+ webNavigation: { onCommitted: listener },
+ tabs: {
+ onUpdated: listener,
+ query: async (q = {}) => {
+ let list = [...tabs.values()];
+ if (q.active === true) list = list.filter((t) => t.active);
+ if (typeof q.windowId === "number") list = list.filter((t) => t.windowId === q.windowId);
+ return list.map((t) => ({ ...t }));
+ },
+ get: async (id) => { const t = tabs.get(id); if (!t) throw new Error(`No tab with id ${id}`); return { ...t }; },
+ create: async ({ url = "about:blank", active = false } = {}) => {
+ const tab = { id: alloc.tab(), windowId: userWindowId, url, active, groupId: -1 };
+ tabs.set(tab.id, tab);
+ return { ...tab };
+ },
+ update: async (id, props = {}) => { const t = tabs.get(id); if (!t) throw new Error(`No tab with id ${id}`); Object.assign(t, props); return { ...t }; },
+ remove: async (id) => { tabs.delete(id); },
+ group: async ({ groupId, tabIds = [] } = {}) => {
+ let gid = groupId;
+ if (typeof gid !== "number") {
+ gid = alloc.group();
+ const firstTab = tabs.get(tabIds[0]);
+ groups.set(gid, { id: gid, title: "", color: "grey", collapsed: false, windowId: firstTab ? firstTab.windowId : userWindowId });
+ }
+ for (const tid of tabIds) { const t = tabs.get(tid); if (t) t.groupId = gid; }
+ return gid;
+ },
+ ungroup: async (id) => { const ids = Array.isArray(id) ? id : [id]; for (const tid of ids) { const t = tabs.get(tid); if (t) t.groupId = -1; } },
+ },
+ storage: withStorage ? {
+ session: {
+ get: async (key) => (key in storage ? { [key]: storage[key] } : {}),
+ set: async (obj) => { Object.assign(storage, obj); },
+ },
+ } : undefined,
+ };
+
+ if (withTabGroups) {
+ chrome.tabGroups = {
+ query: async ({ windowId } = {}) => [...groups.values()].filter((g) => windowId === undefined || g.windowId === windowId).map((g) => ({ ...g })),
+ get: async (id) => { const g = groups.get(id); if (!g) throw new Error(`No group ${id}`); return { ...g }; },
+ update: async (id, props = {}) => { const g = groups.get(id); if (!g) throw new Error(`No group ${id}`); Object.assign(g, props); return { ...g }; },
+ };
+ }
+
+ if (withWindows) {
+ chrome.windows = {
+ create: async ({ url = "about:blank", focused = false } = {}) => {
+ const id = alloc.window();
+ windows.set(id, { id });
+ const tab = { id: alloc.tab(), windowId: id, url, active: true, groupId: -1 };
+ tabs.set(tab.id, tab);
+ return { id, focused, tabs: [{ ...tab }] };
+ },
+ get: async (id) => { const w = windows.get(id); if (!w) throw new Error(`No window with id ${id}`); return { ...w }; },
+ remove: async (id) => { windows.delete(id); for (const [tid, t] of [...tabs]) if (t.windowId === id) tabs.delete(tid); },
+ update: async () => {},
+ };
+ } else {
+ chrome.windows = { update: async () => {} }; // no create/get/remove -> tab fallback path
+ }
+
+ return chrome;
+}
+
+function loadWorker(chrome) {
+ const noop = () => {};
+ const sandbox = {
+ console, JSON, Date, Math, Promise, Array, Object, String, Number, Boolean,
+ Error, TypeError, Map, Set, BigInt, Symbol, structuredClone,
+ setTimeout, clearTimeout, setInterval: () => 0, clearInterval: noop,
+ fetch: async () => { throw new Error("no network in unit test"); },
+ navigator: { userAgent: "unit-test" },
+ WebSocket: function () {},
+ chrome,
+ };
+ sandbox.globalThis = sandbox;
+ sandbox.self = sandbox;
+ vm.createContext(sandbox);
+ vm.runInContext(src, sandbox);
+ return sandbox;
+}
+
+const SK = "session:alpha"; // a representative sessionKey
+
+async function run() {
+ // ===== Isolation: navigation does not touch the user's active/other tabs. =====
+ {
+ const state = makeChromeState();
+ const w = loadWorker(makeChrome(state));
+ const userActiveUrl = state.userArticle.url;
+
+ const nav = await w.dispatch("page.navigate", { url: "https://pi.test/task", waitUntilLoad: false, sessionKey: SK });
+ ok(state.userArticle.url === userActiveUrl, "navigate: active user tab (research article) is not overwritten");
+ ok(state.userGmail.url === "https://mail.google.com/", "navigate: other user tab (Gmail) untouched");
+ ok(nav.url === "https://pi.test/task", "navigate: automation target navigated to requested URL");
+ ok(nav.id !== state.userArticle.id && nav.id !== state.userGmail.id, "navigate: did not reuse any user tab");
+ ok(nav.windowId !== state.userWindowId, "navigate: automation target lives in a dedicated window");
+
+ const status = await w.dispatch("automation.status", { sessionKey: SK });
+ ok(status.tabId === nav.id && status.windowId === nav.windowId, "ownership: target ids tracked for the session");
+ ok(w.isPiChromeOwnedTarget(nav.id, SK) === true, "ownership: isPiChromeOwnedTarget(owned, session) === true");
+ ok(w.isPiChromeOwnedTarget(state.userArticle.id) === false, "ownership: user tab is never owned (any session)");
+
+ // Reuse: a later navigation reuses the same owned target.
+ const nav2 = await w.dispatch("page.navigate", { url: "https://pi.test/step-2", waitUntilLoad: false, sessionKey: SK });
+ ok(nav2.id === nav.id && nav2.windowId === nav.windowId, "reuse: second navigation reuses the same automation window/tab");
+ ok(state.userArticle.url === userActiveUrl, "reuse: user tab still untouched after second navigation");
+
+ // Cleanup closes only the owned window; user tabs/windows survive.
+ const cleanup = await w.dispatch("automation.cleanup", { sessionKey: SK });
+ ok(cleanup.closedWindowId === nav.windowId, "cleanup: closed the owned window");
+ ok(state.tabs.has(state.userArticle.id) && state.tabs.has(state.userGmail.id), "cleanup: user tabs never closed");
+ ok(state.windows.has(state.userWindowId), "cleanup: user window never closed");
+ ok(!state.tabs.has(nav.id), "cleanup: the owned automation tab is gone");
+ const status2 = await w.dispatch("automation.status", { sessionKey: SK });
+ ok(status2.tabId === null && status2.windowId === null, "cleanup: ownership cleared");
+ }
+
+ // ===== Session-group integration: the dedicated-window tab joins this session's group. =====
+ {
+ const state = makeChromeState();
+ const w = loadWorker(makeChrome(state, { withTabGroups: true }));
+ // index.ts tags page.* actions with joinSessionGroup + sessionGroupTitle; replicate that here.
+ const groupTitle = "Pi Session: alpha";
+ const nav = await w.dispatch("page.navigate", {
+ url: "https://pi.test/grouped", waitUntilLoad: false,
+ sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: groupTitle,
+ });
+ const navTab = state.tabs.get(nav.id);
+ ok(navTab.windowId !== state.userWindowId, "group: automation tab is in its dedicated window");
+ ok(typeof navTab.groupId === "number" && navTab.groupId >= 0, "group: automation tab joined a tab group");
+ const grp = state.groups.get(navTab.groupId);
+ ok(grp && grp.title === groupTitle, "group: the group is titled with this session's title");
+ ok(grp.windowId === navTab.windowId, "group: the session group lives inside the dedicated automation window (not the user window)");
+
+ // A second page action reuses the same tab and does not spawn a second group.
+ const groupsBefore = state.groups.size;
+ await w.dispatch("page.navigate", { url: "https://pi.test/grouped-2", waitUntilLoad: false, sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: groupTitle });
+ ok(state.groups.size === groupsBefore, "group: reusing the automation tab does not create a second group");
+ }
+
+ // ===== Grouping is best-effort: a tabGroups failure must not break navigation. =====
+ {
+ const state = makeChromeState();
+ const chrome = makeChrome(state, { withTabGroups: true });
+ chrome.tabs.group = async () => { throw new Error("group blew up"); };
+ const w = loadWorker(chrome);
+ const nav = await w.dispatch("page.navigate", { url: "https://pi.test/group-fail", waitUntilLoad: false, sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: "Pi Session: alpha" });
+ ok(nav.url === "https://pi.test/group-fail", "group-fail: navigation still succeeds when grouping throws");
+ ok(state.tabs.get(nav.id).windowId !== state.userWindowId, "group-fail: still used the dedicated automation window");
+ }
+
+ // ===== Concurrency: two sessions get separate windows; cleanup is per-session. =====
+ {
+ const state = makeChromeState();
+ const w = loadWorker(makeChrome(state));
+ const a = await w.dispatch("page.navigate", { url: "https://pi.test/a", waitUntilLoad: false, sessionKey: "session:A" });
+ const b = await w.dispatch("page.navigate", { url: "https://pi.test/b", waitUntilLoad: false, sessionKey: "session:B" });
+ ok(a.id !== b.id && a.windowId !== b.windowId, "concurrency: each session gets its own dedicated window/tab");
+ ok(w.isPiChromeOwnedTarget(a.id, "session:A") && !w.isPiChromeOwnedTarget(a.id, "session:B"), "concurrency: ownership is scoped to the creating session");
+
+ // Cleaning up session A must not touch session B's target.
+ await w.dispatch("automation.cleanup", { sessionKey: "session:A" });
+ ok(!state.tabs.has(a.id), "concurrency: cleanup closed session A's tab");
+ ok(state.tabs.has(b.id), "concurrency: cleanup left session B's tab open");
+ const bStatus = await w.dispatch("automation.status", { sessionKey: "session:B" });
+ ok(bStatus.tabId === b.id, "concurrency: session B still owns its target after A cleanup");
+ }
+
+ // ===== Service-worker restart / reconnect: persisted ownership re-hydrates from storage. =====
+ {
+ const state = makeChromeState();
+ const w1 = loadWorker(makeChrome(state));
+ const nav = await w1.dispatch("page.navigate", { url: "https://pi.test/persist", waitUntilLoad: false, sessionKey: SK });
+ ok(typeof state.storage.piChromeAutomationTargets === "object", "restart: ownership was persisted to storage.session");
+
+ // Simulate the MV3 service worker being suspended and restarted: fresh sandbox (memory wiped),
+ // same browser tabs/windows + same session storage.
+ const w2 = loadWorker(makeChrome(state));
+ const statusAfterRestart = await w2.dispatch("automation.status", { sessionKey: SK });
+ ok(statusAfterRestart.tabId === nav.id && statusAfterRestart.windowId === nav.windowId, "restart: re-hydrated the owned target from storage");
+
+ // A navigation after restart must REUSE the existing window, not orphan it with a new one.
+ const windowsBefore = state.windows.size;
+ const nav2 = await w2.dispatch("page.navigate", { url: "https://pi.test/persist-2", waitUntilLoad: false, sessionKey: SK });
+ ok(nav2.id === nav.id && nav2.windowId === nav.windowId, "restart: navigation after restart reuses the persisted window (no orphan)");
+ ok(state.windows.size === windowsBefore, "restart: no new window created after restart");
+
+ // Cleanup after restart works and clears persisted state.
+ await w2.dispatch("automation.cleanup", { sessionKey: SK });
+ const persisted = state.storage.piChromeAutomationTargets || {};
+ ok(!(SK in persisted), "restart: cleanup removed the session from persisted storage");
+ }
+
+ // ===== Restart after the user manually closed the window: no orphan, fresh target. =====
+ {
+ const state = makeChromeState();
+ const w1 = loadWorker(makeChrome(state));
+ const nav = await w1.dispatch("page.navigate", { url: "https://pi.test/closed", waitUntilLoad: false, sessionKey: SK });
+ await state.windows.delete(nav.windowId); // user closed pi-chrome's window
+ for (const [tid, t] of [...state.tabs]) if (t.windowId === nav.windowId) state.tabs.delete(tid);
+
+ const w2 = loadWorker(makeChrome(state)); // SW restart
+ const nav2 = await w2.dispatch("page.navigate", { url: "https://pi.test/reopened", waitUntilLoad: false, sessionKey: SK });
+ ok(nav2.id !== nav.id, "restart-after-close: a fresh automation target is created when the persisted one is gone");
+ ok(state.tabs.has(nav2.id), "restart-after-close: new target exists");
+ }
+
+ // ===== tab.* management never auto-creates / never falls back to the user's active tab. =====
+ {
+ const state = makeChromeState();
+ const w = loadWorker(makeChrome(state));
+ const windowsBefore = state.windows.size;
+ const tabsBefore = state.tabs.size;
+
+ await throwsWith(
+ () => w.dispatch("tab.close", { sessionKey: SK }),
+ /no automation tab yet|Pass targetId/,
+ "tab.close: with no target and no owned target, errors instead of closing the user's active tab",
+ );
+ ok(state.tabs.has(state.userArticle.id), "tab.close: user's active tab was NOT closed");
+ ok(state.windows.size === windowsBefore && state.tabs.size === tabsBefore, "tab.close: did not spawn a throwaway tab/window");
+
+ await throwsWith(() => w.dispatch("tab.activate", { sessionKey: SK }), /no automation tab yet|Pass targetId/, "tab.activate: errors with no target/owned target");
+
+ // Once an automation target exists, management actions operate on it (not on the user tab).
+ const nav = await w.dispatch("page.navigate", { url: "https://pi.test/manage", waitUntilLoad: false, sessionKey: SK });
+ const closed = await w.dispatch("tab.close", { sessionKey: SK });
+ ok(closed.closed === nav.id, "tab.close: with an owned target, closes that target");
+ ok(state.tabs.has(state.userArticle.id), "tab.close: user tab still safe after closing the owned target");
+ }
+
+ // ===== Explicit targeting still works on any existing tab (no regression). =====
+ {
+ const state = makeChromeState();
+ const w = loadWorker(makeChrome(state));
+ const nav = await w.dispatch("page.navigate", { url: "https://pi.test/explicit", targetId: String(state.userGmail.id), waitUntilLoad: false, sessionKey: SK });
+ ok(nav.id === state.userGmail.id, "explicit: targetId routes to the requested existing tab");
+ ok(state.userGmail.url === "https://pi.test/explicit", "explicit: explicitly targeted tab is navigated");
+ const status = await w.dispatch("automation.status", { sessionKey: SK });
+ ok(status.tabId === null, "explicit: explicit targeting does not create/claim an automation target");
+ }
+
+ // ===== Window-unavailable fallback: a dedicated TAB is used, and the user's window is safe. =====
+ {
+ const state = makeChromeState();
+ const w = loadWorker(makeChrome(state, { withWindows: false }));
+ const target = await w.getOrCreateAutomationTarget(SK);
+ ok(target.id !== state.userArticle.id && target.id !== state.userGmail.id, "fallback: created a dedicated tab, not a user tab");
+ ok(w.isPiChromeOwnedTarget(target.id, SK) === true, "fallback: dedicated tab is owned");
+ const cleanup = await w.cleanupAutomationTarget(SK);
+ ok(cleanup.closedTabId === target.id && cleanup.closedWindowId === null, "fallback: cleanup closes only the owned tab (never the shared window)");
+ ok(state.windows.has(state.userWindowId), "fallback: cleanup never closes the user/shared window");
+ ok(state.tabs.has(state.userArticle.id) && state.tabs.has(state.userGmail.id), "fallback: cleanup leaves user tabs intact");
+ }
+
+ // ===== Robust cleanup: no-op when nothing created, and when target already closed manually. =====
+ {
+ const state = makeChromeState();
+ const w = loadWorker(makeChrome(state));
+ const empty = await w.cleanupAutomationTarget(SK);
+ ok(empty.closedWindowId === null && empty.closedTabId === null, "cleanup: no-op when nothing was ever created");
+
+ const t = await w.getOrCreateAutomationTarget(SK);
+ // User closed pi-chrome's window manually (Chrome closes its tabs too).
+ state.windows.delete(t.windowId);
+ for (const [tid, tab] of [...state.tabs]) if (tab.windowId === t.windowId) state.tabs.delete(tid);
+ const stale = await w.cleanupAutomationTarget(SK);
+ ok(stale.closedWindowId === null && stale.closedTabId === null, "cleanup: robust when owned window was already closed");
+ }
+
+ console.log(`\n${passes} passed, ${failures} failed`);
+ if (failures) process.exit(1);
+}
+
+run().catch((e) => { console.error(e); process.exit(1); });