From 554e525186b73b35c35f64fa576e05a818d5cf0a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 2 Jul 2026 20:00:17 +0200 Subject: [PATCH] Tighten browser tool schemas --- .../src/server/browser-tools/tools.test.ts | 258 +++++++++++++-- .../server/src/server/browser-tools/tools.ts | 296 +++++++++++++----- 2 files changed, 455 insertions(+), 99 deletions(-) diff --git a/packages/server/src/server/browser-tools/tools.test.ts b/packages/server/src/server/browser-tools/tools.test.ts index a65930615..ad69100a9 100644 --- a/packages/server/src/server/browser-tools/tools.test.ts +++ b/packages/server/src/server/browser-tools/tools.test.ts @@ -13,6 +13,9 @@ const BROWSER_ID = "11111111-1111-4111-8111-111111111111"; const BROWSER_ID_MESSAGE = "browserId must be a real id returned by browser_new_tab or browser_list_tabs"; const WAIT_CONDITION_MESSAGE = "browser_wait requires exactly one of text or url"; +const HTTP_URL_MESSAGE = "URL must use http/https only"; +const WORKSPACE_CONTEXT_MESSAGE = + "This browser tool needs a workspace. Start the agent from a Paseo workspace before calling browser_new_tab or browser_list_tabs."; interface RegisteredTool { config: PaseoToolConfig; @@ -65,6 +68,10 @@ class BrowserToolHarness { return this.get(name).handler(parsed, {}); } + public outputParses(name: string, output: unknown) { + return schemaFor(this.get(name).config.outputSchema).safeParse(output); + } + private get(name: string): RegisteredTool { const tool = this.tools.get(name); if (!tool) { @@ -103,6 +110,49 @@ function listTabsPayload(): Extract { }; } +function newTabPayload(): Extract { + return { + requestId: "req-new-tab", + ok: true, + result: { + command: "new_tab", + browserId: BROWSER_ID, + workspaceId: "wks_workspace_a", + url: "https://example.com", + }, + }; +} + +function snapshotPayload(): Extract { + return { + requestId: "req-snapshot", + ok: true, + result: { + command: "snapshot", + browserId: BROWSER_ID, + workspaceId: "wks_workspace_a", + url: "https://example.com", + title: "Example", + elements: [], + }, + }; +} + +function screenshotPayload(): Extract { + return { + requestId: "req-screenshot", + ok: true, + result: { + command: "screenshot", + browserId: BROWSER_ID, + mimeType: "image/png", + dataBase64: "iVBORw0KGgo=", + width: 800, + height: 600, + }, + }; +} + describe("registerBrowserTools", () => { test("list tabs sends workspace in the request envelope", async () => { const harness = new BrowserToolHarness(); @@ -127,16 +177,7 @@ describe("registerBrowserTools", () => { test("new tab sends workspace in the request envelope", async () => { const harness = new BrowserToolHarness(); - harness.broker.setResponse({ - requestId: "req-new-tab", - ok: true, - result: { - command: "new_tab", - browserId: BROWSER_ID, - workspaceId: "wks_workspace_a", - url: "https://example.com", - }, - }); + harness.broker.setResponse(newTabPayload()); const response = await harness.execute("browser_new_tab", { url: "https://example.com" }); @@ -156,6 +197,155 @@ describe("registerBrowserTools", () => { ]); }); + test.each([ + { + name: "navigate accepts localhost without a scheme as http", + toolName: "browser_navigate", + input: { browserId: BROWSER_ID, url: "localhost:3000" }, + expected: { browserId: BROWSER_ID, url: "http://localhost:3000" }, + }, + { + name: "download accepts an IP host without a scheme as http", + toolName: "browser_download", + input: { browserId: BROWSER_ID, url: "127.0.0.1:8081/admin" }, + expected: { browserId: BROWSER_ID, url: "http://127.0.0.1:8081/admin" }, + }, + { + name: "new tab accepts a domain path without a scheme as http", + toolName: "browser_new_tab", + input: { url: "example.com/x" }, + expected: { url: "http://example.com/x" }, + }, + { + name: "navigate keeps https URLs unchanged", + toolName: "browser_navigate", + input: { browserId: BROWSER_ID, url: "https://example.com/x" }, + expected: { browserId: BROWSER_ID, url: "https://example.com/x" }, + }, + ])("$name", ({ toolName, input, expected }) => { + const harness = new BrowserToolHarness(); + + const parsed = harness.validate(toolName, input); + + expect(parsed).toEqual({ success: true, data: expected }); + }); + + test.each([ + { + name: "navigate rejects file URLs", + toolName: "browser_navigate", + input: { browserId: BROWSER_ID, url: "file:///tmp/index.html" }, + }, + { + name: "download rejects file URLs", + toolName: "browser_download", + input: { browserId: BROWSER_ID, url: "file:///tmp/archive.zip" }, + }, + { + name: "new tab rejects file URLs", + toolName: "browser_new_tab", + input: { url: "file:///tmp/index.html" }, + }, + ])("$name", ({ toolName, input }) => { + const harness = new BrowserToolHarness(); + + const parsed = harness.validate(toolName, input); + + expect(parsed).toMatchObject({ + success: false, + error: { issues: [expect.objectContaining({ message: HTTP_URL_MESSAGE })] }, + }); + }); + + test("list tabs tells agents without a workspace how to proceed", async () => { + const harness = new BrowserToolHarness({ id: "agent-1", cwd: "/repo" }); + + const response = await harness.execute("browser_list_tabs", {}); + + expect(harness.broker.calls).toEqual([]); + expect(response.content).toEqual([{ type: "text", text: WORKSPACE_CONTEXT_MESSAGE }]); + expect(response.structuredContent).toEqual({ + ok: false, + error: { + code: "browser_denied", + message: WORKSPACE_CONTEXT_MESSAGE, + retryable: false, + }, + context: { + agentId: "agent-1", + cwd: "/repo", + }, + }); + }); + + test("new tab tells agents without a workspace how to proceed", async () => { + const harness = new BrowserToolHarness({ id: "agent-1", cwd: "/repo" }); + + const response = await harness.execute("browser_new_tab", {}); + + expect(harness.broker.calls).toEqual([]); + expect(response.content).toEqual([{ type: "text", text: WORKSPACE_CONTEXT_MESSAGE }]); + expect(response.structuredContent).toEqual({ + ok: false, + error: { + code: "browser_denied", + message: WORKSPACE_CONTEXT_MESSAGE, + retryable: false, + }, + context: { + agentId: "agent-1", + cwd: "/repo", + }, + }); + }); + + test("new tab declares the shape it returns", async () => { + const harness = new BrowserToolHarness(); + harness.broker.setResponse(newTabPayload()); + + const response = await harness.execute("browser_new_tab", {}); + + expect(harness.outputParses("browser_new_tab", response.structuredContent)).toEqual({ + success: true, + data: response.structuredContent, + }); + }); + + test("list tabs declares the shape it returns", async () => { + const harness = new BrowserToolHarness(); + + const response = await harness.execute("browser_list_tabs", {}); + + expect(harness.outputParses("browser_list_tabs", response.structuredContent)).toEqual({ + success: true, + data: response.structuredContent, + }); + }); + + test("snapshot declares the shape it returns", async () => { + const harness = new BrowserToolHarness(); + harness.broker.setResponse(snapshotPayload()); + + const response = await harness.execute("browser_snapshot", { browserId: BROWSER_ID }); + + expect(harness.outputParses("browser_snapshot", response.structuredContent)).toEqual({ + success: true, + data: response.structuredContent, + }); + }); + + test("screenshot declares the shape it returns", async () => { + const harness = new BrowserToolHarness(); + harness.broker.setResponse(screenshotPayload()); + + const response = await harness.execute("browser_screenshot", { browserId: BROWSER_ID }); + + expect(harness.outputParses("browser_screenshot", response.structuredContent)).toEqual({ + success: true, + data: response.structuredContent, + }); + }); + test("snapshot rejects calls without a browser id", () => { const harness = new BrowserToolHarness(); @@ -180,18 +370,7 @@ describe("registerBrowserTools", () => { test("snapshot sends browser id in command args only", async () => { const harness = new BrowserToolHarness(); - harness.broker.setResponse({ - requestId: "req-snapshot", - ok: true, - result: { - command: "snapshot", - browserId: BROWSER_ID, - workspaceId: "wks_workspace_a", - url: "https://example.com", - title: "Example", - elements: [], - }, - }); + harness.broker.setResponse(snapshotPayload()); const response = await harness.execute("browser_snapshot", { browserId: BROWSER_ID }); @@ -288,16 +467,41 @@ describe("registerBrowserTools", () => { expect(response.content).toEqual([{ type: "text", text: "Browser wait matched text." }]); }); - test("tools keep empty context when there is no caller agent", async () => { + test("tab tools keep empty context when there is no caller agent", async () => { const harness = new BrowserToolHarness(null, null); + harness.broker.setResponse({ + requestId: "req-page-info", + ok: true, + result: { + command: "page_info", + tab: { + browserId: BROWSER_ID, + url: "https://example.com", + title: "Example", + isActive: false, + isLoading: false, + }, + }, + }); - const response = await harness.execute("browser_list_tabs", {}); + const response = await harness.execute("browser_page_info", { browserId: BROWSER_ID }); - expect(harness.broker.calls).toEqual([{ command: { command: "list_tabs", args: {} } }]); + expect(harness.broker.calls).toEqual([ + { command: { command: "page_info", args: { browserId: BROWSER_ID } } }, + ]); expect(response.structuredContent).toEqual({ ok: true, - result: listTabsPayload().result, - context: {}, + result: { + command: "page_info", + tab: { + browserId: BROWSER_ID, + url: "https://example.com", + title: "Example", + isActive: false, + isLoading: false, + }, + }, + context: { browserId: BROWSER_ID }, }); }); }); diff --git a/packages/server/src/server/browser-tools/tools.ts b/packages/server/src/server/browser-tools/tools.ts index befa5a224..5ab16945b 100644 --- a/packages/server/src/server/browser-tools/tools.ts +++ b/packages/server/src/server/browser-tools/tools.ts @@ -1,5 +1,36 @@ import { z } from "zod"; -import { BrowserAutomationBrowserIdSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas"; +import { + BrowserAutomationBackResultSchema, + BrowserAutomationBrowserIdSchema, + BrowserAutomationCheckResultSchema, + BrowserAutomationClearResultSchema, + BrowserAutomationClickResultSchema, + BrowserAutomationDownloadResultSchema, + BrowserAutomationDragResultSchema, + BrowserAutomationEnvironmentResultSchema, + BrowserAutomationErrorSchema, + BrowserAutomationFillResultSchema, + BrowserAutomationFocusResultSchema, + BrowserAutomationForwardResultSchema, + BrowserAutomationFullPageScreenshotResultSchema, + BrowserAutomationHoverResultSchema, + BrowserAutomationKeypressResultSchema, + BrowserAutomationListTabsResultSchema, + BrowserAutomationLogsResultSchema, + BrowserAutomationNavigateResultSchema, + BrowserAutomationNewTabResultSchema, + BrowserAutomationPageInfoResultSchema, + BrowserAutomationPdfResultSchema, + BrowserAutomationReloadResultSchema, + BrowserAutomationScreenshotResultSchema, + BrowserAutomationSelectResultSchema, + BrowserAutomationSetBackgroundResultSchema, + BrowserAutomationSnapshotResultSchema, + BrowserAutomationStorageResultSchema, + BrowserAutomationTypeResultSchema, + BrowserAutomationUploadResultSchema, + BrowserAutomationWaitResultSchema, +} from "@getpaseo/protocol/browser-automation/rpc-schemas"; import type { BrowserToolsBroker } from "./broker.js"; import type { BrowserToolsResponsePayload } from "./errors.js"; import type { @@ -29,26 +60,50 @@ export interface RegisterBrowserToolsOptions { resolveCallerAgent: () => CallerAgentContext | null; } -const BrowserToolOutputSchema = z.object({ - ok: z.boolean(), - result: z.unknown().optional(), - error: z - .object({ - code: z.string(), - message: z.string(), - retryable: z.boolean(), - }) - .optional(), - context: z - .object({ - agentId: z.string().optional(), - cwd: z.string().optional(), - workspaceId: z.string().optional(), - browserId: z.string().optional(), - }) - .optional(), +const HTTP_URL_ONLY_MESSAGE = "URL must use http/https only"; +const WORKSPACE_CONTEXT_MESSAGE = + "This browser tool needs a workspace. Start the agent from a Paseo workspace before calling browser_new_tab or browser_list_tabs."; +const SCHEMELESS_HTTP_URL_PATTERN = + /^(?:localhost|(?:\d{1,3}\.){3}\d{1,3}|\[[0-9a-f:.]+\]|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+)(?::\d{1,5})?(?:[/?#].*)?$/i; + +const BrowserToolContextSchema = z.object({ + agentId: z.string().optional(), + cwd: z.string().optional(), + workspaceId: z.string().optional(), + browserId: z.string().optional(), }); +const BrowserToolErrorOutputSchema = z.object({ + ok: z.literal(false), + error: BrowserAutomationErrorSchema, + context: BrowserToolContextSchema, +}); + +function browserToolOutputSchema(resultSchema: ResultSchema) { + return z.discriminatedUnion("ok", [ + z.object({ + ok: z.literal(true), + result: resultSchema, + context: BrowserToolContextSchema, + }), + BrowserToolErrorOutputSchema, + ]); +} + +const BrowserHttpUrlInputSchema = z + .string() + .trim() + .transform((value, context) => { + const normalized = normalizeHttpUrlInput(value); + if (!normalized) { + context.addIssue({ + code: "custom", + message: HTTP_URL_ONLY_MESSAGE, + }); + return z.NEVER; + } + return normalized; + }); const BrowserRefInputSchema = z.string().regex(/^@e\d+$/); const BrowserWaitInputSchema = z .object({ @@ -61,18 +116,53 @@ const BrowserWaitInputSchema = z message: "browser_wait requires exactly one of text or url", }); +const BrowserToolOutputs = { + listTabs: browserToolOutputSchema(BrowserAutomationListTabsResultSchema), + newTab: browserToolOutputSchema(BrowserAutomationNewTabResultSchema), + pageInfo: browserToolOutputSchema(BrowserAutomationPageInfoResultSchema), + snapshot: browserToolOutputSchema(BrowserAutomationSnapshotResultSchema), + click: browserToolOutputSchema(BrowserAutomationClickResultSchema), + fill: browserToolOutputSchema(BrowserAutomationFillResultSchema), + wait: browserToolOutputSchema(BrowserAutomationWaitResultSchema), + type: browserToolOutputSchema(BrowserAutomationTypeResultSchema), + keypress: browserToolOutputSchema(BrowserAutomationKeypressResultSchema), + navigate: browserToolOutputSchema(BrowserAutomationNavigateResultSchema), + back: browserToolOutputSchema(BrowserAutomationBackResultSchema), + forward: browserToolOutputSchema(BrowserAutomationForwardResultSchema), + reload: browserToolOutputSchema(BrowserAutomationReloadResultSchema), + screenshot: browserToolOutputSchema(BrowserAutomationScreenshotResultSchema), + fullPageScreenshot: browserToolOutputSchema(BrowserAutomationFullPageScreenshotResultSchema), + pdf: browserToolOutputSchema(BrowserAutomationPdfResultSchema), + download: browserToolOutputSchema(BrowserAutomationDownloadResultSchema), + upload: browserToolOutputSchema(BrowserAutomationUploadResultSchema), + focus: browserToolOutputSchema(BrowserAutomationFocusResultSchema), + clear: browserToolOutputSchema(BrowserAutomationClearResultSchema), + check: browserToolOutputSchema(BrowserAutomationCheckResultSchema), + select: browserToolOutputSchema(BrowserAutomationSelectResultSchema), + hover: browserToolOutputSchema(BrowserAutomationHoverResultSchema), + drag: browserToolOutputSchema(BrowserAutomationDragResultSchema), + logs: browserToolOutputSchema(BrowserAutomationLogsResultSchema), + storage: browserToolOutputSchema(BrowserAutomationStorageResultSchema), + environment: browserToolOutputSchema(BrowserAutomationEnvironmentResultSchema), + setBackground: browserToolOutputSchema(BrowserAutomationSetBackgroundResultSchema), +} as const; + export function registerBrowserTools(options: RegisterBrowserToolsOptions): void { options.registerTool( "browser_list_tabs", { title: "List browser tabs", description: - "List open Paseo desktop browser tabs for this agent's workspace context. Use the returned browserId values for tab-scoped browser tools.", + "List open Paseo desktop browser tabs for this agent's workspace. Use returned browserId values with tab-scoped tools.", inputSchema: {}, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.listTabs, }, async () => { const context = resolveBrowserToolContext(options); + const missingWorkspace = requireWorkspaceContext(context); + if (missingWorkspace) { + return missingWorkspace; + } const payload = await options.broker.execute({ agentId: context.agentId, cwd: context.cwd, @@ -91,14 +181,18 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void { title: "Create browser tab", description: - "Create and focus a new Paseo desktop browser tab in this agent's workspace. Optionally pass an http(s) URL to open immediately. Returns the browserId for later tab-scoped browser tools.", + "Create and focus a new Paseo desktop browser tab in this agent's workspace. Pass an http(s) URL or a scheme-less host URL, which is treated as http; the returned browserId is used by tab-scoped tools.", inputSchema: { - url: z.string().url().optional(), + url: BrowserHttpUrlInputSchema.optional(), }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.newTab, }, async ({ url }) => { const context = resolveBrowserToolContext(options); + const missingWorkspace = requireWorkspaceContext(context); + if (missingWorkspace) { + return missingWorkspace; + } const payload = await options.broker.execute({ agentId: context.agentId, cwd: context.cwd, @@ -117,11 +211,11 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void { title: "Get browser page info", description: - "Get page info for a Paseo desktop browser tab. Pass browserId from browser_new_tab or browser_list_tabs.", + "Get page info for a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs.", inputSchema: { browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.pageInfo, }, async ({ browserId }) => { const context = resolveBrowserToolContext(options); @@ -146,11 +240,11 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void { title: "Snapshot browser page", description: - "Return a model-readable snapshot of a Paseo desktop browser tab. Pass browserId from browser_new_tab or browser_list_tabs. Snapshot refs like @e1 are valid until the page changes or a new snapshot is taken.", + "Return a model-readable snapshot of a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", inputSchema: { browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.snapshot, }, async ({ browserId }) => { const context = resolveBrowserToolContext(options); @@ -175,12 +269,12 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void { title: "Click browser element", description: - "Click an element ref from the latest browser_snapshot for this agent's workspace browser tab.", + "Click an element in a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", inputSchema: { ref: BrowserRefInputSchema, browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.click, }, async ({ ref, browserId }) => { const context = resolveBrowserToolContext(options); @@ -206,13 +300,13 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void { title: "Fill browser element", description: - "Fill an input-like element ref from the latest browser_snapshot for this agent's workspace browser tab.", + "Fill an input-like element in a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", inputSchema: { ref: BrowserRefInputSchema, value: z.string(), browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.fill, }, async ({ ref, value, browserId }) => { const context = resolveBrowserToolContext(options); @@ -239,9 +333,9 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void { title: "Wait for browser condition", description: - "Wait until a Paseo desktop browser tab contains text or reaches a URL fragment. Pass browserId from browser_new_tab or browser_list_tabs.", + "Wait until a Paseo desktop browser tab contains text or reaches a URL fragment. Use browserId from browser_new_tab or browser_list_tabs; waits up to 5s by default on the desktop side.", inputSchema: BrowserWaitInputSchema, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.wait, }, async ({ text, url, timeoutMs, browserId }) => { const context = resolveBrowserToolContext(options); @@ -270,13 +364,13 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void { title: "Type into browser", description: - "Type text into an element ref from the latest browser_snapshot, or into the currently focused browser element when ref is omitted.", + "Type text into an element, or into the focused element when ref is omitted. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", inputSchema: { text: z.string(), ref: BrowserRefInputSchema.optional(), browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.type, }, async ({ text, ref, browserId }) => { const context = resolveBrowserToolContext(options); @@ -303,13 +397,13 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void { title: "Press browser key", description: - "Dispatch a keypress to an element ref from the latest browser_snapshot, or to the currently focused browser element when ref is omitted.", + "Dispatch a keypress to an element, or to the focused element when ref is omitted. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", inputSchema: { key: z.string().min(1), ref: BrowserRefInputSchema.optional(), browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.keypress, }, async ({ key, ref, browserId }) => { const context = resolveBrowserToolContext(options); @@ -336,9 +430,9 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void { title: "Navigate browser", description: - "Navigate a Paseo desktop browser tab to a URL. Pass browserId from browser_new_tab or browser_list_tabs.", - inputSchema: { url: z.string().min(1), browserId: BrowserAutomationBrowserIdSchema }, - outputSchema: BrowserToolOutputSchema, + "Navigate a Paseo desktop browser tab to a URL. Use browserId from browser_new_tab or browser_list_tabs; pass an http(s) URL or a scheme-less host URL, which is treated as http.", + inputSchema: { url: BrowserHttpUrlInputSchema, browserId: BrowserAutomationBrowserIdSchema }, + outputSchema: BrowserToolOutputs.navigate, }, async ({ url, browserId }) => { const context = resolveBrowserToolContext(options); @@ -365,9 +459,9 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void name, { title: `Browser ${command}`, - description: `${command} a Paseo desktop browser tab. Pass browserId from browser_new_tab or browser_list_tabs.`, + description: `${command} a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs.`, inputSchema: { browserId: BrowserAutomationBrowserIdSchema }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs[command], }, async ({ browserId }) => { const context = resolveBrowserToolContext(options); @@ -393,9 +487,9 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void { title: "Capture browser screenshot", description: - "Capture a PNG screenshot of a Paseo desktop browser tab. Pass browserId from browser_new_tab or browser_list_tabs.", + "Capture a PNG screenshot of a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs.", inputSchema: { browserId: BrowserAutomationBrowserIdSchema }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.screenshot, }, async ({ browserId }) => { const context = resolveBrowserToolContext(options); @@ -419,9 +513,10 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void "browser_full_page_screenshot", { title: "Capture full-page browser screenshot", - description: "Capture a full-page PNG screenshot of a Paseo desktop browser tab.", + description: + "Capture a full-page PNG screenshot of a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs.", inputSchema: { browserId: BrowserAutomationBrowserIdSchema }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.fullPageScreenshot, }, async ({ browserId }) => { const context = resolveBrowserToolContext(options); @@ -446,13 +541,13 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void { title: "Export browser page PDF", description: - "Export a Paseo desktop browser tab as a PDF. Pass browserId from browser_new_tab or browser_list_tabs.", + "Export a Paseo desktop browser tab as a PDF. Use browserId from browser_new_tab or browser_list_tabs.", inputSchema: { browserId: BrowserAutomationBrowserIdSchema, landscape: z.boolean().optional(), printBackground: z.boolean().default(true), }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.pdf, }, async ({ browserId, landscape, printBackground }) => { const context = resolveBrowserToolContext(options); @@ -478,13 +573,14 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void "browser_download", { title: "Download file in browser", - description: "Download a URL through the Paseo desktop browser session.", + description: + "Download a URL through a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs; pass an http(s) URL or a scheme-less host URL, which is treated as http.", inputSchema: { - url: z.string().min(1), + url: BrowserHttpUrlInputSchema, fileName: z.string().min(1).optional(), browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.download, }, async ({ url, fileName, browserId }) => { const context = resolveBrowserToolContext(options); @@ -511,13 +607,13 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void { title: "Upload files in browser", description: - "Set workspace files on a file input ref from the latest browser_snapshot. Paths must resolve inside the agent workspace.", + "Set workspace files on a file input in a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", inputSchema: { ref: BrowserRefInputSchema, filePaths: z.array(z.string().min(1)).min(1), browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.upload, }, async ({ ref, filePaths, browserId }) => { const context = resolveBrowserToolContext(options); @@ -544,19 +640,22 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void name: "browser_focus", command: "focus", title: "Focus browser element", - description: "Focus an element ref from the latest browser_snapshot.", + description: + "Focus an element in a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", }, { name: "browser_clear", command: "clear", title: "Clear browser element", - description: "Clear an input-like element ref from the latest browser_snapshot.", + description: + "Clear an input-like element in a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", }, { name: "browser_hover", command: "hover", title: "Hover browser element", - description: "Hover an element ref from the latest browser_snapshot.", + description: + "Hover an element in a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", }, ] as const) { options.registerTool( @@ -565,7 +664,7 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void title: toolConfig.title, description: toolConfig.description, inputSchema: { ref: BrowserRefInputSchema, browserId: BrowserAutomationBrowserIdSchema }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs[toolConfig.command], }, async ({ ref, browserId }) => { const context = resolveBrowserToolContext(options); @@ -591,13 +690,14 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void "browser_check", { title: "Check browser control", - description: "Set a checkbox or radio ref from the latest browser_snapshot.", + description: + "Set a checkbox or radio control in a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", inputSchema: { ref: BrowserRefInputSchema, checked: z.boolean().default(true), browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.check, }, async ({ ref, checked, browserId }) => { const context = resolveBrowserToolContext(options); @@ -623,13 +723,14 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void "browser_select", { title: "Select browser option", - description: "Set a select element ref from the latest browser_snapshot to a value.", + description: + "Set a select element in a Paseo desktop browser tab to a value. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", inputSchema: { ref: BrowserRefInputSchema, value: z.string(), browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.select, }, async ({ ref, value, browserId }) => { const context = resolveBrowserToolContext(options); @@ -656,13 +757,13 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void { title: "Drag browser element", description: - "Drag a source element ref onto a target element ref from the latest browser_snapshot.", + "Drag one element onto another in a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", inputSchema: { sourceRef: BrowserRefInputSchema, targetRef: BrowserRefInputSchema, browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.drag, }, async ({ sourceRef, targetRef, browserId }) => { const context = resolveBrowserToolContext(options); @@ -689,12 +790,12 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void { title: "Read browser logs", description: - "Read recent console messages and browser performance network entries for a Paseo desktop browser tab.", + "Read recent console messages and browser performance network entries for a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs; maxEntries defaults to 50.", inputSchema: { maxEntries: z.number().int().positive().max(200).optional(), browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.logs, }, async ({ maxEntries, browserId }) => { const context = resolveBrowserToolContext(options); @@ -720,9 +821,9 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void { title: "Read browser storage", description: - "Read cookies plus localStorage and sessionStorage for a Paseo desktop browser tab.", + "Read cookies plus localStorage and sessionStorage for a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs.", inputSchema: { browserId: BrowserAutomationBrowserIdSchema }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.storage, }, async ({ browserId }) => { const context = resolveBrowserToolContext(options); @@ -746,7 +847,8 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void "browser_environment", { title: "Set/read browser environment", - description: "Set or read viewport and geolocation for a Paseo desktop browser tab.", + description: + "Set or read viewport and geolocation for a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs.", inputSchema: { viewport: z .object({ @@ -764,7 +866,7 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void .optional(), browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.environment, }, async ({ viewport, geolocation, browserId }) => { const context = resolveBrowserToolContext(options); @@ -791,12 +893,12 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void { title: "Set browser background", description: - "Set the current page background color in a Paseo desktop browser tab. Pass browserId from browser_new_tab or browser_list_tabs.", + "Set the current page background color in a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs.", inputSchema: { color: z.string().min(1), browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, + outputSchema: BrowserToolOutputs.setBackground, }, async ({ color, browserId }) => { const context = resolveBrowserToolContext(options); @@ -831,6 +933,56 @@ function resolveBrowserToolContext(options: RegisterBrowserToolsOptions): { }; } +function normalizeHttpUrlInput(value: string): string | null { + if (value.length === 0) { + return null; + } + + const explicitHttpUrl = /^https?:\/\//i.test(value); + if (explicitHttpUrl) { + return isValidHttpUrl(value) ? value : null; + } + + if (SCHEMELESS_HTTP_URL_PATTERN.test(value)) { + const normalized = `http://${value}`; + return isValidHttpUrl(normalized) ? normalized : null; + } + + return null; +} + +function isValidHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +function requireWorkspaceContext(context: { + agentId?: string; + cwd?: string; + workspaceId?: string; +}): PaseoToolResult | null { + if (context.workspaceId) { + return null; + } + + return browserToolResult({ + payload: { + requestId: "browser-tools-workspace-context", + ok: false, + error: { + code: "browser_denied", + message: WORKSPACE_CONTEXT_MESSAGE, + retryable: false, + }, + }, + context, + }); +} + function browserToolResult(params: { payload: BrowserToolsResponsePayload; context: { agentId?: string; cwd?: string; workspaceId?: string; browserId?: string };