fix: 修复网页读取技能安全性问题

This commit is contained in:
hp0912 2026-08-27 22:44:07 +08:00
parent 43c8637326
commit 999da6083c
5 changed files with 649 additions and 27 deletions

View File

@ -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` 时,无需额外安装浏览器。

View File

@ -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",

View File

@ -3,6 +3,7 @@
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"allowImportingTsExtensions": true,
"lib": ["ES2022", "DOM"],
"types": ["node"],
"strict": true,

View File

@ -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<ScriptResult> {
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(
'<!doctype html><title>click file</title><a id="local-file" href="file:///etc/passwd">打开本地文件</a>',
);
return;
case "/js-location":
response.end(
'<!doctype html><title>js location</title><p>safe</p><script>setTimeout(() => { location.href = "file:///etc/passwd"; }, 50);</script>',
);
return;
case "/iframe-file":
response.end(
'<!doctype html><title>iframe file</title><p>safe</p><iframe src="file:///etc/passwd"></iframe>',
);
return;
case "/popup-file":
response.end(
'<!doctype html><title>popup file</title><button id="open-popup" onclick="window.open(\'file:///etc/passwd\', \'_blank\')">打开弹窗</button>',
);
return;
case "/redirect-http":
response.statusCode = 302;
response.setHeader("Location", "/search?q=normal-http-redirect");
response.end();
return;
case "/search":
response.end(
`<!doctype html><title>search ok</title><main>SEARCH_OK:${requestUrl.searchParams.get("q") || ""}</main><script>console.log("file:///not-an-access-attempt")</script>`,
);
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));
});
});

View File

@ -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<Error>;
private resolveBlocked!: (error: Error) => void;
constructor() {
this.blockedPromise = new Promise<Error>((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<T>(operation: Promise<T>): Promise<T> {
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<string, unknown>) => void;
}
interface CdpEventHandler {
method: string;
sessionId?: string;
handle: (
params: Record<string, unknown>,
sessionId: string | undefined,
) => void | Promise<void>;
}
interface CdpMessage {
id?: number;
method?: string;
@ -700,6 +822,7 @@ class CdpClient {
private nextId: number;
private readonly pending: Map<number, PendingRequest>;
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<string> {
function stringField(
value: Record<string, unknown> | undefined,
key: string,
): string {
const field = value?.[key];
return typeof field === "string" ? field : "";
}
function headerValue(
headers: Record<string, unknown> | 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<void> {
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<void> {
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<PageSession> {
const target = await client.send<{ targetId: string }>(
"Target.createTarget",
{
@ -847,9 +1242,8 @@ async function createPage(client: CdpClient, params: Params): Promise<string> {
},
);
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
}
}
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);
});
}