Add opt-in browser tools for desktop tabs

* Add opt-in browser tools for desktop tabs

Adds the daemon opt-in, desktop tab routing, MCP tools, and real browser automation surfaces for Paseo desktop browser tabs.

* Fix browser tools CI expectations

* Address browser tools review findings

* Restrict browser file automation paths

* Fix browser upload test on Windows

* Harden browser navigation inputs

* Make browser tools create usable tabs

* Update browser MCP empty-state test

* Fail browser tab creation when registration times out

* Fix browser screenshots for agents

* Hide disabled browser tools from agents

* Address browser tools architecture review

* Replace browser tools review tests

* Wrap browser tab registration errors

* Mock Expo Router in app unit tests

* Handle invalid browser automation requests

* Return browser failure on desktop disconnect

* Update browser disconnect websocket test

* Relax browser timeout polling test

* Handle invalid browser responses

* Return browser failure when send fails

* Remove local diagnostics and fixture paths

* Fix dev service home fallback

* Use worktree home for dev services

* Use managed daemon in desktop dev

* fix(browser): keep agent tabs addressable

Track agent-active browser targets separately from human-focused tabs and keep resident webviews alive for automation. Browser tool visibility now comes from registration while the broker reports disabled execution.

* refactor(browser): register tools through catalog

Move browser tool registration onto the shared Paseo tool catalog so the MCP server remains only the transport adapter.

* fix(settings): translate browser tools host error
This commit is contained in:
Mohamed Boudra
2026-06-30 22:33:18 +02:00
committed by GitHub
parent db03b1f3fd
commit 20385bdb50
71 changed files with 10908 additions and 115 deletions

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { sanitizeDownloadFileName } from "./ipc.js";
describe("browser automation IPC", () => {
it("strips directories from agent-supplied download filenames", () => {
expect(
sanitizeDownloadFileName({
url: "https://example.com/fallback.txt",
fileName: "../../.ssh/authorized_keys",
}),
).toBe("authorized_keys");
});
it("falls back to a safe filename when the URL has no basename", () => {
expect(sanitizeDownloadFileName({ url: "https://example.com/" })).toBe("download");
});
});

View File

@@ -0,0 +1,185 @@
import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import type { WebContents } from "electron";
import { ipcMain } from "electron";
import { BrowserAutomationExecuteRequestSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import type {
BrowserAutomationConsoleLogEntry,
BrowserAutomationCookieEntry,
} from "@getpaseo/protocol/browser-automation/rpc-schemas";
import type { TabContents, BrowserRegistry } from "./service.js";
import { executeAutomationCommand } from "./service.js";
import {
listRegisteredPaseoBrowserIds,
listRegisteredPaseoBrowserIdsForWorkspace,
getPaseoBrowserWebContents,
getWorkspaceActivePaseoBrowserWebContents,
getWorkspaceActivePaseoBrowserId,
getAgentActivePaseoBrowserId,
getPaseoBrowserWorkspaceId,
} from "../browser-webviews/index.js";
const MAX_CONSOLE_MESSAGES_PER_TAB = 200;
const consoleMessagesByContentsId = new Map<number, BrowserAutomationConsoleLogEntry[]>();
const observedContentsIds = new Set<number>();
interface IpcHandlerRegistry {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void;
}
function adaptWebContents(contents: WebContents): TabContents {
observeConsoleMessages(contents);
return {
id: contents.id,
getURL: () => contents.getURL(),
getTitle: () => contents.getTitle(),
canGoBack: () => contents.canGoBack(),
canGoForward: () => contents.canGoForward(),
isLoading: () => contents.isLoading(),
isDestroyed: () => contents.isDestroyed(),
executeJavaScript: (code: string) => contents.executeJavaScript(code),
loadURL: (url: string) => contents.loadURL(url),
goBack: () => contents.goBack(),
goForward: () => contents.goForward(),
reload: () => contents.reload(),
capturePage: () => contents.capturePage(),
getConsoleMessages: () => consoleMessagesByContentsId.get(contents.id) ?? [],
getCookies: async (url: string) =>
(await contents.session.cookies.get({ url })).map(normalizeCookie),
sendDebugCommand: async (command: string, params?: Record<string, unknown>) => {
if (!contents.debugger.isAttached()) {
contents.debugger.attach("1.3");
}
return contents.debugger.sendCommand(command, params ?? {});
},
printToPDF: async (options?: Record<string, unknown>) => contents.printToPDF(options ?? {}),
downloadURL: (input) => downloadWithContents(contents, input),
};
}
function downloadWithContents(
contents: WebContents,
input: { url: string; fileName?: string },
): Promise<{ filePath: string; totalBytes?: number; state: string }> {
const downloadDir = join(tmpdir(), "paseo-browser-downloads");
mkdirSync(downloadDir, { recursive: true });
const filePath = join(downloadDir, sanitizeDownloadFileName(input));
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
contents.session.off("will-download", onDownload);
reject(new Error(`Timed out waiting for browser download: ${input.url}`));
}, 30_000);
function onDownload(_event: Electron.Event, item: Electron.DownloadItem): void {
if (item.getURL() !== input.url) {
return;
}
clearTimeout(timeout);
contents.session.off("will-download", onDownload);
item.setSavePath(filePath);
item.once("done", (_doneEvent, state) => {
resolve({ filePath, totalBytes: item.getTotalBytes(), state });
});
}
contents.session.on("will-download", onDownload);
contents.downloadURL(input.url);
});
}
export function sanitizeDownloadFileName(input: { url: string; fileName?: string }): string {
const requestedName = input.fileName ?? basename(new URL(input.url).pathname);
return basename(requestedName) || "download";
}
function normalizeCookie(cookie: Electron.Cookie): BrowserAutomationCookieEntry {
return {
name: cookie.name,
value: cookie.value,
...(cookie.domain ? { domain: cookie.domain } : {}),
...(cookie.path ? { path: cookie.path } : {}),
secure: cookie.secure,
httpOnly: cookie.httpOnly,
...(typeof cookie.expirationDate === "number" ? { expirationDate: cookie.expirationDate } : {}),
};
}
function observeConsoleMessages(contents: WebContents): void {
if (observedContentsIds.has(contents.id)) {
return;
}
observedContentsIds.add(contents.id);
contents.on("console-message", (_event, level, message, line, sourceId) => {
const entry = normalizeConsoleMessage({ level, message, line, sourceId });
const messages = consoleMessagesByContentsId.get(contents.id) ?? [];
messages.push(entry);
consoleMessagesByContentsId.set(contents.id, messages.slice(-MAX_CONSOLE_MESSAGES_PER_TAB));
});
contents.once("destroyed", () => {
observedContentsIds.delete(contents.id);
consoleMessagesByContentsId.delete(contents.id);
});
}
function normalizeConsoleMessage(input: {
level: unknown;
message: unknown;
line: unknown;
sourceId: unknown;
}): BrowserAutomationConsoleLogEntry {
return {
level: typeof input.level === "string" ? input.level : String(input.level ?? "log"),
message: typeof input.message === "string" ? input.message : String(input.message ?? ""),
...(typeof input.sourceId === "string" && input.sourceId.length > 0
? { source: input.sourceId }
: {}),
...(typeof input.line === "number" ? { line: input.line } : {}),
timestamp: Date.now(),
};
}
function createRegistry(): BrowserRegistry {
return {
listRegisteredBrowserIds: listRegisteredPaseoBrowserIds,
listRegisteredBrowserIdsForWorkspace: listRegisteredPaseoBrowserIdsForWorkspace,
getTabContents(browserId: string): TabContents | null {
const contents = getPaseoBrowserWebContents(browserId);
return contents ? adaptWebContents(contents) : null;
},
getBrowserWorkspaceId: getPaseoBrowserWorkspaceId,
getWorkspaceActiveTabContents(workspaceId: string): TabContents | null {
const contents = getWorkspaceActivePaseoBrowserWebContents(workspaceId);
return contents ? adaptWebContents(contents) : null;
},
getWorkspaceActiveBrowserId: getWorkspaceActivePaseoBrowserId,
getAgentActiveBrowserId: getAgentActivePaseoBrowserId,
};
}
export function registerBrowserAutomationIpc(options?: { ipc?: IpcHandlerRegistry }): void {
const ipc = options?.ipc ?? ipcMain;
const registry = createRegistry();
ipc.handle("paseo:browser:execute-automation-command", async (_event, rawRequest: unknown) => {
const parsed = BrowserAutomationExecuteRequestSchema.safeParse(rawRequest);
if (!parsed.success) {
return {
requestId: readRequestId(rawRequest),
ok: false as const,
error: {
code: "browser_unsupported" as const,
message: `Invalid automation request: ${parsed.error.message}`,
retryable: false,
},
};
}
return executeAutomationCommand(parsed.data, registry);
});
}
function readRequestId(rawRequest: unknown): string {
if (typeof rawRequest !== "object" || rawRequest === null || Array.isArray(rawRequest)) {
return "unknown";
}
const requestId = (rawRequest as Record<string, unknown>).requestId;
return typeof requestId === "string" && requestId.length > 0 ? requestId : "unknown";
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { BrowserSnapshotEngine, type SnapshotPage } from "./snapshot-engine.js";
class SnapshotFixture implements SnapshotPage {
public currentUrl = "https://example.com/form";
public actionResult: unknown = true;
public getURL(): string {
return this.currentUrl;
}
public async executeJavaScript(code: string): Promise<unknown> {
if (code.includes("CANDIDATE_SELECTOR")) {
return JSON.stringify([
{
role: "textbox",
tagName: "input",
text: "Name",
selector: "#name",
attributes: { id: "name", type: "text" },
},
{
role: "button",
tagName: "button",
text: "Drop",
selector: "#drop",
attributes: { id: "drop" },
},
]);
}
return this.actionResult;
}
}
describe("BrowserSnapshotEngine", () => {
it("treats a false result from a ref action script as a stale ref", async () => {
const page = new SnapshotFixture();
const engine = new BrowserSnapshotEngine();
await engine.snapshot({ browserId: "browser-1", page });
page.actionResult = false;
await expect(engine.click({ browserId: "browser-1", page, ref: "@e1" })).resolves.toEqual({
ok: false,
reason: "stale_ref",
});
await expect(engine.focus({ browserId: "browser-1", page, ref: "@e1" })).resolves.toEqual({
ok: false,
reason: "stale_ref",
});
});
it("treats a false result from optional ref text/key actions as a stale ref", async () => {
const page = new SnapshotFixture();
const engine = new BrowserSnapshotEngine();
await engine.snapshot({ browserId: "browser-1", page });
page.actionResult = false;
await expect(
engine.typeText({ browserId: "browser-1", page, ref: "@e1", text: "Ada" }),
).resolves.toEqual({ ok: false, reason: "stale_ref" });
await expect(
engine.keypress({ browserId: "browser-1", page, ref: "@e1", key: "Enter" }),
).resolves.toEqual({ ok: false, reason: "stale_ref" });
});
it("treats a false result from drag as a stale ref", async () => {
const page = new SnapshotFixture();
const engine = new BrowserSnapshotEngine();
await engine.snapshot({ browserId: "browser-1", page });
page.actionResult = false;
await expect(
engine.drag({ browserId: "browser-1", page, sourceRef: "@e1", targetRef: "@e2" }),
).resolves.toEqual({ ok: false, reason: "stale_ref" });
});
});

View File

@@ -0,0 +1,570 @@
export interface SnapshotPage {
getURL(): string;
executeJavaScript(code: string): Promise<unknown>;
}
export interface BrowserSnapshotElement extends RawSnapshotElement {
ref: string;
}
interface RawSnapshotElement {
role: string;
tagName: string;
text: string;
selector: string;
attributes: Record<string, string>;
}
interface BrowserRefState {
nextRefNumber: number;
url: string;
refs: Map<string, RawSnapshotElement>;
}
export type BrowserRefActionResult =
| { ok: true }
| { ok: false; reason: "stale_ref" | "missing_ref" };
type BrowserRefFailure = Extract<BrowserRefActionResult, { ok: false }>;
type BrowserRefResolveResult = { ok: true; element: RawSnapshotElement } | BrowserRefFailure;
export class BrowserSnapshotEngine {
private readonly statesByBrowserId = new Map<string, BrowserRefState>();
async snapshot(input: {
browserId: string;
page: SnapshotPage;
}): Promise<BrowserSnapshotElement[]> {
const rawElements = parseRawSnapshotElements(
await input.page.executeJavaScript(SNAPSHOT_SCRIPT),
);
const state = {
nextRefNumber: 1,
url: input.page.getURL(),
refs: new Map<string, RawSnapshotElement>(),
};
const elements = rawElements.map((element) => {
const ref = `@e${state.nextRefNumber++}`;
state.refs.set(ref, element);
return {
ref,
role: element.role,
tagName: element.tagName,
text: element.text,
selector: element.selector,
attributes: element.attributes,
};
});
this.statesByBrowserId.set(input.browserId, state);
return elements;
}
async click(input: {
browserId: string;
page: SnapshotPage;
ref: string;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildClickScript(selector));
}
async fill(input: {
browserId: string;
page: SnapshotPage;
ref: string;
value: string;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildFillScript(selector, input.value));
}
async typeText(input: {
browserId: string;
page: SnapshotPage;
ref?: string;
text: string;
}): Promise<BrowserRefActionResult> {
const selector = this.resolveOptionalRef(input);
if (!selector.ok) {
return selector;
}
const result = await input.page.executeJavaScript(
buildTypeScript(selector.selector, input.text),
);
return input.ref && result === false ? { ok: false, reason: "stale_ref" } : { ok: true };
}
async keypress(input: {
browserId: string;
page: SnapshotPage;
ref?: string;
key: string;
}): Promise<BrowserRefActionResult> {
const selector = this.resolveOptionalRef(input);
if (!selector.ok) {
return selector;
}
const result = await input.page.executeJavaScript(
buildKeypressScript(selector.selector, input.key),
);
return input.ref && result === false ? { ok: false, reason: "stale_ref" } : { ok: true };
}
async focus(input: {
browserId: string;
page: SnapshotPage;
ref: string;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildFocusScript(selector));
}
async clear(input: {
browserId: string;
page: SnapshotPage;
ref: string;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildClearScript(selector));
}
async check(input: {
browserId: string;
page: SnapshotPage;
ref: string;
checked: boolean;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildCheckScript(selector, input.checked));
}
async select(input: {
browserId: string;
page: SnapshotPage;
ref: string;
value: string;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildSelectScript(selector, input.value));
}
async hover(input: {
browserId: string;
page: SnapshotPage;
ref: string;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildHoverScript(selector));
}
async drag(input: {
browserId: string;
page: SnapshotPage;
sourceRef: string;
targetRef: string;
}): Promise<BrowserRefActionResult> {
const source = this.resolveRef({
browserId: input.browserId,
page: input.page,
ref: input.sourceRef,
});
if (!source.ok) {
return source;
}
const target = this.resolveRef({
browserId: input.browserId,
page: input.page,
ref: input.targetRef,
});
if (!target.ok) {
return target;
}
const result = await input.page.executeJavaScript(
buildDragScript(source.element.selector, target.element.selector),
);
return result === false ? { ok: false, reason: "stale_ref" } : { ok: true };
}
clearBrowser(browserId: string): void {
this.statesByBrowserId.delete(browserId);
}
selectorForRef(input: {
browserId: string;
page: SnapshotPage;
ref: string;
}): { ok: true; selector: string } | BrowserRefFailure {
const resolved = this.resolveRef(input);
if (!resolved.ok) {
return resolved;
}
return { ok: true, selector: resolved.element.selector };
}
private async runRefScript(
input: { browserId: string; page: SnapshotPage; ref: string },
buildScript: (selector: string) => string,
): Promise<BrowserRefActionResult> {
const resolved = this.resolveRef(input);
if (!resolved.ok) {
return resolved;
}
const result = await input.page.executeJavaScript(buildScript(resolved.element.selector));
return result === false ? { ok: false, reason: "stale_ref" } : { ok: true };
}
private resolveRef(input: {
browserId: string;
page: SnapshotPage;
ref: string;
}): BrowserRefResolveResult {
const state = this.statesByBrowserId.get(input.browserId);
if (!state || state.url !== input.page.getURL()) {
return { ok: false, reason: "stale_ref" };
}
const element = state.refs.get(input.ref);
if (!element) {
return { ok: false, reason: "missing_ref" };
}
return { ok: true, element };
}
private resolveOptionalRef(input: {
browserId: string;
page: SnapshotPage;
ref?: string;
}): { ok: true; selector: string | undefined } | BrowserRefFailure {
if (!input.ref) {
return { ok: true, selector: undefined };
}
const resolved = this.resolveRef({
browserId: input.browserId,
page: input.page,
ref: input.ref,
});
if (!resolved.ok) {
return resolved;
}
return { ok: true, selector: resolved.element.selector };
}
}
function buildClickScript(selector: string): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
element.scrollIntoView({ block: 'center', inline: 'center' });
element.click();
return true;
})()`;
}
function buildFillScript(selector: string, value: string): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
element.scrollIntoView({ block: 'center', inline: 'center' });
element.focus();
const nextValue = ${JSON.stringify(value)};
if ('value' in element) {
element.value = nextValue;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
element.textContent = nextValue;
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: nextValue }));
return true;
})()`;
}
function buildTypeScript(selector: string | undefined, text: string): string {
return String.raw`(() => {
const element = ${selector ? `document.querySelector(${JSON.stringify(selector)})` : "document.activeElement"};
if (!element) return false;
element.scrollIntoView?.({ block: 'center', inline: 'center' });
element.focus?.();
const text = ${JSON.stringify(text)};
if ('value' in element) {
element.value = String(element.value || '') + text;
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
element.textContent = String(element.textContent || '') + text;
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
return true;
})()`;
}
function buildKeypressScript(selector: string | undefined, key: string): string {
return String.raw`(() => {
const element = ${selector ? `document.querySelector(${JSON.stringify(selector)})` : "document.activeElement"};
if (!element) return false;
element.focus?.();
const key = ${JSON.stringify(key)};
const eventInit = { bubbles: true, cancelable: true, key };
element.dispatchEvent(new KeyboardEvent('keydown', eventInit));
element.dispatchEvent(new KeyboardEvent('keypress', eventInit));
element.dispatchEvent(new KeyboardEvent('keyup', eventInit));
return true;
})()`;
}
function buildFocusScript(selector: string): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
element.scrollIntoView?.({ block: 'center', inline: 'center' });
element.focus?.();
return document.activeElement === element;
})()`;
}
function buildClearScript(selector: string): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
element.scrollIntoView?.({ block: 'center', inline: 'center' });
element.focus?.();
if ('value' in element) {
element.value = '';
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContent' }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
element.textContent = '';
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContent' }));
return true;
})()`;
}
function buildCheckScript(selector: string, checked: boolean): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
element.scrollIntoView?.({ block: 'center', inline: 'center' });
element.focus?.();
const nextChecked = ${JSON.stringify(checked)};
if ('checked' in element) {
element.checked = nextChecked;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
if (element.getAttribute('role') === 'checkbox' || element.getAttribute('role') === 'radio') {
element.setAttribute('aria-checked', String(nextChecked));
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
return false;
})()`;
}
function buildSelectScript(selector: string, value: string): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
element.scrollIntoView?.({ block: 'center', inline: 'center' });
element.focus?.();
const nextValue = ${JSON.stringify(value)};
if ('value' in element) {
element.value = nextValue;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
return false;
})()`;
}
function buildHoverScript(selector: string): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
element.scrollIntoView?.({ block: 'center', inline: 'center' });
const rect = element.getBoundingClientRect();
const eventInit = {
bubbles: true,
cancelable: true,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
screenX: window.screenX + rect.left + rect.width / 2,
screenY: window.screenY + rect.top + rect.height / 2,
view: window,
};
element.dispatchEvent(new MouseEvent('mouseover', eventInit));
element.dispatchEvent(new MouseEvent('mouseenter', eventInit));
element.dispatchEvent(new MouseEvent('mousemove', eventInit));
return true;
})()`;
}
function buildDragScript(sourceSelector: string, targetSelector: string): string {
return String.raw`(() => {
const source = document.querySelector(${JSON.stringify(sourceSelector)});
const target = document.querySelector(${JSON.stringify(targetSelector)});
if (!source || !target) return false;
source.scrollIntoView?.({ block: 'center', inline: 'center' });
target.scrollIntoView?.({ block: 'center', inline: 'center' });
const data = new DataTransfer();
const sourceRect = source.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
function eventInit(rect) {
return {
bubbles: true,
cancelable: true,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
screenX: window.screenX + rect.left + rect.width / 2,
screenY: window.screenY + rect.top + rect.height / 2,
dataTransfer: data,
view: window,
};
}
source.dispatchEvent(new MouseEvent('mousedown', eventInit(sourceRect)));
source.dispatchEvent(new DragEvent('dragstart', eventInit(sourceRect)));
target.dispatchEvent(new DragEvent('dragenter', eventInit(targetRect)));
target.dispatchEvent(new DragEvent('dragover', eventInit(targetRect)));
target.dispatchEvent(new DragEvent('drop', eventInit(targetRect)));
source.dispatchEvent(new DragEvent('dragend', eventInit(sourceRect)));
target.dispatchEvent(new MouseEvent('mouseup', eventInit(targetRect)));
return true;
})()`;
}
function parseRawSnapshotElements(value: unknown): RawSnapshotElement[] {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
if (!Array.isArray(parsed)) {
return [];
}
return parsed.flatMap((item): RawSnapshotElement[] => {
if (!item || typeof item !== "object") {
return [];
}
const record = item as Record<string, unknown>;
const selector = readString(record.selector);
if (!selector) {
return [];
}
return [
{
role: readString(record.role) || "generic",
tagName: (readString(record.tagName) || "element").toLowerCase(),
text: readString(record.text) || "",
selector,
attributes: readAttributes(record.attributes),
},
];
});
}
function readString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function readAttributes(value: unknown): Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
const result: Record<string, string> = {};
for (const [key, attributeValue] of Object.entries(value)) {
if (typeof attributeValue === "string") {
result[key] = attributeValue;
}
}
return result;
}
const SNAPSHOT_SCRIPT = String.raw`(() => {
const MAX_ELEMENTS = 200;
const CANDIDATE_SELECTOR = [
'a[href]',
'button',
'input',
'textarea',
'select',
'summary',
'[role]',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable=""]',
'[contenteditable="true"]'
].join(',');
function cssEscape(value) {
if (window.CSS && typeof window.CSS.escape === 'function') {
return window.CSS.escape(value);
}
return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
}
function isVisible(element) {
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function roleFor(element) {
const explicit = element.getAttribute('role');
if (explicit) return explicit;
const tag = element.tagName.toLowerCase();
if (tag === 'a') return 'link';
if (tag === 'button') return 'button';
if (tag === 'select') return 'combobox';
if (tag === 'textarea') return 'textbox';
if (tag === 'summary') return 'button';
if (tag === 'input') {
const type = (element.getAttribute('type') || 'text').toLowerCase();
if (type === 'checkbox') return 'checkbox';
if (type === 'radio') return 'radio';
if (type === 'button' || type === 'submit' || type === 'reset') return 'button';
return 'textbox';
}
return 'generic';
}
function textFor(element) {
const tag = element.tagName.toLowerCase();
const pieces = [
element.getAttribute('aria-label'),
element.getAttribute('alt'),
element.getAttribute('title'),
tag === 'input' ? element.getAttribute('placeholder') : null,
tag === 'input' || tag === 'textarea' ? element.value : null,
element.innerText,
element.textContent
];
const text = pieces.find((piece) => typeof piece === 'string' && piece.trim().length > 0);
return (text || '').replace(/\s+/g, ' ').trim().slice(0, 300);
}
function selectorFor(element) {
if (element.id) return '#' + cssEscape(element.id);
const parts = [];
let current = element;
while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.body) {
const tag = current.tagName.toLowerCase();
const parent = current.parentElement;
if (!parent) break;
const siblings = Array.from(parent.children).filter((sibling) => sibling.tagName === current.tagName);
const index = siblings.indexOf(current) + 1;
parts.unshift(siblings.length > 1 ? tag + ':nth-of-type(' + index + ')' : tag);
current = parent;
}
return parts.length > 0 ? parts.join(' > ') : element.tagName.toLowerCase();
}
return JSON.stringify(Array.from(document.querySelectorAll(CANDIDATE_SELECTOR))
.filter(isVisible)
.slice(0, MAX_ELEMENTS)
.map((element) => ({
role: roleFor(element),
tagName: element.tagName.toLowerCase(),
text: textFor(element),
selector: selectorFor(element),
attributes: {
...(element.id ? { id: element.id } : {}),
...(element.getAttribute('name') ? { name: element.getAttribute('name') } : {}),
...(element.getAttribute('type') ? { type: element.getAttribute('type') } : {}),
...(element.getAttribute('href') ? { href: element.getAttribute('href') } : {}),
...(element.getAttribute('aria-label') ? { 'aria-label': element.getAttribute('aria-label') } : {})
}
})));
})()`;

View File

@@ -4,11 +4,12 @@ import {
handleBrowserWindowOpenRequest,
isAllowedBrowserWebviewUrl,
} from "./window-open.js";
import { PaseoBrowserWebviewRegistry, type BrowserWorkspaceRegistration } from "./registry.js";
export { BROWSER_NEW_TAB_REQUEST_EVENT, handleBrowserWindowOpenRequest };
export type { BrowserWorkspaceRegistration };
const browserIdsByWebContentsId = new Map<number, string>();
let workspaceActiveBrowserId: string | null = null;
const browserRegistry = new PaseoBrowserWebviewRegistry();
function getBrowserIdFromWebviewPartition(partition: string | undefined): string | null {
const prefix = "persist:paseo-browser-";
@@ -30,16 +31,15 @@ export function readBrowserIdFromWebviewAttach(input: {
}
export function listRegisteredPaseoBrowserIds(): string[] {
return Array.from(new Set(browserIdsByWebContentsId.values())).sort();
return browserRegistry
.listBrowserIds()
.filter((browserId) => getPaseoBrowserWebContents(browserId));
}
export function registerPaseoBrowserWebContents(contents: WebContents, browserId: string): void {
browserIdsByWebContentsId.set(contents.id, browserId);
browserRegistry.registerWebContents({ webContentsId: contents.id, browserId });
contents.once("destroyed", () => {
browserIdsByWebContentsId.delete(contents.id);
if (workspaceActiveBrowserId === browserId) {
workspaceActiveBrowserId = null;
}
browserRegistry.unregisterWebContents(contents.id);
});
}
@@ -47,29 +47,71 @@ export function getPaseoBrowserIdForWebContents(contents: WebContents | null): s
if (!contents || contents.isDestroyed()) {
return null;
}
return browserIdsByWebContentsId.get(contents.id) ?? null;
return browserRegistry.getBrowserIdForWebContents(contents.id);
}
export function setWorkspaceActivePaseoBrowserId(browserId: string | null): void {
workspaceActiveBrowserId = browserId;
export function registerPaseoBrowserWorkspace(input: BrowserWorkspaceRegistration): void {
browserRegistry.registerWorkspace(input);
}
export function getPaseoBrowserWorkspaceId(browserId: string): string | null {
return browserRegistry.getWorkspaceId(browserId);
}
export function listRegisteredPaseoBrowserIdsForWorkspace(workspaceId: string): string[] {
return browserRegistry
.listBrowserIdsForWorkspace(workspaceId)
.filter((browserId) => getPaseoBrowserWebContents(browserId));
}
export function setWorkspaceActivePaseoBrowserId(input: {
workspaceId: string;
browserId: string | null;
}): void {
browserRegistry.setWorkspaceActiveBrowser(input);
}
export function getWorkspaceActivePaseoBrowserId(workspaceId: string): string | null {
return browserRegistry.getWorkspaceActiveBrowserId(workspaceId);
}
export function setAgentActivePaseoBrowserId(input: {
agentId: string;
browserId: string | null;
}): void {
browserRegistry.setAgentActiveBrowser(input);
}
export function getAgentActivePaseoBrowserId(agentId: string): string | null {
return browserRegistry.getAgentActiveBrowserId(agentId);
}
export function getPaseoBrowserWebContents(browserId: string): WebContents | null {
for (const [contentsId, registeredBrowserId] of browserIdsByWebContentsId) {
if (registeredBrowserId !== browserId) continue;
const contents = allWebContents.fromId(contentsId);
if (contents && !contents.isDestroyed()) {
return contents;
}
const contentsId = browserRegistry.getWebContentsIdForBrowser(browserId);
if (contentsId === null) {
return null;
}
const contents = allWebContents.fromId(contentsId);
if (contents && !contents.isDestroyed()) {
return contents;
}
browserRegistry.unregisterWebContents(contentsId);
return null;
}
export function getWorkspaceActivePaseoBrowserWebContents(): WebContents | null {
if (!workspaceActiveBrowserId) {
return null;
}
return getPaseoBrowserWebContents(workspaceActiveBrowserId);
export function getWorkspaceActivePaseoBrowserWebContents(workspaceId: string): WebContents | null {
const activeBrowserId = getWorkspaceActivePaseoBrowserId(workspaceId);
return activeBrowserId ? getPaseoBrowserWebContents(activeBrowserId) : null;
}
export function getAgentActivePaseoBrowserWebContents(agentId: string): WebContents | null {
const activeBrowserId = getAgentActivePaseoBrowserId(agentId);
return activeBrowserId ? getPaseoBrowserWebContents(activeBrowserId) : null;
}
export function getMostRecentWorkspaceActivePaseoBrowserWebContents(): WebContents | null {
const browserId = browserRegistry.getMostRecentWorkspaceActiveBrowserId();
return browserId ? getPaseoBrowserWebContents(browserId) : null;
}
function preventUnsafeBrowserWebviewNavigation(

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { PaseoBrowserWebviewRegistry } from "./registry.js";
describe("PaseoBrowserWebviewRegistry", () => {
it("keeps one authoritative webContents target per browserId", () => {
const registry = new PaseoBrowserWebviewRegistry();
registry.registerWebContents({ webContentsId: 1, browserId: "browser-a" });
registry.registerWorkspace({ browserId: "browser-a", workspaceId: "workspace-a" });
registry.setWorkspaceActiveBrowser({ workspaceId: "workspace-a", browserId: "browser-a" });
registry.setAgentActiveBrowser({ agentId: "agent-a", browserId: "browser-a" });
registry.registerWebContents({ webContentsId: 2, browserId: "browser-a" });
expect(registry.getBrowserIdForWebContents(1)).toBeNull();
expect(registry.getBrowserIdForWebContents(2)).toBe("browser-a");
expect(registry.getWebContentsIdForBrowser("browser-a")).toBe(2);
expect(registry.getWorkspaceId("browser-a")).toBe("workspace-a");
expect(registry.getWorkspaceActiveBrowserId("workspace-a")).toBe("browser-a");
expect(registry.getAgentActiveBrowserId("agent-a")).toBe("browser-a");
});
it("ignores stale destroy events after a duplicate browserId moved", () => {
const registry = new PaseoBrowserWebviewRegistry();
registry.registerWebContents({ webContentsId: 1, browserId: "browser-a" });
registry.registerWebContents({ webContentsId: 2, browserId: "browser-a" });
registry.unregisterWebContents(1);
expect(registry.getWebContentsIdForBrowser("browser-a")).toBe(2);
});
});

View File

@@ -0,0 +1,108 @@
export interface BrowserWorkspaceRegistration {
browserId: string;
workspaceId: string;
}
export class PaseoBrowserWebviewRegistry {
private readonly browserIdsByWebContentsId = new Map<number, string>();
private readonly webContentsIdsByBrowserId = new Map<string, number>();
private readonly workspaceIdsByBrowserId = new Map<string, string>();
private readonly activeBrowserIdsByWorkspaceId = new Map<string, string>();
private readonly activeBrowserIdsByAgentId = new Map<string, string>();
public registerWebContents(input: { webContentsId: number; browserId: string }): void {
const previousWebContentsId = this.webContentsIdsByBrowserId.get(input.browserId) ?? null;
if (previousWebContentsId !== null && previousWebContentsId !== input.webContentsId) {
this.browserIdsByWebContentsId.delete(previousWebContentsId);
}
this.browserIdsByWebContentsId.set(input.webContentsId, input.browserId);
this.webContentsIdsByBrowserId.set(input.browserId, input.webContentsId);
}
public unregisterWebContents(webContentsId: number): void {
const browserId = this.browserIdsByWebContentsId.get(webContentsId) ?? null;
if (!browserId) {
return;
}
this.browserIdsByWebContentsId.delete(webContentsId);
if (this.webContentsIdsByBrowserId.get(browserId) !== webContentsId) {
return;
}
this.webContentsIdsByBrowserId.delete(browserId);
this.workspaceIdsByBrowserId.delete(browserId);
this.deleteActiveBrowserReferences(browserId);
}
public getBrowserIdForWebContents(webContentsId: number): string | null {
return this.browserIdsByWebContentsId.get(webContentsId) ?? null;
}
public getWebContentsIdForBrowser(browserId: string): number | null {
return this.webContentsIdsByBrowserId.get(browserId) ?? null;
}
public listBrowserIds(): string[] {
return Array.from(this.webContentsIdsByBrowserId.keys()).sort();
}
public registerWorkspace(input: BrowserWorkspaceRegistration): void {
this.workspaceIdsByBrowserId.set(input.browserId, input.workspaceId);
}
public getWorkspaceId(browserId: string): string | null {
return this.workspaceIdsByBrowserId.get(browserId) ?? null;
}
public listBrowserIdsForWorkspace(workspaceId: string): string[] {
return this.listBrowserIds().filter(
(browserId) => this.workspaceIdsByBrowserId.get(browserId) === workspaceId,
);
}
public setWorkspaceActiveBrowser(input: { workspaceId: string; browserId: string | null }): void {
if (input.browserId) {
this.workspaceIdsByBrowserId.set(input.browserId, input.workspaceId);
this.activeBrowserIdsByWorkspaceId.delete(input.workspaceId);
this.activeBrowserIdsByWorkspaceId.set(input.workspaceId, input.browserId);
return;
}
this.activeBrowserIdsByWorkspaceId.delete(input.workspaceId);
}
public getWorkspaceActiveBrowserId(workspaceId: string): string | null {
return this.activeBrowserIdsByWorkspaceId.get(workspaceId) ?? null;
}
public getMostRecentWorkspaceActiveBrowserId(): string | null {
return Array.from(this.activeBrowserIdsByWorkspaceId.values()).at(-1) ?? null;
}
public setAgentActiveBrowser(input: { agentId: string; browserId: string | null }): void {
if (input.browserId) {
this.activeBrowserIdsByAgentId.delete(input.agentId);
this.activeBrowserIdsByAgentId.set(input.agentId, input.browserId);
return;
}
this.activeBrowserIdsByAgentId.delete(input.agentId);
}
public getAgentActiveBrowserId(agentId: string): string | null {
return this.activeBrowserIdsByAgentId.get(agentId) ?? null;
}
private deleteActiveBrowserReferences(browserId: string): void {
for (const [workspaceId, activeBrowserId] of this.activeBrowserIdsByWorkspaceId) {
if (activeBrowserId === browserId) {
this.activeBrowserIdsByWorkspaceId.delete(workspaceId);
}
}
for (const [agentId, activeBrowserId] of this.activeBrowserIdsByAgentId) {
if (activeBrowserId === browserId) {
this.activeBrowserIdsByAgentId.delete(agentId);
}
}
}
}

View File

@@ -1,5 +1,5 @@
import { app, Menu, BrowserWindow, ipcMain } from "electron";
import { getWorkspaceActivePaseoBrowserWebContents } from "./browser-webviews/index.js";
import { getMostRecentWorkspaceActivePaseoBrowserWebContents } from "./browser-webviews/index.js";
interface ShowContextMenuInput {
kind?: "terminal";
@@ -20,7 +20,7 @@ function withBrowserWindow(
}
function getReloadTargetBrowserWebContents(): Electron.WebContents | null {
return getWorkspaceActivePaseoBrowserWebContents();
return getMostRecentWorkspaceActivePaseoBrowserWebContents();
}
function reloadFocusedContentsOrWindow(win: BrowserWindow, options?: { ignoreCache?: boolean }) {