diff --git a/src/app/repo/logger.test.ts b/src/app/repo/logger.test.ts new file mode 100644 index 000000000..406b15cdc --- /dev/null +++ b/src/app/repo/logger.test.ts @@ -0,0 +1,63 @@ +import Dexie from "dexie"; +import { IDBFactory, IDBKeyRange } from "fake-indexeddb"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type migrateType from "../migrate"; +import type { Logger, LoggerDAO as LoggerDAOType } from "./logger"; + +let dao: LoggerDAOType; + +beforeAll(async () => { + // dao.ts 在模块求值时创建 Dexie 单例并取走当时的 IndexedDB 来源:先换成独立的 fake-indexeddb 再重新加载 + // 模块(本文件在 isolated 项目中运行,替换不会泄漏给其它文件),并用迁移声明的真实表结构打开数据库。 + // default 导出的类型断言沿用 migrate.test.ts 的做法。 + Dexie.dependencies.indexedDB = new IDBFactory(); + Dexie.dependencies.IDBKeyRange = IDBKeyRange; + vi.resetModules(); + const migrateModule = await import("../migrate.js"); + await (migrateModule.default as unknown as typeof migrateType)(); + const { LoggerDAO } = await import("./logger.js"); + dao = new LoggerDAO(); +}); + +beforeEach(async () => { + await dao.clear(); +}); + +// 与 DBWriter 写日志的方式一致:id 为 0 时由自增主键分配,写入先后决定 id 大小。 +async function seed(rows: Array>) { + for (const row of rows) { + await dao.save({ id: 0, level: "info", label: {}, ...row }); + } +} + +describe("LoggerDAO.walkNewestFirst", () => { + it("只访问 createtime ≥ since 的记录:从新到旧,同一毫秒内后写入的在前", async () => { + await seed([ + { message: "before since", createtime: 99 }, + { message: "at since", createtime: 100 }, + { message: "same ms first", createtime: 200 }, + { message: "same ms second", createtime: 200 }, + { message: "newest", createtime: 300 }, + ]); + const visited: string[] = []; + + await dao.walkNewestFirst(100, (row) => { + visited.push(row.message); + return true; + }); + + expect(visited).toEqual(["newest", "same ms second", "same ms first", "at since"]); + }); + + it("visit 返回 false 后不再访问更早的记录", async () => { + await seed([1, 2, 3, 4].map((n) => ({ message: `row ${n}`, createtime: n }))); + const visited: string[] = []; + + await dao.walkNewestFirst(0, (row) => { + visited.push(row.message); + return visited.length < 2; + }); + + expect(visited).toEqual(["row 4", "row 3"]); + }); +}); diff --git a/src/app/repo/logger.ts b/src/app/repo/logger.ts index f4fa24649..398802cea 100644 --- a/src/app/repo/logger.ts +++ b/src/app/repo/logger.ts @@ -23,6 +23,22 @@ export class LoggerDAO extends DAO { return ret.sort((a, b) => b.createtime - a.createtime); } + /** + * 沿 createtime 索引从最新往回逐条读取 createtime ≥ since 的日志,同一毫秒内 id 大(后写入)的在前; + * visit 返回 false 时停止读取。按标签筛选的调用方据此只读到凑够结果为止,不把整段日志载入内存。 + */ + async walkNewestFirst(since: number, visit: (row: Logger) => boolean): Promise { + let done = false; + await this.table + .where("createtime") + .aboveOrEqual(since) + .reverse() + .until(() => done) + .each((row) => { + done = !visit(row); + }); + } + deleteBefore(time: number) { return this.table.where("createtime").below(time).delete(); } diff --git a/src/app/service/sandbox/runtime.test.ts b/src/app/service/sandbox/runtime.test.ts index 0e0d0e381..a6d3e5c5a 100644 --- a/src/app/service/sandbox/runtime.test.ts +++ b/src/app/service/sandbox/runtime.test.ts @@ -22,7 +22,12 @@ vi.mock("../content/exec_warp", () => ({ BgExecScriptWarp: function (...args: any[]) { return BgExecScriptWarpCtor(...args); }, - CATRetryError: class CATRetryError {}, + CATRetryError: class CATRetryError { + constructor( + public msg: string, + public time: Date + ) {} + }, })); vi.mock("../offscreen/client", () => ({ @@ -31,6 +36,9 @@ vi.mock("../offscreen/client", () => ({ import { Runtime } from "./runtime"; import { initLanguage } from "@App/locales/locales"; +import { proxyUpdateRunStatus } from "../offscreen/client"; +import { CATRetryError } from "../content/exec_warp"; +import { SCRIPT_RUN_STATUS_COMPLETE } from "@App/app/repo/scripts"; initTestEnv(); // 生产中 sandbox init() 会先 initLanguage(),setSandboxLanguage 依赖已初始化的 i18n @@ -163,3 +171,90 @@ describe("Runtime.executeSkillScript 执行环境", () => { expect(BgExecScriptWarpCtor.mock.calls[0][3]).toBe("pt-BR"); }); }); + +describe("Runtime.execScript 被停止的旧运行迟到结束", () => { + // 每次 exec 返回一个由测试控制结束时机的运行;started[n] 在第 n 次 exec 被调用时兑现。 + let runs: Array<{ resolve: (value: unknown) => void; reject: (reason: unknown) => void }>; + let execCalled: Array<() => void>; + const execStarted = (n: number) => new Promise((resolve) => (execCalled[n] = resolve)); + + beforeEach(() => { + BgExecScriptWarpCtor.mockClear(); + mockStop.mockClear(); + vi.mocked(proxyUpdateRunStatus).mockClear(); + runs = []; + execCalled = []; + mockExec.mockImplementation( + () => + new Promise((resolve, reject) => { + runs.push({ resolve, reject }); + execCalled[runs.length]?.(); + }) + ); + vi.useFakeTimers(); + }); + + afterEach(() => { + mockExec.mockReset().mockResolvedValue(undefined); + vi.useRealTimers(); + }); + + // 返回本次运行使用的 logger,info/error 被调用时兑现对应 Promise,作为旧运行收尾处理已执行完的信号。 + const spyRunLogger = (runtime: Runtime) => { + const runLogger = runtime.logger.with(); + vi.spyOn(runtime.logger, "with").mockReturnValue(runLogger); + let infoLogged!: () => void; + let errorLogged!: () => void; + const info = new Promise((resolve) => (infoLogged = resolve)); + const error = new Promise((resolve) => (errorLogged = resolve)); + vi.spyOn(runLogger, "info").mockImplementation(() => infoLogged()); + vi.spyOn(runLogger, "error").mockImplementation(() => errorLogged()); + return { info, error }; + }; + + // execScript 的返回值就是脚本运行的 Promise(交给消息层答复调用方),返回旧运行的那一个供断言其结局。 + const startTwoRuns = async (runtime: Runtime, script: ScriptLoadInfo) => { + const firstStarted = execStarted(1); + const oldRun = runtime.execScript(script); + await firstStarted; + const secondStarted = execStarted(2); + void runtime.execScript(script); + await secondStarted; + vi.mocked(proxyUpdateRunStatus).mockClear(); + return { oldRun }; + }; + + it("旧运行在新运行开始后才完成:不发布 COMPLETE,新运行结束时照常发布", async () => { + const runtime = setup({ inIncognitoContext: false }); + const logged = spyRunLogger(runtime); + const script = buildScript(); + const { oldRun } = await startTwoRuns(runtime, script); + + runs[0].resolve("old"); + await expect(oldRun).resolves.toBe("old"); + await logged.info; + expect(proxyUpdateRunStatus).not.toHaveBeenCalled(); + + runs[1].resolve("new"); + await vi.waitFor(() => + expect(proxyUpdateRunStatus).toHaveBeenCalledWith(expect.anything(), { + uuid: script.uuid, + runStatus: SCRIPT_RUN_STATUS_COMPLETE, + }) + ); + }); + + it("旧运行在新运行开始后才以 CATRetryError 失败:不发布 ERROR、不加入重试队列", async () => { + const runtime = setup({ inIncognitoContext: false }); + const logged = spyRunLogger(runtime); + const script = buildScript(undefined, SCRIPT_TYPE_CRONTAB); + const { oldRun } = await startTwoRuns(runtime, script); + + runs[0].reject(new CATRetryError("retry later", new Date(Date.now() + 60_000))); + await expect(oldRun).rejects.toBeInstanceOf(CATRetryError); + await logged.error; + + expect(proxyUpdateRunStatus).not.toHaveBeenCalled(); + expect(runtime.retryList).toEqual([]); + }); +}); diff --git a/src/app/service/sandbox/runtime.ts b/src/app/service/sandbox/runtime.ts index f51b64f10..a2b66ba55 100644 --- a/src/app/service/sandbox/runtime.ts +++ b/src/app/service/sandbox/runtime.ts @@ -15,7 +15,7 @@ import { BgExecScriptWarp } from "../content/exec_warp"; import type ExecScript from "../content/exec_script"; import { compileScriptCodeByResource } from "../content/utils"; import type { ValueUpdateDataEncoded } from "../content/types"; -import { getStorageName, getMetadataStr, getUserConfigStr, getISOWeek } from "@App/pkg/utils/utils"; +import { getStorageName, getMetadataStr, getUserConfigStr } from "@App/pkg/utils/utils"; import type { EmitEventRequest, ScriptLoadInfo } from "../service_worker/types"; import { CATRetryError } from "../content/exec_warp"; import { parseUserConfig } from "@App/pkg/utils/yaml"; @@ -23,10 +23,7 @@ import { decodeRValue } from "@App/pkg/utils/message_value"; import { extractCronExpr } from "@App/pkg/utils/cron"; import { changeLanguage, initLanguage, t } from "@App/locales/locales"; import { type TExtensionEnv } from "../extension/extension_env"; - -const utime_1min = 60 * 1000; -const utime_1hr = 60 * 60 * 1000; -const utime_1day = 24 * 60 * 60 * 1000; +import { backgroundRunInAllowed, isOnceRunDone } from "./utils"; export class Runtime { cronJob: Map> = new Map(); @@ -143,33 +140,7 @@ export class Runtime { const execOnce = opts?.execOnce ?? false; const cronJobOncePos = opts?.cronJobOncePos ?? 0; - if (cronJobOncePos >= 1) { - // 没有最后一次执行时间表示之前都没执行过,直接执行 - if (script.lastruntime) { - const now = new Date(); - const last = new Date(script.lastruntime); - // 根据once所在的位置去判断执行 - const timeDiff = now.getTime() - last.getTime(); - switch (cronJobOncePos) { - case 1: // 每分钟 - if (timeDiff < 2 * utime_1min && last.getMinutes() === now.getMinutes()) return; - break; - case 2: // 每小时 - if (timeDiff < 2 * utime_1hr && last.getHours() === now.getHours()) return; - break; - case 3: // 每天 - if (timeDiff < 2 * utime_1day && last.getDay() === now.getDay()) return; - break; - case 4: // 每月 - if (timeDiff < 62 * utime_1day && last.getMonth() === now.getMonth()) return; - break; - case 5: // 每周 - if (timeDiff < 14 * utime_1day && getISOWeek(last) === getISOWeek(now)) return; - break; - default: - } - } - } + if (isOnceRunDone(cronJobOncePos, script.lastruntime, new Date())) return; const logger = this.logger.with({ uuid: script.uuid, name: script.name }); if (this.execScriptMap.has(script.uuid)) { @@ -178,19 +149,8 @@ export class Runtime { await this.stopScript(script.uuid); } const extensionEnv = await this.extensionEnvAsync; - - // 判断 run-in - const runIn = script.metadata?.["run-in"]?.[0]; - const inIncognitoContext = extensionEnv?.inIncognitoContext; - // Firefox spanning 只有一个共享 event page / sandbox,没有按普通窗口与隐身窗口拆分的后台环境。 - // 因此后台/定时脚本只能运行一次;tab 专属的 run-in 值在该共享环境中不做伪隔离。 - const hasPerContextBackground = extensionEnv?.incognitoMode !== "spanning"; - if (hasPerContextBackground && runIn && runIn !== "all" && typeof inIncognitoContext === "boolean") { - // 判断插件运行环境 - const contextType = inIncognitoContext ? "incognito-tabs" : "normal-tabs"; - if (runIn !== contextType) { - return; - } + if (!backgroundRunInAllowed(script.metadata, extensionEnv)) { + return; } const exec = new BgExecScriptWarp(script, this.windowMessage, extensionEnv, this.userLanguage); @@ -199,23 +159,27 @@ export class Runtime { // 修改掉脚本掉最后运行时间, 数据库也需要修改 script.lastruntime = Date.now(); const ret = exec.exec(); + // stopScript 只清掉旧运行的计时器,它的 Promise 仍可能在重跑或停用之后才结束。运行状态不带运行 id, + // 只有仍登记为该脚本当前运行的 exec 才能发布终态和安排重试,否则会覆盖新运行(或已停用)的状态。 + const isCurrent = () => this.execScriptMap.get(script.uuid) === exec; if (ret instanceof Promise) { ret .then((resp) => { - // 发送执行完成消息 - proxyUpdateRunStatus(this.windowMessage, { uuid: script.uuid, runStatus: SCRIPT_RUN_STATUS_COMPLETE }); + if (isCurrent()) { + proxyUpdateRunStatus(this.windowMessage, { uuid: script.uuid, runStatus: SCRIPT_RUN_STATUS_COMPLETE }); + } logger.info("exec script complete", { value: resp, }); }) .catch((err) => { - // 发送执行完成+错误消息 + const current = isCurrent(); let errMsg; let nextruntime = 0; if (err instanceof CATRetryError) { // @ts-ignore errMsg = { error: err.msg }; - if (!execOnce) { + if (!execOnce && current) { // 下一次执行时间 // @ts-ignore nextruntime = err.time.getTime(); @@ -226,6 +190,8 @@ export class Runtime { errMsg = Logger.E(err); } logger.error("exec script error", errMsg); + // 被停止的运行已不代表该脚本:它的错误只记入上面的日志,不再当作当前运行的失败抛出。 + if (!current) return; proxyUpdateRunStatus(this.windowMessage, { uuid: script.uuid, runStatus: SCRIPT_RUN_STATUS_ERROR, diff --git a/src/app/service/sandbox/utils.test.ts b/src/app/service/sandbox/utils.test.ts new file mode 100644 index 000000000..df41a3846 --- /dev/null +++ b/src/app/service/sandbox/utils.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import type { TExtensionEnv } from "../extension/extension_env"; +import { backgroundRunInAllowed, isOnceRunDone } from "./utils"; + +const at = (y: number, mo: number, d: number, h = 0, mi = 0, s = 0) => new Date(y, mo - 1, d, h, mi, s); +const env = (inIncognitoContext: boolean | undefined, incognitoMode = "split") => + ({ inIncognitoContext, incognitoMode }) as TExtensionEnv; + +describe("isOnceRunDone:cron 的 once 字段所在周期内已运行过则跳过", () => { + it("没有 once(位置 < 1)或从未运行过时不跳过", () => { + expect(isOnceRunDone(0, at(2026, 11, 5, 10).getTime(), at(2026, 11, 5, 10, 30))).toBe(false); + expect(isOnceRunDone(3, undefined, at(2026, 11, 5, 10))).toBe(false); + }); + + it.each([ + ["分钟", 1, at(2026, 11, 5, 10, 30, 1), at(2026, 11, 5, 10, 30, 59), at(2026, 11, 5, 10, 31, 0)], + ["小时", 2, at(2026, 11, 5, 10, 1), at(2026, 11, 5, 10, 59), at(2026, 11, 5, 11, 0)], + ["天", 3, at(2026, 11, 5, 0, 1), at(2026, 11, 5, 23, 59), at(2026, 11, 6, 0, 0)], + ["月", 4, at(2026, 11, 1), at(2026, 11, 30, 23), at(2026, 12, 1)], + ["周(ISO 周一起算)", 5, at(2026, 11, 2), at(2026, 11, 8, 23), at(2026, 11, 9)], + ])("%s周期:同一周期内跳过,进入下一周期即可运行", (_name, pos, last, samePeriod, nextPeriod) => { + expect(isOnceRunDone(pos, last.getTime(), samePeriod)).toBe(true); + expect(isOnceRunDone(pos, last.getTime(), nextPeriod)).toBe(false); + }); + + it("相隔整整一个更大的周期、同名时间单位相同时不跳过(如隔了一周的同一星期几)", () => { + expect(isOnceRunDone(3, at(2026, 11, 5, 9).getTime(), at(2026, 11, 12, 9))).toBe(false); + expect(isOnceRunDone(2, at(2026, 11, 5, 9).getTime(), at(2026, 11, 6, 9, 30))).toBe(false); + }); +}); + +describe("backgroundRunInAllowed:@run-in 与扩展环境(普通 / 隐身)是否匹配", () => { + const meta = (runIn?: string) => (runIn ? { "run-in": [runIn] } : {}); + + it("普通环境只运行 normal-tabs,隐身环境只运行 incognito-tabs", () => { + expect(backgroundRunInAllowed(meta("normal-tabs"), env(false))).toBe(true); + expect(backgroundRunInAllowed(meta("incognito-tabs"), env(false))).toBe(false); + expect(backgroundRunInAllowed(meta("incognito-tabs"), env(true))).toBe(true); + expect(backgroundRunInAllowed(meta("normal-tabs"), env(true))).toBe(false); + }); + + it("未写 run-in、run-in 为 all、拿不到隐身状态或 Firefox spanning 共享后台时都允许", () => { + expect(backgroundRunInAllowed(meta(), env(true))).toBe(true); + expect(backgroundRunInAllowed(meta("all"), env(true))).toBe(true); + expect(backgroundRunInAllowed(meta("incognito-tabs"), env(undefined))).toBe(true); + expect(backgroundRunInAllowed(meta("incognito-tabs"), undefined)).toBe(true); + expect(backgroundRunInAllowed(meta("incognito-tabs"), env(false, "spanning"))).toBe(true); + }); +}); diff --git a/src/app/service/sandbox/utils.ts b/src/app/service/sandbox/utils.ts new file mode 100644 index 000000000..37915118b --- /dev/null +++ b/src/app/service/sandbox/utils.ts @@ -0,0 +1,49 @@ +import type { SCMetadata } from "@App/app/repo/metadata"; +import type { TExtensionEnv } from "../extension/extension_env"; +import { getISOWeek } from "@App/pkg/utils/utils"; + +/** + * 后台/定时脚本的 @run-in 是否允许它在该扩展环境的 sandbox 中运行。sandbox 据此跳过不匹配的运行, + * 外部接入 scripts.run 据此在触发前拒绝,两处必须同一判断。拿不到隐身状态时不过滤(fail-open), + * 避免静默丢失任务。 + */ +export function backgroundRunInAllowed(metadata: SCMetadata | undefined, extensionEnv: TExtensionEnv | undefined) { + const runIn = metadata?.["run-in"]?.[0]; + const inIncognitoContext = extensionEnv?.inIncognitoContext; + // Firefox spanning 只有一个共享 event page / sandbox,没有按普通窗口与隐身窗口拆分的后台环境。 + // 因此后台/定时脚本只能运行一次;tab 专属的 run-in 值在该共享环境中不做伪隔离。 + const hasPerContextBackground = extensionEnv?.incognitoMode !== "spanning"; + if (hasPerContextBackground && runIn && runIn !== "all" && typeof inIncognitoContext === "boolean") { + return runIn === (inIncognitoContext ? "incognito-tabs" : "normal-tabs"); + } + return true; +} + +const utime_1min = 60 * 1000; +const utime_1hr = 60 * 60 * 1000; +const utime_1day = 24 * 60 * 60 * 1000; + +/** + * cron 表达式 once 字段所在的周期内是否已运行过(应跳过本次触发)。oncePos 是 once 在 6 位 cron 中的位置 + * (见 extractCronExpr),小于 1 表示没有 once。以 lastruntime 计,它在运行开始时就写入,所以失败的运行也算。 + * 时间差上限用来区分「同一周期」与「隔了一个更大周期后同名单位又相同」。 + */ +export function isOnceRunDone(oncePos: number, lastruntime: number | undefined, now: Date): boolean { + if (oncePos < 1 || !lastruntime) return false; + const last = new Date(lastruntime); + const timeDiff = now.getTime() - last.getTime(); + switch (oncePos) { + case 1: + return timeDiff < 2 * utime_1min && last.getMinutes() === now.getMinutes(); + case 2: + return timeDiff < 2 * utime_1hr && last.getHours() === now.getHours(); + case 3: + return timeDiff < 2 * utime_1day && last.getDay() === now.getDay(); + case 4: + return timeDiff < 62 * utime_1day && last.getMonth() === now.getMonth(); + case 5: + return timeDiff < 14 * utime_1day && getISOWeek(last) === getISOWeek(now); + default: + return false; + } +} diff --git a/src/app/service/service_worker/external_access/bridge.test.ts b/src/app/service/service_worker/external_access/bridge.test.ts index 80cec2916..1f8613c76 100644 --- a/src/app/service/service_worker/external_access/bridge.test.ts +++ b/src/app/service/service_worker/external_access/bridge.test.ts @@ -2,11 +2,24 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { ExternalAccessBridge, type ExternalAccessWriteNotice } from "./bridge"; import { ExternalAccessApprovalService, type ExternalAccessScriptMutator } from "./approval"; import { ExternalAccessOperationDAO } from "@App/app/repo/external_access"; -import { ScriptDAO, ScriptCodeDAO, SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts"; +import { + ScriptDAO, + ScriptCodeDAO, + SCRIPT_STATUS_DISABLE, + SCRIPT_STATUS_ENABLE, + SCRIPT_TYPE_BACKGROUND, + SCRIPT_TYPE_CRONTAB, + SCRIPT_TYPE_NORMAL, +} from "@App/app/repo/scripts"; import { TempStorageDAO } from "@App/app/repo/tempStorage"; import { type BridgeAction, type ExternalAccessBridgeRequest, type ExternalAccessBridgeResponse } from "./types"; import type { ExternalAccessWritePolicy, ExternalAccessSourceReadPolicy } from "@App/pkg/config/config"; -import type { ExternalAccessAuditEvent } from "./audit"; +import { EXTERNAL_ACCESS_COMPONENT, type ExternalAccessAuditEvent } from "./audit"; +import { ExternalAccessRunLogs } from "./run_logs"; +import type { Logger, LoggerDAO } from "@App/app/repo/logger"; +import type { LogLabel, LogLevel } from "@App/app/logger/core"; +import type { TExtensionEnv } from "@App/app/service/extension/extension_env"; +import { MessageQueue } from "@Packages/message/message_queue"; import { uuidv4 } from "@App/pkg/utils/uuid"; import { sha256OfText } from "@App/pkg/utils/crypto"; import { createMockOPFS } from "@App/app/repo/test-helpers"; @@ -26,6 +39,22 @@ function expectResponse(r: ExternalAccessBridgeResponse | null): ExternalAccessB return r as ExternalAccessBridgeResponse; } +// 与 LoggerDAO.walkNewestFirst 同一契约:createtime ≥ since,从新到旧、同一毫秒 id 大的在前,visit 返回 false 即停。 +// visited 记录实际读到的行数,用来断言读取有界。 +function memoryLoggerDAO(rows: Logger[], visited: { count: number }): Pick { + return { + walkNewestFirst: async (since, visit) => { + const ordered = rows + .filter((row) => row.createtime >= since) + .sort((a, b) => b.createtime - a.createtime || b.id - a.id); + for (const row of ordered) { + visited.count++; + if (!visit(row)) return; + } + }, + }; +} + function makeRequest( action: BridgeAction, input: unknown, @@ -44,6 +73,14 @@ describe("ExternalAccessBridge(扁平信任 + 双策略)", () => { let sourcePolicy: ExternalAccessSourceReadPolicy; let notifyWrite: ReturnType; let audit: ReturnType; + let mq: MessageQueue; + let runScript: ReturnType Promise>>; + let logRows: Logger[]; + let logsVisited: { count: number }; + + function pushLog(level: LogLevel, message: string, label: LogLabel, createtime = Date.now()) { + logRows.push({ id: logRows.length + 1, level, message, label, createtime }); + } beforeEach(async () => { chrome.storage.local.clear(); @@ -63,10 +100,17 @@ describe("ExternalAccessBridge(扁平信任 + 双策略)", () => { deleteScript: vi.fn().mockResolvedValue(undefined), }; approval = new ExternalAccessApprovalService(mutator, scriptDAO, scriptCodeDAO, operationDAO, new TempStorageDAO()); + mq = new MessageQueue(); + runScript = vi.fn<(uuid: string) => Promise>().mockResolvedValue(undefined); + logRows = []; + logsVisited = { count: 0 }; bridge = new ExternalAccessBridge( scriptDAO, scriptCodeDAO, approval, + new ExternalAccessRunLogs(scriptDAO, { runScript }, mq, memoryLoggerDAO(logRows, logsVisited), { + inIncognitoContext: false, + }), async () => writePolicy, async () => sourcePolicy, notifyWrite as (n: ExternalAccessWriteNotice) => void, @@ -199,6 +243,18 @@ describe("ExternalAccessBridge(扁平信任 + 双策略)", () => { expect(utilsModule.openInCurrentTab).toHaveBeenCalled(); }); + it("$/cancelRequest 作废该请求挂起的待批操作", async () => { + sourcePolicy = "approval"; + await seedScript(SRC_UUID); + const request = makeRequest("scripts.source.get", { uuid: SRC_UUID }); + expect(await bridge.handle(request)).toBeNull(); + + await bridge.cancel(request.requestId); + + expect(await operationDAO.awaitingUser()).toHaveLength(0); + expect((await operationDAO.byRequestId(request.requestId))?.status).toBe("cancelled"); + }); + it("startLine/endLine 开窗读取:直接允许时立即返回切片与行窗字段,sha256 仍是全文哈希", async () => { sourcePolicy = "allow"; await seedScript(SRC_UUID); @@ -459,4 +515,507 @@ console.log('v1');`; ); expect(audit).toHaveBeenCalledWith(expect.objectContaining({ client: "session-1", decision: "denied" })); }); + + describe("scripts.run 运行一次后台/定时脚本", () => { + const RUN_UUID = "22222222-2222-4222-8222-222222222222"; + + function publishRunStatus(runStatus: string, error?: unknown) { + mq.publish("scriptRunStatus", { uuid: RUN_UUID, runStatus, ...(error === undefined ? {} : { error }) }); + } + + it("等待本次 RUNNING 之后的终态:忽略重跑前旧运行的 complete,返回本次 error(对象转字符串)与本次日志", async () => { + await seedScript(RUN_UUID, { type: SCRIPT_TYPE_BACKGROUND }); + pushLog("info", "previous run output", { uuid: RUN_UUID, component: "GM_log" }, Date.now() - 10_000); + runScript.mockImplementation(async () => { + // 重跑时 sandbox 先停旧运行(发 complete),再开始新运行(发 running)。 + publishRunStatus("complete"); + pushLog("info", "first line", { uuid: RUN_UUID, component: "GM_log" }); + publishRunStatus("running"); + pushLog("error", "second line", { uuid: RUN_UUID, component: "GM_log" }); + publishRunStatus("error", { error: "boom" }); + }); + + const response = expectResponse(await bridge.handle(makeRequest("scripts.run", { uuid: RUN_UUID }))); + + expect(runScript).toHaveBeenCalledWith(RUN_UUID); + expect(response.ok).toBe(true); + if (!response.ok) return; + const result = response.result as Record; + expect(result).toMatchObject({ status: "error", error: "boom", contentTrust: "untrusted-user-script-log" }); + expect(typeof result.startedAt).toBe("number"); + expect(result.endedAt as number).toBeGreaterThanOrEqual(result.startedAt as number); + expect((result.logs as Array<{ message: string; severity: string }>).map((l) => [l.message, l.severity])).toEqual( + [ + ["first line", "info"], + ["second line", "error"], + ] + ); + }); + + it("定时脚本未启用也可运行,正常结束返回 complete 且不带 error", async () => { + await seedScript(RUN_UUID, { type: SCRIPT_TYPE_CRONTAB, status: SCRIPT_STATUS_DISABLE }); + runScript.mockImplementation(async () => { + publishRunStatus("running"); + publishRunStatus("complete"); + }); + + const response = expectResponse(await bridge.handle(makeRequest("scripts.run", { uuid: RUN_UUID }))); + + expect(response.ok).toBe(true); + if (!response.ok) return; + expect(response.result).toMatchObject({ status: "complete", logs: [] }); + expect(response.result).not.toHaveProperty("error"); + expect(response.result).toHaveProperty("endedAt"); + }); + + it("到 timeoutMs 仍未见本次运行结束返回 running(sandbox 只发了旧运行被停止的 complete、没有本次 RUNNING)", async () => { + await seedScript(RUN_UUID, { type: SCRIPT_TYPE_BACKGROUND }); + let triggered!: () => void; + const started = new Promise((resolve) => (triggered = resolve)); + runScript.mockImplementation(async () => { + publishRunStatus("complete"); + triggered(); + }); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + let settled = false; + const pending = bridge.handle(makeRequest("scripts.run", { uuid: RUN_UUID, timeoutMs: 5000 })).then((r) => { + settled = true; + return r; + }); + await started; + await vi.advanceTimersByTimeAsync(4999); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + + const response = expectResponse(await pending); + expect(response.ok).toBe(true); + if (!response.ok) return; + expect(response.result).toMatchObject({ status: "running" }); + expect(response.result).not.toHaveProperty("endedAt"); + } finally { + vi.useRealTimers(); + } + }); + + it("脚本的错误文本超过 16384 个 UTF-16 码元时截断并以「…」结尾,日志照常返回", async () => { + // 截断让应答不超 daemon 帧上限(sctl docs/protocol.md 约定 16384)。只需刚越过截断边界的输入: + // 按帧上限构造的 MB 级字符串在 CI 覆盖率与分片负载下会让本例超出单测预算。 + const cap = 16384; + await seedScript(RUN_UUID, { type: SCRIPT_TYPE_BACKGROUND }); + runScript.mockImplementation(async () => { + publishRunStatus("running"); + pushLog("info", "small-log", { uuid: RUN_UUID, component: "GM_log" }); + publishRunStatus("error", { error: "e".repeat(cap + 100) }); + }); + + const response = expectResponse(await bridge.handle(makeRequest("scripts.run", { uuid: RUN_UUID }))); + + expect(response.ok).toBe(true); + if (!response.ok) return; + const result = response.result as { status: string; error: string; logs: unknown[] }; + expect(result.status).toBe("error"); + expect(result.error).toBe(`${"e".repeat(cap)}…`); + expect(result.logs).toHaveLength(1); + }); + + it("同一脚本并发两次等待运行:后一次不把前一次的 RUNNING 与被停止时的 complete 当成自己的终态", async () => { + await seedScript(RUN_UUID, { type: SCRIPT_TYPE_BACKGROUND }); + // 模拟 sandbox:RUNNING 经 offscreen→SW 异步到达;已有运行时先停旧运行(发 complete)再开始新运行。 + let active = false; + let starts = 0; + let secondStarted!: () => void; + const second = new Promise((resolve) => (secondStarted = resolve)); + runScript.mockImplementation(async () => { + setTimeout(() => { + if (active) publishRunStatus("complete"); + active = true; + publishRunStatus("running"); + if (++starts === 2) secondStarted(); + }, 0); + }); + + const first = bridge.handle(makeRequest("scripts.run", { uuid: RUN_UUID })); + const later = bridge.handle(makeRequest("scripts.run", { uuid: RUN_UUID })); + await second; + publishRunStatus("error", { error: "second run failed" }); + + const [a, b] = [expectResponse(await first), expectResponse(await later)]; + expect(runScript).toHaveBeenCalledTimes(2); + expect(a.ok && b.ok).toBe(true); + if (!a.ok || !b.ok) return; + expect(a.result).toMatchObject({ status: "complete" }); + expect(b.result).toMatchObject({ status: "error", error: "second run failed" }); + }); + + it("wait=false 触发即返回 running,不等待终态", async () => { + await seedScript(RUN_UUID, { type: SCRIPT_TYPE_BACKGROUND }); + + const response = expectResponse(await bridge.handle(makeRequest("scripts.run", { uuid: RUN_UUID, wait: false }))); + + expect(runScript).toHaveBeenCalledWith(RUN_UUID); + expect(response.ok).toBe(true); + if (!response.ok) return; + expect(response.result).toMatchObject({ status: "running", logs: [], contentTrust: "untrusted-user-script-log" }); + expect(typeof (response.result as { startedAt: unknown }).startedAt).toBe("number"); + }); + + it("脚本 @run-in 与本 ScriptCat 的环境不符时立即返回 INVALID_REQUEST,不触发运行", async () => { + await seedScript(RUN_UUID, { + type: SCRIPT_TYPE_BACKGROUND, + metadata: { name: ["Existing Script"], "run-in": ["incognito-tabs"] }, + }); + + const response = expectResponse(await bridge.handle(makeRequest("scripts.run", { uuid: RUN_UUID }))); + + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error.code).toBe("INVALID_REQUEST"); + expect(response.error.message).toMatch(/run-in does not match/); + } + expect(runScript).not.toHaveBeenCalled(); + }); + + it("Firefox spanning 共享后台环境与 sandbox 一致,不按 @run-in 拒绝", async () => { + const spanning: TExtensionEnv = { inIncognitoContext: false, incognitoMode: "spanning" }; + bridge = new ExternalAccessBridge( + scriptDAO, + scriptCodeDAO, + approval, + new ExternalAccessRunLogs(scriptDAO, { runScript }, mq, memoryLoggerDAO(logRows, logsVisited), spanning) + ); + await seedScript(RUN_UUID, { + type: SCRIPT_TYPE_BACKGROUND, + metadata: { name: ["Existing Script"], "run-in": ["incognito-tabs"] }, + }); + + const response = expectResponse(await bridge.handle(makeRequest("scripts.run", { uuid: RUN_UUID, wait: false }))); + + expect(response.ok).toBe(true); + expect(runScript).toHaveBeenCalledWith(RUN_UUID); + }); + + describe("同一脚本的外部运行排队、超时与取消", () => { + // 第一次运行已触发但 sandbox 迟迟不发 RUNNING,后续调用只能排在它之后。 + async function startStalledRun(timeoutMs: number) { + let triggered!: () => void; + const started = new Promise((resolve) => (triggered = resolve)); + runScript.mockImplementationOnce(async () => triggered()); + const request = makeRequest("scripts.run", { uuid: RUN_UUID, timeoutMs }); + const response = bridge.handle(request); + await started; + return { request, response }; + } + + beforeEach(async () => { + await seedScript(RUN_UUID, { type: SCRIPT_TYPE_BACKGROUND }); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + }); + + afterEach(() => vi.useRealTimers()); + + it("排队时间计入本次超时:上一次一直未开始时,到本次 timeoutMs 即触发并返回 running", async () => { + const first = await startStalledRun(5000); + let settled = false; + const queued = bridge.handle(makeRequest("scripts.run", { uuid: RUN_UUID, timeoutMs: 1000 })).then((r) => { + settled = true; + return r; + }); + await vi.advanceTimersByTimeAsync(999); + expect(settled).toBe(false); + expect(runScript).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + const response = expectResponse(await queued); + + expect(runScript).toHaveBeenCalledTimes(2); + expect(response.ok && response.result).toMatchObject({ status: "running" }); + await vi.advanceTimersByTimeAsync(4000); + expect(expectResponse(await first.response).ok).toBe(true); + }); + + it("wait=false 不排队:上一次尚未开始也立即触发并返回 running", async () => { + const first = await startStalledRun(5000); + + const response = expectResponse( + await bridge.handle(makeRequest("scripts.run", { uuid: RUN_UUID, wait: false })) + ); + + expect(runScript).toHaveBeenCalledTimes(2); + expect(response.ok && response.result).toMatchObject({ status: "running" }); + await vi.advanceTimersByTimeAsync(5000); + expect(expectResponse(await first.response).ok).toBe(true); + }); + + it("排队中的调用被 $/cancelRequest 取消后放弃本次运行:上一次开始后也不再触发", async () => { + const first = await startStalledRun(5000); + const queuedRequest = makeRequest("scripts.run", { uuid: RUN_UUID, timeoutMs: 5000 }); + const queued = bridge.handle(queuedRequest); + + await bridge.cancel(queuedRequest.requestId); + mq.publish("scriptRunStatus", { uuid: RUN_UUID, runStatus: "running" }); + mq.publish("scriptRunStatus", { uuid: RUN_UUID, runStatus: "complete" }); + await vi.advanceTimersByTimeAsync(5000); + + const response = expectResponse(await queued); + expect(response.ok).toBe(false); + if (!response.ok) expect(response.error.code).toBe("OPERATION_EXPIRED"); + expect(runScript).toHaveBeenCalledTimes(1); + const firstResponse = expectResponse(await first.response); + expect(firstResponse.ok && firstResponse.result).toMatchObject({ status: "complete" }); + }); + + it("连接断开时正在等待的调用立即结束,释放运行状态订阅与超时计时器", async () => { + const unsubscribed = vi.fn(); + const subscribe = mq.subscribe.bind(mq); + vi.spyOn(mq, "subscribe").mockImplementation((topic, handler) => { + const unsubscribe = subscribe(topic, handler); + return () => { + unsubscribed(); + unsubscribe(); + }; + }); + const first = await startStalledRun(5000); + + await bridge.cancel(); + const response = expectResponse(await first.response); + + expect(response.ok && response.result).toMatchObject({ status: "running", logs: [] }); + expect(unsubscribed).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + }); + + it("页面脚本返回 INVALID_REQUEST(只有后台/定时脚本可以运行),不触发运行", async () => { + await seedScript(RUN_UUID, { type: SCRIPT_TYPE_NORMAL }); + + const response = expectResponse(await bridge.handle(makeRequest("scripts.run", { uuid: RUN_UUID }))); + + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error.code).toBe("INVALID_REQUEST"); + expect(response.error.message).toMatch(/only background.*scheduled scripts can run/i); + } + expect(runScript).not.toHaveBeenCalled(); + }); + + it("脚本不存在返回 NOT_FOUND,不触发运行", async () => { + const response = expectResponse(await bridge.handle(makeRequest("scripts.run", { uuid: RUN_UUID }))); + + expect(response.ok).toBe(false); + if (!response.ok) expect(response.error.code).toBe("NOT_FOUND"); + expect(runScript).not.toHaveBeenCalled(); + }); + + it("timeoutMs 越界、wait 非布尔或含未知字段返回 INVALID_REQUEST", async () => { + await seedScript(RUN_UUID, { type: SCRIPT_TYPE_BACKGROUND }); + const invalid = [ + { uuid: RUN_UUID, timeoutMs: 240001 }, + { uuid: RUN_UUID, timeoutMs: 0 }, + { uuid: RUN_UUID, timeoutMs: 1.5 }, + { uuid: RUN_UUID, wait: "no" }, + { uuid: RUN_UUID, unexpected: true }, + ]; + for (const input of invalid) { + const response = expectResponse(await bridge.handle(makeRequest("scripts.run", input))); + expect(response.ok, JSON.stringify(input)).toBe(false); + if (!response.ok) expect(response.error.code).toBe("INVALID_REQUEST"); + } + expect(runScript).not.toHaveBeenCalled(); + }); + }); + + describe("scripts.logs 读取脚本日志", () => { + const LOG_UUID = "33333333-3333-4333-8333-333333333333"; + const OTHER_UUID = "44444444-4444-4444-8444-444444444444"; + + async function readLogs(input: Record) { + const response = expectResponse(await bridge.handle(makeRequest("scripts.logs", { uuid: LOG_UUID, ...input }))); + expect(response.ok).toBe(true); + return (response.ok ? response.result : undefined) as { + logs: Array<{ time: number; severity: string; message: string; component?: string }>; + truncated: boolean; + contentTrust: string; + }; + } + + it("页面脚本的日志同样可读:只含该脚本、排除外部接入审计,oldest first,trace→debug、丢弃 none,非字符串字段 JSON 编码", async () => { + await seedScript(LOG_UUID, { type: SCRIPT_TYPE_NORMAL }); + const t = Date.now(); + pushLog("debug", "sandbox trace", { uuid: LOG_UUID }, t - 1000); + pushLog("trace", "older trace", { uuid: LOG_UUID, name: "S", env: "sandbox" }, t - 3000); + pushLog("info", "gm log", { uuid: LOG_UUID, name: "S", component: "GM_log", extra: { a: 1 } }, t - 2000); + pushLog("none", "silent", { uuid: LOG_UUID }, t - 1500); + pushLog("info", "other script", { uuid: OTHER_UUID, component: "GM_log" }, t - 1200); + pushLog("info", "scripts.logs allowed", { uuid: LOG_UUID, component: EXTERNAL_ACCESS_COMPONENT }, t - 1100); + + const result = await readLogs({}); + + expect(result).toEqual({ + logs: [ + { + time: t - 3000, + severity: "debug", + message: "older trace", + fields: [ + { name: "name", value: "S" }, + { name: "env", value: "sandbox" }, + ], + }, + { + time: t - 2000, + severity: "info", + message: "gm log", + component: "GM_log", + fields: [ + { name: "name", value: "S" }, + { name: "extra", value: '{"a":1}' }, + ], + }, + { time: t - 1000, severity: "debug", message: "sandbox trace" }, + ], + truncated: false, + contentTrust: "untrusted-user-script-log", + }); + }); + + it("since 缺省只看最近 1 小时,显式 since 取其后的记录", async () => { + await seedScript(LOG_UUID); + const t = Date.now(); + pushLog("info", "two hours ago", { uuid: LOG_UUID }, t - 2 * 3600_000); + pushLog("info", "half hour ago", { uuid: LOG_UUID }, t - 1800_000); + + expect((await readLogs({})).logs.map((l) => l.message)).toEqual(["half hour ago"]); + expect((await readLogs({ since: t - 3 * 3600_000 })).logs.map((l) => l.message)).toEqual([ + "two hours ago", + "half hour ago", + ]); + }); + + it("severity 取该级别及以上,text 按不区分大小写的子串匹配", async () => { + await seedScript(LOG_UUID); + const t = Date.now(); + pushLog("debug", "debug hello", { uuid: LOG_UUID }, t - 4000); + pushLog("info", "info hello", { uuid: LOG_UUID }, t - 3000); + pushLog("warn", "warn Hello world", { uuid: LOG_UUID }, t - 2000); + pushLog("error", "error bye", { uuid: LOG_UUID }, t - 1000); + + expect((await readLogs({ severity: "warn" })).logs.map((l) => l.message)).toEqual([ + "warn Hello world", + "error bye", + ]); + expect((await readLogs({ text: "HELLO" })).logs.map((l) => l.message)).toEqual([ + "debug hello", + "info hello", + "warn Hello world", + ]); + }); + + it("超出 limit 时返回最新的 N 条(仍 oldest first)并标注 truncated;limit=1000 可用", async () => { + await seedScript(LOG_UUID); + const t = Date.now(); + for (let i = 1; i <= 5; i++) pushLog("info", `line ${i}`, { uuid: LOG_UUID }, t - (6 - i) * 1000); + + const limited = await readLogs({ limit: 2 }); + expect(limited.logs.map((l) => l.message)).toEqual(["line 4", "line 5"]); + expect(limited.truncated).toBe(true); + + const all = await readLogs({ limit: 1000 }); + expect(all.logs).toHaveLength(5); + expect(all.truncated).toBe(false); + }); + + it("响应超出帧预算时按条丢弃最旧的记录并标注 truncated,而不是报错", async () => { + await seedScript(LOG_UUID); + const t = Date.now(); + const big = "x".repeat(1536 * 1024); + pushLog("info", `a${big}`, { uuid: LOG_UUID }, t - 3000); + pushLog("info", `b${big}`, { uuid: LOG_UUID }, t - 2000); + pushLog("info", `c${big}`, { uuid: LOG_UUID }, t - 1000); + + const result = await readLogs({}); + + expect(result.logs.map((l) => l.message[0])).toEqual(["b", "c"]); + expect(result.truncated).toBe(true); + }); + + it("无法 JSON 编码的字段(BigInt、循环引用)以字符串返回,不让整次查询失败", async () => { + await seedScript(LOG_UUID); + // 后台脚本 return 1n 时 sandbox 记 { value: 1n };抛出的非 Error 对象原样进日志,可能带循环引用。 + const cyclic: Record = { a: 1 }; + cyclic.self = cyclic; + const label: LogLabel = { uuid: LOG_UUID, nested: { n: 2 } }; + Reflect.set(label, "value", 1n); + Reflect.set(label.nested as object, "n", 2n); + label.cyclic = cyclic; + pushLog("info", "exec script complete", label, Date.now() - 1000); + + const result = await readLogs({}); + + expect(result.logs).toHaveLength(1); + expect(result.logs[0]).toMatchObject({ + fields: [ + { name: "nested", value: '{"n":"2"}' }, + { name: "value", value: "1" }, + { name: "cyclic", value: "[unencodable]" }, + ], + }); + }); + + it("从最新往回读到满足 limit 即停止,不读取更早的记录", async () => { + await seedScript(LOG_UUID); + const t = Date.now(); + for (let i = 1; i <= 5; i++) pushLog("info", `line ${i}`, { uuid: LOG_UUID }, t - (6 - i) * 1000); + + const result = await readLogs({ limit: 2 }); + + expect(result.logs.map((l) => l.message)).toEqual(["line 4", "line 5"]); + // 第 3 条用来确认还有更早的匹配记录(truncated),此后不再读取。 + expect(logsVisited.count).toBe(3); + }); + + it("脚本不存在返回 NOT_FOUND", async () => { + const response = expectResponse(await bridge.handle(makeRequest("scripts.logs", { uuid: LOG_UUID }))); + expect(response.ok).toBe(false); + if (!response.ok) expect(response.error.code).toBe("NOT_FOUND"); + }); + + it("limit 越界、severity 非法、since 为负或 text 非字符串返回 INVALID_REQUEST", async () => { + await seedScript(LOG_UUID); + const invalid = [ + { uuid: LOG_UUID, limit: 1001 }, + { uuid: LOG_UUID, limit: 0 }, + { uuid: LOG_UUID, severity: "trace" }, + { uuid: LOG_UUID, since: -1 }, + { uuid: LOG_UUID, text: 1 }, + { uuid: LOG_UUID, unexpected: true }, + ]; + for (const input of invalid) { + const response = expectResponse(await bridge.handle(makeRequest("scripts.logs", input))); + expect(response.ok, JSON.stringify(input)).toBe(false); + if (!response.ok) expect(response.error.code).toBe("INVALID_REQUEST"); + } + }); + }); + + it("scripts.run 与 scripts.logs 都写入审计:记录 clientId 与脚本 uuid,不含日志正文", async () => { + const uuid = "55555555-5555-4555-8555-555555555555"; + await seedScript(uuid, { type: SCRIPT_TYPE_BACKGROUND }); + runScript.mockImplementation(async () => { + mq.publish("scriptRunStatus", { uuid, runStatus: "running" }); + pushLog("info", "secret-log-body", { uuid, component: "GM_log" }); + mq.publish("scriptRunStatus", { uuid, runStatus: "complete" }); + }); + + const run = expectResponse(await bridge.handle(makeRequest("scripts.run", { uuid }))); + const logs = expectResponse(await bridge.handle(makeRequest("scripts.logs", { uuid }))); + + expect(run.ok && logs.ok).toBe(true); + for (const action of ["scripts.run", "scripts.logs"]) { + expect(audit).toHaveBeenCalledWith( + expect.objectContaining({ client: "session-1", action, uuid, decision: "allowed", result: "success" }) + ); + } + expect(JSON.stringify(audit.mock.calls)).not.toContain("secret-log-body"); + }); }); diff --git a/src/app/service/service_worker/external_access/bridge.ts b/src/app/service/service_worker/external_access/bridge.ts index d84f4d46f..bdb59959d 100644 --- a/src/app/service/service_worker/external_access/bridge.ts +++ b/src/app/service/service_worker/external_access/bridge.ts @@ -19,6 +19,13 @@ import { type TextEdit, } from "./source"; import { logExternalAccess, type ExternalAccessAudit } from "./audit"; +import { + type ExternalAccessRunLogs, + type LogSeverity, + RUN_DEFAULT_TIMEOUT_MS, + RUN_MAX_TIMEOUT_MS, + LOGS_MAX_LIMIT, +} from "./run_logs"; import { type BridgeAction, type ExternalAccessBridgeRequest, @@ -56,6 +63,15 @@ function assertKeys(input: Record, allowed: string[]): void { const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +function assertIntegerField(input: Record, field: string, min: number, max: number): void { + const value = input[field]; + if (value !== undefined && (!Number.isInteger(value) || (value as number) < min || (value as number) > max)) { + throw new ExternalAccessBridgeError("INVALID_REQUEST", `${field} must be an integer between ${min} and ${max}`); + } +} + +const LOG_SEVERITIES: readonly LogSeverity[] = ["debug", "info", "warn", "error"]; + function assertUuidField(input: Record, field: string): string { const value = input[field]; if (typeof value !== "string" || !UUID_RE.test(value)) { @@ -181,6 +197,29 @@ const VALIDATORS: Record void> = { } } }, + // 生成的校验器不检查 maximum,上限在这里拒绝。 + "scripts.run": (input) => { + if (!isPlainObject(input)) throw new ExternalAccessBridgeError("INVALID_REQUEST", "input must be an object"); + assertKeys(input, ["uuid", "wait", "timeoutMs"]); + assertUuidField(input, "uuid"); + if (input.wait !== undefined && typeof input.wait !== "boolean") { + throw new ExternalAccessBridgeError("INVALID_REQUEST", "wait must be a boolean"); + } + assertIntegerField(input, "timeoutMs", 1, RUN_MAX_TIMEOUT_MS); + }, + "scripts.logs": (input) => { + if (!isPlainObject(input)) throw new ExternalAccessBridgeError("INVALID_REQUEST", "input must be an object"); + assertKeys(input, ["uuid", "since", "severity", "text", "limit"]); + assertUuidField(input, "uuid"); + assertIntegerField(input, "since", 0, Number.MAX_SAFE_INTEGER); + if (input.severity !== undefined && !LOG_SEVERITIES.includes(input.severity as LogSeverity)) { + throw new ExternalAccessBridgeError("INVALID_REQUEST", "severity must be one of debug, info, warn, error"); + } + if (input.text !== undefined && typeof input.text !== "string") { + throw new ExternalAccessBridgeError("INVALID_REQUEST", "text must be a string"); + } + assertIntegerField(input, "limit", 1, LOGS_MAX_LIMIT); + }, }; function toScriptType(type: SCRIPT_TYPE): ScriptType { @@ -218,6 +257,8 @@ function toSummary(script: Script): ScriptSummary { * by the decide/void event); "allow" executes immediately and fires a notification. * - Source reads (scripts.source.get) → the source-read policy, same two modes. Source is a privacy * read, so it keeps its own gate and — unlike the old model — is no longer CLI-exempt (§2.3). + * - scripts.run / scripts.logs are under neither policy: the code being run was confirmed at install + * time, so they execute immediately like list/metadata. * * The "本会话允许" third tier is applied inside ExternalAccessApprovalService.present(): a session-allowed * (script, kind) auto-approves without opening a page. Writes exactly one audit event per request. @@ -227,17 +268,28 @@ export class ExternalAccessBridge { private readonly scriptDAO: Pick, private readonly scriptCodeDAO: Pick, private readonly approval: ExternalAccessApprovalService, + private readonly runLogs: ExternalAccessRunLogs, private getWritePolicy: () => Promise = async () => "approval", private getSourceReadPolicy: () => Promise = async () => "approval", private readonly notifyWrite: (notice: ExternalAccessWriteNotice) => void = () => {}, private readonly audit: ExternalAccessAudit = logExternalAccess ) {} + // In-flight scripts.run calls by request ID, so a cancel can drop a queued run or end a wait. + private readonly runCalls = new Map(); + // daemon → $/cancelRequest {id}: the requester (MCP client / CLI) timed out, Ctrl-C'd or - // its WS session died. Void the matching pending op; its confirm page's next decide then fails - // cleanly. First-terminal-wins arbitration vs a concurrent decide lives in the approval service. + // its WS session died (no id: every request of the session). Void the matching pending op; its + // confirm page's next decide then fails cleanly. First-terminal-wins arbitration vs a concurrent + // decide lives in the approval service. A matching scripts.run that has not triggered its run yet + // drops it; one already waiting stops waiting (the triggered run keeps going in ScriptCat). cancel(requestId?: string): Promise { - return requestId ? this.approval.cancelByRequestId(requestId) : this.approval.cancelAllPending(); + if (requestId) { + this.runCalls.get(requestId)?.abort(); + return this.approval.cancelByRequestId(requestId); + } + for (const call of this.runCalls.values()) call.abort(); + return this.approval.cancelAllPending(); } async handle(request: ExternalAccessBridgeRequest): Promise { @@ -392,6 +444,26 @@ export class ExternalAccessBridge { requestId, }) ); + case "scripts.run": { + const call = new AbortController(); + this.runCalls.set(request.requestId, call); + try { + return await this.runLogs.run(input.uuid as string, { + wait: (input.wait as boolean | undefined) ?? true, + timeoutMs: (input.timeoutMs as number | undefined) ?? RUN_DEFAULT_TIMEOUT_MS, + signal: call.signal, + }); + } finally { + this.runCalls.delete(request.requestId); + } + } + case "scripts.logs": + return this.runLogs.logs(input.uuid as string, { + since: input.since as number | undefined, + severity: input.severity as LogSeverity | undefined, + text: input.text as string | undefined, + limit: input.limit as number | undefined, + }); } } diff --git a/src/app/service/service_worker/external_access/generated/protocol.generated.ts b/src/app/service/service_worker/external_access/generated/protocol.generated.ts index b9be5998b..5832e81e5 100644 --- a/src/app/service/service_worker/external_access/generated/protocol.generated.ts +++ b/src/app/service/service_worker/external_access/generated/protocol.generated.ts @@ -143,6 +143,44 @@ export interface ScriptsListResult { version?: string; }>; } +export interface ScriptsLogsParams { + limit?: number; + severity?: "debug" | "info" | "warn" | "error"; + since?: number; + text?: string; + uuid: string; +} +export interface ScriptsLogsResult { + contentTrust: "untrusted-user-script-log"; + logs: Array<{ + component?: string; + fields?: Array<{ name: string; value: string }>; + message: string; + severity: "debug" | "info" | "warn" | "error"; + time: number; + }>; + truncated: boolean; +} +export interface ScriptsRunParams { + timeoutMs?: number; + uuid: string; + wait?: boolean; +} +export interface ScriptsRunResult { + contentTrust: "untrusted-user-script-log"; + endedAt?: number; + error?: string; + logs: Array<{ + component?: string; + fields?: Array<{ name: string; value: string }>; + message: string; + severity: "debug" | "info" | "warn" | "error"; + time: number; + }>; + startedAt: number; + status: "complete" | "error" | "running"; + truncated?: boolean; +} export interface ScriptsSourceGetParams { endLine?: number; maxBytes?: number; @@ -170,7 +208,9 @@ export interface RpcMethodMap { "scripts.edit.request": { params: ScriptsEditParams; result: ScriptMutationResult }; "scripts.install.request": { params: ScriptsInstallParams; result: ScriptMutationResult }; "scripts.list": { params: ScriptsListParams; result: ScriptsListResult }; + "scripts.logs": { params: ScriptsLogsParams; result: ScriptsLogsResult }; "scripts.metadata.get": { params: ScriptUUIDParams; result: ScriptMetadata }; + "scripts.run": { params: ScriptsRunParams; result: ScriptsRunResult }; "scripts.source.get": { params: ScriptsSourceGetParams; result: ScriptSource }; "scripts.source.grep": { params: ScriptsSourceGrepParams; result: ScriptSourceGrepResult }; "scripts.toggle.request": { params: ScriptsToggleParams; result: ScriptsToggleResult }; @@ -207,6 +247,13 @@ export const RPC_METHODS = { effect: "read", blocking: "none", }, + "scripts.logs": { + params: "ScriptsLogsParams", + result: "ScriptsLogsResult", + scope: "scripts:logs:read", + effect: "read", + blocking: "none", + }, "scripts.metadata.get": { params: "ScriptUUIDParams", result: "ScriptMetadata", @@ -214,6 +261,13 @@ export const RPC_METHODS = { effect: "read", blocking: "none", }, + "scripts.run": { + params: "ScriptsRunParams", + result: "ScriptsRunResult", + scope: "scripts:run", + effect: "read", + blocking: "none", + }, "scripts.source.get": { params: "ScriptsSourceGetParams", result: "ScriptSource", diff --git a/src/app/service/service_worker/external_access/generated/validators.generated.ts b/src/app/service/service_worker/external_access/generated/validators.generated.ts index 948cda375..41a5ea93b 100644 --- a/src/app/service/service_worker/external_access/generated/validators.generated.ts +++ b/src/app/service/service_worker/external_access/generated/validators.generated.ts @@ -225,6 +225,106 @@ export function validateScriptsListResult(value: unknown): value is Protocol.Scr ); } +export function validateScriptsLogsParams(value: unknown): value is Protocol.ScriptsLogsParams { + return ( + isRecord(value) && + hasOnlyKeys(value, ["limit", "severity", "since", "text", "uuid"]) && + (value["limit"] === undefined || + (typeof value["limit"] === "number" && Number.isInteger(value["limit"]) && value["limit"] >= 1)) && + (value["severity"] === undefined || + value["severity"] === "debug" || + value["severity"] === "info" || + value["severity"] === "warn" || + value["severity"] === "error") && + (value["since"] === undefined || + (typeof value["since"] === "number" && Number.isInteger(value["since"]) && value["since"] >= 0)) && + (value["text"] === undefined || typeof value["text"] === "string") && + typeof value["uuid"] === "string" && + isUUID(value["uuid"]) + ); +} + +export function validateScriptsLogsResult(value: unknown): value is Protocol.ScriptsLogsResult { + return ( + isRecord(value) && + hasOnlyKeys(value, ["contentTrust", "logs", "truncated"]) && + value["contentTrust"] === "untrusted-user-script-log" && + Array.isArray(value["logs"]) && + value["logs"].every( + (item) => + isRecord(item) && + hasOnlyKeys(item, ["component", "fields", "message", "severity", "time"]) && + (item["component"] === undefined || typeof item["component"] === "string") && + (item["fields"] === undefined || + (Array.isArray(item["fields"]) && + item["fields"].every( + (item) => + isRecord(item) && + hasOnlyKeys(item, ["name", "value"]) && + typeof item["name"] === "string" && + typeof item["value"] === "string" + ))) && + typeof item["message"] === "string" && + (item["severity"] === "debug" || + item["severity"] === "info" || + item["severity"] === "warn" || + item["severity"] === "error") && + typeof item["time"] === "number" && + Number.isInteger(item["time"]) + ) && + typeof value["truncated"] === "boolean" + ); +} + +export function validateScriptsRunParams(value: unknown): value is Protocol.ScriptsRunParams { + return ( + isRecord(value) && + hasOnlyKeys(value, ["timeoutMs", "uuid", "wait"]) && + (value["timeoutMs"] === undefined || + (typeof value["timeoutMs"] === "number" && Number.isInteger(value["timeoutMs"]) && value["timeoutMs"] >= 1)) && + typeof value["uuid"] === "string" && + isUUID(value["uuid"]) && + (value["wait"] === undefined || typeof value["wait"] === "boolean") + ); +} + +export function validateScriptsRunResult(value: unknown): value is Protocol.ScriptsRunResult { + return ( + isRecord(value) && + hasOnlyKeys(value, ["contentTrust", "endedAt", "error", "logs", "startedAt", "status", "truncated"]) && + value["contentTrust"] === "untrusted-user-script-log" && + (value["endedAt"] === undefined || (typeof value["endedAt"] === "number" && Number.isInteger(value["endedAt"]))) && + (value["error"] === undefined || typeof value["error"] === "string") && + Array.isArray(value["logs"]) && + value["logs"].every( + (item) => + isRecord(item) && + hasOnlyKeys(item, ["component", "fields", "message", "severity", "time"]) && + (item["component"] === undefined || typeof item["component"] === "string") && + (item["fields"] === undefined || + (Array.isArray(item["fields"]) && + item["fields"].every( + (item) => + isRecord(item) && + hasOnlyKeys(item, ["name", "value"]) && + typeof item["name"] === "string" && + typeof item["value"] === "string" + ))) && + typeof item["message"] === "string" && + (item["severity"] === "debug" || + item["severity"] === "info" || + item["severity"] === "warn" || + item["severity"] === "error") && + typeof item["time"] === "number" && + Number.isInteger(item["time"]) + ) && + typeof value["startedAt"] === "number" && + Number.isInteger(value["startedAt"]) && + (value["status"] === "complete" || value["status"] === "error" || value["status"] === "running") && + (value["truncated"] === undefined || typeof value["truncated"] === "boolean") + ); +} + export function validateScriptsSourceGetParams(value: unknown): value is Protocol.ScriptsSourceGetParams { return ( isRecord(value) && @@ -277,7 +377,9 @@ export const RPC_PARAM_VALIDATORS = { "scripts.edit.request": validateScriptsEditParams, "scripts.install.request": validateScriptsInstallParams, "scripts.list": validateScriptsListParams, + "scripts.logs": validateScriptsLogsParams, "scripts.metadata.get": validateScriptUUIDParams, + "scripts.run": validateScriptsRunParams, "scripts.source.get": validateScriptsSourceGetParams, "scripts.source.grep": validateScriptsSourceGrepParams, "scripts.toggle.request": validateScriptsToggleParams, @@ -288,7 +390,9 @@ export const RPC_RESULT_VALIDATORS = { "scripts.edit.request": validateScriptMutationResult, "scripts.install.request": validateScriptMutationResult, "scripts.list": validateScriptsListResult, + "scripts.logs": validateScriptsLogsResult, "scripts.metadata.get": validateScriptMetadata, + "scripts.run": validateScriptsRunResult, "scripts.source.get": validateScriptSource, "scripts.source.grep": validateScriptSourceGrepResult, "scripts.toggle.request": validateScriptsToggleResult, diff --git a/src/app/service/service_worker/external_access/run_logs.ts b/src/app/service/service_worker/external_access/run_logs.ts new file mode 100644 index 000000000..f84906594 --- /dev/null +++ b/src/app/service/service_worker/external_access/run_logs.ts @@ -0,0 +1,315 @@ +import type { IMessageQueue } from "@Packages/message/message_queue"; +import type { LogLevel } from "@App/app/logger/core"; +import type { Logger, LoggerDAO } from "@App/app/repo/logger"; +import { + type ScriptDAO, + type SCRIPT_RUN_STATUS, + SCRIPT_RUN_STATUS_ERROR, + SCRIPT_RUN_STATUS_RUNNING, + SCRIPT_TYPE_BACKGROUND, + SCRIPT_TYPE_CRONTAB, +} from "@App/app/repo/scripts"; +import type { TExtensionEnv } from "@App/app/service/extension/extension_env"; +import { backgroundRunInAllowed } from "@App/app/service/sandbox/utils"; +import { getCombinedMeta } from "../utils"; +import { EXTERNAL_ACCESS_COMPONENT } from "./audit"; +import { ExternalAccessBridgeError } from "./errors"; +import { LIMITS, type ScriptsLogsResult, type ScriptsRunResult } from "./generated/protocol.generated"; + +export type LogSeverity = ScriptsLogsResult["logs"][number]["severity"]; +type LogEntry = ScriptsLogsResult["logs"][number]; + +export const RUN_DEFAULT_TIMEOUT_MS = 60_000; +export const RUN_MAX_TIMEOUT_MS = 240_000; +const LOGS_DEFAULT_LIMIT = 100; +export const LOGS_MAX_LIMIT = 1000; +const LOGS_DEFAULT_WINDOW_MS = 3600_000; +// 日志正文由脚本控制、单条可能很大;按条丢弃最旧记录,让整个 JSON-RPC 帧不超过 daemon 的读上限。 +// 预留 64 KiB 给信封与结果中的其他字段。 +const LOGS_BYTE_BUDGET = LIMITS.maxFrameBytes - 64 * 1024; +// 运行的错误信息同样由脚本控制(throw 任意长度的文本);截到这么多 UTF-16 单元,剩余预算留给日志。 +const RUN_ERROR_MAX_LENGTH = 16 * 1024; + +const SEVERITY_RANK: Record = { debug: 0, info: 1, warn: 2, error: 3 }; + +// ScriptService.updateRunStatus 实际发布的载荷:error 来自 sandbox,多为 { error: string }(Logger.E / +// CATRetryError),并非 TScriptRunStatus 声明的形状。 +interface RunStatusMessage { + uuid: string; + runStatus: SCRIPT_RUN_STATUS; + error?: unknown; +} + +interface RunEnd { + status: "complete" | "error"; + error?: string; + endedAt: number; +} + +export interface ScriptRunner { + runScript(uuid: string): Promise; +} + +export interface ScriptRunOptions { + wait: boolean; + timeoutMs: number; + // 调用被 $/cancelRequest 取消或连接断开。 + signal?: AbortSignal; +} + +export interface ScriptLogsQuery { + since?: number; + severity?: LogSeverity; + text?: string; + limit?: number; +} + +function toSeverity(level: LogLevel): LogSeverity | undefined { + if (level === "none") return undefined; + return level === "trace" ? "debug" : level; +} + +const UNENCODABLE = "[unencodable]"; + +/** + * 把脚本可控的值编码成文本。日志标签经结构化克隆落库,可能含 BigInt(后台脚本 return 1n)或循环引用 + * (抛出的非 Error 对象原样记录),JSON.stringify 会对它们抛错;这里 BigInt 取十进制文本,其余无法编码的 + * 换成固定占位,一条日志编码不了不能让整次查询失败。 + */ +function encodeValue(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value === "bigint") return value.toString(); + try { + return ( + JSON.stringify(value, (_key, nested) => (typeof nested === "bigint" ? nested.toString() : nested)) ?? UNENCODABLE + ); + } catch { + return UNENCODABLE; + } +} + +function errorText(error: unknown): string | undefined { + if (error === undefined || error === null) return undefined; + if (typeof error === "object" && "error" in error && typeof error.error === "string") return error.error; + return encodeValue(error); +} + +function truncateError(text: string): string { + if (text.length <= RUN_ERROR_MAX_LENGTH) return text; + let end = RUN_ERROR_MAX_LENGTH; + const last = text.charCodeAt(end - 1); + if (last >= 0xd800 && last <= 0xdbff) end--; + return `${text.slice(0, end)}…`; +} + +const encoder = new TextEncoder(); + +function jsonBytes(value: unknown): number { + return encoder.encode(JSON.stringify(value)).length; +} + +function toEntry(row: Logger, severity: LogSeverity): LogEntry { + const { component, uuid: _uuid, ...rest } = row.label; + const fields = Object.entries(rest) + .filter(([, value]) => value !== undefined) + .map(([name, value]) => ({ name, value: encodeValue(value) })); + return { + time: row.createtime, + severity, + message: row.message, + ...(typeof component === "string" ? { component } : {}), + ...(fields.length ? { fields } : {}), + }; +} + +/** + * 外部接入的 scripts.run / scripts.logs:复用编辑器「运行」通路(RuntimeService.runScript)并从 + * ScriptCat 日志读取脚本的运行记录。 + */ +export class ExternalAccessRunLogs { + constructor( + private readonly scriptDAO: Pick, + private readonly runner: ScriptRunner, + private readonly mq: Pick, + private readonly loggerDAO: Pick, + // 本 SW 的扩展环境,即 sandbox 判断 @run-in 时拿到的同一份(经 offscreen 的 getExtensionEnv 转发)。 + private readonly extensionEnv: TExtensionEnv + ) {} + + /** + * 每个脚本最近一次外部触发的「已开始」信号:见到它的 RUNNING、它的等待结束或它被放弃时兑现。 + * scriptRunStatus 不带运行 id,同一脚本的两次外部运行若同时在等,后一次会把前一次的 RUNNING 当成自己的, + * 再把前一次被停止时的 COMPLETE 当成本次终态;所以下一次等待型外部运行要等这个信号后才订阅并触发。这只覆盖外部运行之间的交错:编辑器 + * 「运行」或 cron 恰好与外部运行交错时仍可能误判,根治需要 sandbox 在运行状态里带上运行 id。 + */ + private readonly lastStart = new Map>(); + + async run(uuid: string, options: ScriptRunOptions): Promise { + // 一个截止时间从收到请求起算,覆盖排队与等待;取消同样结束这两段。 + const stop = new AbortController(); + const timer = setTimeout(() => stop.abort(), options.timeoutMs); + const onCancel = () => stop.abort(); + options.signal?.addEventListener("abort", onCancel, { once: true }); + const release = () => { + clearTimeout(timer); + options.signal?.removeEventListener("abort", onCancel); + }; + const stopped = new Promise((resolve) => stop.signal.addEventListener("abort", () => resolve())); + let detached = false; + try { + const script = await this.scriptDAO.get(uuid); + if (!script) throw new ExternalAccessBridgeError("NOT_FOUND", "script not found"); + if (script.type !== SCRIPT_TYPE_BACKGROUND && script.type !== SCRIPT_TYPE_CRONTAB) { + throw new ExternalAccessBridgeError("INVALID_REQUEST", "only background or scheduled scripts can run"); + } + // sandbox 拿到的是合并了用户自定义元数据的 ScriptRunResource.metadata(buildScriptRunResourceBasic)。 + if (!backgroundRunInAllowed(getCombinedMeta(script.metadata, script.selfMetadata), this.extensionEnv)) { + const context = this.extensionEnv.inIncognitoContext ? "incognito" : "normal"; + throw new ExternalAccessBridgeError( + "INVALID_REQUEST", + `script @run-in does not match this ScriptCat's ${context} context, so ScriptCat would not run it` + ); + } + // wait=false 不排队;但仍登记自己的开始信号,让随后的等待型运行排在它之后。 + const previous = options.wait ? this.lastStart.get(uuid) : undefined; + let markStarted!: () => void; + const started = new Promise((resolve) => (markStarted = resolve)); + this.lastStart.set(uuid, started); + void started.then(() => { + if (this.lastStart.get(uuid) === started) this.lastStart.delete(uuid); + }); + if (previous) await Promise.race([previous, stopped]); + if (options.signal?.aborted) { + markStarted(); + throw new ExternalAccessBridgeError("OPERATION_EXPIRED", "run canceled before it was triggered"); + } + + const startedAt = Date.now(); + // 先订阅再触发,避免错过本次的 RUNNING。排队已耗尽截止时间时照样触发,随即按超时返回 running。 + const waiter = this.waitForRunEnd(uuid, stopped, markStarted); + try { + await this.runner.runScript(uuid); + } catch (e) { + waiter.dispose(); + throw e; + } + if (!options.wait) { + // 不等终态,但订阅与截止计时保留到本次 RUNNING 到达(或截止),让随后的外部运行不会认错 RUNNING。 + detached = true; + void started.then(() => { + waiter.dispose(); + release(); + }); + return { status: "running", startedAt, logs: [], contentTrust: "untrusted-user-script-log" }; + } + try { + const end = await waiter.ended; + // 调用方已离开,运行在 ScriptCat 中继续;这个应答无人接收,不再查日志。 + if (options.signal?.aborted) { + return { status: "running", startedAt, logs: [], contentTrust: "untrusted-user-script-log" }; + } + const error = end?.error === undefined ? undefined : truncateError(end.error); + // 终态(COMPLETE/ERROR)先于 sandbox 的 "exec script complete/error" 日志发布,这条收尾日志可能 + // 尚未落库;脚本运行期间写下的日志(GM_log 等)此时已在库中。 + const { logs, truncated } = await this.queryLogs( + uuid, + { since: startedAt }, + LOGS_BYTE_BUDGET - (error === undefined ? 0 : jsonBytes(error)) + ); + if (!end) return { status: "running", startedAt, logs, truncated, contentTrust: "untrusted-user-script-log" }; + return { + status: end.status, + ...(error !== undefined ? { error } : {}), + startedAt, + endedAt: end.endedAt, + logs, + truncated, + contentTrust: "untrusted-user-script-log", + }; + } finally { + waiter.dispose(); + } + } finally { + if (!detached) release(); + } + } + + async logs(uuid: string, query: ScriptLogsQuery): Promise { + const script = await this.scriptDAO.get(uuid); + if (!script) throw new ExternalAccessBridgeError("NOT_FOUND", "script not found"); + const { logs, truncated } = await this.queryLogs( + uuid, + { ...query, since: query.since ?? Date.now() - LOGS_DEFAULT_WINDOW_MS }, + LOGS_BYTE_BUDGET + ); + return { logs, truncated, contentTrust: "untrusted-user-script-log" }; + } + + /** + * scriptRunStatus 不带运行 id:重跑时 sandbox 的 stopScript 先为旧运行发 COMPLETE,再为本次发 RUNNING; + * 被停止的旧运行之后才结束时不再发布任何状态(Runtime.execScript 只让当前运行发布终态)。因此见到本次 + * RUNNING 之后的第一个非 RUNNING 状态就是本次运行的终态。stopped(截止或取消)时 resolve undefined。 + * 见到 RUNNING、结束或 dispose 时调用 onStarted。 + */ + private waitForRunEnd(uuid: string, stopped: Promise, onStarted: () => void) { + let resolveEnd!: (end: RunEnd | undefined) => void; + const ended = new Promise((resolve) => (resolveEnd = resolve)); + void ended.then(onStarted); + void stopped.then(() => resolveEnd(undefined)); + let sawRunning = false; + const unsubscribe = this.mq.subscribe("scriptRunStatus", (message) => { + if (message.uuid !== uuid) return; + if (message.runStatus === SCRIPT_RUN_STATUS_RUNNING) { + sawRunning = true; + onStarted(); + return; + } + if (!sawRunning) return; + if (message.runStatus === SCRIPT_RUN_STATUS_ERROR) { + resolveEnd({ status: "error", error: errorText(message.error), endedAt: Date.now() }); + } else { + resolveEnd({ status: "complete", endedAt: Date.now() }); + } + }); + return { + ended, + dispose() { + unsubscribe(); + onStarted(); + }, + }; + } + + private async queryLogs( + uuid: string, + query: ScriptLogsQuery & { since: number }, + byteBudget: number + ): Promise<{ logs: LogEntry[]; truncated: boolean }> { + const minRank = SEVERITY_RANK[query.severity ?? "debug"]; + const needle = query.text?.toLowerCase(); + const limit = query.limit ?? LOGS_DEFAULT_LIMIT; + // 从最新往回取满 limit 与字节预算即停,再翻成 oldest first。 + const newestFirst: LogEntry[] = []; + let bytes = 0; + let truncated = false; + await this.loggerDAO.walkNewestFirst(query.since, (row) => { + if (row.label.uuid !== uuid || row.label.component === EXTERNAL_ACCESS_COMPONENT) return true; + const severity = toSeverity(row.level); + if (!severity || SEVERITY_RANK[severity] < minRank) return true; + if (needle !== undefined && !row.message.toLowerCase().includes(needle)) return true; + if (newestFirst.length >= limit) { + truncated = true; + return false; + } + const entry = toEntry(row, severity); + bytes += jsonBytes(entry) + 1; + if (bytes > byteBudget) { + truncated = true; + return false; + } + newestFirst.push(entry); + return true; + }); + return { logs: newestFirst.reverse(), truncated }; + } +} diff --git a/src/app/service/service_worker/external_access/types.ts b/src/app/service/service_worker/external_access/types.ts index d52aa1c98..f133f90c2 100644 --- a/src/app/service/service_worker/external_access/types.ts +++ b/src/app/service/service_worker/external_access/types.ts @@ -82,6 +82,8 @@ export const EXTERNAL_ACCESS_SCOPES = [ "scripts:toggle:request", "scripts:delete:request", "scripts:edit:request", + "scripts:run", + "scripts:logs:read", ] as const; export type ExternalAccessScope = (typeof EXTERNAL_ACCESS_SCOPES)[number]; @@ -99,6 +101,8 @@ export const BRIDGE_ACTIONS = [ "scripts.toggle.request", "scripts.delete.request", "scripts.edit.request", + "scripts.run", + "scripts.logs", ] as const; export type BridgeAction = (typeof BRIDGE_ACTIONS)[number]; @@ -262,6 +266,8 @@ export const ACTION_REQUIRED_SCOPE: Record = "scripts.toggle.request": "scripts:toggle:request", "scripts.delete.request": "scripts:delete:request", "scripts.edit.request": "scripts:edit:request", + "scripts.run": "scripts:run", + "scripts.logs": "scripts:logs:read", } as const; export const WRITE_ACTIONS: readonly BridgeAction[] = [ diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts index 75f20357b..70b9d7e92 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -33,6 +33,7 @@ import { } from "@App/app/service/service_worker/external_access/bridge"; import { ExternalAccessController } from "@App/app/service/service_worker/external_access/controller"; import { ExternalAccessUIService } from "@App/app/service/service_worker/external_access/service"; +import { ExternalAccessRunLogs } from "@App/app/service/service_worker/external_access/run_logs"; import { ExternalAccessConnectClient } from "@App/app/service/offscreen/client"; import { hookFirefoxEventPageKeepAliveLoop, hookServiceWorkerKeepAliveLoop } from "../offscreen/keep_alive"; import { NetworkRuleStateDAO } from "@App/app/repo/network_rule"; @@ -212,6 +213,7 @@ export default class ServiceWorkerManager { scriptDAO, script.scriptCodeDAO, externalAccessApproval, + new ExternalAccessRunLogs(scriptDAO, runtime, this.mq, new LoggerDAO(), extensionEnv), () => systemConfig.getExternalAccessWritePolicy(), () => systemConfig.getExternalAccessSourceReadPolicy(), notifyExternalAccessWrite diff --git a/vitest.config.ts b/vitest.config.ts index ecc4b8196..73dcf34f4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -24,9 +24,10 @@ const tplPlugin = { // - web-jszipp(vmThreads 的 VM 上下文没有 TransformStream):backup.test.ts、skill-zip.test.ts // - module-level sharedInitCopy captured against wrong window: create_context.test.ts // - 沙盒断言依赖 vmThreads 中不可用的原生 happy-dom Window 语义:exec_script.test.ts -// - 替换 Dexie 的 IndexedDB 来源并打开 dao.ts 的 Dexie 单例,共享模块会泄漏给其它文件:migrate.test.ts +// - 替换 Dexie 的 IndexedDB 来源并打开 dao.ts 的 Dexie 单例,共享模块会泄漏给其它文件:migrate.test.ts、logger.test.ts const ISOLATED = [ "src/app/migrate.test.ts", + "src/app/repo/logger.test.ts", "src/pkg/backup/backup.test.ts", "src/pkg/utils/skill-zip.test.ts", "src/app/service/content/create_context.test.ts",