feat(browser): add remote-capable profiles
Co-authored-by: James Groat <james@groat.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -153,6 +153,59 @@ Hooks (arming):
|
||||
Clawdis should treat "open/closed" as a health check (fast path), not by scanning
|
||||
global Chrome processes (avoid false positives).
|
||||
|
||||
## Multi-profile support
|
||||
|
||||
Clawdis supports multiple named browser profiles, each with:
|
||||
- Dedicated CDP port (auto-allocated from 18800-18899) **or** a per-profile CDP URL
|
||||
- Persistent user data directory (`~/.clawdis/browser/<name>/user-data/`)
|
||||
- Unique color for visual distinction
|
||||
|
||||
### Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"browser": {
|
||||
"enabled": true,
|
||||
"defaultProfile": "clawd",
|
||||
"profiles": {
|
||||
"clawd": { "cdpPort": 18800, "color": "#FF4500" },
|
||||
"work": { "cdpPort": 18801, "color": "#0066CC" },
|
||||
"remote": { "cdpUrl": "http://10.0.0.42:9222", "color": "#00AA00" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Profile actions
|
||||
|
||||
- `GET /profiles` — list all profiles with status
|
||||
- `POST /profiles/create` `{ name, color?, cdpUrl? }` — create new profile (auto-allocates port if no `cdpUrl`)
|
||||
- `DELETE /profiles/:name` — delete profile (stops browser + removes user data for local profiles)
|
||||
- `POST /reset-profile?profile=<name>` — kill orphan process on profile's port (local profiles only)
|
||||
|
||||
### Profile parameter
|
||||
|
||||
All existing endpoints accept optional `?profile=<name>` query parameter:
|
||||
- `GET /?profile=work` — status for work profile
|
||||
- `POST /start?profile=work` — start work profile browser
|
||||
- `GET /tabs?profile=work` — list tabs for work profile
|
||||
- etc.
|
||||
|
||||
When `profile` is omitted, uses `browser.defaultProfile` (defaults to "clawd").
|
||||
|
||||
### Profile naming rules
|
||||
|
||||
- Lowercase alphanumeric characters and hyphens only
|
||||
- Must start with a letter or number (not a hyphen)
|
||||
- Maximum 64 characters
|
||||
- Examples: `clawd`, `work`, `my-project-1`
|
||||
|
||||
### Port allocation
|
||||
|
||||
Ports are allocated from range 18800-18899 (~100 profiles max). This is far more
|
||||
than practical use — memory and CPU exhaustion occur well before port exhaustion.
|
||||
Ports are allocated once at profile creation and persisted permanently.
|
||||
Remote profiles are attach-only and do **not** use the local port range.
|
||||
## Interaction with the agent (clawd)
|
||||
|
||||
The agent should use browser tools only when:
|
||||
@@ -168,6 +221,13 @@ The agent should not assume tabs are ephemeral. It should:
|
||||
|
||||
## CLI quick reference (one example each)
|
||||
|
||||
All commands accept `--profile <name>` to target a specific profile (default: `clawd`).
|
||||
|
||||
Profile management:
|
||||
- `clawdis browser profiles`
|
||||
- `clawdis browser create-profile --name work`
|
||||
- `clawdis browser create-profile --name remote --cdp-url http://10.0.0.42:9222`
|
||||
- `clawdis browser delete-profile --name work`
|
||||
Basics:
|
||||
- `clawdis browser status`
|
||||
- `clawdis browser start`
|
||||
|
||||
@@ -692,11 +692,16 @@ Example:
|
||||
### `browser` (clawd-managed Chrome)
|
||||
|
||||
Clawdis can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server.
|
||||
Profiles can point at a **remote** Chrome via `profiles.<name>.cdpUrl`. Remote
|
||||
profiles are attach-only (start/stop/reset are disabled).
|
||||
|
||||
`browser.cdpUrl` remains for legacy single-profile configs and as the base
|
||||
scheme/host for profiles that only set `cdpPort`.
|
||||
|
||||
Defaults:
|
||||
- enabled: `true`
|
||||
- control URL: `http://127.0.0.1:18791` (CDP uses `18792`)
|
||||
- CDP URL: `http://127.0.0.1:18792` (control URL + 1)
|
||||
- CDP URL: `http://127.0.0.1:18792` (control URL + 1, legacy single-profile)
|
||||
- profile color: `#FF4500` (lobster-orange)
|
||||
- Note: the control server is started by the running gateway (Clawdis.app menubar, or `clawdis gateway`).
|
||||
|
||||
@@ -705,7 +710,13 @@ Defaults:
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
// cdpUrl: "http://127.0.0.1:18792", // override for remote CDP
|
||||
// cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
|
||||
defaultProfile: "clawd",
|
||||
profiles: {
|
||||
clawd: { cdpPort: 18800, color: "#FF4500" },
|
||||
work: { cdpPort: 18801, color: "#0066CC" },
|
||||
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }
|
||||
},
|
||||
color: "#FF4500",
|
||||
// Advanced:
|
||||
// headless: false,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ensurePortAvailable } from "../infra/ports.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { CONFIG_DIR } from "../utils.js";
|
||||
import { normalizeCdpWsUrl } from "./cdp.js";
|
||||
import type { ResolvedBrowserConfig } from "./config.js";
|
||||
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
|
||||
import {
|
||||
DEFAULT_CLAWD_BROWSER_COLOR,
|
||||
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
|
||||
@@ -116,11 +116,13 @@ function resolveBrowserExecutable(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveClawdUserDataDir() {
|
||||
export function resolveClawdUserDataDir(
|
||||
profileName = DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
|
||||
) {
|
||||
return path.join(
|
||||
CONFIG_DIR,
|
||||
"browser",
|
||||
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
|
||||
profileName,
|
||||
"user-data",
|
||||
);
|
||||
}
|
||||
@@ -255,9 +257,9 @@ function isProfileDecorated(
|
||||
*/
|
||||
export function decorateClawdProfile(
|
||||
userDataDir: string,
|
||||
opts?: { color?: string },
|
||||
opts?: { name?: string; color?: string },
|
||||
) {
|
||||
const desiredName = DEFAULT_CLAWD_BROWSER_PROFILE_NAME;
|
||||
const desiredName = opts?.name ?? DEFAULT_CLAWD_BROWSER_PROFILE_NAME;
|
||||
const desiredColor = (
|
||||
opts?.color ?? DEFAULT_CLAWD_BROWSER_COLOR
|
||||
).toUpperCase();
|
||||
@@ -433,8 +435,14 @@ export async function isChromeCdpReady(
|
||||
|
||||
export async function launchClawdChrome(
|
||||
resolved: ResolvedBrowserConfig,
|
||||
profile: ResolvedBrowserProfile,
|
||||
): Promise<RunningChrome> {
|
||||
await ensurePortAvailable(resolved.cdpPort);
|
||||
if (!profile.cdpIsLoopback) {
|
||||
throw new Error(
|
||||
`Profile "${profile.name}" is remote; cannot launch local Chrome.`,
|
||||
);
|
||||
}
|
||||
await ensurePortAvailable(profile.cdpPort);
|
||||
|
||||
const exe = resolveBrowserExecutable(resolved);
|
||||
if (!exe) {
|
||||
@@ -443,19 +451,19 @@ export async function launchClawdChrome(
|
||||
);
|
||||
}
|
||||
|
||||
const userDataDir = resolveClawdUserDataDir();
|
||||
const userDataDir = resolveClawdUserDataDir(profile.name);
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
|
||||
const needsDecorate = !isProfileDecorated(
|
||||
userDataDir,
|
||||
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
|
||||
(resolved.color ?? DEFAULT_CLAWD_BROWSER_COLOR).toUpperCase(),
|
||||
profile.name,
|
||||
(profile.color ?? DEFAULT_CLAWD_BROWSER_COLOR).toUpperCase(),
|
||||
);
|
||||
|
||||
// First launch to create preference files if missing, then decorate and relaunch.
|
||||
const spawnOnce = () => {
|
||||
const args: string[] = [
|
||||
`--remote-debugging-port=${resolved.cdpPort}`,
|
||||
`--remote-debugging-port=${profile.cdpPort}`,
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
@@ -521,8 +529,11 @@ export async function launchClawdChrome(
|
||||
|
||||
if (needsDecorate) {
|
||||
try {
|
||||
decorateClawdProfile(userDataDir, { color: resolved.color });
|
||||
log.info(`🦞 clawd browser profile decorated (${resolved.color})`);
|
||||
decorateClawdProfile(userDataDir, {
|
||||
name: profile.name,
|
||||
color: profile.color,
|
||||
});
|
||||
log.info(`🦞 clawd browser profile decorated (${profile.color})`);
|
||||
} catch (err) {
|
||||
log.warn(`clawd browser profile decoration failed: ${String(err)}`);
|
||||
}
|
||||
@@ -532,29 +543,31 @@ export async function launchClawdChrome(
|
||||
// Wait for CDP to come up.
|
||||
const readyDeadline = Date.now() + 15_000;
|
||||
while (Date.now() < readyDeadline) {
|
||||
if (await isChromeReachable(resolved.cdpUrl, 500)) break;
|
||||
if (await isChromeReachable(profile.cdpUrl, 500)) break;
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
|
||||
if (!(await isChromeReachable(resolved.cdpUrl, 500))) {
|
||||
if (!(await isChromeReachable(profile.cdpUrl, 500))) {
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(`Failed to start Chrome CDP on port ${resolved.cdpPort}.`);
|
||||
throw new Error(
|
||||
`Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const pid = proc.pid ?? -1;
|
||||
log.info(
|
||||
`🦞 clawd browser started (${exe.kind}) on 127.0.0.1:${resolved.cdpPort} (pid ${pid})`,
|
||||
`🦞 clawd browser started (${exe.kind}) profile "${profile.name}" on 127.0.0.1:${profile.cdpPort} (pid ${pid})`,
|
||||
);
|
||||
|
||||
return {
|
||||
pid,
|
||||
exe,
|
||||
userDataDir,
|
||||
cdpPort: resolved.cdpPort,
|
||||
cdpPort: profile.cdpPort,
|
||||
startedAt,
|
||||
proc,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,10 @@ import type {
|
||||
} from "./client-actions-types.js";
|
||||
import { fetchBrowserJson } from "./client-fetch.js";
|
||||
|
||||
function buildProfileQuery(profile?: string): string {
|
||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||
}
|
||||
|
||||
export type BrowserFormField = {
|
||||
ref: string;
|
||||
type: string;
|
||||
@@ -57,14 +61,18 @@ export type BrowserActResponse = {
|
||||
|
||||
export async function browserNavigate(
|
||||
baseUrl: string,
|
||||
opts: { url: string; targetId?: string },
|
||||
opts: { url: string; targetId?: string; profile?: string },
|
||||
): Promise<BrowserActionTabResult> {
|
||||
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/navigate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: opts.url, targetId: opts.targetId }),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTabResult>(
|
||||
`${baseUrl}/navigate${q}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: opts.url, targetId: opts.targetId }),
|
||||
timeoutMs: 20000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserArmDialog(
|
||||
@@ -74,19 +82,24 @@ export async function browserArmDialog(
|
||||
promptText?: string;
|
||||
targetId?: string;
|
||||
timeoutMs?: number;
|
||||
profile?: string;
|
||||
},
|
||||
): Promise<BrowserActionOk> {
|
||||
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/hooks/dialog`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
accept: opts.accept,
|
||||
promptText: opts.promptText,
|
||||
targetId: opts.targetId,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionOk>(
|
||||
`${baseUrl}/hooks/dialog${q}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
accept: opts.accept,
|
||||
promptText: opts.promptText,
|
||||
targetId: opts.targetId,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserArmFileChooser(
|
||||
@@ -98,10 +111,12 @@ export async function browserArmFileChooser(
|
||||
element?: string;
|
||||
targetId?: string;
|
||||
timeoutMs?: number;
|
||||
profile?: string;
|
||||
},
|
||||
): Promise<BrowserActionOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionOk>(
|
||||
`${baseUrl}/hooks/file-chooser`,
|
||||
`${baseUrl}/hooks/file-chooser${q}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -121,8 +136,10 @@ export async function browserArmFileChooser(
|
||||
export async function browserAct(
|
||||
baseUrl: string,
|
||||
req: BrowserActRequest,
|
||||
opts?: { profile?: string },
|
||||
): Promise<BrowserActResponse> {
|
||||
return await fetchBrowserJson<BrowserActResponse>(`${baseUrl}/act`, {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
return await fetchBrowserJson<BrowserActResponse>(`${baseUrl}/act${q}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(req),
|
||||
@@ -138,10 +155,12 @@ export async function browserScreenshotAction(
|
||||
ref?: string;
|
||||
element?: string;
|
||||
type?: "png" | "jpeg";
|
||||
profile?: string;
|
||||
},
|
||||
): Promise<BrowserActionPathResult> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionPathResult>(
|
||||
`${baseUrl}/screenshot`,
|
||||
`${baseUrl}/screenshot${q}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -2,13 +2,18 @@ import type { BrowserActionPathResult } from "./client-actions-types.js";
|
||||
import { fetchBrowserJson } from "./client-fetch.js";
|
||||
import type { BrowserConsoleMessage } from "./pw-session.js";
|
||||
|
||||
function buildProfileQuery(profile?: string): string {
|
||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||
}
|
||||
|
||||
export async function browserConsoleMessages(
|
||||
baseUrl: string,
|
||||
opts: { level?: string; targetId?: string } = {},
|
||||
opts: { level?: string; targetId?: string; profile?: string } = {},
|
||||
): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string }> {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.level) q.set("level", opts.level);
|
||||
if (opts.targetId) q.set("targetId", opts.targetId);
|
||||
if (opts.profile) q.set("profile", opts.profile);
|
||||
const suffix = q.toString() ? `?${q.toString()}` : "";
|
||||
return await fetchBrowserJson<{
|
||||
ok: true;
|
||||
@@ -19,12 +24,16 @@ export async function browserConsoleMessages(
|
||||
|
||||
export async function browserPdfSave(
|
||||
baseUrl: string,
|
||||
opts: { targetId?: string } = {},
|
||||
opts: { targetId?: string; profile?: string } = {},
|
||||
): Promise<BrowserActionPathResult> {
|
||||
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/pdf`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId }),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionPathResult>(
|
||||
`${baseUrl}/pdf${q}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId }),
|
||||
timeoutMs: 20000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { resolveBrowserConfig } from "./config.js";
|
||||
export type BrowserStatus = {
|
||||
enabled: boolean;
|
||||
controlUrl: string;
|
||||
profile?: string;
|
||||
running: boolean;
|
||||
cdpReady?: boolean;
|
||||
cdpHttp?: boolean;
|
||||
@@ -20,6 +21,17 @@ export type BrowserStatus = {
|
||||
attachOnly: boolean;
|
||||
};
|
||||
|
||||
export type ProfileStatus = {
|
||||
name: string;
|
||||
cdpPort: number;
|
||||
cdpUrl: string;
|
||||
color: string;
|
||||
running: boolean;
|
||||
tabCount: number;
|
||||
isDefault: boolean;
|
||||
isRemote: boolean;
|
||||
};
|
||||
|
||||
export type BrowserResetProfileResult = {
|
||||
ok: true;
|
||||
moved: boolean;
|
||||
@@ -31,6 +43,7 @@ export type BrowserTab = {
|
||||
targetId: string;
|
||||
title: string;
|
||||
url: string;
|
||||
wsUrl?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
@@ -67,21 +80,47 @@ export function resolveBrowserControlUrl(overrideUrl?: string) {
|
||||
return url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export async function browserStatus(baseUrl: string): Promise<BrowserStatus> {
|
||||
return await fetchBrowserJson<BrowserStatus>(`${baseUrl}/`, {
|
||||
function buildProfileQuery(profile?: string): string {
|
||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||
}
|
||||
|
||||
export async function browserStatus(
|
||||
baseUrl: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<BrowserStatus> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
return await fetchBrowserJson<BrowserStatus>(`${baseUrl}/${q}`, {
|
||||
timeoutMs: 1500,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserStart(baseUrl: string): Promise<void> {
|
||||
await fetchBrowserJson(`${baseUrl}/start`, {
|
||||
export async function browserProfiles(
|
||||
baseUrl: string,
|
||||
): Promise<ProfileStatus[]> {
|
||||
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(
|
||||
`${baseUrl}/profiles`,
|
||||
{ timeoutMs: 3000 },
|
||||
);
|
||||
return res.profiles ?? [];
|
||||
}
|
||||
|
||||
export async function browserStart(
|
||||
baseUrl: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<void> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
await fetchBrowserJson(`${baseUrl}/start${q}`, {
|
||||
method: "POST",
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserStop(baseUrl: string): Promise<void> {
|
||||
await fetchBrowserJson(`${baseUrl}/stop`, {
|
||||
export async function browserStop(
|
||||
baseUrl: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<void> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
await fetchBrowserJson(`${baseUrl}/stop${q}`, {
|
||||
method: "POST",
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
@@ -89,9 +128,11 @@ export async function browserStop(baseUrl: string): Promise<void> {
|
||||
|
||||
export async function browserResetProfile(
|
||||
baseUrl: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<BrowserResetProfileResult> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
return await fetchBrowserJson<BrowserResetProfileResult>(
|
||||
`${baseUrl}/reset-profile`,
|
||||
`${baseUrl}/reset-profile${q}`,
|
||||
{
|
||||
method: "POST",
|
||||
timeoutMs: 20000,
|
||||
@@ -99,9 +140,60 @@ export async function browserResetProfile(
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserTabs(baseUrl: string): Promise<BrowserTab[]> {
|
||||
export type BrowserCreateProfileResult = {
|
||||
ok: true;
|
||||
profile: string;
|
||||
cdpPort: number;
|
||||
cdpUrl: string;
|
||||
color: string;
|
||||
isRemote: boolean;
|
||||
};
|
||||
|
||||
export async function browserCreateProfile(
|
||||
baseUrl: string,
|
||||
opts: { name: string; color?: string; cdpUrl?: string },
|
||||
): Promise<BrowserCreateProfileResult> {
|
||||
return await fetchBrowserJson<BrowserCreateProfileResult>(
|
||||
`${baseUrl}/profiles/create`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: opts.name,
|
||||
color: opts.color,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
}),
|
||||
timeoutMs: 10000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type BrowserDeleteProfileResult = {
|
||||
ok: true;
|
||||
profile: string;
|
||||
deleted: boolean;
|
||||
};
|
||||
|
||||
export async function browserDeleteProfile(
|
||||
baseUrl: string,
|
||||
profile: string,
|
||||
): Promise<BrowserDeleteProfileResult> {
|
||||
return await fetchBrowserJson<BrowserDeleteProfileResult>(
|
||||
`${baseUrl}/profiles/${encodeURIComponent(profile)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
timeoutMs: 20000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserTabs(
|
||||
baseUrl: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<BrowserTab[]> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>(
|
||||
`${baseUrl}/tabs`,
|
||||
`${baseUrl}/tabs${q}`,
|
||||
{ timeoutMs: 3000 },
|
||||
);
|
||||
return res.tabs ?? [];
|
||||
@@ -110,8 +202,10 @@ export async function browserTabs(baseUrl: string): Promise<BrowserTab[]> {
|
||||
export async function browserOpenTab(
|
||||
baseUrl: string,
|
||||
url: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<BrowserTab> {
|
||||
return await fetchBrowserJson<BrowserTab>(`${baseUrl}/tabs/open`, {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
return await fetchBrowserJson<BrowserTab>(`${baseUrl}/tabs/open${q}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
@@ -122,8 +216,10 @@ export async function browserOpenTab(
|
||||
export async function browserFocusTab(
|
||||
baseUrl: string,
|
||||
targetId: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<void> {
|
||||
await fetchBrowserJson(`${baseUrl}/tabs/focus`, {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
await fetchBrowserJson(`${baseUrl}/tabs/focus${q}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId }),
|
||||
@@ -134,11 +230,16 @@ export async function browserFocusTab(
|
||||
export async function browserCloseTab(
|
||||
baseUrl: string,
|
||||
targetId: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<void> {
|
||||
await fetchBrowserJson(`${baseUrl}/tabs/${encodeURIComponent(targetId)}`, {
|
||||
method: "DELETE",
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
await fetchBrowserJson(
|
||||
`${baseUrl}/tabs/${encodeURIComponent(targetId)}${q}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
timeoutMs: 5000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserSnapshot(
|
||||
@@ -147,12 +248,14 @@ export async function browserSnapshot(
|
||||
format: "aria" | "ai";
|
||||
targetId?: string;
|
||||
limit?: number;
|
||||
profile?: string;
|
||||
},
|
||||
): Promise<SnapshotResult> {
|
||||
const q = new URLSearchParams();
|
||||
q.set("format", opts.format);
|
||||
if (opts.targetId) q.set("targetId", opts.targetId);
|
||||
if (typeof opts.limit === "number") q.set("limit", String(opts.limit));
|
||||
if (opts.profile) q.set("profile", opts.profile);
|
||||
return await fetchBrowserJson<SnapshotResult>(
|
||||
`${baseUrl}/snapshot?${q.toString()}`,
|
||||
{
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
import type { BrowserConfig } from "../config/config.js";
|
||||
import type { BrowserConfig, BrowserProfileConfig } from "../config/config.js";
|
||||
import {
|
||||
DEFAULT_CLAWD_BROWSER_COLOR,
|
||||
DEFAULT_CLAWD_BROWSER_CONTROL_URL,
|
||||
DEFAULT_CLAWD_BROWSER_ENABLED,
|
||||
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
|
||||
} from "./constants.js";
|
||||
import { CDP_PORT_RANGE_START } from "./profiles.js";
|
||||
|
||||
export type ResolvedBrowserConfig = {
|
||||
enabled: boolean;
|
||||
controlUrl: string;
|
||||
controlHost: string;
|
||||
controlPort: number;
|
||||
cdpUrl: string;
|
||||
cdpProtocol: "http" | "https";
|
||||
cdpHost: string;
|
||||
cdpPort: number;
|
||||
cdpIsLoopback: boolean;
|
||||
color: string;
|
||||
executablePath?: string;
|
||||
headless: boolean;
|
||||
noSandbox: boolean;
|
||||
attachOnly: boolean;
|
||||
defaultProfile: string;
|
||||
profiles: Record<string, BrowserProfileConfig>;
|
||||
};
|
||||
|
||||
export type ResolvedBrowserProfile = {
|
||||
name: string;
|
||||
cdpPort: number;
|
||||
cdpUrl: string;
|
||||
cdpHost: string;
|
||||
cdpIsLoopback: boolean;
|
||||
color: string;
|
||||
};
|
||||
|
||||
function isLoopbackHost(host: string) {
|
||||
@@ -42,7 +54,7 @@ function normalizeHexColor(raw: string | undefined) {
|
||||
return normalized.toUpperCase();
|
||||
}
|
||||
|
||||
function parseHttpUrl(raw: string, label: string) {
|
||||
export function parseHttpUrl(raw: string, label: string) {
|
||||
const trimmed = raw.trim();
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
@@ -69,6 +81,24 @@ function parseHttpUrl(raw: string, label: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the default "clawd" profile exists in the profiles map.
|
||||
* Auto-creates it with the legacy CDP port (from browser.cdpUrl) or first port if missing.
|
||||
*/
|
||||
function ensureDefaultProfile(
|
||||
profiles: Record<string, BrowserProfileConfig> | undefined,
|
||||
defaultColor: string,
|
||||
legacyCdpPort?: number,
|
||||
): Record<string, BrowserProfileConfig> {
|
||||
const result = { ...profiles };
|
||||
if (!result[DEFAULT_CLAWD_BROWSER_PROFILE_NAME]) {
|
||||
result[DEFAULT_CLAWD_BROWSER_PROFILE_NAME] = {
|
||||
cdpPort: legacyCdpPort ?? CDP_PORT_RANGE_START,
|
||||
color: defaultColor,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export function resolveBrowserConfig(
|
||||
cfg: BrowserConfig | undefined,
|
||||
): ResolvedBrowserConfig {
|
||||
@@ -78,6 +108,7 @@ export function resolveBrowserConfig(
|
||||
"browser.controlUrl",
|
||||
);
|
||||
const controlPort = controlInfo.port;
|
||||
const defaultColor = normalizeHexColor(cfg?.color);
|
||||
|
||||
const rawCdpUrl = (cfg?.cdpUrl ?? "").trim();
|
||||
let cdpInfo:
|
||||
@@ -105,26 +136,77 @@ export function resolveBrowserConfig(
|
||||
};
|
||||
}
|
||||
|
||||
const cdpPort = cdpInfo.port;
|
||||
const headless = cfg?.headless === true;
|
||||
const noSandbox = cfg?.noSandbox === true;
|
||||
const attachOnly = cfg?.attachOnly === true;
|
||||
const executablePath = cfg?.executablePath?.trim() || undefined;
|
||||
|
||||
const defaultProfile =
|
||||
cfg?.defaultProfile ?? DEFAULT_CLAWD_BROWSER_PROFILE_NAME;
|
||||
// Use legacy cdpUrl port for backward compatibility when no profiles configured
|
||||
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
|
||||
const profiles = ensureDefaultProfile(
|
||||
cfg?.profiles,
|
||||
defaultColor,
|
||||
legacyCdpPort,
|
||||
);
|
||||
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";
|
||||
|
||||
return {
|
||||
enabled,
|
||||
controlUrl: controlInfo.normalized,
|
||||
controlHost: controlInfo.parsed.hostname,
|
||||
controlPort,
|
||||
cdpUrl: cdpInfo.normalized,
|
||||
cdpProtocol,
|
||||
cdpHost: cdpInfo.parsed.hostname,
|
||||
cdpPort,
|
||||
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
|
||||
color: normalizeHexColor(cfg?.color),
|
||||
color: defaultColor,
|
||||
executablePath,
|
||||
headless,
|
||||
noSandbox,
|
||||
attachOnly,
|
||||
defaultProfile,
|
||||
profiles,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a profile by name from the config.
|
||||
* Returns null if the profile doesn't exist.
|
||||
*/
|
||||
export function resolveProfile(
|
||||
resolved: ResolvedBrowserConfig,
|
||||
profileName: string,
|
||||
): ResolvedBrowserProfile | null {
|
||||
const profile = resolved.profiles[profileName];
|
||||
if (!profile) return null;
|
||||
|
||||
const rawProfileUrl = profile.cdpUrl?.trim() ?? "";
|
||||
let cdpHost = resolved.cdpHost;
|
||||
let cdpPort = profile.cdpPort ?? 0;
|
||||
let cdpUrl = "";
|
||||
|
||||
if (rawProfileUrl) {
|
||||
const parsed = parseHttpUrl(
|
||||
rawProfileUrl,
|
||||
`browser.profiles.${profileName}.cdpUrl`,
|
||||
);
|
||||
cdpHost = parsed.parsed.hostname;
|
||||
cdpPort = parsed.port;
|
||||
cdpUrl = parsed.normalized;
|
||||
} else if (cdpPort) {
|
||||
cdpUrl = `${resolved.cdpProtocol}://${resolved.cdpHost}:${cdpPort}`;
|
||||
} else {
|
||||
throw new Error(`Profile "${profileName}" must define cdpPort or cdpUrl.`);
|
||||
}
|
||||
|
||||
return {
|
||||
name: profileName,
|
||||
cdpPort,
|
||||
cdpUrl,
|
||||
cdpHost,
|
||||
cdpIsLoopback: isLoopbackHost(cdpHost),
|
||||
color: profile.color,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
154
src/browser/profiles-service.test.ts
Normal file
154
src/browser/profiles-service.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { resolveBrowserConfig } from "./config.js";
|
||||
import { createBrowserProfilesService } from "./profiles-service.js";
|
||||
import type {
|
||||
BrowserRouteContext,
|
||||
BrowserServerState,
|
||||
} from "./server-context.js";
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: vi.fn(),
|
||||
writeConfigFile: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("./trash.js", () => ({
|
||||
movePathToTrash: vi.fn(async (targetPath: string) => targetPath),
|
||||
}));
|
||||
|
||||
vi.mock("./chrome.js", () => ({
|
||||
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd-test/clawd/user-data"),
|
||||
}));
|
||||
|
||||
import { loadConfig, writeConfigFile } from "../config/config.js";
|
||||
import { resolveClawdUserDataDir } from "./chrome.js";
|
||||
import { movePathToTrash } from "./trash.js";
|
||||
|
||||
function createCtx(resolved: BrowserServerState["resolved"]) {
|
||||
const state: BrowserServerState = {
|
||||
server: null as unknown as BrowserServerState["server"],
|
||||
port: 0,
|
||||
resolved,
|
||||
profiles: new Map(),
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
state: () => state,
|
||||
listProfiles: vi.fn(async () => []),
|
||||
forProfile: vi.fn(() => ({
|
||||
stopRunningBrowser: vi.fn(async () => ({ stopped: true })),
|
||||
})),
|
||||
} as unknown as BrowserRouteContext;
|
||||
|
||||
return { state, ctx };
|
||||
}
|
||||
|
||||
describe("BrowserProfilesService", () => {
|
||||
it("allocates next local port for new profiles", async () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
});
|
||||
const { ctx, state } = createCtx(resolved);
|
||||
|
||||
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
|
||||
|
||||
const service = createBrowserProfilesService(ctx);
|
||||
const result = await service.createProfile({ name: "work" });
|
||||
|
||||
expect(result.cdpPort).toBe(18801);
|
||||
expect(result.isRemote).toBe(false);
|
||||
expect(state.resolved.profiles.work?.cdpPort).toBe(18801);
|
||||
expect(writeConfigFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts per-profile cdpUrl for remote Chrome", async () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
});
|
||||
const { ctx } = createCtx(resolved);
|
||||
|
||||
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
|
||||
|
||||
const service = createBrowserProfilesService(ctx);
|
||||
const result = await service.createProfile({
|
||||
name: "remote",
|
||||
cdpUrl: "http://10.0.0.42:9222",
|
||||
});
|
||||
|
||||
expect(result.cdpUrl).toBe("http://10.0.0.42:9222");
|
||||
expect(result.cdpPort).toBe(9222);
|
||||
expect(result.isRemote).toBe(true);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
browser: expect.objectContaining({
|
||||
profiles: expect.objectContaining({
|
||||
remote: expect.objectContaining({
|
||||
cdpUrl: "http://10.0.0.42:9222",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes remote profiles without stopping or removing local data", async () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
profiles: {
|
||||
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#0066CC" },
|
||||
},
|
||||
});
|
||||
const { ctx } = createCtx(resolved);
|
||||
|
||||
vi.mocked(loadConfig).mockReturnValue({
|
||||
browser: {
|
||||
defaultProfile: "clawd",
|
||||
profiles: {
|
||||
clawd: { cdpPort: 18800, color: "#FF4500" },
|
||||
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#0066CC" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = createBrowserProfilesService(ctx);
|
||||
const result = await service.deleteProfile("remote");
|
||||
|
||||
expect(result.deleted).toBe(false);
|
||||
expect(ctx.forProfile).not.toHaveBeenCalled();
|
||||
expect(movePathToTrash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes local profiles and moves data to Trash", async () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
profiles: {
|
||||
work: { cdpPort: 18801, color: "#0066CC" },
|
||||
},
|
||||
});
|
||||
const { ctx } = createCtx(resolved);
|
||||
|
||||
vi.mocked(loadConfig).mockReturnValue({
|
||||
browser: {
|
||||
defaultProfile: "clawd",
|
||||
profiles: {
|
||||
clawd: { cdpPort: 18800, color: "#FF4500" },
|
||||
work: { cdpPort: 18801, color: "#0066CC" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join("/tmp", "clawd-profile-"));
|
||||
const userDataDir = path.join(tempDir, "work", "user-data");
|
||||
fs.mkdirSync(path.dirname(userDataDir), { recursive: true });
|
||||
vi.mocked(resolveClawdUserDataDir).mockReturnValue(userDataDir);
|
||||
|
||||
const service = createBrowserProfilesService(ctx);
|
||||
const result = await service.deleteProfile("work");
|
||||
|
||||
expect(result.deleted).toBe(true);
|
||||
expect(movePathToTrash).toHaveBeenCalledWith(path.dirname(userDataDir));
|
||||
});
|
||||
});
|
||||
181
src/browser/profiles-service.ts
Normal file
181
src/browser/profiles-service.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { BrowserProfileConfig, ClawdisConfig } from "../config/config.js";
|
||||
import { loadConfig, writeConfigFile } from "../config/config.js";
|
||||
import { resolveClawdUserDataDir } from "./chrome.js";
|
||||
import { parseHttpUrl, resolveProfile } from "./config.js";
|
||||
import {
|
||||
allocateCdpPort,
|
||||
allocateColor,
|
||||
getUsedColors,
|
||||
getUsedPorts,
|
||||
isValidProfileName,
|
||||
} from "./profiles.js";
|
||||
import type { BrowserRouteContext, ProfileStatus } from "./server-context.js";
|
||||
import { movePathToTrash } from "./trash.js";
|
||||
|
||||
export type CreateProfileParams = {
|
||||
name: string;
|
||||
color?: string;
|
||||
cdpUrl?: string;
|
||||
};
|
||||
|
||||
export type CreateProfileResult = {
|
||||
ok: true;
|
||||
profile: string;
|
||||
cdpPort: number;
|
||||
cdpUrl: string;
|
||||
color: string;
|
||||
isRemote: boolean;
|
||||
};
|
||||
|
||||
export type DeleteProfileResult = {
|
||||
ok: true;
|
||||
profile: string;
|
||||
deleted: boolean;
|
||||
};
|
||||
|
||||
const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
|
||||
|
||||
export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
||||
const listProfiles = async (): Promise<ProfileStatus[]> => {
|
||||
return await ctx.listProfiles();
|
||||
};
|
||||
|
||||
const createProfile = async (
|
||||
params: CreateProfileParams,
|
||||
): Promise<CreateProfileResult> => {
|
||||
const name = params.name.trim();
|
||||
const rawCdpUrl = params.cdpUrl?.trim() || undefined;
|
||||
|
||||
if (!isValidProfileName(name)) {
|
||||
throw new Error(
|
||||
"invalid profile name: use lowercase letters, numbers, and hyphens only",
|
||||
);
|
||||
}
|
||||
|
||||
const state = ctx.state();
|
||||
const resolvedProfiles = state.resolved.profiles;
|
||||
if (name in resolvedProfiles) {
|
||||
throw new Error(`profile "${name}" already exists`);
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const rawProfiles = cfg.browser?.profiles ?? {};
|
||||
if (name in rawProfiles) {
|
||||
throw new Error(`profile "${name}" already exists`);
|
||||
}
|
||||
|
||||
const usedColors = getUsedColors(resolvedProfiles);
|
||||
const profileColor =
|
||||
params.color && HEX_COLOR_RE.test(params.color)
|
||||
? params.color
|
||||
: allocateColor(usedColors);
|
||||
|
||||
let profileConfig: BrowserProfileConfig;
|
||||
if (rawCdpUrl) {
|
||||
const parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl");
|
||||
profileConfig = { cdpUrl: parsed.normalized, color: profileColor };
|
||||
} else {
|
||||
const usedPorts = getUsedPorts(resolvedProfiles);
|
||||
const cdpPort = allocateCdpPort(usedPorts);
|
||||
if (cdpPort === null) {
|
||||
throw new Error("no available CDP ports in range");
|
||||
}
|
||||
profileConfig = { cdpPort, color: profileColor };
|
||||
}
|
||||
|
||||
const nextConfig: ClawdisConfig = {
|
||||
...cfg,
|
||||
browser: {
|
||||
...cfg.browser,
|
||||
profiles: {
|
||||
...rawProfiles,
|
||||
[name]: profileConfig,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
|
||||
state.resolved.profiles[name] = profileConfig;
|
||||
const resolved = resolveProfile(state.resolved, name);
|
||||
if (!resolved) {
|
||||
throw new Error(`profile "${name}" not found after creation`);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
profile: name,
|
||||
cdpPort: resolved.cdpPort,
|
||||
cdpUrl: resolved.cdpUrl,
|
||||
color: resolved.color,
|
||||
isRemote: !resolved.cdpIsLoopback,
|
||||
};
|
||||
};
|
||||
|
||||
const deleteProfile = async (
|
||||
nameRaw: string,
|
||||
): Promise<DeleteProfileResult> => {
|
||||
const name = nameRaw.trim();
|
||||
if (!name) throw new Error("profile name is required");
|
||||
if (!isValidProfileName(name)) {
|
||||
throw new Error("invalid profile name");
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const profiles = cfg.browser?.profiles ?? {};
|
||||
if (!(name in profiles)) {
|
||||
throw new Error(`profile "${name}" not found`);
|
||||
}
|
||||
|
||||
const defaultProfile = cfg.browser?.defaultProfile ?? "clawd";
|
||||
if (name === defaultProfile) {
|
||||
throw new Error(
|
||||
`cannot delete the default profile "${name}"; change browser.defaultProfile first`,
|
||||
);
|
||||
}
|
||||
|
||||
let deleted = false;
|
||||
const state = ctx.state();
|
||||
const resolved = resolveProfile(state.resolved, name);
|
||||
|
||||
if (resolved?.cdpIsLoopback) {
|
||||
try {
|
||||
await ctx.forProfile(name).stopRunningBrowser();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const userDataDir = resolveClawdUserDataDir(name);
|
||||
const profileDir = path.dirname(userDataDir);
|
||||
if (fs.existsSync(profileDir)) {
|
||||
await movePathToTrash(profileDir);
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
const { [name]: _removed, ...remainingProfiles } = profiles;
|
||||
const nextConfig: ClawdisConfig = {
|
||||
...cfg,
|
||||
browser: {
|
||||
...cfg.browser,
|
||||
profiles: remainingProfiles,
|
||||
},
|
||||
};
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
|
||||
delete state.resolved.profiles[name];
|
||||
state.profiles.delete(name);
|
||||
|
||||
return { ok: true, profile: name, deleted };
|
||||
};
|
||||
|
||||
return {
|
||||
listProfiles,
|
||||
createProfile,
|
||||
deleteProfile,
|
||||
};
|
||||
}
|
||||
240
src/browser/profiles.test.ts
Normal file
240
src/browser/profiles.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
allocateCdpPort,
|
||||
allocateColor,
|
||||
CDP_PORT_RANGE_END,
|
||||
CDP_PORT_RANGE_START,
|
||||
getUsedColors,
|
||||
getUsedPorts,
|
||||
isValidProfileName,
|
||||
PROFILE_COLORS,
|
||||
} from "./profiles.js";
|
||||
|
||||
describe("profile name validation", () => {
|
||||
it("accepts valid lowercase names", () => {
|
||||
expect(isValidProfileName("clawd")).toBe(true);
|
||||
expect(isValidProfileName("work")).toBe(true);
|
||||
expect(isValidProfileName("my-profile")).toBe(true);
|
||||
expect(isValidProfileName("test123")).toBe(true);
|
||||
expect(isValidProfileName("a")).toBe(true);
|
||||
expect(isValidProfileName("a-b-c-1-2-3")).toBe(true);
|
||||
expect(isValidProfileName("1test")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty or missing names", () => {
|
||||
expect(isValidProfileName("")).toBe(false);
|
||||
// @ts-expect-error testing invalid input
|
||||
expect(isValidProfileName(null)).toBe(false);
|
||||
// @ts-expect-error testing invalid input
|
||||
expect(isValidProfileName(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects names that are too long", () => {
|
||||
const longName = "a".repeat(65);
|
||||
expect(isValidProfileName(longName)).toBe(false);
|
||||
|
||||
const maxName = "a".repeat(64);
|
||||
expect(isValidProfileName(maxName)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects uppercase letters", () => {
|
||||
expect(isValidProfileName("MyProfile")).toBe(false);
|
||||
expect(isValidProfileName("PROFILE")).toBe(false);
|
||||
expect(isValidProfileName("Work")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects spaces and special characters", () => {
|
||||
expect(isValidProfileName("my profile")).toBe(false);
|
||||
expect(isValidProfileName("my_profile")).toBe(false);
|
||||
expect(isValidProfileName("my.profile")).toBe(false);
|
||||
expect(isValidProfileName("my/profile")).toBe(false);
|
||||
expect(isValidProfileName("my@profile")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects names starting with hyphen", () => {
|
||||
expect(isValidProfileName("-invalid")).toBe(false);
|
||||
expect(isValidProfileName("--double")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("port allocation", () => {
|
||||
it("allocates first port when none used", () => {
|
||||
const usedPorts = new Set<number>();
|
||||
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START);
|
||||
});
|
||||
|
||||
it("skips used ports and returns next available", () => {
|
||||
const usedPorts = new Set([CDP_PORT_RANGE_START, CDP_PORT_RANGE_START + 1]);
|
||||
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START + 2);
|
||||
});
|
||||
|
||||
it("finds first gap in used ports", () => {
|
||||
const usedPorts = new Set([
|
||||
CDP_PORT_RANGE_START,
|
||||
CDP_PORT_RANGE_START + 2, // gap at +1
|
||||
]);
|
||||
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START + 1);
|
||||
});
|
||||
|
||||
it("returns null when all ports are exhausted", () => {
|
||||
const usedPorts = new Set<number>();
|
||||
for (let port = CDP_PORT_RANGE_START; port <= CDP_PORT_RANGE_END; port++) {
|
||||
usedPorts.add(port);
|
||||
}
|
||||
expect(allocateCdpPort(usedPorts)).toBeNull();
|
||||
});
|
||||
|
||||
it("handles ports outside range in used set", () => {
|
||||
const usedPorts = new Set([1, 2, 3, 50000]); // ports outside range
|
||||
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUsedPorts", () => {
|
||||
it("returns empty set for undefined profiles", () => {
|
||||
expect(getUsedPorts(undefined)).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("returns empty set for empty profiles object", () => {
|
||||
expect(getUsedPorts({})).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("extracts ports from profile configs", () => {
|
||||
const profiles = {
|
||||
clawd: { cdpPort: 18792 },
|
||||
work: { cdpPort: 18793 },
|
||||
personal: { cdpPort: 18795 },
|
||||
};
|
||||
const used = getUsedPorts(profiles);
|
||||
expect(used).toEqual(new Set([18792, 18793, 18795]));
|
||||
});
|
||||
|
||||
it("extracts ports from cdpUrl when cdpPort is missing", () => {
|
||||
const profiles = {
|
||||
remote: { cdpUrl: "http://10.0.0.42:9222" },
|
||||
secure: { cdpUrl: "https://example.com:9443" },
|
||||
};
|
||||
const used = getUsedPorts(profiles);
|
||||
expect(used).toEqual(new Set([9222, 9443]));
|
||||
});
|
||||
|
||||
it("ignores invalid cdpUrl values", () => {
|
||||
const profiles = {
|
||||
bad: { cdpUrl: "notaurl" },
|
||||
};
|
||||
const used = getUsedPorts(profiles);
|
||||
expect(used.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("port collision prevention", () => {
|
||||
it("raw config vs resolved config - shows the data source difference", async () => {
|
||||
// This demonstrates WHY the route handler must use resolved config
|
||||
const { resolveBrowserConfig } = await import("./config.js");
|
||||
|
||||
// Fresh config with no profiles defined (like a new install)
|
||||
const rawConfigProfiles = undefined;
|
||||
const usedFromRaw = getUsedPorts(rawConfigProfiles);
|
||||
|
||||
// Raw config shows empty - no ports used
|
||||
expect(usedFromRaw.size).toBe(0);
|
||||
|
||||
// But resolved config has implicit clawd at 18800
|
||||
const resolved = resolveBrowserConfig({});
|
||||
const usedFromResolved = getUsedPorts(resolved.profiles);
|
||||
expect(usedFromResolved.has(CDP_PORT_RANGE_START)).toBe(true);
|
||||
});
|
||||
|
||||
it("create-profile must use resolved config to avoid port collision", async () => {
|
||||
// The route handler must use state.resolved.profiles, not raw config
|
||||
const { resolveBrowserConfig } = await import("./config.js");
|
||||
|
||||
// Simulate what happens with raw config (empty) vs resolved config
|
||||
const rawConfig = { browser: {} }; // Fresh config, no profiles
|
||||
const buggyUsedPorts = getUsedPorts(rawConfig.browser?.profiles);
|
||||
const buggyAllocatedPort = allocateCdpPort(buggyUsedPorts);
|
||||
|
||||
// Raw config: first allocation gets 18800
|
||||
expect(buggyAllocatedPort).toBe(CDP_PORT_RANGE_START);
|
||||
|
||||
// Resolved config: includes implicit clawd at 18800
|
||||
const resolved = resolveBrowserConfig(rawConfig.browser);
|
||||
const fixedUsedPorts = getUsedPorts(resolved.profiles);
|
||||
const fixedAllocatedPort = allocateCdpPort(fixedUsedPorts);
|
||||
|
||||
// Resolved: first NEW profile gets 18801, avoiding collision
|
||||
expect(fixedAllocatedPort).toBe(CDP_PORT_RANGE_START + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("color allocation", () => {
|
||||
it("allocates first color when none used", () => {
|
||||
const usedColors = new Set<string>();
|
||||
expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[0]);
|
||||
});
|
||||
|
||||
it("allocates next unused color from palette", () => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: Test file with known array
|
||||
const usedColors = new Set([PROFILE_COLORS[0]!.toUpperCase()]);
|
||||
expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[1]);
|
||||
});
|
||||
|
||||
it("skips multiple used colors", () => {
|
||||
const usedColors = new Set([
|
||||
// biome-ignore lint/style/noNonNullAssertion: Test file with known array
|
||||
PROFILE_COLORS[0]!.toUpperCase(),
|
||||
// biome-ignore lint/style/noNonNullAssertion: Test file with known array
|
||||
PROFILE_COLORS[1]!.toUpperCase(),
|
||||
// biome-ignore lint/style/noNonNullAssertion: Test file with known array
|
||||
PROFILE_COLORS[2]!.toUpperCase(),
|
||||
]);
|
||||
expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[3]);
|
||||
});
|
||||
|
||||
it("handles case-insensitive color matching", () => {
|
||||
const usedColors = new Set(["#ff4500"]); // lowercase
|
||||
// Should still skip this color (case-insensitive)
|
||||
// Note: allocateColor compares against uppercase, so lowercase won't match
|
||||
// This tests the current behavior
|
||||
expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[0]); // returns first since lowercase doesn't match
|
||||
});
|
||||
|
||||
it("cycles when all colors are used", () => {
|
||||
const usedColors = new Set(PROFILE_COLORS.map((c) => c.toUpperCase()));
|
||||
// Should cycle based on count
|
||||
const result = allocateColor(usedColors);
|
||||
expect(PROFILE_COLORS).toContain(result);
|
||||
});
|
||||
|
||||
it("cycles based on count when palette exhausted", () => {
|
||||
// Add all colors plus some extras
|
||||
const usedColors = new Set([
|
||||
...PROFILE_COLORS.map((c) => c.toUpperCase()),
|
||||
"#AAAAAA",
|
||||
"#BBBBBB",
|
||||
]);
|
||||
const result = allocateColor(usedColors);
|
||||
// Index should be (10 + 2) % 10 = 2
|
||||
expect(result).toBe(PROFILE_COLORS[2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUsedColors", () => {
|
||||
it("returns empty set for undefined profiles", () => {
|
||||
expect(getUsedColors(undefined)).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("returns empty set for empty profiles object", () => {
|
||||
expect(getUsedColors({})).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("extracts and uppercases colors from profile configs", () => {
|
||||
const profiles = {
|
||||
clawd: { color: "#ff4500" },
|
||||
work: { color: "#0066CC" },
|
||||
};
|
||||
const used = getUsedColors(profiles);
|
||||
expect(used).toEqual(new Set(["#FF4500", "#0066CC"]));
|
||||
});
|
||||
});
|
||||
92
src/browser/profiles.ts
Normal file
92
src/browser/profiles.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* CDP port allocation for browser profiles.
|
||||
*
|
||||
* Port range: 18800-18899 (100 profiles max)
|
||||
* Ports are allocated once at profile creation and persisted in config.
|
||||
*
|
||||
* Reserved ports (do not use for CDP):
|
||||
* 18789 - Gateway WebSocket
|
||||
* 18790 - Bridge
|
||||
* 18791 - Browser control server
|
||||
* 18792-18799 - Reserved for future one-off services (canvas at 18793)
|
||||
*/
|
||||
|
||||
export const CDP_PORT_RANGE_START = 18800;
|
||||
export const CDP_PORT_RANGE_END = 18899;
|
||||
|
||||
export const PROFILE_NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
export function isValidProfileName(name: string): boolean {
|
||||
if (!name || name.length > 64) return false;
|
||||
return PROFILE_NAME_REGEX.test(name);
|
||||
}
|
||||
|
||||
export function allocateCdpPort(usedPorts: Set<number>): number | null {
|
||||
for (let port = CDP_PORT_RANGE_START; port <= CDP_PORT_RANGE_END; port++) {
|
||||
if (!usedPorts.has(port)) return port;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getUsedPorts(
|
||||
profiles: Record<string, { cdpPort?: number; cdpUrl?: string }> | undefined,
|
||||
): Set<number> {
|
||||
if (!profiles) return new Set();
|
||||
const used = new Set<number>();
|
||||
for (const profile of Object.values(profiles)) {
|
||||
if (typeof profile.cdpPort === "number") {
|
||||
used.add(profile.cdpPort);
|
||||
continue;
|
||||
}
|
||||
const rawUrl = profile.cdpUrl?.trim();
|
||||
if (!rawUrl) continue;
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
const port =
|
||||
parsed.port && Number.parseInt(parsed.port, 10) > 0
|
||||
? Number.parseInt(parsed.port, 10)
|
||||
: parsed.protocol === "https:"
|
||||
? 443
|
||||
: 80;
|
||||
if (!Number.isNaN(port) && port > 0 && port <= 65535) {
|
||||
used.add(port);
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid URLs
|
||||
}
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
export const PROFILE_COLORS = [
|
||||
"#FF4500", // Orange-red (clawd default)
|
||||
"#0066CC", // Blue
|
||||
"#00AA00", // Green
|
||||
"#9933FF", // Purple
|
||||
"#FF6699", // Pink
|
||||
"#00CCCC", // Cyan
|
||||
"#FF9900", // Orange
|
||||
"#6666FF", // Indigo
|
||||
"#CC3366", // Magenta
|
||||
"#339966", // Teal
|
||||
];
|
||||
|
||||
export function allocateColor(usedColors: Set<string>): string {
|
||||
// Find first unused color from palette
|
||||
for (const color of PROFILE_COLORS) {
|
||||
if (!usedColors.has(color.toUpperCase())) {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
// All colors used, cycle based on count
|
||||
const index = usedColors.size % PROFILE_COLORS.length;
|
||||
// biome-ignore lint/style/noNonNullAssertion: Array is non-empty constant
|
||||
return PROFILE_COLORS[index] ?? PROFILE_COLORS[0]!;
|
||||
}
|
||||
|
||||
export function getUsedColors(
|
||||
profiles: Record<string, { color: string }> | undefined,
|
||||
): Set<string> {
|
||||
if (!profiles) return new Set();
|
||||
return new Set(Object.values(profiles).map((p) => p.color.toUpperCase()));
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import type express from "express";
|
||||
|
||||
import { createBrowserProfilesService } from "../profiles-service.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { jsonError } from "./utils.js";
|
||||
import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
export function registerBrowserBasicRoutes(
|
||||
app: express.Express,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.get("/", async (_req, res) => {
|
||||
// List all profiles with their status
|
||||
app.get("/profiles", async (_req, res) => {
|
||||
try {
|
||||
const service = createBrowserProfilesService(ctx);
|
||||
const profiles = await service.listProfiles();
|
||||
res.json({ profiles });
|
||||
} catch (err) {
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
// Get status (profile-aware)
|
||||
app.get("/", async (req, res) => {
|
||||
let current: ReturnType<typeof ctx.state>;
|
||||
try {
|
||||
current = ctx.state();
|
||||
@@ -15,22 +28,31 @@ export function registerBrowserBasicRoutes(
|
||||
return jsonError(res, 503, "browser server not started");
|
||||
}
|
||||
|
||||
const profileCtx = getProfileContext(req, ctx);
|
||||
if ("error" in profileCtx) {
|
||||
return jsonError(res, profileCtx.status, profileCtx.error);
|
||||
}
|
||||
|
||||
const [cdpHttp, cdpReady] = await Promise.all([
|
||||
ctx.isHttpReachable(300),
|
||||
ctx.isReachable(600),
|
||||
profileCtx.isHttpReachable(300),
|
||||
profileCtx.isReachable(600),
|
||||
]);
|
||||
|
||||
const profileState = current.profiles.get(profileCtx.profile.name);
|
||||
|
||||
res.json({
|
||||
enabled: current.resolved.enabled,
|
||||
controlUrl: current.resolved.controlUrl,
|
||||
profile: profileCtx.profile.name,
|
||||
running: cdpReady,
|
||||
cdpReady,
|
||||
cdpHttp,
|
||||
pid: current.running?.pid ?? null,
|
||||
cdpPort: current.cdpPort,
|
||||
cdpUrl: current.resolved.cdpUrl,
|
||||
chosenBrowser: current.running?.exe.kind ?? null,
|
||||
userDataDir: current.running?.userDataDir ?? null,
|
||||
color: current.resolved.color,
|
||||
pid: profileState?.running?.pid ?? null,
|
||||
cdpPort: profileCtx.profile.cdpPort,
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
chosenBrowser: profileState?.running?.exe.kind ?? null,
|
||||
userDataDir: profileState?.running?.userDataDir ?? null,
|
||||
color: profileCtx.profile.color,
|
||||
headless: current.resolved.headless,
|
||||
noSandbox: current.resolved.noSandbox,
|
||||
executablePath: current.resolved.executablePath ?? null,
|
||||
@@ -38,30 +60,110 @@ export function registerBrowserBasicRoutes(
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/start", async (_req, res) => {
|
||||
// Start browser (profile-aware)
|
||||
app.post("/start", async (req, res) => {
|
||||
const profileCtx = getProfileContext(req, ctx);
|
||||
if ("error" in profileCtx) {
|
||||
return jsonError(res, profileCtx.status, profileCtx.error);
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.ensureBrowserAvailable();
|
||||
res.json({ ok: true });
|
||||
await profileCtx.ensureBrowserAvailable();
|
||||
res.json({ ok: true, profile: profileCtx.profile.name });
|
||||
} catch (err) {
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/stop", async (_req, res) => {
|
||||
// Stop browser (profile-aware)
|
||||
app.post("/stop", async (req, res) => {
|
||||
const profileCtx = getProfileContext(req, ctx);
|
||||
if ("error" in profileCtx) {
|
||||
return jsonError(res, profileCtx.status, profileCtx.error);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.stopRunningBrowser();
|
||||
res.json({ ok: true, stopped: result.stopped });
|
||||
const result = await profileCtx.stopRunningBrowser();
|
||||
res.json({
|
||||
ok: true,
|
||||
stopped: result.stopped,
|
||||
profile: profileCtx.profile.name,
|
||||
});
|
||||
} catch (err) {
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/reset-profile", async (_req, res) => {
|
||||
// Reset profile (profile-aware)
|
||||
app.post("/reset-profile", async (req, res) => {
|
||||
const profileCtx = getProfileContext(req, ctx);
|
||||
if ("error" in profileCtx) {
|
||||
return jsonError(res, profileCtx.status, profileCtx.error);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.resetProfile();
|
||||
res.json({ ok: true, ...result });
|
||||
const result = await profileCtx.resetProfile();
|
||||
res.json({ ok: true, profile: profileCtx.profile.name, ...result });
|
||||
} catch (err) {
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new profile
|
||||
app.post("/profiles/create", async (req, res) => {
|
||||
const name = toStringOrEmpty((req.body as { name?: unknown })?.name);
|
||||
const color = toStringOrEmpty((req.body as { color?: unknown })?.color);
|
||||
const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl);
|
||||
|
||||
if (!name) return jsonError(res, 400, "name is required");
|
||||
|
||||
try {
|
||||
const service = createBrowserProfilesService(ctx);
|
||||
const result = await service.createProfile({
|
||||
name,
|
||||
color: color || undefined,
|
||||
cdpUrl: cdpUrl || undefined,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (msg.includes("already exists")) {
|
||||
return jsonError(res, 409, msg);
|
||||
}
|
||||
if (msg.includes("invalid profile name")) {
|
||||
return jsonError(res, 400, msg);
|
||||
}
|
||||
if (msg.includes("no available CDP ports")) {
|
||||
return jsonError(res, 507, msg);
|
||||
}
|
||||
if (msg.includes("cdpUrl")) {
|
||||
return jsonError(res, 400, msg);
|
||||
}
|
||||
jsonError(res, 500, msg);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a profile
|
||||
app.delete("/profiles/:name", async (req, res) => {
|
||||
const name = toStringOrEmpty(req.params.name);
|
||||
if (!name) return jsonError(res, 400, "profile name is required");
|
||||
|
||||
try {
|
||||
const service = createBrowserProfilesService(ctx);
|
||||
const result = await service.deleteProfile(name);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (msg.includes("invalid profile name")) {
|
||||
return jsonError(res, 400, msg);
|
||||
}
|
||||
if (msg.includes("default profile")) {
|
||||
return jsonError(res, 400, msg);
|
||||
}
|
||||
if (msg.includes("not found")) {
|
||||
return jsonError(res, 404, msg);
|
||||
}
|
||||
jsonError(res, 500, msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||
|
||||
/**
|
||||
* Extract profile name from query string or body and get profile context.
|
||||
* Query string takes precedence over body for consistency with GET routes.
|
||||
*/
|
||||
export function getProfileContext(
|
||||
req: express.Request,
|
||||
ctx: BrowserRouteContext,
|
||||
): ProfileContext | { error: string; status: number } {
|
||||
let profileName: string | undefined;
|
||||
|
||||
// Check query string first (works for GET and POST)
|
||||
if (typeof req.query.profile === "string") {
|
||||
profileName = req.query.profile.trim() || undefined;
|
||||
}
|
||||
|
||||
// Fall back to body for POST requests
|
||||
if (!profileName && req.body && typeof req.body === "object") {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
if (typeof body.profile === "string") {
|
||||
profileName = body.profile.trim() || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return ctx.forProfile(profileName);
|
||||
} catch (err) {
|
||||
return { error: String(err), status: 404 };
|
||||
}
|
||||
}
|
||||
|
||||
export function jsonError(
|
||||
res: express.Response,
|
||||
status: number,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import type { Server } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { runExec } from "../process/exec.js";
|
||||
import { createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
|
||||
import {
|
||||
isChromeCdpReady,
|
||||
@@ -13,27 +10,37 @@ import {
|
||||
resolveClawdUserDataDir,
|
||||
stopClawdChrome,
|
||||
} from "./chrome.js";
|
||||
import type { ResolvedBrowserConfig } from "./config.js";
|
||||
import type { BrowserTab } from "./client.js";
|
||||
import type {
|
||||
ResolvedBrowserConfig,
|
||||
ResolvedBrowserProfile,
|
||||
} from "./config.js";
|
||||
import { resolveProfile } from "./config.js";
|
||||
import { resolveTargetIdFromTabs } from "./target-id.js";
|
||||
import { movePathToTrash } from "./trash.js";
|
||||
|
||||
export type BrowserTab = {
|
||||
targetId: string;
|
||||
title: string;
|
||||
url: string;
|
||||
wsUrl?: string;
|
||||
type?: string;
|
||||
export type { BrowserTab };
|
||||
|
||||
/**
|
||||
* Runtime state for a single profile's Chrome instance.
|
||||
*/
|
||||
export type ProfileRuntimeState = {
|
||||
profile: ResolvedBrowserProfile;
|
||||
running: RunningChrome | null;
|
||||
};
|
||||
|
||||
export type BrowserServerState = {
|
||||
server: Server;
|
||||
port: number;
|
||||
cdpPort: number;
|
||||
running: RunningChrome | null;
|
||||
resolved: ResolvedBrowserConfig;
|
||||
profiles: Map<string, ProfileRuntimeState>;
|
||||
};
|
||||
|
||||
export type BrowserRouteContext = {
|
||||
state: () => BrowserServerState;
|
||||
forProfile: (profileName?: string) => ProfileContext;
|
||||
listProfiles: () => Promise<ProfileStatus[]>;
|
||||
// Legacy methods delegate to default profile for backward compatibility
|
||||
ensureBrowserAvailable: () => Promise<void>;
|
||||
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
|
||||
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
@@ -51,11 +58,50 @@ export type BrowserRouteContext = {
|
||||
mapTabError: (err: unknown) => { status: number; message: string } | null;
|
||||
};
|
||||
|
||||
export type ProfileContext = {
|
||||
profile: ResolvedBrowserProfile;
|
||||
ensureBrowserAvailable: () => Promise<void>;
|
||||
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
|
||||
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
isReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
listTabs: () => Promise<BrowserTab[]>;
|
||||
openTab: (url: string) => Promise<BrowserTab>;
|
||||
focusTab: (targetId: string) => Promise<void>;
|
||||
closeTab: (targetId: string) => Promise<void>;
|
||||
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
|
||||
resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>;
|
||||
};
|
||||
|
||||
export type ProfileStatus = {
|
||||
name: string;
|
||||
cdpPort: number;
|
||||
cdpUrl: string;
|
||||
color: string;
|
||||
running: boolean;
|
||||
tabCount: number;
|
||||
isDefault: boolean;
|
||||
isRemote: boolean;
|
||||
};
|
||||
|
||||
type ContextOptions = {
|
||||
getState: () => BrowserServerState | null;
|
||||
setRunning: (running: RunningChrome | null) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a CDP WebSocket URL to use the correct base URL.
|
||||
*/
|
||||
function normalizeWsUrl(
|
||||
raw: string | undefined,
|
||||
cdpBaseUrl: string,
|
||||
): string | undefined {
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
return normalizeCdpWsUrl(raw, cdpBaseUrl);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson<T>(
|
||||
url: string,
|
||||
timeoutMs = 1500,
|
||||
@@ -87,26 +133,35 @@ async function fetchOk(
|
||||
}
|
||||
}
|
||||
|
||||
export function createBrowserRouteContext(
|
||||
/**
|
||||
* Create a profile-scoped context for browser operations.
|
||||
*/
|
||||
function createProfileContext(
|
||||
opts: ContextOptions,
|
||||
): BrowserRouteContext {
|
||||
profile: ResolvedBrowserProfile,
|
||||
): ProfileContext {
|
||||
const state = () => {
|
||||
const current = opts.getState();
|
||||
if (!current) throw new Error("Browser server not started");
|
||||
return current;
|
||||
};
|
||||
|
||||
const listTabs = async (): Promise<BrowserTab[]> => {
|
||||
const getProfileState = (): ProfileRuntimeState => {
|
||||
const current = state();
|
||||
const base = current.resolved.cdpUrl;
|
||||
const normalizeWsUrl = (raw?: string) => {
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
return normalizeCdpWsUrl(raw, base);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
};
|
||||
let profileState = current.profiles.get(profile.name);
|
||||
if (!profileState) {
|
||||
profileState = { profile, running: null };
|
||||
current.profiles.set(profile.name, profileState);
|
||||
}
|
||||
return profileState;
|
||||
};
|
||||
|
||||
const setProfileRunning = (running: RunningChrome | null) => {
|
||||
const profileState = getProfileState();
|
||||
profileState.running = running;
|
||||
};
|
||||
|
||||
const listTabs = async (): Promise<BrowserTab[]> => {
|
||||
const raw = await fetchJson<
|
||||
Array<{
|
||||
id?: string;
|
||||
@@ -115,22 +170,21 @@ export function createBrowserRouteContext(
|
||||
webSocketDebuggerUrl?: string;
|
||||
type?: string;
|
||||
}>
|
||||
>(`${base.replace(/\/$/, "")}/json/list`);
|
||||
>(`${profile.cdpUrl.replace(/\/$/, "")}/json/list`);
|
||||
return raw
|
||||
.map((t) => ({
|
||||
targetId: t.id ?? "",
|
||||
title: t.title ?? "",
|
||||
url: t.url ?? "",
|
||||
wsUrl: normalizeWsUrl(t.webSocketDebuggerUrl),
|
||||
wsUrl: normalizeWsUrl(t.webSocketDebuggerUrl, profile.cdpUrl),
|
||||
type: t.type,
|
||||
}))
|
||||
.filter((t) => Boolean(t.targetId));
|
||||
};
|
||||
|
||||
const openTab = async (url: string): Promise<BrowserTab> => {
|
||||
const current = state();
|
||||
const createdViaCdp = await createTargetViaCdp({
|
||||
cdpUrl: current.resolved.cdpUrl,
|
||||
cdpUrl: profile.cdpUrl,
|
||||
url,
|
||||
})
|
||||
.then((r) => r.targetId)
|
||||
@@ -148,7 +202,6 @@ export function createBrowserRouteContext(
|
||||
}
|
||||
|
||||
const encoded = encodeURIComponent(url);
|
||||
|
||||
type CdpTarget = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
@@ -157,15 +210,7 @@ export function createBrowserRouteContext(
|
||||
type?: string;
|
||||
};
|
||||
|
||||
const base = current.resolved.cdpUrl.replace(/\/$/, "");
|
||||
const normalizeWsUrl = (raw?: string) => {
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
return normalizeCdpWsUrl(raw, base);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
};
|
||||
const base = profile.cdpUrl.replace(/\/$/, "");
|
||||
const endpoint = `${base}/json/new?${encoded}`;
|
||||
const created = await fetchJson<CdpTarget>(endpoint, 1500, {
|
||||
method: "PUT",
|
||||
@@ -181,76 +226,81 @@ export function createBrowserRouteContext(
|
||||
targetId: created.id,
|
||||
title: created.title ?? "",
|
||||
url: created.url ?? url,
|
||||
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl),
|
||||
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, base),
|
||||
type: created.type,
|
||||
};
|
||||
};
|
||||
|
||||
const isReachable = async (timeoutMs = 300) => {
|
||||
const current = state();
|
||||
const wsTimeout = Math.max(200, Math.min(2000, timeoutMs * 2));
|
||||
return await isChromeCdpReady(
|
||||
current.resolved.cdpUrl,
|
||||
timeoutMs,
|
||||
wsTimeout,
|
||||
);
|
||||
return await isChromeCdpReady(profile.cdpUrl, timeoutMs, wsTimeout);
|
||||
};
|
||||
|
||||
const isHttpReachable = async (timeoutMs = 300) => {
|
||||
const current = state();
|
||||
return await isChromeReachable(current.resolved.cdpUrl, timeoutMs);
|
||||
return await isChromeReachable(profile.cdpUrl, timeoutMs);
|
||||
};
|
||||
|
||||
const attachRunning = (running: RunningChrome) => {
|
||||
opts.setRunning(running);
|
||||
setProfileRunning(running);
|
||||
running.proc.on("exit", () => {
|
||||
const live = opts.getState();
|
||||
if (live?.running?.pid === running.pid) {
|
||||
opts.setRunning(null);
|
||||
// Guard against server teardown (e.g., SIGUSR1 restart)
|
||||
if (!opts.getState()) return;
|
||||
const profileState = getProfileState();
|
||||
if (profileState.running?.pid === running.pid) {
|
||||
setProfileRunning(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const ensureBrowserAvailable = async (): Promise<void> => {
|
||||
const current = state();
|
||||
const remoteCdp = !current.resolved.cdpIsLoopback;
|
||||
const remoteCdp = !profile.cdpIsLoopback;
|
||||
const profileState = getProfileState();
|
||||
const httpReachable = await isHttpReachable();
|
||||
|
||||
if (!httpReachable) {
|
||||
if (current.resolved.attachOnly || remoteCdp) {
|
||||
throw new Error(
|
||||
remoteCdp
|
||||
? "Remote CDP is not reachable. Check browser.cdpUrl."
|
||||
: "Browser attachOnly is enabled and no browser is running.",
|
||||
? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`
|
||||
: `Browser attachOnly is enabled and profile "${profile.name}" is not running.`,
|
||||
);
|
||||
}
|
||||
const launched = await launchClawdChrome(current.resolved);
|
||||
const launched = await launchClawdChrome(current.resolved, profile);
|
||||
attachRunning(launched);
|
||||
return;
|
||||
}
|
||||
|
||||
// Port is reachable - check if we own it
|
||||
if (await isReachable()) return;
|
||||
|
||||
// HTTP responds but WebSocket fails - port in use by something else
|
||||
if (!profileState.running) {
|
||||
throw new Error(
|
||||
`Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by clawdis. ` +
|
||||
`Run action=reset-profile profile=${profile.name} to kill the process.`,
|
||||
);
|
||||
}
|
||||
|
||||
// We own it but WebSocket failed - restart
|
||||
if (current.resolved.attachOnly || remoteCdp) {
|
||||
throw new Error(
|
||||
remoteCdp
|
||||
? "Remote CDP websocket is not reachable. Check browser.cdpUrl."
|
||||
: "Browser attachOnly is enabled and CDP websocket is not reachable.",
|
||||
? `Remote CDP websocket for profile "${profile.name}" is not reachable.`
|
||||
: `Browser attachOnly is enabled and CDP websocket for profile "${profile.name}" is not reachable.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!current.running) {
|
||||
throw new Error(
|
||||
"CDP port responds but websocket handshake failed. Ensure the clawd browser owns the port or stop the conflicting process.",
|
||||
);
|
||||
}
|
||||
await stopClawdChrome(profileState.running);
|
||||
setProfileRunning(null);
|
||||
|
||||
await stopClawdChrome(current.running);
|
||||
opts.setRunning(null);
|
||||
|
||||
const relaunched = await launchClawdChrome(current.resolved);
|
||||
const relaunched = await launchClawdChrome(current.resolved, profile);
|
||||
attachRunning(relaunched);
|
||||
|
||||
if (!(await isReachable(600))) {
|
||||
throw new Error("Chrome CDP websocket is not reachable after restart.");
|
||||
throw new Error(
|
||||
`Chrome CDP websocket for profile "${profile.name}" is not reachable after restart.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -281,8 +331,7 @@ export function createBrowserRouteContext(
|
||||
};
|
||||
|
||||
const focusTab = async (targetId: string): Promise<void> => {
|
||||
const current = state();
|
||||
const base = current.resolved.cdpUrl.replace(/\/$/, "");
|
||||
const base = profile.cdpUrl.replace(/\/$/, "");
|
||||
const tabs = await listTabs();
|
||||
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
||||
if (!resolved.ok) {
|
||||
@@ -295,8 +344,7 @@ export function createBrowserRouteContext(
|
||||
};
|
||||
|
||||
const closeTab = async (targetId: string): Promise<void> => {
|
||||
const current = state();
|
||||
const base = current.resolved.cdpUrl.replace(/\/$/, "");
|
||||
const base = profile.cdpUrl.replace(/\/$/, "");
|
||||
const tabs = await listTabs();
|
||||
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
||||
if (!resolved.ok) {
|
||||
@@ -309,28 +357,34 @@ export function createBrowserRouteContext(
|
||||
};
|
||||
|
||||
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
|
||||
const current = state();
|
||||
if (!current.running) return { stopped: false };
|
||||
await stopClawdChrome(current.running);
|
||||
opts.setRunning(null);
|
||||
const profileState = getProfileState();
|
||||
if (!profileState.running) return { stopped: false };
|
||||
await stopClawdChrome(profileState.running);
|
||||
setProfileRunning(null);
|
||||
return { stopped: true };
|
||||
};
|
||||
|
||||
const resetProfile = async () => {
|
||||
const current = state();
|
||||
if (!current.resolved.cdpIsLoopback) {
|
||||
throw new Error("reset-profile is only supported for local browsers.");
|
||||
}
|
||||
const userDataDir = resolveClawdUserDataDir();
|
||||
|
||||
const httpReachable = await isHttpReachable(300);
|
||||
if (httpReachable && !current.running) {
|
||||
if (!profile.cdpIsLoopback) {
|
||||
throw new Error(
|
||||
"Browser appears to be running but is not owned by clawd. Stop it before resetting the profile.",
|
||||
`reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`,
|
||||
);
|
||||
}
|
||||
const userDataDir = resolveClawdUserDataDir(profile.name);
|
||||
const profileState = getProfileState();
|
||||
|
||||
if (current.running) {
|
||||
const httpReachable = await isHttpReachable(300);
|
||||
if (httpReachable && !profileState.running) {
|
||||
// Port in use but not by us - kill it
|
||||
try {
|
||||
const mod = await import("./pw-ai.js");
|
||||
await mod.closePlaywrightBrowserConnection();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (profileState.running) {
|
||||
await stopRunningBrowser();
|
||||
}
|
||||
|
||||
@@ -349,19 +403,8 @@ export function createBrowserRouteContext(
|
||||
return { moved: true, from: userDataDir, to: moved };
|
||||
};
|
||||
|
||||
const mapTabError = (err: unknown) => {
|
||||
const msg = String(err);
|
||||
if (msg.includes("ambiguous target id prefix")) {
|
||||
return { status: 409, message: "ambiguous target id prefix" };
|
||||
}
|
||||
if (msg.includes("tab not found")) {
|
||||
return { status: 404, message: "tab not found" };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
profile,
|
||||
ensureBrowserAvailable,
|
||||
ensureTabAvailable,
|
||||
isHttpReachable,
|
||||
@@ -372,23 +415,116 @@ export function createBrowserRouteContext(
|
||||
closeTab,
|
||||
stopRunningBrowser,
|
||||
resetProfile,
|
||||
mapTabError,
|
||||
};
|
||||
}
|
||||
|
||||
async function movePathToTrash(targetPath: string): Promise<string> {
|
||||
try {
|
||||
await runExec("trash", [targetPath], { timeoutMs: 10_000 });
|
||||
return targetPath;
|
||||
} catch {
|
||||
const trashDir = path.join(os.homedir(), ".Trash");
|
||||
fs.mkdirSync(trashDir, { recursive: true });
|
||||
const base = path.basename(targetPath);
|
||||
let dest = path.join(trashDir, `${base}-${Date.now()}`);
|
||||
if (fs.existsSync(dest)) {
|
||||
dest = path.join(trashDir, `${base}-${Date.now()}-${Math.random()}`);
|
||||
export function createBrowserRouteContext(
|
||||
opts: ContextOptions,
|
||||
): BrowserRouteContext {
|
||||
const state = () => {
|
||||
const current = opts.getState();
|
||||
if (!current) throw new Error("Browser server not started");
|
||||
return current;
|
||||
};
|
||||
|
||||
const forProfile = (profileName?: string): ProfileContext => {
|
||||
const current = state();
|
||||
const name = profileName ?? current.resolved.defaultProfile;
|
||||
const profile = resolveProfile(current.resolved, name);
|
||||
if (!profile) {
|
||||
const available = Object.keys(current.resolved.profiles).join(", ");
|
||||
throw new Error(
|
||||
`Profile "${name}" not found. Available profiles: ${available || "(none)"}`,
|
||||
);
|
||||
}
|
||||
fs.renameSync(targetPath, dest);
|
||||
return dest;
|
||||
}
|
||||
return createProfileContext(opts, profile);
|
||||
};
|
||||
|
||||
const listProfiles = async (): Promise<ProfileStatus[]> => {
|
||||
const current = state();
|
||||
const result: ProfileStatus[] = [];
|
||||
|
||||
for (const name of Object.keys(current.resolved.profiles)) {
|
||||
const profileState = current.profiles.get(name);
|
||||
const profile = resolveProfile(current.resolved, name);
|
||||
if (!profile) continue;
|
||||
|
||||
let tabCount = 0;
|
||||
let running = false;
|
||||
|
||||
if (profileState?.running) {
|
||||
running = true;
|
||||
try {
|
||||
const ctx = createProfileContext(opts, profile);
|
||||
const tabs = await ctx.listTabs();
|
||||
tabCount = tabs.filter((t) => t.type === "page").length;
|
||||
} catch {
|
||||
// Browser might not be responsive
|
||||
}
|
||||
} else {
|
||||
// Check if something is listening on the port
|
||||
try {
|
||||
const reachable = await isChromeReachable(profile.cdpUrl, 200);
|
||||
if (reachable) {
|
||||
running = true;
|
||||
const ctx = createProfileContext(opts, profile);
|
||||
const tabs = await ctx.listTabs().catch(() => []);
|
||||
tabCount = tabs.filter((t) => t.type === "page").length;
|
||||
}
|
||||
} catch {
|
||||
// Not reachable
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
name,
|
||||
cdpPort: profile.cdpPort,
|
||||
cdpUrl: profile.cdpUrl,
|
||||
color: profile.color,
|
||||
running,
|
||||
tabCount,
|
||||
isDefault: name === current.resolved.defaultProfile,
|
||||
isRemote: !profile.cdpIsLoopback,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Create default profile context for backward compatibility
|
||||
const getDefaultContext = () => forProfile();
|
||||
|
||||
const mapTabError = (err: unknown) => {
|
||||
const msg = String(err);
|
||||
if (msg.includes("ambiguous target id prefix")) {
|
||||
return { status: 409, message: "ambiguous target id prefix" };
|
||||
}
|
||||
if (msg.includes("tab not found")) {
|
||||
return { status: 404, message: "tab not found" };
|
||||
}
|
||||
if (msg.includes("not found")) {
|
||||
return { status: 404, message: msg };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
forProfile,
|
||||
listProfiles,
|
||||
// Legacy methods delegate to default profile
|
||||
ensureBrowserAvailable: () => getDefaultContext().ensureBrowserAvailable(),
|
||||
ensureTabAvailable: (targetId) =>
|
||||
getDefaultContext().ensureTabAvailable(targetId),
|
||||
isHttpReachable: (timeoutMs) =>
|
||||
getDefaultContext().isHttpReachable(timeoutMs),
|
||||
isReachable: (timeoutMs) => getDefaultContext().isReachable(timeoutMs),
|
||||
listTabs: () => getDefaultContext().listTabs(),
|
||||
openTab: (url) => getDefaultContext().openTab(url),
|
||||
focusTab: (targetId) => getDefaultContext().focusTab(targetId),
|
||||
closeTab: (targetId) => getDefaultContext().closeTab(targetId),
|
||||
stopRunningBrowser: () => getDefaultContext().stopRunningBrowser(),
|
||||
resetProfile: () => getDefaultContext().resetProfile(),
|
||||
mapTabError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,9 +36,6 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
|
||||
|
||||
const ctx = createBrowserRouteContext({
|
||||
getState: () => state,
|
||||
setRunning: (running) => {
|
||||
if (state) state.running = running;
|
||||
},
|
||||
});
|
||||
registerBrowserRoutes(app, ctx);
|
||||
|
||||
@@ -58,9 +55,8 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
|
||||
state = {
|
||||
server,
|
||||
port,
|
||||
cdpPort: resolved.cdpPort,
|
||||
running: null,
|
||||
resolved,
|
||||
profiles: new Map(),
|
||||
};
|
||||
|
||||
logServer.info(`Browser control listening on http://127.0.0.1:${port}/`);
|
||||
@@ -73,13 +69,19 @@ export async function stopBrowserControlServer(): Promise<void> {
|
||||
|
||||
const ctx = createBrowserRouteContext({
|
||||
getState: () => state,
|
||||
setRunning: (running) => {
|
||||
if (state) state.running = running;
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await ctx.stopRunningBrowser();
|
||||
const current = state;
|
||||
if (current) {
|
||||
for (const name of Object.keys(current.resolved.profiles)) {
|
||||
try {
|
||||
await ctx.forProfile(name).stopRunningBrowser();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logServer.warn(`clawd browser stop failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
22
src/browser/trash.ts
Normal file
22
src/browser/trash.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { runExec } from "../process/exec.js";
|
||||
|
||||
export async function movePathToTrash(targetPath: string): Promise<string> {
|
||||
try {
|
||||
await runExec("trash", [targetPath], { timeoutMs: 10_000 });
|
||||
return targetPath;
|
||||
} catch {
|
||||
const trashDir = path.join(os.homedir(), ".Trash");
|
||||
fs.mkdirSync(trashDir, { recursive: true });
|
||||
const base = path.basename(targetPath);
|
||||
let dest = path.join(trashDir, `${base}-${Date.now()}`);
|
||||
if (fs.existsSync(dest)) {
|
||||
dest = path.join(trashDir, `${base}-${Date.now()}-${Math.random()}`);
|
||||
}
|
||||
fs.renameSync(targetPath, dest);
|
||||
return dest;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,8 +2,11 @@ import type { Command } from "commander";
|
||||
|
||||
import {
|
||||
browserCloseTab,
|
||||
browserCreateProfile,
|
||||
browserDeleteProfile,
|
||||
browserFocusTab,
|
||||
browserOpenTab,
|
||||
browserProfiles,
|
||||
browserResetProfile,
|
||||
browserStart,
|
||||
browserStatus,
|
||||
@@ -11,7 +14,7 @@ import {
|
||||
browserTabs,
|
||||
resolveBrowserControlUrl,
|
||||
} from "../browser/client.js";
|
||||
import { browserAct } from "../browser/client-actions.js";
|
||||
import { browserAct } from "../browser/client-actions-core.js";
|
||||
import { danger, info } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
@@ -27,13 +30,16 @@ export function registerBrowserManageCommands(
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
try {
|
||||
const status = await browserStatus(baseUrl);
|
||||
const status = await browserStatus(baseUrl, {
|
||||
profile: parent?.profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(status, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(
|
||||
[
|
||||
`profile: ${status.profile ?? "clawd"}`,
|
||||
`enabled: ${status.enabled}`,
|
||||
`running: ${status.running}`,
|
||||
`controlUrl: ${status.controlUrl}`,
|
||||
@@ -51,18 +57,22 @@ export function registerBrowserManageCommands(
|
||||
|
||||
browser
|
||||
.command("start")
|
||||
.description("Start the clawd browser (no-op if already running)")
|
||||
.description("Start the browser (no-op if already running)")
|
||||
.action(async (_opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.profile;
|
||||
try {
|
||||
await browserStart(baseUrl);
|
||||
const status = await browserStatus(baseUrl);
|
||||
await browserStart(baseUrl, { profile });
|
||||
const status = await browserStatus(baseUrl, { profile });
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(status, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(info(`🦞 clawd browser running: ${status.running}`));
|
||||
const name = status.profile ?? "clawd";
|
||||
defaultRuntime.log(
|
||||
info(`🦞 browser [${name}] running: ${status.running}`),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
@@ -71,18 +81,22 @@ export function registerBrowserManageCommands(
|
||||
|
||||
browser
|
||||
.command("stop")
|
||||
.description("Stop the clawd browser (best-effort)")
|
||||
.description("Stop the browser (best-effort)")
|
||||
.action(async (_opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.profile;
|
||||
try {
|
||||
await browserStop(baseUrl);
|
||||
const status = await browserStatus(baseUrl);
|
||||
await browserStop(baseUrl, { profile });
|
||||
const status = await browserStatus(baseUrl, { profile });
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(status, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(info(`🦞 clawd browser running: ${status.running}`));
|
||||
const name = status.profile ?? "clawd";
|
||||
defaultRuntime.log(
|
||||
info(`🦞 browser [${name}] running: ${status.running}`),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
@@ -91,24 +105,23 @@ export function registerBrowserManageCommands(
|
||||
|
||||
browser
|
||||
.command("reset-profile")
|
||||
.description("Reset clawd browser profile (moves it to Trash)")
|
||||
.description("Reset browser profile (moves it to Trash)")
|
||||
.action(async (_opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.profile;
|
||||
try {
|
||||
const result = await browserResetProfile(baseUrl);
|
||||
const result = await browserResetProfile(baseUrl, { profile });
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
if (!result.moved) {
|
||||
defaultRuntime.log(info("🦞 clawd browser profile already missing."));
|
||||
defaultRuntime.log(info(`🦞 browser profile already missing.`));
|
||||
return;
|
||||
}
|
||||
const dest = result.to ?? result.from;
|
||||
defaultRuntime.log(
|
||||
info(`🦞 clawd browser profile moved to Trash (${dest})`),
|
||||
);
|
||||
defaultRuntime.log(info(`🦞 browser profile moved to Trash (${dest})`));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
@@ -121,8 +134,9 @@ export function registerBrowserManageCommands(
|
||||
.action(async (_opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.profile;
|
||||
try {
|
||||
const tabs = await browserTabs(baseUrl);
|
||||
const tabs = await browserTabs(baseUrl, { profile });
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify({ tabs }, null, 2));
|
||||
return;
|
||||
@@ -149,11 +163,12 @@ export function registerBrowserManageCommands(
|
||||
.command("open")
|
||||
.description("Open a URL in a new tab")
|
||||
.argument("<url>", "URL to open")
|
||||
.action(async (url: string, cmd) => {
|
||||
.action(async (url: string, _opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.profile;
|
||||
try {
|
||||
const tab = await browserOpenTab(baseUrl, url);
|
||||
const tab = await browserOpenTab(baseUrl, url, { profile });
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(tab, null, 2));
|
||||
return;
|
||||
@@ -169,11 +184,12 @@ export function registerBrowserManageCommands(
|
||||
.command("focus")
|
||||
.description("Focus a tab by target id (or unique prefix)")
|
||||
.argument("<targetId>", "Target id or unique prefix")
|
||||
.action(async (targetId: string, cmd) => {
|
||||
.action(async (targetId: string, _opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.profile;
|
||||
try {
|
||||
await browserFocusTab(baseUrl, targetId);
|
||||
await browserFocusTab(baseUrl, targetId, { profile });
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
|
||||
return;
|
||||
@@ -189,14 +205,15 @@ export function registerBrowserManageCommands(
|
||||
.command("close")
|
||||
.description("Close a tab (target id optional)")
|
||||
.argument("[targetId]", "Target id or unique prefix (optional)")
|
||||
.action(async (targetId: string | undefined, cmd) => {
|
||||
.action(async (targetId: string | undefined, _opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.profile;
|
||||
try {
|
||||
if (targetId?.trim()) {
|
||||
await browserCloseTab(baseUrl, targetId.trim());
|
||||
await browserCloseTab(baseUrl, targetId.trim(), { profile });
|
||||
} else {
|
||||
await browserAct(baseUrl, { kind: "close" });
|
||||
await browserAct(baseUrl, { kind: "close" }, { profile });
|
||||
}
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
|
||||
@@ -208,4 +225,102 @@ export function registerBrowserManageCommands(
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Profile management commands
|
||||
browser
|
||||
.command("profiles")
|
||||
.description("List all browser profiles")
|
||||
.action(async (_opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
try {
|
||||
const profiles = await browserProfiles(baseUrl);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify({ profiles }, null, 2));
|
||||
return;
|
||||
}
|
||||
if (profiles.length === 0) {
|
||||
defaultRuntime.log("No profiles configured.");
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(
|
||||
profiles
|
||||
.map((p) => {
|
||||
const status = p.running ? "running" : "stopped";
|
||||
const tabs = p.running ? ` (${p.tabCount} tabs)` : "";
|
||||
const def = p.isDefault ? " [default]" : "";
|
||||
const loc = p.isRemote
|
||||
? `cdpUrl: ${p.cdpUrl}`
|
||||
: `port: ${p.cdpPort}`;
|
||||
const remote = p.isRemote ? " [remote]" : "";
|
||||
return `${p.name}: ${status}${tabs}${def}${remote}\n ${loc}, color: ${p.color}`;
|
||||
})
|
||||
.join("\n"),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("create-profile")
|
||||
.description("Create a new browser profile")
|
||||
.requiredOption(
|
||||
"--name <name>",
|
||||
"Profile name (lowercase, numbers, hyphens)",
|
||||
)
|
||||
.option("--color <hex>", "Profile color (hex format, e.g. #0066CC)")
|
||||
.option("--cdp-url <url>", "CDP URL for remote Chrome (http/https)")
|
||||
.action(
|
||||
async (opts: { name: string; color?: string; cdpUrl?: string }, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
try {
|
||||
const result = await browserCreateProfile(baseUrl, {
|
||||
name: opts.name,
|
||||
color: opts.color,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
const loc = result.isRemote
|
||||
? ` cdpUrl: ${result.cdpUrl}`
|
||||
: ` port: ${result.cdpPort}`;
|
||||
defaultRuntime.log(
|
||||
info(
|
||||
`🦞 Created profile "${result.profile}"\n${loc}\n color: ${result.color}`,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
browser
|
||||
.command("delete-profile")
|
||||
.description("Delete a browser profile")
|
||||
.requiredOption("--name <name>", "Profile name to delete")
|
||||
.action(async (opts: { name: string }, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
try {
|
||||
const result = await browserDeleteProfile(baseUrl, opts.name);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
const msg = result.deleted
|
||||
? `🦞 Deleted profile "${result.profile}" (user data removed)`
|
||||
: `🦞 Deleted profile "${result.profile}" (no user data found)`;
|
||||
defaultRuntime.log(info(msg));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type BrowserParentOpts = { url?: string; json?: boolean };
|
||||
export type BrowserParentOpts = { url?: string; json?: boolean; profile?: string };
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "./browser-cli-examples.js";
|
||||
import { registerBrowserInspectCommands } from "./browser-cli-inspect.js";
|
||||
import { registerBrowserManageCommands } from "./browser-cli-manage.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
|
||||
export function registerBrowserCli(program: Command) {
|
||||
const browser = program
|
||||
@@ -19,6 +20,7 @@ export function registerBrowserCli(program: Command) {
|
||||
"--url <url>",
|
||||
"Override browser control URL (default from ~/.clawdis/clawdis.json)",
|
||||
)
|
||||
.option("--profile <name>", "Browser profile name (default from config)")
|
||||
.option("--json", "Output machine-readable JSON", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
@@ -33,7 +35,7 @@ export function registerBrowserCli(program: Command) {
|
||||
});
|
||||
|
||||
const parentOpts = (cmd: Command) =>
|
||||
cmd.parent?.opts?.() as { url?: string; json?: boolean };
|
||||
cmd.parent?.opts?.() as BrowserParentOpts;
|
||||
|
||||
registerBrowserManageCommands(browser, parentOpts);
|
||||
registerBrowserInspectCommands(browser, parentOpts);
|
||||
|
||||
@@ -92,6 +92,14 @@ export type WhatsAppConfig = {
|
||||
>;
|
||||
};
|
||||
|
||||
export type BrowserProfileConfig = {
|
||||
/** CDP port for this profile. Allocated once at creation, persisted permanently. */
|
||||
cdpPort?: number;
|
||||
/** CDP URL for this profile (use for remote Chrome). */
|
||||
cdpUrl?: string;
|
||||
/** Profile color (hex). Auto-assigned at creation. */
|
||||
color: string;
|
||||
};
|
||||
export type BrowserConfig = {
|
||||
enabled?: boolean;
|
||||
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
|
||||
@@ -108,6 +116,10 @@ export type BrowserConfig = {
|
||||
noSandbox?: boolean;
|
||||
/** If true: never launch; only attach to an existing browser. Default: false */
|
||||
attachOnly?: boolean;
|
||||
/** Default profile to use when profile param is omitted. Default: "clawd" */
|
||||
defaultProfile?: string;
|
||||
/** Named browser profiles with explicit CDP ports or URLs. */
|
||||
profiles?: Record<string, BrowserProfileConfig>;
|
||||
};
|
||||
|
||||
export type CronConfig = {
|
||||
@@ -1092,6 +1104,26 @@ export const ClawdisSchema = z.object({
|
||||
headless: z.boolean().optional(),
|
||||
noSandbox: z.boolean().optional(),
|
||||
attachOnly: z.boolean().optional(),
|
||||
defaultProfile: z.string().optional(),
|
||||
profiles: z
|
||||
.record(
|
||||
z
|
||||
.string()
|
||||
.regex(
|
||||
/^[a-z0-9-]+$/,
|
||||
"Profile names must be alphanumeric with hyphens only",
|
||||
),
|
||||
z
|
||||
.object({
|
||||
cdpPort: z.number().int().min(1).max(65535).optional(),
|
||||
cdpUrl: z.string().optional(),
|
||||
color: HexColorSchema,
|
||||
})
|
||||
.refine((value) => value.cdpPort || value.cdpUrl, {
|
||||
message: "Profile must set cdpPort or cdpUrl",
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
ui: z
|
||||
|
||||
Reference in New Issue
Block a user