mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
chore(lint): enable async endpoint handler rule
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user