fix(browser): add profile param to tabs routes and browser-tool

- tabs.ts now uses getProfileContext like other routes
- browser-tool threads profile param through all actions
- add tests for profile query param on /tabs endpoints
- update docs with browser tool profile parameter
This commit is contained in:
James Groat
2026-01-06 11:04:33 -07:00
parent 6cebd26529
commit cdd0cb6089
6 changed files with 130 additions and 23 deletions

View File

@@ -189,10 +189,26 @@ All existing endpoints accept optional `?profile=<name>` query parameter:
- `GET /?profile=work` — status for work profile - `GET /?profile=work` — status for work profile
- `POST /start?profile=work` — start work profile browser - `POST /start?profile=work` — start work profile browser
- `GET /tabs?profile=work` — list tabs for work profile - `GET /tabs?profile=work` — list tabs for work profile
- `POST /tabs/open?profile=work` — open tab in work profile
- etc. - etc.
When `profile` is omitted, uses `browser.defaultProfile` (defaults to "clawd"). 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 ### Profile naming rules
- Lowercase alphanumeric characters and hyphens only - Lowercase alphanumeric characters and hyphens only

View File

@@ -182,7 +182,9 @@ export function createBrowserTool(opts?: {
const targetUrl = readStringParam(params, "targetUrl", { const targetUrl = readStringParam(params, "targetUrl", {
required: true, required: true,
}); });
return jsonResult(await browserOpenTab(baseUrl, targetUrl, { profile })); return jsonResult(
await browserOpenTab(baseUrl, targetUrl, { profile }),
);
} }
case "focus": { case "focus": {
const targetId = readStringParam(params, "targetId", { const targetId = readStringParam(params, "targetId", {
@@ -250,7 +252,11 @@ export function createBrowserTool(opts?: {
}); });
const targetId = readStringParam(params, "targetId"); const targetId = readStringParam(params, "targetId");
return jsonResult( return jsonResult(
await browserNavigate(baseUrl, { url: targetUrl, targetId, profile }), await browserNavigate(baseUrl, {
url: targetUrl,
targetId,
profile,
}),
); );
} }
case "console": { case "console": {

View File

@@ -1,18 +1,26 @@
import type express from "express"; import type express from "express";
import type { BrowserRouteContext } from "../server-context.js"; 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( export function registerBrowserTabRoutes(
app: express.Express, app: express.Express,
ctx: BrowserRouteContext, 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 { try {
const reachable = await ctx.isReachable(300); const reachable = await profileCtx.isReachable(300);
if (!reachable) if (!reachable)
return res.json({ running: false, tabs: [] as unknown[] }); return res.json({ running: false, tabs: [] as unknown[] });
const tabs = await ctx.listTabs(); const tabs = await profileCtx.listTabs();
res.json({ running: true, tabs }); res.json({ running: true, tabs });
} catch (err) { } catch (err) {
jsonError(res, 500, String(err)); jsonError(res, 500, String(err));
@@ -20,11 +28,14 @@ export function registerBrowserTabRoutes(
}); });
app.post("/tabs/open", async (req, res) => { 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); const url = toStringOrEmpty((req.body as { url?: unknown })?.url);
if (!url) return jsonError(res, 400, "url is required"); if (!url) return jsonError(res, 400, "url is required");
try { try {
await ctx.ensureBrowserAvailable(); await profileCtx.ensureBrowserAvailable();
const tab = await ctx.openTab(url); const tab = await profileCtx.openTab(url);
res.json(tab); res.json(tab);
} catch (err) { } catch (err) {
jsonError(res, 500, String(err)); jsonError(res, 500, String(err));
@@ -32,14 +43,17 @@ export function registerBrowserTabRoutes(
}); });
app.post("/tabs/focus", async (req, res) => { 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( const targetId = toStringOrEmpty(
(req.body as { targetId?: unknown })?.targetId, (req.body as { targetId?: unknown })?.targetId,
); );
if (!targetId) return jsonError(res, 400, "targetId is required"); if (!targetId) return jsonError(res, 400, "targetId is required");
try { try {
if (!(await ctx.isReachable(300))) if (!(await profileCtx.isReachable(300)))
return jsonError(res, 409, "browser not running"); return jsonError(res, 409, "browser not running");
await ctx.focusTab(targetId); await profileCtx.focusTab(targetId);
res.json({ ok: true }); res.json({ ok: true });
} catch (err) { } catch (err) {
const mapped = ctx.mapTabError(err); const mapped = ctx.mapTabError(err);
@@ -49,12 +63,15 @@ export function registerBrowserTabRoutes(
}); });
app.delete("/tabs/:targetId", async (req, res) => { 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); const targetId = toStringOrEmpty(req.params.targetId);
if (!targetId) return jsonError(res, 400, "targetId is required"); if (!targetId) return jsonError(res, 400, "targetId is required");
try { try {
if (!(await ctx.isReachable(300))) if (!(await profileCtx.isReachable(300)))
return jsonError(res, 409, "browser not running"); return jsonError(res, 409, "browser not running");
await ctx.closeTab(targetId); await profileCtx.closeTab(targetId);
res.json({ ok: true }); res.json({ ok: true });
} catch (err) { } catch (err) {
const mapped = ctx.mapTabError(err); const mapped = ctx.mapTabError(err);
@@ -64,37 +81,40 @@ export function registerBrowserTabRoutes(
}); });
app.post("/tabs/action", async (req, res) => { 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 action = toStringOrEmpty((req.body as { action?: unknown })?.action);
const index = toNumber((req.body as { index?: unknown })?.index); const index = toNumber((req.body as { index?: unknown })?.index);
try { try {
if (action === "list") { 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[] }); 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 }); return res.json({ ok: true, tabs });
} }
if (action === "new") { if (action === "new") {
await ctx.ensureBrowserAvailable(); await profileCtx.ensureBrowserAvailable();
const tab = await ctx.openTab("about:blank"); const tab = await profileCtx.openTab("about:blank");
return res.json({ ok: true, tab }); return res.json({ ok: true, tab });
} }
if (action === "close") { if (action === "close") {
const tabs = await ctx.listTabs(); const tabs = await profileCtx.listTabs();
const target = typeof index === "number" ? tabs[index] : tabs.at(0); const target = typeof index === "number" ? tabs[index] : tabs.at(0);
if (!target) return jsonError(res, 404, "tab not found"); 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 }); return res.json({ ok: true, targetId: target.targetId });
} }
if (action === "select") { if (action === "select") {
if (typeof index !== "number") if (typeof index !== "number")
return jsonError(res, 400, "index is required"); return jsonError(res, 400, "index is required");
const tabs = await ctx.listTabs(); const tabs = await profileCtx.listTabs();
const target = tabs[index]; const target = tabs[index];
if (!target) return jsonError(res, 404, "tab not found"); 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 }); return res.json({ ok: true, targetId: target.targetId });
} }

View File

@@ -894,6 +894,61 @@ describe("backward compatibility (profile parameter)", () => {
// Should at least have the default clawd profile // Should at least have the default clawd profile
expect(result.profiles.some((p) => p.name === "clawd")).toBe(true); 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", () => { describe("profile CRUD endpoints", () => {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { Command } from "commander"; import { Command } from "commander";
import { describe, expect, it } from "vitest";
describe("browser CLI --browser-profile flag", () => { describe("browser CLI --browser-profile flag", () => {
it("parses --browser-profile from parent command options", () => { it("parses --browser-profile from parent command options", () => {
@@ -17,7 +17,14 @@ describe("browser CLI --browser-profile flag", () => {
capturedProfile = parent?.browserProfile; 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"); expect(capturedProfile).toBe("onasset");
}); });

View File

@@ -20,7 +20,10 @@ export function registerBrowserCli(program: Command) {
"--url <url>", "--url <url>",
"Override browser control URL (default from ~/.clawdbot/clawdbot.json)", "Override browser control URL (default from ~/.clawdbot/clawdbot.json)",
) )
.option("--browser-profile <name>", "Browser profile name (default from config)") .option(
"--browser-profile <name>",
"Browser profile name (default from config)",
)
.option("--json", "Output machine-readable JSON", false) .option("--json", "Output machine-readable JSON", false)
.addHelpText( .addHelpText(
"after", "after",