From cc919db83b838632f4725b96d3e57349a8367e0b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 19:13:23 +0100 Subject: [PATCH] chore(lint): enable async endpoint handler rule --- .oxlintrc.json | 2 +- .../src/browser/routes/agent.act.download.ts | 170 ++-- .../src/browser/routes/agent.act.hooks.ts | 255 +++--- .../browser/src/browser/routes/agent.act.ts | 685 ++++++++-------- .../browser/src/browser/routes/agent.debug.ts | 255 +++--- .../src/browser/routes/agent.snapshot.ts | 662 +++++++-------- .../src/browser/routes/agent.storage.ts | 776 +++++++++--------- .../browser/src/browser/routes/basic.ts | 329 ++++---- .../browser/routes/dispatcher.abort.test.ts | 51 +- extensions/browser/src/browser/routes/tabs.ts | 301 +++---- .../browser/src/browser/routes/utils.ts | 6 +- src/media/server.ts | 127 +-- 12 files changed, 1890 insertions(+), 1729 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index c8cb7be4699..82006eecb10 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -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", diff --git a/extensions/browser/src/browser/routes/agent.act.download.ts b/extensions/browser/src/browser/routes/agent.act.download.ts index 92f1ee589f3..e186683d053 100644 --- a/extensions/browser/src/browser/routes/agent.act.download.ts +++ b/extensions/browser/src/browser/routes/agent.act.download.ts @@ -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 }); + }, + }); + }), + ); } diff --git a/extensions/browser/src/browser/routes/agent.act.hooks.ts b/extensions/browser/src/browser/routes/agent.act.hooks.ts index 3c2d310f333..6b6e0b1e392 100644 --- a/extensions/browser/src/browser/routes/agent.act.hooks.ts +++ b/extensions/browser/src/browser/routes/agent.act.hooks.ts @@ -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 }); + }, + }); + }), + ); } diff --git a/extensions/browser/src/browser/routes/agent.act.ts b/extensions/browser/src/browser/routes/agent.act.ts index d174f5f753e..ceda4ceeb9c 100644 --- a/extensions/browser/src/browser/routes/agent.act.ts +++ b/extensions/browser/src/browser/routes/agent.act.ts @@ -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 { 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(); - 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(); + 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 }); + }, + }); + }), + ); } diff --git a/extensions/browser/src/browser/routes/agent.debug.ts b/extensions/browser/src/browser/routes/agent.debug.ts index 534e581428a..4337ad1e060 100644 --- a/extensions/browser/src/browser/routes/agent.debug.ts +++ b/extensions/browser/src/browser/routes/agent.debug.ts @@ -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), + }); + }, + }); + }), + ); } diff --git a/extensions/browser/src/browser/routes/agent.snapshot.ts b/extensions/browser/src/browser/routes/agent.snapshot.ts index 5a23a19b382..104c4783a60 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.ts @@ -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); - } - }); + }), + ); } diff --git a/extensions/browser/src/browser/routes/agent.storage.ts b/extensions/browser/src/browser/routes/agent.storage.ts index a4427c16513..94c23c24adb 100644 --- a/extensions/browser/src/browser/routes/agent.storage.ts +++ b/extensions/browser/src/browser/routes/agent.storage.ts @@ -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) - : 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) - : null; - if (!headers) { - return jsonError(res, 400, "headers is required"); - } - - const parsed: Record = {}; - 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) + : 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) + : 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 = {}; + 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 }); + }, + }); + }), + ); } diff --git a/extensions/browser/src/browser/routes/basic.ts b/extensions/browser/src/browser/routes/basic.ts index b781bc62694..a913d980a43 100644 --- a/extensions/browser/src/browser/routes/basic.ts +++ b/extensions/browser/src/browser/routes/basic.ts @@ -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; - try { - current = ctx.state(); - } catch { - return jsonError(res, 503, "browser server not started"); - } + app.get( + "/", + asyncBrowserRoute(async (req, res) => { + let current: ReturnType; + 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), + }); + }), + ); } diff --git a/extensions/browser/src/browser/routes/dispatcher.abort.test.ts b/extensions/browser/src/browser/routes/dispatcher.abort.test.ts index b67c1cb0197..775ff0ccc56 100644 --- a/extensions/browser/src/browser/routes/dispatcher.abort.test.ts +++ b/extensions/browser/src/browser/routes/dispatcher.abort.test.ts @@ -6,35 +6,44 @@ let createBrowserRouteDispatcher: typeof import("./dispatcher.js").createBrowser describe("browser route dispatcher (abort)", () => { beforeAll(async () => { vi.doMock("./index.js", () => { + const asyncRoute = ( + handler: (req: Req, res: Res) => void | Promise, + ): ((req: Req, res: Res) => void | Promise) => { + 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((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((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 }, - res: { json: (body: unknown) => void }, - ) => { - res.json({ id: req.params?.id ?? null }); - }, + asyncRoute( + ( + req: { params?: Record }, + res: { json: (body: unknown) => void }, + ) => { + res.json({ id: req.params?.id ?? null }); + }, + ), ); }, }; diff --git a/extensions/browser/src/browser/routes/tabs.ts b/extensions/browser/src/browser/routes/tabs.ts index a190bfaaf4f..2b4894b3633 100644 --- a/extensions/browser/src/browser/routes/tabs.ts +++ b/extensions/browser/src/browser/routes/tabs.ts @@ -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"); + }, + }); + }), + ); } diff --git a/extensions/browser/src/browser/routes/utils.ts b/extensions/browser/src/browser/routes/utils.ts index 89e7244a986..83cee88b21e 100644 --- a/extensions/browser/src/browser/routes/utils.ts +++ b/extensions/browser/src/browser/routes/utils.ts @@ -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. diff --git a/src/media/server.ts b/src/media/server.ts index b7cbc60498c..d4126624a2a 100644 --- a/src/media/server.ts +++ b/src/media/server.ts @@ -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(() => {