From 373a117c885cacfa12a53fb7cdcdb4036254b8b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 4 Nov 2026 20:21:44 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20=E5=A4=96=E9=83=A8=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=EF=BC=9A=E6=A1=A5=E6=8E=A5=E6=94=AF=E6=8C=81=20script?= =?UTF-8?q?s.run=20=E8=BF=90=E8=A1=8C=E5=90=8E=E5=8F=B0/=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E5=B9=B6=E8=BF=94=E5=9B=9E=E6=9C=AC=E6=AC=A1?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E4=B8=8E=E6=97=A5=E5=BF=97=E3=80=81scripts.l?= =?UTF-8?q?ogs=20=E8=AF=BB=E5=8F=96=E8=84=9A=E6=9C=AC=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../external_access/bridge.test.ts | 327 +++++++++++++++++- .../service_worker/external_access/bridge.ts | 54 +++ .../generated/protocol.generated.ts | 54 +++ .../generated/validators.generated.ts | 104 ++++++ .../external_access/run_logs.ts | 204 +++++++++++ .../service_worker/external_access/types.ts | 6 + src/app/service/service_worker/index.ts | 2 + 7 files changed, 749 insertions(+), 2 deletions(-) create mode 100644 src/app/service/service_worker/external_access/run_logs.ts 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..02abd2b1f 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,23 @@ 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 { 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 +38,14 @@ function expectResponse(r: ExternalAccessBridgeResponse | null): ExternalAccessB return r as ExternalAccessBridgeResponse; } +// 与 LoggerDAO.queryLogs 同一契约:createtime ∈ [start, end),newest first。 +function memoryLoggerDAO(rows: Logger[]): Pick { + return { + queryLogs: async (start, end) => + rows.filter((row) => row.createtime >= start && row.createtime < end).sort((a, b) => b.createtime - a.createtime), + }; +} + function makeRequest( action: BridgeAction, input: unknown, @@ -44,6 +64,13 @@ describe("ExternalAccessBridge(扁平信任 + 双策略)", () => { let sourcePolicy: ExternalAccessSourceReadPolicy; let notifyWrite: ReturnType; let audit: ReturnType; + let mq: MessageQueue; + let runScript: ReturnType Promise>>; + let logRows: Logger[]; + + 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 +90,14 @@ 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 = []; bridge = new ExternalAccessBridge( scriptDAO, scriptCodeDAO, approval, + new ExternalAccessRunLogs(scriptDAO, { runScript }, mq, memoryLoggerDAO(logRows)), async () => writePolicy, async () => sourcePolicy, notifyWrite as (n: ExternalAccessWriteNotice) => void, @@ -459,4 +490,296 @@ 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(run-in 不匹配时只有旧运行的 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("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("页面脚本返回 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("脚本不存在返回 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..eb9602960 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,6 +268,7 @@ 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 = () => {}, @@ -392,6 +434,18 @@ export class ExternalAccessBridge { requestId, }) ); + case "scripts.run": + return this.runLogs.run(input.uuid as string, { + wait: (input.wait as boolean | undefined) ?? true, + timeoutMs: (input.timeoutMs as number | undefined) ?? RUN_DEFAULT_TIMEOUT_MS, + }); + 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..f049b15c7 --- /dev/null +++ b/src/app/service/service_worker/external_access/run_logs.ts @@ -0,0 +1,204 @@ +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 { 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; +export 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; + +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 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; +} + +function errorText(error: unknown): string | undefined { + if (error === undefined || error === null) return undefined; + if (typeof error === "string") return error; + if (typeof error === "object" && "error" in error && typeof error.error === "string") return error.error; + return JSON.stringify(error); +} + +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: typeof value === "string" ? value : JSON.stringify(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 + ) {} + + async run(uuid: string, options: { wait: boolean; timeoutMs: number }): Promise { + 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"); + } + const startedAt = Date.now(); + if (!options.wait) { + await this.runner.runScript(uuid); + return { status: "running", startedAt, logs: [], contentTrust: "untrusted-user-script-log" }; + } + // 先订阅再触发,避免错过本次的 RUNNING。 + const waiter = this.waitForRunEnd(uuid, options.timeoutMs); + try { + await this.runner.runScript(uuid); + const end = await waiter.ended; + // 终态(COMPLETE/ERROR)先于 sandbox 的 "exec script complete/error" 日志发布,这条收尾日志可能 + // 尚未落库;脚本运行期间写下的日志(GM_log 等)此时已在库中。 + const { logs, truncated } = await this.queryLogs(uuid, { since: startedAt }); + if (!end) return { status: "running", startedAt, logs, truncated, contentTrust: "untrusted-user-script-log" }; + return { + status: end.status, + ...(end.error !== undefined ? { error: end.error } : {}), + startedAt, + endedAt: end.endedAt, + logs, + truncated, + contentTrust: "untrusted-user-script-log", + }; + } finally { + waiter.dispose(); + } + } + + 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, + }); + return { logs, truncated, contentTrust: "untrusted-user-script-log" }; + } + + /** + * scriptRunStatus 不带运行 id:重跑时 sandbox 先为旧运行发 COMPLETE 再为本次发 RUNNING,因此只有 + * 见到本次 RUNNING 之后的第一个非 RUNNING 状态才是本次运行的终态。run-in 与当前环境不匹配时 sandbox + * 不运行也不发 RUNNING,这种情况按超时返回 running。超时 resolve undefined。 + */ + private waitForRunEnd(uuid: string, timeoutMs: number) { + let resolveEnd!: (end: RunEnd | undefined) => void; + const ended = new Promise((resolve) => (resolveEnd = resolve)); + let sawRunning = false; + const unsubscribe = this.mq.subscribe("scriptRunStatus", (message) => { + if (message.uuid !== uuid) return; + if (message.runStatus === SCRIPT_RUN_STATUS_RUNNING) { + sawRunning = true; + 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() }); + } + }); + const timer = setTimeout(() => resolveEnd(undefined), timeoutMs); + return { + ended, + dispose() { + clearTimeout(timer); + unsubscribe(); + }, + }; + } + + private async queryLogs( + uuid: string, + query: ScriptLogsQuery & { since: 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。queryLogs 只按 createtime 排序,同一毫秒内 + // 的先后要靠自增 id 区分,这里自行排序。 + const rows = (await this.loggerDAO.queryLogs(query.since, Number.MAX_SAFE_INTEGER)).sort( + (a, b) => b.createtime - a.createtime || b.id - a.id + ); + const newestFirst: LogEntry[] = []; + const encoder = new TextEncoder(); + let bytes = 0; + let truncated = false; + for (const row of rows) { + if (row.label.uuid !== uuid || row.label.component === EXTERNAL_ACCESS_COMPONENT) continue; + const severity = toSeverity(row.level); + if (!severity || SEVERITY_RANK[severity] < minRank) continue; + if (needle !== undefined && !row.message.toLowerCase().includes(needle)) continue; + if (newestFirst.length >= limit) { + truncated = true; + break; + } + const entry = toEntry(row, severity); + bytes += encoder.encode(JSON.stringify(entry)).length + 1; + if (bytes > LOGS_BYTE_BUDGET) { + truncated = true; + break; + } + newestFirst.push(entry); + } + 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..022251a7a 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()), () => systemConfig.getExternalAccessWritePolicy(), () => systemConfig.getExternalAccessSourceReadPolicy(), notifyExternalAccessWrite From 1570587c9bb2bd7735a5e99bce0626e78d67882a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 4 Nov 2026 20:38:28 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=90=9B=20=E5=A4=96=E9=83=A8=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=20scripts.run=EF=BC=9A=E6=88=AA=E6=96=AD=E8=B6=85?= =?UTF-8?q?=E5=A4=A7=E9=94=99=E8=AF=AF=E6=96=87=E6=9C=AC=E4=BD=BF=E5=BA=94?= =?UTF-8?q?=E7=AD=94=E4=B8=8D=E8=B6=85=E5=B8=A7=E4=B8=8A=E9=99=90=EF=BC=8C?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E8=84=9A=E6=9C=AC=E7=9A=84=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E7=AD=89=E4=B8=8A=E4=B8=80=E6=AC=A1=E5=BC=80?= =?UTF-8?q?=E5=A7=8B=E5=90=8E=E5=86=8D=E8=A7=A6=E5=8F=91=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E8=AF=AF=E8=AE=A4=E5=AF=B9=E6=96=B9=E7=9A=84=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../external_access/bridge.test.ts | 50 +++++++++++ .../external_access/run_logs.ts | 83 +++++++++++++++---- 2 files changed, 116 insertions(+), 17 deletions(-) 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 02abd2b1f..83ca390f9 100644 --- a/src/app/service/service_worker/external_access/bridge.test.ts +++ b/src/app/service/service_worker/external_access/bridge.test.ts @@ -16,6 +16,7 @@ import { type BridgeAction, type ExternalAccessBridgeRequest, type ExternalAcces import type { ExternalAccessWritePolicy, ExternalAccessSourceReadPolicy } from "@App/pkg/config/config"; import { EXTERNAL_ACCESS_COMPONENT, type ExternalAccessAuditEvent } from "./audit"; import { ExternalAccessRunLogs } from "./run_logs"; +import { LIMITS } from "./generated/protocol.generated"; import type { Logger, LoggerDAO } from "@App/app/repo/logger"; import type { LogLabel, LogLevel } from "@App/app/logger/core"; import { MessageQueue } from "@Packages/message/message_queue"; @@ -573,6 +574,55 @@ console.log('v1');`; } }); + it("脚本抛出超大错误信息时截断错误文本,整个应答帧仍在 daemon 的帧上限内,日志照常返回", async () => { + await seedScript(RUN_UUID, { type: SCRIPT_TYPE_BACKGROUND }); + runScript.mockImplementation(async () => { + publishRunStatus("running"); + pushLog("info", "y".repeat(2 * 1024 * 1024), { uuid: RUN_UUID, component: "GM_log" }); + publishRunStatus("error", { error: "e".repeat(LIMITS.maxFrameBytes) }); + }); + + 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[] }; + const frame = JSON.stringify({ jsonrpc: "2.0", id: uuidv4(), result }); + expect(new TextEncoder().encode(frame).length).toBeLessThanOrEqual(LIMITS.maxFrameBytes); + expect(result.status).toBe("error"); + expect(result.error.startsWith("eeee")).toBe(true); + 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 }); diff --git a/src/app/service/service_worker/external_access/run_logs.ts b/src/app/service/service_worker/external_access/run_logs.ts index f049b15c7..1a5eab615 100644 --- a/src/app/service/service_worker/external_access/run_logs.ts +++ b/src/app/service/service_worker/external_access/run_logs.ts @@ -18,12 +18,14 @@ type LogEntry = ScriptsLogsResult["logs"][number]; export const RUN_DEFAULT_TIMEOUT_MS = 60_000; export const RUN_MAX_TIMEOUT_MS = 240_000; -export const LOGS_DEFAULT_LIMIT = 100; +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 }; @@ -64,6 +66,20 @@ function errorText(error: unknown): string | undefined { return JSON.stringify(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) @@ -90,29 +106,57 @@ export class ExternalAccessRunLogs { private readonly loggerDAO: Pick ) {} + /** + * 每个脚本最近一次外部触发的「已开始」信号:见到它的 RUNNING、或它的等待结束时兑现。scriptRunStatus 不带 + * 运行 id,同一脚本的两次外部运行若同时在等,后一次会把前一次的 RUNNING 当成自己的,再把前一次被停止时的 + * COMPLETE 当成本次终态;所以下一次外部运行要等这个信号后才订阅并触发。这只覆盖外部运行之间的交错:编辑器 + * 「运行」或 cron 恰好与外部运行交错时仍可能误判,根治需要 sandbox 在运行状态里带上运行 id。 + */ + private readonly lastStart = new Map>(); + async run(uuid: string, options: { wait: boolean; timeoutMs: number }): Promise { 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"); } + const previous = this.lastStart.get(uuid); + 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); + }); + await previous; + const startedAt = Date.now(); - if (!options.wait) { + // 先订阅再触发,避免错过本次的 RUNNING。 + const waiter = this.waitForRunEnd(uuid, options.timeoutMs, markStarted); + try { await this.runner.runScript(uuid); + } catch (e) { + waiter.dispose(); + throw e; + } + if (!options.wait) { + // 不等终态,但订阅保留到本次 RUNNING 到达(或超时),让随后的外部运行不会认错 RUNNING。 + void started.then(waiter.dispose); return { status: "running", startedAt, logs: [], contentTrust: "untrusted-user-script-log" }; } - // 先订阅再触发,避免错过本次的 RUNNING。 - const waiter = this.waitForRunEnd(uuid, options.timeoutMs); try { - await this.runner.runScript(uuid); const end = await waiter.ended; + 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 }); + 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, - ...(end.error !== undefined ? { error: end.error } : {}), + ...(error !== undefined ? { error } : {}), startedAt, endedAt: end.endedAt, logs, @@ -127,26 +171,30 @@ export class ExternalAccessRunLogs { 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, - }); + 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 先为旧运行发 COMPLETE 再为本次发 RUNNING,因此只有 * 见到本次 RUNNING 之后的第一个非 RUNNING 状态才是本次运行的终态。run-in 与当前环境不匹配时 sandbox - * 不运行也不发 RUNNING,这种情况按超时返回 running。超时 resolve undefined。 + * 不运行也不发 RUNNING,这种情况按超时返回 running。超时 resolve undefined。见到 RUNNING、超时或 dispose + * 时调用 onStarted。 */ - private waitForRunEnd(uuid: string, timeoutMs: number) { + private waitForRunEnd(uuid: string, timeoutMs: number, onStarted: () => void) { let resolveEnd!: (end: RunEnd | undefined) => void; const ended = new Promise((resolve) => (resolveEnd = resolve)); + void ended.then(onStarted); 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; @@ -162,13 +210,15 @@ export class ExternalAccessRunLogs { dispose() { clearTimeout(timer); unsubscribe(); + onStarted(); }, }; } private async queryLogs( uuid: string, - query: ScriptLogsQuery & { since: number } + query: ScriptLogsQuery & { since: number }, + byteBudget: number ): Promise<{ logs: LogEntry[]; truncated: boolean }> { const minRank = SEVERITY_RANK[query.severity ?? "debug"]; const needle = query.text?.toLowerCase(); @@ -179,7 +229,6 @@ export class ExternalAccessRunLogs { (a, b) => b.createtime - a.createtime || b.id - a.id ); const newestFirst: LogEntry[] = []; - const encoder = new TextEncoder(); let bytes = 0; let truncated = false; for (const row of rows) { @@ -192,8 +241,8 @@ export class ExternalAccessRunLogs { break; } const entry = toEntry(row, severity); - bytes += encoder.encode(JSON.stringify(entry)).length + 1; - if (bytes > LOGS_BYTE_BUDGET) { + bytes += jsonBytes(entry) + 1; + if (bytes > byteBudget) { truncated = true; break; } From 7a5cec5261aabdc52cce4849220871a7bc8d0052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 5 Nov 2026 09:50:50 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=85=20=E5=A4=96=E9=83=A8=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=20scripts.run=20=E9=94=99=E8=AF=AF=E6=88=AA=E6=96=AD?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E6=94=B9=E7=94=A8=E5=88=9A=E8=B6=8A=E8=BF=87?= =?UTF-8?q?=E6=88=AA=E6=96=AD=E8=BE=B9=E7=95=8C=E7=9A=84=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原用例按帧上限构造 4 MiB 错误文本与 2 MiB 日志,单跑约 40ms,但在 CI 覆盖率与分片负载下 达 280–630ms,超出 340ms 单测预算(PR #1825 CI shard 2/2)。2 MiB 日志本身不区分实现对错, 改为错误文本刚超过 16384 码元并直接断言截断结果;去掉截断时该用例仍失败。 --- .../service_worker/external_access/bridge.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 83ca390f9..dd80eedd2 100644 --- a/src/app/service/service_worker/external_access/bridge.test.ts +++ b/src/app/service/service_worker/external_access/bridge.test.ts @@ -16,7 +16,6 @@ import { type BridgeAction, type ExternalAccessBridgeRequest, type ExternalAcces import type { ExternalAccessWritePolicy, ExternalAccessSourceReadPolicy } from "@App/pkg/config/config"; import { EXTERNAL_ACCESS_COMPONENT, type ExternalAccessAuditEvent } from "./audit"; import { ExternalAccessRunLogs } from "./run_logs"; -import { LIMITS } from "./generated/protocol.generated"; import type { Logger, LoggerDAO } from "@App/app/repo/logger"; import type { LogLabel, LogLevel } from "@App/app/logger/core"; import { MessageQueue } from "@Packages/message/message_queue"; @@ -574,12 +573,15 @@ console.log('v1');`; } }); - it("脚本抛出超大错误信息时截断错误文本,整个应答帧仍在 daemon 的帧上限内,日志照常返回", async () => { + 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", "y".repeat(2 * 1024 * 1024), { uuid: RUN_UUID, component: "GM_log" }); - publishRunStatus("error", { error: "e".repeat(LIMITS.maxFrameBytes) }); + 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 }))); @@ -587,10 +589,8 @@ console.log('v1');`; expect(response.ok).toBe(true); if (!response.ok) return; const result = response.result as { status: string; error: string; logs: unknown[] }; - const frame = JSON.stringify({ jsonrpc: "2.0", id: uuidv4(), result }); - expect(new TextEncoder().encode(frame).length).toBeLessThanOrEqual(LIMITS.maxFrameBytes); expect(result.status).toBe("error"); - expect(result.error.startsWith("eeee")).toBe(true); + expect(result.error).toBe(`${"e".repeat(cap)}…`); expect(result.logs).toHaveLength(1); }); From 4f7ccc1bd4802a860a784532e134bf5021762b6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 5 Nov 2026 10:37:30 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=90=9B=20=E5=90=8E=E5=8F=B0=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E8=A2=AB=E9=87=8D=E8=B7=91=E6=88=96=E5=81=9C=E7=94=A8?= =?UTF-8?q?=E5=90=8E=EF=BC=8C=E6=97=A7=E8=BF=90=E8=A1=8C=E8=BF=9F=E5=88=B0?= =?UTF-8?q?=E7=9A=84=E7=BB=93=E6=9D=9F=E4=B8=8D=E5=86=8D=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E8=BF=90=E8=A1=8C=E7=8A=B6=E6=80=81=E3=80=81?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=AE=89=E6=8E=92=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stopScript 只清掉旧运行的计时器并移出 execScriptMap,旧运行的 Promise 之后结束时仍会发布 COMPLETE/ERROR,CATRetryError 还会把已停用的定时脚本加回重试队列。运行状态不带运行 id, 脚本列表与外部接入 scripts.run 会把旧运行的终态当成新运行的。现在只有仍登记为该脚本当前 运行的 exec 才发布终态与安排重试;旧运行的结果照常写日志。 --- src/app/service/sandbox/runtime.test.ts | 97 ++++++++++++++++++++++++- src/app/service/sandbox/runtime.ts | 14 +++- 2 files changed, 106 insertions(+), 5 deletions(-) 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..536beda0b 100644 --- a/src/app/service/sandbox/runtime.ts +++ b/src/app/service/sandbox/runtime.ts @@ -199,23 +199,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 +230,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, From 3b0038f5802c522ff89f2e359ddfad9fbfcd1a50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 5 Nov 2026 11:02:24 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=90=9B=20=E5=A4=96=E9=83=A8=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=20scripts.run/logs=EF=BC=9Arun-in=20=E4=B8=8D?= =?UTF-8?q?=E7=AC=A6=E7=AB=8B=E5=8D=B3=E6=8B=92=E7=BB=9D=E3=80=81=E6=88=AA?= =?UTF-8?q?=E6=AD=A2=E6=97=B6=E9=97=B4=E8=A6=86=E7=9B=96=E6=8E=92=E9=98=9F?= =?UTF-8?q?=E3=80=81=E5=8F=96=E6=B6=88=E6=94=BE=E5=BC=83=E6=8E=92=E9=98=9F?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E3=80=81=E6=97=A5=E5=BF=97=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E5=AE=B9=E9=94=99=E3=80=81=E6=9C=89=E7=95=8C=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 sctl spec(729a663)修订后的约定: - 脚本 @run-in 与本实例环境不符时 sandbox 不会运行它,scripts.run 现在立即返回 INVALID_REQUEST 而不是等满超时报 running。判断抽成 sandbox/run_in.ts 的 backgroundRunInAllowed,sandbox 与外部接入 共用(含 Firefox spanning 例外),外部接入按 sandbox 同样合并 selfMetadata。 - 一个截止时间从收到请求起算,覆盖排队与等待,排队调用不再可能耗时“上一次超时 + 本次超时”; wait=false 不排队。排队耗尽截止时间时照样触发并返回 running。 - $/cancelRequest 与断开连接现在也送达进行中的 scripts.run:尚未触发的排队运行被放弃 (OPERATION_EXPIRED,不再触发),正在等待的调用立即结束并释放订阅与计时器;审批取消语义不变。 - 日志标签含 BigInt 或循环引用时不再让整次查询 INTERNAL_ERROR:BigInt 取十进制文本, 无法编码的值返回 "[unencodable]";运行错误文本同样处理。 - 日志改用 LoggerDAO.walkNewestFirst 沿 createtime 索引从新到旧游标读取,凑够 limit/字节预算即停, 不再把 since 之后全部脚本的日志载入内存再排序;同毫秒按 id 先后由索引顺序保证。 --- src/app/repo/logger.test.ts | 63 ++++++ src/app/repo/logger.ts | 16 ++ src/app/service/sandbox/run_in.ts | 19 ++ src/app/service/sandbox/runtime.ts | 16 +- .../external_access/bridge.test.ts | 198 +++++++++++++++- .../service_worker/external_access/bridge.ts | 34 ++- .../external_access/run_logs.ts | 212 +++++++++++------- src/app/service/service_worker/index.ts | 2 +- vitest.config.ts | 3 +- 9 files changed, 459 insertions(+), 104 deletions(-) create mode 100644 src/app/repo/logger.test.ts create mode 100644 src/app/service/sandbox/run_in.ts 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/run_in.ts b/src/app/service/sandbox/run_in.ts new file mode 100644 index 000000000..7219626ed --- /dev/null +++ b/src/app/service/sandbox/run_in.ts @@ -0,0 +1,19 @@ +import type { SCMetadata } from "@App/app/repo/metadata"; +import type { TExtensionEnv } from "../extension/extension_env"; + +/** + * 后台/定时脚本的 @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; +} diff --git a/src/app/service/sandbox/runtime.ts b/src/app/service/sandbox/runtime.ts index 536beda0b..660b89f8e 100644 --- a/src/app/service/sandbox/runtime.ts +++ b/src/app/service/sandbox/runtime.ts @@ -23,6 +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"; +import { backgroundRunInAllowed } from "./run_in"; const utime_1min = 60 * 1000; const utime_1hr = 60 * 60 * 1000; @@ -178,19 +179,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); 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 dd80eedd2..1f8613c76 100644 --- a/src/app/service/service_worker/external_access/bridge.test.ts +++ b/src/app/service/service_worker/external_access/bridge.test.ts @@ -18,6 +18,7 @@ import { EXTERNAL_ACCESS_COMPONENT, type ExternalAccessAuditEvent } from "./audi 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"; @@ -38,11 +39,19 @@ function expectResponse(r: ExternalAccessBridgeResponse | null): ExternalAccessB return r as ExternalAccessBridgeResponse; } -// 与 LoggerDAO.queryLogs 同一契约:createtime ∈ [start, end),newest first。 -function memoryLoggerDAO(rows: Logger[]): Pick { +// 与 LoggerDAO.walkNewestFirst 同一契约:createtime ≥ since,从新到旧、同一毫秒 id 大的在前,visit 返回 false 即停。 +// visited 记录实际读到的行数,用来断言读取有界。 +function memoryLoggerDAO(rows: Logger[], visited: { count: number }): Pick { return { - queryLogs: async (start, end) => - rows.filter((row) => row.createtime >= start && row.createtime < end).sort((a, b) => b.createtime - a.createtime), + 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; + } + }, }; } @@ -67,6 +76,7 @@ describe("ExternalAccessBridge(扁平信任 + 双策略)", () => { 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 }); @@ -93,11 +103,14 @@ describe("ExternalAccessBridge(扁平信任 + 双策略)", () => { 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)), + new ExternalAccessRunLogs(scriptDAO, { runScript }, mq, memoryLoggerDAO(logRows, logsVisited), { + inIncognitoContext: false, + }), async () => writePolicy, async () => sourcePolicy, notifyWrite as (n: ExternalAccessWriteNotice) => void, @@ -230,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); @@ -543,7 +568,7 @@ console.log('v1');`; expect(response.result).toHaveProperty("endedAt"); }); - it("到 timeoutMs 仍未见本次运行结束返回 running(run-in 不匹配时只有旧运行的 complete、没有 RUNNING)", async () => { + 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)); @@ -635,6 +660,132 @@ console.log('v1');`; 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 }); @@ -788,6 +939,41 @@ console.log('v1');`; 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); diff --git a/src/app/service/service_worker/external_access/bridge.ts b/src/app/service/service_worker/external_access/bridge.ts index eb9602960..bdb59959d 100644 --- a/src/app/service/service_worker/external_access/bridge.ts +++ b/src/app/service/service_worker/external_access/bridge.ts @@ -275,11 +275,21 @@ export class ExternalAccessBridge { 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 { @@ -434,11 +444,19 @@ export class ExternalAccessBridge { requestId, }) ); - case "scripts.run": - return this.runLogs.run(input.uuid as string, { - wait: (input.wait as boolean | undefined) ?? true, - timeoutMs: (input.timeoutMs as number | undefined) ?? RUN_DEFAULT_TIMEOUT_MS, - }); + 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, diff --git a/src/app/service/service_worker/external_access/run_logs.ts b/src/app/service/service_worker/external_access/run_logs.ts index 1a5eab615..5c47ff267 100644 --- a/src/app/service/service_worker/external_access/run_logs.ts +++ b/src/app/service/service_worker/external_access/run_logs.ts @@ -9,6 +9,9 @@ import { 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/run_in"; +import { getCombinedMeta } from "../utils"; import { EXTERNAL_ACCESS_COMPONENT } from "./audit"; import { ExternalAccessBridgeError } from "./errors"; import { LIMITS, type ScriptsLogsResult, type ScriptsRunResult } from "./generated/protocol.generated"; @@ -47,6 +50,13 @@ export interface ScriptRunner { runScript(uuid: string): Promise; } +export interface ScriptRunOptions { + wait: boolean; + timeoutMs: number; + // 调用被 $/cancelRequest 取消或连接断开。 + signal?: AbortSignal; +} + export interface ScriptLogsQuery { since?: number; severity?: LogSeverity; @@ -59,11 +69,29 @@ function toSeverity(level: LogLevel): LogSeverity | 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 === "string") return error; if (typeof error === "object" && "error" in error && typeof error.error === "string") return error.error; - return JSON.stringify(error); + return encodeValue(error); } function truncateError(text: string): string { @@ -84,7 +112,7 @@ 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: typeof value === "string" ? value : JSON.stringify(value) })); + .map(([name, value]) => ({ name, value: encodeValue(value) })); return { time: row.createtime, severity, @@ -103,68 +131,106 @@ export class ExternalAccessRunLogs { private readonly scriptDAO: Pick, private readonly runner: ScriptRunner, private readonly mq: Pick, - private readonly loggerDAO: Pick + private readonly loggerDAO: Pick, + // 本 SW 的扩展环境,即 sandbox 判断 @run-in 时拿到的同一份(经 offscreen 的 getExtensionEnv 转发)。 + private readonly extensionEnv: TExtensionEnv ) {} /** - * 每个脚本最近一次外部触发的「已开始」信号:见到它的 RUNNING、或它的等待结束时兑现。scriptRunStatus 不带 - * 运行 id,同一脚本的两次外部运行若同时在等,后一次会把前一次的 RUNNING 当成自己的,再把前一次被停止时的 - * COMPLETE 当成本次终态;所以下一次外部运行要等这个信号后才订阅并触发。这只覆盖外部运行之间的交错:编辑器 + * 每个脚本最近一次外部触发的「已开始」信号:见到它的 RUNNING、它的等待结束或它被放弃时兑现。 + * scriptRunStatus 不带运行 id,同一脚本的两次外部运行若同时在等,后一次会把前一次的 RUNNING 当成自己的, + * 再把前一次被停止时的 COMPLETE 当成本次终态;所以下一次等待型外部运行要等这个信号后才订阅并触发。这只覆盖外部运行之间的交错:编辑器 * 「运行」或 cron 恰好与外部运行交错时仍可能误判,根治需要 sandbox 在运行状态里带上运行 id。 */ private readonly lastStart = new Map>(); - async run(uuid: string, options: { wait: boolean; timeoutMs: number }): Promise { - 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"); - } - const previous = this.lastStart.get(uuid); - 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); - }); - await previous; - - const startedAt = Date.now(); - // 先订阅再触发,避免错过本次的 RUNNING。 - const waiter = this.waitForRunEnd(uuid, options.timeoutMs, markStarted); - try { - await this.runner.runScript(uuid); - } catch (e) { - waiter.dispose(); - throw e; - } - if (!options.wait) { - // 不等终态,但订阅保留到本次 RUNNING 到达(或超时),让随后的外部运行不会认错 RUNNING。 - void started.then(waiter.dispose); - return { status: "running", startedAt, logs: [], contentTrust: "untrusted-user-script-log" }; - } + 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 end = await waiter.ended; - 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", - }; + 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 { - waiter.dispose(); + if (!detached) release(); } } @@ -180,15 +246,16 @@ export class ExternalAccessRunLogs { } /** - * scriptRunStatus 不带运行 id:重跑时 sandbox 先为旧运行发 COMPLETE 再为本次发 RUNNING,因此只有 - * 见到本次 RUNNING 之后的第一个非 RUNNING 状态才是本次运行的终态。run-in 与当前环境不匹配时 sandbox - * 不运行也不发 RUNNING,这种情况按超时返回 running。超时 resolve undefined。见到 RUNNING、超时或 dispose - * 时调用 onStarted。 + * scriptRunStatus 不带运行 id:重跑时 sandbox 的 stopScript 先为旧运行发 COMPLETE,再为本次发 RUNNING; + * 被停止的旧运行之后才结束时不再发布任何状态(Runtime.execScript 只让当前运行发布终态)。因此见到本次 + * RUNNING 之后的第一个非 RUNNING 状态就是本次运行的终态。stopped(截止或取消)时 resolve undefined。 + * 见到 RUNNING、结束或 dispose 时调用 onStarted。 */ - private waitForRunEnd(uuid: string, timeoutMs: number, onStarted: () => void) { + 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; @@ -204,11 +271,9 @@ export class ExternalAccessRunLogs { resolveEnd({ status: "complete", endedAt: Date.now() }); } }); - const timer = setTimeout(() => resolveEnd(undefined), timeoutMs); return { ended, dispose() { - clearTimeout(timer); unsubscribe(); onStarted(); }, @@ -223,31 +288,28 @@ export class ExternalAccessRunLogs { const minRank = SEVERITY_RANK[query.severity ?? "debug"]; const needle = query.text?.toLowerCase(); const limit = query.limit ?? LOGS_DEFAULT_LIMIT; - // 从最新往回取满 limit 与字节预算,再翻成 oldest first。queryLogs 只按 createtime 排序,同一毫秒内 - // 的先后要靠自增 id 区分,这里自行排序。 - const rows = (await this.loggerDAO.queryLogs(query.since, Number.MAX_SAFE_INTEGER)).sort( - (a, b) => b.createtime - a.createtime || b.id - a.id - ); + // 从最新往回取满 limit 与字节预算即停,再翻成 oldest first。 const newestFirst: LogEntry[] = []; let bytes = 0; let truncated = false; - for (const row of rows) { - if (row.label.uuid !== uuid || row.label.component === EXTERNAL_ACCESS_COMPONENT) continue; + 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) continue; - if (needle !== undefined && !row.message.toLowerCase().includes(needle)) continue; + if (!severity || SEVERITY_RANK[severity] < minRank) return true; + if (needle !== undefined && !row.message.toLowerCase().includes(needle)) return true; if (newestFirst.length >= limit) { truncated = true; - break; + return false; } const entry = toEntry(row, severity); bytes += jsonBytes(entry) + 1; if (bytes > byteBudget) { truncated = true; - break; + return false; } newestFirst.push(entry); - } + return true; + }); return { logs: newestFirst.reverse(), truncated }; } } diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts index 022251a7a..70b9d7e92 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -213,7 +213,7 @@ export default class ServiceWorkerManager { scriptDAO, script.scriptCodeDAO, externalAccessApproval, - new ExternalAccessRunLogs(scriptDAO, runtime, this.mq, new LoggerDAO()), + 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", From bdc914d165830d5e7deece41c1cca79d39f40dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 5 Nov 2026 12:12:38 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20sandbox=EF=BC=9Arun-in?= =?UTF-8?q?=20=E5=88=A4=E6=96=AD=E4=B8=8E=20once=20=E5=91=A8=E6=9C=9F?= =?UTF-8?q?=E9=97=A8=E6=8E=A7=E5=BD=92=E5=85=A5=20sandbox/utils.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_in.ts 更名为 utils.ts;runtime.execScript 内联的 once 门控(按 once 所在字段比对 lastruntime) 提成纯函数 isOnceRunDone,行为不变。两者此前分别只被间接覆盖与完全没有测试,补上 utils.test.ts。 --- src/app/service/sandbox/run_in.ts | 19 ------- src/app/service/sandbox/runtime.ts | 36 ++------------ src/app/service/sandbox/utils.test.ts | 49 +++++++++++++++++++ src/app/service/sandbox/utils.ts | 49 +++++++++++++++++++ .../external_access/run_logs.ts | 2 +- 5 files changed, 102 insertions(+), 53 deletions(-) delete mode 100644 src/app/service/sandbox/run_in.ts create mode 100644 src/app/service/sandbox/utils.test.ts create mode 100644 src/app/service/sandbox/utils.ts diff --git a/src/app/service/sandbox/run_in.ts b/src/app/service/sandbox/run_in.ts deleted file mode 100644 index 7219626ed..000000000 --- a/src/app/service/sandbox/run_in.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { SCMetadata } from "@App/app/repo/metadata"; -import type { TExtensionEnv } from "../extension/extension_env"; - -/** - * 后台/定时脚本的 @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; -} diff --git a/src/app/service/sandbox/runtime.ts b/src/app/service/sandbox/runtime.ts index 660b89f8e..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,11 +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"; -import { backgroundRunInAllowed } from "./run_in"; - -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(); @@ -144,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)) { 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/run_logs.ts b/src/app/service/service_worker/external_access/run_logs.ts index 5c47ff267..f84906594 100644 --- a/src/app/service/service_worker/external_access/run_logs.ts +++ b/src/app/service/service_worker/external_access/run_logs.ts @@ -10,7 +10,7 @@ import { 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/run_in"; +import { backgroundRunInAllowed } from "@App/app/service/sandbox/utils"; import { getCombinedMeta } from "../utils"; import { EXTERNAL_ACCESS_COMPONENT } from "./audit"; import { ExternalAccessBridgeError } from "./errors";