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:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user