From 55042a16ea39bcc8d6be9315a22f915bcabede55 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Nov 2026 14:36:03 -0800 Subject: [PATCH 1/6] feat(desktop): one browser session and extension system for every pane Panes an agent opened ran in a separate persist:browser-agent session with none of the user's extensions or logins, and the bridge refused the user's own panes. Every browser pane now runs in persist:browser: agent panes get the user's extensions, extension toolbar and logins, and agents can list, drive and wake any browser pane. The agent flag on a pane now only picks its hidden-webview cap. This drops the autofill isolation recorded in plans/20261030-browser-pane-passkeys.md; the browser skill tells agents never to read autofilled passwords or cookies. --- .../src/lib/trpc/routers/browser/browser.ts | 9 ++- .../main/lib/browser/browser-bridge.test.ts | 59 +++++---------- .../src/main/lib/browser/browser-bridge.ts | 75 +++++-------------- .../src/main/lib/browser/download-manager.ts | 4 +- .../src/main/lib/browser/pane-session.ts | 56 +++++++------- apps/desktop/src/main/lib/extensions/index.ts | 22 +++--- .../BrowserPane/browserRuntimeRegistry.ts | 16 ++-- .../BrowserPaneToolbar/BrowserPaneToolbar.tsx | 21 ++---- .../usePersistentWebview.ts | 3 +- .../useBrowserOpenRequests.ts | 3 +- .../usePaneLayoutRequests.ts | 11 ++- apps/desktop/src/shared/constants.ts | 3 - packages/i18n/locales/cs/messages.po | 4 +- packages/i18n/locales/de/messages.po | 4 +- packages/i18n/locales/en/messages.po | 4 +- packages/i18n/locales/es/messages.po | 4 +- packages/i18n/locales/fr/messages.po | 4 +- packages/i18n/locales/id/messages.po | 4 +- packages/i18n/locales/it/messages.po | 4 +- packages/i18n/locales/ja/messages.po | 4 +- packages/i18n/locales/ko/messages.po | 4 +- packages/i18n/locales/nl/messages.po | 4 +- packages/i18n/locales/pl/messages.po | 4 +- packages/i18n/locales/pt-BR/messages.po | 4 +- packages/i18n/locales/ru/messages.po | 4 +- packages/i18n/locales/tr/messages.po | 4 +- packages/i18n/locales/uk/messages.po | 4 +- packages/i18n/locales/vi/messages.po | 4 +- packages/i18n/locales/zh-CN/messages.po | 4 +- packages/i18n/locales/zh-TW/messages.po | 4 +- plans/20261030-browser-pane-passkeys.md | 2 +- plugins/superset/skills/browser/SKILL.md | 24 +++--- plugins/superset/skills/panes/SKILL.md | 4 +- 33 files changed, 156 insertions(+), 228 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/browser/browser.ts b/apps/desktop/src/lib/trpc/routers/browser/browser.ts index 00854365d5e..e5157558820 100644 --- a/apps/desktop/src/lib/trpc/routers/browser/browser.ts +++ b/apps/desktop/src/lib/trpc/routers/browser/browser.ts @@ -6,7 +6,7 @@ import { } from "main/lib/browser/browser-manager"; import { clearPaneData } from "main/lib/browser/clear-pane-data"; import { screenshotManager } from "main/lib/screenshot-manager"; -import { BROWSER_PARTITION, PANE_PARTITIONS } from "shared/constants"; +import { BROWSER_PARTITION } from "shared/constants"; import type { ForwardedKey } from "shared/hotkey-chord"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -379,9 +379,10 @@ export const createBrowserRouter = () => { }), ) .mutation(async ({ input }) => { - for (const partition of PANE_PARTITIONS) { - await clearPaneData(session.fromPartition(partition), input.type); - } + await clearPaneData( + session.fromPartition(BROWSER_PARTITION), + input.type, + ); return { success: true }; }), diff --git a/apps/desktop/src/main/lib/browser/browser-bridge.test.ts b/apps/desktop/src/main/lib/browser/browser-bridge.test.ts index a5e663e6219..58c10d4679b 100644 --- a/apps/desktop/src/main/lib/browser/browser-bridge.test.ts +++ b/apps/desktop/src/main/lib/browser/browser-bridge.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { PaneLayoutSnapshot } from "@superset/shared/pane-layout-ops"; import { dialog, session, webContents } from "electron"; -import { AGENT_BROWSER_PARTITION, BROWSER_PARTITION } from "shared/constants"; +import { BROWSER_PARTITION } from "shared/constants"; import { type PaneLayoutRendererRequest, paneLayoutRequests, @@ -92,7 +92,7 @@ beforeAll(async () => { }); fromIdMock.mockImplementation((id: number) => panes.get(id) ?? null); fakePane(901, BROWSER_PARTITION); - fakePane(902, AGENT_BROWSER_PARTITION); + fakePane(902, BROWSER_PARTITION); browserManager.register("user-pane", 901, "ws-bridge"); browserManager.register("agent-pane", 902, "ws-bridge"); await startBrowserBridge({ homeDir: home, extensions, answerDeadlineMs: 50 }); @@ -107,7 +107,7 @@ afterAll(async () => { fromPartitionMock.mockImplementation(preloadFromPartition ?? (() => ({}))); }); -test("agents reach only the panes they opened, never the user's own browser", async () => { +test("agents reach every browser pane, the user's own included", async () => { const bridge = getBrowserBridgeInfo(); if (!bridge) throw new Error("the bridge did not start"); const call = (path: string, init?: RequestInit) => @@ -118,37 +118,17 @@ test("agents reach only the panes they opened, never the user's own browser", as "content-type": "application/json", }, }); - const evaluate = (paneId: string) => - call(`/panes/${paneId}/eval`, { - method: "POST", - body: JSON.stringify({ - workspaceId: "ws-bridge", - code: "document.title", - }), - }); - const cdpClose = (paneId: string) => - new Promise((resolve) => { - const url = new URL(`/panes/${paneId}/cdp`, bridge.endpoint); - url.protocol = "ws:"; - url.searchParams.set("workspaceId", "ws-bridge"); - url.searchParams.set("token", bridge.secret); - const socket = new WebSocket(url); - socket.onclose = (event) => resolve(event.code); - }); const listed = await (await call("/panes?workspaceId=ws-bridge")).json(); - expect(listed.panes.map((pane: { paneId: string }) => pane.paneId)).toEqual([ - "agent-pane", - ]); + expect( + listed.panes.map((pane: { paneId: string }) => pane.paneId).sort(), + ).toEqual(["agent-pane", "user-pane"]); - const userEval = await evaluate("user-pane"); - expect(userEval.status).toBe(403); - expect(await userEval.text()).not.toContain("page data"); - expect(await (await evaluate("agent-pane")).json()).toEqual({ - result: "page data", + const userEval = await call("/panes/user-pane/eval", { + method: "POST", + body: JSON.stringify({ workspaceId: "ws-bridge", code: "document.title" }), }); - - expect(await cdpClose("user-pane")).toBe(1008); + expect(await userEval.json()).toEqual({ result: "page data" }); }); test("an agent copies the user's Chrome logins only after the user allows it", async () => { @@ -183,8 +163,8 @@ test("an agent copies the user's Chrome logins only after the user allows it", a test("an open answers with the pane made for it, even when an unloaded pane is rebuilt first", async () => { const bridge = getBrowserBridgeInfo(); if (!bridge) throw new Error("the bridge did not start"); - fakePane(903, AGENT_BROWSER_PARTITION); - fakePane(904, AGENT_BROWSER_PARTITION); + fakePane(903, BROWSER_PARTITION); + fakePane(904, BROWSER_PARTITION); browserManager.once("open-request", (request: { requestId: string }) => { // Showing the workspace rebuilds its unloaded tab before the new one exists. browserManager.register("rebuilt-pane", 903, "ws-bridge"); @@ -211,7 +191,7 @@ test("an open answers with the pane made for it, even when an unloaded pane is r } }); -test("an agent lists and uses its panes after hidden-pane eviction unloaded them", async () => { +test("an agent lists and uses panes after hidden-pane eviction unloaded them", async () => { const bridge = getBrowserBridgeInfo(); if (!bridge) throw new Error("the bridge did not start"); const RENDERER = 990; @@ -242,7 +222,7 @@ test("an agent lists and uses its panes after hidden-pane eviction unloaded them }; const renderer = (request: PaneLayoutRendererRequest) => { if (request.op.type === "wakeBrowser") { - if (request.op.paneId !== "unloaded-agent-pane") { + if (request.op.paneId !== "unloaded-user-pane") { paneLayoutRequests.respond( { requestId: request.requestId, @@ -252,8 +232,8 @@ test("an agent lists and uses its panes after hidden-pane eviction unloaded them ); return; } - fakePane(905, AGENT_BROWSER_PARTITION); - browserManager.register("unloaded-agent-pane", 905, "ws-bridge"); + fakePane(905, BROWSER_PARTITION); + browserManager.register("unloaded-user-pane", 905, "ws-bridge"); } paneLayoutRequests.respond( { @@ -291,15 +271,16 @@ test("an agent lists and uses its panes after hidden-pane eviction unloaded them ).toEqual([ ["agent-pane", true], ["unloaded-agent-pane", false], + ["unloaded-user-pane", false], + ["user-pane", true], ]); - expect(await (await evaluate("unloaded-agent-pane")).json()).toEqual({ + expect(await (await evaluate("unloaded-user-pane")).json()).toEqual({ result: "page data", }); - expect((await evaluate("unloaded-user-pane")).status).toBe(404); } finally { paneLayoutRequests.off("request", renderer); paneLayoutRequests.removeSubscriber(RENDERER); - browserManager.unregister("unloaded-agent-pane"); + browserManager.unregister("unloaded-user-pane"); } }); diff --git a/apps/desktop/src/main/lib/browser/browser-bridge.ts b/apps/desktop/src/main/lib/browser/browser-bridge.ts index db2519fb174..3746574eda5 100644 --- a/apps/desktop/src/main/lib/browser/browser-bridge.ts +++ b/apps/desktop/src/main/lib/browser/browser-bridge.ts @@ -17,10 +17,9 @@ import { allLayoutTabs, paneLayoutRequestSchema, } from "@superset/shared/pane-layout-ops"; -import { BrowserWindow, dialog, session } from "electron"; +import { BrowserWindow, dialog } from "electron"; import log from "electron-log"; import express, { type Request, type Response } from "express"; -import { AGENT_BROWSER_PARTITION } from "shared/constants"; import { type WebSocket, WebSocketServer } from "ws"; import { z } from "zod"; import { showCliNotification } from "../notifications/show-cli-notification"; @@ -50,9 +49,6 @@ const WAKE_PANE_TIMEOUT_MS = 10_000; const MAX_CDP_MESSAGE_BYTES = 4 * 1024 * 1024; const MAX_EARLY_CDP_FRAMES = 64; const CDP_PATH = /^\/panes\/([^/]+)\/cdp$/; -const USER_PANE_ERROR = - "This pane is the user's own browser, with their logins and extensions. Agents can use only the panes they open."; - const LOGIN_IMPORT_REFUSED = "The user did not allow this login import."; const EXTENSION_ID = /^[a-p]{32}$/; const EXTENSION_INSTALL_REFUSED = "The user did not allow this extension."; @@ -75,10 +71,7 @@ export type UserExtensionInstall = } | { status: "declined" }; -/** - * The extensions in the user's own browser. The CLI can manage them but never - * open, click or read them: an agent there could reach a password manager. - */ +/** The browser panes' extensions. The CLI can manage them but never open, click or read them. */ export interface UserExtensions { list(): (UserExtensionSummary & { pinned: boolean })[]; /** The question closes, as a no, when `signal` aborts. */ @@ -91,7 +84,7 @@ export interface UserExtensions { setPinned(extensionId: string, pinned: boolean): void; } -// Cookies are live sign-ins, so the user says yes to each copy into an agent's pane. +// Cookies are live sign-ins, so the user says yes to each copy. async function confirmLoginImport( browserName: string, profileName: string, @@ -109,7 +102,7 @@ async function confirmLoginImport( ), detail: i18n._( msg({ - message: `It copies the cookies from your "${profileName}" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to.`, + message: `It copies the cookies from your "${profileName}" profile into the in-app browser. Agents can then act on the sites you are signed in to.`, }), ), }; @@ -121,10 +114,6 @@ async function confirmLoginImport( return response === 0; } -function isAgentPane(wc: Electron.WebContents): boolean { - return wc.session === session.fromPartition(AGENT_BROWSER_PARTITION); -} - let server: Server | null = null; // Opens still waiting on the renderer per workspace, so a stuck renderer (each @@ -145,7 +134,7 @@ function isAuthorized(secret: string, req: IncomingMessage): boolean { /** * The pane's live webContents, rebuilding it first when hidden-pane eviction - * unloaded it. Only an agent's pane is rebuilt; the renderer refuses others. + * unloaded it. */ async function liveWebContents( paneId: string, @@ -183,7 +172,7 @@ async function liveWebContents( } /** - * Resolve the workspace-scoped agent pane every pane op must name, or answer + * Resolve the workspace-scoped pane every pane op must name, or answer * the request with an error. The workspaceId is required (body for POST, * query for GET): the bridge never operates unscoped. */ @@ -217,16 +206,12 @@ async function resolvePane( .json({ error: `No browser pane ${paneId} in this workspace` }); return null; } - if (!isAgentPane(wc)) { - res.status(403).json({ error: USER_PANE_ERROR }); - return null; - } browserManager.markAgentUse(paneId); return { wc, paneId, workspaceId }; } /** Runs `fn` on the resolved pane; a throw anywhere answers 500 instead of leaving the request open. */ -function withAgentPane( +function withPane( req: Request, res: Response, wakeTimeoutMs: number, @@ -245,16 +230,10 @@ function withAgentPane( }); } -async function listAgentPanes( - workspaceId: string, -): Promise { +async function listPanes(workspaceId: string): Promise { const live = new Map( browserManager .listPanes(workspaceId) - .filter((pane) => { - const wc = browserManager.getWebContents(pane.paneId); - return wc !== null && isAgentPane(wc); - }) .map((pane) => [pane.paneId, { ...pane, loaded: true }]), ); const layout = await paneLayoutRequests @@ -268,7 +247,7 @@ async function listAgentPanes( if (listed) { panes.push(listed); live.delete(pane.id); - } else if (pane.browser?.agent) { + } else if (pane.browser) { panes.push({ paneId: pane.id, workspaceId, @@ -312,16 +291,12 @@ export async function startBrowserBridge({ ? req.query.workspaceId : undefined; if (workspaceId) { - listAgentPanes(workspaceId) + listPanes(workspaceId) .then((panes) => res.json({ panes })) .catch((err) => res.status(500).json({ error: errorMessage(err) })); return; } - const panes = browserManager.listPanes().filter((pane) => { - const wc = browserManager.getWebContents(pane.paneId); - return wc !== null && isAgentPane(wc); - }); - res.json({ panes }); + res.json({ panes: browserManager.listPanes() }); }); app.post("/open", (req, res) => { @@ -382,14 +357,6 @@ export async function startBrowserBridge({ ); void registered.ready.then((wc) => { if (!wc) return; - if (!isAgentPane(wc)) { - finish(() => - res - .status(500) - .json({ error: "The opened pane is not an agent pane" }), - ); - return; - } const info = browserManager .listPanes(workspaceId) .find((pane) => pane.paneId === paneId); @@ -428,7 +395,7 @@ export async function startBrowserBridge({ res.status(400).json({ error: errorMessage(err) }); return; } - withAgentPane(req, res, wakeTimeoutMs, (pane) => { + withPane(req, res, wakeTimeoutMs, (pane) => { try { browserManager.navigate(pane.paneId, resolvedUrl, pane.workspaceId); res.json({ ok: true }); @@ -439,21 +406,21 @@ export async function startBrowserBridge({ }); app.post("/panes/:paneId/back", (req, res) => { - withAgentPane(req, res, wakeTimeoutMs, (pane) => { + withPane(req, res, wakeTimeoutMs, (pane) => { if (pane.wc.canGoBack()) pane.wc.goBack(); res.json({ ok: true }); }); }); app.post("/panes/:paneId/forward", (req, res) => { - withAgentPane(req, res, wakeTimeoutMs, (pane) => { + withPane(req, res, wakeTimeoutMs, (pane) => { if (pane.wc.canGoForward()) pane.wc.goForward(); res.json({ ok: true }); }); }); app.post("/panes/:paneId/reload", (req, res) => { - withAgentPane(req, res, wakeTimeoutMs, (pane) => { + withPane(req, res, wakeTimeoutMs, (pane) => { if (req.body?.hard) { pane.wc.reloadIgnoringCache(); } else { @@ -464,7 +431,7 @@ export async function startBrowserBridge({ }); app.post("/panes/:paneId/screenshot", (req, res) => { - withAgentPane(req, res, wakeTimeoutMs, (pane) => { + withPane(req, res, wakeTimeoutMs, (pane) => { browserManager .capturePng(pane.paneId, pane.workspaceId) .then((base64) => res.json({ base64 })) @@ -478,7 +445,7 @@ export async function startBrowserBridge({ res.status(400).json({ error: "code is required" }); return; } - withAgentPane(req, res, wakeTimeoutMs, (pane) => { + withPane(req, res, wakeTimeoutMs, (pane) => { browserManager .evaluateJS(pane.paneId, code, pane.workspaceId) .then((result) => res.json({ result: result ?? null })) @@ -487,7 +454,7 @@ export async function startBrowserBridge({ }); app.get("/panes/:paneId/console", (req, res) => { - withAgentPane(req, res, wakeTimeoutMs, (pane) => { + withPane(req, res, wakeTimeoutMs, (pane) => { res.json({ entries: browserManager.getConsoleLogs(pane.paneId, pane.workspaceId), }); @@ -538,7 +505,7 @@ export async function startBrowserBridge({ res.status(404).json({ error: "Unknown import source" }); return; } - withAgentPane(req, res, wakeTimeoutMs, (pane) => { + withPane(req, res, wakeTimeoutMs, (pane) => { confirmLoginImport(profile.browserName, profile.profileName) .then(async (allowed) => { if (!allowed) { @@ -741,10 +708,6 @@ function handleCdpSocket( ); return; } - if (!isAgentPane(wc)) { - ws.close(1008, "This pane is the user's own browser."); - return; - } browserManager.markAgentUse(paneId); let cdp: ReturnType; try { diff --git a/apps/desktop/src/main/lib/browser/download-manager.ts b/apps/desktop/src/main/lib/browser/download-manager.ts index 1d53b886ee7..7e03eca0c56 100644 --- a/apps/desktop/src/main/lib/browser/download-manager.ts +++ b/apps/desktop/src/main/lib/browser/download-manager.ts @@ -5,7 +5,7 @@ import { basename, extname, join } from "node:path"; import { downloads } from "@superset/local-db"; import { desc, eq, ne } from "drizzle-orm"; import { app, session, shell } from "electron"; -import { APP_PARTITION, PANE_PARTITIONS } from "shared/constants"; +import { APP_PARTITION, BROWSER_PARTITION } from "shared/constants"; import { localDb } from "../local-db"; const MAX_TRACKED_DOWNLOADS = 200; @@ -108,7 +108,7 @@ class DownloadManager extends EventEmitter { }); }; - for (const partition of [APP_PARTITION, ...PANE_PARTITIONS]) { + for (const partition of [APP_PARTITION, BROWSER_PARTITION]) { const ses = session.fromPartition(partition); ses.setDownloadPath(downloadDir); ses.on("will-download", onWillDownload); diff --git a/apps/desktop/src/main/lib/browser/pane-session.ts b/apps/desktop/src/main/lib/browser/pane-session.ts index 9fdd10ffc56..fcc1391a058 100644 --- a/apps/desktop/src/main/lib/browser/pane-session.ts +++ b/apps/desktop/src/main/lib/browser/pane-session.ts @@ -5,38 +5,36 @@ import { firefoxUserAgent, GOOGLE_SIGN_IN_HOST, } from "shared/chrome-user-agent"; -import { PANE_PARTITIONS } from "shared/constants"; +import { BROWSER_PARTITION } from "shared/constants"; import { webRequestHub } from "./web-request-hub"; -/** Sites see Chrome in every pane session, and Google's sign-in sees Firefox. */ +/** Sites see Chrome in the pane session, and Google's sign-in sees Firefox. */ export function preparePaneSessions(): void { - for (const partition of PANE_PARTITIONS) { - const paneSession = session.fromPartition(partition); - const electronUserAgent = paneSession.getUserAgent(); - paneSession.setUserAgent(chromeUserAgent(electronUserAgent)); - const googleSignInUserAgent = firefoxUserAgent(electronUserAgent); - webRequestHub(paneSession).add({ - urls: [`https://${GOOGLE_SIGN_IN_HOST}/*`], - onBeforeSendHeaders: (_details, headers) => { - const requestHeaders = Object.fromEntries( - Object.entries(headers).filter( - ([name]) => !name.toLowerCase().startsWith("sec-ch-ua"), - ), - ); - requestHeaders["User-Agent"] = googleSignInUserAgent; - return requestHeaders; - }, + const paneSession = session.fromPartition(BROWSER_PARTITION); + const electronUserAgent = paneSession.getUserAgent(); + paneSession.setUserAgent(chromeUserAgent(electronUserAgent)); + const googleSignInUserAgent = firefoxUserAgent(electronUserAgent); + webRequestHub(paneSession).add({ + urls: [`https://${GOOGLE_SIGN_IN_HOST}/*`], + onBeforeSendHeaders: (_details, headers) => { + const requestHeaders = Object.fromEntries( + Object.entries(headers).filter( + ([name]) => !name.toLowerCase().startsWith("sec-ch-ua"), + ), + ); + requestHeaders["User-Agent"] = googleSignInUserAgent; + return requestHeaders; + }, + }); + const shimsPreload = path.join( + __dirname, + "../preload/browser-extension-shims.js", + ); + for (const type of ["frame", "service-worker"] as const) { + paneSession.registerPreloadScript({ + id: `browser-extension-shims-${type}`, + type, + filePath: shimsPreload, }); - const shimsPreload = path.join( - __dirname, - "../preload/browser-extension-shims.js", - ); - for (const type of ["frame", "service-worker"] as const) { - paneSession.registerPreloadScript({ - id: `browser-extension-shims-${type}`, - type, - filePath: shimsPreload, - }); - } } } diff --git a/apps/desktop/src/main/lib/extensions/index.ts b/apps/desktop/src/main/lib/extensions/index.ts index 77e5e1884f4..def4c85c60e 100644 --- a/apps/desktop/src/main/lib/extensions/index.ts +++ b/apps/desktop/src/main/lib/extensions/index.ts @@ -3,7 +3,7 @@ import { existsSync, readdirSync } from "node:fs"; import path from "node:path"; import { app, session } from "electron"; import { env } from "main/env.main"; -import { APP_PARTITION, PANE_PARTITIONS } from "shared/constants"; +import { APP_PARTITION, BROWSER_PARTITION } from "shared/constants"; import { getChromiumUserDataDirs } from "../browser/chromium-profiles"; const REACT_DEVTOOLS_EXTENSION_ID = "fmkadmapgofadopljbjfkapdkoienihi"; @@ -173,16 +173,14 @@ export async function loadWebviewBrowserExtension(): Promise { return; } - for (const partition of PANE_PARTITIONS) { - try { - await session - .fromPartition(partition) - .extensions.loadExtension(extensionPath); - console.log(`[main] Browser extension loaded in ${partition}`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes("already loaded")) continue; - console.error("[main] Failed to load browser extension:", error); - } + try { + await session + .fromPartition(BROWSER_PARTITION) + .extensions.loadExtension(extensionPath); + console.log("[main] Browser extension loaded"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("already loaded")) return; + console.error("[main] Failed to load browser extension:", error); } } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts index 0a67100907c..7ea2d27247b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts @@ -1,7 +1,7 @@ import { attachBrowserViewportZoom } from "renderer/lib/browser-viewport-zoom"; import { pointerPassthrough } from "renderer/lib/pointer-passthrough"; import { electronTrpcClient } from "renderer/lib/trpc-client"; -import { AGENT_BROWSER_PARTITION, BROWSER_PARTITION } from "shared/constants"; +import { BROWSER_PARTITION } from "shared/constants"; import type { BrowserLoadError } from "shared/tabs-types"; import { sanitizeUrl } from "./sanitizeUrl"; import { selectWebviewsToUnload } from "./utils/selectWebviewsToUnload"; @@ -352,11 +352,11 @@ export class BrowserRuntimeRegistryImpl { paneId: string, initialUrl: string, workspaceId: string, - partition: string, + agent: boolean, ): RegistryEntry { const webview = document.createElement("webview") as Electron.WebviewTag; const detachViewportZoom = attachBrowserViewportZoom(webview); - webview.setAttribute("partition", partition); + webview.setAttribute("partition", BROWSER_PARTITION); webview.setAttribute("allowpopups", ""); webview.style.position = "fixed"; webview.style.top = "0"; @@ -396,7 +396,7 @@ export class BrowserRuntimeRegistryImpl { resizeObserver: null, visible: false, lastUsedAt: 0, - agent: partition === AGENT_BROWSER_PARTITION, + agent, }; const firePersist = () => { @@ -588,12 +588,12 @@ export class BrowserRuntimeRegistryImpl { url: string, workspaceId: string, onPersist: (state: PersistableBrowserState) => void, - partition = BROWSER_PARTITION, + agent = false, ): RegistryEntry { const existing = this.entries.get(paneId); if (existing) return existing; const root = this.ensureRootContainer(); - const entry = this.createEntry(paneId, url, workspaceId, partition); + const entry = this.createEntry(paneId, url, workspaceId, agent); entry.onPersist = onPersist; entry.lastUsedAt = this.stamp(); // Give automation a usable viewport before this pane has ever been shown. @@ -614,14 +614,14 @@ export class BrowserRuntimeRegistryImpl { workspaceId: string, onPersist: (state: PersistableBrowserState) => void, onClose: () => void, - partition = BROWSER_PARTITION, + agent = false, ): void { const entry = this.openBackground( paneId, initialUrl, workspaceId, onPersist, - partition, + agent, ); // A reused pane can move between workspaces (the attach effect keys on // workspaceId). Keep the registration's workspace current so main-side diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserPaneToolbar/BrowserPaneToolbar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserPaneToolbar/BrowserPaneToolbar.tsx index c607d6b717a..e1460d1a899 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserPaneToolbar/BrowserPaneToolbar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserPaneToolbar/BrowserPaneToolbar.tsx @@ -10,7 +10,7 @@ import { useBrowserExtensions, } from "renderer/components/BrowserExtensions"; import { OpenBrowserPageInAppButton } from "renderer/components/OpenBrowserPageInAppButton"; -import type { BrowserPaneData, PaneViewerData } from "../../../../../../types"; +import type { PaneViewerData } from "../../../../../../types"; import { browserRuntimeRegistry } from "../../browserRuntimeRegistry"; import { designModeStore, useDesignModeState } from "../../designModeStore"; import { @@ -33,7 +33,6 @@ export function BrowserPaneToolbar({ ctx }: BrowserPaneToolbarProps) { const designMode = useDesignModeState(paneId); const deviceToolbar = useDeviceToolbarState(paneId); const extensions = useBrowserExtensions(paneId); - const isAgentPane = (ctx.pane.data as BrowserPaneData).agent === true; const handleToggleDesignMode = useCallback(() => { designModeStore.toggle(paneId); @@ -75,17 +74,13 @@ export function BrowserPaneToolbar({ ctx }: BrowserPaneToolbarProps) { onNavigate={handleNavigate} />
- {!isAgentPane && ( - <> - - - - )} + + diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts index e311e693dd6..9eda0d06cc8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts @@ -7,7 +7,6 @@ import type { BrowserPaneData, PaneViewerData, } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; -import { AGENT_BROWSER_PARTITION, BROWSER_PARTITION } from "shared/constants"; import { browserRuntimeRegistry } from "../../browserRuntimeRegistry"; import { DEFAULT_BROWSER_URL } from "../../constants"; @@ -70,7 +69,7 @@ export function usePersistentWebview({ () => { void ctxRef.current.actions.close(); }, - agentRef.current ? AGENT_BROWSER_PARTITION : BROWSER_PARTITION, + agentRef.current, ); setOverlayContainer(browserRuntimeRegistry.getOverlayContainer(paneId)); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useBrowserOpenRequests/useBrowserOpenRequests.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useBrowserOpenRequests/useBrowserOpenRequests.ts index f7253880298..e756c9a1b63 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useBrowserOpenRequests/useBrowserOpenRequests.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useBrowserOpenRequests/useBrowserOpenRequests.ts @@ -6,7 +6,6 @@ import { browserRuntimeRegistry } from "renderer/routes/_authenticated/_dashboar import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { persistAgentBrowserState } from "renderer/routes/_authenticated/utils/persistAgentBrowserState"; import { writeWorkspacePaneLayout } from "renderer/stores/workspace-creates/writeWorkspacePaneLayout"; -import { AGENT_BROWSER_PARTITION } from "shared/constants"; import { openBackgroundBrowser } from "./utils/openBackgroundBrowser"; interface BrowserOpenRequest { @@ -51,7 +50,7 @@ export function useBrowserOpenRequests() { request.workspaceId, paneId, ), - AGENT_BROWSER_PARTITION, + true, ); electronTrpcClient.browser.resolveOpenRequest .mutate({ requestId: request.requestId, paneId }) diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalPaneLayoutRequests/hooks/usePaneLayoutRequests/usePaneLayoutRequests.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalPaneLayoutRequests/hooks/usePaneLayoutRequests/usePaneLayoutRequests.ts index d76d2a85104..a055cd0b94e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalPaneLayoutRequests/hooks/usePaneLayoutRequests/usePaneLayoutRequests.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalPaneLayoutRequests/hooks/usePaneLayoutRequests/usePaneLayoutRequests.ts @@ -30,7 +30,6 @@ import { applyRememberedV2PaneSelection, rememberV2PaneSelection, } from "renderer/stores/v2-pane-selection"; -import { AGENT_BROWSER_PARTITION } from "shared/constants"; import { applyPaneLayoutOp, type PaneAreas, @@ -195,7 +194,7 @@ function runPaneLayoutOp( }; } -function wakeAgentBrowser( +function wakeBrowser( collections: Pick, workspaceId: string, paneId: string, @@ -205,10 +204,10 @@ function wakeAgentBrowser( tab.panes.some((pane) => pane.id === paneId), ); const pane = tab?.panes.find((pane) => pane.id === paneId); - if (!tab || !pane?.browser?.agent) { + if (!tab || !pane?.browser) { throw new PaneLayoutOpError( "NOT_FOUND", - `No agent browser pane ${paneId} in this workspace`, + `No browser pane ${paneId} in this workspace`, ); } browserRuntimeRegistry.openBackground( @@ -216,7 +215,7 @@ function wakeAgentBrowser( pane.browser.url || "about:blank", workspaceId, persistAgentBrowserState(collections, workspaceId, paneId), - AGENT_BROWSER_PARTITION, + pane.browser.agent === true, ); return { ...listed, paneId, tabId: tab.id }; } @@ -236,7 +235,7 @@ export function usePaneLayoutRequests() { try { const { paneId, tabId, layout } = op.type === "wakeBrowser" - ? wakeAgentBrowser(collections, workspaceId, op.paneId) + ? wakeBrowser(collections, workspaceId, op.paneId) : runPaneLayoutOp(collections, workspaceId, op); reply = { requestId, result: { paneId, tabId, layout } }; } catch (error) { diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 4e974b87dd4..75dd5f2db85 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -23,9 +23,6 @@ export const PORTS_FILE_NAME = "ports.json"; // Separate sessions keep browser-pane extensions away from the app's own requests. export const APP_PARTITION = "persist:superset"; export const BROWSER_PARTITION = "persist:browser"; -// Panes agents open: no Web Store extensions, so no password manager an agent can read. -export const AGENT_BROWSER_PARTITION = "persist:browser-agent"; -export const PANE_PARTITIONS = [BROWSER_PARTITION, AGENT_BROWSER_PARTITION]; export const CONFIG_TEMPLATE = `{ "setup": [], diff --git a/packages/i18n/locales/cs/messages.po b/packages/i18n/locales/cs/messages.po index 9ea42bb398b..38f64c843b7 100644 --- a/packages/i18n/locales/cs/messages.po +++ b/packages/i18n/locales/cs/messages.po @@ -10502,8 +10502,8 @@ msgstr "Může:" msgid "It can't be brought back." msgstr "Nepůjde ho obnovit." -msgid "It copies the cookies from your \"{profileName}\" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to." -msgstr "Zkopíruje soubory cookie z vašeho profilu „{profileName}“ do panelu prohlížeče, který agent otevřel. Agent pak může jednat na webech, kde jste přihlášeni." +msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." +msgstr "Zkopíruje soubory cookie z vašeho profilu „{profileName}“ do prohlížeče v aplikaci. Agenti pak mohou jednat na webech, kde jste přihlášeni." msgid "It keeps the title from your prompt." msgstr "Zůstane mu název z vašeho zadání." diff --git a/packages/i18n/locales/de/messages.po b/packages/i18n/locales/de/messages.po index 7d04397afbd..fd4efe7780c 100644 --- a/packages/i18n/locales/de/messages.po +++ b/packages/i18n/locales/de/messages.po @@ -10502,8 +10502,8 @@ msgstr "Berechtigungen:" msgid "It can't be brought back." msgstr "Er lässt sich nicht wiederherstellen." -msgid "It copies the cookies from your \"{profileName}\" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to." -msgstr "Die Cookies aus deinem Profil „{profileName}“ werden in einen Browserbereich kopiert, den der Agent geöffnet hat. Der Agent kann dann auf den Websites handeln, auf denen du angemeldet bist." +msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." +msgstr "Die Cookies aus deinem Profil „{profileName}“ werden in den Browser der App kopiert. Agenten können dann auf den Websites handeln, auf denen du angemeldet bist." msgid "It keeps the title from your prompt." msgstr "Der Titel aus deinem Prompt bleibt erhalten." diff --git a/packages/i18n/locales/en/messages.po b/packages/i18n/locales/en/messages.po index aa30c5dd224..24e9801cd00 100644 --- a/packages/i18n/locales/en/messages.po +++ b/packages/i18n/locales/en/messages.po @@ -10502,8 +10502,8 @@ msgstr "It can:" msgid "It can't be brought back." msgstr "It can't be brought back." -msgid "It copies the cookies from your \"{profileName}\" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to." -msgstr "It copies the cookies from your \"{profileName}\" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to." +msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." +msgstr "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." msgid "It keeps the title from your prompt." msgstr "It keeps the title from your prompt." diff --git a/packages/i18n/locales/es/messages.po b/packages/i18n/locales/es/messages.po index ad4384ca5cb..69229d2e3c4 100644 --- a/packages/i18n/locales/es/messages.po +++ b/packages/i18n/locales/es/messages.po @@ -10502,8 +10502,8 @@ msgstr "Puede:" msgid "It can't be brought back." msgstr "No se podrá recuperar." -msgid "It copies the cookies from your \"{profileName}\" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to." -msgstr "Copia las cookies de tu perfil \"{profileName}\" a un panel de navegador que abrió el agente. Así el agente puede actuar en los sitios donde tienes la sesión iniciada." +msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." +msgstr "Copia las cookies de tu perfil \"{profileName}\" al navegador de la app. Así los agentes pueden actuar en los sitios donde tienes la sesión iniciada." msgid "It keeps the title from your prompt." msgstr "Conserva el título de tu instrucción." diff --git a/packages/i18n/locales/fr/messages.po b/packages/i18n/locales/fr/messages.po index ad1b68ae798..c480ce6c41a 100644 --- a/packages/i18n/locales/fr/messages.po +++ b/packages/i18n/locales/fr/messages.po @@ -10502,8 +10502,8 @@ msgstr "Elle peut :" msgid "It can't be brought back." msgstr "Il ne pourra pas être restauré." -msgid "It copies the cookies from your \"{profileName}\" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to." -msgstr "Cette opération copie les cookies de votre profil « {profileName} » dans un panneau de navigateur ouvert par l'agent. L'agent peut alors agir sur les sites où vous êtes connecté." +msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." +msgstr "Cette opération copie les cookies de votre profil « {profileName} » dans le navigateur de l'app. Les agents peuvent alors agir sur les sites où vous êtes connecté." msgid "It keeps the title from your prompt." msgstr "Le titre issu de votre prompt est conservé." diff --git a/packages/i18n/locales/id/messages.po b/packages/i18n/locales/id/messages.po index 65bb5ec4bd8..133a2e55b5a 100644 --- a/packages/i18n/locales/id/messages.po +++ b/packages/i18n/locales/id/messages.po @@ -10502,8 +10502,8 @@ msgstr "Ekstensi ini dapat:" msgid "It can't be brought back." msgstr "Komentar ini tidak bisa dikembalikan." -msgid "It copies the cookies from your \"{profileName}\" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to." -msgstr "Menyalin cookie dari profil \"{profileName}\" Anda ke panel browser yang dibuka agen. Agen lalu dapat bertindak di situs tempat Anda masuk." +msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." +msgstr "Menyalin cookie dari profil \"{profileName}\" Anda ke browser di dalam aplikasi. Agen lalu dapat bertindak di situs tempat Anda masuk." msgid "It keeps the title from your prompt." msgstr "Judul dari prompt Anda tetap dipakai." diff --git a/packages/i18n/locales/it/messages.po b/packages/i18n/locales/it/messages.po index 4f89d1a1b0e..d868869244e 100644 --- a/packages/i18n/locales/it/messages.po +++ b/packages/i18n/locales/it/messages.po @@ -10502,8 +10502,8 @@ msgstr "Può:" msgid "It can't be brought back." msgstr "Non potrà essere recuperato." -msgid "It copies the cookies from your \"{profileName}\" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to." -msgstr "Copia i cookie dal tuo profilo \"{profileName}\" in un riquadro del browser aperto dall'agente. L'agente potrà così agire sui siti in cui hai effettuato l'accesso." +msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." +msgstr "Copia i cookie dal tuo profilo \"{profileName}\" nel browser dell'app. Gli agenti potranno così agire sui siti in cui hai effettuato l'accesso." msgid "It keeps the title from your prompt." msgstr "Mantiene il titolo del tuo prompt." diff --git a/packages/i18n/locales/ja/messages.po b/packages/i18n/locales/ja/messages.po index 338c6565c8e..d0c5117594f 100644 --- a/packages/i18n/locales/ja/messages.po +++ b/packages/i18n/locales/ja/messages.po @@ -10502,8 +10502,8 @@ msgstr "次の操作を行えます:" msgid "It can't be brought back." msgstr "元に戻すことはできません。" -msgid "It copies the cookies from your \"{profileName}\" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to." -msgstr "エージェントが開いたブラウザペインに、\"{profileName}\" プロファイルの Cookie をコピーします。エージェントはその後、あなたがログインしているサイトで操作できます。" +msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." +msgstr "\"{profileName}\" プロファイルの Cookie をアプリ内ブラウザにコピーします。エージェントはその後、あなたがログインしているサイトで操作できます。" msgid "It keeps the title from your prompt." msgstr "プロンプトから付けたタイトルのままになります。" diff --git a/packages/i18n/locales/ko/messages.po b/packages/i18n/locales/ko/messages.po index 1e51daafc4c..c2e31eccdbf 100644 --- a/packages/i18n/locales/ko/messages.po +++ b/packages/i18n/locales/ko/messages.po @@ -10502,8 +10502,8 @@ msgstr "가능한 작업:" msgid "It can't be brought back." msgstr "되돌릴 수 없습니다." -msgid "It copies the cookies from your \"{profileName}\" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to." -msgstr "\"{profileName}\" 프로필의 쿠키를 에이전트가 연 브라우저 창으로 복사합니다. 그러면 에이전트가 로그인된 사이트에서 작업할 수 있습니다." +msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." +msgstr "\"{profileName}\" 프로필의 쿠키를 앱 내 브라우저로 복사합니다. 그러면 에이전트가 로그인된 사이트에서 작업할 수 있습니다." msgid "It keeps the title from your prompt." msgstr "프롬프트에서 가져온 제목이 유지됩니다." diff --git a/packages/i18n/locales/nl/messages.po b/packages/i18n/locales/nl/messages.po index 1a1a891e326..e03fc8d5ebb 100644 --- a/packages/i18n/locales/nl/messages.po +++ b/packages/i18n/locales/nl/messages.po @@ -10502,8 +10502,8 @@ msgstr "De extensie kan:" msgid "It can't be brought back." msgstr "Dit kan niet ongedaan worden gemaakt." -msgid "It copies the cookies from your \"{profileName}\" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to." -msgstr "De cookies uit je profiel \"{profileName}\" worden gekopieerd naar een browservenster dat de agent heeft geopend. De agent kan dan handelen op de sites waar je bent ingelogd." +msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." +msgstr "De cookies uit je profiel \"{profileName}\" worden gekopieerd naar de browser in de app. Agents kunnen dan handelen op de sites waar je bent ingelogd." msgid "It keeps the title from your prompt." msgstr "De titel uit je prompt blijft staan." diff --git a/packages/i18n/locales/pl/messages.po b/packages/i18n/locales/pl/messages.po index ce8ab3de9bc..010044fc97e 100644 --- a/packages/i18n/locales/pl/messages.po +++ b/packages/i18n/locales/pl/messages.po @@ -10502,8 +10502,8 @@ msgstr "Może:" msgid "It can't be brought back." msgstr "Nie będzie można go przywrócić." -msgid "It copies the cookies from your \"{profileName}\" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to." -msgstr "Kopiuje pliki cookie z Twojego profilu „{profileName}” do panelu przeglądarki otwartego przez agenta. Agent może wtedy działać na stronach, na których jesteś zalogowany." +msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." +msgstr "Kopiuje pliki cookie z Twojego profilu „{profileName}” do przeglądarki w aplikacji. Agenci mogą wtedy działać na stronach, na których jesteś zalogowany." msgid "It keeps the title from your prompt." msgstr "Zachowa tytuł z Twojego polecenia." diff --git a/packages/i18n/locales/pt-BR/messages.po b/packages/i18n/locales/pt-BR/messages.po index 628ade8c460..42e512e39bd 100644 --- a/packages/i18n/locales/pt-BR/messages.po +++ b/packages/i18n/locales/pt-BR/messages.po @@ -10502,8 +10502,8 @@ msgstr "Ela pode:" msgid "It can't be brought back." msgstr "Não será possível recuperá-lo." -msgid "It copies the cookies from your \"{profileName}\" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to." -msgstr "Copia os cookies do seu perfil \"{profileName}\" para um painel do navegador que o agente abriu. Assim o agente pode agir nos sites em que você está conectado." +msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." +msgstr "Copia os cookies do seu perfil \"{profileName}\" para o navegador do app. Assim os agentes podem agir nos sites em que você está conectado." msgid "It keeps the title from your prompt." msgstr "O título do seu prompt será mantido." diff --git a/packages/i18n/locales/ru/messages.po b/packages/i18n/locales/ru/messages.po index 7eff7aa2621..eccb7fdb7df 100644 --- a/packages/i18n/locales/ru/messages.po +++ b/packages/i18n/locales/ru/messages.po @@ -10502,8 +10502,8 @@ msgstr "Расширение сможет:" msgid "It can't be brought back." msgstr "Его нельзя будет вернуть." -msgid "It copies the cookies from your \"{profileName}\" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to." -msgstr "Cookie из вашего профиля «{profileName}» копируются в панель браузера, которую открыл агент. Тогда агент сможет действовать на сайтах, где вы вошли в аккаунт." +msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." +msgstr "Cookie из вашего профиля «{profileName}» копируются во встроенный браузер приложения. Тогда агенты смогут действовать на сайтах, где вы вошли в аккаунт." msgid "It keeps the title from your prompt." msgstr "Останется название из вашего запроса." diff --git a/packages/i18n/locales/tr/messages.po b/packages/i18n/locales/tr/messages.po index 0a6db1e651f..ac8c831a380 100644 --- a/packages/i18n/locales/tr/messages.po +++ b/packages/i18n/locales/tr/messages.po @@ -10502,8 +10502,8 @@ msgstr "Şunları yapabilir:" msgid "It can't be brought back." msgstr "Geri getirilemez." -msgid "It copies the cookies from your \"{profileName}\" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to." -msgstr "\"{profileName}\" profilinizdeki çerezleri, ajanın açtığı bir tarayıcı bölmesine kopyalar. Ajan böylece oturum açtığınız sitelerde işlem yapabilir." +msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." +msgstr "\"{profileName}\" profilinizdeki çerezleri uygulama içi tarayıcıya kopyalar. Ajanlar böylece oturum açtığınız sitelerde işlem yapabilir." msgid "It keeps the title from your prompt." msgstr "İsteminizdeki başlık korunur." diff --git a/packages/i18n/locales/uk/messages.po b/packages/i18n/locales/uk/messages.po index ace68410dff..e6a3bad3e76 100644 --- a/packages/i18n/locales/uk/messages.po +++ b/packages/i18n/locales/uk/messages.po @@ -10496,8 +10496,8 @@ msgstr "Розширення зможе:" msgid "It can't be brought back." msgstr "Відновити його не вийде." -msgid "It copies the cookies from your \"{profileName}\" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to." -msgstr "Файли cookie з вашого профілю «{profileName}» буде скопійовано в панель браузера, яку відкрив агент. Після цього агент зможе діяти на сайтах, де ви ввійшли в обліковий запис." +msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." +msgstr "Файли cookie з вашого профілю «{profileName}» буде скопійовано у вбудований браузер застосунку. Після цього агенти зможуть діяти на сайтах, де ви ввійшли в обліковий запис." msgid "It keeps the title from your prompt." msgstr "Назва з вашого промпту залишиться." diff --git a/packages/i18n/locales/vi/messages.po b/packages/i18n/locales/vi/messages.po index 6e83bbe7f82..309f37ff49a 100644 --- a/packages/i18n/locales/vi/messages.po +++ b/packages/i18n/locales/vi/messages.po @@ -10502,8 +10502,8 @@ msgstr "Tiện ích có thể:" msgid "It can't be brought back." msgstr "Không thể khôi phục." -msgid "It copies the cookies from your \"{profileName}\" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to." -msgstr "Sao chép cookie từ hồ sơ \"{profileName}\" của bạn vào một khung trình duyệt mà agent đã mở. Sau đó agent có thể thao tác trên các trang bạn đã đăng nhập." +msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." +msgstr "Sao chép cookie từ hồ sơ \"{profileName}\" của bạn vào trình duyệt trong ứng dụng. Sau đó agent có thể thao tác trên các trang bạn đã đăng nhập." msgid "It keeps the title from your prompt." msgstr "Tiêu đề từ lời nhắc của bạn sẽ được giữ nguyên." diff --git a/packages/i18n/locales/zh-CN/messages.po b/packages/i18n/locales/zh-CN/messages.po index b5073912748..dabb272b9f9 100644 --- a/packages/i18n/locales/zh-CN/messages.po +++ b/packages/i18n/locales/zh-CN/messages.po @@ -10502,8 +10502,8 @@ msgstr "它可以:" msgid "It can't be brought back." msgstr "删除后无法恢复。" -msgid "It copies the cookies from your \"{profileName}\" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to." -msgstr "将你的 \"{profileName}\" 配置文件中的 Cookie 复制到智能体打开的浏览器窗格中。之后智能体可以在你已登录的网站上执行操作。" +msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." +msgstr "将你的 \"{profileName}\" 配置文件中的 Cookie 复制到应用内浏览器中。之后智能体可以在你已登录的网站上执行操作。" msgid "It keeps the title from your prompt." msgstr "将保留根据你的提示生成的标题。" diff --git a/packages/i18n/locales/zh-TW/messages.po b/packages/i18n/locales/zh-TW/messages.po index 99b37b02384..a55aef596a1 100644 --- a/packages/i18n/locales/zh-TW/messages.po +++ b/packages/i18n/locales/zh-TW/messages.po @@ -10502,8 +10502,8 @@ msgstr "它可以:" msgid "It can't be brought back." msgstr "刪除後無法復原。" -msgid "It copies the cookies from your \"{profileName}\" profile into a browser pane the agent opened. The agent can then act on the sites you are signed in to." -msgstr "將你的「{profileName}」設定檔中的 Cookie 複製到代理程式開啟的瀏覽器窗格中。之後代理程式可以在你已登入的網站上執行操作。" +msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." +msgstr "將你的「{profileName}」設定檔中的 Cookie 複製到應用程式內建瀏覽器中。之後代理程式可以在你已登入的網站上執行操作。" msgid "It keeps the title from your prompt." msgstr "將保留根據你的提示產生的標題。" diff --git a/plans/20261030-browser-pane-passkeys.md b/plans/20261030-browser-pane-passkeys.md index c6e358dcd7d..50ebd38585a 100644 --- a/plans/20261030-browser-pane-passkeys.md +++ b/plans/20261030-browser-pane-passkeys.md @@ -79,7 +79,7 @@ that to the app itself, and must not trust an extension it did not verify. | Supply chain | One maintainer. `electron-chrome-extensions@4.10.0` shipped 2026-10-25. | Exact versions, lockfile hashes, diff review on every bump. | | One click installs | The package installs on one click, with every permission the extension asks for. Chrome first asks "Add {name}?" and lists what the extension can reach. | `beforeInstall` shows the same question, with Chrome's wording (`extension-host/install-warnings.ts`). Cancel denies the install. | | Agents reach extension worlds | Agents drive panes through the browser bridge (eval, screenshot, raw CDP). A pane cannot open `chrome-extension://` pages, so the vault page is out of reach. Raw CDP can still list a content script's world (`Runtime.executionContextCreated` with a `chrome-extension://` origin) and run code in it, and that world can message its extension. `Debugger` and `HeapProfiler` reach the same JS heap. | The CDP session passes on only the execution contexts it saw created outside an extension, refuses commands that name any other context, and refuses the `Debugger` and `HeapProfiler` domains (`browser/cdp-extension-worlds.ts`). | -| Agents read autofill | If an agent drove a pane in the user's session, a password manager could fill a field there and the agent's eval could read it. | Fixed. Panes an agent opens use `persist:browser-agent`: no Web Store extensions and none of the user's cookies. The bridge lists, evaluates and attaches CDP only to panes in that session, and answers 403 (CDP close 1008) for the user's own panes. A link an agent pane opens in a new pane stays in the agent session. Trade-off: agent panes start signed out; `superset browser` cookie import still copies the user's Chrome logins into the agent session on request. | +| Agents read autofill | If an agent drove a pane in the user's session, a password manager could fill a field there and the agent's eval could read it. | Accepted (2026-11-08). Every pane, agent-opened or not, uses `persist:browser`, so extensions and logins work everywhere. The separate `persist:browser-agent` session is gone, and the bridge drives any browser pane. The browser skill tells agents never to read autofilled passwords or cookies. | | Debug flags | Electron honors `--remote-debugging-port` and `--remote-debugging-pipe` from the command line. Any local program could relaunch the app with one and drive every pane. | The switches are removed before `ready` unless a dev build was started with `RENDERER_REMOTE_DEBUG_PORT`. | | Lookalike store origin | The package served the store API to any page whose origin starts with `https://chromewebstore.google.com`, so `https://chromewebstore.google.com.attacker.example` could list extensions and remove them with no dialog (`showConfirmDialog: false`). | Patched to the exact origin in the preload and the main process. Remove always asks, whatever the page passes. Guard: `web-store.test.ts`. | | Updates widen access | The updater loaded a new version with more permissions than the user approved, with no question. The install question also showed the store page's manifest, not the downloaded one. | An update or a new install downloads into `browser-extensions.staging`, which the app never loads at start. It moves into place only after the user agrees and its manifest asks for no more than the user approved, so a stop half way cannot load an unapproved version. An update that adds access asks "{name} wants more access" and keeps the old version on no. A download whose manifest reaches further than the approved one is removed. Rule: `addedAccess` in `install-warnings.ts` compares hosts by scheme (`http://*/*` does not cover `https`), and any new API permission asks, even one Chrome shows no warning for (`cookies`). | diff --git a/plugins/superset/skills/browser/SKILL.md b/plugins/superset/skills/browser/SKILL.md index 252d68d2210..d06147a5f67 100644 --- a/plugins/superset/skills/browser/SKILL.md +++ b/plugins/superset/skills/browser/SKILL.md @@ -56,11 +56,10 @@ the pane verbs already cover those. ## Find or open a pane -Agents reach only the panes they open. Those panes run in their own session, -with none of the user's browser extensions, so a password manager is never in -reach. They hold none of the user's logins unless the user imports them -(`import-login`, below). A pane the user opened answers 403 to every verb; `list` does -not show it. Open your own pane instead. +Every browser pane, whether you or the user opened it, shares one browser: +the same logins and the same extensions. `list` shows them all. Prefer a pane you +opened; drive a pane the user opened only when the task is about that page, as +it may be what they are looking at. Every pane has a stable `paneId`, scoped to its workspace: pass the same `--workspace` you opened it under or the operation is rejected. @@ -79,9 +78,8 @@ open the workspace. To show the user a page in the right sidebar beside their work, and when `superset panes --help` succeeds (older CLIs lack `panes`), run `superset panes open browser --workspace --url ` -(`superset:panes`). That pane is the user's own: `browser list` does not show -it and the browser verbs answer 403. To read or drive a page, open your own -pane with `superset browser open` as above. +(`superset:panes`). It shows up in `browser list`, and the browser verbs work on +it like any other pane. ## Drive with the high-level verbs @@ -112,7 +110,7 @@ rejected with a clear error. `import-login` copies a system browser's cookies into the pane so it's signed in to the sites the user already uses. It imports every cookie that profile has on -disk, not just the one site, into the session every agent pane shares, so say that +disk, not just the one site, into the browser every pane shares, so say that when offering and run it only with the user's go-ahead. The desktop app also asks the user to allow each import. Offering it is how most users discover it, so reach for it in two cases: @@ -231,8 +229,7 @@ along at their relative paths, so nothing needs inlining. superset pages publish ./evidence/ --workspace --title "Checkout flow: 3 blockers" ``` -Read every screenshot before it goes up. An agent pane holds every login imported -into it, so a capture can hold a session token, an email address, a customer +Read every screenshot before it goes up. A pane holds every login the user has, so a capture can hold a session token, an email address, a customer name, or a dashboard that was never meant to leave the tab. New pages default to org-wide visibility. Crop or redact what does not belong in the report, pass `--visibility just_me` when the org does not need it, and confirm with the user @@ -240,8 +237,9 @@ before publishing. ## Safety -- Agent panes share one session, so `eval` and CDP reach every login imported - into it (GitHub, dashboards, …). Never read cookies, +- All panes share one session with the user's logins and extensions (password + managers included), so `eval` and CDP reach every site they are signed in to + (GitHub, dashboards, …). Never read cookies, autofilled passwords, tokens, or credentials, exfiltrate session data, or act on authenticated sites beyond the task. - Confirm with the user before a step that submits a form, makes a purchase, diff --git a/plugins/superset/skills/panes/SKILL.md b/plugins/superset/skills/panes/SKILL.md index 8d332178d6e..a8680f7e84b 100644 --- a/plugins/superset/skills/panes/SKILL.md +++ b/plugins/superset/skills/panes/SKILL.md @@ -148,8 +148,8 @@ superset panes open changes --workspace $WS --json >/dev/null W=$(superset panes open browser --workspace $WS --url http://localhost:3000 --json | jq -r .paneId) ``` -That browser pane is the user's own, so agents cannot drive it. To screenshot, -click or read the console, open an agent pane with superset:browser. +To screenshot, click or read the console in that pane, use superset:browser +with its `paneId`. A grid of N panes in a new tab: open the tab, split the first pane right for each column, split each column down for each row, then `equalize --tab`. From f607c7235850a936b752d031595c0d4a472a6818 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Nov 2026 14:43:26 -0800 Subject: [PATCH 2/6] fix(desktop): scope pane listing, let agents sign in with the password manager - The bridge's /panes now requires a workspaceId, so it never lists the user's panes across workspaces. - Bridge tests cover CDP on the user's pane and waking both an agent's and the user's unloaded pane. - The browser skill lets an agent sign in with the user's password manager when asked, without reading the filled value back. - fr/vi wording fixes for the login-import dialog. --- .../main/lib/browser/browser-bridge.test.ts | 30 +++++++++++++++---- .../src/main/lib/browser/browser-bridge.ts | 15 ++++------ packages/i18n/locales/fr/messages.po | 2 +- packages/i18n/locales/vi/messages.po | 2 +- plugins/superset/skills/browser/SKILL.md | 17 +++++++---- 5 files changed, 43 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/main/lib/browser/browser-bridge.test.ts b/apps/desktop/src/main/lib/browser/browser-bridge.test.ts index 58c10d4679b..d911a0b92df 100644 --- a/apps/desktop/src/main/lib/browser/browser-bridge.test.ts +++ b/apps/desktop/src/main/lib/browser/browser-bridge.test.ts @@ -129,6 +129,18 @@ test("agents reach every browser pane, the user's own included", async () => { body: JSON.stringify({ workspaceId: "ws-bridge", code: "document.title" }), }); expect(await userEval.json()).toEqual({ result: "page data" }); + + const cdpClose = await new Promise((resolve) => { + const url = new URL("/panes/user-pane/cdp", bridge.endpoint); + url.protocol = "ws:"; + url.searchParams.set("workspaceId", "ws-bridge"); + url.searchParams.set("token", bridge.secret); + const socket = new WebSocket(url); + socket.onclose = (event) => resolve(event.code); + }); + expect(cdpClose).not.toBe(1008); + + expect((await call("/panes")).status).toBe(400); }); test("an agent copies the user's Chrome logins only after the user allows it", async () => { @@ -222,7 +234,10 @@ test("an agent lists and uses panes after hidden-pane eviction unloaded them", a }; const renderer = (request: PaneLayoutRendererRequest) => { if (request.op.type === "wakeBrowser") { - if (request.op.paneId !== "unloaded-user-pane") { + const woken = { "unloaded-agent-pane": 905, "unloaded-user-pane": 906 }[ + request.op.paneId + ]; + if (!woken) { paneLayoutRequests.respond( { requestId: request.requestId, @@ -232,8 +247,8 @@ test("an agent lists and uses panes after hidden-pane eviction unloaded them", a ); return; } - fakePane(905, BROWSER_PARTITION); - browserManager.register("unloaded-user-pane", 905, "ws-bridge"); + fakePane(woken, BROWSER_PARTITION); + browserManager.register(request.op.paneId, woken, "ws-bridge"); } paneLayoutRequests.respond( { @@ -274,12 +289,15 @@ test("an agent lists and uses panes after hidden-pane eviction unloaded them", a ["unloaded-user-pane", false], ["user-pane", true], ]); - expect(await (await evaluate("unloaded-user-pane")).json()).toEqual({ - result: "page data", - }); + for (const paneId of ["unloaded-agent-pane", "unloaded-user-pane"]) { + expect(await (await evaluate(paneId)).json()).toEqual({ + result: "page data", + }); + } } finally { paneLayoutRequests.off("request", renderer); paneLayoutRequests.removeSubscriber(RENDERER); + browserManager.unregister("unloaded-agent-pane"); browserManager.unregister("unloaded-user-pane"); } }); diff --git a/apps/desktop/src/main/lib/browser/browser-bridge.ts b/apps/desktop/src/main/lib/browser/browser-bridge.ts index 3746574eda5..63d84fda477 100644 --- a/apps/desktop/src/main/lib/browser/browser-bridge.ts +++ b/apps/desktop/src/main/lib/browser/browser-bridge.ts @@ -286,17 +286,14 @@ export async function startBrowserBridge({ }); app.get("/panes", (req, res) => { - const workspaceId = - typeof req.query.workspaceId === "string" - ? req.query.workspaceId - : undefined; - if (workspaceId) { - listPanes(workspaceId) - .then((panes) => res.json({ panes })) - .catch((err) => res.status(500).json({ error: errorMessage(err) })); + const workspaceId = req.query.workspaceId; + if (typeof workspaceId !== "string" || workspaceId.length === 0) { + res.status(400).json({ error: "workspaceId is required" }); return; } - res.json({ panes: browserManager.listPanes() }); + listPanes(workspaceId) + .then((panes) => res.json({ panes })) + .catch((err) => res.status(500).json({ error: errorMessage(err) })); }); app.post("/open", (req, res) => { diff --git a/packages/i18n/locales/fr/messages.po b/packages/i18n/locales/fr/messages.po index c480ce6c41a..dcb466a50fc 100644 --- a/packages/i18n/locales/fr/messages.po +++ b/packages/i18n/locales/fr/messages.po @@ -10503,7 +10503,7 @@ msgid "It can't be brought back." msgstr "Il ne pourra pas être restauré." msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." -msgstr "Cette opération copie les cookies de votre profil « {profileName} » dans le navigateur de l'app. Les agents peuvent alors agir sur les sites où vous êtes connecté." +msgstr "Cette opération copie les cookies de votre profil « {profileName} » dans le navigateur intégré. Les agents peuvent alors agir sur les sites où vous êtes connecté." msgid "It keeps the title from your prompt." msgstr "Le titre issu de votre prompt est conservé." diff --git a/packages/i18n/locales/vi/messages.po b/packages/i18n/locales/vi/messages.po index 309f37ff49a..e8198feda2b 100644 --- a/packages/i18n/locales/vi/messages.po +++ b/packages/i18n/locales/vi/messages.po @@ -10503,7 +10503,7 @@ msgid "It can't be brought back." msgstr "Không thể khôi phục." msgid "It copies the cookies from your \"{profileName}\" profile into the in-app browser. Agents can then act on the sites you are signed in to." -msgstr "Sao chép cookie từ hồ sơ \"{profileName}\" của bạn vào trình duyệt trong ứng dụng. Sau đó agent có thể thao tác trên các trang bạn đã đăng nhập." +msgstr "Sao chép cookie từ hồ sơ \"{profileName}\" của bạn vào trình duyệt trong ứng dụng. Sau đó các agent có thể thao tác trên các trang bạn đã đăng nhập." msgid "It keeps the title from your prompt." msgstr "Tiêu đề từ lời nhắc của bạn sẽ được giữ nguyên." diff --git a/plugins/superset/skills/browser/SKILL.md b/plugins/superset/skills/browser/SKILL.md index d06147a5f67..a61886da807 100644 --- a/plugins/superset/skills/browser/SKILL.md +++ b/plugins/superset/skills/browser/SKILL.md @@ -229,8 +229,9 @@ along at their relative paths, so nothing needs inlining. superset pages publish ./evidence/ --workspace --title "Checkout flow: 3 blockers" ``` -Read every screenshot before it goes up. A pane holds every login the user has, so a capture can hold a session token, an email address, a customer -name, or a dashboard that was never meant to leave the tab. New pages default +Read every screenshot before it goes up. A pane is signed in to every site in +the shared browser session, so a capture can hold a session token, an email +address, a customer name, or a dashboard that was never meant to leave the tab. New pages default to org-wide visibility. Crop or redact what does not belong in the report, pass `--visibility just_me` when the org does not need it, and confirm with the user before publishing. @@ -239,13 +240,17 @@ before publishing. - All panes share one session with the user's logins and extensions (password managers included), so `eval` and CDP reach every site they are signed in to - (GitHub, dashboards, …). Never read cookies, autofilled passwords, - tokens, or credentials, exfiltrate session data, or act on authenticated + (GitHub, dashboards, …). Never read cookies, filled passwords, tokens, + or credentials, exfiltrate session data, or act on authenticated sites beyond the task. - Confirm with the user before a step that submits a form, makes a purchase, or takes another consequential action. -- Login walls stay with the user: never enter passwords or MFA yourself. At a - login wall you may offer `import-login` (above) rather than only stopping. +- At a login wall, stop and ask unless the user already told you to sign in. + When they ask, sign in with their password manager: click its fill + suggestion on the field, or its inline menu, and submit. Never type a + password from anywhere else, never read the filled value back with `eval`, + and never put it in output or a screenshot. MFA codes stay with the user. + You may also offer `import-login` (above). - Never install Browser Use, enable Chrome remote debugging, attach to the user's signed-in profile, or start a billed cloud browser without explicit consent. If consent is refused, report what you couldn't do rather than From 6be7969a96dbc971d9cf31fce6cee1194fa2820d Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Nov 2026 15:06:21 -0800 Subject: [PATCH 3/6] fix(desktop): agents cannot read or write cookies over CDP Now that agent panes share the user's session, raw CDP could dump every cookie, HttpOnly session tokens included, or plant new ones. The pane's CDP session refuses the cookie commands (Network/Storage get, set, delete and clear, clearDataForOrigin with cookies, Cookie or Set-Cookie headers in Network and Fetch commands) and strips cookie headers and cookie lists from Network and Fetch events. Importing logins stays the only way to add cookies. --- .../src/main/lib/browser/browser-manager.ts | 7 +- .../main/lib/browser/cdp-cookie-guard.test.ts | 89 +++++++++++++++++ .../src/main/lib/browser/cdp-cookie-guard.ts | 97 +++++++++++++++++++ plugins/superset/skills/browser/SKILL.md | 5 +- 4 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/main/lib/browser/cdp-cookie-guard.test.ts create mode 100644 apps/desktop/src/main/lib/browser/cdp-cookie-guard.ts diff --git a/apps/desktop/src/main/lib/browser/browser-manager.ts b/apps/desktop/src/main/lib/browser/browser-manager.ts index 42234f6fec9..2781eed63bc 100644 --- a/apps/desktop/src/main/lib/browser/browser-manager.ts +++ b/apps/desktop/src/main/lib/browser/browser-manager.ts @@ -13,6 +13,7 @@ import type { } from "shared/browser-design-mode"; import { PROTOCOL_SCHEME } from "shared/constants"; import { chordFromInput, type ForwardedKey } from "shared/hotkey-chord"; +import { cookieRefusal, redactCookies } from "./cdp-cookie-guard"; import { createExtensionWorldFilter } from "./cdp-extension-worlds"; import { forwardSessionFor, @@ -592,7 +593,7 @@ class BrowserManager extends EventEmitter { onMessage( JSON.stringify({ method, - params, + params: redactCookies(method, params), ...(outSessionId ? { sessionId: outSessionId } : {}), }), ); @@ -675,7 +676,9 @@ class BrowserManager extends EventEmitter { ); return; } - const refusal = extensionWorlds.refusal(method, params); + const refusal = + cookieRefusal(method, params) ?? + extensionWorlds.refusal(method, params); if (refusal) { onMessage( JSON.stringify({ diff --git a/apps/desktop/src/main/lib/browser/cdp-cookie-guard.test.ts b/apps/desktop/src/main/lib/browser/cdp-cookie-guard.test.ts new file mode 100644 index 00000000000..0c553156991 --- /dev/null +++ b/apps/desktop/src/main/lib/browser/cdp-cookie-guard.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "bun:test"; +import { cookieRefusal, redactCookies } from "./cdp-cookie-guard"; + +describe("cookieRefusal", () => { + it("refuses every command that reads or writes cookies", () => { + for (const method of [ + "Network.getAllCookies", + "Network.getCookies", + "Network.setCookies", + "Storage.getCookies", + "Storage.clearCookies", + ]) { + expect(cookieRefusal(method, {})).not.toBeNull(); + } + expect( + cookieRefusal("Storage.clearDataForOrigin", { + origin: "https://a.test", + storageTypes: "local_storage,cookies", + }), + ).not.toBeNull(); + expect( + cookieRefusal("Network.setExtraHTTPHeaders", { + headers: { Cookie: "sid=1" }, + }), + ).not.toBeNull(); + expect( + cookieRefusal("Fetch.fulfillRequest", { + requestId: "1", + responseCode: 200, + responseHeaders: [{ name: "Set-Cookie", value: "sid=1" }], + }), + ).not.toBeNull(); + }); + + it("lets other commands through", () => { + expect(cookieRefusal("Page.navigate", { url: "https://a.test" })).toBe( + null, + ); + expect( + cookieRefusal("Storage.clearDataForOrigin", { + origin: "https://a.test", + storageTypes: "local_storage", + }), + ).toBe(null); + expect( + cookieRefusal("Network.setExtraHTTPHeaders", { + headers: { "X-Test": "1" }, + }), + ).toBe(null); + }); +}); + +describe("redactCookies", () => { + it("strips cookie headers and cookie lists from network events", () => { + expect( + redactCookies("Network.requestWillBeSentExtraInfo", { + requestId: "1", + associatedCookies: [{ cookie: { name: "sid", value: "secret" } }], + headers: { cookie: "sid=secret", accept: "*/*" }, + }), + ).toEqual({ requestId: "1", headers: { accept: "*/*" } }); + expect( + redactCookies("Network.responseReceived", { + response: { + headers: { "Set-Cookie": "sid=secret", "Content-Type": "text/html" }, + headersText: "HTTP/1.1 200 OK\r\nSet-Cookie: sid=secret\r\nA: b", + }, + }), + ).toEqual({ + response: { + headers: { "Content-Type": "text/html" }, + headersText: "HTTP/1.1 200 OK\r\nA: b", + }, + }); + expect( + redactCookies("Fetch.requestPaused", { + responseHeaders: [ + { name: "set-cookie", value: "sid=secret" }, + { name: "a", value: "b" }, + ], + }), + ).toEqual({ responseHeaders: [{ name: "a", value: "b" }] }); + }); + + it("leaves other events alone", () => { + const params = { headers: { cookie: "kept" } }; + expect(redactCookies("Page.frameNavigated", params)).toBe(params); + }); +}); diff --git a/apps/desktop/src/main/lib/browser/cdp-cookie-guard.ts b/apps/desktop/src/main/lib/browser/cdp-cookie-guard.ts new file mode 100644 index 00000000000..6da61e62fbf --- /dev/null +++ b/apps/desktop/src/main/lib/browser/cdp-cookie-guard.ts @@ -0,0 +1,97 @@ +const COOKIE_METHODS = new Set([ + "Network.getAllCookies", + "Network.getCookies", + "Network.setCookie", + "Network.setCookies", + "Network.deleteCookies", + "Network.clearBrowserCookies", + "Storage.getCookies", + "Storage.setCookies", + "Storage.clearCookies", +]); +const CLEAR_DATA_METHODS = new Set([ + "Storage.clearDataForOrigin", + "Storage.clearDataForStorageKey", +]); +const COOKIE_HEADERS = new Set(["cookie", "set-cookie", "set-cookie2"]); +const HEADER_KEYS = new Set(["headers", "requestHeaders", "responseHeaders"]); +const HEADER_TEXT_KEYS = new Set(["headersText", "requestHeadersText"]); +const COOKIE_LIST_KEYS = new Set([ + "associatedCookies", + "blockedCookies", + "exemptedCookies", +]); +const REFUSAL = + "Cookies are not available over CDP. Ask the user to import logins instead"; + +const isCookieHeader = (name: unknown) => + typeof name === "string" && COOKIE_HEADERS.has(name.toLowerCase()); + +function namesCookieHeader(headers: unknown): boolean { + if (Array.isArray(headers)) { + return headers.some((header) => isCookieHeader(header?.name)); + } + return ( + typeof headers === "object" && + headers !== null && + Object.keys(headers).some(isCookieHeader) + ); +} + +/** Agents never read or write the session's cookies; importing logins is the only way in. */ +export function cookieRefusal(method: string, params: unknown): string | null { + if (COOKIE_METHODS.has(method)) return REFUSAL; + const p = (params ?? {}) as Record; + if (CLEAR_DATA_METHODS.has(method)) { + const types = String(p.storageTypes ?? "").split(","); + return types.some((type) => ["all", "cookies"].includes(type.trim())) + ? REFUSAL + : null; + } + if (!method.startsWith("Network.") && !method.startsWith("Fetch.")) { + return null; + } + return p.binaryResponseHeaders !== undefined || + namesCookieHeader(p.headers) || + namesCookieHeader(p.responseHeaders) + ? REFUSAL + : null; +} + +function withoutCookieHeaders(headers: unknown): unknown { + if (Array.isArray(headers)) { + return headers.filter((header) => !isCookieHeader(header?.name)); + } + if (typeof headers !== "object" || headers === null) return headers; + return Object.fromEntries( + Object.entries(headers).filter(([name]) => !isCookieHeader(name)), + ); +} + +function withoutCookieLines(text: unknown): unknown { + if (typeof text !== "string") return text; + return text + .split("\r\n") + .filter((line) => !isCookieHeader(line.split(":")[0]?.trim())) + .join("\r\n"); +} + +function redact(value: unknown, depth: number): unknown { + if (depth > 6 || typeof value !== "object" || value === null) return value; + if (Array.isArray(value)) return value.map((item) => redact(item, depth + 1)); + const out: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (COOKIE_LIST_KEYS.has(key)) continue; + if (HEADER_KEYS.has(key)) out[key] = withoutCookieHeaders(item); + else if (HEADER_TEXT_KEYS.has(key)) out[key] = withoutCookieLines(item); + else out[key] = redact(item, depth + 1); + } + return out; +} + +/** Strips cookie headers and cookie lists from Network and Fetch events. */ +export function redactCookies(method: string, params: unknown): unknown { + return method.startsWith("Network.") || method.startsWith("Fetch.") + ? redact(params, 0) + : params; +} diff --git a/plugins/superset/skills/browser/SKILL.md b/plugins/superset/skills/browser/SKILL.md index a61886da807..0d6e40afbf7 100644 --- a/plugins/superset/skills/browser/SKILL.md +++ b/plugins/superset/skills/browser/SKILL.md @@ -240,8 +240,9 @@ before publishing. - All panes share one session with the user's logins and extensions (password managers included), so `eval` and CDP reach every site they are signed in to - (GitHub, dashboards, …). Never read cookies, filled passwords, tokens, - or credentials, exfiltrate session data, or act on authenticated + (GitHub, dashboards, …). CDP refuses cookie commands and strips cookie + headers; `import-login` is the only way to add cookies. Never read cookies + (`document.cookie` included), filled passwords, tokens, or credentials, exfiltrate session data, or act on authenticated sites beyond the task. - Confirm with the user before a step that submits a form, makes a purchase, or takes another consequential action. From ba6c183fea374505d55ddfdef3d9ab23ccf693c5 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Nov 2026 15:12:22 -0800 Subject: [PATCH 4/6] refactor(desktop): browser panes act the same no matter who opened them Drop the `agent` flag from browser panes. Panes an agent opens no longer get a separate hidden-webview pool or idle timeout, a different split target, or an `openUrlAgent` route param; every browser pane shares one eviction budget. Pane layout snapshots no longer report `browser.agent`. persistAgentBrowserState is now persistBrowserPaneState and markAgentUse is markUse. --- .../main/lib/browser/browser-bridge.test.ts | 48 +++++----- .../src/main/lib/browser/browser-bridge.ts | 4 +- .../src/main/lib/browser/browser-manager.ts | 2 +- .../_dashboard/utils/workspace-navigation.ts | 1 - .../useConsumeOpenUrlRequest.ts | 14 +-- .../browserRuntimeRegistry.test.ts | 23 +---- .../BrowserPane/browserRuntimeRegistry.ts | 92 ++++++++----------- .../usePersistentWebview.ts | 13 +-- .../v2-workspace/$workspaceId/page.tsx | 4 - .../v2-workspace/$workspaceId/types.ts | 5 +- .../openUrlInV2Workspace.ts | 4 +- .../useBrowserOpenRequests.ts | 6 +- .../openBackgroundBrowser.test.ts | 2 +- .../openBackgroundBrowser.ts | 2 +- .../usePaneLayoutRequests.ts | 5 +- .../describePaneLayout/describePaneLayout.ts | 5 +- .../utils/persistAgentBrowserState/index.ts | 1 - .../utils/persistBrowserPaneState/index.ts | 1 + .../persistBrowserPaneState.ts} | 4 +- packages/shared/src/pane-layout-ops.ts | 4 +- plans/20261030-browser-pane-passkeys.md | 2 +- plugins/superset/skills/browser/SKILL.md | 7 +- 22 files changed, 90 insertions(+), 159 deletions(-) delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/utils/persistAgentBrowserState/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/utils/persistBrowserPaneState/index.ts rename apps/desktop/src/renderer/routes/_authenticated/utils/{persistAgentBrowserState/persistAgentBrowserState.ts => persistBrowserPaneState/persistBrowserPaneState.ts} (93%) diff --git a/apps/desktop/src/main/lib/browser/browser-bridge.test.ts b/apps/desktop/src/main/lib/browser/browser-bridge.test.ts index d911a0b92df..ba37363e7dd 100644 --- a/apps/desktop/src/main/lib/browser/browser-bridge.test.ts +++ b/apps/desktop/src/main/lib/browser/browser-bridge.test.ts @@ -93,21 +93,21 @@ beforeAll(async () => { fromIdMock.mockImplementation((id: number) => panes.get(id) ?? null); fakePane(901, BROWSER_PARTITION); fakePane(902, BROWSER_PARTITION); - browserManager.register("user-pane", 901, "ws-bridge"); - browserManager.register("agent-pane", 902, "ws-bridge"); + browserManager.register("pane-b", 901, "ws-bridge"); + browserManager.register("pane-a", 902, "ws-bridge"); await startBrowserBridge({ homeDir: home, extensions, answerDeadlineMs: 50 }); }); afterAll(async () => { await stopBrowserBridge(); rmSync(home, { recursive: true, force: true }); - browserManager.unregister("user-pane"); - browserManager.unregister("agent-pane"); + browserManager.unregister("pane-b"); + browserManager.unregister("pane-a"); fromIdMock.mockImplementation(preloadFromId ?? (() => null)); fromPartitionMock.mockImplementation(preloadFromPartition ?? (() => ({}))); }); -test("agents reach every browser pane, the user's own included", async () => { +test("agents reach every browser pane", async () => { const bridge = getBrowserBridgeInfo(); if (!bridge) throw new Error("the bridge did not start"); const call = (path: string, init?: RequestInit) => @@ -122,16 +122,16 @@ test("agents reach every browser pane, the user's own included", async () => { const listed = await (await call("/panes?workspaceId=ws-bridge")).json(); expect( listed.panes.map((pane: { paneId: string }) => pane.paneId).sort(), - ).toEqual(["agent-pane", "user-pane"]); + ).toEqual(["pane-a", "pane-b"]); - const userEval = await call("/panes/user-pane/eval", { + const userEval = await call("/panes/pane-b/eval", { method: "POST", body: JSON.stringify({ workspaceId: "ws-bridge", code: "document.title" }), }); expect(await userEval.json()).toEqual({ result: "page data" }); const cdpClose = await new Promise((resolve) => { - const url = new URL("/panes/user-pane/cdp", bridge.endpoint); + const url = new URL("/panes/pane-b/cdp", bridge.endpoint); url.protocol = "ws:"; url.searchParams.set("workspaceId", "ws-bridge"); url.searchParams.set("token", bridge.secret); @@ -150,7 +150,7 @@ test("an agent copies the user's Chrome logins only after the user allows it", a typeof mock >; const importLogins = () => - fetch(`${bridge.endpoint}/panes/agent-pane/import-cookies`, { + fetch(`${bridge.endpoint}/panes/pane-a/import-cookies`, { method: "POST", headers: { authorization: `Bearer ${bridge.secret}`, @@ -207,13 +207,13 @@ test("an agent lists and uses panes after hidden-pane eviction unloaded them", a const bridge = getBrowserBridgeInfo(); if (!bridge) throw new Error("the bridge did not start"); const RENDERER = 990; - const layoutPane = (id: string, agent: boolean) => ({ + const layoutPane = (id: string) => ({ id, kind: "browser", title: id, terminalId: null, active: false, - browser: { url: `https://example.com/${id}`, agent }, + browser: { url: `https://example.com/${id}` }, }); const layout: PaneLayoutSnapshot = { activeTabId: "tab", @@ -222,19 +222,19 @@ test("an agent lists and uses panes after hidden-pane eviction unloaded them", a id: "tab", title: null, active: true, - activePaneId: "agent-pane", - layout: { type: "pane", paneId: "agent-pane" }, + activePaneId: "pane-a", + layout: { type: "pane", paneId: "pane-a" }, panes: [ - layoutPane("agent-pane", true), - layoutPane("unloaded-agent-pane", true), - layoutPane("unloaded-user-pane", false), + layoutPane("pane-a"), + layoutPane("unloaded-pane-a"), + layoutPane("unloaded-pane-b"), ], }, ], }; const renderer = (request: PaneLayoutRendererRequest) => { if (request.op.type === "wakeBrowser") { - const woken = { "unloaded-agent-pane": 905, "unloaded-user-pane": 906 }[ + const woken = { "unloaded-pane-a": 905, "unloaded-pane-b": 906 }[ request.op.paneId ]; if (!woken) { @@ -284,12 +284,12 @@ test("an agent lists and uses panes after hidden-pane eviction unloaded them", a pane.loaded, ]), ).toEqual([ - ["agent-pane", true], - ["unloaded-agent-pane", false], - ["unloaded-user-pane", false], - ["user-pane", true], + ["pane-a", true], + ["unloaded-pane-a", false], + ["unloaded-pane-b", false], + ["pane-b", true], ]); - for (const paneId of ["unloaded-agent-pane", "unloaded-user-pane"]) { + for (const paneId of ["unloaded-pane-a", "unloaded-pane-b"]) { expect(await (await evaluate(paneId)).json()).toEqual({ result: "page data", }); @@ -297,8 +297,8 @@ test("an agent lists and uses panes after hidden-pane eviction unloaded them", a } finally { paneLayoutRequests.off("request", renderer); paneLayoutRequests.removeSubscriber(RENDERER); - browserManager.unregister("unloaded-agent-pane"); - browserManager.unregister("unloaded-user-pane"); + browserManager.unregister("unloaded-pane-a"); + browserManager.unregister("unloaded-pane-b"); } }); diff --git a/apps/desktop/src/main/lib/browser/browser-bridge.ts b/apps/desktop/src/main/lib/browser/browser-bridge.ts index 63d84fda477..66a1f5b3826 100644 --- a/apps/desktop/src/main/lib/browser/browser-bridge.ts +++ b/apps/desktop/src/main/lib/browser/browser-bridge.ts @@ -206,7 +206,7 @@ async function resolvePane( .json({ error: `No browser pane ${paneId} in this workspace` }); return null; } - browserManager.markAgentUse(paneId); + browserManager.markUse(paneId); return { wc, paneId, workspaceId }; } @@ -705,7 +705,7 @@ function handleCdpSocket( ); return; } - browserManager.markAgentUse(paneId); + browserManager.markUse(paneId); let cdp: ReturnType; try { cdp = browserManager.attachCdp( diff --git a/apps/desktop/src/main/lib/browser/browser-manager.ts b/apps/desktop/src/main/lib/browser/browser-manager.ts index 2781eed63bc..040cc8d9235 100644 --- a/apps/desktop/src/main/lib/browser/browser-manager.ts +++ b/apps/desktop/src/main/lib/browser/browser-manager.ts @@ -499,7 +499,7 @@ class BrowserManager extends EventEmitter { this.emit(`open-resolved:${requestId}`, paneId); } - markAgentUse(paneId: string): void { + markUse(paneId: string): void { this.emit("agent-use", { paneId }); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts index aa0a48f66b5..c0dd1f58150 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts @@ -85,7 +85,6 @@ export interface V2WorkspaceSearchParams extends SubagentLinkSearchParams { openUrl?: string; openUrlTarget?: "current-tab" | "new-tab"; openUrlRequestId?: string; - openUrlAgent?: true; } /** diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/useConsumeOpenUrlRequest.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/useConsumeOpenUrlRequest.ts index 618abd66ab6..2d35e835947 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/useConsumeOpenUrlRequest.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/useConsumeOpenUrlRequest.ts @@ -11,7 +11,6 @@ interface UseConsumeOpenUrlRequestArgs { url: string | undefined; target: V2WorkspaceUrlOpenTarget | undefined; requestId: string | undefined; - agent?: true; consumeSearch: ConsumeSearch; } @@ -20,7 +19,6 @@ export function useConsumeOpenUrlRequest({ url, target, requestId, - agent, consumeSearch, }: UseConsumeOpenUrlRequestArgs): void { const consumedRef = useRef>(new Set()); @@ -40,22 +38,16 @@ export function useConsumeOpenUrlRequest({ store: viewer.store, target: resolvedTarget, url, - agent, }); - if (agent && requestId && paneId) { + if (requestId && paneId) { electronTrpcClient.browser.resolveOpenRequest .mutate({ requestId, paneId }) .catch((err) => { console.error("[useConsumeOpenUrlRequest] resolve open failed:", err); }); } - consumeSearch([ - "openUrl", - "openUrlTarget", - "openUrlRequestId", - "openUrlAgent", - ]); - }, [viewer, target, url, requestId, agent, consumeSearch]); + consumeSearch(["openUrl", "openUrlTarget", "openUrlRequestId"]); + }, [viewer, target, url, requestId, consumeSearch]); } export function getOpenUrlRequestConsumeKey({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.test.ts index a15235187a0..3e7a85e6019 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.test.ts @@ -41,7 +41,7 @@ const browserRuntimeRegistry = new BrowserRuntimeRegistryImpl({ }, } as unknown as ConstructorParameters[0]); -const hiddenEntry = (lastUsedAt: number, agent: boolean) => ({ +const hiddenEntry = (lastUsedAt: number) => ({ webview: { remove: () => {}, style: { visibility: "hidden" } }, overlay: { remove: () => {}, style: { visibility: "hidden" } }, state: {}, @@ -52,7 +52,6 @@ const hiddenEntry = (lastUsedAt: number, agent: boolean) => ({ resizeObserver: null, visible: false, lastUsedAt, - agent, }); describe("browserRuntimeRegistry detached persistence", () => { @@ -94,16 +93,16 @@ describe("browserRuntimeRegistry detached persistence", () => { evictExcessHiddenWebviews: () => Promise; touch: (paneId: string) => void; }; - // Hidden 1 GB pages, oldest first, on a 16 GB machine: each group's budget is under 1 GB. + // Hidden 1 GB pages, oldest first, on a 16 GB machine: the budget is under 1 GB. async function sweep( - pages: { id: string; agent?: boolean }[], + pages: { id: string }[], setup: () => void = () => {}, ): Promise { const now = Date.now(); for (const [i, page] of pages.entries()) { registryInternals.entries.set( page.id, - hiddenEntry(now - (pages.length - i), page.agent ?? false), + hiddenEntry(now - (pages.length - i)), ); } setup(); @@ -133,20 +132,6 @@ describe("browserRuntimeRegistry detached persistence", () => { expect(unloaded).toEqual(["p4", "p5"]); }); - test("agent pages have their own budget, and agent use keeps a page loaded", async () => { - const unloaded = await sweep( - [ - ...[1, 2, 3].map((i) => ({ id: `user-${i}` })), - ...[1, 2, 3, 4, 5, 6, 7].map((i) => ({ - id: `agent-${i}`, - agent: true, - })), - ], - () => registryInternals.touch("agent-1"), - ); - expect(unloaded).toEqual(["agent-2", "agent-3", "agent-4", "agent-5"]); - }); - test("a page an agent uses while the sweep waits on main stays loaded", async () => { const unloaded = await sweep( ["p1", "p2", "p3", "p4", "p5"].map((id) => ({ id })), diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts index 7ea2d27247b..7e6cee93977 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts @@ -61,26 +61,15 @@ interface RegistryEntry { visible: boolean; /** Time of last use in ms, kept strictly increasing so ties never happen. */ lastUsedAt: number; - agent: boolean; } // Each hidden webview is a full guest Chromium process. (SUPER-1545) -const HIDDEN_POOLS = [ - { - agent: false, - budgetShare: 0.06, - minLoaded: 3, - maxLoaded: 20, - idleMs: Number.POSITIVE_INFINITY, - }, - { - agent: true, - budgetShare: 0.06, - minLoaded: 3, - maxLoaded: 20, - idleMs: 30 * 60_000, - }, -]; +const HIDDEN_POOL = { + budgetShare: 0.06, + minLoaded: 3, + maxLoaded: 20, + idleMs: Number.POSITIVE_INFINITY, +}; const UNLOAD_SWEEP_INTERVAL_MS = 60_000; const UNLOAD_INFO_TIMEOUT_MS = 5_000; @@ -352,7 +341,6 @@ export class BrowserRuntimeRegistryImpl { paneId: string, initialUrl: string, workspaceId: string, - agent: boolean, ): RegistryEntry { const webview = document.createElement("webview") as Electron.WebviewTag; const detachViewportZoom = attachBrowserViewportZoom(webview); @@ -396,7 +384,6 @@ export class BrowserRuntimeRegistryImpl { resizeObserver: null, visible: false, lastUsedAt: 0, - agent, }; const firePersist = () => { @@ -582,18 +569,17 @@ export class BrowserRuntimeRegistryImpl { return entry; } - /** Create an agent's guest without mounting or focusing its workspace route. */ + /** Create a guest without mounting or focusing its workspace route. */ openBackground( paneId: string, url: string, workspaceId: string, onPersist: (state: PersistableBrowserState) => void, - agent = false, ): RegistryEntry { const existing = this.entries.get(paneId); if (existing) return existing; const root = this.ensureRootContainer(); - const entry = this.createEntry(paneId, url, workspaceId, agent); + const entry = this.createEntry(paneId, url, workspaceId); entry.onPersist = onPersist; entry.lastUsedAt = this.stamp(); // Give automation a usable viewport before this pane has ever been shown. @@ -614,14 +600,12 @@ export class BrowserRuntimeRegistryImpl { workspaceId: string, onPersist: (state: PersistableBrowserState) => void, onClose: () => void, - agent = false, ): void { const entry = this.openBackground( paneId, initialUrl, workspaceId, onPersist, - agent, ); // A reused pane can move between workspaces (the attach effect keys on // workspaceId). Keep the registration's workspace current so main-side @@ -704,38 +688,34 @@ export class BrowserRuntimeRegistryImpl { return null; }); const now = Date.now(); - for (const pool of HIDDEN_POOLS) { - const candidates = hidden - .filter(([, entry]) => entry.agent === pool.agent) - .map(([paneId, entry]) => { - const pane = info?.panes[paneId]; - return { - paneId, - lastUsedAt: entry.lastUsedAt, - memoryKb: pane?.memoryKb ?? 0, - keepLoaded: - this.agentActivePaneIds.has(paneId) || - pane?.audible === true || - pane?.unsavedInput === true, - }; - }); - const unload = selectWebviewsToUnload(candidates, { - now, - budgetKb: info - ? info.totalMemoryKb * pool.budgetShare - : Number.POSITIVE_INFINITY, - minLoaded: pool.minLoaded, - maxLoaded: pool.maxLoaded, - idleMs: pool.idleMs, - }); - for (const paneId of unload) { - const entry = this.entries.get(paneId); - const seen = candidates.find((c) => c.paneId === paneId); - // Shown or used while the info was on its way. - if (!entry || entry.visible || entry.lastUsedAt !== seen?.lastUsedAt) - continue; - this.destroy(paneId); - } + const candidates = hidden.map(([paneId, entry]) => { + const pane = info?.panes[paneId]; + return { + paneId, + lastUsedAt: entry.lastUsedAt, + memoryKb: pane?.memoryKb ?? 0, + keepLoaded: + this.agentActivePaneIds.has(paneId) || + pane?.audible === true || + pane?.unsavedInput === true, + }; + }); + const unload = selectWebviewsToUnload(candidates, { + now, + budgetKb: info + ? info.totalMemoryKb * HIDDEN_POOL.budgetShare + : Number.POSITIVE_INFINITY, + minLoaded: HIDDEN_POOL.minLoaded, + maxLoaded: HIDDEN_POOL.maxLoaded, + idleMs: HIDDEN_POOL.idleMs, + }); + for (const paneId of unload) { + const entry = this.entries.get(paneId); + const seen = candidates.find((c) => c.paneId === paneId); + // Shown or used while the info was on its way. + if (!entry || entry.visible || entry.lastUsedAt !== seen?.lastUsedAt) + continue; + this.destroy(paneId); } } finally { this.sweeping = false; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts index 9eda0d06cc8..d5bee8a52ab 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts @@ -39,8 +39,6 @@ export function usePersistentWebview({ // at first mount. const attachUrlRef = useRef(paneData.url || DEFAULT_BROWSER_URL); attachUrlRef.current = paneData.url || DEFAULT_BROWSER_URL; - const agentRef = useRef(paneData.agent); - agentRef.current = paneData.agent; useEffect(() => { const placeholder = placeholderRef.current; @@ -69,7 +67,6 @@ export function usePersistentWebview({ () => { void ctxRef.current.actions.close(); }, - agentRef.current, ); setOverlayContainer(browserRuntimeRegistry.getOverlayContainer(paneId)); @@ -86,10 +83,7 @@ export function usePersistentWebview({ onData: ({ url }: { url: string }) => { ctxRef.current.actions.split("right", { kind: "browser", - data: { - url, - ...(agentRef.current && { agent: true }), - } as BrowserPaneData, + data: { url } as BrowserPaneData, }); }, }, @@ -102,10 +96,7 @@ export function usePersistentWebview({ if (action === "open-in-split") { ctxRef.current.actions.split("right", { kind: "browser", - data: { - url, - ...(agentRef.current && { agent: true }), - } as BrowserPaneData, + data: { url } as BrowserPaneData, }); } }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index b476e033f3f..22e22adc912 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -127,7 +127,6 @@ interface WorkspaceSearch { openUrl?: string; openUrlTarget?: V2WorkspaceUrlOpenTarget; openUrlRequestId?: string; - openUrlAgent?: true; } function parseOpenUrlTarget( @@ -153,7 +152,6 @@ export const Route = createFileRoute( openUrl: parseNonEmptyString(raw.openUrl), openUrlTarget: parseOpenUrlTarget(raw.openUrlTarget), openUrlRequestId: parseNonEmptyString(raw.openUrlRequestId), - openUrlAgent: raw.openUrlAgent === true ? true : undefined, }), }); @@ -201,7 +199,6 @@ function V2WorkspaceContent() { openUrl, openUrlTarget, openUrlRequestId, - openUrlAgent, } = Route.useSearch(); const { workspace } = useWorkspace(); const workspaceId = workspace.id; @@ -378,7 +375,6 @@ function V2WorkspaceContent() { url: openUrl, target: openUrlTarget, requestId: openUrlRequestId, - agent: openUrlAgent, consumeSearch, }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts index 2e1a145163b..c7b6f40bcbd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts @@ -38,8 +38,6 @@ export interface BrowserPaneData { url: string; pageTitle?: string; faviconUrl?: string | null; - /** Opened by an agent: it runs in the agent session, without the user's extensions. */ - agent?: true; } export interface DevtoolsPaneData { @@ -182,8 +180,7 @@ export type WorkspaceSearchKey = | "subagentType" | "openUrl" | "openUrlTarget" - | "openUrlRequestId" - | "openUrlAgent"; + | "openUrlRequestId"; /** * Drops the search params a deep link arrived with, once the hook that owns diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace/openUrlInV2Workspace.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace/openUrlInV2Workspace.ts index b592ac834c9..a44d5f72944 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace/openUrlInV2Workspace.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace/openUrlInV2Workspace.ts @@ -8,16 +8,14 @@ export function openUrlInV2Workspace({ store, target, url, - agent, }: { store: StoreApi>; target: V2WorkspaceUrlOpenTarget; url: string; - agent?: true; }): string | null { const pane = { kind: "browser", - data: { url, ...(agent && { agent }) } satisfies BrowserPaneData, + data: { url } satisfies BrowserPaneData, }; const state = store.getState(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useBrowserOpenRequests/useBrowserOpenRequests.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useBrowserOpenRequests/useBrowserOpenRequests.ts index e756c9a1b63..a21764fee16 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useBrowserOpenRequests/useBrowserOpenRequests.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useBrowserOpenRequests/useBrowserOpenRequests.ts @@ -4,7 +4,7 @@ import { electronTrpcClient } from "renderer/lib/trpc-client"; import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { browserRuntimeRegistry } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { persistAgentBrowserState } from "renderer/routes/_authenticated/utils/persistAgentBrowserState"; +import { persistBrowserPaneState } from "renderer/routes/_authenticated/utils/persistBrowserPaneState"; import { writeWorkspacePaneLayout } from "renderer/stores/workspace-creates/writeWorkspacePaneLayout"; import { openBackgroundBrowser } from "./utils/openBackgroundBrowser"; @@ -45,12 +45,11 @@ export function useBrowserOpenRequests() { paneId, request.url, request.workspaceId, - persistAgentBrowserState( + persistBrowserPaneState( collections, request.workspaceId, paneId, ), - true, ); electronTrpcClient.browser.resolveOpenRequest .mutate({ requestId: request.requestId, paneId }) @@ -73,7 +72,6 @@ export function useBrowserOpenRequests() { openUrl: request.url, openUrlTarget: request.target, openUrlRequestId: request.requestId, - openUrlAgent: true, }, }).catch((err) => { console.error("[useBrowserOpenRequests] navigate failed:", err); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useBrowserOpenRequests/utils/openBackgroundBrowser/openBackgroundBrowser.test.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useBrowserOpenRequests/utils/openBackgroundBrowser/openBackgroundBrowser.test.ts index ea82826a811..2562c74c33a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useBrowserOpenRequests/utils/openBackgroundBrowser/openBackgroundBrowser.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useBrowserOpenRequests/utils/openBackgroundBrowser/openBackgroundBrowser.test.ts @@ -53,7 +53,7 @@ describe("openBackgroundBrowser", () => { .flatMap((tab) => Object.values(tab.panes)) .find((pane) => pane.id === paneId); expect(pane?.kind).toBe("browser"); - expect(pane?.data).toEqual({ url: "https://example.com", agent: true }); + expect(pane?.data).toEqual({ url: "https://example.com" }); expect(layout.tabs.length).toBe(target === "new-tab" ? 2 : 1); }); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useBrowserOpenRequests/utils/openBackgroundBrowser/openBackgroundBrowser.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useBrowserOpenRequests/utils/openBackgroundBrowser/openBackgroundBrowser.ts index fca4c083a6e..dae2851eb91 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useBrowserOpenRequests/utils/openBackgroundBrowser/openBackgroundBrowser.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useBrowserOpenRequests/utils/openBackgroundBrowser/openBackgroundBrowser.ts @@ -29,7 +29,7 @@ export function openBackgroundBrowser({ const store = createWorkspaceStore({ initialState: previous, }); - const paneId = openUrlInV2Workspace({ store, url, target, agent: true }); + const paneId = openUrlInV2Workspace({ store, url, target }); if (!paneId) throw new Error("Browser open did not create a pane"); const next = store.getState(); const paneLayout = preserveLocalPaneSelection(previous, { diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalPaneLayoutRequests/hooks/usePaneLayoutRequests/usePaneLayoutRequests.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalPaneLayoutRequests/hooks/usePaneLayoutRequests/usePaneLayoutRequests.ts index a055cd0b94e..4d6df00bae4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalPaneLayoutRequests/hooks/usePaneLayoutRequests/usePaneLayoutRequests.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalPaneLayoutRequests/hooks/usePaneLayoutRequests/usePaneLayoutRequests.ts @@ -20,7 +20,7 @@ import { import { addPendingChatStop } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/pendingChatStops"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { AppCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/collections"; -import { persistAgentBrowserState } from "renderer/routes/_authenticated/utils/persistAgentBrowserState"; +import { persistBrowserPaneState } from "renderer/routes/_authenticated/utils/persistBrowserPaneState"; import { useRightPaneFullScreen } from "renderer/stores/right-pane-full-screen"; import { getV2PaneLayoutStore, @@ -214,8 +214,7 @@ function wakeBrowser( paneId, pane.browser.url || "about:blank", workspaceId, - persistAgentBrowserState(collections, workspaceId, paneId), - pane.browser.agent === true, + persistBrowserPaneState(collections, workspaceId, paneId), ); return { ...listed, paneId, tabId: tab.id }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalPaneLayoutRequests/hooks/usePaneLayoutRequests/utils/describePaneLayout/describePaneLayout.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalPaneLayoutRequests/hooks/usePaneLayoutRequests/utils/describePaneLayout/describePaneLayout.ts index 7b398ec2b70..82c97043f5f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalPaneLayoutRequests/hooks/usePaneLayoutRequests/utils/describePaneLayout/describePaneLayout.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalPaneLayoutRequests/hooks/usePaneLayoutRequests/utils/describePaneLayout/describePaneLayout.ts @@ -60,10 +60,7 @@ export function describePaneLayout( terminalId: readString(pane.data, "terminalId"), active: id === tab.activePaneId, ...(pane.kind === "browser" && { - browser: { - url: readString(pane.data, "url") ?? "", - agent: (pane.data as { agent?: unknown })?.agent === true, - }, + browser: { url: readString(pane.data, "url") ?? "" }, }), }, ]; diff --git a/apps/desktop/src/renderer/routes/_authenticated/utils/persistAgentBrowserState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/utils/persistAgentBrowserState/index.ts deleted file mode 100644 index 59dc430a261..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/utils/persistAgentBrowserState/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { persistAgentBrowserState } from "./persistAgentBrowserState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/utils/persistBrowserPaneState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/utils/persistBrowserPaneState/index.ts new file mode 100644 index 00000000000..d16bea3fba5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/utils/persistBrowserPaneState/index.ts @@ -0,0 +1 @@ +export { persistBrowserPaneState } from "./persistBrowserPaneState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/utils/persistAgentBrowserState/persistAgentBrowserState.ts b/apps/desktop/src/renderer/routes/_authenticated/utils/persistBrowserPaneState/persistBrowserPaneState.ts similarity index 93% rename from apps/desktop/src/renderer/routes/_authenticated/utils/persistAgentBrowserState/persistAgentBrowserState.ts rename to apps/desktop/src/renderer/routes/_authenticated/utils/persistBrowserPaneState/persistBrowserPaneState.ts index 3f56c7d6ce0..69736d48ba4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/utils/persistAgentBrowserState/persistAgentBrowserState.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/utils/persistBrowserPaneState/persistBrowserPaneState.ts @@ -13,10 +13,10 @@ import { const SLOTS: PaneLayoutSlot[] = ["paneLayout", "rightPaneLayout"]; /** - * Persists a page an agent's background browser pane navigated to, into the + * Persists a page a background browser pane navigated to, into the * workspace's mounted layout when it is shown, else into its saved layout. */ -export function persistAgentBrowserState( +export function persistBrowserPaneState( collections: Pick, workspaceId: string, paneId: string, diff --git a/packages/shared/src/pane-layout-ops.ts b/packages/shared/src/pane-layout-ops.ts index 5329f553353..390cb313cbc 100644 --- a/packages/shared/src/pane-layout-ops.ts +++ b/packages/shared/src/pane-layout-ops.ts @@ -117,8 +117,8 @@ export interface PaneLayoutPane { terminalTitle?: string | null; /** Filled in by the host when an agent runs in the pane's terminal. */ agent?: { id: string; state: AgentRunState } | null; - /** Browser panes only. `agent` marks a pane an agent opened, in the agent session. */ - browser?: { url: string; agent: boolean }; + /** Browser panes only. */ + browser?: { url: string }; } export interface PaneLayoutTab { diff --git a/plans/20261030-browser-pane-passkeys.md b/plans/20261030-browser-pane-passkeys.md index 50ebd38585a..79f47a1e590 100644 --- a/plans/20261030-browser-pane-passkeys.md +++ b/plans/20261030-browser-pane-passkeys.md @@ -79,7 +79,7 @@ that to the app itself, and must not trust an extension it did not verify. | Supply chain | One maintainer. `electron-chrome-extensions@4.10.0` shipped 2026-10-25. | Exact versions, lockfile hashes, diff review on every bump. | | One click installs | The package installs on one click, with every permission the extension asks for. Chrome first asks "Add {name}?" and lists what the extension can reach. | `beforeInstall` shows the same question, with Chrome's wording (`extension-host/install-warnings.ts`). Cancel denies the install. | | Agents reach extension worlds | Agents drive panes through the browser bridge (eval, screenshot, raw CDP). A pane cannot open `chrome-extension://` pages, so the vault page is out of reach. Raw CDP can still list a content script's world (`Runtime.executionContextCreated` with a `chrome-extension://` origin) and run code in it, and that world can message its extension. `Debugger` and `HeapProfiler` reach the same JS heap. | The CDP session passes on only the execution contexts it saw created outside an extension, refuses commands that name any other context, and refuses the `Debugger` and `HeapProfiler` domains (`browser/cdp-extension-worlds.ts`). | -| Agents read autofill | If an agent drove a pane in the user's session, a password manager could fill a field there and the agent's eval could read it. | Accepted (2026-11-08). Every pane, agent-opened or not, uses `persist:browser`, so extensions and logins work everywhere. The separate `persist:browser-agent` session is gone, and the bridge drives any browser pane. The browser skill tells agents never to read autofilled passwords or cookies. | +| Agents read autofill | If an agent drove a pane in the user's session, a password manager could fill a field there and the agent's eval could read it. | Accepted (2026-11-08). Every pane, agent-opened or not, uses `persist:browser`, so extensions and logins work everywhere. The separate `persist:browser-agent` session and the pane `agent` flag are gone: no pane acts differently based on who opened it. The browser skill tells agents never to read autofilled passwords or cookies. | | Debug flags | Electron honors `--remote-debugging-port` and `--remote-debugging-pipe` from the command line. Any local program could relaunch the app with one and drive every pane. | The switches are removed before `ready` unless a dev build was started with `RENDERER_REMOTE_DEBUG_PORT`. | | Lookalike store origin | The package served the store API to any page whose origin starts with `https://chromewebstore.google.com`, so `https://chromewebstore.google.com.attacker.example` could list extensions and remove them with no dialog (`showConfirmDialog: false`). | Patched to the exact origin in the preload and the main process. Remove always asks, whatever the page passes. Guard: `web-store.test.ts`. | | Updates widen access | The updater loaded a new version with more permissions than the user approved, with no question. The install question also showed the store page's manifest, not the downloaded one. | An update or a new install downloads into `browser-extensions.staging`, which the app never loads at start. It moves into place only after the user agrees and its manifest asks for no more than the user approved, so a stop half way cannot load an unapproved version. An update that adds access asks "{name} wants more access" and keeps the old version on no. A download whose manifest reaches further than the approved one is removed. Rule: `addedAccess` in `install-warnings.ts` compares hosts by scheme (`http://*/*` does not cover `https`), and any new API permission asks, even one Chrome shows no warning for (`cookies`). | diff --git a/plugins/superset/skills/browser/SKILL.md b/plugins/superset/skills/browser/SKILL.md index 0d6e40afbf7..725266eef3f 100644 --- a/plugins/superset/skills/browser/SKILL.md +++ b/plugins/superset/skills/browser/SKILL.md @@ -56,10 +56,9 @@ the pane verbs already cover those. ## Find or open a pane -Every browser pane, whether you or the user opened it, shares one browser: -the same logins and the same extensions. `list` shows them all. Prefer a pane you -opened; drive a pane the user opened only when the task is about that page, as -it may be what they are looking at. +All browser panes are the same: one browser with the same logins and +extensions, no matter who opened the pane. `list` shows them all, and every verb +works on any of them. Every pane has a stable `paneId`, scoped to its workspace: pass the same `--workspace` you opened it under or the operation is rejected. From 2f90b68b2738e75672aaa90f07c171f3e5f5c827 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Nov 2026 16:03:03 -0800 Subject: [PATCH 5/6] fix(desktop): extension popups act on the pane whose button was clicked A popup such as Dark Reader's asks Chrome for the active tab. That was the pane registered last or last clicked inside, so after switching workspace, or after an agent opened a pane anywhere, the popup acted on another pane (it showed "about:" and "This page is protected by browser"). A click on a pane's extension button now selects that pane first, and a newly registered pane no longer takes the active tab. --- .../extension-host/browser-extensions.test.ts | 43 +++++++++++++++++++ .../extension-host/browser-extensions.ts | 5 +++ .../browser/extension-host/extension-host.ts | 17 +++++--- 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/lib/browser/extension-host/browser-extensions.test.ts b/apps/desktop/src/main/lib/browser/extension-host/browser-extensions.test.ts index 48b3b38f855..610dec563a9 100644 --- a/apps/desktop/src/main/lib/browser/extension-host/browser-extensions.test.ts +++ b/apps/desktop/src/main/lib/browser/extension-host/browser-extensions.test.ts @@ -10,6 +10,7 @@ import path from "node:path"; import { app } from "electron"; import type { ElectronChromeExtensions } from "electron-chrome-extensions"; import { + activateBrowserExtension, attachBrowserExtensions, removeBrowserExtension, setTurnedOffBrowserExtensions, @@ -58,6 +59,7 @@ function attachTurnedOff(onRemoved: (id: string) => void = () => {}) { session: session as unknown as Electron.Session, extensions, tabOf: () => undefined, + selectPane: () => {}, onRemoved, }); mkdirSync(path.join(turnedOffDir(), "1.0_0"), { recursive: true }); @@ -105,3 +107,44 @@ test("a removed extension's saved state is forgotten even when clearing its data expect(existsSync(turnedOffDir())).toBe(false); expect(onRemoved).toHaveBeenCalledWith(EXTENSION_ID); }); + +test("a click on a pane's extension button makes that pane the active tab first", () => { + const calls: string[] = []; + const tab = { + id: 7, + isDestroyed: () => false, + } as unknown as Electron.WebContents; + attachBrowserExtensions({ + session: { + extensions: { + on: () => {}, + getExtension: () => undefined, + getAllExtensions: () => [], + }, + } as unknown as Electron.Session, + extensions: { + on: () => {}, + api: { + browserAction: { + getState: () => ({ actions: [] }), + activate: (_sender: unknown, details: { tabId: number }) => + calls.push(`activate tab ${details.tabId}`), + handleCRXRequest: () => new Response(null, { status: 404 }), + observers: new Set(), + }, + }, + } as unknown as ElectronChromeExtensions, + tabOf: (paneId) => (paneId === "pane-b" ? tab : undefined), + selectPane: (paneId) => calls.push(`select ${paneId}`), + onRemoved: () => {}, + }); + + activateBrowserExtension("pane-b", EXTENSION_ID, { + x: 0, + y: 0, + width: 1, + height: 1, + }); + + expect(calls).toEqual(["select pane-b", "activate tab 7"]); +}); diff --git a/apps/desktop/src/main/lib/browser/extension-host/browser-extensions.ts b/apps/desktop/src/main/lib/browser/extension-host/browser-extensions.ts index 58e307a2745..436d3abd41a 100644 --- a/apps/desktop/src/main/lib/browser/extension-host/browser-extensions.ts +++ b/apps/desktop/src/main/lib/browser/extension-host/browser-extensions.ts @@ -69,6 +69,7 @@ interface Host { session: Electron.Session; actions: BrowserActionApi; tabOf(paneId: string): Electron.WebContents | undefined; + selectPane(paneId: string): void; onRemoved(extensionId: string): void; } @@ -140,6 +141,7 @@ export function attachBrowserExtensions(options: { session: Electron.Session; extensions: ElectronChromeExtensions; tabOf: (paneId: string) => Electron.WebContents | undefined; + selectPane: (paneId: string) => void; onRemoved: (extensionId: string) => void; }): void { const actions = browserActionApi(options.extensions); @@ -147,6 +149,7 @@ export function attachBrowserExtensions(options: { session: options.session, actions, tabOf: options.tabOf, + selectPane: options.selectPane, onRemoved: options.onRemoved, }; actions.observers.add({ @@ -330,6 +333,8 @@ export function activateBrowserExtension( ) { return; } + // A popup asks for the active tab, which must be the pane whose button was clicked. + host.selectPane(paneId); activating = target; try { host.actions.activate( diff --git a/apps/desktop/src/main/lib/browser/extension-host/extension-host.ts b/apps/desktop/src/main/lib/browser/extension-host/extension-host.ts index a7f9d537a01..7e654128c50 100644 --- a/apps/desktop/src/main/lib/browser/extension-host/extension-host.ts +++ b/apps/desktop/src/main/lib/browser/extension-host/extension-host.ts @@ -194,11 +194,19 @@ function startExtensionHost(): void { : [], ); + const selectPane = (paneId: string) => { + const contents = paneTabs.get(paneId); + if (!contents || contents.isDestroyed()) return; + activePaneId = paneId; + extensions.selectTab(contents); + }; + try { attachBrowserExtensions({ session: browserSession, extensions, tabOf: (paneId) => paneTabs.get(paneId), + selectPane, onRemoved: (extensionId) => state.forget(extensionId), }); } catch (error) { @@ -228,7 +236,7 @@ function startExtensionHost(): void { if (!window) return; paneTabs.set(paneId, contents); extensions.addTab(contents, window); - activePaneId = paneId; + activePaneId ??= paneId; notifyBrowserExtensionsChanged(); }); onPaneEvent("pane-unregistered", (paneId) => { @@ -238,12 +246,7 @@ function startExtensionHost(): void { if (contents && !contents.isDestroyed()) extensions.removeTab(contents); notifyBrowserExtensionsChanged(); }); - onPaneEvent("pane-focused", (paneId) => { - const contents = paneTabs.get(paneId); - if (!contents || contents.isDestroyed()) return; - activePaneId = paneId; - extensions.selectTab(contents); - }); + onPaneEvent("pane-focused", selectPane); void installBrowserWebStore(browserSession, { removeExtension: removeBrowserExtension, From 4da8c08312ca727fb71edf370cc13a643be19871 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Nov 2026 16:07:55 -0800 Subject: [PATCH 6/6] fix(desktop): an extension's new tab never claims a pane a browser open made With one session for every pane, the session no longer told an extension's new tab apart from a pane `superset browser open` made in the same workspace at the same moment, so the extension could claim and drive that pane. Panes named through resolveOpenRequest are now skipped by the extension's waiter. Browser skill: scratch pages open in a new tab, a password is filled only on the origin the user named, and import-login says it replaces existing logins. --- .../src/main/lib/browser/browser-manager.ts | 8 ++++++++ .../extension-host/wait-for-new-pane.test.ts | 5 +++-- .../browser/extension-host/wait-for-new-pane.ts | 1 + plugins/superset/skills/browser/SKILL.md | 17 ++++++++++------- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/lib/browser/browser-manager.ts b/apps/desktop/src/main/lib/browser/browser-manager.ts index 040cc8d9235..f6c3f9ddde3 100644 --- a/apps/desktop/src/main/lib/browser/browser-manager.ts +++ b/apps/desktop/src/main/lib/browser/browser-manager.ts @@ -287,6 +287,7 @@ class BrowserManager extends EventEmitter { private focusListeners = new Map void>(); private formEditListeners = new Map void>(); private cdpDetachers = new Map void>(); + private requestedPanes = new Set(); // Ref-count of in-flight agent work per pane (a live CDP session, a // screenshot capture). While present the guest renderer stays // un-throttled — see acquireAgentWake. The entry object's identity ties @@ -349,6 +350,7 @@ class BrowserManager extends EventEmitter { } unregister(paneId: string): void { + this.requestedPanes.delete(paneId); for (const map of [ this.consoleListeners, this.contextMenuListeners, @@ -496,9 +498,15 @@ class BrowserManager extends EventEmitter { } resolveOpenRequest(requestId: string, paneId: string): void { + this.requestedPanes.add(paneId); this.emit(`open-resolved:${requestId}`, paneId); } + /** Made for a `requestOpen`, so it is not the pane an extension's new tab is waiting for. */ + isRequestedPane(paneId: string): boolean { + return this.requestedPanes.has(paneId); + } + markUse(paneId: string): void { this.emit("agent-use", { paneId }); } diff --git a/apps/desktop/src/main/lib/browser/extension-host/wait-for-new-pane.test.ts b/apps/desktop/src/main/lib/browser/extension-host/wait-for-new-pane.test.ts index 1f489427922..1940b5668f3 100644 --- a/apps/desktop/src/main/lib/browser/extension-host/wait-for-new-pane.test.ts +++ b/apps/desktop/src/main/lib/browser/extension-host/wait-for-new-pane.test.ts @@ -13,7 +13,7 @@ afterAll(() => { fromIdMock.mockImplementation(preloadFromId ?? (() => null)); }); -test("each of an extension's new tabs gets its own user pane, never an agent pane", async () => { +test("each of an extension's new tabs gets its own pane, never one a browser open request made", async () => { const userSession = {} as Electron.Session; const fakePane = (paneSession: object) => ({ session: paneSession, @@ -27,7 +27,7 @@ test("each of an extension's new tabs gets its own user pane, never an agent pan off: () => {}, }); const panes = new Map([ - [801, fakePane({})], + [801, fakePane(userSession)], [802, fakePane(userSession)], [803, fakePane(userSession)], ]); @@ -35,6 +35,7 @@ test("each of an extension's new tabs gets its own user pane, never an agent pan const opened = waitForNewPane("ws-ext", userSession); const openedNext = waitForNewPane("ws-ext", userSession); + browserManager.resolveOpenRequest("agent-open", "ext-agent-pane"); browserManager.register("ext-agent-pane", 801, "ws-ext"); browserManager.register("ext-user-pane", 802, "ws-ext"); browserManager.register("ext-second-pane", 803, "ws-ext"); diff --git a/apps/desktop/src/main/lib/browser/extension-host/wait-for-new-pane.ts b/apps/desktop/src/main/lib/browser/extension-host/wait-for-new-pane.ts index 289c1b3128d..ba9c5500c2a 100644 --- a/apps/desktop/src/main/lib/browser/extension-host/wait-for-new-pane.ts +++ b/apps/desktop/src/main/lib/browser/extension-host/wait-for-new-pane.ts @@ -22,6 +22,7 @@ function onRegistered(event: { (waiter) => waiter.workspaceId === event.workspaceId && !waiter.known.has(event.paneId) && + !browserManager.isRequestedPane(event.paneId) && waiter.paneSession === session, ); const [waiter] = index === -1 ? [] : waiters.splice(index, 1); diff --git a/plugins/superset/skills/browser/SKILL.md b/plugins/superset/skills/browser/SKILL.md index 725266eef3f..0cfea881ed0 100644 --- a/plugins/superset/skills/browser/SKILL.md +++ b/plugins/superset/skills/browser/SKILL.md @@ -65,12 +65,13 @@ Every pane has a stable `paneId`, scoped to its workspace: pass the same ```bash superset browser list --workspace --json -superset browser open --workspace --url https://example.com --json -superset browser open --workspace --url http://localhost:3000 --target new-tab --json +superset browser open --workspace --url https://example.com --target new-tab --json +superset browser open --workspace --url http://localhost:3000 --json ``` `--target new-tab` opens a fresh tab and focuses it; the default `current-tab` -reuses the active browser pane. Opening needs the workspace visible in the +can replace the active browser pane, which may be the page the user is on, so +use `new-tab` unless the task is about that page. Opening needs the workspace visible in the desktop app (the renderer creates the pane), so if it times out, ask the user to open the workspace. @@ -109,8 +110,8 @@ rejected with a clear error. `import-login` copies a system browser's cookies into the pane so it's signed in to the sites the user already uses. It imports every cookie that profile has on -disk, not just the one site, into the browser every pane shares, so say that -when offering and run it only with the user's go-ahead. The desktop app also +disk, not just the one site, into the browser every pane shares, and it +replaces the logins already there for the same sites, so say both when offering and run it only with the user's go-ahead. The desktop app also asks the user to allow each import. Offering it is how most users discover it, so reach for it in two cases: @@ -246,8 +247,10 @@ before publishing. - Confirm with the user before a step that submits a form, makes a purchase, or takes another consequential action. - At a login wall, stop and ask unless the user already told you to sign in. - When they ask, sign in with their password manager: click its fill - suggestion on the field, or its inline menu, and submit. Never type a + When they ask, sign in with their password manager, and only after checking + that the page's origin is the site the user named (`location.origin`); a + look-alike or unexpected subdomain gets the user's call, not a fill. Click + its fill suggestion on the field, or its inline menu, and submit. Never type a password from anywhere else, never read the filled value back with `eval`, and never put it in output or a screenshot. MFA codes stay with the user. You may also offer `import-login` (above).