Fix browser tool ergonomics followups

This commit is contained in:
Mohamed Boudra
2026-07-02 20:18:22 +02:00
parent 554e525186
commit b2ad903005
3 changed files with 115 additions and 16 deletions
@@ -39,6 +39,7 @@ import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
const REPO_CWD = resolvePath("/tmp/repo");
const TARGET_CWD = resolvePath("/tmp/target");
const BROWSER_WORKSPACE_ID = "wks_browser_tools";
interface LooseSafeParseResult {
success: boolean;
@@ -583,7 +584,11 @@ describe("browser MCP tools", () => {
it("keeps browser tools registered when browser tools are disabled", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({ id: "agent-1", cwd: REPO_CWD });
spies.agentManager.getAgent.mockReturnValue({
id: "agent-1",
cwd: REPO_CWD,
workspaceId: BROWSER_WORKSPACE_ID,
});
const execute = vi.fn().mockResolvedValue({
requestId: "req-browser-disabled",
ok: false,
@@ -609,6 +614,7 @@ describe("browser MCP tools", () => {
expect(execute).toHaveBeenCalledWith({
agentId: "agent-1",
cwd: REPO_CWD,
workspaceId: BROWSER_WORKSPACE_ID,
command: { command: "list_tabs", args: {} },
});
expect(response.structuredContent).toEqual({
@@ -618,13 +624,17 @@ describe("browser MCP tools", () => {
message: "Browser tools are disabled.",
retryable: false,
},
context: { agentId: "agent-1", cwd: REPO_CWD },
context: { agentId: "agent-1", cwd: REPO_CWD, workspaceId: BROWSER_WORKSPACE_ID },
});
});
it("wires browser tools through the browser tools broker", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({ id: "agent-1", cwd: REPO_CWD });
spies.agentManager.getAgent.mockReturnValue({
id: "agent-1",
cwd: REPO_CWD,
workspaceId: BROWSER_WORKSPACE_ID,
});
const execute = vi.fn().mockResolvedValue({
requestId: "req-browser-tabs",
ok: true,
@@ -645,6 +655,7 @@ describe("browser MCP tools", () => {
expect(execute).toHaveBeenCalledWith({
agentId: "agent-1",
cwd: REPO_CWD,
workspaceId: BROWSER_WORKSPACE_ID,
command: { command: "list_tabs", args: {} },
});
expect(response.content).toEqual([
@@ -656,6 +667,45 @@ describe("browser MCP tools", () => {
expect(response.structuredContent).toEqual({
ok: true,
result: { command: "list_tabs", tabs: [] },
context: { agentId: "agent-1", cwd: REPO_CWD, workspaceId: BROWSER_WORKSPACE_ID },
});
});
it("tells browser callers without a workspace how to proceed before broker execution", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({ id: "agent-1", cwd: REPO_CWD });
const execute = vi.fn().mockResolvedValue({
requestId: "req-browser-tabs",
ok: true,
result: { command: "list_tabs", tabs: [] },
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
browserToolsBroker: { execute } as never,
callerAgentId: "agent-1",
logger,
});
const tool = registeredTool(server, "browser_list_tabs");
const response = await tool.handler({});
expect(execute).not.toHaveBeenCalled();
expect(response.content).toEqual([
{
type: "text",
text: "This browser tool needs a workspace. Start the agent from a Paseo workspace before calling browser_new_tab or browser_list_tabs.",
},
]);
expect(response.structuredContent).toEqual({
ok: false,
error: {
code: "browser_denied",
message:
"This browser tool needs a workspace. Start the agent from a Paseo workspace before calling browser_new_tab or browser_list_tabs.",
retryable: false,
},
context: { agentId: "agent-1", cwd: REPO_CWD },
});
});
@@ -216,6 +216,24 @@ describe("registerBrowserTools", () => {
input: { url: "example.com/x" },
expected: { url: "http://example.com/x" },
},
{
name: "navigate accepts a single-label host with a port as http",
toolName: "browser_navigate",
input: { browserId: BROWSER_ID, url: "devbox:8080" },
expected: { browserId: BROWSER_ID, url: "http://devbox:8080" },
},
{
name: "navigate accepts an IPv6 host with a port as http",
toolName: "browser_navigate",
input: { browserId: BROWSER_ID, url: "[::1]:5173" },
expected: { browserId: BROWSER_ID, url: "http://[::1]:5173" },
},
{
name: "navigate trims whitespace around a URL",
toolName: "browser_navigate",
input: { browserId: BROWSER_ID, url: " https://example.com/x " },
expected: { browserId: BROWSER_ID, url: "https://example.com/x" },
},
{
name: "navigate keeps https URLs unchanged",
toolName: "browser_navigate",
@@ -246,6 +264,16 @@ describe("registerBrowserTools", () => {
toolName: "browser_new_tab",
input: { url: "file:///tmp/index.html" },
},
{
name: "navigate rejects invalid ports",
toolName: "browser_navigate",
input: { browserId: BROWSER_ID, url: "devbox:99999" },
},
{
name: "navigate rejects URLs with spaces",
toolName: "browser_navigate",
input: { browserId: BROWSER_ID, url: "dev box:8080" },
},
])("$name", ({ toolName, input }) => {
const harness = new BrowserToolHarness();
@@ -63,8 +63,8 @@ export interface RegisterBrowserToolsOptions {
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 URL_WHITESPACE_PATTERN = /\s/;
const NON_HTTP_EXPLICIT_SCHEME_PATTERN = /^(?!https?:\/\/)[a-z][a-z0-9+.-]*:\/\//i;
const BrowserToolContextSchema = z.object({
agentId: z.string().optional(),
@@ -453,15 +453,36 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
},
);
for (const name of ["browser_back", "browser_forward", "browser_reload"] as const) {
const command = name.replace("browser_", "") as "back" | "forward" | "reload";
for (const toolConfig of [
{
name: "browser_back",
command: "back",
title: "Browser back",
description:
"Go back in a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs.",
},
{
name: "browser_forward",
command: "forward",
title: "Browser forward",
description:
"Go forward in a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs.",
},
{
name: "browser_reload",
command: "reload",
title: "Browser reload",
description:
"Reload a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs.",
},
] as const) {
options.registerTool(
name,
toolConfig.name,
{
title: `Browser ${command}`,
description: `${command} a Paseo desktop browser tab. Use browserId from browser_new_tab or browser_list_tabs.`,
title: toolConfig.title,
description: toolConfig.description,
inputSchema: { browserId: BrowserAutomationBrowserIdSchema },
outputSchema: BrowserToolOutputs[command],
outputSchema: BrowserToolOutputs[toolConfig.command],
},
async ({ browserId }) => {
const context = resolveBrowserToolContext(options);
@@ -471,7 +492,7 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
...(context.workspaceId ? { workspaceId: context.workspaceId } : {}),
command: {
command,
command: toolConfig.command,
args: {
browserId,
},
@@ -943,12 +964,12 @@ function normalizeHttpUrlInput(value: string): string | null {
return isValidHttpUrl(value) ? value : null;
}
if (SCHEMELESS_HTTP_URL_PATTERN.test(value)) {
const normalized = `http://${value}`;
return isValidHttpUrl(normalized) ? normalized : null;
if (URL_WHITESPACE_PATTERN.test(value) || NON_HTTP_EXPLICIT_SCHEME_PATTERN.test(value)) {
return null;
}
return null;
const normalized = `http://${value}`;
return isValidHttpUrl(normalized) ? normalized : null;
}
function isValidHttpUrl(value: string): boolean {