From 12ba32c724f63b37c5da547bdb88e697ec2c9660 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 4 Jan 2026 03:32:40 +0000 Subject: [PATCH] feat(browser): add remote-capable profiles Co-authored-by: James Groat --- CHANGELOG.md | 1 + docs/browser.md | 60 ++++ docs/configuration.md | 15 +- docs/tools.md | 14 + src/agents/sandbox.ts | 18 +- src/browser/bridge-server.ts | 6 +- src/browser/chrome.ts | 47 ++-- src/browser/client-actions-core.ts | 61 +++-- src/browser/client-actions-observe.ts | 25 +- src/browser/client.ts | 133 ++++++++- src/browser/config.test.ts | 49 +++- src/browser/config.ts | 98 ++++++- src/browser/profiles-service.test.ts | 154 +++++++++++ src/browser/profiles-service.ts | 181 +++++++++++++ src/browser/profiles.test.ts | 240 ++++++++++++++++ src/browser/profiles.ts | 92 +++++++ src/browser/routes/agent.ts | 68 +++-- src/browser/routes/basic.ts | 140 ++++++++-- src/browser/routes/utils.ts | 32 +++ src/browser/server-context.ts | 362 +++++++++++++++++-------- src/browser/server.test.ts | 317 +++++++++++++++++++++- src/browser/server.ts | 20 +- src/browser/trash.ts | 22 ++ src/cli/browser-cli-actions-input.ts | 36 ++- src/cli/browser-cli-actions-observe.ts | 4 + src/cli/browser-cli-inspect.ts | 4 + src/cli/browser-cli-manage.ts | 163 +++++++++-- src/cli/browser-cli-shared.ts | 2 +- src/cli/browser-cli.ts | 4 +- src/config/config.ts | 32 +++ 30 files changed, 2102 insertions(+), 298 deletions(-) create mode 100644 src/browser/profiles-service.test.ts create mode 100644 src/browser/profiles-service.ts create mode 100644 src/browser/profiles.test.ts create mode 100644 src/browser/profiles.ts create mode 100644 src/browser/trash.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 64d343056..d6996d4c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/browser.md b/docs/browser.md index e0a8f422a..00a636964 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -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//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=` — kill orphan process on profile's port (local profiles only) + +### Profile parameter + +All existing endpoints accept optional `?profile=` query parameter: +- `GET /?profile=work` — status for work profile +- `POST /start?profile=work` — start work profile browser +- `GET /tabs?profile=work` — list tabs for work profile +- 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 ` 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` diff --git a/docs/configuration.md b/docs/configuration.md index 74f49d3b9..b8440973e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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..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, diff --git a/docs/tools.md b/docs/tools.md index 4d43706ff..c5f3522dd 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -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). diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 338a66191..b4ed45692 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -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, diff --git a/src/browser/bridge-server.ts b/src/browser/bridge-server.ts index c20db834b..44161e9a5 100644 --- a/src/browser/bridge-server.ts +++ b/src/browser/bridge-server.ts @@ -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); diff --git a/src/browser/chrome.ts b/src/browser/chrome.ts index 801583978..5da9f115c 100644 --- a/src/browser/chrome.ts +++ b/src/browser/chrome.ts @@ -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 { - 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, }; diff --git a/src/browser/client-actions-core.ts b/src/browser/client-actions-core.ts index 5c3fd5a66..c1bd15d91 100644 --- a/src/browser/client-actions-core.ts +++ b/src/browser/client-actions-core.ts @@ -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 { - return await fetchBrowserJson(`${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( + `${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 { - return await fetchBrowserJson(`${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( + `${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 { + const q = buildProfileQuery(opts.profile); return await fetchBrowserJson( - `${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 { - return await fetchBrowserJson(`${baseUrl}/act`, { + const q = buildProfileQuery(opts?.profile); + return await fetchBrowserJson(`${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 { + const q = buildProfileQuery(opts.profile); return await fetchBrowserJson( - `${baseUrl}/screenshot`, + `${baseUrl}/screenshot${q}`, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/src/browser/client-actions-observe.ts b/src/browser/client-actions-observe.ts index f5dbdbdaa..2a9b8b7f0 100644 --- a/src/browser/client-actions-observe.ts +++ b/src/browser/client-actions-observe.ts @@ -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 { - return await fetchBrowserJson(`${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( + `${baseUrl}/pdf${q}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ targetId: opts.targetId }), + timeoutMs: 20000, + }, + ); } diff --git a/src/browser/client.ts b/src/browser/client.ts index 06f6bb2ea..eb729ecb7 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -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 { - return await fetchBrowserJson(`${baseUrl}/`, { +function buildProfileQuery(profile?: string): string { + return profile ? `?profile=${encodeURIComponent(profile)}` : ""; +} + +export async function browserStatus( + baseUrl: string, + opts?: { profile?: string }, +): Promise { + const q = buildProfileQuery(opts?.profile); + return await fetchBrowserJson(`${baseUrl}/${q}`, { timeoutMs: 1500, }); } -export async function browserStart(baseUrl: string): Promise { - await fetchBrowserJson(`${baseUrl}/start`, { +export async function browserProfiles( + baseUrl: string, +): Promise { + const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>( + `${baseUrl}/profiles`, + { timeoutMs: 3000 }, + ); + return res.profiles ?? []; +} + +export async function browserStart( + baseUrl: string, + opts?: { profile?: string }, +): Promise { + const q = buildProfileQuery(opts?.profile); + await fetchBrowserJson(`${baseUrl}/start${q}`, { method: "POST", timeoutMs: 15000, }); } -export async function browserStop(baseUrl: string): Promise { - await fetchBrowserJson(`${baseUrl}/stop`, { +export async function browserStop( + baseUrl: string, + opts?: { profile?: string }, +): Promise { + 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 { export async function browserResetProfile( baseUrl: string, + opts?: { profile?: string }, ): Promise { + const q = buildProfileQuery(opts?.profile); return await fetchBrowserJson( - `${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 { +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 { + return await fetchBrowserJson( + `${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 { + return await fetchBrowserJson( + `${baseUrl}/profiles/${encodeURIComponent(profile)}`, + { + method: "DELETE", + timeoutMs: 20000, + }, + ); +} + +export async function browserTabs( + baseUrl: string, + opts?: { profile?: string }, +): Promise { + 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 { export async function browserOpenTab( baseUrl: string, url: string, + opts?: { profile?: string }, ): Promise { - return await fetchBrowserJson(`${baseUrl}/tabs/open`, { + const q = buildProfileQuery(opts?.profile); + return await fetchBrowserJson(`${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 { - 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 { - 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 { 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( `${baseUrl}/snapshot?${q.toString()}`, { diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts index 60be5a9e2..a6663dff3 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -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", () => { diff --git a/src/browser/config.ts b/src/browser/config.ts index 6c3899eee..26a427f5d 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -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; +}; + +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 | undefined, + defaultColor: string, + legacyCdpPort?: number, +): Record { + 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, }; } diff --git a/src/browser/profiles-service.test.ts b/src/browser/profiles-service.test.ts new file mode 100644 index 000000000..b5d8f3a87 --- /dev/null +++ b/src/browser/profiles-service.test.ts @@ -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)); + }); +}); diff --git a/src/browser/profiles-service.ts b/src/browser/profiles-service.ts new file mode 100644 index 000000000..8f1ce90ea --- /dev/null +++ b/src/browser/profiles-service.ts @@ -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 => { + return await ctx.listProfiles(); + }; + + const createProfile = async ( + params: CreateProfileParams, + ): Promise => { + 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 => { + 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, + }; +} diff --git a/src/browser/profiles.test.ts b/src/browser/profiles.test.ts new file mode 100644 index 000000000..bc6a5e5ef --- /dev/null +++ b/src/browser/profiles.test.ts @@ -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(); + 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(); + 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(); + 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"])); + }); +}); diff --git a/src/browser/profiles.ts b/src/browser/profiles.ts new file mode 100644 index 000000000..120d62889 --- /dev/null +++ b/src/browser/profiles.ts @@ -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 | 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 | undefined, +): Set { + if (!profiles) return new Set(); + const used = new Set(); + 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 { + // 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 | undefined, +): Set { + if (!profiles) return new Set(); + return new Set(Object.values(profiles).map((p) => p.color.toUpperCase())); +} diff --git a/src/browser/routes/agent.ts b/src/browser/routes/agent.ts index 2719bfba6..3dbe13a82 100644 --- a/src/browser/routes/agent.ts +++ b/src/browser/routes/agent.ts @@ -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({ diff --git a/src/browser/routes/basic.ts b/src/browser/routes/basic.ts index 184d5d753..4bc5b2d79 100644 --- a/src/browser/routes/basic.ts +++ b/src/browser/routes/basic.ts @@ -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; 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); + } + }); } diff --git a/src/browser/routes/utils.ts b/src/browser/routes/utils.ts index 26b2da37c..0eb7ce725 100644 --- a/src/browser/routes/utils.ts +++ b/src/browser/routes/utils.ts @@ -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; + 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, diff --git a/src/browser/server-context.ts b/src/browser/server-context.ts index 0519abd6b..5ed9feda6 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -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; }; export type BrowserRouteContext = { state: () => BrowserServerState; + forProfile: (profileName?: string) => ProfileContext; + listProfiles: () => Promise; + // Legacy methods delegate to default profile for backward compatibility ensureBrowserAvailable: () => Promise; ensureTabAvailable: (targetId?: string) => Promise; isHttpReachable: (timeoutMs?: number) => Promise; @@ -51,11 +58,50 @@ export type BrowserRouteContext = { mapTabError: (err: unknown) => { status: number; message: string } | null; }; +export type ProfileContext = { + profile: ResolvedBrowserProfile; + ensureBrowserAvailable: () => Promise; + ensureTabAvailable: (targetId?: string) => Promise; + isHttpReachable: (timeoutMs?: number) => Promise; + isReachable: (timeoutMs?: number) => Promise; + listTabs: () => Promise; + openTab: (url: string) => Promise; + focusTab: (targetId: string) => Promise; + closeTab: (targetId: string) => Promise; + 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( 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 => { + 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 => { 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 => { - 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(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 => { 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 => { - 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 => { - 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 { - 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 => { + 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, + }; } diff --git a/src/browser/server.test.ts b/src/browser/server.test.ts index 723bda985..32184120e 100644 --- a/src/browser/server.test.ts +++ b/src/browser/server.test.ts @@ -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"); + }); +}); diff --git a/src/browser/server.ts b/src/browser/server.ts index 1e171e12c..f382b2247 100644 --- a/src/browser/server.ts +++ b/src/browser/server.ts @@ -36,9 +36,6 @@ export async function startBrowserControlServerFromConfig(): Promise state, - setRunning: (running) => { - if (state) state.running = running; - }, }); registerBrowserRoutes(app, ctx); @@ -58,9 +55,8 @@ export async function startBrowserControlServerFromConfig(): Promise { 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)}`); } diff --git a/src/browser/trash.ts b/src/browser/trash.ts new file mode 100644 index 000000000..f6efcc952 --- /dev/null +++ b/src/browser/trash.ts @@ -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 { + 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; + } +} diff --git a/src/cli/browser-cli-actions-input.ts b/src/cli/browser-cli-actions-input.ts index 54d8b9590..b3ee66fcb 100644 --- a/src/cli/browser-cli-actions-input.ts +++ b/src/cli/browser-cli-actions-input.ts @@ -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; diff --git a/src/cli/browser-cli-actions-observe.ts b/src/cli/browser-cli-actions-observe.ts index 7ca1693ef..b39a3347e 100644 --- a/src/cli/browser-cli-actions-observe.ts +++ b/src/cli/browser-cli-actions-observe.ts @@ -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)); diff --git a/src/cli/browser-cli-inspect.ts b/src/cli/browser-cli-inspect.ts index 59a3125c5..0a78af641 100644 --- a/src/cli/browser-cli-inspect.ts +++ b/src/cli/browser-cli-inspect.ts @@ -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) { diff --git a/src/cli/browser-cli-manage.ts b/src/cli/browser-cli-manage.ts index 156e20da7..725b7f987 100644 --- a/src/cli/browser-cli-manage.ts +++ b/src/cli/browser-cli-manage.ts @@ -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 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("", "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 ", + "Profile name (lowercase, numbers, hyphens)", + ) + .option("--color ", "Profile color (hex format, e.g. #0066CC)") + .option("--cdp-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 ", "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); + } + }); } diff --git a/src/cli/browser-cli-shared.ts b/src/cli/browser-cli-shared.ts index 0c31b9558..f38225d4a 100644 --- a/src/cli/browser-cli-shared.ts +++ b/src/cli/browser-cli-shared.ts @@ -1 +1 @@ -export type BrowserParentOpts = { url?: string; json?: boolean }; +export type BrowserParentOpts = { url?: string; json?: boolean; profile?: string }; diff --git a/src/cli/browser-cli.ts b/src/cli/browser-cli.ts index 144a81fd4..c896f27ce 100644 --- a/src/cli/browser-cli.ts +++ b/src/cli/browser-cli.ts @@ -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 ", "Override browser control URL (default from ~/.clawdis/clawdis.json)", ) + .option("--profile ", "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); diff --git a/src/config/config.ts b/src/config/config.ts index 4fd444a95..562caea04 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -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; }; 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