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:
@@ -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
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user