diff --git a/docs/browser.md b/docs/browser.md index ab3ae214d..af162af05 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -189,10 +189,26 @@ All existing endpoints accept optional `?profile=` query parameter: - `GET /?profile=work` — status for work profile - `POST /start?profile=work` — start work profile browser - `GET /tabs?profile=work` — list tabs for work profile +- `POST /tabs/open?profile=work` — open tab in work profile - etc. When `profile` is omitted, uses `browser.defaultProfile` (defaults to "clawd"). +### Agent browser tool + +The `browser` tool accepts an optional `profile` parameter for all actions: + +```json +{ + "action": "open", + "targetUrl": "https://example.com", + "profile": "work" +} +``` + +This routes the operation to the specified profile's browser instance. Omitting +`profile` uses the default profile. + ### Profile naming rules - Lowercase alphanumeric characters and hyphens only diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts index cfae25603..8681e3abb 100644 --- a/src/agents/tools/browser-tool.ts +++ b/src/agents/tools/browser-tool.ts @@ -182,7 +182,9 @@ export function createBrowserTool(opts?: { const targetUrl = readStringParam(params, "targetUrl", { required: true, }); - return jsonResult(await browserOpenTab(baseUrl, targetUrl, { profile })); + return jsonResult( + await browserOpenTab(baseUrl, targetUrl, { profile }), + ); } case "focus": { const targetId = readStringParam(params, "targetId", { @@ -250,7 +252,11 @@ export function createBrowserTool(opts?: { }); const targetId = readStringParam(params, "targetId"); return jsonResult( - await browserNavigate(baseUrl, { url: targetUrl, targetId, profile }), + await browserNavigate(baseUrl, { + url: targetUrl, + targetId, + profile, + }), ); } case "console": { diff --git a/src/browser/routes/tabs.ts b/src/browser/routes/tabs.ts index ef786afa6..509395414 100644 --- a/src/browser/routes/tabs.ts +++ b/src/browser/routes/tabs.ts @@ -1,18 +1,26 @@ import type express from "express"; import type { BrowserRouteContext } from "../server-context.js"; -import { jsonError, toNumber, toStringOrEmpty } from "./utils.js"; +import { + getProfileContext, + jsonError, + toNumber, + toStringOrEmpty, +} from "./utils.js"; export function registerBrowserTabRoutes( app: express.Express, ctx: BrowserRouteContext, ) { - app.get("/tabs", async (_req, res) => { + app.get("/tabs", async (req, res) => { + const profileCtx = getProfileContext(req, ctx); + if ("error" in profileCtx) + return jsonError(res, profileCtx.status, profileCtx.error); try { - const reachable = await ctx.isReachable(300); + const reachable = await profileCtx.isReachable(300); if (!reachable) return res.json({ running: false, tabs: [] as unknown[] }); - const tabs = await ctx.listTabs(); + const tabs = await profileCtx.listTabs(); res.json({ running: true, tabs }); } catch (err) { jsonError(res, 500, String(err)); @@ -20,11 +28,14 @@ export function registerBrowserTabRoutes( }); app.post("/tabs/open", async (req, res) => { + const profileCtx = getProfileContext(req, ctx); + if ("error" in profileCtx) + return jsonError(res, profileCtx.status, profileCtx.error); const url = toStringOrEmpty((req.body as { url?: unknown })?.url); if (!url) return jsonError(res, 400, "url is required"); try { - await ctx.ensureBrowserAvailable(); - const tab = await ctx.openTab(url); + await profileCtx.ensureBrowserAvailable(); + const tab = await profileCtx.openTab(url); res.json(tab); } catch (err) { jsonError(res, 500, String(err)); @@ -32,14 +43,17 @@ export function registerBrowserTabRoutes( }); app.post("/tabs/focus", async (req, res) => { + const profileCtx = getProfileContext(req, ctx); + if ("error" in profileCtx) + return jsonError(res, profileCtx.status, profileCtx.error); const targetId = toStringOrEmpty( (req.body as { targetId?: unknown })?.targetId, ); if (!targetId) return jsonError(res, 400, "targetId is required"); try { - if (!(await ctx.isReachable(300))) + if (!(await profileCtx.isReachable(300))) return jsonError(res, 409, "browser not running"); - await ctx.focusTab(targetId); + await profileCtx.focusTab(targetId); res.json({ ok: true }); } catch (err) { const mapped = ctx.mapTabError(err); @@ -49,12 +63,15 @@ export function registerBrowserTabRoutes( }); app.delete("/tabs/:targetId", async (req, res) => { + const profileCtx = getProfileContext(req, ctx); + if ("error" in profileCtx) + return jsonError(res, profileCtx.status, profileCtx.error); const targetId = toStringOrEmpty(req.params.targetId); if (!targetId) return jsonError(res, 400, "targetId is required"); try { - if (!(await ctx.isReachable(300))) + if (!(await profileCtx.isReachable(300))) return jsonError(res, 409, "browser not running"); - await ctx.closeTab(targetId); + await profileCtx.closeTab(targetId); res.json({ ok: true }); } catch (err) { const mapped = ctx.mapTabError(err); @@ -64,37 +81,40 @@ export function registerBrowserTabRoutes( }); app.post("/tabs/action", async (req, res) => { + const profileCtx = getProfileContext(req, ctx); + if ("error" in profileCtx) + return jsonError(res, profileCtx.status, profileCtx.error); const action = toStringOrEmpty((req.body as { action?: unknown })?.action); const index = toNumber((req.body as { index?: unknown })?.index); try { if (action === "list") { - const reachable = await ctx.isReachable(300); + const reachable = await profileCtx.isReachable(300); if (!reachable) return res.json({ ok: true, tabs: [] as unknown[] }); - const tabs = await ctx.listTabs(); + const tabs = await profileCtx.listTabs(); return res.json({ ok: true, tabs }); } if (action === "new") { - await ctx.ensureBrowserAvailable(); - const tab = await ctx.openTab("about:blank"); + await profileCtx.ensureBrowserAvailable(); + const tab = await profileCtx.openTab("about:blank"); return res.json({ ok: true, tab }); } if (action === "close") { - const tabs = await ctx.listTabs(); + const tabs = await profileCtx.listTabs(); const target = typeof index === "number" ? tabs[index] : tabs.at(0); if (!target) return jsonError(res, 404, "tab not found"); - await ctx.closeTab(target.targetId); + await profileCtx.closeTab(target.targetId); return res.json({ ok: true, targetId: target.targetId }); } if (action === "select") { if (typeof index !== "number") return jsonError(res, 400, "index is required"); - const tabs = await ctx.listTabs(); + const tabs = await profileCtx.listTabs(); const target = tabs[index]; if (!target) return jsonError(res, 404, "tab not found"); - await ctx.focusTab(target.targetId); + await profileCtx.focusTab(target.targetId); return res.json({ ok: true, targetId: target.targetId }); } diff --git a/src/browser/server.test.ts b/src/browser/server.test.ts index d1318771b..4124c956c 100644 --- a/src/browser/server.test.ts +++ b/src/browser/server.test.ts @@ -894,6 +894,61 @@ describe("backward compatibility (profile parameter)", () => { // Should at least have the default clawd profile expect(result.profiles.some((p) => p.name === "clawd")).toBe(true); }); + + it("GET /tabs?profile=clawd returns tabs for specified profile", async () => { + const { startBrowserControlServerFromConfig } = await import("./server.js"); + await startBrowserControlServerFromConfig(); + const base = `http://127.0.0.1:${testPort}`; + + await realFetch(`${base}/start`, { method: "POST" }); + + const result = (await realFetch(`${base}/tabs?profile=clawd`).then((r) => + r.json(), + )) as { running: boolean; tabs: unknown[] }; + expect(result.running).toBe(true); + expect(Array.isArray(result.tabs)).toBe(true); + }); + + it("POST /tabs/open?profile=clawd opens tab in specified profile", async () => { + const { startBrowserControlServerFromConfig } = await import("./server.js"); + await startBrowserControlServerFromConfig(); + const base = `http://127.0.0.1:${testPort}`; + + await realFetch(`${base}/start`, { method: "POST" }); + + const result = (await realFetch(`${base}/tabs/open?profile=clawd`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: "https://example.com" }), + }).then((r) => r.json())) as { targetId?: string }; + expect(result.targetId).toBe("newtab1"); + }); + + it("GET /tabs?profile=unknown returns 404", async () => { + const { startBrowserControlServerFromConfig } = await import("./server.js"); + await startBrowserControlServerFromConfig(); + const base = `http://127.0.0.1:${testPort}`; + + const result = await realFetch(`${base}/tabs?profile=unknown`); + expect(result.status).toBe(404); + const body = (await result.json()) as { error: string }; + expect(body.error).toContain("not found"); + }); + + it("POST /tabs/open?profile=unknown returns 404", async () => { + const { startBrowserControlServerFromConfig } = await import("./server.js"); + await startBrowserControlServerFromConfig(); + const base = `http://127.0.0.1:${testPort}`; + + const result = await realFetch(`${base}/tabs/open?profile=unknown`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: "https://example.com" }), + }); + expect(result.status).toBe(404); + const body = (await result.json()) as { error: string }; + expect(body.error).toContain("not found"); + }); }); describe("profile CRUD endpoints", () => { diff --git a/src/cli/browser-cli.test.ts b/src/cli/browser-cli.test.ts index bae4f2175..6dc5a0a48 100644 --- a/src/cli/browser-cli.test.ts +++ b/src/cli/browser-cli.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; import { Command } from "commander"; +import { describe, expect, it } from "vitest"; describe("browser CLI --browser-profile flag", () => { it("parses --browser-profile from parent command options", () => { @@ -17,7 +17,14 @@ describe("browser CLI --browser-profile flag", () => { capturedProfile = parent?.browserProfile; }); - program.parse(["node", "test", "browser", "--browser-profile", "onasset", "status"]); + program.parse([ + "node", + "test", + "browser", + "--browser-profile", + "onasset", + "status", + ]); expect(capturedProfile).toBe("onasset"); }); diff --git a/src/cli/browser-cli.ts b/src/cli/browser-cli.ts index c38809428..88d6022f5 100644 --- a/src/cli/browser-cli.ts +++ b/src/cli/browser-cli.ts @@ -20,7 +20,10 @@ export function registerBrowserCli(program: Command) { "--url ", "Override browser control URL (default from ~/.clawdbot/clawdbot.json)", ) - .option("--browser-profile ", "Browser profile name (default from config)") + .option( + "--browser-profile ", + "Browser profile name (default from config)", + ) .option("--json", "Output machine-readable JSON", false) .addHelpText( "after",