chore(lint): enable async endpoint handler rule

This commit is contained in:
Peter Steinberger
2026-04-18 19:13:23 +01:00
parent 84aed919a9
commit cc919db83b
12 changed files with 1890 additions and 1729 deletions

View File

@@ -16,7 +16,7 @@
"eslint/no-unmodified-loop-condition": "error",
"eslint-plugin-unicorn/prefer-set-size": "error",
"oxc/no-accumulating-spread": "error",
"oxc/no-async-endpoint-handlers": "off",
"oxc/no-async-endpoint-handlers": "error",
"oxc/no-map-spread": "off",
"typescript/consistent-return": "error",
"typescript/no-explicit-any": "error",

View File

@@ -10,7 +10,7 @@ import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
import { ensureOutputRootDir, resolveWritableOutputPathOrRespond } from "./output-paths.js";
import { DEFAULT_DOWNLOAD_DIR } from "./path-output.js";
import type { BrowserRouteRegistrar } from "./types.js";
import { jsonError, toNumber, toStringOrEmpty } from "./utils.js";
import { asyncBrowserRoute, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
function buildDownloadRequestBase(cdpUrl: string, targetId: string, timeoutMs: number | undefined) {
return {
@@ -24,93 +24,99 @@ export function registerBrowserAgentActDownloadRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.post("/wait/download", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const out = toStringOrEmpty(body.path) || "";
const timeoutMs = toNumber(body.timeoutMs);
app.post(
"/wait/download",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const out = toStringOrEmpty(body.path) || "";
const timeoutMs = toNumber(body.timeoutMs);
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.download.waitUnsupported);
}
const pw = await requirePwAi(res, "wait for download");
if (!pw) {
return;
}
await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR);
let downloadPath: string | undefined;
if (out.trim()) {
const resolvedDownloadPath = await resolveWritableOutputPathOrRespond({
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.download.waitUnsupported);
}
const pw = await requirePwAi(res, "wait for download");
if (!pw) {
return;
}
await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR);
let downloadPath: string | undefined;
if (out.trim()) {
const resolvedDownloadPath = await resolveWritableOutputPathOrRespond({
res,
rootDir: DEFAULT_DOWNLOAD_DIR,
requestedPath: out,
scopeLabel: "downloads directory",
});
if (!resolvedDownloadPath) {
return;
}
downloadPath = resolvedDownloadPath;
}
const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs);
const result = await pw.waitForDownloadViaPlaywright({
...requestBase,
path: downloadPath,
});
res.json({ ok: true, targetId: tab.targetId, download: result });
},
});
}),
);
app.post(
"/download",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const ref = toStringOrEmpty(body.ref);
const out = toStringOrEmpty(body.path);
const timeoutMs = toNumber(body.timeoutMs);
if (!ref) {
return jsonError(res, 400, "ref is required");
}
if (!out) {
return jsonError(res, 400, "path is required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.download.downloadUnsupported);
}
const pw = await requirePwAi(res, "download");
if (!pw) {
return;
}
await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR);
const downloadPath = await resolveWritableOutputPathOrRespond({
res,
rootDir: DEFAULT_DOWNLOAD_DIR,
requestedPath: out,
scopeLabel: "downloads directory",
});
if (!resolvedDownloadPath) {
if (!downloadPath) {
return;
}
downloadPath = resolvedDownloadPath;
}
const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs);
const result = await pw.waitForDownloadViaPlaywright({
...requestBase,
path: downloadPath,
});
res.json({ ok: true, targetId: tab.targetId, download: result });
},
});
});
app.post("/download", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const ref = toStringOrEmpty(body.ref);
const out = toStringOrEmpty(body.path);
const timeoutMs = toNumber(body.timeoutMs);
if (!ref) {
return jsonError(res, 400, "ref is required");
}
if (!out) {
return jsonError(res, 400, "path is required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.download.downloadUnsupported);
}
const pw = await requirePwAi(res, "download");
if (!pw) {
return;
}
await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR);
const downloadPath = await resolveWritableOutputPathOrRespond({
res,
rootDir: DEFAULT_DOWNLOAD_DIR,
requestedPath: out,
scopeLabel: "downloads directory",
});
if (!downloadPath) {
return;
}
const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs);
const result = await pw.downloadViaPlaywright({
...requestBase,
ref,
path: downloadPath,
});
res.json({ ok: true, targetId: tab.targetId, download: result });
},
});
});
const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs);
const result = await pw.downloadViaPlaywright({
...requestBase,
ref,
path: downloadPath,
});
res.json({ ok: true, targetId: tab.targetId, download: result });
},
});
}),
);
}

View File

@@ -10,124 +10,136 @@ import {
import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
import { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "./path-output.js";
import type { BrowserRouteRegistrar } from "./types.js";
import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
import {
asyncBrowserRoute,
jsonError,
toBoolean,
toNumber,
toStringArray,
toStringOrEmpty,
} from "./utils.js";
export function registerBrowserAgentActHookRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.post("/hooks/file-chooser", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const ref = toStringOrEmpty(body.ref) || undefined;
const inputRef = toStringOrEmpty(body.inputRef) || undefined;
const element = toStringOrEmpty(body.element) || undefined;
const paths = toStringArray(body.paths) ?? [];
const timeoutMs = toNumber(body.timeoutMs);
if (!paths.length) {
return jsonError(res, 400, "paths are required");
}
app.post(
"/hooks/file-chooser",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const ref = toStringOrEmpty(body.ref) || undefined;
const inputRef = toStringOrEmpty(body.inputRef) || undefined;
const element = toStringOrEmpty(body.element) || undefined;
const paths = toStringArray(body.paths) ?? [];
const timeoutMs = toNumber(body.timeoutMs);
if (!paths.length) {
return jsonError(res, 400, "paths are required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
const uploadPathsResult = await resolveExistingPathsWithinRoot({
rootDir: DEFAULT_UPLOAD_DIR,
requestedPaths: paths,
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
});
if (!uploadPathsResult.ok) {
res.status(400).json({ error: uploadPathsResult.error });
return;
}
const resolvedPaths = uploadPathsResult.paths;
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (element) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadElement);
}
if (resolvedPaths.length !== 1) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadSingleFile);
}
const uid = inputRef || ref;
if (!uid) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadRefRequired);
}
await uploadChromeMcpFile({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid,
filePath: resolvedPaths[0] ?? "",
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
const uploadPathsResult = await resolveExistingPathsWithinRoot({
rootDir: DEFAULT_UPLOAD_DIR,
requestedPaths: paths,
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
});
return res.json({ ok: true });
}
const pw = await requirePwAi(res, "file chooser hook");
if (!pw) {
return;
}
if (inputRef || element) {
if (ref) {
return jsonError(res, 400, "ref cannot be combined with inputRef/element");
if (!uploadPathsResult.ok) {
res.status(400).json({ error: uploadPathsResult.error });
return;
}
await pw.setInputFilesViaPlaywright({
cdpUrl,
targetId: tab.targetId,
inputRef,
element,
paths: resolvedPaths,
});
} else {
await pw.armFileUploadViaPlaywright({
cdpUrl,
targetId: tab.targetId,
paths: resolvedPaths,
timeoutMs: timeoutMs ?? undefined,
});
if (ref) {
await pw.clickViaPlaywright({
const resolvedPaths = uploadPathsResult.paths;
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (element) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadElement);
}
if (resolvedPaths.length !== 1) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadSingleFile);
}
const uid = inputRef || ref;
if (!uid) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadRefRequired);
}
await uploadChromeMcpFile({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid,
filePath: resolvedPaths[0] ?? "",
});
return res.json({ ok: true });
}
const pw = await requirePwAi(res, "file chooser hook");
if (!pw) {
return;
}
if (inputRef || element) {
if (ref) {
return jsonError(res, 400, "ref cannot be combined with inputRef/element");
}
await pw.setInputFilesViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
ref,
inputRef,
element,
paths: resolvedPaths,
});
} else {
await pw.armFileUploadViaPlaywright({
cdpUrl,
targetId: tab.targetId,
paths: resolvedPaths,
timeoutMs: timeoutMs ?? undefined,
});
if (ref) {
await pw.clickViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
ref,
});
}
}
}
res.json({ ok: true });
},
});
});
res.json({ ok: true });
},
});
}),
);
app.post("/hooks/dialog", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const accept = toBoolean(body.accept);
const promptText = toStringOrEmpty(body.promptText) || undefined;
const timeoutMs = toNumber(body.timeoutMs);
if (accept === undefined) {
return jsonError(res, 400, "accept is required");
}
app.post(
"/hooks/dialog",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const accept = toBoolean(body.accept);
const promptText = toStringOrEmpty(body.promptText) || undefined;
const timeoutMs = toNumber(body.timeoutMs);
if (accept === undefined) {
return jsonError(res, 400, "accept is required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (timeoutMs) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.dialogTimeout);
}
await evaluateChromeMcpScript({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
fn: `() => {
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (timeoutMs) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.dialogTimeout);
}
await evaluateChromeMcpScript({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
fn: `() => {
const state = (window.__openclawDialogHook ??= {});
if (!state.originals) {
state.originals = {
@@ -166,22 +178,23 @@ export function registerBrowserAgentActHookRoutes(
};
return true;
}`,
});
return res.json({ ok: true });
}
const pw = await requirePwAi(res, "dialog hook");
if (!pw) {
return;
}
await pw.armDialogViaPlaywright({
cdpUrl,
targetId: tab.targetId,
accept,
promptText,
timeoutMs: timeoutMs ?? undefined,
});
return res.json({ ok: true });
}
const pw = await requirePwAi(res, "dialog hook");
if (!pw) {
return;
}
await pw.armDialogViaPlaywright({
cdpUrl,
targetId: tab.targetId,
accept,
promptText,
timeoutMs: timeoutMs ?? undefined,
});
res.json({ ok: true });
},
});
});
res.json({ ok: true });
},
});
}),
);
}

View File

@@ -37,7 +37,7 @@ import {
} from "./agent.shared.js";
import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
import type { BrowserRouteRegistrar } from "./types.js";
import { jsonError, toNumber, toStringOrEmpty } from "./utils.js";
import { asyncBrowserRoute, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -333,347 +333,355 @@ export function registerBrowserAgentActRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.post("/act", async (req, res) => {
const body = readBody(req);
const kindRaw = toStringOrEmpty(body.kind);
if (!isActKind(kindRaw)) {
return jsonActError(res, 400, ACT_ERROR_CODES.kindRequired, "kind is required");
}
const kind: ActKind = kindRaw;
let action: BrowserActRequest;
try {
action = normalizeActRequest(body);
} catch (err) {
return jsonActError(res, 400, ACT_ERROR_CODES.invalidRequest, formatErrorMessage(err));
}
const targetId = resolveTargetIdFromBody(body);
if (Object.hasOwn(body, "selector") && !SELECTOR_ALLOWED_KINDS.has(kind)) {
return jsonActError(
res,
400,
ACT_ERROR_CODES.selectorUnsupported,
SELECTOR_UNSUPPORTED_MESSAGE,
);
}
const earlyFn = action.kind === "wait" || action.kind === "evaluate" ? action.fn : "";
if (
(action.kind === "evaluate" || (action.kind === "wait" && earlyFn)) &&
!ctx.state().resolved.evaluateEnabled
) {
return jsonActError(
res,
403,
ACT_ERROR_CODES.evaluateDisabled,
browserEvaluateDisabledMessage(action.kind === "evaluate" ? "evaluate" : "wait"),
);
}
app.post(
"/act",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const kindRaw = toStringOrEmpty(body.kind);
if (!isActKind(kindRaw)) {
return jsonActError(res, 400, ACT_ERROR_CODES.kindRequired, "kind is required");
}
const kind: ActKind = kindRaw;
let action: BrowserActRequest;
try {
action = normalizeActRequest(body);
} catch (err) {
return jsonActError(res, 400, ACT_ERROR_CODES.invalidRequest, formatErrorMessage(err));
}
const targetId = resolveTargetIdFromBody(body);
if (Object.hasOwn(body, "selector") && !SELECTOR_ALLOWED_KINDS.has(kind)) {
return jsonActError(
res,
400,
ACT_ERROR_CODES.selectorUnsupported,
SELECTOR_UNSUPPORTED_MESSAGE,
);
}
const earlyFn = action.kind === "wait" || action.kind === "evaluate" ? action.fn : "";
if (
(action.kind === "evaluate" || (action.kind === "wait" && earlyFn)) &&
!ctx.state().resolved.evaluateEnabled
) {
return jsonActError(
res,
403,
ACT_ERROR_CODES.evaluateDisabled,
browserEvaluateDisabledMessage(action.kind === "evaluate" ? "evaluate" : "wait"),
);
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
const evaluateEnabled = ctx.state().resolved.evaluateEnabled;
const ssrfPolicy = ctx.state().resolved.ssrfPolicy;
if (action.targetId && action.targetId !== tab.targetId) {
return jsonActError(
res,
403,
ACT_ERROR_CODES.targetIdMismatch,
"action targetId must match request targetId",
);
}
const isExistingSession = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
const profileName = profileCtx.profile.name;
if (isExistingSession) {
const initialTabTargetIds = withBrowserNavigationPolicy(ssrfPolicy).ssrfPolicy
? new Set((await profileCtx.listTabs()).map((currentTab) => currentTab.targetId))
: new Set<string>();
const existingSessionNavigationGuard = {
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
ssrfPolicy,
listTabs: () => profileCtx.listTabs(),
initialTabTargetIds,
};
const unsupportedMessage = getExistingSessionUnsupportedMessage(action);
if (unsupportedMessage) {
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
const evaluateEnabled = ctx.state().resolved.evaluateEnabled;
const ssrfPolicy = ctx.state().resolved.ssrfPolicy;
if (action.targetId && action.targetId !== tab.targetId) {
return jsonActError(
res,
501,
ACT_ERROR_CODES.unsupportedForExistingSession,
unsupportedMessage,
403,
ACT_ERROR_CODES.targetIdMismatch,
"action targetId must match request targetId",
);
}
switch (action.kind) {
case "click":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
clickChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: action.ref!,
doubleClick: action.doubleClick ?? false,
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
case "type":
await runExistingSessionActionWithNavigationGuard({
execute: async () => {
await fillChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: action.ref!,
value: action.text,
});
if (action.submit) {
await pressChromeMcpKey({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
key: "Enter",
});
}
},
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "press":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
pressChromeMcpKey({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
key: action.key,
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "hover":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
hoverChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: action.ref!,
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "scrollIntoView":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
evaluateChromeMcpScript({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
fn: `(el) => { el.scrollIntoView({ block: "center", inline: "center" }); return true; }`,
args: [action.ref!],
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "drag":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
dragChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
fromUid: action.startRef!,
toUid: action.endRef!,
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "select":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
fillChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: action.ref!,
value: action.values[0] ?? "",
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "fill":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
fillChromeMcpForm({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
elements: action.fields.map((field) => ({
uid: field.ref,
value: String(field.value ?? ""),
})),
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "resize":
await resizeChromeMcpPage({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
width: action.width,
height: action.height,
});
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
case "wait":
await waitForExistingSessionCondition({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
timeMs: action.timeMs,
text: action.text,
textGone: action.textGone,
selector: action.selector,
url: action.url,
loadState: action.loadState,
fn: action.fn,
timeoutMs: action.timeoutMs,
});
return res.json({ ok: true, targetId: tab.targetId });
case "evaluate": {
const result = await runExistingSessionActionWithNavigationGuard({
execute: () =>
evaluateChromeMcpScript({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
fn: action.fn,
args: action.ref ? [action.ref] : undefined,
}),
guard: existingSessionNavigationGuard,
});
return res.json({
ok: true,
targetId: tab.targetId,
url: tab.url,
result,
});
}
case "close":
await closeChromeMcpTab(profileName, tab.targetId, profileCtx.profile.userDataDir);
return res.json({ ok: true, targetId: tab.targetId });
case "batch":
const isExistingSession = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
const profileName = profileCtx.profile.name;
if (isExistingSession) {
const initialTabTargetIds = withBrowserNavigationPolicy(ssrfPolicy).ssrfPolicy
? new Set((await profileCtx.listTabs()).map((currentTab) => currentTab.targetId))
: new Set<string>();
const existingSessionNavigationGuard = {
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
ssrfPolicy,
listTabs: () => profileCtx.listTabs(),
initialTabTargetIds,
};
const unsupportedMessage = getExistingSessionUnsupportedMessage(action);
if (unsupportedMessage) {
return jsonActError(
res,
501,
ACT_ERROR_CODES.unsupportedForExistingSession,
EXISTING_SESSION_LIMITS.act.batch,
unsupportedMessage,
);
}
switch (action.kind) {
case "click":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
clickChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: action.ref!,
doubleClick: action.doubleClick ?? false,
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
case "type":
await runExistingSessionActionWithNavigationGuard({
execute: async () => {
await fillChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: action.ref!,
value: action.text,
});
if (action.submit) {
await pressChromeMcpKey({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
key: "Enter",
});
}
},
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "press":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
pressChromeMcpKey({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
key: action.key,
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "hover":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
hoverChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: action.ref!,
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "scrollIntoView":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
evaluateChromeMcpScript({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
fn: `(el) => { el.scrollIntoView({ block: "center", inline: "center" }); return true; }`,
args: [action.ref!],
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "drag":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
dragChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
fromUid: action.startRef!,
toUid: action.endRef!,
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "select":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
fillChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: action.ref!,
value: action.values[0] ?? "",
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "fill":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
fillChromeMcpForm({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
elements: action.fields.map((field) => ({
uid: field.ref,
value: String(field.value ?? ""),
})),
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "resize":
await resizeChromeMcpPage({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
width: action.width,
height: action.height,
});
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
case "wait":
await waitForExistingSessionCondition({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
timeMs: action.timeMs,
text: action.text,
textGone: action.textGone,
selector: action.selector,
url: action.url,
loadState: action.loadState,
fn: action.fn,
timeoutMs: action.timeoutMs,
});
return res.json({ ok: true, targetId: tab.targetId });
case "evaluate": {
const result = await runExistingSessionActionWithNavigationGuard({
execute: () =>
evaluateChromeMcpScript({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
fn: action.fn,
args: action.ref ? [action.ref] : undefined,
}),
guard: existingSessionNavigationGuard,
});
return res.json({
ok: true,
targetId: tab.targetId,
url: tab.url,
result,
});
}
case "close":
await closeChromeMcpTab(profileName, tab.targetId, profileCtx.profile.userDataDir);
return res.json({ ok: true, targetId: tab.targetId });
case "batch":
return jsonActError(
res,
501,
ACT_ERROR_CODES.unsupportedForExistingSession,
EXISTING_SESSION_LIMITS.act.batch,
);
}
}
}
const pw = await requirePwAi(res, `act:${kind}`);
if (!pw) {
return;
}
if (action.kind === "batch") {
const targetIdError = validateBatchTargetIds(action.actions, tab.targetId);
if (targetIdError) {
return jsonActError(res, 403, ACT_ERROR_CODES.targetIdMismatch, targetIdError);
const pw = await requirePwAi(res, `act:${kind}`);
if (!pw) {
return;
}
}
const result = await pw.executeActViaPlaywright({
cdpUrl,
action,
targetId: tab.targetId,
evaluateEnabled,
ssrfPolicy,
signal: req.signal,
});
switch (action.kind) {
case "batch":
return res.json({ ok: true, targetId: tab.targetId, results: result.results ?? [] });
case "evaluate":
return res.json({
ok: true,
targetId: tab.targetId,
url: tab.url,
result: result.result,
});
case "click":
case "resize":
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
default:
return res.json({ ok: true, targetId: tab.targetId });
}
},
});
});
if (action.kind === "batch") {
const targetIdError = validateBatchTargetIds(action.actions, tab.targetId);
if (targetIdError) {
return jsonActError(res, 403, ACT_ERROR_CODES.targetIdMismatch, targetIdError);
}
}
const result = await pw.executeActViaPlaywright({
cdpUrl,
action,
targetId: tab.targetId,
evaluateEnabled,
ssrfPolicy,
signal: req.signal,
});
switch (action.kind) {
case "batch":
return res.json({ ok: true, targetId: tab.targetId, results: result.results ?? [] });
case "evaluate":
return res.json({
ok: true,
targetId: tab.targetId,
url: tab.url,
result: result.result,
});
case "click":
case "resize":
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
default:
return res.json({ ok: true, targetId: tab.targetId });
}
},
});
}),
);
registerBrowserAgentActHookRoutes(app, ctx);
registerBrowserAgentActDownloadRoutes(app, ctx);
app.post("/response/body", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const url = toStringOrEmpty(body.url);
const timeoutMs = toNumber(body.timeoutMs);
const maxChars = toNumber(body.maxChars);
if (!url) {
return jsonError(res, 400, "url is required");
}
app.post(
"/response/body",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const url = toStringOrEmpty(body.url);
const timeoutMs = toNumber(body.timeoutMs);
const maxChars = toNumber(body.maxChars);
if (!url) {
return jsonError(res, 400, "url is required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.responseBody);
}
const pw = await requirePwAi(res, "response body");
if (!pw) {
return;
}
const result = await pw.responseBodyViaPlaywright({
cdpUrl,
targetId: tab.targetId,
url,
timeoutMs: timeoutMs ?? undefined,
maxChars: maxChars ?? undefined,
});
res.json({ ok: true, targetId: tab.targetId, response: result });
},
});
});
app.post("/highlight", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const ref = toStringOrEmpty(body.ref);
if (!ref) {
return jsonError(res, 400, "ref is required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
await evaluateChromeMcpScript({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.responseBody);
}
const pw = await requirePwAi(res, "response body");
if (!pw) {
return;
}
const result = await pw.responseBodyViaPlaywright({
cdpUrl,
targetId: tab.targetId,
args: [ref],
fn: `(el) => {
url,
timeoutMs: timeoutMs ?? undefined,
maxChars: maxChars ?? undefined,
});
res.json({ ok: true, targetId: tab.targetId, response: result });
},
});
}),
);
app.post(
"/highlight",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const ref = toStringOrEmpty(body.ref);
if (!ref) {
return jsonError(res, 400, "ref is required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
await evaluateChromeMcpScript({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
args: [ref],
fn: `(el) => {
if (!(el instanceof Element)) {
return false;
}
@@ -688,20 +696,21 @@ export function registerBrowserAgentActRoutes(
}, 2000);
return true;
}`,
});
return res.json({ ok: true, targetId: tab.targetId });
}
const pw = await requirePwAi(res, "highlight");
if (!pw) {
return;
}
await pw.highlightViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ref,
});
return res.json({ ok: true, targetId: tab.targetId });
}
const pw = await requirePwAi(res, "highlight");
if (!pw) {
return;
}
await pw.highlightViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ref,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
}

View File

@@ -11,138 +11,153 @@ import {
import { resolveWritableOutputPathOrRespond } from "./output-paths.js";
import { DEFAULT_TRACE_DIR } from "./path-output.js";
import type { BrowserRouteRegistrar } from "./types.js";
import { toBoolean, toStringOrEmpty } from "./utils.js";
import { asyncBrowserRoute, toBoolean, toStringOrEmpty } from "./utils.js";
export function registerBrowserAgentDebugRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.get("/console", async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
const level = typeof req.query.level === "string" ? req.query.level : "";
app.get(
"/console",
asyncBrowserRoute(async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
const level = typeof req.query.level === "string" ? req.query.level : "";
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "console messages",
run: async ({ cdpUrl, tab, pw }) => {
const messages = await pw.getConsoleMessagesViaPlaywright({
cdpUrl,
targetId: tab.targetId,
level: normalizeOptionalString(level),
});
res.json({ ok: true, messages, targetId: tab.targetId });
},
});
});
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "console messages",
run: async ({ cdpUrl, tab, pw }) => {
const messages = await pw.getConsoleMessagesViaPlaywright({
cdpUrl,
targetId: tab.targetId,
level: normalizeOptionalString(level),
});
res.json({ ok: true, messages, targetId: tab.targetId });
},
});
}),
);
app.get("/errors", async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
const clear = toBoolean(req.query.clear) ?? false;
app.get(
"/errors",
asyncBrowserRoute(async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
const clear = toBoolean(req.query.clear) ?? false;
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "page errors",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.getPageErrorsViaPlaywright({
cdpUrl,
targetId: tab.targetId,
clear,
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
});
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "page errors",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.getPageErrorsViaPlaywright({
cdpUrl,
targetId: tab.targetId,
clear,
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
}),
);
app.get("/requests", async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
const filter = typeof req.query.filter === "string" ? req.query.filter : "";
const clear = toBoolean(req.query.clear) ?? false;
app.get(
"/requests",
asyncBrowserRoute(async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
const filter = typeof req.query.filter === "string" ? req.query.filter : "";
const clear = toBoolean(req.query.clear) ?? false;
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "network requests",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.getNetworkRequestsViaPlaywright({
cdpUrl,
targetId: tab.targetId,
filter: normalizeOptionalString(filter),
clear,
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
});
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "network requests",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.getNetworkRequestsViaPlaywright({
cdpUrl,
targetId: tab.targetId,
filter: normalizeOptionalString(filter),
clear,
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
}),
);
app.post("/trace/start", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const screenshots = toBoolean(body.screenshots) ?? undefined;
const snapshots = toBoolean(body.snapshots) ?? undefined;
const sources = toBoolean(body.sources) ?? undefined;
app.post(
"/trace/start",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const screenshots = toBoolean(body.screenshots) ?? undefined;
const snapshots = toBoolean(body.snapshots) ?? undefined;
const sources = toBoolean(body.sources) ?? undefined;
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "trace start",
run: async ({ cdpUrl, tab, pw }) => {
await pw.traceStartViaPlaywright({
cdpUrl,
targetId: tab.targetId,
screenshots,
snapshots,
sources,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "trace start",
run: async ({ cdpUrl, tab, pw }) => {
await pw.traceStartViaPlaywright({
cdpUrl,
targetId: tab.targetId,
screenshots,
snapshots,
sources,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
app.post("/trace/stop", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const out = toStringOrEmpty(body.path) || "";
app.post(
"/trace/stop",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const out = toStringOrEmpty(body.path) || "";
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "trace stop",
run: async ({ cdpUrl, tab, pw }) => {
const id = crypto.randomUUID();
const tracePath = await resolveWritableOutputPathOrRespond({
res,
rootDir: DEFAULT_TRACE_DIR,
requestedPath: out,
scopeLabel: "trace directory",
defaultFileName: `browser-trace-${id}.zip`,
ensureRootDir: true,
});
if (!tracePath) {
return;
}
await pw.traceStopViaPlaywright({
cdpUrl,
targetId: tab.targetId,
path: tracePath,
});
res.json({
ok: true,
targetId: tab.targetId,
path: path.resolve(tracePath),
});
},
});
});
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "trace stop",
run: async ({ cdpUrl, tab, pw }) => {
const id = crypto.randomUUID();
const tracePath = await resolveWritableOutputPathOrRespond({
res,
rootDir: DEFAULT_TRACE_DIR,
requestedPath: out,
scopeLabel: "trace directory",
defaultFileName: `browser-trace-${id}.zip`,
ensureRootDir: true,
});
if (!tracePath) {
return;
}
await pw.traceStopViaPlaywright({
cdpUrl,
targetId: tab.targetId,
path: tracePath,
});
res.json({
ok: true,
targetId: tab.targetId,
path: path.resolve(tracePath),
});
},
});
}),
);
}

View File

@@ -40,7 +40,7 @@ import {
} from "./agent.snapshot.plan.js";
import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
import type { BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { jsonError, toBoolean, toStringOrEmpty } from "./utils.js";
import { asyncBrowserRoute, jsonError, toBoolean, toStringOrEmpty } from "./utils.js";
const CHROME_MCP_OVERLAY_ATTR = "data-openclaw-mcp-overlay";
@@ -177,121 +177,169 @@ export function registerBrowserAgentSnapshotRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.post("/navigate", async (req, res) => {
const body = readBody(req);
const url = toStringOrEmpty(body.url);
const targetId = toStringOrEmpty(body.targetId) || undefined;
if (!url) {
return jsonError(res, 400, "url is required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, tab, cdpUrl }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
const result = await navigateChromeMcpPage({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
app.post(
"/navigate",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const url = toStringOrEmpty(body.url);
const targetId = toStringOrEmpty(body.targetId) || undefined;
if (!url) {
return jsonError(res, 400, "url is required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, tab, cdpUrl }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
const result = await navigateChromeMcpPage({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
url,
});
await assertBrowserNavigationResultAllowed({ url: result.url, ...ssrfPolicyOpts });
return res.json({ ok: true, targetId: tab.targetId, ...result });
}
const pw = await requirePwAi(res, "navigate");
if (!pw) {
return;
}
const result = await pw.navigateViaPlaywright({
cdpUrl,
targetId: tab.targetId,
url,
...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy),
});
await assertBrowserNavigationResultAllowed({ url: result.url, ...ssrfPolicyOpts });
return res.json({ ok: true, targetId: tab.targetId, ...result });
}
const pw = await requirePwAi(res, "navigate");
if (!pw) {
return;
}
const result = await pw.navigateViaPlaywright({
cdpUrl,
targetId: tab.targetId,
url,
...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy),
});
const currentTargetId = await resolveTargetIdAfterNavigate({
oldTargetId: tab.targetId,
navigatedUrl: result.url,
listTabs: () => profileCtx.listTabs(),
});
res.json({ ok: true, targetId: currentTargetId, ...result });
},
});
});
const currentTargetId = await resolveTargetIdAfterNavigate({
oldTargetId: tab.targetId,
navigatedUrl: result.url,
listTabs: () => profileCtx.listTabs(),
});
res.json({ ok: true, targetId: currentTargetId, ...result });
},
});
}),
);
app.post("/pdf", async (req, res) => {
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) {
return;
}
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.snapshot.pdfUnsupported);
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "pdf",
run: async ({ cdpUrl, tab, pw }) => {
const pdf = await pw.pdfViaPlaywright({
cdpUrl,
targetId: tab.targetId,
});
await saveBrowserMediaResponse({
res,
buffer: pdf.buffer,
contentType: "application/pdf",
maxBytes: pdf.buffer.byteLength,
targetId: tab.targetId,
url: tab.url,
});
},
});
});
app.post(
"/pdf",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) {
return;
}
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.snapshot.pdfUnsupported);
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "pdf",
run: async ({ cdpUrl, tab, pw }) => {
const pdf = await pw.pdfViaPlaywright({
cdpUrl,
targetId: tab.targetId,
});
await saveBrowserMediaResponse({
res,
buffer: pdf.buffer,
contentType: "application/pdf",
maxBytes: pdf.buffer.byteLength,
targetId: tab.targetId,
url: tab.url,
});
},
});
}),
);
app.post("/screenshot", async (req, res) => {
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const fullPage = toBoolean(body.fullPage) ?? false;
const ref = toStringOrEmpty(body.ref) || undefined;
const element = toStringOrEmpty(body.element) || undefined;
const type = body.type === "jpeg" ? "jpeg" : "png";
app.post(
"/screenshot",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const fullPage = toBoolean(body.fullPage) ?? false;
const ref = toStringOrEmpty(body.ref) || undefined;
const element = toStringOrEmpty(body.element) || undefined;
const type = body.type === "jpeg" ? "jpeg" : "png";
if (fullPage && (ref || element)) {
return jsonError(res, 400, "fullPage is not supported for element screenshots");
}
if (fullPage && (ref || element)) {
return jsonError(res, 400, "fullPage is not supported for element screenshots");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, tab, cdpUrl }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
if (element) {
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.screenshotElement);
}
if (ssrfPolicyOpts.ssrfPolicy) {
await assertBrowserNavigationResultAllowed({
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, tab, cdpUrl }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
if (element) {
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.screenshotElement);
}
if (ssrfPolicyOpts.ssrfPolicy) {
await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts,
});
}
const buffer = await takeChromeMcpScreenshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: ref,
fullPage,
format: type,
});
await saveNormalizedScreenshotResponse({
res,
buffer,
type,
targetId: tab.targetId,
url: tab.url,
...ssrfPolicyOpts,
});
return;
}
let buffer: Buffer;
const shouldUsePlaywright = shouldUsePlaywrightForScreenshot({
profile: profileCtx.profile,
wsUrl: tab.wsUrl,
ref,
element,
});
if (shouldUsePlaywright) {
const pw = await requirePwAi(res, "screenshot");
if (!pw) {
return;
}
const snap = await pw.takeScreenshotViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ref,
element,
fullPage,
type,
});
buffer = snap.buffer;
} else {
buffer = await captureScreenshot({
wsUrl: tab.wsUrl ?? "",
fullPage,
format: type,
quality: type === "jpeg" ? 85 : undefined,
});
}
const buffer = await takeChromeMcpScreenshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: ref,
fullPage,
format: type,
});
await saveNormalizedScreenshotResponse({
res,
buffer,
@@ -299,118 +347,164 @@ export function registerBrowserAgentSnapshotRoutes(
targetId: tab.targetId,
url: tab.url,
});
return;
}
},
});
}),
);
let buffer: Buffer;
const shouldUsePlaywright = shouldUsePlaywrightForScreenshot({
profile: profileCtx.profile,
wsUrl: tab.wsUrl,
ref,
element,
});
if (shouldUsePlaywright) {
const pw = await requirePwAi(res, "screenshot");
if (!pw) {
return;
}
const snap = await pw.takeScreenshotViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ref,
element,
fullPage,
type,
});
buffer = snap.buffer;
} else {
buffer = await captureScreenshot({
wsUrl: tab.wsUrl ?? "",
fullPage,
format: type,
quality: type === "jpeg" ? 85 : undefined,
});
}
await saveNormalizedScreenshotResponse({
res,
buffer,
type,
targetId: tab.targetId,
url: tab.url,
});
},
});
});
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 hasPlaywright = Boolean(await getPwAiModule());
const plan = resolveSnapshotPlan({
profile: profileCtx.profile,
query: req.query,
hasPlaywright,
});
try {
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") {
return jsonError(res, 400, "labels/mode=efficient require format=ai");
app.get(
"/snapshot",
asyncBrowserRoute(async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) {
return;
}
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
if (plan.selectorValue || plan.frameSelectorValue) {
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.snapshotSelector);
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const hasPlaywright = Boolean(await getPwAiModule());
const plan = resolveSnapshotPlan({
profile: profileCtx.profile,
query: req.query,
hasPlaywright,
});
try {
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") {
return jsonError(res, 400, "labels/mode=efficient require format=ai");
}
if (ssrfPolicyOpts.ssrfPolicy) {
await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts,
});
}
const snapshot = await takeChromeMcpSnapshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
});
if (plan.format === "aria") {
return res.json({
ok: true,
format: "aria",
targetId: tab.targetId,
url: tab.url,
nodes: flattenChromeMcpSnapshotToAriaNodes(snapshot, plan.limit),
});
}
const built = buildAiSnapshotFromChromeMcpSnapshot({
root: snapshot,
options: {
interactive: plan.interactive ?? undefined,
compact: plan.compact ?? undefined,
maxDepth: plan.depth ?? undefined,
},
maxChars: plan.resolvedMaxChars,
});
if (plan.labels) {
const refs = Object.keys(built.refs);
const labelResult = await renderChromeMcpLabels({
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
if (plan.selectorValue || plan.frameSelectorValue) {
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.snapshotSelector);
}
if (ssrfPolicyOpts.ssrfPolicy) {
await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts,
});
}
const snapshot = await takeChromeMcpSnapshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
refs,
});
try {
const labeled = await takeChromeMcpScreenshot({
if (plan.format === "aria") {
return res.json({
ok: true,
format: "aria",
targetId: tab.targetId,
url: tab.url,
nodes: flattenChromeMcpSnapshotToAriaNodes(snapshot, plan.limit),
});
}
const built = buildAiSnapshotFromChromeMcpSnapshot({
root: snapshot,
options: {
interactive: plan.interactive ?? undefined,
compact: plan.compact ?? undefined,
maxDepth: plan.depth ?? undefined,
},
maxChars: plan.resolvedMaxChars,
});
if (plan.labels) {
const refs = Object.keys(built.refs);
const labelResult = await renderChromeMcpLabels({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
format: "png",
refs,
});
const normalized = await normalizeBrowserScreenshot(labeled, {
try {
const labeled = await takeChromeMcpScreenshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
format: "png",
});
const normalized = await normalizeBrowserScreenshot(labeled, {
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
});
await ensureMediaDir();
const saved = await saveMediaBuffer(
normalized.buffer,
normalized.contentType ?? "image/png",
"browser",
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
);
return res.json({
ok: true,
format: "ai",
targetId: tab.targetId,
url: tab.url,
labels: true,
labelsCount: labelResult.labels,
labelsSkipped: labelResult.skipped,
imagePath: path.resolve(saved.path),
imageType: normalized.contentType?.includes("jpeg") ? "jpeg" : "png",
...built,
});
} finally {
await clearChromeMcpOverlay({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
});
}
}
return res.json({
ok: true,
format: "ai",
targetId: tab.targetId,
url: tab.url,
...built,
});
}
if (plan.format === "ai") {
const pw = await requirePwAi(res, "ai snapshot");
if (!pw) {
return;
}
const roleSnapshotArgs = {
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
selector: plan.selectorValue,
frameSelector: plan.frameSelectorValue,
refsMode: plan.refsMode,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
options: {
interactive: plan.interactive ?? undefined,
compact: plan.compact ?? undefined,
maxDepth: plan.depth ?? undefined,
},
};
const snap = plan.wantsRoleSnapshot
? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs)
: await pw
.snapshotAiViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
...(typeof plan.resolvedMaxChars === "number"
? { maxChars: plan.resolvedMaxChars }
: {}),
})
.catch(async (err) => {
// Public-API fallback when Playwright's private _snapshotForAI is missing.
if (String(err).toLowerCase().includes("_snapshotforai")) {
return await pw.snapshotRoleViaPlaywright(roleSnapshotArgs);
}
throw err;
});
if (plan.labels) {
const labeled = await pw.screenshotWithLabelsViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
refs: "refs" in snap ? snap.refs : {},
type: "png",
});
const normalized = await normalizeBrowserScreenshot(labeled.buffer, {
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
});
@@ -421,147 +515,65 @@ export function registerBrowserAgentSnapshotRoutes(
"browser",
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
);
const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png";
return res.json({
ok: true,
format: "ai",
format: plan.format,
targetId: tab.targetId,
url: tab.url,
labels: true,
labelsCount: labelResult.labels,
labelsSkipped: labelResult.skipped,
labelsCount: labeled.labels,
labelsSkipped: labeled.skipped,
imagePath: path.resolve(saved.path),
imageType: normalized.contentType?.includes("jpeg") ? "jpeg" : "png",
...built,
});
} finally {
await clearChromeMcpOverlay({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
imageType,
...snap,
});
}
}
return res.json({
ok: true,
format: "ai",
targetId: tab.targetId,
url: tab.url,
...built,
});
}
if (plan.format === "ai") {
const pw = await requirePwAi(res, "ai snapshot");
if (!pw) {
return;
}
const roleSnapshotArgs = {
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
selector: plan.selectorValue,
frameSelector: plan.frameSelectorValue,
refsMode: plan.refsMode,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
options: {
interactive: plan.interactive ?? undefined,
compact: plan.compact ?? undefined,
maxDepth: plan.depth ?? undefined,
},
};
const snap = plan.wantsRoleSnapshot
? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs)
: await pw
.snapshotAiViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
...(typeof plan.resolvedMaxChars === "number"
? { maxChars: plan.resolvedMaxChars }
: {}),
})
.catch(async (err) => {
// Public-API fallback when Playwright's private _snapshotForAI is missing.
if (String(err).toLowerCase().includes("_snapshotforai")) {
return await pw.snapshotRoleViaPlaywright(roleSnapshotArgs);
}
throw err;
});
if (plan.labels) {
const labeled = await pw.screenshotWithLabelsViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
refs: "refs" in snap ? snap.refs : {},
type: "png",
});
const normalized = await normalizeBrowserScreenshot(labeled.buffer, {
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
});
await ensureMediaDir();
const saved = await saveMediaBuffer(
normalized.buffer,
normalized.contentType ?? "image/png",
"browser",
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
);
const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png";
return res.json({
ok: true,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
labels: true,
labelsCount: labeled.labels,
labelsSkipped: labeled.skipped,
imagePath: path.resolve(saved.path),
imageType,
...snap,
});
}
const snap = shouldUsePlaywrightForAriaSnapshot({
profile: profileCtx.profile,
wsUrl: tab.wsUrl,
})
? (() => {
// Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session.
// Also covers cases where wsUrl is missing/unusable.
return requirePwAi(res, "aria snapshot").then(async (pw) => {
if (!pw) {
return null;
}
return await pw.snapshotAriaViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
limit: plan.limit,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
});
})()
: snapshotAria({ wsUrl: tab.wsUrl ?? "", limit: plan.limit });
const resolved = await Promise.resolve(snap);
if (!resolved) {
return;
}
return res.json({
ok: true,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...snap,
...resolved,
});
} catch (err) {
handleRouteError(ctx, res, err);
}
const snap = shouldUsePlaywrightForAriaSnapshot({
profile: profileCtx.profile,
wsUrl: tab.wsUrl,
})
? (() => {
// Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session.
// Also covers cases where wsUrl is missing/unusable.
return requirePwAi(res, "aria snapshot").then(async (pw) => {
if (!pw) {
return null;
}
return await pw.snapshotAriaViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
limit: plan.limit,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
});
})()
: snapshotAria({ wsUrl: tab.wsUrl ?? "", limit: plan.limit });
const resolved = await Promise.resolve(snap);
if (!resolved) {
return;
}
return res.json({
ok: true,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...resolved,
});
} catch (err) {
handleRouteError(ctx, res, err);
}
});
}),
);
}

View File

@@ -7,7 +7,7 @@ import {
withPlaywrightRouteContext,
} from "./agent.shared.js";
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
import { asyncBrowserRoute, jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
type StorageKind = "local" | "session";
@@ -68,385 +68,427 @@ export function registerBrowserAgentStorageRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.get("/cookies", async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "cookies",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.cookiesGetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
});
app.get(
"/cookies",
asyncBrowserRoute(async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "cookies",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.cookiesGetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
}),
);
app.post("/cookies/set", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const cookie =
body.cookie && typeof body.cookie === "object" && !Array.isArray(body.cookie)
? (body.cookie as Record<string, unknown>)
: null;
if (!cookie) {
return jsonError(res, 400, "cookie is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "cookies set",
run: async ({ cdpUrl, tab, pw }) => {
await pw.cookiesSetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
cookie: {
name: toStringOrEmpty(cookie.name),
value: toStringOrEmpty(cookie.value),
url: toStringOrEmpty(cookie.url) || undefined,
domain: toStringOrEmpty(cookie.domain) || undefined,
path: toStringOrEmpty(cookie.path) || undefined,
expires: toNumber(cookie.expires) ?? undefined,
httpOnly: toBoolean(cookie.httpOnly) ?? undefined,
secure: toBoolean(cookie.secure) ?? undefined,
sameSite:
cookie.sameSite === "Lax" ||
cookie.sameSite === "None" ||
cookie.sameSite === "Strict"
? cookie.sameSite
: undefined,
},
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post("/cookies/clear", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "cookies clear",
run: async ({ cdpUrl, tab, pw }) => {
await pw.cookiesClearViaPlaywright({
cdpUrl,
targetId: tab.targetId,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.get("/storage/:kind", async (req, res) => {
const kind = parseStorageKind(toStringOrEmpty(req.params.kind));
if (!kind) {
return jsonError(res, 400, "kind must be local|session");
}
const targetId = resolveTargetIdFromQuery(req.query);
const key = toStringOrEmpty(req.query.key);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "storage get",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.storageGetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
kind,
key: normalizeOptionalString(key),
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
});
app.post("/storage/:kind/set", async (req, res) => {
const mutation = parseStorageMutationFromRequest(req, res);
if (!mutation) {
return;
}
const key = toStringOrEmpty(mutation.body.key);
if (!key) {
return jsonError(res, 400, "key is required");
}
const value = typeof mutation.body.value === "string" ? mutation.body.value : "";
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId: mutation.parsed.targetId,
feature: "storage set",
run: async ({ cdpUrl, tab, pw }) => {
await pw.storageSetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
kind: mutation.parsed.kind,
key,
value,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post("/storage/:kind/clear", async (req, res) => {
const mutation = parseStorageMutationFromRequest(req, res);
if (!mutation) {
return;
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId: mutation.parsed.targetId,
feature: "storage clear",
run: async ({ cdpUrl, tab, pw }) => {
await pw.storageClearViaPlaywright({
cdpUrl,
targetId: tab.targetId,
kind: mutation.parsed.kind,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post("/set/offline", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const offline = toBoolean(body.offline);
if (offline === undefined) {
return jsonError(res, 400, "offline is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "offline",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setOfflineViaPlaywright({
cdpUrl,
targetId: tab.targetId,
offline,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post("/set/headers", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const headers =
body.headers && typeof body.headers === "object" && !Array.isArray(body.headers)
? (body.headers as Record<string, unknown>)
: null;
if (!headers) {
return jsonError(res, 400, "headers is required");
}
const parsed: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) {
if (typeof v === "string") {
parsed[k] = v;
app.post(
"/cookies/set",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const cookie =
body.cookie && typeof body.cookie === "object" && !Array.isArray(body.cookie)
? (body.cookie as Record<string, unknown>)
: null;
if (!cookie) {
return jsonError(res, 400, "cookie is required");
}
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "headers",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setExtraHTTPHeadersViaPlaywright({
cdpUrl,
targetId: tab.targetId,
headers: parsed,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "cookies set",
run: async ({ cdpUrl, tab, pw }) => {
await pw.cookiesSetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
cookie: {
name: toStringOrEmpty(cookie.name),
value: toStringOrEmpty(cookie.value),
url: toStringOrEmpty(cookie.url) || undefined,
domain: toStringOrEmpty(cookie.domain) || undefined,
path: toStringOrEmpty(cookie.path) || undefined,
expires: toNumber(cookie.expires) ?? undefined,
httpOnly: toBoolean(cookie.httpOnly) ?? undefined,
secure: toBoolean(cookie.secure) ?? undefined,
sameSite:
cookie.sameSite === "Lax" ||
cookie.sameSite === "None" ||
cookie.sameSite === "Strict"
? cookie.sameSite
: undefined,
},
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
app.post("/set/credentials", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const clear = toBoolean(body.clear) ?? false;
const username = toStringOrEmpty(body.username) || undefined;
const password = readStringValue(body.password);
app.post(
"/cookies/clear",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "http credentials",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setHttpCredentialsViaPlaywright({
cdpUrl,
targetId: tab.targetId,
username,
password,
clear,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "cookies clear",
run: async ({ cdpUrl, tab, pw }) => {
await pw.cookiesClearViaPlaywright({
cdpUrl,
targetId: tab.targetId,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
app.post("/set/geolocation", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const clear = toBoolean(body.clear) ?? false;
const latitude = toNumber(body.latitude);
const longitude = toNumber(body.longitude);
const accuracy = toNumber(body.accuracy) ?? undefined;
const origin = toStringOrEmpty(body.origin) || undefined;
app.get(
"/storage/:kind",
asyncBrowserRoute(async (req, res) => {
const kind = parseStorageKind(toStringOrEmpty(req.params.kind));
if (!kind) {
return jsonError(res, 400, "kind must be local|session");
}
const targetId = resolveTargetIdFromQuery(req.query);
const key = toStringOrEmpty(req.query.key);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "geolocation",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setGeolocationViaPlaywright({
cdpUrl,
targetId: tab.targetId,
latitude,
longitude,
accuracy,
origin,
clear,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "storage get",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.storageGetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
kind,
key: normalizeOptionalString(key),
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
}),
);
app.post("/set/media", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const schemeRaw = toStringOrEmpty(body.colorScheme);
const colorScheme =
schemeRaw === "dark" || schemeRaw === "light" || schemeRaw === "no-preference"
? schemeRaw
: schemeRaw === "none"
? null
: undefined;
if (colorScheme === undefined) {
return jsonError(res, 400, "colorScheme must be dark|light|no-preference|none");
}
app.post(
"/storage/:kind/set",
asyncBrowserRoute(async (req, res) => {
const mutation = parseStorageMutationFromRequest(req, res);
if (!mutation) {
return;
}
const key = toStringOrEmpty(mutation.body.key);
if (!key) {
return jsonError(res, 400, "key is required");
}
const value = typeof mutation.body.value === "string" ? mutation.body.value : "";
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "media emulation",
run: async ({ cdpUrl, tab, pw }) => {
await pw.emulateMediaViaPlaywright({
cdpUrl,
targetId: tab.targetId,
colorScheme,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId: mutation.parsed.targetId,
feature: "storage set",
run: async ({ cdpUrl, tab, pw }) => {
await pw.storageSetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
kind: mutation.parsed.kind,
key,
value,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
app.post("/set/timezone", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const timezoneId = toStringOrEmpty(body.timezoneId);
if (!timezoneId) {
return jsonError(res, 400, "timezoneId is required");
}
app.post(
"/storage/:kind/clear",
asyncBrowserRoute(async (req, res) => {
const mutation = parseStorageMutationFromRequest(req, res);
if (!mutation) {
return;
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "timezone",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setTimezoneViaPlaywright({
cdpUrl,
targetId: tab.targetId,
timezoneId,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId: mutation.parsed.targetId,
feature: "storage clear",
run: async ({ cdpUrl, tab, pw }) => {
await pw.storageClearViaPlaywright({
cdpUrl,
targetId: tab.targetId,
kind: mutation.parsed.kind,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
app.post("/set/locale", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const locale = toStringOrEmpty(body.locale);
if (!locale) {
return jsonError(res, 400, "locale is required");
}
app.post(
"/set/offline",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const offline = toBoolean(body.offline);
if (offline === undefined) {
return jsonError(res, 400, "offline is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "locale",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setLocaleViaPlaywright({
cdpUrl,
targetId: tab.targetId,
locale,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "offline",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setOfflineViaPlaywright({
cdpUrl,
targetId: tab.targetId,
offline,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
app.post("/set/device", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const name = toStringOrEmpty(body.name);
if (!name) {
return jsonError(res, 400, "name is required");
}
app.post(
"/set/headers",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const headers =
body.headers && typeof body.headers === "object" && !Array.isArray(body.headers)
? (body.headers as Record<string, unknown>)
: null;
if (!headers) {
return jsonError(res, 400, "headers is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "device emulation",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setDeviceViaPlaywright({
cdpUrl,
targetId: tab.targetId,
name,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
const parsed: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) {
if (typeof v === "string") {
parsed[k] = v;
}
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "headers",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setExtraHTTPHeadersViaPlaywright({
cdpUrl,
targetId: tab.targetId,
headers: parsed,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
app.post(
"/set/credentials",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const clear = toBoolean(body.clear) ?? false;
const username = toStringOrEmpty(body.username) || undefined;
const password = readStringValue(body.password);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "http credentials",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setHttpCredentialsViaPlaywright({
cdpUrl,
targetId: tab.targetId,
username,
password,
clear,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
app.post(
"/set/geolocation",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const clear = toBoolean(body.clear) ?? false;
const latitude = toNumber(body.latitude);
const longitude = toNumber(body.longitude);
const accuracy = toNumber(body.accuracy) ?? undefined;
const origin = toStringOrEmpty(body.origin) || undefined;
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "geolocation",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setGeolocationViaPlaywright({
cdpUrl,
targetId: tab.targetId,
latitude,
longitude,
accuracy,
origin,
clear,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
app.post(
"/set/media",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const schemeRaw = toStringOrEmpty(body.colorScheme);
const colorScheme =
schemeRaw === "dark" || schemeRaw === "light" || schemeRaw === "no-preference"
? schemeRaw
: schemeRaw === "none"
? null
: undefined;
if (colorScheme === undefined) {
return jsonError(res, 400, "colorScheme must be dark|light|no-preference|none");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "media emulation",
run: async ({ cdpUrl, tab, pw }) => {
await pw.emulateMediaViaPlaywright({
cdpUrl,
targetId: tab.targetId,
colorScheme,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
app.post(
"/set/timezone",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const timezoneId = toStringOrEmpty(body.timezoneId);
if (!timezoneId) {
return jsonError(res, 400, "timezoneId is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "timezone",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setTimezoneViaPlaywright({
cdpUrl,
targetId: tab.targetId,
timezoneId,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
app.post(
"/set/locale",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const locale = toStringOrEmpty(body.locale);
if (!locale) {
return jsonError(res, 400, "locale is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "locale",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setLocaleViaPlaywright({
cdpUrl,
targetId: tab.targetId,
locale,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
app.post(
"/set/device",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const name = toStringOrEmpty(body.name);
if (!name) {
return jsonError(res, 400, "name is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "device emulation",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setDeviceViaPlaywright({
cdpUrl,
targetId: tab.targetId,
name,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
}

View File

@@ -6,7 +6,7 @@ import { createBrowserProfilesService } from "../profiles-service.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import { resolveProfileContext } from "./agent.shared.js";
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
import { asyncBrowserRoute, getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
function handleBrowserRouteError(res: BrowserResponse, err: unknown) {
const mapped = toBrowserErrorResponse(err);
@@ -49,177 +49,198 @@ async function withProfilesServiceMutation(params: {
export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
// 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));
}
});
app.get(
"/profiles",
asyncBrowserRoute(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();
} catch {
return jsonError(res, 503, "browser server not started");
}
app.get(
"/",
asyncBrowserRoute(async (req, res) => {
let current: ReturnType<typeof ctx.state>;
try {
current = ctx.state();
} catch {
return jsonError(res, 503, "browser server not started");
}
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
return jsonError(res, profileCtx.status, profileCtx.error);
}
try {
const [cdpHttp, cdpReady] = await Promise.all([
profileCtx.isHttpReachable(300),
profileCtx.isReachable(600),
]);
const profileState = current.profiles.get(profileCtx.profile.name);
const capabilities = getBrowserProfileCapabilities(profileCtx.profile);
let detectedBrowser: string | null = null;
let detectedExecutablePath: string | null = null;
let detectError: string | null = null;
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
return jsonError(res, profileCtx.status, profileCtx.error);
}
try {
const detected = resolveBrowserExecutableForPlatform(current.resolved, process.platform);
if (detected) {
detectedBrowser = detected.kind;
detectedExecutablePath = detected.path;
}
} catch (err) {
detectError = String(err);
}
const [cdpHttp, cdpReady] = await Promise.all([
profileCtx.isHttpReachable(300),
profileCtx.isReachable(600),
]);
res.json({
enabled: current.resolved.enabled,
profile: profileCtx.profile.name,
driver: profileCtx.profile.driver,
transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp",
running: cdpReady,
cdpReady,
cdpHttp,
pid: capabilities.usesChromeMcp
? getChromeMcpPid(profileCtx.profile.name)
: (profileState?.running?.pid ?? null),
cdpPort: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpPort,
cdpUrl: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpUrl,
chosenBrowser: profileState?.running?.exe.kind ?? null,
detectedBrowser,
detectedExecutablePath,
detectError,
userDataDir: profileState?.running?.userDataDir ?? profileCtx.profile.userDataDir ?? null,
color: profileCtx.profile.color,
headless: current.resolved.headless,
noSandbox: current.resolved.noSandbox,
executablePath: current.resolved.executablePath ?? null,
attachOnly: profileCtx.profile.attachOnly,
});
} catch (err) {
const mapped = toBrowserErrorResponse(err);
if (mapped) {
return jsonError(res, mapped.status, mapped.message);
const profileState = current.profiles.get(profileCtx.profile.name);
const capabilities = getBrowserProfileCapabilities(profileCtx.profile);
let detectedBrowser: string | null = null;
let detectedExecutablePath: string | null = null;
let detectError: string | null = null;
try {
const detected = resolveBrowserExecutableForPlatform(current.resolved, process.platform);
if (detected) {
detectedBrowser = detected.kind;
detectedExecutablePath = detected.path;
}
} catch (err) {
detectError = String(err);
}
res.json({
enabled: current.resolved.enabled,
profile: profileCtx.profile.name,
driver: profileCtx.profile.driver,
transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp",
running: cdpReady,
cdpReady,
cdpHttp,
pid: capabilities.usesChromeMcp
? getChromeMcpPid(profileCtx.profile.name)
: (profileState?.running?.pid ?? null),
cdpPort: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpPort,
cdpUrl: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpUrl,
chosenBrowser: profileState?.running?.exe.kind ?? null,
detectedBrowser,
detectedExecutablePath,
detectError,
userDataDir: profileState?.running?.userDataDir ?? profileCtx.profile.userDataDir ?? null,
color: profileCtx.profile.color,
headless: current.resolved.headless,
noSandbox: current.resolved.noSandbox,
executablePath: current.resolved.executablePath ?? null,
attachOnly: profileCtx.profile.attachOnly,
});
} catch (err) {
const mapped = toBrowserErrorResponse(err);
if (mapped) {
return jsonError(res, mapped.status, mapped.message);
}
jsonError(res, 500, String(err));
}
jsonError(res, 500, String(err));
}
});
}),
);
// Start browser (profile-aware)
app.post("/start", async (req, res) => {
await withBasicProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
await profileCtx.ensureBrowserAvailable();
res.json({ ok: true, profile: profileCtx.profile.name });
},
});
});
app.post(
"/start",
asyncBrowserRoute(async (req, res) => {
await withBasicProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
await profileCtx.ensureBrowserAvailable();
res.json({ ok: true, profile: profileCtx.profile.name });
},
});
}),
);
// Stop browser (profile-aware)
app.post("/stop", async (req, res) => {
await withBasicProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
const result = await profileCtx.stopRunningBrowser();
res.json({
ok: true,
stopped: result.stopped,
profile: profileCtx.profile.name,
});
},
});
});
app.post(
"/stop",
asyncBrowserRoute(async (req, res) => {
await withBasicProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
const result = await profileCtx.stopRunningBrowser();
res.json({
ok: true,
stopped: result.stopped,
profile: profileCtx.profile.name,
});
},
});
}),
);
// Reset profile (profile-aware)
app.post("/reset-profile", async (req, res) => {
await withBasicProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
const result = await profileCtx.resetProfile();
res.json({ ok: true, profile: profileCtx.profile.name, ...result });
},
});
});
app.post(
"/reset-profile",
asyncBrowserRoute(async (req, res) => {
await withBasicProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
const result = await profileCtx.resetProfile();
res.json({ ok: true, profile: profileCtx.profile.name, ...result });
},
});
}),
);
// 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);
const userDataDir = toStringOrEmpty((req.body as { userDataDir?: unknown })?.userDataDir);
const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver);
app.post(
"/profiles/create",
asyncBrowserRoute(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);
const userDataDir = toStringOrEmpty((req.body as { userDataDir?: unknown })?.userDataDir);
const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver);
if (!name) {
return jsonError(res, 400, "name is required");
}
if (driver && driver !== "openclaw" && driver !== "clawd" && driver !== "existing-session") {
return jsonError(
if (!name) {
return jsonError(res, 400, "name is required");
}
if (driver && driver !== "openclaw" && driver !== "clawd" && driver !== "existing-session") {
return jsonError(
res,
400,
`unsupported profile driver "${driver}"; use "openclaw", "clawd", or "existing-session"`,
);
}
await withProfilesServiceMutation({
res,
400,
`unsupported profile driver "${driver}"; use "openclaw", "clawd", or "existing-session"`,
);
}
await withProfilesServiceMutation({
res,
ctx,
run: async (service) =>
await service.createProfile({
name,
color: color || undefined,
cdpUrl: cdpUrl || undefined,
userDataDir: userDataDir || undefined,
driver:
driver === "existing-session"
? "existing-session"
: driver === "openclaw" || driver === "clawd"
? "openclaw"
: undefined,
}),
});
});
ctx,
run: async (service) =>
await service.createProfile({
name,
color: color || undefined,
cdpUrl: cdpUrl || undefined,
userDataDir: userDataDir || undefined,
driver:
driver === "existing-session"
? "existing-session"
: driver === "openclaw" || driver === "clawd"
? "openclaw"
: undefined,
}),
});
}),
);
// 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");
}
app.delete(
"/profiles/:name",
asyncBrowserRoute(async (req, res) => {
const name = toStringOrEmpty(req.params.name);
if (!name) {
return jsonError(res, 400, "profile name is required");
}
await withProfilesServiceMutation({
res,
ctx,
run: async (service) => await service.deleteProfile(name),
});
});
await withProfilesServiceMutation({
res,
ctx,
run: async (service) => await service.deleteProfile(name),
});
}),
);
}

View File

@@ -6,35 +6,44 @@ let createBrowserRouteDispatcher: typeof import("./dispatcher.js").createBrowser
describe("browser route dispatcher (abort)", () => {
beforeAll(async () => {
vi.doMock("./index.js", () => {
const asyncRoute = <Req, Res>(
handler: (req: Req, res: Res) => void | Promise<void>,
): ((req: Req, res: Res) => void | Promise<void>) => {
return (req, res) => handler(req, res);
};
return {
registerBrowserRoutes(app: { get: (path: string, handler: unknown) => void }) {
app.get(
"/slow",
async (req: { signal?: AbortSignal }, res: { json: (body: unknown) => void }) => {
const signal = req.signal;
await new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason ?? new Error("aborted"));
return;
}
const onAbort = () => reject(signal?.reason ?? new Error("aborted"));
signal?.addEventListener("abort", onAbort, { once: true });
queueMicrotask(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
asyncRoute(
async (req: { signal?: AbortSignal }, res: { json: (body: unknown) => void }) => {
const signal = req.signal;
await new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason ?? new Error("aborted"));
return;
}
const onAbort = () => reject(signal?.reason ?? new Error("aborted"));
signal?.addEventListener("abort", onAbort, { once: true });
queueMicrotask(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
});
});
});
res.json({ ok: true });
},
res.json({ ok: true });
},
),
);
app.get(
"/echo/:id",
async (
req: { params?: Record<string, string> },
res: { json: (body: unknown) => void },
) => {
res.json({ id: req.params?.id ?? null });
},
asyncRoute(
(
req: { params?: Record<string, string> },
res: { json: (body: unknown) => void },
) => {
res.json({ id: req.params?.id ?? null });
},
),
);
},
};

View File

@@ -11,7 +11,13 @@ import {
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import { resolveTargetIdFromTabs } from "../target-id.js";
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
import {
asyncBrowserRoute,
getProfileContext,
jsonError,
toNumber,
toStringOrEmpty,
} from "./utils.js";
function resolveTabsProfileContext(
req: BrowserRequest,
@@ -138,165 +144,180 @@ async function runTabTargetMutation(params: {
}
export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
app.get("/tabs", async (req, res) => {
await withTabsProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
const reachable = await profileCtx.isReachable(300);
if (!reachable) {
return res.json({ running: false, tabs: [] as unknown[] });
}
const tabs = await redactBlockedTabUrls({
tabs: await profileCtx.listTabs(),
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
res.json({ running: true, tabs });
},
});
});
app.post("/tabs/open", async (req, res) => {
const url = toStringOrEmpty((req.body as { url?: unknown })?.url);
if (!url) {
return jsonError(res, 400, "url is required");
}
await withTabsProfileRoute({
req,
res,
ctx,
mapTabError: true,
run: async (profileCtx) => {
await assertBrowserNavigationAllowed({
url,
...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy),
});
await profileCtx.ensureBrowserAvailable();
const tab = await profileCtx.openTab(url);
res.json(tab);
},
});
});
app.post("/tabs/focus", async (req, res) => {
const targetId = parseRequiredTargetId(res, (req.body as { targetId?: unknown })?.targetId);
if (!targetId) {
return;
}
await runTabTargetMutation({
req,
res,
ctx,
targetId,
mutate: async (profileCtx, id) => {
const tabs = await profileCtx.listTabs();
const resolved = resolveTargetIdFromTabs(id, tabs);
if (!resolved.ok) {
if (resolved.reason === "ambiguous") {
throw new BrowserTargetAmbiguousError();
}
throw new BrowserTabNotFoundError();
}
const tab = tabs.find((currentTab) => currentTab.targetId === resolved.targetId);
if (!tab) {
throw new BrowserTabNotFoundError();
}
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
if (ssrfPolicyOpts.ssrfPolicy) {
await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts,
});
}
await profileCtx.focusTab(resolved.targetId);
},
});
});
app.delete("/tabs/:targetId", async (req, res) => {
const targetId = parseRequiredTargetId(res, req.params.targetId);
if (!targetId) {
return;
}
await runTabTargetMutation({
req,
res,
ctx,
targetId,
mutate: async (profileCtx, id) => {
await profileCtx.closeTab(id);
},
});
});
app.post("/tabs/action", async (req, res) => {
const action = toStringOrEmpty((req.body as { action?: unknown })?.action);
const index = toNumber((req.body as { index?: unknown })?.index);
await withTabsProfileRoute({
req,
res,
ctx,
mapTabError: true,
run: async (profileCtx) => {
if (action === "list") {
app.get(
"/tabs",
asyncBrowserRoute(async (req, res) => {
await withTabsProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
const reachable = await profileCtx.isReachable(300);
if (!reachable) {
return res.json({ ok: true, tabs: [] as unknown[] });
return res.json({ running: false, tabs: [] as unknown[] });
}
const tabs = await redactBlockedTabUrls({
tabs: await profileCtx.listTabs(),
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
return res.json({ ok: true, tabs });
}
res.json({ running: true, tabs });
},
});
}),
);
if (action === "new") {
app.post(
"/tabs/open",
asyncBrowserRoute(async (req, res) => {
const url = toStringOrEmpty((req.body as { url?: unknown })?.url);
if (!url) {
return jsonError(res, 400, "url is required");
}
await withTabsProfileRoute({
req,
res,
ctx,
mapTabError: true,
run: async (profileCtx) => {
await assertBrowserNavigationAllowed({
url,
...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy),
});
await profileCtx.ensureBrowserAvailable();
const tab = await profileCtx.openTab("about:blank");
return res.json({ ok: true, tab });
}
const tab = await profileCtx.openTab(url);
res.json(tab);
},
});
}),
);
if (action === "close") {
if (!(await ensureBrowserRunning(profileCtx, res))) {
return;
}
app.post(
"/tabs/focus",
asyncBrowserRoute(async (req, res) => {
const targetId = parseRequiredTargetId(res, (req.body as { targetId?: unknown })?.targetId);
if (!targetId) {
return;
}
await runTabTargetMutation({
req,
res,
ctx,
targetId,
mutate: async (profileCtx, id) => {
const tabs = await profileCtx.listTabs();
const target = resolveIndexedTab(tabs, index);
if (!target) {
const resolved = resolveTargetIdFromTabs(id, tabs);
if (!resolved.ok) {
if (resolved.reason === "ambiguous") {
throw new BrowserTargetAmbiguousError();
}
throw new BrowserTabNotFoundError();
}
await profileCtx.closeTab(target.targetId);
return res.json({ ok: true, targetId: target.targetId });
}
if (action === "select") {
if (typeof index !== "number") {
return jsonError(res, 400, "index is required");
}
if (!(await ensureBrowserRunning(profileCtx, res))) {
return;
}
const tabs = await profileCtx.listTabs();
const target = tabs[index];
if (!target) {
const tab = tabs.find((currentTab) => currentTab.targetId === resolved.targetId);
if (!tab) {
throw new BrowserTabNotFoundError();
}
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
if (ssrfPolicyOpts.ssrfPolicy) {
await assertBrowserNavigationResultAllowed({
url: target.url,
url: tab.url,
...ssrfPolicyOpts,
});
}
await profileCtx.focusTab(target.targetId);
return res.json({ ok: true, targetId: target.targetId });
}
await profileCtx.focusTab(resolved.targetId);
},
});
}),
);
return jsonError(res, 400, "unknown tab action");
},
});
});
app.delete(
"/tabs/:targetId",
asyncBrowserRoute(async (req, res) => {
const targetId = parseRequiredTargetId(res, req.params.targetId);
if (!targetId) {
return;
}
await runTabTargetMutation({
req,
res,
ctx,
targetId,
mutate: async (profileCtx, id) => {
await profileCtx.closeTab(id);
},
});
}),
);
app.post(
"/tabs/action",
asyncBrowserRoute(async (req, res) => {
const action = toStringOrEmpty((req.body as { action?: unknown })?.action);
const index = toNumber((req.body as { index?: unknown })?.index);
await withTabsProfileRoute({
req,
res,
ctx,
mapTabError: true,
run: async (profileCtx) => {
if (action === "list") {
const reachable = await profileCtx.isReachable(300);
if (!reachable) {
return res.json({ ok: true, tabs: [] as unknown[] });
}
const tabs = await redactBlockedTabUrls({
tabs: await profileCtx.listTabs(),
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
return res.json({ ok: true, tabs });
}
if (action === "new") {
await profileCtx.ensureBrowserAvailable();
const tab = await profileCtx.openTab("about:blank");
return res.json({ ok: true, tab });
}
if (action === "close") {
if (!(await ensureBrowserRunning(profileCtx, res))) {
return;
}
const tabs = await profileCtx.listTabs();
const target = resolveIndexedTab(tabs, index);
if (!target) {
throw new BrowserTabNotFoundError();
}
await profileCtx.closeTab(target.targetId);
return res.json({ ok: true, targetId: target.targetId });
}
if (action === "select") {
if (typeof index !== "number") {
return jsonError(res, 400, "index is required");
}
if (!(await ensureBrowserRunning(profileCtx, res))) {
return;
}
const tabs = await profileCtx.listTabs();
const target = tabs[index];
if (!target) {
throw new BrowserTabNotFoundError();
}
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
if (ssrfPolicyOpts.ssrfPolicy) {
await assertBrowserNavigationResultAllowed({
url: target.url,
...ssrfPolicyOpts,
});
}
await profileCtx.focusTab(target.targetId);
return res.json({ ok: true, targetId: target.targetId });
}
return jsonError(res, 400, "unknown tab action");
},
});
}),
);
}

View File

@@ -1,7 +1,11 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { parseBooleanValue } from "../../utils/boolean.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import type { BrowserRequest, BrowserResponse } from "./types.js";
import type { BrowserRequest, BrowserResponse, BrowserRouteHandler } from "./types.js";
export function asyncBrowserRoute(handler: BrowserRouteHandler): BrowserRouteHandler {
return (req, res) => handler(req, res);
}
/**
* Extract profile name from query string or body and get profile context.

View File

@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import type { Server } from "node:http";
import express, { type Express } from "express";
import express, { type Express, type RequestHandler } from "express";
import { danger } from "../globals.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { detectMime } from "./mime.js";
@@ -17,6 +17,12 @@ const MAX_MEDIA_ID_CHARS = 200;
const MEDIA_ID_PATTERN = /^[\p{L}\p{N}._-]+$/u;
const MAX_MEDIA_BYTES = MEDIA_MAX_BYTES;
function asyncMediaRoute(handler: RequestHandler): RequestHandler {
return (req, res, next) => {
Promise.resolve(handler(req, res, next)).catch(next);
};
}
const isValidMediaId = (id: string) => {
if (!id) {
return false;
@@ -37,67 +43,70 @@ export function attachMediaRoutes(
) {
const mediaDir = getMediaDir();
app.get("/media/:id", async (req, res) => {
res.setHeader("X-Content-Type-Options", "nosniff");
const id = req.params.id;
if (!isValidMediaId(id)) {
res.status(400).send("invalid path");
return;
}
try {
const {
buffer: data,
realPath,
stat,
} = await readFileWithinRoot({
rootDir: mediaDir,
relativePath: id,
maxBytes: MAX_MEDIA_BYTES,
});
if (Date.now() - stat.mtimeMs > ttlMs) {
await fs.rm(realPath).catch(() => {});
res.status(410).send("expired");
app.get(
"/media/:id",
asyncMediaRoute(async (req, res) => {
res.setHeader("X-Content-Type-Options", "nosniff");
const id = typeof req.params.id === "string" ? req.params.id : "";
if (!isValidMediaId(id)) {
res.status(400).send("invalid path");
return;
}
const mime = await detectMime({ buffer: data, filePath: realPath });
if (mime) {
res.type(mime);
try {
const {
buffer: data,
realPath,
stat,
} = await readFileWithinRoot({
rootDir: mediaDir,
relativePath: id,
maxBytes: MAX_MEDIA_BYTES,
});
if (Date.now() - stat.mtimeMs > ttlMs) {
await fs.rm(realPath).catch(() => {});
res.status(410).send("expired");
return;
}
const mime = await detectMime({ buffer: data, filePath: realPath });
if (mime) {
res.type(mime);
}
res.send(data);
// best-effort single-use cleanup after response ends
res.on("finish", () => {
const cleanup = () => {
void fs.rm(realPath).catch(() => {});
};
// Tests should not pay for time-based cleanup delays.
if (process.env.VITEST || process.env.NODE_ENV === "test") {
queueMicrotask(cleanup);
return;
}
setTimeout(cleanup, 50);
});
} catch (err) {
if (isSafeOpenError(err)) {
if (err.code === "outside-workspace") {
res.status(400).send("file is outside workspace root");
return;
}
if (err.code === "invalid-path") {
res.status(400).send("invalid path");
return;
}
if (err.code === "not-found") {
res.status(404).send("not found");
return;
}
if (err.code === "too-large") {
res.status(413).send("too large");
return;
}
}
res.status(404).send("not found");
}
res.send(data);
// best-effort single-use cleanup after response ends
res.on("finish", () => {
const cleanup = () => {
void fs.rm(realPath).catch(() => {});
};
// Tests should not pay for time-based cleanup delays.
if (process.env.VITEST || process.env.NODE_ENV === "test") {
queueMicrotask(cleanup);
return;
}
setTimeout(cleanup, 50);
});
} catch (err) {
if (isSafeOpenError(err)) {
if (err.code === "outside-workspace") {
res.status(400).send("file is outside workspace root");
return;
}
if (err.code === "invalid-path") {
res.status(400).send("invalid path");
return;
}
if (err.code === "not-found") {
res.status(404).send("not found");
return;
}
if (err.code === "too-large") {
res.status(413).send("too large");
return;
}
}
res.status(404).send("not found");
}
});
}),
);
// periodic cleanup
setInterval(() => {