From 999da6083ca7d79fe68a3dbf43f902bd2592480a Mon Sep 17 00:00:00 2001 From: hp0912 <809211365@qq.com> Date: Thu, 27 Aug 2026 22:44:07 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=BD=91=E9=A1=B5?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E6=8A=80=E8=83=BD=E5=AE=89=E5=85=A8=E6=80=A7?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/web-page/SKILL.md | 2 +- skills/web-page/scripts/package.json | 4 + skills/web-page/scripts/tsconfig.json | 1 + skills/web-page/scripts/web_page.test.ts | 181 +++++++++ skills/web-page/scripts/web_page.ts | 488 +++++++++++++++++++++-- 5 files changed, 649 insertions(+), 27 deletions(-) create mode 100644 skills/web-page/scripts/web_page.test.ts diff --git a/skills/web-page/SKILL.md b/skills/web-page/SKILL.md index 9e931eb..d94b560 100644 --- a/skills/web-page/SKILL.md +++ b/skills/web-page/SKILL.md @@ -7,7 +7,7 @@ description: "网页内容读取、自动化交互和截图工具。当用户提 ## 描述 -这是一个本地网页读取、自动化交互和截图技能。它使用基础镜像中的 Chromium 以 headless 模式打开网页,通过 Chrome DevTools Protocol 在本地完成页面渲染、点击、输入、表单操作、正文抽取和截图,不调用外部 AI 接口。 +这是一个网页读取、自动化交互和截图技能。它使用基础镜像中的 Chromium 以 headless 模式打开网页,通过 Chrome DevTools Protocol 完成页面渲染、点击、输入、表单操作、正文抽取和截图,不调用外部 AI 接口。浏览器禁止访问 `file:` 和 `filesystem:` 本地文件地址。 技能脚本位于 `scripts/web_page.ts`,依赖基础镜像提供的 Node.js 24+ 和全局安装的 `tsx`(用于直接运行 TypeScript)以及 Chromium。基础镜像中已配置 `CHROME_BIN=/usr/bin/chromium` 和 `CHROME_PATH=/usr/bin/chromium` 时,无需额外安装浏览器。 diff --git a/skills/web-page/scripts/package.json b/skills/web-page/scripts/package.json index 521e6a9..48cfb38 100644 --- a/skills/web-page/scripts/package.json +++ b/skills/web-page/scripts/package.json @@ -2,6 +2,10 @@ "name": "web-page-skill", "private": true, "type": "module", + "scripts": { + "test": "node --experimental-strip-types --test web_page.test.ts", + "typecheck": "tsc --noEmit" + }, "devDependencies": { "@types/node": "^25.9.1", "tsx": "^4.22.4", diff --git a/skills/web-page/scripts/tsconfig.json b/skills/web-page/scripts/tsconfig.json index 5c43298..22d9c10 100644 --- a/skills/web-page/scripts/tsconfig.json +++ b/skills/web-page/scripts/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "nodenext", "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, "lib": ["ES2022", "DOM"], "types": ["node"], "strict": true, diff --git a/skills/web-page/scripts/web_page.test.ts b/skills/web-page/scripts/web_page.test.ts new file mode 100644 index 0000000..c027787 --- /dev/null +++ b/skills/web-page/scripts/web_page.test.ts @@ -0,0 +1,181 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import http from "node:http"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { isLocalFileUrl, validateUrl } from "./web_page.ts"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const SCRIPT_PATH = path.join(SCRIPT_DIR, "web_page.ts"); +const PASSWD_MARKER = "root:x:0:0"; + +interface ScriptResult { + code: number | null; + stdout: string; + stderr: string; +} + +function runWebPage(url: string, args: string[] = []): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [ + "--experimental-strip-types", + SCRIPT_PATH, + "--url", + url, + "--mode", + "content", + "--wait_ms", + "300", + "--timeout_ms", + "8000", + "--max_chars", + "4000", + ...args, + ], + { + cwd: SCRIPT_DIR, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + child.once("error", reject); + + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`web_page.ts 执行超时\nstdout: ${stdout}\nstderr: ${stderr}`)); + }, 20000); + child.once("close", (code) => { + clearTimeout(timer); + resolve({ code, stdout, stderr }); + }); + }); +} + +function assertLocalFileBlocked(result: ScriptResult): void { + const output = `${result.stdout}\n${result.stderr}`; + assert.notEqual(result.code, 0, output); + assert.match( + output, + /已阻止浏览器|网页链接必须是 http 或 https 地址|网页导航失败/, + ); + assert.doesNotMatch(output, new RegExp(PASSWD_MARKER)); +} + +test("web-page 本地文件访问防护", async (t) => { + const server = http.createServer((request, response) => { + const requestUrl = new URL(request.url || "/", "http://127.0.0.1"); + response.setHeader("Content-Type", "text/html; charset=utf-8"); + + switch (requestUrl.pathname) { + case "/redirect-file": + response.statusCode = 302; + response.setHeader("Location", "file:///etc/passwd"); + response.end(); + return; + case "/click-file": + response.end( + 'click file打开本地文件', + ); + return; + case "/js-location": + response.end( + 'js location

safe

', + ); + return; + case "/iframe-file": + response.end( + 'iframe file

safe

', + ); + return; + case "/popup-file": + response.end( + 'popup file', + ); + return; + case "/redirect-http": + response.statusCode = 302; + response.setHeader("Location", "/search?q=normal-http-redirect"); + response.end(); + return; + case "/search": + response.end( + `search ok
SEARCH_OK:${requestUrl.searchParams.get("q") || ""}
`, + ); + return; + default: + response.statusCode = 404; + response.end("not found"); + } + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => server.close()); + + const address = server.address(); + assert.ok(address && typeof address === "object"); + const baseUrl = `http://127.0.0.1:${address.port}`; + + await t.test("直接访问 file:///etc/passwd", async () => { + assertLocalFileBlocked(await runWebPage("file:///etc/passwd")); + }); + + await t.test("HTTP 302 跳转到 file://", async () => { + assertLocalFileBlocked(await runWebPage(`${baseUrl}/redirect-file`)); + }); + + await t.test("点击 file:// 链接", async () => { + assertLocalFileBlocked( + await runWebPage(`${baseUrl}/click-file`, [ + "--actions", + JSON.stringify([{ type: "click", selector: "#local-file" }]), + ]), + ); + }); + + await t.test("JavaScript 修改 location", async () => { + assertLocalFileBlocked(await runWebPage(`${baseUrl}/js-location`)); + }); + + await t.test("iframe 加载本地文件", async () => { + assertLocalFileBlocked(await runWebPage(`${baseUrl}/iframe-file`)); + }); + + await t.test("弹窗加载本地文件", async () => { + assertLocalFileBlocked( + await runWebPage(`${baseUrl}/popup-file`, [ + "--actions", + JSON.stringify([{ type: "click", selector: "#open-popup" }]), + ]), + ); + }); + + await t.test("正常 HTTP/HTTPS 搜索及浏览不受影响", async () => { + assert.equal( + validateUrl("https://example.com/search?q=normal-https"), + "https://example.com/search?q=normal-https", + ); + assert.equal(isLocalFileUrl("https://example.com/file.txt"), false); + assert.equal(isLocalFileUrl("file:///etc/passwd"), true); + assert.equal(isLocalFileUrl("filesystem:https://example.com/temporary/a"), true); + + const normal = await runWebPage(`${baseUrl}/redirect-http`); + assert.equal(normal.code, 0, `${normal.stdout}\n${normal.stderr}`); + assert.match(normal.stdout, /SEARCH_OK:normal-http-redirect/); + assert.doesNotMatch(normal.stdout, new RegExp(PASSWD_MARKER)); + }); +}); diff --git a/skills/web-page/scripts/web_page.ts b/skills/web-page/scripts/web_page.ts index 1ceeb11..2e61733 100644 --- a/skills/web-page/scripts/web_page.ts +++ b/skills/web-page/scripts/web_page.ts @@ -8,6 +8,7 @@ import os from "node:os"; import path from "node:path"; import { spawn, type ChildProcess } from "node:child_process"; import type { IncomingMessage } from "node:http"; +import { pathToFileURL } from "node:url"; import { parseArgs as parseNodeArgs } from "node:util"; const DEFAULT_VIEWPORT_WIDTH = 1365; @@ -18,6 +19,19 @@ const DEFAULT_MAX_CHARS = 16000; const DEFAULT_ACTION_TIMEOUT_MS = 15000; const DEFAULT_ACTION_WAIT_MS = 300; const DEFAULT_TIMEZONE = "Asia/Shanghai"; +const LOCAL_FILE_PROTOCOLS = new Set(["file:", "filesystem:"]); +const LOCAL_FILE_BLOCK_PATTERNS = ["file://*", "filesystem:*"]; +const PAGE_TARGET_TYPES = new Set([ + "page", + "iframe", + "webview", + "background_page", +]); +const TARGET_AUTO_ATTACH_OPTIONS = { + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: true, +}; const WEIXIN_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.62(0x18003e2f) NetType/WIFI Language/zh_CN"; @@ -124,6 +138,12 @@ interface ElementPoint { x: number; y: number; description: string; + navigationUrl: string; +} + +interface PageSession { + targetId: string; + sessionId: string; } interface ExtractedLink { @@ -371,7 +391,48 @@ function parseActions( ); } -function validateUrl(value: string): string { +function localFileProtocol(value: string): string | null { + let candidate = String(value || "").trim(); + if (!candidate) { + return null; + } + + for (let depth = 0; depth < 4; depth += 1) { + const lowered = candidate.toLowerCase(); + if (lowered.startsWith("view-source:")) { + candidate = candidate.slice("view-source:".length); + continue; + } + if (lowered.startsWith("blob:")) { + candidate = candidate.slice("blob:".length); + continue; + } + try { + const protocol = new URL(candidate).protocol.toLowerCase(); + return LOCAL_FILE_PROTOCOLS.has(protocol) ? protocol : null; + } catch { + return null; + } + } + return null; +} + +function blockedLocalFileUrlFromLogText(value: string): string { + const text = String(value || ""); + if (!/(?:not allowed to load local resource|refused to load)/i.test(text)) { + return ""; + } + const match = text.match( + /(?:view-source:|blob:)*(?:file|filesystem):[^\s"'<>)]*/i, + ); + return match ? match[0] : ""; +} + +export function isLocalFileUrl(value: string): boolean { + return localFileProtocol(value) !== null; +} + +export function validateUrl(value: string): string { if (!value || !value.trim()) { throw new Error("缺少网页链接"); } @@ -387,6 +448,58 @@ function validateUrl(value: string): string { return parsed.toString(); } +class LocalFileAccessGuard { + private blockedError: Error | null = null; + private readonly blockedPromise: Promise; + private resolveBlocked!: (error: Error) => void; + + constructor() { + this.blockedPromise = new Promise((resolve) => { + this.resolveBlocked = resolve; + }); + } + + block(url: string, source: string): boolean { + const protocol = localFileProtocol(url); + if (!protocol) { + return false; + } + if (!this.blockedError) { + this.blockedError = new Error( + `已阻止浏览器通过${source}访问本地文件协议 ${protocol}`, + ); + this.resolveBlocked(this.blockedError); + } + return true; + } + + fail(error: unknown, source: string): void { + if (this.blockedError) { + return; + } + this.blockedError = new Error( + `${source}失败: ${errorMessage(error)}`, + ); + this.resolveBlocked(this.blockedError); + } + + throwIfBlocked(): void { + if (this.blockedError) { + throw this.blockedError; + } + } + + async race(operation: Promise): Promise { + this.throwIfBlocked(); + const result = await Promise.race([ + operation, + this.blockedPromise.then((error) => Promise.reject(error)), + ]); + this.throwIfBlocked(); + return result; + } +} + function shouldUseWeixinUserAgent(url: string): boolean { try { const hostname = new URL(url).hostname.toLowerCase(); @@ -686,6 +799,15 @@ interface CdpEventListener { resolve: (params: Record) => void; } +interface CdpEventHandler { + method: string; + sessionId?: string; + handle: ( + params: Record, + sessionId: string | undefined, + ) => void | Promise; +} + interface CdpMessage { id?: number; method?: string; @@ -700,6 +822,7 @@ class CdpClient { private nextId: number; private readonly pending: Map; private listeners: CdpEventListener[]; + private handlers: CdpEventHandler[]; private ws: WebSocket | null; constructor(websocketUrl: string) { @@ -707,6 +830,7 @@ class CdpClient { this.nextId = 1; this.pending = new Map(); this.listeners = []; + this.handlers = []; this.ws = null; } @@ -783,6 +907,25 @@ class CdpClient { listener.resolve(message.params || {}); this.listeners = this.listeners.filter((item) => item !== listener); } + for (const handler of [...this.handlers]) { + if (handler.method !== message.method) { + continue; + } + if (handler.sessionId && handler.sessionId !== message.sessionId) { + continue; + } + try { + const handled = handler.handle( + message.params || {}, + message.sessionId, + ); + if (handled instanceof Promise) { + void handled.catch(() => undefined); + } + } catch { + // Security-sensitive handlers report failures through their guard. + } + } } } @@ -825,6 +968,18 @@ class CdpClient { ); } + onEvent( + method: string, + handle: CdpEventHandler["handle"], + sessionId?: string, + ): () => void { + const handler: CdpEventHandler = { method, sessionId, handle }; + this.handlers.push(handler); + return () => { + this.handlers = this.handlers.filter((item) => item !== handler); + }; + } + close(): void { if (this.ws) { this.ws.close(); @@ -832,7 +987,247 @@ class CdpClient { } } -async function createPage(client: CdpClient, params: Params): Promise { +function stringField( + value: Record | undefined, + key: string, +): string { + const field = value?.[key]; + return typeof field === "string" ? field : ""; +} + +function headerValue( + headers: Record | undefined, + name: string, +): string { + if (!headers) { + return ""; + } + const normalizedName = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === normalizedName && value !== undefined) { + return String(value); + } + } + return ""; +} + +function inspectRedirectResponse( + rawResponse: unknown, + guard: LocalFileAccessGuard, +): void { + if (!isPlainObject(rawResponse)) { + return; + } + const status = Number(rawResponse.status ?? 0); + if (status < 300 || status >= 400) { + return; + } + const headers = isPlainObject(rawResponse.headers) + ? rawResponse.headers + : undefined; + const location = headerValue(headers, "location"); + if (!location) { + return; + } + const responseUrl = stringField(rawResponse, "url"); + try { + guard.block(new URL(location, responseUrl || undefined).toString(), "HTTP 重定向"); + } catch { + guard.block(location, "HTTP 重定向"); + } +} + +async function installSessionLocalFileGuards( + client: CdpClient, + sessionId: string, + guard: LocalFileAccessGuard, +): Promise { + client.onEvent( + "Page.frameRequestedNavigation", + (params) => { + guard.block(stringField(params, "url"), "页面导航"); + }, + sessionId, + ); + client.onEvent( + "Page.frameStartedNavigating", + (params) => { + guard.block(stringField(params, "url"), "页面导航"); + }, + sessionId, + ); + client.onEvent( + "Page.frameNavigated", + (params) => { + if (!isPlainObject(params.frame)) { + return; + } + guard.block(stringField(params.frame, "url"), "页面导航"); + guard.block(stringField(params.frame, "unreachableUrl"), "页面导航"); + }, + sessionId, + ); + client.onEvent( + "Page.windowOpen", + (params) => { + guard.block(stringField(params, "url"), "弹窗"); + }, + sessionId, + ); + client.onEvent( + "Log.entryAdded", + (params) => { + const entry = isPlainObject(params.entry) ? params.entry : undefined; + guard.block(stringField(entry, "url"), "浏览器安全日志"); + guard.block( + blockedLocalFileUrlFromLogText(stringField(entry, "text")), + "浏览器安全日志", + ); + }, + sessionId, + ); + client.onEvent( + "Network.requestWillBeSent", + (params) => { + if (isPlainObject(params.request)) { + guard.block(stringField(params.request, "url"), "网络请求"); + } + inspectRedirectResponse(params.redirectResponse, guard); + }, + sessionId, + ); + client.onEvent( + "Network.responseReceived", + (params) => { + inspectRedirectResponse(params.response, guard); + }, + sessionId, + ); + client.onEvent( + "Fetch.requestPaused", + (params) => { + void (async () => { + const requestId = stringField(params, "requestId"); + const request = isPlainObject(params.request) + ? params.request + : undefined; + const requestUrl = stringField(request, "url"); + if (guard.block(requestUrl, "网络请求")) { + await client.send( + "Fetch.failRequest", + { requestId, errorReason: "BlockedByClient" }, + sessionId, + ); + return; + } + await client.send("Fetch.continueRequest", { requestId }, sessionId); + })().catch((error) => guard.fail(error, "本地文件请求拦截")); + }, + sessionId, + ); + + await client.send("Page.enable", {}, sessionId); + await client.send("Network.enable", {}, sessionId); + await client.send("Log.enable", {}, sessionId); + await client.send( + "Network.setBlockedURLs", + { urls: LOCAL_FILE_BLOCK_PATTERNS }, + sessionId, + ); + await client.send( + "Fetch.enable", + { + patterns: LOCAL_FILE_BLOCK_PATTERNS.map((urlPattern) => ({ + urlPattern, + requestStage: "Request", + })), + }, + sessionId, + ); +} + +async function enableNewTargetLocalFileGuards( + client: CdpClient, + primaryPage: PageSession, + guard: LocalFileAccessGuard, +): Promise { + const guardedTargetIds = new Set([primaryPage.targetId]); + + const closeBlockedTarget = (targetId: string): void => { + if (!targetId) { + return; + } + void client + .send("Target.closeTarget", { targetId }) + .catch(() => undefined); + }; + + const inspectTargetInfo = (rawTargetInfo: unknown): boolean => { + if (!isPlainObject(rawTargetInfo)) { + return false; + } + const targetUrl = stringField(rawTargetInfo, "url"); + if (!guard.block(targetUrl, "新页面")) { + return false; + } + closeBlockedTarget(stringField(rawTargetInfo, "targetId")); + return true; + }; + + client.onEvent("Target.targetCreated", (params) => { + inspectTargetInfo(params.targetInfo); + }); + client.onEvent("Target.targetInfoChanged", (params) => { + inspectTargetInfo(params.targetInfo); + }); + client.onEvent("Target.attachedToTarget", (params) => { + void (async () => { + const sessionId = stringField(params, "sessionId"); + const targetInfo = isPlainObject(params.targetInfo) + ? params.targetInfo + : undefined; + const targetId = stringField(targetInfo, "targetId"); + const targetType = stringField(targetInfo, "type"); + const waitingForDebugger = Boolean(params.waitingForDebugger); + + if (!sessionId) { + throw new Error("新页面缺少 CDP sessionId"); + } + if (inspectTargetInfo(targetInfo)) { + return; + } + if ( + PAGE_TARGET_TYPES.has(targetType) && + !guardedTargetIds.has(targetId) + ) { + guardedTargetIds.add(targetId); + await installSessionLocalFileGuards(client, sessionId, guard); + } + await client.send( + "Target.setAutoAttach", + TARGET_AUTO_ATTACH_OPTIONS, + sessionId, + ); + if (waitingForDebugger) { + await client.send("Runtime.runIfWaitingForDebugger", {}, sessionId); + } + })().catch((error) => guard.fail(error, "新页面本地文件防护")); + }); + + await client.send("Target.setDiscoverTargets", { discover: true }); + await client.send("Target.setAutoAttach", TARGET_AUTO_ATTACH_OPTIONS); + await client.send( + "Target.setAutoAttach", + TARGET_AUTO_ATTACH_OPTIONS, + primaryPage.sessionId, + ); +} + +async function createPage( + client: CdpClient, + params: Params, + guard: LocalFileAccessGuard, +): Promise { const target = await client.send<{ targetId: string }>( "Target.createTarget", { @@ -847,9 +1242,8 @@ async function createPage(client: CdpClient, params: Params): Promise { }, ); const sessionId = attached.sessionId; - await client.send("Page.enable", {}, sessionId); + await installSessionLocalFileGuards(client, sessionId, guard); await client.send("Runtime.enable", {}, sessionId); - await client.send("Network.enable", {}, sessionId); if (shouldUseWeixinUserAgent(params.url)) { await client.send( "Emulation.setUserAgentOverride", @@ -920,22 +1314,33 @@ window.navigator.permissions.query = (params) => }, sessionId, ); - return sessionId; + return { targetId: target.targetId, sessionId }; } async function navigate( client: CdpClient, sessionId: string, params: Params, + guard: LocalFileAccessGuard, ): Promise { const loadEvent = client .waitForEvent("Page.loadEventFired", sessionId, params.timeoutMs) .catch(() => null); - await client.send("Page.navigate", { url: params.url }, sessionId); - await loadEvent; - if (params.waitMs > 0) { - await wait(params.waitMs); + const navigation = await guard.race( + client.send<{ errorText?: string }>( + "Page.navigate", + { url: params.url }, + sessionId, + ), + ); + if (navigation.errorText) { + throw new Error(`网页导航失败: ${navigation.errorText}`); } + await guard.race(loadEvent); + if (params.waitMs > 0) { + await guard.race(wait(params.waitMs)); + } + guard.throwIfBlocked(); } interface RuntimeEvaluateResult { @@ -1005,10 +1410,12 @@ async function getElementPoint( el.scrollIntoView({ block: 'center', inline: 'center' }); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const rect = el.getBoundingClientRect(); + const anchor = el.closest('a[href]'); return { x: Math.max(0, Math.min(window.innerWidth - 1, rect.left + rect.width / 2)), y: Math.max(0, Math.min(window.innerHeight - 1, rect.top + rect.height / 2)), description: action.selector || action.text || textOf(el), + navigationUrl: anchor ? anchor.href : '', }; })()`, ); @@ -1023,8 +1430,12 @@ async function clickElement( client: CdpClient, sessionId: string, action: NormalizedAction, + guard: LocalFileAccessGuard, ): Promise { const point = await getElementPoint(client, sessionId, action); + if (guard.block(point.navigationUrl, "点击链接")) { + guard.throwIfBlocked(); + } const clickCount = Math.max( 1, parseInteger(action.click_count, "click_count", 1), @@ -1380,10 +1791,11 @@ async function runSingleAction( client: CdpClient, sessionId: string, action: NormalizedAction, + guard: LocalFileAccessGuard, ): Promise { switch (action.type) { case "click": - await clickElement(client, sessionId, action); + await clickElement(client, sessionId, action, guard); return; case "fill": await fillElement(client, sessionId, action); @@ -1427,8 +1839,10 @@ async function runActions( client: CdpClient, sessionId: string, params: Params, + guard: LocalFileAccessGuard, ): Promise { if (!params.actions.length) { + guard.throwIfBlocked(); return; } @@ -1440,17 +1854,20 @@ async function runActions( .waitForEvent("Page.loadEventFired", sessionId, action.timeoutMs) .catch(() => null) : null; - await withTimeout( - runSingleAction(client, sessionId, action), - action.timeoutMs, - `执行 action 超时: ${action.type}`, + await guard.race( + withTimeout( + runSingleAction(client, sessionId, action, guard), + action.timeoutMs, + `执行 action 超时: ${action.type}`, + ), ); if (loadEvent) { - await loadEvent; + await guard.race(loadEvent); } if (action.waitMsAfter > 0) { - await wait(action.waitMsAfter); + await guard.race(wait(action.waitMsAfter)); } + guard.throwIfBlocked(); } catch (error) { throw new Error( `第 ${index + 1} 个 action(${action.type}) 执行失败: ${errorMessage(error)}`, @@ -1774,18 +2191,28 @@ async function run(): Promise { const params = normalizeParams(parseCliArgs(process.argv.slice(2))); const browser = await launchChromium(params); const client = new CdpClient(browser.websocketUrl); + const guard = new LocalFileAccessGuard(); try { await client.connect(params.timeoutMs); - const sessionId = await createPage(client, params); - await navigate(client, sessionId, params); - await runActions(client, sessionId, params); + const page = await createPage(client, params, guard); + await enableNewTargetLocalFileGuards(client, page, guard); + await navigate(client, page.sessionId, params, guard); + await runActions(client, page.sessionId, params, guard); + guard.throwIfBlocked(); if (params.mode === "content") { - stdout(await extractContent(client, sessionId, params)); + const content = await guard.race( + extractContent(client, page.sessionId, params), + ); + guard.throwIfBlocked(); + stdout(content); return; } - const screenshot = await captureScreenshot(client, sessionId, params); + const screenshot = await guard.race( + captureScreenshot(client, page.sessionId, params), + ); + guard.throwIfBlocked(); const sent = await maybeSendScreenshot(params, screenshot.output); if (sent) { const suffix = screenshot.clip.truncated @@ -1804,9 +2231,18 @@ async function run(): Promise { } } -run().catch((error: unknown) => { - stdout( - `执行失败: ${error instanceof Error && error.stack ? error.stack : String(error)}`, +function isMainModule(): boolean { + const entry = process.argv[1]; + return Boolean( + entry && import.meta.url === pathToFileURL(path.resolve(entry)).href, ); - process.exit(1); -}); +} + +if (isMainModule()) { + run().catch((error: unknown) => { + stdout( + `执行失败: ${error instanceof Error && error.stack ? error.stack : String(error)}`, + ); + process.exit(1); + }); +}