feat(browser): add remote-capable profiles

Co-authored-by: James Groat <james@groat.com>
This commit is contained in:
Peter Steinberger
2026-01-04 03:32:40 +00:00
parent 0e75aa2716
commit 12ba32c724
30 changed files with 2102 additions and 298 deletions

View File

@@ -16,6 +16,7 @@
- Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes.
- Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC.
- Config: expose schema + UI hints for generic config forms (Web UI + future clients).
- Browser: add multi-profile browser control with per-profile remote CDP URLs — thanks @jamesgroat.
- Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia.
- Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) — thanks @thewilloftheshadow.
- Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning.

View File

@@ -153,6 +153,59 @@ Hooks (arming):
Clawdis should treat "open/closed" as a health check (fast path), not by scanning
global Chrome processes (avoid false positives).
## Multi-profile support
Clawdis supports multiple named browser profiles, each with:
- Dedicated CDP port (auto-allocated from 18800-18899) **or** a per-profile CDP URL
- Persistent user data directory (`~/.clawdis/browser/<name>/user-data/`)
- Unique color for visual distinction
### Configuration
```json
{
"browser": {
"enabled": true,
"defaultProfile": "clawd",
"profiles": {
"clawd": { "cdpPort": 18800, "color": "#FF4500" },
"work": { "cdpPort": 18801, "color": "#0066CC" },
"remote": { "cdpUrl": "http://10.0.0.42:9222", "color": "#00AA00" }
}
}
}
```
### Profile actions
- `GET /profiles` — list all profiles with status
- `POST /profiles/create` `{ name, color?, cdpUrl? }` — create new profile (auto-allocates port if no `cdpUrl`)
- `DELETE /profiles/:name` — delete profile (stops browser + removes user data for local profiles)
- `POST /reset-profile?profile=<name>` — kill orphan process on profile's port (local profiles only)
### Profile parameter
All existing endpoints accept optional `?profile=<name>` query parameter:
- `GET /?profile=work` — status for work profile
- `POST /start?profile=work` — start work profile browser
- `GET /tabs?profile=work` — list tabs for work profile
- etc.
When `profile` is omitted, uses `browser.defaultProfile` (defaults to "clawd").
### Profile naming rules
- Lowercase alphanumeric characters and hyphens only
- Must start with a letter or number (not a hyphen)
- Maximum 64 characters
- Examples: `clawd`, `work`, `my-project-1`
### Port allocation
Ports are allocated from range 18800-18899 (~100 profiles max). This is far more
than practical use — memory and CPU exhaustion occur well before port exhaustion.
Ports are allocated once at profile creation and persisted permanently.
Remote profiles are attach-only and do **not** use the local port range.
## Interaction with the agent (clawd)
The agent should use browser tools only when:
@@ -168,6 +221,13 @@ The agent should not assume tabs are ephemeral. It should:
## CLI quick reference (one example each)
All commands accept `--profile <name>` to target a specific profile (default: `clawd`).
Profile management:
- `clawdis browser profiles`
- `clawdis browser create-profile --name work`
- `clawdis browser create-profile --name remote --cdp-url http://10.0.0.42:9222`
- `clawdis browser delete-profile --name work`
Basics:
- `clawdis browser status`
- `clawdis browser start`

View File

@@ -692,11 +692,16 @@ Example:
### `browser` (clawd-managed Chrome)
Clawdis can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server.
Profiles can point at a **remote** Chrome via `profiles.<name>.cdpUrl`. Remote
profiles are attach-only (start/stop/reset are disabled).
`browser.cdpUrl` remains for legacy single-profile configs and as the base
scheme/host for profiles that only set `cdpPort`.
Defaults:
- enabled: `true`
- control URL: `http://127.0.0.1:18791` (CDP uses `18792`)
- CDP URL: `http://127.0.0.1:18792` (control URL + 1)
- CDP URL: `http://127.0.0.1:18792` (control URL + 1, legacy single-profile)
- profile color: `#FF4500` (lobster-orange)
- Note: the control server is started by the running gateway (Clawdis.app menubar, or `clawdis gateway`).
@@ -705,7 +710,13 @@ Defaults:
browser: {
enabled: true,
controlUrl: "http://127.0.0.1:18791",
// cdpUrl: "http://127.0.0.1:18792", // override for remote CDP
// cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: 18800, color: "#FF4500" },
work: { cdpPort: 18801, color: "#0066CC" },
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }
},
color: "#FF4500",
// Advanced:
// headless: false,

View File

@@ -47,9 +47,23 @@ Core actions:
- `act` (UI actions: click/type/press/hover/drag/select/fill/resize/wait/evaluate)
- `navigate`, `console`, `pdf`, `upload`, `dialog`
Profile management:
- `profiles` — list all browser profiles with status
- `create-profile` — create new profile with auto-allocated port (or `cdpUrl`)
- `delete-profile` — stop browser, delete user data, remove from config (local only)
- `reset-profile` — kill orphan process on profile's port (local only)
Common parameters:
- `controlUrl` (defaults from config)
- `profile` (optional; defaults to `browser.defaultProfile`)
Notes:
- Requires `browser.enabled=true` in `~/.clawdis/clawdis.json`.
- Uses `browser.controlUrl` unless `controlUrl` is passed explicitly.
- All actions accept optional `profile` parameter for multi-instance support.
- When `profile` is omitted, uses `browser.defaultProfile` (defaults to "clawd").
- Profile names: lowercase alphanumeric + hyphens only (max 64 chars).
- Port range: 18800-18899 (~100 profiles max).
- Remote profiles are attach-only (no start/stop/reset).
- `snapshot` defaults to `ai`; use `aria` for the accessibility tree.
- `act` requires `ref` from `snapshot --format ai`; use `evaluate` for rare CSS selector needs.
- Avoid `act``wait` by default; use it only in exceptional cases (no reliable UI state to wait on).

View File

@@ -9,7 +9,10 @@ import {
startBrowserBridgeServer,
stopBrowserBridgeServer,
} from "../browser/bridge-server.js";
import type { ResolvedBrowserConfig } from "../browser/config.js";
import {
type ResolvedBrowserConfig,
resolveProfile,
} from "../browser/config.js";
import { DEFAULT_CLAWD_BROWSER_COLOR } from "../browser/constants.js";
import type { ClawdisConfig } from "../config/config.js";
import { STATE_DIR_CLAWDIS } from "../config/config.js";
@@ -508,21 +511,23 @@ function buildSandboxBrowserResolvedConfig(params: {
const controlHost = "127.0.0.1";
const controlUrl = `http://${controlHost}:${params.controlPort}`;
const cdpHost = "127.0.0.1";
const cdpUrl = `http://${cdpHost}:${params.cdpPort}`;
return {
enabled: true,
controlUrl,
controlHost,
controlPort: params.controlPort,
cdpUrl,
cdpProtocol: "http",
cdpHost,
cdpPort: params.cdpPort,
cdpIsLoopback: true,
color: DEFAULT_CLAWD_BROWSER_COLOR,
executablePath: undefined,
headless: params.headless,
noSandbox: false,
attachOnly: true,
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: params.cdpPort, color: DEFAULT_CLAWD_BROWSER_COLOR },
},
};
}
@@ -600,10 +605,13 @@ async function ensureSandboxBrowser(params: {
: null;
const existing = BROWSER_BRIDGES.get(params.sessionKey);
const existingProfile = existing
? resolveProfile(existing.bridge.state.resolved, "clawd")
: null;
const shouldReuse =
existing &&
existing.containerName === containerName &&
existing.bridge.state.resolved.cdpPort === mappedCdp;
existingProfile?.cdpPort === mappedCdp;
if (existing && !shouldReuse) {
await stopBrowserBridgeServer(existing.bridge.server).catch(
() => undefined,

View File

@@ -30,16 +30,12 @@ export async function startBrowserBridgeServer(params: {
const state: BrowserServerState = {
server: null as unknown as Server,
port,
cdpPort: params.resolved.cdpPort,
running: null,
resolved: params.resolved,
profiles: new Map(),
};
const ctx = createBrowserRouteContext({
getState: () => state,
setRunning: (running) => {
state.running = running;
},
});
registerBrowserRoutes(app, ctx);

View File

@@ -8,7 +8,7 @@ import { ensurePortAvailable } from "../infra/ports.js";
import { createSubsystemLogger } from "../logging.js";
import { CONFIG_DIR } from "../utils.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import type { ResolvedBrowserConfig } from "./config.js";
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
import {
DEFAULT_CLAWD_BROWSER_COLOR,
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
@@ -116,11 +116,13 @@ function resolveBrowserExecutable(
return null;
}
export function resolveClawdUserDataDir() {
export function resolveClawdUserDataDir(
profileName = DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
) {
return path.join(
CONFIG_DIR,
"browser",
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
profileName,
"user-data",
);
}
@@ -255,9 +257,9 @@ function isProfileDecorated(
*/
export function decorateClawdProfile(
userDataDir: string,
opts?: { color?: string },
opts?: { name?: string; color?: string },
) {
const desiredName = DEFAULT_CLAWD_BROWSER_PROFILE_NAME;
const desiredName = opts?.name ?? DEFAULT_CLAWD_BROWSER_PROFILE_NAME;
const desiredColor = (
opts?.color ?? DEFAULT_CLAWD_BROWSER_COLOR
).toUpperCase();
@@ -433,8 +435,14 @@ export async function isChromeCdpReady(
export async function launchClawdChrome(
resolved: ResolvedBrowserConfig,
profile: ResolvedBrowserProfile,
): Promise<RunningChrome> {
await ensurePortAvailable(resolved.cdpPort);
if (!profile.cdpIsLoopback) {
throw new Error(
`Profile "${profile.name}" is remote; cannot launch local Chrome.`,
);
}
await ensurePortAvailable(profile.cdpPort);
const exe = resolveBrowserExecutable(resolved);
if (!exe) {
@@ -443,19 +451,19 @@ export async function launchClawdChrome(
);
}
const userDataDir = resolveClawdUserDataDir();
const userDataDir = resolveClawdUserDataDir(profile.name);
fs.mkdirSync(userDataDir, { recursive: true });
const needsDecorate = !isProfileDecorated(
userDataDir,
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
(resolved.color ?? DEFAULT_CLAWD_BROWSER_COLOR).toUpperCase(),
profile.name,
(profile.color ?? DEFAULT_CLAWD_BROWSER_COLOR).toUpperCase(),
);
// First launch to create preference files if missing, then decorate and relaunch.
const spawnOnce = () => {
const args: string[] = [
`--remote-debugging-port=${resolved.cdpPort}`,
`--remote-debugging-port=${profile.cdpPort}`,
`--user-data-dir=${userDataDir}`,
"--no-first-run",
"--no-default-browser-check",
@@ -521,8 +529,11 @@ export async function launchClawdChrome(
if (needsDecorate) {
try {
decorateClawdProfile(userDataDir, { color: resolved.color });
log.info(`🦞 clawd browser profile decorated (${resolved.color})`);
decorateClawdProfile(userDataDir, {
name: profile.name,
color: profile.color,
});
log.info(`🦞 clawd browser profile decorated (${profile.color})`);
} catch (err) {
log.warn(`clawd browser profile decoration failed: ${String(err)}`);
}
@@ -532,29 +543,31 @@ export async function launchClawdChrome(
// Wait for CDP to come up.
const readyDeadline = Date.now() + 15_000;
while (Date.now() < readyDeadline) {
if (await isChromeReachable(resolved.cdpUrl, 500)) break;
if (await isChromeReachable(profile.cdpUrl, 500)) break;
await new Promise((r) => setTimeout(r, 200));
}
if (!(await isChromeReachable(resolved.cdpUrl, 500))) {
if (!(await isChromeReachable(profile.cdpUrl, 500))) {
try {
proc.kill("SIGKILL");
} catch {
// ignore
}
throw new Error(`Failed to start Chrome CDP on port ${resolved.cdpPort}.`);
throw new Error(
`Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}".`,
);
}
const pid = proc.pid ?? -1;
log.info(
`🦞 clawd browser started (${exe.kind}) on 127.0.0.1:${resolved.cdpPort} (pid ${pid})`,
`🦞 clawd browser started (${exe.kind}) profile "${profile.name}" on 127.0.0.1:${profile.cdpPort} (pid ${pid})`,
);
return {
pid,
exe,
userDataDir,
cdpPort: resolved.cdpPort,
cdpPort: profile.cdpPort,
startedAt,
proc,
};

View File

@@ -5,6 +5,10 @@ import type {
} from "./client-actions-types.js";
import { fetchBrowserJson } from "./client-fetch.js";
function buildProfileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
export type BrowserFormField = {
ref: string;
type: string;
@@ -57,14 +61,18 @@ export type BrowserActResponse = {
export async function browserNavigate(
baseUrl: string,
opts: { url: string; targetId?: string },
opts: { url: string; targetId?: string; profile?: string },
): Promise<BrowserActionTabResult> {
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/navigate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: opts.url, targetId: opts.targetId }),
timeoutMs: 20000,
});
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTabResult>(
`${baseUrl}/navigate${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: opts.url, targetId: opts.targetId }),
timeoutMs: 20000,
},
);
}
export async function browserArmDialog(
@@ -74,19 +82,24 @@ export async function browserArmDialog(
promptText?: string;
targetId?: string;
timeoutMs?: number;
profile?: string;
},
): Promise<BrowserActionOk> {
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/hooks/dialog`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
accept: opts.accept,
promptText: opts.promptText,
targetId: opts.targetId,
timeoutMs: opts.timeoutMs,
}),
timeoutMs: 20000,
});
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(
`${baseUrl}/hooks/dialog${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
accept: opts.accept,
promptText: opts.promptText,
targetId: opts.targetId,
timeoutMs: opts.timeoutMs,
}),
timeoutMs: 20000,
},
);
}
export async function browserArmFileChooser(
@@ -98,10 +111,12 @@ export async function browserArmFileChooser(
element?: string;
targetId?: string;
timeoutMs?: number;
profile?: string;
},
): Promise<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(
`${baseUrl}/hooks/file-chooser`,
`${baseUrl}/hooks/file-chooser${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -121,8 +136,10 @@ export async function browserArmFileChooser(
export async function browserAct(
baseUrl: string,
req: BrowserActRequest,
opts?: { profile?: string },
): Promise<BrowserActResponse> {
return await fetchBrowserJson<BrowserActResponse>(`${baseUrl}/act`, {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserActResponse>(`${baseUrl}/act${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
@@ -138,10 +155,12 @@ export async function browserScreenshotAction(
ref?: string;
element?: string;
type?: "png" | "jpeg";
profile?: string;
},
): Promise<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(
`${baseUrl}/screenshot`,
`${baseUrl}/screenshot${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },

View File

@@ -2,13 +2,18 @@ import type { BrowserActionPathResult } from "./client-actions-types.js";
import { fetchBrowserJson } from "./client-fetch.js";
import type { BrowserConsoleMessage } from "./pw-session.js";
function buildProfileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
export async function browserConsoleMessages(
baseUrl: string,
opts: { level?: string; targetId?: string } = {},
opts: { level?: string; targetId?: string; profile?: string } = {},
): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string }> {
const q = new URLSearchParams();
if (opts.level) q.set("level", opts.level);
if (opts.targetId) q.set("targetId", opts.targetId);
if (opts.profile) q.set("profile", opts.profile);
const suffix = q.toString() ? `?${q.toString()}` : "";
return await fetchBrowserJson<{
ok: true;
@@ -19,12 +24,16 @@ export async function browserConsoleMessages(
export async function browserPdfSave(
baseUrl: string,
opts: { targetId?: string } = {},
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionPathResult> {
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/pdf`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
timeoutMs: 20000,
});
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(
`${baseUrl}/pdf${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
timeoutMs: 20000,
},
);
}

View File

@@ -5,6 +5,7 @@ import { resolveBrowserConfig } from "./config.js";
export type BrowserStatus = {
enabled: boolean;
controlUrl: string;
profile?: string;
running: boolean;
cdpReady?: boolean;
cdpHttp?: boolean;
@@ -20,6 +21,17 @@ export type BrowserStatus = {
attachOnly: boolean;
};
export type ProfileStatus = {
name: string;
cdpPort: number;
cdpUrl: string;
color: string;
running: boolean;
tabCount: number;
isDefault: boolean;
isRemote: boolean;
};
export type BrowserResetProfileResult = {
ok: true;
moved: boolean;
@@ -31,6 +43,7 @@ export type BrowserTab = {
targetId: string;
title: string;
url: string;
wsUrl?: string;
type?: string;
};
@@ -67,21 +80,47 @@ export function resolveBrowserControlUrl(overrideUrl?: string) {
return url.replace(/\/$/, "");
}
export async function browserStatus(baseUrl: string): Promise<BrowserStatus> {
return await fetchBrowserJson<BrowserStatus>(`${baseUrl}/`, {
function buildProfileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
export async function browserStatus(
baseUrl: string,
opts?: { profile?: string },
): Promise<BrowserStatus> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserStatus>(`${baseUrl}/${q}`, {
timeoutMs: 1500,
});
}
export async function browserStart(baseUrl: string): Promise<void> {
await fetchBrowserJson(`${baseUrl}/start`, {
export async function browserProfiles(
baseUrl: string,
): Promise<ProfileStatus[]> {
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(
`${baseUrl}/profiles`,
{ timeoutMs: 3000 },
);
return res.profiles ?? [];
}
export async function browserStart(
baseUrl: string,
opts?: { profile?: string },
): Promise<void> {
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(`${baseUrl}/start${q}`, {
method: "POST",
timeoutMs: 15000,
});
}
export async function browserStop(baseUrl: string): Promise<void> {
await fetchBrowserJson(`${baseUrl}/stop`, {
export async function browserStop(
baseUrl: string,
opts?: { profile?: string },
): Promise<void> {
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(`${baseUrl}/stop${q}`, {
method: "POST",
timeoutMs: 15000,
});
@@ -89,9 +128,11 @@ export async function browserStop(baseUrl: string): Promise<void> {
export async function browserResetProfile(
baseUrl: string,
opts?: { profile?: string },
): Promise<BrowserResetProfileResult> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserResetProfileResult>(
`${baseUrl}/reset-profile`,
`${baseUrl}/reset-profile${q}`,
{
method: "POST",
timeoutMs: 20000,
@@ -99,9 +140,60 @@ export async function browserResetProfile(
);
}
export async function browserTabs(baseUrl: string): Promise<BrowserTab[]> {
export type BrowserCreateProfileResult = {
ok: true;
profile: string;
cdpPort: number;
cdpUrl: string;
color: string;
isRemote: boolean;
};
export async function browserCreateProfile(
baseUrl: string,
opts: { name: string; color?: string; cdpUrl?: string },
): 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,
}),
timeoutMs: 10000,
},
);
}
export type BrowserDeleteProfileResult = {
ok: true;
profile: string;
deleted: boolean;
};
export async function browserDeleteProfile(
baseUrl: string,
profile: string,
): Promise<BrowserDeleteProfileResult> {
return await fetchBrowserJson<BrowserDeleteProfileResult>(
`${baseUrl}/profiles/${encodeURIComponent(profile)}`,
{
method: "DELETE",
timeoutMs: 20000,
},
);
}
export async function browserTabs(
baseUrl: string,
opts?: { profile?: string },
): Promise<BrowserTab[]> {
const q = buildProfileQuery(opts?.profile);
const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>(
`${baseUrl}/tabs`,
`${baseUrl}/tabs${q}`,
{ timeoutMs: 3000 },
);
return res.tabs ?? [];
@@ -110,8 +202,10 @@ export async function browserTabs(baseUrl: string): Promise<BrowserTab[]> {
export async function browserOpenTab(
baseUrl: string,
url: string,
opts?: { profile?: string },
): Promise<BrowserTab> {
return await fetchBrowserJson<BrowserTab>(`${baseUrl}/tabs/open`, {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserTab>(`${baseUrl}/tabs/open${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
@@ -122,8 +216,10 @@ export async function browserOpenTab(
export async function browserFocusTab(
baseUrl: string,
targetId: string,
opts?: { profile?: string },
): Promise<void> {
await fetchBrowserJson(`${baseUrl}/tabs/focus`, {
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(`${baseUrl}/tabs/focus${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId }),
@@ -134,11 +230,16 @@ export async function browserFocusTab(
export async function browserCloseTab(
baseUrl: string,
targetId: string,
opts?: { profile?: string },
): Promise<void> {
await fetchBrowserJson(`${baseUrl}/tabs/${encodeURIComponent(targetId)}`, {
method: "DELETE",
timeoutMs: 5000,
});
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(
`${baseUrl}/tabs/${encodeURIComponent(targetId)}${q}`,
{
method: "DELETE",
timeoutMs: 5000,
},
);
}
export async function browserSnapshot(
@@ -147,12 +248,14 @@ export async function browserSnapshot(
format: "aria" | "ai";
targetId?: string;
limit?: number;
profile?: string;
},
): Promise<SnapshotResult> {
const q = new URLSearchParams();
q.set("format", opts.format);
if (opts.targetId) q.set("targetId", opts.targetId);
if (typeof opts.limit === "number") q.set("limit", String(opts.limit));
if (opts.profile) q.set("profile", opts.profile);
return await fetchBrowserJson<SnapshotResult>(
`${baseUrl}/snapshot?${q.toString()}`,
{

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
resolveBrowserConfig,
resolveProfile,
shouldStartLocalBrowserServer,
} from "./config.js";
@@ -9,11 +10,13 @@ describe("browser config", () => {
const resolved = resolveBrowserConfig(undefined);
expect(resolved.enabled).toBe(true);
expect(resolved.controlPort).toBe(18791);
expect(resolved.cdpPort).toBe(18792);
expect(resolved.cdpUrl).toBe("http://127.0.0.1:18792");
expect(resolved.controlHost).toBe("127.0.0.1");
expect(resolved.color).toBe("#FF4500");
expect(shouldStartLocalBrowserServer(resolved)).toBe(true);
const profile = resolveProfile(resolved, resolved.defaultProfile);
expect(profile?.cdpPort).toBe(18800);
expect(profile?.cdpUrl).toBe("http://127.0.0.1:18800");
expect(profile?.cdpIsLoopback).toBe(true);
});
it("normalizes hex colors", () => {
@@ -39,23 +42,51 @@ describe("browser config", () => {
expect(shouldStartLocalBrowserServer(resolved)).toBe(false);
});
it("derives CDP port as control port + 1", () => {
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.cdpPort).toBe(19001);
expect(resolved.cdpUrl).toBe("http://127.0.0.1:19001");
expect(resolved.cdpHost).toBe("127.0.0.1");
expect(resolved.cdpProtocol).toBe("http");
});
it("supports explicit CDP URLs", () => {
it("supports explicit CDP URLs for the default profile", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
cdpUrl: "http://example.com:9222",
});
expect(resolved.cdpPort).toBe(9222);
expect(resolved.cdpUrl).toBe("http://example.com:9222");
expect(resolved.cdpIsLoopback).toBe(false);
const profile = resolveProfile(resolved, resolved.defaultProfile);
expect(profile?.cdpPort).toBe(9222);
expect(profile?.cdpUrl).toBe("http://example.com:9222");
expect(profile?.cdpIsLoopback).toBe(false);
});
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" },
},
});
const remote = resolveProfile(resolved, "remote");
expect(remote?.cdpUrl).toBe("http://10.0.0.42:9222");
expect(remote?.cdpHost).toBe("10.0.0.42");
expect(remote?.cdpIsLoopback).toBe(false);
});
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" },
},
});
const work = resolveProfile(resolved, "work");
expect(work?.cdpUrl).toBe("https://example.com:18801");
});
it("rejects unsupported protocols", () => {

View File

@@ -1,24 +1,36 @@
import type { BrowserConfig } from "../config/config.js";
import type { BrowserConfig, BrowserProfileConfig } from "../config/config.js";
import {
DEFAULT_CLAWD_BROWSER_COLOR,
DEFAULT_CLAWD_BROWSER_CONTROL_URL,
DEFAULT_CLAWD_BROWSER_ENABLED,
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
} from "./constants.js";
import { CDP_PORT_RANGE_START } from "./profiles.js";
export type ResolvedBrowserConfig = {
enabled: boolean;
controlUrl: string;
controlHost: string;
controlPort: number;
cdpUrl: string;
cdpProtocol: "http" | "https";
cdpHost: string;
cdpPort: number;
cdpIsLoopback: boolean;
color: string;
executablePath?: string;
headless: boolean;
noSandbox: boolean;
attachOnly: boolean;
defaultProfile: string;
profiles: Record<string, BrowserProfileConfig>;
};
export type ResolvedBrowserProfile = {
name: string;
cdpPort: number;
cdpUrl: string;
cdpHost: string;
cdpIsLoopback: boolean;
color: string;
};
function isLoopbackHost(host: string) {
@@ -42,7 +54,7 @@ function normalizeHexColor(raw: string | undefined) {
return normalized.toUpperCase();
}
function parseHttpUrl(raw: string, label: string) {
export function parseHttpUrl(raw: string, label: string) {
const trimmed = raw.trim();
const parsed = new URL(trimmed);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
@@ -69,6 +81,24 @@ function parseHttpUrl(raw: string, label: string) {
};
}
/**
* Ensure the default "clawd" profile exists in the profiles map.
* Auto-creates it with the legacy CDP port (from browser.cdpUrl) or first port if missing.
*/
function ensureDefaultProfile(
profiles: Record<string, BrowserProfileConfig> | undefined,
defaultColor: string,
legacyCdpPort?: number,
): Record<string, BrowserProfileConfig> {
const result = { ...profiles };
if (!result[DEFAULT_CLAWD_BROWSER_PROFILE_NAME]) {
result[DEFAULT_CLAWD_BROWSER_PROFILE_NAME] = {
cdpPort: legacyCdpPort ?? CDP_PORT_RANGE_START,
color: defaultColor,
};
}
return result;
}
export function resolveBrowserConfig(
cfg: BrowserConfig | undefined,
): ResolvedBrowserConfig {
@@ -78,6 +108,7 @@ export function resolveBrowserConfig(
"browser.controlUrl",
);
const controlPort = controlInfo.port;
const defaultColor = normalizeHexColor(cfg?.color);
const rawCdpUrl = (cfg?.cdpUrl ?? "").trim();
let cdpInfo:
@@ -105,26 +136,77 @@ export function resolveBrowserConfig(
};
}
const cdpPort = cdpInfo.port;
const headless = cfg?.headless === true;
const noSandbox = cfg?.noSandbox === true;
const attachOnly = cfg?.attachOnly === true;
const executablePath = cfg?.executablePath?.trim() || undefined;
const defaultProfile =
cfg?.defaultProfile ?? DEFAULT_CLAWD_BROWSER_PROFILE_NAME;
// Use legacy cdpUrl port for backward compatibility when no profiles configured
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
const profiles = ensureDefaultProfile(
cfg?.profiles,
defaultColor,
legacyCdpPort,
);
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";
return {
enabled,
controlUrl: controlInfo.normalized,
controlHost: controlInfo.parsed.hostname,
controlPort,
cdpUrl: cdpInfo.normalized,
cdpProtocol,
cdpHost: cdpInfo.parsed.hostname,
cdpPort,
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
color: normalizeHexColor(cfg?.color),
color: defaultColor,
executablePath,
headless,
noSandbox,
attachOnly,
defaultProfile,
profiles,
};
}
/**
* Resolve a profile by name from the config.
* Returns null if the profile doesn't exist.
*/
export function resolveProfile(
resolved: ResolvedBrowserConfig,
profileName: string,
): ResolvedBrowserProfile | null {
const profile = resolved.profiles[profileName];
if (!profile) return null;
const rawProfileUrl = profile.cdpUrl?.trim() ?? "";
let cdpHost = resolved.cdpHost;
let cdpPort = profile.cdpPort ?? 0;
let cdpUrl = "";
if (rawProfileUrl) {
const parsed = parseHttpUrl(
rawProfileUrl,
`browser.profiles.${profileName}.cdpUrl`,
);
cdpHost = parsed.parsed.hostname;
cdpPort = parsed.port;
cdpUrl = parsed.normalized;
} else if (cdpPort) {
cdpUrl = `${resolved.cdpProtocol}://${resolved.cdpHost}:${cdpPort}`;
} else {
throw new Error(`Profile "${profileName}" must define cdpPort or cdpUrl.`);
}
return {
name: profileName,
cdpPort,
cdpUrl,
cdpHost,
cdpIsLoopback: isLoopbackHost(cdpHost),
color: profile.color,
};
}

View File

@@ -0,0 +1,154 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveBrowserConfig } from "./config.js";
import { createBrowserProfilesService } from "./profiles-service.js";
import type {
BrowserRouteContext,
BrowserServerState,
} from "./server-context.js";
vi.mock("../config/config.js", () => ({
loadConfig: vi.fn(),
writeConfigFile: vi.fn(async () => {}),
}));
vi.mock("./trash.js", () => ({
movePathToTrash: vi.fn(async (targetPath: string) => targetPath),
}));
vi.mock("./chrome.js", () => ({
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd-test/clawd/user-data"),
}));
import { loadConfig, writeConfigFile } from "../config/config.js";
import { resolveClawdUserDataDir } from "./chrome.js";
import { movePathToTrash } from "./trash.js";
function createCtx(resolved: BrowserServerState["resolved"]) {
const state: BrowserServerState = {
server: null as unknown as BrowserServerState["server"],
port: 0,
resolved,
profiles: new Map(),
};
const ctx = {
state: () => state,
listProfiles: vi.fn(async () => []),
forProfile: vi.fn(() => ({
stopRunningBrowser: vi.fn(async () => ({ stopped: true })),
})),
} as unknown as BrowserRouteContext;
return { state, ctx };
}
describe("BrowserProfilesService", () => {
it("allocates next local port for new profiles", async () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
});
const { ctx, state } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({ name: "work" });
expect(result.cdpPort).toBe(18801);
expect(result.isRemote).toBe(false);
expect(state.resolved.profiles.work?.cdpPort).toBe(18801);
expect(writeConfigFile).toHaveBeenCalled();
});
it("accepts per-profile cdpUrl for remote Chrome", async () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({
name: "remote",
cdpUrl: "http://10.0.0.42:9222",
});
expect(result.cdpUrl).toBe("http://10.0.0.42:9222");
expect(result.cdpPort).toBe(9222);
expect(result.isRemote).toBe(true);
expect(writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
browser: expect.objectContaining({
profiles: expect.objectContaining({
remote: expect.objectContaining({
cdpUrl: "http://10.0.0.42:9222",
}),
}),
}),
}),
);
});
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" },
},
});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({
browser: {
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: 18800, color: "#FF4500" },
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#0066CC" },
},
},
});
const service = createBrowserProfilesService(ctx);
const result = await service.deleteProfile("remote");
expect(result.deleted).toBe(false);
expect(ctx.forProfile).not.toHaveBeenCalled();
expect(movePathToTrash).not.toHaveBeenCalled();
});
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" },
},
});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({
browser: {
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: 18800, color: "#FF4500" },
work: { cdpPort: 18801, color: "#0066CC" },
},
},
});
const tempDir = fs.mkdtempSync(path.join("/tmp", "clawd-profile-"));
const userDataDir = path.join(tempDir, "work", "user-data");
fs.mkdirSync(path.dirname(userDataDir), { recursive: true });
vi.mocked(resolveClawdUserDataDir).mockReturnValue(userDataDir);
const service = createBrowserProfilesService(ctx);
const result = await service.deleteProfile("work");
expect(result.deleted).toBe(true);
expect(movePathToTrash).toHaveBeenCalledWith(path.dirname(userDataDir));
});
});

View File

@@ -0,0 +1,181 @@
import fs from "node:fs";
import path from "node:path";
import type { BrowserProfileConfig, ClawdisConfig } from "../config/config.js";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { resolveClawdUserDataDir } from "./chrome.js";
import { parseHttpUrl, resolveProfile } from "./config.js";
import {
allocateCdpPort,
allocateColor,
getUsedColors,
getUsedPorts,
isValidProfileName,
} from "./profiles.js";
import type { BrowserRouteContext, ProfileStatus } from "./server-context.js";
import { movePathToTrash } from "./trash.js";
export type CreateProfileParams = {
name: string;
color?: string;
cdpUrl?: string;
};
export type CreateProfileResult = {
ok: true;
profile: string;
cdpPort: number;
cdpUrl: string;
color: string;
isRemote: boolean;
};
export type DeleteProfileResult = {
ok: true;
profile: string;
deleted: boolean;
};
const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
export function createBrowserProfilesService(ctx: BrowserRouteContext) {
const listProfiles = async (): Promise<ProfileStatus[]> => {
return await ctx.listProfiles();
};
const createProfile = async (
params: CreateProfileParams,
): Promise<CreateProfileResult> => {
const name = params.name.trim();
const rawCdpUrl = params.cdpUrl?.trim() || undefined;
if (!isValidProfileName(name)) {
throw new Error(
"invalid profile name: use lowercase letters, numbers, and hyphens only",
);
}
const state = ctx.state();
const resolvedProfiles = state.resolved.profiles;
if (name in resolvedProfiles) {
throw new Error(`profile "${name}" already exists`);
}
const cfg = loadConfig();
const rawProfiles = cfg.browser?.profiles ?? {};
if (name in rawProfiles) {
throw new Error(`profile "${name}" already exists`);
}
const usedColors = getUsedColors(resolvedProfiles);
const profileColor =
params.color && HEX_COLOR_RE.test(params.color)
? params.color
: allocateColor(usedColors);
let profileConfig: BrowserProfileConfig;
if (rawCdpUrl) {
const parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl");
profileConfig = { cdpUrl: parsed.normalized, color: profileColor };
} else {
const usedPorts = getUsedPorts(resolvedProfiles);
const cdpPort = allocateCdpPort(usedPorts);
if (cdpPort === null) {
throw new Error("no available CDP ports in range");
}
profileConfig = { cdpPort, color: profileColor };
}
const nextConfig: ClawdisConfig = {
...cfg,
browser: {
...cfg.browser,
profiles: {
...rawProfiles,
[name]: profileConfig,
},
},
};
await writeConfigFile(nextConfig);
state.resolved.profiles[name] = profileConfig;
const resolved = resolveProfile(state.resolved, name);
if (!resolved) {
throw new Error(`profile "${name}" not found after creation`);
}
return {
ok: true,
profile: name,
cdpPort: resolved.cdpPort,
cdpUrl: resolved.cdpUrl,
color: resolved.color,
isRemote: !resolved.cdpIsLoopback,
};
};
const deleteProfile = async (
nameRaw: string,
): Promise<DeleteProfileResult> => {
const name = nameRaw.trim();
if (!name) throw new Error("profile name is required");
if (!isValidProfileName(name)) {
throw new Error("invalid profile name");
}
const cfg = loadConfig();
const profiles = cfg.browser?.profiles ?? {};
if (!(name in profiles)) {
throw new Error(`profile "${name}" not found`);
}
const defaultProfile = cfg.browser?.defaultProfile ?? "clawd";
if (name === defaultProfile) {
throw new Error(
`cannot delete the default profile "${name}"; change browser.defaultProfile first`,
);
}
let deleted = false;
const state = ctx.state();
const resolved = resolveProfile(state.resolved, name);
if (resolved?.cdpIsLoopback) {
try {
await ctx.forProfile(name).stopRunningBrowser();
} catch {
// ignore
}
const userDataDir = resolveClawdUserDataDir(name);
const profileDir = path.dirname(userDataDir);
if (fs.existsSync(profileDir)) {
await movePathToTrash(profileDir);
deleted = true;
}
}
const { [name]: _removed, ...remainingProfiles } = profiles;
const nextConfig: ClawdisConfig = {
...cfg,
browser: {
...cfg.browser,
profiles: remainingProfiles,
},
};
await writeConfigFile(nextConfig);
delete state.resolved.profiles[name];
state.profiles.delete(name);
return { ok: true, profile: name, deleted };
};
return {
listProfiles,
createProfile,
deleteProfile,
};
}

View File

@@ -0,0 +1,240 @@
import { describe, expect, it } from "vitest";
import {
allocateCdpPort,
allocateColor,
CDP_PORT_RANGE_END,
CDP_PORT_RANGE_START,
getUsedColors,
getUsedPorts,
isValidProfileName,
PROFILE_COLORS,
} from "./profiles.js";
describe("profile name validation", () => {
it("accepts valid lowercase names", () => {
expect(isValidProfileName("clawd")).toBe(true);
expect(isValidProfileName("work")).toBe(true);
expect(isValidProfileName("my-profile")).toBe(true);
expect(isValidProfileName("test123")).toBe(true);
expect(isValidProfileName("a")).toBe(true);
expect(isValidProfileName("a-b-c-1-2-3")).toBe(true);
expect(isValidProfileName("1test")).toBe(true);
});
it("rejects empty or missing names", () => {
expect(isValidProfileName("")).toBe(false);
// @ts-expect-error testing invalid input
expect(isValidProfileName(null)).toBe(false);
// @ts-expect-error testing invalid input
expect(isValidProfileName(undefined)).toBe(false);
});
it("rejects names that are too long", () => {
const longName = "a".repeat(65);
expect(isValidProfileName(longName)).toBe(false);
const maxName = "a".repeat(64);
expect(isValidProfileName(maxName)).toBe(true);
});
it("rejects uppercase letters", () => {
expect(isValidProfileName("MyProfile")).toBe(false);
expect(isValidProfileName("PROFILE")).toBe(false);
expect(isValidProfileName("Work")).toBe(false);
});
it("rejects spaces and special characters", () => {
expect(isValidProfileName("my profile")).toBe(false);
expect(isValidProfileName("my_profile")).toBe(false);
expect(isValidProfileName("my.profile")).toBe(false);
expect(isValidProfileName("my/profile")).toBe(false);
expect(isValidProfileName("my@profile")).toBe(false);
});
it("rejects names starting with hyphen", () => {
expect(isValidProfileName("-invalid")).toBe(false);
expect(isValidProfileName("--double")).toBe(false);
});
});
describe("port allocation", () => {
it("allocates first port when none used", () => {
const usedPorts = new Set<number>();
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START);
});
it("skips used ports and returns next available", () => {
const usedPorts = new Set([CDP_PORT_RANGE_START, CDP_PORT_RANGE_START + 1]);
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START + 2);
});
it("finds first gap in used ports", () => {
const usedPorts = new Set([
CDP_PORT_RANGE_START,
CDP_PORT_RANGE_START + 2, // gap at +1
]);
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START + 1);
});
it("returns null when all ports are exhausted", () => {
const usedPorts = new Set<number>();
for (let port = CDP_PORT_RANGE_START; port <= CDP_PORT_RANGE_END; port++) {
usedPorts.add(port);
}
expect(allocateCdpPort(usedPorts)).toBeNull();
});
it("handles ports outside range in used set", () => {
const usedPorts = new Set([1, 2, 3, 50000]); // ports outside range
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START);
});
});
describe("getUsedPorts", () => {
it("returns empty set for undefined profiles", () => {
expect(getUsedPorts(undefined)).toEqual(new Set());
});
it("returns empty set for empty profiles object", () => {
expect(getUsedPorts({})).toEqual(new Set());
});
it("extracts ports from profile configs", () => {
const profiles = {
clawd: { cdpPort: 18792 },
work: { cdpPort: 18793 },
personal: { cdpPort: 18795 },
};
const used = getUsedPorts(profiles);
expect(used).toEqual(new Set([18792, 18793, 18795]));
});
it("extracts ports from cdpUrl when cdpPort is missing", () => {
const profiles = {
remote: { cdpUrl: "http://10.0.0.42:9222" },
secure: { cdpUrl: "https://example.com:9443" },
};
const used = getUsedPorts(profiles);
expect(used).toEqual(new Set([9222, 9443]));
});
it("ignores invalid cdpUrl values", () => {
const profiles = {
bad: { cdpUrl: "notaurl" },
};
const used = getUsedPorts(profiles);
expect(used.size).toBe(0);
});
});
describe("port collision prevention", () => {
it("raw config vs resolved config - shows the data source difference", async () => {
// This demonstrates WHY the route handler must use resolved config
const { resolveBrowserConfig } = await import("./config.js");
// Fresh config with no profiles defined (like a new install)
const rawConfigProfiles = undefined;
const usedFromRaw = getUsedPorts(rawConfigProfiles);
// Raw config shows empty - no ports used
expect(usedFromRaw.size).toBe(0);
// But resolved config has implicit clawd at 18800
const resolved = resolveBrowserConfig({});
const usedFromResolved = getUsedPorts(resolved.profiles);
expect(usedFromResolved.has(CDP_PORT_RANGE_START)).toBe(true);
});
it("create-profile must use resolved config to avoid port collision", async () => {
// The route handler must use state.resolved.profiles, not raw config
const { resolveBrowserConfig } = await import("./config.js");
// Simulate what happens with raw config (empty) vs resolved config
const rawConfig = { browser: {} }; // Fresh config, no profiles
const buggyUsedPorts = getUsedPorts(rawConfig.browser?.profiles);
const buggyAllocatedPort = allocateCdpPort(buggyUsedPorts);
// Raw config: first allocation gets 18800
expect(buggyAllocatedPort).toBe(CDP_PORT_RANGE_START);
// Resolved config: includes implicit clawd at 18800
const resolved = resolveBrowserConfig(rawConfig.browser);
const fixedUsedPorts = getUsedPorts(resolved.profiles);
const fixedAllocatedPort = allocateCdpPort(fixedUsedPorts);
// Resolved: first NEW profile gets 18801, avoiding collision
expect(fixedAllocatedPort).toBe(CDP_PORT_RANGE_START + 1);
});
});
describe("color allocation", () => {
it("allocates first color when none used", () => {
const usedColors = new Set<string>();
expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[0]);
});
it("allocates next unused color from palette", () => {
// biome-ignore lint/style/noNonNullAssertion: Test file with known array
const usedColors = new Set([PROFILE_COLORS[0]!.toUpperCase()]);
expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[1]);
});
it("skips multiple used colors", () => {
const usedColors = new Set([
// biome-ignore lint/style/noNonNullAssertion: Test file with known array
PROFILE_COLORS[0]!.toUpperCase(),
// biome-ignore lint/style/noNonNullAssertion: Test file with known array
PROFILE_COLORS[1]!.toUpperCase(),
// biome-ignore lint/style/noNonNullAssertion: Test file with known array
PROFILE_COLORS[2]!.toUpperCase(),
]);
expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[3]);
});
it("handles case-insensitive color matching", () => {
const usedColors = new Set(["#ff4500"]); // lowercase
// Should still skip this color (case-insensitive)
// Note: allocateColor compares against uppercase, so lowercase won't match
// This tests the current behavior
expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[0]); // returns first since lowercase doesn't match
});
it("cycles when all colors are used", () => {
const usedColors = new Set(PROFILE_COLORS.map((c) => c.toUpperCase()));
// Should cycle based on count
const result = allocateColor(usedColors);
expect(PROFILE_COLORS).toContain(result);
});
it("cycles based on count when palette exhausted", () => {
// Add all colors plus some extras
const usedColors = new Set([
...PROFILE_COLORS.map((c) => c.toUpperCase()),
"#AAAAAA",
"#BBBBBB",
]);
const result = allocateColor(usedColors);
// Index should be (10 + 2) % 10 = 2
expect(result).toBe(PROFILE_COLORS[2]);
});
});
describe("getUsedColors", () => {
it("returns empty set for undefined profiles", () => {
expect(getUsedColors(undefined)).toEqual(new Set());
});
it("returns empty set for empty profiles object", () => {
expect(getUsedColors({})).toEqual(new Set());
});
it("extracts and uppercases colors from profile configs", () => {
const profiles = {
clawd: { color: "#ff4500" },
work: { color: "#0066CC" },
};
const used = getUsedColors(profiles);
expect(used).toEqual(new Set(["#FF4500", "#0066CC"]));
});
});

92
src/browser/profiles.ts Normal file
View File

@@ -0,0 +1,92 @@
/**
* CDP port allocation for browser profiles.
*
* Port range: 18800-18899 (100 profiles max)
* Ports are allocated once at profile creation and persisted in config.
*
* Reserved ports (do not use for CDP):
* 18789 - Gateway WebSocket
* 18790 - Bridge
* 18791 - Browser control server
* 18792-18799 - Reserved for future one-off services (canvas at 18793)
*/
export const CDP_PORT_RANGE_START = 18800;
export const CDP_PORT_RANGE_END = 18899;
export const PROFILE_NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/;
export function isValidProfileName(name: string): boolean {
if (!name || name.length > 64) return false;
return PROFILE_NAME_REGEX.test(name);
}
export function allocateCdpPort(usedPorts: Set<number>): number | null {
for (let port = CDP_PORT_RANGE_START; port <= CDP_PORT_RANGE_END; port++) {
if (!usedPorts.has(port)) return port;
}
return null;
}
export function getUsedPorts(
profiles: Record<string, { cdpPort?: number; cdpUrl?: string }> | undefined,
): Set<number> {
if (!profiles) return new Set();
const used = new Set<number>();
for (const profile of Object.values(profiles)) {
if (typeof profile.cdpPort === "number") {
used.add(profile.cdpPort);
continue;
}
const rawUrl = profile.cdpUrl?.trim();
if (!rawUrl) continue;
try {
const parsed = new URL(rawUrl);
const port =
parsed.port && Number.parseInt(parsed.port, 10) > 0
? Number.parseInt(parsed.port, 10)
: parsed.protocol === "https:"
? 443
: 80;
if (!Number.isNaN(port) && port > 0 && port <= 65535) {
used.add(port);
}
} catch {
// ignore invalid URLs
}
}
return used;
}
export const PROFILE_COLORS = [
"#FF4500", // Orange-red (clawd default)
"#0066CC", // Blue
"#00AA00", // Green
"#9933FF", // Purple
"#FF6699", // Pink
"#00CCCC", // Cyan
"#FF9900", // Orange
"#6666FF", // Indigo
"#CC3366", // Magenta
"#339966", // Teal
];
export function allocateColor(usedColors: Set<string>): string {
// Find first unused color from palette
for (const color of PROFILE_COLORS) {
if (!usedColors.has(color.toUpperCase())) {
return color;
}
}
// All colors used, cycle based on count
const index = usedColors.size % PROFILE_COLORS.length;
// biome-ignore lint/style/noNonNullAssertion: Array is non-empty constant
return PROFILE_COLORS[index] ?? PROFILE_COLORS[0]!;
}
export function getUsedColors(
profiles: Record<string, { color: string }> | undefined,
): Set<string> {
if (!profiles) return new Set();
return new Set(Object.values(profiles).map((p) => p.color.toUpperCase()));
}

View File

@@ -10,8 +10,9 @@ import {
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
normalizeBrowserScreenshot,
} from "../screenshot.js";
import type { BrowserRouteContext } from "../server-context.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import {
getProfileContext,
jsonError,
toBoolean,
toNumber,
@@ -61,6 +62,19 @@ function handleRouteError(
jsonError(res, 500, String(err));
}
function resolveProfileContext(
req: express.Request,
res: express.Response,
ctx: BrowserRouteContext,
): ProfileContext | null {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
jsonError(res, profileCtx.status, profileCtx.error);
return null;
}
return profileCtx;
}
function parseClickButton(raw: string): ClickButton | undefined {
if (raw === "left" || raw === "right" || raw === "middle") return raw;
return undefined;
@@ -100,16 +114,18 @@ export function registerBrowserAgentRoutes(
ctx: BrowserRouteContext,
) {
app.post("/navigate", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const url = toStringOrEmpty(body.url);
const targetId = toStringOrEmpty(body.targetId) || undefined;
if (!url) return jsonError(res, 400, "url is required");
try {
const tab = await ctx.ensureTabAvailable(targetId);
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "navigate");
if (!pw) return;
const result = await pw.navigateViaPlaywright({
cdpUrl: ctx.state().resolved.cdpUrl,
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
url,
});
@@ -120,6 +136,8 @@ export function registerBrowserAgentRoutes(
});
app.post("/act", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const kind = toStringOrEmpty(body.kind) as ActKind;
const targetId = toStringOrEmpty(body.targetId) || undefined;
@@ -144,8 +162,8 @@ export function registerBrowserAgentRoutes(
}
try {
const tab = await ctx.ensureTabAvailable(targetId);
const cdpUrl = ctx.state().resolved.cdpUrl;
const tab = await profileCtx.ensureTabAvailable(targetId);
const cdpUrl = profileCtx.profile.cdpUrl;
const pw = await requirePwAi(res, `act:${kind}`);
if (!pw) return;
@@ -336,6 +354,8 @@ export function registerBrowserAgentRoutes(
});
app.post("/hooks/file-chooser", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const ref = toStringOrEmpty(body.ref) || undefined;
@@ -345,7 +365,7 @@ export function registerBrowserAgentRoutes(
const timeoutMs = toNumber(body.timeoutMs);
if (!paths.length) return jsonError(res, 400, "paths are required");
try {
const tab = await ctx.ensureTabAvailable(targetId);
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "file chooser hook");
if (!pw) return;
if (inputRef || element) {
@@ -357,7 +377,7 @@ export function registerBrowserAgentRoutes(
);
}
await pw.setInputFilesViaPlaywright({
cdpUrl: ctx.state().resolved.cdpUrl,
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
inputRef,
element,
@@ -365,14 +385,14 @@ export function registerBrowserAgentRoutes(
});
} else {
await pw.armFileUploadViaPlaywright({
cdpUrl: ctx.state().resolved.cdpUrl,
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
paths,
timeoutMs: timeoutMs ?? undefined,
});
if (ref) {
await pw.clickViaPlaywright({
cdpUrl: ctx.state().resolved.cdpUrl,
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
ref,
});
@@ -385,6 +405,8 @@ export function registerBrowserAgentRoutes(
});
app.post("/hooks/dialog", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const accept = toBoolean(body.accept);
@@ -392,11 +414,11 @@ export function registerBrowserAgentRoutes(
const timeoutMs = toNumber(body.timeoutMs);
if (accept === undefined) return jsonError(res, 400, "accept is required");
try {
const tab = await ctx.ensureTabAvailable(targetId);
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "dialog hook");
if (!pw) return;
await pw.armDialogViaPlaywright({
cdpUrl: ctx.state().resolved.cdpUrl,
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
accept,
promptText,
@@ -409,16 +431,18 @@ export function registerBrowserAgentRoutes(
});
app.get("/console", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const targetId =
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const level = typeof req.query.level === "string" ? req.query.level : "";
try {
const tab = await ctx.ensureTabAvailable(targetId || undefined);
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
const pw = await requirePwAi(res, "console messages");
if (!pw) return;
const messages = await pw.getConsoleMessagesViaPlaywright({
cdpUrl: ctx.state().resolved.cdpUrl,
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
level: level.trim() || undefined,
});
@@ -429,14 +453,16 @@ export function registerBrowserAgentRoutes(
});
app.post("/pdf", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
try {
const tab = await ctx.ensureTabAvailable(targetId);
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "pdf");
if (!pw) return;
const pdf = await pw.pdfViaPlaywright({
cdpUrl: ctx.state().resolved.cdpUrl,
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
});
await ensureMediaDir();
@@ -458,6 +484,8 @@ export function registerBrowserAgentRoutes(
});
app.post("/screenshot", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const fullPage = toBoolean(body.fullPage) ?? false;
@@ -474,13 +502,13 @@ export function registerBrowserAgentRoutes(
}
try {
const tab = await ctx.ensureTabAvailable(targetId);
const tab = await profileCtx.ensureTabAvailable(targetId);
let buffer: Buffer;
if (ref || element) {
const pw = await requirePwAi(res, "element/ref screenshot");
if (!pw) return;
const snap = await pw.takeScreenshotViaPlaywright({
cdpUrl: ctx.state().resolved.cdpUrl,
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
ref,
element,
@@ -520,6 +548,8 @@ export function registerBrowserAgentRoutes(
});
app.get("/snapshot", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const targetId =
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const format =
@@ -534,12 +564,12 @@ export function registerBrowserAgentRoutes(
typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
try {
const tab = await ctx.ensureTabAvailable(targetId || undefined);
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
if (format === "ai") {
const pw = await requirePwAi(res, "ai snapshot");
if (!pw) return;
const snap = await pw.snapshotAiViaPlaywright({
cdpUrl: ctx.state().resolved.cdpUrl,
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
});
return res.json({

View File

@@ -1,13 +1,26 @@
import type express from "express";
import { createBrowserProfilesService } from "../profiles-service.js";
import type { BrowserRouteContext } from "../server-context.js";
import { jsonError } from "./utils.js";
import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
export function registerBrowserBasicRoutes(
app: express.Express,
ctx: BrowserRouteContext,
) {
app.get("/", async (_req, res) => {
// List all profiles with their status
app.get("/profiles", async (_req, res) => {
try {
const service = createBrowserProfilesService(ctx);
const profiles = await service.listProfiles();
res.json({ profiles });
} catch (err) {
jsonError(res, 500, String(err));
}
});
// Get status (profile-aware)
app.get("/", async (req, res) => {
let current: ReturnType<typeof ctx.state>;
try {
current = ctx.state();
@@ -15,22 +28,31 @@ export function registerBrowserBasicRoutes(
return jsonError(res, 503, "browser server not started");
}
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
return jsonError(res, profileCtx.status, profileCtx.error);
}
const [cdpHttp, cdpReady] = await Promise.all([
ctx.isHttpReachable(300),
ctx.isReachable(600),
profileCtx.isHttpReachable(300),
profileCtx.isReachable(600),
]);
const profileState = current.profiles.get(profileCtx.profile.name);
res.json({
enabled: current.resolved.enabled,
controlUrl: current.resolved.controlUrl,
profile: profileCtx.profile.name,
running: cdpReady,
cdpReady,
cdpHttp,
pid: current.running?.pid ?? null,
cdpPort: current.cdpPort,
cdpUrl: current.resolved.cdpUrl,
chosenBrowser: current.running?.exe.kind ?? null,
userDataDir: current.running?.userDataDir ?? null,
color: current.resolved.color,
pid: profileState?.running?.pid ?? null,
cdpPort: profileCtx.profile.cdpPort,
cdpUrl: profileCtx.profile.cdpUrl,
chosenBrowser: profileState?.running?.exe.kind ?? null,
userDataDir: profileState?.running?.userDataDir ?? null,
color: profileCtx.profile.color,
headless: current.resolved.headless,
noSandbox: current.resolved.noSandbox,
executablePath: current.resolved.executablePath ?? null,
@@ -38,30 +60,110 @@ export function registerBrowserBasicRoutes(
});
});
app.post("/start", async (_req, res) => {
// Start browser (profile-aware)
app.post("/start", async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
return jsonError(res, profileCtx.status, profileCtx.error);
}
try {
await ctx.ensureBrowserAvailable();
res.json({ ok: true });
await profileCtx.ensureBrowserAvailable();
res.json({ ok: true, profile: profileCtx.profile.name });
} catch (err) {
jsonError(res, 500, String(err));
}
});
app.post("/stop", async (_req, res) => {
// Stop browser (profile-aware)
app.post("/stop", async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
return jsonError(res, profileCtx.status, profileCtx.error);
}
try {
const result = await ctx.stopRunningBrowser();
res.json({ ok: true, stopped: result.stopped });
const result = await profileCtx.stopRunningBrowser();
res.json({
ok: true,
stopped: result.stopped,
profile: profileCtx.profile.name,
});
} catch (err) {
jsonError(res, 500, String(err));
}
});
app.post("/reset-profile", async (_req, res) => {
// Reset profile (profile-aware)
app.post("/reset-profile", async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
return jsonError(res, profileCtx.status, profileCtx.error);
}
try {
const result = await ctx.resetProfile();
res.json({ ok: true, ...result });
const result = await profileCtx.resetProfile();
res.json({ ok: true, profile: profileCtx.profile.name, ...result });
} catch (err) {
jsonError(res, 500, String(err));
}
});
// Create a new profile
app.post("/profiles/create", async (req, res) => {
const name = toStringOrEmpty((req.body as { name?: unknown })?.name);
const color = toStringOrEmpty((req.body as { color?: unknown })?.color);
const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl);
if (!name) return jsonError(res, 400, "name is required");
try {
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({
name,
color: color || undefined,
cdpUrl: cdpUrl || undefined,
});
res.json(result);
} catch (err) {
const msg = String(err);
if (msg.includes("already exists")) {
return jsonError(res, 409, msg);
}
if (msg.includes("invalid profile name")) {
return jsonError(res, 400, msg);
}
if (msg.includes("no available CDP ports")) {
return jsonError(res, 507, msg);
}
if (msg.includes("cdpUrl")) {
return jsonError(res, 400, msg);
}
jsonError(res, 500, msg);
}
});
// Delete a profile
app.delete("/profiles/:name", async (req, res) => {
const name = toStringOrEmpty(req.params.name);
if (!name) return jsonError(res, 400, "profile name is required");
try {
const service = createBrowserProfilesService(ctx);
const result = await service.deleteProfile(name);
res.json(result);
} catch (err) {
const msg = String(err);
if (msg.includes("invalid profile name")) {
return jsonError(res, 400, msg);
}
if (msg.includes("default profile")) {
return jsonError(res, 400, msg);
}
if (msg.includes("not found")) {
return jsonError(res, 404, msg);
}
jsonError(res, 500, msg);
}
});
}

View File

@@ -1,5 +1,37 @@
import type express from "express";
import type { BrowserRouteContext, ProfileContext } from "../server-context.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,
ctx: BrowserRouteContext,
): ProfileContext | { error: string; status: number } {
let profileName: string | undefined;
// Check query string first (works for GET and POST)
if (typeof req.query.profile === "string") {
profileName = req.query.profile.trim() || undefined;
}
// Fall back to body for POST requests
if (!profileName && req.body && typeof req.body === "object") {
const body = req.body as Record<string, unknown>;
if (typeof body.profile === "string") {
profileName = body.profile.trim() || undefined;
}
}
try {
return ctx.forProfile(profileName);
} catch (err) {
return { error: String(err), status: 404 };
}
}
export function jsonError(
res: express.Response,
status: number,

View File

@@ -1,9 +1,6 @@
import fs from "node:fs";
import type { Server } from "node:http";
import os from "node:os";
import path from "node:path";
import { runExec } from "../process/exec.js";
import { createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
import {
isChromeCdpReady,
@@ -13,27 +10,37 @@ import {
resolveClawdUserDataDir,
stopClawdChrome,
} from "./chrome.js";
import type { ResolvedBrowserConfig } from "./config.js";
import type { BrowserTab } from "./client.js";
import type {
ResolvedBrowserConfig,
ResolvedBrowserProfile,
} from "./config.js";
import { resolveProfile } from "./config.js";
import { resolveTargetIdFromTabs } from "./target-id.js";
import { movePathToTrash } from "./trash.js";
export type BrowserTab = {
targetId: string;
title: string;
url: string;
wsUrl?: string;
type?: string;
export type { BrowserTab };
/**
* Runtime state for a single profile's Chrome instance.
*/
export type ProfileRuntimeState = {
profile: ResolvedBrowserProfile;
running: RunningChrome | null;
};
export type BrowserServerState = {
server: Server;
port: number;
cdpPort: number;
running: RunningChrome | null;
resolved: ResolvedBrowserConfig;
profiles: Map<string, ProfileRuntimeState>;
};
export type BrowserRouteContext = {
state: () => BrowserServerState;
forProfile: (profileName?: string) => ProfileContext;
listProfiles: () => Promise<ProfileStatus[]>;
// Legacy methods delegate to default profile for backward compatibility
ensureBrowserAvailable: () => Promise<void>;
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
@@ -51,11 +58,50 @@ export type BrowserRouteContext = {
mapTabError: (err: unknown) => { status: number; message: string } | null;
};
export type ProfileContext = {
profile: ResolvedBrowserProfile;
ensureBrowserAvailable: () => Promise<void>;
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
isReachable: (timeoutMs?: number) => Promise<boolean>;
listTabs: () => Promise<BrowserTab[]>;
openTab: (url: string) => Promise<BrowserTab>;
focusTab: (targetId: string) => Promise<void>;
closeTab: (targetId: string) => Promise<void>;
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>;
};
export type ProfileStatus = {
name: string;
cdpPort: number;
cdpUrl: string;
color: string;
running: boolean;
tabCount: number;
isDefault: boolean;
isRemote: boolean;
};
type ContextOptions = {
getState: () => BrowserServerState | null;
setRunning: (running: RunningChrome | null) => void;
};
/**
* Normalize a CDP WebSocket URL to use the correct base URL.
*/
function normalizeWsUrl(
raw: string | undefined,
cdpBaseUrl: string,
): string | undefined {
if (!raw) return undefined;
try {
return normalizeCdpWsUrl(raw, cdpBaseUrl);
} catch {
return raw;
}
}
async function fetchJson<T>(
url: string,
timeoutMs = 1500,
@@ -87,26 +133,35 @@ async function fetchOk(
}
}
export function createBrowserRouteContext(
/**
* Create a profile-scoped context for browser operations.
*/
function createProfileContext(
opts: ContextOptions,
): BrowserRouteContext {
profile: ResolvedBrowserProfile,
): ProfileContext {
const state = () => {
const current = opts.getState();
if (!current) throw new Error("Browser server not started");
return current;
};
const listTabs = async (): Promise<BrowserTab[]> => {
const getProfileState = (): ProfileRuntimeState => {
const current = state();
const base = current.resolved.cdpUrl;
const normalizeWsUrl = (raw?: string) => {
if (!raw) return undefined;
try {
return normalizeCdpWsUrl(raw, base);
} catch {
return raw;
}
};
let profileState = current.profiles.get(profile.name);
if (!profileState) {
profileState = { profile, running: null };
current.profiles.set(profile.name, profileState);
}
return profileState;
};
const setProfileRunning = (running: RunningChrome | null) => {
const profileState = getProfileState();
profileState.running = running;
};
const listTabs = async (): Promise<BrowserTab[]> => {
const raw = await fetchJson<
Array<{
id?: string;
@@ -115,22 +170,21 @@ export function createBrowserRouteContext(
webSocketDebuggerUrl?: string;
type?: string;
}>
>(`${base.replace(/\/$/, "")}/json/list`);
>(`${profile.cdpUrl.replace(/\/$/, "")}/json/list`);
return raw
.map((t) => ({
targetId: t.id ?? "",
title: t.title ?? "",
url: t.url ?? "",
wsUrl: normalizeWsUrl(t.webSocketDebuggerUrl),
wsUrl: normalizeWsUrl(t.webSocketDebuggerUrl, profile.cdpUrl),
type: t.type,
}))
.filter((t) => Boolean(t.targetId));
};
const openTab = async (url: string): Promise<BrowserTab> => {
const current = state();
const createdViaCdp = await createTargetViaCdp({
cdpUrl: current.resolved.cdpUrl,
cdpUrl: profile.cdpUrl,
url,
})
.then((r) => r.targetId)
@@ -148,7 +202,6 @@ export function createBrowserRouteContext(
}
const encoded = encodeURIComponent(url);
type CdpTarget = {
id?: string;
title?: string;
@@ -157,15 +210,7 @@ export function createBrowserRouteContext(
type?: string;
};
const base = current.resolved.cdpUrl.replace(/\/$/, "");
const normalizeWsUrl = (raw?: string) => {
if (!raw) return undefined;
try {
return normalizeCdpWsUrl(raw, base);
} catch {
return raw;
}
};
const base = profile.cdpUrl.replace(/\/$/, "");
const endpoint = `${base}/json/new?${encoded}`;
const created = await fetchJson<CdpTarget>(endpoint, 1500, {
method: "PUT",
@@ -181,76 +226,81 @@ export function createBrowserRouteContext(
targetId: created.id,
title: created.title ?? "",
url: created.url ?? url,
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl),
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, base),
type: created.type,
};
};
const isReachable = async (timeoutMs = 300) => {
const current = state();
const wsTimeout = Math.max(200, Math.min(2000, timeoutMs * 2));
return await isChromeCdpReady(
current.resolved.cdpUrl,
timeoutMs,
wsTimeout,
);
return await isChromeCdpReady(profile.cdpUrl, timeoutMs, wsTimeout);
};
const isHttpReachable = async (timeoutMs = 300) => {
const current = state();
return await isChromeReachable(current.resolved.cdpUrl, timeoutMs);
return await isChromeReachable(profile.cdpUrl, timeoutMs);
};
const attachRunning = (running: RunningChrome) => {
opts.setRunning(running);
setProfileRunning(running);
running.proc.on("exit", () => {
const live = opts.getState();
if (live?.running?.pid === running.pid) {
opts.setRunning(null);
// Guard against server teardown (e.g., SIGUSR1 restart)
if (!opts.getState()) return;
const profileState = getProfileState();
if (profileState.running?.pid === running.pid) {
setProfileRunning(null);
}
});
};
const ensureBrowserAvailable = async (): Promise<void> => {
const current = state();
const remoteCdp = !current.resolved.cdpIsLoopback;
const remoteCdp = !profile.cdpIsLoopback;
const profileState = getProfileState();
const httpReachable = await isHttpReachable();
if (!httpReachable) {
if (current.resolved.attachOnly || remoteCdp) {
throw new Error(
remoteCdp
? "Remote CDP is not reachable. Check browser.cdpUrl."
: "Browser attachOnly is enabled and no browser is running.",
? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`
: `Browser attachOnly is enabled and profile "${profile.name}" is not running.`,
);
}
const launched = await launchClawdChrome(current.resolved);
const launched = await launchClawdChrome(current.resolved, profile);
attachRunning(launched);
return;
}
// Port is reachable - check if we own it
if (await isReachable()) return;
// HTTP responds but WebSocket fails - port in use by something else
if (!profileState.running) {
throw new Error(
`Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by clawdis. ` +
`Run action=reset-profile profile=${profile.name} to kill the process.`,
);
}
// We own it but WebSocket failed - restart
if (current.resolved.attachOnly || remoteCdp) {
throw new Error(
remoteCdp
? "Remote CDP websocket is not reachable. Check browser.cdpUrl."
: "Browser attachOnly is enabled and CDP websocket is not reachable.",
? `Remote CDP websocket for profile "${profile.name}" is not reachable.`
: `Browser attachOnly is enabled and CDP websocket for profile "${profile.name}" is not reachable.`,
);
}
if (!current.running) {
throw new Error(
"CDP port responds but websocket handshake failed. Ensure the clawd browser owns the port or stop the conflicting process.",
);
}
await stopClawdChrome(profileState.running);
setProfileRunning(null);
await stopClawdChrome(current.running);
opts.setRunning(null);
const relaunched = await launchClawdChrome(current.resolved);
const relaunched = await launchClawdChrome(current.resolved, profile);
attachRunning(relaunched);
if (!(await isReachable(600))) {
throw new Error("Chrome CDP websocket is not reachable after restart.");
throw new Error(
`Chrome CDP websocket for profile "${profile.name}" is not reachable after restart.`,
);
}
};
@@ -281,8 +331,7 @@ export function createBrowserRouteContext(
};
const focusTab = async (targetId: string): Promise<void> => {
const current = state();
const base = current.resolved.cdpUrl.replace(/\/$/, "");
const base = profile.cdpUrl.replace(/\/$/, "");
const tabs = await listTabs();
const resolved = resolveTargetIdFromTabs(targetId, tabs);
if (!resolved.ok) {
@@ -295,8 +344,7 @@ export function createBrowserRouteContext(
};
const closeTab = async (targetId: string): Promise<void> => {
const current = state();
const base = current.resolved.cdpUrl.replace(/\/$/, "");
const base = profile.cdpUrl.replace(/\/$/, "");
const tabs = await listTabs();
const resolved = resolveTargetIdFromTabs(targetId, tabs);
if (!resolved.ok) {
@@ -309,28 +357,34 @@ export function createBrowserRouteContext(
};
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
const current = state();
if (!current.running) return { stopped: false };
await stopClawdChrome(current.running);
opts.setRunning(null);
const profileState = getProfileState();
if (!profileState.running) return { stopped: false };
await stopClawdChrome(profileState.running);
setProfileRunning(null);
return { stopped: true };
};
const resetProfile = async () => {
const current = state();
if (!current.resolved.cdpIsLoopback) {
throw new Error("reset-profile is only supported for local browsers.");
}
const userDataDir = resolveClawdUserDataDir();
const httpReachable = await isHttpReachable(300);
if (httpReachable && !current.running) {
if (!profile.cdpIsLoopback) {
throw new Error(
"Browser appears to be running but is not owned by clawd. Stop it before resetting the profile.",
`reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`,
);
}
const userDataDir = resolveClawdUserDataDir(profile.name);
const profileState = getProfileState();
if (current.running) {
const httpReachable = await isHttpReachable(300);
if (httpReachable && !profileState.running) {
// Port in use but not by us - kill it
try {
const mod = await import("./pw-ai.js");
await mod.closePlaywrightBrowserConnection();
} catch {
// ignore
}
}
if (profileState.running) {
await stopRunningBrowser();
}
@@ -349,19 +403,8 @@ export function createBrowserRouteContext(
return { moved: true, from: userDataDir, to: moved };
};
const mapTabError = (err: unknown) => {
const msg = String(err);
if (msg.includes("ambiguous target id prefix")) {
return { status: 409, message: "ambiguous target id prefix" };
}
if (msg.includes("tab not found")) {
return { status: 404, message: "tab not found" };
}
return null;
};
return {
state,
profile,
ensureBrowserAvailable,
ensureTabAvailable,
isHttpReachable,
@@ -372,23 +415,116 @@ export function createBrowserRouteContext(
closeTab,
stopRunningBrowser,
resetProfile,
mapTabError,
};
}
async function movePathToTrash(targetPath: string): Promise<string> {
try {
await runExec("trash", [targetPath], { timeoutMs: 10_000 });
return targetPath;
} catch {
const trashDir = path.join(os.homedir(), ".Trash");
fs.mkdirSync(trashDir, { recursive: true });
const base = path.basename(targetPath);
let dest = path.join(trashDir, `${base}-${Date.now()}`);
if (fs.existsSync(dest)) {
dest = path.join(trashDir, `${base}-${Date.now()}-${Math.random()}`);
export function createBrowserRouteContext(
opts: ContextOptions,
): BrowserRouteContext {
const state = () => {
const current = opts.getState();
if (!current) throw new Error("Browser server not started");
return current;
};
const forProfile = (profileName?: string): ProfileContext => {
const current = state();
const name = profileName ?? current.resolved.defaultProfile;
const profile = resolveProfile(current.resolved, name);
if (!profile) {
const available = Object.keys(current.resolved.profiles).join(", ");
throw new Error(
`Profile "${name}" not found. Available profiles: ${available || "(none)"}`,
);
}
fs.renameSync(targetPath, dest);
return dest;
}
return createProfileContext(opts, profile);
};
const listProfiles = async (): Promise<ProfileStatus[]> => {
const current = state();
const result: ProfileStatus[] = [];
for (const name of Object.keys(current.resolved.profiles)) {
const profileState = current.profiles.get(name);
const profile = resolveProfile(current.resolved, name);
if (!profile) continue;
let tabCount = 0;
let running = false;
if (profileState?.running) {
running = true;
try {
const ctx = createProfileContext(opts, profile);
const tabs = await ctx.listTabs();
tabCount = tabs.filter((t) => t.type === "page").length;
} catch {
// Browser might not be responsive
}
} else {
// Check if something is listening on the port
try {
const reachable = await isChromeReachable(profile.cdpUrl, 200);
if (reachable) {
running = true;
const ctx = createProfileContext(opts, profile);
const tabs = await ctx.listTabs().catch(() => []);
tabCount = tabs.filter((t) => t.type === "page").length;
}
} catch {
// Not reachable
}
}
result.push({
name,
cdpPort: profile.cdpPort,
cdpUrl: profile.cdpUrl,
color: profile.color,
running,
tabCount,
isDefault: name === current.resolved.defaultProfile,
isRemote: !profile.cdpIsLoopback,
});
}
return result;
};
// Create default profile context for backward compatibility
const getDefaultContext = () => forProfile();
const mapTabError = (err: unknown) => {
const msg = String(err);
if (msg.includes("ambiguous target id prefix")) {
return { status: 409, message: "ambiguous target id prefix" };
}
if (msg.includes("tab not found")) {
return { status: 404, message: "tab not found" };
}
if (msg.includes("not found")) {
return { status: 404, message: msg };
}
return null;
};
return {
state,
forProfile,
listProfiles,
// Legacy methods delegate to default profile
ensureBrowserAvailable: () => getDefaultContext().ensureBrowserAvailable(),
ensureTabAvailable: (targetId) =>
getDefaultContext().ensureTabAvailable(targetId),
isHttpReachable: (timeoutMs) =>
getDefaultContext().isHttpReachable(timeoutMs),
isReachable: (timeoutMs) => getDefaultContext().isReachable(timeoutMs),
listTabs: () => getDefaultContext().listTabs(),
openTab: (url) => getDefaultContext().openTab(url),
focusTab: (targetId) => getDefaultContext().focusTab(targetId),
closeTab: (targetId) => getDefaultContext().closeTab(targetId),
stopRunningBrowser: () => getDefaultContext().stopRunningBrowser(),
resetProfile: () => getDefaultContext().resetProfile(),
mapTabError,
};
}

View File

@@ -72,26 +72,33 @@ vi.mock("../config/config.js", () => ({
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
},
},
}),
writeConfigFile: vi.fn(async () => {}),
}));
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => reachable),
isChromeReachable: vi.fn(async () => reachable),
launchClawdChrome: vi.fn(async (resolved: { cdpPort: number }) => {
launchCalls.push({ port: resolved.cdpPort });
reachable = true;
return {
pid: 123,
exe: { kind: "chrome", path: "/fake/chrome" },
userDataDir: "/tmp/clawd",
cdpPort: resolved.cdpPort,
startedAt: Date.now(),
proc,
};
}),
launchClawdChrome: vi.fn(
async (_resolved: unknown, profile: { cdpPort: number }) => {
launchCalls.push({ port: profile.cdpPort });
reachable = true;
return {
pid: 123,
exe: { kind: "chrome", path: "/fake/chrome" },
userDataDir: "/tmp/clawd",
cdpPort: profile.cdpPort,
startedAt: Date.now(),
proc,
};
},
),
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd"),
stopClawdChrome: vi.fn(async () => {
reachable = false;
@@ -746,3 +753,289 @@ describe("browser control server", () => {
expect(snapAmbiguous.status).toBe(409);
});
});
describe("backward compatibility (profile parameter)", () => {
beforeEach(async () => {
reachable = false;
cfgAttachOnly = false;
createTargetId = null;
for (const fn of Object.values(pwMocks)) fn.mockClear();
for (const fn of Object.values(cdpMocks)) fn.mockClear();
testPort = await getFreePort();
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) return makeResponse([]);
return makeResponse([
{
id: "abcd1234",
title: "Tab",
url: "https://example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
type: "page",
},
]);
}
if (u.includes("/json/new?")) {
return makeResponse({
id: "newtab1",
title: "",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
type: "page",
});
}
if (u.includes("/json/activate/")) return makeResponse("ok");
if (u.includes("/json/close/")) return makeResponse("ok");
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
it("GET / without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const status = (await realFetch(`${base}/`).then((r) => r.json())) as {
running: boolean;
profile?: string;
};
expect(status.running).toBe(false);
// Should use default profile (clawd)
expect(status.profile).toBe("clawd");
});
it("POST /start without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = (await realFetch(`${base}/start`, { method: "POST" }).then(
(r) => r.json(),
)) as { ok: boolean; profile?: string };
expect(result.ok).toBe(true);
expect(result.profile).toBe("clawd");
});
it("POST /stop without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" });
const result = (await realFetch(`${base}/stop`, { method: "POST" }).then(
(r) => r.json(),
)) as { ok: boolean; profile?: string };
expect(result.ok).toBe(true);
expect(result.profile).toBe("clawd");
});
it("GET /tabs without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" });
const result = (await realFetch(`${base}/tabs`).then((r) => r.json())) as {
running: boolean;
tabs: unknown[];
};
expect(result.running).toBe(true);
expect(Array.isArray(result.tabs)).toBe(true);
});
it("POST /tabs/open without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" });
const result = (await realFetch(`${base}/tabs/open`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com" }),
}).then((r) => r.json())) as { targetId?: string };
expect(result.targetId).toBe("newtab1");
});
it("GET /profiles returns list of profiles", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = (await realFetch(`${base}/profiles`).then((r) =>
r.json(),
)) as { profiles: Array<{ name: string }> };
expect(Array.isArray(result.profiles)).toBe(true);
// Should at least have the default clawd profile
expect(result.profiles.some((p) => p.name === "clawd")).toBe(true);
});
});
describe("profile CRUD endpoints", () => {
beforeEach(async () => {
reachable = false;
cfgAttachOnly = false;
for (const fn of Object.values(pwMocks)) fn.mockClear();
for (const fn of Object.values(cdpMocks)) fn.mockClear();
testPort = await getFreePort();
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const u = String(url);
if (u.includes("/json/list")) return makeResponse([]);
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
it("POST /profiles/create returns 400 for missing name", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("name is required");
});
it("POST /profiles/create returns 400 for invalid name format", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Invalid Name!" }),
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("invalid profile name");
});
it("POST /profiles/create returns 409 for duplicate name", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
// "clawd" already exists as the default profile
const result = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "clawd" }),
});
expect(result.status).toBe(409);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("already exists");
});
it("POST /profiles/create accepts cdpUrl for remote profiles", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "remote", cdpUrl: "http://10.0.0.42:9222" }),
});
expect(result.status).toBe(200);
const body = (await result.json()) as {
profile?: string;
cdpUrl?: string;
isRemote?: boolean;
};
expect(body.profile).toBe("remote");
expect(body.cdpUrl).toBe("http://10.0.0.42:9222");
expect(body.isRemote).toBe(true);
});
it("POST /profiles/create returns 400 for invalid cdpUrl", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "badremote", cdpUrl: "ws://bad" }),
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("cdpUrl");
});
it("DELETE /profiles/:name returns 404 for non-existent profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/nonexistent`, {
method: "DELETE",
});
expect(result.status).toBe(404);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("not found");
});
it("DELETE /profiles/:name returns 400 for default profile deletion", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
// clawd is the default profile
const result = await realFetch(`${base}/profiles/clawd`, {
method: "DELETE",
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("cannot delete the default profile");
});
it("DELETE /profiles/:name returns 400 for invalid name format", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/Invalid-Name!`, {
method: "DELETE",
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("invalid profile name");
});
});

View File

@@ -36,9 +36,6 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
const ctx = createBrowserRouteContext({
getState: () => state,
setRunning: (running) => {
if (state) state.running = running;
},
});
registerBrowserRoutes(app, ctx);
@@ -58,9 +55,8 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
state = {
server,
port,
cdpPort: resolved.cdpPort,
running: null,
resolved,
profiles: new Map(),
};
logServer.info(`Browser control listening on http://127.0.0.1:${port}/`);
@@ -73,13 +69,19 @@ export async function stopBrowserControlServer(): Promise<void> {
const ctx = createBrowserRouteContext({
getState: () => state,
setRunning: (running) => {
if (state) state.running = running;
},
});
try {
await ctx.stopRunningBrowser();
const current = state;
if (current) {
for (const name of Object.keys(current.resolved.profiles)) {
try {
await ctx.forProfile(name).stopRunningBrowser();
} catch {
// ignore
}
}
}
} catch (err) {
logServer.warn(`clawd browser stop failed: ${String(err)}`);
}

22
src/browser/trash.ts Normal file
View File

@@ -0,0 +1,22 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { runExec } from "../process/exec.js";
export async function movePathToTrash(targetPath: string): Promise<string> {
try {
await runExec("trash", [targetPath], { timeoutMs: 10_000 });
return targetPath;
} catch {
const trashDir = path.join(os.homedir(), ".Trash");
fs.mkdirSync(trashDir, { recursive: true });
const base = path.basename(targetPath);
let dest = path.join(trashDir, `${base}-${Date.now()}`);
if (fs.existsSync(dest)) {
dest = path.join(trashDir, `${base}-${Date.now()}-${Math.random()}`);
}
fs.renameSync(targetPath, dest);
return dest;
}
}

View File

@@ -64,10 +64,12 @@ export function registerBrowserActionInputCommands(
.action(async (url: string, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserNavigate(baseUrl, {
url,
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
@@ -89,6 +91,7 @@ export function registerBrowserActionInputCommands(
.action(async (width: number, height: number, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
if (!Number.isFinite(width) || !Number.isFinite(height)) {
defaultRuntime.error(danger("width and height must be numbers"));
defaultRuntime.exit(1);
@@ -100,7 +103,7 @@ export function registerBrowserActionInputCommands(
width,
height,
targetId: opts.targetId?.trim() || undefined,
});
}, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -123,6 +126,7 @@ export function registerBrowserActionInputCommands(
.action(async (ref: string | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
const refValue = typeof ref === "string" ? ref.trim() : "";
if (!refValue) {
defaultRuntime.error(danger("ref is required"));
@@ -143,7 +147,7 @@ export function registerBrowserActionInputCommands(
doubleClick: Boolean(opts.double),
button: opts.button?.trim() || undefined,
modifiers,
});
}, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -167,6 +171,7 @@ export function registerBrowserActionInputCommands(
.action(async (ref: string | undefined, text: string, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
const refValue = typeof ref === "string" ? ref.trim() : "";
if (!refValue) {
defaultRuntime.error(danger("ref is required"));
@@ -181,7 +186,7 @@ export function registerBrowserActionInputCommands(
submit: Boolean(opts.submit),
slowly: Boolean(opts.slowly),
targetId: opts.targetId?.trim() || undefined,
});
}, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -201,12 +206,13 @@ export function registerBrowserActionInputCommands(
.action(async (key: string, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserAct(baseUrl, {
kind: "press",
key,
targetId: opts.targetId?.trim() || undefined,
});
}, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -226,12 +232,13 @@ export function registerBrowserActionInputCommands(
.action(async (ref: string, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserAct(baseUrl, {
kind: "hover",
ref,
targetId: opts.targetId?.trim() || undefined,
});
}, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -252,13 +259,14 @@ export function registerBrowserActionInputCommands(
.action(async (startRef: string, endRef: string, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserAct(baseUrl, {
kind: "drag",
startRef,
endRef,
targetId: opts.targetId?.trim() || undefined,
});
}, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -279,13 +287,14 @@ export function registerBrowserActionInputCommands(
.action(async (ref: string, values: string[], opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserAct(baseUrl, {
kind: "select",
ref,
values,
targetId: opts.targetId?.trim() || undefined,
});
}, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -313,6 +322,7 @@ export function registerBrowserActionInputCommands(
.action(async (paths: string[], opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserArmFileChooser(baseUrl, {
paths,
@@ -323,6 +333,7 @@ export function registerBrowserActionInputCommands(
timeoutMs: Number.isFinite(opts.timeoutMs)
? opts.timeoutMs
: undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
@@ -344,6 +355,7 @@ export function registerBrowserActionInputCommands(
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const fields = await readFields({
fields: opts.fields,
@@ -353,7 +365,7 @@ export function registerBrowserActionInputCommands(
kind: "fill",
fields,
targetId: opts.targetId?.trim() || undefined,
});
}, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -380,6 +392,7 @@ export function registerBrowserActionInputCommands(
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
const accept = opts.accept ? true : opts.dismiss ? false : undefined;
if (accept === undefined) {
defaultRuntime.error(danger("Specify --accept or --dismiss"));
@@ -394,6 +407,7 @@ export function registerBrowserActionInputCommands(
timeoutMs: Number.isFinite(opts.timeoutMs)
? opts.timeoutMs
: undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
@@ -416,6 +430,7 @@ export function registerBrowserActionInputCommands(
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserAct(baseUrl, {
kind: "wait",
@@ -423,7 +438,7 @@ export function registerBrowserActionInputCommands(
text: opts.text?.trim() || undefined,
textGone: opts.textGone?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
});
}, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -444,6 +459,7 @@ export function registerBrowserActionInputCommands(
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
if (!opts.fn) {
defaultRuntime.error(danger("Missing --fn"));
defaultRuntime.exit(1);
@@ -455,7 +471,7 @@ export function registerBrowserActionInputCommands(
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;

View File

@@ -20,10 +20,12 @@ export function registerBrowserActionObserveCommands(
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserConsoleMessages(baseUrl, {
level: opts.level?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
@@ -43,9 +45,11 @@ export function registerBrowserActionObserveCommands(
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserPdfSave(baseUrl, {
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));

View File

@@ -24,6 +24,7 @@ export function registerBrowserInspectCommands(
.action(async (targetId: string | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserScreenshotAction(baseUrl, {
targetId: targetId?.trim() || undefined,
@@ -31,6 +32,7 @@ export function registerBrowserInspectCommands(
ref: opts.ref?.trim() || undefined,
element: opts.element?.trim() || undefined,
type: opts.type === "jpeg" ? "jpeg" : "png",
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
@@ -57,12 +59,14 @@ export function registerBrowserInspectCommands(
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
const format = opts.format === "aria" ? "aria" : "ai";
try {
const result = await browserSnapshot(baseUrl, {
format,
targetId: opts.targetId?.trim() || undefined,
limit: Number.isFinite(opts.limit) ? opts.limit : undefined,
profile,
});
if (opts.out) {

View File

@@ -2,8 +2,11 @@ import type { Command } from "commander";
import {
browserCloseTab,
browserCreateProfile,
browserDeleteProfile,
browserFocusTab,
browserOpenTab,
browserProfiles,
browserResetProfile,
browserStart,
browserStatus,
@@ -11,7 +14,7 @@ import {
browserTabs,
resolveBrowserControlUrl,
} from "../browser/client.js";
import { browserAct } from "../browser/client-actions.js";
import { browserAct } from "../browser/client-actions-core.js";
import { danger, info } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
@@ -27,13 +30,16 @@ export function registerBrowserManageCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
const status = await browserStatus(baseUrl);
const status = await browserStatus(baseUrl, {
profile: parent?.profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(status, null, 2));
return;
}
defaultRuntime.log(
[
`profile: ${status.profile ?? "clawd"}`,
`enabled: ${status.enabled}`,
`running: ${status.running}`,
`controlUrl: ${status.controlUrl}`,
@@ -51,18 +57,22 @@ export function registerBrowserManageCommands(
browser
.command("start")
.description("Start the clawd browser (no-op if already running)")
.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?.profile;
try {
await browserStart(baseUrl);
const status = await browserStatus(baseUrl);
await browserStart(baseUrl, { profile });
const status = await browserStatus(baseUrl, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(status, null, 2));
return;
}
defaultRuntime.log(info(`🦞 clawd browser running: ${status.running}`));
const name = status.profile ?? "clawd";
defaultRuntime.log(
info(`🦞 browser [${name}] running: ${status.running}`),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -71,18 +81,22 @@ export function registerBrowserManageCommands(
browser
.command("stop")
.description("Stop the clawd browser (best-effort)")
.description("Stop the browser (best-effort)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
await browserStop(baseUrl);
const status = await browserStatus(baseUrl);
await browserStop(baseUrl, { profile });
const status = await browserStatus(baseUrl, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(status, null, 2));
return;
}
defaultRuntime.log(info(`🦞 clawd browser running: ${status.running}`));
const name = status.profile ?? "clawd";
defaultRuntime.log(
info(`🦞 browser [${name}] running: ${status.running}`),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -91,24 +105,23 @@ export function registerBrowserManageCommands(
browser
.command("reset-profile")
.description("Reset clawd browser profile (moves it to Trash)")
.description("Reset browser profile (moves it to Trash)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserResetProfile(baseUrl);
const result = await browserResetProfile(baseUrl, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
if (!result.moved) {
defaultRuntime.log(info("🦞 clawd browser profile already missing."));
defaultRuntime.log(info(`🦞 browser profile already missing.`));
return;
}
const dest = result.to ?? result.from;
defaultRuntime.log(
info(`🦞 clawd browser profile moved to Trash (${dest})`),
);
defaultRuntime.log(info(`🦞 browser profile moved to Trash (${dest})`));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -121,8 +134,9 @@ export function registerBrowserManageCommands(
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const tabs = await browserTabs(baseUrl);
const tabs = await browserTabs(baseUrl, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ tabs }, null, 2));
return;
@@ -149,11 +163,12 @@ export function registerBrowserManageCommands(
.command("open")
.description("Open a URL in a new tab")
.argument("<url>", "URL to open")
.action(async (url: string, cmd) => {
.action(async (url: string, _opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const tab = await browserOpenTab(baseUrl, url);
const tab = await browserOpenTab(baseUrl, url, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(tab, null, 2));
return;
@@ -169,11 +184,12 @@ export function registerBrowserManageCommands(
.command("focus")
.description("Focus a tab by target id (or unique prefix)")
.argument("<targetId>", "Target id or unique prefix")
.action(async (targetId: string, cmd) => {
.action(async (targetId: string, _opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
await browserFocusTab(baseUrl, targetId);
await browserFocusTab(baseUrl, targetId, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
return;
@@ -189,14 +205,15 @@ export function registerBrowserManageCommands(
.command("close")
.description("Close a tab (target id optional)")
.argument("[targetId]", "Target id or unique prefix (optional)")
.action(async (targetId: string | undefined, cmd) => {
.action(async (targetId: string | undefined, _opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
if (targetId?.trim()) {
await browserCloseTab(baseUrl, targetId.trim());
await browserCloseTab(baseUrl, targetId.trim(), { profile });
} else {
await browserAct(baseUrl, { kind: "close" });
await browserAct(baseUrl, { kind: "close" }, { profile });
}
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
@@ -208,4 +225,102 @@ export function registerBrowserManageCommands(
defaultRuntime.exit(1);
}
});
// Profile management commands
browser
.command("profiles")
.description("List all browser profiles")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
const profiles = await browserProfiles(baseUrl);
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ profiles }, null, 2));
return;
}
if (profiles.length === 0) {
defaultRuntime.log("No profiles configured.");
return;
}
defaultRuntime.log(
profiles
.map((p) => {
const status = p.running ? "running" : "stopped";
const tabs = p.running ? ` (${p.tabCount} tabs)` : "";
const def = p.isDefault ? " [default]" : "";
const loc = p.isRemote
? `cdpUrl: ${p.cdpUrl}`
: `port: ${p.cdpPort}`;
const remote = p.isRemote ? " [remote]" : "";
return `${p.name}: ${status}${tabs}${def}${remote}\n ${loc}, color: ${p.color}`;
})
.join("\n"),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
browser
.command("create-profile")
.description("Create a new browser profile")
.requiredOption(
"--name <name>",
"Profile name (lowercase, numbers, hyphens)",
)
.option("--color <hex>", "Profile color (hex format, e.g. #0066CC)")
.option("--cdp-url <url>", "CDP URL for remote Chrome (http/https)")
.action(
async (opts: { name: string; color?: string; cdpUrl?: string }, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
const result = await browserCreateProfile(baseUrl, {
name: opts.name,
color: opts.color,
cdpUrl: opts.cdpUrl,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const loc = result.isRemote
? ` cdpUrl: ${result.cdpUrl}`
: ` port: ${result.cdpPort}`;
defaultRuntime.log(
info(
`🦞 Created profile "${result.profile}"\n${loc}\n color: ${result.color}`,
),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
},
);
browser
.command("delete-profile")
.description("Delete a browser profile")
.requiredOption("--name <name>", "Profile name to delete")
.action(async (opts: { name: string }, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
const result = await browserDeleteProfile(baseUrl, opts.name);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const msg = result.deleted
? `🦞 Deleted profile "${result.profile}" (user data removed)`
: `🦞 Deleted profile "${result.profile}" (no user data found)`;
defaultRuntime.log(info(msg));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
}

View File

@@ -1 +1 @@
export type BrowserParentOpts = { url?: string; json?: boolean };
export type BrowserParentOpts = { url?: string; json?: boolean; profile?: string };

View File

@@ -10,6 +10,7 @@ import {
} from "./browser-cli-examples.js";
import { registerBrowserInspectCommands } from "./browser-cli-inspect.js";
import { registerBrowserManageCommands } from "./browser-cli-manage.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
export function registerBrowserCli(program: Command) {
const browser = program
@@ -19,6 +20,7 @@ export function registerBrowserCli(program: Command) {
"--url <url>",
"Override browser control URL (default from ~/.clawdis/clawdis.json)",
)
.option("--profile <name>", "Browser profile name (default from config)")
.option("--json", "Output machine-readable JSON", false)
.addHelpText(
"after",
@@ -33,7 +35,7 @@ export function registerBrowserCli(program: Command) {
});
const parentOpts = (cmd: Command) =>
cmd.parent?.opts?.() as { url?: string; json?: boolean };
cmd.parent?.opts?.() as BrowserParentOpts;
registerBrowserManageCommands(browser, parentOpts);
registerBrowserInspectCommands(browser, parentOpts);

View File

@@ -92,6 +92,14 @@ export type WhatsAppConfig = {
>;
};
export type BrowserProfileConfig = {
/** CDP port for this profile. Allocated once at creation, persisted permanently. */
cdpPort?: number;
/** CDP URL for this profile (use for remote Chrome). */
cdpUrl?: string;
/** Profile color (hex). Auto-assigned at creation. */
color: string;
};
export type BrowserConfig = {
enabled?: boolean;
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
@@ -108,6 +116,10 @@ export type BrowserConfig = {
noSandbox?: boolean;
/** If true: never launch; only attach to an existing browser. Default: false */
attachOnly?: boolean;
/** Default profile to use when profile param is omitted. Default: "clawd" */
defaultProfile?: string;
/** Named browser profiles with explicit CDP ports or URLs. */
profiles?: Record<string, BrowserProfileConfig>;
};
export type CronConfig = {
@@ -1092,6 +1104,26 @@ export const ClawdisSchema = z.object({
headless: z.boolean().optional(),
noSandbox: z.boolean().optional(),
attachOnly: z.boolean().optional(),
defaultProfile: z.string().optional(),
profiles: z
.record(
z
.string()
.regex(
/^[a-z0-9-]+$/,
"Profile names must be alphanumeric with hyphens only",
),
z
.object({
cdpPort: z.number().int().min(1).max(65535).optional(),
cdpUrl: z.string().optional(),
color: HexColorSchema,
})
.refine((value) => value.cdpPort || value.cdpUrl, {
message: "Profile must set cdpPort or cdpUrl",
}),
)
.optional(),
})
.optional(),
ui: z