mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 19:01:47 +03:00
refactor: route browser control via gateway/node
This commit is contained in:
@@ -20,11 +20,8 @@ import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
|
||||
import { createTtsTool } from "./tools/tts-tool.js";
|
||||
|
||||
export function createClawdbotTools(options?: {
|
||||
browserControlUrl?: string;
|
||||
sandboxBrowserBridgeUrl?: string;
|
||||
allowHostBrowserControl?: boolean;
|
||||
allowedControlUrls?: string[];
|
||||
allowedControlHosts?: string[];
|
||||
allowedControlPorts?: number[];
|
||||
agentSessionKey?: string;
|
||||
agentChannel?: GatewayMessageChannel;
|
||||
agentAccountId?: string;
|
||||
@@ -75,11 +72,8 @@ export function createClawdbotTools(options?: {
|
||||
});
|
||||
const tools: AnyAgentTool[] = [
|
||||
createBrowserTool({
|
||||
defaultControlUrl: options?.browserControlUrl,
|
||||
sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl,
|
||||
allowHostControl: options?.allowHostBrowserControl,
|
||||
allowedControlUrls: options?.allowedControlUrls,
|
||||
allowedControlHosts: options?.allowedControlHosts,
|
||||
allowedControlPorts: options?.allowedControlPorts,
|
||||
}),
|
||||
createCanvasTool(),
|
||||
createNodesTool({
|
||||
|
||||
@@ -127,7 +127,7 @@ describe("buildEmbeddedSandboxInfo", () => {
|
||||
},
|
||||
browserAllowHostControl: true,
|
||||
browser: {
|
||||
controlUrl: "http://localhost:9222",
|
||||
bridgeUrl: "http://localhost:9222",
|
||||
noVncUrl: "http://localhost:6080",
|
||||
containerName: "clawdbot-sbx-browser-test",
|
||||
},
|
||||
@@ -138,7 +138,7 @@ describe("buildEmbeddedSandboxInfo", () => {
|
||||
workspaceDir: "/tmp/clawdbot-sandbox",
|
||||
workspaceAccess: "none",
|
||||
agentWorkspaceMount: undefined,
|
||||
browserControlUrl: "http://localhost:9222",
|
||||
browserBridgeUrl: "http://localhost:9222",
|
||||
browserNoVncUrl: "http://localhost:6080",
|
||||
hostBrowserAllowed: true,
|
||||
});
|
||||
|
||||
@@ -13,12 +13,9 @@ export function buildEmbeddedSandboxInfo(
|
||||
workspaceDir: sandbox.workspaceDir,
|
||||
workspaceAccess: sandbox.workspaceAccess,
|
||||
agentWorkspaceMount: sandbox.workspaceAccess === "ro" ? "/agent" : undefined,
|
||||
browserControlUrl: sandbox.browser?.controlUrl,
|
||||
browserBridgeUrl: sandbox.browser?.bridgeUrl,
|
||||
browserNoVncUrl: sandbox.browser?.noVncUrl,
|
||||
hostBrowserAllowed: sandbox.browserAllowHostControl,
|
||||
allowedControlUrls: sandbox.browserAllowedControlUrls,
|
||||
allowedControlHosts: sandbox.browserAllowedControlHosts,
|
||||
allowedControlPorts: sandbox.browserAllowedControlPorts,
|
||||
...(elevatedAllowed
|
||||
? {
|
||||
elevated: {
|
||||
|
||||
@@ -69,12 +69,9 @@ export type EmbeddedSandboxInfo = {
|
||||
workspaceDir?: string;
|
||||
workspaceAccess?: "none" | "ro" | "rw";
|
||||
agentWorkspaceMount?: string;
|
||||
browserControlUrl?: string;
|
||||
browserBridgeUrl?: string;
|
||||
browserNoVncUrl?: string;
|
||||
hostBrowserAllowed?: boolean;
|
||||
allowedControlUrls?: string[];
|
||||
allowedControlHosts?: string[];
|
||||
allowedControlPorts?: number[];
|
||||
elevated?: {
|
||||
allowed: boolean;
|
||||
defaultLevel: "on" | "off" | "ask" | "full";
|
||||
|
||||
-1
@@ -96,7 +96,6 @@ describe("createClawdbotCodingTools", () => {
|
||||
};
|
||||
expect(parameters.properties?.action).toBeDefined();
|
||||
expect(parameters.properties?.target).toBeDefined();
|
||||
expect(parameters.properties?.controlUrl).toBeDefined();
|
||||
expect(parameters.properties?.targetUrl).toBeDefined();
|
||||
expect(parameters.properties?.request).toBeDefined();
|
||||
expect(parameters.required ?? []).toContain("action");
|
||||
|
||||
@@ -294,11 +294,8 @@ export function createClawdbotCodingTools(options?: {
|
||||
// Channel docking: include channel-defined agent tools (login, etc.).
|
||||
...listChannelAgentTools({ cfg: options?.config }),
|
||||
...createClawdbotTools({
|
||||
browserControlUrl: sandbox?.browser?.controlUrl,
|
||||
sandboxBrowserBridgeUrl: sandbox?.browser?.bridgeUrl,
|
||||
allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true,
|
||||
allowedControlUrls: sandbox?.browserAllowedControlUrls,
|
||||
allowedControlHosts: sandbox?.browserAllowedControlHosts,
|
||||
allowedControlPorts: sandbox?.browserAllowedControlPorts,
|
||||
agentSessionKey: options?.sessionKey,
|
||||
agentChannel: resolveGatewayMessageChannel(options?.messageProvider),
|
||||
agentAccountId: options?.agentAccountId,
|
||||
|
||||
@@ -40,13 +40,9 @@ function buildSandboxBrowserResolvedConfig(params: {
|
||||
cdpPort: number;
|
||||
headless: boolean;
|
||||
}): ResolvedBrowserConfig {
|
||||
const controlHost = "127.0.0.1";
|
||||
const controlUrl = `http://${controlHost}:${params.controlPort}`;
|
||||
const cdpHost = "127.0.0.1";
|
||||
return {
|
||||
enabled: true,
|
||||
controlUrl,
|
||||
controlHost,
|
||||
controlPort: params.controlPort,
|
||||
cdpProtocol: "http",
|
||||
cdpHost,
|
||||
@@ -204,7 +200,7 @@ export async function ensureSandboxBrowser(params: {
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
controlUrl: resolvedBridge.baseUrl,
|
||||
bridgeUrl: resolvedBridge.baseUrl,
|
||||
noVncUrl,
|
||||
containerName,
|
||||
};
|
||||
|
||||
@@ -86,11 +86,6 @@ export function resolveSandboxBrowserConfig(params: {
|
||||
}): SandboxBrowserConfig {
|
||||
const agentBrowser = params.scope === "shared" ? undefined : params.agentBrowser;
|
||||
const globalBrowser = params.globalBrowser;
|
||||
const allowedControlUrls = agentBrowser?.allowedControlUrls ?? globalBrowser?.allowedControlUrls;
|
||||
const allowedControlHosts =
|
||||
agentBrowser?.allowedControlHosts ?? globalBrowser?.allowedControlHosts;
|
||||
const allowedControlPorts =
|
||||
agentBrowser?.allowedControlPorts ?? globalBrowser?.allowedControlPorts;
|
||||
return {
|
||||
enabled: agentBrowser?.enabled ?? globalBrowser?.enabled ?? false,
|
||||
image: agentBrowser?.image ?? globalBrowser?.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE,
|
||||
@@ -105,18 +100,6 @@ export function resolveSandboxBrowserConfig(params: {
|
||||
headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false,
|
||||
enableNoVnc: agentBrowser?.enableNoVnc ?? globalBrowser?.enableNoVnc ?? true,
|
||||
allowHostControl: agentBrowser?.allowHostControl ?? globalBrowser?.allowHostControl ?? false,
|
||||
allowedControlUrls:
|
||||
Array.isArray(allowedControlUrls) && allowedControlUrls.length > 0
|
||||
? allowedControlUrls
|
||||
: undefined,
|
||||
allowedControlHosts:
|
||||
Array.isArray(allowedControlHosts) && allowedControlHosts.length > 0
|
||||
? allowedControlHosts
|
||||
: undefined,
|
||||
allowedControlPorts:
|
||||
Array.isArray(allowedControlPorts) && allowedControlPorts.length > 0
|
||||
? allowedControlPorts
|
||||
: undefined,
|
||||
autoStart: agentBrowser?.autoStart ?? globalBrowser?.autoStart ?? true,
|
||||
autoStartTimeoutMs:
|
||||
agentBrowser?.autoStartTimeoutMs ??
|
||||
|
||||
@@ -87,9 +87,6 @@ export async function resolveSandboxContext(params: {
|
||||
docker: cfg.docker,
|
||||
tools: cfg.tools,
|
||||
browserAllowHostControl: cfg.browser.allowHostControl,
|
||||
browserAllowedControlUrls: cfg.browser.allowedControlUrls,
|
||||
browserAllowedControlHosts: cfg.browser.allowedControlHosts,
|
||||
browserAllowedControlPorts: cfg.browser.allowedControlPorts,
|
||||
browser: browser ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,9 +37,6 @@ export type SandboxBrowserConfig = {
|
||||
headless: boolean;
|
||||
enableNoVnc: boolean;
|
||||
allowHostControl: boolean;
|
||||
allowedControlUrls?: string[];
|
||||
allowedControlHosts?: string[];
|
||||
allowedControlPorts?: number[];
|
||||
autoStart: boolean;
|
||||
autoStartTimeoutMs: number;
|
||||
};
|
||||
@@ -63,7 +60,7 @@ export type SandboxConfig = {
|
||||
};
|
||||
|
||||
export type SandboxBrowserContext = {
|
||||
controlUrl: string;
|
||||
bridgeUrl: string;
|
||||
noVncUrl?: string;
|
||||
containerName: string;
|
||||
};
|
||||
@@ -79,9 +76,6 @@ export type SandboxContext = {
|
||||
docker: SandboxDockerConfig;
|
||||
tools: SandboxToolPolicy;
|
||||
browserAllowHostControl: boolean;
|
||||
browserAllowedControlUrls?: string[];
|
||||
browserAllowedControlHosts?: string[];
|
||||
browserAllowedControlPorts?: number[];
|
||||
browser?: SandboxBrowserContext;
|
||||
};
|
||||
|
||||
|
||||
@@ -165,12 +165,9 @@ export function buildAgentSystemPrompt(params: {
|
||||
workspaceDir?: string;
|
||||
workspaceAccess?: "none" | "ro" | "rw";
|
||||
agentWorkspaceMount?: string;
|
||||
browserControlUrl?: string;
|
||||
browserBridgeUrl?: string;
|
||||
browserNoVncUrl?: string;
|
||||
hostBrowserAllowed?: boolean;
|
||||
allowedControlUrls?: string[];
|
||||
allowedControlHosts?: string[];
|
||||
allowedControlPorts?: number[];
|
||||
elevated?: {
|
||||
allowed: boolean;
|
||||
defaultLevel: "on" | "off" | "ask" | "full";
|
||||
@@ -419,9 +416,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
: ""
|
||||
}`
|
||||
: "",
|
||||
params.sandboxInfo.browserControlUrl
|
||||
? `Sandbox browser control URL: ${params.sandboxInfo.browserControlUrl}`
|
||||
: "",
|
||||
params.sandboxInfo.browserBridgeUrl ? "Sandbox browser: enabled." : "",
|
||||
params.sandboxInfo.browserNoVncUrl
|
||||
? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}`
|
||||
: "",
|
||||
@@ -430,15 +425,6 @@ export function buildAgentSystemPrompt(params: {
|
||||
: params.sandboxInfo.hostBrowserAllowed === false
|
||||
? "Host browser control: blocked."
|
||||
: "",
|
||||
params.sandboxInfo.allowedControlUrls?.length
|
||||
? `Browser control URL allowlist: ${params.sandboxInfo.allowedControlUrls.join(", ")}`
|
||||
: "",
|
||||
params.sandboxInfo.allowedControlHosts?.length
|
||||
? `Browser control host allowlist: ${params.sandboxInfo.allowedControlHosts.join(", ")}`
|
||||
: "",
|
||||
params.sandboxInfo.allowedControlPorts?.length
|
||||
? `Browser control port allowlist: ${params.sandboxInfo.allowedControlPorts.join(", ")}`
|
||||
: "",
|
||||
params.sandboxInfo.elevated?.allowed
|
||||
? "Elevated exec is available for this session."
|
||||
: "",
|
||||
|
||||
@@ -35,7 +35,7 @@ const BROWSER_TOOL_ACTIONS = [
|
||||
"act",
|
||||
] as const;
|
||||
|
||||
const BROWSER_TARGETS = ["sandbox", "host", "custom", "node"] as const;
|
||||
const BROWSER_TARGETS = ["sandbox", "host", "node"] as const;
|
||||
|
||||
const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const;
|
||||
const BROWSER_SNAPSHOT_MODES = ["efficient"] as const;
|
||||
@@ -86,7 +86,6 @@ export const BrowserToolSchema = Type.Object({
|
||||
target: optionalStringEnum(BROWSER_TARGETS),
|
||||
node: Type.Optional(Type.String()),
|
||||
profile: Type.Optional(Type.String()),
|
||||
controlUrl: Type.Optional(Type.String()),
|
||||
targetUrl: Type.Optional(Type.String()),
|
||||
targetId: Type.Optional(Type.String()),
|
||||
limit: Type.Optional(Type.Number()),
|
||||
|
||||
@@ -28,23 +28,7 @@ vi.mock("../../browser/client.js", () => browserClientMocks);
|
||||
const browserConfigMocks = vi.hoisted(() => ({
|
||||
resolveBrowserConfig: vi.fn(() => ({
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
controlHost: "127.0.0.1",
|
||||
controlPort: 18791,
|
||||
cdpProtocol: "http",
|
||||
cdpHost: "127.0.0.1",
|
||||
cdpIsLoopback: true,
|
||||
color: "#FF0000",
|
||||
headless: true,
|
||||
noSandbox: false,
|
||||
attachOnly: false,
|
||||
defaultProfile: "clawd",
|
||||
profiles: {
|
||||
clawd: {
|
||||
cdpPort: 18792,
|
||||
color: "#FF0000",
|
||||
},
|
||||
},
|
||||
})),
|
||||
}));
|
||||
vi.mock("../../browser/config.js", () => browserConfigMocks);
|
||||
@@ -99,7 +83,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18791",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
format: "ai",
|
||||
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
|
||||
@@ -117,7 +101,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
});
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18791",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
maxChars: override,
|
||||
}),
|
||||
@@ -141,7 +125,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.(null, { action: "profiles" });
|
||||
|
||||
expect(browserClientMocks.browserProfiles).toHaveBeenCalledWith("http://127.0.0.1:18791");
|
||||
expect(browserClientMocks.browserProfiles).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("passes refs mode through to browser snapshot", async () => {
|
||||
@@ -149,7 +133,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai", refs: "aria" });
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18791",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
format: "ai",
|
||||
refs: "aria",
|
||||
@@ -165,7 +149,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18791",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
mode: "efficient",
|
||||
}),
|
||||
@@ -185,11 +169,11 @@ describe("browser tool snapshot maxChars", () => {
|
||||
});
|
||||
|
||||
it("defaults to host when using profile=chrome (even in sandboxed sessions)", async () => {
|
||||
const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
|
||||
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
|
||||
await tool.execute?.(null, { action: "snapshot", profile: "chrome", snapshotFormat: "ai" });
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18791",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
profile: "chrome",
|
||||
}),
|
||||
@@ -220,7 +204,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps sandbox control url when node proxy is available", async () => {
|
||||
it("keeps sandbox bridge url when node proxy is available", async () => {
|
||||
nodesUtilsMocks.listNodes.mockResolvedValue([
|
||||
{
|
||||
nodeId: "node-1",
|
||||
@@ -230,7 +214,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
commands: ["browser.proxy"],
|
||||
},
|
||||
]);
|
||||
const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
|
||||
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
|
||||
await tool.execute?.(null, { action: "status" });
|
||||
|
||||
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
|
||||
@@ -254,7 +238,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
await tool.execute?.(null, { action: "status", profile: "chrome" });
|
||||
|
||||
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18791",
|
||||
undefined,
|
||||
expect.objectContaining({ profile: "chrome" }),
|
||||
);
|
||||
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
|
||||
|
||||
@@ -55,9 +55,8 @@ function isBrowserNode(node: NodeListNode) {
|
||||
|
||||
async function resolveBrowserNodeTarget(params: {
|
||||
requestedNode?: string;
|
||||
target?: "sandbox" | "host" | "custom" | "node";
|
||||
controlUrl?: string;
|
||||
defaultControlUrl?: string;
|
||||
target?: "sandbox" | "host" | "node";
|
||||
sandboxBridgeUrl?: string;
|
||||
}): Promise<BrowserNodeTarget | null> {
|
||||
const cfg = loadConfig();
|
||||
const policy = cfg.gateway?.nodes?.browser;
|
||||
@@ -68,10 +67,9 @@ async function resolveBrowserNodeTarget(params: {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (params.defaultControlUrl?.trim() && params.target !== "node" && !params.requestedNode) {
|
||||
if (params.sandboxBridgeUrl?.trim() && params.target !== "node" && !params.requestedNode) {
|
||||
return null;
|
||||
}
|
||||
if (params.controlUrl?.trim()) return null;
|
||||
if (params.target && params.target !== "node") return null;
|
||||
if (mode === "manual" && params.target !== "node" && !params.requestedNode) {
|
||||
return null;
|
||||
@@ -187,70 +185,22 @@ function applyProxyPaths(result: unknown, mapping: Map<string, string>) {
|
||||
}
|
||||
|
||||
function resolveBrowserBaseUrl(params: {
|
||||
target?: "sandbox" | "host" | "custom";
|
||||
controlUrl?: string;
|
||||
defaultControlUrl?: string;
|
||||
target?: "sandbox" | "host";
|
||||
sandboxBridgeUrl?: string;
|
||||
allowHostControl?: boolean;
|
||||
allowedControlUrls?: string[];
|
||||
allowedControlHosts?: string[];
|
||||
allowedControlPorts?: number[];
|
||||
}) {
|
||||
}): string | undefined {
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser);
|
||||
const normalizedControlUrl = params.controlUrl?.trim() ?? "";
|
||||
const normalizedDefault = params.defaultControlUrl?.trim() ?? "";
|
||||
const target =
|
||||
params.target ?? (normalizedControlUrl ? "custom" : normalizedDefault ? "sandbox" : "host");
|
||||
|
||||
const assertAllowedControlUrl = (url: string) => {
|
||||
const allowedUrls = params.allowedControlUrls?.map((entry) => entry.trim().replace(/\/$/, ""));
|
||||
const allowedHosts = params.allowedControlHosts?.map((entry) => entry.trim().toLowerCase());
|
||||
const allowedPorts = params.allowedControlPorts;
|
||||
if (!allowedUrls?.length && !allowedHosts?.length && !allowedPorts?.length) {
|
||||
return;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Invalid browser controlUrl: ${url}`);
|
||||
}
|
||||
const normalizedUrl = parsed.toString().replace(/\/$/, "");
|
||||
if (allowedUrls?.length && !allowedUrls.includes(normalizedUrl)) {
|
||||
throw new Error("Browser controlUrl is not in the allowed URL list.");
|
||||
}
|
||||
if (allowedHosts?.length && !allowedHosts.includes(parsed.hostname)) {
|
||||
throw new Error("Browser controlUrl hostname is not in the allowed host list.");
|
||||
}
|
||||
if (allowedPorts?.length) {
|
||||
const port =
|
||||
parsed.port?.trim() !== "" ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80;
|
||||
if (!Number.isFinite(port) || !allowedPorts.includes(port)) {
|
||||
throw new Error("Browser controlUrl port is not in the allowed port list.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (target !== "custom" && params.target && normalizedControlUrl) {
|
||||
throw new Error('controlUrl is only supported with target="custom".');
|
||||
}
|
||||
|
||||
if (target === "custom") {
|
||||
if (!normalizedControlUrl) {
|
||||
throw new Error("Custom browser target requires controlUrl.");
|
||||
}
|
||||
const normalized = normalizedControlUrl.replace(/\/$/, "");
|
||||
assertAllowedControlUrl(normalized);
|
||||
return normalized;
|
||||
}
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
const normalizedSandbox = params.sandboxBridgeUrl?.trim() ?? "";
|
||||
const target = params.target ?? (normalizedSandbox ? "sandbox" : "host");
|
||||
|
||||
if (target === "sandbox") {
|
||||
if (!normalizedDefault) {
|
||||
if (!normalizedSandbox) {
|
||||
throw new Error(
|
||||
'Sandbox browser is unavailable. Enable agents.defaults.sandbox.browser.enabled or use target="host" if allowed.',
|
||||
);
|
||||
}
|
||||
return normalizedDefault.replace(/\/$/, "");
|
||||
return normalizedSandbox.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
if (params.allowHostControl === false) {
|
||||
@@ -261,27 +211,16 @@ function resolveBrowserBaseUrl(params: {
|
||||
"Browser control is disabled. Set browser.enabled=true in ~/.clawdbot/clawdbot.json.",
|
||||
);
|
||||
}
|
||||
const normalized = resolved.controlUrl.replace(/\/$/, "");
|
||||
assertAllowedControlUrl(normalized);
|
||||
return normalized;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createBrowserTool(opts?: {
|
||||
defaultControlUrl?: string;
|
||||
sandboxBridgeUrl?: string;
|
||||
allowHostControl?: boolean;
|
||||
allowedControlUrls?: string[];
|
||||
allowedControlHosts?: string[];
|
||||
allowedControlPorts?: number[];
|
||||
}): AnyAgentTool {
|
||||
const targetDefault = opts?.defaultControlUrl ? "sandbox" : "host";
|
||||
const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host";
|
||||
const hostHint =
|
||||
opts?.allowHostControl === false ? "Host target blocked by policy." : "Host target allowed.";
|
||||
const allowlistHint =
|
||||
opts?.allowedControlUrls?.length ||
|
||||
opts?.allowedControlHosts?.length ||
|
||||
opts?.allowedControlPorts?.length
|
||||
? "Custom targets are restricted by sandbox allowlists."
|
||||
: "Custom targets are unrestricted.";
|
||||
return {
|
||||
label: "Browser",
|
||||
name: "browser",
|
||||
@@ -294,33 +233,22 @@ export function createBrowserTool(opts?: {
|
||||
"When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).",
|
||||
'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.',
|
||||
"Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
|
||||
`target selects browser location (sandbox|host|custom|node). Default: ${targetDefault}.`,
|
||||
"controlUrl implies target=custom (remote control server).",
|
||||
`target selects browser location (sandbox|host|node). Default: ${targetDefault}.`,
|
||||
hostHint,
|
||||
allowlistHint,
|
||||
].join(" "),
|
||||
parameters: BrowserToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
const action = readStringParam(params, "action", { required: true });
|
||||
const controlUrl = readStringParam(params, "controlUrl");
|
||||
const profile = readStringParam(params, "profile");
|
||||
const requestedNode = readStringParam(params, "node");
|
||||
let target = readStringParam(params, "target") as
|
||||
| "sandbox"
|
||||
| "host"
|
||||
| "custom"
|
||||
| "node"
|
||||
| undefined;
|
||||
let target = readStringParam(params, "target") as "sandbox" | "host" | "node" | undefined;
|
||||
|
||||
if (controlUrl?.trim() && (target === "node" || requestedNode)) {
|
||||
throw new Error('controlUrl is not supported with target="node".');
|
||||
}
|
||||
if (target === "custom" && requestedNode) {
|
||||
throw new Error('node is not supported with target="custom".');
|
||||
if (requestedNode && target && target !== "node") {
|
||||
throw new Error('node is only supported with target="node".');
|
||||
}
|
||||
|
||||
if (!target && !controlUrl?.trim() && !requestedNode && profile === "chrome") {
|
||||
if (!target && !requestedNode && profile === "chrome") {
|
||||
// Chrome extension relay takeover is a host Chrome feature; prefer host unless explicitly targeting a node.
|
||||
target = "host";
|
||||
}
|
||||
@@ -328,21 +256,16 @@ export function createBrowserTool(opts?: {
|
||||
const nodeTarget = await resolveBrowserNodeTarget({
|
||||
requestedNode: requestedNode ?? undefined,
|
||||
target,
|
||||
controlUrl,
|
||||
defaultControlUrl: opts?.defaultControlUrl,
|
||||
sandboxBridgeUrl: opts?.sandboxBridgeUrl,
|
||||
});
|
||||
|
||||
const resolvedTarget = target === "node" ? undefined : target;
|
||||
const baseUrl = nodeTarget
|
||||
? ""
|
||||
? undefined
|
||||
: resolveBrowserBaseUrl({
|
||||
target: resolvedTarget,
|
||||
controlUrl,
|
||||
defaultControlUrl: opts?.defaultControlUrl,
|
||||
sandboxBridgeUrl: opts?.sandboxBridgeUrl,
|
||||
allowHostControl: opts?.allowHostControl,
|
||||
allowedControlUrls: opts?.allowedControlUrls,
|
||||
allowedControlHosts: opts?.allowedControlHosts,
|
||||
allowedControlPorts: opts?.allowedControlPorts,
|
||||
});
|
||||
|
||||
const proxyRequest = nodeTarget
|
||||
|
||||
@@ -4,6 +4,7 @@ import express from "express";
|
||||
|
||||
import type { ResolvedBrowserConfig } from "./config.js";
|
||||
import { registerBrowserRoutes } from "./routes/index.js";
|
||||
import type { BrowserRouteRegistrar } from "./routes/types.js";
|
||||
import {
|
||||
type BrowserServerState,
|
||||
createBrowserRouteContext,
|
||||
@@ -50,7 +51,7 @@ export async function startBrowserBridgeServer(params: {
|
||||
getState: () => state,
|
||||
onEnsureAttachTarget: params.onEnsureAttachTarget,
|
||||
});
|
||||
registerBrowserRoutes(app, ctx);
|
||||
registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx);
|
||||
|
||||
const server = await new Promise<Server>((resolve, reject) => {
|
||||
const s = app.listen(port, host, () => resolve(s));
|
||||
@@ -61,11 +62,9 @@ export async function startBrowserBridgeServer(params: {
|
||||
const resolvedPort = address?.port ?? port;
|
||||
state.server = server;
|
||||
state.port = resolvedPort;
|
||||
state.resolved.controlHost = host;
|
||||
state.resolved.controlPort = resolvedPort;
|
||||
state.resolved.controlUrl = `http://${host}:${resolvedPort}`;
|
||||
|
||||
const baseUrl = state.resolved.controlUrl;
|
||||
const baseUrl = `http://${host}:${resolvedPort}`;
|
||||
return { server, port: resolvedPort, baseUrl, state };
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@ function buildProfileQuery(profile?: string): string {
|
||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||
}
|
||||
|
||||
function withBaseUrl(baseUrl: string | undefined, path: string): string {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) return path;
|
||||
return `${trimmed.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
|
||||
export type BrowserFormField = {
|
||||
ref: string;
|
||||
type: string;
|
||||
@@ -92,11 +98,15 @@ export type BrowserDownloadPayload = {
|
||||
};
|
||||
|
||||
export async function browserNavigate(
|
||||
baseUrl: string,
|
||||
opts: { url: string; targetId?: string; profile?: string },
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
url: string;
|
||||
targetId?: string;
|
||||
profile?: string;
|
||||
},
|
||||
): Promise<BrowserActionTabResult> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/navigate${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionTabResult>(withBaseUrl(baseUrl, `/navigate${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: opts.url, targetId: opts.targetId }),
|
||||
@@ -105,7 +115,7 @@ export async function browserNavigate(
|
||||
}
|
||||
|
||||
export async function browserArmDialog(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
accept: boolean;
|
||||
promptText?: string;
|
||||
@@ -115,7 +125,7 @@ export async function browserArmDialog(
|
||||
},
|
||||
): Promise<BrowserActionOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/hooks/dialog${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/hooks/dialog${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -129,7 +139,7 @@ export async function browserArmDialog(
|
||||
}
|
||||
|
||||
export async function browserArmFileChooser(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
paths: string[];
|
||||
ref?: string;
|
||||
@@ -141,7 +151,7 @@ export async function browserArmFileChooser(
|
||||
},
|
||||
): Promise<BrowserActionOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/hooks/file-chooser${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/hooks/file-chooser${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -157,7 +167,7 @@ export async function browserArmFileChooser(
|
||||
}
|
||||
|
||||
export async function browserWaitForDownload(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
path?: string;
|
||||
targetId?: string;
|
||||
@@ -170,7 +180,7 @@ export async function browserWaitForDownload(
|
||||
ok: true;
|
||||
targetId: string;
|
||||
download: BrowserDownloadPayload;
|
||||
}>(`${baseUrl}/wait/download${q}`, {
|
||||
}>(withBaseUrl(baseUrl, `/wait/download${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -183,7 +193,7 @@ export async function browserWaitForDownload(
|
||||
}
|
||||
|
||||
export async function browserDownload(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
ref: string;
|
||||
path: string;
|
||||
@@ -197,7 +207,7 @@ export async function browserDownload(
|
||||
ok: true;
|
||||
targetId: string;
|
||||
download: BrowserDownloadPayload;
|
||||
}>(`${baseUrl}/download${q}`, {
|
||||
}>(withBaseUrl(baseUrl, `/download${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -211,12 +221,12 @@ export async function browserDownload(
|
||||
}
|
||||
|
||||
export async function browserAct(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
req: BrowserActRequest,
|
||||
opts?: { profile?: string },
|
||||
): Promise<BrowserActResponse> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
return await fetchBrowserJson<BrowserActResponse>(`${baseUrl}/act${q}`, {
|
||||
return await fetchBrowserJson<BrowserActResponse>(withBaseUrl(baseUrl, `/act${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(req),
|
||||
@@ -225,7 +235,7 @@ export async function browserAct(
|
||||
}
|
||||
|
||||
export async function browserScreenshotAction(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
targetId?: string;
|
||||
fullPage?: boolean;
|
||||
@@ -236,7 +246,7 @@ export async function browserScreenshotAction(
|
||||
},
|
||||
): Promise<BrowserActionPathResult> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/screenshot${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/screenshot${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -10,8 +10,14 @@ function buildProfileQuery(profile?: string): string {
|
||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||
}
|
||||
|
||||
function withBaseUrl(baseUrl: string | undefined, path: string): string {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) return path;
|
||||
return `${trimmed.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
|
||||
export async function browserConsoleMessages(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { level?: string; targetId?: string; profile?: string } = {},
|
||||
): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string }> {
|
||||
const q = new URLSearchParams();
|
||||
@@ -23,15 +29,15 @@ export async function browserConsoleMessages(
|
||||
ok: true;
|
||||
messages: BrowserConsoleMessage[];
|
||||
targetId: string;
|
||||
}>(`${baseUrl}/console${suffix}`, { timeoutMs: 20000 });
|
||||
}>(withBaseUrl(baseUrl, `/console${suffix}`), { timeoutMs: 20000 });
|
||||
}
|
||||
|
||||
export async function browserPdfSave(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { targetId?: string; profile?: string } = {},
|
||||
): Promise<BrowserActionPathResult> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/pdf${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/pdf${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId }),
|
||||
@@ -40,7 +46,7 @@ export async function browserPdfSave(
|
||||
}
|
||||
|
||||
export async function browserPageErrors(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { targetId?: string; clear?: boolean; profile?: string } = {},
|
||||
): Promise<{ ok: true; targetId: string; errors: BrowserPageError[] }> {
|
||||
const q = new URLSearchParams();
|
||||
@@ -52,11 +58,11 @@ export async function browserPageErrors(
|
||||
ok: true;
|
||||
targetId: string;
|
||||
errors: BrowserPageError[];
|
||||
}>(`${baseUrl}/errors${suffix}`, { timeoutMs: 20000 });
|
||||
}>(withBaseUrl(baseUrl, `/errors${suffix}`), { timeoutMs: 20000 });
|
||||
}
|
||||
|
||||
export async function browserRequests(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
targetId?: string;
|
||||
filter?: string;
|
||||
@@ -74,11 +80,11 @@ export async function browserRequests(
|
||||
ok: true;
|
||||
targetId: string;
|
||||
requests: BrowserNetworkRequest[];
|
||||
}>(`${baseUrl}/requests${suffix}`, { timeoutMs: 20000 });
|
||||
}>(withBaseUrl(baseUrl, `/requests${suffix}`), { timeoutMs: 20000 });
|
||||
}
|
||||
|
||||
export async function browserTraceStart(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
targetId?: string;
|
||||
screenshots?: boolean;
|
||||
@@ -88,7 +94,7 @@ export async function browserTraceStart(
|
||||
} = {},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/trace/start${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/trace/start${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -102,11 +108,11 @@ export async function browserTraceStart(
|
||||
}
|
||||
|
||||
export async function browserTraceStop(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { targetId?: string; path?: string; profile?: string } = {},
|
||||
): Promise<BrowserActionPathResult> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/trace/stop${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/trace/stop${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId, path: opts.path }),
|
||||
@@ -115,11 +121,11 @@ export async function browserTraceStop(
|
||||
}
|
||||
|
||||
export async function browserHighlight(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { ref: string; targetId?: string; profile?: string },
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/highlight${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/highlight${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId, ref: opts.ref }),
|
||||
@@ -128,7 +134,7 @@ export async function browserHighlight(
|
||||
}
|
||||
|
||||
export async function browserResponseBody(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
url: string;
|
||||
targetId?: string;
|
||||
@@ -158,7 +164,7 @@ export async function browserResponseBody(
|
||||
body: string;
|
||||
truncated?: boolean;
|
||||
};
|
||||
}>(`${baseUrl}/response/body${q}`, {
|
||||
}>(withBaseUrl(baseUrl, `/response/body${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -5,8 +5,14 @@ function buildProfileQuery(profile?: string): string {
|
||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||
}
|
||||
|
||||
function withBaseUrl(baseUrl: string | undefined, path: string): string {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) return path;
|
||||
return `${trimmed.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
|
||||
export async function browserCookies(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { targetId?: string; profile?: string } = {},
|
||||
): Promise<{ ok: true; targetId: string; cookies: unknown[] }> {
|
||||
const q = new URLSearchParams();
|
||||
@@ -17,11 +23,11 @@ export async function browserCookies(
|
||||
ok: true;
|
||||
targetId: string;
|
||||
cookies: unknown[];
|
||||
}>(`${baseUrl}/cookies${suffix}`, { timeoutMs: 20000 });
|
||||
}>(withBaseUrl(baseUrl, `/cookies${suffix}`), { timeoutMs: 20000 });
|
||||
}
|
||||
|
||||
export async function browserCookiesSet(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
cookie: Record<string, unknown>;
|
||||
targetId?: string;
|
||||
@@ -29,7 +35,7 @@ export async function browserCookiesSet(
|
||||
},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/cookies/set${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/cookies/set${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId, cookie: opts.cookie }),
|
||||
@@ -38,11 +44,11 @@ export async function browserCookiesSet(
|
||||
}
|
||||
|
||||
export async function browserCookiesClear(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { targetId?: string; profile?: string } = {},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/cookies/clear${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/cookies/clear${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId }),
|
||||
@@ -51,7 +57,7 @@ export async function browserCookiesClear(
|
||||
}
|
||||
|
||||
export async function browserStorageGet(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
kind: "local" | "session";
|
||||
key?: string;
|
||||
@@ -68,11 +74,11 @@ export async function browserStorageGet(
|
||||
ok: true;
|
||||
targetId: string;
|
||||
values: Record<string, string>;
|
||||
}>(`${baseUrl}/storage/${opts.kind}${suffix}`, { timeoutMs: 20000 });
|
||||
}>(withBaseUrl(baseUrl, `/storage/${opts.kind}${suffix}`), { timeoutMs: 20000 });
|
||||
}
|
||||
|
||||
export async function browserStorageSet(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
kind: "local" | "session";
|
||||
key: string;
|
||||
@@ -82,25 +88,28 @@ export async function browserStorageSet(
|
||||
},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/storage/${opts.kind}/set${q}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
targetId: opts.targetId,
|
||||
key: opts.key,
|
||||
value: opts.value,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||
withBaseUrl(baseUrl, `/storage/${opts.kind}/set${q}`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
targetId: opts.targetId,
|
||||
key: opts.key,
|
||||
value: opts.value,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserStorageClear(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { kind: "local" | "session"; targetId?: string; profile?: string },
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||
`${baseUrl}/storage/${opts.kind}/clear${q}`,
|
||||
withBaseUrl(baseUrl, `/storage/${opts.kind}/clear${q}`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -111,11 +120,11 @@ export async function browserStorageClear(
|
||||
}
|
||||
|
||||
export async function browserSetOffline(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { offline: boolean; targetId?: string; profile?: string },
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/offline${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/offline${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId, offline: opts.offline }),
|
||||
@@ -124,7 +133,7 @@ export async function browserSetOffline(
|
||||
}
|
||||
|
||||
export async function browserSetHeaders(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
headers: Record<string, string>;
|
||||
targetId?: string;
|
||||
@@ -132,7 +141,7 @@ export async function browserSetHeaders(
|
||||
},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/headers${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/headers${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId, headers: opts.headers }),
|
||||
@@ -141,7 +150,7 @@ export async function browserSetHeaders(
|
||||
}
|
||||
|
||||
export async function browserSetHttpCredentials(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
@@ -151,21 +160,24 @@ export async function browserSetHttpCredentials(
|
||||
} = {},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/credentials${q}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
targetId: opts.targetId,
|
||||
username: opts.username,
|
||||
password: opts.password,
|
||||
clear: opts.clear,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||
withBaseUrl(baseUrl, `/set/credentials${q}`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
targetId: opts.targetId,
|
||||
username: opts.username,
|
||||
password: opts.password,
|
||||
clear: opts.clear,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserSetGeolocation(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
@@ -177,23 +189,26 @@ export async function browserSetGeolocation(
|
||||
} = {},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/geolocation${q}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
targetId: opts.targetId,
|
||||
latitude: opts.latitude,
|
||||
longitude: opts.longitude,
|
||||
accuracy: opts.accuracy,
|
||||
origin: opts.origin,
|
||||
clear: opts.clear,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||
withBaseUrl(baseUrl, `/set/geolocation${q}`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
targetId: opts.targetId,
|
||||
latitude: opts.latitude,
|
||||
longitude: opts.longitude,
|
||||
accuracy: opts.accuracy,
|
||||
origin: opts.origin,
|
||||
clear: opts.clear,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserSetMedia(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
colorScheme: "dark" | "light" | "no-preference" | "none";
|
||||
targetId?: string;
|
||||
@@ -201,7 +216,7 @@ export async function browserSetMedia(
|
||||
},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/media${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/media${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -213,11 +228,11 @@ export async function browserSetMedia(
|
||||
}
|
||||
|
||||
export async function browserSetTimezone(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { timezoneId: string; targetId?: string; profile?: string },
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/timezone${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/timezone${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -229,11 +244,11 @@ export async function browserSetTimezone(
|
||||
}
|
||||
|
||||
export async function browserSetLocale(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { locale: string; targetId?: string; profile?: string },
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/locale${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/locale${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId, locale: opts.locale }),
|
||||
@@ -242,11 +257,11 @@ export async function browserSetLocale(
|
||||
}
|
||||
|
||||
export async function browserSetDevice(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { name: string; targetId?: string; profile?: string },
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/device${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/device${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId, name: opts.name }),
|
||||
@@ -255,11 +270,11 @@ export async function browserSetDevice(
|
||||
}
|
||||
|
||||
export async function browserClearPermissions(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { targetId?: string; profile?: string } = {},
|
||||
): Promise<BrowserActionOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/set/geolocation${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/set/geolocation${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId, clear: true }),
|
||||
|
||||
+80
-67
@@ -1,57 +1,44 @@
|
||||
import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { resolveBrowserConfig } from "./config.js";
|
||||
import {
|
||||
createBrowserControlContext,
|
||||
startBrowserControlServiceFromConfig,
|
||||
} from "./control-service.js";
|
||||
import { createBrowserRouteDispatcher } from "./routes/dispatcher.js";
|
||||
|
||||
let cachedConfigToken: string | null | undefined = undefined;
|
||||
|
||||
function getBrowserControlToken(): string | null {
|
||||
const env = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim();
|
||||
if (env) return env;
|
||||
|
||||
if (cachedConfigToken !== undefined) return cachedConfigToken;
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser);
|
||||
const token = resolved.controlToken?.trim() || "";
|
||||
cachedConfigToken = token ? token : null;
|
||||
} catch {
|
||||
cachedConfigToken = null;
|
||||
}
|
||||
return cachedConfigToken;
|
||||
}
|
||||
|
||||
function unwrapCause(err: unknown): unknown {
|
||||
if (!err || typeof err !== "object") return null;
|
||||
const cause = (err as { cause?: unknown }).cause;
|
||||
return cause ?? null;
|
||||
function isAbsoluteHttp(url: string): boolean {
|
||||
return /^https?:\/\//i.test(url.trim());
|
||||
}
|
||||
|
||||
function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error {
|
||||
const cause = unwrapCause(err);
|
||||
const code = extractErrorCode(cause) ?? extractErrorCode(err) ?? "";
|
||||
|
||||
const hint = `Start (or restart) the Clawdbot gateway (Clawdbot.app menubar, or \`${formatCliCommand("clawdbot gateway")}\`) and try again.`;
|
||||
|
||||
if (code === "ECONNREFUSED") {
|
||||
const hint = isAbsoluteHttp(url)
|
||||
? "If this is a sandboxed session, ensure the sandbox browser is running and try again."
|
||||
: `Start (or restart) the Clawdbot gateway (Clawdbot.app menubar, or \`${formatCliCommand("clawdbot gateway")}\`) and try again.`;
|
||||
const msg = String(err);
|
||||
if (msg.toLowerCase().includes("timed out") || msg.toLowerCase().includes("timeout")) {
|
||||
return new Error(
|
||||
`Can't reach the clawd browser control server at ${url} (connection refused). ${hint}`,
|
||||
);
|
||||
}
|
||||
if (code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||
return new Error(
|
||||
`Can't reach the clawd browser control server at ${url} (timed out after ${timeoutMs}ms). ${hint}`,
|
||||
`Can't reach the clawd browser control service (timed out after ${timeoutMs}ms). ${hint}`,
|
||||
);
|
||||
}
|
||||
return new Error(`Can't reach the clawd browser control service. ${hint} (${msg})`);
|
||||
}
|
||||
|
||||
const msg = formatErrorMessage(err);
|
||||
if (msg.toLowerCase().includes("abort")) {
|
||||
return new Error(
|
||||
`Can't reach the clawd browser control server at ${url} (timed out after ${timeoutMs}ms). ${hint}`,
|
||||
);
|
||||
async function fetchHttpJson<T>(
|
||||
url: string,
|
||||
init: RequestInit & { timeoutMs?: number },
|
||||
): Promise<T> {
|
||||
const timeoutMs = init.timeoutMs ?? 5000;
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal: ctrl.signal });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
|
||||
return new Error(`Can't reach the clawd browser control server at ${url}. ${hint} (${msg})`);
|
||||
}
|
||||
|
||||
export async function fetchBrowserJson<T>(
|
||||
@@ -59,32 +46,58 @@ export async function fetchBrowserJson<T>(
|
||||
init?: RequestInit & { timeoutMs?: number },
|
||||
): Promise<T> {
|
||||
const timeoutMs = init?.timeoutMs ?? 5000;
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
let res: Response;
|
||||
try {
|
||||
const token = getBrowserControlToken();
|
||||
const mergedHeaders = (() => {
|
||||
if (!token) return init?.headers;
|
||||
const h = new Headers(init?.headers ?? {});
|
||||
if (!h.has("Authorization")) {
|
||||
h.set("Authorization", `Bearer ${token}`);
|
||||
if (isAbsoluteHttp(url)) {
|
||||
return await fetchHttpJson<T>(url, { ...(init ?? {}), timeoutMs });
|
||||
}
|
||||
const started = await startBrowserControlServiceFromConfig();
|
||||
if (!started) {
|
||||
throw new Error("browser control disabled");
|
||||
}
|
||||
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
|
||||
const parsed = new URL(url, "http://localhost");
|
||||
const query: Record<string, unknown> = {};
|
||||
for (const [key, value] of parsed.searchParams.entries()) {
|
||||
query[key] = value;
|
||||
}
|
||||
let body = init?.body;
|
||||
if (typeof body === "string") {
|
||||
try {
|
||||
body = JSON.parse(body);
|
||||
} catch {
|
||||
// keep as string
|
||||
}
|
||||
return h;
|
||||
})();
|
||||
res = await fetch(url, {
|
||||
...init,
|
||||
...(mergedHeaders ? { headers: mergedHeaders } : {}),
|
||||
signal: ctrl.signal,
|
||||
} as RequestInit);
|
||||
}
|
||||
const dispatchPromise = dispatcher.dispatch({
|
||||
method:
|
||||
init?.method?.toUpperCase() === "DELETE"
|
||||
? "DELETE"
|
||||
: init?.method?.toUpperCase() === "POST"
|
||||
? "POST"
|
||||
: "GET",
|
||||
path: parsed.pathname,
|
||||
query,
|
||||
body,
|
||||
});
|
||||
|
||||
const result = await (timeoutMs
|
||||
? Promise.race([
|
||||
dispatchPromise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("timed out")), timeoutMs),
|
||||
),
|
||||
])
|
||||
: dispatchPromise);
|
||||
|
||||
if (result.status >= 400) {
|
||||
const message =
|
||||
result.body && typeof result.body === "object" && "error" in result.body
|
||||
? String((result.body as { error?: unknown }).error)
|
||||
: `HTTP ${result.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return result.body as T;
|
||||
} catch (err) {
|
||||
throw enhanceBrowserFetchError(url, err, timeoutMs);
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(text ? `${res.status}: ${text}` : `HTTP ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("browser client", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("wraps connection failures with a gateway hint", async () => {
|
||||
it("wraps connection failures with a sandbox hint", async () => {
|
||||
const refused = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1"), {
|
||||
code: "ECONNREFUSED",
|
||||
});
|
||||
@@ -26,7 +26,7 @@ describe("browser client", () => {
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(fetchFailed));
|
||||
|
||||
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/Start .*gateway/i);
|
||||
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/sandboxed session/i);
|
||||
});
|
||||
|
||||
it("adds useful timeout messaging for abort-like failures", async () => {
|
||||
@@ -34,41 +34,6 @@ describe("browser client", () => {
|
||||
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/timed out/i);
|
||||
});
|
||||
|
||||
it("adds Authorization when CLAWDBOT_BROWSER_CONTROL_TOKEN is set", async () => {
|
||||
const prev = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
|
||||
process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = "t1";
|
||||
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
running: false,
|
||||
pid: null,
|
||||
cdpPort: 18792,
|
||||
chosenBrowser: null,
|
||||
userDataDir: null,
|
||||
color: "#FF0000",
|
||||
headless: true,
|
||||
attachOnly: false,
|
||||
}),
|
||||
} as unknown as Response;
|
||||
}),
|
||||
);
|
||||
|
||||
await browserStatus("http://127.0.0.1:18791");
|
||||
const init = calls[0]?.init;
|
||||
const auth = new Headers(init?.headers ?? {}).get("Authorization");
|
||||
expect(auth).toBe("Bearer t1");
|
||||
|
||||
process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = prev;
|
||||
});
|
||||
|
||||
it("surfaces non-2xx responses with body text", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -81,7 +46,7 @@ describe("browser client", () => {
|
||||
|
||||
await expect(
|
||||
browserSnapshot("http://127.0.0.1:18791", { format: "aria", limit: 1 }),
|
||||
).rejects.toThrow(/409: conflict/i);
|
||||
).rejects.toThrow(/conflict/i);
|
||||
});
|
||||
|
||||
it("adds labels + efficient mode query params to snapshots", async () => {
|
||||
@@ -255,7 +220,6 @@ describe("browser client", () => {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
running: true,
|
||||
pid: 1,
|
||||
cdpPort: 18792,
|
||||
|
||||
+56
-51
@@ -1,10 +1,7 @@
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { fetchBrowserJson } from "./client-fetch.js";
|
||||
import { resolveBrowserConfig } from "./config.js";
|
||||
|
||||
export type BrowserStatus = {
|
||||
enabled: boolean;
|
||||
controlUrl: string;
|
||||
profile?: string;
|
||||
running: boolean;
|
||||
cdpReady?: boolean;
|
||||
@@ -89,59 +86,64 @@ export type SnapshotResult =
|
||||
imageType?: "png" | "jpeg";
|
||||
};
|
||||
|
||||
export function resolveBrowserControlUrl(overrideUrl?: string) {
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser);
|
||||
const url = overrideUrl?.trim() ? overrideUrl.trim() : resolved.controlUrl;
|
||||
return url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function buildProfileQuery(profile?: string): string {
|
||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||
}
|
||||
|
||||
function withBaseUrl(baseUrl: string | undefined, path: string): string {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) return path;
|
||||
return `${trimmed.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
|
||||
export async function browserStatus(
|
||||
baseUrl: string,
|
||||
baseUrl?: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<BrowserStatus> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
return await fetchBrowserJson<BrowserStatus>(`${baseUrl}/${q}`, {
|
||||
return await fetchBrowserJson<BrowserStatus>(withBaseUrl(baseUrl, `/${q}`), {
|
||||
timeoutMs: 1500,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserProfiles(baseUrl: string): Promise<ProfileStatus[]> {
|
||||
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(`${baseUrl}/profiles`, {
|
||||
timeoutMs: 3000,
|
||||
});
|
||||
export async function browserProfiles(baseUrl?: string): Promise<ProfileStatus[]> {
|
||||
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(
|
||||
withBaseUrl(baseUrl, `/profiles`),
|
||||
{
|
||||
timeoutMs: 3000,
|
||||
},
|
||||
);
|
||||
return res.profiles ?? [];
|
||||
}
|
||||
|
||||
export async function browserStart(baseUrl: string, opts?: { profile?: string }): Promise<void> {
|
||||
export async function browserStart(baseUrl?: string, opts?: { profile?: string }): Promise<void> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
await fetchBrowserJson(`${baseUrl}/start${q}`, {
|
||||
await fetchBrowserJson(withBaseUrl(baseUrl, `/start${q}`), {
|
||||
method: "POST",
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserStop(baseUrl: string, opts?: { profile?: string }): Promise<void> {
|
||||
export async function browserStop(baseUrl?: string, opts?: { profile?: string }): Promise<void> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
await fetchBrowserJson(`${baseUrl}/stop${q}`, {
|
||||
await fetchBrowserJson(withBaseUrl(baseUrl, `/stop${q}`), {
|
||||
method: "POST",
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserResetProfile(
|
||||
baseUrl: string,
|
||||
baseUrl?: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<BrowserResetProfileResult> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
return await fetchBrowserJson<BrowserResetProfileResult>(`${baseUrl}/reset-profile${q}`, {
|
||||
method: "POST",
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
return await fetchBrowserJson<BrowserResetProfileResult>(
|
||||
withBaseUrl(baseUrl, `/reset-profile${q}`),
|
||||
{
|
||||
method: "POST",
|
||||
timeoutMs: 20000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type BrowserCreateProfileResult = {
|
||||
@@ -154,7 +156,7 @@ export type BrowserCreateProfileResult = {
|
||||
};
|
||||
|
||||
export async function browserCreateProfile(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
name: string;
|
||||
color?: string;
|
||||
@@ -162,17 +164,20 @@ export async function browserCreateProfile(
|
||||
driver?: "clawd" | "extension";
|
||||
},
|
||||
): Promise<BrowserCreateProfileResult> {
|
||||
return await fetchBrowserJson<BrowserCreateProfileResult>(`${baseUrl}/profiles/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: opts.name,
|
||||
color: opts.color,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
driver: opts.driver,
|
||||
}),
|
||||
timeoutMs: 10000,
|
||||
});
|
||||
return await fetchBrowserJson<BrowserCreateProfileResult>(
|
||||
withBaseUrl(baseUrl, `/profiles/create`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: opts.name,
|
||||
color: opts.color,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
driver: opts.driver,
|
||||
}),
|
||||
timeoutMs: 10000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type BrowserDeleteProfileResult = {
|
||||
@@ -182,11 +187,11 @@ export type BrowserDeleteProfileResult = {
|
||||
};
|
||||
|
||||
export async function browserDeleteProfile(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
profile: string,
|
||||
): Promise<BrowserDeleteProfileResult> {
|
||||
return await fetchBrowserJson<BrowserDeleteProfileResult>(
|
||||
`${baseUrl}/profiles/${encodeURIComponent(profile)}`,
|
||||
withBaseUrl(baseUrl, `/profiles/${encodeURIComponent(profile)}`),
|
||||
{
|
||||
method: "DELETE",
|
||||
timeoutMs: 20000,
|
||||
@@ -195,24 +200,24 @@ export async function browserDeleteProfile(
|
||||
}
|
||||
|
||||
export async function browserTabs(
|
||||
baseUrl: string,
|
||||
baseUrl?: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<BrowserTab[]> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>(
|
||||
`${baseUrl}/tabs${q}`,
|
||||
withBaseUrl(baseUrl, `/tabs${q}`),
|
||||
{ timeoutMs: 3000 },
|
||||
);
|
||||
return res.tabs ?? [];
|
||||
}
|
||||
|
||||
export async function browserOpenTab(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
url: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<BrowserTab> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
return await fetchBrowserJson<BrowserTab>(`${baseUrl}/tabs/open${q}`, {
|
||||
return await fetchBrowserJson<BrowserTab>(withBaseUrl(baseUrl, `/tabs/open${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
@@ -221,12 +226,12 @@ export async function browserOpenTab(
|
||||
}
|
||||
|
||||
export async function browserFocusTab(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
targetId: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<void> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
await fetchBrowserJson(`${baseUrl}/tabs/focus${q}`, {
|
||||
await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/focus${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId }),
|
||||
@@ -235,19 +240,19 @@ export async function browserFocusTab(
|
||||
}
|
||||
|
||||
export async function browserCloseTab(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
targetId: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<void> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
await fetchBrowserJson(`${baseUrl}/tabs/${encodeURIComponent(targetId)}${q}`, {
|
||||
await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/${encodeURIComponent(targetId)}${q}`), {
|
||||
method: "DELETE",
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserTabAction(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
action: "list" | "new" | "close" | "select";
|
||||
index?: number;
|
||||
@@ -255,7 +260,7 @@ export async function browserTabAction(
|
||||
},
|
||||
): Promise<unknown> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson(`${baseUrl}/tabs/action${q}`, {
|
||||
return await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/action${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -267,7 +272,7 @@ export async function browserTabAction(
|
||||
}
|
||||
|
||||
export async function browserSnapshot(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
format: "aria" | "ai";
|
||||
targetId?: string;
|
||||
@@ -301,7 +306,7 @@ export async function browserSnapshot(
|
||||
if (opts.labels === true) q.set("labels", "1");
|
||||
if (opts.mode) q.set("mode", opts.mode);
|
||||
if (opts.profile) q.set("profile", opts.profile);
|
||||
return await fetchBrowserJson<SnapshotResult>(`${baseUrl}/snapshot?${q.toString()}`, {
|
||||
return await fetchBrowserJson<SnapshotResult>(withBaseUrl(baseUrl, `/snapshot?${q.toString()}`), {
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
+31
-24
@@ -2,13 +2,14 @@ import { describe, expect, it } from "vitest";
|
||||
import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js";
|
||||
|
||||
describe("browser config", () => {
|
||||
it("defaults to enabled with loopback control url and lobster-orange color", () => {
|
||||
it("defaults to enabled with loopback defaults and lobster-orange color", () => {
|
||||
const resolved = resolveBrowserConfig(undefined);
|
||||
expect(resolved.enabled).toBe(true);
|
||||
expect(resolved.controlPort).toBe(18791);
|
||||
expect(resolved.controlHost).toBe("127.0.0.1");
|
||||
expect(resolved.color).toBe("#FF4500");
|
||||
expect(shouldStartLocalBrowserServer(resolved)).toBe(true);
|
||||
expect(resolved.cdpHost).toBe("127.0.0.1");
|
||||
expect(resolved.cdpProtocol).toBe("http");
|
||||
const profile = resolveProfile(resolved, resolved.defaultProfile);
|
||||
expect(profile?.name).toBe("chrome");
|
||||
expect(profile?.driver).toBe("extension");
|
||||
@@ -46,9 +47,31 @@ describe("browser config", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("derives default ports from gateway.port when env is unset", () => {
|
||||
const prev = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
try {
|
||||
const resolved = resolveBrowserConfig(undefined, { gateway: { port: 19011 } });
|
||||
expect(resolved.controlPort).toBe(19013);
|
||||
const chrome = resolveProfile(resolved, "chrome");
|
||||
expect(chrome?.driver).toBe("extension");
|
||||
expect(chrome?.cdpPort).toBe(19014);
|
||||
expect(chrome?.cdpUrl).toBe("http://127.0.0.1:19014");
|
||||
|
||||
const clawd = resolveProfile(resolved, "clawd");
|
||||
expect(clawd?.cdpPort).toBe(19022);
|
||||
expect(clawd?.cdpUrl).toBe("http://127.0.0.1:19022");
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes hex colors", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://localhost:18791",
|
||||
color: "ff4500",
|
||||
});
|
||||
expect(resolved.color).toBe("#FF4500");
|
||||
@@ -56,7 +79,6 @@ describe("browser config", () => {
|
||||
|
||||
it("supports custom remote CDP timeouts", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
remoteCdpTimeoutMs: 2200,
|
||||
remoteCdpHandshakeTimeoutMs: 5000,
|
||||
});
|
||||
@@ -66,31 +88,21 @@ describe("browser config", () => {
|
||||
|
||||
it("falls back to default color for invalid hex", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://localhost:18791",
|
||||
color: "#GGGGGG",
|
||||
});
|
||||
expect(resolved.color).toBe("#FF4500");
|
||||
});
|
||||
|
||||
it("treats non-loopback control urls as remote", () => {
|
||||
it("treats non-loopback cdpUrl as remote", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://example.com:18791",
|
||||
cdpUrl: "http://example.com:9222",
|
||||
});
|
||||
expect(shouldStartLocalBrowserServer(resolved)).toBe(false);
|
||||
});
|
||||
|
||||
it("derives CDP host/protocol from control url when cdpUrl is unset", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:19000",
|
||||
});
|
||||
expect(resolved.controlPort).toBe(19000);
|
||||
expect(resolved.cdpHost).toBe("127.0.0.1");
|
||||
expect(resolved.cdpProtocol).toBe("http");
|
||||
const profile = resolveProfile(resolved, "clawd");
|
||||
expect(profile?.cdpIsLoopback).toBe(false);
|
||||
});
|
||||
|
||||
it("supports explicit CDP URLs for the default profile", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
cdpUrl: "http://example.com:9222",
|
||||
});
|
||||
const profile = resolveProfile(resolved, "clawd");
|
||||
@@ -101,7 +113,6 @@ describe("browser config", () => {
|
||||
|
||||
it("uses profile cdpUrl when provided", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
profiles: {
|
||||
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#0066CC" },
|
||||
},
|
||||
@@ -115,7 +126,6 @@ describe("browser config", () => {
|
||||
|
||||
it("uses base protocol for profiles with only cdpPort", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
cdpUrl: "https://example.com:9443",
|
||||
profiles: {
|
||||
work: { cdpPort: 18801, color: "#0066CC" },
|
||||
@@ -127,14 +137,11 @@ describe("browser config", () => {
|
||||
});
|
||||
|
||||
it("rejects unsupported protocols", () => {
|
||||
expect(() => resolveBrowserConfig({ controlUrl: "ws://127.0.0.1:18791" })).toThrow(
|
||||
/must be http/i,
|
||||
);
|
||||
expect(() => resolveBrowserConfig({ cdpUrl: "ws://127.0.0.1:18791" })).toThrow(/must be http/i);
|
||||
});
|
||||
|
||||
it("does not add the built-in chrome extension profile if the derived relay port is already used", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
profiles: {
|
||||
clawd: { cdpPort: 18792, color: "#FF4500" },
|
||||
},
|
||||
|
||||
+13
-30
@@ -1,11 +1,12 @@
|
||||
import type { BrowserConfig, BrowserProfileConfig } from "../config/config.js";
|
||||
import type { BrowserConfig, BrowserProfileConfig, ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
deriveDefaultBrowserCdpPortRange,
|
||||
deriveDefaultBrowserControlPort,
|
||||
DEFAULT_BROWSER_CONTROL_PORT,
|
||||
} from "../config/port-defaults.js";
|
||||
import { resolveGatewayPort } from "../config/paths.js";
|
||||
import {
|
||||
DEFAULT_CLAWD_BROWSER_COLOR,
|
||||
DEFAULT_CLAWD_BROWSER_CONTROL_URL,
|
||||
DEFAULT_CLAWD_BROWSER_ENABLED,
|
||||
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
|
||||
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
|
||||
@@ -14,10 +15,7 @@ import { CDP_PORT_RANGE_START, getUsedPorts } from "./profiles.js";
|
||||
|
||||
export type ResolvedBrowserConfig = {
|
||||
enabled: boolean;
|
||||
controlUrl: string;
|
||||
controlHost: string;
|
||||
controlPort: number;
|
||||
controlToken?: string;
|
||||
cdpProtocol: "http" | "https";
|
||||
cdpHost: string;
|
||||
cdpIsLoopback: boolean;
|
||||
@@ -137,24 +135,13 @@ function ensureDefaultChromeExtensionProfile(
|
||||
};
|
||||
return result;
|
||||
}
|
||||
export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBrowserConfig {
|
||||
export function resolveBrowserConfig(
|
||||
cfg: BrowserConfig | undefined,
|
||||
rootConfig?: ClawdbotConfig,
|
||||
): ResolvedBrowserConfig {
|
||||
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
|
||||
const envControlUrl = process.env.CLAWDBOT_BROWSER_CONTROL_URL?.trim();
|
||||
const controlToken = cfg?.controlToken?.trim() || undefined;
|
||||
const derivedControlPort = (() => {
|
||||
const raw = process.env.CLAWDBOT_GATEWAY_PORT?.trim();
|
||||
if (!raw) return null;
|
||||
const gatewayPort = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(gatewayPort) || gatewayPort <= 0) return null;
|
||||
return deriveDefaultBrowserControlPort(gatewayPort);
|
||||
})();
|
||||
const derivedControlUrl = derivedControlPort ? `http://127.0.0.1:${derivedControlPort}` : null;
|
||||
|
||||
const controlInfo = parseHttpUrl(
|
||||
cfg?.controlUrl ?? envControlUrl ?? derivedControlUrl ?? DEFAULT_CLAWD_BROWSER_CONTROL_URL,
|
||||
"browser.controlUrl",
|
||||
);
|
||||
const controlPort = controlInfo.port;
|
||||
const gatewayPort = resolveGatewayPort(rootConfig);
|
||||
const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT);
|
||||
const defaultColor = normalizeHexColor(cfg?.color);
|
||||
const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500);
|
||||
const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs(
|
||||
@@ -178,11 +165,10 @@ export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBr
|
||||
const derivedPort = controlPort + 1;
|
||||
if (derivedPort > 65535) {
|
||||
throw new Error(
|
||||
`browser.controlUrl port (${controlPort}) is too high; cannot derive CDP port (${derivedPort})`,
|
||||
`Derived CDP port (${derivedPort}) is too high; check gateway port configuration.`,
|
||||
);
|
||||
}
|
||||
const derived = new URL(controlInfo.normalized);
|
||||
derived.port = String(derivedPort);
|
||||
const derived = new URL(`http://127.0.0.1:${derivedPort}`);
|
||||
cdpInfo = {
|
||||
parsed: derived,
|
||||
port: derivedPort,
|
||||
@@ -211,10 +197,7 @@ export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBr
|
||||
|
||||
return {
|
||||
enabled,
|
||||
controlUrl: controlInfo.normalized,
|
||||
controlHost: controlInfo.parsed.hostname,
|
||||
controlPort,
|
||||
...(controlToken ? { controlToken } : {}),
|
||||
cdpProtocol,
|
||||
cdpHost: cdpInfo.parsed.hostname,
|
||||
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
|
||||
@@ -269,6 +252,6 @@ export function resolveProfile(
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldStartLocalBrowserServer(resolved: ResolvedBrowserConfig) {
|
||||
return isLoopbackHost(resolved.controlHost);
|
||||
export function shouldStartLocalBrowserServer(_resolved: ResolvedBrowserConfig) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export const DEFAULT_CLAWD_BROWSER_ENABLED = true;
|
||||
export const DEFAULT_CLAWD_BROWSER_CONTROL_URL = "http://127.0.0.1:18791";
|
||||
export const DEFAULT_CLAWD_BROWSER_COLOR = "#FF4500";
|
||||
export const DEFAULT_CLAWD_BROWSER_PROFILE_NAME = "clawd";
|
||||
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "chrome";
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { resolveBrowserConfig, resolveProfile } from "./config.js";
|
||||
import { ensureChromeExtensionRelayServer } from "./extension-relay.js";
|
||||
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
|
||||
|
||||
let state: BrowserServerState | null = null;
|
||||
const log = createSubsystemLogger("browser");
|
||||
const logService = log.child("service");
|
||||
|
||||
export function getBrowserControlState(): BrowserServerState | null {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function createBrowserControlContext() {
|
||||
return createBrowserRouteContext({
|
||||
getState: () => state,
|
||||
});
|
||||
}
|
||||
|
||||
export async function startBrowserControlServiceFromConfig(): Promise<BrowserServerState | null> {
|
||||
if (state) return state;
|
||||
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
if (!resolved.enabled) return null;
|
||||
|
||||
state = {
|
||||
server: null,
|
||||
port: resolved.controlPort,
|
||||
resolved,
|
||||
profiles: new Map(),
|
||||
};
|
||||
|
||||
// If any profile uses the Chrome extension relay, start the local relay server eagerly
|
||||
// so the extension can connect before the first browser action.
|
||||
for (const name of Object.keys(resolved.profiles)) {
|
||||
const profile = resolveProfile(resolved, name);
|
||||
if (!profile || profile.driver !== "extension") continue;
|
||||
await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch((err) => {
|
||||
logService.warn(`Chrome extension relay init failed for profile "${name}": ${String(err)}`);
|
||||
});
|
||||
}
|
||||
|
||||
logService.info(
|
||||
`Browser control service ready (profiles=${Object.keys(resolved.profiles).length})`,
|
||||
);
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function stopBrowserControlService(): Promise<void> {
|
||||
const current = state;
|
||||
if (!current) return;
|
||||
|
||||
const ctx = createBrowserRouteContext({
|
||||
getState: () => state,
|
||||
});
|
||||
|
||||
try {
|
||||
for (const name of Object.keys(current.resolved.profiles)) {
|
||||
try {
|
||||
await ctx.forProfile(name).stopRunningBrowser();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logService.warn(`clawd browser stop failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
state = null;
|
||||
|
||||
// Optional: Playwright is not always available (e.g. embedded gateway builds).
|
||||
try {
|
||||
const mod = await import("./pw-ai.js");
|
||||
await mod.closePlaywrightBrowserConnection();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -49,9 +49,7 @@ function createCtx(resolved: BrowserServerState["resolved"]) {
|
||||
|
||||
describe("BrowserProfilesService", () => {
|
||||
it("allocates next local port for new profiles", async () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
});
|
||||
const resolved = resolveBrowserConfig({});
|
||||
const { ctx, state } = createCtx(resolved);
|
||||
|
||||
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
|
||||
@@ -66,9 +64,7 @@ describe("BrowserProfilesService", () => {
|
||||
});
|
||||
|
||||
it("accepts per-profile cdpUrl for remote Chrome", async () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
});
|
||||
const resolved = resolveBrowserConfig({});
|
||||
const { ctx } = createCtx(resolved);
|
||||
|
||||
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
|
||||
@@ -97,7 +93,6 @@ describe("BrowserProfilesService", () => {
|
||||
|
||||
it("deletes remote profiles without stopping or removing local data", async () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
profiles: {
|
||||
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#0066CC" },
|
||||
},
|
||||
@@ -124,7 +119,6 @@ describe("BrowserProfilesService", () => {
|
||||
|
||||
it("deletes local profiles and moves data to Trash", async () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
profiles: {
|
||||
work: { cdpPort: 18801, color: "#0066CC" },
|
||||
},
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserFormField } from "../client-actions-core.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import {
|
||||
@@ -16,8 +14,12 @@ import {
|
||||
SELECTOR_UNSUPPORTED_MESSAGE,
|
||||
} from "./agent.shared.js";
|
||||
import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
export function registerBrowserAgentActRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||
export function registerBrowserAgentActRoutes(
|
||||
app: BrowserRouteRegistrar,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.post("/act", async (req, res) => {
|
||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||
if (!profileCtx) return;
|
||||
|
||||
@@ -2,13 +2,15 @@ import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { handleRouteError, readBody, requirePwAi, resolveProfileContext } from "./agent.shared.js";
|
||||
import { toBoolean, toStringOrEmpty } from "./utils.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
export function registerBrowserAgentDebugRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||
export function registerBrowserAgentDebugRoutes(
|
||||
app: BrowserRouteRegistrar,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.get("/console", async (req, res) => {
|
||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||
if (!profileCtx) return;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||
import type { PwAiModule } from "../pw-ai-module.js";
|
||||
import { getPwAiModule as getPwAiModuleBase } from "../pw-ai-module.js";
|
||||
import { getProfileContext, jsonError } from "./utils.js";
|
||||
import type { BrowserRequest, BrowserResponse } from "./types.js";
|
||||
|
||||
export const SELECTOR_UNSUPPORTED_MESSAGE = [
|
||||
"Error: 'selector' is not supported. Use 'ref' from snapshot instead.",
|
||||
@@ -15,21 +14,21 @@ export const SELECTOR_UNSUPPORTED_MESSAGE = [
|
||||
"This is more reliable for modern SPAs.",
|
||||
].join("\n");
|
||||
|
||||
export function readBody(req: express.Request): Record<string, unknown> {
|
||||
export function readBody(req: BrowserRequest): Record<string, unknown> {
|
||||
const body = req.body as Record<string, unknown> | undefined;
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return {};
|
||||
return body;
|
||||
}
|
||||
|
||||
export function handleRouteError(ctx: BrowserRouteContext, res: express.Response, err: unknown) {
|
||||
export function handleRouteError(ctx: BrowserRouteContext, res: BrowserResponse, err: unknown) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
|
||||
export function resolveProfileContext(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
req: BrowserRequest,
|
||||
res: BrowserResponse,
|
||||
ctx: BrowserRouteContext,
|
||||
): ProfileContext | null {
|
||||
const profileCtx = getProfileContext(req, ctx);
|
||||
@@ -45,7 +44,7 @@ export async function getPwAiModule(): Promise<PwAiModule | null> {
|
||||
}
|
||||
|
||||
export async function requirePwAi(
|
||||
res: express.Response,
|
||||
res: BrowserResponse,
|
||||
feature: string,
|
||||
): Promise<PwAiModule | null> {
|
||||
const mod = await getPwAiModule();
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type express from "express";
|
||||
|
||||
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
|
||||
import { captureScreenshot, snapshotAria } from "../cdp.js";
|
||||
import {
|
||||
@@ -23,8 +21,12 @@ import {
|
||||
resolveProfileContext,
|
||||
} from "./agent.shared.js";
|
||||
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
export function registerBrowserAgentSnapshotRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||
export function registerBrowserAgentSnapshotRoutes(
|
||||
app: BrowserRouteRegistrar,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.post("/navigate", async (req, res) => {
|
||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||
if (!profileCtx) return;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { handleRouteError, readBody, requirePwAi, resolveProfileContext } from "./agent.shared.js";
|
||||
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
export function registerBrowserAgentStorageRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||
export function registerBrowserAgentStorageRoutes(
|
||||
app: BrowserRouteRegistrar,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.get("/cookies", async (req, res) => {
|
||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||
if (!profileCtx) return;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { registerBrowserAgentActRoutes } from "./agent.act.js";
|
||||
import { registerBrowserAgentDebugRoutes } from "./agent.debug.js";
|
||||
import { registerBrowserAgentSnapshotRoutes } from "./agent.snapshot.js";
|
||||
import { registerBrowserAgentStorageRoutes } from "./agent.storage.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
export function registerBrowserAgentRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||
export function registerBrowserAgentRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
|
||||
registerBrowserAgentSnapshotRoutes(app, ctx);
|
||||
registerBrowserAgentActRoutes(app, ctx);
|
||||
registerBrowserAgentDebugRoutes(app, ctx);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type express from "express";
|
||||
|
||||
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
|
||||
import { createBrowserProfilesService } from "../profiles-service.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
export function registerBrowserBasicRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||
export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
|
||||
// List all profiles with their status
|
||||
app.get("/profiles", async (_req, res) => {
|
||||
try {
|
||||
@@ -53,7 +52,6 @@ export function registerBrowserBasicRoutes(app: express.Express, ctx: BrowserRou
|
||||
|
||||
res.json({
|
||||
enabled: current.resolved.enabled,
|
||||
controlUrl: current.resolved.controlUrl,
|
||||
profile: profileCtx.profile.name,
|
||||
running: cdpReady,
|
||||
cdpReady,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { registerBrowserRoutes } from "./index.js";
|
||||
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
type BrowserDispatchRequest = {
|
||||
method: "GET" | "POST" | "DELETE";
|
||||
path: string;
|
||||
query?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
type BrowserDispatchResponse = {
|
||||
status: number;
|
||||
body: unknown;
|
||||
};
|
||||
|
||||
type RouteEntry = {
|
||||
method: BrowserDispatchRequest["method"];
|
||||
path: string;
|
||||
regex: RegExp;
|
||||
paramNames: string[];
|
||||
handler: (req: BrowserRequest, res: BrowserResponse) => void | Promise<void>;
|
||||
};
|
||||
|
||||
function escapeRegex(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function compileRoute(path: string): { regex: RegExp; paramNames: string[] } {
|
||||
const paramNames: string[] = [];
|
||||
const parts = path.split("/").map((part) => {
|
||||
if (part.startsWith(":")) {
|
||||
const name = part.slice(1);
|
||||
paramNames.push(name);
|
||||
return "([^/]+)";
|
||||
}
|
||||
return escapeRegex(part);
|
||||
});
|
||||
return { regex: new RegExp(`^${parts.join("/")}$`), paramNames };
|
||||
}
|
||||
|
||||
function createRegistry() {
|
||||
const routes: RouteEntry[] = [];
|
||||
const register =
|
||||
(method: RouteEntry["method"]) => (path: string, handler: RouteEntry["handler"]) => {
|
||||
const { regex, paramNames } = compileRoute(path);
|
||||
routes.push({ method, path, regex, paramNames, handler });
|
||||
};
|
||||
const router: BrowserRouteRegistrar = {
|
||||
get: register("GET"),
|
||||
post: register("POST"),
|
||||
delete: register("DELETE"),
|
||||
};
|
||||
return { routes, router };
|
||||
}
|
||||
|
||||
function normalizePath(path: string) {
|
||||
if (!path) return "/";
|
||||
return path.startsWith("/") ? path : `/${path}`;
|
||||
}
|
||||
|
||||
export function createBrowserRouteDispatcher(ctx: BrowserRouteContext) {
|
||||
const registry = createRegistry();
|
||||
registerBrowserRoutes(registry.router, ctx);
|
||||
|
||||
return {
|
||||
dispatch: async (req: BrowserDispatchRequest): Promise<BrowserDispatchResponse> => {
|
||||
const method = req.method;
|
||||
const path = normalizePath(req.path);
|
||||
const query = req.query ?? {};
|
||||
const body = req.body;
|
||||
|
||||
const match = registry.routes.find((route) => {
|
||||
if (route.method !== method) return false;
|
||||
return route.regex.test(path);
|
||||
});
|
||||
if (!match) {
|
||||
return { status: 404, body: { error: "Not Found" } };
|
||||
}
|
||||
|
||||
const exec = match.regex.exec(path);
|
||||
const params: Record<string, string> = {};
|
||||
if (exec) {
|
||||
for (const [idx, name] of match.paramNames.entries()) {
|
||||
const value = exec[idx + 1];
|
||||
if (typeof value === "string") {
|
||||
params[name] = decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = 200;
|
||||
let payload: unknown = undefined;
|
||||
const res: BrowserResponse = {
|
||||
status(code) {
|
||||
status = code;
|
||||
return res;
|
||||
},
|
||||
json(bodyValue) {
|
||||
payload = bodyValue;
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await match.handler(
|
||||
{
|
||||
params,
|
||||
query,
|
||||
body,
|
||||
},
|
||||
res,
|
||||
);
|
||||
} catch (err) {
|
||||
return { status: 500, body: { error: String(err) } };
|
||||
}
|
||||
|
||||
return { status, body: payload };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type { BrowserDispatchRequest, BrowserDispatchResponse };
|
||||
@@ -1,11 +1,10 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { registerBrowserAgentRoutes } from "./agent.js";
|
||||
import { registerBrowserBasicRoutes } from "./basic.js";
|
||||
import { registerBrowserTabRoutes } from "./tabs.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
export function registerBrowserRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||
export function registerBrowserRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
|
||||
registerBrowserBasicRoutes(app, ctx);
|
||||
registerBrowserTabRoutes(app, ctx);
|
||||
registerBrowserAgentRoutes(app, ctx);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
export function registerBrowserTabRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||
export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
|
||||
app.get("/tabs", async (req, res) => {
|
||||
const profileCtx = getProfileContext(req, ctx);
|
||||
if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export type BrowserRequest = {
|
||||
params: Record<string, string>;
|
||||
query: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
export type BrowserResponse = {
|
||||
status: (code: number) => BrowserResponse;
|
||||
json: (body: unknown) => void;
|
||||
};
|
||||
|
||||
export type BrowserRouteHandler = (
|
||||
req: BrowserRequest,
|
||||
res: BrowserResponse,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export type BrowserRouteRegistrar = {
|
||||
get: (path: string, handler: BrowserRouteHandler) => void;
|
||||
post: (path: string, handler: BrowserRouteHandler) => void;
|
||||
delete: (path: string, handler: BrowserRouteHandler) => void;
|
||||
};
|
||||
@@ -1,14 +1,13 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||
import { parseBooleanValue } from "../../utils/boolean.js";
|
||||
import type { BrowserRequest, BrowserResponse } from "./types.js";
|
||||
|
||||
/**
|
||||
* Extract profile name from query string or body and get profile context.
|
||||
* Query string takes precedence over body for consistency with GET routes.
|
||||
*/
|
||||
export function getProfileContext(
|
||||
req: express.Request,
|
||||
req: BrowserRequest,
|
||||
ctx: BrowserRouteContext,
|
||||
): ProfileContext | { error: string; status: number } {
|
||||
let profileName: string | undefined;
|
||||
@@ -33,7 +32,7 @@ export function getProfileContext(
|
||||
}
|
||||
}
|
||||
|
||||
export function jsonError(res: express.Response, status: number, message: string) {
|
||||
export function jsonError(res: BrowserResponse, status: number, message: string) {
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
|
||||
|
||||
@@ -62,8 +62,6 @@ describe("browser server-context ensureTabAvailable", () => {
|
||||
port: 0,
|
||||
resolved: {
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
controlHost: "127.0.0.1",
|
||||
controlPort: 18791,
|
||||
cdpProtocol: "http",
|
||||
cdpHost: "127.0.0.1",
|
||||
@@ -121,8 +119,6 @@ describe("browser server-context ensureTabAvailable", () => {
|
||||
port: 0,
|
||||
resolved: {
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
controlHost: "127.0.0.1",
|
||||
controlPort: 18791,
|
||||
cdpProtocol: "http",
|
||||
cdpHost: "127.0.0.1",
|
||||
@@ -170,8 +166,6 @@ describe("browser server-context ensureTabAvailable", () => {
|
||||
port: 0,
|
||||
resolved: {
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
controlHost: "127.0.0.1",
|
||||
controlPort: 18791,
|
||||
cdpProtocol: "http",
|
||||
cdpHost: "127.0.0.1",
|
||||
|
||||
@@ -21,8 +21,6 @@ function makeState(
|
||||
port: 0,
|
||||
resolved: {
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
controlHost: "127.0.0.1",
|
||||
controlPort: 18791,
|
||||
cdpProtocol: profile === "remote" ? "https" : "http",
|
||||
cdpHost: profile === "remote" ? "browserless.example" : "127.0.0.1",
|
||||
|
||||
@@ -17,7 +17,7 @@ export type ProfileRuntimeState = {
|
||||
};
|
||||
|
||||
export type BrowserServerState = {
|
||||
server: Server;
|
||||
server?: Server | null;
|
||||
port: number;
|
||||
resolved: ResolvedBrowserConfig;
|
||||
profiles: Map<string, ProfileRuntimeState>;
|
||||
|
||||
@@ -8,6 +8,7 @@ let cdpBaseUrl = "";
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let createTargetId: string | null = null;
|
||||
let prevGatewayPort: string | undefined;
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
createTargetViaCdp: vi.fn(async () => {
|
||||
@@ -88,7 +89,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
loadConfig: () => ({
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||
color: "#FF4500",
|
||||
attachOnly: cfgAttachOnly,
|
||||
headless: true,
|
||||
@@ -197,6 +197,8 @@ describe("browser control server", () => {
|
||||
|
||||
testPort = await getFreePort();
|
||||
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
@@ -248,6 +250,11 @@ describe("browser control server", () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
if (prevGatewayPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
||||
}
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ let cdpBaseUrl = "";
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let createTargetId: string | null = null;
|
||||
let prevGatewayPort: string | undefined;
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
createTargetViaCdp: vi.fn(async () => {
|
||||
@@ -89,7 +90,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
loadConfig: () => ({
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||
color: "#FF4500",
|
||||
attachOnly: cfgAttachOnly,
|
||||
headless: true,
|
||||
@@ -198,6 +198,8 @@ describe("browser control server", () => {
|
||||
|
||||
testPort = await getFreePort();
|
||||
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
@@ -249,6 +251,11 @@ describe("browser control server", () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
if (prevGatewayPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
||||
}
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ let _cdpBaseUrl = "";
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let createTargetId: string | null = null;
|
||||
let prevGatewayPort: string | undefined;
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
createTargetViaCdp: vi.fn(async () => {
|
||||
@@ -88,7 +89,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
loadConfig: () => ({
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||
color: "#FF4500",
|
||||
attachOnly: cfgAttachOnly,
|
||||
headless: true,
|
||||
@@ -197,6 +197,8 @@ describe("browser control server", () => {
|
||||
|
||||
testPort = await getFreePort();
|
||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
@@ -248,6 +250,11 @@ describe("browser control server", () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
if (prevGatewayPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
||||
}
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ let _cdpBaseUrl = "";
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let createTargetId: string | null = null;
|
||||
let prevGatewayPort: string | undefined;
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
createTargetViaCdp: vi.fn(async () => {
|
||||
@@ -88,7 +89,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
loadConfig: () => ({
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||
color: "#FF4500",
|
||||
attachOnly: cfgAttachOnly,
|
||||
headless: true,
|
||||
@@ -197,6 +197,8 @@ describe("browser control server", () => {
|
||||
|
||||
testPort = await getFreePort();
|
||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
@@ -248,6 +250,11 @@ describe("browser control server", () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
if (prevGatewayPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
||||
}
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ let _cdpBaseUrl = "";
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let createTargetId: string | null = null;
|
||||
let prevGatewayPort: string | undefined;
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
createTargetViaCdp: vi.fn(async () => {
|
||||
@@ -88,7 +89,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
loadConfig: () => ({
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||
color: "#FF4500",
|
||||
attachOnly: cfgAttachOnly,
|
||||
headless: true,
|
||||
@@ -197,6 +197,8 @@ describe("browser control server", () => {
|
||||
|
||||
testPort = await getFreePort();
|
||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
@@ -248,6 +250,11 @@ describe("browser control server", () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
if (prevGatewayPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
||||
}
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ let cdpBaseUrl = "";
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let createTargetId: string | null = null;
|
||||
let prevGatewayPort: string | undefined;
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
createTargetViaCdp: vi.fn(async () => {
|
||||
@@ -88,7 +89,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
loadConfig: () => ({
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||
color: "#FF4500",
|
||||
attachOnly: cfgAttachOnly,
|
||||
headless: true,
|
||||
@@ -197,6 +197,8 @@ describe("browser control server", () => {
|
||||
|
||||
testPort = await getFreePort();
|
||||
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
@@ -248,6 +250,11 @@ describe("browser control server", () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
if (prevGatewayPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
||||
}
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
@@ -394,8 +401,6 @@ describe("browser control server", () => {
|
||||
const bridge = await startBrowserBridgeServer({
|
||||
resolved: {
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:0",
|
||||
controlHost: "127.0.0.1",
|
||||
controlPort: 0,
|
||||
cdpProtocol: "http",
|
||||
cdpHost: "127.0.0.1",
|
||||
|
||||
+9
-13
@@ -3,9 +3,10 @@ import express from "express";
|
||||
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js";
|
||||
import { resolveBrowserConfig, resolveProfile } from "./config.js";
|
||||
import { ensureChromeExtensionRelayServer } from "./extension-relay.js";
|
||||
import { registerBrowserRoutes } from "./routes/index.js";
|
||||
import type { BrowserRouteRegistrar } from "./routes/types.js";
|
||||
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
|
||||
|
||||
let state: BrowserServerState | null = null;
|
||||
@@ -16,23 +17,16 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
|
||||
if (state) return state;
|
||||
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser);
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
if (!resolved.enabled) return null;
|
||||
|
||||
if (!shouldStartLocalBrowserServer(resolved)) {
|
||||
logServer.info(
|
||||
`browser control URL is non-loopback (${resolved.controlUrl}); skipping local server start`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
const ctx = createBrowserRouteContext({
|
||||
getState: () => state,
|
||||
});
|
||||
registerBrowserRoutes(app, ctx);
|
||||
registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx);
|
||||
|
||||
const port = resolved.controlPort;
|
||||
const server = await new Promise<Server>((resolve, reject) => {
|
||||
@@ -89,9 +83,11 @@ export async function stopBrowserControlServer(): Promise<void> {
|
||||
logServer.warn(`clawd browser stop failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
current.server.close(() => resolve());
|
||||
});
|
||||
if (current.server) {
|
||||
await new Promise<void>((resolve) => {
|
||||
current.server?.close(() => resolve());
|
||||
});
|
||||
}
|
||||
state = null;
|
||||
|
||||
// Optional: Playwright is not always available (e.g. embedded gateway builds).
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Command } from "commander";
|
||||
import { browserAct } from "../../browser/client-actions.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
import { requireRef, resolveBrowserActionContext } from "./shared.js";
|
||||
import { callBrowserAct, requireRef, resolveBrowserActionContext } from "./shared.js";
|
||||
|
||||
export function registerBrowserElementCommands(
|
||||
browser: Command,
|
||||
@@ -18,7 +17,7 @@ export function registerBrowserElementCommands(
|
||||
.option("--button <left|right|middle>", "Mouse button to use")
|
||||
.option("--modifiers <list>", "Comma-separated modifiers (Shift,Alt,Meta)")
|
||||
.action(async (ref: string | undefined, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const refValue = requireRef(ref);
|
||||
if (!refValue) return;
|
||||
const modifiers = opts.modifiers
|
||||
@@ -28,9 +27,10 @@ export function registerBrowserElementCommands(
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
const result = await callBrowserAct({
|
||||
parent,
|
||||
profile,
|
||||
body: {
|
||||
kind: "click",
|
||||
ref: refValue,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
@@ -38,8 +38,7 @@ export function registerBrowserElementCommands(
|
||||
button: opts.button?.trim() || undefined,
|
||||
modifiers,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -61,13 +60,14 @@ export function registerBrowserElementCommands(
|
||||
.option("--slowly", "Type slowly (human-like)", false)
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (ref: string | undefined, text: string, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const refValue = requireRef(ref);
|
||||
if (!refValue) return;
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
const result = await callBrowserAct({
|
||||
parent,
|
||||
profile,
|
||||
body: {
|
||||
kind: "type",
|
||||
ref: refValue,
|
||||
text,
|
||||
@@ -75,8 +75,7 @@ export function registerBrowserElementCommands(
|
||||
slowly: Boolean(opts.slowly),
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -94,13 +93,13 @@ export function registerBrowserElementCommands(
|
||||
.argument("<key>", "Key to press (e.g. Enter)")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (key: string, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{ kind: "press", key, targetId: opts.targetId?.trim() || undefined },
|
||||
{ profile },
|
||||
);
|
||||
const result = await callBrowserAct({
|
||||
parent,
|
||||
profile,
|
||||
body: { kind: "press", key, targetId: opts.targetId?.trim() || undefined },
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -118,13 +117,13 @@ export function registerBrowserElementCommands(
|
||||
.argument("<ref>", "Ref id from snapshot")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (ref: string, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{ kind: "hover", ref, targetId: opts.targetId?.trim() || undefined },
|
||||
{ profile },
|
||||
);
|
||||
const result = await callBrowserAct({
|
||||
parent,
|
||||
profile,
|
||||
body: { kind: "hover", ref, targetId: opts.targetId?.trim() || undefined },
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -145,20 +144,21 @@ export function registerBrowserElementCommands(
|
||||
Number(v),
|
||||
)
|
||||
.action(async (ref: string | undefined, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const refValue = requireRef(ref);
|
||||
if (!refValue) return;
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
const result = await callBrowserAct({
|
||||
parent,
|
||||
profile,
|
||||
body: {
|
||||
kind: "scrollIntoView",
|
||||
ref: refValue,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -177,18 +177,18 @@ export function registerBrowserElementCommands(
|
||||
.argument("<endRef>", "End ref id")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (startRef: string, endRef: string, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
const result = await callBrowserAct({
|
||||
parent,
|
||||
profile,
|
||||
body: {
|
||||
kind: "drag",
|
||||
startRef,
|
||||
endRef,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -207,18 +207,18 @@ export function registerBrowserElementCommands(
|
||||
.argument("<values...>", "Option values to select")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (ref: string, values: string[], opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
const result = await callBrowserAct({
|
||||
parent,
|
||||
profile,
|
||||
body: {
|
||||
kind: "select",
|
||||
ref,
|
||||
values,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
browserArmDialog,
|
||||
browserArmFileChooser,
|
||||
browserDownload,
|
||||
browserWaitForDownload,
|
||||
} from "../../browser/client-actions.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
import { resolveBrowserActionContext } from "./shared.js";
|
||||
import { shortenHomePath } from "../../utils.js";
|
||||
|
||||
@@ -29,17 +23,26 @@ export function registerBrowserFilesAndDownloadsCommands(
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (paths: string[], opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const result = await browserArmFileChooser(baseUrl, {
|
||||
paths,
|
||||
ref: opts.ref?.trim() || undefined,
|
||||
inputRef: opts.inputRef?.trim() || undefined,
|
||||
element: opts.element?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||
profile,
|
||||
});
|
||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
paths,
|
||||
ref: opts.ref?.trim() || undefined,
|
||||
inputRef: opts.inputRef?.trim() || undefined,
|
||||
element: opts.element?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: timeoutMs ?? 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -62,14 +65,23 @@ export function registerBrowserFilesAndDownloadsCommands(
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (outPath: string | undefined, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const result = await browserWaitForDownload(baseUrl, {
|
||||
path: outPath?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||
profile,
|
||||
});
|
||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/wait/download",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
path: outPath?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: timeoutMs ?? 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -93,15 +105,24 @@ export function registerBrowserFilesAndDownloadsCommands(
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (ref: string, outPath: string, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const result = await browserDownload(baseUrl, {
|
||||
ref,
|
||||
path: outPath,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||
profile,
|
||||
});
|
||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/download",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
ref,
|
||||
path: outPath,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: timeoutMs ?? 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -126,7 +147,7 @@ export function registerBrowserFilesAndDownloadsCommands(
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const accept = opts.accept ? true : opts.dismiss ? false : undefined;
|
||||
if (accept === undefined) {
|
||||
defaultRuntime.error(danger("Specify --accept or --dismiss"));
|
||||
@@ -134,13 +155,22 @@ export function registerBrowserFilesAndDownloadsCommands(
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await browserArmDialog(baseUrl, {
|
||||
accept,
|
||||
promptText: opts.prompt?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||
profile,
|
||||
});
|
||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/hooks/dialog",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
accept,
|
||||
promptText: opts.prompt?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: timeoutMs ?? 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Command } from "commander";
|
||||
import { browserAct } from "../../browser/client-actions.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
import { readFields, resolveBrowserActionContext } from "./shared.js";
|
||||
import { callBrowserAct, readFields, resolveBrowserActionContext } from "./shared.js";
|
||||
|
||||
export function registerBrowserFormWaitEvalCommands(
|
||||
browser: Command,
|
||||
@@ -16,21 +15,21 @@ export function registerBrowserFormWaitEvalCommands(
|
||||
.option("--fields-file <path>", "Read JSON array from a file")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const fields = await readFields({
|
||||
fields: opts.fields,
|
||||
fieldsFile: opts.fieldsFile,
|
||||
});
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
const result = await callBrowserAct({
|
||||
parent,
|
||||
profile,
|
||||
body: {
|
||||
kind: "fill",
|
||||
fields,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -59,16 +58,18 @@ export function registerBrowserFormWaitEvalCommands(
|
||||
)
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (selector: string | undefined, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const sel = selector?.trim() || undefined;
|
||||
const load =
|
||||
opts.load === "load" || opts.load === "domcontentloaded" || opts.load === "networkidle"
|
||||
? (opts.load as "load" | "domcontentloaded" | "networkidle")
|
||||
: undefined;
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
||||
const result = await callBrowserAct({
|
||||
parent,
|
||||
profile,
|
||||
body: {
|
||||
kind: "wait",
|
||||
timeMs: Number.isFinite(opts.time) ? opts.time : undefined,
|
||||
text: opts.text?.trim() || undefined,
|
||||
@@ -78,10 +79,10 @@ export function registerBrowserFormWaitEvalCommands(
|
||||
loadState: load,
|
||||
fn: opts.fn?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||
timeoutMs,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
timeoutMs,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -100,23 +101,23 @@ export function registerBrowserFormWaitEvalCommands(
|
||||
.option("--ref <id>", "Ref from snapshot")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
if (!opts.fn) {
|
||||
defaultRuntime.error(danger("Missing --fn"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
const result = await callBrowserAct({
|
||||
parent,
|
||||
profile,
|
||||
body: {
|
||||
kind: "evaluate",
|
||||
fn: opts.fn,
|
||||
ref: opts.ref?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import { browserAct, browserNavigate } from "../../browser/client-actions.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
import { requireRef, resolveBrowserActionContext } from "./shared.js";
|
||||
|
||||
export function registerBrowserNavigationCommands(
|
||||
@@ -15,13 +14,21 @@ export function registerBrowserNavigationCommands(
|
||||
.argument("<url>", "URL to navigate to")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (url: string, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const result = await browserNavigate(baseUrl, {
|
||||
url,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest<{ url?: string }>(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/navigate",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
url,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -40,22 +47,27 @@ export function registerBrowserNavigationCommands(
|
||||
.argument("<height>", "Viewport height", (v: string) => Number(v))
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (width: number, height: number, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height)) {
|
||||
defaultRuntime.error(danger("width and height must be numbers"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
kind: "resize",
|
||||
width,
|
||||
height,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
method: "POST",
|
||||
path: "/act",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
kind: "resize",
|
||||
width,
|
||||
height,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{ profile },
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import type { Command } from "commander";
|
||||
import { resolveBrowserControlUrl } from "../../browser/client.js";
|
||||
import type { BrowserFormField } from "../../browser/client-actions-core.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
|
||||
export type BrowserActionContext = {
|
||||
parent: BrowserParentOpts;
|
||||
baseUrl: string;
|
||||
profile: string | undefined;
|
||||
};
|
||||
|
||||
@@ -16,9 +14,26 @@ export function resolveBrowserActionContext(
|
||||
parentOpts: (cmd: Command) => BrowserParentOpts,
|
||||
): BrowserActionContext {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
return { parent, baseUrl, profile };
|
||||
return { parent, profile };
|
||||
}
|
||||
|
||||
export async function callBrowserAct<T = unknown>(params: {
|
||||
parent: BrowserParentOpts;
|
||||
profile?: string;
|
||||
body: Record<string, unknown>;
|
||||
timeoutMs?: number;
|
||||
}): Promise<T> {
|
||||
return await callBrowserRequest<T>(
|
||||
params.parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/act",
|
||||
query: params.profile ? { profile: params.profile } : undefined,
|
||||
body: params.body,
|
||||
},
|
||||
{ timeoutMs: params.timeoutMs ?? 20000 },
|
||||
);
|
||||
}
|
||||
|
||||
export function requireRef(ref: string | undefined) {
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import { resolveBrowserControlUrl } from "../browser/client.js";
|
||||
import {
|
||||
browserConsoleMessages,
|
||||
browserPdfSave,
|
||||
browserResponseBody,
|
||||
} from "../browser/client-actions.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
import { runCommandWithRuntime } from "./cli-utils.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
|
||||
@@ -29,14 +23,21 @@ export function registerBrowserActionObserveCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserObserve(async () => {
|
||||
const result = await browserConsoleMessages(baseUrl, {
|
||||
level: opts.level?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest<{ messages: unknown[] }>(
|
||||
parent,
|
||||
{
|
||||
method: "GET",
|
||||
path: "/console",
|
||||
query: {
|
||||
level: opts.level?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -51,13 +52,18 @@ export function registerBrowserActionObserveCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserObserve(async () => {
|
||||
const result = await browserPdfSave(baseUrl, {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest<{ path: string }>(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/pdf",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: { targetId: opts.targetId?.trim() || undefined },
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -81,16 +87,25 @@ export function registerBrowserActionObserveCommands(
|
||||
)
|
||||
.action(async (url: string, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserObserve(async () => {
|
||||
const result = await browserResponseBody(baseUrl, {
|
||||
url,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||
maxChars: Number.isFinite(opts.maxChars) ? opts.maxChars : undefined,
|
||||
profile,
|
||||
});
|
||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
||||
const maxChars = Number.isFinite(opts.maxChars) ? opts.maxChars : undefined;
|
||||
const result = await callBrowserRequest<{ response: { body: string } }>(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/response/body",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
url,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs,
|
||||
maxChars,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: timeoutMs ?? 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { resolveBrowserControlUrl } from "../browser/client.js";
|
||||
import {
|
||||
browserHighlight,
|
||||
browserPageErrors,
|
||||
browserRequests,
|
||||
browserTraceStart,
|
||||
browserTraceStop,
|
||||
} from "../browser/client-actions.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
import { runCommandWithRuntime } from "./cli-utils.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
|
||||
@@ -32,14 +24,21 @@ export function registerBrowserDebugCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (ref: string, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserDebug(async () => {
|
||||
const result = await browserHighlight(baseUrl, {
|
||||
ref: ref.trim(),
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/highlight",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
ref: ref.trim(),
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -55,14 +54,23 @@ export function registerBrowserDebugCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserDebug(async () => {
|
||||
const result = await browserPageErrors(baseUrl, {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
clear: Boolean(opts.clear),
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest<{
|
||||
errors: Array<{ timestamp: string; name?: string; message: string }>;
|
||||
}>(
|
||||
parent,
|
||||
{
|
||||
method: "GET",
|
||||
path: "/errors",
|
||||
query: {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
clear: Boolean(opts.clear),
|
||||
profile,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -87,15 +95,31 @@ export function registerBrowserDebugCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserDebug(async () => {
|
||||
const result = await browserRequests(baseUrl, {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
filter: opts.filter?.trim() || undefined,
|
||||
clear: Boolean(opts.clear),
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest<{
|
||||
requests: Array<{
|
||||
timestamp: string;
|
||||
method: string;
|
||||
status?: number;
|
||||
ok?: boolean;
|
||||
url: string;
|
||||
failureText?: string;
|
||||
}>;
|
||||
}>(
|
||||
parent,
|
||||
{
|
||||
method: "GET",
|
||||
path: "/requests",
|
||||
query: {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
filter: opts.filter?.trim() || undefined,
|
||||
clear: Boolean(opts.clear),
|
||||
profile,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -128,16 +152,23 @@ export function registerBrowserDebugCommands(
|
||||
.option("--sources", "Include sources (bigger traces)", false)
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserDebug(async () => {
|
||||
const result = await browserTraceStart(baseUrl, {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
screenshots: Boolean(opts.screenshots),
|
||||
snapshots: Boolean(opts.snapshots),
|
||||
sources: Boolean(opts.sources),
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/trace/start",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
screenshots: Boolean(opts.screenshots),
|
||||
snapshots: Boolean(opts.snapshots),
|
||||
sources: Boolean(opts.sources),
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -153,14 +184,21 @@ export function registerBrowserDebugCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserDebug(async () => {
|
||||
const result = await browserTraceStop(baseUrl, {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
path: opts.out?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest<{ path: string }>(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/trace/stop",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
path: opts.out?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { browserSnapshot, resolveBrowserControlUrl } from "../browser/client.js";
|
||||
import { browserScreenshotAction } from "../browser/client-actions.js";
|
||||
import type { SnapshotResult } from "../browser/client.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
|
||||
export function registerBrowserInspectCommands(
|
||||
browser: Command,
|
||||
@@ -22,17 +21,24 @@ export function registerBrowserInspectCommands(
|
||||
.option("--type <png|jpeg>", "Output type (default: png)", "png")
|
||||
.action(async (targetId: string | undefined, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserScreenshotAction(baseUrl, {
|
||||
targetId: targetId?.trim() || undefined,
|
||||
fullPage: Boolean(opts.fullPage),
|
||||
ref: opts.ref?.trim() || undefined,
|
||||
element: opts.element?.trim() || undefined,
|
||||
type: opts.type === "jpeg" ? "jpeg" : "png",
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest<{ path: string }>(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/screenshot",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
targetId: targetId?.trim() || undefined,
|
||||
fullPage: Boolean(opts.fullPage),
|
||||
ref: opts.ref?.trim() || undefined,
|
||||
element: opts.element?.trim() || undefined,
|
||||
type: opts.type === "jpeg" ? "jpeg" : "png",
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -61,7 +67,6 @@ export function registerBrowserInspectCommands(
|
||||
.option("--out <path>", "Write snapshot to a file")
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
const format = opts.format === "aria" ? "aria" : "ai";
|
||||
const configMode =
|
||||
@@ -70,19 +75,28 @@ export function registerBrowserInspectCommands(
|
||||
: undefined;
|
||||
const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : configMode;
|
||||
try {
|
||||
const result = await browserSnapshot(baseUrl, {
|
||||
const query: Record<string, string | number | boolean | undefined> = {
|
||||
format,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
limit: Number.isFinite(opts.limit) ? opts.limit : undefined,
|
||||
interactive: Boolean(opts.interactive) || undefined,
|
||||
compact: Boolean(opts.compact) || undefined,
|
||||
interactive: opts.interactive ? true : undefined,
|
||||
compact: opts.compact ? true : undefined,
|
||||
depth: Number.isFinite(opts.depth) ? opts.depth : undefined,
|
||||
selector: opts.selector?.trim() || undefined,
|
||||
frame: opts.frame?.trim() || undefined,
|
||||
labels: Boolean(opts.labels) || undefined,
|
||||
labels: opts.labels ? true : undefined,
|
||||
mode,
|
||||
profile,
|
||||
});
|
||||
};
|
||||
const result = await callBrowserRequest<SnapshotResult>(
|
||||
parent,
|
||||
{
|
||||
method: "GET",
|
||||
path: "/snapshot",
|
||||
query,
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
|
||||
if (opts.out) {
|
||||
const fs = await import("node:fs/promises");
|
||||
|
||||
+186
-72
@@ -1,25 +1,16 @@
|
||||
import type { Command } from "commander";
|
||||
import type { BrowserTab } from "../browser/client.js";
|
||||
import {
|
||||
browserCloseTab,
|
||||
browserCreateProfile,
|
||||
browserDeleteProfile,
|
||||
browserFocusTab,
|
||||
browserOpenTab,
|
||||
browserProfiles,
|
||||
browserResetProfile,
|
||||
browserStart,
|
||||
browserStatus,
|
||||
browserStop,
|
||||
browserTabAction,
|
||||
browserTabs,
|
||||
resolveBrowserControlUrl,
|
||||
import type {
|
||||
BrowserCreateProfileResult,
|
||||
BrowserDeleteProfileResult,
|
||||
BrowserResetProfileResult,
|
||||
BrowserStatus,
|
||||
BrowserTab,
|
||||
ProfileStatus,
|
||||
} from "../browser/client.js";
|
||||
import { browserAct } from "../browser/client-actions-core.js";
|
||||
import { danger, info } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
import { runCommandWithRuntime } from "./cli-utils.js";
|
||||
|
||||
function runBrowserCommand(action: () => Promise<void>) {
|
||||
@@ -38,11 +29,18 @@ export function registerBrowserManageCommands(
|
||||
.description("Show browser status")
|
||||
.action(async (_opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
await runBrowserCommand(async () => {
|
||||
const status = await browserStatus(baseUrl, {
|
||||
profile: parent?.browserProfile,
|
||||
});
|
||||
const status = await callBrowserRequest<BrowserStatus>(
|
||||
parent,
|
||||
{
|
||||
method: "GET",
|
||||
path: "/",
|
||||
query: parent?.browserProfile ? { profile: parent.browserProfile } : undefined,
|
||||
},
|
||||
{
|
||||
timeoutMs: 1500,
|
||||
},
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(status, null, 2));
|
||||
return;
|
||||
@@ -54,7 +52,6 @@ export function registerBrowserManageCommands(
|
||||
`profile: ${status.profile ?? "clawd"}`,
|
||||
`enabled: ${status.enabled}`,
|
||||
`running: ${status.running}`,
|
||||
`controlUrl: ${status.controlUrl}`,
|
||||
`cdpPort: ${status.cdpPort}`,
|
||||
`cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`,
|
||||
`browser: ${status.chosenBrowser ?? "unknown"}`,
|
||||
@@ -72,11 +69,26 @@ export function registerBrowserManageCommands(
|
||||
.description("Start the browser (no-op if already running)")
|
||||
.action(async (_opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
await browserStart(baseUrl, { profile });
|
||||
const status = await browserStatus(baseUrl, { profile });
|
||||
await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/start",
|
||||
query: profile ? { profile } : undefined,
|
||||
},
|
||||
{ timeoutMs: 15000 },
|
||||
);
|
||||
const status = await callBrowserRequest<BrowserStatus>(
|
||||
parent,
|
||||
{
|
||||
method: "GET",
|
||||
path: "/",
|
||||
query: profile ? { profile } : undefined,
|
||||
},
|
||||
{ timeoutMs: 1500 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(status, null, 2));
|
||||
return;
|
||||
@@ -91,11 +103,26 @@ export function registerBrowserManageCommands(
|
||||
.description("Stop the browser (best-effort)")
|
||||
.action(async (_opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
await browserStop(baseUrl, { profile });
|
||||
const status = await browserStatus(baseUrl, { profile });
|
||||
await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/stop",
|
||||
query: profile ? { profile } : undefined,
|
||||
},
|
||||
{ timeoutMs: 15000 },
|
||||
);
|
||||
const status = await callBrowserRequest<BrowserStatus>(
|
||||
parent,
|
||||
{
|
||||
method: "GET",
|
||||
path: "/",
|
||||
query: profile ? { profile } : undefined,
|
||||
},
|
||||
{ timeoutMs: 1500 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(status, null, 2));
|
||||
return;
|
||||
@@ -110,10 +137,17 @@ export function registerBrowserManageCommands(
|
||||
.description("Reset browser profile (moves it to Trash)")
|
||||
.action(async (_opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
const result = await browserResetProfile(baseUrl, { profile });
|
||||
const result = await callBrowserRequest<BrowserResetProfileResult>(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/reset-profile",
|
||||
query: profile ? { profile } : undefined,
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -132,10 +166,18 @@ export function registerBrowserManageCommands(
|
||||
.description("List open tabs")
|
||||
.action(async (_opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
const tabs = await browserTabs(baseUrl, { profile });
|
||||
const result = await callBrowserRequest<{ running: boolean; tabs: BrowserTab[] }>(
|
||||
parent,
|
||||
{
|
||||
method: "GET",
|
||||
path: "/tabs",
|
||||
query: profile ? { profile } : undefined,
|
||||
},
|
||||
{ timeoutMs: 3000 },
|
||||
);
|
||||
const tabs = result.tabs ?? [];
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify({ tabs }, null, 2));
|
||||
return;
|
||||
@@ -159,13 +201,20 @@ export function registerBrowserManageCommands(
|
||||
.description("Tab shortcuts (index-based)")
|
||||
.action(async (_opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
const result = (await browserTabAction(baseUrl, {
|
||||
action: "list",
|
||||
profile,
|
||||
})) as { ok: true; tabs: BrowserTab[] };
|
||||
const result = await callBrowserRequest<{ ok: true; tabs: BrowserTab[] }>(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/tabs/action",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
action: "list",
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
const tabs = result.tabs ?? [];
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify({ tabs }, null, 2));
|
||||
@@ -190,13 +239,18 @@ export function registerBrowserManageCommands(
|
||||
.description("Open a new tab (about:blank)")
|
||||
.action(async (_opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
const result = await browserTabAction(baseUrl, {
|
||||
action: "new",
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/tabs/action",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: { action: "new" },
|
||||
},
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -211,7 +265,6 @@ export function registerBrowserManageCommands(
|
||||
.argument("<index>", "Tab index (1-based)", (v: string) => Number(v))
|
||||
.action(async (index: number, _opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
if (!Number.isFinite(index) || index < 1) {
|
||||
defaultRuntime.error(danger("index must be a positive number"));
|
||||
@@ -219,11 +272,16 @@ export function registerBrowserManageCommands(
|
||||
return;
|
||||
}
|
||||
await runBrowserCommand(async () => {
|
||||
const result = await browserTabAction(baseUrl, {
|
||||
action: "select",
|
||||
index: Math.floor(index) - 1,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/tabs/action",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: { action: "select", index: Math.floor(index) - 1 },
|
||||
},
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -238,7 +296,6 @@ export function registerBrowserManageCommands(
|
||||
.argument("[index]", "Tab index (1-based)", (v: string) => Number(v))
|
||||
.action(async (index: number | undefined, _opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
const idx =
|
||||
typeof index === "number" && Number.isFinite(index) ? Math.floor(index) - 1 : undefined;
|
||||
@@ -248,11 +305,16 @@ export function registerBrowserManageCommands(
|
||||
return;
|
||||
}
|
||||
await runBrowserCommand(async () => {
|
||||
const result = await browserTabAction(baseUrl, {
|
||||
action: "close",
|
||||
index: idx,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/tabs/action",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: { action: "close", index: idx },
|
||||
},
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -267,10 +329,18 @@ export function registerBrowserManageCommands(
|
||||
.argument("<url>", "URL to open")
|
||||
.action(async (url: string, _opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
const tab = await browserOpenTab(baseUrl, url, { profile });
|
||||
const tab = await callBrowserRequest<BrowserTab>(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/tabs/open",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: { url },
|
||||
},
|
||||
{ timeoutMs: 15000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(tab, null, 2));
|
||||
return;
|
||||
@@ -285,10 +355,18 @@ export function registerBrowserManageCommands(
|
||||
.argument("<targetId>", "Target id or unique prefix")
|
||||
.action(async (targetId: string, _opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
await browserFocusTab(baseUrl, targetId, { profile });
|
||||
await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/tabs/focus",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: { targetId },
|
||||
},
|
||||
{ timeoutMs: 5000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
|
||||
return;
|
||||
@@ -303,13 +381,29 @@ export function registerBrowserManageCommands(
|
||||
.argument("[targetId]", "Target id or unique prefix (optional)")
|
||||
.action(async (targetId: string | undefined, _opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
if (targetId?.trim()) {
|
||||
await browserCloseTab(baseUrl, targetId.trim(), { profile });
|
||||
await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/tabs/${encodeURIComponent(targetId.trim())}`,
|
||||
query: profile ? { profile } : undefined,
|
||||
},
|
||||
{ timeoutMs: 5000 },
|
||||
);
|
||||
} else {
|
||||
await browserAct(baseUrl, { kind: "close" }, { profile });
|
||||
await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/act",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: { kind: "close" },
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
}
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
|
||||
@@ -325,9 +419,16 @@ export function registerBrowserManageCommands(
|
||||
.description("List all browser profiles")
|
||||
.action(async (_opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
await runBrowserCommand(async () => {
|
||||
const profiles = await browserProfiles(baseUrl);
|
||||
const result = await callBrowserRequest<{ profiles: ProfileStatus[] }>(
|
||||
parent,
|
||||
{
|
||||
method: "GET",
|
||||
path: "/profiles",
|
||||
},
|
||||
{ timeoutMs: 3000 },
|
||||
);
|
||||
const profiles = result.profiles ?? [];
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify({ profiles }, null, 2));
|
||||
return;
|
||||
@@ -361,14 +462,21 @@ export function registerBrowserManageCommands(
|
||||
.action(
|
||||
async (opts: { name: string; color?: string; cdpUrl?: string; driver?: string }, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
await runBrowserCommand(async () => {
|
||||
const result = await browserCreateProfile(baseUrl, {
|
||||
name: opts.name,
|
||||
color: opts.color,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
driver: opts.driver === "extension" ? "extension" : undefined,
|
||||
});
|
||||
const result = await callBrowserRequest<BrowserCreateProfileResult>(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/profiles/create",
|
||||
body: {
|
||||
name: opts.name,
|
||||
color: opts.color,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
driver: opts.driver === "extension" ? "extension" : undefined,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -391,9 +499,15 @@ export function registerBrowserManageCommands(
|
||||
.requiredOption("--name <name>", "Profile name to delete")
|
||||
.action(async (opts: { name: string }, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
await runBrowserCommand(async () => {
|
||||
const result = await browserDeleteProfile(baseUrl, opts.name);
|
||||
const result = await callBrowserRequest<BrowserDeleteProfileResult>(
|
||||
parent,
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/profiles/${encodeURIComponent(opts.name)}`,
|
||||
},
|
||||
{ timeoutMs: 20_000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { danger, info } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveBrowserConfig, resolveProfile } from "../browser/config.js";
|
||||
import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../browser/bridge-server.js";
|
||||
import { ensureChromeExtensionRelayServer } from "../browser/extension-relay.js";
|
||||
|
||||
function isLoopbackBindHost(host: string) {
|
||||
const h = host.trim().toLowerCase();
|
||||
return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "[::1]";
|
||||
}
|
||||
|
||||
function parsePort(raw: unknown): number | null {
|
||||
const v = typeof raw === "string" ? raw.trim() : "";
|
||||
if (!v) return null;
|
||||
const n = Number.parseInt(v, 10);
|
||||
if (!Number.isFinite(n) || n < 0 || n > 65535) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
export function registerBrowserServeCommands(
|
||||
browser: Command,
|
||||
_parentOpts: (cmd: Command) => unknown,
|
||||
) {
|
||||
browser
|
||||
.command("serve")
|
||||
.description("Run a standalone browser control server (for remote gateways)")
|
||||
.option("--bind <host>", "Bind host (default: 127.0.0.1)")
|
||||
.option("--port <port>", "Bind port (default: from browser.controlUrl)")
|
||||
.option(
|
||||
"--token <token>",
|
||||
"Require Authorization: Bearer <token> (required when binding non-loopback)",
|
||||
)
|
||||
.action(async (opts: { bind?: string; port?: string; token?: string }) => {
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser);
|
||||
if (!resolved.enabled) {
|
||||
defaultRuntime.error(
|
||||
danger("Browser control is disabled. Set browser.enabled=true and try again."),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
const host = (opts.bind ?? "127.0.0.1").trim();
|
||||
const port = parsePort(opts.port) ?? resolved.controlPort;
|
||||
|
||||
const envToken = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim();
|
||||
const authToken = (opts.token ?? envToken ?? resolved.controlToken)?.trim();
|
||||
if (!isLoopbackBindHost(host) && !authToken) {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
`Refusing to bind browser control on ${host} without --token (or CLAWDBOT_BROWSER_CONTROL_TOKEN, or browser.controlToken).`,
|
||||
),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
const bridge = await startBrowserBridgeServer({
|
||||
resolved,
|
||||
host,
|
||||
port,
|
||||
...(authToken ? { authToken } : {}),
|
||||
});
|
||||
|
||||
// If any profile uses the Chrome extension relay, start the local relay server eagerly
|
||||
// so the extension can connect before the first browser action.
|
||||
for (const name of Object.keys(resolved.profiles)) {
|
||||
const profile = resolveProfile(resolved, name);
|
||||
if (!profile || profile.driver !== "extension") continue;
|
||||
await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch((err) => {
|
||||
defaultRuntime.error(
|
||||
danger(`Chrome extension relay init failed for profile "${name}": ${String(err)}`),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
defaultRuntime.log(
|
||||
info(
|
||||
[
|
||||
`🦞 Browser control listening on ${bridge.baseUrl}/`,
|
||||
authToken ? "Auth: Bearer token required." : "Auth: off (loopback only).",
|
||||
"",
|
||||
"Paste on the Gateway (clawdbot.json):",
|
||||
JSON.stringify(
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: bridge.baseUrl,
|
||||
...(authToken ? { controlToken: authToken } : {}),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
...(authToken
|
||||
? [
|
||||
"",
|
||||
"Or use env on the Gateway (instead of controlToken in config):",
|
||||
`export CLAWDBOT_BROWSER_CONTROL_TOKEN=${JSON.stringify(authToken)}`,
|
||||
]
|
||||
: []),
|
||||
].join("\n"),
|
||||
),
|
||||
);
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (signal: string) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
defaultRuntime.log(info(`Shutting down (${signal})...`));
|
||||
await stopBrowserBridgeServer(bridge.server).catch(() => {});
|
||||
process.exit(0);
|
||||
};
|
||||
process.once("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.once("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
|
||||
await new Promise(() => {});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,58 @@
|
||||
export type BrowserParentOpts = {
|
||||
url?: string;
|
||||
import type { GatewayRpcOpts } from "./gateway-rpc.js";
|
||||
import { callGatewayFromCli } from "./gateway-rpc.js";
|
||||
|
||||
export type BrowserParentOpts = GatewayRpcOpts & {
|
||||
json?: boolean;
|
||||
browserProfile?: string;
|
||||
};
|
||||
|
||||
type BrowserRequestParams = {
|
||||
method: "GET" | "POST" | "DELETE";
|
||||
path: string;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
function normalizeQuery(query: BrowserRequestParams["query"]): Record<string, string> | undefined {
|
||||
if (!query) return undefined;
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined) continue;
|
||||
out[key] = String(value);
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
|
||||
export async function callBrowserRequest<T>(
|
||||
opts: BrowserParentOpts,
|
||||
params: BrowserRequestParams,
|
||||
extra?: { timeoutMs?: number; progress?: boolean },
|
||||
): Promise<T> {
|
||||
const resolvedTimeoutMs =
|
||||
typeof extra?.timeoutMs === "number" && Number.isFinite(extra.timeoutMs)
|
||||
? Math.max(1, Math.floor(extra.timeoutMs))
|
||||
: typeof opts.timeout === "string"
|
||||
? Number.parseInt(opts.timeout, 10)
|
||||
: undefined;
|
||||
const resolvedTimeout =
|
||||
typeof resolvedTimeoutMs === "number" && Number.isFinite(resolvedTimeoutMs)
|
||||
? resolvedTimeoutMs
|
||||
: undefined;
|
||||
const timeout = typeof resolvedTimeout === "number" ? String(resolvedTimeout) : opts.timeout;
|
||||
const payload = await callGatewayFromCli(
|
||||
"browser.request",
|
||||
{ ...opts, timeout },
|
||||
{
|
||||
method: params.method,
|
||||
path: params.path,
|
||||
query: normalizeQuery(params.query),
|
||||
body: params.body,
|
||||
timeoutMs: resolvedTimeout,
|
||||
},
|
||||
{ progress: extra?.progress },
|
||||
);
|
||||
if (payload === undefined) {
|
||||
throw new Error("Unexpected browser.request response");
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { resolveBrowserControlUrl } from "../browser/client.js";
|
||||
import {
|
||||
browserCookies,
|
||||
browserCookiesClear,
|
||||
browserCookiesSet,
|
||||
browserStorageClear,
|
||||
browserStorageGet,
|
||||
browserStorageSet,
|
||||
} from "../browser/client-actions.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
|
||||
export function registerBrowserCookiesAndStorageCommands(
|
||||
browser: Command,
|
||||
@@ -23,13 +14,20 @@ export function registerBrowserCookiesAndStorageCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserCookies(baseUrl, {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest<{ cookies?: unknown[] }>(
|
||||
parent,
|
||||
{
|
||||
method: "GET",
|
||||
path: "/cookies",
|
||||
query: {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -50,14 +48,21 @@ export function registerBrowserCookiesAndStorageCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (name: string, value: string, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserCookiesSet(baseUrl, {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
cookie: { name, value, url: opts.url },
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/cookies/set",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
cookie: { name, value, url: opts.url },
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -75,13 +80,20 @@ export function registerBrowserCookiesAndStorageCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserCookiesClear(baseUrl, {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/cookies/clear",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -105,15 +117,21 @@ export function registerBrowserCookiesAndStorageCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (key: string | undefined, opts, cmd2) => {
|
||||
const parent = parentOpts(cmd2);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserStorageGet(baseUrl, {
|
||||
kind,
|
||||
key: key?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest<{ values?: Record<string, string> }>(
|
||||
parent,
|
||||
{
|
||||
method: "GET",
|
||||
path: `/storage/${kind}`,
|
||||
query: {
|
||||
key: key?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -133,16 +151,22 @@ export function registerBrowserCookiesAndStorageCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (key: string, value: string, opts, cmd2) => {
|
||||
const parent = parentOpts(cmd2);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserStorageSet(baseUrl, {
|
||||
kind,
|
||||
key,
|
||||
value,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: `/storage/${kind}/set`,
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
key,
|
||||
value,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -160,14 +184,20 @@ export function registerBrowserCookiesAndStorageCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd2) => {
|
||||
const parent = parentOpts(cmd2);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserStorageClear(baseUrl, {
|
||||
kind,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: `/storage/${kind}/clear`,
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
|
||||
+123
-75
@@ -1,21 +1,9 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { resolveBrowserControlUrl } from "../browser/client.js";
|
||||
import {
|
||||
browserSetDevice,
|
||||
browserSetGeolocation,
|
||||
browserSetHeaders,
|
||||
browserSetHttpCredentials,
|
||||
browserSetLocale,
|
||||
browserSetMedia,
|
||||
browserSetOffline,
|
||||
browserSetTimezone,
|
||||
} from "../browser/client-actions.js";
|
||||
import { browserAct } from "../browser/client-actions-core.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { parseBooleanValue } from "../utils/boolean.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
import { registerBrowserCookiesAndStorageCommands } from "./browser-cli-state.cookies-storage.js";
|
||||
import { runCommandWithRuntime } from "./cli-utils.js";
|
||||
|
||||
@@ -47,7 +35,6 @@ export function registerBrowserStateCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (width: number, height: number, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height)) {
|
||||
defaultRuntime.error(danger("width and height must be numbers"));
|
||||
@@ -55,15 +42,20 @@ export function registerBrowserStateCommands(
|
||||
return;
|
||||
}
|
||||
await runBrowserCommand(async () => {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
kind: "resize",
|
||||
width,
|
||||
height,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
method: "POST",
|
||||
path: "/act",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
kind: "resize",
|
||||
width,
|
||||
height,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{ profile },
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
@@ -80,7 +72,6 @@ export function registerBrowserStateCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (value: string, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
const offline = parseOnOff(value);
|
||||
if (offline === null) {
|
||||
@@ -89,11 +80,19 @@ export function registerBrowserStateCommands(
|
||||
return;
|
||||
}
|
||||
await runBrowserCommand(async () => {
|
||||
const result = await browserSetOffline(baseUrl, {
|
||||
offline,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/set/offline",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
offline,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -109,7 +108,6 @@ export function registerBrowserStateCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
const parsed = JSON.parse(String(opts.json)) as unknown;
|
||||
@@ -120,11 +118,19 @@ export function registerBrowserStateCommands(
|
||||
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof v === "string") headers[k] = v;
|
||||
}
|
||||
const result = await browserSetHeaders(baseUrl, {
|
||||
headers,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/set/headers",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
headers,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -142,16 +148,23 @@ export function registerBrowserStateCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (username: string | undefined, password: string | undefined, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
const result = await browserSetHttpCredentials(baseUrl, {
|
||||
username: username?.trim() || undefined,
|
||||
password,
|
||||
clear: Boolean(opts.clear),
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/set/credentials",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
username: username?.trim() || undefined,
|
||||
password,
|
||||
clear: Boolean(opts.clear),
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -171,18 +184,25 @@ export function registerBrowserStateCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (latitude: number | undefined, longitude: number | undefined, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
const result = await browserSetGeolocation(baseUrl, {
|
||||
latitude: Number.isFinite(latitude) ? latitude : undefined,
|
||||
longitude: Number.isFinite(longitude) ? longitude : undefined,
|
||||
accuracy: Number.isFinite(opts.accuracy) ? opts.accuracy : undefined,
|
||||
origin: opts.origin?.trim() || undefined,
|
||||
clear: Boolean(opts.clear),
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/set/geolocation",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
latitude: Number.isFinite(latitude) ? latitude : undefined,
|
||||
longitude: Number.isFinite(longitude) ? longitude : undefined,
|
||||
accuracy: Number.isFinite(opts.accuracy) ? opts.accuracy : undefined,
|
||||
origin: opts.origin?.trim() || undefined,
|
||||
clear: Boolean(opts.clear),
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -198,7 +218,6 @@ export function registerBrowserStateCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (value: string, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
const v = value.trim().toLowerCase();
|
||||
const colorScheme =
|
||||
@@ -209,11 +228,19 @@ export function registerBrowserStateCommands(
|
||||
return;
|
||||
}
|
||||
await runBrowserCommand(async () => {
|
||||
const result = await browserSetMedia(baseUrl, {
|
||||
colorScheme,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/set/media",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
colorScheme,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -229,14 +256,21 @@ export function registerBrowserStateCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (timezoneId: string, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
const result = await browserSetTimezone(baseUrl, {
|
||||
timezoneId,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/set/timezone",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
timezoneId,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -252,14 +286,21 @@ export function registerBrowserStateCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (locale: string, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
const result = await browserSetLocale(baseUrl, {
|
||||
locale,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/set/locale",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
locale,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -275,14 +316,21 @@ export function registerBrowserStateCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (name: string, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
const result = await browserSetDevice(baseUrl, {
|
||||
name,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/set/device",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
name,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
|
||||
@@ -13,15 +13,14 @@ import { browserActionExamples, browserCoreExamples } from "./browser-cli-exampl
|
||||
import { registerBrowserExtensionCommands } from "./browser-cli-extension.js";
|
||||
import { registerBrowserInspectCommands } from "./browser-cli-inspect.js";
|
||||
import { registerBrowserManageCommands } from "./browser-cli-manage.js";
|
||||
import { registerBrowserServeCommands } from "./browser-cli-serve.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
import { registerBrowserStateCommands } from "./browser-cli-state.js";
|
||||
import { addGatewayClientOptions } from "./gateway-rpc.js";
|
||||
|
||||
export function registerBrowserCli(program: Command) {
|
||||
const browser = program
|
||||
.command("browser")
|
||||
.description("Manage clawd's dedicated browser (Chrome/Chromium)")
|
||||
.option("--url <url>", "Override browser control URL (default from ~/.clawdbot/clawdbot.json)")
|
||||
.option("--browser-profile <name>", "Browser profile name (default from config)")
|
||||
.option("--json", "Output machine-readable JSON", false)
|
||||
.addHelpText(
|
||||
@@ -43,11 +42,12 @@ export function registerBrowserCli(program: Command) {
|
||||
defaultRuntime.exit(1);
|
||||
});
|
||||
|
||||
addGatewayClientOptions(browser);
|
||||
|
||||
const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts;
|
||||
|
||||
registerBrowserManageCommands(browser, parentOpts);
|
||||
registerBrowserExtensionCommands(browser, parentOpts);
|
||||
registerBrowserServeCommands(browser, parentOpts);
|
||||
registerBrowserInspectCommands(browser, parentOpts);
|
||||
registerBrowserActionInputCommands(browser, parentOpts);
|
||||
registerBrowserActionObserveCommands(browser, parentOpts);
|
||||
|
||||
@@ -279,7 +279,6 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"ui.seamColor": "Accent Color",
|
||||
"ui.assistant.name": "Assistant Name",
|
||||
"ui.assistant.avatar": "Assistant Avatar",
|
||||
"browser.controlUrl": "Browser Control URL",
|
||||
"browser.snapshotDefaults": "Browser Snapshot Defaults",
|
||||
"browser.snapshotDefaults.mode": "Browser Snapshot Mode",
|
||||
"browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)",
|
||||
|
||||
@@ -14,16 +14,7 @@ export type BrowserSnapshotDefaults = {
|
||||
};
|
||||
export type BrowserConfig = {
|
||||
enabled?: boolean;
|
||||
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
|
||||
controlUrl?: string;
|
||||
/**
|
||||
* Shared token for the browser control server.
|
||||
* If set, clients must send `Authorization: Bearer <token>`.
|
||||
*
|
||||
* Prefer `CLAWDBOT_BROWSER_CONTROL_TOKEN` env for ephemeral setups; use this for "works after reboot".
|
||||
*/
|
||||
controlToken?: string;
|
||||
/** Base URL of the CDP endpoint. Default: controlUrl with port + 1. */
|
||||
/** Base URL of the CDP endpoint (for remote browsers). Default: loopback CDP on the derived port. */
|
||||
cdpUrl?: string;
|
||||
/** Remote CDP HTTP timeout (ms). Default: 1500. */
|
||||
remoteCdpTimeoutMs?: number;
|
||||
|
||||
@@ -58,21 +58,6 @@ export type SandboxBrowserSettings = {
|
||||
* Default: false.
|
||||
*/
|
||||
allowHostControl?: boolean;
|
||||
/**
|
||||
* Allowlist of exact control URLs for target="custom".
|
||||
* When set, any custom controlUrl must match this list.
|
||||
*/
|
||||
allowedControlUrls?: string[];
|
||||
/**
|
||||
* Allowlist of hostnames for control URLs (hostname only, no ports).
|
||||
* When set, controlUrl hostname must match.
|
||||
*/
|
||||
allowedControlHosts?: string[];
|
||||
/**
|
||||
* Allowlist of ports for control URLs.
|
||||
* When set, controlUrl port must match (defaults: http=80, https=443).
|
||||
*/
|
||||
allowedControlPorts?: number[];
|
||||
/**
|
||||
* When true (default), sandboxed browser control will try to start/reattach to
|
||||
* the sandbox browser container when a tool call needs it.
|
||||
|
||||
@@ -130,9 +130,6 @@ export const SandboxBrowserSchema = z
|
||||
headless: z.boolean().optional(),
|
||||
enableNoVnc: z.boolean().optional(),
|
||||
allowHostControl: z.boolean().optional(),
|
||||
allowedControlUrls: z.array(z.string()).optional(),
|
||||
allowedControlHosts: z.array(z.string()).optional(),
|
||||
allowedControlPorts: z.array(z.number().int().positive()).optional(),
|
||||
autoStart: z.boolean().optional(),
|
||||
autoStartTimeoutMs: z.number().int().positive().optional(),
|
||||
})
|
||||
|
||||
@@ -134,8 +134,6 @@ export const ClawdbotSchema = z
|
||||
browser: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
controlUrl: z.string().optional(),
|
||||
controlToken: z.string().optional(),
|
||||
cdpUrl: z.string().optional(),
|
||||
remoteCdpTimeoutMs: z.number().int().nonnegative().optional(),
|
||||
remoteCdpHandshakeTimeoutMs: z.number().int().nonnegative().optional(),
|
||||
|
||||
@@ -9,7 +9,19 @@ export async function startBrowserControlServerIfEnabled(): Promise<BrowserContr
|
||||
// Lazy import: keeps startup fast, but still bundles for the embedded
|
||||
// gateway (bun --compile) via the static specifier path.
|
||||
const override = process.env.CLAWDBOT_BROWSER_CONTROL_MODULE?.trim();
|
||||
const mod = override ? await import(override) : await import("../browser/server.js");
|
||||
await mod.startBrowserControlServerFromConfig();
|
||||
return { stop: mod.stopBrowserControlServer };
|
||||
const mod = override ? await import(override) : await import("../browser/control-service.js");
|
||||
const start =
|
||||
typeof (mod as { startBrowserControlServiceFromConfig?: unknown })
|
||||
.startBrowserControlServiceFromConfig === "function"
|
||||
? (mod as { startBrowserControlServiceFromConfig: () => Promise<unknown> })
|
||||
.startBrowserControlServiceFromConfig
|
||||
: (mod as { startBrowserControlServerFromConfig?: () => Promise<unknown> })
|
||||
.startBrowserControlServerFromConfig;
|
||||
const stop =
|
||||
typeof (mod as { stopBrowserControlService?: unknown }).stopBrowserControlService === "function"
|
||||
? (mod as { stopBrowserControlService: () => Promise<void> }).stopBrowserControlService
|
||||
: (mod as { stopBrowserControlServer?: () => Promise<void> }).stopBrowserControlServer;
|
||||
if (!start) return null;
|
||||
await start();
|
||||
return { stop: stop ?? (async () => {}) };
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ const BASE_METHODS = [
|
||||
"agent",
|
||||
"agent.identity.get",
|
||||
"agent.wait",
|
||||
"browser.request",
|
||||
// WebChat WebSocket-native chat methods
|
||||
"chat.history",
|
||||
"chat.abort",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ErrorCodes, errorShape } from "./protocol/index.js";
|
||||
import { agentHandlers } from "./server-methods/agent.js";
|
||||
import { agentsHandlers } from "./server-methods/agents.js";
|
||||
import { browserHandlers } from "./server-methods/browser.js";
|
||||
import { channelsHandlers } from "./server-methods/channels.js";
|
||||
import { chatHandlers } from "./server-methods/chat.js";
|
||||
import { configHandlers } from "./server-methods/config.js";
|
||||
@@ -86,6 +87,7 @@ const WRITE_METHODS = new Set([
|
||||
"node.invoke",
|
||||
"chat.send",
|
||||
"chat.abort",
|
||||
"browser.request",
|
||||
]);
|
||||
|
||||
function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) {
|
||||
@@ -168,6 +170,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
...usageHandlers,
|
||||
...agentHandlers,
|
||||
...agentsHandlers,
|
||||
...browserHandlers,
|
||||
};
|
||||
|
||||
export async function handleGatewayRequest(
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
createBrowserControlContext,
|
||||
startBrowserControlServiceFromConfig,
|
||||
} from "../../browser/control-service.js";
|
||||
import { createBrowserRouteDispatcher } from "../../browser/routes/dispatcher.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { saveMediaBuffer } from "../../media/store.js";
|
||||
import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js";
|
||||
import type { NodeSession } from "../node-registry.js";
|
||||
import { ErrorCodes, errorShape } from "../protocol/index.js";
|
||||
import { safeParseJson } from "./nodes.helpers.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
type BrowserRequestParams = {
|
||||
method?: string;
|
||||
path?: string;
|
||||
query?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type BrowserProxyFile = {
|
||||
path: string;
|
||||
base64: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
type BrowserProxyResult = {
|
||||
result: unknown;
|
||||
files?: BrowserProxyFile[];
|
||||
};
|
||||
|
||||
function isBrowserNode(node: NodeSession) {
|
||||
const caps = Array.isArray(node.caps) ? node.caps : [];
|
||||
const commands = Array.isArray(node.commands) ? node.commands : [];
|
||||
return caps.includes("browser") || commands.includes("browser.proxy");
|
||||
}
|
||||
|
||||
function normalizeNodeKey(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function resolveBrowserNode(nodes: NodeSession[], query: string): NodeSession | null {
|
||||
const q = query.trim();
|
||||
if (!q) return null;
|
||||
const qNorm = normalizeNodeKey(q);
|
||||
const matches = nodes.filter((node) => {
|
||||
if (node.nodeId === q) return true;
|
||||
if (typeof node.remoteIp === "string" && node.remoteIp === q) return true;
|
||||
const name = typeof node.displayName === "string" ? node.displayName : "";
|
||||
if (name && normalizeNodeKey(name) === qNorm) return true;
|
||||
if (q.length >= 6 && node.nodeId.startsWith(q)) return true;
|
||||
return false;
|
||||
});
|
||||
if (matches.length === 1) return matches[0] ?? null;
|
||||
if (matches.length === 0) return null;
|
||||
throw new Error(
|
||||
`ambiguous node: ${q} (matches: ${matches
|
||||
.map((node) => node.displayName || node.remoteIp || node.nodeId)
|
||||
.join(", ")})`,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveBrowserNodeTarget(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
nodes: NodeSession[];
|
||||
}): NodeSession | null {
|
||||
const policy = params.cfg.gateway?.nodes?.browser;
|
||||
const mode = policy?.mode ?? "auto";
|
||||
if (mode === "off") return null;
|
||||
const browserNodes = params.nodes.filter((node) => isBrowserNode(node));
|
||||
if (browserNodes.length === 0) {
|
||||
if (policy?.node?.trim()) {
|
||||
throw new Error("No connected browser-capable nodes.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const requested = policy?.node?.trim() || "";
|
||||
if (requested) {
|
||||
const resolved = resolveBrowserNode(browserNodes, requested);
|
||||
if (!resolved) {
|
||||
throw new Error(`Configured browser node not connected: ${requested}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
if (mode === "manual") return null;
|
||||
if (browserNodes.length === 1) return browserNodes[0] ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function persistProxyFiles(files: BrowserProxyFile[] | undefined) {
|
||||
if (!files || files.length === 0) return new Map<string, string>();
|
||||
const mapping = new Map<string, string>();
|
||||
for (const file of files) {
|
||||
const buffer = Buffer.from(file.base64, "base64");
|
||||
const saved = await saveMediaBuffer(buffer, file.mimeType, "browser", buffer.byteLength);
|
||||
mapping.set(file.path, saved.path);
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
function applyProxyPaths(result: unknown, mapping: Map<string, string>) {
|
||||
if (!result || typeof result !== "object") return;
|
||||
const obj = result as Record<string, unknown>;
|
||||
if (typeof obj.path === "string" && mapping.has(obj.path)) {
|
||||
obj.path = mapping.get(obj.path);
|
||||
}
|
||||
if (typeof obj.imagePath === "string" && mapping.has(obj.imagePath)) {
|
||||
obj.imagePath = mapping.get(obj.imagePath);
|
||||
}
|
||||
const download = obj.download;
|
||||
if (download && typeof download === "object") {
|
||||
const d = download as Record<string, unknown>;
|
||||
if (typeof d.path === "string" && mapping.has(d.path)) {
|
||||
d.path = mapping.get(d.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const browserHandlers: GatewayRequestHandlers = {
|
||||
"browser.request": async ({ params, respond, context }) => {
|
||||
const typed = params as BrowserRequestParams;
|
||||
const methodRaw = typeof typed.method === "string" ? typed.method.trim().toUpperCase() : "";
|
||||
const path = typeof typed.path === "string" ? typed.path.trim() : "";
|
||||
const query = typed.query && typeof typed.query === "object" ? typed.query : undefined;
|
||||
const body = typed.body;
|
||||
const timeoutMs =
|
||||
typeof typed.timeoutMs === "number" && Number.isFinite(typed.timeoutMs)
|
||||
? Math.max(1, Math.floor(typed.timeoutMs))
|
||||
: undefined;
|
||||
|
||||
if (!methodRaw || !path) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "method and path are required"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (methodRaw !== "GET" && methodRaw !== "POST" && methodRaw !== "DELETE") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "method must be GET, POST, or DELETE"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
let nodeTarget: NodeSession | null = null;
|
||||
try {
|
||||
nodeTarget = resolveBrowserNodeTarget({
|
||||
cfg,
|
||||
nodes: context.nodeRegistry.listConnected(),
|
||||
});
|
||||
} catch (err) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (nodeTarget) {
|
||||
const allowlist = resolveNodeCommandAllowlist(cfg, nodeTarget);
|
||||
const allowed = isNodeCommandAllowed({
|
||||
command: "browser.proxy",
|
||||
declaredCommands: nodeTarget.commands,
|
||||
allowlist,
|
||||
});
|
||||
if (!allowed.ok) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "node command not allowed", {
|
||||
details: { reason: allowed.reason, command: "browser.proxy" },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const proxyParams = {
|
||||
method: methodRaw,
|
||||
path,
|
||||
query,
|
||||
body,
|
||||
timeoutMs,
|
||||
profile: typeof query?.profile === "string" ? query.profile : undefined,
|
||||
};
|
||||
const res = await context.nodeRegistry.invoke({
|
||||
nodeId: nodeTarget.nodeId,
|
||||
command: "browser.proxy",
|
||||
params: proxyParams,
|
||||
timeoutMs,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", {
|
||||
details: { nodeError: res.error ?? null },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload;
|
||||
const proxy = payload && typeof payload === "object" ? (payload as BrowserProxyResult) : null;
|
||||
if (!proxy || !("result" in proxy)) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "browser proxy failed"));
|
||||
return;
|
||||
}
|
||||
const mapping = await persistProxyFiles(proxy.files);
|
||||
applyProxyPaths(proxy.result, mapping);
|
||||
respond(true, proxy.result);
|
||||
return;
|
||||
}
|
||||
|
||||
const ready = await startBrowserControlServiceFromConfig();
|
||||
if (!ready) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "browser control is disabled"));
|
||||
return;
|
||||
}
|
||||
|
||||
let dispatcher;
|
||||
try {
|
||||
dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
|
||||
} catch (err) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await dispatcher.dispatch({
|
||||
method: methodRaw,
|
||||
path,
|
||||
query,
|
||||
body,
|
||||
});
|
||||
|
||||
if (result.status >= 400) {
|
||||
const message =
|
||||
result.body && typeof result.body === "object" && "error" in result.body
|
||||
? String((result.body as { error?: unknown }).error)
|
||||
: `browser request failed (${result.status})`;
|
||||
const code = result.status >= 500 ? ErrorCodes.UNAVAILABLE : ErrorCodes.INVALID_REQUEST;
|
||||
respond(false, undefined, errorShape(code, message, { details: result.body }));
|
||||
return;
|
||||
}
|
||||
|
||||
respond(true, result.body);
|
||||
},
|
||||
};
|
||||
@@ -206,7 +206,7 @@ describe("gateway hot reload", () => {
|
||||
},
|
||||
cron: { enabled: true, store: "/tmp/cron.json" },
|
||||
agents: { defaults: { heartbeat: { every: "1m" }, maxConcurrent: 2 } },
|
||||
browser: { enabled: true, controlUrl: "http://127.0.0.1:18791" },
|
||||
browser: { enabled: true },
|
||||
web: { enabled: true },
|
||||
channels: {
|
||||
telegram: { botToken: "token" },
|
||||
|
||||
+64
-59
@@ -33,7 +33,12 @@ import {
|
||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveBrowserConfig, shouldStartLocalBrowserServer } from "../browser/config.js";
|
||||
import { resolveBrowserConfig } from "../browser/config.js";
|
||||
import {
|
||||
createBrowserControlContext,
|
||||
startBrowserControlServiceFromConfig,
|
||||
} from "../browser/control-service.js";
|
||||
import { createBrowserRouteDispatcher } from "../browser/routes/dispatcher.js";
|
||||
import { detectMime } from "../media/mime.js";
|
||||
import { resolveAgentConfig } from "../agents/agent-scope.js";
|
||||
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
||||
@@ -235,23 +240,39 @@ function resolveBrowserProxyConfig() {
|
||||
|
||||
let browserControlReady: Promise<void> | null = null;
|
||||
|
||||
async function ensureBrowserControlServer(): Promise<void> {
|
||||
async function ensureBrowserControlService(): Promise<void> {
|
||||
if (browserControlReady) return browserControlReady;
|
||||
browserControlReady = (async () => {
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser);
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
if (!resolved.enabled) {
|
||||
throw new Error("browser control disabled");
|
||||
}
|
||||
if (!shouldStartLocalBrowserServer(resolved)) {
|
||||
throw new Error("browser control URL is non-loopback");
|
||||
}
|
||||
const mod = await import("../browser/server.js");
|
||||
await mod.startBrowserControlServerFromConfig();
|
||||
const started = await startBrowserControlServiceFromConfig();
|
||||
if (!started) throw new Error("browser control disabled");
|
||||
})();
|
||||
return browserControlReady;
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs?: number, label?: string): Promise<T> {
|
||||
const resolved =
|
||||
typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
|
||||
? Math.max(1, Math.floor(timeoutMs))
|
||||
: undefined;
|
||||
if (!resolved) return await promise;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`${label ?? "request"} timed out`));
|
||||
}, resolved);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function isProfileAllowed(params: { allowProfiles: string[]; profile?: string | null }) {
|
||||
const { allowProfiles, profile } = params;
|
||||
if (!allowProfiles.length) return true;
|
||||
@@ -488,11 +509,8 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
|
||||
const cfg = loadConfig();
|
||||
const browserProxy = resolveBrowserProxyConfig();
|
||||
const resolvedBrowser = resolveBrowserConfig(cfg.browser);
|
||||
const browserProxyEnabled =
|
||||
browserProxy.enabled &&
|
||||
resolvedBrowser.enabled &&
|
||||
shouldStartLocalBrowserServer(resolvedBrowser);
|
||||
const resolvedBrowser = resolveBrowserConfig(cfg.browser, cfg);
|
||||
const browserProxyEnabled = browserProxy.enabled && resolvedBrowser.enabled;
|
||||
const isRemoteMode = cfg.gateway?.mode === "remote";
|
||||
const token =
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
|
||||
@@ -584,9 +602,11 @@ async function handleInvoke(
|
||||
payloadJSON: JSON.stringify(payload),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
const code = message.toLowerCase().includes("timed out") ? "TIMEOUT" : "INVALID_REQUEST";
|
||||
await sendInvokeResult(client, frame, {
|
||||
ok: false,
|
||||
error: { code: "INVALID_REQUEST", message: String(err) },
|
||||
error: { code, message },
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -667,8 +687,9 @@ async function handleInvoke(
|
||||
if (!proxyConfig.enabled) {
|
||||
throw new Error("UNAVAILABLE: node browser proxy disabled");
|
||||
}
|
||||
await ensureBrowserControlServer();
|
||||
const resolved = resolveBrowserConfig(loadConfig().browser);
|
||||
await ensureBrowserControlService();
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
const requestedProfile = typeof params.profile === "string" ? params.profile.trim() : "";
|
||||
const allowedProfiles = proxyConfig.allowProfiles;
|
||||
if (allowedProfiles.length > 0) {
|
||||
@@ -684,54 +705,38 @@ async function handleInvoke(
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(
|
||||
pathValue.startsWith("/") ? pathValue : `/${pathValue}`,
|
||||
resolved.controlUrl,
|
||||
);
|
||||
if (requestedProfile) {
|
||||
url.searchParams.set("profile", requestedProfile);
|
||||
}
|
||||
const query = params.query ?? {};
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
|
||||
const path = pathValue.startsWith("/") ? pathValue : `/${pathValue}`;
|
||||
const body = params.body;
|
||||
const ctrl = new AbortController();
|
||||
const timeoutMs =
|
||||
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
|
||||
? Math.max(1, Math.floor(params.timeoutMs))
|
||||
: 20_000;
|
||||
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
const headers = new Headers();
|
||||
let bodyJson: string | undefined;
|
||||
if (body !== undefined) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
bodyJson = JSON.stringify(body);
|
||||
const query: Record<string, unknown> = {};
|
||||
if (requestedProfile) {
|
||||
query.profile = requestedProfile;
|
||||
}
|
||||
const token =
|
||||
process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim() || resolved.controlToken?.trim();
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
const rawQuery = params.query ?? {};
|
||||
for (const [key, value] of Object.entries(rawQuery)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
query[key] = typeof value === "string" ? value : String(value);
|
||||
}
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url.toString(), {
|
||||
method,
|
||||
headers,
|
||||
body: bodyJson,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
|
||||
const response = await withTimeout(
|
||||
dispatcher.dispatch({
|
||||
method: method === "DELETE" ? "DELETE" : method === "POST" ? "POST" : "GET",
|
||||
path,
|
||||
query,
|
||||
body,
|
||||
}),
|
||||
params.timeoutMs,
|
||||
"browser proxy request",
|
||||
);
|
||||
if (response.status >= 400) {
|
||||
const message =
|
||||
response.body && typeof response.body === "object" && "error" in response.body
|
||||
? String((response.body as { error?: unknown }).error)
|
||||
: `HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(text ? `${res.status}: ${text}` : `HTTP ${res.status}`);
|
||||
}
|
||||
const result = (await res.json()) as unknown;
|
||||
if (allowedProfiles.length > 0 && url.pathname === "/profiles") {
|
||||
const result = response.body as unknown;
|
||||
if (allowedProfiles.length > 0 && path === "/profiles") {
|
||||
const obj =
|
||||
typeof result === "object" && result !== null ? (result as Record<string, unknown>) : {};
|
||||
const profiles = Array.isArray(obj.profiles) ? obj.profiles : [];
|
||||
|
||||
@@ -73,7 +73,7 @@ export function collectAttackSurfaceSummaryFindings(cfg: ClawdbotConfig): Securi
|
||||
const group = summarizeGroupPolicy(cfg);
|
||||
const elevated = cfg.tools?.elevated?.enabled !== false;
|
||||
const hooksEnabled = cfg.hooks?.enabled === true;
|
||||
const browserEnabled = Boolean(cfg.browser?.enabled ?? cfg.browser?.controlUrl);
|
||||
const browserEnabled = cfg.browser?.enabled ?? true;
|
||||
|
||||
const detail =
|
||||
`groups: open=${group.open}, allowlist=${group.allowlist}` +
|
||||
@@ -143,20 +143,6 @@ export function collectSecretsInConfigFindings(cfg: ClawdbotConfig): SecurityAud
|
||||
});
|
||||
}
|
||||
|
||||
const browserToken =
|
||||
typeof cfg.browser?.controlToken === "string" ? cfg.browser.controlToken.trim() : "";
|
||||
if (browserToken && !looksLikeEnvRef(browserToken)) {
|
||||
findings.push({
|
||||
checkId: "config.secrets.browser_control_token_in_config",
|
||||
severity: "warn",
|
||||
title: "Browser control token is stored in config",
|
||||
detail:
|
||||
"browser.controlToken is set in the config file; prefer environment variables for secrets when possible.",
|
||||
remediation:
|
||||
"Prefer CLAWDBOT_BROWSER_CONTROL_TOKEN (env) and remove browser.controlToken from disk.",
|
||||
});
|
||||
}
|
||||
|
||||
const hooksToken = typeof cfg.hooks?.token === "string" ? cfg.hooks.token.trim() : "";
|
||||
if (cfg.hooks?.enabled === true && hooksToken && !looksLikeEnvRef(hooksToken)) {
|
||||
findings.push({
|
||||
@@ -206,21 +192,6 @@ export function collectHooksHardeningFindings(cfg: ClawdbotConfig): SecurityAudi
|
||||
});
|
||||
}
|
||||
|
||||
const browserToken =
|
||||
typeof cfg.browser?.controlToken === "string" && cfg.browser.controlToken.trim()
|
||||
? cfg.browser.controlToken.trim()
|
||||
: process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim() || null;
|
||||
if (token && browserToken && token === browserToken) {
|
||||
findings.push({
|
||||
checkId: "hooks.token_reuse_browser_token",
|
||||
severity: "warn",
|
||||
title: "Hooks token reuses the browser control token",
|
||||
detail:
|
||||
"hooks.token matches browser control token; compromise of hooks may enable browser control endpoints.",
|
||||
remediation: "Use a separate hooks.token dedicated to hook ingress.",
|
||||
});
|
||||
}
|
||||
|
||||
const rawPath = typeof cfg.hooks?.path === "string" ? cfg.hooks.path.trim() : "";
|
||||
if (rawPath === "/") {
|
||||
findings.push({
|
||||
@@ -457,7 +428,7 @@ function isWebFetchEnabled(cfg: ClawdbotConfig): boolean {
|
||||
|
||||
function isBrowserEnabled(cfg: ClawdbotConfig): boolean {
|
||||
try {
|
||||
return resolveBrowserConfig(cfg.browser).enabled;
|
||||
return resolveBrowserConfig(cfg.browser, cfg).enabled;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -274,41 +274,13 @@ describe("security audit", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("flags remote browser control without token as critical", async () => {
|
||||
const prev = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
|
||||
delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
|
||||
try {
|
||||
const cfg: ClawdbotConfig = {
|
||||
browser: {
|
||||
controlUrl: "http://example.com:18791",
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "browser.control_remote_no_token",
|
||||
severity: "critical",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
|
||||
else process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it("warns when browser control token matches gateway auth token", async () => {
|
||||
const token = "0123456789abcdef0123456789abcdef";
|
||||
it("warns when remote CDP uses HTTP", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
gateway: { auth: { token } },
|
||||
browser: { controlUrl: "https://browser.example.com", controlToken: token },
|
||||
browser: {
|
||||
profiles: {
|
||||
remote: { cdpUrl: "http://example.com:9222", color: "#0066CC" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
@@ -319,42 +291,11 @@ describe("security audit", () => {
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "browser.control_token_reuse_gateway_token",
|
||||
severity: "warn",
|
||||
}),
|
||||
expect.objectContaining({ checkId: "browser.remote_cdp_http", severity: "warn" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when remote browser control uses HTTP", async () => {
|
||||
const prev = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
|
||||
delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
|
||||
try {
|
||||
const cfg: ClawdbotConfig = {
|
||||
browser: {
|
||||
controlUrl: "http://example.com:18791",
|
||||
controlToken: "0123456789abcdef01234567",
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ checkId: "browser.control_remote_http", severity: "warn" }),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
|
||||
else process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it("warns when control UI allows insecure auth", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
gateway: {
|
||||
|
||||
+14
-55
@@ -356,82 +356,41 @@ function collectGatewayConfigFindings(
|
||||
return findings;
|
||||
}
|
||||
|
||||
function isLoopbackClientHost(hostname: string): boolean {
|
||||
const h = hostname.trim().toLowerCase();
|
||||
return h === "localhost" || h === "127.0.0.1" || h === "::1";
|
||||
}
|
||||
|
||||
function collectBrowserControlFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
|
||||
let resolved: ReturnType<typeof resolveBrowserConfig>;
|
||||
try {
|
||||
resolved = resolveBrowserConfig(cfg.browser);
|
||||
resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
} catch (err) {
|
||||
findings.push({
|
||||
checkId: "browser.control_invalid_config",
|
||||
severity: "warn",
|
||||
title: "Browser control config looks invalid",
|
||||
detail: String(err),
|
||||
remediation: `Fix browser.controlUrl/browser.cdpUrl in ${resolveConfigPath()} and re-run "${formatCliCommand("clawdbot security audit --deep")}".`,
|
||||
remediation: `Fix browser.cdpUrl in ${resolveConfigPath()} and re-run "${formatCliCommand("clawdbot security audit --deep")}".`,
|
||||
});
|
||||
return findings;
|
||||
}
|
||||
|
||||
if (!resolved.enabled) return findings;
|
||||
|
||||
const url = new URL(resolved.controlUrl);
|
||||
const isLoopback = isLoopbackClientHost(url.hostname);
|
||||
const envToken = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim();
|
||||
const controlToken = (envToken || resolved.controlToken)?.trim() || null;
|
||||
|
||||
if (!isLoopback) {
|
||||
if (!controlToken) {
|
||||
findings.push({
|
||||
checkId: "browser.control_remote_no_token",
|
||||
severity: "critical",
|
||||
title: "Remote browser control is missing an auth token",
|
||||
detail: `browser.controlUrl is non-loopback (${resolved.controlUrl}) but no browser.controlToken (or CLAWDBOT_BROWSER_CONTROL_TOKEN) is configured.`,
|
||||
remediation:
|
||||
"Set browser.controlToken (or export CLAWDBOT_BROWSER_CONTROL_TOKEN) and prefer serving over Tailscale Serve or HTTPS reverse proxy.",
|
||||
});
|
||||
for (const name of Object.keys(resolved.profiles)) {
|
||||
const profile = resolveProfile(resolved, name);
|
||||
if (!profile || profile.cdpIsLoopback) continue;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(profile.cdpUrl);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (url.protocol === "http:") {
|
||||
findings.push({
|
||||
checkId: "browser.control_remote_http",
|
||||
checkId: "browser.remote_cdp_http",
|
||||
severity: "warn",
|
||||
title: "Remote browser control uses HTTP",
|
||||
detail: `browser.controlUrl=${resolved.controlUrl} is http; this is OK only if it's tailnet-only (Tailscale) or behind another encrypted tunnel.`,
|
||||
remediation: `Prefer HTTPS termination (Tailscale Serve) and keep the endpoint tailnet-only.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (controlToken && controlToken.length < 24) {
|
||||
findings.push({
|
||||
checkId: "browser.control_token_too_short",
|
||||
severity: "warn",
|
||||
title: "Browser control token looks short",
|
||||
detail: `browser control token is ${controlToken.length} chars; prefer a long random token.`,
|
||||
});
|
||||
}
|
||||
|
||||
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
|
||||
const gatewayAuth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode });
|
||||
const gatewayToken =
|
||||
gatewayAuth.mode === "token" &&
|
||||
typeof gatewayAuth.token === "string" &&
|
||||
gatewayAuth.token.trim()
|
||||
? gatewayAuth.token.trim()
|
||||
: null;
|
||||
|
||||
if (controlToken && gatewayToken && controlToken === gatewayToken) {
|
||||
findings.push({
|
||||
checkId: "browser.control_token_reuse_gateway_token",
|
||||
severity: "warn",
|
||||
title: "Browser control token reuses the Gateway token",
|
||||
detail: `browser.controlToken matches gateway.auth token; compromise of browser control expands blast radius to the Gateway API.`,
|
||||
remediation: `Use a separate browser.controlToken dedicated to browser control.`,
|
||||
title: "Remote CDP uses HTTP",
|
||||
detail: `browser profile "${name}" uses http CDP (${profile.cdpUrl}); this is OK only if it's tailnet-only or behind an encrypted tunnel.`,
|
||||
remediation: `Prefer HTTPS/TLS or a tailnet-only endpoint for remote CDP.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user