diff --git a/AGENTS.md b/AGENTS.md index c46b70f4b..83beeddcd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -226,7 +226,7 @@ Service Worker (src/service_worker.ts) ├── ExtensionMessage ──────────────→ Content Script (src/content.ts) │ └── CustomEventMessage ──→ Inject Script (src/inject.ts) └── ServiceWorkerMessageSend ──────→ Offscreen (src/offscreen.ts) (Chrome; Firefox uses EventPageOffscreenManager) - └── WindowMessage ──→ Sandbox (src/sandbox.ts) + └── private MessagePort ──→ Sandbox (src/sandbox.ts) ``` > SW → Offscreen uses `ServiceWorkerMessageSend` (`clients.matchAll()` + `postMessage`) on Chrome and @@ -244,8 +244,9 @@ Sandbox. ### Message Passing (`packages/message/`) -`ExtensionMessage` (chrome.runtime — SW ↔ Content / Inject / Offscreen), `WindowMessage` (postMessage — Offscreen ↔ -Sandbox), `ServiceWorkerMessageSend` (`clients.matchAll()` + `postMessage` — SW → Offscreen on Chrome), +`ExtensionMessage` (chrome.runtime — SW ↔ Content / Inject / Offscreen), `MessagePortMessage` + +`SandboxChannelHost` (one-shot Window bootstrap, then private MessagePort — Offscreen/EventPage ↔ Sandbox), +`ServiceWorkerMessageSend` (`clients.matchAll()` + `postMessage` — SW → Offscreen on Chrome), `CustomEventMessage` (CustomEvent — Content ↔ Inject), and `MessageQueue` (cross-context broadcast). ### Service & Data Layers diff --git a/docs/DOC-MAINTENANCE.md b/docs/DOC-MAINTENANCE.md index b5c291d67..d90b1f76b 100644 --- a/docs/DOC-MAINTENANCE.md +++ b/docs/DOC-MAINTENANCE.md @@ -104,7 +104,7 @@ of sanitization patterns can otherwise look like matches — so don't rely on a | [`design.md`](./design.md) | The design system; tokens, component palette, and layout/motion/state/a11y patterns → the three `references/design-*.md`. | | [`verification.md`](./verification.md) | *When* to drive the real built extension, where its evidence goes, how to report honestly. Not the harness — link to `e2e/README.md`, don't restate fixtures/isolation/env vars. | | [`../e2e/README.md`](../e2e/README.md) | The harness itself: the two tracks and their configs, isolation, fixture/helper inventory, protocol mocks, `E2E_*` variables, artifact paths. | -| [`architecture.md`](./architecture.md) | Deep internals; subsystem deep-dives → the six `references/architecture-*.md`. | +| [`architecture.md`](./architecture.md) | Deep internals; subsystem deep-dives → `references/architecture-*.md` and [`references/main-world-message-privacy.md`](./references/main-world-message-privacy.md). | | [`cloud-sync.md`](./cloud-sync.md) | Cloud sync internals: sync files, digest/status semantics, provider differences, error classification, retry policy. | | [`translation.md`](./translation.md) | Translation / localization single source of truth. | | [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | This guide: organization rules, fact-check / anti-drift discipline, policy-consistency checks — across every tracked contributor Markdown, not just `AGENTS.md` + `docs/*`. | diff --git a/docs/README.md b/docs/README.md index 399c49439..fe0c2e827 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,7 @@ | [`design.md`](./design.md) | 设计系统参考:主题机制、shadcn 组件选型、新建页面配方总览;令牌完整值拆到 [`references/design-tokens.md`](./references/design-tokens.md),组件清单拆到 [`references/design-components.md`](./references/design-components.md),布局/响应式/动效/状态/无障碍范式拆到 [`references/design-patterns.md`](./references/design-patterns.md)。**做页面/对话框/区块前先读。** | | [`../e2e/README.md`](../e2e/README.md) | E2E 测试台手册:两条赛道(committed smoke / gitignored scratch)、浏览器与 profile 隔离、fixtures 与 helper 清单、协议 mock、`E2E_*` 环境变量、产物与失败排查路径。**跑 / 写 E2E 或一次性验证脚本前先读。** | | [`verification.md`](./verification.md) | 功能验证指南:启一个常驻会话(默认无头、可多 worktree 并发)逐条命令驱动真实扩展,只在需要复现顺序/时序时才写 spec,不跑全量 E2E、不加永久用例;驱动方法(GM API in-page self-test、SW 消息、主题)拆到 [`references/verification-methods.md`](./references/verification-methods.md),报告模板拆到 [`references/verification-report-template.md`](./references/verification-report-template.md),调试 FAQ 拆到 [`references/verification-debugging.md`](./references/verification-debugging.md)。**验证改动是否真正跑通时读。** | -| [`architecture.md`](./architecture.md) | 内部原理总览:多进程模型、消息传递;各子系统深入拆到 [`references/architecture-services.md`](./references/architecture-services.md)(服务层)、[`references/architecture-data.md`](./references/architecture-data.md)(数据层)、[`references/architecture-gm-api.md`](./references/architecture-gm-api.md)(GM API)、[`references/architecture-execution.md`](./references/architecture-execution.md)(脚本执行)、[`references/architecture-build.md`](./references/architecture-build.md)(构建管线)、[`references/architecture-agent.md`](./references/architecture-agent.md)(Agent 子系统)。 | +| [`architecture.md`](./architecture.md) | 内部原理总览:多进程模型、消息传递;各子系统深入拆到 [`references/architecture-services.md`](./references/architecture-services.md)(服务层)、[`references/architecture-data.md`](./references/architecture-data.md)(数据层)、[`references/architecture-gm-api.md`](./references/architecture-gm-api.md)(GM API)、[`references/architecture-execution.md`](./references/architecture-execution.md)(脚本执行)、[`references/main-world-message-privacy.md`](./references/main-world-message-privacy.md)(MAIN 消息隐私与回退策略)、[`references/sandbox-message-port-security.md`](./references/sandbox-message-port-security.md)(Sandbox 私有 MessagePort 威胁模型与验证)、[`references/architecture-build.md`](./references/architecture-build.md)(构建管线)、[`references/architecture-agent.md`](./references/architecture-agent.md)(Agent 子系统)。 | | [`cloud-sync.md`](./cloud-sync.md) | 云同步实现说明:同步文件语义、主流程、状态合并、provider 差异、错误分类、retry 策略和维护注意事项。 | | [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | 文档维护与事实核对指南:组织规则、逐条核对清单、跨文档政策一致性核对、隐私清理、以及在 resolved final tree 上的复核方法,覆盖全部 tracked 的 agent/contributor Markdown(不止 `AGENTS.md` + `docs/*`,还包括 `.github/*.md`、package-local README)。**改/审文档前先读。** | diff --git a/docs/architecture.md b/docs/architecture.md index 06436d77f..3aede5d78 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,7 +36,7 @@ into several sandboxed JavaScript realms that cannot share memory; ScriptCat the Three ideas explain almost everything in the codebase: - **Contexts are processes.** Each entry point (`service_worker`, `content`, `inject`, `offscreen`, `sandbox`) - is an isolated realm. They never share objects — only serializable messages. + is an isolated realm. They do not share ordinary mutable objects — cross-context state moves through serialized or transferable messages. - **One message layer, several transports.** [`packages/message`](../packages/message) abstracts `chrome.runtime`, `postMessage`, and DOM `CustomEvent` behind a single RPC + pub/sub API, so services are written against interfaces (`Server`/`Group`/`Client`/`IMessageQueue`), not raw browser APIs. @@ -62,9 +62,10 @@ Three ideas explain almost everything in the codebase: │ bridges SW ↔ inject │ │ DOM-capable background │ │ Server("content") │ │ Server("offscreen") │ └──────────┬───────────┘ └─────────────┬────────────┘ - CustomEventMessage WindowMessage - (DOM CustomEvent) (window.postMessage) - ▼ ▼ + CustomEventMessage SandboxChannelHost + (DOM CustomEvent) one-shot Window bootstrap + ▼ + private MessagePort + ▼ ┌──────────────────────┐ ┌──────────────────────────┐ │ INJECT SCRIPT │ │ SANDBOX (iframe) │ │ page realm, │ │ with(){} script eval, │ @@ -77,6 +78,12 @@ Three ideas explain almost everything in the codebase: inject, and sandbox don't hold a MessageQueue instance. ``` +The diagram compresses the page-facing routes: USER_SCRIPT content uses a native extension channel after bootstrap, while MAIN inject intentionally uses a single keyed performance-event bridge through `scripting`. The `scripting` bundle is a +document-start extension content script registered per matching frame; it runs a page-bridge runtime and is a +supporting per-document helper rather than a separate service/background context in this five-context model. +`CustomEventMessage` carries the content bootstrap +handoff and synchronous DOM handles; `PageEventMessage` carries MAIN pageLoad, runtime event/value updates, the whitelisted `external.Scriptcat` API, and validated GM RPC through the isolated `scripting` broker. It uses a random event name on `performance`, not the global `window.message` bus. The bridge itself is not an authenticated extension origin. + --- ## The Five Contexts (Process Model) @@ -86,13 +93,15 @@ Each context is a separate bundle (see [Build pipeline & manifest](./references/ | Context | Entry | Realm / capabilities | Bootstraps | |---|---|---|---| | **Service Worker** | [`src/service_worker.ts`](../src/service_worker.ts) | No DOM. Owns `chrome.*` privileged APIs, storage, permissions, routing. | `ExtensionMessage(true)` → `Server("serviceWorker")` + `MessageQueue` → `ServiceWorkerManager` | -| **Content** | [`src/content.ts`](../src/content.ts) | Isolated content-script world. Bridges SW and the page. | `CustomEventMessage` channel to inject + `Server("content")` → `ScriptRuntime` | -| **Inject** | [`src/inject.ts`](../src/inject.ts) | Page (`MAIN`) world. Has `unsafeWindow`; runs page userscripts. | `CustomEventMessage` to content + `Server("inject")` | -| **Offscreen** | [`src/offscreen.ts`](../src/offscreen.ts) | DOM-capable background page (Blobs, clipboard, DOM scraping, local storage). | `ExtensionMessage()` + `WindowMessage(window, sandbox)` → `OffscreenManager` | -| **Sandbox** | [`src/sandbox.ts`](../src/sandbox.ts) | `sandbox`ed iframe inside offscreen. Evaluates background/scheduled scripts; runs cron. | `WindowMessage(window, parent)` + `Server("sandbox")` → `SandboxManager` | +| **Content** | [`src/content.ts`](../src/content.ts) | `USER_SCRIPT` world. Receives a document bootstrap token through the page-side bridge, then uses a native extension channel for script loading, GM RPC, value updates, and callbacks. Dedicated USER_SCRIPT listeners are used when available; otherwise the regular port is token-bound. | `ExtensionMessage` + native callback port → `Server("content")` → `ScriptRuntime`; `CustomEventMessage` for bootstrap handoff and DOM handles | +| **Inject** | [`src/inject.ts`](../src/inject.ts) | Page (`MAIN`) world. Has `unsafeWindow`; runs page userscripts. | `PageEventMessage` keyed performance-event bridge → `scripting`; broker/SW validate privileged RPC; `CustomEventMessage` for synchronous DOM handles | +| **Offscreen** | [`src/offscreen.ts`](../src/offscreen.ts) | DOM-capable background page (Blobs, clipboard, DOM scraping, local storage). | SW receiver + `SandboxChannelHost`; parent installs the one-shot bootstrap listener before attaching the sandbox iframe | +| **Sandbox** | [`src/sandbox.ts`](../src/sandbox.ts) | `sandbox`ed iframe inside offscreen/event page. Evaluates background/scheduled scripts; runs cron. | creates `MessageChannel`, wires `MessagePortMessage` + `Server("sandbox")`, then transfers the peer port to the parent as the readiness signal | -There is also a sixth bundle, [`src/scripting.ts`](../src/scripting.ts), injected via `chrome.userScripts` / -`chrome.scripting` to carry the compiled page-script payload (see [Script execution](./references/architecture-execution.md)). +The [`scripting` bundle](../src/scripting.ts) is a document-start content script registered through +`chrome.scripting`; it supplies the per-document page bridge. Compiled userscript payloads and the `inject.js` / +`content.js` runners are registered separately through `chrome.userScripts` (see +[Script execution](./references/architecture-execution.md)). ### Service-worker bootstrap @@ -126,22 +135,17 @@ already has DOM and plays the offscreen role directly. `ExtensionMessage` (`chrome.runtime`). - **Firefox:** [`EventPageOffscreenManager`](../src/app/service/offscreen/event_page_manager.ts) substitutes for the offscreen document; its sandbox iframe is a `sandbox` manifest page, which Firefox 154+ loads as a - cross-origin frame (`contentDocument` is `null`, `contentWindow.location` is unreadable). Only the sandbox - itself knows when it's actually ready, so the parent never polls or pings it: `SandboxManager` - ([`src/app/service/sandbox/index.ts`](../src/app/service/sandbox/index.ts)) proactively posts a - `preparationSandbox` message once its own `Server` is wired up (same mechanism on both platforms), and - [`BackgroundEnvManagerBase.preparationSandbox`](../src/app/service/offscreen/base.ts) immediately tells the - service worker `preparationOffscreen({ verified: true })` — no round trip, no waiting. The service worker - replays enabled background/scheduled scripts and the current language only for this verified signal, once. - Separately and non-blockingly, the - sandbox reuses its own in-flight `getExtensionEnv` request to self-check that the channel is genuinely - bidirectional, and reports the outcome via `reportSandboxChannelHealth`, which the parent logs (visible in - the parent's own console/log, since the sandbox iframe's console is far less discoverable). If the sandbox - never announces readiness at all (iframe failed to load, script error), a fallback timer in - `BackgroundEnvManagerBase` still tells the service worker `preparationOffscreen({ verified: false })` after - `SANDBOX_READY_FALLBACK_MS`, logging a clear error instead of hanging forever. That unverified notification - does not send initialization through an unavailable channel; if the real handshake arrives later, the - verified state replay still occurs exactly once. + cross-origin frame (`contentDocument` is `null`, `contentWindow.location` is unreadable). The sandbox transport + is nevertheless the same as Chromium: the parent first installs a `SandboxChannelHost` bootstrap listener, + then attaches the iframe. After `sandbox.ts` has wired its `Server` and `Runtime`, it creates/transfers one + `MessagePort` to the parent. The parent accepts that capability only when `event.source` is the exact sandbox + `contentWindow`, removes the global Window `"message"` listener immediately, and uses the private port for all + subsequent traffic. Receiving the port is also the only verified sandbox-readiness signal; only then does + `BackgroundEnvManagerBase` call `preparationOffscreen({ verified: true })`, which lets the service worker replay + enabled background/scheduled scripts and language state. There is no second `preparationSandbox` RPC, health + ping, or unverified timeout-ready path. The transport threat model, same-realm prototype hardening, failure + semantics, and verification matrix are documented in + [Private Offscreen/EventPage ↔ Sandbox MessagePort](./references/sandbox-message-port-security.md). Firefox packages use `incognito: "spanning"`, so normal and private page scripts share one event page but retain their own `sender.tab.incognito` value for global-switch checks, `@run-in`, and `GM_info.isIncognito`. Background @@ -167,9 +171,10 @@ communication styles** over **several transports**. | Class | File | Connects | Underlying API | |---|---|---|---| -| `ExtensionMessage` | [`extension_message.ts`](../packages/message/extension_message.ts) | SW ↔ Content / Inject / Offscreen | `chrome.runtime.sendMessage` / `onConnect` (+ `onUserScript*` on Firefox) | -| `CustomEventMessage` | [`custom_event_message.ts`](../packages/message/custom_event_message.ts) | Content ↔ Inject | DOM `CustomEvent` dispatch (bypasses page tampering) | -| `WindowMessage` | [`window_message.ts`](../packages/message/window_message.ts) | Offscreen ↔ Sandbox | `window.postMessage` | +| `ExtensionMessage` | [`extension_message.ts`](../packages/message/extension_message.ts) | SW ↔ Content / Inject / Offscreen | `chrome.runtime.sendMessage` / `onConnect`; browser-identified USER_SCRIPT messages are action-gated, and regular-port fallbacks are token-bound | +| `PageEventMessage` | [`page_event_message.ts`](../packages/message/page_event_message.ts) | `scripting` ↔ Inject | Keyed `performance` `CustomEvent`; MAIN pageLoad, runtime updates, whitelisted external API, and GM RPC routed through the isolated broker and validated by `PageRpcRegistry` | +| `CustomEventMessage` | [`custom_event_message.ts`](../packages/message/custom_event_message.ts) | Content ↔ `scripting` page helper | DOM `CustomEvent`; bootstrap handoff and synchronous DOM references, not privileged GM RPC | +| `MessagePortMessage` / `SandboxChannelHost` | [`message_port_message.ts`](../packages/message/message_port_message.ts), [`sandbox_message_channel.ts`](../packages/message/sandbox_message_channel.ts) | Offscreen/EventPage ↔ Sandbox | sandbox-created `MessageChannel`; one strict source-checked Window bootstrap transfers the peer port, then all payloads use the private `MessagePort` | | `ServiceWorkerMessageSend` | [`window_message.ts`](../packages/message/window_message.ts) | SW → Offscreen (Chrome) | `clients.matchAll()` + `postMessage` | | `MessageQueue` | [`message_queue.ts`](../packages/message/message_queue.ts) | Broadcast among the contexts that instantiate it — SW, Offscreen, UI pages | `chrome.runtime.sendMessage` + local `EventEmitter3` | | `MockMessage` | [`mock_message.ts`](../packages/message/mock_message.ts) | Tests | in-memory `EventEmitter3` | diff --git a/docs/develop.md b/docs/develop.md index a3dfaccf1..601d6ab01 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -11,7 +11,7 @@ ```bash pnpm install # install deps (preinstall enforces pnpm) pnpm run dev # dev build (source maps); load dist/ext as unpacked extension -pnpm run dev:noMap # dev build w/o source maps (incognito) +pnpm run dev:noMap # dev build w/o source maps pnpm run build # production Rspack build pnpm run pack # package the extension (requires dist/scriptcat.pem) diff --git a/docs/references/architecture-agent.md b/docs/references/architecture-agent.md index 8defc00b4..f46812a0b 100644 --- a/docs/references/architecture-agent.md +++ b/docs/references/architecture-agent.md @@ -98,6 +98,34 @@ The Agent subsystem does not use one persistence pattern; pick by data shape, ma attachments), `AgentTaskRunRepo` (task run history), `SkillRepo` (skill `.md`/script bundles). - `MCPServerRepo` (`Repo`) — MCP server configs. +## Userscript resource ownership + +The `CAT.agent.*` APIs are granted per script, but a grant alone does not decide which persisted resources that +script can access. The service-worker GM handlers take the caller identity from `request.script.uuid` and pass it +to the Agent services; they do not use a caller-supplied `scriptUuid` as the authority. + +- **Conversations** created by a script persist `ownerScriptUuid`. Script reads, chats, attaches, and mutations + check that owner. UI and legacy conversations without an owner remain available to the extension UI but are not + visible to script callers. Ephemeral chats are not persisted conversations. +- **Tasks** created by a script persist `ownerScriptUuid`; script list/get/update/delete/enable/run/history + operations are scoped to that owner. For compatibility, a legacy event task without an owner remains visible + only to the script named by `sourceScriptUuid`. +- **DOM monitors** are scoped to the script UUID supplied by the service-worker GM handler and to the tab. A + script caller cannot peek, stop, or replace a monitor owned by another script. +- **Attachments** live in the shared OPFS workspace and do not carry owner metadata themselves. Before + `CAT.agent.opfs.readAttachment` returns a file, `AgentChatRepo` verifies that a persisted message references + it from a conversation owned by the calling script. A guessed ID or a reference borrowed from another script's + conversation is insufficient. + +The checks are implemented in [`gm_agent.ts`](../../src/app/service/service_worker/gm_api/gm_agent.ts), +[`gm_agent_dom.ts`](../../src/app/service/service_worker/gm_api/gm_agent_dom.ts), +[`gm_agent_task.ts`](../../src/app/service/service_worker/gm_api/gm_agent_task.ts), +[`chat_service.ts`](../../src/app/service/agent/service_worker/chat_service.ts), +[`task_service.ts`](../../src/app/service/agent/service_worker/task_service.ts), +[`background_session_manager.ts`](../../src/app/service/agent/service_worker/background_session_manager.ts), +[`opfs_service.ts`](../../src/app/service/agent/service_worker/opfs_service.ts), and +[`dom_cdp.ts`](../../src/app/service/agent/service_worker/dom_cdp.ts). + ## Page / offscreen / sandbox delegation and permission boundaries - **Content (`src/app/service/content/gm_api/cat_agent.ts`)** exposes the `CAT.agent.*` API to user scripts — @@ -120,7 +148,8 @@ The Agent subsystem does not use one persistence pattern; pick by data shape, ma uses CDP; a background (non-active) tab tries CDP first and falls back to `chrome.tabs.captureVisibleTab` on failure; an active tab with no selector uses `chrome.tabs.captureVisibleTab` directly. - **Tab monitoring** (`startMonitor`/`stopMonitor`/`peekMonitor`) is unconditionally CDP-based — there is no - non-CDP path for it at all. + non-CDP path for it at all. A monitor is scoped to its tab and initiating script; other scripts cannot + inspect, stop, or replace it. CDP attaches the debugger to a tab and carries the extra permission/user-visible-banner implications that come with `chrome.debugger`; how often that applies depends on which action you're looking at, not a single diff --git a/docs/references/architecture-build.md b/docs/references/architecture-build.md index 7d7cf3054..965bf1640 100644 --- a/docs/references/architecture-build.md +++ b/docs/references/architecture-build.md @@ -9,7 +9,7 @@ ``` context bundles : service_worker · offscreen · sandbox · content · inject · scripting shared : common (pre-React bootstrap, e.g. early theme init — see src/pages/common.ts) -UI pages (React): popup · options · install · batchupdate · confirm · import +UI pages (React): popup · options · install · batchupdate · confirm · external_access_confirm · import workers : editor.worker · ts.worker · json.worker (Monaco) · linter.worker ``` @@ -19,8 +19,8 @@ Output goes to `dist/ext/src/[name].js` (cleaned each build). Notable behavior: - **Path aliases** mirror `tsconfig.json`: `@App → src`, `@Packages → packages` (the `@Tests → tests` alias is test-only — defined in `vitest.config.ts` / `tsconfig.json`, not in the Rspack build). -- **Dev vs prod** via `NODE_ENV`: dev enables watch + inline source maps (skipped when `NO_MAP=true`, needed - for incognito); prod minifies with SWC + Lightning CSS and drops debug. +- **Dev vs prod** via `NODE_ENV`: dev enables watch + inline source maps (skipped when `NO_MAP=true`); prod minifies + with SWC + Lightning CSS and drops debug. - **Code splitting** pulls big libs into named `lib_*` chunks (react, monaco, radix-ui, dnd-kit, eslint, message), but **never splits** `service_worker`, `content`, `inject`, `scripting`, or the workers — MV3 requires those to be single self-contained files. @@ -78,7 +78,7 @@ MV3 officially supports Firefox, so `PACK_FIREFOX` is `true` by default and the | Package | Purpose | |---|---| | [`message`](../../packages/message) | The cross-context RPC + pub/sub layer (see [Message Passing](../architecture.md#message-passing)). Ships its own mocks. | -| [`filesystem`](../../packages/filesystem) | Pluggable FS adapters for sync/backup — WebDAV, cloud drives (OneDrive, Google Drive, Dropbox, Baidu, S3), and zip archives. | +| [`filesystem`](../../packages/filesystem) | Pluggable FS adapters for sync/backup; see the [package README](../../packages/filesystem/README.md) for providers and Zip behavior. | | [`cloudscript`](../../packages/cloudscript) | Cloud-script integration. | | [`eslint`](../../packages/eslint) | The ESLint config + globals shipped to the in-editor linter for userscripts (`CAT_*`, `GM_*`, `CATRetryError`, …). | | [`chrome-extension-mock`](../../packages/chrome-extension-mock) | A mock `chrome.*` + message bus for Vitest. | diff --git a/docs/references/architecture-data.md b/docs/references/architecture-data.md index a6534f85a..482074a70 100644 --- a/docs/references/architecture-data.md +++ b/docs/references/architecture-data.md @@ -40,8 +40,8 @@ Design notes: - **Cache:** `enableCache()` switches reads/writes to a process-local cache that mirrors storage — used for hot collections (scripts) to avoid repeated async reads. A subclass that overrides `joinKey` can hash keys (e.g. resources keyed by URL via a UUID-v5 namespace). -- **Storage errors are logged, not thrown** — `chrome.runtime.lastError` is checked and reads continue, since - a transient storage hiccup should not crash the worker. +- **Storage errors reject their promises.** The storage callback paths check `chrome.runtime.lastError` and reject; + `Repo` does not log the error and continue. ### Repository inventory @@ -57,6 +57,8 @@ Names ending in `DAO` don't all share one base class — check which backend bef | `PermissionDAO` | [`permission.ts`](../../src/app/repo/permission.ts) | `Permission` | Composite key `::` | | `SubscribeDAO` | [`subscribe.ts`](../../src/app/repo/subscribe.ts) | `Subscribe` | Keyed by feed URL | | `FaviconDAO`, `LocalStorageDAO`, `ExportDAO`, `TempStorageDAO` | `src/app/repo/*.ts` | misc | Same `Repo` pattern | +| `ExternalAccessOperationDAO` | [`external_access.ts`](../../src/app/repo/external_access.ts) | `ExternalAccessOperation` | External-access operation records | +| `NetworkRuleStateDAO` | [`network_rule.ts`](../../src/app/repo/network_rule.ts) | `NetworkRuleState` | Declarative network-rule state | | `AgentModelRepo` | [`agent_model.ts`](../../src/app/repo/agent_model.ts) | `AgentModelConfig` | Agent model configs — small, no indexed query need | | `AgentTaskRepo` | [`agent_task.ts`](../../src/app/repo/agent_task.ts) | `AgentTask` | Scheduled agent task definitions | | `MCPServerRepo` | [`mcp_server_repo.ts`](../../src/app/repo/mcp_server_repo.ts) | `MCPServerConfig` | MCP server configs | diff --git a/docs/references/architecture-execution.md b/docs/references/architecture-execution.md index 717ea0f12..798b3a7fb 100644 --- a/docs/references/architecture-execution.md +++ b/docs/references/architecture-execution.md @@ -10,40 +10,92 @@ There are three execution paths; all share one **compilation** step. go through a controlled context object instead of the page's real globals: ```ts -// compileScriptCodeByResource(): the emitted wrapper -[ - "with(arguments[0]||this.$){", // arguments[0] = the GM context (sandbox) / this.$ = one-shot Proxy - preCode, // @require dependencies, concatenated - "return(async function(){", // async → user code may use top-level await - code, // the user's script body - "}).call(this);}", -].join("\n"); -// then wrapped in try/catch and compiled with `new Function(code)` +with (arguments[0] || this.$) { // arguments[0] = GM context; this.$ = one-shot Proxy + // @require dependencies, concatenated + return async function () { // user code may use top-level await + // userscript body + }; +} ``` +The generated code is wrapped in `try/catch` and compiled with `new Function`. `compileScript()` invokes the +returned async function with captured `nativeCall` (`Reflect.apply`), preserving userscript `this` without +consulting page-modifiable `call`, `apply`, or `bind` properties. + Key points: - `with(arguments[0]||this.$)` makes every bare identifier resolve against the GM context first. The context is - a `Proxy` that intercepts reads, so the script sees `unsafeWindow`, the granted `GM_*` functions, and a - controlled view of globals — not the raw page scope. -- Context and script name are passed as **unnamed `arguments`** (`arguments[0]`, `arguments[1]`) so user code - can't shadow them by declaring variables of the same name. -- `.call(this)` preserves `this` because `chrome.userScripts` invokes the function free-standing (an arrow - function would capture the wrong `this`). + a descriptor-based pseudo-window that projects `unsafeWindow`, the granted `GM_*` functions, and a controlled + view of globals — not the raw page scope. It is a compatibility projection rather than a security membrane. +- The code and script name are passed through unnamed `arguments` (`arguments[0]`, `arguments[1]`) so user code + cannot shadow them by declaring variables with the same names. + +### MAIN-world wrapper and early start + +The MAIN registration mounts the generated wrapper with an ordinary `window[flag] = wrapper` assignment. +[`compilePreInjectScript()`](../../src/app/service/content/utils.ts) also dispatches a page-visible `performance` +event whose detail contains the script flag. Page code can observe, replace, or delete these mounts and events; +neither surface establishes an authenticated origin. + +Before execution, [`ScriptExecutor`](../../src/app/service/content/script_executor.ts) checks the candidate function +with captured native `Function.prototype.toString` against the generated wrapper source, then passes its build token +through the trusted invocation path to read closure-held metadata. It verifies that the metadata UUID and flag match +the registered script before executing the wrapper. The wrapper mount and event do not grant GM capability. + +An early-start wrapper may run its page-side body before the authoritative page-load list arrives. Pre-inject +metadata redacts `value`, `config`, `userConfig`, and `userConfigStr`; privileged requests that cross to the broker +wait on the early context's load gate. Reconciliation requires the same UUID, flag, and compiled `scriptRevision`; +scripts with grants or context-menu behavior also require a valid execution binding. On success, the executor +refreshes script information and `GM_info` before resolving the load gate. A missing or stale script, or an invalid +binding, invalidates the context and settles the pending load wait so waiting broker requests stop without being +sent. Early contexts absent from the authoritative list are invalidated as well. ### Path A — Page scripts → `chrome.userScripts` -Normal userscripts run in the page. The SW builds a `RegisteredUserScript` from the script's `@match`/`@include` -patterns and registers the compiled payload (the `scripting` bundle) with `chrome.userScripts.register`, in the -`MAIN` or `USER_SCRIPT` world as required. At document time the content/inject pair +The SW compiles enabled userscripts and registers each payload through `chrome.userScripts` with its match, world, +and run-time settings. It also registers the `inject.js` and `content.js` runners there for the `MAIN` and +`USER_SCRIPT` paths. Separately, `scripting.js` is registered through `chrome.scripting` as a document-start +content script that supplies the page bridge. At document time the content/inject pair ([`script_runtime.ts`](../../src/app/service/content/script_runtime.ts), [`exec_script.ts`](../../src/app/service/content/exec_script.ts)) evaluates the compiled function with the GM -context. +context. The `USER_SCRIPT` content path obtains its matched scripts directly from the service worker over +`ExtensionMessage` after a bootstrap-token handoff. The MAIN `inject` path deliberately has one cross-world +transport: `PageEventMessage`, carried by a random event name on `performance`. It carries authoritative +pageLoad data, MAIN event/value updates, the whitelisted `external.Scriptcat` API, and GM RPC through the isolated +`scripting` broker. Before forwarding privileged GM RPC, [`PageRpcRegistry`](../../src/app/service/content/page_rpc.ts) +validates the request shape, active execution handle, request sequence, and granted API; the Service Worker then +resolves canonical identity again from the handle and real sender. The handle identifies a binding but is not an +authorization secret. +`CustomEventMessage` carries the content bootstrap handoff and synchronous DOM references. Neither page-visible +bridge establishes an authenticated extension origin, so consumers must validate its payloads before acting on +them. + +### Path B — Background scripts → Offscreen/EventPage → Sandbox + +`@background` scripts have no page. Chromium uses the Offscreen document as the DOM-capable parent; Firefox uses +the MV3 event page in the same role. Both create the sandbox iframe only after installing +[`SandboxChannelHost`](../../packages/message/sandbox_message_channel.ts), so the bootstrap receiver always exists +before the child can start. + +The sandbox creates its own `MessageChannel`. It keeps one port inside the trusted `sandbox.ts` module closure, +wires [`MessagePortMessage`](../../packages/message/message_port_message.ts), `Server("sandbox")`, and +[`Runtime`](../../src/app/service/sandbox/runtime.ts), then transfers only the peer port to the parent. The +one-time transfer uses Window `postMessage` because that is the cross-frame bootstrap mechanism; it contains no +script/GM payload. The parent requires the message source to be the exact sandbox `contentWindow`, accepts exactly +one port, removes its Window `"message"` listener, and thereafter sends pageLoad-equivalent lifecycle data, GM +request/reply traffic, value/event updates, and skill-script requests only through the private port. + +This split matters because untrusted `@background`/`@crontab` code executes in the sandbox Window and can use +that Window's ordinary event APIs. A global Window-message transport would therefore let one background script +passively observe other sandbox traffic. A private `MessagePort` is reference-scoped: a userscript that never +receives the port cannot subscribe to or inject packets into that channel. The port is a transport-isolation +capability, not the final GM authorization boundary; existing broker/Service Worker permission and identity checks +still apply. -### Path B — Background scripts → Offscreen → Sandbox +Receiving the transferred port is also the parent's sandbox-readiness signal. The parent does not separately +poll/ping the iframe and does not mark an unavailable channel ready after a timeout. Once the port is attached, +the parent notifies the Service Worker, which replays enabled background/scheduled scripts and language state. -`@background` scripts have no page. The SW asks the Offscreen document to host them, and the Offscreen forwards -evaluation into the **Sandbox iframe** ([`src/app/service/sandbox/runtime.ts`](../../src/app/service/sandbox/runtime.ts)). The sandbox wraps execution in `BgExecScriptWarp`, which supplies managed `setTimeout`/`setInterval` and `CATRetryError` semantics so long-lived scripts can be cleanly torn down and retried. diff --git a/docs/references/architecture-gm-api.md b/docs/references/architecture-gm-api.md index 8282aae48..9850cdc57 100644 --- a/docs/references/architecture-gm-api.md +++ b/docs/references/architecture-gm-api.md @@ -7,12 +7,15 @@ across contexts to a privileged handler, then streams the result back. The imple - **Content side** ([`src/app/service/content/gm_api/`](../../src/app/service/content/gm_api)) — what runs *near* the userscript. Synchronous-feeling APIs (`GM_getValue`, `GM_log`) and the client half of async ones - (`GM_xmlhttpRequest`, `GM_setValue`). Built on `GM_Base`, which owns the messaging plumbing. + (`GM_xmlhttpRequest`, `GM_setValue`). Built on `GM_Base`, which owns the request facade. `USER_SCRIPT` calls use + the native extension channel; the DOM helper remains a narrow synchronous `CustomEventMessage` path. - **Service-worker side** ([`src/app/service/service_worker/gm_api/`](../../src/app/service/service_worker/gm_api)) — the privileged half: permission verification, cross-origin requests, DNR rule building. - **Offscreen side** ([`src/app/service/offscreen/gm_api.ts`](../../src/app/service/offscreen/gm_api.ts)) — DOM-dependent operations for background scripts (page-context XHR, `window.open`, clipboard). -- **Values** flow through `ValueService` and are broadcast so every tab running the same script sees updates. +- **Values** flow through `ValueService`. MAIN updates use the scripting broadcast, while USER_SCRIPT updates are + delivered over the native per-document callback port so privileged packets do not cross the page-observable DOM + channel. ### Registration: the `@GMContext.API` decorator @@ -82,4 +85,6 @@ traditional GM API: `@GMContext.API` on the content side [`compat-grant.js`](../../packages/eslint/compat-grant.js). What differs is the naming and transport shape — the grant is dotted (`CAT.agent.conversation`) and bound with `follow:` rather than `alias:`, the SW handlers set `dotAlias: false`, and conversation chat streams over `connect()` instead of `sendMessage`. Copy -the nearest existing `CAT.agent.*` method rather than a `GM_*` one. +the nearest existing `CAT.agent.*` method rather than a `GM_*` one. The service-worker handlers derive the script +identity from `request.script.uuid`, then the Agent services enforce persisted resource ownership; see +[`architecture-agent.md`](./architecture-agent.md#userscript-resource-ownership) for the scope and legacy rules. diff --git a/docs/references/architecture-services.md b/docs/references/architecture-services.md index a5a67c7b0..d921c7132 100644 --- a/docs/references/architecture-services.md +++ b/docs/references/architecture-services.md @@ -122,8 +122,8 @@ The `group("name")` call is what gives each service its action prefix (`resource on the single `serviceWorker` `Server`. Other contexts have their own composition roots (`OffscreenManager`, `SandboxManager`, `ScriptRuntime` for content/inject) that play a similar "wire dependencies, register handlers" role, but they are **not** built to the same dependency/initialization -shape as `ServiceWorkerManager` or each other: `OffscreenManager`'s constructor wraps a `WindowMessage` + -`Server` + `ServiceWorkerClient` into a shared base class; `SandboxManager` builds its own `Server` and hands +shape as `ServiceWorkerManager` or each other: `OffscreenManager`'s constructor wires a `SandboxChannelHost` + +`Server` + `ServiceWorkerClient` into a shared base class; `SandboxManager` builds its own `Server` over the private port and hands it to a single `Runtime`; and `ScriptRuntime` (content/inject) additionally owns lifecycle methods the others don't have, such as `contentInit()` and `externalMessage()`. Read each manager's own file rather than assuming it mirrors `ServiceWorkerManager`. diff --git a/docs/references/develop-testing.md b/docs/references/develop-testing.md index fab471b13..522915d15 100644 --- a/docs/references/develop-testing.md +++ b/docs/references/develop-testing.md @@ -312,6 +312,13 @@ before/after in one environment with the JSON-report method below. - Co-locate `*.test.ts`/`*.test.tsx` next to source (or place in `tests`). - Use `describe.concurrent()` / `it.concurrent()` where independent. - Single file: `pnpm test -- --run path/to/file.test.ts`. +- Without `CI`, `fast` and `ui` run every file in one shared worker (`isolate: false`, `maxWorkers: 1`); with `CI` + they spread across workers. `tests/vitest.reset-modules.ts` resets the module registry before each file's setup so + `vi.mock` binds the same way in both modes, but globals stay shared. Restore whatever a test mutates on + `Array.prototype`, `Object.prototype`, `navigator`, `window`, or an externalized singleton such as i18next — and + note that some mutations survive cleanup (defining `Array.prototype[0]` raises its `length`; use + `installArrayPrototypeIndexAccessor` from `tests/array_prototype_index.ts`). Before pushing, run both + `pnpm test -- --run` and `pnpm run test:ci`; either can fail alone. - Playwright tests are `*.spec.ts` files in `e2e`; worker count, retries, and artifact settings come from [`playwright.config.ts`](../../playwright.config.ts) and the CI matrix. Run targeted tests while iterating, then run `pnpm run lint` plus the relevant full suite before a PR. diff --git a/docs/references/main-world-message-privacy.md b/docs/references/main-world-message-privacy.md new file mode 100644 index 000000000..7c0548aca --- /dev/null +++ b/docs/references/main-world-message-privacy.md @@ -0,0 +1,48 @@ +# MAIN-world message privacy and fallback policy + +## Core value + +Ordinary host-page JavaScript should not be able to enumerate ScriptCat's full cross-world traffic merely by registering a global `window.message` listener. + +A random event name is **not authentication**. It does preserve an important privacy property: page code that does not know the per-document key has no wildcard API for subscribing to every custom event type on `performance`. + +## Final transport split + +| Path | Transport | Policy | +| --- | --- | --- | +| MAIN ↔ `scripting` | `PageEventMessage` over random-key `performance` CustomEvent | single path; no global `window.message` bus | +| MAIN privileged GM RPC | same bridge → isolated broker → Service Worker | authorization remains in broker/SW | +| MAIN pageLoad/value/event/external API | same keyed bridge | one lifecycle and one review boundary | +| USER_SCRIPT ↔ Service Worker | native `ExtensionMessage` / user-script port | browser-provided isolated transport | +| USER_SCRIPT listener unavailable | token-bound ordinary extension port | real browser-compatibility fallback | +| synchronous DOM node handoff | `CustomEventMessage` / MouseEvent relatedTarget | live DOM nodes are not structured-cloneable | +| Offscreen ↔ Sandbox | `WindowMessage` / `window.postMessage` | genuinely separate Window/frame transport | + +## MAIN fallback complexity removed + +This review branch intentionally removes: + +- native MAIN runtime-port probing; +- the one-second native bootstrap timeout; +- MAIN reconnect-token loop; +- `createMainWorldPageLoadGate`; +- `pageLoadFallback` request/replay; +- the MAIN-only bootstrap token used to choose between native and page transports; +- the dedicated MAIN-fallback E2E scenario. + +Those mechanisms implemented two transports for the same MAIN capability surface, making correctness depend on timing and doubling the data paths to audit. + +## Security invariants retained + +- Service Worker-issued execution handles stay bound to tab/frame/document/environment. +- `PageRpcRegistry` still checks handle, grant and sequence before forwarding page GM RPC. +- The Service Worker still resolves canonical script identity from the handle and real sender. +- Hostile accessor/proxy envelopes remain rejected by strict descriptor parsing. +- pageLoad/value/event DTOs keep the existing clone/shape validation. +- live `Document` transfer remains replaced by serialization. + +## Threat-model boundary + +The keyed event name reduces passive observability by ordinary page code. It must never be treated as a bearer capability or authorization secret. Privilege continues to depend on the isolated broker and Service Worker checks. + +Review rule: **do not publish all ScriptCat MAIN traffic onto a browser-global event type that arbitrary page code can subscribe to without first discovering any ScriptCat-specific key.** diff --git a/docs/references/sandbox-message-port-security.md b/docs/references/sandbox-message-port-security.md new file mode 100644 index 000000000..b5835193c --- /dev/null +++ b/docs/references/sandbox-message-port-security.md @@ -0,0 +1,195 @@ +# Private Offscreen/EventPage ↔ Sandbox MessagePort + +This note documents the transport boundary used for background and scheduled userscripts. + +## Problem statement + +Background and scheduled userscripts execute inside `src/sandbox.html`. They receive a pseudo-window backed by +the real sandbox Window's event APIs, so an untrusted script can register `window.onmessage` / +`window.addEventListener("message", ...)`. + +The previous `WindowMessage` transport placed all Offscreen/EventPage ↔ Sandbox envelopes on that same global +Window `"message"` bus. A background userscript could therefore passively observe other scripts' lifecycle +payloads, value/event callbacks, GM request/reply traffic, and skill-script execution payloads. Because +`WindowMessage` also accepted messages whose source was the current Window, the shared bus was also an +unnecessary injection surface. + +The goal is not to make background userscripts mutually unobservable at the JavaScript-realm level in general. +The narrower invariant is: + +> No untrusted userscript realm shares a global Window `"message"` transport with ScriptCat internal sandbox +> payloads. + +## Common protocol on Chromium and Firefox + +The parent differs by browser, but the sandbox protocol is identical. + +### Chromium + +```text +Service Worker + │ + │ existing SW/Offscreen messaging + ▼ +Offscreen document + │ + │ SandboxChannelHost + │ 1. install Window bootstrap listener + │ 2. create/attach sandbox iframe + ▼ +sandbox.html +``` + +### Firefox MV3 + +```text +Service Worker + EventPageOffscreenManager + │ + │ in-process SW/offscreen bridge + │ + │ SandboxChannelHost + │ 1. install Window bootstrap listener + │ 2. create/attach sandbox iframe + ▼ +sandbox.html +``` + +In both cases `sandbox.ts`: + +1. creates a new `MessageChannel`; +2. constructs `MessagePortMessage` around `port1`; +3. wires `Server("sandbox")`, `Runtime`, logger and GM plumbing; +4. transfers only `port2` to the parent with one `parent.postMessage(..., [port2])` call. + +The bootstrap is the primitive string `scriptcat/sandbox-message-port/v1`. It contains no script, GM, value, event, +token, configuration, resource, or execution payload. + +## Readiness semantics + +Port transfer is also the readiness signal. + +The parent accepts the bootstrap only when all of these are true: + +- the event source is exactly the current sandbox iframe `contentWindow`; +- the data is exactly the versioned bootstrap string; +- exactly one transferred `MessagePort` is present. + +After accepting the port the parent immediately removes its Window `"message"` bootstrap listener. All subsequent +traffic uses the private port. + +Only then does `BackgroundEnvManagerBase` call +`preparationOffscreen({ verified: true })`, which causes the Service Worker to replay enabled background/scheduled +scripts and language state. + +There is intentionally no second `preparationSandbox` RPC, no channel-health ping, and no timeout path that marks +an unavailable channel ready. The transport capability and the readiness fact are the same event. + +This ordering also prevents an installed malicious background script from racing the trusted bootstrap: no +background userscript is replayed until after the parent already owns the transferred port and has removed the +Window listener. + +## Confidentiality / injection properties + +After bootstrap: + +```text +parent trusted code <====================> sandbox trusted transport + private MessagePort + +background userscript: + window.onmessage -> does not receive port traffic + window.postMessage(...) -> cannot inject into the private port + no port reference -> cannot subscribe or send +``` + +A `MessagePort` reference is therefore a transport-isolation capability. It is not treated as final authorization. +GM privilege still depends on the existing ScriptCat script context, grant checks, execution identity and +Service Worker validation. + +## Same-realm prototype hardening + +The sandbox transport and untrusted background userscripts ultimately execute in the same JavaScript realm. +Keeping the port reference private is not sufficient if trusted code performs later lookups through mutable DOM +prototypes. + +For that reason `MessagePortMessage` captures/binds, before any userscript is replayed: + +- `MessagePort.postMessage`; +- `MessagePort.addEventListener`; +- `MessagePort.removeEventListener`; +- `MessagePort.start`; +- `MessagePort.close`; +- the native `MessageEvent.prototype.data` getter. + +Incoming packets are read through the captured `MessageEvent.data` getter rather than `event.data`. A malicious +background script that later replaces `MessageEvent.prototype.data` therefore cannot observe the private payload +when ScriptCat's handler reads it. + +The wire envelope is still parsed with captured `Reflect.ownKeys` / +`Object.getOwnPropertyDescriptor` helpers so accessor/proxy envelopes are rejected before field access. + +## Why not a random Window message key? + +A random key would reduce casual observation but would still keep payloads on a shared global Window event type. +Any userscript could subscribe to every `"message"` event first and inspect the key after the first packet. +A private port removes the payload from that global event bus entirely. + +## Why not chrome.runtime from sandbox? + +The manifest sandbox page is intentionally outside the ordinary extension privileged API surface. The parent can +use extension messaging, but the sandbox should not be given a direct `chrome.runtime` capability merely to avoid +Window messaging. + +A one-shot Window bootstrap that transfers a port is the narrow bridge: + +```text +Window.postMessage: capability transfer only +MessagePort: all real sandbox traffic +``` + +## Structured-clone and async semantics + +`MessagePort.postMessage` retains the useful properties of the old Window transport: + +- asynchronous message delivery; +- structured-clone serialization; +- transferable support where needed; +- no synchronous CustomEvent reentrancy. + +The change is transport visibility, not the higher-level `Message` / `MessageConnect` RPC contract. + +## Failure and lifecycle tradeoffs + +The design deliberately fails closed on readiness: if the iframe never transfers a valid port, the parent does not +announce a verified sandbox and does not replay privileged/background script state. + +This is safer and simpler than the removed timeout-ready path, but it means an iframe load/bootstrap failure can +leave background execution unavailable until the parent context is recreated. If crash/reload recovery is added +later, it should create a new explicit channel lifecycle rather than re-opening a permanent global Window message +bus. + +## Verification + +The review branch contains: + +- `packages/message/sandbox_message_channel.test.ts` + - request/reply and `MessageConnect` behavior; + - wrong source rejection; + - one-port bootstrap; + - Window listener removal; + - sender-side exact marker and one-shot transfer; + - object/accessor/proxy bootstrap rejection without inspecting object values; + - poisoned `MessageEvent.prototype.data` regression when the test DOM exposes the WebIDL getter. +- `src/app/service/offscreen/base.test.ts` + - no Service Worker readiness before port attachment; + - script/language replay only after private-channel readiness. +- `src/app/service/offscreen/event_page_manager.test.ts` + - Firefox Event Page creates the iframe only after parent transport setup. +- `src/app/service/sandbox/index.test.ts` + - no legacy readiness/health RPC. +- `e2e/sandbox-message-port.spec.ts` + - a real Chromium background userscript installs `window.onmessage` and a `"message"` listener; + - it also replaces the real browser `MessageEvent.prototype.data` getter with a snooping wrapper; + - another background script is installed/enabled to force real parent→sandbox lifecycle and GM storage traffic; + - the spy confirms that no internal envelope appears on the Window bus and the poisoned prototype getter never + observes a private-port envelope. diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index e79841391..26abb08c4 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -3,7 +3,7 @@ import path from "path"; import os from "os"; import { createServer, STATUS_CODES, type IncomingMessage, type ServerResponse } from "http"; import type { AddressInfo } from "net"; -import { test as base, expect, chromium, type BrowserContext, type Page } from "@playwright/test"; +import { test as base, expect, chromium, type BrowserContext, type Page, type Worker } from "@playwright/test"; import { headlessArgs } from "./launch-args"; import { autoApprovePermissions, installScriptByCode } from "./utils"; @@ -516,7 +516,7 @@ function patchTargetMatchCode(code: string, targetUrl: string): string { const url = new URL(targetUrl); const targetPattern = `${url.protocol}//${url.hostname}/*${url.search}`; return code.replace( - /^\/\/\s*@match\s+.*\?(gm_api_sync|gm_api_async|inject_content|early_inject_content|early_inject_page|WINDOW_MESSAGE_TEST_SC|SANDBOX_TEST_SC|unwrap_e2e_test|GM_XHR_REDIRECT_TEST_SC|GM_XHR_TEST_SC)$/gm, + /^\/\/\s*@match\s+.*\?(gm_api_sync|gm_api_async|inject_content|early_inject_content|early_inject_page|WINDOW_MESSAGE_TEST_SC|SANDBOX_TEST_SC|unwrap_e2e_test|GM_XHR_REDIRECT_TEST_SC|GM_XHR_TEST_SC|GM_STORAGE_COMPATIBILITY)$/gm, `// @match ${targetPattern}` ); } @@ -559,6 +559,56 @@ function patchGMApiTestCode(code: string, mockOrigin: string): string { ); } +const SW_E2E_ERROR_BUFFER = "__scriptcatE2EUnhandledErrors"; + +async function installServiceWorkerErrorCapture(worker: Worker): Promise { + await worker.evaluate((bufferKey) => { + const target = globalThis as typeof globalThis & Record; + const installedKey = `${bufferKey}Installed`; + if (target[installedKey]) return; + + const errors: string[] = []; + target[bufferKey] = errors; + target[installedKey] = true; + + const describe = (value: unknown) => { + if (value instanceof Error) return value.stack || `${value.name}: ${value.message}`; + if (typeof value === "string") return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } + }; + + globalThis.addEventListener("error", (event) => { + const detail = event as Event & { message?: string; error?: unknown }; + errors.push(detail.error ? describe(detail.error) : detail.message || "Service Worker error"); + }); + globalThis.addEventListener("unhandledrejection", (event) => { + const detail = event as Event & { reason?: unknown }; + errors.push(`Unhandled rejection: ${describe(detail.reason)}`); + }); + }, SW_E2E_ERROR_BUFFER); +} + +async function readServiceWorkerErrors(context: BrowserContext): Promise { + const batches = await Promise.all( + context.serviceWorkers().map(async (worker) => { + try { + return await worker.evaluate((bufferKey) => { + const target = globalThis as typeof globalThis & Record; + const errors = target[bufferKey]; + return Array.isArray(errors) ? (errors as string[]).slice() : []; + }, SW_E2E_ERROR_BUFFER); + } catch { + return []; + } + }) + ); + return batches.flat(); +} + async function runTestScript( context: BrowserContext, extensionId: string, @@ -568,26 +618,49 @@ async function runTestScript( options?: { patchCode?: (code: string) => string; requireOrigin?: string; + expectedSummaryCount?: number; // 声明为 auto:false 的 sctest 套件不随页面加载开跑,要先点面板的「运行」按钮。首次加载时 // ConsoleReporter 已经打过一次汇总(那时用例全被预置为 skip,即 "通过: 0 / 失败: 0"), // 所以点击后必须等**新的一次**汇总,不能沿用已有值。 beforeCollect?: (page: Page) => Promise; } -): Promise<{ summary: SCTestSummary; logs: string[] }> { +): Promise<{ summary: SCTestSummary; summaries: SCTestSummary[]; logs: string[] }> { let code = fs.readFileSync(path.join(__dirname, `../example/tests/${scriptFile}`), "utf-8"); code = patchScriptCode(code); if (options?.requireOrigin) code = patchRequireCode(code, options.requireOrigin); code = patchTargetMatchCode(code, targetUrl); code = options?.patchCode ? options.patchCode(code) : code; + const diagnosticLogs: string[] = []; + const handleServiceWorker = (worker: Worker) => { + void installServiceWorkerErrorCapture(worker).catch((error) => { + diagnosticLogs.push(`[serviceworker-capture] ${String(error)}`); + }); + }; + for (const worker of context.serviceWorkers()) { + await installServiceWorkerErrorCapture(worker).catch((error) => { + diagnosticLogs.push(`[serviceworker-capture] ${String(error)}`); + }); + } + context.on("serviceworker", handleServiceWorker); + autoApprovePermissions(context); await installScriptByCode(context, extensionId, code); const page = await context.newPage(); const logs: string[] = []; + const pageErrors: string[] = []; + const summaries: SCTestSummary[] = []; let summary: SCTestSummary | null = null; - let summaryCount = 0; + const expectedStartupSummaryCount = options?.expectedSummaryCount ?? 1; + const expectedFinalSummaryCount = expectedStartupSummaryCount + (options?.beforeCollect ? 1 : 0); + + page.on("pageerror", (error) => { + const detail = error.stack || `${error.name}: ${error.message}`; + pageErrors.push(detail); + logs.push(`[pageerror] ${detail}`); + }); page.on("console", (msg) => { const text = msg.text(); @@ -597,40 +670,82 @@ async function runTestScript( const parsed = JSON.parse(text.slice("[SCTEST_RESULT] ".length)) as SCTestSummary; if (parsed.protocol !== "sctest/v1") return; summary = parsed; + summaries.push(parsed); summaryCount++; } catch { // Keep collecting console output; the assertion below reports a missing valid summary. } }); - await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + const collectFatalErrors = async () => { + const workerErrors = await readServiceWorkerErrors(context); + return [ + ...pageErrors.map((error) => `[page] ${error}`), + ...workerErrors.map((error) => `[service-worker] ${error}`), + ]; + }; - if (options?.beforeCollect) { - // 顺序很重要:先等页面加载时那组汇总打完(那时 auto:false 的用例还全是 skip, - // 汇总是 "通过: 0 / 失败: 0"),再点按钮,最后等下一组汇总。 - // 若在 goto 之后立刻取快照,首次汇总往往还没打,会让第二个轮询被它立即满足而读到 0/0。 - await expect - .poll(() => summaryCount > 0, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) - .toBe(true) - .catch(() => undefined); - const seenBefore = summaryCount; - await options.beforeCollect(page); - await expect - .poll(() => summaryCount > seenBefore, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) - .toBe(true) - .catch(() => undefined); - } else { - await expect - .poll(() => summary !== null, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) - .toBe(true) - .catch(() => undefined); + const throwIfStartupFailed = async (expectedSummaryCount: number, seenFatalCount: number, phase: string) => { + const fatalErrors = await collectFatalErrors(); + if (summaryCount >= expectedSummaryCount || fatalErrors.length <= seenFatalCount) return; + throw new Error( + `Unhandled ${phase} error before SCTest summary for ${scriptFile}:\n${fatalErrors + .slice(seenFatalCount) + .join("\n\n")}\n\nConsole:\n${[...diagnosticLogs, ...logs].join("\n")}` + ); + }; + + try { + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + + if (options?.beforeCollect) { + await expect + .poll(async () => summaryCount >= expectedStartupSummaryCount || (await collectFatalErrors()).length > 0, { + timeout: timeoutMs, + intervals: [100, 250, 500, 1_000], + }) + .toBe(true) + .catch(() => undefined); + await throwIfStartupFailed(expectedStartupSummaryCount, 0, "startup"); + + const seenBefore = summaryCount; + const seenFatalCount = (await collectFatalErrors()).length; + await options.beforeCollect(page); + await expect + .poll(async () => summaryCount > seenBefore || (await collectFatalErrors()).length > seenFatalCount, { + timeout: timeoutMs, + intervals: [100, 250, 500, 1_000], + }) + .toBe(true) + .catch(() => undefined); + await throwIfStartupFailed(seenBefore + 1, seenFatalCount, "post-action"); + } else { + await expect + .poll(async () => summaryCount >= expectedStartupSummaryCount || (await collectFatalErrors()).length > 0, { + timeout: timeoutMs, + intervals: [100, 250, 500, 1_000], + }) + .toBe(true) + .catch(() => undefined); + await throwIfStartupFailed(expectedStartupSummaryCount, 0, "startup"); + } + } finally { + context.off("serviceworker", handleServiceWorker); + await page.close().catch(() => undefined); } - await page.close(); - expect(summary, `No valid SCTest summary found for ${scriptFile}:\n${logs.join("\n")}`).not.toBeNull(); - return { summary: summary!, logs }; + expect( + summary, + `Expected ${expectedFinalSummaryCount} valid SCTest summary result(s) for ${scriptFile}; found ${summaryCount}:\n${[ + ...diagnosticLogs, + ...logs, + ].join("\n")}` + ).not.toBeNull(); + expect(summaryCount, `Expected ${expectedFinalSummaryCount} SCTest summary result(s) for ${scriptFile}`).toBe( + expectedFinalSummaryCount + ); + return { summary: summary!, summaries, logs: [...diagnosticLogs, ...logs] }; } - // 设计稿统一为“运行全部”入口;旧面板若仍提供 suite 专属按钮则优先使用。 // 两条路径都只执行自动用例,itManual 保持待人工确认。 function clickSuiteRunButton(suiteName: string) { @@ -880,6 +995,34 @@ test.describe("GM API", () => { expect(summary.passed, "No test results found - script may not have run").toBeGreaterThan(0); }); + test("GM storage compatibility script checks cloning, value normalization, and reload persistence from one URL", async ({ + context, + extensionId, + }) => { + const targetUrl = `${gmApiMockServer.cspOrigin}/?GM_STORAGE_COMPATIBILITY`; + const { summary, logs } = await runTestScript(context, extensionId, "gm_storage_test.js", targetUrl, 60_000, { + requireOrigin: gmApiMockServer.origin, + expectedSummaryCount: 1, + }); + + console.log("[GM Storage Compatibility]", summary); + if (summary.failed !== 0) { + console.log("[GM Storage Compatibility] logs:", logs.join("\n")); + } + + expect(summary.name).toBe("GM Storage Compatibility"); + expect(summary.environment.url).toBe(targetUrl); + expect( + summary.failed, + `GM Storage Compatibility reports failed storage assertions: ${failedCaseNames(summary).join(", ")}` + ).toBe(0); + expect( + summary.total, + "The unified storage suite must register all clone, normalization, and persistence checks" + ).toBe(24); + expect(summary.passed, "All unified storage compatibility checks must pass").toBe(24); + }); + test("GM.* async API tests (gm_api_async_test.js)", async ({ context, extensionId }) => { const { summary, logs } = await runTestScript( context, diff --git a/e2e/main-world-keyed-bridge.spec.ts b/e2e/main-world-keyed-bridge.spec.ts new file mode 100644 index 000000000..9b3630b21 --- /dev/null +++ b/e2e/main-world-keyed-bridge.spec.ts @@ -0,0 +1,88 @@ +import { expect, startMockServer, test } from "./server-fixtures"; +import { autoApprovePermissions, installScriptByCode } from "./utils"; + +const scriptName = "MAIN world keyed bridge GM compatibility"; +const scriptCode = `// ==UserScript== +// @name ${scriptName} +// @namespace https://e2e.test +// @version 1.0.0 +// @match http://sitea.test/* +// @grant GM_getValue +// @grant GM_setValue +// ==/UserScript== + +const previous = GM_getValue("bridge-value", "missing"); +GM_setValue("bridge-value", "stored"); +document.documentElement.setAttribute("data-main-world-gm-value", previous + "|" + GM_getValue("bridge-value", "missing")); +`; + +const earlyBarrierScriptCode = `// ==UserScript== +// @name Early-start storage freshness barrier +// @namespace https://e2e.test +// @version 1.0.0 +// @match http://sitea.test/* +// @run-at document-start +// @early-start +// @grant GM_getValue +// @grant GM.setValue +// ==/UserScript== + +const firstLineValue = GM_getValue("early-barrier-value", "missing"); +if (location.search.includes("phase=commit")) { + await GM.setValue("early-barrier-value", "fresh"); + location.replace("/page?phase=verify"); +} else if (location.search.includes("phase=verify")) { + document.documentElement.setAttribute("data-early-barrier-value", firstLineValue); +} +`; + +test("MAIN-world keyed bridge keeps GM storage working without window message payloads", async ({ + context, + extensionId, +}) => { + const server = await startMockServer(); + try { + autoApprovePermissions(context); + await installScriptByCode(context, extensionId, scriptCode); + + const page = await context.newPage(); + await page.addInitScript(() => { + const pageWindow = window as Window & { __scWindowMessageLeaks: unknown[] }; + pageWindow.__scWindowMessageLeaks = []; + window.addEventListener("message", (event) => pageWindow.__scWindowMessageLeaks.push(event.data)); + }); + + await page.goto(server.url("sitea.test", "/page?phase=first"), { waitUntil: "domcontentloaded" }); + await expect(page.locator("html")).toHaveAttribute("data-main-world-gm-value", "missing|stored"); + await expect + .poll(() => + page.evaluate(() => (window as Window & { __scWindowMessageLeaks: unknown[] }).__scWindowMessageLeaks) + ) + .toEqual([]); + + await page.goto(server.url("sitea.test", "/page?phase=second"), { waitUntil: "domcontentloaded" }); + await expect(page.locator("html")).toHaveAttribute("data-main-world-gm-value", "stored|stored"); + await expect + .poll(() => + page.evaluate(() => (window as Window & { __scWindowMessageLeaks: unknown[] }).__scWindowMessageLeaks) + ) + .toEqual([]); + } finally { + await server.close(); + } +}); + +test("await GM.setValue is a freshness barrier for the next early-start document", async ({ context, extensionId }) => { + const server = await startMockServer(); + try { + autoApprovePermissions(context); + await installScriptByCode(context, extensionId, earlyBarrierScriptCode); + + const page = await context.newPage(); + await page.goto(server.url("sitea.test", "/page?phase=commit")); + await page.waitForURL(/phase=verify/); + await expect(page.locator("html")).toHaveAttribute("data-early-barrier-value", "fresh"); + } finally { + await server.close(); + } +}); diff --git a/e2e/sandbox-message-port.spec.ts b/e2e/sandbox-message-port.spec.ts new file mode 100644 index 000000000..47b2707fd --- /dev/null +++ b/e2e/sandbox-message-port.spec.ts @@ -0,0 +1,192 @@ +import { randomUUID } from "crypto"; +import type { BrowserContext } from "@playwright/test"; +import { testWithUserScripts as test, expect } from "./fixtures"; +import { autoApprovePermissions, installScriptByCode, openOptionsPage } from "./utils"; + +const TARGET_ORIGIN = "http://sandbox-channel.test"; + +type ScriptActionResponse = { + code?: number; + data?: T; + message?: string; +}; + +type InstalledScript = { + name: string; + uuid: string; +}; + +async function serveTargetPage(context: BrowserContext): Promise { + await context.route(`${TARGET_ORIGIN}/**`, (route) => + route.fulfill({ + status: 200, + contentType: "text/html; charset=utf-8", + body: "Sandbox channel E2E", + }) + ); +} + +async function enableBackgroundScript(context: BrowserContext, extensionId: string, name: string): Promise { + const page = await openOptionsPage(context, extensionId); + try { + const scripts = await page.evaluate(async () => { + const response = (await chrome.runtime.sendMessage({ + action: "serviceWorker/script/getAllScripts", + })) as ScriptActionResponse; + if (!response || response.code) throw new Error(response?.message || "getAllScripts failed"); + return response.data || []; + }); + const script = scripts.find((item) => item.name === name); + expect(script, `missing installed background script: ${name}`).toBeDefined(); + + const response = await page.evaluate(async (uuid) => { + return chrome.runtime.sendMessage({ + action: "serviceWorker/script/enable", + data: { uuid, enable: true }, + }) as Promise>>; + }, script!.uuid); + expect(response.code || 0, response.message).toBe(0); + } finally { + await page.close(); + } +} + +test.describe("private Offscreen/EventPage ↔ Sandbox MessagePort", () => { + test.setTimeout(120_000); + + test("background userscript window.onmessage cannot observe ScriptCat sandbox transport payloads", async ({ + context, + extensionId, + }) => { + await serveTargetPage(context); + const token = randomUUID().replaceAll("-", ""); + const storageName = `scriptcat-e2e-sandbox-port-${token}`; + const spyName = `E2E sandbox message spy ${token}`; + const victimName = `E2E sandbox victim ${token}`; + const readyAttribute = `data-sc-${token}-spy-ready`; + const countAttribute = `data-sc-${token}-window-message-count`; + const victimReadyAttribute = `data-sc-${token}-victim-ready`; + const prototypeHookAttribute = `data-sc-${token}-prototype-hooked`; + const prototypeCountAttribute = `data-sc-${token}-prototype-message-count`; + + const spyCode = `// ==UserScript== +// @name ${spyName} +// @namespace https://e2e.scriptcat.test/${token}/spy +// @version 1.0.0 +// @background +// @grant GM_setValue +// @storageName ${storageName} +// ==/UserScript== + +const observed = []; +const capture = (event) => { + observed.push(event.data); + GM_setValue("window-message-count", observed.length); +}; + +let prototypeReads = 0; +const dataDescriptor = Object.getOwnPropertyDescriptor(MessageEvent.prototype, "data"); +const prototypeHooked = !!dataDescriptor?.get && dataDescriptor.configurable === true; +if (prototypeHooked) { + Object.defineProperty(MessageEvent.prototype, "data", { + ...dataDescriptor, + get() { + const value = dataDescriptor.get.call(this); + if ( + value && + typeof value === "object" && + typeof value.messageId === "string" && + typeof value.type === "string" + ) { + prototypeReads += 1; + GM_setValue("prototype-message-count", prototypeReads); + } + return value; + }, + }); +} + +window.addEventListener("message", capture); +window.onmessage = capture; +GM_setValue("window-message-count", 0); +GM_setValue("prototype-message-count", 0); +GM_setValue("prototype-hooked", prototypeHooked); +GM_setValue("spy-ready", true); +return new Promise(() => {}); +`; + + const readerCode = `// ==UserScript== +// @name E2E sandbox message reader ${token} +// @namespace https://e2e.scriptcat.test/${token}/reader +// @version 1.0.0 +// @match ${TARGET_ORIGIN}/* +// @run-at document-start +// @grant GM_getValue +// @grant GM_addValueChangeListener +// @storageName ${storageName} +// ==/UserScript== + +const setMarker = (name, value) => { + const apply = () => document.documentElement?.setAttribute(name, String(value)); + if (document.documentElement) apply(); + else document.addEventListener("DOMContentLoaded", apply, { once: true }); +}; + +const sync = () => { + setMarker(${JSON.stringify(readyAttribute)}, GM_getValue("spy-ready", false)); + setMarker(${JSON.stringify(countAttribute)}, GM_getValue("window-message-count", -1)); + setMarker(${JSON.stringify(victimReadyAttribute)}, GM_getValue("victim-ready", false)); + setMarker(${JSON.stringify(prototypeHookAttribute)}, GM_getValue("prototype-hooked", false)); + setMarker(${JSON.stringify(prototypeCountAttribute)}, GM_getValue("prototype-message-count", -1)); +}; +GM_addValueChangeListener("spy-ready", sync); +GM_addValueChangeListener("window-message-count", sync); +GM_addValueChangeListener("victim-ready", sync); +GM_addValueChangeListener("prototype-hooked", sync); +GM_addValueChangeListener("prototype-message-count", sync); +sync(); +`; + + const victimCode = `// ==UserScript== +// @name ${victimName} +// @namespace https://e2e.scriptcat.test/${token}/victim +// @version 1.0.0 +// @background +// @grant GM_setValue +// @storageName ${storageName} +// ==/UserScript== + +GM_setValue("victim-ready", true); +return new Promise(() => {}); +`; + + await installScriptByCode(context, extensionId, spyCode); + await installScriptByCode(context, extensionId, readerCode); + autoApprovePermissions(context); + await enableBackgroundScript(context, extensionId, spyName); + + const page = await context.newPage(); + try { + await page.goto(`${TARGET_ORIGIN}/page?token=${token}`, { waitUntil: "domcontentloaded" }); + const root = page.locator("html"); + await expect(root).toHaveAttribute(readyAttribute, "true", { timeout: 20_000 }); + await expect(root).toHaveAttribute(prototypeHookAttribute, "true", { timeout: 20_000 }); + await expect.poll(() => root.getAttribute(countAttribute), { timeout: 20_000 }).toBe("0"); + await expect.poll(() => root.getAttribute(prototypeCountAttribute), { timeout: 20_000 }).toBe("0"); + + // Installing/enabling another background script forces parent → sandbox lifecycle traffic. + // With the old WindowMessage carrier the spy sees those envelopes on the global message bus. + await installScriptByCode(context, extensionId, victimCode); + await enableBackgroundScript(context, extensionId, victimName); + + // Wait for a real completion signal from the victim rather than sleeping. If any internal + // envelope leaked onto Window.message, the spy writes the non-zero count directly from + // its message handler, so victim-ready + count=0 proves lifecycle traffic stayed private. + await expect(root).toHaveAttribute(victimReadyAttribute, "true", { timeout: 20_000 }); + await expect.poll(() => root.getAttribute(countAttribute), { timeout: 5_000 }).toBe("0"); + await expect.poll(() => root.getAttribute(prototypeCountAttribute), { timeout: 5_000 }).toBe("0"); + } finally { + await page.close(); + } + }); +}); diff --git a/example/tests/gm_api_async_test.js b/example/tests/gm_api_async_test.js index 32c6ca9c7..b254d4096 100644 --- a/example/tests/gm_api_async_test.js +++ b/example/tests/gm_api_async_test.js @@ -288,7 +288,9 @@ }); expect(controller).toBeTypeOf("object"); expect(controller.abort).toBeTypeOf("function"); + const aborted = controller.catch((error) => error); controller.abort(); + expect(await aborted).toBe("AbortError"); }, null, null, diff --git a/example/tests/gm_storage_test.js b/example/tests/gm_storage_test.js new file mode 100644 index 000000000..c76a8a011 --- /dev/null +++ b/example/tests/gm_storage_test.js @@ -0,0 +1,1207 @@ +// ==UserScript== +// @name GM Storage Compatibility: Values, Cloning, and Persistence +// @namespace https://example.invalid/gm-storage +// @version 1.3.0 +// @description Runs one resilient GM storage compatibility suite covering cloning, normalization, dictionary shape, settled writes, and reload persistence for legacy and modern APIs, with pre-reload failures reported in the final UI. +// @match https://example.com/*?GM_STORAGE_COMPATIBILITY +// @run-at document-idle +// @grant GM_getValue +// @grant GM_setValue +// @grant GM_setValues +// @grant GM_listValues +// @grant GM_deleteValue +// @grant GM.getValue +// @grant GM.setValue +// @grant GM.deleteValue +// @grant GM_getValues +// @grant GM.getValues +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@36ab4ce5ff23c820a32cd13ac5a04d8834ab4d82/example/tests/lib/sctest.js +// ==/UserScript== + +(function () { + "use strict"; + + const QUERY_TOKEN = "GM_STORAGE_COMPATIBILITY"; + const AUTO_ASSERT = "自动断言"; + const PERSISTENCE_STATE_KEY = "__gm_persistence__:state-v2"; + + function readPersistenceState() { + try { + return JSON.parse(sessionStorage.getItem(PERSISTENCE_STATE_KEY) || "null"); + } catch { + sessionStorage.removeItem(PERSISTENCE_STATE_KEY); + return null; + } + } + + function describeError(error) { + if (error instanceof Error) { + return { + name: error.name || "Error", + message: error.message || String(error), + stack: typeof error.stack === "string" ? error.stack : undefined, + }; + } + + return { + name: "Error", + message: String(error), + stack: undefined, + }; + } + + function formatError(error) { + if (!error) return "Unknown error"; + const name = error.name || "Error"; + const message = error.message || String(error); + return name + ": " + message; + } + + // ----------------------------------------------------------------------------- + // Structured cloning and batch property semantics + // ----------------------------------------------------------------------------- + + function registerCloneCompatibilityTests(test) { + const { describe, check, expect } = test; + + const PREFIX = "__clone_probe__:"; + const DEFAULT = "__DEFAULT__"; + const SEED = "__OLD_VALUE__"; + + const keys = { + proxy: PREFIX + "proxy", + accessor: PREFIX + "accessor", + fn: PREFIX + "function", + symbol: PREFIX + "symbol", + symbolKey: PREFIX + "symbol-key", + batchNormal: PREFIX + "batch-normal", + batchAccessor: PREFIX + "batch-accessor", + }; + + function inspect(key) { + return { + listed: GM_listValues().includes(key), + value: GM_getValue(key, DEFAULT), + }; + } + + function expectTopLevelInvalidated(result) { + expect(result.thrown).toBe(undefined); + + // Tampermonkey keeps an own undefined entry for unsupported top-level values, + // while ScriptCat maps the same transition to its historical undefined=delete model. + // Both are compatible as long as the previous value is no longer observable. + const deleted = !result.immediate.listed && result.immediate.value === DEFAULT; + const storedUndefined = result.immediate.listed && result.immediate.value === undefined; + expect(deleted || storedUndefined).toBe(true); + } + + function runSingle(key, factory) { + GM_setValue(key, SEED); + + let thrown; + try { + GM_setValue(key, factory()); + } catch (error) { + thrown = String(error); + } + + return { + thrown, + immediate: inspect(key), + }; + } + + describe("GM_setValue clone compatibility", () => { + check( + AUTO_ASSERT, + "Proxy plain object is cloned instead of falling back to the default", + () => { + const proxyTraps = { + ownKeys: 0, + getOwnPropertyDescriptor: 0, + get: 0, + }; + const result = runSingle(keys.proxy, () => { + const target = { + a: 1, + nested: { b: 2 }, + }; + return new Proxy(target, { + ownKeys(target) { + proxyTraps.ownKeys++; + return Reflect.ownKeys(target); + }, + getOwnPropertyDescriptor(target, key) { + proxyTraps.getOwnPropertyDescriptor++; + return Reflect.getOwnPropertyDescriptor(target, key); + }, + get(target, key, receiver) { + proxyTraps.get++; + return Reflect.get(target, key, receiver); + }, + }); + }); + + expect(result.thrown).toBe(undefined); + expect(result.immediate.listed).toBe(true); + expect(result.immediate.value).toEqual({ a: 1, nested: { b: 2 } }); + expect(proxyTraps.ownKeys).toBe(1); + expect(proxyTraps.getOwnPropertyDescriptor).toBe(2); + // JSON/legacy-compatible cloning must observe property reads. Engines may additionally + // probe toJSON, so lock the semantic lower bound instead of an engine-internal count. + expect(proxyTraps.get >= 2).toBeTruthy(); + }, + null, + null, + null + ); + + check( + AUTO_ASSERT, + "enumerable getter is evaluated exactly once and stored as a data value", + () => { + let getterCalls = 0; + const result = runSingle(keys.accessor, () => { + const value = { normal: 123 }; + Object.defineProperty(value, "calculated", { + enumerable: true, + configurable: true, + get() { + getterCalls++; + return 456; + }, + }); + return value; + }); + + expect(result.thrown).toBe(undefined); + expect(getterCalls).toBe(1); + expect(result.immediate.listed).toBe(true); + expect(result.immediate.value).toEqual({ normal: 123, calculated: 456 }); + expect(Object.getOwnPropertyDescriptor(result.immediate.value, "calculated").get).toBe(undefined); + }, + null, + null, + null + ); + + check( + AUTO_ASSERT, + "function value invalidates the previous top-level value", + () => { + const result = runSingle(keys.fn, () => { + return function storedFunction() { + return 123; + }; + }); + + expectTopLevelInvalidated(result); + }, + null, + null, + null + ); + + check( + AUTO_ASSERT, + "symbol primitive invalidates the previous top-level value", + () => { + const result = runSingle(keys.symbol, () => Symbol("stored-symbol")); + + expectTopLevelInvalidated(result); + }, + null, + null, + null + ); + + check( + AUTO_ASSERT, + "symbol-keyed metadata is omitted while normal string-keyed data remains", + () => { + const result = runSingle(keys.symbolKey, () => { + const meta = Symbol("private-meta"); + const value = { normal: 123 }; + Object.defineProperty(value, meta, { + enumerable: false, + configurable: true, + value: "invisible metadata", + }); + return value; + }); + + expect(result.thrown).toBe(undefined); + expect(result.immediate.listed).toBe(true); + expect(result.immediate.value).toEqual({ normal: 123 }); + expect(Object.getOwnPropertySymbols(result.immediate.value)).toEqual([]); + }, + null, + null, + null + ); + }); + + describe("GM_setValues top-level property semantics", () => { + check( + AUTO_ASSERT, + "enumerable top-level accessor is read once and both batch values are updated", + () => { + GM_setValues({ + [keys.batchNormal]: SEED, + [keys.batchAccessor]: SEED, + }); + + let batchGetterCalls = 0; + const batch = { + [keys.batchNormal]: "NEW_NORMAL", + }; + Object.defineProperty(batch, keys.batchAccessor, { + enumerable: true, + configurable: true, + get() { + batchGetterCalls++; + return "NEW_FROM_GETTER"; + }, + }); + + let thrown; + try { + GM_setValues(batch); + } catch (error) { + thrown = String(error); + } + + expect(thrown).toBe(undefined); + expect(batchGetterCalls).toBe(1); + expect(inspect(keys.batchNormal)).toEqual({ listed: true, value: "NEW_NORMAL" }); + expect(inspect(keys.batchAccessor)).toEqual({ listed: true, value: "NEW_FROM_GETTER" }); + }, + null, + null, + null + ); + }); + + } + + // ----------------------------------------------------------------------------- + // Dictionary shape and value normalization + // ----------------------------------------------------------------------------- + + function registerValueNormalizationTests(test) { + const { describe, check, expect } = test; + + const PREFIX = "__gm_normalization__:"; + const DEFAULT = "__DEFAULT__"; + + function listed(key) { + return GM_listValues().includes(key); + } + + function expectNullPrototypeDictionary(value, ownKeys) { + expect(Object.getPrototypeOf(value)).toBe(null); + expect(value instanceof Object).toBe(false); + expect(typeof value.hasOwnProperty).toBe("undefined"); + + for (const key of ownKeys) { + expect(Object.hasOwn(value, key)).toBe(true); + } + } + + function expectInvalidatedTopLevel(key) { + const isListed = listed(key); + const value = GM_getValue(key, DEFAULT); + + // ScriptCat intentionally maps top-level unsupported values to delete. + // Tampermonkey keeps an own undefined value. Both are compatible here as long as the + // previous value is invalidated and no old value remains observable. + const scriptCatDelete = !isListed && value === DEFAULT; + const tampermonkeyUndefined = isListed && value === undefined; + expect(scriptCatDelete || tampermonkeyUndefined).toBe(true); + } + + describe("GM_getValues dictionary shape", () => { + check( + AUTO_ASSERT, + "all legacy and modern GM_getValues forms return null-prototype dictionaries", + async () => { + const normalKey = PREFIX + "shape-normal"; + const keys = [normalKey, "__proto__", "constructor", "toString"]; + + GM_setValue(normalKey, "NORMAL"); + GM_setValue("__proto__", "PROTO"); + GM_setValue("constructor", "CONSTRUCTOR"); + GM_setValue("toString", "TOSTRING"); + + const defaults = {}; + for (const [key, value] of [ + [normalKey, "DEFAULT-NORMAL"], + ["__proto__", "DEFAULT-PROTO"], + ["constructor", "DEFAULT-CONSTRUCTOR"], + ["toString", "DEFAULT-TOSTRING"], + ]) { + Object.defineProperty(defaults, key, { + configurable: true, + enumerable: true, + writable: true, + value, + }); + } + + const nullDefaults = Object.create(null); + for (const key of Reflect.ownKeys(defaults)) { + Object.defineProperty(nullDefaults, key, Object.getOwnPropertyDescriptor(defaults, key)); + } + + const results = [ + GM_getValues(keys), + GM_getValues(defaults), + GM_getValues(nullDefaults), + GM_getValues(null), + GM_getValues(undefined), + await GM.getValues(keys), + await GM.getValues(defaults), + await GM.getValues(nullDefaults), + await GM.getValues(null), + await GM.getValues(undefined), + ]; + + for (const result of results) { + expectNullPrototypeDictionary(result, keys); + expect(result[normalKey]).toBe("NORMAL"); + expect(result.__proto__).toBe("PROTO"); + expect(result.constructor).toBe("CONSTRUCTOR"); + expect(result.toString).toBe("TOSTRING"); + } + }, + null, + null, + null + ); + }); + + describe("top-level cross-manager compatibility", () => { + check( + AUTO_ASSERT, + "Function invalidates an existing value without preserving the old value", + () => { + const key = PREFIX + "function"; + GM_setValue(key, "OLD"); + GM_setValue(key, function storedFunction() { + return 123; + }); + + expectInvalidatedTopLevel(key); + }, + null, + null, + null + ); + + check( + AUTO_ASSERT, + "Symbol invalidates an existing value without preserving the old value", + () => { + const key = PREFIX + "symbol"; + GM_setValue(key, "OLD"); + GM_setValue(key, Symbol("stored-symbol")); + + expectInvalidatedTopLevel(key); + }, + null, + null, + null + ); + + check( + AUTO_ASSERT, + "BigInt invalidates an existing value without preserving the old value", + () => { + const key = PREFIX + "bigint"; + GM_setValue(key, "OLD"); + GM_setValue(key, 123n); + + expectInvalidatedTopLevel(key); + }, + null, + null, + null + ); + + check( + AUTO_ASSERT, + "invalid top-level write is a state transition and a later valid write still replaces it", + () => { + const key = PREFIX + "sequence"; + GM_setValue(key, "OLD"); + GM_setValue(key, () => "invalid"); + expectInvalidatedTopLevel(key); + + GM_setValue(key, "NEW"); + + expect(GM_getValue(key, DEFAULT)).toBe("NEW"); + expect(listed(key)).toBe(true); + }, + null, + null, + null + ); + }); + + describe("nested Tampermonkey-style normalization", () => { + check( + AUTO_ASSERT, + "plain object omits nested Function Symbol and undefined recursively", + () => { + const key = PREFIX + "object"; + GM_setValue(key, { + before: 1, + fn() { + return 2; + }, + symbol: Symbol("nested-symbol"), + undef: undefined, + after: 3, + deep: { + before: 4, + fn() { + return 5; + }, + symbol: Symbol("deep-symbol"), + undef: undefined, + after: 6, + }, + }); + + expect(GM_getValue(key)).toEqual({ + before: 1, + after: 3, + deep: { + before: 4, + after: 6, + }, + }); + }, + null, + null, + null + ); + + check( + AUTO_ASSERT, + "array converts nested Function Symbol and undefined slots to null", + () => { + const key = PREFIX + "array"; + GM_setValue(key, [1, () => 2, Symbol("array-symbol"), undefined, 5]); + + expect(GM_getValue(key)).toEqual([1, null, null, null, 5]); + }, + null, + null, + null + ); + + check( + AUTO_ASSERT, + "GM_setValues applies the same top-level delete and nested normalization rules", + () => { + const fnKey = PREFIX + "batch-function"; + const symbolKey = PREFIX + "batch-symbol"; + const objectKey = PREFIX + "batch-object"; + const arrayKey = PREFIX + "batch-array"; + + GM_setValues({ + [fnKey]: "OLD_FN", + [symbolKey]: "OLD_SYMBOL", + }); + GM_setValues({ + [fnKey]: () => "invalid", + [symbolKey]: Symbol("invalid"), + [objectKey]: { before: 1, undef: undefined, fn() {}, after: 2 }, + [arrayKey]: [1, undefined, () => 2, Symbol("nested"), 5], + }); + + expectInvalidatedTopLevel(fnKey); + expectInvalidatedTopLevel(symbolKey); + expect(GM_getValue(objectKey)).toEqual({ before: 1, after: 2 }); + expect(GM_getValue(arrayKey)).toEqual([1, null, null, null, 5]); + }, + null, + null, + null + ); + }); + + describe("measured scalar and object compatibility", () => { + check( + AUTO_ASSERT, + "non-array special objects become enumerable-property bags", + () => { + const key = PREFIX + "special-property-bags"; + const error = new Error("hidden"); + error.cause = { reason: "cause" }; + error.extra = "visible"; + class ProbeClass { + constructor() { + this.number = 123; + this.text = "class-instance"; + } + + method() { + return "METHOD"; + } + } + + GM_setValue(key, { + date: new Date("2024-01-02T03:04:05.000Z"), + invalidDate: new Date(NaN), + url: new URL("https://example.com/path"), + regexp: /probe/gi, + map: new Map([["a", 1]]), + set: new Set(["x"]), + arrayBuffer: new Uint8Array([1, 2, 3]).buffer, + dataView: new DataView(new ArrayBuffer(4)), + typed: new Uint8Array([9, 8, 7]), + error, + classInstance: new ProbeClass(), + }); + + expect(GM_getValue(key)).toEqual({ + date: {}, + invalidDate: {}, + url: {}, + regexp: {}, + map: {}, + set: {}, + arrayBuffer: {}, + dataView: {}, + typed: { 0: 9, 1: 8, 2: 7 }, + error: { cause: { reason: "cause" }, extra: "visible" }, + classInstance: { number: 123, text: "class-instance" }, + }); + }, + null, + null, + null + ); + + check( + AUTO_ASSERT, + "special objects nested in arrays keep array shape and use property-bag serialization", + () => { + const key = PREFIX + "array-special-types"; + GM_setValue(key, [ + new Date("2021-02-03T04:05:06.789Z"), + new Map([["map", 1]]), + new Set(["set"]), + new Uint16Array([100, 200, 300]), + ]); + + expect(GM_getValue(key)).toEqual([{}, {}, {}, { 0: 100, 1: 200, 2: 300 }]); + }, + null, + null, + null + ); + + check( + AUTO_ASSERT, + "negative zero is canonicalized to positive zero", + () => { + const key = PREFIX + "negative-zero"; + GM_setValue(key, -0); + const stored = GM_getValue(key); + + expect(stored).toBe(0); + expect(Object.is(stored, -0)).toBe(false); + }, + null, + null, + null + ); + + check( + AUTO_ASSERT, + "GM.setValue keeps NaN and infinities lossless after await", + async () => { + const values = { + nan: NaN, + positiveInfinity: Infinity, + negativeInfinity: -Infinity, + }; + + for (const [name, value] of Object.entries(values)) { + const key = PREFIX + "async-" + name; + await GM.setValue(key, value); + const stored = GM_getValue(key); + + if (Number.isNaN(value)) { + expect(Number.isNaN(stored)).toBe(true); + } else { + expect(stored).toBe(value); + } + } + }, + null, + null, + null + ); + }); + + } + + // ----------------------------------------------------------------------------- + // Same-document settling and reload persistence + // ----------------------------------------------------------------------------- + + async function prepareOrRegisterPersistenceTests(test) { + const PREFIX = "__gm_persistence__:"; + const DEFAULT = PREFIX + "__DEFAULT__"; + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + class ProbeClass { + constructor() { + this.number = 123; + this.text = "class-instance"; + } + + method() { + return "METHOD"; + } + } + + function makeSpecialObject() { + const error = new Error("hidden"); + error.cause = { reason: "cause" }; + error.extra = "custom-property"; + + return { + date: new Date("2024-03-14T12:34:56.789Z"), + invalidDate: new Date(NaN), + url: new URL("https://example.com/a/b?q=1#hash"), + regexp: /foo\d+(bar)?/gimu, + map: new Map([ + ["string-key", 123], + [42, "number-key"], + ]), + set: new Set(["alpha", 123]), + arrayBuffer: new Uint8Array([0, 1, 2, 255]).buffer, + dataView: new DataView(new ArrayBuffer(8)), + typed: new Uint8Array([9, 8, 7]), + error, + classInstance: new ProbeClass(), + }; + } + + function makeArraySpecial() { + return [ + new Date("2021-02-03T04:05:06.789Z"), + new Map([["map", 1]]), + new Set(["set"]), + new Uint16Array([100, 200, 300]), + ]; + } + + function makeCycle() { + const value = { name: "cycle-root" }; + value.self = value; + return value; + } + + const cases = [ + { id: "nan", make: () => NaN, expected: NaN }, + { id: "positive-infinity", make: () => Infinity, expected: Infinity }, + { id: "negative-infinity", make: () => -Infinity, expected: -Infinity }, + { id: "negative-zero", make: () => -0, expected: 0 }, + { id: "date", make: () => new Date("2024-03-14T12:34:56.789Z"), expected: {} }, + { id: "invalid-date", make: () => new Date(NaN), expected: {} }, + { id: "url", make: () => new URL("https://example.com/a/b?q=1#hash"), expected: {} }, + { id: "regexp", make: () => /foo\d+(bar)?/gimu, expected: {} }, + { + id: "map", + make: () => + new Map([ + ["string-key", 123], + [42, "number-key"], + ]), + expected: {}, + }, + { id: "set", make: () => new Set(["alpha", 123]), expected: {} }, + { id: "array-buffer", make: () => new Uint8Array([0, 1, 2, 255]).buffer, expected: {} }, + { id: "data-view", make: () => new DataView(new ArrayBuffer(8)), expected: {} }, + { + id: "uint8array", + make: () => new Uint8Array([0, 1, 127, 128, 255]), + expected: { 0: 0, 1: 1, 2: 127, 3: 128, 4: 255 }, + }, + { + id: "int16array", + make: () => new Int16Array([-32768, -1, 0, 1, 32767]), + expected: { 0: -32768, 1: -1, 2: 0, 3: 1, 4: 32767 }, + }, + { + id: "float64array", + make: () => new Float64Array([1.5, -2.25, NaN, Infinity, -Infinity]), + expected: { 0: 1.5, 1: -2.25, 2: NaN, 3: Infinity, 4: -Infinity }, + }, + { + id: "error", + make: () => { + const error = new TypeError("hidden"); + error.cause = { reason: "cause" }; + error.extra = "custom-property"; + return error; + }, + expected: { cause: { reason: "cause" }, extra: "custom-property" }, + }, + { + id: "class-instance", + make: () => new ProbeClass(), + expected: { number: 123, text: "class-instance" }, + }, + { + id: "special-object", + make: makeSpecialObject, + expected: { + date: {}, + invalidDate: {}, + url: {}, + regexp: {}, + map: {}, + set: {}, + arrayBuffer: {}, + dataView: {}, + typed: { 0: 9, 1: 8, 2: 7 }, + error: { cause: { reason: "cause" }, extra: "custom-property" }, + classInstance: { number: 123, text: "class-instance" }, + }, + }, + { + id: "array-special", + make: makeArraySpecial, + expected: [{}, {}, {}, { 0: 100, 1: 200, 2: 300 }], + }, + { id: "bigint", make: () => 123456789012345678901234567890n, seed: true, missing: true }, + { id: "cyclic-object", make: makeCycle, seed: true, missing: true }, + ]; + + if (typeof Blob === "function") { + cases.push({ + id: "blob", + make: () => new Blob(["hello GM storage blob"], { type: "text/plain" }), + expected: {}, + }); + } + + if (typeof File === "function") { + cases.push({ + id: "file", + make: () => new File(["file payload"], "probe.txt", { type: "text/plain", lastModified: 1700000000123 }), + expected: {}, + }); + } + + function describeValue(value) { + if (value === undefined) return { type: "undefined" }; + if (value === null) return { type: "null" }; + + if (typeof value === "number") { + return { + type: "number", + value: Number.isNaN(value) + ? "NaN" + : value === Infinity + ? "Infinity" + : value === -Infinity + ? "-Infinity" + : Object.is(value, -0) + ? "-0" + : String(value), + }; + } + + if (typeof value === "bigint") { + return { type: "bigint", value: value.toString() }; + } + + if (typeof value === "string" || typeof value === "boolean") { + return { type: typeof value, value }; + } + + if (Array.isArray(value)) { + return { + type: "array", + items: value.map((item) => describeValue(item)), + }; + } + + if (typeof value === "object") { + return { + type: "object", + entries: Object.keys(value) + .sort() + .map((key) => [key, describeValue(value[key])]), + }; + } + + return { type: typeof value, value: String(value) }; + } + + const expected = {}; + for (const testCase of cases) { + expected[testCase.id] = testCase.missing + ? { listed: false, value: { type: "missing" } } + : { listed: true, value: describeValue(testCase.expected) }; + } + + function sameDescriptor(left, right) { + return JSON.stringify(left) === JSON.stringify(right); + } + + function stringifyDescriptor(value) { + try { + const json = JSON.stringify(value); + return json === undefined ? String(value) : json; + } catch (error) { + return ""; + } + } + + function isInvalidatedTopLevel(entry) { + if (!entry) return false; + if (!entry.listed && entry.value?.type === "missing") return true; + if (entry.value?.type === "undefined" || entry.value?.type === "null") return true; + return entry.value?.type === "string" && entry.value.value === DEFAULT; + } + + function isCompatibleFloat64Array(entry) { + if (!entry?.listed || entry.value?.type !== "object") return false; + const entries = Object.fromEntries(entry.value.entries || []); + + if (!sameDescriptor(entries["0"], describeValue(1.5))) return false; + if (!sameDescriptor(entries["1"], describeValue(-2.25))) return false; + + const special = [ + ["2", describeValue(NaN)], + ["3", describeValue(Infinity)], + ["4", describeValue(-Infinity)], + ]; + return special.every(([key, exact]) => { + const actual = entries[key]; + return sameDescriptor(actual, exact) || sameDescriptor(actual, { type: "null" }); + }); + } + + function assertCompatibleSnapshot(actual, phase, operationErrors = []) { + const mismatches = []; + + for (const operationError of operationErrors) { + mismatches.push( + operationError.caseId + + " [" + + operationError.stage + + "]: " + + formatError(operationError.error) + ); + } + + for (const testCase of cases) { + const id = testCase.id; + const actualEntry = actual?.[id]; + let compatible; + + if (id === "float64array") { + compatible = isCompatibleFloat64Array(actualEntry); + } else if (id === "bigint") { + // Tampermonkey exposes an own undefined entry; ScriptCat maps the same unsupported + // top-level transition to its historical undefined=delete behavior. + compatible = + sameDescriptor(actualEntry, expected[id]) || + sameDescriptor(actualEntry, { listed: true, value: { type: "undefined" } }) || + (phase !== "persisted" && isInvalidatedTopLevel(actualEntry)); + } else if (id === "cyclic-object") { + // Both managers reject cyclic persistence. Their same-document transient + // cache/listing state differs, so only require the seeded OLD value + // to be invalidated before reload. + compatible = + phase === "persisted" + ? sameDescriptor(actualEntry, expected[id]) + : isInvalidatedTopLevel(actualEntry); + } else { + compatible = sameDescriptor(actualEntry, expected[id]); + } + + if (!compatible) { + mismatches.push( + id + + ": expected " + + stringifyDescriptor(expected[id]) + + ", actual " + + stringifyDescriptor(actualEntry) + ); + } + } + + if (mismatches.length > 0) { + throw new Error("Incompatible " + phase + " storage snapshot:\n" + mismatches.join("\n")); + } + } + function keyFor(api, id) { + return PREFIX + api + ":" + id; + } + + function describeKey(key) { + const isListed = GM_listValues().includes(key); + const value = GM_getValue(key, DEFAULT); + + if (!isListed && value === DEFAULT) { + return { listed: false, value: { type: "missing" } }; + } + + return { + listed: isListed, + value: describeValue(value), + }; + } + + function snapshot(api) { + const result = {}; + for (const testCase of cases) { + result[testCase.id] = describeKey(keyFor(api, testCase.id)); + } + return result; + } + + function captureOperationError(errors, caseId, stage, error) { + errors.push({ + caseId, + stage, + error: describeError(error), + }); + } + + async function deleteKey(key, caseId, errors) { + try { + await GM.deleteValue(key); + return; + } catch (modernError) { + try { + const fallback = GM_deleteValue(key); + if (fallback && typeof fallback.then === "function") { + await fallback; + } + return; + } catch (legacyError) { + captureOperationError( + errors, + caseId, + "delete", + new Error( + "GM.deleteValue failed (" + + formatError(describeError(modernError)) + + "); GM_deleteValue fallback also failed (" + + formatError(describeError(legacyError)) + + ")" + ) + ); + } + } + } + + async function write(api, key, value, caseId, stage, errors) { + try { + if (api === "modern") { + await GM.setValue(key, value); + } else { + const result = GM_setValue(key, value); + // Legacy GM_setValue is normally synchronous, but some compatibility + // layers return a thenable. Observe it when present so a rejection does + // not escape as an unhandled promise and terminate the preparation pass. + if (result && typeof result.then === "function") { + await result; + } + } + return true; + } catch (error) { + captureOperationError(errors, caseId, stage, error); + return false; + } + } + + async function populate(api) { + const errors = []; + + for (const testCase of cases) { + const key = keyFor(api, testCase.id); + await deleteKey(key, testCase.id, errors); + + if (testCase.seed) { + await write(api, key, "OLD", testCase.id, "seed write", errors); + } + + let value; + try { + value = testCase.make(); + } catch (error) { + captureOperationError(errors, testCase.id, "value construction", error); + continue; + } + + await write(api, key, value, testCase.id, "test write", errors); + } + + return errors; + } + + const state = readPersistenceState(); + + if (!state || state.phase !== "verify") { + const setupErrors = { + legacy: await populate("legacy"), + modern: await populate("modern"), + }; + + const immediate = { + legacy: snapshot("legacy"), + modern: snapshot("modern"), + }; + + // Let legacy fire-and-forget transport and any echoed value updates settle before reload. + await sleep(1200); + + const settled = { + legacy: snapshot("legacy"), + modern: snapshot("modern"), + }; + + sessionStorage.setItem( + PERSISTENCE_STATE_KEY, + JSON.stringify({ + phase: "verify", + immediate, + settled, + setupErrors, + }) + ); + + location.reload(); + return; + } + + sessionStorage.removeItem(PERSISTENCE_STATE_KEY); + + const persisted = { + legacy: snapshot("legacy"), + modern: snapshot("modern"), + }; + + const { describe, check } = test; + + if (state.fatalSetupError) { + describe("Persistence harness", () => { + check( + AUTO_ASSERT, + "pre-reload persistence preparation completes without an uncaught error", + () => { + throw new Error( + "Persistence preparation failed before all snapshots could be captured: " + + formatError(state.fatalSetupError) + ); + }, + null, + null, + null + ); + }); + } + + for (const api of ["legacy", "modern"]) { + describe(api === "legacy" ? "GM_setValue persistence" : "GM.setValue persistence", () => { + check( + AUTO_ASSERT, + "immediate userscript-visible values match measured compatibility semantics", + () => { + assertCompatibleSnapshot( + state.immediate?.[api], + "immediate", + state.setupErrors?.[api] || [] + ); + }, + null, + null, + null + ); + + check( + AUTO_ASSERT, + "settled same-document values remain consistent after transport echo", + () => { + assertCompatibleSnapshot(state.settled?.[api], "settled"); + }, + null, + null, + null + ); + + check( + AUTO_ASSERT, + "values keep the same representation after reload persistence", + () => { + assertCompatibleSnapshot(persisted[api], "persisted"); + }, + null, + null, + null + ); + }); + } + + } + + // ----------------------------------------------------------------------------- + // Orchestration + // ----------------------------------------------------------------------------- + + async function main() { + if (!location.search.includes(QUERY_TOKEN)) return; + + const persistenceState = readPersistenceState(); + + // The first pass only captures the persistence snapshots required for reload + // verification. API failures are recorded as serializable test data instead of + // escaping as unhandled rejections. SCTest is not created here, so no partial + // test UI is rendered. + if (persistenceState?.phase !== "verify") { + try { + await prepareOrRegisterPersistenceTests(null); + } catch (error) { + // Last-resort bridge into the visible SCTest pass. Keep only plain strings + // in sessionStorage so even serialization-related failures can be reported. + const emergencyState = { + phase: "verify", + immediate: { legacy: {}, modern: {} }, + settled: { legacy: {}, modern: {} }, + setupErrors: { legacy: [], modern: [] }, + fatalSetupError: describeError(error), + }; + + sessionStorage.setItem(PERSISTENCE_STATE_KEY, JSON.stringify(emergencyState)); + location.reload(); + } + return; + } + + // The verification pass owns exactly one SCTest instance. Every check is + // registered into this instance and rendered by one final run(). + const test = SCTest.create({ name: "GM Storage Compatibility" }); + + registerCloneCompatibilityTests(test); + registerValueNormalizationTests(test); + await prepareOrRegisterPersistenceTests(test); + + await test.run(); + } + + void main(); +})(); diff --git a/packages/message/README.md b/packages/message/README.md index 7e15272ea..7b07187ad 100644 --- a/packages/message/README.md +++ b/packages/message/README.md @@ -1,21 +1,28 @@ # 消息 -跨 context(service_worker / content / inject / offscreen / sandbox)消息交互的抽象层。按调用形态选择传输方式: +跨 context(service_worker / content / inject / offscreen / sandbox)消息交互的抽象层,也包含与 +`scripting` 页面桥接辅助脚本的消息。按调用形态选择传输方式: - **单次 request/reply**(调用一次拿一次结果,例如大多数 GM API、扩展页面对 service_worker 的一次性调用)—— 使用 `sendMessage`(`Server`/`Group`/`Client` 的 RPC 封装)。 - **流式/进度/长响应,或需要持续双向交换**(例如需要分块返回大响应的 GM API、需要多次调用/多次结果的场景)—— 使用 `connect()`(`MessageConnect`)建立持久连接。 -- **广播**(service_worker/offscreen 触发的状态变化需要通知所有页面)——使用 `MessageQueue` 的 +- **广播**(service_worker/offscreen 的状态变化需要通知已实例化 `MessageQueue` 并订阅对应 topic 的上下文)——使用 `MessageQueue` 的 `publish`/`subscribe`,而不是上面两种点对点方式。 Service Worker → Offscreen 在 Chrome 与 Firefox 上走不同路径(Chrome 使用 `ServiceWorkerMessageSend`/`clients.matchAll()`;Firefox 用 `EventPageOffscreenManager` 替代真实的 offscreen -document),细节见 +document);但 Offscreen/EventPage ↔ Sandbox 两边统一使用 sandbox 主动创建并 transfer 的 private +`MessagePort`,只有一次不带业务 payload 的 Window bootstrap。细节见 [`docs/architecture.md` § Chrome vs Firefox: the offscreen split](../../docs/architecture.md#chrome-vs-firefox-the-offscreen-split)。 ## 注意点 -- service_worker 和 offscreen 之间可以使用 postMessage 的方式进行通信,避免同时监听 message 与 connect 导致冲突的问题。 +- service_worker 和 offscreen 之间可以使用 postMessage 的方式进行通信,避免同时监听 message 与 connect 导致冲突的问题。这个通道不与 sandbox userscript 共用 Window;Offscreen/EventPage ↔ Sandbox 不得使用全局 `window.message` 承载业务 payload。 - service_worker 会在空闲后进入不活动状态;与它建立的 `connect()` 长连接会在此时中断,因此需要长连接的场景要考虑 重连/状态恢复,而不是假定连接一直存活——这不是禁止在 service_worker 上使用 `connect`,只是需要为其生命周期设计容错。 +- USER_SCRIPT content 使用 `ExtensionMessage` 原生扩展通道;专用 USER_SCRIPT listener 不可用时才退到受文档 bootstrap token 约束的普通 extension port。MAIN inject 不维护 native/fallback 双轨。 +- `Server("serviceWorker")` 对浏览器标记的 `userScript` 来源仅允许受控的 USER_SCRIPT 注册/GM API/reconnect 路径;普通 extension port fallback 只服务 USER_SCRIPT,并在 `runtime/registerUserScript` 握手中校验文档 bootstrap token。 +- `CustomEventMessage` 和 `PageEventMessage` 都使用 `performance` 上的随机 key 事件。前者承载 USER_SCRIPT bootstrap 与同步 DOM 节点引用;后者是 MAIN↔`scripting` 的唯一普通消息桥。不要把 MAIN payload 改回 `window.postMessage`:全局 `"message"` event 会让页面只用一个 listener 就被动观察全部 payload。随机 event key 不是授权秘密;MAIN GM RPC 仍必须先通过 `PageRpcRegistry`,再由 Service Worker 结合真实 sender 重验。 + +- Sandbox transport 使用 `SandboxChannelHost` + `MessagePortMessage`:sandbox 在可信入口完成 Server/Runtime wiring 后才 transfer peer port;parent 以精确 `event.source === iframe.contentWindow` 校验一次性 bootstrap,成功后立即移除 Window `"message"` listener。background/crontab userscript 只拿到自己的 sandbox facade,不拿到 private port reference,因此不能靠 `window.onmessage` 被动枚举内部 payload。 diff --git a/packages/message/common.ts b/packages/message/common.ts index 009fd8a51..4ada98840 100644 --- a/packages/message/common.ts +++ b/packages/message/common.ts @@ -1,16 +1,19 @@ -import { type TExtensionEnv } from "@App/app/service/extension/extension_env"; -import { randomMessageFlag } from "@App/pkg/utils/utils"; - // 避免页面载入后改动全域物件导致消息传递失败 export const MouseEventClone = MouseEvent; export const CustomEventClone = CustomEvent; +export const FocusEventClone = FocusEvent; // for relatedTarget Setting without cloneInto const performanceClone = (process.env.VI_TESTING === "true" ? new EventTarget() : performance) as Performance; // 避免页面载入后改动 EventTarget.prototype 的方法导致消息传递失败 -export const pageDispatchEvent = performanceClone.dispatchEvent.bind(performanceClone); -export const pageAddEventListener = performanceClone.addEventListener.bind(performanceClone); -export const pageRemoveEventListener = performanceClone.removeEventListener.bind(performanceClone); +const nativeReflectApply = Reflect.apply; +const nativeFunctionBind = Function.prototype.bind; +const bindNative = any>(fn: T, receiver: any): T => + nativeReflectApply(nativeFunctionBind, fn, [receiver]) as T; + +export const pageDispatchEvent = bindNative(performanceClone.dispatchEvent, performanceClone); +export const pageAddEventListener = bindNative(performanceClone.addEventListener, performanceClone); +export const pageRemoveEventListener = bindNative(performanceClone.removeEventListener, performanceClone); const detailClone = typeof cloneInto === "function" ? cloneInto : null; export const pageDispatchCustomEvent = (eventType: string, detail: T) => { if (detailClone && detail) detail = detailClone(detail, performanceClone); @@ -21,67 +24,52 @@ export const pageDispatchCustomEvent = (eventType: string, detail: T) = return pageDispatchEvent(ev); }; -// flag协商 -export function negotiateEventFlag( - messageFlag: string, - extensionEnv: TExtensionEnv, - readyCount: number, - onInit: (eventFlag: string) => void -): void { - const eventFlag = randomMessageFlag(); - onInit(eventFlag); - // 监听 inject/content 发来的请求 eventFlag 的消息 - let ready = 0; - const fnEventFlagRequestHandler: EventListener = (ev: Event) => { - if (!(ev instanceof CustomEvent)) return; - - switch (ev.detail?.action) { - case "receivedEventFlag": - // 对方已收到 eventFlag - ready += 1; - if (ready >= readyCount) { - // 已收到两个环境的请求,移除监听 - pageRemoveEventListener(messageFlag, fnEventFlagRequestHandler); - } - break; - case "requestEventFlag": - // 广播通信 flag 给 inject/content - pageDispatchCustomEvent(messageFlag, { action: "broadcastEventFlag", eventFlag: eventFlag, extensionEnv }); - break; +// data协商 +export function broadcastSCIData(messageFlag: string, eData: T, readyCount: number): void { + // 监听 inject/content 发来的请求 data 的消息 + let remaining = readyCount; + let broadcastPayload: any = { action: "broadcastData", eData }; + const listener: EventListener = (ev: Event) => { + if (!(ev instanceof CustomEventClone)) return; + const action = ev.detail?.action; + if (action === "requestData") { + // 广播通信 data 给 inject/content + pageDispatchCustomEvent(messageFlag, broadcastPayload); + } else if (action === "dataReceived" && --remaining <= 0) { + // 已收到两个环境的请求,移除监听 + pageRemoveEventListener(messageFlag, listener); + broadcastPayload = null; } }; - // 设置事件,然后广播通信 flag 给 inject/content - pageAddEventListener(messageFlag, fnEventFlagRequestHandler); - pageDispatchCustomEvent(messageFlag, { action: "broadcastEventFlag", eventFlag: eventFlag, extensionEnv }); + // 设置事件,然后广播通信 data 给 inject/content + pageAddEventListener(messageFlag, listener); + pageDispatchCustomEvent(messageFlag, broadcastPayload); } -// 获取协商后的 eventFlag -export function getEventFlag( - messageFlag: string, - onReady: (eventFlag: string, extensionEnv: TExtensionEnv | undefined) => void -) { - let eventFlag = ""; - let extensionEnv: TExtensionEnv | undefined = undefined; - const fnEventFlagListener: EventListener = (ev: Event) => { - if (!(ev instanceof CustomEvent)) return; - if (ev.detail?.action != "broadcastEventFlag") return; - eventFlag = ev.detail.eventFlag; - extensionEnv = ev.detail.extensionEnv; - pageRemoveEventListener(messageFlag, fnEventFlagListener); - // 告知对方已收到 eventFlag - pageDispatchCustomEvent(messageFlag, { action: "receivedEventFlag" }); - onReady(eventFlag, extensionEnv); +// 获取协商后的 data +export function obtainSCIData(messageFlag: string, onReady: (eData: T) => void) { + const listener: EventListener = (ev: Event) => { + if (!(ev instanceof CustomEventClone)) return; + const detail = ev.detail; + const action = detail?.action; + if (action === "broadcastData") { + const eData: T = detail.eData; + pageRemoveEventListener(messageFlag, listener); + // 告知对方已收到 data + pageDispatchCustomEvent(messageFlag, { action: "dataReceived" }); + onReady(eData); + } }; - // 设置事件,然后对 scripting 请求 flag - pageAddEventListener(messageFlag, fnEventFlagListener); - pageDispatchCustomEvent(messageFlag, { action: "requestEventFlag" }); + // 设置事件,然后对 scripting 请求 data + pageAddEventListener(messageFlag, listener); + pageDispatchCustomEvent(messageFlag, { action: "requestData" }); } export const createMouseEvent = process.env.VI_TESTING === "true" - ? (type: string, eventInitDict?: MouseEventInit | undefined): MouseEvent => { + ? (type: string, eventInitDict?: MouseEventInit): MouseEvent => { const ev = new MouseEventClone(type, eventInitDict); eventInitDict = eventInitDict || {}; for (const [key, value] of Object.entries(eventInitDict)) { @@ -90,6 +78,6 @@ export const createMouseEvent = } return ev; } - : (type: string, eventInitDict?: MouseEventInit | undefined): MouseEvent => { + : (type: string, eventInitDict?: MouseEventInit): MouseEvent => { return new MouseEventClone(type, eventInitDict); }; diff --git a/packages/message/custom_event_message.test.ts b/packages/message/custom_event_message.test.ts index 190d7f3cb..290e79a5c 100644 --- a/packages/message/custom_event_message.test.ts +++ b/packages/message/custom_event_message.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { CustomEventMessage } from "./custom_event_message"; import { createMouseEvent, pageDispatchEvent } from "@Packages/message/common"; @@ -17,6 +17,30 @@ function createMessagePair() { } describe("CustomEventMessage relatedTarget lifecycle", () => { + it("ignores accessor envelopes without executing their getters", () => { + const receiver = new CustomEventMessage(`custom-event-message-test-${++flagCounter}`, true, ""); + const received = vi.fn(); + receiver.onMessage(received); + const envelope: Record = { + messageId: "hostile", + type: "sendMessage", + data: { action: "custom-event-message-test/hostile" }, + }; + let accessed = false; + Object.defineProperty(envelope, "data", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + expect(() => receiver.messageHandle(envelope as any, { postMessage: vi.fn() })).not.toThrow(); + expect(accessed).toBe(false); + expect(received).not.toHaveBeenCalled(); + }); + it("stores a received target on the receiving message until it is consumed", () => { const { sender, receiver } = createMessagePair(); const target = document.createElement("div"); diff --git a/packages/message/custom_event_message.ts b/packages/message/custom_event_message.ts index 63b33038a..dda181001 100644 --- a/packages/message/custom_event_message.ts +++ b/packages/message/custom_event_message.ts @@ -1,6 +1,11 @@ import type { Message, MessageConnect, RuntimeMessageSender, TMessage } from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; -import { type PostMessage, type WindowMessageBody, WindowMessageConnect } from "./window_message"; +import { + parseWindowMessageBody, + type PostMessage, + type WindowMessageBody, + WindowMessageConnect, +} from "./window_message"; import EventEmitter from "eventemitter3"; import { DefinedFlags } from "@App/app/service/service_worker/runtime.consts"; import { @@ -78,6 +83,9 @@ export class CustomEventMessage implements Message { } messageHandle(data: WindowMessageBody, target: PostMessage) { + const safeData = parseWindowMessageBody(data); + if (!safeData) return; + data = safeData; // 处理消息 if (data.type === "sendMessage") { // 接收到消息 diff --git a/packages/message/extension_message.test.ts b/packages/message/extension_message.test.ts new file mode 100644 index 000000000..a547f7eb3 --- /dev/null +++ b/packages/message/extension_message.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; +import { ExtensionContentMessageSend, ExtensionMessage, ExtensionMessageConnect } from "./extension_message"; + +describe("ExtensionMessage USER_SCRIPT compatibility", () => { + it("reports a failed dedicated listener registration so USER_SCRIPT can use the regular-port fallback", () => { + const runtime = chrome.runtime as unknown as { + onUserScriptConnect?: { addListener: (callback: (...args: any[]) => void) => void }; + onUserScriptMessage?: { addListener: (callback: (...args: any[]) => void) => void }; + messageListener?: Array<(message: any, sender: any, sendResponse: (response: any) => void) => void>; + connectListener?: Array<(port: chrome.runtime.Port) => void>; + }; + const originalConnect = runtime.onUserScriptConnect; + const originalMessage = runtime.onUserScriptMessage; + const initialMessageListenerCount = runtime.messageListener?.length ?? 0; + const initialConnectListenerCount = runtime.connectListener?.length ?? 0; + try { + runtime.onUserScriptConnect = { + addListener: () => { + throw new Error("userScripts permission unavailable"); + }, + }; + runtime.onUserScriptMessage = { + addListener: () => { + throw new Error("userScripts permission unavailable"); + }, + }; + const message = new ExtensionMessage(true); + message.onConnect(() => undefined); + message.onMessage(() => undefined); + + const response = vi.fn(); + const listeners = runtime.messageListener ?? []; + listeners.at(-1)?.({ type: "userScripts.LISTEN_CONNECTIONS" }, {}, response); + + expect(response).toHaveBeenCalledWith(false); + } finally { + if (runtime.messageListener) runtime.messageListener.length = initialMessageListenerCount; + if (runtime.connectListener) runtime.connectListener.length = initialConnectListenerCount; + runtime.onUserScriptConnect = originalConnect; + runtime.onUserScriptMessage = originalMessage; + } + }); + + it("does not require unavailable runtime event listeners", () => { + const runtime = chrome.runtime as unknown as { + onConnect?: typeof chrome.runtime.onConnect; + onMessage?: typeof chrome.runtime.onMessage; + }; + const onConnect = runtime.onConnect; + const onMessage = runtime.onMessage; + + try { + runtime.onConnect = undefined; + runtime.onMessage = undefined; + const message = new ExtensionMessage(); + + expect(() => message.onConnect(() => undefined)).not.toThrow(); + expect(() => message.onMessage(() => undefined)).not.toThrow(); + } finally { + runtime.onConnect = onConnect; + runtime.onMessage = onMessage; + } + }); + + it("keeps native sendMessage and connect bindings after runtime mutation", async () => { + const runtime = chrome.runtime as unknown as { + sendMessage: unknown; + connect: unknown; + }; + const sendMessage = runtime.sendMessage; + const connect = runtime.connect; + const message = new ExtensionMessage(); + + try { + runtime.sendMessage = () => { + throw new Error("patched sendMessage"); + }; + runtime.connect = () => { + throw new Error("patched connect"); + }; + + await expect(message.sendMessage({ action: "test" })).resolves.toMatchObject({ success: true }); + await expect(message.connect({ action: "test" })).resolves.toBeDefined(); + } finally { + runtime.sendMessage = sendMessage; + runtime.connect = connect; + } + }); + + it("keeps a native port postMessage binding after port mutation", () => { + const nativePostMessage = vi.fn(); + const port = { + postMessage: nativePostMessage, + onMessage: { addListener: vi.fn(), removeListener: vi.fn() }, + onDisconnect: { addListener: vi.fn(), removeListener: vi.fn() }, + disconnect: vi.fn(), + } as unknown as chrome.runtime.Port; + const connection = new ExtensionMessageConnect(port); + + port.postMessage = vi.fn(); + connection.sendMessage({ action: "native" }); + + expect(nativePostMessage).toHaveBeenCalledWith({ action: "native" }); + connection.disconnect(true); + }); + + it("preserves an explicit main-frame target when frameId is zero", async () => { + const sendMessage = vi + .spyOn(chrome.tabs, "sendMessage") + .mockImplementation((_tabId, _message, optionsOrCallback, callback) => { + const responseCallback = typeof optionsOrCallback === "function" ? optionsOrCallback : callback; + responseCallback?.({ success: true }); + return Promise.resolve({ success: true }); + }); + try { + await new ExtensionContentMessageSend(7, { frameId: 0 }).sendMessage({ action: "private" }); + + expect(sendMessage).toHaveBeenCalledWith(7, { action: "private" }, { frameId: 0 }, expect.any(Function)); + } finally { + sendMessage.mockRestore(); + } + }); +}); diff --git a/packages/message/extension_message.ts b/packages/message/extension_message.ts index e0702c98f..b04f6872e 100644 --- a/packages/message/extension_message.ts +++ b/packages/message/extension_message.ts @@ -1,24 +1,46 @@ import EventEmitter from "eventemitter3"; -import type { Message, MessageConnect, MessageSend, RuntimeMessageSender, TMessage, TMessageCommAction } from "./types"; +import type { + Message, + MessageConnect, + MessageSend, + RuntimeMessageSender, + MessageOrigin, + TMessage, + TMessageCommAction, +} from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; const listenerMgr = new EventEmitter(); // 单一管理器 - +// 这些引用必须在页面或 USER_SCRIPT 世界有机会改写 chrome.runtime 方法前捕获, +// 否则消息边界会再次查找页面可变的属性。 +const runtimeApi = typeof chrome === "undefined" ? undefined : chrome.runtime; +const nativeRuntimeConnect = + typeof runtimeApi?.connect === "function" ? runtimeApi.connect.bind(runtimeApi) : undefined; +const nativeRuntimeSendMessage = + typeof runtimeApi?.sendMessage === "function" ? runtimeApi.sendMessage.bind(runtimeApi) : undefined; export class ExtensionMessage implements Message { + private userScriptConnectionListenerReady = false; + private userScriptMessageListenerReady = false; + constructor(private backgroundPrimary = false) {} connect(data: TMessage): Promise { return new Promise((resolve) => { - const con = chrome.runtime.connect(); - con.postMessage(data); - resolve(new ExtensionMessageConnect(con)); + if (!nativeRuntimeConnect) throw new Error("chrome.runtime.connect is unavailable"); + const con = nativeRuntimeConnect(); + const connection = new ExtensionMessageConnect(con); + connection.sendMessage(data); + resolve(connection); }); } // 发送消息 注意不进行回调的内存泄漏 sendMessage(data: TMessage): Promise { return new Promise((resolve: ((value: T) => void) | null) => { - chrome.runtime.sendMessage(data, (resp: T) => { + if (!nativeRuntimeSendMessage) { + throw new Error("chrome.runtime.sendMessage is unavailable"); + } + nativeRuntimeSendMessage(data, (resp: T) => { const lastError = chrome.runtime.lastError; if (lastError) { console.error("chrome.runtime.lastError in chrome.runtime.sendMessage:", lastError); @@ -38,23 +60,25 @@ export class ExtensionMessage implements Message { }; onConnect(callback: (data: TMessage, con: MessageConnect) => void) { - chrome.runtime.onConnect.addListener((port: chrome.runtime.Port) => { - let myPort: chrome.runtime.Port | null = port; - const lastError = chrome.runtime.lastError; - if (lastError) { - console.error("chrome.runtime.lastError in chrome.runtime.onConnect", lastError); - // 消息API发生错误因此不继续执行 - } - const handler = (msg: TMessage) => { - const port = myPort; - if (port !== null) { - myPort = null; - port.onMessage.removeListener(handler); - callback(msg, new ExtensionMessageConnect(port)); + if (typeof chrome.runtime?.onConnect?.addListener === "function") { + chrome.runtime.onConnect.addListener((port: chrome.runtime.Port) => { + let myPort: chrome.runtime.Port | null = port; + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.runtime.onConnect", lastError); + // 消息API发生错误因此不继续执行 } - }; - myPort.onMessage.addListener(handler); - }); + const handler = (msg: TMessage) => { + const port = myPort; + if (port !== null) { + myPort = null; + port.onMessage.removeListener(handler); + callback(msg, new ExtensionMessageConnect(port, "extension")); + } + }; + myPort.onMessage.addListener(handler); + }); + } if (this.backgroundPrimary) { let addUserScriptConnectionListener: (() => void) | null = () => { @@ -71,20 +95,23 @@ export class ExtensionMessage implements Message { if (port !== null) { myPort = null; port.onMessage.removeListener(handler); - callback(msg, new ExtensionMessageConnect(port)); + callback(msg, new ExtensionMessageConnect(port, "userScript")); } }; myPort.onMessage.addListener(handler); }); addUserScriptConnectionListener = null; + this.userScriptConnectionListenerReady = true; } catch { - // do nothing + this.userScriptConnectionListenerReady = false; } }; // Firefox 需要先得到 userScripts 权限才能进行 onUserScriptConnect 的监听 this.tryEnableUserScriptConnectionListener = () => { if (typeof chrome.runtime.onUserScriptConnect?.addListener === "function") { addUserScriptConnectionListener && addUserScriptConnectionListener(); + } else { + this.userScriptConnectionListenerReady = false; } }; // Chrome 在初始化时就能监听 @@ -97,32 +124,35 @@ export class ExtensionMessage implements Message { callback: ( data: TMessageCommAction, sendResponse: (data: any) => void, - sender: RuntimeMessageSender + sender: RuntimeMessageSender, + origin?: MessageOrigin ) => boolean | void ): void { - chrome.runtime.onMessage.addListener((msg: TMessage, sender, sendResponse) => { - const lastError = chrome.runtime.lastError; - if (lastError) { - console.error("chrome.runtime.lastError in chrome.runtime.onMessage:", lastError); - // 消息API发生错误因此不继续执行 - return false; - } - if ((msg as any)?.type === "userScripts.LISTEN_CONNECTIONS" && this.backgroundPrimary) { - if ( - typeof chrome.runtime.onUserScriptConnect?.addListener === "function" && - typeof chrome.runtime.onUserScriptMessage?.addListener === "function" - ) { - this.tryEnableUserScriptConnectionListener(); - this.tryEnableUserScriptMessageListener(); - sendResponse(true); - } else { - sendResponse(false); + if (typeof chrome.runtime?.onMessage?.addListener === "function") { + chrome.runtime.onMessage.addListener((msg: TMessage, sender, sendResponse) => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.runtime.onMessage:", lastError); + // 消息API发生错误因此不继续执行 + return false; } - return false; - } - if (typeof msg.action !== "string") return; - return callback(msg, sendResponse, sender); - }); + if ((msg as any)?.type === "userScripts.LISTEN_CONNECTIONS" && this.backgroundPrimary) { + if ( + typeof chrome.runtime.onUserScriptConnect?.addListener === "function" && + typeof chrome.runtime.onUserScriptMessage?.addListener === "function" + ) { + this.tryEnableUserScriptConnectionListener(); + this.tryEnableUserScriptMessageListener(); + sendResponse(this.userScriptConnectionListenerReady && this.userScriptMessageListenerReady); + } else { + sendResponse(false); + } + return false; + } + if (typeof msg.action !== "string") return; + return callback(msg, sendResponse, sender, "extension"); + }); + } if (this.backgroundPrimary) { let addUserScriptMessageListener: (() => void) | null = () => { @@ -130,23 +160,32 @@ export class ExtensionMessage implements Message { // 监听用户脚本的消息 chrome.runtime.onUserScriptMessage.addListener((msg: TMessage, sender, sendResponse) => { const lastError = chrome.runtime.lastError; - if (typeof msg.action !== "string") return; if (lastError) { console.error("chrome.runtime.lastError in chrome.runtime.onUserScriptMessage:", lastError); // 消息API发生错误因此不继续执行 return false; } - return callback(msg, sendResponse, sender); + if ((msg as any)?.type === "userScripts.LISTEN_CONNECTIONS" && this.backgroundPrimary) { + this.tryEnableUserScriptConnectionListener(); + this.tryEnableUserScriptMessageListener(); + sendResponse(this.userScriptConnectionListenerReady && this.userScriptMessageListenerReady); + return false; + } + if (typeof msg.action !== "string") return; + return callback(msg, sendResponse, sender, "userScript"); }); addUserScriptMessageListener = null; + this.userScriptMessageListenerReady = true; } catch { - // do nothing + this.userScriptMessageListenerReady = false; } }; // Firefox 需要先得到 userScripts 权限才能进行 onUserScriptMessage 的监听 this.tryEnableUserScriptMessageListener = () => { if (typeof chrome.runtime.onUserScriptMessage?.addListener === "function") { addUserScriptMessageListener && addUserScriptMessageListener(); + } else { + this.userScriptMessageListenerReady = false; } }; // Chrome 在初始化时就能监听 @@ -158,10 +197,18 @@ export class ExtensionMessage implements Message { export class ExtensionMessageConnect implements MessageConnect { private readonly listenerId = `${uuidv4()}`; // 使用 uuidv4 确保唯一 private con: chrome.runtime.Port | null; + private readonly postMessage: (data: TMessage) => void; private isSelfDisconnected = false; - constructor(con: chrome.runtime.Port) { + constructor( + con: chrome.runtime.Port, + // 来源只记录浏览器原生通道的来源,供服务端区分 USER_SCRIPT 与扩展内部消息。 + private readonly origin: "extension" | "userScript" = "extension" + ) { this.con = con; // 强引用 + if (typeof con.postMessage !== "function") throw new TypeError("Invalid runtime port"); + // Port 的原型可能被页面改写;后续发送固定使用构造时取得的绑定方法。 + this.postMessage = con.postMessage.bind(con); const handler = (msg: TMessage, _con: chrome.runtime.Port) => { listenerMgr.emit(`onMessage:${this.listenerId}`, msg); }; @@ -188,7 +235,7 @@ export class ExtensionMessageConnect implements MessageConnect { // 無法 sendMessage 不应该屏蔽错误 throw new Error("Attempted to sendMessage on a disconnected port."); } - this.con.postMessage(data); + this.postMessage(data); } onMessage(callback: (data: TMessage) => void) { @@ -229,6 +276,10 @@ export class ExtensionMessageConnect implements MessageConnect { } return this.con; } + + getOrigin(): "extension" | "userScript" { + return this.origin; + } } export class ExtensionContentMessageSend implements MessageSend { @@ -242,7 +293,7 @@ export class ExtensionContentMessageSend implements MessageSend { sendMessage(data: TMessage): Promise { return new Promise((resolve) => { - if (!this.options?.documentId && !this.options?.frameId) { + if (this.options?.documentId === undefined && this.options?.frameId === undefined) { // 发送给指定的tab chrome.tabs.sendMessage(this.tabId, data, (resp: T) => { const lastError = chrome.runtime.lastError; @@ -269,7 +320,7 @@ export class ExtensionContentMessageSend implements MessageSend { return new Promise((resolve) => { const con = chrome.tabs.connect(this.tabId, this.options); con.postMessage(data); - resolve(new ExtensionMessageConnect(con)); + resolve(new ExtensionMessageConnect(con, "extension")); }); } } diff --git a/packages/message/message_port_message.ts b/packages/message/message_port_message.ts new file mode 100644 index 000000000..84e80a63f --- /dev/null +++ b/packages/message/message_port_message.ts @@ -0,0 +1,166 @@ +import { uuidv4 } from "@App/pkg/utils/uuid"; +import EventEmitter from "eventemitter3"; +import type { + Message, + MessageConnect, + OnConnectCallback, + OnMessageCallback, + RuntimeMessageSender, + TMessage, +} from "./types"; +import { + WindowMessageConnect, + parseWindowMessageBody, + type PostMessage, + type WindowMessageBody, +} from "./window_message"; + +const nativeReflectApply = Reflect.apply; +const nativeFunctionBind = Function.prototype.bind; +const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const nativeMessageEventDataGetter = nativeObjectGetOwnPropertyDescriptor(MessageEvent.prototype, "data")?.get; + +const readMessageEventData = (event: MessageEvent): unknown => { + if (nativeMessageEventDataGetter) { + try { + return nativeReflectApply(nativeMessageEventDataGetter, event, []); + } catch { + // Test/mocked Event objects may not carry the browser MessageEvent internal slots. + } + } + const descriptor = nativeObjectGetOwnPropertyDescriptor(event, "data"); + return descriptor && "value" in descriptor ? descriptor.value : undefined; +}; + +const bindNative = any>(fn: T, receiver: any): T => + nativeReflectApply(nativeFunctionBind, fn, [receiver]) as T; + +class MessagePortPostMessage implements PostMessage { + private readonly post: (message: unknown) => void; + + constructor(port: MessagePort) { + this.post = bindNative(port.postMessage, port) as (message: unknown) => void; + } + + postMessage(message: T): void { + this.post(message); + } +} + +/** + * Message implementation backed by a private MessagePort. + * + * Unlike WindowMessage, traffic is delivered only to code holding the port reference; it is not + * broadcast through the host Window's global "message" event. The wire envelope intentionally + * stays compatible with WindowMessage so existing Server/Client/MessageConnect semantics remain + * unchanged while the carrier is replaced. + */ +export class MessagePortMessage implements Message { + readonly EE = new EventEmitter(); + + private readonly target: PostMessage; + private readonly removeMessageListener: (type: string, listener: EventListenerOrEventListenerObject) => void; + private readonly closePort: () => void; + private readonly messageHandler: EventListener; + private disposed = false; + + constructor(private readonly port: MessagePort) { + const addMessageListener = bindNative(port.addEventListener, port); + this.removeMessageListener = bindNative(port.removeEventListener, port); + const startPort = bindNative(port.start, port); + this.closePort = bindNative(port.close, port); + this.target = new MessagePortPostMessage(port); + this.messageHandler = ((event: MessageEvent) => { + this.messageHandle(readMessageEventData(event)); + }) as EventListener; + addMessageListener("message", this.messageHandler); + startPort(); + } + + private assertOpen() { + if (this.disposed) { + throw new Error("MessagePortMessage is disposed."); + } + } + + private messageHandle(value: unknown) { + const data = parseWindowMessageBody(value); + if (!data || this.disposed) return; + + if (data.type === "sendMessage") { + this.EE.emit( + "message", + data.data, + (resp: any) => { + if (!data.messageId || this.disposed) return; + this.target.postMessage({ + messageId: data.messageId, + type: "respMessage", + data: resp, + } satisfies WindowMessageBody); + }, + {} as RuntimeMessageSender + ); + } else if (data.type === "respMessage") { + this.EE.emit(`response:${data.messageId}`, data); + } else if (data.type === "connect") { + this.EE.emit("connect", data.data, new WindowMessageConnect(data.messageId, this.EE, this.target)); + } else if (data.type === "disconnect") { + this.EE.emit(`disconnect:${data.messageId}`); + } else if (data.type === "connectMessage") { + this.EE.emit(`connectMessage:${data.messageId}`, data.data); + } + } + + onConnect(callback: OnConnectCallback): void { + this.assertOpen(); + this.EE.addListener("connect", callback); + } + + connect(data: TMessage): Promise { + this.assertOpen(); + const messageId = uuidv4(); + this.target.postMessage({ + messageId, + type: "connect", + data, + } satisfies WindowMessageBody); + return Promise.resolve(new WindowMessageConnect(messageId, this.EE, this.target)); + } + + onMessage(callback: OnMessageCallback): void { + this.assertOpen(); + this.EE.addListener("message", callback); + } + + sendMessage(data: TMessage): Promise { + this.assertOpen(); + return new Promise((resolve, reject) => { + const messageId = uuidv4(); + const eventId = `response:${messageId}`; + const handler = (body: WindowMessageBody) => { + this.EE.removeAllListeners(eventId); + resolve(body.data as T); + }; + this.EE.addListener(eventId, handler); + try { + this.target.postMessage({ + messageId, + type: "sendMessage", + data, + } satisfies WindowMessageBody); + } catch (error) { + this.EE.removeAllListeners(eventId); + reject(error); + } + }); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.removeMessageListener("message", this.messageHandler); + this.EE.removeAllListeners(); + this.closePort(); + } +} diff --git a/packages/message/page_event_message.test.ts b/packages/message/page_event_message.test.ts new file mode 100644 index 000000000..aeb11e8bf --- /dev/null +++ b/packages/message/page_event_message.test.ts @@ -0,0 +1,168 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PageEventMessage } from "./page_event_message"; +import { pageDispatchCustomEvent } from "./common"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("PageEventMessage", () => { + it("routes messages only to the opposite role over a keyed performance event", async () => { + const scripting = new PageEventMessage("page-message-test", "scripting"); + const inject = new PageEventMessage("page-message-test", "inject"); + const received = vi.fn((_data, sendResponse) => sendResponse({ code: 0, data: "pong" })); + inject.onMessage(received); + + const response = await scripting.sendMessage({ action: "inject/ping", data: "ping" }); + + expect(response).toEqual({ code: 0, data: "pong" }); + expect(received).toHaveBeenCalledWith( + { action: "inject/ping", data: "ping" }, + expect.any(Function), + expect.any(Object) + ); + + scripting.dispose(); + inject.dispose(); + }); + + it("does not publish bridge payloads on window message", async () => { + const onWindowMessage = vi.fn(); + window.addEventListener("message", onWindowMessage); + const scripting = new PageEventMessage("page-message-no-window", "scripting"); + const inject = new PageEventMessage("page-message-no-window", "inject"); + inject.onMessage((_data, sendResponse) => sendResponse({ code: 0 })); + + await scripting.sendMessage({ action: "inject/ping" }); + await Promise.resolve(); + + expect(onWindowMessage).not.toHaveBeenCalled(); + + window.removeEventListener("message", onWindowMessage); + scripting.dispose(); + inject.dispose(); + }); + + it("supports scoped connections and removes its keyed listener on dispose", async () => { + const scripting = new PageEventMessage("page-message-connect", "scripting"); + const inject = new PageEventMessage("page-message-connect", "inject"); + const received = vi.fn(); + inject.onConnect((_data, connection) => connection.onMessage(received)); + + const connection = await scripting.connect({ action: "inject/connect" }); + connection.sendMessage({ action: "inject/message", data: 1 }); + + expect(received).toHaveBeenCalledWith({ action: "inject/message", data: 1 }); + + scripting.dispose(); + inject.dispose(); + + expect(() => + pageDispatchCustomEvent("page-message-connect.pageEventMessage.scripting.inject", { + messageId: "after-dispose", + type: "connectMessage", + data: { action: "inject/message", data: 2 }, + }) + ).not.toThrow(); + expect(received).toHaveBeenCalledTimes(1); + }); + + it("preserves local and peer disconnect notifications", async () => { + const scripting = new PageEventMessage("page-message-disconnect", "scripting"); + const inject = new PageEventMessage("page-message-disconnect", "inject"); + const localDisconnect = vi.fn(); + const peerDisconnect = vi.fn(); + inject.onConnect((_data, connection) => connection.onDisconnect(peerDisconnect)); + + const connection = await scripting.connect({ action: "inject/connect" }); + connection.onDisconnect(localDisconnect); + connection.disconnect(); + + expect(localDisconnect).toHaveBeenCalledWith(true); + expect(peerDisconnect).toHaveBeenCalledWith(false); + expect(() => connection.disconnect(true)).not.toThrow(); + expect(() => connection.sendMessage({ action: "inject/message" })).toThrow(); + scripting.dispose(); + inject.dispose(); + }); + + it("ignores events on a different channel or role route", () => { + const inject = new PageEventMessage("page-message-route", "inject"); + const received = vi.fn(); + inject.onMessage(received); + const body = { messageId: "wrong-route", type: "sendMessage", data: { action: "inject/ping" } } as const; + + pageDispatchCustomEvent("other-channel.pageEventMessage.scripting.inject", body); + pageDispatchCustomEvent("page-message-route.pageEventMessage.inject.inject", body); + + expect(received).not.toHaveBeenCalled(); + inject.dispose(); + }); + + it("ignores envelopes with accessor fields without executing the accessor", () => { + const inject = new PageEventMessage("page-message-accessor", "inject"); + const received = vi.fn(); + inject.onMessage(received); + const envelope: Record = { + messageId: "hostile", + type: "sendMessage", + data: { action: "inject/ping" }, + }; + let accessed = false; + Object.defineProperty(envelope, "data", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + expect(() => + pageDispatchCustomEvent("page-message-accessor.pageEventMessage.scripting.inject", envelope) + ).not.toThrow(); + expect(accessed).toBe(false); + expect(received).not.toHaveBeenCalled(); + inject.dispose(); + }); + + it("ignores proxy envelopes whose own-key inspection is hostile", () => { + const inject = new PageEventMessage("page-message-proxy", "inject"); + const received = vi.fn(); + inject.onMessage(received); + const envelope = new Proxy( + { + messageId: "hostile", + type: "sendMessage", + data: { action: "inject/ping" }, + }, + { + ownKeys() { + throw new Error("page proxy executed"); + }, + } + ); + + expect(() => + pageDispatchCustomEvent("page-message-proxy.pageEventMessage.scripting.inject", envelope) + ).not.toThrow(); + expect(received).not.toHaveBeenCalled(); + inject.dispose(); + }); + + it("rejects envelopes with unexpected own keys", () => { + const inject = new PageEventMessage("page-message-extra-key", "inject"); + const received = vi.fn(); + inject.onMessage(received); + + pageDispatchCustomEvent("page-message-extra-key.pageEventMessage.scripting.inject", { + messageId: "hostile", + type: "sendMessage", + data: { action: "inject/ping" }, + extra: true, + }); + + expect(received).not.toHaveBeenCalled(); + inject.dispose(); + }); +}); diff --git a/packages/message/page_event_message.ts b/packages/message/page_event_message.ts new file mode 100644 index 000000000..61f1e648e --- /dev/null +++ b/packages/message/page_event_message.ts @@ -0,0 +1,115 @@ +import EventEmitter from "eventemitter3"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import type { + Message, + MessageConnect, + OnConnectCallback, + OnMessageCallback, + RuntimeMessageSender, + TMessage, +} from "./types"; +import { CustomEventClone, pageAddEventListener, pageDispatchCustomEvent, pageRemoveEventListener } from "./common"; +import { + parseWindowMessageBody, + type PostMessage, + type WindowMessageBody, + WindowMessageConnect, +} from "./window_message"; + +export type PageEventMessageRole = "scripting" | "inject"; + +const otherRole = (role: PageEventMessageRole): PageEventMessageRole => (role === "scripting" ? "inject" : "scripting"); + +/** + * 页面 RPC 专用通道。 + * + * 使用随机 channel 派生的 performance CustomEvent 名称,避免把所有跨世界 payload 暴露到 + * host page 可无条件监听的 window "message" 总线上。event name 只降低普通页面代码的被动 + * 可观察性,不是认证边界;调用方仍必须验证每个请求,并把权限绑定到隔离执行记录。 + */ +export class PageEventMessage implements Message { + readonly EE = new EventEmitter(); + private readonly receiveEventName: string; + private readonly messageHandler: (event: Event) => void; + private readonly targetRole: PageEventMessageRole; + private readonly target: PostMessage = { + postMessage: (body) => { + this.sendEnvelope(this.targetRole, body as WindowMessageBody); + }, + }; + + constructor( + private readonly channel: string, + private readonly role: PageEventMessageRole + ) { + this.targetRole = otherRole(role); + this.receiveEventName = `${channel}.pageEventMessage.${this.targetRole}.${role}`; + this.messageHandler = (event: Event) => { + if (!(event instanceof CustomEventClone)) return; + const body = parseWindowMessageBody(event.detail); + if (!body) return; + this.messageHandle(body); + }; + pageAddEventListener(this.receiveEventName, this.messageHandler); + } + + private sendEnvelope(target: PageEventMessageRole, body: WindowMessageBody): void { + pageDispatchCustomEvent(`${this.channel}.pageEventMessage.${this.role}.${target}`, body); + } + + private messageHandle(body: WindowMessageBody): void { + if (body.type === "sendMessage") { + this.EE.emit( + "message", + body.data, + (response: TMessage) => { + this.sendEnvelope(this.targetRole, { + messageId: body.messageId, + type: "respMessage", + data: response, + }); + }, + {} as RuntimeMessageSender + ); + } else if (body.type === "respMessage") { + this.EE.emit(`response:${body.messageId}`, body); + } else if (body.type === "connect") { + this.EE.emit("connect", body.data, new WindowMessageConnect(body.messageId, this.EE, this.target)); + } else if (body.type === "disconnect") { + this.EE.emit(`disconnect:${body.messageId}`); + } else if (body.type === "connectMessage") { + this.EE.emit(`connectMessage:${body.messageId}`, body.data); + } + } + + onConnect(callback: OnConnectCallback): void { + this.EE.addListener("connect", callback); + } + + onMessage(callback: OnMessageCallback): void { + this.EE.addListener("message", callback); + } + + connect(data: TMessage): Promise { + const messageId = uuidv4(); + this.sendEnvelope(this.targetRole, { messageId, type: "connect", data }); + return Promise.resolve(new WindowMessageConnect(messageId, this.EE, this.target)); + } + + sendMessage(data: TMessage): Promise { + return new Promise((resolve) => { + const messageId = uuidv4(); + const eventId = `response:${messageId}`; + this.EE.addListener(eventId, (body: WindowMessageBody) => { + this.EE.removeAllListeners(eventId); + resolve(body.data as T); + }); + this.sendEnvelope(this.targetRole, { messageId, type: "sendMessage", data }); + }); + } + + dispose(): void { + pageRemoveEventListener(this.receiveEventName, this.messageHandler); + this.EE.removeAllListeners(); + } +} diff --git a/packages/message/request_sequence_window.test.ts b/packages/message/request_sequence_window.test.ts new file mode 100644 index 000000000..abd9c1e01 --- /dev/null +++ b/packages/message/request_sequence_window.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { REQUEST_SEQUENCE_WINDOW_SIZE, RequestSequenceWindow } from "./request_sequence_window"; + +describe("RequestSequenceWindow", () => { + it("accepts out-of-order requests once within its bounded window", () => { + const window = new RequestSequenceWindow(); + + window.consume(1); + window.consume(3); + window.consume(2); + + expect(() => window.consume(2)).toThrow("already used"); + }); + + it("accepts a large forward gap on a fresh window", () => { + // 本地 CAT_createBlobUrl / CAT_fetchBlob / CAT_fetchDocument 等 broker-only + // 请求会消耗上下文序列号但从不到达 SW,因此 SW 端合法请求的序列号 + // 可能一次性领先超过 4096。 + const window = new RequestSequenceWindow(); + + expect(() => window.consume(REQUEST_SEQUENCE_WINDOW_SIZE + 1)).not.toThrow(); + }); + + it("resets the bitmap in bounded work exactly at the window-size boundary", () => { + const window = new RequestSequenceWindow(); + + window.consume(1); + expect(() => window.consume(1 + REQUEST_SEQUENCE_WINDOW_SIZE)).not.toThrow(); + expect(() => window.consume(1)).toThrow("replay window"); + }); + + it("accepts forward gaps larger than the window size", () => { + const window = new RequestSequenceWindow(); + + window.consume(1); + expect(() => window.consume(1 + REQUEST_SEQUENCE_WINDOW_SIZE * 3)).not.toThrow(); + }); + + it("still rejects a duplicate sequence after a large forward jump", () => { + const window = new RequestSequenceWindow(); + const farSequence = REQUEST_SEQUENCE_WINDOW_SIZE * 5; + + window.consume(farSequence); + + expect(() => window.consume(farSequence)).toThrow("already used"); + }); + + it("still rejects a sequence older than the retained history after a large forward jump", () => { + const window = new RequestSequenceWindow(); + + window.consume(1); + window.consume(1 + REQUEST_SEQUENCE_WINDOW_SIZE); + + expect(() => window.consume(1)).toThrow("replay window"); + }); + + it("keeps fixed-size replay state as sequence numbers advance", () => { + const window = new RequestSequenceWindow(); + + for (let sequence = 1; sequence <= REQUEST_SEQUENCE_WINDOW_SIZE * 4; sequence += 1) { + window.consume(sequence); + } + + expect((window as unknown as { bitmap: Uint32Array }).bitmap).toHaveLength(REQUEST_SEQUENCE_WINDOW_SIZE / 32); + expect(() => window.consume(1)).toThrow("replay window"); + }); + + it("keeps fixed-size replay state after an arbitrarily large forward jump", () => { + const window = new RequestSequenceWindow(); + + window.consume(Number.MAX_SAFE_INTEGER - 10); + + expect((window as unknown as { bitmap: Uint32Array }).bitmap).toHaveLength(REQUEST_SEQUENCE_WINDOW_SIZE / 32); + }); + + it("rejects non-positive and non-safe sequence numbers", () => { + const window = new RequestSequenceWindow(); + + expect(() => window.consume(0)).toThrow("sequence is invalid"); + expect(() => window.consume(Number.MAX_SAFE_INTEGER + 1)).toThrow("sequence is invalid"); + }); +}); diff --git a/packages/message/request_sequence_window.ts b/packages/message/request_sequence_window.ts new file mode 100644 index 000000000..f27c4a21f --- /dev/null +++ b/packages/message/request_sequence_window.ts @@ -0,0 +1,45 @@ +const REQUEST_SEQUENCE_WINDOW_WORDS = 128; +export const REQUEST_SEQUENCE_WINDOW_SIZE = REQUEST_SEQUENCE_WINDOW_WORDS * 32; +const NativeUint32Array = Uint32Array; + +export class RequestSequenceWindow { + private highWater = 0; + private readonly bitmap = new NativeUint32Array(REQUEST_SEQUENCE_WINDOW_WORDS); + + consume(sequence: unknown): void { + if (typeof sequence !== "number" || !Number.isSafeInteger(sequence) || sequence < 1) { + throw new Error("page RPC sequence is invalid"); + } + + if (sequence <= this.highWater - REQUEST_SEQUENCE_WINDOW_SIZE) { + throw new Error("page RPC sequence is outside the replay window"); + } + + const slot = sequence % REQUEST_SEQUENCE_WINDOW_SIZE; + const wordIndex = slot >>> 5; + const bit = 1 << (slot & 31); + + if (sequence > this.highWater) { + // 广播端存在本地 broker-only 请求(不会推进 SW 侧序列),因此合法序列 + // 可能一次性向前跳跃超过窗口大小;跳跃达到窗口大小时,逐位清理已不再 + // 有意义,直接以固定 word 数整体清空 bitmap。 + const jump = sequence - this.highWater; + if (jump >= REQUEST_SEQUENCE_WINDOW_SIZE) { + this.bitmap.fill(0); + } else { + const firstExpired = Math.max(1, this.highWater - REQUEST_SEQUENCE_WINDOW_SIZE + 1); + const lastExpired = sequence - REQUEST_SEQUENCE_WINDOW_SIZE; + for (let expired = firstExpired; expired <= lastExpired; expired += 1) { + const expiredSlot = expired % REQUEST_SEQUENCE_WINDOW_SIZE; + const expiredWord = expiredSlot >>> 5; + this.bitmap[expiredWord] &= ~(1 << (expiredSlot & 31)); + } + } + this.highWater = sequence; + } else if ((this.bitmap[wordIndex] & bit) !== 0) { + throw new Error("page RPC sequence was already used"); + } + + this.bitmap[wordIndex] |= bit; + } +} diff --git a/packages/message/sandbox_message_channel.test.ts b/packages/message/sandbox_message_channel.test.ts new file mode 100644 index 000000000..990f65f83 --- /dev/null +++ b/packages/message/sandbox_message_channel.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it, vi } from "vitest"; +import { MessagePortMessage } from "./message_port_message"; +import { createSandboxChannelClient, SandboxChannelHost } from "./sandbox_message_channel"; + +class FakePort { + peer?: FakePort; + private listeners = new Set(); + closed = false; + + postMessage = (data: unknown) => { + if (this.closed) throw new Error("closed"); + const event = new MessageEvent("message", { data }); + queueMicrotask(() => this.peer?.dispatch(event)); + }; + + addEventListener = (_type: string, listener: EventListenerOrEventListenerObject) => { + this.listeners.add(listener); + }; + + removeEventListener = (_type: string, listener: EventListenerOrEventListenerObject) => { + this.listeners.delete(listener); + }; + + start = vi.fn(); + + close = () => { + this.closed = true; + }; + + private dispatch(event: MessageEvent) { + for (const listener of this.listeners) { + if (typeof listener === "function") listener(event); + else listener.handleEvent(event); + } + } +} + +const makePortPair = () => { + const a = new FakePort(); + const b = new FakePort(); + a.peer = b; + b.peer = a; + return [a as unknown as MessagePort, b as unknown as MessagePort] as const; +}; + +class FakeWindow { + private listeners = new Set(); + + addEventListener = (_type: string, listener: EventListenerOrEventListenerObject) => { + this.listeners.add(listener); + }; + + removeEventListener = (_type: string, listener: EventListenerOrEventListenerObject) => { + this.listeners.delete(listener); + }; + + dispatchMessage(event: MessageEvent) { + for (const listener of this.listeners) { + if (typeof listener === "function") listener(event); + else listener.handleEvent(event); + } + } + + listenerCount() { + return this.listeners.size; + } +} + +describe("MessagePortMessage", () => { + it("preserves request/response semantics without a Window message bus", async () => { + const [leftPort, rightPort] = makePortPair(); + const left = new MessagePortMessage(leftPort); + const right = new MessagePortMessage(rightPort); + right.onMessage((data, sendResponse) => { + sendResponse({ code: 0, data: data.data }); + }); + + await expect(left.sendMessage({ action: "sandbox/ping", data: "pong" })).resolves.toEqual({ + code: 0, + data: "pong", + }); + + left.dispose(); + right.dispose(); + }); + + it("does not consult a userscript-poisoned MessageEvent.prototype.data getter", async () => { + const descriptor = Object.getOwnPropertyDescriptor(MessageEvent.prototype, "data"); + // happy-dom currently models MessageEvent.data as an own field rather than a WebIDL + // prototype getter. Real-browser poisoning is covered by sandbox-message-port.spec.ts. + if (!descriptor?.get || descriptor.configurable !== true) return; + + let poisonedReads = 0; + Object.defineProperty(MessageEvent.prototype, "data", { + ...descriptor, + get() { + poisonedReads += 1; + return descriptor!.get!.call(this); + }, + }); + + const [leftPort, rightPort] = makePortPair(); + const left = new MessagePortMessage(leftPort); + const right = new MessagePortMessage(rightPort); + right.onMessage((data, sendResponse) => { + sendResponse({ code: 0, data: data.data }); + }); + + try { + await expect(left.sendMessage({ action: "sandbox/ping", data: "private" })).resolves.toEqual({ + code: 0, + data: "private", + }); + expect(poisonedReads).toBe(0); + } finally { + left.dispose(); + right.dispose(); + Object.defineProperty(MessageEvent.prototype, "data", descriptor!); + } + }); + + it("preserves scoped MessageConnect traffic", async () => { + const [leftPort, rightPort] = makePortPair(); + const left = new MessagePortMessage(leftPort); + const right = new MessagePortMessage(rightPort); + const received = vi.fn(); + right.onConnect((_data, connection) => connection.onMessage(received)); + + const connection = await left.connect({ action: "sandbox/connect" }); + connection.sendMessage({ action: "sandbox/chunk", data: 1 }); + await Promise.resolve(); + + expect(received).toHaveBeenCalledWith({ action: "sandbox/chunk", data: 1 }); + + left.dispose(); + right.dispose(); + }); +}); + +describe("SandboxChannelHost", () => { + it("accepts exactly one port from the expected sandbox Window and removes the global listener", async () => { + const parentWindow = new FakeWindow(); + const expectedSandbox = {} as Window; + const hostileSandbox = {} as Window; + const host = new SandboxChannelHost(parentWindow as unknown as Window, expectedSandbox); + const [sandboxPort, parentPort] = makePortPair(); + const sandboxMessage = new MessagePortMessage(sandboxPort); + + parentWindow.dispatchMessage({ + source: hostileSandbox, + data: "scriptcat/sandbox-message-port/v1", + ports: [parentPort], + } as unknown as MessageEvent); + expect(host.isReady()).toBe(false); + + parentWindow.dispatchMessage({ + source: expectedSandbox, + data: "scriptcat/sandbox-message-port/v1", + ports: [parentPort], + } as unknown as MessageEvent); + + await host.ready(); + expect(host.isReady()).toBe(true); + expect(parentWindow.listenerCount()).toBe(0); + + const received = vi.fn((_data, sendResponse) => sendResponse({ code: 0, data: "ok" })); + host.onMessage(received); + await expect(sandboxMessage.sendMessage({ action: "offscreen/ping" })).resolves.toEqual({ + code: 0, + data: "ok", + }); + + host.dispose(); + sandboxMessage.dispose(); + }); + + it("rejects object bootstrap values without inspecting them", () => { + const parentWindow = new FakeWindow(); + const expectedSandbox = {} as Window; + const host = new SandboxChannelHost(parentWindow as unknown as Window, expectedSandbox); + let getterExecuted = false; + const accessor: Record = {}; + Object.defineProperty(accessor, "type", { + enumerable: true, + configurable: true, + get() { + getterExecuted = true; + return "scriptcat/sandbox-message-port/v1"; + }, + }); + let proxyInspected = false; + const proxy = new Proxy( + {}, + { + ownKeys() { + proxyInspected = true; + throw new Error("hostile proxy"); + }, + } + ); + + const [accessorPort] = makePortPair(); + parentWindow.dispatchMessage({ + source: expectedSandbox, + data: accessor, + ports: [accessorPort], + } as unknown as MessageEvent); + expect(getterExecuted).toBe(false); + expect(host.isReady()).toBe(false); + + const [proxyPort] = makePortPair(); + parentWindow.dispatchMessage({ + source: expectedSandbox, + data: proxy, + ports: [proxyPort], + } as unknown as MessageEvent); + expect(proxyInspected).toBe(false); + expect(host.isReady()).toBe(false); + expect(parentWindow.listenerCount()).toBe(1); + host.dispose(); + }); + + it("transfers the private port with one versioned primitive marker exactly once", () => { + const postMessage = vi.fn(); + const parentWindow = { postMessage } as unknown as Window; + const [port1, port2] = makePortPair(); + const channel = { port1, port2 } as MessageChannel; + const client = createSandboxChannelClient(parentWindow, channel); + + client.transferToParent(); + + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledWith("scriptcat/sandbox-message-port/v1", "*", [channel.port2]); + expect(() => client.transferToParent()).toThrow("Sandbox channel has already been transferred."); + expect(postMessage).toHaveBeenCalledTimes(1); + client.message.dispose(); + }); +}); diff --git a/packages/message/sandbox_message_channel.ts b/packages/message/sandbox_message_channel.ts new file mode 100644 index 000000000..7405a7f4e --- /dev/null +++ b/packages/message/sandbox_message_channel.ts @@ -0,0 +1,152 @@ +import type { Message, MessageConnect, OnConnectCallback, OnMessageCallback, TMessage } from "./types"; +import { MessagePortMessage } from "./message_port_message"; + +export const SANDBOX_CHANNEL_BOOTSTRAP_MARKER = "scriptcat/sandbox-message-port/v1"; + +const nativeReflectApply = Reflect.apply; +const nativeFunctionBind = Function.prototype.bind; +const bindNative = any>(fn: T, receiver: any): T => + nativeReflectApply(nativeFunctionBind, fn, [receiver]) as T; + +type PendingListeners = { + messages: OnMessageCallback[]; + connects: OnConnectCallback[]; +}; + +/** + * Parent-side transport for Offscreen/EventPage ↔ Sandbox. + * + * The Window "message" listener exists only until the sandbox transfers exactly one MessagePort. + * After that first valid bootstrap the listener is removed permanently and all real payloads use + * the private port. Source-window identity is checked before accepting the transferred capability. + */ +export class SandboxChannelHost implements Message { + private readonly getTarget: () => Window; + private readonly removeWindowListener: (type: string, listener: EventListenerOrEventListenerObject) => void; + private readonly bootstrapHandler: EventListener; + private readonly pending: PendingListeners = { messages: [], connects: [] }; + private delegate?: MessagePortMessage; + private readonly readyPromise: Promise; + private resolveReady!: () => void; + private disposed = false; + + constructor(sourceWindow: Window, target: Window | (() => Window)) { + this.getTarget = typeof target === "function" ? target : () => target; + const addWindowListener = bindNative(sourceWindow.addEventListener, sourceWindow); + this.removeWindowListener = bindNative(sourceWindow.removeEventListener, sourceWindow); + this.readyPromise = new Promise((resolve) => { + this.resolveReady = resolve; + }); + + this.bootstrapHandler = ((event: MessageEvent) => { + if (this.disposed || this.delegate) return; + + let expectedSource: Window; + try { + expectedSource = this.getTarget(); + } catch { + return; + } + if (event.source !== expectedSource) return; + if (event.data !== SANDBOX_CHANNEL_BOOTSTRAP_MARKER) return; + if (event.ports.length !== 1 || !event.ports[0]) return; + + const delegate = new MessagePortMessage(event.ports[0]); + this.delegate = delegate; + this.removeWindowListener("message", this.bootstrapHandler); + + for (let i = 0; i < this.pending.messages.length; i += 1) { + delegate.onMessage(this.pending.messages[i]); + } + for (let i = 0; i < this.pending.connects.length; i += 1) { + delegate.onConnect(this.pending.connects[i]); + } + this.pending.messages.length = 0; + this.pending.connects.length = 0; + this.resolveReady(); + }) as EventListener; + + addWindowListener("message", this.bootstrapHandler); + } + + ready(): Promise { + return this.readyPromise; + } + + isReady(): boolean { + return this.delegate !== undefined; + } + + async connect(data: TMessage): Promise { + await this.readyPromise; + if (this.disposed || !this.delegate) throw new Error("Sandbox channel is unavailable."); + return this.delegate.connect(data); + } + + async sendMessage(data: TMessage): Promise { + await this.readyPromise; + if (this.disposed || !this.delegate) throw new Error("Sandbox channel is unavailable."); + return this.delegate.sendMessage(data); + } + + onConnect(callback: OnConnectCallback): void { + if (this.disposed) throw new Error("SandboxChannelHost is disposed."); + if (this.delegate) { + this.delegate.onConnect(callback); + return; + } + this.pending.connects.push(callback); + } + + onMessage(callback: OnMessageCallback): void { + if (this.disposed) throw new Error("SandboxChannelHost is disposed."); + if (this.delegate) { + this.delegate.onMessage(callback); + return; + } + this.pending.messages.push(callback); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (!this.delegate) this.removeWindowListener("message", this.bootstrapHandler); + this.pending.messages.length = 0; + this.pending.connects.length = 0; + this.delegate?.dispose(); + } +} + +export type SandboxChannelClient = { + message: MessagePortMessage; + transferToParent(): void; +}; + +/** + * Sandbox-side channel factory. + * + * The private endpoint is created and wired before transfer. Call transferToParent() only after + * the sandbox Server/Runtime listeners are installed; receiving the transferred port therefore + * doubles as the parent's verified "sandbox ready" signal. + */ +export const createSandboxChannelClient = ( + parentWindow: Window = parent, + channel: MessageChannel = new MessageChannel() +): SandboxChannelClient => { + const message = new MessagePortMessage(channel.port1); + const parentPostMessage = bindNative(parentWindow.postMessage, parentWindow) as ( + message: unknown, + targetOrigin: string, + transfer?: Transferable[] + ) => void; + let transferred = false; + + return { + message, + transferToParent() { + if (transferred) throw new Error("Sandbox channel has already been transferred."); + transferred = true; + parentPostMessage(SANDBOX_CHANNEL_BOOTSTRAP_MARKER, "*", [channel.port2]); + }, + }; +}; diff --git a/packages/message/server.test.ts b/packages/message/server.test.ts index b7b1ff007..e00f40c33 100644 --- a/packages/message/server.test.ts +++ b/packages/message/server.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach, vi, afterEach } from "vitest"; -import { GetSenderType, SenderConnect, SenderRuntime, Server, type IGetSender } from "./server"; +import { forwardMessage, GetSenderType, SenderConnect, SenderRuntime, Server, type IGetSender } from "./server"; import { CustomEventMessage } from "./custom_event_message"; import type { MessageConnect, RuntimeMessageSender } from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; @@ -35,6 +35,87 @@ afterEach(() => { }); describe("Server", () => { + it("ignores message envelopes with accessor actions without executing the accessor", () => { + const handler = vi.fn(); + server.on("on-hostile", handler); + const message: Record = { data: {} }; + let accessed = false; + Object.defineProperty(message, "action", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + expect(() => inboundMessage.EE.emit("message", message, vi.fn(), {})).not.toThrow(); + expect(accessed).toBe(false); + expect(handler).not.toHaveBeenCalled(); + }); + + it("ignores message envelopes with accessor data without executing the accessor", () => { + const handler = vi.fn(); + server.on("on-hostile-data", handler); + const message: Record = { action: "api/on-hostile-data" }; + let accessed = false; + Object.defineProperty(message, "data", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + expect(() => inboundMessage.EE.emit("message", message, vi.fn(), {})).not.toThrow(); + expect(accessed).toBe(false); + expect(handler).not.toHaveBeenCalled(); + }); + + it("应该在消息和长连接转发中都应用参数转换", async () => { + const transformed: unknown[] = []; + const targetFlag = `${uuidv4()}::target`; + const targetInbound = new CustomEventMessage(targetFlag, true); + const targetOutbound = new CustomEventMessage(targetFlag, false); + const targetServer = new Server("service", targetInbound); + targetServer.on("stream", (params) => { + transformed.push(params); + return "connected"; + }); + targetServer.on("call", (params) => { + transformed.push(params); + return "called"; + }); + + const sourceFlag = `${uuidv4()}::source`; + const sourceInbound = new CustomEventMessage(sourceFlag, true); + const sourceOutbound = new CustomEventMessage(sourceFlag, false); + const sourceServer = new Server("source", sourceInbound); + const targetSender = { + sendMessage: (data: any) => targetOutbound.sendMessage(data), + connect: (data: any) => targetOutbound.connect(data), + }; + forwardMessage("service", "stream", sourceServer, targetSender, undefined, (params) => ({ + ...params, + transformed: true, + })); + forwardMessage("service", "call", sourceServer, targetSender, undefined, (params) => ({ + ...params, + transformed: true, + })); + + const stream = await sourceOutbound.connect({ action: "source/stream", data: { value: 1 } }); + const response = await sourceOutbound.sendMessage({ action: "source/call", data: { value: 2 } }); + + expect(response.data).toBe("called"); + expect(transformed).toEqual([ + { value: 1, transformed: true }, + { value: 2, transformed: true }, + ]); + stream.disconnect(true); + }); + describe("基本功能测试 1", () => { it.concurrent("应该能够注册和调用 API", async () => { const mockHandler = vi.fn().mockResolvedValue("test response"); @@ -489,6 +570,36 @@ describe("Server", () => { expect(extSender.documentId).toBe("doc-123"); }); + it("应该保留有效的零标签页和窗口编号", () => { + let capturedSender: IGetSender; + + server.on("on-zero-ids", (_params, sender) => { + capturedSender = sender; + }); + + const mockSender: RuntimeMessageSender = { + tab: { id: 0, windowId: 0 }, + frameId: 0, + } as RuntimeMessageSender; + + (server as any).messageHandle("on-zero-ids", {}, vi.fn(), mockSender); + + expect(capturedSender!.getExtMessageSender()).toMatchObject({ tabId: 0, windowId: 0, frameId: 0 }); + }); + + it("应该把扩展消息来源传给 SenderRuntime", () => { + let capturedOrigin: string | undefined; + server.on("on-origin", (_params, sender) => { + capturedOrigin = sender.getConnectOrigin?.(); + }); + + const sendResponse = vi.fn(); + const mockSender = { tab: { id: 123 } } as RuntimeMessageSender; + (server as any).messageHandle("on-origin", {}, sendResponse, mockSender, "userScript"); + + expect(capturedOrigin).toBe("userScript"); + }); + it.concurrent("应该为没有 tab 的 sender 返回 -1 tabId", async () => { let capturedSender: IGetSender; @@ -534,6 +645,59 @@ describe("Server", () => { }); }); + describe("USER_SCRIPT action boundary", () => { + it("rejects privileged service worker actions before dispatch", () => { + const serviceWorkerServer = new Server("serviceWorker", inboundMessage); + const handler = vi.fn(); + serviceWorkerServer.on("script/getAllScripts", handler); + const sendResponse = vi.fn(); + const sender = {} as RuntimeMessageSender; + + (serviceWorkerServer as any).messageHandle("script/getAllScripts", {}, sendResponse, sender, "userScript"); + + expect(handler).not.toHaveBeenCalled(); + expect(sendResponse).toHaveBeenCalledWith({ code: -1, message: "userScript action is not allowed" }); + }); + + it("allows only the USER_SCRIPT GM API message", () => { + const serviceWorkerServer = new Server("serviceWorker", inboundMessage); + const handler = vi.fn().mockReturnValue("ok"); + serviceWorkerServer.on("runtime/gmApi", handler); + const sendResponse = vi.fn(); + const sender = {} as RuntimeMessageSender; + + (serviceWorkerServer as any).messageHandle( + "runtime/gmApi", + { api: "GM_log" }, + sendResponse, + sender, + "userScript" + ); + + expect(handler).toHaveBeenCalledWith({ api: "GM_log" }, expect.any(SenderRuntime)); + expect(sendResponse).toHaveBeenCalledWith({ code: 0, data: "ok" }); + }); + + it("allows the native USER_SCRIPT reconnect request", () => { + const serviceWorkerServer = new Server("serviceWorker", inboundMessage); + const handler = vi.fn().mockReturnValue({ bootstrapToken: "next-token" }); + serviceWorkerServer.on("runtime/reconnectUserScript", handler); + const sendResponse = vi.fn(); + const sender = {} as RuntimeMessageSender; + + (serviceWorkerServer as any).messageHandle( + "runtime/reconnectUserScript", + undefined, + sendResponse, + sender, + "userScript" + ); + + expect(handler).toHaveBeenCalledWith(undefined, expect.any(SenderRuntime)); + expect(sendResponse).toHaveBeenCalledWith({ code: 0, data: { bootstrapToken: "next-token" } }); + }); + }); + describe("Connect 功能测试", () => { it("应该能够处理连接消息", async () => { const mockHandler = vi.fn(); @@ -562,6 +726,32 @@ describe("Server", () => { expect(capturedConnection!).toBeDefined(); }); + it("does not reply when a connected RPC handler rejects after the caller disconnects", async () => { + let rejectHandler!: (error: Error) => void; + let notifyDisconnect = (_isSelfDisconnected: boolean) => {}; + const sendMessage = vi.fn(); + const onDisconnect = vi.fn((callback: (isSelfDisconnected: boolean) => void) => { + notifyDisconnect = callback; + }); + const mockConnect = { + onMessage: vi.fn(), + sendMessage, + disconnect: vi.fn(), + onDisconnect, + } as MessageConnect; + + server.on("on-rejected-after-disconnect", () => new Promise((_, reject) => (rejectHandler = reject))); + + (server as any).connectHandle("on-rejected-after-disconnect", {}, mockConnect); + notifyDisconnect(false); + rejectHandler(new Error("request cancelled")); + await nextTick(); + await nextTick(); + + expect(sendMessage).not.toHaveBeenCalled(); + expect(onDisconnect).toHaveBeenCalledWith(expect.any(Function)); + }); + it("应该能够通过连接发送消息", async () => { let serverConnection: MessageConnect; const serverMessageHandler = vi.fn(); diff --git a/packages/message/server.ts b/packages/message/server.ts index 3df109f80..252c94615 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -1,9 +1,41 @@ -import type { RuntimeMessageSender, MessageConnect, ExtMessageSender, Message, TMessage, MessageSend } from "./types"; +import type { + RuntimeMessageSender, + MessageConnect, + ExtMessageSender, + Message, + MessageOrigin, + TMessage, + MessageSend, +} from "./types"; import LoggerCore from "@App/app/logger/core"; import { connect, sendMessage } from "./client"; import { ExtensionMessageConnect } from "./extension_message"; import Logger from "@App/app/logger/logger"; +const nativeReflectApply = Reflect.apply; +const nativeFunctionBind = Function.prototype.bind; +const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +// 转发监听器会跨 context 保存一段时间,绑定时固定原生 bind,避免页面改写原型。 +const bindNative = any>(fn: T, receiver: any): T => + nativeReflectApply(nativeFunctionBind, fn, [receiver]) as T; + +type ParsedServerMessage = { action: string; data?: unknown }; + +const parseServerMessage = (value: unknown): ParsedServerMessage | undefined => { + if (value === null || typeof value !== "object") return undefined; + try { + const actionDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "action"); + if (!actionDescriptor || !("value" in actionDescriptor) || typeof actionDescriptor.value !== "string") { + return undefined; + } + const dataDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "data"); + if (dataDescriptor && !("value" in dataDescriptor)) return undefined; + return { action: actionDescriptor.value, data: dataDescriptor?.value }; + } catch { + return undefined; + } +}; + export const enum GetSenderType { CONNECT = 1, EXTCONNECT = 1 | 2, @@ -15,6 +47,7 @@ export interface IGetSender { getSender(): RuntimeMessageSender | undefined; getExtMessageSender(): ExtMessageSender; getConnect(): MessageConnect | undefined; + getConnectOrigin?(): MessageOrigin | undefined; } export class SenderConnect { @@ -47,8 +80,8 @@ export class SenderConnect { if (this.sender instanceof ExtensionMessageConnect) { const con = this.sender.getPort(); return { - windowId: con.sender?.tab?.windowId || -1, // -1表示后台脚本 - tabId: con.sender?.tab?.id || -1, // -1表示后台脚本 + windowId: con.sender?.tab?.windowId ?? -1, // -1表示后台脚本 + tabId: con.sender?.tab?.id ?? -1, // -1表示后台脚本 frameId: con.sender?.frameId, documentId: con.sender?.documentId, }; @@ -65,11 +98,18 @@ export class SenderConnect { getConnect(): MessageConnect { return this.sender; } + + getConnectOrigin(): "extension" | "userScript" | undefined { + return this.sender instanceof ExtensionMessageConnect ? this.sender.getOrigin() : undefined; + } } export class SenderRuntime { private readonly mType; - constructor(private sender: RuntimeMessageSender) { + constructor( + private sender: RuntimeMessageSender, + private readonly origin?: MessageOrigin + ) { this.mType = GetSenderType.RUNTIME; } @@ -97,8 +137,8 @@ export class SenderRuntime { }; } return { - windowId: sender.tab?.windowId || -1, // -1表示后台脚本 - tabId: sender.tab?.id || -1, // -1表示后台脚本 + windowId: sender.tab?.windowId ?? -1, // -1表示后台脚本 + tabId: sender.tab?.id ?? -1, // -1表示后台脚本 frameId: sender.frameId, documentId: sender.documentId, }; @@ -107,6 +147,10 @@ export class SenderRuntime { getConnect(): undefined { return undefined; } + + getConnectOrigin(): MessageOrigin | undefined { + return this.origin; + } } type ApiFunction = (params: any, con: IGetSender) => Promise | any | void; @@ -132,7 +176,7 @@ export class Server { private logger = LoggerCore.getInstance().logger({ service: "messageServer" }); constructor( - prefix: string, + private readonly prefix: string, msgReceiver: Message | Message[], private enableConnect: boolean = true ) { @@ -140,10 +184,11 @@ export class Server { if (this.enableConnect) { msgReceiverList.forEach((msg) => { msg.onConnect((msg: TMessage, con: MessageConnect) => { - if (typeof msg.action !== "string") return; - this.logger.trace("server onConnect", { msg }); - if (msg.action?.startsWith(prefix)) { - return this.connectHandle(msg.action.slice(prefix.length + 1), msg.data, con); + const parsed = parseServerMessage(msg); + if (!parsed) return; + this.logger.trace("server onConnect", { action: parsed.action }); + if (parsed.action.startsWith(this.prefix)) { + return this.connectHandle(parsed.action.slice(this.prefix.length + 1), parsed.data, con); } return false; }); @@ -151,11 +196,18 @@ export class Server { } msgReceiverList.forEach((msg) => { - msg.onMessage((msg: TMessage, sendResponse, sender) => { - if (typeof msg.action !== "string") return; - this.logger.trace("server onMessage", { msg: msg as any }); - if (msg.action?.startsWith(prefix)) { - return this.messageHandle(msg.action.slice(prefix.length + 1), msg.data, sendResponse, sender); + msg.onMessage((msg: TMessage, sendResponse, sender, origin) => { + const parsed = parseServerMessage(msg); + if (!parsed) return; + this.logger.trace("server onMessage", { action: parsed.action }); + if (parsed.action.startsWith(this.prefix)) { + return this.messageHandle( + parsed.action.slice(this.prefix.length + 1), + parsed.data, + sendResponse, + sender, + origin + ); } }); return false; @@ -171,22 +223,36 @@ export class Server { } private connectHandle(msg: string, params: any, con: MessageConnect) { + let isDisconnected = false; + con.onDisconnect(() => { + isDisconnected = true; + }); + const sendResponse = (response: TMessage) => { + if (!isDisconnected) con.sendMessage(response); + }; + + const sender = new SenderConnect(con); + if (!this.isUserScriptActionAllowed(msg, sender.getConnectOrigin(), true)) { + sendResponse({ code: -1, message: "userScript action is not allowed" }); + con.disconnect(true); + return true; + } const func = this.apiFunctionMap.get(msg); if (func) { - const ret = func(params, new SenderConnect(con)); + const ret = func(params, sender); if (ret) { if (ret instanceof Promise) { ret .then((data) => { - data && con.sendMessage({ code: 0, data }); + data && sendResponse({ code: 0, data }); }) .catch((e: Error) => { - con.sendMessage({ code: -1, message: formatErrorToClient(e) }); + sendResponse({ code: -1, message: formatErrorToClient(e) }); this.logger.error("connectHandle error", Logger.E(e)); }); return true; } else { - con.sendMessage({ code: 0, data: ret }); + sendResponse({ code: 0, data: ret }); } } return true; @@ -197,12 +263,18 @@ export class Server { action: string, params: any, sendResponse: (response: any) => void, - sender: RuntimeMessageSender + sender: RuntimeMessageSender, + origin?: MessageOrigin ) { + if (!this.isUserScriptActionAllowed(action, origin, false)) { + sendResponse({ code: -1, message: "userScript action is not allowed" }); + this.logger.warn("userScript action rejected", { action }); + return; + } const func = this.apiFunctionMap.get(action); if (func) { try { - const ret = func(params, new SenderRuntime(sender)); + const ret = func(params, new SenderRuntime(sender, origin)); if (ret instanceof Promise) { ret .then((data) => { @@ -229,6 +301,14 @@ export class Server { this.logger.error("no such api", { action: action }); } } + + private isUserScriptActionAllowed(action: string, origin: MessageOrigin | undefined, isConnect: boolean): boolean { + // USER_SCRIPT 只应取得注册握手、断线重连和 GM RPC;其他 serviceWorker API 仍只接受扩展通道。 + if (this.prefix !== "serviceWorker" || origin !== "userScript") return true; + return isConnect + ? action === "runtime/registerUserScript" || action === "runtime/gmApi" + : action === "runtime/gmApi" || action === "runtime/reconnectUserScript"; + } } export class Group { @@ -293,22 +373,23 @@ export function forwardMessage( path: string, receiverFrom: Server, senderTo: MessageSend, - middleware?: ApiFunctionSync + middleware?: ApiFunctionSync, + transform?: (params: any, con: IGetSender) => any ) { const handler = async (params: any, fromCon: IGetSender): Promise => { const fromConnect: MessageConnect | undefined = fromCon.getConnect(); if (fromConnect) { const toCon: MessageConnect = await connect(senderTo, `${prefix}/${path}`, params); - fromConnect.onMessage(toCon.sendMessage.bind(toCon)); - toCon.onMessage(fromConnect.sendMessage.bind(fromConnect)); - fromConnect.onDisconnect(toCon.disconnect.bind(toCon)); - toCon.onDisconnect(fromConnect.disconnect.bind(fromConnect)); + fromConnect.onMessage(bindNative(toCon.sendMessage, toCon)); + toCon.onMessage(bindNative(fromConnect.sendMessage, fromConnect)); + fromConnect.onDisconnect(bindNative(toCon.disconnect, toCon)); + toCon.onDisconnect(bindNative(fromConnect.disconnect, fromConnect)); return undefined; } else { return sendMessage(senderTo, prefix + "/" + path, params); } }; - receiverFrom.on(path, (params, sender) => { + const processTransformed = (params: any, sender: IGetSender) => { if (middleware) { // 此处是为了处理CustomEventMessage的同步消息情况 const resp = middleware(params, sender) as any; @@ -324,5 +405,15 @@ export function forwardMessage( } } return handler(params, sender); - }); + }; + const process = transform + ? (params: any, sender: IGetSender) => { + // 转换先于中间件和转发执行,使跨世界输入只在一个受控位置完成校验/复制。 + const transformed = transform(params, sender); + return transformed instanceof Promise + ? transformed.then((data) => processTransformed(data, sender)) + : processTransformed(transformed, sender); + } + : processTransformed; + receiverFrom.on(path, process); } diff --git a/packages/message/types.ts b/packages/message/types.ts index 1b323b8de..7d710f50b 100644 --- a/packages/message/types.ts +++ b/packages/message/types.ts @@ -28,12 +28,14 @@ export type TMessageCommCode = { export type TMessage = TMessagQueueUnit | TMessageCommAction | TMessageCommCode; export type RuntimeMessageSender = chrome.runtime.MessageSender; +export type MessageOrigin = "extension" | "userScript"; export type OnConnectCallback = (data: TMessage, con: MessageConnect) => void; export type OnMessageCallback = ( data: TMessage, sendResponse: (data: any) => void, - sender: RuntimeMessageSender + sender: RuntimeMessageSender, + origin?: MessageOrigin ) => boolean | void; export interface Message { diff --git a/packages/message/window_message.test.ts b/packages/message/window_message.test.ts index 00be4f8d7..76898acf3 100644 --- a/packages/message/window_message.test.ts +++ b/packages/message/window_message.test.ts @@ -3,6 +3,7 @@ import { ServiceWorkerMessageSend, ServiceWorkerClientMessage, WindowMessage, + parseWindowMessageBody, type WindowMessageBody, } from "./window_message"; import { Server } from "./server"; @@ -46,6 +47,21 @@ afterEach(() => { delete (self as any).clients; }); +describe("parseWindowMessageBody", () => { + it("rejects proxies that report unexpected own keys", () => { + const value = new Proxy( + { messageId: "message", type: "sendMessage", data: { action: "test" } }, + { + ownKeys() { + return ["other-1", "other-2", "other-3"]; + }, + } + ); + + expect(parseWindowMessageBody(value)).toBeUndefined(); + }); +}); + describe("ServiceWorkerMessageSend", () => { describe("messageHandle 处理来自 Offscreen 的请求", () => { it("处理 sendMessage 类型,调用 onMessage 回调并发送响应", () => { @@ -210,6 +226,63 @@ describe("WindowMessage.connect", () => { }); }); +describe("WindowMessage envelope validation", () => { + it("ignores accessor envelopes without executing their getters", () => { + let messageHandler: ((event: MessageEvent) => void) | undefined; + const sourceWindow = { + addEventListener: vi.fn((_event: string, handler: (event: MessageEvent) => void) => { + messageHandler = handler; + }), + } as unknown as Window; + const targetWindow = {} as unknown as Window; + const windowMessage = new WindowMessage(sourceWindow, targetWindow); + const received = vi.fn(); + windowMessage.onMessage(received); + const envelope: Record = { + messageId: "hostile", + type: "sendMessage", + data: { action: "offscreen/ping" }, + }; + let accessed = false; + Object.defineProperty(envelope, "data", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + expect(() => messageHandler!({ source: targetWindow, data: envelope } as unknown as MessageEvent)).not.toThrow(); + expect(accessed).toBe(false); + expect(received).not.toHaveBeenCalled(); + }); + + it("ignores proxy envelopes whose own-key inspection throws", () => { + let messageHandler: ((event: MessageEvent) => void) | undefined; + const sourceWindow = { + addEventListener: vi.fn((_event: string, handler: (event: MessageEvent) => void) => { + messageHandler = handler; + }), + } as unknown as Window; + const targetWindow = {} as unknown as Window; + const windowMessage = new WindowMessage(sourceWindow, targetWindow); + const received = vi.fn(); + windowMessage.onMessage(received); + const envelope = new Proxy( + { messageId: "hostile", type: "sendMessage", data: { action: "offscreen/ping" } }, + { + ownKeys() { + throw new Error("page proxy executed"); + }, + } + ); + + expect(() => messageHandler!({ source: targetWindow, data: envelope } as unknown as MessageEvent)).not.toThrow(); + expect(received).not.toHaveBeenCalled(); + }); +}); + // 单测重点:target 支持传入惰性求值函数,避免在 Firefox sandbox iframe 尚处于初始 about:blank // 阶段就缓存 contentWindow 快照——导航到真正的 sandbox 页面后,浏览器是否仍保证该快照与 // 事件的 e.source 全等属于实现细节,不可依赖;每次发送/比对都应重新读取当前值。 diff --git a/packages/message/window_message.ts b/packages/message/window_message.ts index 9946bd089..b9c98b23f 100644 --- a/packages/message/window_message.ts +++ b/packages/message/window_message.ts @@ -32,6 +32,52 @@ export type WindowMessageBody = { data: T | null; // 消息数据 }; +const nativeReflectOwnKeys = Reflect.ownKeys; +const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const WINDOW_MESSAGE_KEYS = ["messageId", "type", "data"] as const; + +export const parseWindowMessageBody = (value: unknown): WindowMessageBody | undefined => { + if (value === null || typeof value !== "object") return undefined; + + try { + const keys = nativeReflectOwnKeys(value); + if (keys.length !== WINDOW_MESSAGE_KEYS.length) return undefined; + for (let index = 0; index < keys.length; index += 1) { + let known = false; + for (let expectedIndex = 0; expectedIndex < WINDOW_MESSAGE_KEYS.length; expectedIndex += 1) { + if (keys[index] === WINDOW_MESSAGE_KEYS[expectedIndex]) { + known = true; + break; + } + } + if (!known) return undefined; + } + + const messageId = nativeObjectGetOwnPropertyDescriptor(value, "messageId"); + const type = nativeObjectGetOwnPropertyDescriptor(value, "type"); + const data = nativeObjectGetOwnPropertyDescriptor(value, "data"); + if (!messageId || !("value" in messageId) || !type || !("value" in type) || !data || !("value" in data)) { + return undefined; + } + + const messageType = type.value; + if ( + typeof messageId.value !== "string" || + (messageType !== "sendMessage" && + messageType !== "respMessage" && + messageType !== "connect" && + messageType !== "disconnect" && + messageType !== "connectMessage") + ) { + return undefined; + } + + return { messageId: messageId.value, type: messageType, data: data.value } as WindowMessageBody; + } catch { + return undefined; + } +}; + export class WindowMessage implements Message { EE = new EventEmitter(); @@ -78,6 +124,9 @@ export class WindowMessage implements Message { } messageHandle(data: WindowMessageBody, target: PostMessage) { + const safeData = parseWindowMessageBody(data); + if (!safeData) return; + data = safeData; // 处理消息 if (data.type === "sendMessage") { // 接收到消息 @@ -204,7 +253,7 @@ export class WindowMessageConnect implements MessageConnect { listenerMgr.addListener(`onMessage:${this.listenerId}`, callback); } - disconnect(ignoreAlreadyDisconnected: boolean) { + disconnect(ignoreAlreadyDisconnected = false) { if (!this.target) { if (ignoreAlreadyDisconnected) return; console.warn("Attempted to disconnect on a disconnected Target."); @@ -257,6 +306,9 @@ export class ServiceWorkerMessageSend implements Message { } messageHandle(data: WindowMessageBody, source?: PostMessage) { + const safeData = parseWindowMessageBody(data); + if (!safeData) return; + data = safeData; // 处理消息 if (data.type === "sendMessage" && source) { // 接收到来自offscreen的请求消息 @@ -358,6 +410,9 @@ export class ServiceWorkerClientMessage implements Message { } messageHandle(data: WindowMessageBody, source?: PostMessage) { + const safeData = parseWindowMessageBody(data); + if (!safeData) return; + data = safeData; // 只处理响应类消息,请求类消息由WindowMessage处理 if (data.type === "sendMessage" && source) { this.EE.emit( diff --git a/rspack.config.ts b/rspack.config.ts index f9e8ae578..1e0ddcf13 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -135,9 +135,16 @@ export default { ], }, plugins: [ + // 浏览器没有 Node 核心模块;运行时有原生 MessageChannel 时不会读取这个替代模块。 + new rspack.NormalModuleReplacementPlugin( + /^node:worker_threads$/, + `${src}/pkg/utils/node-worker-threads-browser.ts` + ), new rspack.DefinePlugin({ "process.env.VI_TESTING": "'false'", "process.env.SC_RANDOM_KEY": `'${uuidv4()}'`, + // 每次构建都生成独立标记,脚本包装器只接受扩展内部传入的完整性密钥。 + "process.env.SC_RANDOM_FNKEY": `'${uuidv4()}'`, "process.env.SC_DISABLE_AGENT": `'${enableAgent ? "false" : "true"}'`, }), new rspack.CopyRspackPlugin({ diff --git a/src/app/repo/agent_chat.test.ts b/src/app/repo/agent_chat.test.ts index 95b0dc1ce..a3fc1da32 100644 --- a/src/app/repo/agent_chat.test.ts +++ b/src/app/repo/agent_chat.test.ts @@ -157,6 +157,44 @@ describe("AgentChatRepo 附件存储", () => { expect(result).toBeInstanceOf(Blob); }); + it("附件读取权限只授予拥有引用该附件的脚本会话", async () => { + const conversation = await repo.createConversation({ + id: "conv-script-attachment", + ownerScriptUuid: "script-a", + title: "Script", + modelId: "m1", + createtime: 1, + updatetime: 1, + }); + await repo.saveMessages( + conversation.id, + [ + { + id: "message-script-attachment", + conversationId: conversation.id, + role: "user", + content: [{ type: "image", attachmentId: "script-image", mimeType: "image/png" }], + ownedAttachmentIds: ["script-image"], + createtime: 1, + }, + { + id: "message-borrowed-attachment", + conversationId: conversation.id, + role: "user", + content: [{ type: "image", attachmentId: "borrowed-image", mimeType: "image/png" }], + createtime: 2, + }, + ], + undefined, + { generation: conversation.generation! } + ); + + await expect(repo.isAttachmentAccessibleToScript("script-image", "script-a")).resolves.toBe(true); + await expect(repo.isAttachmentAccessibleToScript("borrowed-image", "script-a")).resolves.toBe(false); + await expect(repo.isAttachmentAccessibleToScript("script-image", "script-b")).resolves.toBe(false); + await expect(repo.isAttachmentAccessibleToScript("unreferenced", "script-a")).resolves.toBe(false); + }); + it("getAttachment 不存在的附件应返回 null", async () => { const result = await repo.getAttachment("nonexistent"); diff --git a/src/app/repo/agent_chat.ts b/src/app/repo/agent_chat.ts index 88fbaf1ff..5643f89da 100644 --- a/src/app/repo/agent_chat.ts +++ b/src/app/repo/agent_chat.ts @@ -500,6 +500,20 @@ export class AgentChatRepo extends OPFSRepo { } } + // 用户脚本只能读取自己拥有的会话消息声明过的附件;附件文件本身不携带 owner 元数据, + // 因此必须以持久化消息中的所有权字段作为授权依据,不能仅凭可猜测的附件 ID 或借用引用放行。 + async isAttachmentAccessibleToScript(id: string, scriptUuid: string): Promise { + if (!id || !scriptUuid) return false; + for (const conversation of await this.listConversations()) { + if (conversation.ownerScriptUuid !== scriptUuid) continue; + const snapshot = await this.getMessageSnapshot(conversation.id, conversation.generation); + if (collectMessageAttachmentIds(snapshot.messages, isLegacyGeneration(conversation.generation)).has(id)) { + return true; + } + } + return false; + } + // 删除单个附件(同时清理新旧路径) async deleteAttachment(id: string): Promise { // 新路径: agents/workspace/uploads/{id} diff --git a/src/app/repo/resource.ts b/src/app/repo/resource.ts index 6f4ef629a..39a3c6c75 100644 --- a/src/app/repo/resource.ts +++ b/src/app/repo/resource.ts @@ -81,6 +81,7 @@ export type CompiledResource = { name: string; flag: string; uuid: string; + scriptRevision: string; require: string[]; // 仅存储url,节省空间 matches: string[]; // primary includeGlobs: string[]; // includeGlobs applied after matches @@ -109,7 +110,7 @@ export class ResourceDAO extends Repo { } } -// CompiledResource结构或 matches 计算规则变更时,建议修改 CompiledResourceNamespace 以删除旧Cache +// CompiledResource 结构、matches 计算规则或 revision 绑定变更时,建议修改 CompiledResourceNamespace 以删除旧 Cache export const CompiledResourceNamespace = "a8d3d2a3-db3a-4e87-ab6f-9817fe6bd942"; export class CompiledResourceDAO extends Repo { diff --git a/src/app/repo/scripts.ts b/src/app/repo/scripts.ts index cf99f6de4..2903ff1a6 100644 --- a/src/app/repo/scripts.ts +++ b/src/app/repo/scripts.ts @@ -3,6 +3,7 @@ import type { Resource, ResourceType } from "./resource"; import type { SCMetadata } from "./metadata"; import type { GMInfoEnv } from "../service/content/types"; import type { URLRuleEntry } from "@App/pkg/utils/url_matcher"; +import type { ScriptEnvTag } from "@Packages/message/consts"; // 脚本模型 export type SCRIPT_TYPE = 1 | 2 | 3; @@ -110,6 +111,14 @@ export interface ScriptRunResource extends Script { resourceByType?: ScriptResourceByType; metadata: SCMetadata; // 经自定义覆盖的 Metadata originalMetadata: SCMetadata; // 原本的 Metadata (目前只需要 match, include, exclude) + /** 页面执行环境绑定的能力句柄。 */ + executionHandle?: string; + /** 执行脚本所在的页面环境。 */ + executionEnvTag?: ScriptEnvTag; + /** 页面执行绑定使用的值更新关联标识。 */ + executionRunFlag?: string; + /** 与已注册 userscript wrapper 对应的编译 revision。 */ + scriptRevision?: string; } /** @@ -139,6 +148,7 @@ export type TScriptInfo = Override< code: "" | string; sort?: number; flag: string; + scriptRevision?: string; runStatus?: SCRIPT_RUN_STATUS; type?: SCRIPT_TYPE; status?: SCRIPT_STATUS; @@ -151,6 +161,8 @@ export type TClientPageLoadInfo = injectScriptList: TScriptInfo[]; contentScriptList: TScriptInfo[]; envInfo: GMInfoEnv; + /** 一次性令牌,供 USER_SCRIPT world 请求私有 bootstrap。 */ + userScriptBootstrapToken?: string; } | { ok: false }; diff --git a/src/app/service/agent/core/types.ts b/src/app/service/agent/core/types.ts index c2f197d5e..4f2ee5cc0 100644 --- a/src/app/service/agent/core/types.ts +++ b/src/app/service/agent/core/types.ts @@ -22,6 +22,8 @@ export type MessageContent = string | ContentBlock[]; export type Conversation = { id: string; + /** ScriptCat API owner; absent on conversations created by the extension UI or older records. */ + ownerScriptUuid?: string; /** Immutable identity for this incarnation of an ID. Filled when legacy records are loaded. */ generation?: string; /** Optimistic-concurrency version. Filled when legacy records are loaded. */ @@ -640,6 +642,8 @@ export type MCPApiRequest = /** 定时任务基础字段(两种模式共用) */ type AgentTaskBase = { id: string; + /** ScriptCat API owner; absent on tasks created by the extension UI or older records. */ + ownerScriptUuid?: string; /** Immutable identity for this incarnation of the task ID. */ generation?: string; /** Optimistic-concurrency version. */ @@ -733,5 +737,6 @@ export type ConversationApiRequest = generation?: string; messageIds: string[]; preserveAttachmentIds?: string[]; + scriptUuid?: string; } - | { action: "delete"; conversationId: string; generation: string; revision?: number }; + | { action: "delete"; conversationId: string; generation: string; revision?: number; scriptUuid?: string }; diff --git a/src/app/service/agent/service_worker/agent.ts b/src/app/service/agent/service_worker/agent.ts index 4ef1420f7..507e694f9 100644 --- a/src/app/service/agent/service_worker/agent.ts +++ b/src/app/service/agent/service_worker/agent.ts @@ -352,8 +352,8 @@ export class AgentService { } // 处理定时任务 API 请求,供 GMApi 调用 - async handleAgentTaskApi(params: AgentTaskApiRequest) { - return this.agentTaskService.handleAgentTask(params); + async handleAgentTaskApi(params: AgentTaskApiRequest, ownerScriptUuid?: string) { + return this.agentTaskService.handleAgentTask(params, ownerScriptUuid); } // 处理 CAT.agent.model API 请求,委托给 AgentModelService @@ -386,7 +386,7 @@ export class AgentService { // 附加到后台运行会话,供 GMApi 调用 async handleAttachToConversationFromGmApi( - params: { conversationId: string; generation?: string }, + params: { conversationId: string; generation?: string; scriptUuid: string }, sender: IGetSender ) { return this.handleAttachToConversation(params, sender); @@ -399,7 +399,7 @@ export class AgentService { // 附加到后台运行中的会话(委托给 BackgroundSessionManager) private async handleAttachToConversation( - params: { conversationId: string; generation?: string }, + params: { conversationId: string; generation?: string; scriptUuid?: string }, sender: IGetSender ) { return this.bgSessionManager.handleAttach(params, sender); diff --git a/src/app/service/agent/service_worker/background_session_manager.test.ts b/src/app/service/agent/service_worker/background_session_manager.test.ts new file mode 100644 index 000000000..bab0570ca --- /dev/null +++ b/src/app/service/agent/service_worker/background_session_manager.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { BackgroundSessionManager, type RunningConversation } from "./background_session_manager"; + +function createSender() { + const sentMessages: any[] = []; + const connection = { + sendMessage: (message: any) => sentMessages.push(message), + onMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + return { + sender: { + isType: (type: any) => type === 1, + getConnect: () => connection, + } as any, + sentMessages, + }; +} + +describe("BackgroundSessionManager script ownership", () => { + it("does not attach a script to another script's running conversation", async () => { + const manager = new BackgroundSessionManager(); + const rc: RunningConversation = { + conversationId: "conv-owned", + generation: "gen-a", + ownerScriptUuid: "script-a", + abortController: new AbortController(), + listeners: new Set(), + streamingState: { content: "secret", thinking: "", toolCalls: [] }, + askResolvers: new Map(), + tasks: [], + status: "running" as const, + }; + manager.set(rc.conversationId, rc); + const { sender, sentMessages } = createSender(); + + await manager.handleAttach( + { conversationId: rc.conversationId, generation: rc.generation, scriptUuid: "script-b" }, + sender + ); + + expect(sentMessages).toContainEqual({ action: "event", data: { type: "sync", tasks: [], status: "done" } }); + expect(rc.listeners.size).toBe(0); + }); +}); diff --git a/src/app/service/agent/service_worker/background_session_manager.ts b/src/app/service/agent/service_worker/background_session_manager.ts index 1378502fa..a2c385e23 100644 --- a/src/app/service/agent/service_worker/background_session_manager.ts +++ b/src/app/service/agent/service_worker/background_session_manager.ts @@ -10,6 +10,8 @@ export type ListenerEntry = { // 后台运行会话状态 export type RunningConversation = { conversationId: string; + /** ScriptCat API owner; absent for conversations started by the extension UI. */ + ownerScriptUuid?: string; // 该次运行绑定的会话 generation;attach() 的调用方必须持有同一 generation 才允许附加, // 否则会静默观察到删除重建后无关的新一代会话 generation: string; @@ -191,7 +193,10 @@ export class BackgroundSessionManager { } // 附加 UI 连接到后台运行中的会话(同步快照 + listener + askUser resolver + stop) - async handleAttach(params: { conversationId: string; generation?: string }, sender: IGetSender): Promise { + async handleAttach( + params: { conversationId: string; generation?: string; scriptUuid?: string }, + sender: IGetSender + ): Promise { if (!sender.isType(GetSenderType.CONNECT)) { throw new Error("attachToConversation requires connect mode"); } @@ -209,6 +214,13 @@ export class BackgroundSessionManager { return; } + // Script callers may observe only the running conversation owned by the same script. + // Missing owners are legacy/UI records and therefore fail closed for scripts. + if (params.scriptUuid !== undefined && rc.ownerScriptUuid !== params.scriptUuid) { + sendEvent({ type: "sync", tasks: [], status: "done" }); + return; + } + // 调用方持有的 generation 与实际运行中的会话不一致:会话已被删除重建, // 不能让旧一代的调用方附加到无关的新一代会话上 if (params.generation !== undefined && rc.generation !== params.generation) { diff --git a/src/app/service/agent/service_worker/chat.test.ts b/src/app/service/agent/service_worker/chat.test.ts index 97ff823b6..7a2f80468 100644 --- a/src/app/service/agent/service_worker/chat.test.ts +++ b/src/app/service/agent/service_worker/chat.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createTestService, makeSkillRecord, makeSkillScriptRecord, makeTextResponse } from "./test-helpers"; +import { + createMockSender, + createTestService, + makeSkillRecord, + makeSkillScriptRecord, + makeTextResponse, +} from "./test-helpers"; // ---- handleConversationChat skipSaveUserMessage(重新生成 bug 修复验证)---- @@ -206,6 +212,83 @@ describe("handleConversationChat skipSaveUserMessage", () => { }); }); +describe("CAT.agent.conversation owner isolation", () => { + it("creates a persisted conversation bound to the requesting script", async () => { + const { service, mockRepo } = createTestService(); + + await (service as any).handleConversationApi({ + action: "create", + options: { model: "test-openai" }, + scriptUuid: "script-a", + }); + + expect(mockRepo.createConversation).toHaveBeenCalledWith(expect.objectContaining({ ownerScriptUuid: "script-a" })); + }); + + it("does not expose an owned or legacy conversation to another script", async () => { + const { service, mockRepo } = createTestService(); + mockRepo.listConversations.mockResolvedValue([ + { id: "owned", title: "Owned", modelId: "test-openai", ownerScriptUuid: "script-a" }, + { id: "legacy", title: "Legacy", modelId: "test-openai" }, + ]); + + await expect( + (service as any).handleConversationApi({ action: "get", id: "owned", scriptUuid: "script-b" }) + ).resolves.toBeNull(); + await expect( + (service as any).handleConversationApi({ action: "get", id: "legacy", scriptUuid: "script-a" }) + ).resolves.toBeNull(); + await expect( + (service as any).handleConversationApi({ action: "get", id: "owned", scriptUuid: "script-a" }) + ).resolves.toMatchObject({ id: "owned" }); + }); + + it("rejects every script mutation before it reaches message or conversation storage", async () => { + const { service, mockRepo } = createTestService(); + mockRepo.listConversations.mockResolvedValue([ + { id: "owned", title: "Owned", modelId: "test-openai", ownerScriptUuid: "script-a" }, + ]); + const requests = [ + { action: "getMessages", conversationId: "owned" }, + { action: "save", conversationId: "owned" }, + { action: "clearMessages", conversationId: "owned" }, + { action: "deleteMessages", conversationId: "owned", messageIds: [] }, + { action: "delete", conversationId: "owned", generation: "gen" }, + ]; + + for (const request of requests) { + await expect((service as any).handleConversationApi({ ...request, scriptUuid: "script-b" })).rejects.toThrow( + "Conversation not found" + ); + } + + expect(mockRepo.getMessageSnapshot).not.toHaveBeenCalled(); + expect(mockRepo.saveMessages).not.toHaveBeenCalled(); + expect(mockRepo.deleteConversation).not.toHaveBeenCalled(); + }); + + it("rejects a foreign script's chat before loading history or calling the model", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + mockRepo.listConversations.mockResolvedValue([ + { id: "owned", title: "Owned", modelId: "test-openai", ownerScriptUuid: "script-a" }, + ]); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + await (service as any).handleConversationChat( + { conversationId: "owned", message: "secret", scriptUuid: "script-b" }, + sender + ); + + expect(sentMessages.map((message) => message.data)).toContainEqual( + expect.objectContaining({ type: "error", message: "Conversation not found" }) + ); + expect(mockRepo.getMessages).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); +}); + describe("userscript 会话工具隔离", () => { it("携带 scriptUuid 时不注册无法交互的 ask_user 工具", async () => { const { service } = createTestService(); @@ -394,6 +477,7 @@ describe("handleConversationChat 场景补充", () => { id: "conv-1", title: "Test", modelId: "test-openai", + ownerScriptUuid: "script-1", generation: "gen-b", createtime: Date.now(), updatetime: Date.now(), diff --git a/src/app/service/agent/service_worker/chat_service.ts b/src/app/service/agent/service_worker/chat_service.ts index 89f79bd28..738246641 100644 --- a/src/app/service/agent/service_worker/chat_service.ts +++ b/src/app/service/agent/service_worker/chat_service.ts @@ -269,8 +269,10 @@ export class ChatService { case "create": return this.createConversation(params); case "get": - return this.getConversation(params.id); + return this.getConversation(params.id, params.scriptUuid); case "getMessages": + if (params.scriptUuid !== undefined) + await this.requireConversationAccess(params.conversationId, params.scriptUuid); // params.generation 提供时,与当前存储不一致(会话已被删除重建)则拒绝而非返回无关一代的消息; // 未提供 generation 时保留旧行为:会话不存在则返回空数组 try { @@ -281,15 +283,19 @@ export class ChatService { } case "save": { // 对话已经在 chat 过程中持久化,这里确保元数据也保存;仍需校验调用方持有的 generation - if (params.generation !== undefined) { - const conv = await this.getConversation(params.conversationId); - if (!conv || conv.generation !== params.generation) { - throw new Error("Conversation generation mismatch"); - } + const conv = + params.scriptUuid !== undefined || params.generation !== undefined + ? await this.getConversation(params.conversationId, params.scriptUuid) + : undefined; + if (params.scriptUuid !== undefined && !conv) throw new Error("Conversation not found"); + if (params.generation !== undefined && (!conv || conv.generation !== params.generation)) { + throw new Error("Conversation generation mismatch"); } return true; } case "clearMessages": + if (params.scriptUuid !== undefined) + await this.requireConversationAccess(params.conversationId, params.scriptUuid); // 会话正在等待脚本工具结果时,这个 clear 很可能来自该工具 handler 内部的 // await conv.clear():chat 持有会话队列锁等待 toolResults,clear 排队等锁, // 相互等待成死锁。对这个窗口显式拒绝(fail fast);其余时刻仍与 chat/compact @@ -317,6 +323,8 @@ export class ChatService { return true; }); case "deleteMessages": + if (params.scriptUuid !== undefined) + await this.requireConversationAccess(params.conversationId, params.scriptUuid); return stackAsyncTask(conversationChatLockKey(params.conversationId), async () => { const snapshot = await this.chatRepo.getMessageSnapshot(params.conversationId, params.generation); const ids = new Set(params.messageIds); @@ -333,6 +341,8 @@ export class ChatService { return true; }); case "delete": { + if (params.scriptUuid !== undefined) + await this.requireConversationAccess(params.conversationId, params.scriptUuid); this.abortAdmittedChats(params.conversationId); this.bgSessionManager.stop(params.conversationId); return stackAsyncTask(conversationChatLockKey(params.conversationId), async () => { @@ -352,6 +362,7 @@ export class ChatService { const model = await this.modelService.getModel(params.options.model); const conv: Conversation = { id: params.options.id || uuidv4(), + ownerScriptUuid: params.scriptUuid, title: "New Chat", modelId: model.id, system: params.options.system, @@ -362,10 +373,10 @@ export class ChatService { return this.chatRepo.createConversation(conv); } - private async getConversation(id: string): Promise { + private async getConversation(id: string, scriptUuid?: string): Promise { const conversations = await this.chatRepo.listConversations(); const conversation = conversations.find((item) => item.id === id); - if (!conversation) return null; + if (!conversation || (scriptUuid !== undefined && conversation.ownerScriptUuid !== scriptUuid)) return null; return { ...conversation, generation: conversation.generation || `legacy:${conversation.id}`, @@ -373,6 +384,12 @@ export class ChatService { }; } + private async requireConversationAccess(id: string, scriptUuid: string): Promise { + const conversation = await this.getConversation(id, scriptUuid); + if (!conversation) throw new Error("Conversation not found"); + return conversation; + } + // 统一的流式 conversation chat(UI 和脚本 API 共用) // 同一 conversationId 的 chat / compact(compact 复用本方法的 params.compact 分支)都必须与 // clearMessages 串行执行,避免并发读改写互相覆盖对方的持久化写入。 @@ -402,6 +419,21 @@ export class ChatService { // 后台模式:非 ephemeral、非 compact 时可用 const isBackground = params.background === true && !params.ephemeral && !params.compact; + // Script callers must prove ownership before entering the queue or touching a connection. + // Legacy/UI conversations have no owner and therefore fail closed for scripts. + if (!params.ephemeral && params.scriptUuid !== undefined) { + const conversation = await this.getConversation(params.conversationId, params.scriptUuid); + if (!conversation) { + try { + msgConn.sendMessage({ action: "event", data: { type: "error", message: "Conversation not found" } }); + } catch { + // 端口已断开,无需通知 + } + await releaseProvisionalUserAttachments(); + return; + } + } + if (!params.ephemeral && this.conversationsAwaitingScriptTools.has(params.conversationId)) { try { msgConn.sendMessage({ @@ -619,7 +651,7 @@ export class ChatService { if (isBackground) { // 后台会话必须先确认调用方持有的 generation 与当前存储一致,否则一次删除重建后的 // 陈旧调用会静默附加到无关的新一代会话上 - const conv = await this.getConversation(params.conversationId); + const conv = await this.getConversation(params.conversationId, params.scriptUuid); if (!conv) { await releaseProvisionalUserAttachments(); sendEventDirect({ type: "error", message: "Conversation not found" }); @@ -637,6 +669,7 @@ export class ChatService { rc = { conversationId: params.conversationId, generation: conv.generation!, + ownerScriptUuid: conv.ownerScriptUuid, abortController, listeners: new Set(), streamingState: { content: "", thinking: "", toolCalls: [] }, @@ -741,7 +774,7 @@ export class ChatService { } // 获取对话和模型 - const conv = await this.getConversation(params.conversationId); + const conv = await this.getConversation(params.conversationId, params.scriptUuid); if (!conv) { sendEvent({ type: "error", message: "Conversation not found" }); return; @@ -924,7 +957,7 @@ export class ChatService { abortController: AbortController ): Promise { const startTime = Date.now(); - const conv = await this.getConversation(params.conversationId); + const conv = await this.getConversation(params.conversationId, params.scriptUuid); if (!conv) { sendEvent({ type: "error", message: "Conversation not found" }); return; diff --git a/src/app/service/agent/service_worker/dom.test.ts b/src/app/service/agent/service_worker/dom.test.ts index a0cf6829d..9d9938a24 100644 --- a/src/app/service/agent/service_worker/dom.test.ts +++ b/src/app/service/agent/service_worker/dom.test.ts @@ -406,6 +406,14 @@ describe("AgentDomService", () => { }); }); + describe("monitor", () => { + it("应拒绝在浏览器内部页面启动监控", async () => { + mockTabsGet.mockResolvedValue({ id: 1, url: "chrome://settings", status: "complete", discarded: false }); + + await expect(service.startMonitor(1)).rejects.toThrow("Agent DOM operation not allowed for URL:"); + }); + }); + describe("resolveTabId", () => { it("应在 tab 被 discard 时自动 reload", async () => { mockTabsGet.mockResolvedValueOnce({ diff --git a/src/app/service/agent/service_worker/dom.ts b/src/app/service/agent/service_worker/dom.ts index df0f6d951..7a1ac953e 100644 --- a/src/app/service/agent/service_worker/dom.ts +++ b/src/app/service/agent/service_worker/dom.ts @@ -264,18 +264,20 @@ export class AgentDomService { } // 启动页面监控(CDP:dialog 自动处理 + MutationObserver) - async startMonitor(tabId: number): Promise { - return cdpStartMonitor(tabId); + async startMonitor(tabId: number, scriptUuid?: string): Promise { + const tab = await chrome.tabs.get(tabId); + assertDomUrlAllowed(tab.url || ""); + return cdpStartMonitor(tabId, scriptUuid); } // 停止监控并返回收集的结果 - async stopMonitor(tabId: number): Promise { - return cdpStopMonitor(tabId); + async stopMonitor(tabId: number, scriptUuid?: string): Promise { + return cdpStopMonitor(tabId, scriptUuid); } // 查询当前 monitor 状态(不停止监控) - peekMonitor(tabId: number): MonitorStatus { - return cdpPeekMonitor(tabId); + peekMonitor(tabId: number, scriptUuid?: string): MonitorStatus { + return cdpPeekMonitor(tabId, scriptUuid); } // 处理 GM API 请求路由 @@ -300,11 +302,11 @@ export class AgentDomService { case "executeScript": return this.executeScript(request.code, request.options); case "startMonitor": - return this.startMonitor(request.tabId); + return this.startMonitor(request.tabId, request.scriptUuid); case "stopMonitor": - return this.stopMonitor(request.tabId); + return this.stopMonitor(request.tabId, request.scriptUuid); case "peekMonitor": - return this.peekMonitor(request.tabId); + return this.peekMonitor(request.tabId, request.scriptUuid); default: throw new Error(`Unknown DOM action: ${(request as any).action}`); } diff --git a/src/app/service/agent/service_worker/dom_cdp.test.ts b/src/app/service/agent/service_worker/dom_cdp.test.ts index f09cfa183..c7f97ddec 100644 --- a/src/app/service/agent/service_worker/dom_cdp.test.ts +++ b/src/app/service/agent/service_worker/dom_cdp.test.ts @@ -17,7 +17,7 @@ vi.stubGlobal("chrome", { tabs: { get: mockTabsGet }, }); -import { cdpClick } from "./dom_cdp"; +import { cdpClick, cdpPeekMonitor, cdpStartMonitor, cdpStopMonitor } from "./dom_cdp"; afterAll(() => { vi.stubGlobal("chrome", savedChrome); @@ -91,4 +91,40 @@ describe("agent_dom_cdp", () => { }); await expect(cdpClick(999, "#nonexistent")).rejects.toThrow(/Element not found/); }); + + it("页面监控只能由创建它的脚本重新启动", async () => { + mockTabsGet.mockResolvedValue({ url: "https://example.com" }); + mockSendCommand.mockResolvedValue({ root: { nodeId: 1 } }); + + await cdpStartMonitor(999, "script-a"); + + await expect(cdpStartMonitor(999, "script-b")).rejects.toThrow("Monitor belongs to another script"); + + await cdpStopMonitor(999, "script-a"); + }); + + it("并发重启同一标签页的监控不会泄漏旧监听器", async () => { + mockTabsGet.mockResolvedValue({ url: "https://example.com" }); + mockSendCommand.mockResolvedValue({ root: { nodeId: 1 } }); + + await Promise.all([cdpStartMonitor(997, "script-a"), cdpStartMonitor(997, "script-a")]); + await cdpStopMonitor(997, "script-a"); + + expect(mockAttach).toHaveBeenCalledTimes(2); + expect(mockDetach).toHaveBeenCalledTimes(2); + expect(chrome.debugger.onEvent.addListener as ReturnType).toHaveBeenCalledTimes(2); + expect(chrome.debugger.onEvent.removeListener as ReturnType).toHaveBeenCalledTimes(2); + }); + + it("页面监控的结果不能被其他脚本读取或停止", async () => { + mockTabsGet.mockResolvedValue({ url: "https://example.com" }); + mockSendCommand.mockResolvedValue({ root: { nodeId: 1 } }); + + await cdpStartMonitor(998, "script-a"); + + expect(cdpPeekMonitor(998, "script-b")).toEqual({ hasChanges: false, dialogCount: 0, nodeCount: 0 }); + await expect(cdpStopMonitor(998, "script-b")).rejects.toThrow("Monitor belongs to another script"); + + await cdpStopMonitor(998, "script-a"); + }); }); diff --git a/src/app/service/agent/service_worker/dom_cdp.ts b/src/app/service/agent/service_worker/dom_cdp.ts index f05980868..26d08229d 100644 --- a/src/app/service/agent/service_worker/dom_cdp.ts +++ b/src/app/service/agent/service_worker/dom_cdp.ts @@ -16,12 +16,32 @@ type CapturedNode = { }; type MonitorSession = { + ownerScriptUuid?: string; dialogs: Array<{ type: string; message: string }>; capturedNodes: CapturedNode[]; // 从事件中直接提取的节点信息 listener: MonitorEventListener; }; const activeMonitors = new Map(); +const monitorOperationQueues = new Map>(); + +async function withMonitorOperation(tabId: number, operation: () => Promise): Promise { + const previous = monitorOperationQueues.get(tabId) || Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + monitorOperationQueues.set(tabId, current); + await previous; + try { + return await operation(); + } finally { + release(); + if (monitorOperationQueues.get(tabId) === current) { + monitorOperationQueues.delete(tabId); + } + } +} // 生命周期管理:attach → 执行 → detach // 如果该 tabId 已有活跃的 monitor(已 attach),则复用连接,不做 attach/detach @@ -239,10 +259,18 @@ export async function cdpScreenshot(tabId: number, options?: ScreenshotOptions): // ---- 页面监控(startMonitor / stopMonitor) ---- // 启动页面监控:attach debugger,纯 CDP 事件监听(dialog + DOM 变化),零注入 -export async function cdpStartMonitor(tabId: number): Promise { +export function cdpStartMonitor(tabId: number, ownerScriptUuid?: string): Promise { + return withMonitorOperation(tabId, () => startMonitor(tabId, ownerScriptUuid)); +} + +async function startMonitor(tabId: number, ownerScriptUuid?: string): Promise { // 如果已有 monitor,先停止 - if (activeMonitors.has(tabId)) { - await cdpStopMonitor(tabId); + const current = activeMonitors.get(tabId); + if (current) { + if (current.ownerScriptUuid !== ownerScriptUuid) { + throw new Error("Monitor belongs to another script"); + } + await stopMonitor(tabId, ownerScriptUuid); } const dialogs: Array<{ type: string; message: string }> = []; @@ -292,13 +320,16 @@ export async function cdpStartMonitor(tabId: number): Promise { }; chrome.debugger.onEvent.addListener(listener); - activeMonitors.set(tabId, { dialogs, capturedNodes, listener }); + activeMonitors.set(tabId, { ownerScriptUuid, dialogs, capturedNodes, listener }); } // 轻量查询当前 monitor 状态(不停止监控) -export function cdpPeekMonitor(tabId: number): { hasChanges: boolean; dialogCount: number; nodeCount: number } { +export function cdpPeekMonitor( + tabId: number, + ownerScriptUuid?: string +): { hasChanges: boolean; dialogCount: number; nodeCount: number } { const monitor = activeMonitors.get(tabId); - if (!monitor) { + if (!monitor || monitor.ownerScriptUuid !== ownerScriptUuid) { return { hasChanges: false, dialogCount: 0, nodeCount: 0 }; } const dialogCount = monitor.dialogs.length; @@ -315,8 +346,15 @@ function stripHtmlTags(html: string): string { } // 停止监控:纯 CDP 解析新增节点 → 收集结果 → detach -export async function cdpStopMonitor(tabId: number): Promise { +export function cdpStopMonitor(tabId: number, ownerScriptUuid?: string): Promise { + return withMonitorOperation(tabId, () => stopMonitor(tabId, ownerScriptUuid)); +} + +async function stopMonitor(tabId: number, ownerScriptUuid?: string): Promise { const monitor = activeMonitors.get(tabId); + if (monitor && monitor.ownerScriptUuid !== ownerScriptUuid) { + throw new Error("Monitor belongs to another script"); + } const result: MonitorResult = { dialogs: monitor?.dialogs || [], addedNodes: [], diff --git a/src/app/service/agent/service_worker/opfs.test.ts b/src/app/service/agent/service_worker/opfs.test.ts index 9f2377f0b..e48a94a29 100644 --- a/src/app/service/agent/service_worker/opfs.test.ts +++ b/src/app/service/agent/service_worker/opfs.test.ts @@ -176,6 +176,24 @@ describe("handleOPFSApi", () => { expect(mockRepo.getAttachment).toHaveBeenCalledWith("att-123"); }); + it("readAttachment 不得读取其他脚本未拥有的附件", async () => { + const { service, mockRepo } = createTestService(); + mockRepo.isAttachmentAccessibleToScript.mockResolvedValue(false); + mockRepo.getAttachment = vi.fn().mockResolvedValue(new Blob(["secret"], { type: "image/png" })); + + await expect( + service.handleOPFSApi( + { + action: "readAttachment", + id: "att-private", + scriptUuid: "script-b", + }, + mockOPFSSender + ) + ).rejects.toThrow("Attachment access denied: att-private"); + expect(mockRepo.getAttachment).not.toHaveBeenCalled(); + }); + it("readAttachment 附件不存在时应抛出错误", async () => { const { service, mockRepo } = createTestService(); mockRepo.getAttachment = vi.fn().mockResolvedValue(null); diff --git a/src/app/service/agent/service_worker/opfs_service.ts b/src/app/service/agent/service_worker/opfs_service.ts index ac3a51d30..c082e5331 100644 --- a/src/app/service/agent/service_worker/opfs_service.ts +++ b/src/app/service/agent/service_worker/opfs_service.ts @@ -59,6 +59,9 @@ export class AgentOPFSService { return { path: safePath2, content: textContent, size: file2.size }; } case "readAttachment": { + if (!(await repo.isAttachmentAccessibleToScript(request.id, request.scriptUuid))) { + throw new Error(`Attachment access denied: ${request.id}`); + } const blob = await repo.getAttachment(request.id); if (!blob) { throw new Error(`Attachment not found: ${request.id}`); diff --git a/src/app/service/agent/service_worker/task_service.test.ts b/src/app/service/agent/service_worker/task_service.test.ts index 58e62a272..5c120b528 100644 --- a/src/app/service/agent/service_worker/task_service.test.ts +++ b/src/app/service/agent/service_worker/task_service.test.ts @@ -128,6 +128,7 @@ describe("AgentTaskService 任务生命周期", () => { function createMutationService() { const current = { id: "task-cas", + ownerScriptUuid: "script-a", generation: "generation-current", revision: 3, name: "current", @@ -142,6 +143,7 @@ describe("AgentTaskService 任务生命周期", () => { } as const; const taskRepo = { getTask: vi.fn().mockResolvedValue(current), + listTasks: vi.fn().mockResolvedValue([current]), createTask: vi.fn(async (candidate: any) => candidate), saveTask: vi.fn(async (candidate: any) => { if (candidate.generation !== current.generation || candidate.revision !== current.revision) { @@ -195,6 +197,55 @@ describe("AgentTaskService 任务生命周期", () => { expect(taskRepo.saveTask).toHaveBeenCalledWith(expect.objectContaining({ revision: 2 })); }); + it("脚本创建的任务只允许同一脚本读取和修改", async () => { + const { service, taskRepo, scheduler, current } = createMutationService(); + const other = { ...current, id: "task-other", ownerScriptUuid: "script-b" }; + taskRepo.listTasks.mockResolvedValue([current, other]); + + await expect(service.handleAgentTask({ action: "list" }, "script-a")).resolves.toEqual([current]); + await expect(service.handleAgentTask({ action: "get", id: current.id }, "script-b")).rejects.toThrow( + "Task not found" + ); + await expect( + service.handleAgentTask( + { + action: "update", + id: current.id, + generation: current.generation, + revision: current.revision, + task: { name: "forged edit" }, + }, + "script-b" + ) + ).rejects.toThrow("Task not found"); + await expect(service.handleAgentTask({ action: "runNow", id: current.id }, "script-b")).rejects.toThrow( + "Task not found" + ); + expect(scheduler.executeTask).not.toHaveBeenCalled(); + }); + + it("脚本创建的任务绑定创建者身份而不是请求体伪造的身份", async () => { + const { service, taskRepo } = createMutationService(); + + await service.handleAgentTask( + { + action: "create", + task: { + name: "owned task", + mode: "internal", + crontab: "0 9 * * *", + prompt: "hello", + enabled: true, + notify: false, + ownerScriptUuid: "script-b", + }, + } as any, + "script-a" + ); + + expect(taskRepo.createTask).toHaveBeenCalledWith(expect.objectContaining({ ownerScriptUuid: "script-a" })); + }); + it("delete 应先取消活动执行并使用客户端版本删除", async () => { const { service, taskRepo, scheduler } = createMutationService(); diff --git a/src/app/service/agent/service_worker/task_service.ts b/src/app/service/agent/service_worker/task_service.ts index 425787c20..184354286 100644 --- a/src/app/service/agent/service_worker/task_service.ts +++ b/src/app/service/agent/service_worker/task_service.ts @@ -265,17 +265,38 @@ export class AgentTaskService { }; } + private taskBelongsTo(task: AgentTask, ownerScriptUuid: string): boolean { + return ( + task.ownerScriptUuid === ownerScriptUuid || + (task.ownerScriptUuid === undefined && task.mode === "event" && task.sourceScriptUuid === ownerScriptUuid) + ); + } + + private assertTaskAccess(task: AgentTask | undefined, ownerScriptUuid: string | undefined): AgentTask { + if (!task || (ownerScriptUuid !== undefined && !this.taskBelongsTo(task, ownerScriptUuid))) { + throw new Error("Task not found"); + } + return task; + } + // 处理定时任务 CRUD 及 run 操作 - async handleAgentTask(params: AgentTaskApiRequest): Promise { + async handleAgentTask(params: AgentTaskApiRequest, ownerScriptUuid?: string): Promise { switch (params.action) { - case "list": - return this.taskRepo.listTasks(); - case "get": - return this.taskRepo.getTask(params.id); + case "list": { + const tasks = await this.taskRepo.listTasks(); + return ownerScriptUuid === undefined + ? tasks + : tasks.filter((task) => this.taskBelongsTo(task, ownerScriptUuid)); + } + case "get": { + const task = await this.taskRepo.getTask(params.id); + return this.assertTaskAccess(task, ownerScriptUuid); + } case "create": { const now = Date.now(); const task = { ...params.task, + ownerScriptUuid, id: uuidv4(), createtime: now, updatetime: now, @@ -300,11 +321,11 @@ export class AgentTaskService { return this.taskRepo.createTask(task); } case "update": { - const existing = await this.taskRepo.getTask(params.id); - if (!existing) throw new Error("Task not found"); + const existing = this.assertTaskAccess(await this.taskRepo.getTask(params.id), ownerScriptUuid); const updated = { ...existing, ...params.task, + ownerScriptUuid: existing.ownerScriptUuid ?? ownerScriptUuid, id: params.id, generation: params.generation, revision: params.revision, @@ -332,17 +353,17 @@ export class AgentTaskService { return this.taskRepo.saveTask(updated); } case "delete": { + const task = this.assertTaskAccess(await this.taskRepo.getTask(params.id), ownerScriptUuid); // 先中止正在运行的执行,再清理元数据/运行记录:cancelTask 是同步的 abort(),必须最先 // 发生,否则被删除的任务会在 removeTask(含 run-history 清理)完成前继续调用 LLM/工具/ // 产生外部副作用;若 removeTask 之后才 cancel,一旦 removeTask 因清理失败而抛出, // cancelTask 根本不会被调用,执行也就永远不会被中止 - this.taskScheduler?.cancelTask(params.id); + this.taskScheduler?.cancelTask(task.id); await this.taskRepo.removeTask(params.id, params.generation, params.revision); return true; } case "enable": { - const task = await this.taskRepo.getTask(params.id); - if (!task) throw new Error("Task not found"); + const task = this.assertTaskAccess(await this.taskRepo.getTask(params.id), ownerScriptUuid); const updated = { ...task, enabled: params.enabled, @@ -361,19 +382,22 @@ export class AgentTaskService { return this.taskRepo.saveTask(updated); } case "runNow": { - const task = await this.taskRepo.getTask(params.id); - if (!task) throw new Error("Task not found"); + const task = this.assertTaskAccess(await this.taskRepo.getTask(params.id), ownerScriptUuid); // 不 await,立即返回 const now = Date.now(); const claimScheduled = Boolean(task.enabled && task.nextruntime && task.nextruntime <= now); this.taskScheduler?.executeTask(task, claimScheduled, now).catch(() => {}); return true; } - case "listRuns": + case "listRuns": { + this.assertTaskAccess(await this.taskRepo.getTask(params.taskId), ownerScriptUuid); return this.taskRunRepo.listRuns(params.taskId, params.limit); - case "clearRuns": + } + case "clearRuns": { + this.assertTaskAccess(await this.taskRepo.getTask(params.taskId), ownerScriptUuid); await this.taskRunRepo.clearRuns(params.taskId); return true; + } default: throw new Error(`Unknown agentTask action: ${(params as any).action}`); } diff --git a/src/app/service/agent/service_worker/test-helpers.ts b/src/app/service/agent/service_worker/test-helpers.ts index 3944c5e4a..38422e813 100644 --- a/src/app/service/agent/service_worker/test-helpers.ts +++ b/src/app/service/agent/service_worker/test-helpers.ts @@ -90,6 +90,7 @@ export function createTestService() { getTasks: vi.fn().mockResolvedValue([]), getTaskSnapshot: vi.fn().mockResolvedValue({ generation: "test-generation", revision: 0, tasks: [] }), saveTasks: vi.fn().mockResolvedValue(undefined), + isAttachmentAccessibleToScript: vi.fn().mockResolvedValue(true), getAttachment: vi.fn().mockResolvedValue(null), saveAttachment: vi.fn().mockResolvedValue(0), deleteAttachment: vi.fn().mockResolvedValue(undefined), diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 058e59c95..e9e235dbe 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -2,7 +2,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ScriptLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; import { encodeRValue } from "@App/pkg/utils/message_value"; import { createContext, createProxyContext, shouldFnBind, type RealmRoots } from "./create_context"; +import { GMContextApiGet } from "./gm_api/gm_context"; import { trimScriptInfo } from "./utils"; +import { Native } from "./global"; +import { installArrayPrototypeIndexAccessor } from "@Tests/array_prototype_index"; type AnyRecord = Record; @@ -287,6 +290,61 @@ const createTestContext = (grants: string[], metadata: Record new Set(grants) ); +describe("context 生命周期方法:构造与投影边界", () => { + it("继承的 Object.prototype.get 不应让 createContext 构造失败", () => { + // 描述符字面量 {configurable, enumerable, value} 是普通对象,会继承 Object.prototype。 + // 若生命周期方法仍用 Native.objectDefineProperty(..., {value: ...}) 构造, + // 页面预先在 Object.prototype 上放置的 get/set 会让该字面量同时具备 + // value 和 get,触发 "同时指定访问器与 value" 的 TypeError。 + // 用不带任何 @grant 的 fixture,排除 capability name/length 描述符调用的干扰。 + const originalDescriptor = Object.getOwnPropertyDescriptor(Object.prototype, "get"); + let context: ReturnType | undefined; + let threw: unknown; + try { + Object.defineProperty(Object.prototype, "get", { + configurable: true, + value: () => undefined, + }); + try { + context = createTestContext([]); + } catch (error) { + threw = error; + } + } finally { + if (originalDescriptor) { + Object.defineProperty(Object.prototype, "get", originalDescriptor); + } else { + delete (Object.prototype as AnyRecord).get; + } + } + + expect(threw).toBeUndefined(); + expect(context).toBeDefined(); + expect(context!.valueUpdate).toBeTypeOf("function"); + }); + + it("六个内部生命周期方法在 context 上可调用,但不投影到实际的脚本沙盒", () => { + const context = createTestContext(["GM_getValue"]); + const sandbox = createProxyContext(context, createSplitRealmRoots().roots); + + expect(context.valueUpdate).toBeTypeOf("function"); + expect(context.emitEvent).toBeTypeOf("function"); + expect(context.setInvalidContext).toBeTypeOf("function"); + expect(context.isInvalidContext).toBeTypeOf("function"); + expect(context.setExecutionRunFlag).toBeTypeOf("function"); + expect(context.resolveLoadScript).toBeTypeOf("function"); + + expect(sandbox.valueUpdate).toBeUndefined(); + expect(sandbox.emitEvent).toBeUndefined(); + expect(sandbox.setInvalidContext).toBeUndefined(); + expect(sandbox.isInvalidContext).toBeUndefined(); + expect(sandbox.setExecutionRunFlag).toBeUndefined(); + expect(sandbox.resolveLoadScript).toBeUndefined(); + // 已授权的 API 仍应正常投影,证明过滤只挡内部键。 + expect(sandbox.GM_getValue).toBeTypeOf("function"); + }); +}); + describe("shouldFnBind", () => { it("只把 native-like callable 視為需要 receiver binding", () => { expect(shouldFnBind(Object.prototype.valueOf)).toBe(true); @@ -304,6 +362,209 @@ describe("shouldFnBind", () => { }); describe("createContext: capability and lifecycle contract", () => { + it("does not expose broker state on the script-facing context", () => { + const context = createTestContext(["GM_getValue"]); + + expect(context).not.toHaveProperty("message"); + expect(context).not.toHaveProperty("scriptRes"); + expect(context).not.toHaveProperty("valueChangeListener"); + expect(context).not.toHaveProperty("EE"); + expect(context).not.toHaveProperty("grantSet"); + }); + + it("does not let page prototype pollution hide granted APIs", () => { + const descriptor = Object.getOwnPropertyDescriptor(Object.prototype, "GM_getValue"); + try { + Object.defineProperty(Object.prototype, "GM_getValue", { + configurable: true, + value: true, + }); + + const context = createTestContext(["GM_getValue"]); + + expect(context.GM_getValue).toBeTypeOf("function"); + } finally { + if (descriptor) Object.defineProperty(Object.prototype, "GM_getValue", descriptor); + else Reflect.deleteProperty(Object.prototype, "GM_getValue"); + } + }); + + it("creates collection instances from frozen captured-method subclasses", () => { + const set = new Native.Set(["grant"]); + const map = new Native.Map(); + const weakMap = new Native.WeakMap(); + + expect(set).toBeInstanceOf(Native.Set); + expect(map).toBeInstanceOf(Native.Map); + expect(weakMap).toBeInstanceOf(Native.WeakMap); + expect(Object.hasOwn(Object.getPrototypeOf(set), "add")).toBe(true); + expect(Object.hasOwn(Object.getPrototypeOf(map), "get")).toBe(true); + expect(Object.hasOwn(Object.getPrototypeOf(weakMap), "get")).toBe(true); + expect(Object.isFrozen(Object.getPrototypeOf(set))).toBe(true); + expect(Object.isFrozen(Object.getPrototypeOf(map))).toBe(true); + expect(Object.isFrozen(Object.getPrototypeOf(weakMap))).toBe(true); + }); + + it("keeps grant construction on captured Set and iterator intrinsics", () => { + const NativeSet = Set; + const nativeArrayIsArray = Array.isArray; + const nativeArrayIterator = Array.prototype[Symbol.iterator]; + const nativeSetIterator = Set.prototype[Symbol.iterator]; + const grants = new NativeSet(); + NativeSet.prototype.add.call(grants, "GM_getValue"); + const poisonedIterator = function () { + let first = true; + return { + next() { + if (!first) return { value: undefined, done: true }; + first = false; + return { value: "GM_cookie", done: false }; + }, + }; + }; + try { + Array.isArray = (() => false) as unknown as typeof Array.isArray; + Object.defineProperty(Array.prototype, Symbol.iterator, { configurable: true, value: poisonedIterator }); + Object.defineProperty(NativeSet.prototype, Symbol.iterator, { configurable: true, value: poisonedIterator }); + (globalThis as typeof globalThis & { Set: typeof Set }).Set = class PoisonedSet { + constructor() { + throw new Error("page replaced Set"); + } + } as unknown as typeof Set; + + const arrayBackedSet = new Native.Set(["GM_getValue"]); + expect(arrayBackedSet.has("GM_getValue")).toBe(true); + + const context = createContext( + createScriptInfo({ grant: ["GM_getValue"] }), + { script: { name: "create-context-test" }, scriptMetaStr: "" }, + "vitest", + undefined as any, + undefined as any, + grants + ); + + expect(context.GM_getValue).toBeTypeOf("function"); + expect(context.GM_cookie).toBeUndefined(); + } finally { + Array.isArray = nativeArrayIsArray; + Object.defineProperty(Array.prototype, Symbol.iterator, { configurable: true, value: nativeArrayIterator }); + Object.defineProperty(NativeSet.prototype, Symbol.iterator, { configurable: true, value: nativeSetIterator }); + (globalThis as typeof globalThis & { Set: typeof Set }).Set = NativeSet; + } + }); + + it("ignores non-string grants without coercing them", () => { + const coerceGrant = vi.fn(() => "GM_getValue"); + const grant = { [Symbol.toPrimitive]: coerceGrant }; + const context = createTestContext([grant] as unknown as string[]); + + expect(context.GM_getValue).toBeUndefined(); + expect(coerceGrant).not.toHaveBeenCalled(); + }); + + it("keeps long capability calls safe from inherited numeric setters and preserves visible arity", () => { + const apiValues = GMContextApiGet("GM_getValue")!; + const originalApi = apiValues[0].api; + let receiver: unknown; + const api = function longArgumentProbe( + apiContext: unknown, + first: number, + second: number, + third: number, + fourth: number, + fifth: number, + sixth: number, + seventh: number + ) { + receiver = apiContext; + return [first, second, third, fourth, fifth, sixth, seventh]; + }; + let setterCalls = 0; + let result: unknown; + let context: ReturnType | undefined; + let capability: ((...args: number[]) => unknown) | undefined; + let restoreArrayIndex: (() => void) | undefined; + + apiValues[0].api = api; + try { + context = createTestContext(["GM_getValue"]); + capability = context.GM_getValue; + restoreArrayIndex = installArrayPrototypeIndexAccessor({ + set() { + setterCalls += 1; + }, + }); + result = capability!(1, 2, 3, 4, 5, 6, 7); + } finally { + restoreArrayIndex?.(); + apiValues[0].api = originalApi; + } + + expect(capability!.length).toBe(7); + expect(setterCalls).toBe(0); + expect(receiver).toBeTypeOf("object"); + expect(receiver).not.toBe(context); + expect(result).toEqual([1, 2, 3, 4, 5, 6, 7]); + }); + + it("uses the service-worker execution run flag for value acknowledgments", async () => { + const script = { + ...createScriptInfo({ grant: ["GM_setValue"] }), + executionRunFlag: "canonical-run", + } as TScriptInfo; + const message = { + sendMessage: vi.fn().mockResolvedValue({ code: 0, data: "bar" }), + }; + const context = createContext( + script, + { script: { name: "create-context-test" }, scriptMetaStr: "" }, + "vitest", + message as any, + undefined as any, + new Set(["GM_setValue"]) + ); + + context.GM_setValue("foo", "next"); + expect(message.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ runFlag: "canonical-run" }), + }) + ); + }); + + it("installs capabilities without looking up a page-patchable Function.prototype.bind", () => { + const apiValues = GMContextApiGet("GM_getValue")!; + const originalApi = apiValues[0].api; + const replacement = function (_ctx: unknown, key: string, fallback?: unknown) { + return fallback; + }; + Object.defineProperty(replacement, "bind", { configurable: true, value: undefined }); + apiValues[0].api = replacement; + try { + const context = createTestContext(["GM_getValue"]); + expect(context.GM_getValue("key", "fallback")).toBe("fallback"); + } finally { + apiValues[0].api = originalApi; + } + }); + + it("uses captured object operations when page code replaces assign and keys", () => { + const assign = vi.spyOn(Object, "assign").mockImplementation(() => { + throw new Error("page replacement"); + }); + const keys = vi.spyOn(Object, "keys").mockImplementation(() => { + throw new Error("page replacement"); + }); + try { + const context = createTestContext(["GM_getValue"]); + expect(context.GM_getValue("foo", "fallback")).toBe("bar"); + } finally { + assign.mockRestore(); + keys.mockRestore(); + } + }); + const resourceGrantChecks: Array<{ grant: string; read: (context: ReturnType) => unknown; @@ -355,9 +616,6 @@ describe("createContext: capability and lifecycle contract", () => { expect(context.GM_cookie.list).toBeTypeOf("function"); expect(context.GM_cookie.delete).toBeTypeOf("function"); expect(context.not_exist).toBeUndefined(); - expect(context.grantSet.has("not_exist")).toBe(false); - expect(context.grantSet.has("GM_getValue")).toBe(true); - expect(context.grantSet.has("GM.getValue")).toBe(true); }); it.each(["GM.cookie", "GM_cookie"] as const)("雙向注入 cookie API:輸入 %s 時兩種公開形狀都可用", (grant) => { @@ -371,8 +629,6 @@ describe("createContext: capability and lifecycle contract", () => { expect(context.GM_cookie.set).toBeTypeOf("function"); expect(context.GM_cookie.list).toBeTypeOf("function"); expect(context.GM_cookie.delete).toBeTypeOf("function"); - expect(context.grantSet.has("GM.cookie")).toBe(true); - expect(context.grantSet.has("GM_cookie")).toBe(true); }); it("將 window grant 留在 context.window,投影時才暴露到 sandbox", () => { @@ -399,12 +655,28 @@ describe("createContext: capability and lifecycle contract", () => { await Promise.resolve(); expect(loaded).toBe(false); - const loadScriptResolve = (context as unknown as AnyRecord).loadScriptResolve as () => void; - loadScriptResolve(); + context.resolveLoadScript(); await loadedPromise; expect(loaded).toBe(true); }); + it("失效 early-start context 時取消 loadScript 等待", async () => { + const context = createTestContext(["CAT_scriptLoaded"], { + "early-start": [""], + "run-at": ["document-start"], + }); + let loaded = false; + const loadedPromise = context.CAT_scriptLoaded().then(() => { + loaded = true; + }); + + context.setInvalidContext(); + await Promise.resolve(); + + expect(loaded).toBe(true); + await loadedPromise; + }); + it("非 early-start 不建立多餘的等待點", () => { const context = createTestContext(["CAT_scriptLoaded"], { "run-at": ["document-end"] }); const contextValues = context as unknown as AnyRecord; @@ -439,20 +711,31 @@ describe("createContext: capability and lifecycle contract", () => { update("remote-1", "next", 7); expect(listener).toHaveBeenCalledWith("foo", "bar", "next", true, 7); - const contextValues = context as unknown as AnyRecord; - const runFlag = contextValues.runFlag; context.setInvalidContext(); context.setInvalidContext(); expect(context.isInvalidContext()).toBe(true); - expect(contextValues.runFlag).not.toBe(runFlag); - expect(contextValues.runFlag).toContain("(invalid)"); - expect(contextValues.message).toBeNull(); - expect(contextValues.scriptRes).toBeNull(); update("remote-2", "again", 8); expect(listener).toHaveBeenCalledTimes(1); }); + + it("事件回调收到独立快照,不能改写传输中的事件数据", () => { + const context = createTestContext(["CAT.agent.task"]); + let observed: { nested: { value: number } } | undefined; + const received = vi.fn((data: { nested: { value: number } }) => { + observed = { nested: { value: data.nested.value } }; + data.nested.value = 99; + }); + + context.CAT.agent.task.addListener("task-a", received); + const eventData = { nested: { value: 1 } }; + context.emitEvent("agentTask", "task-a", eventData); + + expect(received).toHaveBeenCalledTimes(1); + expect(observed).toEqual({ nested: { value: 1 } }); + expect(eventData).toEqual({ nested: { value: 1 } }); + }); }); describe.sequential("createProxyContext: module default split roots", () => { @@ -577,6 +860,23 @@ describe.sequential("createProxyContext: module default split roots", () => { }); describe("createProxyContext: deterministic realm contract", () => { + it("uses the captured descriptor intrinsic when building the pseudo-window", () => { + const fixture = createSplitRealmRoots(); + const defineProperty = Object.defineProperty; + let sandbox: ReturnType | undefined; + + Object.defineProperty = (() => { + throw new Error("page replaced Object.defineProperty"); + }) as typeof Object.defineProperty; + try { + sandbox = createProxyContext(Object.create(null), fixture.roots); + } finally { + Object.defineProperty = defineProperty; + } + + expect(sandbox).toBeDefined(); + }); + it("固定 window/self/globalThis,並把每次 sandbox 的寫入隔離", () => { const first = createProxyFixture({ GM_getValue: vi.fn() }); const second = createProxyFixture({ GM_getValue: vi.fn() }); @@ -728,10 +1028,21 @@ describe("createProxyContext: deterministic realm contract", () => { expect(Object.prototype.toString.call(sandbox)).toBe("[object Window]"); expect(sandbox.constructor).toBe(fixture.hostWindow.constructor); - expect(sandbox.__proto__).toBe(fixture.hostWindow.__proto__); + // 直接对照 hostWindow 的真实原型,而不是经由 legacy __proto__ getter 读出的值。 + expect(sandbox.__proto__).toBe(Object.getPrototypeOf(fixture.hostWindow)); expect(Object.getPrototypeOf(sandbox)).toBeNull(); }); + it("pseudo-window 的三个兼容 own descriptor 都是只读、不可枚举、可 configure", () => { + const fixture = createSplitRealmRoots(); + const sandbox = createProxyContext(Object.create(null), fixture.roots); + const expectedFlags = { writable: false, enumerable: false, configurable: true }; + + expect(Object.getOwnPropertyDescriptor(sandbox, "constructor")).toMatchObject(expectedFlags); + expect(Object.getOwnPropertyDescriptor(sandbox, "__proto__")).toMatchObject(expectedFlags); + expect(Object.getOwnPropertyDescriptor(sandbox, Symbol.toStringTag)).toMatchObject(expectedFlags); + }); + it("抽出 host EventTarget 方法後仍可呼叫,且 listener 只觸發一次", () => { const fixture = createSplitRealmRoots(); const sandbox = createProxyContext(Object.create(null), fixture.roots); @@ -766,6 +1077,64 @@ describe("createProxyContext: deterministic realm contract", () => { expect(fixture.eventTarget.listenerCount("message")).toBe(0); }); + it("creates event descriptors without invoking inherited setters", () => { + const fixture = createSplitRealmRoots(); + const defineProperty = Object.defineProperty; + const previousEventDescriptor = Object.getOwnPropertyDescriptor(Object.prototype, "onmessage"); + let setterCalls = 0; + let sandbox: ReturnType | undefined; + + const restoreArrayIndex = installArrayPrototypeIndexAccessor({ + set() { + setterCalls += 1; + }, + }); + defineProperty(Object.prototype, "onmessage", { + configurable: true, + set() { + setterCalls += 1; + }, + }); + try { + sandbox = createProxyContext(Object.create(null), fixture.roots); + } finally { + if (previousEventDescriptor) defineProperty(Object.prototype, "onmessage", previousEventDescriptor); + else Reflect.deleteProperty(Object.prototype, "onmessage"); + restoreArrayIndex(); + } + + const handler = vi.fn(); + sandbox!.onmessage = handler; + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("message")); + + expect(setterCalls).toBe(0); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("event descriptor 直接赋值到 ownDescs 后消费出的最终 descriptor 形状保持不变", () => { + // ownDescs 的 event-entry 写入从 Native.objectDefineProperty 改成直接赋值后, + // Object.create(proto, ownDescs) 消费出的最终 accessor descriptor 必须逐位一致—— + // on* key 从未进入 overriddenDescs/protoBaseDescs(只被记录进 eventKeys),所以 + // ownDescs[key] 在这个 forEach 之前本来就是 undefined,{...undefined, ...eventSetterGetter} + // 只剩 get/set 两个字段,configurable/enumerable 沿用 Object.create 对省略字段的默认值 false。 + // 这个形状在改动前后必须完全一致。 + const fixture = createSplitRealmRoots(); + const sandbox = createProxyContext(Object.create(null), fixture.roots); + + const descriptor = Object.getOwnPropertyDescriptor(sandbox, "onload"); + + expect(descriptor).toMatchObject({ configurable: false, enumerable: false }); + expect(typeof descriptor?.get).toBe("function"); + expect(typeof descriptor?.set).toBe("function"); + expect(descriptor).not.toHaveProperty("value"); + expect(descriptor).not.toHaveProperty("writable"); + // get/set 必须是 createEventProp 生成的 sandbox 专属实现,不是原始 host getter/setter。 + const handler = vi.fn(); + sandbox.onload = handler; + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("load")); + expect(handler).toHaveBeenCalledTimes(1); + }); + it("host prototype accessor 以最近 descriptor 為準,不被 parent descriptor 覆寫", () => { const fixture = createSplitRealmRoots(); const parentPrototype = Object.create(null) as AnyRecord; @@ -904,6 +1273,25 @@ describe("createProxyContext: deterministic realm contract", () => { expect(third).toHaveBeenCalledTimes(1); }); + it("事件 callback 的 call 屬性被頁面改寫時仍保留 sandbox this", () => { + const fixture = createSplitRealmRoots(); + const sandbox = createProxyContext(Object.create(null), fixture.roots); + const handler = vi.fn(function (this: unknown) { + expect(this).toBe(sandbox); + }); + Object.defineProperty(handler, "call", { + configurable: true, + value: () => { + throw new Error("poisoned call"); + }, + }); + + sandbox.onload = handler; + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("load")); + + expect(handler).toHaveBeenCalledTimes(1); + }); + it("split realm 下 self/window/globalThis 寫入都留在當前 sandbox", () => { const fixture = createSplitRealmRoots(); const sandbox = createProxyContext(Object.create(null), fixture.roots); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 44d4ccf20..6a1462b80 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -6,11 +6,72 @@ import { GMContextApiGet, protect } from "./gm_api/gm_context"; import { getGrantCandidates } from "./gm_api/grant"; import { isEarlyStartScript } from "./utils"; import { ListenerManager } from "./listener_manager"; -import { createGMBase } from "./gm_api/gm_api"; +import { createGMBase, type IGM_Base } from "./gm_api/gm_api"; import { attachNavigateHandler, type UrlChangeEvent } from "./gm_api/navigation_handle"; +import { nativeCall, Native } from "./global"; + +const createCapability = (api: (...args: any[]) => any, receiver: object) => { + // 由闭包提供上下文,脚本侧只传 API 自身的参数。 + /* eslint-disable prefer-rest-params -- 以固定参数转发保留调用参数数量,避免每次调用创建 rest 数组。 */ + const capability = function (this: unknown) { + switch (arguments.length) { + case 0: + return api(receiver); + case 1: + return api(receiver, arguments[0]); + case 2: + return api(receiver, arguments[0], arguments[1]); + case 3: + return api(receiver, arguments[0], arguments[1], arguments[2]); + case 4: + return api(receiver, arguments[0], arguments[1], arguments[2], arguments[3]); + default: { + // Reflect.apply accepts an array-like object; a null prototype avoids inherited index setters. + const args = Native.objectCreate(null) as { length: number; [index: number]: unknown }; + args[0] = receiver; + for (let i = 0; i < arguments.length; i += 1) args[i + 1] = arguments[i]; + args.length = arguments.length + 1; + return Native.reflectApply(api, undefined, args); + } + } + }; + /* eslint-enable prefer-rest-params */ + Native.objectDefineProperty(capability, "name", { + configurable: true, + value: api.name, + }); + Native.objectDefineProperty(capability, "length", { + configurable: true, + value: api.length > 1 ? api.length - 1 : 0, + }); + return capability; +}; // 不要使用 {}, 改使用 Object.create(null) - 避免在页面生成沙盒时,受到 Object.prototype 被注入的影响 +export type ScriptContext = IGM_Base & { + [key: string]: any; + setExecutionRunFlag(runFlag: string): void; + takePendingEarlyValueKeys(): Set; + resolveLoadScript(): void; +}; + +type InternalScriptContext = IGM_Base & { + [key: string]: any; + runFlag: string; + loadScriptResolve?: () => void; + pendingEarlyValueKeys?: Set; +}; + +// context → mySandbox 投影时必须排除的内部键:protect 中登记的 GM_Base 生命周期成员, +// 加上只存在于 facade、未登记在 protect 的早期启动钩子。 +// 供 createProxyContext 的投影过滤,以及 exec_script.ts 的 globalInjection 碰撞检查复用。 +export const isInternalContextKey = (key: string): boolean => + key === "setExecutionRunFlag" || + key === "takePendingEarlyValueKeys" || + key === "resolveLoadScript" || + Native.objectHasOwn(protect, key); + // 构建沙盒上下文 export const createContext = ( scriptRes: TScriptInfo, @@ -20,19 +81,22 @@ export const createContext = ( contentMsg: Message, scriptGrants: Set ) => { + // 复制授权集合并使用捕获的 Set 实现,避免页面改写迭代器后影响 API 注入。 + const scriptGrantSet = new Native.Set(scriptGrants); // 按照GMApi构建 - const valueChangeListener = new ListenerManager(); + const valueChangeListener = new ListenerManager(); const EE = new EventEmitter(); // 如果是preDocumentStart脚本,装载loadScriptPromise let loadScriptPromise: Promise | undefined; let loadScriptResolve: (() => void) | undefined; - if (isEarlyStartScript(scriptRes.metadata)) { + const earlyStart = isEarlyStartScript(scriptRes.metadata); + if (earlyStart) { loadScriptPromise = new Promise((resolve) => { loadScriptResolve = resolve; }); } let invalid = false; - const GM = Object.create(null); + const GM = Native.objectCreate(null); GM.info = GMInfo; const context = createGMBase({ prefix: envPrefix, @@ -41,71 +105,105 @@ export const createContext = ( scriptRes, valueChangeListener, EE, - runFlag: uuidv4(), + runFlag: scriptRes.executionRunFlag || uuidv4(), eventId: 10000, GM: GM, GM_info: GMInfo, - window: Object.create(null), - grantSet: new Set(), + window: Native.objectCreate(null), + grantSet: new Native.Set(), loadScriptPromise, loadScriptResolve, + pendingEarlyValueKeys: earlyStart ? new Native.Set() : undefined, setInvalidContext() { if (invalid) return; invalid = true; + this.loadScriptResolve?.(); + this.loadScriptResolve = undefined; this.valueChangeListener.clear(); this.EE.removeAllListeners(); this.runFlag = `${uuidv4()}(invalid)`; // 更改 uuid 防止 runFlag 相关操作 // 释放记忆 this.message = null; this.scriptRes = null; + this.pendingEarlyValueKeys = undefined; this.valueChangeListener = null; this.EE = null; }, isInvalidContext() { return invalid; }, - }); - const grantedAPIs: { [key: string]: any } = Object.create(null); + }) as unknown as InternalScriptContext; + const publicContext = Native.objectCreate(null) as ScriptContext; + publicContext.GM = GM; + publicContext.GM_info = GMInfo; + publicContext.window = Native.objectCreate(null); + publicContext.unsafeWindow = window; + + // 生命周期方法只供隔离执行器使用;对脚本不可见由 createProxyContext 的显式 + // isInternalContextKey 投影过滤保证,不依赖此处的描述符可枚举性——描述符字面量 + // 会继承 Object.prototype,页面预先放置的 get/set 会让 defineProperty 抛错。 + publicContext.valueUpdate = (data: any) => context.valueUpdate(data); + publicContext.emitEvent = (event: string, eventId: string, data: any) => context.emitEvent(event, eventId, data); + publicContext.setInvalidContext = () => context.setInvalidContext(); + publicContext.isInvalidContext = () => context.isInvalidContext(); + publicContext.setExecutionRunFlag = (runFlag: string) => { + context.runFlag = runFlag; + }; + publicContext.takePendingEarlyValueKeys = () => { + const keys = context.pendingEarlyValueKeys || new Native.Set(); + context.pendingEarlyValueKeys = undefined; + return keys; + }; + publicContext.resolveLoadScript = () => { + context.loadScriptResolve?.(); + context.loadScriptResolve = undefined; + }; + + const grantedAPIs: { [key: string]: any } = Native.objectCreate(null); const __methodInject__ = (grant: string): boolean => { const grantSet: Set = context.grantSet; const s = GMContextApiGet(grant); if (!s) return false; // @grant 的定义未实现,略过 (返回 false 表示 @grant 不存在) if (grantSet.has(grant)) return true; // 重复的@grant,略过 (返回 true 表示 @grant 存在) grantSet.add(grant); - for (const { fnKey, api, param } of s) { - grantedAPIs[fnKey] = api.bind(context); + for (let i = 0; i < s.length; i += 1) { + const { fnKey, api, param } = s[i]; + grantedAPIs[fnKey] = createCapability(api, context); const depend = param?.depend; if (depend) { - for (const grant of depend) { - __methodInject__(grant); - } + for (let j = 0; j < depend.length; j += 1) __methodInject__(depend[j]); } } return true; }; - for (const grant of scriptGrants) { - for (const candidate of getGrantCandidates(grant)) { + // 只能调用捕获的 forEach;此处不依赖页面提供的 Set iterator。 + scriptGrantSet.forEach((grant) => { + if (typeof grant !== "string") return; + const candidates = getGrantCandidates(grant); + for (let i = 0; i < candidates.length; i += 1) { + const candidate = candidates[i]; __methodInject__(candidate); } - } + }); // 兼容GM.Cookie.* - for (const fnKey of Object.keys(grantedAPIs)) { + const grantedKeys = Native.objectKeys(grantedAPIs); + for (let i = 0; i < grantedKeys.length; i += 1) { + const fnKey = grantedKeys[i]; const fnKeyArray = fnKey.split("."); const m = fnKeyArray.length; - let g = context; + let g = publicContext; let s = ""; for (let i = 0; i < m; i++) { const part = fnKeyArray[i]; s += `${i ? "." : ""}${part}`; - g = g[part] || (g[part] = grantedAPIs[s] || Object.create(null)); + g = g[part] || (g[part] = grantedAPIs[s] || Native.objectCreate(null)); } } - context.unsafeWindow = window; - if (scriptGrants.has("window.onurlchange") && context.onurlchange === undefined) { - context.onurlchange = null; + if (scriptGrantSet.has("window.onurlchange") && context.onurlchange === undefined) { + publicContext.onurlchange = null; attachNavigateHandler(window as any); } - return context; + return publicContext; }; const noEval = false; @@ -161,11 +259,13 @@ const getAllPropertyDescriptors = ( callback: (key: string | symbol, descriptor: PropertyDescriptor) => void ) => { while (obj && obj !== Object) { - const descs = Object.getOwnPropertyDescriptors(obj); - for (const key of Reflect.ownKeys(descs)) { + const descs = Native.objectGetOwnPropertyDescriptors(obj); + const keys = Native.reflectOwnKeys(descs); + for (let i = 0; i < keys.length; i += 1) { + const key = keys[i]; callback(key, descs[key as keyof typeof descs]); } - obj = Object.getPrototypeOf(obj); + obj = Native.objectGetPrototypeOf(obj); } }; @@ -178,21 +278,19 @@ const isConstructorOrInterface = (value: unknown) => { }; // 避免 host/Xray function 的 .bind lookup 不可靠 -const bindFn = Function.prototype.bind; - const materializeDescriptor = (descriptor: PropertyDescriptor, receiver: DescriptorOwner): PropertyDescriptor => { if ("value" in descriptor) { if (typeof descriptor.value !== "function" || isConstructorOrInterface(descriptor.value)) return descriptor; return { ...descriptor, - value: bindFn.call(descriptor.value, receiver), + value: Native.bind(descriptor.value, receiver), }; } if (!descriptor.get && !descriptor.set) return descriptor; return { ...descriptor, - get: descriptor.get ? bindFn.call(descriptor.get, receiver) : undefined, - set: descriptor.set ? bindFn.call(descriptor.set, receiver) : undefined, + get: descriptor.get ? Native.bind(descriptor.get, receiver) : undefined, + set: descriptor.set ? Native.bind(descriptor.set, receiver) : undefined, }; }; @@ -206,28 +304,44 @@ export type RealmRoots = { hostWindow: DescriptorOwner; }; +// sandbox 的实际原型固定为 null,因此三个 Window 兼容 own descriptor(constructor/__proto__/ +// toStringTag)都是只读、不可枚举、可 configure 的兼容语义,不是真正的沙盒行为。 +const readonlyCompatDescriptor = (value: unknown): PropertyDescriptor => { + const descriptor = Native.objectCreate(null) as PropertyDescriptor; + descriptor.value = value; + descriptor.writable = false; + descriptor.enumerable = false; + descriptor.configurable = true; + return descriptor; +}; + const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSnapshot => { // 在 CacheSet 加入的 propKeys 将会在 mySandbox 实装阶段时设置。 // 先处理的 descriptor 覆盖后续父类。 - const descsCache: Set = new Set(["eval", "window", "self", "globalThis", "top", "parent"]); + const descsCache: Set = new Native.Set(["eval", "window", "self", "globalThis", "top", "parent"]); // realmGlobal own descriptor 优先,hostWindow descriptor 只补足 host 成员。 - const initOwnDescs = Object.getOwnPropertyDescriptors(realmGlobal); + const initOwnDescs = Native.objectGetOwnPropertyDescriptors(realmGlobal); // overriddenDescs 将以物件 OwnPropertyDescriptor 方式进行物件属性修改。 // 覆盖原有的 OwnPropertyDescriptor 定义或父类的 PropertyDescriptor 定义。 - const overriddenDescs: DescriptorMap = Object.create(null); + const overriddenDescs: DescriptorMap = Native.objectCreate(null); // 记录原生 onxxxxx 的 property key。 - const eventKeys = new Set(); + const eventKeys = new Native.Set(); - // 在 USE_PSEUDO_WINDOW 情况下,由于没有类的 prototype,父类的成员要手动传下去。 - const protoBaseDescs: DescriptorMap = Object.create(null); + // sandbox 没有真实 Window.prototype,因此祖先成员要手动传下去。 + const protoBaseDescs: DescriptorMap = Native.objectCreate(null); + + // 用来装 constructor/__proto__/toStringTag 三个 Window 兼容 own descriptor。 + const pseudoWindowDescs = Native.objectCreate(null) as Record; const collectRealmDescriptors = () => { // 只读取 realmGlobal own descriptors,避免混合 Firefox 的两个 realm。 - const descriptors = Object.getOwnPropertyDescriptors(realmGlobal); - for (const key of Object.keys(descriptors)) { + const descriptors = Native.objectGetOwnPropertyDescriptors(realmGlobal); + const keys = Native.objectKeys(descriptors); + for (let i = 0; i < keys.length; i += 1) { + const key = keys[i]; const desc = descriptors[key]; if (descsCache.has(key)) continue; descsCache.add(key); // realm own descriptors take precedence over host descriptors @@ -273,7 +387,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (shouldFnBind(desc.value)) { overriddenDescs[key] = materializeDescriptor(desc, hostWindow); descsCache.add(key); - } else if (!(key in initOwnDescs) && !Object.hasOwn(realmGlobal, key) && !protoBaseDescs[key]) { + } else if (!(key in initOwnDescs) && !Native.objectHasOwn(realmGlobal, key) && !protoBaseDescs[key]) { protoBaseDescs[key] = materializeDescriptor(desc, hostWindow); } return; @@ -298,43 +412,22 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn // + 覆盖定义 (document, location, setTimeout, setInterval, addEventListener 等) // sharedInitCopy: ScriptCat脚本共通使用 - // PseudoWindow 没有真实 Window.prototype,因此祖先成员必须先手动复制到 sandbox own descriptors。 - const USE_PSEUDO_WINDOW = true; // 日后或能设置使 ScriptCat的沙盒 window 能以 name / id 存取页面元素 - - class PseudoWindow {} - const PseudoWindowPrototype = PseudoWindow.prototype; - Object.defineProperty(PseudoWindowPrototype, Symbol.toStringTag, { - //@ts-ignore - value: hostWindow[Symbol.toStringTag], - writable: false, - enumerable: false, - configurable: true, + // sandbox 的实际原型固定为 null(兼容 TM 沙盒),因此 toString.call/constructor/__proto__ + // 这三个 Window 兼容语义改为直接作为 own descriptor 构造,而不是先建一个临时 class 的 + // prototype 再把它的 descriptors 摊平进来。 + // TS 把字面量 "constructor" 的下标存取解析成 Object 内建的 Function 型别成员, + // 因此用一个非字面量的 string 变量绕开,实际仍是普通的下标赋值。 + const constructorKey: string = "constructor"; + pseudoWindowDescs[constructorKey] = readonlyCompatDescriptor(hostWindow.constructor); + pseudoWindowDescs["__proto__"] = readonlyCompatDescriptor(Native.objectGetPrototypeOf(hostWindow)); + pseudoWindowDescs[Symbol.toStringTag] = readonlyCompatDescriptor(hostWindow[Symbol.toStringTag]); + + const sharedInitCopy = Native.objectCreate(null, { + ...protoBaseDescs, // 较快的 @unwrap 注入时有机会改变 EventTarget.prototype + ...pseudoWindowDescs, + ...initOwnDescs, + ...overriddenDescs, }); - Object.defineProperty(PseudoWindowPrototype, "constructor", { - value: hostWindow.constructor, - writable: false, - enumerable: false, - configurable: true, - }); - Object.defineProperty(PseudoWindowPrototype, "__proto__", { - //@ts-ignore - value: hostWindow.__proto__, - writable: false, - enumerable: false, - configurable: true, - }); - - const sharedInitCopy = USE_PSEUDO_WINDOW - ? Object.create(null, { - ...protoBaseDescs, // 较快的 @unwrap 注入时有机会改变 EventTarget.prototype - ...Object.getOwnPropertyDescriptors(PseudoWindowPrototype), - ...initOwnDescs, - ...overriddenDescs, - }) - : Object.create(Object.getPrototypeOf(realmGlobal), { - ...initOwnDescs, - ...overriddenDescs, - }); return { sharedInitCopy, eventKeys }; }; @@ -342,8 +435,8 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn const defaultGlobalSnapshot = createGlobalSnapshot({ realmGlobal: global, hostWindow: window }); // 把沙盒的 console 和网页的 console 隔离 -const initConsoleDescs = Object.getOwnPropertyDescriptors(console); -const ConsolePrototype = Object.getPrototypeOf(console); +const initConsoleDescs = Native.objectGetOwnPropertyDescriptors(console); +const ConsolePrototype = Native.objectGetPrototypeOf(console); type GMWorldContext = typeof globalThis & Record; @@ -359,12 +452,16 @@ export const createProxyContext = ( const { sharedInitCopy, eventKeys } = roots.realmGlobal === global && roots.hostWindow === window ? defaultGlobalSnapshot : createGlobalSnapshot(roots); - const ownDescs = Object.getOwnPropertyDescriptors(sharedInitCopy); + // Descriptor maps receive page-controlled event names, so keep lookups and writes out of Object.prototype. + const ownDescs = Native.objectAssign( + Native.objectCreate(null), + Native.objectGetOwnPropertyDescriptors(sharedInitCopy) + ) as Record; // mySandbox: ScriptCat各脚本独自使用 let mySandbox: typeof sharedInitCopy | undefined = undefined; - const hostAddEventListener = roots.hostWindow.addEventListener.bind(roots.hostWindow); - const hostRemoveEventListener = roots.hostWindow.removeEventListener.bind(roots.hostWindow); + const hostAddEventListener = Native.bind(roots.hostWindow.addEventListener, roots.hostWindow); + const hostRemoveEventListener = Native.bind(roots.hostWindow.removeEventListener, roots.hostWindow); // 用 eventHandling 机制模拟 onxxxxxxx 事件设置 // 监听事件实际上的方法是eventObject.handleEvent @@ -379,7 +476,7 @@ export const createProxyContext = ( hostRemoveEventListener(eventName, eventObject); this.fn = null; } else { - fn.call(mySandbox, event); + nativeCall(fn, mySandbox, event); } }, }; @@ -411,16 +508,22 @@ export const createProxyContext = ( }; }; - for (const key of eventKeys) { + eventKeys.forEach((key) => { const eventSetterGetter = createEventProp(key); + const ownDescriptor = Native.objectGetOwnPropertyDescriptor(ownDescs, key)?.value as PropertyDescriptor | undefined; + // ownDescs 是 Native.objectCreate(null) 建出的纯字典,这里写入的每个 key 也都是普通可写 + // 数据属性(由前面的 objectAssign 建立),不存在继承 setter 的风险,直接赋值即可等价于 + // defineProperty 显式声明的 configurable/enumerable/writable:true。 ownDescs[key] = { - ...ownDescs[key], + ...ownDescriptor, ...eventSetterGetter, }; - } + }); // split realm 下 hostWindow 可能经由 realmGlobal.window 暴露;这些别名必须始终留在当前 sandbox 内。 - for (const key of ["window", "self", "globalThis"]) { + const sandboxAliases = ["window", "self", "globalThis"]; + for (let i = 0; i < sandboxAliases.length; i += 1) { + const key = sandboxAliases[i]; ownDescs[key] = { configurable: true, enumerable: true, @@ -429,9 +532,11 @@ export const createProxyContext = ( }, }; } - for (const key of ["top", "parent", "frames"]) { + const windowAliases = ["top", "parent", "frames"]; + for (let i = 0; i < windowAliases.length; i += 1) { + const key = windowAliases[i]; const descriptor = ownDescs[key]; - const hostValue = Reflect.get(roots.hostWindow, key, roots.hostWindow); + const hostValue = Native.reflectGet(roots.hostWindow, key, roots.hostWindow); if (hostValue === undefined && !descriptor) continue; ownDescs[key] = { @@ -439,7 +544,7 @@ export const createProxyContext = ( configurable: true, enumerable: descriptor?.enumerable ?? true, get() { - const value = Reflect.get(roots.hostWindow, key, roots.hostWindow); + const value = Native.reflectGet(roots.hostWindow, key, roots.hostWindow); return value === roots.hostWindow || value === roots.realmGlobal ? mySandbox : value; }, set: undefined, @@ -472,20 +577,20 @@ export const createProxyContext = ( get() { return currentValue; }, - set(nv) { - if (typeof nv !== "function") nv = null; - currentValue = nv; + set(nv: unknown) { + currentValue = typeof nv === "function" ? (nv as (this: GlobalEventHandlers, ev: UrlChangeEvent) => any) : null; return true; }, }; } - // 把初始Copy加上特殊变量后,生成一份新Copy - mySandbox = Object.create(Object.getPrototypeOf(sharedInitCopy), ownDescs) as typeof globalThis & - Record; + // 把初始Copy加上特殊变量后,生成一份新Copy;sandbox 的实际原型固定为 null(兼容 TM 沙盒)。 + mySandbox = Native.objectCreate(null, ownDescs) as typeof globalThis & Record; // 处理特殊关键字,不能穿越出沙盒,也不能被外部修改 - for (const key of ["define", "module", "exports"]) { + const moduleKeys = ["define", "module", "exports"]; + for (let i = 0; i < moduleKeys.length; i += 1) { + const key = moduleKeys[i]; mySandbox[key] = undefined; } @@ -493,8 +598,10 @@ export const createProxyContext = ( // 把 GM Api (或其他全域API) 复制到 脚本window // 请手动检查避开key,防止与window的属性setter有冲突 或 属性名重复 - for (const key of Object.keys(context)) { - if (key in protect || key === "window") continue; + const contextKeys = Native.objectKeys(context); + for (let i = 0; i < contextKeys.length; i += 1) { + const key = contextKeys[i]; + if (isInternalContextKey(key) || key === "window") continue; mySandbox[key] = context[key]; // window以外 } @@ -517,11 +624,11 @@ export const createProxyContext = ( const handle = function (this: Window & Record, e: UrlChangeEvent) { this.onurlchange?.(e); } as EventListener; - (roots.hostWindow).addEventListener("urlchange", handle.bind(mySandbox), false); + (roots.hostWindow).addEventListener("urlchange", Native.bind(handle, mySandbox), false); } // 从网页 console 隔离出来的沙盒 console - mySandbox.console = Object.create(ConsolePrototype, initConsoleDescs); + mySandbox.console = Native.objectCreate(ConsolePrototype, initConsoleDescs); return mySandbox; }; diff --git a/src/app/service/content/exec_script.test.ts b/src/app/service/content/exec_script.test.ts index 528999b35..2899ba4d1 100644 --- a/src/app/service/content/exec_script.test.ts +++ b/src/app/service/content/exec_script.test.ts @@ -32,7 +32,7 @@ function makeScript(overrides: Partial = {}): ScriptLoadInfo { function setExecCode(exec: ExecScript, script: ScriptLoadInfo, code: string): void { script.code = code; - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); } function makeExec(code: string, grant?: string[]): { exec: ExecScript; script: ScriptLoadInfo } { @@ -68,6 +68,22 @@ describe.concurrent("GM_info", () => { expect(ret.GM_info.script.version).toEqual("1.0.0"); expect(ret._this).not.toEqual(global); }); + + it.concurrent("does not resolve a mutable script function call property", async () => { + const { exec } = makeExec("return this;"); + const scriptFunc = function (_token: string, context: unknown) { + return context; + } as ScriptFunc & { call?: unknown }; + Object.defineProperty(scriptFunc, "call", { + configurable: true, + value: () => { + throw new Error("poisoned call"); + }, + }); + exec.scriptFunc = scriptFunc; + + expect(await exec.exec()).toBe(exec.execContext); + }); }); describe.concurrent("unsafeWindow", () => { @@ -566,3 +582,81 @@ return [str.match(reg), RegExp.$1];`); expect(ret2.onblur).toBeNull(); }); }); + +describe("getEffectiveScriptGrants consumer (P1-2)", () => { + it("context-menu + grant none builds a sandbox with GM_registerMenuCommand instead of the bare GM.info branch", () => { + const script = makeScript({ + metadata: { grant: ["none"], "run-at": ["context-menu"], version: ["1.0.0"] }, + }); + const message = {} as Message; + const exec = new ExecScript(script, { + envPrefix: "scripting", + message, + contentMsg: message, + code: nilFn, + envInfo, + }); + + // 只有当 effective grants 内含 GM_registerMenuCommand 且不再是纯 "none" 时才会走 sandboxContext 分支。 + expect(exec.sandboxContext).not.toBeUndefined(); + expect(exec.named).toBeUndefined(); + }); +}); + +describe("globalInjection", () => { + it("接受合法的注入键,并投影到脚本沙盒", () => { + const script = makeScript({ metadata: { grant: ["GM_getValue"], version: ["1.0.0"] } }); + const message = {} as Message; + const exec = new ExecScript(script, { + envPrefix: "scripting", + message, + contentMsg: message, + code: nilFn, + envInfo, + globalInjection: { customGlobal: () => "injected" }, + }); + + expect(exec.sandboxContext).not.toBeUndefined(); + expect((exec.sandboxContext as unknown as { customGlobal: () => string }).customGlobal()).toBe("injected"); + + exec.exec(); + expect((exec.execContext as { customGlobal: () => string }).customGlobal()).toBe("injected"); + }); + + it("撞上已存在的内部生命周期键时立即抛出 TypeError,而不是静默跳过", () => { + const script = makeScript({ metadata: { grant: ["GM_getValue"], version: ["1.0.0"] } }); + const message = {} as Message; + + expect( + () => + new ExecScript(script, { + envPrefix: "scripting", + message, + contentMsg: message, + code: nilFn, + envInfo, + globalInjection: { setInvalidContext: () => undefined }, + }) + ).toThrow(TypeError); + }); + + it("使用 protect 登记但当前不在 facade 上的键时不抛错(保留原 Object.assign 行为)", () => { + // message/scriptRes/runFlag 等是 GM_Base 的 @protected 成员,但从不出现在 + // createContext() 返回的 publicContext 自身键上;沿用旧 Object.assign 语义, + // 不能把这类不存在的键也当成"内部键碰撞"而新增拒绝。 + const script = makeScript({ metadata: { grant: ["GM_getValue"], version: ["1.0.0"] } }); + const message = {} as Message; + + expect( + () => + new ExecScript(script, { + envPrefix: "scripting", + message, + contentMsg: message, + code: nilFn, + envInfo, + globalInjection: { message: "not-a-real-message-object" }, + }) + ).not.toThrow(); + }); +}); diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index 6c5b93730..b1a15a7a0 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -1,13 +1,16 @@ import LoggerCore from "@App/app/logger/core"; import type Logger from "@App/app/logger/logger"; -import { createContext, createProxyContext } from "./create_context"; +import { createContext, createProxyContext, isInternalContextKey, type ScriptContext } from "./create_context"; import type { GMInfoEnv, ScriptFunc } from "./types"; -import { compileScript, isContextMenuScript } from "./utils"; +import { compileScript, getEffectiveScriptGrants, isContextMenuScript } from "./utils"; import type { Message } from "@Packages/message/types"; import type { ValueUpdateDataEncoded } from "./types"; import { evaluateGMInfo } from "./gm_api/gm_info"; -import type { IGM_Base } from "./gm_api/gm_api"; import type { TScriptInfo } from "@App/app/repo/scripts"; +import { installTrustedDataPropertiesStrict, Native, nativeCall, refreshExposedDataProperties } from "./global"; + +// 编译函数只在收到本次构建的密钥时执行,避免页面直接复用包装器。 +const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; // 执行脚本,控制脚本执行与停止 export default class ExecScript { @@ -19,7 +22,7 @@ export default class ExecScript { // proxyContext: typeof globalThis; - sandboxContext?: IGM_Base & { [key: string]: any }; + sandboxContext?: ScriptContext; named?: { [key: string]: any }; @@ -44,27 +47,39 @@ export default class ExecScript { const GM_info = evaluateGMInfo(envInfo, scriptRes); // 构建脚本资源 if (typeof code === "string") { - this.scriptFunc = compileScript(code); + this.scriptFunc = compileScript(code, true); } else { this.scriptFunc = code; } - const grantSet = new Set(scriptRes.metadata.grant || []); - if (isContextMenuScript(scriptRes.metadata)) { - grantSet.add("GM_registerMenuCommand"); - grantSet.delete("none"); - } + const grantSet = new Native.Set(getEffectiveScriptGrants(scriptRes.metadata)); if (grantSet.has("none")) { // 不注入任何GM api // ScriptCat行为:GM.info 和 GM_info 同时注入 // 在不改变 Context 的情况下,以 named 传入多个全域变量 - const GM = Object.create(null); + const GM = Native.objectCreate(null); GM.info = GM_info; this.named = { GM, GM_info }; } else { // 构建脚本GM上下文 - this.sandboxContext = createContext(scriptRes, GM_info, envPrefix, message, contentMsg, grantSet); + const sandboxContext = (this.sandboxContext = createContext( + scriptRes, + GM_info, + envPrefix, + message, + contentMsg, + grantSet + )); if (globalInjection) { - Object.assign(this.sandboxContext, globalInjection); + // 可信扩展代码提供的 key 一般不会撞上内部生命周期键;一旦撞上说明调用方有 bug, + // 应立即失败而不是静默跳过——因此只在真正冲突时才拒绝,其余按原行为直接写入。 + const keys = Native.objectKeys(globalInjection); + for (let i = 0; i < keys.length; i += 1) { + const key = keys[i]; + if (isInternalContextKey(key) && Native.objectHasOwn(sandboxContext, key)) { + throw new TypeError(`globalInjection cannot overwrite internal context key: ${key}`); + } + sandboxContext[key] = globalInjection[key]; + } } } } @@ -88,22 +103,84 @@ export default class ExecScript { this.logger.debug("script start"); const sandboxContext = this.sandboxContext; this.execContext = sandboxContext ? createProxyContext(sandboxContext) : global; // this.$ 只能执行一次 - return this.scriptFunc.call(this.execContext, this.named, this.scriptRes.name); + return this.scriptFunc(fnStrIntegrity, this.execContext, this.named, this.scriptRes.name, nativeCall); }; - // 早期启动的脚本,处理GM API - updateEarlyScriptGMInfo(envInfo: GMInfoEnv) { - let GM_info; + reconcileEarlyScript(envInfo: GMInfoEnv, scriptInfo?: TScriptInfo): boolean { + const current = this.scriptRes; + const grants = current.metadata.grant || []; + const incomingGrants = scriptInfo?.metadata.grant || []; + const needsBinding = + isContextMenuScript(current.metadata) || + isContextMenuScript(scriptInfo?.metadata || {}) || + grants.some((grant) => grant !== "none") || + incomingGrants.some((grant) => grant !== "none"); + const hasBindingData = + scriptInfo?.executionHandle !== undefined || + scriptInfo?.executionEnvTag !== undefined || + scriptInfo?.executionRunFlag !== undefined; + const hasValidBinding = + typeof scriptInfo?.executionHandle === "string" && + scriptInfo.executionHandle.length > 0 && + (scriptInfo.executionEnvTag === "it" || scriptInfo.executionEnvTag === "ct") && + typeof scriptInfo.executionRunFlag === "string" && + scriptInfo.executionRunFlag.length > 0; + + if ( + !scriptInfo || + scriptInfo.uuid !== current.uuid || + scriptInfo.flag !== current.flag || + typeof current.scriptRevision !== "string" || + scriptInfo.scriptRevision !== current.scriptRevision || + (hasBindingData && !hasValidBinding) || + (needsBinding && !hasValidBinding) + ) { + this.sandboxContext?.setInvalidContext(); + return false; + } + + // Snapshot keys the userscript synchronously changed while privileged transport was still + // waiting for the authoritative page binding. Untouched keys should refresh from pageLoad, + // but explicit local read/modify/write operations must not be rolled back in between. + const pendingValueKeys = this.sandboxContext?.takePendingEarlyValueKeys(); + const pendingValueOverrides = new Native.Map(); + if (pendingValueKeys) { + pendingValueKeys.forEach((key) => { + const valueStore = current.value; + const hasValue = Native.objectHasOwn(valueStore, key); + pendingValueOverrides.set(key, [hasValue, hasValue ? valueStore[key] : undefined]); + }); + } + + // current 是内部可信状态(this.scriptRes):任何无法安全重定义的既有属性都说明契约被破坏, + // 直接失败,绝不调用继承的 setter 或触发 "__proto__" 的原型变更语义。 + installTrustedDataPropertiesStrict(current, scriptInfo); + + pendingValueOverrides.forEach((entry, key) => { + if (!entry[0]) { + if (Native.objectHasOwn(current.value, key)) delete current.value[key]; + return; + } + const descriptor = Native.objectCreate(null) as PropertyDescriptor; + descriptor.configurable = true; + descriptor.enumerable = true; + descriptor.writable = true; + descriptor.value = entry[1]; + Native.objectDefineProperty(current.value, key, descriptor); + }); + + const updatedGMInfo = evaluateGMInfo(envInfo, current); + const gmInfo = this.sandboxContext ? this.execContext["GM_info"] : this.named?.GM_info; + // gmInfo 是暴露给脚本的信息面:脚本可能已经在某个字段上安装了 non-configurable setter 来 + // "锁死"它,这是脚本对自己信息面的合法操作,不能因此阻断内部权威状态的刷新——遇到这种字段 + // 时跳过它,继续刷新其余字段,绝不调用该 setter。 + if (gmInfo) refreshExposedDataProperties(gmInfo, updatedGMInfo); + if (this.sandboxContext) { - // 触发loadScriptResolve - this.sandboxContext["loadScriptResolve"]?.(); - GM_info = this.execContext["GM_info"]; - } else { - GM_info = this.named?.GM_info; + if (hasValidBinding) this.sandboxContext.setExecutionRunFlag(scriptInfo.executionRunFlag!); + this.sandboxContext.resolveLoadScript(); } - GM_info.isIncognito = envInfo.isIncognito; - GM_info.sandboxMode = envInfo.sandboxMode; - GM_info.userAgentData = envInfo.userAgentData; + return true; } stop() { diff --git a/src/app/service/content/external.ts b/src/app/service/content/external.ts index 2ff7aa223..8aa255266 100644 --- a/src/app/service/content/external.ts +++ b/src/app/service/content/external.ts @@ -14,10 +14,12 @@ const isExternalWhitelisted = (hostname: string) => { }; // 生成暴露给页面的 Scriptcat 外部接口 -const createScriptcatExpose = (msg: Message) => { +const createScriptcatExpose = (msg: Message, messagePrefix: string) => { const scriptExpose: App.ExternalScriptCat = { isInstalled(name: string, namespace: string, callback: (res: App.IsInstalledResponse | undefined) => unknown) { - sendMessage(msg, "scripting/script/isInstalled", { name, namespace }).then(callback); + sendMessage(msg, `${messagePrefix}/script/isInstalled`, { name, namespace }).then( + callback + ); }, }; return scriptExpose; @@ -63,7 +65,7 @@ const patchTampermonkeyIsInstalled = (external: any, scriptExpose: App.ExternalS }; // inject 环境 pageLoad 后执行:按白名单对页面注入 external 接口 -export const onInjectPageLoaded = (msg: Message) => { +export const onInjectPageLoaded = (msg: Message, messagePrefix = "scripting") => { const hostname = window.location.hostname; // 不在白名单则不对外暴露接口 @@ -73,7 +75,7 @@ export const onInjectPageLoaded = (msg: Message) => { const external: External = (window.external || (window.external = {} as External)) as External; // 创建 Scriptcat 暴露对象 - const scriptExpose = createScriptcatExpose(msg); + const scriptExpose = createScriptcatExpose(msg, messagePrefix); // 尝试设置 external.Scriptcat safeSetExternal(external, "Scriptcat", scriptExpose); diff --git a/src/app/service/content/global.test.ts b/src/app/service/content/global.test.ts new file mode 100644 index 000000000..359766e52 --- /dev/null +++ b/src/app/service/content/global.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect } from "vitest"; +import { installTrustedDataPropertiesStrict, Native, nativeBind, refreshExposedDataProperties } from "./global"; + +describe("Native.bind", () => { + it("delivers the receiver correctly under normal conditions", () => { + const target = { + value: 42, + read(this: { value: number }) { + return this.value; + }, + }; + const bound = nativeBind(target.read, target); + expect(bound()).toBe(42); + }); + + it("is not influenced by a hostile Array.prototype[Symbol.iterator]", () => { + // 页面可替换 Array 迭代器影响基于 spread/apply 的绑定实现; + // Native.bind 必须完全不依赖被绑定函数参数列表的迭代行为。 + const originalIterator = Array.prototype[Symbol.iterator]; + let hostileIteratorInvoked = false; + let result: number | undefined; + try { + Array.prototype[Symbol.iterator] = () => { + hostileIteratorInvoked = true; + throw new Error("hostile iterator invoked"); + }; + + const target = { + value: 7, + read(this: { value: number }) { + return this.value; + }, + }; + const bound = Native.bind(target.read, target); + result = bound(); + } finally { + Array.prototype[Symbol.iterator] = originalIterator; + } + + expect(hostileIteratorInvoked).toBe(false); + expect(result).toBe(7); + }); +}); + +describe("installTrustedDataPropertiesStrict / refreshExposedDataProperties", () => { + it("does not invoke a source getter", () => { + let getterCalls = 0; + const source: Record = {}; + Object.defineProperty(source, "x", { + enumerable: true, + get() { + getterCalls += 1; + return 123; + }, + }); + + expect(() => installTrustedDataPropertiesStrict({}, source)).toThrow(); + expect(getterCalls).toBe(0); + }); + + it("rejects a source accessor for both the strict and skip-on-blocked target policies", () => { + const source: Record = {}; + Object.defineProperty(source, "x", { enumerable: true, get: () => 1 }); + + expect(() => installTrustedDataPropertiesStrict({}, source)).toThrow(); + expect(() => refreshExposedDataProperties({}, source)).toThrow(); + }); + + it("transfers every enumerable own data property, including falsy values", () => { + const target: Record = {}; + installTrustedDataPropertiesStrict(target, { a: 1, b: undefined, c: null, d: false, e: 0, f: "" }); + + expect(target).toEqual({ a: 1, b: undefined, c: null, d: false, e: 0, f: "" }); + expect(Object.hasOwn(target, "b")).toBe(true); + }); + + it("ignores non-enumerable source properties, matching Object.assign", () => { + const source: Record = {}; + Object.defineProperty(source, "hidden", { value: 1, enumerable: false }); + Object.defineProperty(source, "visible", { value: 2, enumerable: true }); + + const target: Record = {}; + installTrustedDataPropertiesStrict(target, source); + + expect(Object.hasOwn(target, "hidden")).toBe(false); + expect(target.visible).toBe(2); + }); + + it("skips symbol-keyed source properties (string-keyed-only scope, matching copyOwnEnumerableDataProperties)", () => { + const sym = Symbol("s"); + const source = { a: 1, [sym]: "symbol value" }; + const target: Record = {}; + installTrustedDataPropertiesStrict(target, source); + + expect(target).toEqual({ a: 1 }); + expect(Object.hasOwn(target, sym)).toBe(false); + }); + + it("updates only the value of an existing writable non-configurable target data property, preserving its flags", () => { + const target: Record = {}; + Object.defineProperty(target, "x", { configurable: false, enumerable: true, writable: true, value: 1 }); + + installTrustedDataPropertiesStrict(target, { x: 2 }); + + const descriptor = Object.getOwnPropertyDescriptor(target, "x")!; + expect(descriptor).toMatchObject({ value: 2, configurable: false, enumerable: true, writable: true }); + }); + + it("installs an own '__proto__' data property without mutating the target's prototype", () => { + const source: Record = {}; + Object.defineProperty(source, "__proto__", { enumerable: true, configurable: true, value: { forged: true } }); + const target: Record = {}; + const originalPrototype = Object.getPrototypeOf(target); + + installTrustedDataPropertiesStrict(target, source); + + expect(Object.getPrototypeOf(target)).toBe(originalPrototype); + expect(Object.hasOwn(target, "__proto__")).toBe(true); + expect((target as any).__proto__).toEqual({ forged: true }); + }); + + it("strict policy throws on a non-configurable target accessor and never invokes its setter", () => { + let setterCalls = 0; + const target: Record = {}; + Object.defineProperty(target, "x", { + configurable: false, + enumerable: true, + get: () => 1, + set: () => { + setterCalls += 1; + }, + }); + + expect(() => installTrustedDataPropertiesStrict(target, { x: 2 })).toThrow(); + expect(setterCalls).toBe(0); + }); + + it("skip policy silently skips a non-configurable target accessor, never invokes its setter, and still installs the remaining fields", () => { + let setterCalls = 0; + const target: Record = {}; + Object.defineProperty(target, "locked", { + configurable: false, + enumerable: true, + get: () => "script-locked", + set: () => { + setterCalls += 1; + }, + }); + + refreshExposedDataProperties(target, { locked: "authoritative", open: "authoritative" }); + + expect(setterCalls).toBe(0); + expect(target.locked).toBe("script-locked"); + expect(target.open).toBe("authoritative"); + }); + + it("both policies replace a configurable target accessor with an ordinary data property instead of invoking its setter", () => { + for (const install of [installTrustedDataPropertiesStrict, refreshExposedDataProperties]) { + let setterCalls = 0; + const target: Record = {}; + Object.defineProperty(target, "x", { + configurable: true, + enumerable: true, + get: () => "old", + set: () => { + setterCalls += 1; + }, + }); + + install(target, { x: "new" }); + + expect(setterCalls).toBe(0); + const descriptor = Object.getOwnPropertyDescriptor(target, "x")!; + expect(descriptor).toMatchObject({ value: "new", configurable: true, enumerable: true, writable: true }); + } + }); + + it("installs properties correctly even when Object.prototype.get is poisoned (descriptor literals must not inherit an accessor)", () => { + // Object.defineProperty(target, key, descriptor) 的 descriptor 参数本身是普通对象,会继承 + // Object.prototype;若用对象字面量 {value} 之类构造它,页面预先在 Object.prototype 上放置的 + // get/set 会让这份字面量"看起来"同时具备 own value 和继承来的 accessor,导致 defineProperty + // 抛 "Cannot both specify accessors and a value or writable attribute"。安装原语内部必须用 + // null 原型对象构造 descriptor,而不是对象字面量。 + // 断言必须在 finally 恢复 Object.prototype.get 之后才执行:vitest 自身的 expect() 机制在 + // 求值期间也可能构造 descriptor 风格的对象,在 Object.prototype 被污染时提前断言会被 + // 测试框架自身的内部实现(而不是被测代码)触发同一个 TypeError,制造假阳性。 + const originalDescriptor = Object.getOwnPropertyDescriptor(Object.prototype, "get"); + let freshThrew: unknown; + let existingThrew: unknown; + const freshTarget: Record = {}; + const existingTarget: Record = { a: 1 }; + try { + Object.defineProperty(Object.prototype, "get", { configurable: true, value: () => undefined }); + + // 场景一:target 尚无 own key(走"整体重新定义"分支)。 + try { + installTrustedDataPropertiesStrict(freshTarget, { a: 1 }); + } catch (e) { + freshThrew = e; + } + + // 场景二:target 已有可写 own data property(走"只替换 value"分支)。 + try { + installTrustedDataPropertiesStrict(existingTarget, { a: 2 }); + } catch (e) { + existingThrew = e; + } + } finally { + if (originalDescriptor) { + Object.defineProperty(Object.prototype, "get", originalDescriptor); + } else { + delete (Object.prototype as any).get; + } + } + + expect(freshThrew).toBeUndefined(); + expect(freshTarget.a).toBe(1); + expect(existingThrew).toBeUndefined(); + expect(existingTarget.a).toBe(2); + }); +}); diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 3b78911ff..24e2ce740 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -1,36 +1,195 @@ // 避免在全局页面环境中,内置处理函数被篡改或重写 -const unsupportedAPI = () => { - throw "unsupportedAPI"; + +// 在页面或用户脚本替换调用内建函数前完成捕获。 +export const nativeReflectApply = Reflect.apply; +const nativeFunctionBind = Function.prototype.bind; +// Wrapper inspection runs after page scripts can replace both values. +const nativeFunctionToString = Function.prototype.toString; +const nativeDocument = typeof document === "undefined" ? undefined : document; +const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; +const nativeSetConstructor = Set; +const nativeSetAdd = Set.prototype.add; +const nativeSetHas = Set.prototype.has; +const nativeSetDelete = Set.prototype.delete; +const nativeSetClear = Set.prototype.clear; +const nativeSetForEach = Set.prototype.forEach; +const nativeSetValues = Set.prototype.values; +const nativeArrayIsArray = Array.isArray; +const nativeMapConstructor = Map; +const nativeMapGet = Map.prototype.get; +const nativeMapSet = Map.prototype.set; +const nativeMapHas = Map.prototype.has; +const nativeMapDelete = Map.prototype.delete; +const nativeMapClear = Map.prototype.clear; +const nativeMapForEach = Map.prototype.forEach; +const nativeWeakMapConstructor = WeakMap; +const nativeWeakMapGet = WeakMap.prototype.get; +const nativeWeakMapSet = WeakMap.prototype.set; +const nativeWeakMapHas = WeakMap.prototype.has; +const nativeWeakMapDelete = WeakMap.prototype.delete; +const nativeObjectFreeze = Object.freeze; +const nativeReflectOwnKeys = Reflect.ownKeys; +const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const nativeDocumentCreateElement = typeof Document === "undefined" ? undefined : Document.prototype.createElement; +const nativeOwnFragment = typeof DocumentFragment === "undefined" ? undefined : new DocumentFragment(); + +// Keep the captured methods on private subclasses. Instances can then be created +// without reassigning every method, while the subclass prototypes remain outside +// the page's mutable built-in prototypes. +const NativeSetConstructor = class extends nativeSetConstructor { + constructor(values?: readonly T[] | Set | null) { + // 不把 values 传给 Set 构造器:它会读取 values 的 @@iterator,而页面可改写该方法。 + super(); + if (nativeArrayIsArray(values)) { + for (let i = 0; i < values.length; i += 1) this.add(values[i]); + } else if (values) { + nativeReflectApply(nativeSetForEach, values, [(value: T) => this.add(value)]); + } + } }; +NativeSetConstructor.prototype.add = nativeSetAdd; +NativeSetConstructor.prototype.has = nativeSetHas; +NativeSetConstructor.prototype.delete = nativeSetDelete; +NativeSetConstructor.prototype.clear = nativeSetClear; +NativeSetConstructor.prototype.forEach = nativeSetForEach; +NativeSetConstructor.prototype.values = nativeSetValues; +nativeObjectFreeze(NativeSetConstructor.prototype); + +const NativeMapConstructor = class extends nativeMapConstructor {}; +NativeMapConstructor.prototype.get = nativeMapGet; +NativeMapConstructor.prototype.set = nativeMapSet; +NativeMapConstructor.prototype.has = nativeMapHas; +NativeMapConstructor.prototype.delete = nativeMapDelete; +NativeMapConstructor.prototype.clear = nativeMapClear; +NativeMapConstructor.prototype.forEach = nativeMapForEach; +nativeObjectFreeze(NativeMapConstructor.prototype); + +const NativeWeakMapConstructor = class extends nativeWeakMapConstructor {}; +NativeWeakMapConstructor.prototype.get = nativeWeakMapGet; +NativeWeakMapConstructor.prototype.set = nativeWeakMapSet; +NativeWeakMapConstructor.prototype.has = nativeWeakMapHas; +NativeWeakMapConstructor.prototype.delete = nativeWeakMapDelete; +nativeObjectFreeze(NativeWeakMapConstructor.prototype); + +const nativeFunctionApply = nativeReflectApply(nativeFunctionBind, Function.prototype.apply, [ + Function.prototype.apply, +]) as (fn: (...args: any[]) => any, receiver: any, args: any[]) => any; +const nativeFunctionCall = nativeReflectApply(nativeFunctionBind, Function.prototype.call, [ + Function.prototype.call, +]) as (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => any; + +export const nativeApply = nativeFunctionApply; +export const nativeCall = nativeFunctionCall; +// Reflect.apply 把 [receiver] 当作 array-like 消费,不读取其 @@iterator, +// 因此页面篡改 Array.prototype[Symbol.iterator] 不会影响绑定过程。 +export const nativeBind = (fn: (...args: any[]) => any, receiver: any) => + nativeReflectApply(nativeFunctionBind, fn, [receiver]); export const Native = { - structuredClone: typeof structuredClone === "function" ? structuredClone : unsupportedAPI, - jsonStringify: JSON.stringify.bind(JSON), - jsonParse: JSON.parse.bind(JSON), - createElement: Document.prototype.createElement, - ownFragment: new DocumentFragment(), - objectCreate: Object.create.bind(Object), - objectGetOwnPropertyDescriptors: Object.getOwnPropertyDescriptors.bind(Object), - objectGetOwnPropertyDescriptor: Object.getOwnPropertyDescriptor.bind(Object), - objectGetPrototypeOf: Object.getPrototypeOf.bind(Object), + Set: NativeSetConstructor, + Map: NativeMapConstructor, + WeakMap: NativeWeakMapConstructor, + bind: nativeBind, + reflectApply: nativeReflectApply, + functionToString: (fn: object) => nativeReflectApply(nativeFunctionToString, fn, []) as string, + document: nativeDocument, + structuredClone: nativeStructuredClone, + jsonStringify: nativeBind(JSON.stringify, JSON), + jsonParse: nativeBind(JSON.parse, JSON), + createElement: nativeDocumentCreateElement, + ownFragment: nativeOwnFragment, + objectCreate: nativeBind(Object.create, Object), + objectAssign: nativeBind(Object.assign, Object), + arrayIsArray: nativeArrayIsArray, + objectKeys: nativeBind(Object.keys, Object), + objectHasOwn: nativeBind(Object.hasOwn, Object), + objectDefineProperty: nativeBind(Object.defineProperty, Object), + objectGetOwnPropertyDescriptors: nativeBind(Object.getOwnPropertyDescriptors, Object), + objectGetOwnPropertyDescriptor: nativeBind(Object.getOwnPropertyDescriptor, Object), + objectGetPrototypeOf: nativeBind(Object.getPrototypeOf, Object), + reflectOwnKeys: nativeBind(Reflect.ownKeys, Reflect), + reflectGet: nativeBind(Reflect.get, Reflect), } as const; export const customClone = (o: any) => { - // 非对象类型直接返回(包含 Symbol、undefined、基本类型等) + // 非对象类型直接返回(包含 undefined、基本类型等);函数和 Symbol 不可跨边界传输。 // 接受参数:阵列、物件、null - if (typeof o !== "object") return o; + if (o === null || typeof o !== "object") { + return typeof o === "function" || typeof o === "symbol" ? undefined : o; + } - try { - // 优先使用 structuredClone,支持大多数可克隆对象 - return Native.structuredClone(o); - } catch { - // 例如:被 Proxy 包装的对象(如 Vue 等框架处理过的 reactive 对象) - // structuredClone 可能会失败,忽略错误继续尝试其他方式 + // 先验证自有字段都是数据描述符,避免 JSON fallback 执行页面 getter 或 Proxy trap。 + const seen = new Native.WeakMap(); + const isDataOnly = (value: object): boolean => { + if (seen.has(value)) return true; + seen.set(value, true); + + // Map/Set 条目不在自有属性中,必须先检查,避免 structuredClone 遍历时触发嵌套访问器。 + try { + let valid = true; + nativeReflectApply(nativeMapForEach, value as Map, [ + (key: unknown, entry: unknown) => { + if (valid && (!isDataOnlyValue(key) || !isDataOnlyValue(entry))) valid = false; + }, + ]); + return valid; + } catch { + // 不是 Map,继续检查普通自有属性。 + } + try { + let valid = true; + nativeReflectApply(nativeSetForEach, value as Set, [ + (entry: unknown) => { + if (valid && !isDataOnlyValue(entry)) valid = false; + }, + ]); + return valid; + } catch { + // 不是 Set,继续检查普通自有属性。 + } + + let keys: PropertyKey[]; + try { + keys = nativeReflectOwnKeys(value); + } catch { + return false; + } + for (const key of keys) { + if (typeof key === "symbol") return false; + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + } catch { + return false; + } + if (!descriptor || !("value" in descriptor)) return false; + if ( + typeof descriptor.value === "function" || + (descriptor.value !== null && typeof descriptor.value === "object" && !isDataOnly(descriptor.value)) + ) { + return false; + } + } + return true; + }; + const isDataOnlyValue = (value: unknown): boolean => { + if (value === null || typeof value !== "object") return true; + return isDataOnly(value); + }; + if (!isDataOnly(o)) return undefined; + + if (nativeStructuredClone) { + try { + // 优先使用 structuredClone,支持大多数可克隆对象 + return nativeStructuredClone(o); + } catch { + // structuredClone 拒绝的值不再退回会执行 getter 的 JSON 序列化。 + return undefined; + } } try { - // 退而求其次,使用 JSON 序列化方式进行深拷贝 - // 仅适用于可被 JSON 表示的普通对象 + // 旧浏览器没有 structuredClone 时,只复制已验证的数据属性。 return Native.jsonParse(Native.jsonStringify(o)); } catch { // 序列化失败,忽略错误 @@ -41,6 +200,80 @@ export const customClone = (o: any) => { return undefined; }; +// Install trusted own enumerable string-keyed data without ordinary assignment. Descriptor reads +// avoid source getters; defineProperty avoids target/inherited setters and "__proto__" mutation. +// Sources must be trusted objects because proxy reflection can run traps. Internal targets throw on +// locked fields; exposed GM_info skips them. +const readOwnStringKeyedDataProperties = (source: object): Array<[string, unknown]> => { + const keys = nativeReflectOwnKeys(source); + const entries: Array<[string, unknown]> = []; + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key !== "string") continue; + const descriptor = nativeObjectGetOwnPropertyDescriptor(source, key); + if (!descriptor || !descriptor.enumerable) continue; + if (!("value" in descriptor)) { + // An accessor violates the trusted-data contract; skip would lose data and reading it runs code. + throw new TypeError(`installTrustedDataProperties: source has an accessor own property "${key}"`); + } + entries.push([key, descriptor.value]); + } + return entries; +}; + +// Locked own properties cannot be replaced safely: internal targets fail, while GM_info skips the +// field so a script's locked metadata cannot block the rest of the refresh. +// A null-prototype descriptor prevents page-installed Object.prototype accessors from changing how +// Native.objectDefineProperty interprets it. +const valueOnlyDescriptor = (value: unknown): PropertyDescriptor => { + const descriptor = Native.objectCreate(null) as PropertyDescriptor; + descriptor.value = value; + return descriptor; +}; + +const trustedDataDescriptor = (value: unknown): PropertyDescriptor => { + const descriptor = Native.objectCreate(null) as PropertyDescriptor; + descriptor.configurable = true; + descriptor.enumerable = true; + descriptor.writable = true; + descriptor.value = value; + return descriptor; +}; + +const installOwnDataProperty = (target: object, key: string, value: unknown, onBlocked: "throw" | "skip"): void => { + const existing = nativeObjectGetOwnPropertyDescriptor(target, key); + if (existing) { + if ("value" in existing && existing.writable) { + // Updating only value is allowed even when the other attributes are non-configurable. + Native.objectDefineProperty(target, key, valueOnlyDescriptor(value)); + return; + } + if (!existing.configurable) { + if (onBlocked === "throw") { + throw new TypeError(`installTrustedDataProperties: target property is not redefinable: "${key}"`); + } + return; + } + } + Native.objectDefineProperty(target, key, trustedDataDescriptor(value)); +}; + +/** Install source data on an internal target; fail if an existing property cannot be replaced safely. */ +export const installTrustedDataPropertiesStrict = (target: object, source: object): void => { + const entries = readOwnStringKeyedDataProperties(source); + for (let index = 0; index < entries.length; index += 1) { + installOwnDataProperty(target, entries[index][0], entries[index][1], "throw"); + } +}; + +/** Refresh script-visible metadata; skip locked fields without invoking their setters. */ +export const refreshExposedDataProperties = (target: object, source: object): void => { + const entries = readOwnStringKeyedDataProperties(source); + for (let index = 0; index < entries.length; index += 1) { + installOwnDataProperty(target, entries[index][0], entries[index][1], "skip"); + } +}; + /** is Firefox browser? */ //@ts-ignore const bFirefox = typeof mozInnerScreenX === "number"; diff --git a/src/app/service/content/gm_api/api_dependencies.ts b/src/app/service/content/gm_api/api_dependencies.ts new file mode 100644 index 000000000..fdafeaedf --- /dev/null +++ b/src/app/service/content/gm_api/api_dependencies.ts @@ -0,0 +1,62 @@ +/** Methods injected when these APIs are granted. */ +export const API_DEPENDENCIES: Readonly> = { + "GM.getValues": ["GM_getValues"], + "GM.addValueChangeListener": ["GM_addValueChangeListener"], + "GM.removeValueChangeListener": ["GM_removeValueChangeListener"], + "GM.log": ["GM_log"], + "GM.registerMenuCommand": ["GM_registerMenuCommand"], + CAT_registerMenuInput: ["GM_registerMenuCommand"], + "GM.addStyle": ["GM_addStyle"], + "GM.addElement": ["GM_addElement"], + "GM.unregisterMenuCommand": ["GM_unregisterMenuCommand"], + CAT_unregisterMenuInput: ["GM_unregisterMenuCommand"], + CAT_fileStorage: ["CAT_fetchBlob"], + GM_openInTab: ["GM_closeInTab"], + "GM.openInTab": ["GM_openInTab", "GM_closeInTab"], + "GM.getTab": ["GM_getTab"], + "GM.saveTab": ["GM_saveTab"], + "GM.getTabs": ["GM_getTabs"], + "GM.setClipboard": ["GM_setClipboard"], + "GM.getResourceText": ["GM_getResourceText"], + "GM.getResourceURL": ["GM_getResourceURL"], + "GM.getResourceUrl": ["GM_getResourceURL"], +}; + +/** Backing GM grants needed by convenience APIs that forward their calls. */ +export const PAGE_RPC_DEPENDENCIES: Readonly> = { + "GM.cookie": ["GM.cookie.set", "GM.cookie.list", "GM.cookie.delete"], + GM_cookie: ["GM_cookie.set", "GM_cookie.list", "GM_cookie.delete"], + "GM.deleteValue": ["GM_setValue"], + GM_deleteValue: ["GM_setValue"], + "GM.deleteValues": ["GM_setValues"], + GM_deleteValues: ["GM_setValues"], + "GM.setValue": ["GM_setValue"], + "GM.setValues": ["GM_setValues"], + "GM.listValues": ["GM_listValues"], + "GM.download": ["GM_download"], + "GM.notification": ["GM_notification"], +}; + +/** Calls forwarded by a granted API to an internal Service Worker endpoint. */ +export const INTERNAL_APIS_BY_GRANT: Readonly> = { + "CAT.agent.conversation": ["CAT_agentConversation", "CAT_agentConversationChat", "CAT_agentAttachToConversation"], + "CAT.agent.dom": ["CAT_agentDom"], + "CAT.agent.model": ["CAT_agentModel"], + "CAT.agent.opfs": ["CAT_agentOPFS", "CAT_fetchBlob"], + "CAT.agent.skills": ["CAT_agentSkills"], + "CAT.agent.task": ["CAT_agentTask"], + CAT_fileStorage: ["CAT_fetchBlob", "CAT_createBlobUrl"], + "GM.xmlHttpRequest": ["GM_xmlhttpRequest"], +}; + +const hasOwn = Object.prototype.hasOwnProperty; + +export const getApiDependencies = (api: string): readonly string[] => + hasOwn.call(API_DEPENDENCIES, api) ? API_DEPENDENCIES[api] : []; + +export const getPageRpcDependencies = (api: string): readonly string[] => + hasOwn.call(API_DEPENDENCIES, api) + ? API_DEPENDENCIES[api] + : hasOwn.call(PAGE_RPC_DEPENDENCIES, api) + ? PAGE_RPC_DEPENDENCIES[api] + : []; diff --git a/src/app/service/content/gm_api/cat_agent.test.ts b/src/app/service/content/gm_api/cat_agent.test.ts index 1ea74a921..717741020 100644 --- a/src/app/service/content/gm_api/cat_agent.test.ts +++ b/src/app/service/content/gm_api/cat_agent.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { ConversationInstance } from "./cat_agent"; +import { ConversationInstance, type ConversationStreamChunk } from "./cat_agent"; import type { Conversation, StreamChunk } from "@App/app/service/agent/core/types"; import type { MessageConnect } from "@Packages/message/types"; @@ -65,6 +65,33 @@ function createInstance( } describe("ConversationInstance 命令机制", () => { + it("不会把通用 GM 传输能力作为实例属性暴露", () => { + const { instance } = createInstance(); + const ownNames = Object.getOwnPropertyNames(instance); + + expect(ownNames).not.toContain("gmSendMessage"); + expect(ownNames).not.toContain("gmConnect"); + expect(ownNames).not.toContain("conv"); + expect(ownNames).not.toContain("scriptUuid"); + expect(ownNames).not.toContain("toolHandlers"); + expect(ownNames).not.toContain("toolDefs"); + expect(ownNames).not.toContain("messageHistory"); + }); + + it("does not let public mutation replace private conversation state", async () => { + const { instance } = createEphemeralInstance(); + const exposed = instance as unknown as Record; + exposed.messageHistory = [{ role: "user", content: "forged" }]; + exposed.toolHandlers = new Map([["forged", vi.fn()]]); + exposed.toolDefs = [{ name: "forged", description: "forged", parameters: {} }]; + + await instance.chat("real"); + + const messages = await instance.getMessages(); + expect(messages[0]).toMatchObject({ role: "user", content: "real" }); + expect(messages).not.toContainEqual({ role: "user", content: "forged" }); + }); + it("内置 /new 命令清空消息历史", async () => { const { instance, gmSendMessage } = createInstance(); @@ -983,3 +1010,117 @@ describe("ConversationInstance tool_call_complete 结果净化", () => { expect(completionChunk?.toolCall).not.toHaveProperty("subAgent"); }); }); + +describe("ConversationInstance 在页面桥接处校验 CAT 流事件(cat_stream_event.ts 的集成验证)", () => { + it("chat() 丢弃携带 own '__proto__' 数据属性的伪造事件,不产生副作用,后续正常事件仍正确处理", async () => { + const forged: Record = { type: "content_delta", delta: "POLLUTED" }; + Object.defineProperty(forged, "__proto__", { + configurable: true, + enumerable: true, + value: { forgedPrototype: true }, + }); + const conn = mockConnectWithSequence([ + { delayMs: 0, data: forged }, + { delayMs: 1, data: { type: "content_delta", delta: "real reply" } }, + { delayMs: 2, data: { type: "done", usage: { inputTokens: 1, outputTokens: 1 }, durationMs: 10 } }, + ]); + const { instance } = createInstance(undefined, conn); + + const reply = await instance.chat("hi"); + + expect(reply.content).toBe("real reply"); + }); + + it("chat() 丢弃携带 own '__proto__' 的伪造 error 事件;后续真实 error 事件产生的 Error 原型不受污染", async () => { + const forgedError: Record = { type: "error", message: "forged" }; + Object.defineProperty(forgedError, "__proto__", { + configurable: true, + enumerable: true, + value: { forgedPrototype: true }, + }); + const conn = mockConnectWithSequence([ + { delayMs: 0, data: forgedError }, + { delayMs: 1, data: { type: "error", message: "real error" } }, + ]); + const { instance } = createInstance(undefined, conn); + + const err = await instance.chat("hi").catch((e) => e); + + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe("real error"); + expect(Object.getPrototypeOf(err)).toBe(Error.prototype); + expect((err as any).forgedPrototype).toBeUndefined(); + }); + + it("chatStream() 丢弃携带未知字段的伪造事件,不影响后续 chunk 序列", async () => { + const forged = { type: "content_delta", delta: "POLLUTED", unexpectedField: "x" }; + const conn = mockConnectWithSequence([ + { delayMs: 0, data: forged }, + { delayMs: 1, data: { type: "content_delta", delta: "real" } }, + { delayMs: 2, data: { type: "done", usage: { inputTokens: 1, outputTokens: 1 }, durationMs: 10 } }, + ]); + const { instance } = createInstance(undefined, conn); + + const stream = await instance.chatStream("hi"); + const chunks: StreamChunk[] = []; + for await (const chunk of stream) chunks.push(chunk); + + expect(chunks).toEqual([ + { type: "content_delta", content: "real" }, + { type: "done", usage: { inputTokens: 1, outputTokens: 1 }, durationMs: 10 }, + ]); + }); + + it("attach() 丢弃携带未知嵌套 ToolCall 字段的伪造 sync 事件,不重置/结算流,后续正常事件仍继续", async () => { + // sync 分支的副作用面比其它分支大得多(reset 工具调用重建、替换 streamingMessage 快照、 + // done 状态判定、必要时 disconnect),仅用顶层 content_delta 的伪造事件验证不足以证明 + // 这个分支在校验失败时同样不产生任何副作用,需要单独覆盖。 + const forgedSync = { + type: "sync", + streamingMessage: { + content: "", + toolCalls: [{ id: "tc-1", name: "t", arguments: "", unexpectedField: "x" }], + }, + tasks: [], + status: "running", + }; + const conn = mockConnectWithSequence([ + { delayMs: 0, data: forgedSync }, + { delayMs: 1, data: { type: "content_delta", delta: "real" } }, + { delayMs: 2, data: { type: "done", usage: { inputTokens: 1, outputTokens: 1 }, durationMs: 10 } }, + ]); + const gmConnect = vi.fn().mockResolvedValue(conn); + const instance = new ConversationInstance( + mockConversation(), + vi.fn().mockResolvedValue(undefined), + gmConnect, + "uuid" + ); + + const stream = await instance.attach(); + const chunks: ConversationStreamChunk[] = []; + for await (const chunk of stream) chunks.push(chunk); + + // 没有 sync chunk:伪造事件被在校验层丢弃,从未进入 push()/reset() 逻辑 + expect(chunks.some((chunk) => chunk.type === "sync")).toBe(false); + expect(chunks).toEqual([ + { type: "content_delta", content: "real" }, + { type: "done", usage: { inputTokens: 1, outputTokens: 1 }, durationMs: 10 }, + ]); + }); + + it("忽略 CAT 未消费的 retry 事件后仍处理后续 stream 事件", async () => { + // Unsupported protocol variants are ignored without settling the active CAT stream. + const conn = mockConnectWithSequence([ + { delayMs: 0, data: { type: "retry", attempt: 1, maxRetries: 3, error: "timeout", delayMs: 100 } }, + { delayMs: 1, data: { type: "content_delta", delta: "real" } }, + { delayMs: 2, data: { type: "done", usage: { inputTokens: 1, outputTokens: 1 }, durationMs: 10 } }, + ]); + const { instance } = createInstance(undefined, conn); + + const reply = await instance.chat("hi"); + + expect(reply.content).toBe("real"); + expect(reply.durationMs).toBe(10); + }); +}); diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index 8f63e06cd..896f89524 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -3,7 +3,6 @@ import { uuidv4 } from "@App/pkg/utils/uuid"; import type { MessageConnect } from "@Packages/message/types"; import type { ChatReply, - ChatStreamEvent, CommandHandler, ContentBlock, Conversation, @@ -18,6 +17,8 @@ import type { MessageContent, } from "@App/app/service/agent/core/types"; import { getTextContent } from "@App/app/service/agent/core/content_utils"; +import { Native } from "../global"; +import { buildChatStreamError, cloneCatChatStreamEvent } from "./cat_stream_event"; export type ConversationStreamChunk = | StreamChunk @@ -81,27 +82,36 @@ function resolveToolCall( // 对话实例,暴露给用户脚本 // 导出供测试使用 +type ConversationPrivateState = { + conv: Conversation; + gmSendMessage: (api: string, params: any[]) => Promise; + gmConnect: (api: string, params: any[]) => Promise; + scriptUuid: string; + commandHandlers: Map; + cache?: boolean; + systemPrompt?: string; + background: boolean; +}; + export class ConversationInstance { - public toolHandlers: Map = new Map(); - public toolDefs: ToolDefinition[] = []; - private commandHandlers: Map = new Map(); - public ephemeral: boolean; - private cache?: boolean; - private systemPrompt?: string; - public messageHistory: Array<{ + // 私有状态包含跨 context 的发送函数;用 private field 隐藏它,避免脚本读取或替换传输入口。 + #state: ConversationPrivateState; + + #toolHandlers: Map = new Map(); + #toolDefs: ToolDefinition[] = []; + #ephemeral: boolean; + #messageHistory: Array<{ role: MessageRole; content: MessageContent; toolCallId?: string; toolCalls?: ToolCall[]; }> = []; - private background: boolean; - constructor( - private conv: Conversation, - private gmSendMessage: (api: string, params: any[]) => Promise, - private gmConnect: (api: string, params: any[]) => Promise, - private scriptUuid: string, + conv: Conversation, + gmSendMessage: (api: string, params: any[]) => Promise, + gmConnect: (api: string, params: any[]) => Promise, + scriptUuid: string, initialTools?: ConversationCreateOptions["tools"], commands?: Record, ephemeral?: boolean, @@ -109,19 +119,27 @@ export class ConversationInstance { cache?: boolean, background?: boolean ) { - this.ephemeral = ephemeral || false; - this.background = background || false; - this.cache = cache; - this.systemPrompt = system; + const state: ConversationPrivateState = { + conv, + gmSendMessage, + gmConnect, + scriptUuid, + commandHandlers: new Map(), + cache, + systemPrompt: system, + background: background || false, + }; + this.#state = state; + this.#ephemeral = ephemeral || false; if (initialTools) { for (const tool of initialTools) { - this.toolHandlers.set(tool.name, tool.handler); - this.toolDefs.push({ name: tool.name, description: tool.description, parameters: tool.parameters }); + this.#toolHandlers.set(tool.name, tool.handler); + this.#toolDefs.push({ name: tool.name, description: tool.description, parameters: tool.parameters }); } } // 注册内置 /new 命令 - this.commandHandlers.set("/new", async () => { + state.commandHandlers.set("/new", async () => { await this.clear(); return "对话已清空"; }); @@ -129,21 +147,21 @@ export class ConversationInstance { // 用户传入的 commands 覆盖内置命令 if (commands) { for (const [name, handler] of Object.entries(commands)) { - this.commandHandlers.set(name, handler); + state.commandHandlers.set(name, handler); } } } get id() { - return this.conv.id; + return this.#state.conv.id; } get title() { - return this.conv.title; + return this.#state.conv.title; } get modelId() { - return this.conv.modelId; + return this.#state.conv.modelId; } // 发送消息并获取回复(内置 tool calling 循环) @@ -154,42 +172,43 @@ export class ConversationInstance { if (cmdResult !== undefined) return cmdResult; const { toolDefs, handlers } = this.mergeTools(options?.tools); + const state = this.#state; // ephemeral 模式:追加 user message 到内存历史 - if (this.ephemeral) { - this.messageHistory.push({ role: "user", content }); + if (this.#ephemeral) { + this.#messageHistory.push({ role: "user", content }); } // 通过 GM API connect 建立流式连接 const connectParams: Record = { - conversationId: this.conv.id, - generation: this.conv.generation, + conversationId: state.conv.id, + generation: state.conv.generation, message: content, tools: toolDefs.length > 0 ? toolDefs : undefined, - scriptUuid: this.scriptUuid, + scriptUuid: state.scriptUuid, }; - if (this.cache !== undefined) { - connectParams.cache = this.cache; + if (state.cache !== undefined) { + connectParams.cache = state.cache; } - if (this.background) { + if (state.background) { connectParams.background = true; } - if (this.ephemeral) { + if (this.#ephemeral) { connectParams.ephemeral = true; - connectParams.messages = this.messageHistory; - connectParams.system = this.systemPrompt; - connectParams.modelId = this.conv.modelId; + connectParams.messages = this.#messageHistory; + connectParams.system = state.systemPrompt; + connectParams.modelId = state.conv.modelId; } - const conn = await this.gmConnect("CAT_agentConversationChat", [connectParams]); + const conn = await state.gmConnect("CAT_agentConversationChat", [connectParams]); const reply = await this.processChat(conn, handlers); // ephemeral 模式:中间轮次(带 tool calls)已在 processChat 内按 new_message 边界追加到内存历史, // 这里只需追加不含 tool calls 的最终回复(done 事件保证到达时已无待处理的 tool calls)。 - if (this.ephemeral) { - this.messageHistory.push({ role: "assistant", content: reply.content }); + if (this.#ephemeral) { + this.#messageHistory.push({ role: "assistant", content: reply.content }); } return reply; @@ -221,39 +240,40 @@ export class ConversationInstance { } const { toolDefs, handlers } = this.mergeTools(options?.tools); + const state = this.#state; // ephemeral 模式:追加 user message 到内存历史 - if (this.ephemeral) { - this.messageHistory.push({ role: "user", content }); + if (this.#ephemeral) { + this.#messageHistory.push({ role: "user", content }); } const connectParams: Record = { - conversationId: this.conv.id, - generation: this.conv.generation, + conversationId: state.conv.id, + generation: state.conv.generation, message: content, tools: toolDefs.length > 0 ? toolDefs : undefined, - scriptUuid: this.scriptUuid, + scriptUuid: state.scriptUuid, }; - if (this.cache !== undefined) { - connectParams.cache = this.cache; + if (state.cache !== undefined) { + connectParams.cache = state.cache; } - if (this.background) { + if (state.background) { connectParams.background = true; } - if (this.ephemeral) { + if (this.#ephemeral) { connectParams.ephemeral = true; - connectParams.messages = this.messageHistory; - connectParams.system = this.systemPrompt; - connectParams.modelId = this.conv.modelId; + connectParams.messages = this.#messageHistory; + connectParams.system = state.systemPrompt; + connectParams.modelId = state.conv.modelId; } - const conn = await this.gmConnect("CAT_agentConversationChat", [connectParams]); + const conn = await state.gmConnect("CAT_agentConversationChat", [connectParams]); // chat 连接不会收到 sync 事件(sync 快照仅由 attach 的 SW 端发出), // 公开签名与 scriptcat.d.ts 保持一致:chatStream 只产出 StreamChunk // ephemeral 模式:包装 stream 以收集 assistant 消息到内存历史 - if (this.ephemeral) { + if (this.#ephemeral) { return this.processStreamEphemeral(conn, handlers) as AsyncIterable; } @@ -274,7 +294,7 @@ export class ConversationInstance { const parsed = this.parseCommand(content); if (!parsed) return undefined; - const handler = this.commandHandlers.get(parsed.name); + const handler = this.#state.commandHandlers.get(parsed.name); if (!handler) return undefined; const result = await handler(parsed.args, this); @@ -284,8 +304,8 @@ export class ConversationInstance { // 合并实例级别和调用级别的工具定义(调用级同名工具同时替换 schema 与 handler) protected mergeTools(callTools?: ChatOptions["tools"]) { - const toolDefs: ToolDefinition[] = [...this.toolDefs]; - const handlers = new Map(this.toolHandlers); + const toolDefs: ToolDefinition[] = [...this.#toolDefs]; + const handlers = new Map(this.#toolHandlers); for (const tool of callTools || []) { const definition = { name: tool.name, @@ -302,11 +322,12 @@ export class ConversationInstance { // 获取对话历史 async getMessages(): Promise { - if (this.ephemeral) { + const state = this.#state; + if (this.#ephemeral) { // ephemeral 模式:从内存历史转换为 ChatMessage 格式 - return this.messageHistory.map((msg, idx) => ({ + return this.#messageHistory.map((msg, idx) => ({ id: `ephemeral-${idx}`, - conversationId: this.conv.id, + conversationId: state.conv.id, role: msg.role, content: msg.content, toolCallId: msg.toolCallId, @@ -314,12 +335,12 @@ export class ConversationInstance { createtime: Date.now(), })); } - const messages = await this.gmSendMessage("CAT_agentConversation", [ + const messages = await state.gmSendMessage("CAT_agentConversation", [ { action: "getMessages", - conversationId: this.conv.id, - generation: this.conv.generation, - scriptUuid: this.scriptUuid, + conversationId: state.conv.id, + generation: state.conv.generation, + scriptUuid: state.scriptUuid, } as ConversationApiRequest, ]); return messages || []; @@ -327,36 +348,39 @@ export class ConversationInstance { // 清空对话消息历史 async clear(): Promise { - if (this.ephemeral) { - this.messageHistory = []; + if (this.#ephemeral) { + this.#messageHistory = []; return; } - await this.gmSendMessage("CAT_agentConversation", [ + const state = this.#state; + await state.gmSendMessage("CAT_agentConversation", [ { action: "clearMessages", - conversationId: this.conv.id, - generation: this.conv.generation, - scriptUuid: this.scriptUuid, + conversationId: state.conv.id, + generation: state.conv.generation, + scriptUuid: state.scriptUuid, } as ConversationApiRequest, ]); } // 持久化对话 async save(): Promise { - await this.gmSendMessage("CAT_agentConversation", [ + const state = this.#state; + await state.gmSendMessage("CAT_agentConversation", [ { action: "save", - conversationId: this.conv.id, - generation: this.conv.generation, - scriptUuid: this.scriptUuid, + conversationId: state.conv.id, + generation: state.conv.generation, + scriptUuid: state.scriptUuid, } as ConversationApiRequest, ]); } // 附加到后台运行中的会话,返回流式事件(首个 chunk 为 sync 快照) async attach(): Promise> { - const conn = await this.gmConnect("CAT_agentAttachToConversation", [ - { conversationId: this.conv.id, generation: this.conv.generation, scriptUuid: this.scriptUuid }, + const state = this.#state; + const conn = await state.gmConnect("CAT_agentAttachToConversation", [ + { conversationId: state.conv.id, generation: state.conv.generation, scriptUuid: state.scriptUuid }, ]); return this.processStream(conn, new Map()); } @@ -381,15 +405,15 @@ export class ConversationInstance { const finalContent = buildContent(content, blocks); const round = ordered.map(cloneToolCall); aggregate.push(...round); - if (this.ephemeral && record && (content || blocks.length || round.length)) { - this.messageHistory.push({ + if (this.#ephemeral && record && (content || blocks.length || round.length)) { + this.#messageHistory.push({ role: "assistant", content: finalContent, toolCalls: round.length ? round : undefined, }); for (const toolCall of round) { if (toolCall.result !== undefined) { - this.messageHistory.push({ + this.#messageHistory.push({ role: "tool", content: toolCall.result, toolCallId: toolCall.id, @@ -450,7 +474,8 @@ export class ConversationInstance { return; } if (message.action !== "event") return; - const event: ChatStreamEvent = message.data; + const event = cloneCatChatStreamEvent(message.data); + if (!event) return; if ("subAgent" in event && event.subAgent) return; switch (event.type) { case "content_delta": @@ -510,7 +535,7 @@ export class ConversationInstance { case "error": settled = true; abortBatches(); - reject(Object.assign(new Error(event.message), event)); + reject(buildChatStreamError(event)); conn.disconnect(); break; } @@ -595,7 +620,8 @@ export class ConversationInstance { return; } if (message.action !== "event") return; - const event: ChatStreamEvent = message.data; + const event = cloneCatChatStreamEvent(message.data); + if (!event) return; if ("subAgent" in event && event.subAgent) return; switch (event.type) { case "sync": @@ -692,7 +718,7 @@ export class ConversationInstance { usage: event.usage, durationMs: event.durationMs, }); - error = Object.assign(new Error(event.message), event); + error = buildChatStreamError(event); done = true; abortBatches(); conn.disconnect(); @@ -775,13 +801,13 @@ export class ConversationInstance { result: JSON.stringify({ error: "Tool call cancelled: stream ended before it completed" }), }); }); - this.messageHistory.push({ + this.#messageHistory.push({ role: "assistant", content: buildContent(text, blocks), toolCalls: finalized.length ? finalized : undefined, }); for (const toolCall of finalized) { - this.messageHistory.push({ + this.#messageHistory.push({ role: "tool", content: toolCall.result!, toolCallId: toolCall.id, @@ -879,24 +905,25 @@ export class ConversationInstance { } } -// 运行时 this 是 GM_Base 实例,定义其实际拥有的字段类型 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: (api: string, params: unknown[]) => Promise; connect: (api: string, params: unknown[]) => Promise; scriptRes?: { uuid: string }; } -// 构建 ConversationInstance,独立函数避免 this 绑定问题 -// (装饰器方法运行时 this 是 GM_Base 实例,不是 CATAgentApi) +// 构建 ConversationInstance,保留 GM_Base 的消息上下文。 function buildInstance( ctx: GMBaseContext, conv: Conversation, options?: ConversationCreateOptions ): ConversationInstance { + const sendMessage = Native.bind(ctx.sendMessage, ctx); + const connect = Native.bind(ctx.connect, ctx); return new ConversationInstance( conv, - ctx.sendMessage.bind(ctx), - ctx.connect.bind(ctx), + sendMessage, + connect, ctx.scriptRes?.uuid || "", options?.tools, options?.commands, @@ -922,7 +949,10 @@ export default class CATAgentApi { // CAT.agent.conversation.create() @GMContext.API({ follow: "CAT.agent.conversation" }) - public "CAT.agent.conversation.create"(options: ConversationCreateOptions = {}): Promise { + public "CAT.agent.conversation.create"( + ctx: GMBaseContext, + options: ConversationCreateOptions = {} + ): Promise { return (async () => { if (options.ephemeral) { // ephemeral 模式:不发请求到 SW,直接在脚本端构造 @@ -934,26 +964,26 @@ export default class CATAgentApi { createtime: Date.now(), updatetime: Date.now(), }; - return buildInstance(this as unknown as GMBaseContext, conv, options); + return buildInstance(ctx as unknown as GMBaseContext, conv, options); } const { tools: _tools, ephemeral: _ephemeral, ...serverOptions } = options; - const conv = (await this.sendMessage("CAT_agentConversation", [ - { action: "create", options: serverOptions, scriptUuid: this.scriptRes?.uuid || "" } as ConversationApiRequest, + const conv = (await ctx.sendMessage("CAT_agentConversation", [ + { action: "create", options: serverOptions, scriptUuid: ctx.scriptRes?.uuid || "" } as ConversationApiRequest, ])) as Conversation; - return buildInstance(this as unknown as GMBaseContext, conv, options); + return buildInstance(ctx as unknown as GMBaseContext, conv, options); })(); } // CAT.agent.conversation.get() @GMContext.API({ follow: "CAT.agent.conversation" }) - public "CAT.agent.conversation.get"(id: string): Promise { + public "CAT.agent.conversation.get"(ctx: GMBaseContext, id: string): Promise { return (async () => { - const conv = (await this.sendMessage("CAT_agentConversation", [ - { action: "get", id, scriptUuid: this.scriptRes?.uuid || "" } as ConversationApiRequest, + const conv = (await ctx.sendMessage("CAT_agentConversation", [ + { action: "get", id, scriptUuid: ctx.scriptRes?.uuid || "" } as ConversationApiRequest, ])) as Conversation | null; if (!conv) return null; - return buildInstance(this as unknown as GMBaseContext, conv); + return buildInstance(ctx as unknown as GMBaseContext, conv); })(); } } diff --git a/src/app/service/content/gm_api/cat_agent_dom.ts b/src/app/service/content/gm_api/cat_agent_dom.ts index 1208d39ed..914b16f37 100644 --- a/src/app/service/content/gm_api/cat_agent_dom.ts +++ b/src/app/service/content/gm_api/cat_agent_dom.ts @@ -23,7 +23,7 @@ import type { MonitorStatus, } from "@App/app/service/agent/core/types"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: (api: string, params: unknown[]) => Promise; scriptRes?: { uuid: string }; @@ -37,96 +37,105 @@ export default class CATAgentDomApi { protected scriptRes?: any; @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.listTabs"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.listTabs"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "listTabs", scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.navigate"(url: string, options?: NavigateOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.navigate"(ctx: GMBaseContext, url: string, options?: NavigateOptions): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "navigate", url, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.readPage"(options?: ReadPageOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.readPage"(ctx: GMBaseContext, options?: ReadPageOptions): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "readPage", options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.screenshot"(options?: ScreenshotOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.screenshot"(ctx: GMBaseContext, options?: ScreenshotOptions): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "screenshot", options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.click"(selector: string, options?: DomActionOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.click"( + ctx: GMBaseContext, + selector: string, + options?: DomActionOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "click", selector, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.fill"(selector: string, value: string, options?: DomActionOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.fill"( + ctx: GMBaseContext, + selector: string, + value: string, + options?: DomActionOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "fill", selector, value, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.scroll"(direction: ScrollDirection, options?: ScrollOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.scroll"( + ctx: GMBaseContext, + direction: ScrollDirection, + options?: ScrollOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "scroll", direction, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.waitFor"(selector: string, options?: WaitForOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.waitFor"( + ctx: GMBaseContext, + selector: string, + options?: WaitForOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "waitFor", selector, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.executeScript"(code: string, options?: ExecuteScriptOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.executeScript"( + ctx: GMBaseContext, + code: string, + options?: ExecuteScriptOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "executeScript", code, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.startMonitor"(tabId: number): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.startMonitor"(ctx: GMBaseContext, tabId: number): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "startMonitor", tabId, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.stopMonitor"(tabId: number): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.stopMonitor"(ctx: GMBaseContext, tabId: number): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "stopMonitor", tabId, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.peekMonitor"(tabId: number): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.peekMonitor"(ctx: GMBaseContext, tabId: number): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "peekMonitor", tabId, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); diff --git a/src/app/service/content/gm_api/cat_agent_model.test.ts b/src/app/service/content/gm_api/cat_agent_model.test.ts index 614342ae4..f9746766f 100644 --- a/src/app/service/content/gm_api/cat_agent_model.test.ts +++ b/src/app/service/content/gm_api/cat_agent_model.test.ts @@ -31,7 +31,7 @@ describe.concurrent("CATAgentModelApi", () => { const apis = GMContextApiGet("CAT.agent.model")!; const listApi = apis.find((a) => a.fnKey === "CAT.agent.model.list")!; - const result = await listApi.api.call(ctx); + const result = await listApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ { action: "list", scriptUuid: "test-uuid" } as ModelApiRequest, @@ -57,7 +57,7 @@ describe.concurrent("CATAgentModelApi", () => { const apis = GMContextApiGet("CAT.agent.model")!; const getApi = apis.find((a) => a.fnKey === "CAT.agent.model.get")!; - const result = await getApi.api.call(ctx, "m1"); + const result = await getApi.api(ctx, "m1"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ { action: "get", id: "m1", scriptUuid: "test-uuid" } as ModelApiRequest, @@ -75,7 +75,7 @@ describe.concurrent("CATAgentModelApi", () => { const apis = GMContextApiGet("CAT.agent.model")!; const getDefaultApi = apis.find((a) => a.fnKey === "CAT.agent.model.getDefault")!; - const result = await getDefaultApi.api.call(ctx); + const result = await getDefaultApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ { action: "getDefault", scriptUuid: "test-uuid" } as ModelApiRequest, @@ -93,7 +93,7 @@ describe.concurrent("CATAgentModelApi", () => { const apis = GMContextApiGet("CAT.agent.model")!; const listApi = apis.find((a) => a.fnKey === "CAT.agent.model.list")!; - await listApi.api.call(ctx); + await listApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ { action: "list", scriptUuid: "" } as ModelApiRequest, diff --git a/src/app/service/content/gm_api/cat_agent_model.ts b/src/app/service/content/gm_api/cat_agent_model.ts index 3cc53c8e8..fe3d7d5f2 100644 --- a/src/app/service/content/gm_api/cat_agent_model.ts +++ b/src/app/service/content/gm_api/cat_agent_model.ts @@ -1,7 +1,7 @@ import type { AgentModelSafeConfig, ModelApiRequest } from "@App/app/service/agent/core/types"; import GMContext from "./gm_context"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: ( api: string, @@ -23,32 +23,28 @@ export default class CATAgentModelApi { protected scriptRes?: { uuid: string }; @GMContext.API({ follow: "CAT.agent.model" }) - public "CAT.agent.model.list"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.model.list"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "list", scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, ]) as Promise; } @GMContext.API({ follow: "CAT.agent.model" }) - public "CAT.agent.model.get"(id: string): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.model.get"(ctx: GMBaseContext, id: string): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "get", id, scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, ]) as Promise; } @GMContext.API({ follow: "CAT.agent.model" }) - public "CAT.agent.model.getDefault"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.model.getDefault"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "getDefault", scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, ]) as Promise; } @GMContext.API({ follow: "CAT.agent.model" }) - public "CAT.agent.model.getSummary"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.model.getSummary"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "getSummary", scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, ]) as Promise; diff --git a/src/app/service/content/gm_api/cat_agent_opfs.test.ts b/src/app/service/content/gm_api/cat_agent_opfs.test.ts index d189ccf6e..0b87bfea2 100644 --- a/src/app/service/content/gm_api/cat_agent_opfs.test.ts +++ b/src/app/service/content/gm_api/cat_agent_opfs.test.ts @@ -24,7 +24,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const writeApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.write")!; - const result = await writeApi.api.call(ctx, "hello.txt", "Hello"); + const result = await writeApi.api(ctx, "hello.txt", "Hello"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "write", path: "hello.txt", content: "Hello", scriptUuid: "test-uuid" } as OPFSApiRequest, @@ -38,7 +38,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.read")!; - const result = await readApi.api.call(ctx, "f.txt"); + const result = await readApi.api(ctx, "f.txt"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "read", path: "f.txt", scriptUuid: "test-uuid" } as OPFSApiRequest, @@ -54,14 +54,14 @@ describe.concurrent("CATAgentOPFSApi", () => { const listApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.list")!; // 不带 path - await listApi.api.call(ctx); + await listApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "list", path: undefined, scriptUuid: "test-uuid" } as OPFSApiRequest, ]); // 带 path mockSendMessage.mockClear(); - await listApi.api.call(ctx, "sub"); + await listApi.api(ctx, "sub"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "list", path: "sub", scriptUuid: "test-uuid" } as OPFSApiRequest, ]); @@ -73,7 +73,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const deleteApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.delete")!; - const result = await deleteApi.api.call(ctx, "old.txt"); + const result = await deleteApi.api(ctx, "old.txt"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "delete", path: "old.txt", scriptUuid: "test-uuid" } as OPFSApiRequest, @@ -87,7 +87,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const listApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.list")!; - await listApi.api.call(ctx); + await listApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "list", path: undefined, scriptUuid: "" } as OPFSApiRequest, @@ -108,7 +108,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readAttachmentApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.readAttachment")!; - const result = await readAttachmentApi.api.call(ctx, "att-1"); + const result = await readAttachmentApi.api(ctx, "att-1"); expect(mockSendMessage).toHaveBeenCalledTimes(1); expect((result as any).data).toBe(testBlob); @@ -126,7 +126,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.read")!; - const result = await readApi.api.call(ctx, "img.png", "blob"); + const result = await readApi.api(ctx, "img.png", "blob"); expect(mockSendMessage).toHaveBeenCalledTimes(1); expect((result as any).data).toBe(testBlob); @@ -154,7 +154,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readAttachmentApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.readAttachment")!; - const result = await readAttachmentApi.api.call(ctx, "att-1"); + const result = await readAttachmentApi.api(ctx, "att-1"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_fetchBlob", ["blob:chrome-extension://test/123"]); expect((result as any).data).toBe(testBlob); @@ -181,7 +181,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.read")!; - const result = await readApi.api.call(ctx, "img.png", "blob"); + const result = await readApi.api(ctx, "img.png", "blob"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_fetchBlob", ["blob:chrome-extension://test/456"]); expect((result as any).data).toBe(testBlob); diff --git a/src/app/service/content/gm_api/cat_agent_opfs.ts b/src/app/service/content/gm_api/cat_agent_opfs.ts index 6355e5517..549919162 100644 --- a/src/app/service/content/gm_api/cat_agent_opfs.ts +++ b/src/app/service/content/gm_api/cat_agent_opfs.ts @@ -1,7 +1,7 @@ import type { OPFSApiRequest } from "@App/app/service/agent/core/types"; import GMContext from "./gm_context"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: (api: string, params: any[]) => Promise; scriptRes?: { uuid: string }; @@ -17,8 +17,11 @@ export default class CATAgentOPFSApi { protected scriptRes?: { uuid: string }; @GMContext.API({ follow: "CAT.agent.opfs" }) - public "CAT.agent.opfs.write"(path: string, content: string | Blob): Promise<{ path: string; size: number }> { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.opfs.write"( + ctx: GMBaseContext, + path: string, + content: string | Blob + ): Promise<{ path: string; size: number }> { return ctx.sendMessage("CAT_agentOPFS", [ { action: "write", path, content, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]) as Promise<{ path: string; size: number }>; @@ -26,10 +29,10 @@ export default class CATAgentOPFSApi { @GMContext.API({ follow: "CAT.agent.opfs" }) public async "CAT.agent.opfs.read"( + ctx: GMBaseContext, path: string, format?: "text" | "blob" ): Promise<{ path: string; content?: string; data?: Blob; size: number; mimeType?: string }> { - const ctx = this as unknown as GMBaseContext; const result = await ctx.sendMessage("CAT_agentOPFS", [ { action: "read", path, format, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]); @@ -42,8 +45,10 @@ export default class CATAgentOPFSApi { } @GMContext.API({ follow: "CAT.agent.opfs" }) - public "CAT.agent.opfs.list"(path?: string): Promise> { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.opfs.list"( + ctx: GMBaseContext, + path?: string + ): Promise> { return ctx.sendMessage("CAT_agentOPFS", [ { action: "list", path, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]) as Promise>; @@ -51,9 +56,9 @@ export default class CATAgentOPFSApi { @GMContext.API({ follow: "CAT.agent.opfs" }) public async "CAT.agent.opfs.readAttachment"( + ctx: GMBaseContext, id: string ): Promise<{ id: string; data: Blob; size: number; mimeType?: string }> { - const ctx = this as unknown as GMBaseContext; const result = await ctx.sendMessage("CAT_agentOPFS", [ { action: "readAttachment", id, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]); @@ -66,8 +71,7 @@ export default class CATAgentOPFSApi { } @GMContext.API({ follow: "CAT.agent.opfs" }) - public "CAT.agent.opfs.delete"(path: string): Promise<{ success: true }> { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.opfs.delete"(ctx: GMBaseContext, path: string): Promise<{ success: true }> { return ctx.sendMessage("CAT_agentOPFS", [ { action: "delete", path, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]) as Promise<{ success: true }>; diff --git a/src/app/service/content/gm_api/cat_agent_skills.ts b/src/app/service/content/gm_api/cat_agent_skills.ts index 8e9dd6314..be7715338 100644 --- a/src/app/service/content/gm_api/cat_agent_skills.ts +++ b/src/app/service/content/gm_api/cat_agent_skills.ts @@ -1,7 +1,7 @@ import type { SkillApiRequest, SkillRecord, SkillSummary } from "@App/app/service/agent/core/types"; import GMContext from "./gm_context"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: ( api: string, @@ -23,16 +23,14 @@ export default class CATAgentSkillsApi { protected scriptRes?: { uuid: string }; @GMContext.API({ follow: "CAT.agent.skills" }) - public "CAT.agent.skills.list"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.skills.list"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentSkills", [ { action: "list", scriptUuid: ctx.scriptRes?.uuid || "" } as SkillApiRequest, ]) as Promise; } @GMContext.API({ follow: "CAT.agent.skills" }) - public "CAT.agent.skills.get"(name: string): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.skills.get"(ctx: GMBaseContext, name: string): Promise { return ctx.sendMessage("CAT_agentSkills", [ { action: "get", name, scriptUuid: ctx.scriptRes?.uuid || "" } as SkillApiRequest, ]) as Promise; @@ -40,11 +38,11 @@ export default class CATAgentSkillsApi { @GMContext.API({ follow: "CAT.agent.skills" }) public "CAT.agent.skills.install"( + ctx: GMBaseContext, skillMd: string, scripts?: Array<{ name: string; code: string }>, references?: Array<{ name: string; content: string }> ): Promise { - const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentSkills", [ { action: "install", @@ -57,8 +55,7 @@ export default class CATAgentSkillsApi { } @GMContext.API({ follow: "CAT.agent.skills" }) - public "CAT.agent.skills.remove"(name: string): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.skills.remove"(ctx: GMBaseContext, name: string): Promise { return ctx.sendMessage("CAT_agentSkills", [ { action: "remove", name, scriptUuid: ctx.scriptRes?.uuid || "" } as SkillApiRequest, ]) as Promise; @@ -66,11 +63,11 @@ export default class CATAgentSkillsApi { @GMContext.API({ follow: "CAT.agent.skills" }) public "CAT.agent.skills.call"( + ctx: GMBaseContext, skillName: string, scriptName: string, params?: Record ): Promise { - const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentSkills", [ { action: "call", diff --git a/src/app/service/content/gm_api/cat_agent_task.ts b/src/app/service/content/gm_api/cat_agent_task.ts index 70321ee91..4a0d9cd73 100644 --- a/src/app/service/content/gm_api/cat_agent_task.ts +++ b/src/app/service/content/gm_api/cat_agent_task.ts @@ -7,8 +7,9 @@ import type { EventAgentTask, } from "@App/app/service/agent/core/types"; import type EventEmitter from "eventemitter3"; +import { Native } from "../global"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: (api: string, params: unknown[]) => Promise; scriptRes?: { uuid: string }; @@ -17,8 +18,18 @@ interface GMBaseContext { // 内部 listener 计数器 let listenerCounter = 0; -// listener id → { eventName, callback } 映射,供 removeListener 使用 -const listenerMap = new Map void }>(); +type ListenerRecord = { id: number; eventName: string; callback: (...args: any[]) => void }; +const listenerMaps = new Native.WeakMap>(); +// 监听记录按 GM context 隔离;WeakMap 让脚本结束后不会因监听表反向持有 context。 + +const getListenerRecords = (owner: object): Map => { + let records = listenerMaps.get(owner); + if (!records) { + records = new Native.Map(); + listenerMaps.set(owner, records); + } + return records; +}; // CAT.agent.task API,注入到脚本上下文 export default class CATAgentTaskApi { @@ -33,11 +44,11 @@ export default class CATAgentTaskApi { @GMContext.API({ follow: "CAT.agent.task" }) public "CAT.agent.task.create"( + ctx: GMBaseContext, options: | Omit | Omit ): Promise { - const ctx = this as unknown as GMBaseContext; // event 模式:自动注入 sourceScriptUuid(脚本无需手动传入) const task = options.mode === "event" ? { ...options, sourceScriptUuid: ctx.scriptRes?.uuid || "" } : { ...options }; @@ -50,14 +61,12 @@ export default class CATAgentTaskApi { } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.list"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.list"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentTask", [{ action: "list" } as AgentTaskApiRequest]) as Promise; } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.get"(id: string): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.get"(ctx: GMBaseContext, id: string): Promise { return ctx.sendMessage("CAT_agentTask", [{ action: "get", id } as AgentTaskApiRequest]) as Promise< AgentTask | undefined >; @@ -66,8 +75,7 @@ export default class CATAgentTaskApi { // task 必须携带 get()/list() 返回的 generation/revision(乐观并发版本号), // 否则服务端无法区分"修改的是当前这个任务"还是"ID 被删除重建后的另一个任务" @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.update"(id: string, task: Partial): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.update"(ctx: GMBaseContext, id: string, task: Partial): Promise { if (task.generation === undefined || task.revision === undefined) { throw new Error( "CAT.agent.task.update: task must include the generation/revision returned by CAT.agent.task.get() or list() — spread the fetched task before applying changes." @@ -79,8 +87,11 @@ export default class CATAgentTaskApi { } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.remove"(id: string, task: Pick): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.remove"( + ctx: GMBaseContext, + id: string, + task: Pick + ): Promise { if (task?.generation === undefined || task?.revision === undefined) { throw new Error( "CAT.agent.task.remove: task must include the generation/revision returned by CAT.agent.task.get() or list()." @@ -92,16 +103,18 @@ export default class CATAgentTaskApi { } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.runNow"(id: string): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.runNow"(ctx: GMBaseContext, id: string): Promise { return ctx.sendMessage("CAT_agentTask", [{ action: "runNow", id } as AgentTaskApiRequest]) as Promise; } // 监听任务触发事件 // 利用 EE.on("agentTask:{taskId}", callback) 注册监听 @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.addListener"(taskId: string, callback: (trigger: AgentTaskTrigger) => void): number { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.addListener"( + ctx: GMBaseContext, + taskId: string, + callback: (trigger: AgentTaskTrigger) => void + ): number { if (!ctx.EE) return 0; const listenerId = ++listenerCounter; @@ -112,20 +125,21 @@ export default class CATAgentTaskApi { }; ctx.EE.on(eventName, wrappedCallback); - listenerMap.set(listenerId, { eventName, callback: wrappedCallback }); + getListenerRecords(ctx).set(listenerId, { id: listenerId, eventName, callback: wrappedCallback }); return listenerId; } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.removeListener"(listenerId: number): void { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.removeListener"(ctx: GMBaseContext, listenerId: number): void { if (!ctx.EE) return; - const entry = listenerMap.get(listenerId); + const records = getListenerRecords(ctx); + const entry = records.get(listenerId); if (entry) { + // 记录事件名和包装回调后可直接移除,不必扫描所有任务监听器。 + records.delete(listenerId); ctx.EE.off(entry.eventName, entry.callback); - listenerMap.delete(listenerId); } } } diff --git a/src/app/service/content/gm_api/cat_stream_event.test.ts b/src/app/service/content/gm_api/cat_stream_event.test.ts new file mode 100644 index 000000000..95c920768 --- /dev/null +++ b/src/app/service/content/gm_api/cat_stream_event.test.ts @@ -0,0 +1,380 @@ +import { describe, expect, it } from "vitest"; +import { + buildChatStreamError, + cloneCatChatStreamEvent as cloneChatStreamEvent, + type CatChatStreamEvent, +} from "./cat_stream_event"; +import type { ChatStreamEvent, SubAgentDetails, ToolCall } from "@App/app/service/agent/core/types"; + +// 每个变体一份"最大化"合法样例:把该变体所有可选字段都填上,作为 CAT 消费事件的校验输入, +// 以及 CAT 明确忽略其它已声明事件的 fixture。 +const fullSubAgentDetails: SubAgentDetails = { + agentId: "sa-1", + description: "child task", + subAgentType: "general", + messages: [{ content: "child reply", thinking: "child thinking", toolCalls: [] }], + usage: { inputTokens: 3, outputTokens: 4 }, +}; + +const fullToolCall: ToolCall = { + id: "tc-1", + name: "my_tool", + arguments: "{}", + result: "tool result", + attachments: [{ id: "att-1", type: "image", name: "a.png", mimeType: "image/png", size: 10 }], + ownedAttachmentIds: ["att-1"], + subAgentDetails: fullSubAgentDetails, + status: "completed", +}; + +const subAgent = { agentId: "sa-1", description: "child task", subAgentType: "general", toolCallId: "tc-parent" }; + +const fixtures: { [T in ChatStreamEvent["type"]]: Extract } = { + content_delta: { type: "content_delta", delta: "hello", subAgent }, + thinking_delta: { type: "thinking_delta", delta: "thinking", subAgent }, + tool_call_start: { + type: "tool_call_start", + toolCall: { + id: "tc-1", + name: "my_tool", + arguments: "{}", + attachments: [{ id: "att-1", type: "file", name: "a.txt", mimeType: "text/plain" }], + ownedAttachmentIds: ["att-1"], + subAgentDetails: fullSubAgentDetails, + status: "running", + }, + subAgent, + }, + tool_call_delta: { type: "tool_call_delta", id: "tc-1", delta: "chunk", index: 0, subAgent }, + tool_call_complete: { + type: "tool_call_complete", + id: "tc-1", + result: "done", + status: "completed", + attachments: [{ id: "att-1", type: "audio", name: "a.mp3", mimeType: "audio/mpeg", size: 5 }], + ownedAttachmentIds: ["att-1"], + subAgent, + }, + content_block_start: { + type: "content_block_start", + block: { type: "image", mimeType: "image/png", name: "generated" }, + subAgent, + }, + content_block_complete: { + type: "content_block_complete", + block: { type: "file", attachmentId: "att-1", mimeType: "text/plain", name: "a.txt", size: 10 }, + data: "base64==", + subAgent, + }, + new_message: { type: "new_message", subAgent }, + done: { + type: "done", + usage: { inputTokens: 1, outputTokens: 2, cacheCreationInputTokens: 3, cacheReadInputTokens: 4 }, + durationMs: 100, + subAgent, + }, + error: { + type: "error", + message: "boom", + errorCode: "api_error", + usage: { inputTokens: 1, outputTokens: 2 }, + durationMs: 50, + subAgent, + }, + retry: { type: "retry", attempt: 1, maxRetries: 3, error: "timeout", delayMs: 200, subAgent }, + system_warning: { type: "system_warning", message: "careful", subAgent }, + ask_user: { + type: "ask_user", + id: "q-1", + question: "pick one", + options: ["a", "b"], + optionValues: ["A", "B"], + multiple: true, + allowCustom: false, + }, + ask_user_expired: { type: "ask_user_expired", id: "q-1" }, + ask_user_resolved: { type: "ask_user_resolved", id: "q-1" }, + task_update: { + type: "task_update", + tasks: [{ id: "t-1", subject: "do it", status: "in_progress", description: "details" }], + }, + compact_done: { type: "compact_done", summary: "summary text", originalCount: 42 }, + sync: { + type: "sync", + streamingMessage: { content: "hi", thinking: "hmm", toolCalls: [fullToolCall] }, + pendingAskUser: { + id: "q-1", + question: "pick one", + options: ["a", "b"], + optionValues: ["A", "B"], + multiple: true, + allowCustom: true, + }, + tasks: [{ id: "t-1", subject: "do it", status: "pending", description: "pending task detail" }], + status: "running", + }, +}; + +const catEventTypes: CatChatStreamEvent["type"][] = [ + "content_delta", + "thinking_delta", + "tool_call_start", + "tool_call_delta", + "tool_call_complete", + "content_block_complete", + "new_message", + "done", + "error", + "system_warning", + "sync", +]; + +describe("cloneCatChatStreamEvent:CAT 消费的事件允许完整可选字段", () => { + for (const type of catEventTypes) { + it(`接受 ${type}`, () => { + const fixture = fixtures[type]; + const cloned = cloneChatStreamEvent(fixture); + expect(cloned).toBeDefined(); + expect(cloned).toEqual(fixture); + }); + } +}); + +describe("cloneCatChatStreamEvent:CAT 未消费的顶层事件不进入 CAT", () => { + it.each([ + "content_block_start", + "retry", + "ask_user", + "ask_user_expired", + "ask_user_resolved", + "task_update", + "compact_done", + ] as const)("忽略 %s", (type) => { + expect(cloneChatStreamEvent(fixtures[type])).toBeUndefined(); + }); +}); + +describe("cloneCatChatStreamEvent:未知 key 一律拒绝(不是 __proto__ 黑名单)", () => { + it("拒绝顶层携带 own '__proto__' 数据属性的事件", () => { + const forged: Record = { type: "error", message: "forged" }; + Object.defineProperty(forged, "__proto__", { + configurable: true, + enumerable: true, + value: { forgedPrototype: true }, + }); + expect(cloneChatStreamEvent(forged)).toBeUndefined(); + }); + + it("拒绝顶层携带普通未知字段的事件", () => { + const forged = { type: "error", message: "forged", unexpectedField: "surprise" }; + expect(cloneChatStreamEvent(forged)).toBeUndefined(); + }); + + it("拒绝 sync.streamingMessage.toolCalls 里携带未知字段的 ToolCall", () => { + const forged = { + type: "sync", + streamingMessage: { content: "", toolCalls: [{ ...fullToolCall, unexpectedField: "x" }] }, + tasks: [], + status: "running", + }; + expect(cloneChatStreamEvent(forged)).toBeUndefined(); + }); + + it("拒绝 attachments 数组里携带未知字段的 Attachment", () => { + const forged = { + type: "tool_call_complete", + id: "tc-1", + result: "done", + attachments: [{ id: "a", type: "file", name: "n", mimeType: "text/plain", unexpectedField: "x" }], + }; + expect(cloneChatStreamEvent(forged)).toBeUndefined(); + }); + + it("拒绝 subAgent 里携带未知字段", () => { + const forged = { + type: "content_delta", + delta: "x", + subAgent: { agentId: "a", description: "d", unexpectedField: "x" }, + }; + expect(cloneChatStreamEvent(forged)).toBeUndefined(); + }); +}); + +describe("cloneCatChatStreamEvent:tool_call_start 与 sync.toolCalls 使用不同的 ToolCall schema", () => { + it("tool_call_start.toolCall 携带 result 字段时被拒绝(Omit)", () => { + const forged = { + type: "tool_call_start", + toolCall: { id: "tc-1", name: "t", arguments: "", result: "should not be here" }, + }; + expect(cloneChatStreamEvent(forged)).toBeUndefined(); + }); + + it("sync.streamingMessage.toolCalls 里的完整 ToolCall 允许携带 result", () => { + const valid = { + type: "sync", + streamingMessage: { content: "", toolCalls: [fullToolCall] }, + tasks: [], + status: "done", + }; + const cloned = cloneChatStreamEvent(valid); + expect(cloned).toBeDefined(); + expect((cloned as any).streamingMessage.toolCalls[0].result).toBe("tool result"); + }); +}); + +describe("cloneCatChatStreamEvent:结构性/值域校验", () => { + it("拒绝非对象 message.data", () => { + expect(cloneChatStreamEvent("not an object")).toBeUndefined(); + expect(cloneChatStreamEvent(null)).toBeUndefined(); + expect(cloneChatStreamEvent(undefined)).toBeUndefined(); + }); + + it("拒绝未声明的 type", () => { + expect(cloneChatStreamEvent({ type: "not_a_real_type" })).toBeUndefined(); + }); + + it("接受 tool_call_delta.id 为空字符串(OpenAI/Anthropic 后续 arguments chunk 的真实产出形态)", () => { + // providers/openai.ts 与 providers/anthropic.ts 的后续 tool_call_delta 均以 + // `id: tc.id || ""` 发出;resolveToolCall() 本就按 id → index → 最后一个 running 的顺序 + // 回退匹配,空 id 不是伪造数据,是当前生产者的合法产出形态,不能被当作"非空字符串"要求拒绝。 + const cloned = cloneChatStreamEvent({ + type: "tool_call_delta", + id: "", + index: 0, + delta: '{"city":"Tokyo"}', + }); + expect(cloned).toEqual({ + type: "tool_call_delta", + id: "", + index: 0, + delta: '{"city":"Tokyo"}', + }); + }); + + it("拒绝 tool_call_complete.status 使用 ToolCall 才有的 pending/running 值", () => { + expect( + cloneChatStreamEvent({ type: "tool_call_complete", id: "tc-1", result: "x", status: "pending" }) + ).toBeUndefined(); + expect( + cloneChatStreamEvent({ type: "tool_call_complete", id: "tc-1", result: "x", status: "running" }) + ).toBeUndefined(); + expect( + cloneChatStreamEvent({ type: "tool_call_complete", id: "tc-1", result: "x", status: "completed" }) + ).toBeDefined(); + }); + + it("拒绝 content_block_complete.block 使用 text 类型(该联合不含 TextBlock)", () => { + expect( + cloneChatStreamEvent({ type: "content_block_complete", block: { type: "text", text: "hi" } }) + ).toBeUndefined(); + }); +}); + +describe("cloneCatChatStreamEvent:行为承载的输入(accessor / Proxy)", () => { + // 这里针对的是 MAIN/content 兼容路径(CustomEventMessage → parseWindowMessageBody): + // 信封的 own-key 集合被校验,但信封内层的 data 负载原样透传,不会被递归 clone。 + // customClone() 在读取描述符前就能判断某个 own key 是不是 accessor 并拒绝,不需要调用 getter。 + it("拒绝携带 enumerable getter 的顶层事件,且不会触发该 getter", () => { + let getterCalls = 0; + const forged: Record = {}; + Object.defineProperty(forged, "type", { value: "error", enumerable: true, configurable: true }); + Object.defineProperty(forged, "message", { + enumerable: true, + configurable: true, + get() { + getterCalls += 1; + return "forged via getter"; + }, + }); + + expect(cloneChatStreamEvent(forged)).toBeUndefined(); + expect(getterCalls).toBe(0); + }); + + // Proxy 的反射操作(Reflect.ownKeys / getOwnPropertyDescriptor / getPrototypeOf 等)本身可能 + // 触发 trap,因此不断言 trap 从未被调用——只断言无论 trap 抛出还是返回伪造数据,最终结果都是 + // 拒绝且不产生任何 CAT 状态副作用。customClone() 不是一个 JavaScript 沙箱。 + it("拒绝 Proxy(无论其反射 trap 抛出还是返回伪造数据),且不接受为合法事件", () => { + const throwingProxy = new Proxy( + { type: "error", message: "forged" }, + { + ownKeys() { + throw new Error("hostile trap"); + }, + } + ); + expect(cloneChatStreamEvent(throwingProxy)).toBeUndefined(); + + const lyingProxy = new Proxy( + { type: "error", message: "forged" }, + { + get(target, prop, receiver) { + if (prop === "type") return "content_delta"; + return Reflect.get(target, prop, receiver); + }, + } + ); + // 不主张这里的结果一定是 undefined(trap 可能让校验逻辑读到不一致但仍结构合法的数据), + // 只主张它绝不会被当作携带任意页面控制字段的对象直接放行——即便被接受,也必须是一份 + // 已经过 exact-key 校验的干净拷贝,而不是对 Proxy 本身的引用。 + const cloned = cloneChatStreamEvent(lyingProxy); + if (cloned !== undefined) { + expect(cloned).not.toBe(lyingProxy); + expect(Object.getPrototypeOf(cloned)).not.toBe(Proxy.prototype); + } + }); +}); + +describe("buildChatStreamError:固定字段重建 Error,保留 own-key 存在性语义", () => { + it("完整字段:message/type/errorCode/usage/durationMs 全部保留", () => { + const event = fixtures.error; + const err = buildChatStreamError(event); + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe("boom"); + expect(err.type).toBe("error"); + expect(err.errorCode).toBe("api_error"); + expect(err.usage).toEqual({ inputTokens: 1, outputTokens: 2 }); + expect(err.durationMs).toBe(50); + }); + + it("缺失的可选字段在结果上也不作为 own key 出现", () => { + const err = buildChatStreamError({ type: "error", message: "boom" }); + expect(Object.hasOwn(err, "errorCode")).toBe(false); + expect(Object.hasOwn(err, "usage")).toBe(false); + expect(Object.hasOwn(err, "durationMs")).toBe(false); + }); + + it("即使把恶意 event 传进来,构造出的 Error 原型链也不会被污染(cloneChatStreamEvent 已提前拦截,这里作为二道防线核对)", () => { + const err = buildChatStreamError({ type: "error", message: "boom" } as any); + expect(Object.getPrototypeOf(err)).toBe(Error.prototype); + }); + + it("即使 Error.prototype 上被安装了同名 setter,元数据字段仍以自有数据属性写入,不触发该 setter", () => { + // 字段名集合是固定的(type/errorCode/usage/durationMs),但普通 `err.key = value` 赋值仍然 + // 会先查找原型链;MAIN world 下页面可以提前在 Error.prototype 上放一个同名 setter。 + // buildChatStreamError() 必须用 Native.objectDefineProperty 直接定义自有属性绕开它。 + let setterCalls = 0; + const original = Object.getOwnPropertyDescriptor(Error.prototype, "errorCode"); + Object.defineProperty(Error.prototype, "errorCode", { + configurable: true, + set() { + setterCalls += 1; + }, + get() { + return undefined; + }, + }); + try { + const err = buildChatStreamError({ type: "error", message: "boom", errorCode: "rate_limit" }); + expect(setterCalls).toBe(0); + expect(Object.hasOwn(err, "errorCode")).toBe(true); + expect(err.errorCode).toBe("rate_limit"); + } finally { + if (original) { + Object.defineProperty(Error.prototype, "errorCode", original); + } else { + delete (Error.prototype as any).errorCode; + } + } + }); +}); diff --git a/src/app/service/content/gm_api/cat_stream_event.ts b/src/app/service/content/gm_api/cat_stream_event.ts new file mode 100644 index 000000000..902243e3b --- /dev/null +++ b/src/app/service/content/gm_api/cat_stream_event.ts @@ -0,0 +1,638 @@ +import { customClone, Native } from "../global"; +import type { + AudioBlock, + Attachment, + ChatStreamEvent, + FileBlock, + ImageBlock, + SubAgentEventInfo, + ToolCall, +} from "@App/app/service/agent/core/types"; + +// ============================================================================ +// 通用数据形状检查 +// ============================================================================ +// CAT 事件可能经由 clone 语义不同的多条传输路径到达:native 扩展消息通道确实提供 +// clone 语义,但 MAIN/content 兼容路径(CustomEventMessage → parseWindowMessageBody) +// 只校验信封自身的 own-key 集合(messageId/type/data),信封内层的 data 负载原样透传, +// 不会被递归 clone 或校验。因此这里永远不能假设 message.data 已经因为"某条传输路径会 +// structured clone"而安全——必须在这个共用入口先用 customClone() 主动复制一次,拒绝 +// Map/Set 伪装、不可 clone 的值,并把结果规范成 null 原型或原生 Object.prototype 的 +// 纯数据字典,随后只对这份 clone 做结构校验,不直接读取 raw 上的任何字段。 + +const isRecord = (value: unknown): value is Record => { + if (value === null || typeof value !== "object" || Native.arrayIsArray(value)) return false; + const prototype = Native.objectGetPrototypeOf(value); + return prototype === null || Native.objectGetPrototypeOf(prototype) === null; +}; + +const hasOnlyKeys = (value: Record, required: readonly string[], optional: readonly string[] = []) => { + const keys = Native.objectKeys(value); + for (let index = 0; index < required.length; index += 1) { + if (!Native.objectHasOwn(value, required[index])) return false; + } + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + let known = false; + for (let keyIndex = 0; keyIndex < required.length; keyIndex += 1) { + if (required[keyIndex] === key) { + known = true; + break; + } + } + if (!known) { + for (let keyIndex = 0; keyIndex < optional.length; keyIndex += 1) { + if (optional[keyIndex] === key) { + known = true; + break; + } + } + } + if (!known) return false; + } + return true; +}; + +const isNonEmptyString = (value: unknown): value is string => typeof value === "string" && value.length > 0; +const isOptionalString = (value: unknown): value is string | undefined => + value === undefined || typeof value === "string"; +const isOptionalBoolean = (value: unknown): value is boolean | undefined => + value === undefined || typeof value === "boolean"; +const isOptionalNumber = (value: unknown): value is number | undefined => + value === undefined || typeof value === "number"; +const isStringArray = (value: unknown): value is string[] => { + if (!Native.arrayIsArray(value)) return false; + for (let index = 0; index < value.length; index += 1) { + if (typeof value[index] !== "string") return false; + } + return true; +}; +const isOptionalStringArray = (value: unknown): value is string[] | undefined => + value === undefined || isStringArray(value); + +// ============================================================================ +// 编译期 key 契约:防止运行期 allowlist 与 types.ts 静默漂移(OA-P10) +// ============================================================================ +// RequiredKeysOf/OptionalKeysOf 是标准的 TS 必填/可选 key 提取技巧;TypeEqual 是 +// 不做联合类型分发的类型相等判定。AssertKeys 只有在 R 的成员集合恰好等于 +// T 的必填 key 集合、且 O 的成员集合恰好等于 T 的可选 key 集合时才是 `true`; +// 下方每个 `_assert* = AssertTrue<...>` 都是纯编译期检查,drift 会直接让 +// `pnpm run typecheck` 失败,而不是让运行期校验静默漏掉新字段。 +// 下面两行的 {} 是标准的 "该 key 是否可选" 判定技巧({} extends Pick),不是 +// "any non-nullish value" 的误用,故逐行关闭 no-empty-object-type。 +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +type RequiredKeysOf = { [K in keyof T]-?: {} extends Pick ? never : K }[keyof T]; +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +type OptionalKeysOf = { [K in keyof T]-?: {} extends Pick ? K : never }[keyof T]; +type TypeEqual = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type AssertKeys = + TypeEqual> extends true + ? TypeEqual> extends true + ? true + : { readonly optionalKeyDrift: OptionalKeysOf } + : { readonly requiredKeyDrift: RequiredKeysOf }; +type AssertTrue = T; + +// ============================================================================ +// 嵌套形状:Attachment / TokenUsage / SubAgentEventInfo / ToolCall(start 与 full 两种) +// ============================================================================ + +const ATTACHMENT_REQUIRED = ["id", "type", "name", "mimeType"] as const; +const ATTACHMENT_OPTIONAL = ["size"] as const; +type _AssertAttachment = AssertKeys; +type _assertAttachment = AssertTrue<_AssertAttachment>; + +const isAttachment = (value: unknown): value is Attachment => { + if (!isRecord(value) || !hasOnlyKeys(value, ATTACHMENT_REQUIRED, ATTACHMENT_OPTIONAL)) return false; + return ( + isNonEmptyString(value.id) && + (value.type === "image" || value.type === "file" || value.type === "audio") && + typeof value.name === "string" && + typeof value.mimeType === "string" && + isOptionalNumber(value.size) + ); +}; + +const isAttachmentArray = (value: unknown): value is Attachment[] => { + if (!Native.arrayIsArray(value)) return false; + for (let index = 0; index < value.length; index += 1) { + if (!isAttachment(value[index])) return false; + } + return true; +}; +const isOptionalAttachmentArray = (value: unknown): value is Attachment[] | undefined => + value === undefined || isAttachmentArray(value); + +const TOKEN_USAGE_REQUIRED = ["inputTokens", "outputTokens"] as const; +const TOKEN_USAGE_OPTIONAL = ["cacheCreationInputTokens", "cacheReadInputTokens"] as const; +type _AssertTokenUsage = AssertKeys< + Extract["usage"] & {}, + typeof TOKEN_USAGE_REQUIRED, + typeof TOKEN_USAGE_OPTIONAL +>; +type _assertTokenUsage = AssertTrue<_AssertTokenUsage>; + +const isTokenUsage = (value: unknown): boolean => { + if (!isRecord(value) || !hasOnlyKeys(value, TOKEN_USAGE_REQUIRED, TOKEN_USAGE_OPTIONAL)) return false; + return ( + typeof value.inputTokens === "number" && + typeof value.outputTokens === "number" && + isOptionalNumber(value.cacheCreationInputTokens) && + isOptionalNumber(value.cacheReadInputTokens) + ); +}; +const isOptionalTokenUsage = (value: unknown): boolean => value === undefined || isTokenUsage(value); + +const SUB_AGENT_EVENT_INFO_REQUIRED = ["agentId", "description"] as const; +const SUB_AGENT_EVENT_INFO_OPTIONAL = ["subAgentType", "toolCallId"] as const; +type _AssertSubAgentEventInfo = AssertKeys< + SubAgentEventInfo, + typeof SUB_AGENT_EVENT_INFO_REQUIRED, + typeof SUB_AGENT_EVENT_INFO_OPTIONAL +>; +type _assertSubAgentEventInfo = AssertTrue<_AssertSubAgentEventInfo>; + +const isSubAgentEventInfo = (value: unknown): value is SubAgentEventInfo => { + if (!isRecord(value) || !hasOnlyKeys(value, SUB_AGENT_EVENT_INFO_REQUIRED, SUB_AGENT_EVENT_INFO_OPTIONAL)) { + return false; + } + return ( + isNonEmptyString(value.agentId) && + typeof value.description === "string" && + isOptionalString(value.subAgentType) && + isOptionalString(value.toolCallId) + ); +}; +const isOptionalSubAgentEventInfo = (value: unknown): boolean => value === undefined || isSubAgentEventInfo(value); + +const TOOL_CALL_STATUS = new Native.Set(["pending", "running", "completed", "error"]); +const isOptionalToolCallStatus = (value: unknown): boolean => + value === undefined || TOOL_CALL_STATUS.has(value as string); + +// tool_call_complete 事件自己的 status 字段只取 "completed" | "error" 两个值,比 ToolCall.status +// 的四值集合更窄(见 LLMStreamEvent 的 tool_call_complete 分支),不能复用 TOOL_CALL_STATUS。 +const TOOL_CALL_COMPLETE_STATUS = new Native.Set(["completed", "error"]); +const isOptionalToolCallCompleteStatus = (value: unknown): boolean => + value === undefined || TOOL_CALL_COMPLETE_STATUS.has(value as string); + +// tool_call_start.toolCall:Omit。不允许出现 "result"(见 OA plan §15)。 +const TOOL_CALL_START_REQUIRED = ["id", "name", "arguments"] as const; +const TOOL_CALL_START_OPTIONAL = ["attachments", "ownedAttachmentIds", "subAgentDetails", "status"] as const; +type _AssertToolCallStart = AssertKeys< + Omit, + typeof TOOL_CALL_START_REQUIRED, + typeof TOOL_CALL_START_OPTIONAL +>; +type _assertToolCallStart = AssertTrue<_AssertToolCallStart>; + +const isToolCallStart = (value: unknown): value is Omit => { + if (!isRecord(value) || !hasOnlyKeys(value, TOOL_CALL_START_REQUIRED, TOOL_CALL_START_OPTIONAL)) return false; + return ( + isNonEmptyString(value.id) && + typeof value.name === "string" && + typeof value.arguments === "string" && + isOptionalAttachmentArray(value.attachments) && + isOptionalStringArray(value.ownedAttachmentIds) && + // subAgentDetails 只要求"是 customClone 之后的纯数据值";CAT 目前不解读其内部字段, + // 递归建模整份子代理消息图不在本次边界内(OA-P12/OA-P16)。 + (value.subAgentDetails === undefined || isRecord(value.subAgentDetails)) && + isOptionalToolCallStatus(value.status) + ); +}; + +// 完整 ToolCall(sync.streamingMessage.toolCalls 使用):与 start 相同 + 可选 result。 +const TOOL_CALL_FULL_REQUIRED = TOOL_CALL_START_REQUIRED; +const TOOL_CALL_FULL_OPTIONAL = [...TOOL_CALL_START_OPTIONAL, "result"] as const; +type _AssertToolCallFull = AssertKeys; +type _assertToolCallFull = AssertTrue<_AssertToolCallFull>; + +const isToolCallFull = (value: unknown): value is ToolCall => { + if (!isRecord(value) || !hasOnlyKeys(value, TOOL_CALL_FULL_REQUIRED, TOOL_CALL_FULL_OPTIONAL)) return false; + return ( + isNonEmptyString(value.id) && + typeof value.name === "string" && + typeof value.arguments === "string" && + isOptionalString(value.result) && + isOptionalAttachmentArray(value.attachments) && + isOptionalStringArray(value.ownedAttachmentIds) && + (value.subAgentDetails === undefined || isRecord(value.subAgentDetails)) && + isOptionalToolCallStatus(value.status) + ); +}; + +const isToolCallFullArray = (value: unknown): value is ToolCall[] => { + if (!Native.arrayIsArray(value)) return false; + for (let index = 0; index < value.length; index += 1) { + if (!isToolCallFull(value[index])) return false; + } + return true; +}; + +// ============================================================================ +// content_block_complete 的 block 形状 +// ============================================================================ + +const CONTENT_BLOCK_IMAGE_REQUIRED = ["type", "attachmentId", "mimeType"] as const; +const CONTENT_BLOCK_IMAGE_OPTIONAL = ["name"] as const; +const CONTENT_BLOCK_FILE_REQUIRED = ["type", "attachmentId", "mimeType", "name"] as const; +const CONTENT_BLOCK_FILE_OPTIONAL = ["size"] as const; +const CONTENT_BLOCK_AUDIO_REQUIRED = ["type", "attachmentId", "mimeType"] as const; +const CONTENT_BLOCK_AUDIO_OPTIONAL = ["name", "durationMs"] as const; +type _AssertContentBlockImage = AssertKeys< + ImageBlock, + typeof CONTENT_BLOCK_IMAGE_REQUIRED, + typeof CONTENT_BLOCK_IMAGE_OPTIONAL +>; +type _assertContentBlockImage = AssertTrue<_AssertContentBlockImage>; +type _AssertContentBlockFile = AssertKeys< + FileBlock, + typeof CONTENT_BLOCK_FILE_REQUIRED, + typeof CONTENT_BLOCK_FILE_OPTIONAL +>; +type _assertContentBlockFile = AssertTrue<_AssertContentBlockFile>; +type _AssertContentBlockAudio = AssertKeys< + AudioBlock, + typeof CONTENT_BLOCK_AUDIO_REQUIRED, + typeof CONTENT_BLOCK_AUDIO_OPTIONAL +>; +type _assertContentBlockAudio = AssertTrue<_AssertContentBlockAudio>; + +// content_block_complete.block 的类型是直接书写的 ImageBlock | FileBlock | AudioBlock 联合, +// 没有经过 Pick/Omit 改写,因此三个分支各自的字段(size、durationMs 等)都完整保留, +// 与 content_block_start 因 Omit 扁平化而丢字段的情况不同。 +const isContentBlock = (value: unknown): value is ImageBlock | FileBlock | AudioBlock => { + if (!isRecord(value)) return false; + if (value.type === "image") { + return ( + hasOnlyKeys(value, CONTENT_BLOCK_IMAGE_REQUIRED, CONTENT_BLOCK_IMAGE_OPTIONAL) && + isNonEmptyString(value.attachmentId) && + typeof value.mimeType === "string" && + isOptionalString(value.name) + ); + } + if (value.type === "file") { + return ( + hasOnlyKeys(value, CONTENT_BLOCK_FILE_REQUIRED, CONTENT_BLOCK_FILE_OPTIONAL) && + isNonEmptyString(value.attachmentId) && + typeof value.mimeType === "string" && + typeof value.name === "string" && + isOptionalNumber(value.size) + ); + } + if (value.type === "audio") { + return ( + hasOnlyKeys(value, CONTENT_BLOCK_AUDIO_REQUIRED, CONTENT_BLOCK_AUDIO_OPTIONAL) && + isNonEmptyString(value.attachmentId) && + typeof value.mimeType === "string" && + isOptionalString(value.name) && + isOptionalNumber(value.durationMs) + ); + } + return false; +}; + +// ============================================================================ +// sync.pendingAskUser 的形状 +// ============================================================================ + +const ASK_USER_SHAPE_REQUIRED = ["id", "question"] as const; +const ASK_USER_SHAPE_OPTIONAL = ["options", "optionValues", "multiple", "allowCustom"] as const; +type PendingAskUserShape = NonNullable["pendingAskUser"]>; +type _AssertPendingAskUserShape = AssertKeys< + PendingAskUserShape, + typeof ASK_USER_SHAPE_REQUIRED, + typeof ASK_USER_SHAPE_OPTIONAL +>; +type _assertPendingAskUserShape = AssertTrue<_AssertPendingAskUserShape>; + +const isAskUserShape = (value: Record): boolean => + hasOnlyKeys(value, ASK_USER_SHAPE_REQUIRED, ASK_USER_SHAPE_OPTIONAL) && + isNonEmptyString(value.id) && + typeof value.question === "string" && + isOptionalStringArray(value.options) && + isOptionalStringArray(value.optionValues) && + isOptionalBoolean(value.multiple) && + isOptionalBoolean(value.allowCustom); + +// ============================================================================ +// sync.tasks 的任务条目形状 +// ============================================================================ + +const TASK_ITEM_REQUIRED = ["id", "subject", "status"] as const; +const TASK_ITEM_OPTIONAL = ["description"] as const; +type _AssertTaskItem = AssertKeys< + Extract["tasks"][number], + typeof TASK_ITEM_REQUIRED, + typeof TASK_ITEM_OPTIONAL +>; +type _assertTaskItem = AssertTrue<_AssertTaskItem>; + +const TASK_STATUS = new Native.Set(["pending", "in_progress", "completed"]); + +const isTaskArray = (value: unknown): boolean => { + if (!Native.arrayIsArray(value)) return false; + for (let index = 0; index < value.length; index += 1) { + const item = value[index]; + if ( + !isRecord(item) || + !hasOnlyKeys(item, TASK_ITEM_REQUIRED, TASK_ITEM_OPTIONAL) || + !isNonEmptyString(item.id) || + typeof item.subject !== "string" || + !TASK_STATUS.has(item.status as string) || + !isOptionalString(item.description) + ) { + return false; + } + } + return true; +}; + +// ============================================================================ +// 顶层事件 key 契约 +// ============================================================================ + +// ForwardableEvent 派生的十二个变体都额外允许可选的 subAgent;ask_user 系列 / task_update / +// compact_done / sync 六个变体没有 subAgent 字段(见 types.ts 的联合定义)。 +const CONTENT_DELTA_REQUIRED = ["type", "delta"] as const; +const CONTENT_DELTA_OPTIONAL = ["subAgent"] as const; +type _AssertContentDelta = AssertKeys< + Extract, + typeof CONTENT_DELTA_REQUIRED, + typeof CONTENT_DELTA_OPTIONAL +>; +type _assertContentDelta = AssertTrue<_AssertContentDelta>; +type _AssertThinkingDelta = AssertKeys< + Extract, + typeof CONTENT_DELTA_REQUIRED, + typeof CONTENT_DELTA_OPTIONAL +>; +type _assertThinkingDelta = AssertTrue<_AssertThinkingDelta>; + +const TOOL_CALL_START_EVENT_REQUIRED = ["type", "toolCall"] as const; +const TOOL_CALL_START_EVENT_OPTIONAL = ["subAgent"] as const; +type _AssertToolCallStartEvent = AssertKeys< + Extract, + typeof TOOL_CALL_START_EVENT_REQUIRED, + typeof TOOL_CALL_START_EVENT_OPTIONAL +>; +type _assertToolCallStartEvent = AssertTrue<_AssertToolCallStartEvent>; + +const TOOL_CALL_DELTA_REQUIRED = ["type", "id", "delta"] as const; +const TOOL_CALL_DELTA_OPTIONAL = ["index", "subAgent"] as const; +type _AssertToolCallDelta = AssertKeys< + Extract, + typeof TOOL_CALL_DELTA_REQUIRED, + typeof TOOL_CALL_DELTA_OPTIONAL +>; +type _assertToolCallDelta = AssertTrue<_AssertToolCallDelta>; + +const TOOL_CALL_COMPLETE_REQUIRED = ["type", "id", "result"] as const; +const TOOL_CALL_COMPLETE_OPTIONAL = ["status", "attachments", "ownedAttachmentIds", "subAgent"] as const; +type _AssertToolCallComplete = AssertKeys< + Extract, + typeof TOOL_CALL_COMPLETE_REQUIRED, + typeof TOOL_CALL_COMPLETE_OPTIONAL +>; +type _assertToolCallComplete = AssertTrue<_AssertToolCallComplete>; + +const CONTENT_BLOCK_COMPLETE_REQUIRED = ["type", "block"] as const; +const CONTENT_BLOCK_COMPLETE_OPTIONAL = ["data", "subAgent"] as const; +type _AssertContentBlockCompleteEvent = AssertKeys< + Extract, + typeof CONTENT_BLOCK_COMPLETE_REQUIRED, + typeof CONTENT_BLOCK_COMPLETE_OPTIONAL +>; +type _assertContentBlockCompleteEvent = AssertTrue<_AssertContentBlockCompleteEvent>; + +const NEW_MESSAGE_REQUIRED = ["type"] as const; +const NEW_MESSAGE_OPTIONAL = ["subAgent"] as const; +type _AssertNewMessage = AssertKeys< + Extract, + typeof NEW_MESSAGE_REQUIRED, + typeof NEW_MESSAGE_OPTIONAL +>; +type _assertNewMessage = AssertTrue<_AssertNewMessage>; + +const DONE_REQUIRED = ["type"] as const; +const DONE_OPTIONAL = ["usage", "durationMs", "subAgent"] as const; +type _AssertDone = AssertKeys, typeof DONE_REQUIRED, typeof DONE_OPTIONAL>; +type _assertDone = AssertTrue<_AssertDone>; + +const ERROR_REQUIRED = ["type", "message"] as const; +const ERROR_OPTIONAL = ["errorCode", "usage", "durationMs", "subAgent"] as const; +type _AssertError = AssertKeys< + Extract, + typeof ERROR_REQUIRED, + typeof ERROR_OPTIONAL +>; +type _assertError = AssertTrue<_AssertError>; + +const SYSTEM_WARNING_REQUIRED = ["type", "message"] as const; +const SYSTEM_WARNING_OPTIONAL = ["subAgent"] as const; +type _AssertSystemWarning = AssertKeys< + Extract, + typeof SYSTEM_WARNING_REQUIRED, + typeof SYSTEM_WARNING_OPTIONAL +>; +type _assertSystemWarning = AssertTrue<_AssertSystemWarning>; + +const SYNC_REQUIRED = ["type", "tasks", "status"] as const; +const SYNC_OPTIONAL = ["streamingMessage", "pendingAskUser"] as const; +type _AssertSync = AssertKeys, typeof SYNC_REQUIRED, typeof SYNC_OPTIONAL>; +type _assertSync = AssertTrue<_AssertSync>; + +const SYNC_STREAMING_MESSAGE_REQUIRED = ["content", "toolCalls"] as const; +const SYNC_STREAMING_MESSAGE_OPTIONAL = ["thinking"] as const; +type _AssertSyncStreamingMessage = AssertKeys< + Extract["streamingMessage"] & {}, + typeof SYNC_STREAMING_MESSAGE_REQUIRED, + typeof SYNC_STREAMING_MESSAGE_OPTIONAL +>; +type _assertSyncStreamingMessage = AssertTrue<_AssertSyncStreamingMessage>; + +const SYNC_STATUS = new Native.Set(["running", "done", "error"]); + +export type CatChatStreamEvent = Extract< + ChatStreamEvent, + { + type: + | "content_delta" + | "thinking_delta" + | "tool_call_start" + | "tool_call_delta" + | "tool_call_complete" + | "content_block_complete" + | "new_message" + | "done" + | "error" + | "system_warning" + | "sync"; + } +>; + +// ============================================================================ +// 主入口 +// ============================================================================ + +/** + * 先 customClone 再做结构校验;校验失败一律返回 undefined,调用方在此之前不得读取 + * 原始 message.data 的任何字段。这里只放行 CAT 消费的顶层事件;完整协议由 ChatStreamEvent 表达。 + */ +export const cloneCatChatStreamEvent = (raw: unknown): CatChatStreamEvent | undefined => { + const cloned = customClone(raw); + if (!isRecord(cloned) || typeof cloned.type !== "string") return undefined; + + switch (cloned.type) { + case "content_delta": + case "thinking_delta": { + if (!hasOnlyKeys(cloned, CONTENT_DELTA_REQUIRED, CONTENT_DELTA_OPTIONAL)) return undefined; + if (typeof cloned.delta !== "string" || !isOptionalSubAgentEventInfo(cloned.subAgent)) return undefined; + return cloned as unknown as CatChatStreamEvent; + } + case "tool_call_start": { + if (!hasOnlyKeys(cloned, TOOL_CALL_START_EVENT_REQUIRED, TOOL_CALL_START_EVENT_OPTIONAL)) return undefined; + if (!isToolCallStart(cloned.toolCall) || !isOptionalSubAgentEventInfo(cloned.subAgent)) return undefined; + return cloned as unknown as CatChatStreamEvent; + } + case "tool_call_delta": { + if (!hasOnlyKeys(cloned, TOOL_CALL_DELTA_REQUIRED, TOOL_CALL_DELTA_OPTIONAL)) return undefined; + if ( + // id 允许空字符串:OpenAI/Anthropic 的后续 arguments chunk 均以 `id: tc.id || ""` 发出, + // resolveToolCall() 本就按 id → index → 最后一个 running 的顺序回退匹配,空 id 是合法产出。 + typeof cloned.id !== "string" || + typeof cloned.delta !== "string" || + !isOptionalNumber(cloned.index) || + !isOptionalSubAgentEventInfo(cloned.subAgent) + ) { + return undefined; + } + return cloned as unknown as CatChatStreamEvent; + } + case "tool_call_complete": { + if (!hasOnlyKeys(cloned, TOOL_CALL_COMPLETE_REQUIRED, TOOL_CALL_COMPLETE_OPTIONAL)) return undefined; + if ( + !isNonEmptyString(cloned.id) || + typeof cloned.result !== "string" || + !isOptionalToolCallCompleteStatus(cloned.status) || + !isOptionalAttachmentArray(cloned.attachments) || + !isOptionalStringArray(cloned.ownedAttachmentIds) || + !isOptionalSubAgentEventInfo(cloned.subAgent) + ) { + return undefined; + } + return cloned as unknown as CatChatStreamEvent; + } + case "content_block_complete": { + if (!hasOnlyKeys(cloned, CONTENT_BLOCK_COMPLETE_REQUIRED, CONTENT_BLOCK_COMPLETE_OPTIONAL)) return undefined; + if ( + !isContentBlock(cloned.block) || + !isOptionalString(cloned.data) || + !isOptionalSubAgentEventInfo(cloned.subAgent) + ) { + return undefined; + } + return cloned as unknown as CatChatStreamEvent; + } + case "new_message": { + if (!hasOnlyKeys(cloned, NEW_MESSAGE_REQUIRED, NEW_MESSAGE_OPTIONAL)) return undefined; + if (!isOptionalSubAgentEventInfo(cloned.subAgent)) return undefined; + return cloned as unknown as CatChatStreamEvent; + } + case "done": { + if (!hasOnlyKeys(cloned, DONE_REQUIRED, DONE_OPTIONAL)) return undefined; + if ( + !isOptionalTokenUsage(cloned.usage) || + !isOptionalNumber(cloned.durationMs) || + !isOptionalSubAgentEventInfo(cloned.subAgent) + ) { + return undefined; + } + return cloned as unknown as CatChatStreamEvent; + } + case "error": { + if (!hasOnlyKeys(cloned, ERROR_REQUIRED, ERROR_OPTIONAL)) return undefined; + if ( + typeof cloned.message !== "string" || + !isOptionalString(cloned.errorCode) || + !isOptionalTokenUsage(cloned.usage) || + !isOptionalNumber(cloned.durationMs) || + !isOptionalSubAgentEventInfo(cloned.subAgent) + ) { + return undefined; + } + return cloned as unknown as CatChatStreamEvent; + } + case "system_warning": { + if (!hasOnlyKeys(cloned, SYSTEM_WARNING_REQUIRED, SYSTEM_WARNING_OPTIONAL)) return undefined; + if (typeof cloned.message !== "string" || !isOptionalSubAgentEventInfo(cloned.subAgent)) return undefined; + return cloned as unknown as CatChatStreamEvent; + } + case "sync": { + if (!hasOnlyKeys(cloned, SYNC_REQUIRED, SYNC_OPTIONAL)) return undefined; + if (!isTaskArray(cloned.tasks) || !SYNC_STATUS.has(cloned.status as string)) return undefined; + if (cloned.streamingMessage !== undefined) { + const streamingMessage = cloned.streamingMessage; + if ( + !isRecord(streamingMessage) || + !hasOnlyKeys(streamingMessage, SYNC_STREAMING_MESSAGE_REQUIRED, SYNC_STREAMING_MESSAGE_OPTIONAL) || + typeof streamingMessage.content !== "string" || + !isOptionalString(streamingMessage.thinking) || + !isToolCallFullArray(streamingMessage.toolCalls) + ) { + return undefined; + } + } + if (cloned.pendingAskUser !== undefined) { + const pendingAskUser = cloned.pendingAskUser; + if (!isRecord(pendingAskUser) || !isAskUserShape(pendingAskUser)) return undefined; + } + return cloned as unknown as CatChatStreamEvent; + } + default: + return undefined; + } +}; + +// ============================================================================ +// error 事件 -> Error 对象 +// ============================================================================ + +export type ChatStreamErrorEvent = Extract; +type ChatStreamError = Error & + Pick & + Partial>; + +// buildChatStreamError() 用固定 own data property 描述符写入每个字段,而不是普通 `err.key = value` +// 赋值:字段名集合虽然已经是固定的(type/errorCode/usage/durationMs),但普通 [[Set]] 仍然会先查找 +// 原型链——在 MAIN world 里,页面可以提前在 Error.prototype(或其它内建原型)上为这些名字之一放一个 +// setter,那样 `err.errorCode = ...` 会调用页面的 setter 而不是在 err 上创建自有属性。用 +// Native.objectDefineProperty 直接定义自有属性可以绕开这一步。descriptor 的 configurable/ +// enumerable/writable 三个标志都设为 true,和原来 `Object.assign`/普通赋值产生的自有数据属性 +// 完全一致,只是不再经过任何继承的 setter。 +const defineErrorData = (err: Error, key: string, value: unknown): void => { + Native.objectDefineProperty(err, key, { + configurable: true, + enumerable: true, + writable: true, + value, + }); +}; + +/** + * 用固定字段集重建 Error,替代 `Object.assign(new Error(event.message), event)`: + * 后者对每个 key 做的是普通 [[Set]],若 event 上有 own enumerable 的 "__proto__" 数据属性, + * 会经由 Error.prototype 继承的 Object.prototype.__proto__ setter 真的改写这个 Error 实例的 + * 原型链——这与调用的是哪一份 Object.assign 实现无关。cloneCatChatStreamEvent() 已经用 + * exact-key allowlist 排除了这种事件,这里只需要按已知字段名逐个搬运,不再整体搬运 event。 + * 用 Native.objectHasOwn 判断是否搬运每个可选字段,保留"own key 存在但值为 undefined"与 + * "整个 key 不存在"的语义差异;用 defineErrorData 而不是普通赋值,避免触发页面可能安装在 + * 内建原型上的同名 setter(见上方注释)。 + */ +export const buildChatStreamError = (event: ChatStreamErrorEvent): ChatStreamError => { + const err = new Error(event.message) as ChatStreamError; + defineErrorData(err, "type", "error"); + if (Native.objectHasOwn(event, "errorCode")) defineErrorData(err, "errorCode", event.errorCode); + if (Native.objectHasOwn(event, "usage")) defineErrorData(err, "usage", event.usage); + if (Native.objectHasOwn(event, "durationMs")) defineErrorData(err, "durationMs", event.durationMs); + return err; +}; diff --git a/src/app/service/content/gm_api/gm_api.test.ts b/src/app/service/content/gm_api/gm_api.test.ts index cf926aed6..04916d703 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -3,11 +3,13 @@ import ExecScript from "../exec_script"; import type { ScriptLoadInfo } from "@App/app/service/service_worker/types"; import type { GMInfoEnv, ScriptFunc } from "../types"; import { compileScript, compileScriptCode } from "../utils"; -import type { Message } from "@Packages/message/types"; +import type { Message, MessageConnect } from "@Packages/message/types"; import { encodeRValue } from "@App/pkg/utils/message_value"; import { uuidv4 } from "@App/pkg/utils/uuid"; import type { ScriptRunResource } from "@App/app/repo/scripts"; import GMApi from "./gm_api"; +import { parseSerializedDocumentResponse } from "./gm_xhr"; +import { installArrayPrototypeIndexAccessor } from "@Tests/array_prototype_index"; const nilFn: ScriptFunc = () => {}; const scriptRes = { @@ -32,6 +34,273 @@ const envInfo: GMInfoEnv = { isIncognito: false, }; +describe("early-start page RPC", () => { + it("assigns a monotonic sequence to each page GM request and connection", async () => { + const sendMessage = vi.fn().mockResolvedValue({ code: 0, data: undefined }); + const connectMessage = vi.fn().mockResolvedValue({} as MessageConnect); + const script = { + ...scriptRes, + uuid: "sequenced-page-script", + executionHandle: "page-binding", + executionEnvTag: "it", + } as ScriptLoadInfo; + const api = new GMApi( + "scripting", + { sendMessage, connect: connectMessage } as unknown as Message, + {} as Message, + script + ); + + await api.sendMessage("GM_log", ["first"]); + await api.connect("GM_xmlhttpRequest", []); + + expect(sendMessage.mock.calls[0][0].data).toMatchObject({ version: 2, sequence: 1 }); + expect(connectMessage.mock.calls[0][0].data).toMatchObject({ version: 2, sequence: 2 }); + }); + + it("waits for the page binding before opening a long-lived connection", async () => { + let release!: () => void; + const ready = new Promise((resolve) => { + release = resolve; + }); + const connection = {} as MessageConnect; + const connectMessage = vi.fn().mockResolvedValue(connection); + const script = { + ...scriptRes, + uuid: "early-start-script", + executionHandle: "page-binding", + executionEnvTag: "it", + } as ScriptLoadInfo; + const api = new GMApi("scripting", { connect: connectMessage } as unknown as Message, {} as Message, script); + Object.defineProperty(api, "loadScriptPromise", { configurable: true, value: ready, writable: true }); + + const pending = api.connect("GM_xmlhttpRequest", []); + expect(connectMessage).not.toHaveBeenCalled(); + + release(); + await expect(pending).resolves.toBe(connection); + expect(connectMessage).toHaveBeenCalledWith({ + action: "scripting/runtime/gmApi", + data: expect.objectContaining({ + api: "GM_xmlhttpRequest", + handle: "page-binding", + version: 2, + sequence: 1, + }), + }); + }); + + it("cancels a waiting long-lived connection when its context is invalidated", async () => { + let release!: () => void; + const ready = new Promise((resolve) => { + release = resolve; + }); + const connectMessage = vi.fn(); + const script = { + ...scriptRes, + uuid: "early-start-invalidated-connection", + executionHandle: "page-binding", + executionEnvTag: "it", + } as ScriptLoadInfo; + const api = new GMApi("scripting", { connect: connectMessage } as unknown as Message, {} as Message, script); + Object.defineProperty(api, "loadScriptPromise", { configurable: true, value: ready, writable: true }); + let rejected = false; + + const pending = api.connect("GM_xmlhttpRequest", []).catch((error: unknown) => { + rejected = error instanceof Error && error.message === "Invalid Context"; + }); + api.setInvalidContext(); + release(); + await vi.waitFor(() => expect(rejected).toBe(true), { timeout: 100 }); + await pending; + + expect(connectMessage).not.toHaveBeenCalled(); + }); + + it("uses the authoritative run flag for early-start async value acknowledgments", async () => { + const script = { + ...scriptRes, + uuid: "early-start-value-script", + scriptRevision: "early-start-value-script:1:0", + metadata: { grant: ["GM.setValue"], "early-start": [""], "run-at": ["document-start"] }, + executionHandle: undefined, + executionEnvTag: undefined, + executionRunFlag: undefined, + } as ScriptLoadInfo; + const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const exec = new ExecScript(script, { + envPrefix: "scripting", + message: { sendMessage: mockSendMessage } as unknown as Message, + contentMsg: undefined as any, + code: nilFn, + envInfo, + }); + + exec.scriptFunc = function (_token: string, context: any) { + return context.GM.setValue("a", 123); + } as unknown as ScriptFunc; + const result = exec.exec(); + await Promise.resolve(); + expect(mockSendMessage).not.toHaveBeenCalled(); + + expect( + exec.reconcileEarlyScript(envInfo, { + ...script, + scriptRevision: "early-start-value-script:1:0", + executionHandle: "page-binding", + executionEnvTag: "it", + executionRunFlag: "canonical-run", + } as any) + ).toBe(true); + await Promise.resolve(); + expect(mockSendMessage).toHaveBeenCalledTimes(1); + + const request = mockSendMessage.mock.calls[0][0].data; + exec.valueUpdate({ + id: request.params[0], + entries: [["a", encodeRValue(123), encodeRValue(undefined)]], + uuid: script.uuid, + storageName: script.uuid, + sender: { runFlag: "canonical-run", tabId: -2 }, + valueUpdated: true, + }); + + await expect(result).resolves.toBeUndefined(); + }); +}); + +describe("page RPC v2 wire identity (Commit 3)", () => { + // Wire identity is `handle` only, on every transport: neither the native SW hop (prefix + // "serviceWorker" — MAIN native or USER_SCRIPT, which is always native) nor the MAIN fallback + // hop through the content-script PageRpcRegistry (prefix "scripting") may duplicate it as + // `executionHandle`, and neither may carry a page-supplied requestId/uuid/runFlag/envTag. + // Canonical identity is resolved solely from `handle` + the real sender, by the SW. + it.each([ + ["serviceWorker" as const, "main-native-script", "main-binding"], + ["scripting" as const, "main-fallback-script", "main-binding"], + ])("sendMessage over the %s transport carries only the v2 handle", async (prefix, uuid, handle) => { + const sendMessage = vi.fn().mockResolvedValue({ code: 0, data: undefined }); + const script = { + ...scriptRes, + uuid, + executionHandle: handle, + executionEnvTag: "it", + } as ScriptLoadInfo; + const api = new GMApi(prefix, { sendMessage } as unknown as Message, {} as Message, script); + + await api.sendMessage("GM_setValue", ["a", 1]); + + const data = sendMessage.mock.calls[0][0].data; + expect(Reflect.ownKeys(data)).toEqual(["version", "sequence", "handle", "api", "params"]); + expect(data).toMatchObject({ version: 2, sequence: 1, handle, api: "GM_setValue" }); + }); + + it.each([ + ["serviceWorker" as const, "main-native-connect-script", "main-binding"], + ["scripting" as const, "main-fallback-connect-script", "main-binding"], + ])("connect over the %s transport carries only the v2 handle", async (prefix, uuid, handle) => { + const connectMessage = vi.fn().mockResolvedValue({} as MessageConnect); + const script = { + ...scriptRes, + uuid, + executionHandle: handle, + executionEnvTag: "it", + } as ScriptLoadInfo; + const api = new GMApi(prefix, { connect: connectMessage } as unknown as Message, {} as Message, script); + + await api.connect("GM_xmlhttpRequest", []); + + const data = connectMessage.mock.calls[0][0].data; + expect(Reflect.ownKeys(data)).toEqual(["version", "sequence", "handle", "api", "params"]); + expect(data).toMatchObject({ version: 2, handle, api: "GM_xmlhttpRequest" }); + }); + + it("USER_SCRIPT (ct) is always native and still carries only the v2 handle (regression)", async () => { + const sendMessage = vi.fn().mockResolvedValue({ code: 0, data: undefined }); + const script = { + ...scriptRes, + uuid: "ct-script", + executionHandle: "ct-binding", + executionEnvTag: "ct", + } as ScriptLoadInfo; + const api = new GMApi("serviceWorker", { sendMessage } as unknown as Message, {} as Message, script); + + await api.sendMessage("GM_setValue", ["a", 1]); + + const data = sendMessage.mock.calls[0][0].data; + expect(data.handle).toBe("ct-binding"); + expect(Reflect.ownKeys(data)).toEqual(["version", "sequence", "handle", "api", "params"]); + }); + + it("a request with no executionHandle keeps the legacy uuid/runFlag shape", async () => { + const sendMessage = vi.fn().mockResolvedValue({ code: 0, data: undefined }); + const script = { + ...scriptRes, + uuid: "legacy-script", + executionHandle: undefined, + executionEnvTag: undefined, + } as ScriptLoadInfo; + const api = new GMApi("serviceWorker", { sendMessage } as unknown as Message, {} as Message, script); + + await api.sendMessage("GM_setValue", ["a", 1]); + + const data = sendMessage.mock.calls[0][0].data; + expect(data).toMatchObject({ + uuid: "legacy-script", + api: "GM_setValue", + params: ["a", 1], + }); + expect(data.version).toBeUndefined(); + expect(data.handle).toBeUndefined(); + }); +}); + +describe("CAT_fetchDocument", () => { + it("rebuilds documents from a data-only response instead of a relatedTarget reference", async () => { + const script = Object.assign({}, scriptRes, { + executionEnvTag: "it", + metadata: { grant: ["CAT_fetchDocument"] }, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ + code: 0, + data: { + text: '
ok
', + contentType: "text/html", + }, + }); + const api = new GMApi("scripting", { sendMessage } as unknown as Message, {} as Message, script); + + const document = await api.CAT_fetchDocument(api, "https://example.test/document"); + + expect(document?.querySelector("main")?.getAttribute("data-source")).toBe("serialized"); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "scripting/runtime/gmApi", + data: expect.objectContaining({ api: "CAT_fetchDocument", params: ["https://example.test/document", false] }), + }) + ); + }); + + it("does not execute accessors in a forged serialized response", () => { + const getter = vi.fn(() => "secret"); + const data = { contentType: "text/html" } as Record; + Object.defineProperty(data, "text", { configurable: true, enumerable: true, get: getter }); + + expect(parseSerializedDocumentResponse(data)).toBeUndefined(); + expect(getter).not.toHaveBeenCalled(); + + const proxy = new Proxy( + { text: "", contentType: "text/html" }, + { + getOwnPropertyDescriptor: () => { + throw new Error("proxy trap"); + }, + } + ); + expect(parseSerializedDocumentResponse(proxy)).toBeUndefined(); + }); +}); + const makeResource = (url: string, content: string, type: "require" | "require-css" | "resource") => ({ url, content, @@ -59,10 +328,10 @@ describe("GM Resource API", () => { } as unknown as ScriptRunResource; const api = new GMApi("test", {} as Message, {} as Message, script); - expect(api.GM_getResourceText(name)).toBe("declared resource"); - expect(api.GM_getResourceURL(name)).toContain("ZGVjbGFyZWQgcmVzb3VyY2U="); - expect(await api["GM.getResourceText"](name)).toBe("declared resource"); - expect(await api["GM.getResourceUrl"](name)).toContain("ZGVjbGFyZWQgcmVzb3VyY2U="); + expect(api.GM_getResourceText(api, name)).toBe("declared resource"); + expect(api.GM_getResourceURL(api, name)).toContain("ZGVjbGFyZWQgcmVzb3VyY2U="); + expect(await api["GM.getResourceText"](api, name)).toBe("declared resource"); + expect(await api["GM.getResourceUrl"](api, name)).toContain("ZGVjbGFyZWQgcmVzb3VyY2U="); const legacyScript = { ...script, @@ -71,7 +340,7 @@ describe("GM Resource API", () => { } as unknown as ScriptRunResource; const legacyApi = new GMApi("test", {} as Message, {} as Message, legacyScript); - expect(legacyApi.GM_getResourceText(name)).toBe("legacy resource"); + expect(legacyApi.GM_getResourceText(legacyApi, name)).toBe("legacy resource"); }); }); @@ -114,26 +383,26 @@ describe.concurrent("@grant GM", () => { ["GM_xmlhttpRequest"]: this.GM_xmlhttpRequest || function nil(){}, ["GM.xmlhttpRequest"]: this.GM.xmlhttpRequest || function nil(){}, }`; - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); // getValue - expect(ret.GM_getValue?.name).toEqual("bound GM_getValue"); + expect(ret.GM_getValue?.name).toEqual("GM_getValue"); // getTab / getTabs / saveTab - expect(ret.GM_getTab?.name).toEqual("bound GM_getTab"); - expect(ret.GM_getTabs?.name).toEqual("bound GM_getTabs"); - expect(ret.GM_saveTab?.name).toEqual("bound GM_saveTab"); + expect(ret.GM_getTab?.name).toEqual("GM_getTab"); + expect(ret.GM_getTabs?.name).toEqual("GM_getTabs"); + expect(ret.GM_saveTab?.name).toEqual("GM_saveTab"); // cookie - expect(ret.GM_cookie?.name).toEqual("bound GM_cookie"); - expect(ret["GM_cookie.list"]?.name).toEqual("bound GM_cookie.list"); + expect(ret.GM_cookie?.name).toEqual("GM_cookie"); + expect(ret["GM_cookie.list"]?.name).toEqual("GM_cookie.list"); // GM_与GM.应该都在 - expect(ret["GM_addElement"]?.name).toEqual("bound GM_addElement"); - expect(ret["GM.addElement"]?.name).toEqual("bound GM.addElement"); - expect(ret["GM_openInTab"]?.name).toEqual("bound GM_openInTab"); - expect(ret["GM.openInTab"]?.name).toEqual("bound GM.openInTab"); - expect(ret["GM_log"]?.name).toEqual("bound GM_log"); - expect(ret["GM.log"]?.name).toEqual("bound GM.log"); - expect(ret["GM_notification"]?.name).toEqual("bound GM_notification"); - expect(ret["GM.notification"]?.name).toEqual("bound GM.notification"); + expect(ret["GM_addElement"]?.name).toEqual("GM_addElement"); + expect(ret["GM.addElement"]?.name).toEqual("GM.addElement"); + expect(ret["GM_openInTab"]?.name).toEqual("GM_openInTab"); + expect(ret["GM.openInTab"]?.name).toEqual("GM.openInTab"); + expect(ret["GM_log"]?.name).toEqual("GM_log"); + expect(ret["GM.log"]?.name).toEqual("GM.log"); + expect(ret["GM_notification"]?.name).toEqual("GM_notification"); + expect(ret["GM.notification"]?.name).toEqual("GM.notification"); // 没有grant应返回 nil expect(ret["GM_xmlhttpRequest"]?.name).toEqual("nil"); expect(ret["GM.xmlhttpRequest"]?.name).toEqual("nil"); @@ -176,26 +445,26 @@ describe.concurrent("@grant GM", () => { ["GM_xmlhttpRequest"]: this.GM_xmlhttpRequest || function nil(){}, ["GM.xmlhttpRequest"]: this.GM.xmlhttpRequest || function nil(){}, }`; - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); // getValue - expect(ret["GM.getValue"]?.name).toEqual("bound GM.getValue"); + expect(ret["GM.getValue"]?.name).toEqual("GM.getValue"); // getTab / getTabs / saveTab - expect(ret["GM.getTab"]?.name).toEqual("bound GM.getTab"); - expect(ret["GM.getTabs"]?.name).toEqual("bound GM.getTabs"); - expect(ret["GM.saveTab"]?.name).toEqual("bound GM.saveTab"); + expect(ret["GM.getTab"]?.name).toEqual("GM.getTab"); + expect(ret["GM.getTabs"]?.name).toEqual("GM.getTabs"); + expect(ret["GM.saveTab"]?.name).toEqual("GM.saveTab"); // cookie - expect(ret["GM.cookie"]?.name).toEqual("bound GM.cookie"); - expect(ret["GM.cookie"]?.list?.name).toEqual("bound GM.cookie.list"); + expect(ret["GM.cookie"]?.name).toEqual("GM.cookie"); + expect(ret["GM.cookie"]?.list?.name).toEqual("GM.cookie.list"); // GM_与GM.应该都在 - expect(ret["GM_addElement"]?.name).toEqual("bound GM_addElement"); - expect(ret["GM.addElement"]?.name).toEqual("bound GM.addElement"); - expect(ret["GM_openInTab"]?.name).toEqual("bound GM_openInTab"); - expect(ret["GM.openInTab"]?.name).toEqual("bound GM.openInTab"); - expect(ret["GM_log"]?.name).toEqual("bound GM_log"); - expect(ret["GM.log"]?.name).toEqual("bound GM.log"); - expect(ret["GM_notification"]?.name).toEqual("bound GM_notification"); - expect(ret["GM.notification"]?.name).toEqual("bound GM.notification"); + expect(ret["GM_addElement"]?.name).toEqual("GM_addElement"); + expect(ret["GM.addElement"]?.name).toEqual("GM.addElement"); + expect(ret["GM_openInTab"]?.name).toEqual("GM_openInTab"); + expect(ret["GM.openInTab"]?.name).toEqual("GM.openInTab"); + expect(ret["GM_log"]?.name).toEqual("GM_log"); + expect(ret["GM.log"]?.name).toEqual("GM.log"); + expect(ret["GM_notification"]?.name).toEqual("GM_notification"); + expect(ret["GM.notification"]?.name).toEqual("GM.notification"); // 没有grant应返回 nil expect(ret["GM_xmlhttpRequest"]?.name).toEqual("nil"); expect(ret["GM.xmlhttpRequest"]?.name).toEqual("nil"); @@ -214,7 +483,7 @@ describe.concurrent("window.*", () => { code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(ret).toEqual(expect.any(Function)); }); @@ -233,7 +502,7 @@ describe.concurrent("GM Api", () => { code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(ret).toEqual("ok"); }); @@ -249,7 +518,7 @@ describe.concurrent("GM Api", () => { code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(ret).toEqual("ok!"); }); @@ -266,7 +535,7 @@ describe.concurrent("GM Api", () => { code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(ret).toEqual("test1-test2-test3"); }); @@ -287,7 +556,7 @@ describe.concurrent("GM Api", () => { code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(ret).toEqual("test5-test2-test3-test1"); // TM也没有sort }); @@ -304,7 +573,7 @@ describe.concurrent("GM Api", () => { code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(ret).toEqual("test1-test2-test3"); }); @@ -325,7 +594,7 @@ describe.concurrent("GM Api", () => { code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(ret).toEqual("test5-test2-test3-test1"); // TM也没有sort }); @@ -342,14 +611,14 @@ describe.concurrent("GM Api", () => { code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(ret.test1).toEqual("23"); expect(ret.test2).toEqual(45); expect(ret.test3).toEqual("67"); // object default script.code = `return GM_getValues({test4: "default",test2:123});`; - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret2 = await exec.exec(); expect(ret2.test1).toBeUndefined(); expect(ret2.test2).toEqual(45); @@ -368,7 +637,7 @@ describe.concurrent("GM Api", () => { code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(ret.test1).toEqual("23"); expect(ret.test2).toEqual(45); @@ -390,12 +659,12 @@ describe.concurrent("early-script", () => { code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); // 抛出错误 await expect(exec.exec()).rejects.toThrowError(); }); it.concurrent("成功", async () => { - const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + const script = Object.assign({}, scriptRes, { scriptRevision: "script-uuid:1:0" }) as ScriptLoadInfo; script.metadata = {}; script.metadata["early-start"] = [""]; script.metadata["run-at"] = ["document-start"]; @@ -408,10 +677,18 @@ describe.concurrent("early-script", () => { code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = exec.exec(); // 触发envInfo - exec.updateEarlyScriptGMInfo(envInfo); + expect( + exec.reconcileEarlyScript(envInfo, { + ...script, + scriptRevision: "script-uuid:1:0", + executionHandle: "page-binding", + executionEnvTag: "it", + executionRunFlag: "page-run", + } as any) + ).toBe(true); expect(await ret).toEqual(123); }); }); @@ -434,7 +711,7 @@ describe.concurrent("GM_menu", () => { code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const retPromise = exec.exec(); // 验证 sendMessage 是否被调用 @@ -470,6 +747,39 @@ describe.concurrent("GM_menu", () => { expect(await retPromise).toEqual(123); }); + it.concurrent("注册菜单不会执行选项 getter", async () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_registerMenuCommand"]; + script.code = ` + let getterCalls = 0; + const options = { accessKey: "s" }; + Object.defineProperty(options, "secret", { enumerable: true, get() { getterCalls += 1; return "forged"; } }); + GM_registerMenuCommand("safe", () => {}, options); + return getterCalls; + `; + const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const mockMessage = { sendMessage: mockSendMessage } as unknown as Message; + const exec = new ExecScript(script, { + envPrefix: "scripting", + message: mockMessage, + contentMsg: undefined as any, + code: nilFn, + envInfo, + }); + exec.scriptFunc = compileScript(compileScriptCode(script), true); + + await expect(exec.exec()).resolves.toBe(0); + expect(mockSendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + api: "GM_registerMenuCommand", + params: [expect.any(String), "safe", expect.objectContaining({ accessKey: "s" })], + }), + }) + ); + expect(mockSendMessage.mock.calls[0][0].data.params[2]).not.toHaveProperty("secret"); + }); + it.concurrent("取消注册菜单", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_registerMenuCommand", "GM_unregisterMenuCommand"]; @@ -489,7 +799,7 @@ describe.concurrent("GM_menu", () => { code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = exec.exec(); // 验证 sendMessage 是否被调用 expect(mockSendMessage).toHaveBeenCalled(); @@ -516,7 +826,7 @@ describe.concurrent("GM_menu", () => { code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const retPromise = exec.exec(); // 验证 sendMessage 是否被调用 @@ -584,13 +894,75 @@ describe.concurrent("GM_menu", () => { code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(ret).toEqual({ id1: "abc", id2: "abc", id3: 1, id4: 2, id5: "3", id6: 3, id7: 3, id8: 4 }); }); }); describe.concurrent("GM_value", () => { + it.each(["__proto__", "constructor", "prototype"])("stores %s as an ordinary value key", (key) => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_getValue", "GM_setValue"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const stored = { leaked: "secret" }; + + api.GM_setValue(api, key, stored); + + expect(Object.prototype.hasOwnProperty.call(script.value, key)).toBe(true); + expect(Object.getPrototypeOf(script.value)).toBe(Object.prototype); + expect(api.GM_getValue(api, key)).toEqual(stored); + expect(api.GM_getValue(api, "leaked")).toBeUndefined(); + }); + + it("returns a null-prototype dictionary for GM_getValues null/undefined and GM.getValues", async () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_getValues", "GM.getValues"] }, + value: { alpha: 1, nested: { beta: 2 } }, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + + for (const input of [null, undefined] as const) { + const legacy = api.GM_getValues(api, input); + expect(Object.getPrototypeOf(legacy)).toBeNull(); + expect(legacy).toEqual({ alpha: 1, nested: { beta: 2 } }); + expect((legacy as any).hasOwnProperty).toBeUndefined(); + + const modern = await api["GM.getValues"](api, input); + expect(Object.getPrototypeOf(modern)).toBeNull(); + expect(modern).toEqual({ alpha: 1, nested: { beta: 2 } }); + expect((modern as any).hasOwnProperty).toBeUndefined(); + } + }); + + it("returns __proto__ as an own key without changing the result prototype", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_getValue", "GM_setValue", "GM_getValues"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const stored = { leaked: "secret" }; + + api.GM_setValue(api, "__proto__", stored); + + const selected = api.GM_getValues(api, ["__proto__"]); + const defaults = Object.create(null) as Record; + defaults.__proto__ = "fallback"; + const withDefaults = api.GM_getValues(api, defaults); + + expect(Object.getPrototypeOf(selected)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(selected, "__proto__")).toBe(true); + expect(selected.__proto__).toEqual(stored); + expect(Object.getPrototypeOf(withDefaults)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(withDefaults, "__proto__")).toBe(true); + expect(withDefaults.__proto__).toEqual(stored); + }); + it.concurrent("GM_setValue", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValue", "GM_setValue"]; @@ -618,7 +990,7 @@ describe.concurrent("GM_value", () => { code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(mockSendMessage).toHaveBeenCalled(); @@ -652,14 +1024,14 @@ describe.concurrent("GM_value", () => { }) ); - // 第三次调用:设置值为 Proxy 对象(应失败) + // 第三次调用:plain-object Proxy 走 JSON fallback,保持 userscript compatibility。 expect(mockSendMessage).toHaveBeenNthCalledWith( 3, expect.objectContaining({ action: "scripting/runtime/gmApi", data: { api: "GM_setValue", - params: [expect.any(String), "proxy-key", {}], // Proxy 会被转换为空对象 + params: [expect.any(String), "proxy-key", {}], runFlag: expect.any(String), uuid: undefined, }, @@ -753,7 +1125,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(mockSendMessage).toHaveBeenCalled(); @@ -846,6 +1218,312 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 }); }); + it("GM_setValue preserves legacy getter clone semantics at the userscript boundary", () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_getValue", "GM_setValue"]; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const getter = vi.fn(() => "secret"); + const payload = { normal: 1 } as Record; + Object.defineProperty(payload, "secret", { configurable: true, enumerable: true, get: getter }); + + api.GM_setValue(api, "compat", payload); + + expect(getter).toHaveBeenCalledTimes(1); + expect(api.GM_getValue(api, "compat")).toEqual({ normal: 1, secret: "secret" }); + }); + + it("GM_setValue JSON-falls back for Proxy-wrapped plain objects", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_getValue", "GM_setValue"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const traps = { ownKeys: 0, getOwnPropertyDescriptor: 0, get: 0 }; + const payload = new Proxy( + { a: 1, nested: { b: 2 } }, + { + ownKeys(target) { + traps.ownKeys += 1; + return Reflect.ownKeys(target); + }, + getOwnPropertyDescriptor(target, key) { + traps.getOwnPropertyDescriptor += 1; + return Reflect.getOwnPropertyDescriptor(target, key); + }, + get(target, key, receiver) { + traps.get += 1; + return Reflect.get(target, key, receiver); + }, + } + ); + + api.GM_setValue(api, "proxy", payload); + + expect(api.GM_getValue(api, "proxy")).toEqual({ a: 1, nested: { b: 2 } }); + expect(traps.ownKeys).toBeGreaterThan(0); + expect(traps.getOwnPropertyDescriptor).toBeGreaterThan(0); + expect(traps.get).toBeGreaterThan(0); + }); + + it("GM.setValues turns accessor failures into Promise rejection instead of a synchronous throw", async () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM.setValues"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const payload = {} as Record; + Object.defineProperty(payload, "broken", { + configurable: true, + enumerable: true, + get() { + throw new Error("accessor failed"); + }, + }); + + let result!: Promise; + expect(() => { + result = api["GM.setValues"](api, payload); + }).not.toThrow(); + + await expect(result).rejects.toThrow("accessor failed"); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it("GM_setValues invokes top-level enumerable accessors once", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_setValues"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const getter = vi.fn(() => "secret"); + const payload = { valid: 1 } as Record; + Object.defineProperty(payload, "secret", { configurable: true, enumerable: true, get: getter }); + + api.GM_setValues(api, payload); + + expect(getter).toHaveBeenCalledTimes(1); + expect(script.value).toEqual({ valid: 1, secret: "secret" }); + }); + + it("GM_getValue does not substitute the default for an existing undefined value", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_getValue"] }, + value: Object.create(null), + }) as ScriptLoadInfo; + Object.defineProperty(script.value, "symbol", { + configurable: true, + enumerable: true, + writable: true, + value: undefined, + }); + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + + expect(api.GM_listValues(api)).toContain("symbol"); + expect(api.GM_getValue(api, "symbol", "__DEFAULT__")).toBeUndefined(); + }); + + it("GM_setValues does not trust a hooked Array.prototype.push for transport", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_setValues"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const originalPush = Array.prototype.push; + Array.prototype.push = function (...items: unknown[]): number { + return originalPush.call(this, ...items, ["injected", encodeRValue("forged")]); + }; + + try { + api.GM_setValues(api, { valid: 1 }); + } finally { + Array.prototype.push = originalPush; + } + + expect(script.value).toEqual({ valid: 1 }); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ params: [expect.any(String), [["valid", [0, 1]]]] }) }) + ); + }); + + it.each([ + ["Function", "executable", () => "secret"], + ["BigInt", "bigint", 123n], + ["Symbol", "symbol", Symbol("secret")], + ] as const)("normalizes a top-level %s to ScriptCat delete semantics immediately", (_type, key, value) => { + const script = Object.assign({}, scriptRes, { value: { [key]: "OLD" } }) as ScriptLoadInfo; + script.metadata.grant = ["GM_setValue"]; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + + api.GM_setValue(api, key, value); + + expect(Object.hasOwn(script.value, key)).toBe(false); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ params: [expect.any(String), key] }) }) + ); + }); + + it("normalizes nested unsupported values with Tampermonkey-style plain object/array semantics", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_getValue", "GM_setValue"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + + api.GM_setValue(api, "object", { + before: 1, + fn() { + return 2; + }, + symbol: Symbol("nested"), + undef: undefined, + after: 3, + deep: { + before: 4, + fn() { + return 5; + }, + symbol: Symbol("deep"), + undef: undefined, + after: 6, + }, + }); + api.GM_setValue(api, "array", [1, () => 2, Symbol("nested"), undefined, 5]); + + expect(api.GM_getValue(api, "object")).toEqual({ + before: 1, + after: 3, + deep: { before: 4, after: 6 }, + }); + expect(api.GM_getValue(api, "array")).toEqual([1, null, null, null, 5]); + }); + + it("GM_setValues deletes Function/Symbol entries after normalization instead of keeping transient undefined", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_setValues"] }, + value: { fn: "OLD_FN", symbol: "OLD_SYMBOL", keep: "OLD_KEEP" }, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + + api.GM_setValues(api, { + fn: () => "new", + symbol: Symbol("new"), + keep: "NEW_KEEP", + }); + + expect(script.value).toEqual({ keep: "NEW_KEEP" }); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + params: [ + expect.any(String), + [ + ["fn", encodeRValue(undefined)], + ["symbol", encodeRValue(undefined)], + ["keep", encodeRValue("NEW_KEEP")], + ], + ], + }), + }) + ); + }); + + it("normalizes every non-array object to enumerable own string properties", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_getValue", "GM_setValue"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + + const error = new Error("hidden message") as Error & { extra?: string }; + error.extra = "visible"; + class Box { + visible = 7; + } + + api.GM_setValue(api, "special", { + date: new Date("2024-01-02T03:04:05.000Z"), + invalidDate: new Date(Number.NaN), + url: new URL("https://example.com/path"), + regexp: /probe/gi, + map: new Map([["a", 1]]), + set: new Set(["x"]), + typed: new Uint8Array([9, 8, 7]), + error, + box: new Box(), + }); + + expect(api.GM_getValue(api, "special")).toEqual({ + date: {}, + invalidDate: {}, + url: {}, + regexp: {}, + map: {}, + set: {}, + typed: { 0: 9, 1: 8, 2: 7 }, + error: { extra: "visible" }, + box: { visible: 7 }, + }); + }); + + it("rejects cyclic GM storage graphs instead of preserving them through structuredClone", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_getValue", "GM_setValue"] }, + value: { cyclic: "OLD" }, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const cyclic: Record = { value: 1 }; + cyclic.self = cyclic; + + api.GM_setValue(api, "cyclic", cyclic); + + expect(api.GM_getValue(api, "cyclic", "MISSING")).toBe("MISSING"); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ params: [expect.any(String), "cyclic"] }) }) + ); + }); + + it("canonicalizes negative zero to positive zero for single and batch writes", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_getValue", "GM_setValue", "GM_setValues"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + + api.GM_setValue(api, "single", -0); + api.GM_setValues(api, { batch: -0, nested: { value: -0 }, array: [-0] }); + + expect(Object.is(api.GM_getValue(api, "single"), -0)).toBe(false); + expect(api.GM_getValue(api, "single")).toBe(0); + expect(api.GM_getValue(api, "batch")).toBe(0); + expect(api.GM_getValue(api, "nested")).toEqual({ value: 0 }); + expect(api.GM_getValue(api, "array")).toEqual([0]); + }); + + it("GM_setValues deletes existing falsy values when given undefined", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_setValues"] }, + value: { zero: 0, no: false, empty: "", nil: null }, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + + api.GM_setValues(api, { zero: undefined, no: undefined, empty: undefined, nil: undefined }); + + expect(script.value).toEqual({}); + }); + it.concurrent("GM_setValues", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValues", "GM_setValues"]; @@ -873,7 +1551,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(mockSendMessage).toHaveBeenCalled(); @@ -928,7 +1606,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 }) ); - // 第三次调用:设置值为 Proxy 对象(应失败) + // 第三次调用:plain-object Proxy 走 JSON fallback。 expect(mockSendMessage).toHaveBeenNthCalledWith( 3, expect.objectContaining({ @@ -1001,7 +1679,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(mockSendMessage).toHaveBeenCalled(); @@ -1075,7 +1753,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(mockSendMessage).toHaveBeenCalled(); @@ -1137,6 +1815,9 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 const script = Object.assign({ uuid: uuidv4() }, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValue", "GM_setValue", "GM_addValueChangeListener"]; script.metadata.storageName = ["testStorage"]; + script.executionHandle = "page-binding"; + script.executionEnvTag = "it"; + script.executionRunFlag = "canonical-run"; script.code = ` return new Promise(resolve=>{ GM_addValueChangeListener("param1", (name, oldValue, newValue, remote)=>{ @@ -1156,7 +1837,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const retPromise = exec.exec(); expect(mockSendMessage).toHaveBeenCalledTimes(1); // 模拟值变化 @@ -1165,7 +1846,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 entries: [["param1", encodeRValue(123), encodeRValue(undefined)]], uuid: script.uuid, storageName: script.uuid, - sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, + sender: { runFlag: script.executionRunFlag, tabId: -2 }, valueUpdated: true, }); const ret = await retPromise; @@ -1195,7 +1876,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); // remote = true const retPromise = exec.exec(); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -1211,11 +1892,36 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 const ret2 = await retPromise; expect(ret2).toEqual({ name: "param2", oldValue: undefined, newValue: 456, remote: true }); }); - it.concurrent("异步GM.setValue,等待回调", async () => { + + it.concurrent("value change listeners receive snapshots instead of the cached object", () => { + const script = Object.assign({ uuid: uuidv4() }, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_getValue", "GM_addValueChangeListener"]; + script.value = {}; + const api = new GMApi("test", {} as Message, {} as Message, script); + api.GM_addValueChangeListener(api, "snapshot", (_name, _oldValue, newValue) => { + const snapshot = newValue as { nested: { value: number } }; + snapshot.nested.value = 99; + }); + + api.valueUpdate({ + entries: [["snapshot", encodeRValue({ nested: { value: 1 } }), encodeRValue(undefined)]], + uuid: script.uuid, + storageName: script.uuid, + sender: { runFlag: "remote", tabId: -2 }, + valueUpdated: true, + }); + + expect(api.GM_getValue(api, "snapshot")).toEqual({ nested: { value: 1 } }); + }); + it.concurrent("异步GM.setValue,等待RPC完成而不是valueUpdate广播", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM.getValue", "GM.setValue"]; script.code = `await GM.setValue("a", 123); return await GM.getValue("a");`; - const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 }); + let resolveRpc!: (value: unknown) => void; + const rpcBarrier = new Promise((resolve) => { + resolveRpc = resolve; + }); + const mockSendMessage = vi.fn().mockReturnValue(rpcBarrier); const mockMessage = { sendMessage: mockSendMessage, } as unknown as Message; @@ -1226,34 +1932,132 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 code: nilFn, envInfo, }); - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const retPromise = exec.exec(); - await Promise.resolve(); // 等待一轮微任务,让GM.setValue执行 + await Promise.resolve(); - expect(mockSendMessage).toHaveBeenCalled(); expect(mockSendMessage).toHaveBeenCalledTimes(1); - // 获取调用参数 - const actualCall = mockSendMessage.mock.calls[0][0]; - const id = actualCall.data.params[0]; - - expect(id).toBeTypeOf("string"); - expect(id.length).greaterThan(0); - // 触发valueUpdate - exec.valueUpdate({ - id: id, - entries: [["a", encodeRValue(123), encodeRValue(undefined)]], - uuid: script.uuid, - storageName: script.uuid, - sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, - valueUpdated: true, + let settled = false; + void retPromise.then(() => { + settled = true; }); + await Promise.resolve(); + expect(settled).toBe(false); + resolveRpc({ code: 0 }); const ret = await retPromise; expect(ret).toEqual(123); }); }); +describe("GM_value hostile intrinsics", () => { + it("GM_getValues 直接赋值到 result 时不会触发 Object.prototype 上的继承 setter", () => { + // result 是 Native.objectCreate(null) 建出的纯字典,setOwnValue 改成直接赋值后, + // 即使 Object.prototype 被投毒了同名 setter,写入也必须落在 result 的自有属性上, + // 不会被继承 setter 拦截——这正是 result 在数组路径和默认值路径都保持空原型的原因。 + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_getValue", "GM_setValue", "GM_getValues"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + api.GM_setValue(api, "poisonedKey", "own-value"); + + const previousDescriptor = Object.getOwnPropertyDescriptor(Object.prototype, "poisonedKey"); + let setterCalls = 0; + Object.defineProperty(Object.prototype, "poisonedKey", { + configurable: true, + set() { + setterCalls += 1; + }, + }); + let selected: Record; + let withDefault: Record; + try { + selected = api.GM_getValues(api, ["poisonedKey"]); + withDefault = api.GM_getValues(api, { poisonedKey: "fallback" }); + } finally { + if (previousDescriptor) Object.defineProperty(Object.prototype, "poisonedKey", previousDescriptor); + else Reflect.deleteProperty(Object.prototype, "poisonedKey"); + } + + expect(setterCalls).toBe(0); + expect(Object.getPrototypeOf(selected!)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(selected!, "poisonedKey")).toBe(true); + expect(selected!.poisonedKey).toBe("own-value"); + expect(Object.getPrototypeOf(withDefault!)).toBeNull(); + expect(withDefault!.poisonedKey).toBe("own-value"); + }); + + it("GM_setValues avoids inherited numeric setters for its entry arrays", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_setValues"] }, + value: {}, + }) as ScriptLoadInfo; + const api = new GMApi("test", {} as Message, {} as Message, script as unknown as ScriptRunResource); + let sentParams: unknown[] | undefined; + api.sendMessage = (_name: string, params: any[]) => { + sentParams = params; + return Promise.resolve(undefined); + }; + let setterCalls = 0; + + const restoreArrayIndex = installArrayPrototypeIndexAccessor({ + set() { + setterCalls += 1; + }, + }); + try { + api.GM_setValues(api, { valid: 1 }); + } finally { + restoreArrayIndex(); + } + + expect(setterCalls).toBe(0); + expect(sentParams).toEqual([expect.any(String), [["valid", [0, 1]]]]); + }); +}); + +describe("GM_openInTab DTO", () => { + it("does not execute accessor options", () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_openInTab"]; + const getter = vi.fn(() => "forged"); + const options = { active: true } as Record; + Object.defineProperty(options, "secret", { enumerable: true, configurable: true, get: getter }); + const sendMessage = vi.fn().mockResolvedValue(1); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script); + + api.GM_openInTab(api, "https://example.com", options as never); + + expect(getter).not.toHaveBeenCalled(); + const sentOptions = sendMessage.mock.calls[0][0].data.params[1]; + expect(sentOptions.active).toBe(true); + expect(Object.getOwnPropertyDescriptor(sentOptions, "secret")).toBeUndefined(); + }); +}); + +describe("GM_notification DTO", () => { + it("does not execute accessor details", async () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_notification"]; + const getter = vi.fn(() => "forged"); + const details = { text: "safe" } as Record; + Object.defineProperty(details, "secret", { enumerable: true, configurable: true, get: getter }); + const sendMessage = vi.fn().mockResolvedValue("notification-id"); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script); + + api.GM_notification(api, details as never); + await Promise.resolve(); + + expect(getter).not.toHaveBeenCalled(); + const sentDetails = sendMessage.mock.calls[0][0].data.params[0]; + expect(sentDetails.text).toBe("safe"); + expect(Object.getOwnPropertyDescriptor(sentDetails, "secret")).toBeUndefined(); + }); +}); + describe("@grant GM_download", () => { it("空 url 应触发 onerror 而不是发起下载(GM_download)", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; @@ -1275,7 +2079,7 @@ describe("@grant GM_download", () => { }); setTimeout(() => resolve({ onloadCalled, error: "TIMEOUT" }), 100); })`; - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(ret.onloadCalled).toEqual(false); expect(ret.error).toEqual("unknown"); @@ -1295,7 +2099,7 @@ describe("@grant GM_download", () => { () => ({ resolved: true }), () => ({ resolved: false }) )`; - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(ret.resolved).toEqual(false); }); @@ -1317,7 +2121,7 @@ describe("@grant CAT.agent.conversation", () => { create: typeof CAT.agent.conversation.create, get: typeof CAT.agent.conversation.get, }`; - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(ret.CAT).toEqual("object"); expect(ret.create).toEqual("function"); @@ -1349,7 +2153,7 @@ describe("@grant CAT.agent.dom", () => { scroll: typeof CAT.agent.dom.scroll, waitFor: typeof CAT.agent.dom.waitFor, }`; - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(ret.CAT).toEqual("object"); expect(ret.agent).toEqual("object"); @@ -1380,7 +2184,7 @@ describe("@grant CAT.agent.dom", () => { envInfo, }); script.code = `return CAT.agent.dom.readPage({ tabId: 1, mode: "summary", maxLength: 2000 });`; - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(ret).toEqual({ title: "Test", url: "https://example.com" }); expect(mockSendMessage).toHaveBeenCalledWith( @@ -1411,7 +2215,7 @@ describe("@grant CAT.agent.dom", () => { envInfo, }); script.code = `return { hasCat: typeof CAT !== "undefined" && CAT?.agent?.dom?.readPage !== undefined }`; - exec.scriptFunc = compileScript(compileScriptCode(script)); + exec.scriptFunc = compileScript(compileScriptCode(script), true); const ret = await exec.exec(); expect(ret.hasCat).toEqual(false); }); diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index d9c52c8e4..8c2489e54 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -1,4 +1,4 @@ -import { customClone, Native } from "../global"; +import { customClone, installTrustedDataPropertiesStrict, nativeApply, Native } from "../global"; import type { Message, MessageConnect } from "@Packages/message/types"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import type { @@ -9,7 +9,6 @@ import type { SWScriptMenuItemOption, TScriptMenuItemID, TScriptMenuItemKey, - MessageRequest, } from "@App/app/service/service_worker/types"; import { base64ToBlob, randNum, randomMessageFlag, strToBase64 } from "@App/pkg/utils/utils"; import LoggerCore from "@App/app/logger/core"; @@ -19,12 +18,13 @@ import { type ScriptRunResource } from "@App/app/repo/scripts"; import type { ValueUpdateDataEncoded } from "../types"; import { connect, sendMessage } from "@Packages/message/client"; import { ScriptEnvTag } from "@Packages/message/consts"; +import { isExtensionBlobUrl } from "../page_rpc"; import { getStorageName } from "@App/pkg/utils/utils"; import { ListenerManager } from "../listener_manager"; import { decodeRValue, encodeRValue, type REncoded } from "@App/pkg/utils/message_value"; import { type TGMKeyValue } from "@App/app/repo/value"; import type { ContextType } from "./gm_xhr"; -import { convObjectToURL, GM_xmlhttpRequest, toBlobURL, urlToDocumentInContentPage } from "./gm_xhr"; +import { convObjectToURL, GM_xmlhttpRequest, parseSerializedDocumentResponse, toBlobURL } from "./gm_xhr"; // 导入 CAT Agent API 以触发装饰器注册 // 注意:不能使用 import "./cat_agent",sideEffects 配置会导致 tree-shaking 移除纯副作用导入 import CATAgentApi from "./cat_agent"; @@ -59,12 +59,144 @@ let valChangeCounterId = 0; let valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; -const valueChangePromiseMap = new Map(); +const copyOwnEnumerableDataProperties = (value: object): Record => { + const result = Native.objectCreate(null) as Record; + const keys = Native.reflectOwnKeys(value); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key !== "string") continue; + const descriptor = Native.objectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; + result[key] = descriptor.value; + } + return result; +}; + +const setOwnValue = (store: Record, key: string, value: any): void => { + Native.objectDefineProperty(store, key, { + configurable: true, + enumerable: true, + writable: true, + value, + }); +}; + +const gmStorageOmit = Native.objectCreate(null); +const gmStorageInvalid = Native.objectCreate(null); + +const normalizeGMStorageCompatibilityValue = ( + value: any, + active: WeakMap = new Native.WeakMap() +): any => { + if (value === undefined || typeof value === "function" || typeof value === "symbol") { + return gmStorageOmit; + } + if (value === null || typeof value !== "object") { + // Tampermonkey canonicalizes negative zero when values cross the GM storage boundary. + return typeof value === "number" && value === 0 && 1 / value === -Infinity ? 0 : value; + } + + // GM storage is serialization, not graph cloning. Reject cycles but serialize repeated + // non-cyclic references independently after their recursion frame has completed. + if (active.has(value)) return gmStorageInvalid; + active.set(value, true); + + try { + if (Native.arrayIsArray(value)) { + const result: any[] = []; + const length = Native.reflectGet(value, "length") as number; + for (let index = 0; index < length; index += 1) { + const normalized = normalizeGMStorageCompatibilityValue(Native.reflectGet(value, `${index}`), active); + if (normalized === gmStorageInvalid) return gmStorageInvalid; + Native.objectDefineProperty(result, index, { + configurable: true, + enumerable: true, + writable: true, + value: normalized === gmStorageOmit ? null : normalized, + }); + } + return result; + } + + // Tampermonkey treats every non-array object as an enumerable-own-string-property bag. + // Date/URL/Map/Set/RegExp/ArrayBuffer/etc. therefore become {} unless they expose their + // own enumerable properties; typed arrays naturally keep their enumerable numeric keys. + const result = Native.objectCreate(null) as Record; + const keys = Native.reflectOwnKeys(value); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key !== "string") continue; + const descriptor = Native.objectGetOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable) continue; + const normalized = normalizeGMStorageCompatibilityValue(Native.reflectGet(value, key), active); + if (normalized === gmStorageInvalid) return gmStorageInvalid; + if (normalized === gmStorageOmit) continue; + setOwnValue(result, key, normalized); + } + return result; + } finally { + active.delete(value); + } +}; + +/** + * GM storage is a userscript compatibility boundary. Arrays retain array shape while every + * non-array object is serialized as its own enumerable string-keyed property bag. Unsupported + * nested values are omitted from objects and become null in arrays; cyclic graphs are rejected. + * + * Proxy/accessor reads are intentionally observable here. If normalization itself throws, + * retain the historical structured-clone/JSON fallback for hostile inputs. + */ +const cloneGMStorageValue = (value: any): any => { + if (value === null) return value; + const valueType = typeof value; + // Top-level BigInt follows the same ScriptCat approximation used for other TM-invalid + // top-level values. Nested BigInt remains intentionally unspecified until measured directly. + if (valueType === "function" || valueType === "symbol" || valueType === "bigint") return undefined; + if (valueType !== "object") { + return valueType === "number" && value === 0 && 1 / value === -Infinity ? 0 : value; + } + + let cloneSource = value; + try { + const normalized = normalizeGMStorageCompatibilityValue(value); + if (normalized === gmStorageInvalid || normalized === gmStorageOmit) return undefined; + cloneSource = normalized; + } catch { + // Preserve the existing fallback behavior for hostile Proxy/accessor inputs. + } + + if (Native.structuredClone) { + try { + return Native.structuredClone(cloneSource); + } catch { + // Proxy and some legacy-compatible values cannot be structured-cloned. + } + } + + try { + return Native.jsonParse(Native.jsonStringify(cloneSource)); + } catch { + return undefined; + } +}; + +// 通知 ID 只属于对应 GM context;WeakMap 不让脚本结束后残留监听状态。 +const notificationTagMaps = new Native.WeakMap>(); + +const getNotificationTagMap = (owner: object): Map => { + let map = notificationTagMaps.get(owner); + if (!map) { + map = new Native.Map(); + notificationTagMaps.set(owner, map); + } + return map; +}; const execEnvInit = (execEnv: GMApi) => { if (!execEnv.contentEnvKey) { execEnv.contentEnvKey = randomMessageFlag(); // 不重复识别字串。用于区分 mainframe subframe 等执行环境 - execEnv.menuKeyRegistered = new Set(); + execEnv.menuKeyRegistered = new Native.Set(); execEnv.menuIdCounter = 0; execEnv.regMenuCounter = 0; } @@ -79,6 +211,9 @@ class GM_Base implements IGM_Base { @GMContext.protected() protected prefix!: string; + @GMContext.protected() + protected pageRpcSequence = 0; + // Extension Context 无效时释放 scriptRes @GMContext.protected() protected message?: Message | null; @@ -92,7 +227,7 @@ class GM_Base implements IGM_Base { // Extension Context 无效时释放 valueChangeListener @GMContext.protected() - protected valueChangeListener?: ListenerManager; + protected valueChangeListener?: ListenerManager; // Extension Context 无效时释放 EE @GMContext.protected() @@ -113,9 +248,14 @@ class GM_Base implements IGM_Base { @GMContext.protected() protected loadScriptPromise: Promise | undefined; + @GMContext.protected() + protected pendingEarlyValueKeys: Set | undefined; + constructor(options: any = null, obj: any = null) { if (obj !== integrity) throw new TypeError("Illegal invocation"); - Object.assign(this, options); + // options 是 createContext() 构造的内部纯数据初始化选项,this 是内部可信实例: + // 任何无法安全重定义的既有属性都说明契约被破坏,直接失败,绝不调用继承的 setter。 + installTrustedDataPropertiesStrict(this, options); } @GMContext.protected() @@ -132,18 +272,49 @@ class GM_Base implements IGM_Base { // 单次回调使用 @GMContext.protected() public async sendMessage(api: string, params: any[]) { + if (this.isInvalidContext()) { + return; + } if (!this.message || !this.scriptRes) return; if (this.loadScriptPromise) { await this.loadScriptPromise; } + if (this.isInvalidContext() || !this.message || !this.scriptRes) { + return; + } + // USER_SCRIPT 自己的 realm 已有 DOM 与 fetch;这些辅助操作必须留在本地, + // 不能改走只有隔离 broker 才实现的内部 CAT service worker 请求。 + if (this.scriptRes.executionEnvTag === ScriptEnvTag.content) { + if (api === "CAT_fetchBlob") { + if (!isExtensionBlobUrl(params[0])) throw new Error("CAT_fetchBlob expects an extension blob URL"); + return fetch(params[0]).then((response) => response.blob()); + } + if (api === "CAT_createBlobUrl") { + if (typeof URL.createObjectURL !== "function") throw new Error("Blob URLs are unavailable in USER_SCRIPT"); + return URL.createObjectURL(params[0] as Blob); + } + } let ret; try { - ret = await sendMessage(this.message, `${this.prefix}/runtime/gmApi`, { - uuid: this.scriptRes.uuid, - api, - params, - runFlag: this.runFlag, - } as MessageRequest); + // 有页面句柄时走版本化 RPC;后台脚本和未迁移上下文继续使用旧请求形状。 + // wire 身份只携带 handle:SW 端用它加上真实 sender 解析出 canonical + // uuid/runFlag/envTag,页面不能预先带上这些字段来冒充身份。 + const executionHandle = this.scriptRes.executionHandle; + const request = executionHandle + ? { + version: 2 as const, + sequence: ++this.pageRpcSequence, + handle: executionHandle, + api, + params, + } + : { + uuid: this.scriptRes.uuid, + api, + params, + runFlag: this.runFlag, + }; + ret = await sendMessage(this.message, `${this.prefix}/runtime/gmApi`, request); } catch (e: any) { if (`${e?.message || e}`.includes("Extension context invalidated.")) { this.setInvalidContext(); // 之后不再进行 sendMessage 跟 EE操作 @@ -157,31 +328,42 @@ class GM_Base implements IGM_Base { // 长连接使用,connect只用于接受消息,不发送消息 @GMContext.protected() - public connect(api: string, params: any[]) { + public async connect(api: string, params: any[]) { + if (this.isInvalidContext()) throw new Error("Invalid Context"); + if (!this.message || !this.scriptRes) return new Promise(() => {}); + if (this.loadScriptPromise) { + await this.loadScriptPromise; + } + if (this.isInvalidContext()) throw new Error("Invalid Context"); if (!this.message || !this.scriptRes) return new Promise(() => {}); - return connect(this.message, `${this.prefix}/runtime/gmApi`, { - uuid: this.scriptRes.uuid, - api, - params, - runFlag: this.runFlag, - } as MessageRequest); + // 长连接也必须携带同一页面句柄,否则 broker 无法把连接绑定回脚本和文档。 + // wire 身份只携带 handle,理由同 sendMessage()。 + const executionHandle = this.scriptRes.executionHandle; + const request = executionHandle + ? { + version: 2 as const, + sequence: ++this.pageRpcSequence, + handle: executionHandle, + api, + params, + } + : { + uuid: this.scriptRes.uuid, + api, + params, + runFlag: this.runFlag, + }; + return connect(this.message, `${this.prefix}/runtime/gmApi`, request); } @GMContext.protected() public valueUpdate(data: ValueUpdateDataEncoded) { if (!this.scriptRes || !this.valueChangeListener) return; const scriptRes = this.scriptRes; - const { id, uuid, entries, storageName, sender, valueUpdated } = data; + const { uuid, entries, storageName, sender, valueUpdated } = data; if (uuid === scriptRes.uuid || storageName === getStorageName(scriptRes)) { const valueStore = scriptRes.value; const remote = sender.runFlag !== this.runFlag; - if (!remote && id) { - const fn = valueChangePromiseMap.get(id); - if (fn) { - valueChangePromiseMap.delete(id); - fn(); - } - } if (valueUpdated) { const valueChanges = entries; for (const [key, rTyped1, rTyped2] of valueChanges) { @@ -189,13 +371,16 @@ class GM_Base implements IGM_Base { const oldValue = decodeRValue(rTyped2); // 触发,并更新值 if (value === undefined) { - if (valueStore[key] !== undefined) { + if (Native.objectHasOwn(valueStore, key)) { delete valueStore[key]; } } else { - valueStore[key] = value; + setOwnValue(valueStore, key, value); } - this.valueChangeListener.execute(key, oldValue, value, remote, sender.tabId); + // 监听器属于脚本,传副本避免回调修改 GM 存储或跨 context 共享对象。 + const listenerValue = value && typeof value === "object" ? customClone(value) : value; + const listenerOldValue = oldValue && typeof oldValue === "object" ? customClone(oldValue) : oldValue; + this.valueChangeListener.execute(key, listenerOldValue, listenerValue, remote, sender.tabId); } } } @@ -204,17 +389,14 @@ class GM_Base implements IGM_Base { @GMContext.protected() emitEvent(event: string, eventId: string, data: any) { if (!this.EE) return; - this.EE.emit(`${event}:${eventId}`, data); + // 事件回调同样不能拿到 broker 内部对象的可变引用。 + const callbackData = data && typeof data === "object" ? customClone(data) : data; + this.EE.emit(`${event}:${eventId}`, callbackData); } } // GMApi 定义 外部用API函数。不使用@protected export default class GMApi extends GM_Base { - /** - * - */ - notificationTagMap?: Map; - constructor( public prefix: string, public message: Message, @@ -222,7 +404,7 @@ export default class GMApi extends GM_Base { public scriptRes: ScriptRunResource ) { // testing only 仅供测试用 - const valueChangeListener = new ListenerManager(); + const valueChangeListener = new ListenerManager(); const EE = new EventEmitter(); let invalid = false; super( @@ -232,7 +414,6 @@ export default class GMApi extends GM_Base { scriptRes, valueChangeListener, EE, - notificationTagMap: new Map(), eventId: 0, setInvalidContext() { if (invalid) return; @@ -255,161 +436,176 @@ export default class GMApi extends GM_Base { static _GM_getValue(a: GMApi, key: string, defaultValue?: any) { if (!a.scriptRes) return undefined; + if (!Native.objectHasOwn(a.scriptRes.value, key)) return defaultValue; const ret = a.scriptRes.value[key]; - if (ret !== undefined) { - if (ret && typeof ret === "object") { - return customClone(ret)!; - } - return ret; + if (ret && typeof ret === "object") { + return customClone(ret)!; } - return defaultValue; + // An own undefined value is observably different from a missing key: + // GM_listValues still lists it and GM_getValue must not substitute defaultValue. + return ret; } // 获取脚本的值,可以通过@storageName让多个脚本共享一个储存空间 @GMContext.API() - public GM_getValue(key: string, defaultValue?: any) { - return _GM_getValue(this, key, defaultValue); + public GM_getValue(ctx: GMApi, key: string, defaultValue?: any) { + return _GM_getValue(ctx, key, defaultValue); } @GMContext.API() - public "GM.getValue"(key: string, defaultValue?: any): Promise { + public "GM.getValue"(ctx: GMApi, key: string, defaultValue?: any): Promise { // 兼容GM.getValue return new Promise((resolve) => { - const ret = _GM_getValue(this, key, defaultValue); + const ret = _GM_getValue(ctx, key, defaultValue); resolve(ret); }); } - static _GM_setValue(a: GMApi, promise: any, key: string, value: any) { + static _GM_setValue(a: GMApi, key: string, value: any): Promise { key = `${key}`; - if (!a.scriptRes) return; + if (!a.scriptRes) return Promise.resolve(); + // Before the authoritative page bootstrap resolves, GM_setValue must still have immediate + // local semantics. Remember the touched key so reconciliation cannot overwrite this write. + a.pendingEarlyValueKeys?.add(key); if (valChangeCounterId > 1e8) { // 防止 valChangeCounterId 过大导致无法正常工作 valChangeCounterId = 0; valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; } const id = `${valChangeRandomId}::${++valChangeCounterId}`; - if (promise) { - valueChangePromiseMap.set(id, promise); - } + // Normalize before mutating local state. ScriptCat intentionally keeps its historical + // top-level undefined=delete contract, so unsupported top-level values normalize to the + // same delete semantics instead of creating a transient own-undefined entry. + value = cloneGMStorageValue(value); if (value === undefined) { - delete a.scriptRes.value[key]; - a.sendMessage("GM_setValue", [id, key]); - } else { - // 对object的value进行一次转化 - if (value && typeof value === "object") { - value = customClone(value); - } - // customClone 可能返回 undefined - a.scriptRes.value[key] = value; - if (value === undefined) { - a.sendMessage("GM_setValue", [id, key]); - } else { - a.sendMessage("GM_setValue", [id, key, value]); - } + if (Native.objectHasOwn(a.scriptRes.value, key)) delete a.scriptRes.value[key]; + return a.sendMessage("GM_setValue", [id, key]); } - return id; + + setOwnValue(a.scriptRes.value, key, value); + return a.sendMessage("GM_setValue", [id, key, value]); } - static _GM_setValues(a: GMApi, promise: any, values: TGMKeyValue) { - if (!a.scriptRes) return; + static _GM_setValues(a: GMApi, values: TGMKeyValue): Promise { + if (!a.scriptRes) return Promise.resolve(); if (valChangeCounterId > 1e8) { // 防止 valChangeCounterId 过大导致无法正常工作 valChangeCounterId = 0; valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; } const id = `${valChangeRandomId}::${++valChangeCounterId}`; - if (promise) { - valueChangePromiseMap.set(id, promise); - } const valueStore = a.scriptRes.value; const keyValuePairs = [] as [string, REncoded][]; - for (const [key, value] of Object.entries(values)) { - let value_ = value; + // Snapshot own enumerable string entries with ordinary property-read semantics. + // This intentionally executes a userscript-supplied getter/Proxy get trap once, matching + // Object.entries/Tampermonkey behavior, while still avoiding mutable Array.prototype helpers. + const valueEntries: [string, unknown][] = []; + const valueKeys = Native.reflectOwnKeys(values); + for (let index = 0; index < valueKeys.length; index += 1) { + const key = valueKeys[index]; + if (typeof key !== "string") continue; + const descriptor = Native.objectGetOwnPropertyDescriptor(values, key); + if (!descriptor || !descriptor.enumerable) continue; + Native.objectDefineProperty(valueEntries, valueEntries.length, { + configurable: true, + enumerable: true, + writable: true, + value: [key, Native.reflectGet(values, key)], + }); + } + for (let index = 0; index < valueEntries.length; index += 1) { + const [key, value] = valueEntries[index]; + // Same compatibility rule as GM_setValue: the userscript-visible local write wins until + // the queued authoritative write is flushed after bootstrap. + a.pendingEarlyValueKeys?.add(key); + const value_ = cloneGMStorageValue(value); if (value_ === undefined) { - if (valueStore[key]) delete valueStore[key]; + if (Native.objectHasOwn(valueStore, key)) delete valueStore[key]; } else { - // 对object的value进行一次转化 - if (value_ && typeof value_ === "object") { - value_ = customClone(value_); - } - // customClone 可能返回 undefined - valueStore[key] = value_; + setOwnValue(valueStore, key, value_); } // 避免undefined 等空值流失,先进行映射处理 - keyValuePairs.push([key, encodeRValue(value_)]); + // Keep this a real array for transport while avoiding inherited index setters. + Native.objectDefineProperty(keyValuePairs, keyValuePairs.length, { + configurable: true, + enumerable: true, + writable: true, + value: [key, encodeRValue(value_)], + }); } - a.sendMessage("GM_setValues", [id, keyValuePairs]); - return id; + return a.sendMessage("GM_setValues", [id, keyValuePairs]); } @GMContext.API() - public GM_setValue(key: string, value: any) { - _GM_setValue(this, null, key, value); + public GM_setValue(ctx: GMApi, key: string, value: any) { + void _GM_setValue(ctx, key, value); } @GMContext.API() - public "GM.setValue"(key: string, value: any): Promise { - // Asynchronous wrapper for GM_setValue to support GM.setValue - return new Promise((resolve) => { - _GM_setValue(this, resolve, key, value); - }); + public async "GM.setValue"(ctx: GMApi, key: string, value: any): Promise { + await _GM_setValue(ctx, key, value); } @GMContext.API() - public GM_deleteValue(key: string): void { - _GM_setValue(this, null, key, undefined); + public GM_deleteValue(ctx: GMApi, key: string): void { + void _GM_setValue(ctx, key, undefined); } @GMContext.API() - public "GM.deleteValue"(key: string): Promise { - // Asynchronous wrapper for GM_deleteValue to support GM.deleteValue - return new Promise((resolve) => { - _GM_setValue(this, resolve, key, undefined); - }); + public async "GM.deleteValue"(ctx: GMApi, key: string): Promise { + await _GM_setValue(ctx, key, undefined); } @GMContext.API() - public GM_listValues(): string[] { - if (!this.scriptRes) return []; - const keys = Object.keys(this.scriptRes.value); + public GM_listValues(ctx: GMApi): string[] { + if (!ctx.scriptRes) return []; + const keys = Native.objectKeys(ctx.scriptRes.value); return keys; } @GMContext.API() - public "GM.listValues"(): Promise { + public "GM.listValues"(ctx: GMApi): Promise { // Asynchronous wrapper for GM_listValues to support GM.listValues return new Promise((resolve) => { - if (!this.scriptRes) return resolve([]); - const keys = Object.keys(this.scriptRes.value); + if (!ctx.scriptRes) return resolve([]); + const keys = Native.objectKeys(ctx.scriptRes.value); resolve(keys); }); } @GMContext.API() - public GM_setValues(values: TGMKeyValue) { + public GM_setValues(ctx: GMApi, values: TGMKeyValue) { if (!values || typeof values !== "object") { throw new Error("GM_setValues: values must be an object"); } - _GM_setValues(this, null, values); + void _GM_setValues(ctx, values); } @GMContext.API() - public GM_getValues(keysOrDefaults: TGMKeyValue | string[] | null | undefined) { - if (!this.scriptRes) return {}; + public GM_getValues(ctx: GMApi, keysOrDefaults: TGMKeyValue | string[] | null | undefined) { + if (!ctx.scriptRes) return {}; + // All GM_getValues shapes use the same null-prototype dictionary contract. + const result: TGMKeyValue = Native.objectCreate(null); if (!keysOrDefaults) { - // Returns all values - return customClone(this.scriptRes.value)!; + for (const key of Native.objectKeys(ctx.scriptRes.value)) { + let value = ctx.scriptRes.value[key]; + if (value && typeof value === "object") { + value = customClone(value)!; + } + setOwnValue(result, key, value); + } + return result; } - const result: TGMKeyValue = {}; - if (Array.isArray(keysOrDefaults)) { + // result 是 Native.objectCreate(null) 建出的纯字典,没有可被继承 setter 或 __proto__ + // 劫持的原型,直接赋值即可,不需要逐键 defineProperty。 + if (Native.arrayIsArray(keysOrDefaults)) { // 键名数组 // Handle array of keys (e.g., ['foo', 'bar']) for (let index = 0; index < keysOrDefaults.length; index++) { const key = keysOrDefaults[index]; - if (key in this.scriptRes.value) { + if (Native.objectHasOwn(ctx.scriptRes.value, key)) { // 对object的value进行一次转化 - let value = this.scriptRes.value[key]; + let value = ctx.scriptRes.value[key]; if (value && typeof value === "object") { value = customClone(value)!; } @@ -419,39 +615,42 @@ export default class GMApi extends GM_Base { } else { // 对象 键: 默认值 // Handle object with default values (e.g., { foo: 1, bar: 2, baz: 3 }) - for (const key of Object.keys(keysOrDefaults)) { + for (const key of Native.objectKeys(keysOrDefaults)) { const defaultValue = keysOrDefaults[key]; - result[key] = _GM_getValue(this, key, defaultValue); + result[key] = _GM_getValue(ctx, key, defaultValue); } } return result; } // Asynchronous wrapper for GM.getValues - @GMContext.API({ depend: ["GM_getValues"] }) - public "GM.getValues"(keysOrDefaults: TGMKeyValue | string[] | null | undefined): Promise { - if (!this.scriptRes) return new Promise(() => {}); + @GMContext.API() + public "GM.getValues"(ctx: GMApi, keysOrDefaults: TGMKeyValue | string[] | null | undefined): Promise { + if (!ctx.scriptRes) { + return ctx.isInvalidContext() ? Promise.resolve({}) : new Promise(() => {}); + } return new Promise((resolve) => { - const ret = this.GM_getValues(keysOrDefaults); + const ret = GMApi.prototype.GM_getValues(ctx, keysOrDefaults); resolve(ret); }); } @GMContext.API() - public "GM.setValues"(values: { [key: string]: any }): Promise { - if (!this.scriptRes) return new Promise(() => {}); - return new Promise((resolve) => { - if (!values || typeof values !== "object") { - throw new Error("GM.setValues: values must be an object"); - } - _GM_setValues(this, resolve, values); - }); + public async "GM.setValues"(ctx: GMApi, values: { [key: string]: any }): Promise { + if (!ctx.scriptRes) { + if (ctx.isInvalidContext()) return; + return new Promise(() => {}); + } + if (!values || typeof values !== "object") { + throw new Error("GM.setValues: values must be an object"); + } + await _GM_setValues(ctx, values); } @GMContext.API() - public GM_deleteValues(keys: string[]) { - if (!this.scriptRes) return; - if (!Array.isArray(keys)) { + public GM_deleteValues(ctx: GMApi, keys: string[]) { + if (!ctx.scriptRes) return; + if (!Native.arrayIsArray(keys)) { console.warn("GM_deleteValues: keys must be string[]"); return; } @@ -459,94 +658,111 @@ export default class GMApi extends GM_Base { for (const key of keys) { req[key] = undefined; } - _GM_setValues(this, null, req); + void _GM_setValues(ctx, req); } // Asynchronous wrapper for GM.deleteValues @GMContext.API() - public "GM.deleteValues"(keys: string[]): Promise { - if (!this.scriptRes) return new Promise(() => {}); - return new Promise((resolve) => { - if (!Array.isArray(keys)) { - throw new Error("GM.deleteValues: keys must be string[]"); - } else { - const req = {} as Record; - for (const key of keys) { - req[key] = undefined; - } - _GM_setValues(this, resolve, req); - } - }); + public async "GM.deleteValues"(ctx: GMApi, keys: string[]): Promise { + if (!ctx.scriptRes) { + if (ctx.isInvalidContext()) return; + return new Promise(() => {}); + } + if (!Native.arrayIsArray(keys)) { + throw new Error("GM.deleteValues: keys must be string[]"); + } + const req = {} as Record; + for (const key of keys) { + req[key] = undefined; + } + await _GM_setValues(ctx, req); } @GMContext.API() - public GM_addValueChangeListener(name: string, listener: GMTypes.ValueChangeListener): number { - if (!this.valueChangeListener) return 0; - return this.valueChangeListener.add(name, listener); + public GM_addValueChangeListener(ctx: GMApi, name: string, listener: GMTypes.ValueChangeListener): number { + if (!ctx.valueChangeListener) return 0; + return ctx.valueChangeListener.add(name, listener); } - @GMContext.API({ depend: ["GM_addValueChangeListener"] }) - public "GM.addValueChangeListener"(name: string, listener: GMTypes.ValueChangeListener): Promise { + @GMContext.API() + public "GM.addValueChangeListener"(ctx: GMApi, name: string, listener: GMTypes.ValueChangeListener): Promise { return new Promise((resolve) => { - const ret = this.GM_addValueChangeListener(name, listener); + const ret = GMApi.prototype.GM_addValueChangeListener(ctx, name, listener); resolve(ret); }); } @GMContext.API() - public GM_removeValueChangeListener(listenerId: number): void { - if (!this.valueChangeListener) return; - this.valueChangeListener.remove(listenerId); + public GM_removeValueChangeListener(ctx: GMApi, listenerId: number): void { + if (!ctx.valueChangeListener) return; + ctx.valueChangeListener.remove(listenerId); } - @GMContext.API({ depend: ["GM_removeValueChangeListener"] }) - public "GM.removeValueChangeListener"(listenerId: number): Promise { + @GMContext.API() + public "GM.removeValueChangeListener"(ctx: GMApi, listenerId: number): Promise { return new Promise((resolve) => { - this.GM_removeValueChangeListener(listenerId); + GMApi.prototype.GM_removeValueChangeListener(ctx, listenerId); resolve(); }); } @GMContext.API() - public GM_log(message: string, level: GMTypes.LoggerLevel = "info", ...labels: GMTypes.LoggerLabel[]): void { - if (this.isInvalidContext()) return; + public GM_log( + ctx: GMApi, + message: string, + level: GMTypes.LoggerLevel = "info", + ...labels: GMTypes.LoggerLabel[] + ): void { + if (ctx.isInvalidContext()) return; if (typeof message !== "string") { message = Native.jsonStringify(message); } - this.sendMessage("GM_log", [`${message}`, `${level}`, labels]); + ctx.sendMessage("GM_log", [`${message}`, `${level}`, labels]); } - @GMContext.API({ depend: ["GM_log"] }) + @GMContext.API() public "GM.log"( + ctx: GMApi, message: string, level: GMTypes.LoggerLevel = "info", ...labels: GMTypes.LoggerLabel[] ): Promise { return new Promise((resolve) => { - this.GM_log(message, level, ...labels); + GMApi.prototype.GM_log(ctx, message, level, ...labels); resolve(); }); } @GMContext.API() - public CAT_createBlobUrl(blob: Blob): Promise { - return Promise.resolve(toBlobURL(this, blob)); + public CAT_createBlobUrl(ctx: GMApi, blob: Blob): Promise { + return Promise.resolve(toBlobURL(ctx, blob)); } // 辅助GM_xml获取blob数据 @GMContext.API() - public CAT_fetchBlob(url: string): Promise { - return this.sendMessage("CAT_fetchBlob", [`${url}`]); + public CAT_fetchBlob(ctx: GMApi, url: string): Promise { + return ctx.sendMessage("CAT_fetchBlob", [`${url}`]); } @GMContext.API() - public async CAT_fetchDocument(url: string): Promise { + public async CAT_fetchDocument(ctx: GMApi, url: string): Promise { // 上下文已失效时直接返回,避免访问已释放的 message 造成异常 - if (this.isInvalidContext()) return undefined; + if (ctx.isInvalidContext()) return undefined; + + const isContentEnv = ctx.scriptRes?.executionEnvTag === ScriptEnvTag.content; + if (isContentEnv) { + // USER_SCRIPT 可直接在 content realm 创建 Document;跨到 scripting 只会丢失节点引用。 + return new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.responseType = "document"; + xhr.open("GET", url); + xhr.onloadend = () => resolve((xhr.response as Document | null) || undefined); + xhr.onerror = () => resolve(undefined); + xhr.send(); + }); + } - const message = this.message as CustomEventMessage | null; - const isContentEnv = !!message && message.envTag === ScriptEnvTag.content; - return urlToDocumentInContentPage(this, url, isContentEnv); + return parseSerializedDocumentResponse(await ctx.sendMessage("CAT_fetchDocument", [`${url}`, isContentEnv])); } static _GM_cookie( @@ -583,36 +799,36 @@ export default class GMApi extends GM_Base { } @GMContext.API() - public "GM.cookie"(action: string, details: GMTypes.CookieDetails) { + public "GM.cookie"(ctx: GMApi, action: string, details: GMTypes.CookieDetails) { return new Promise((resolve, reject) => { - _GM_cookie(this, action, details, (cookie, error) => { + _GM_cookie(ctx, action, details, (cookie, error) => { error ? reject(error) : resolve(cookie); }); }); } @GMContext.API({ follow: "GM.cookie" }) - public "GM.cookie.set"(details: GMTypes.CookieDetails) { + public "GM.cookie.set"(ctx: GMApi, details: GMTypes.CookieDetails) { return new Promise((resolve, reject) => { - _GM_cookie(this, "set", details, (cookie, error) => { + _GM_cookie(ctx, "set", details, (cookie, error) => { error ? reject(error) : resolve(cookie); }); }); } @GMContext.API({ follow: "GM.cookie" }) - public "GM.cookie.list"(details: GMTypes.CookieDetails) { + public "GM.cookie.list"(ctx: GMApi, details: GMTypes.CookieDetails) { return new Promise((resolve, reject) => { - _GM_cookie(this, "list", details, (cookie, error) => { + _GM_cookie(ctx, "list", details, (cookie, error) => { error ? reject(error) : resolve(cookie); }); }); } @GMContext.API({ follow: "GM.cookie" }) - public "GM.cookie.delete"(details: GMTypes.CookieDetails) { + public "GM.cookie.delete"(ctx: GMApi, details: GMTypes.CookieDetails) { return new Promise((resolve, reject) => { - _GM_cookie(this, "delete", details, (cookie, error) => { + _GM_cookie(ctx, "delete", details, (cookie, error) => { error ? reject(error) : resolve(cookie); }); }); @@ -620,35 +836,39 @@ export default class GMApi extends GM_Base { @GMContext.API({ follow: "GM_cookie" }) public "GM_cookie.set"( + ctx: GMApi, details: GMTypes.CookieDetails, done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void ) { - _GM_cookie(this, "set", details, done); + _GM_cookie(ctx, "set", details, done); } @GMContext.API({ follow: "GM_cookie" }) public "GM_cookie.list"( + ctx: GMApi, details: GMTypes.CookieDetails, done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void ) { - _GM_cookie(this, "list", details, done); + _GM_cookie(ctx, "list", details, done); } @GMContext.API({ follow: "GM_cookie" }) public "GM_cookie.delete"( + ctx: GMApi, details: GMTypes.CookieDetails, done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void ) { - _GM_cookie(this, "delete", details, done); + _GM_cookie(ctx, "delete", details, done); } @GMContext.API() public GM_cookie( + ctx: GMApi, action: string, details: GMTypes.CookieDetails, done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void ) { - _GM_cookie(this, action, details, done); + _GM_cookie(ctx, action, details, done); } // 已注册的「菜单唯一键」集合,用于去重与解除绑定。 @@ -670,32 +890,43 @@ export default class GMApi extends GM_Base { @GMContext.API() public GM_registerMenuCommand( + ctx: GMApi, name: string, listener?: (inputValue?: any) => void, options_or_accessKey?: ScriptMenuItemOption | string ): TScriptMenuItemID { - if (!this.EE) return -1; - execEnvInit(this); - this.regMenuCounter! += 1; + if (!ctx.EE) return -1; + execEnvInit(ctx); + ctx.regMenuCounter! += 1; // 兼容 GM_registerMenuCommand(name, options_or_accessKey) if (!options_or_accessKey && typeof listener === "object") { options_or_accessKey = listener; listener = undefined; } // 浅拷贝避免修改/共用参数 - const options: SWScriptMenuItemOption = ( - typeof options_or_accessKey === "string" - ? { accessKey: options_or_accessKey } - : options_or_accessKey - ? { ...options_or_accessKey, id: undefined, individual: undefined } // id不直接储存在options (id 影响 groupKey 操作) - : {} - ) as ScriptMenuItemOption; + const optionObject = typeof options_or_accessKey === "object" && options_or_accessKey !== null; + let options: SWScriptMenuItemOption; + let optionId: string | number | undefined; + let optionIndividual: boolean | undefined; + if (typeof options_or_accessKey === "string") { + options = { accessKey: options_or_accessKey }; + } else if (optionObject) { + const safeOptions = copyOwnEnumerableDataProperties(options_or_accessKey as object); + optionId = safeOptions.id as string | number | undefined; + optionIndividual = safeOptions.individual as boolean | undefined; + // id不直接储存在options (id 影响 groupKey 操作) + safeOptions.id = undefined; + safeOptions.individual = undefined; + options = safeOptions as SWScriptMenuItemOption; + } else { + options = {}; + } const isSeparator = !listener && !name; - let isIndividual = typeof options_or_accessKey === "object" ? options_or_accessKey.individual : undefined; + let isIndividual = optionObject ? optionIndividual : undefined; if (isIndividual === undefined && isSeparator) { isIndividual = true; } - options.mIndividualKey = isIndividual ? this.regMenuCounter : 0; + options.mIndividualKey = isIndividual ? ctx.regMenuCounter : 0; if (options.autoClose === undefined) { options.autoClose = true; } @@ -710,54 +941,57 @@ export default class GMApi extends GM_Base { } else { options.mSeparator = false; } - let providedId: string | number | undefined = - typeof options_or_accessKey === "object" ? options_or_accessKey.id : undefined; - if (providedId === undefined) providedId = this.menuIdCounter! += 1; // 如无指定,使用累计器id + let providedId: string | number | undefined = optionObject ? optionId : undefined; + if (providedId === undefined) providedId = ctx.menuIdCounter! += 1; // 如无指定,使用累计器id const ret = providedId! as TScriptMenuItemID; providedId = `t${providedId!}`; // 见 TScriptMenuItemID 注释 - providedId = `${this.contentEnvKey!}.${providedId}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 + providedId = `${ctx.contentEnvKey!}.${providedId}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 const menuKey = providedId; // menuKey为唯一键:{环境识别符}.t{注册ID} // 检查之前有否注册 - if (menuKey && this.menuKeyRegistered!.has(menuKey)) { + if (menuKey && ctx.menuKeyRegistered!.has(menuKey)) { // 有注册过,先移除 listeners - this.EE.removeAllListeners("menuClick:" + menuKey); + ctx.EE.removeAllListeners("menuClick:" + menuKey); } else { // 没注册过,先记录一下 - this.menuKeyRegistered!.add(menuKey); + ctx.menuKeyRegistered!.add(menuKey); } if (listener) { // GM_registerMenuCommand("hi", undefined, {accessKey:"h"}) 时TM不会报错 - this.EE.addListener("menuClick:" + menuKey, listener); + ctx.EE.addListener("menuClick:" + menuKey, listener); } // 发送至 service worker 处理(唯一键,显示名字,不包括id的其他设定) - this.sendMessage("GM_registerMenuCommand", [menuKey, `${name}`, options] as GMRegisterMenuCommandParam); + ctx.sendMessage("GM_registerMenuCommand", [menuKey, `${name}`, options] as GMRegisterMenuCommandParam); return ret; } - @GMContext.API({ depend: ["GM_registerMenuCommand"] }) + @GMContext.API() public "GM.registerMenuCommand"( + ctx: GMApi, name: string, listener?: (inputValue?: any) => void, options_or_accessKey?: ScriptMenuItemOption | string ): Promise { return new Promise((resolve) => { - const ret = this.GM_registerMenuCommand(name, listener, options_or_accessKey); + const ret = GMApi.prototype.GM_registerMenuCommand(ctx, name, listener, options_or_accessKey); resolve(ret); }); } - @GMContext.API({ depend: ["GM_registerMenuCommand"] }) - public CAT_registerMenuInput(...args: Parameters): TScriptMenuItemID { - return this.GM_registerMenuCommand(...args); + @GMContext.API() + public CAT_registerMenuInput( + ctx: GMApi, + ...args: [name: string, listener?: (inputValue?: any) => void, options_or_accessKey?: ScriptMenuItemOption | string] + ): TScriptMenuItemID { + return GMApi.prototype.GM_registerMenuCommand(ctx, ...args); } @GMContext.API() - public GM_addStyle(css: string): Element | undefined { - if (!this.message || !this.scriptRes) return; + public GM_addStyle(ctx: GMApi, css: string): Element | undefined { + if (!ctx.message || !ctx.scriptRes) return; if (typeof css !== "string") throw new Error("The parameter 'css' of GM_addStyle shall be a string."); // 与content页的消息通讯实际是同步,此方法不需要经过background // 这里直接使用同步的方式去处理, 不要有promise - const resp = (this.contentMsg).syncSendMessage({ + const resp = (ctx.contentMsg).syncSendMessage({ action: `content/runtime/addElement`, data: { params: [ @@ -772,24 +1006,25 @@ export default class GMApi extends GM_Base { if (resp.code) { throw new Error(resp.message); } - return (this.contentMsg).getAndDelRelatedTarget(resp.data) as Element; + return (ctx.contentMsg).getAndDelRelatedTarget(resp.data) as Element; } - @GMContext.API({ depend: ["GM_addStyle"] }) - public "GM.addStyle"(css: string): Promise { + @GMContext.API() + public "GM.addStyle"(ctx: GMApi, css: string): Promise { return new Promise((resolve) => { - const ret = this.GM_addStyle(css); + const ret = GMApi.prototype.GM_addStyle(ctx, css); resolve(ret); }); } @GMContext.API() public GM_addElement( + ctx: GMApi, parentNode: Node | string, tagName: string | Record, attrs: Record | null = {} ): Element | undefined { - if (!this.message || !this.scriptRes) return; + if (!ctx.message || !ctx.scriptRes) return; // 与content页的消息通讯实际是同步, 此方法不需要经过background // 这里直接使用同步的方式去处理, 不要有promise // 在content脚本执行的话,与直接 DOM 无异 @@ -799,7 +1034,7 @@ export default class GMApi extends GM_Base { let parentNodeId: number | null; if (typeof parentNode !== "string") { - const id = (this.contentMsg).sendRelatedTarget(parentNode); + const id = (ctx.contentMsg).sendRelatedTarget(parentNode); parentNodeId = id; } else { parentNodeId = null; @@ -813,22 +1048,30 @@ export default class GMApi extends GM_Base { } // 控制传送参数,避免参数出现 non-json-selizable - const attrsCT = {} as Record; - const setAttr = {} as Record; - for (const [key, value] of Object.entries(attrs as Record)) { - if (typeof value === "string" || typeof value === "number") { - // 数字不是标准的 attribute value type, 但常见于实际使用 - attrsCT[key] = value; - } else { - // property setter for non attribute (e.g. Function, Symbol, boolean, etc) - // Function, Symbol 无法跨环境传递 - setAttr[key] = value; + const attrsCT = Native.objectCreate(null) as Record; + const setAttr = Native.objectCreate(null) as Record; + if (attrs !== null) { + const keys = Native.reflectOwnKeys(attrs); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key !== "string") continue; + const descriptor = Native.objectGetOwnPropertyDescriptor(attrs, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; + const value = descriptor.value; + if (typeof value === "string" || typeof value === "number") { + // 数字不是标准的 attribute value type, 但常见于实际使用 + attrsCT[key] = value; + } else { + // property setter for non attribute (e.g. Function, Symbol, boolean, etc) + // Function, Symbol 无法跨环境传递 + setAttr[key] = value; + } } } // 使用contentMsg同步发送消息到content脚本,由content脚本创建元素并返回 // 不使用message,因为message是在scripting环境处理的,会因为扩展的 CSP 而无法操作 DOM - const resp = (this.contentMsg).syncSendMessage({ + const resp = (ctx.contentMsg).syncSendMessage({ action: `content/runtime/addElement`, data: { params: [parentNodeId, tagName, attrsCT], @@ -838,7 +1081,7 @@ export default class GMApi extends GM_Base { throw new Error(resp.message); } - const el = (this.contentMsg).getAndDelRelatedTarget(resp.data) as Element; + const el = (ctx.contentMsg).getAndDelRelatedTarget(resp.data) as Element; // 设置属性 for (const [key, value] of Object.entries(setAttr)) { (el as any)[key] = value; @@ -848,58 +1091,55 @@ export default class GMApi extends GM_Base { return el; } - @GMContext.API({ depend: ["GM_addElement"] }) + @GMContext.API() public "GM.addElement"( + ctx: GMApi, parentNode: Node | string, tagName: string | Record, attrs: Record | null = {} ): Promise { return new Promise((resolve) => { - const ret = this.GM_addElement(parentNode, tagName, attrs); + const ret = GMApi.prototype.GM_addElement(ctx, parentNode, tagName, attrs); resolve(ret); }); } @GMContext.API() - public GM_unregisterMenuCommand(menuId: TScriptMenuItemID): void { - if (!this.EE) return; - if (!this.contentEnvKey) { + public GM_unregisterMenuCommand(ctx: GMApi, menuId: TScriptMenuItemID): void { + if (!ctx.EE) return; + if (!ctx.contentEnvKey) { return; } let menuKey = `t${menuId}`; // 见 TScriptMenuItemID 注释 - menuKey = `${this.contentEnvKey!}.${menuKey}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 - this.menuKeyRegistered!.delete(menuKey); - this.EE.removeAllListeners("menuClick:" + menuKey); + menuKey = `${ctx.contentEnvKey!}.${menuKey}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 + ctx.menuKeyRegistered!.delete(menuKey); + ctx.EE.removeAllListeners("menuClick:" + menuKey); // 发送至 service worker 处理(唯一键) - this.sendMessage("GM_unregisterMenuCommand", [menuKey] as GMUnRegisterMenuCommandParam); + ctx.sendMessage("GM_unregisterMenuCommand", [menuKey] as GMUnRegisterMenuCommandParam); } - @GMContext.API({ depend: ["GM_unregisterMenuCommand"] }) - public "GM.unregisterMenuCommand"(menuId: TScriptMenuItemID): Promise { + @GMContext.API() + public "GM.unregisterMenuCommand"(ctx: GMApi, menuId: TScriptMenuItemID): Promise { return new Promise((resolve) => { - this.GM_unregisterMenuCommand(menuId); + GMApi.prototype.GM_unregisterMenuCommand(ctx, menuId); resolve(); }); } - @GMContext.API({ - depend: ["GM_unregisterMenuCommand"], - }) - public CAT_unregisterMenuInput(...args: Parameters): void { - this.GM_unregisterMenuCommand(...args); + @GMContext.API() + public CAT_unregisterMenuInput(ctx: GMApi, menuId: TScriptMenuItemID): void { + GMApi.prototype.GM_unregisterMenuCommand(ctx, menuId); } @GMContext.API() - public CAT_userConfig() { - return this.sendMessage("CAT_userConfig", []); + public CAT_userConfig(ctx: GMApi) { + return ctx.sendMessage("CAT_userConfig", []); } - @GMContext.API({ - depend: ["CAT_fetchBlob"], - }) - public async CAT_fileStorage(action: "list" | "download" | "upload" | "delete" | "config", details: any) { + @GMContext.API() + public async CAT_fileStorage(ctx: GMApi, action: "list" | "download" | "upload" | "delete" | "config", details: any) { if (action === "config") { - this.sendMessage("CAT_fileStorage", ["config"]); + ctx.sendMessage("CAT_fileStorage", ["config"]); return; } const sendDetails: CATType.CATFileStorageDetails = { @@ -909,44 +1149,42 @@ export default class GMApi extends GM_Base { file: details.file, }; if (action === "upload") { - const url = await toBlobURL(this, details.data); + const url = await toBlobURL(ctx, details.data); sendDetails.data = url; } - this.sendMessage("CAT_fileStorage", [`${action}`, sendDetails]).then( - async (resp: { action: string; data: any }) => { - switch (resp.action) { - case "onload": { - if (action === "download") { - // 读取blob - const blob = await this.CAT_fetchBlob(resp.data); - details.onload && details.onload(blob); - } else { - details.onload && details.onload(resp.data); - } - break; + ctx.sendMessage("CAT_fileStorage", [`${action}`, sendDetails]).then(async (resp: { action: string; data: any }) => { + switch (resp.action) { + case "onload": { + if (action === "download") { + // 读取blob + const blob = await GMApi.prototype.CAT_fetchBlob(ctx, resp.data); + details.onload && details.onload(blob); + } else { + details.onload && details.onload(resp.data); } - case "error": { - if (typeof resp.data.code === "undefined") { - details.onerror && details.onerror({ code: -1, message: resp.data.message }); - return; - } - details.onerror && details.onerror(resp.data); + break; + } + case "error": { + if (typeof resp.data.code === "undefined") { + details.onerror && details.onerror({ code: -1, message: resp.data.message }); + return; } + details.onerror && details.onerror(resp.data); } } - ); + }); } // 用于脚本跨域请求,需要@connect domain指定允许的域名 @GMContext.API() - public GM_xmlhttpRequest(details: GMTypes.XHRDetails) { - const { abort } = GM_xmlhttpRequest(this, details, false); + public GM_xmlhttpRequest(ctx: GMApi, details: GMTypes.XHRDetails) { + const { abort } = GM_xmlhttpRequest(ctx, details, false); return { abort }; } @GMContext.API() - public "GM.xmlHttpRequest"(details: GMTypes.XHRDetails): Promise & GMRequestHandle { - const { retPromise, abort } = GM_xmlhttpRequest(this, details, true); + public "GM.xmlHttpRequest"(ctx: GMApi, details: GMTypes.XHRDetails): Promise & GMRequestHandle { + const { retPromise, abort } = GM_xmlhttpRequest(ctx, details, true); const ret = retPromise as Promise & GMRequestHandle; ret.abort = abort; return ret; @@ -1220,16 +1458,16 @@ export default class GMApi extends GM_Base { // 用于脚本跨域请求,需要@connect domain指定允许的域名 @GMContext.API() - public GM_download(arg1: GMTypes.DownloadDetails | string, arg2?: string) { + public GM_download(ctx: GMApi, arg1: GMTypes.DownloadDetails | string, arg2?: string) { const details = typeof arg1 === "string" ? { url: arg1, name: arg2 } : { ...arg1 }; - const { abort } = _GM_download(this, details as GMTypes.DownloadDetails, false); + const { abort } = _GM_download(ctx, details as GMTypes.DownloadDetails, false); return { abort }; } @GMContext.API() - public "GM.download"(arg1: GMTypes.DownloadDetails | string, arg2?: string) { + public "GM.download"(ctx: GMApi, arg1: GMTypes.DownloadDetails | string, arg2?: string) { const details = typeof arg1 === "string" ? { url: arg1, name: arg2 } : { ...arg1 }; - const { retPromise, abort } = _GM_download(this, details as GMTypes.DownloadDetails, true); + const { retPromise, abort } = _GM_download(ctx, details as GMTypes.DownloadDetails, true); const ret = retPromise as Promise & GMRequestHandle; ret.abort = abort; return ret; @@ -1243,7 +1481,7 @@ export default class GMApi extends GM_Base { onclick?: GMTypes.NotificationOnClick ): Promise { if (gmApi.isInvalidContext()) return Promise.resolve(); - const notificationTagMap: Map = gmApi.notificationTagMap || (gmApi.notificationTagMap = new Map()); + const notificationTagMap = getNotificationTagMap(gmApi); gmApi.eventId += 1; let data: GMTypes.NotificationDetails; if (typeof detail === "string") { @@ -1263,7 +1501,7 @@ export default class GMApi extends GM_Base { break; } } else { - data = Object.assign({}, detail); + data = copyOwnEnumerableDataProperties(detail) as GMTypes.NotificationDetails; data.ondone = data.ondone || ondone; } let click: GMTypes.NotificationOnClick; @@ -1288,7 +1526,7 @@ export default class GMApi extends GM_Base { gmApi.sendMessage("GM_notification", [customClone(data), notificationId]).then((id) => { if (!gmApi.EE) return; if (create) { - create.apply({ id }, [id]); + nativeApply(create, { id }, [id]); } if (typeof data.tag === "string") { notificationTagMap.set(data.tag, id); @@ -1325,8 +1563,8 @@ export default class GMApi extends GM_Base { title: data.title, url: data.url, }; - click && click.apply({ id }, [clickEvent]); - done && done.apply({ id }, []); + click && nativeApply(click, { id }, [clickEvent]); + done && nativeApply(done, { id }, []); if (!isPreventDefault) { if (typeof data.url === "string") { @@ -1339,7 +1577,7 @@ export default class GMApi extends GM_Base { break; } case "close": { - done && done.apply({ id }, [resp.params.byUser]); + done && nativeApply(done, { id }, [resp.params.byUser]); clearNotificationIdMap(); gmApi.EE.removeAllListeners("GM_notification:" + gmApi.eventId); break; @@ -1357,44 +1595,46 @@ export default class GMApi extends GM_Base { @GMContext.API() public async "GM.notification"( + ctx: GMApi, detail: GMTypes.NotificationDetails | string, ondone?: GMTypes.NotificationOnDone | string, image?: string, onclick?: GMTypes.NotificationOnClick ): Promise { - return _GM_notification(this, detail, ondone, image, onclick); + return _GM_notification(ctx, detail, ondone, image, onclick); } @GMContext.API() public GM_notification( + ctx: GMApi, detail: GMTypes.NotificationDetails | string, ondone?: GMTypes.NotificationOnDone | string, image?: string, onclick?: GMTypes.NotificationOnClick ): void { - _GM_notification(this, detail, ondone, image, onclick); + _GM_notification(ctx, detail, ondone, image, onclick); } // ScriptCat 额外API @GMContext.API({ alias: "GM.closeNotification" }) - public GM_closeNotification(id: string): void { - this.sendMessage("GM_closeNotification", [`${id}`]); + public GM_closeNotification(ctx: GMApi, id: string): void { + ctx.sendMessage("GM_closeNotification", [`${id}`]); } // ScriptCat 额外API @GMContext.API({ alias: "GM.updateNotification" }) - public GM_updateNotification(id: string, details: GMTypes.NotificationDetails): void { - this.sendMessage("GM_updateNotification", [`${id}`, customClone(details)]); + public GM_updateNotification(ctx: GMApi, id: string, details: GMTypes.NotificationDetails): void { + ctx.sendMessage("GM_updateNotification", [`${id}`, customClone(details)]); } - @GMContext.API({ depend: ["GM_closeInTab"] }) - public GM_openInTab(url: string, param?: GMTypes.OpenTabOptions | boolean): GMTypes.Tab | undefined { - if (this.isInvalidContext()) return undefined; + @GMContext.API() + public GM_openInTab(ctx: GMApi, url: string, param?: GMTypes.OpenTabOptions | boolean): GMTypes.Tab | undefined { + if (ctx.isInvalidContext()) return undefined; let option = {} as GMTypes.OpenTabOptions; if (typeof param === "boolean") { option.active = !param; // Greasemonkey 3.x loadInBackground } else if (param) { - option = { ...param } as GMTypes.OpenTabOptions; + option = copyOwnEnumerableDataProperties(param) as GMTypes.OpenTabOptions; } if (typeof option.active !== "boolean" && typeof option.loadInBackground === "boolean") { // TM 同时兼容 active 和 loadInBackground ( active 优先 ) @@ -1413,19 +1653,19 @@ export default class GMApi extends GM_Base { const ret: GMTypes.Tab = { close: () => { - tabid && this.GM_closeInTab(tabid); + tabid && GMApi.prototype.GM_closeInTab(ctx, tabid); }, closed: false, // 占位 onclose() {}, }; - this.sendMessage("GM_openInTab", [url, option as GMTypes.SWOpenTabOptions]).then((id) => { - if (!this.EE) return; + ctx.sendMessage("GM_openInTab", [url, option as GMTypes.SWOpenTabOptions]).then((id) => { + if (!ctx.EE) return; if (id) { tabid = id; - this.EE.addListener("GM_openInTab:" + id, (resp: any) => { - if (!this.EE) return; + ctx.EE.addListener("GM_openInTab:" + id, (resp: any) => { + if (!ctx.EE) return; switch (resp.event) { case "oncreate": tabid = resp.tabId; @@ -1433,7 +1673,7 @@ export default class GMApi extends GM_Base { case "onclose": ret.onclose && ret.onclose(); ret.closed = true; - this.EE.removeAllListeners("GM_openInTab:" + id); + ctx.EE.removeAllListeners("GM_openInTab:" + id); break; default: LoggerCore.logger().warn("GM_openInTab resp is error", { @@ -1451,75 +1691,79 @@ export default class GMApi extends GM_Base { return ret; } - @GMContext.API({ depend: ["GM_openInTab", "GM_closeInTab"] }) - public "GM.openInTab"(url: string, param?: GMTypes.OpenTabOptions | boolean): Promise { + @GMContext.API() + public "GM.openInTab"( + ctx: GMApi, + url: string, + param?: GMTypes.OpenTabOptions | boolean + ): Promise { return new Promise((resolve) => { - const ret = this.GM_openInTab(url, param); + const ret = GMApi.prototype.GM_openInTab(ctx, url, param); resolve(ret); }); } // ScriptCat 额外API @GMContext.API({ alias: "GM.closeInTab" }) - public GM_closeInTab(tabid: string) { - if (this.isInvalidContext()) return; - return this.sendMessage("GM_closeInTab", [tabid]); + public GM_closeInTab(ctx: GMApi, tabid: string) { + if (ctx.isInvalidContext()) return; + return ctx.sendMessage("GM_closeInTab", [tabid]); } @GMContext.API() - public GM_getTab(callback: (tabData: object) => void) { - if (this.isInvalidContext()) return; - this.sendMessage("GM_getTab", []).then((tabData) => { + public GM_getTab(ctx: GMApi, callback: (tabData: object) => void) { + if (ctx.isInvalidContext()) return; + ctx.sendMessage("GM_getTab", []).then((tabData) => { callback(tabData ?? {}); }); } - @GMContext.API({ depend: ["GM_getTab"] }) - public "GM.getTab"(): Promise { + @GMContext.API() + public "GM.getTab"(ctx: GMApi): Promise { return new Promise((resolve) => { - this.GM_getTab((data) => { + GMApi.prototype.GM_getTab(ctx, (data) => { resolve(data); }); }); } @GMContext.API() - public GM_saveTab(tabData: object): void { - if (this.isInvalidContext()) return; + public GM_saveTab(ctx: GMApi, tabData: object): void { + if (ctx.isInvalidContext()) return; if (typeof tabData === "object") { tabData = customClone(tabData); } - this.sendMessage("GM_saveTab", [tabData]); + ctx.sendMessage("GM_saveTab", [tabData]); } - @GMContext.API({ depend: ["GM_saveTab"] }) - public "GM.saveTab"(tabData: object): Promise { + @GMContext.API() + public "GM.saveTab"(ctx: GMApi, tabData: object): Promise { return new Promise((resolve) => { - this.GM_saveTab(tabData); + GMApi.prototype.GM_saveTab(ctx, tabData); resolve(); }); } @GMContext.API() - public GM_getTabs(callback: (tabsData: { [key: number]: object }) => any) { - if (this.isInvalidContext()) return; - this.sendMessage("GM_getTabs", []).then((tabsData) => { + public GM_getTabs(ctx: GMApi, callback: (tabsData: { [key: number]: object }) => any) { + if (ctx.isInvalidContext()) return; + ctx.sendMessage("GM_getTabs", []).then((tabsData) => { callback(tabsData); }); } - @GMContext.API({ depend: ["GM_getTabs"] }) - public "GM.getTabs"(): Promise<{ [key: number]: object }> { + @GMContext.API() + public "GM.getTabs"(ctx: GMApi): Promise<{ [key: number]: object }> { return new Promise<{ [key: number]: object }>((resolve) => { - this.GM_getTabs((tabsData) => { + GMApi.prototype.GM_getTabs(ctx, (tabsData) => { resolve(tabsData); }); }); } @GMContext.API() - public GM_setClipboard(data: string, info?: GMTypes.GMClipboardInfo, cb?: () => void) { - if (this.isInvalidContext()) return; + public GM_setClipboard(ctx: GMApi, data: string, info?: GMTypes.GMClipboardInfo, cb?: () => void) { + if (ctx.isInvalidContext()) return; // 物件参数意义不明。日后再检视特殊处理 // 未支持 TM4.19+ application/octet-stream // 参考: https://github.com/Tampermonkey/tampermonkey/issues/1250 @@ -1532,7 +1776,8 @@ export default class GMApi extends GM_Base { else if (mimetype === "html") mimetype = "text/html"; } data = `${data}`; // 强制 string type - this.sendMessage("GM_setClipboard", [data, mimetype]) + ctx + .sendMessage("GM_setClipboard", [data, mimetype]) .then(() => { if (typeof cb === "function") { cb(); @@ -1545,37 +1790,41 @@ export default class GMApi extends GM_Base { }); } - @GMContext.API({ depend: ["GM_setClipboard"] }) - public "GM.setClipboard"(data: string, info?: string | { type?: string; mimetype?: string }): Promise { - if (this.isInvalidContext()) return new Promise(() => {}); + @GMContext.API() + public "GM.setClipboard"( + ctx: GMApi, + data: string, + info?: string | { type?: string; mimetype?: string } + ): Promise { + if (ctx.isInvalidContext()) return new Promise(() => {}); return new Promise((resolve) => { - this.GM_setClipboard(data, info, () => { + GMApi.prototype.GM_setClipboard(ctx, data, info, () => { resolve(); }); }); } @GMContext.API() - public GM_getResourceText(name: string): string | undefined { - const r = (this.scriptRes?.resourceByType?.resource ?? this.scriptRes?.resource)?.[name]; + public GM_getResourceText(ctx: GMApi, name: string): string | undefined { + const r = (ctx.scriptRes?.resourceByType?.resource ?? ctx.scriptRes?.resource)?.[name]; if (r) { return r.content; } return undefined; } - @GMContext.API({ depend: ["GM_getResourceText"] }) - public "GM.getResourceText"(name: string): Promise { + @GMContext.API() + public "GM.getResourceText"(ctx: GMApi, name: string): Promise { // Asynchronous wrapper for GM_getResourceText to support GM.getResourceText return new Promise((resolve) => { - const ret = this.GM_getResourceText(name); + const ret = GMApi.prototype.GM_getResourceText(ctx, name); resolve(ret); }); } @GMContext.API() - public GM_getResourceURL(name: string, isBlobUrl?: boolean): string | undefined { - const r = (this.scriptRes?.resourceByType?.resource ?? this.scriptRes?.resource)?.[name]; + public GM_getResourceURL(ctx: GMApi, name: string, isBlobUrl?: boolean): string | undefined { + const r = (ctx.scriptRes?.resourceByType?.resource ?? ctx.scriptRes?.resource)?.[name]; if (r) { let base64 = r.base64; if (!base64) { @@ -1590,40 +1839,40 @@ export default class GMApi extends GM_Base { return undefined; } - @GMContext.API({ depend: ["GM_getResourceURL"] }) - public "GM.getResourceURL"(name: string, isBlobUrl?: boolean): Promise { + @GMContext.API() + public "GM.getResourceURL"(ctx: GMApi, name: string, isBlobUrl?: boolean): Promise { return new Promise((resolve) => { - const ret = this.GM_getResourceURL(name, isBlobUrl); + const ret = GMApi.prototype.GM_getResourceURL(ctx, name, isBlobUrl); resolve(ret); }); } // GM_getResourceURL的异步版本,用来兼容GM.getResourceUrl - @GMContext.API({ depend: ["GM_getResourceURL"] }) - public "GM.getResourceUrl"(name: string, isBlobUrl?: boolean): Promise { + @GMContext.API() + public "GM.getResourceUrl"(ctx: GMApi, name: string, isBlobUrl?: boolean): Promise { // Asynchronous wrapper for GM_getResourceURL to support GM.getResourceURL return new Promise((resolve) => { - const ret = this.GM_getResourceURL(name, isBlobUrl); + const ret = GMApi.prototype.GM_getResourceURL(ctx, name, isBlobUrl); resolve(ret); }); } @GMContext.API() - public "window.close"() { - return this.sendMessage("window.close", []); + public "window.close"(ctx: GMApi) { + return ctx.sendMessage("window.close", []); } @GMContext.API() - public "window.focus"() { - return this.sendMessage("window.focus", []); + public "window.focus"(ctx: GMApi) { + return ctx.sendMessage("window.focus", []); } @GMContext.protected() apiLoadPromise: Promise | undefined; @GMContext.API() - public CAT_scriptLoaded() { - return this.loadScriptPromise; + public CAT_scriptLoaded(ctx: GMApi) { + return ctx.loadScriptPromise; } } diff --git a/src/app/service/content/gm_api/gm_context.ts b/src/app/service/content/gm_api/gm_context.ts index f127a99b8..791ac37e4 100644 --- a/src/app/service/content/gm_api/gm_context.ts +++ b/src/app/service/content/gm_api/gm_context.ts @@ -1,6 +1,15 @@ import type { ApiParam, ApiValue } from "../types"; +import { Native } from "../global"; +import { getApiDependencies } from "./api_dependencies"; -const apis: Map = new Map(); +const apiRegistry: Record = Native.objectCreate(null); +const apis = { + get: (name: string) => apiRegistry[name], + set: (name: string, values: ApiValue[]) => { + apiRegistry[name] = values; + }, + keys: () => Native.objectKeys(apiRegistry), +}; export function GMContextApiGet(name: string): ApiValue[] | undefined { // 回传 Api 列表 @@ -17,10 +26,10 @@ function GMContextApiSet(grant: string, fnKey: string, api: any, param: ApiParam // 一个 @grant 可以扩充多个 API 函数 let m: ApiValue[] | undefined = apis.get(grant); if (!m) apis.set(grant, (m = [])); - m.push({ fnKey, api, param }); + m[m.length] = { fnKey, api, param }; } -export const protect: { [key: string]: any } = {}; +export const protect: { [key: string]: any } = Native.objectCreate(null); export default class GMContext { public static protected(value: any = undefined) { @@ -34,15 +43,16 @@ export default class GMContext { public static API(param: ApiParam = {}) { return (target: any, propertyName: string, descriptor: PropertyDescriptor) => { const key = propertyName; - let { follow } = param; - const { alias } = param; + const apiParam = { ...param, depend: param.depend ?? getApiDependencies(key) }; + let { follow } = apiParam; + const { alias } = apiParam; if (!follow) { follow = key; // follow 是实际 @grant 的权限;使用follow时,不要使用alias以避免混乱 } - GMContextApiSet(follow, key, descriptor.value, param); + GMContextApiSet(follow, key, descriptor.value, apiParam); if (alias) { // 追加别名呼叫(参数和回传完全一致,为 GM_xxx 与 GM.xxx 等问题设计) - GMContextApiSet(alias, alias, descriptor.value, param); + GMContextApiSet(alias, alias, descriptor.value, apiParam); } }; } diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts new file mode 100644 index 000000000..16b095d12 --- /dev/null +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it, vi } from "vitest"; +import { initTestEnv } from "@Tests/utils"; +import { GM_xmlhttpRequest } from "./gm_xhr"; + +initTestEnv(); + +describe("GM_xmlhttpRequest callback cleanup", () => { + it("settles and disconnects when an error callback throws", async () => { + let onMessage!: (message: any) => void; + const connection = { + onMessage: vi.fn((callback: (message: any) => void) => { + onMessage = callback; + }), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const onloadend = vi.fn(); + const onload = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onerror: () => { + throw new Error("user callback failed"); + }, + onload, + onloadend, + }, + true + ); + + await vi.waitFor(() => expect(onMessage).toBeTypeOf("function")); + onMessage({ + code: 0, + action: "onerror", + data: { + finalUrl: "https://example.com/data", + readyState: 4, + status: 500, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "onerror", + ok: false, + contentType: "text/plain", + error: "network", + }, + }); + onMessage({ + code: 0, + action: "onload", + data: { + finalUrl: "https://example.com/data", + readyState: 4, + status: 500, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "onload", + ok: false, + contentType: "text/plain", + }, + }); + onMessage({ + code: 0, + action: "onloadend", + data: { + finalUrl: "https://example.com/data", + readyState: 4, + status: 500, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "onloadend", + ok: false, + contentType: "text/plain", + }, + }); + + await expect(request.retPromise).rejects.toBe("network"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + expect(onload).not.toHaveBeenCalled(); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it("synthesizes loadend when an error has no terminal broker event", async () => { + let onMessage!: (message: any) => void; + const connection = { + onMessage: vi.fn((callback: (message: any) => void) => { + onMessage = callback; + }), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onerror: vi.fn(), + onloadend, + }, + true + ); + + await vi.waitFor(() => expect(onMessage).toBeTypeOf("function")); + onMessage({ + code: 0, + action: "onerror", + data: { + finalUrl: "https://example.com/data", + readyState: 4, + status: 500, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "onerror", + ok: false, + contentType: "text/plain", + error: "network", + }, + }); + + await expect(request.retPromise).rejects.toBe("network"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it("aborts and releases the connection even without an onabort callback", async () => { + const connection = { + onMessage: vi.fn(), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onloadend, + }, + true + ); + + await vi.waitFor(() => expect(connection.onMessage).toHaveBeenCalled()); + request.abort(); + + await expect(request.retPromise).rejects.toBe("AbortError"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + await vi.waitFor(() => expect(onloadend).toHaveBeenCalledTimes(1)); + }); + + it("honors abort requested before the native connection is ready", async () => { + const connection = { + onMessage: vi.fn(), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onloadend, + }, + true + ); + + request.abort(); + + await expect(request.retPromise).rejects.toBe("AbortError"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + await vi.waitFor(() => expect(onloadend).toHaveBeenCalledTimes(1)); + }); + + it("settles the request when connection setup rejects", async () => { + const onerror = vi.fn(); + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockRejectedValue(new Error("connection failed")), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onerror, + onloadend, + }, + true + ); + + await expect(request.retPromise).rejects.toBe("connection failed"); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it("settles the request when data encoding rejects before connection setup", async () => { + const onerror = vi.fn(); + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn(), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + data: Promise.reject(new Error("data failed")) as unknown as GMTypes.XHRDetails["data"], + onerror, + onloadend, + }, + true + ); + + await expect(request.retPromise).rejects.toBe("data failed"); + expect(api.connect).not.toHaveBeenCalled(); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it("disconnects an established connection when listener setup throws", async () => { + const onerror = vi.fn(); + const onloadend = vi.fn(); + const connection = { + onMessage: vi.fn(() => { + throw new Error("listener setup failed"); + }), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onerror, + onloadend, + }, + true + ); + + await expect(request.retPromise).rejects.toBe("listener setup failed"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it("does not execute accessor headers while preparing the request", async () => { + const getter = vi.fn(() => "forged"); + const headers = {} as Record; + Object.defineProperty(headers, "X-Hostile", { enumerable: true, configurable: true, get: getter }); + const connection = { + onMessage: vi.fn(), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest(api as any, { url: "https://example.com/data", headers }, false); + + await vi.waitFor(() => expect(api.connect).toHaveBeenCalled()); + expect(getter).not.toHaveBeenCalled(); + expect(Object.getOwnPropertyDescriptor(api.connect.mock.calls[0][1][0].headers, "X-Hostile")).toBeUndefined(); + request.abort(); + }); +}); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index d3b147f88..32d8f7bb2 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -1,11 +1,11 @@ import { Native } from "../global"; -import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import type GMApi from "./gm_api"; import { dataEncode } from "@App/pkg/utils/xhr/xhr_data"; import type { MessageConnect, TMessage } from "@Packages/message/types"; import { base64ToUint8, concatUint8 } from "@App/pkg/utils/datatype"; import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import LoggerCore from "@App/app/logger/core"; +import Logger from "@App/app/logger/logger"; const ChunkResponseCode = { NONE: 0, @@ -112,10 +112,33 @@ export const convObjectToURL = async (object: string | URL | Blob | File | undef return url; }; -export const urlToDocumentInContentPage = async (a: GMApi, url: string, isContent: boolean) => { - // url (e.g. blob url) -> XMLHttpRequest (CONTENT) -> Document (CONTENT) - const nodeId = await a.sendMessage("CAT_fetchDocument", [`${url}`, isContent]); - return (a.message).getAndDelRelatedTarget(nodeId) as Document; +export type SerializedDocumentResponse = { + text: string; + contentType: string; +}; + +const readDataProperty = (value: object, key: string): unknown => { + try { + const descriptor = Native.objectGetOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; + } catch { + return undefined; + } +}; + +export const parseSerializedDocumentResponse = (value: unknown): Document | undefined => { + if (value === null || typeof value !== "object") return undefined; + const text = readDataProperty(value, "text"); + const contentType = readDataProperty(value, "contentType"); + if (typeof text !== "string" || typeof contentType !== "string") return undefined; + + const mime = getMimeType(contentType); + const parseType = docParseTypes.has(mime) ? (mime as DOMParserSupportedType) : "text/xml"; + try { + return new DOMParser().parseFromString(text, parseType); + } catch { + return undefined; + } }; const getMimeType = (contentType: string) => { @@ -126,9 +149,15 @@ const getMimeType = (contentType: string) => { return mime; }; -const docParseTypes = new Set(["application/xhtml+xml", "application/xml", "image/svg+xml", "text/html", "text/xml"]); +const docParseTypes = new Native.Set([ + "application/xhtml+xml", + "application/xml", + "image/svg+xml", + "text/html", + "text/xml", +]); -const retStateFnMap = new WeakMap, RetStateFnRecord>(); +const retStateFnMap = new Native.WeakMap(); interface RetStateFnRecord { getResponseText(): string | undefined; @@ -171,15 +200,26 @@ export function GM_xmlhttpRequest( requirePromise: boolean, isDownload: boolean = false ) { + const invokeCallback = (name: string, callback: ((value: any) => void) | undefined, value: any) => { + if (!callback) return; + try { + callback(value); + } catch (error) { + // User callback failures are reported without rejecting the internal + // message queue or interrupting request settlement. + LoggerCore.logger().error("GM_xmlhttpRequest callback failed", { name, ...Logger.E(error) }); + } + }; let reqDone = false; + let abortRequested = false; if (a.isInvalidContext()) { return { retPromise: requirePromise ? Promise.reject("GM_xmlhttpRequest: Invalid Context") : null, abort: () => {}, }; } - let retPromiseResolve: (value: unknown) => void | undefined; - let retPromiseReject: (reason?: any) => void | undefined; + let retPromiseResolve: ((value: unknown) => void) | undefined; + let retPromiseReject: ((reason?: any) => void) | undefined; const retPromise = requirePromise ? new Promise((resolve, reject) => { retPromiseResolve = resolve; @@ -189,11 +229,20 @@ export function GM_xmlhttpRequest( const urlPromiseLike = typeof details.url === "object" ? convObjectToURL(details.url) : details.url; const dataPromise = dataEncode(details.data); const headers = details.headers; + let requestHeaders: Record | undefined; + let requestCookie = details.cookie; if (headers) { - for (const key of Object.keys(headers)) { + requestHeaders = Native.objectCreate(null) as Record; + const keys = Native.reflectOwnKeys(headers); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key !== "string") continue; + const descriptor = Native.objectGetOwnPropertyDescriptor(headers, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; if (key.toLowerCase() === "cookie") { - details.cookie = headers[key]; - delete headers[key]; + requestCookie = descriptor.value as string; + } else { + requestHeaders[key] = descriptor.value as string; } } } @@ -206,8 +255,8 @@ export function GM_xmlhttpRequest( method: details.method, timeout: details.timeout, url: "", - headers: details.headers, - cookie: details.cookie, + headers: requestHeaders, + cookie: requestCookie, responseType: details.responseType, overrideMimeType: details.overrideMimeType, anonymous: details.anonymous, @@ -260,19 +309,30 @@ export function GM_xmlhttpRequest( } } // 发送信息 - let connectMessage: Promise; - if (isDownload) { - // 如果是下载,带上 downloadMode 参数,呼叫 SW 的 GM_download - // 在 SW 中处理,实际使用 GM_xmlhttpRequest 进行下载 - const method = param.method === "POST" ? "POST" : "GET"; - const downloadParam: GMTypes.DownloadDetails = { ...param, method, downloadMode: "native", name: "" }; - connectMessage = a.connect("GM_download", [downloadParam]); - } else { - // 一般 GM_xmlhttpRequest,呼叫 SW 的 GM_xmlhttpRequest - connectMessage = a.connect("GM_xmlhttpRequest", [param]); + try { + let connectMessage: Promise; + if (isDownload) { + // 如果是下载,带上 downloadMode 参数,呼叫 SW 的 GM_download + // 在 SW 中处理,实际使用 GM_xmlhttpRequest 进行下载 + const method = param.method === "POST" ? "POST" : "GET"; + const downloadParam: GMTypes.DownloadDetails = { ...param, method, downloadMode: "native", name: "" }; + connectMessage = a.connect("GM_download", [downloadParam]); + } else { + // 一般 GM_xmlhttpRequest,呼叫 SW 的 GM_xmlhttpRequest + connectMessage = a.connect("GM_xmlhttpRequest", [param]); + } + param = null; // GC + connect = await connectMessage; + } catch (error) { + param = null; + const message = error instanceof Error ? error.message : `${error}`; + reqDone = true; + const response = { readyState: ReadyStateCode.DONE, error: message }; + invokeCallback("onerror", details.onerror, response); + retPromiseReject?.(message); + invokeCallback("onloadend", details.onloadend, response); + return; } - param = null; // GC - connect = await connectMessage; const resultTexts = [] as string[]; // 函数参考清掉后,变数会被GC const resultBuffers = [] as Uint8Array[]; // 函数参考清掉后,变数会被GC @@ -501,6 +561,7 @@ export function GM_xmlhttpRequest( }; let makeXHRCallbackParam: typeof makeXHRCallbackParam_ | null = makeXHRCallbackParam_; let loadendCalled = false; + let loadCalled = false; const doLoadEnd = (data: TXhrCallBackArg) => { if (!loadendCalled) { loadendCalled = true; @@ -509,24 +570,35 @@ export function GM_xmlhttpRequest( finalResultBuffers = null; finalResultText = null; const xhrResponse = makeXHRCallbackParam?.(data) ?? {}; - details.onloadend?.(xhrResponse); if (errorOccur === null) { retPromiseResolve?.(xhrResponse); } else { retPromiseReject?.(errorOccur); } refCleanup?.(); + invokeCallback("onloadend", details.onloadend, xhrResponse); } }; + const scheduleSyntheticLoadEnd = () => { + // abort/error/timeout 可能没有 broker 的 onloadend,补发一次以释放连接和引用。 + Promise.resolve({ + error: "loadend", + responseHeaders: "", + readyState: 0, + status: 0, + statusText: "", + } as TXhrCallBackArg).then(doLoadEnd); + }; doAbort = (data: TXhrCallBackArg) => { if (!reqDone) { errorOccur = "AbortError"; - details.onabort?.(makeXHRCallbackParam?.(data) ?? {}); reqDone = true; + // 先标记完成再调用用户代码;回调抛错也不能留下未收尾的连接。 + invokeCallback("onabort", details.onabort, makeXHRCallbackParam?.(data) ?? {}); // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); // doAbort 不是由通讯管控 onloadend. 需要手动处理. 排程在下一个 microTask 避免影响 Abort 流程 - Promise.resolve({ ...data, type: "loadend" }).then(doLoadEnd); + scheduleSyntheticLoadEnd(); } doAbort = null; }; @@ -557,22 +629,16 @@ export function GM_xmlhttpRequest( }); if (!reqDone) { errorOccur = message; - details.onerror?.({ + reqDone = true; + invokeCallback("onerror", details.onerror, { readyState: ReadyStateCode.DONE, error: message, }); - reqDone = true; // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); // 此错误多为 API 非正常执行,估计不会有 loadend 触发。见 Aborted 处理 - Promise.resolve({ - error: "loadend", - responseHeaders: "", - readyState: 0, - status: 0, - statusText: "", - } as TXhrCallBackArg).then(doLoadEnd); + scheduleSyntheticLoadEnd(); } return; } @@ -646,14 +712,16 @@ export function GM_xmlhttpRequest( break; } case "onload": - details.onload?.(makeXHRCallbackParam?.(data) ?? {}); + if (loadCalled || reqDone) break; + loadCalled = true; + invokeCallback("onload", details.onload, makeXHRCallbackParam?.(data) ?? {}); break; case "onloadend": { doLoadEnd(data); break; } case "onloadstart": - details.onloadstart?.(makeXHRCallbackParam?.(data) ?? {}); + invokeCallback("onloadstart", details.onloadstart, makeXHRCallbackParam?.(data) ?? {}); break; case "onprogress": { if (details.onprogress) { @@ -665,7 +733,7 @@ export function GM_xmlhttpRequest( done: data.loaded, totalSize: data.total, }; - details.onprogress?.(res); + invokeCallback("onprogress", details.onprogress, res); } break; } @@ -678,14 +746,15 @@ export function GM_xmlhttpRequest( // readable stream 的 controller 可以释放 controller = undefined; // GC用 } - details.onreadystatechange?.(makeXHRCallbackParam?.(data) ?? {}); + invokeCallback("onreadystatechange", details.onreadystatechange, makeXHRCallbackParam?.(data) ?? {}); break; } case "ontimeout": if (!reqDone) { errorOccur = "TimeoutError"; - details.ontimeout?.(makeXHRCallbackParam?.(data) ?? {}); reqDone = true; + invokeCallback("ontimeout", details.ontimeout, makeXHRCallbackParam?.(data) ?? {}); + scheduleSyntheticLoadEnd(); // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); } @@ -694,10 +763,14 @@ export function GM_xmlhttpRequest( if (!reqDone) { data.error ||= "Unknown Error"; errorOccur = data.error; - details.onerror?.((makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError); reqDone = true; - // 不要进行 refCleanup !要等待最后的 onloadend - // refCleanup?.(); + invokeCallback( + "onerror", + details.onerror, + (makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError + ); + // 错误消息可能没有对应的 onloadend,补发一次以收尾并释放连接。 + scheduleSyntheticLoadEnd(); } break; case "onabort": @@ -716,16 +789,37 @@ export function GM_xmlhttpRequest( }; connect?.onMessage((msgData) => onMessageHandler?.(msgData)); - })(); + if (abortRequested && !reqDone) { + doAbort?.({ + error: "aborted", + responseHeaders: "", + readyState: 0, + status: 0, + statusText: "", + } as TXhrCallBackArg); + } + })().catch((error) => { + const pendingConnection = connect; + connect = null; + pendingConnection?.disconnect(true); + if (reqDone) return; + reqDone = true; + const message = error instanceof Error ? error.message : `${error}`; + const response = { readyState: ReadyStateCode.DONE, error: message }; + invokeCallback("onerror", details.onerror, response); + retPromiseReject?.(message); + invokeCallback("onloadend", details.onloadend, response); + }); // 由于需要同步返回一个abort,但是一些操作是异步的,所以需要在这里处理 return { retPromise, abort: () => { + abortRequested = true; if (connect) { connect.disconnect(true); // 断开连结(容忍已断开) connect = null; } - if (doAbort && details.onabort && !reqDone) { + if (doAbort && !reqDone) { // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/abort // When a request is aborted, its readyState is changed to XMLHttpRequest.UNSENT (0) and the request's status code is set to 0. doAbort?.({ diff --git a/src/app/service/content/gm_api/grant.ts b/src/app/service/content/gm_api/grant.ts index ab1e91c39..579e03112 100644 --- a/src/app/service/content/gm_api/grant.ts +++ b/src/app/service/content/gm_api/grant.ts @@ -1,9 +1,13 @@ +const nativeReflectApply = Reflect.apply; +const nativeStringStartsWith = String.prototype.startsWith; +const nativeStringSlice = String.prototype.slice; + export function getGrantCandidates(grant: string): string[] { - if (grant.startsWith("GM.")) { - return [grant, `GM_${grant.slice(3)}`]; + if (nativeReflectApply(nativeStringStartsWith, grant, ["GM."])) { + return [grant, `GM_${nativeReflectApply(nativeStringSlice, grant, [3])}`]; } - if (grant.startsWith("GM_")) { - return [grant, `GM.${grant.slice(3)}`]; + if (nativeReflectApply(nativeStringStartsWith, grant, ["GM_"])) { + return [grant, `GM.${nativeReflectApply(nativeStringSlice, grant, [3])}`]; } return [grant]; } diff --git a/src/app/service/content/gm_api/navigation_handle.test.ts b/src/app/service/content/gm_api/navigation_handle.test.ts index 29d7dfbdd..beb779ce9 100644 --- a/src/app/service/content/gm_api/navigation_handle.test.ts +++ b/src/app/service/content/gm_api/navigation_handle.test.ts @@ -40,14 +40,16 @@ describe("attachNavigateHandler", () => { listeners, dispatched, // 模拟触发 navigate 事件 - fireNavigate(destUrl: string) { - // 更新 location.href 模拟浏览器行为 - currentHref = destUrl; + fireNavigate(destUrl: string, updateHref = true) { + if (updateHref) currentHref = destUrl; const ev = { type: "navigate", destination: { url: destUrl } } as any; for (const fn of listeners["navigate"] || []) { fn(ev); } }, + updateHref(url: string) { + currentHref = url; + }, }; }; @@ -94,6 +96,50 @@ describe("attachNavigateHandler", () => { expect(ev.url).toBe("https://example.com/new"); }); + it("href 延迟更新时应在下一任务中读取新 URL", async () => { + const mock = createMockWin("https://example.com/"); + attachNavigateHandler(mock.win); + + mock.fireNavigate("https://example.com/new", false); + mock.updateHref("https://example.com/new"); + + await vi.waitFor(() => { + expect(mock.dispatched).toHaveLength(1); + }); + expect((mock.dispatched[0] as UrlChangeEvent).url).toBe("https://example.com/new"); + }); + + it("重叠的延迟导航应复用一次 task 并只派发最新 URL", async () => { + const mock = createMockWin("https://example.com/"); + attachNavigateHandler(mock.win); + + mock.fireNavigate("https://example.com/first", false); + mock.fireNavigate("https://example.com/last", false); + mock.updateHref("https://example.com/last"); + + await vi.waitFor(() => { + expect(mock.dispatched).toHaveLength(1); + }); + expect((mock.dispatched[0] as UrlChangeEvent).url).toBe("https://example.com/last"); + }); + + it("dispatchEvent 的 bind 屬性被改寫時仍能派發事件", async () => { + const mock = createMockWin("https://example.com/"); + Object.defineProperty(mock.win.dispatchEvent, "bind", { + configurable: true, + value: () => { + throw new Error("poisoned bind"); + }, + }); + + attachNavigateHandler(mock.win); + mock.fireNavigate("https://example.com/new"); + + await vi.waitFor(() => { + expect(mock.dispatched).toHaveLength(1); + }); + }); + it("URL 未变化时不应派发事件", async () => { const mock = createMockWin("https://example.com/"); attachNavigateHandler(mock.win); diff --git a/src/app/service/content/gm_api/navigation_handle.ts b/src/app/service/content/gm_api/navigation_handle.ts index a536f6a70..4200fd1b1 100644 --- a/src/app/service/content/gm_api/navigation_handle.ts +++ b/src/app/service/content/gm_api/navigation_handle.ts @@ -1,4 +1,5 @@ import { Native } from "../global"; +import { createDeferToNextTaskKernel } from "@App/pkg/utils/mesasge-channel"; export class UrlChangeEvent extends Event { readonly url: string; @@ -8,18 +9,27 @@ export class UrlChangeEvent extends Event { } } +interface IDeferToNextTaskKernel { + release(): void; + nextMarcoTask(): Promise; +} + +let m: IDeferToNextTaskKernel | undefined; + let attached = false; -// 仅供测试使用,重置 attached 标记 +// 仅供测试使用,重置 attached 标记并释放复用的 MessageChannel export const resetAttachedForTest = () => { attached = false; + m?.release(); + m = undefined; }; const getPropGetter = (obj: T, key: keyof T) => { // 避免直接 obj[key] 读取。或会被 hack for (let t = obj; t; t = Native.objectGetPrototypeOf(t)) { const pd = Native.objectGetOwnPropertyDescriptor(t, key); - if (pd) return pd.get?.bind(obj); + if (pd) return pd.get ? Native.bind(pd.get, obj) : undefined; } }; @@ -33,21 +43,20 @@ export const attachNavigateHandler = (win: Window & { navigation: EventTarget }) // 以 location.href 判断避免 replaceState/pushState 重复执行重复触发 const loc = win.location; const getUrl = getPropGetter(loc, "href"); - const dispatch = win.dispatchEvent.bind(win); + const dispatch = Native.bind(win.dispatchEvent, win); let lastUrl = getUrl?.(); let callSeq = 0; + const deferToNextTask = createDeferToNextTaskKernel(); + m = deferToNextTask; const handler = async (ev: Event): Promise => { callSeq = callSeq > 512 ? 1 : callSeq + 1; const seq = callSeq; let newUrl = getUrl?.(); // 取得当前 location.href const destUrl = (ev as any).destination?.url; if (destUrl !== newUrl && newUrl === lastUrl) { - // 某些情况,location.href 未更新就触发了 - // 用 postMessage 推迟到下一个 macrotask 阶段 - await new Promise((resolve) => { - self.addEventListener("message", resolve, { once: true }); - self.postMessage({ [`${Math.random()}`]: {} }, "*"); // 传一个 dummy message - }); + // 某些情况,location.href 未更新就触发了。复用一个私有 MessageChannel + // 让出一个 task;同一轮内的重叠导航共享这次等待,再由 callSeq 丢弃旧 continuation。 + await deferToNextTask.nextMarcoTask(); if (seq !== callSeq) return; // 等待时,或许已经触发了其他 navigate newUrl = getUrl?.(); // 再次取得当前 location.href } diff --git a/src/app/service/content/gm_api/related_target_lifecycle.test.ts b/src/app/service/content/gm_api/related_target_lifecycle.test.ts index 722111552..b3368382a 100644 --- a/src/app/service/content/gm_api/related_target_lifecycle.test.ts +++ b/src/app/service/content/gm_api/related_target_lifecycle.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ScriptEnvTag } from "@Packages/message/consts"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; import { Server } from "@Packages/message/server"; @@ -50,19 +50,19 @@ describe("relatedTarget lifecycle across content runtime callers", () => { const parent = document.createElement("section"); try { - const style = api.GM_addStyle("body { color: red; }"); + const style = api.GM_addStyle(api, "body { color: red; }"); expect(style?.tagName).toBe("STYLE"); expect(style?.textContent).toBe("body { color: red; }"); expect(sender.relatedTarget).toHaveProperty("size", 0); expect(receiver.relatedTarget).toHaveProperty("size", 0); - const child = api.GM_addElement(parent, "span", { id: "child" }); + const child = api.GM_addElement(api, parent, "span", { id: "child" }); expect(child?.parentNode).toBe(parent); expect(child?.id).toBe("child"); expect(sender.relatedTarget).toHaveProperty("size", 0); expect(receiver.relatedTarget).toHaveProperty("size", 0); - const root = api.GM_addElement("div", { id: "root" }); + const root = api.GM_addElement(api, "div", { id: "root" }); expect(root?.tagName).toBe("DIV"); expect(root?.id).toBe("root"); expect(sender.relatedTarget).toHaveProperty("size", 0); @@ -72,4 +72,21 @@ describe("relatedTarget lifecycle across content runtime callers", () => { receiver.relatedTarget.clear(); } }); + + it("skips accessor attributes without executing their getter", () => { + const { api, sender, receiver } = createApiWithContentRuntime(); + const getter = vi.fn(() => "forged"); + const attrs = { id: "safe" } as Record; + Object.defineProperty(attrs, "secret", { enumerable: true, configurable: true, get: getter }); + + try { + const element = api.GM_addElement(api, "div", attrs); + expect(element?.id).toBe("safe"); + expect(element).not.toHaveProperty("secret"); + expect(getter).not.toHaveBeenCalled(); + } finally { + sender.relatedTarget.clear(); + receiver.relatedTarget.clear(); + } + }); }); diff --git a/src/app/service/content/listener_manager.test.ts b/src/app/service/content/listener_manager.test.ts index ac562974d..77570aebd 100644 --- a/src/app/service/content/listener_manager.test.ts +++ b/src/app/service/content/listener_manager.test.ts @@ -1,33 +1,31 @@ // listener_manager.test.ts -// 根据需要调整导入路径,确保能正确导入 ListenerManager 类。 +// ListenerManager 现在是固定为 GMTypes.ValueChangeListener 五参数签名的监听器管理器。 import { describe, it, expect, vi } from "vitest"; import { ListenerManager } from "./listener_manager"; - -// 定义监听函数类型 -type Handler = (key: string, n: number, s: string) => void; +import { installArrayPrototypeIndexAccessor } from "@Tests/array_prototype_index"; describe.concurrent("ListenerManager(监听器管理器)", () => { it.concurrent("添加并执行单个监听器", () => { - const lm = new ListenerManager(); - const spy = vi.fn(); + const lm = new ListenerManager(); + const spy = vi.fn(); const id = lm.add("alpha", spy); expect(id).toBeGreaterThan(0); - lm.execute("alpha", 123, "hi"); + lm.execute("alpha", 1, "new", false, 7); expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith("alpha", 123, "hi"); + expect(spy).toHaveBeenCalledWith("alpha", 1, "new", false, 7); }); it.concurrent("为同一个 key 执行多个监听器,执行顺序应与添加顺序一致", () => { - const lm = new ListenerManager(); - const spy1 = vi.fn(); - const spy2 = vi.fn(); + const lm = new ListenerManager(); + const spy1 = vi.fn(); + const spy2 = vi.fn(); lm.add("beta", spy1); lm.add("beta", spy2); - lm.execute("beta", 7, "x"); + lm.execute("beta", "old", "new", true, undefined); expect(spy1).toHaveBeenCalledTimes(1); expect(spy2).toHaveBeenCalledTimes(1); @@ -36,22 +34,22 @@ describe.concurrent("ListenerManager(监听器管理器)", () => { }); it.concurrent("不会执行注册在其他 key 下的监听器", () => { - const lm = new ListenerManager(); - const spyA = vi.fn(); - const spyB = vi.fn(); + const lm = new ListenerManager(); + const spyA = vi.fn(); + const spyB = vi.fn(); lm.add("gamma", spyA); lm.add("delta", spyB); - lm.execute("gamma", 1, "A"); + lm.execute("gamma", 1, "A", false, 1); expect(spyA).toHaveBeenCalledTimes(1); expect(spyB).not.toHaveBeenCalled(); }); it.concurrent("remove() 删除已存在的监听器 id 后返回 true,并防止后续被执行", () => { - const lm = new ListenerManager(); - const spyA = vi.fn(); - const spyB = vi.fn(); + const lm = new ListenerManager(); + const spyA = vi.fn(); + const spyB = vi.fn(); const idA = lm.add("k", spyA); lm.add("k", spyB); @@ -59,25 +57,25 @@ describe.concurrent("ListenerManager(监听器管理器)", () => { const removed = lm.remove(idA); expect(removed).toBe(true); - lm.execute("k", 9, "z"); + lm.execute("k", 9, "z", false, undefined); expect(spyA).not.toHaveBeenCalled(); expect(spyB).toHaveBeenCalledTimes(1); }); it.concurrent("remove() 可以接受字符串类型的 id 并通过数字转换删除", () => { - const lm = new ListenerManager(); - const spy = vi.fn(); + const lm = new ListenerManager(); + const spy = vi.fn(); const id = lm.add("key", spy); const removed = lm.remove(String(id)); expect(removed).toBe(true); - lm.execute("key", 5, "after"); + lm.execute("key", 5, "after", false, undefined); expect(spy).not.toHaveBeenCalled(); }); it.concurrent("remove() 对无效或不存在的 id 返回 false", () => { - const lm = new ListenerManager(); + const lm = new ListenerManager(); expect(lm.remove(0)).toBe(false); expect(lm.remove(-1)).toBe(false); expect(lm.remove("")).toBe(false); @@ -85,37 +83,37 @@ describe.concurrent("ListenerManager(监听器管理器)", () => { }); it.concurrent("对不存在监听器的 key 执行 execute() 不会抛出错误(无操作)", () => { - const lm = new ListenerManager(); - expect(() => lm.execute("missing", 42, "nope")).not.toThrow(); + const lm = new ListenerManager(); + expect(() => lm.execute("missing", 42, "nope", false, undefined)).not.toThrow(); }); it.concurrent("id 在多次添加监听器时应递增", () => { - const lm = new ListenerManager(); - const id1 = lm.add("a", vi.fn()); - const id2 = lm.add("a", vi.fn()); - const id3 = lm.add("b", vi.fn()); + const lm = new ListenerManager(); + const id1 = lm.add("a", vi.fn()); + const id2 = lm.add("a", vi.fn()); + const id3 = lm.add("b", vi.fn()); expect(id2).toBeGreaterThan(id1); expect(id3).toBeGreaterThan(id2); }); it.concurrent("当删除某个 key 下最后一个监听器后,再执行该 key 时不应触发任何监听", () => { - const lm = new ListenerManager(); - const spy = vi.fn(); + const lm = new ListenerManager(); + const spy = vi.fn(); const id = lm.add("solo", spy); expect(lm.remove(id)).toBe(true); - lm.execute("solo", 100, "gone"); + lm.execute("solo", 100, "gone", false, undefined); expect(spy).not.toHaveBeenCalled(); }); it.concurrent("clear()", () => { - const lm = new ListenerManager(); - const spyA = vi.fn(); - const spyB = vi.fn(); - const spyC = vi.fn(); - const spyD = vi.fn(); + const lm = new ListenerManager(); + const spyA = vi.fn(); + const spyB = vi.fn(); + const spyC = vi.fn(); + const spyD = vi.fn(); lm.add("k", spyA); lm.add("k", spyB); @@ -124,11 +122,114 @@ describe.concurrent("ListenerManager(监听器管理器)", () => { lm.clear(); - lm.execute("k", 9, "z"); - lm.execute("z", 1, "z"); + lm.execute("k", 9, "z", false, undefined); + lm.execute("z", 1, "z", false, undefined); expect(spyA).not.toHaveBeenCalled(); expect(spyB).not.toHaveBeenCalled(); expect(spyC).not.toHaveBeenCalled(); expect(spyD).not.toHaveBeenCalled(); }); + + it.concurrent("监听器可以在被触发时移除自身,不影响其余监听器执行", () => { + const lm = new ListenerManager(); + const order: string[] = []; + let selfId = 0; + const spySelf = vi.fn(() => { + order.push("self"); + lm.remove(selfId); + }); + const spyOther = vi.fn(() => order.push("other")); + + selfId = lm.add("k", spySelf); + lm.add("k", spyOther); + + lm.execute("k", 1, 2, false, undefined); + expect(order).toEqual(["self", "other"]); + + lm.execute("k", 1, 2, false, undefined); + expect(spySelf).toHaveBeenCalledTimes(1); + expect(spyOther).toHaveBeenCalledTimes(2); + }); + + it.concurrent("一个监听器可以在执行期间移除同一 key 下尚未轮到的另一个监听器", () => { + const lm = new ListenerManager(); + let idB = 0; + const spyB = vi.fn(); + const spyA = vi.fn(() => lm.remove(idB)); + + lm.add("k", spyA); + idB = lm.add("k", spyB); + + lm.execute("k", 1, 2, false, undefined); + + expect(spyA).toHaveBeenCalledTimes(1); + expect(spyB).not.toHaveBeenCalled(); + }); + + it.concurrent("执行期间新增的监听器不会影响本次已在进行的分发抛错", () => { + const lm = new ListenerManager(); + const spyLate = vi.fn(); + const spyFirst = vi.fn(() => { + lm.add("k", spyLate); + }); + + lm.add("k", spyFirst); + + expect(() => lm.execute("k", 1, 2, false, undefined)).not.toThrow(); + expect(spyFirst).toHaveBeenCalledTimes(1); + }); + + it.concurrent("不受继承的数字 Array.prototype setter 影响(add 不再使用数组下标赋值)", () => { + // 旧实现用 this.listeners[this.listeners.length] = ... 赋值,页面可在 Array.prototype + // 上预先放置继承的数字 setter 来截获新监听器;固定为 Native.Map 存储后不应再受影响。 + const lm = new ListenerManager(); + let intercepted: unknown; + const restoreArrayIndex = installArrayPrototypeIndexAccessor({ + set(value: unknown) { + intercepted = value; + }, + get() { + return undefined; + }, + }); + const spy = vi.fn(); + try { + lm.add("alpha", spy); + } finally { + restoreArrayIndex(); + } + + expect(intercepted).toBeUndefined(); + lm.execute("alpha", 1, 2, false, undefined); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it.concurrent("不受劫持的 Array.prototype[Symbol.iterator] 影响(execute 不再展开参数数组)", () => { + const lm = new ListenerManager(); + const received: unknown[] = []; + lm.add("alpha", (key: string, oldValue: unknown, newValue: unknown, remote: boolean, tabid?: number) => { + received.push(key, oldValue, newValue, remote, tabid); + }); + + const originalIterator = Array.prototype[Symbol.iterator]; + let hostileIteratorInvoked = false; + let threw = false; + try { + Array.prototype[Symbol.iterator] = () => { + hostileIteratorInvoked = true; + throw new Error("hostile iterator invoked"); + }; + try { + lm.execute("alpha", 1, "x", true, 9); + } catch { + threw = true; + } + } finally { + Array.prototype[Symbol.iterator] = originalIterator; + } + + expect(hostileIteratorInvoked).toBe(false); + expect(threw).toBe(false); + expect(received).toEqual(["alpha", 1, "x", true, 9]); + }); }); diff --git a/src/app/service/content/listener_manager.ts b/src/app/service/content/listener_manager.ts index cc4de0f9e..87edf3a3b 100644 --- a/src/app/service/content/listener_manager.ts +++ b/src/app/service/content/listener_manager.ts @@ -1,47 +1,49 @@ -// 把 valueChangeListener 抽出来做一个高效执行的Class -// 删除会较慢但执行会较快 -export class ListenerManager void> { +import { Native } from "./global"; + +// 把 valueChangeListener 抽出来做一个高效执行的Class。 +// 固定为 GMTypes.ValueChangeListener 的实际调用形状(生产环境唯一调用点是 +// key/oldValue/newValue/remote/tabid 五参数),避免通用 rest/spread 依赖页面可篡改的 +// Array 迭代协议;存储改用捕获的 Native.Map,避免下标赋值触发继承的数字 setter。 +export class ListenerManager { private counterId = 0; - private readonly listeners = new Map>(); + private readonly buckets = new Native.Map< + string, + InstanceType> + >(); - public add(key: string, handler: T): number { + public add(key: string, handler: GMTypes.ValueChangeListener): number { const id = ++this.counterId; - let listenrMap = this.listeners.get(key); - if (!listenrMap) { - this.listeners.set(key, (listenrMap = new Map())); + let bucket = this.buckets.get(key); + if (!bucket) { + bucket = new Native.Map(); + this.buckets.set(key, bucket); } - listenrMap.set(id, handler); + bucket.set(id, handler); return id; } - public execute(key: string, ...args: T extends (key: string, ...a: infer A) => any ? A : never): void { - const handlers = this.listeners.get(key); - if (handlers) { - for (const handler of handlers.values()) { - handler?.(key, ...args); - } - } + public execute(key: string, oldValue: unknown, newValue: unknown, remote: boolean, tabid: number | undefined): void { + const bucket = this.buckets.get(key); + if (!bucket) return; + bucket.forEach((handler) => { + handler(key, oldValue, newValue, remote, tabid); + }); } public remove(id: number | string): boolean { const idNum = +id || 0; - if (idNum > 0) { - for (const [key, handlers] of this.listeners) { - if (handlers.delete(idNum)) { - if (handlers.size === 0) { - this.listeners.delete(key); - } - return true; - } + if (idNum <= 0) return false; + let removed = false; + this.buckets.forEach((bucket) => { + if (!removed && bucket.has(idNum)) { + bucket.delete(idNum); + removed = true; } - } - return false; + }); + return removed; } public clear(): void { - for (const [_key, handlers] of this.listeners) { - handlers.clear(); - } - this.listeners.clear(); + this.buckets.clear(); } } diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts new file mode 100644 index 000000000..d4a755bb2 --- /dev/null +++ b/src/app/service/content/page_rpc.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it, vi } from "vitest"; +import { Blob as NodeBlob } from "node:buffer"; +import { + getPageRpcAllowedAPIs, + setPageRpcExtensionOrigin, + isExtensionBlobUrl, + PageRpcError, + PageRpcRegistry, + validatePageGMRequest, +} from "./page_rpc"; + +describe("page GM RPC", () => { + it("expands only the helper operations reachable from an explicit public grant", () => { + const allowed = getPageRpcAllowedAPIs(["CAT.agent.opfs", "GM_xmlhttpRequest"]); + + expect(allowed).toEqual( + expect.arrayContaining([ + "CAT.agent.opfs", + "CAT_agentOPFS", + "CAT_fetchBlob", + "GM_xmlhttpRequest", + "GM.xmlhttpRequest", + ]) + ); + expect(allowed).not.toContain("CAT_fetchDocument"); + expect(allowed).not.toContain("CAT_createBlobUrl"); + expect(allowed).not.toContain("CAT_agentSkills"); + }); + + it("includes APIs exposed through the same dependency graph as the script context", () => { + const allowed = getPageRpcAllowedAPIs(["GM.openInTab"]); + + expect(allowed).toEqual(expect.arrayContaining(["GM.openInTab", "GM_openInTab", "GM_closeInTab"])); + }); + + it.each([ + { deleteAPI: "GM_deleteValue", setAPI: "GM_setValue" }, + { deleteAPI: "GM.deleteValues", setAPI: "GM_setValues" }, + ])("includes $setAPI for the $deleteAPI wrapper dependency", ({ deleteAPI, setAPI }) => { + expect(getPageRpcAllowedAPIs([deleteAPI])).toEqual(expect.arrayContaining([deleteAPI, setAPI])); + }); + + it.each([ + { grant: "GM.cookie", methods: ["GM.cookie.set", "GM.cookie.list", "GM.cookie.delete"] }, + { grant: "GM_cookie", methods: ["GM_cookie.set", "GM_cookie.list", "GM_cookie.delete"] }, + ])("includes nested cookie methods for $grant", ({ grant, methods }) => { + expect(getPageRpcAllowedAPIs([grant])).toEqual(expect.arrayContaining(methods)); + }); + + it("does not create a GM capability set for a none grant", () => { + expect(getPageRpcAllowedAPIs(["none", "GM_getValue", "CAT.agent.dom"])).toEqual([]); + }); + + it("honors a none grant when Array.prototype.some is hooked", () => { + const originalSome = Array.prototype.some; + Array.prototype.some = (() => false) as typeof Array.prototype.some; + let allowed: string[]; + try { + allowed = getPageRpcAllowedAPIs(["none", "GM_getValue"]); + } finally { + Array.prototype.some = originalSome; + } + + expect(allowed!).toEqual([]); + }); + + it("does not let a hooked String.prototype.slice enlarge grant aliases", () => { + const originalSlice = String.prototype.slice; + String.prototype.slice = (() => "xmlhttpRequest") as typeof String.prototype.slice; + let allowed: string[]; + try { + allowed = getPageRpcAllowedAPIs(["GM.getValue"]); + } finally { + String.prototype.slice = originalSlice; + } + + expect(allowed!).toContain("GM_getValue"); + expect(allowed!).not.toContain("GM_xmlhttpRequest"); + }); + + it("ignores inherited capability-map properties for unknown grant names", () => { + expect(getPageRpcAllowedAPIs(["constructor", "toString"])).toEqual(["constructor", "toString"]); + }); + + it("does not let a hooked Array.prototype.push enlarge the capability result", () => { + const originalPush = Array.prototype.push; + Array.prototype.push = function (...items: unknown[]): number { + return originalPush.call(this, ...items, "GM_xmlhttpRequest"); + }; + let allowed: string[]; + try { + allowed = getPageRpcAllowedAPIs(["GM_getValue"]); + } finally { + Array.prototype.push = originalPush; + } + + expect(allowed!).not.toContain("GM_xmlhttpRequest"); + }); + + it("allows the internal request name used by the GM.xmlHttpRequest wrapper", () => { + const allowed = getPageRpcAllowedAPIs(["GM.xmlHttpRequest"]); + + expect(allowed).toContain("GM_xmlhttpRequest"); + expect(allowed).not.toContain("CAT_fetchBlob"); + expect(allowed).not.toContain("CAT_fetchDocument"); + expect(allowed).not.toContain("CAT_createBlobUrl"); + }); + + it.each([ + { api: "CAT_fetchBlob", params: ["https://example.com/file"] }, + { api: "CAT_fetchDocument", params: ["https://example.com/file", false] }, + ])("rejects direct internal fetch helper $api from a GM XHR binding", ({ api, params }) => { + const registry = new PageRpcRegistry(); + const handle = registry.register("handle-a", getPageRpcAllowedAPIs(["GM_xmlhttpRequest"])); + + expect(() => validatePageGMRequest({ version: 2, sequence: 1, handle, api, params }, registry)).toThrow( + "API is not granted" + ); + }); + + it("accepts a request for the active execution binding and clones parameters", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("handle-a", ["GM_getValue"]); + const params = { nested: { value: 1 } }; + + const request = validatePageGMRequest( + { version: 2, sequence: 1, handle, api: "GM_getValue", params: [params] }, + registry + ); + + expect(request).toEqual({ + version: 2, + sequence: 1, + handle, + api: "GM_getValue", + params: [params], + }); + expect(request.params[0]).not.toBe(params); + expect(() => + validatePageGMRequest({ version: 2, sequence: 1, handle, api: "GM_getValue", params: [] }, registry) + ).toThrow("already used"); + }); + + it("rejects an unknown or stale execution binding", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("handle-a", ["GM_getValue"]); + + expect(() => + validatePageGMRequest({ version: 2, sequence: 1, handle: "missing", api: "GM_getValue", params: [] }, registry) + ).toThrow(PageRpcError); + + registry.revoke(handle); + expect(() => + validatePageGMRequest({ version: 2, sequence: 1, handle, api: "GM_getValue", params: [] }, registry) + ).toThrow(PageRpcError); + + const activeHandle = registry.register("handle-b", ["GM_getValue"]); + expect( + validatePageGMRequest({ version: 2, sequence: 1, handle: activeHandle, api: "GM_getValue", params: [] }, registry) + ).toMatchObject({ handle: activeHandle }); + }); + + it("rejects a packet that carries a page-supplied canonical identity field", () => { + // v2 wire 身份只允许 handle;页面附带 uuid/runFlag/envTag 等字段会被判定为多余字段而拒绝, + // canonical 身份只能由 SW 依据 handle + 真实 sender 解析出来。 + const registry = new PageRpcRegistry(); + const handle = registry.register("handle-a", ["GM_getValue"]); + + expect(() => + validatePageGMRequest( + { version: 2, sequence: 1, handle, uuid: "script-b", api: "GM_getValue", params: [] }, + registry + ) + ).toThrow("unexpected fields"); + }); + + it("rejects APIs outside the binding and packets with accessors or unsupported values", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("handle-a", ["GM_getValue"]); + const accessorRequest = { version: 2, sequence: 1, handle, api: "GM_getValue", params: [] }; + Object.defineProperty(accessorRequest, "api", { get: () => "GM_getValue" }); + + expect(() => validatePageGMRequest(accessorRequest, registry)).toThrow(PageRpcError); + expect(() => + validatePageGMRequest({ version: 2, sequence: 1, handle, api: "GM_setValue", params: [] }, registry) + ).toThrow(PageRpcError); + expect(() => + validatePageGMRequest( + { version: 2, sequence: 1, handle, api: "GM_getValue", params: [() => undefined] }, + registry + ) + ).toThrow(PageRpcError); + }); + + it("rejects accessors nested in collection RPC parameters", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("handle-a", ["GM_getValue"]); + const getter = vi.fn(() => "secret"); + const nested = {} as Record; + Object.defineProperty(nested, "value", { configurable: true, enumerable: true, get: getter }); + + expect(() => + validatePageGMRequest( + { version: 2, sequence: 1, handle, api: "GM_getValue", params: [new Map([["nested", nested]])] }, + registry + ) + ).toThrow(PageRpcError); + expect(getter).not.toHaveBeenCalled(); + }); + + it("rejects accessors nested in set RPC parameters", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("handle-a", ["GM_getValue"]); + const getter = vi.fn(() => "secret"); + const nested = {} as Record; + Object.defineProperty(nested, "value", { configurable: true, enumerable: true, get: getter }); + + expect(() => + validatePageGMRequest( + { version: 2, sequence: 1, handle, api: "GM_getValue", params: [new Set([nested])] }, + registry + ) + ).toThrow(PageRpcError); + expect(getter).not.toHaveBeenCalled(); + }); + + it("does not execute a Symbol.toStringTag accessor while validating RPC values", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("handle-a", ["GM_getValue"]); + const getter = vi.fn(() => "Blob"); + const nested = Object.create(null) as Record; + Object.defineProperty(nested, Symbol.toStringTag, { configurable: true, get: getter }); + + expect(() => + validatePageGMRequest({ version: 2, sequence: 1, handle, api: "GM_getValue", params: [nested] }, registry) + ).toThrow(PageRpcError); + expect(getter).not.toHaveBeenCalled(); + }); + + it("keeps validation on captured intrinsics after page prototype hooks", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("handle-a", ["GM_getValue"]); + const ownKeysSpy = vi.spyOn(Reflect, "ownKeys").mockImplementation(() => { + throw new Error("page hook"); + }); + const descriptorSpy = vi.spyOn(Object, "getOwnPropertyDescriptor").mockImplementation(() => { + throw new Error("page hook"); + }); + + let result: ReturnType | undefined; + try { + result = validatePageGMRequest({ version: 2, sequence: 1, handle, api: "GM_getValue", params: [] }, registry); + } finally { + ownKeysSpy.mockRestore(); + descriptorSpy.mockRestore(); + } + expect(result).toMatchObject({ handle }); + }); + + it("rejects malformed parameters for privileged helper operations", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("handle-a", ["CAT_fetchBlob"]); + + expect(() => + validatePageGMRequest({ version: 2, sequence: 1, handle, api: "CAT_fetchBlob", params: [42] }, registry) + ).toThrow("CAT_fetchBlob expects an extension blob URL"); + + expect(isExtensionBlobUrl("https://example.com/file")).toBe(false); + const extensionBlobUrl = `blob:${chrome.runtime.getURL("/").replace(/\/$/, "")}/internal`; + expect(isExtensionBlobUrl(extensionBlobUrl)).toBe(true); + + expect( + validatePageGMRequest( + { version: 2, sequence: 1, handle, api: "CAT_fetchBlob", params: [extensionBlobUrl] }, + registry + ).params + ).toEqual([extensionBlobUrl]); + expect(isExtensionBlobUrl("blob:https://example.com/internal")).toBe(false); + expect(isExtensionBlobUrl("blob:chrome-extension://other/internal")).toBe(false); + }); + + it("requires a Blob for CAT_createBlobUrl after parameter cloning", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("handle-a", ["CAT_createBlobUrl"]); + + expect(() => + validatePageGMRequest({ version: 2, sequence: 1, handle, api: "CAT_createBlobUrl", params: [{}] }, registry) + ).toThrow("CAT_createBlobUrl expects one Blob value"); + + const blob = new NodeBlob(["payload"], { type: "text/plain" }); + expect(Object.prototype.toString.call(blob)).toBe("[object Blob]"); + expect(Object.prototype.toString.call(structuredClone(blob))).toBe("[object Blob]"); + const request = validatePageGMRequest( + { version: 2, sequence: 1, handle, api: "CAT_createBlobUrl", params: [blob] }, + registry + ); + expect(Object.prototype.toString.call(request.params[0])).toBe("[object Blob]"); + expect(request.params[0]).not.toBe(blob); + }); + + it("validates extension blobs in USER_SCRIPT when runtime.getURL is unavailable", () => { + const runtime = chrome.runtime as unknown as { getURL?: typeof chrome.runtime.getURL }; + const getURL = runtime.getURL; + const extensionBlobUrl = `blob:chrome-extension://${chrome.runtime.id}/internal`; + try { + runtime.getURL = undefined; + setPageRpcExtensionOrigin({ protocol: "chrome-extension:", hostname: chrome.runtime.id, port: "" }); + expect(isExtensionBlobUrl(extensionBlobUrl)).toBe(true); + expect(isExtensionBlobUrl("blob:https://example.com/internal")).toBe(false); + } finally { + runtime.getURL = getURL; + setPageRpcExtensionOrigin(undefined); + } + }); + + it("rejects an old request sequence after the replay window advances", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("handle-a", ["GM_getValue"]); + const binding = registry.resolve(handle, "GM_getValue"); + + for (let sequence = 1; sequence <= 4097; sequence += 1) { + registry.consumeRequestSequence(binding, sequence); + } + + expect(() => + validatePageGMRequest({ version: 2, sequence: 1, handle, api: "GM_getValue", params: [] }, registry) + ).toThrow("outside the replay window"); + }); +}); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts new file mode 100644 index 000000000..dd5aff09f --- /dev/null +++ b/src/app/service/content/page_rpc.ts @@ -0,0 +1,364 @@ +import { RequestSequenceWindow } from "@Packages/message/request_sequence_window"; +import { getGrantCandidates } from "./gm_api/grant"; +import { getPageRpcDependencies, INTERNAL_APIS_BY_GRANT } from "./gm_api/api_dependencies"; +import { Native, nativeReflectApply } from "./global"; + +export const PAGE_RPC_VERSION = 2 as const; +const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; +const nativeObjectToString = Object.prototype.toString; +const nativeMapForEach = Map.prototype.forEach; +const nativeSetForEach = Set.prototype.forEach; +const EXTENSION_PROTOCOLS = new Native.Set(["chrome-extension:", "moz-extension:"]); +const nativeReflectOwnKeys = Native.reflectOwnKeys; +const nativeObjectGetOwnPropertyDescriptor = Native.objectGetOwnPropertyDescriptor; +const nativeArrayIsArray = Array.isArray; +const nativeURL = URL; +const nativeBlob = typeof Blob === "function" ? Blob : undefined; +const nativeStringSlice = String.prototype.slice; + +export type ExtensionOrigin = Pick; + +export const getExtensionOrigin = (): ExtensionOrigin | undefined => { + if (typeof chrome === "undefined" || typeof chrome.runtime?.getURL !== "function") return undefined; + try { + const url = new nativeURL(chrome.runtime.getURL("/")); + if (!EXTENSION_PROTOCOLS.has(url.protocol) || !url.hostname) return undefined; + return { protocol: url.protocol, hostname: url.hostname, port: url.port }; + } catch { + // Ignore malformed runtime metadata and reject the URL below. + } + return undefined; +}; + +// USER_SCRIPT 的 blob URL 必须回指当前扩展 origin,origin 由隔离 context 提供并缓存。 +let configuredExtensionOrigin: ExtensionOrigin | undefined; + +export const setPageRpcExtensionOrigin = (value: unknown): void => { + if (value === null || typeof value !== "object") { + configuredExtensionOrigin = undefined; + return; + } + try { + const read = (key: keyof ExtensionOrigin): unknown => { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; + }; + const protocol = read("protocol"); + const hostname = read("hostname"); + const port = read("port"); + if ( + (protocol !== "chrome-extension:" && protocol !== "moz-extension:") || + typeof hostname !== "string" || + hostname.length === 0 || + typeof port !== "string" + ) { + configuredExtensionOrigin = undefined; + return; + } + configuredExtensionOrigin = { protocol, hostname, port }; + } catch { + configuredExtensionOrigin = undefined; + } +}; + +export const isExtensionBlobUrl = (value: unknown): value is string => { + if (typeof value !== "string") return false; + const extensionOrigin = configuredExtensionOrigin || getExtensionOrigin(); + if (!extensionOrigin) return false; + try { + const url = new nativeURL(value); + if (url.protocol !== "blob:") return false; + const creatorOrigin = new nativeURL(nativeReflectApply(nativeStringSlice, value, ["blob:".length])); + return ( + creatorOrigin.protocol === extensionOrigin.protocol && + creatorOrigin.hostname === extensionOrigin.hostname && + creatorOrigin.port === extensionOrigin.port + ); + } catch { + return false; + } +}; + +export type PageExecutionBinding = { + readonly handle: string; + readonly allowedAPIs: ReadonlySet; + requestSequenceWindow: RequestSequenceWindow; +}; + +/** MAIN world 脚本可提交的不可信数据包;校验通过后原样转发,canonical 身份由 SW 依据 handle + 真实 sender 解析。 */ +export type PageGMRequest = { + readonly version: typeof PAGE_RPC_VERSION; + readonly sequence: number; + readonly handle: string; + readonly api: string; + readonly params: readonly unknown[]; +}; + +export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { + for (let index = 0; index < grants.length; index += 1) { + if (grants[index] === "none") return []; + } + const allowed = new Native.Set(); + const visited = new Native.Set(); + const visitGrant = (grant: string): void => { + const candidates = getGrantCandidates(grant); + for (let index = 0; index < candidates.length; index += 1) { + const candidate = candidates[index]; + if (visited.has(candidate)) continue; + visited.add(candidate); + allowed.add(candidate); + if (Native.objectHasOwn(INTERNAL_APIS_BY_GRANT, candidate)) { + const internalAPIs = INTERNAL_APIS_BY_GRANT[candidate]; + for (let index = 0; index < internalAPIs.length; index += 1) allowed.add(internalAPIs[index]); + } + const dependencies = getPageRpcDependencies(candidate); + for (let index = 0; index < dependencies.length; index += 1) visitGrant(dependencies[index]); + } + }; + for (let index = 0; index < grants.length; index += 1) visitGrant(grants[index]); + const result: string[] = []; + allowed.forEach((value) => { + result[result.length] = value; + }); + return result; +}; + +export class PageRpcError extends Error { + constructor(message: string) { + super(message); + this.name = "PageRpcError"; + } +} + +const ownData = (value: object, key: PropertyKey): unknown => { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + throw new PageRpcError(`page RPC field ${String(key)} must be a data property`); + } + return descriptor.value; +}; + +const isBlobLike = (value: object): boolean => { + let current: object | null = value; + while (current !== null) { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = nativeObjectGetOwnPropertyDescriptor(current, Symbol.toStringTag); + } catch { + throw new PageRpcError("page RPC value cannot be inspected"); + } + if (descriptor) { + if (!("value" in descriptor)) throw new PageRpcError("page RPC values cannot contain accessor properties"); + return descriptor.value === "Blob"; + } + try { + current = Native.objectGetPrototypeOf(current); + } catch { + throw new PageRpcError("page RPC value cannot be inspected"); + } + } + return false; +}; + +const assertDataOnly = (value: unknown, seen: Set): void => { + // 先检查自有数据描述符,再做 structuredClone;这样页面 getter/Proxy 不会在 broker 中执行。 + if (value === null || typeof value !== "object") return; + if (seen.has(value)) return; + seen.add(value); + + // Blob 的内部槽由浏览器管理;只检查可由页面添加的字符串属性,忽略其内部 symbol 属性。 + if (nativeBlob && (value instanceof nativeBlob || isBlobLike(value))) { + let keys: (string | symbol)[]; + try { + keys = nativeReflectOwnKeys(value); + } catch { + throw new PageRpcError("page RPC value cannot be inspected"); + } + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key === "string") assertDataOnly(ownData(value, key), seen); + } + return; + } + + // Map/Set 条目不在自有属性中,必须先检查,避免 structuredClone 遍历时触发嵌套访问器。 + try { + nativeReflectApply(nativeMapForEach, value as Map, [ + (key: unknown, entry: unknown) => { + assertDataOnly(key, seen); + assertDataOnly(entry, seen); + }, + ]); + return; + } catch (error) { + if (error instanceof PageRpcError) throw error; + // 不是 Map,继续检查普通自有属性。 + } + try { + nativeReflectApply(nativeSetForEach, value as Set, [(entry: unknown) => assertDataOnly(entry, seen)]); + return; + } catch (error) { + if (error instanceof PageRpcError) throw error; + // 不是 Set,继续检查普通自有属性。 + } + + let keys: (string | symbol)[]; + try { + keys = nativeReflectOwnKeys(value); + } catch { + throw new PageRpcError("page RPC value cannot be inspected"); + } + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key === "symbol") throw new PageRpcError("page RPC values cannot contain symbol properties"); + const child = ownData(value, key); + assertDataOnly(child, seen); + } +}; + +const cloneParams = (params: unknown): readonly unknown[] => { + // 复制发生在交给 service worker 之前,后续 broker 只处理隔离后的普通值。 + if (!nativeArrayIsArray(params)) throw new PageRpcError("page RPC params must be an array"); + assertDataOnly(params, new Native.Set()); + if (!nativeStructuredClone) throw new PageRpcError("structured clone is unavailable"); + try { + return nativeStructuredClone(params) as readonly unknown[]; + } catch { + throw new PageRpcError("page RPC params are not cloneable"); + } +}; + +const validateOperationParams = (api: string, params: readonly unknown[]): void => { + switch (api) { + case "CAT_fetchBlob": + if (params.length !== 1 || !isExtensionBlobUrl(params[0])) { + throw new PageRpcError("CAT_fetchBlob expects an extension blob URL"); + } + return; + case "CAT_createBlobUrl": + if ( + params.length !== 1 || + !nativeBlob || + (!(params[0] instanceof nativeBlob) && nativeObjectToString.call(params[0]) !== "[object Blob]") + ) { + throw new PageRpcError("CAT_createBlobUrl expects one Blob value"); + } + return; + case "CAT_fetchDocument": + if (params.length !== 2 || typeof params[0] !== "string" || typeof params[1] !== "boolean") { + throw new PageRpcError("CAT_fetchDocument expects a URL and content flag"); + } + return; + case "CAT_agentOPFS": + if ( + params.length !== 1 || + params[0] === null || + typeof params[0] !== "object" || + nativeArrayIsArray(params[0]) || + typeof (params[0] as { action?: unknown }).action !== "string" + ) { + throw new PageRpcError("CAT_agentOPFS expects an operation object"); + } + return; + default: + return; + } +}; + +export class PageRpcRegistry { + private readonly bindings = new Native.Map(); + + register(handle: string, allowedAPIs: readonly string[]): string { + if (!handle || this.bindings.has(handle)) { + throw new PageRpcError("invalid page execution binding"); + } + this.bindings.set(handle, { + handle, + allowedAPIs: new Native.Set(allowedAPIs), + requestSequenceWindow: new RequestSequenceWindow(), + }); + return handle; + } + + revoke(handle: string): void { + this.bindings.delete(handle); + } + + revokeAll(): void { + this.bindings.clear(); + } + + resolve(handle: string, api: string): PageExecutionBinding { + const binding = this.bindings.get(handle); + if (!binding) throw new PageRpcError("page execution binding is inactive"); + if (!binding.allowedAPIs.has(api)) throw new PageRpcError("API is not granted to this execution"); + return binding; + } + + consumeRequestSequence(binding: PageExecutionBinding, sequence: number): void { + try { + binding.requestSequenceWindow.consume(sequence); + } catch (error) { + throw new PageRpcError(error instanceof Error ? error.message : "page RPC sequence is invalid"); + } + } +} + +const REQUEST_KEYS = ["version", "sequence", "handle", "api", "params"] as const; + +export const validatePageGMRequest = (value: unknown, registry: PageRpcRegistry): PageGMRequest => { + if (value === null || typeof value !== "object") throw new PageRpcError("page RPC request must be an object"); + + let keys: (string | symbol)[]; + try { + keys = nativeReflectOwnKeys(value); + } catch { + throw new PageRpcError("page RPC request cannot be inspected"); + } + if (keys.length !== REQUEST_KEYS.length) { + throw new PageRpcError("page RPC request has unexpected fields"); + } + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + let knownKey = false; + if (typeof key === "string") { + for (let expectedIndex = 0; expectedIndex < REQUEST_KEYS.length; expectedIndex += 1) { + const expected = REQUEST_KEYS[expectedIndex]; + if (expected === key) { + knownKey = true; + break; + } + } + } + if (!knownKey) { + throw new PageRpcError("page RPC request has unexpected fields"); + } + } + + const version = ownData(value, "version"); + const sequence = ownData(value, "sequence"); + const handle = ownData(value, "handle"); + const api = ownData(value, "api"); + const params = ownData(value, "params"); + + if (version !== PAGE_RPC_VERSION) throw new PageRpcError("unsupported page RPC version"); + if (typeof sequence !== "number" || !Number.isSafeInteger(sequence) || sequence < 1) { + throw new PageRpcError("page RPC sequence is invalid"); + } + if (typeof handle !== "string" || typeof api !== "string") { + throw new PageRpcError("page RPC identity fields are invalid"); + } + + const binding = registry.resolve(handle, api); + // resolve 同时执行句柄和授权检查;不要把页面传来的 api 直接转发给后端。 + const clonedParams = cloneParams(params); + validateOperationParams(api, clonedParams); + registry.consumeRequestSequence(binding, sequence); + return { + version: PAGE_RPC_VERSION, + sequence, + handle, + api, + params: clonedParams, + }; +}; diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index a4fe6c02a..a07f7f236 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -2,10 +2,15 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import type { Message } from "@Packages/message/types"; import type { ScriptLoadInfo } from "../service_worker/types"; import type { TScriptInfo } from "@App/app/repo/scripts"; -import { initEnvInfo, ScriptExecutor } from "./script_executor"; +import type { GMInfoEnv } from "./types"; +import { initEnvInfo, ScriptExecutor, type ExecScriptEntry } from "./script_executor"; +import { compileInjectScript, compilePreInjectScript, compileScriptCode } from "./utils"; +import { DefinedFlags } from "../service_worker/runtime.consts"; +import { pageDispatchEvent } from "@Packages/message/common"; const styleUrl = "https://example.com/style.css"; const secondStyleUrl = "https://example.com/second-style.css"; +const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; function makeScript(overrides: Partial> = {}): ScriptLoadInfo { return { @@ -30,7 +35,881 @@ function makeScript(overrides: Partial[0] = {}): TScriptInfo { + const script = makeScript({ + ...overrides, + metadata: { + "early-start": [""], + "run-at": ["document-start"], + ...overrides.metadata, + }, + }); + return { ...script, scriptRevision: overrides.scriptRevision ?? `${script.uuid}:1:0` } as TScriptInfo; +} + +function withPageBinding( + script: TScriptInfo, + overrides: Partial> = {} +): TScriptInfo { + return { + ...script, + executionHandle: overrides.executionHandle ?? "page-binding", + executionEnvTag: overrides.executionEnvTag ?? "it", + executionRunFlag: overrides.executionRunFlag ?? "page-run", + }; +} + +function mountInjectScript(script: ScriptLoadInfo, code: string) { + const execute = new Function("window", compileInjectScript(script, code)) as (target: Window) => void; + execute(window); +} + +function mountPreInjectScript(script: ScriptLoadInfo, scriptCode: string = "") { + const performance = { dispatchEvent: vi.fn(() => false), addEventListener: vi.fn() }; + const execute = new Function("window", "performance", "CustomEvent", compilePreInjectScript(script, scriptCode)) as ( + target: Window, + perf: typeof performance, + customEvent: typeof CustomEvent + ) => void; + execute(window, performance, CustomEvent); + return performance; +} + +function attachLegacyPreInjectMetadata(scriptFunc: (...args: unknown[]) => unknown, scriptInfo: TScriptInfo) { + const documentId = "script-executor-test-document"; + const documentIdKey = `${fnStrIntegrity}:documentId`; + if (!Object.prototype.hasOwnProperty.call(window, documentIdKey)) { + Object.defineProperty(window, documentIdKey, { configurable: false, writable: false, value: documentId }); + } + Object.defineProperty(scriptFunc, fnStrIntegrity, { value: true }); + Object.defineProperty(scriptFunc, `${fnStrIntegrity}:scriptInfo`, { value: JSON.stringify(scriptInfo) }); + Object.defineProperty(scriptFunc, `${fnStrIntegrity}:documentUrl`, { value: window.location.href }); + Object.defineProperty(scriptFunc, documentIdKey, { value: documentId }); +} + +type EarlyScriptExecution = { + scriptRes: TScriptInfo; + reconcileEarlyScript: (envInfo: GMInfoEnv, scriptInfo?: TScriptInfo) => boolean; + execContext: any; +}; + +function createEarlyExecution( + scriptLoadInfo: TScriptInfo, + scriptFunc: ExecScriptEntry["scriptFunc"] = () => undefined, + message: Message = {} as Message +) { + const executor = new ScriptExecutor(message, {} as Message); + executor.execScriptEntry({ + scriptLoadInfo, + scriptFlag: scriptLoadInfo.flag, + envInfo: initEnvInfo, + scriptFunc, + }); + const exec = ( + executor as unknown as { + execScripts: Map; + } + ).execScripts.get(scriptLoadInfo.uuid)!; + return { exec }; +} + describe("ScriptExecutor", () => { + it("uses the configured transport prefix for USER_SCRIPT GM calls", () => { + const sendMessage = vi.fn().mockResolvedValue(undefined); + const script = makeScript({ metadata: { grant: ["GM_log"] } }); + const executor = new ScriptExecutor({ sendMessage } as unknown as Message, {} as Message, "serviceWorker"); + + executor.execScriptEntry({ + scriptLoadInfo: script, + scriptFlag: script.flag, + envInfo: initEnvInfo, + scriptFunc: (_token: string, context: any) => context.GM_log("transport prefix"), + }); + + expect(sendMessage).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/gmApi", + data: expect.objectContaining({ api: "GM_log" }), + }); + }); + + it("does not resolve page-patchable Map methods for execution bookkeeping", () => { + const originalSet = Map.prototype.set; + const originalGet = Map.prototype.get; + const originalValues = Map.prototype.values; + const receivers: Map[] = []; + Map.prototype.set = function (key, value) { + receivers.push(this); + return originalSet.call(this, key, value); + }; + Map.prototype.get = function (key) { + receivers.push(this); + return originalGet.call(this, key); + }; + Map.prototype.values = function () { + receivers.push(this); + return originalValues.call(this); + }; + try { + const executor = new ScriptExecutor({} as Message, {} as Message); + executor.execScriptEntry({ + scriptLoadInfo: makeScript(), + scriptFlag: "executor-test-flag", + envInfo: initEnvInfo, + scriptFunc: () => undefined, + }); + expect(receivers).toHaveLength(0); + } finally { + Map.prototype.set = originalSet; + Map.prototype.get = originalGet; + Map.prototype.values = originalValues; + } + }); + + it("attaches the page execution binding when an early-start script is reconciled", () => { + const initial = makeEarlyScript(); + const { exec } = createEarlyExecution(initial); + expect(exec.scriptRes.executionHandle).toBeUndefined(); + const gmInfo = exec.execContext.GM_info; + + expect( + exec.reconcileEarlyScript( + initEnvInfo, + withPageBinding({ + ...initial, + value: { secret: "authoritative-value" }, + config: { + private: { secret: { title: "Private", description: "", index: 0, default: "authoritative" } }, + }, + userConfig: { + account: { profile: { title: "Profile", description: "", index: 0, default: "authoritative" } }, + }, + userConfigStr: '{"profile":"authoritative"}', + }) + ) + ).toBe(true); + + expect(exec.scriptRes.executionHandle).toBe("page-binding"); + expect(exec.scriptRes.executionEnvTag).toBe("it"); + expect(exec.scriptRes.executionRunFlag).toBe("page-run"); + expect(exec.scriptRes.value).toEqual({ secret: "authoritative-value" }); + expect(exec.scriptRes.config).toEqual({ + private: { secret: { title: "Private", description: "", index: 0, default: "authoritative" } }, + }); + expect(exec.scriptRes.userConfig).toEqual({ + account: { profile: { title: "Profile", description: "", index: 0, default: "authoritative" } }, + }); + expect(exec.scriptRes.userConfigStr).toBe('{"profile":"authoritative"}'); + expect(exec.execContext.GM_info).toMatchObject({ + userConfig: { + account: { profile: { title: "Profile", description: "", index: 0, default: "authoritative" } }, + }, + userConfigStr: '{"profile":"authoritative"}', + isIncognito: false, + sandboxMode: "raw", + }); + expect(exec.execContext.GM_info).toBe(gmInfo); + }); + + it("preserves synchronous early-start value writes across authoritative reconciliation", () => { + const initial = makeEarlyScript({ + uuid: "early-rmw-uuid", + flag: "early-rmw-flag", + metadata: { + grant: ["GM_getValue", "GM_setValue", "GM_deleteValue"], + "early-start": [""], + "run-at": ["document-start"], + }, + value: { counter: 100, deleted: "preload", untouched: "preload" }, + }); + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const { exec: execScript } = createEarlyExecution( + initial, + (_token: string, context: any) => { + const counter = context.GM_getValue("counter", 0); + context.GM_setValue("counter", counter + 1); + context.GM_setValue("local-only", "local"); + context.GM_deleteValue("deleted"); + }, + { sendMessage } as unknown as Message + ); + + expect(execScript.scriptRes.value).toEqual({ + counter: 101, + untouched: "preload", + "local-only": "local", + }); + + expect( + execScript.reconcileEarlyScript( + initEnvInfo, + withPageBinding( + { + ...initial, + value: { + counter: 999, + deleted: "authoritative", + untouched: "authoritative", + "server-only": "server", + }, + }, + { executionHandle: "early-rmw-binding", executionRunFlag: "early-rmw-run" } + ) + ) + ).toBe(true); + + expect(execScript.scriptRes.value).toEqual({ + counter: 101, + untouched: "authoritative", + "local-only": "local", + "server-only": "server", + }); + expect(Object.prototype.hasOwnProperty.call(execScript.scriptRes.value, "deleted")).toBe(false); + }); + + it("early-start reconciliation installs new scriptRes fields without invoking an inherited setter", () => { + // Native.objectAssign(current, scriptInfo) 对每个 key 做普通 [[Set]];current 自身没有 + // executionHandle 这个 own key(它是本轮 reconcile 才第一次出现的字段),普通赋值会沿原型链 + // 查找继承的 setter 并调用它。安全的安装原语必须绕开这一步,直接在 current 上定义 own + // data property。 + const original = Object.getOwnPropertyDescriptor(Object.prototype, "executionHandle"); + let setterCalls = 0; + Object.defineProperty(Object.prototype, "executionHandle", { + configurable: true, + set() { + setterCalls += 1; + }, + get() { + return undefined; + }, + }); + try { + const initial = makeEarlyScript({ + uuid: "inherited-setter-test-uuid", + flag: "inherited-setter-test-flag", + }); + const { exec } = createEarlyExecution(initial); + + const ok = exec.reconcileEarlyScript(initEnvInfo, withPageBinding(initial)); + + expect(ok).toBe(true); + expect(setterCalls).toBe(0); + expect(Object.hasOwn(exec.scriptRes, "executionHandle")).toBe(true); + expect(exec.scriptRes.executionHandle).toBe("page-binding"); + } finally { + if (original) { + Object.defineProperty(Object.prototype, "executionHandle", original); + } else { + delete (Object.prototype as any).executionHandle; + } + } + }); + + it("early-start reconciliation does not let an own '__proto__' data property on scriptInfo mutate scriptRes's prototype", () => { + // customClone/structuredClone 把 "__proto__" 当成普通字符串 key,不当成原型设置语法, + // 所以一份经由 pageLoad 传输的 scriptInfo 理论上仍可能带有一个 own enumerable 的 + // "__proto__" 数据属性。Object.assign 对它做普通 [[Set]] 会触发 Object.prototype 上继承的 + // __proto__ setter,真的改写 current 的原型;安全的安装原语必须把它当成普通数据字段。 + const initial = makeEarlyScript({ + uuid: "proto-key-test-uuid", + flag: "proto-key-test-flag", + }); + const { exec } = createEarlyExecution(initial); + const originalPrototype = Object.getPrototypeOf(exec.scriptRes); + + const forgedScriptInfo: Record = { ...withPageBinding(initial) }; + Object.defineProperty(forgedScriptInfo, "__proto__", { + configurable: true, + enumerable: true, + value: { forgedPrototype: true }, + }); + + const ok = exec.reconcileEarlyScript(initEnvInfo, forgedScriptInfo as TScriptInfo); + + expect(ok).toBe(true); + expect(Object.getPrototypeOf(exec.scriptRes)).toBe(originalPrototype); + }); + + it("early-start reconciliation never invokes a userscript-installed configurable setter on GM_info, and replaces it with authoritative data", () => { + const initial = makeEarlyScript({ + uuid: "gminfo-setter-test-uuid", + flag: "gminfo-setter-test-flag", + }); + const { exec } = createEarlyExecution(initial); + const gmInfo = exec.execContext.GM_info; + + let setterCalls = 0; + Object.defineProperty(gmInfo, "sandboxMode", { + configurable: true, + enumerable: true, + set() { + setterCalls += 1; + }, + get() { + return "script-installed"; + }, + }); + + const ok = exec.reconcileEarlyScript(initEnvInfo, withPageBinding(initial)); + + expect(ok).toBe(true); + expect(setterCalls).toBe(0); + expect(Object.getOwnPropertyDescriptor(gmInfo, "sandboxMode")).toMatchObject({ value: "raw", writable: true }); + expect(gmInfo.sandboxMode).toBe("raw"); + }); + + it("early-start reconciliation is not blocked by a non-configurable hostile GM_info accessor; the locked field is skipped but everything else still reconciles", () => { + const initial = makeEarlyScript({ + uuid: "gminfo-nonconfigurable-test-uuid", + flag: "gminfo-nonconfigurable-test-flag", + }); + const { exec } = createEarlyExecution(initial); + const gmInfo = exec.execContext.GM_info; + + let setterCalls = 0; + Object.defineProperty(gmInfo, "sandboxMode", { + configurable: false, + enumerable: true, + get() { + return "script-locked"; + }, + set() { + setterCalls += 1; + throw new Error("must never execute"); + }, + }); + + const ok = exec.reconcileEarlyScript( + initEnvInfo, + withPageBinding({ ...initial, value: { secret: "authoritative-value" } }) + ); + + // 内部权威状态(scriptRes、execution binding、load lifecycle)必须照常完全生效, + // 完全不受脚本锁死自己 GM_info 某个字段这件事影响。 + expect(ok).toBe(true); + expect(setterCalls).toBe(0); + expect(exec.scriptRes.executionHandle).toBe("page-binding"); + expect(exec.scriptRes.executionEnvTag).toBe("it"); + expect(exec.scriptRes.executionRunFlag).toBe("page-run"); + expect(exec.scriptRes.value).toEqual({ secret: "authoritative-value" }); + // 被锁死的字段维持脚本自己安装的值——不是被静默改写,也不是抛错阻断了其余字段。 + expect(gmInfo.sandboxMode).toBe("script-locked"); + // 未被锁死的字段仍然正常刷新。 + expect(gmInfo.isIncognito).toBe(false); + }); + + it("GM.info stays the same object as GM_info across early-start reconciliation", () => { + const initial = makeEarlyScript({ + uuid: "gminfo-identity-test-uuid", + flag: "gminfo-identity-test-flag", + }); + const { exec } = createEarlyExecution(initial); + + const gmInfoBefore = exec.execContext.GM_info; + expect(exec.execContext.GM.info).toBe(gmInfoBefore); + + const ok = exec.reconcileEarlyScript(initEnvInfo, withPageBinding(initial)); + + expect(ok).toBe(true); + expect(exec.execContext.GM_info).toBe(gmInfoBefore); + expect(exec.execContext.GM.info).toBe(gmInfoBefore); + }); + + it("rejects a different early-start revision and cancels its pending GM work", async () => { + const initial = makeEarlyScript({ + uuid: "early-revision-mismatch", + flag: "early-revision-mismatch-flag", + createtime: 1, + updatetime: 2, + metadata: { grant: ["CAT_scriptLoaded", "GM.setValue"], "early-start": [""], "run-at": ["document-start"] }, + scriptRevision: "early-revision-mismatch:1:2", + }); + const authoritative = withPageBinding( + { ...initial, scriptRevision: "early-revision-mismatch:1:3" }, + { executionHandle: "current-binding", executionRunFlag: "current-run" } + ); + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const executor = new ScriptExecutor({ sendMessage } as unknown as Message, {} as Message); + let loadPromise: Promise | undefined; + let setValuePromise: Promise | undefined; + executor.execScriptEntry({ + scriptLoadInfo: initial, + scriptFlag: initial.flag, + envInfo: initEnvInfo, + scriptFunc: (_token: string, context: any) => { + loadPromise = context.CAT_scriptLoaded(); + setValuePromise = context.GM.setValue("key", "value"); + }, + }); + const internal = executor as unknown as { + earlyScriptFlags: Set; + execScripts: Map; + }; + internal.earlyScriptFlags.add(initial.flag); + + executor.startScripts([authoritative], initEnvInfo); + + expect(internal.execScripts.has(initial.uuid)).toBe(false); + expect(sendMessage).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(loadPromise).resolves.toBeUndefined(), { timeout: 100 }); + await vi.waitFor(() => expect(setValuePromise).resolves.toBeUndefined(), { timeout: 100 }); + }); + + it("invalidates early-start scripts omitted from an authoritative pageLoad", () => { + const early = makeEarlyScript({ + uuid: "early-omitted-script", + flag: "early-omitted-flag", + metadata: { grant: ["GM_log"] }, + }); + const other = withPageBinding( + { + ...makeScript({ uuid: "current-script", flag: "current-script-flag" }), + scriptRevision: "current-script:1:0", + } as TScriptInfo, + { executionHandle: "current-binding", executionRunFlag: "current-run" } + ); + const executor = new ScriptExecutor({} as Message, {} as Message); + executor.execScriptEntry({ + scriptLoadInfo: early, + scriptFlag: early.flag, + envInfo: initEnvInfo, + scriptFunc: () => undefined, + }); + const internal = executor as unknown as { + earlyScriptFlags: Set; + execScripts: Map< + string, + { + sandboxContext?: { isInvalidContext(): boolean }; + emitEvent(event: string, eventId: string, data: unknown): void; + valueUpdate(data: unknown): void; + } + >; + }; + internal.earlyScriptFlags.add(early.flag); + const earlyExec = internal.execScripts.get(early.uuid)!; + const emitEvent = vi.spyOn(earlyExec, "emitEvent"); + const valueUpdate = vi.spyOn(earlyExec, "valueUpdate"); + + try { + executor.startScripts([other], initEnvInfo); + executor.emitEvent({ uuid: early.uuid, event: "menuClick", eventId: "menu-id" } as any); + executor.valueUpdate({ uuid: early.uuid, storageName: "", entries: [], sender: { runFlag: "other" } } as any); + + expect(earlyExec.sandboxContext?.isInvalidContext()).toBe(true); + expect(internal.execScripts.has(early.uuid)).toBe(false); + expect(emitEvent).not.toHaveBeenCalled(); + expect(valueUpdate).not.toHaveBeenCalled(); + } finally { + delete (window as unknown as Record)[other.flag]; + } + }); + + it("requires a binding before reconciling an early script with GM grants", () => { + const initial = makeEarlyScript({ + uuid: "early-unbound-script", + flag: "early-unbound-flag", + metadata: { grant: ["GM_log"] }, + }); + const executor = new ScriptExecutor({} as Message, {} as Message); + executor.execScriptEntry({ + scriptLoadInfo: initial, + scriptFlag: initial.flag, + envInfo: initEnvInfo, + scriptFunc: () => undefined, + }); + const internal = executor as unknown as { + earlyScriptFlags: Set; + execScripts: Map; + }; + internal.earlyScriptFlags.add(initial.flag); + const earlyExec = internal.execScripts.get(initial.uuid)!; + const current = { ...initial } as TScriptInfo; + + executor.startScripts([current], initEnvInfo); + + expect(earlyExec.sandboxContext?.isInvalidContext()).toBe(true); + expect(internal.execScripts.has(initial.uuid)).toBe(false); + }); + + it("ignores a counterfeit mount with a copied marker and keeps listening for the genuine wrapper", () => { + const script = makeScript({ flag: "executor-counterfeit-flag", scriptRevision: "counterfeit-flag-revision" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const pageWindow = window as unknown as Record; + const attacker = vi.fn((_token: string, context: unknown) => { + Reflect.set(pageWindow, "__capturedExecutionContext", context); + }); + Object.defineProperty(attacker, fnStrIntegrity, { value: true }); + + try { + executor.startScripts([script], initEnvInfo); + pageWindow[script.flag] = attacker; + + expect(attacker).not.toHaveBeenCalled(); + expect(pageWindow.__capturedExecutionContext).toBeUndefined(); + + mountInjectScript(script, "window.__genuineWrapperExecuted = true;"); + expect(pageWindow.__genuineWrapperExecuted).toBe(true); + expect(attacker).not.toHaveBeenCalled(); + } finally { + delete pageWindow[script.flag]; + delete pageWindow.__capturedExecutionContext; + delete pageWindow.__genuineWrapperExecuted; + } + }); + + it("runs an early wrapper with synchronous preload state before authoritative pageLoad", () => { + const script = makeScript({ + uuid: "executor-preload-first-line-uuid", + flag: "#-executor-preload-first-line-uuid", + metadata: { + grant: ["GM_getValue", "GM_getValues", "GM_listValues"], + "early-start": [""], + "run-at": ["document-start"], + }, + value: { stored: "persisted", counter: 7 }, + userConfig: { + profile: { selected: { title: "Selected", description: "", index: 0, default: "custom" } }, + }, + userConfigStr: '{"profile":{"selected":"custom"}}', + }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const pageWindow = window as unknown as Record; + const code = compileScriptCode( + script, + `unsafeWindow.__earlyPreloadProbe = { + value: GM_getValue("stored", "DEFAULT"), + counter: GM_getValues(["counter"]).counter, + listed: GM_listValues().includes("stored"), + userConfigStr: GM_info.userConfigStr + };` + ); + + try { + mountPreInjectScript(script, code); + + expect(executor.execEarlyScript(script.flag, initEnvInfo)).toBe(true); + expect(pageWindow.__earlyPreloadProbe).toEqual({ + value: "persisted", + counter: 7, + listed: true, + userConfigStr: script.userConfigStr, + }); + } finally { + delete pageWindow[script.flag]; + delete pageWindow.__earlyPreloadProbe; + } + }); + + it("rejects a counterfeit early-start wrapper before execution", () => { + const script = makeScript({ + uuid: "executor-counterfeit-early-uuid", + flag: "#-executor-counterfeit-early-uuid", + metadata: { "early-start": [""], "run-at": ["document-start"] }, + }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const attacker = vi.fn(); + const pageWindow = window as unknown as Record; + + try { + pageWindow[script.flag] = attacker; + executor.execEarlyScript(script.flag, initEnvInfo); + expect(attacker).not.toHaveBeenCalled(); + + mountPreInjectScript(script); + expect(executor.execEarlyScript(script.flag, initEnvInfo)).toBe(true); + } finally { + delete pageWindow[script.flag]; + } + }); + + it("rejects an own toString spoof that impersonates the generated wrapper", () => { + const script = makeScript({ + uuid: "executor-own-to-string-uuid", + flag: "#-executor-own-to-string-uuid", + metadata: { "early-start": [""], "run-at": ["document-start"] }, + }); + const pageWindow = window as unknown as Record; + mountPreInjectScript(script); + const genuineSource = Function.prototype.toString.call(pageWindow[script.flag]); + const attacker = vi.fn((token: string, context: unknown, marker: unknown) => { + if (context === null && marker === document) return JSON.stringify(script); + Reflect.set(pageWindow, "__capturedExecutionContext", context); + return undefined; + }); + Object.defineProperty(attacker, "toString", { value: () => genuineSource }); + const executor = new ScriptExecutor({} as Message, {} as Message); + + try { + pageWindow[script.flag] = attacker; + + expect(executor.execEarlyScript(script.flag, initEnvInfo)).toBeUndefined(); + expect(attacker).not.toHaveBeenCalled(); + expect(pageWindow.__capturedExecutionContext).toBeUndefined(); + } finally { + delete pageWindow[script.flag]; + delete pageWindow.__capturedExecutionContext; + } + }); + + it("uses captured function source inspection when the page replaces toString", () => { + const script = makeScript({ flag: "executor-spoofed-to-string-flag" }); + const targetWindow: Record = {}; + const execute = new Function("window", compileInjectScript(script, "")) as ( + target: Record + ) => void; + execute(targetWindow); + const genuineSource = Function.prototype.toString.call(targetWindow[script.flag]); + const attacker = vi.fn((token: string, target: unknown, marker: unknown) => { + if (token === fnStrIntegrity && target === null && marker === document) { + return JSON.stringify({ uuid: script.uuid, flag: script.flag }); + } + Reflect.set(targetWindow, "__capturedExecutionContext", target); + }); + Object.defineProperty(attacker, fnStrIntegrity, { value: true }); + const originalToString = Function.prototype.toString; + const executor = new ScriptExecutor({} as Message, {} as Message); + const pageWindow = window as unknown as Record; + + try { + Function.prototype.toString = function () { + return genuineSource; + }; + executor.startScripts([script], initEnvInfo); + pageWindow[script.flag] = attacker; + + expect(attacker).not.toHaveBeenCalled(); + expect(targetWindow.__capturedExecutionContext).toBeUndefined(); + } finally { + Function.prototype.toString = originalToString; + delete pageWindow[script.flag]; + delete targetWindow[script.flag]; + delete targetWindow.__capturedExecutionContext; + } + }); + + it("rejects page-copied early-start metadata on a counterfeit function", () => { + const script = makeScript({ + uuid: "executor-forged-early-uuid", + flag: "#-executor-forged-early-uuid", + metadata: { "early-start": [""], "run-at": ["document-start"] }, + }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const counterfeit = vi.fn(); + attachLegacyPreInjectMetadata(counterfeit, script); + + try { + (window as unknown as Record)[script.flag] = counterfeit; + expect(executor.execEarlyScript(script.flag, initEnvInfo)).toBeUndefined(); + expect(counterfeit).not.toHaveBeenCalled(); + } finally { + delete (window as unknown as Record)[script.flag]; + } + }); + + it("rejects early metadata that retargets the flag or carries a page binding", () => { + const script = makeScript({ flag: "#-executor-test-uuid" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const wrongUuid = vi.fn(); + const bound = vi.fn(); + const pageWindow = window as unknown as Record; + attachLegacyPreInjectMetadata(wrongUuid, { ...script, uuid: "other-script" } as TScriptInfo); + attachLegacyPreInjectMetadata(bound, { ...script, executionHandle: "other-binding" } as TScriptInfo); + + try { + pageWindow[script.flag] = wrongUuid; + executor.execEarlyScript(script.flag, initEnvInfo); + expect(wrongUuid).not.toHaveBeenCalled(); + + pageWindow[script.flag] = bound; + executor.execEarlyScript(script.flag, initEnvInfo); + expect(bound).not.toHaveBeenCalled(); + } finally { + delete pageWindow[script.flag]; + } + }); + + it("rejects same-UUID early metadata mutations", () => { + const script = makeScript({ + uuid: "executor-early-authenticated-uuid", + flag: "#-executor-early-authenticated-uuid", + metadata: { + grant: ["GM_getValue", "GM_getResourceText"], + resource: ["canonical https://example.com/canonical"], + "early-start": [""], + "run-at": ["document-start"], + }, + resource: { + canonical: { + url: "https://example.com/canonical", + content: "canonical", + base64: "", + hash: { md5: "", sha1: "", sha256: "", sha384: "", sha512: "" }, + type: "resource", + link: {}, + contentType: "text/plain", + createtime: Date.now(), + }, + }, + }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const pageWindow = window as unknown as Record; + const performance = { dispatchEvent: vi.fn(() => false), addEventListener: vi.fn() }; + const generated = new Function("window", "performance", "CustomEvent", compilePreInjectScript(script, "")); + + try { + generated(pageWindow, performance, CustomEvent); + const forged = { + ...script, + metadata: { grant: ["GM_setValue"] }, + resource: { forged: { content: "forged", contentType: "text/plain" } }, + } as TScriptInfo; + + executor.checkEarlyStartScript("it", initEnvInfo); + const hostileDetail = {}; + const flagGetter = vi.fn(() => script.flag); + Object.defineProperty(hostileDetail, "scriptFlag", { get: flagGetter }); + pageDispatchEvent( + new CustomEvent(`evt${process.env.SC_RANDOM_KEY}.it${DefinedFlags.scriptLoadComplete}`, { + detail: hostileDetail, + cancelable: true, + }) + ); + expect(flagGetter).not.toHaveBeenCalled(); + + pageDispatchEvent( + new CustomEvent(`evt${process.env.SC_RANDOM_KEY}.it${DefinedFlags.scriptLoadComplete}`, { + detail: { scriptFlag: script.flag, scriptInfo: forged }, + cancelable: true, + }) + ); + + const exec = ( + executor as unknown as { + execScripts: Map; + } + ).execScripts.get(script.uuid); + expect(exec?.scriptRes.metadata).toEqual(script.metadata); + expect(exec?.scriptRes.resource).toEqual({ + canonical: { base64: "", content: "canonical", contentType: "text/plain" }, + }); + } finally { + delete pageWindow[script.flag]; + } + }); + + it("accepts an early-start wrapper after a same-document URL change", () => { + const script = makeScript({ + uuid: "executor-early-document-uuid", + flag: "#-executor-early-document-uuid", + metadata: { "early-start": [""], "run-at": ["document-start"] }, + }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const pageWindow = window as unknown as Record; + const initialUrl = window.location.href; + + try { + window.history.pushState({}, "", `${initialUrl}#same-document-change`); + mountPreInjectScript(script); + expect(executor.execEarlyScript(script.flag, initEnvInfo)).toBe(true); + } finally { + window.history.replaceState({}, "", initialUrl); + delete pageWindow[script.flag]; + } + }); + + it("continues loading later scripts after reconciling an early-start entry", () => { + const early = makeEarlyScript({ + uuid: "early-script", + flag: "executor-early-batch", + }); + const later = makeScript({ + uuid: "later-script", + flag: "executor-later-batch", + scriptRevision: "later-batch-revision", + }); + const executor = new ScriptExecutor({} as Message, {} as Message); + executor.execScriptEntry({ + scriptLoadInfo: early, + scriptFlag: early.flag, + envInfo: initEnvInfo, + scriptFunc: () => undefined, + }); + + const internal = executor as unknown as { + earlyScriptFlags: Set; + execScripts: Map boolean }>; + }; + internal.earlyScriptFlags.add(early.flag); + const reconcileEarlyScript = vi.spyOn(internal.execScripts.get(early.uuid)!, "reconcileEarlyScript"); + const pageWindow = window as unknown as Record; + + try { + executor.startScripts([early, later], initEnvInfo); + mountInjectScript(later, ""); + + expect(reconcileEarlyScript).toHaveBeenCalledWith(initEnvInfo, early); + expect(internal.execScripts.has(later.uuid)).toBe(true); + } finally { + delete pageWindow[later.flag]; + } + }); + + describe("normal wrapper compiled-revision enforcement", () => { + it("rejects a genuine wrapper compiled for an older revision, then accepts a later genuine match", () => { + const authoritativeR1 = makeScript({ + uuid: "revision-mismatch-uuid", + flag: "revision-mismatch-flag", + scriptRevision: "revision-r1", + }); + const authoritativeR2 = { ...authoritativeR1, scriptRevision: "revision-r2" }; + const executor = new ScriptExecutor({} as Message, {} as Message); + const pageWindow = window as unknown as Record; + const internal = executor as unknown as { execScripts: Map }; + + try { + // Service Worker 的权威数据已经是 R2;页面上真正挂载的 wrapper 却还带着编译时的 R1。 + executor.startScripts([authoritativeR2], initEnvInfo); + mountInjectScript(authoritativeR1, "window.__r1Executed = true;"); + + expect(pageWindow.__r1Executed).toBeUndefined(); + expect(internal.execScripts.has(authoritativeR1.uuid)).toBe(false); + + // 拒绝之后仍继续监听;随后到来的真正 R2 wrapper 必须能正常执行。 + mountInjectScript(authoritativeR2, "window.__r2Executed = true;"); + + expect(pageWindow.__r2Executed).toBe(true); + expect(internal.execScripts.has(authoritativeR2.uuid)).toBe(true); + } finally { + delete pageWindow[authoritativeR1.flag]; + delete pageWindow.__r1Executed; + delete pageWindow.__r2Executed; + } + }); + + it("rejects a genuine wrapper compiled without any scriptRevision (legacy shape) against authoritative revision data", () => { + const legacyWrapperScript = makeScript({ + uuid: "revisionless-uuid", + flag: "revisionless-flag", + // scriptRevision 故意不设置:模拟这次安全修复落地前就已注册、仍留在浏览器里的旧 wrapper。 + }); + const authoritative = { ...legacyWrapperScript, scriptRevision: "revision-r2" }; + const executor = new ScriptExecutor({} as Message, {} as Message); + const pageWindow = window as unknown as Record; + const internal = executor as unknown as { execScripts: Map }; + + try { + executor.startScripts([authoritative], initEnvInfo); + mountInjectScript(legacyWrapperScript, "window.__legacyExecuted = true;"); + + expect(pageWindow.__legacyExecuted).toBeUndefined(); + expect(internal.execScripts.has(legacyWrapperScript.uuid)).toBe(false); + } finally { + delete pageWindow[legacyWrapperScript.flag]; + delete pageWindow.__legacyExecuted; + } + }); + }); + describe("resource execution", () => { let adoptedSheets: CSSStyleSheet[]; diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 7bbcd9bb3..3a17eb58d 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -3,13 +3,19 @@ import { getStorageName } from "@App/pkg/utils/utils"; import type { EmitEventRequest } from "../service_worker/types"; import ExecScript from "./exec_script"; import type { GMInfoEnv, ScriptFunc, ValueUpdateDataEncoded } from "./types"; -import { addStyleSheet, definePropertyListener, waitBody } from "./utils"; -import type { ScriptLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; +import { + addStyleSheet, + definePropertyListener, + getCompiledScriptMetadata, + isEarlyStartScript, + waitBody, +} from "./utils"; +import type { TScriptInfo } from "@App/app/repo/scripts"; import { DefinedFlags } from "../service_worker/runtime.consts"; import { pageAddEventListener, pageDispatchEvent } from "@Packages/message/common"; import { isUrlExcluded } from "@App/pkg/utils/match"; import type { ScriptEnvTag } from "@Packages/message/consts"; -import { localizeObject } from "./global"; +import { localizeObject, Native } from "./global"; export type ExecScriptEntry = { scriptLoadInfo: TScriptInfo; @@ -30,33 +36,32 @@ export const initEnvInfo: GMInfoEnv = { // 脚本执行器 export class ScriptExecutor { - earlyScriptFlag: Set = new Set(); - execScriptMap: Map = new Map(); + private readonly earlyScriptFlags = new Native.Set(); + private readonly execScripts = new Native.Map(); constructor( private msg: Message, - private contentMsg: Message // 用于 content <-> content/inject 通讯 + private contentMsg: Message, // 用于 content <-> content/inject 通讯 + private readonly envPrefix = "scripting" ) {} emitEvent(data: EmitEventRequest) { // 转发给脚本 - const exec = this.execScriptMap.get(data.uuid); - if (exec) { - exec.emitEvent(data.event, data.eventId, data.data); - } + this.execScripts.get(data.uuid)?.emitEvent(data.event, data.eventId, data.data); } valueUpdate(data: ValueUpdateDataEncoded) { // runtime/valueUpdate const { uuid, storageName } = data; - for (const val of this.execScriptMap.values()) { - if (val.scriptRes.uuid === uuid || getStorageName(val.scriptRes) === storageName) { - val.valueUpdate(data); + this.execScripts.forEach((exec) => { + if (exec.scriptRes.uuid === uuid || getStorageName(exec.scriptRes) === storageName) { + exec.valueUpdate(data); } - } + }); } startScripts(scripts: TScriptInfo[], envInfo: GMInfoEnv) { + const pageWindow = window as unknown as Record; const loadExec = (script: TScriptInfo, scriptFunc: any) => { this.execScriptEntry({ scriptLoadInfo: script, @@ -65,23 +70,55 @@ export class ScriptExecutor { envInfo: envInfo, }); }; + this.execScripts.forEach((exec, uuid) => { + const earlyScript = exec.scriptRes; + if (!this.earlyScriptFlags.has(earlyScript.flag)) return; + let authoritativeScript: TScriptInfo | undefined; + for (let scriptIndex = 0; scriptIndex < scripts.length; scriptIndex += 1) { + const script = scripts[scriptIndex]; + if (script.uuid === uuid && script.flag === earlyScript.flag) { + authoritativeScript = script; + break; + } + } + if (!exec.reconcileEarlyScript(envInfo, authoritativeScript)) this.execScripts.delete(uuid); + }); // 监听脚本加载 - scripts.forEach((script) => { + for (let scriptIndex = 0; scriptIndex < scripts.length; scriptIndex += 1) { + const script = scripts[scriptIndex]; const flag = script.flag; - // 如果是EarlyScriptFlag,处理沙盒环境 - if (this.earlyScriptFlag.has(flag)) { - for (const val of this.execScriptMap.values()) { - if (val.scriptRes.flag === flag) { - // 处理早期脚本的沙盒环境 - val.updateEarlyScriptGMInfo(envInfo); + if (this.earlyScriptFlags.has(flag)) continue; + const listenForScript = () => { + definePropertyListener(window, flag, (val: ScriptFunc) => { + const metadataJSON = getCompiledScriptMetadata(val); + let metadata: { uuid?: unknown; flag?: unknown; scriptRevision?: unknown } | undefined; + try { + metadata = metadataJSON === undefined ? undefined : (Native.jsonParse(metadataJSON) as typeof metadata); + } catch { + metadata = undefined; + } + // wrapper 的编译 revision 必须和 SW 权威数据一致,否则可能是浏览器还没来得及用最新 + // 代码重新注册留下的旧 wrapper——旧代码不能拿到当前的 ScriptInfo/权限状态执行。 + if ( + !metadata || + metadata.uuid !== script.uuid || + metadata.flag !== flag || + typeof metadata.scriptRevision !== "string" || + typeof script.scriptRevision !== "string" || + metadata.scriptRevision !== script.scriptRevision + ) { + const mountDescriptor = Native.objectGetOwnPropertyDescriptor(pageWindow, flag); + if (mountDescriptor?.configurable) { + delete pageWindow[flag]; + listenForScript(); + } return; } - } - } - definePropertyListener(window, flag, (val: ScriptFunc) => { - loadExec(script, val); - }); - }); + loadExec(script, val); + }); + }; + listenForScript(); + } } checkEarlyStartScript(scriptEnvTag: ScriptEnvTag, envInfo: GMInfoEnv) { @@ -91,33 +128,18 @@ export class ScriptExecutor { // 监听 脚本加载 // 适用于此「通知环境加载完成」代码执行后的脚本加载 const scriptLoadCompleteHandler: EventListener = (ev: Event) => { - const detail = (ev as CustomEvent).detail as { - scriptFlag: string; - scriptInfo: ScriptLoadInfo; - }; - const scriptFlag = detail?.scriptFlag; - if (typeof scriptFlag === "string") { - ev.preventDefault(); // dispatchEvent 会回传 false -> 分离环境也能得知环境加载代码已执行 - // 检查是否有 urlPattern,有则执行匹配再决定是否略过注入 - if (detail.scriptInfo.scriptUrlPatterns) { - // 以 REGEX 情况为例 - // "@include /REGEX/" 的情况下,MV3 UserScripts API 基础匹配范围扩大,会比实际需要的广阔,然后在 earlyScript 把不符合 REGEX 的除去 - // (All @include = false -> 除去) - // 注:如果 @include 混合了 regex 跟 一般的,即使 regex 的 @include 不匹对当前网址,但匹对了一般 @include 也视为有效 - // 相反如果 @include 混合了 regex 跟 一般的,regex 的 @include 匹对了即可 - // "@exclude /REGEX/" 的情况下,MV3 UserScripts API 基础匹配范围不会扩大,然后在 earlyScript 把符合 REGEX 的匹配除去 - // (Any @exclude = true -> 除去) - // 注:如果一早已被除排,根本不会被 MV3 UserScripts API 注入。所以只考虑排除「多余的匹配」。(略过注入) - try { - if (isUrlExcluded(window.location.href, detail.scriptInfo.scriptUrlPatterns)) { - // 「多余的匹配」-> 略过注入 - return; - } - } catch (e) { - console.warn("Unexpected match error", e); - } - } - this.execEarlyScript(scriptFlag, detail.scriptInfo, envInfo); + let scriptFlag: unknown; + try { + const detail = (ev as CustomEvent).detail; + if (!detail || typeof detail !== "object") return; + const flagDescriptor = Native.objectGetOwnPropertyDescriptor(detail, "scriptFlag"); + if (!flagDescriptor || !("value" in flagDescriptor)) return; + scriptFlag = flagDescriptor.value; + } catch { + return; + } + if (typeof scriptFlag === "string" && !this.earlyScriptFlags.has(scriptFlag)) { + if (this.execEarlyScript(scriptFlag, envInfo)) ev.preventDefault(); // dispatchEvent 会回传 false -> 分离环境也能得知环境加载代码已执行 } }; pageAddEventListener(scriptLoadCompleteEvtName, scriptLoadCompleteHandler); @@ -127,15 +149,51 @@ export class ScriptExecutor { pageDispatchEvent(ev); } - execEarlyScript(flag: string, scriptInfo: TScriptInfo, envInfo: GMInfoEnv) { - const scriptFunc = (window as any)[flag] as ScriptFunc; + execEarlyScript(flag: string, envInfo: GMInfoEnv) { + const mountDescriptor = Native.objectGetOwnPropertyDescriptor(window, flag); + const scriptFunc = + mountDescriptor && "value" in mountDescriptor ? (mountDescriptor.value as ScriptFunc) : undefined; + const scriptInfoJSON = getCompiledScriptMetadata(scriptFunc); + if (scriptInfoJSON === undefined) return; + let scriptInfo: TScriptInfo | undefined; + try { + scriptInfo = Native.jsonParse(scriptInfoJSON) as TScriptInfo | undefined; + } catch { + return; + } + if ( + !scriptInfo || + scriptInfo.flag !== flag || + typeof scriptInfo.uuid !== "string" || + !scriptInfo.uuid || + (flag.startsWith("#-") && scriptInfo.uuid !== flag.slice(2)) || + !isEarlyStartScript(scriptInfo.metadata || {}) + ) { + return; + } + if ( + scriptInfo.executionHandle !== undefined || + scriptInfo.executionEnvTag !== undefined || + scriptInfo.executionRunFlag !== undefined + ) { + return; + } + // MV3 对正则匹配会放宽注入范围,必须用编译器绑定的模式在当前页面再确认一次。 + if (scriptInfo.scriptUrlPatterns) { + try { + if (isUrlExcluded(window.location.href, scriptInfo.scriptUrlPatterns)) return; + } catch (e) { + console.warn("Unexpected match error", e); + } + } this.execScriptEntry({ scriptLoadInfo: scriptInfo, scriptFunc: scriptFunc, scriptFlag: flag, envInfo: envInfo, }); - this.earlyScriptFlag.add(flag); + this.earlyScriptFlags.add(flag); + return true; } execScriptEntry(scriptEntry: ExecScriptEntry) { @@ -144,18 +202,20 @@ export class ScriptExecutor { const scriptLoadInfo = localizeObject(scriptEntry.scriptLoadInfo); const execScript = new ExecScript(scriptLoadInfo, { - envPrefix: "scripting", + envPrefix: this.envPrefix, message: this.msg, contentMsg: this.contentMsg, code: scriptFunc, envInfo, }); - this.execScriptMap.set(scriptLoadInfo.uuid, execScript); + this.execScripts.set(scriptLoadInfo.uuid, execScript); const metadata = scriptLoadInfo.metadata || {}; const resource = scriptLoadInfo.requireCssResource ?? scriptLoadInfo.resource; // 注入css if (metadata["require-css"] && resource) { - for (const val of metadata["require-css"]) { + const requireCss = metadata["require-css"]; + for (let i = 0; i < requireCss.length; i += 1) { + const val = requireCss[i]; const res = resource[val]; if (res) { addStyleSheet(res.content); diff --git a/src/app/service/content/script_runtime.test.ts b/src/app/service/content/script_runtime.test.ts new file mode 100644 index 000000000..18a37d0b9 --- /dev/null +++ b/src/app/service/content/script_runtime.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Message } from "@Packages/message/types"; +import type { Server } from "@Packages/message/server"; +import type { CustomEventMessage } from "@Packages/message/custom_event_message"; +import { ScriptRuntime } from "./script_runtime"; +import type { ScriptExecutor } from "./script_executor"; + +describe("ScriptRuntime DOM bridge", () => { + it("rejects accessor attributes without executing their getters", () => { + let handler: ((data: any) => unknown) | undefined; + const server = { + on: vi.fn((_name: string, callback: (data: any) => unknown) => { + handler = callback; + }), + } as unknown as Server; + const runtime = new ScriptRuntime("ct", server, {} as Message, {} as any, undefined); + runtime.contentInit(server, {} as CustomEventMessage); + + const getter = vi.fn(() => "secret"); + const attrs = {} as Record; + Object.defineProperty(attrs, "id", { configurable: true, enumerable: true, get: getter }); + + expect(handler?.({ params: [null, "div", attrs] })).toBeUndefined(); + expect(getter).not.toHaveBeenCalled(); + }); + + it("creates an element only from the cloned flat attribute payload", () => { + let handler: ((data: any) => unknown) | undefined; + const domMessage = { + getAndDelRelatedTarget: vi.fn(), + sendRelatedTarget: vi.fn(() => 1), + } as unknown as CustomEventMessage; + const server = { + on: vi.fn((_name: string, callback: (data: any) => unknown) => { + handler = callback; + }), + } as unknown as Server; + const runtime = new ScriptRuntime("ct", server, {} as Message, {} as any, undefined); + runtime.contentInit(server, domMessage); + + const result = handler?.({ params: [null, "div", { id: "safe", textContent: "hello" }] }); + + expect(result).toBe(1); + expect(domMessage.sendRelatedTarget).toHaveBeenCalledWith(expect.any(HTMLDivElement)); + const element = (domMessage.sendRelatedTarget as any).mock.calls[0][0] as HTMLDivElement; + expect(element.id).toBe("safe"); + expect(element.textContent).toBe("hello"); + }); +}); + +describe("ScriptRuntime inject page bootstrap", () => { + const makeServer = () => { + const handlers = new Map unknown>(); + const server = { + on: vi.fn((name: string, callback: (data: unknown) => unknown) => { + handlers.set(name, callback); + }), + } as unknown as Server; + return { handlers, server }; + }; + + const makeExecutor = () => ({ + checkEarlyStartScript: vi.fn(), + startScripts: vi.fn(), + emitEvent: vi.fn(), + valueUpdate: vi.fn(), + }); + + const makePageLoad = () => ({ + scripts: [ + { + uuid: "inject-script", + scriptRevision: "inject-script:1:0", + name: "Inject script", + flag: "inject-script-flag", + code: "", + metadata: { grant: [] }, + resource: {}, + value: {}, + executionHandle: "page-binding", + executionEnvTag: "it", + executionRunFlag: "page-run", + }, + ], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + }); + + it("rejects pageLoad payloads with accessors before starting scripts", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = makePageLoad(); + const scripts = pageLoad.scripts; + const getter = vi.fn(() => scripts); + Object.defineProperty(pageLoad, "scripts", { configurable: true, enumerable: true, get: getter }); + + handlers.get("pageLoad")?.(pageLoad); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.startScripts).not.toHaveBeenCalled(); + }); + + it("rejects pageLoad scripts without a source revision", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = makePageLoad(); + delete (pageLoad.scripts[0] as { scriptRevision?: string }).scriptRevision; + + handlers.get("pageLoad")?.(pageLoad); + + expect(executor.startScripts).not.toHaveBeenCalled(); + }); + + it("rejects pageLoad payloads whose own-key enumeration throws", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = new Proxy(makePageLoad(), { + ownKeys() { + throw new Error("hostile enumeration"); + }, + }); + + handlers.get("pageLoad")?.(pageLoad); + + expect(executor.startScripts).not.toHaveBeenCalled(); + }); + + it("rejects callback DTO accessors before entering the script context", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const eventData = { uuid: "script", event: "menuClick", eventId: "1", data: { value: 1 } }; + const getter = vi.fn(() => eventData.data); + Object.defineProperty(eventData, "data", { configurable: true, enumerable: true, get: getter }); + + handlers.get("runtime/emitEvent")?.(eventData); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.emitEvent).not.toHaveBeenCalled(); + }); + + it("rejects accessors nested in collection callback payloads", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const getter = vi.fn(() => "secret"); + const nested = {} as Record; + Object.defineProperty(nested, "value", { configurable: true, enumerable: true, get: getter }); + const eventData = { + uuid: "script", + event: "menuClick", + eventId: "1", + data: new Map([["nested", nested]]), + }; + + handlers.get("runtime/emitEvent")?.(eventData); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.emitEvent).not.toHaveBeenCalled(); + }); + + it("rejects accessors nested in set callback payloads", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const getter = vi.fn(() => "secret"); + const nested = {} as Record; + Object.defineProperty(nested, "value", { configurable: true, enumerable: true, get: getter }); + const eventData = { + uuid: "script", + event: "menuClick", + eventId: "1", + data: new Set([nested]), + }; + + handlers.get("runtime/emitEvent")?.(eventData); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.emitEvent).not.toHaveBeenCalled(); + }); + + it("clones valid callback and value-update DTOs before dispatch", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const eventData = { uuid: "script", event: "menuClick", eventId: "1", data: { value: 1 } }; + const valueData = { + uuid: "script", + storageName: "script", + entries: [["key", [0, { value: 1 }], [2]]], + sender: { runFlag: "run", tabId: 3 }, + valueUpdated: true, + }; + + handlers.get("runtime/emitEvent")?.(eventData); + handlers.get("runtime/valueUpdate")?.(valueData); + + expect(executor.emitEvent).toHaveBeenCalledOnce(); + expect(executor.valueUpdate).toHaveBeenCalledOnce(); + expect(executor.emitEvent.mock.calls[0][0]).not.toBe(eventData); + expect(executor.valueUpdate.mock.calls[0][0]).not.toBe(valueData); + expect(executor.emitEvent.mock.calls[0][0]).toEqual(eventData); + expect(executor.valueUpdate.mock.calls[0][0]).toEqual(valueData); + }); + + it("rejects inject scripts without the current execution binding", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = makePageLoad(); + Object.defineProperty(pageLoad.scripts[0], "executionHandle", { configurable: true, value: undefined }); + + handlers.get("pageLoad")?.(pageLoad); + + expect(executor.startScripts).not.toHaveBeenCalled(); + }); + + it("starts scripts only after validating and cloning the execution binding", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = makePageLoad(); + handlers.get("pageLoad")?.(pageLoad); + + expect(executor.startScripts).toHaveBeenCalledOnce(); + const [scripts, envInfo] = executor.startScripts.mock.calls[0]; + expect(scripts).not.toBe(pageLoad.scripts); + expect(scripts[0]).toMatchObject({ + executionHandle: "page-binding", + executionEnvTag: "it", + executionRunFlag: "page-run", + }); + expect(envInfo).toEqual(pageLoad.envInfo); + }); + + it("does not execute the same native bootstrap twice after a USER_SCRIPT reconnect", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const first = makePageLoad(); + const replay = makePageLoad(); + handlers.get("pageLoad")?.(first); + handlers.get("pageLoad")?.(replay); + + expect(executor.startScripts).toHaveBeenCalledOnce(); + }); + + it("keeps the content pageLoad path on the native payload", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("ct", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = { scripts: [], envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false } }; + handlers.get("pageLoad")?.(pageLoad); + + expect(executor.startScripts).toHaveBeenCalledWith(pageLoad.scripts, pageLoad.envInfo); + }); + + it("rejects content pageLoad accessors before starting scripts", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("ct", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = { + scripts: [ + { + uuid: "content-script", + name: "Content script", + flag: "content-script-flag", + code: "", + metadata: { grant: [] }, + resource: {}, + value: {}, + executionHandle: "content-binding", + executionEnvTag: "ct", + executionRunFlag: "content-run", + }, + ], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + }; + const scripts = pageLoad.scripts; + const getter = vi.fn(() => scripts); + Object.defineProperty(pageLoad, "scripts", { configurable: true, enumerable: true, get: getter }); + + handlers.get("pageLoad")?.(pageLoad); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.startScripts).not.toHaveBeenCalled(); + }); + + it("rejects content callback DTO accessors before dispatch", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("ct", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const eventData = { uuid: "script", event: "menuClick", eventId: "1", data: { value: 1 } }; + const eventPayload = eventData.data; + const getter = vi.fn(() => eventPayload); + Object.defineProperty(eventData, "data", { configurable: true, enumerable: true, get: getter }); + + handlers.get("runtime/emitEvent")?.(eventData); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.emitEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 817dd2a23..a0b50e435 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -8,8 +8,214 @@ import type { ScriptEnvTag } from "@Packages/message/consts"; import { onInjectPageLoaded } from "./external"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import { type TExtensionEnv } from "../extension/extension_env"; +import { RuntimeClient } from "../service_worker/client"; +import { customClone, Native } from "./global"; +import { setPageRpcExtensionOrigin, type ExtensionOrigin } from "./page_rpc"; + +const MAX_EXECUTION_TOKEN_LENGTH = 256; + +// Inject pageLoad crosses the page-visible bridge, so only a cloned DTO with a current broker binding may reach the executor. +const isRecord = (value: unknown): value is Record => { + if (value === null || typeof value !== "object" || Native.arrayIsArray(value)) return false; + const prototype = Native.objectGetPrototypeOf(value); + return prototype === null || Native.objectGetPrototypeOf(prototype) === null; +}; + +const isStringArray = (value: unknown): value is string[] => { + if (!Native.arrayIsArray(value)) return false; + for (let index = 0; index < value.length; index += 1) { + if (typeof value[index] !== "string") return false; + } + return true; +}; + +const isExecutionToken = (value: unknown): value is string => + typeof value === "string" && value.length > 0 && value.length <= MAX_EXECUTION_TOKEN_LENGTH; + +const isPageResourceMap = (value: unknown): boolean => { + if (!isRecord(value)) return false; + const keys = Native.objectKeys(value); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + const resource = value[key]; + if (!isRecord(resource) || typeof resource.content !== "string" || typeof resource.contentType !== "string") { + return false; + } + if (resource.base64 !== undefined && typeof resource.base64 !== "string") return false; + } + return true; +}; + +const isPageScriptInfo = (value: unknown, envTag: "it" | "ct"): value is TScriptInfo => { + if (!isRecord(value)) return false; + if ( + typeof value.uuid !== "string" || + value.uuid.length === 0 || + typeof value.scriptRevision !== "string" || + value.scriptRevision.length === 0 || + typeof value.name !== "string" || + typeof value.flag !== "string" || + value.flag.length === 0 || + typeof value.code !== "string" || + !isRecord(value.metadata) || + !isRecord(value.value) || + !isPageResourceMap(value.resource) || + (value.requireCssResource !== undefined && !isPageResourceMap(value.requireCssResource)) || + !isExecutionToken(value.executionHandle) || + value.executionEnvTag !== envTag || + !isExecutionToken(value.executionRunFlag) + ) { + return false; + } + const metadataKeys = Native.objectKeys(value.metadata); + for (let index = 0; index < metadataKeys.length; index += 1) { + const key = metadataKeys[index]; + if (!isStringArray(value.metadata[key])) return false; + } + return true; +}; + +const hasOnlyKeys = (value: Record, required: readonly string[], optional: readonly string[] = []) => { + const keys = Native.objectKeys(value); + for (let index = 0; index < required.length; index += 1) { + if (!Native.objectHasOwn(value, required[index])) return false; + } + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + let known = false; + for (let keyIndex = 0; keyIndex < required.length; keyIndex += 1) { + if (required[keyIndex] === key) { + known = true; + break; + } + } + if (!known) { + for (let keyIndex = 0; keyIndex < optional.length; keyIndex += 1) { + if (optional[keyIndex] === key) { + known = true; + break; + } + } + } + if (!known) return false; + } + return true; +}; + +const isEncodedValue = (value: unknown): boolean => { + if (!Native.arrayIsArray(value)) return false; + if (value.length === 1) return value[0] === 1 || value[0] === 2; + return value.length === 2 && value[0] === 0; +}; + +const cloneInjectValueUpdate = (data: unknown): ValueUpdateDataEncoded | undefined => { + const cloned = customClone(data); + if ( + !isRecord(cloned) || + !hasOnlyKeys(cloned, ["entries", "uuid", "storageName", "sender", "valueUpdated"], ["id"]) || + (cloned.id !== undefined && (typeof cloned.id !== "string" || cloned.id.length > MAX_EXECUTION_TOKEN_LENGTH)) || + typeof cloned.uuid !== "string" || + typeof cloned.storageName !== "string" || + typeof cloned.valueUpdated !== "boolean" || + !isRecord(cloned.sender) || + !hasOnlyKeys(cloned.sender, ["runFlag"], ["tabId"]) || + typeof cloned.sender.runFlag !== "string" || + (cloned.sender.tabId !== undefined && typeof cloned.sender.tabId !== "number") || + !Native.arrayIsArray(cloned.entries) + ) { + return undefined; + } + for (let index = 0; index < cloned.entries.length; index += 1) { + const entry = cloned.entries[index]; + if ( + !Native.arrayIsArray(entry) || + entry.length !== 3 || + typeof entry[0] !== "string" || + !isEncodedValue(entry[1]) || + !isEncodedValue(entry[2]) + ) { + return undefined; + } + } + return cloned as unknown as ValueUpdateDataEncoded; +}; + +const cloneInjectEmitEvent = (data: unknown): EmitEventRequest | undefined => { + const cloned = customClone(data); + if ( + !isRecord(cloned) || + !hasOnlyKeys(cloned, ["uuid", "event", "eventId"], ["data"]) || + typeof cloned.uuid !== "string" || + typeof cloned.event !== "string" || + typeof cloned.eventId !== "string" + ) { + return undefined; + } + return cloned as unknown as EmitEventRequest; +}; + +type InjectPageLoadData = { + scripts: TScriptInfo[]; + envInfo: GMInfoEnv; + reconnectToken?: string; +}; + +type PageLoadData = InjectPageLoadData & { + extensionOrigin?: ExtensionOrigin; +}; + +const isExtensionOrigin = (value: unknown): value is ExtensionOrigin => { + if (!isRecord(value) || !hasOnlyKeys(value, ["protocol", "hostname", "port"])) return false; + return ( + (value.protocol === "chrome-extension:" || value.protocol === "moz-extension:") && + typeof value.hostname === "string" && + value.hostname.length > 0 && + typeof value.port === "string" + ); +}; + +const clonePageLoad = ( + data: unknown, + envTag: "it" | "ct", + allowEmpty: boolean, + allowExtensionOrigin: boolean +): PageLoadData | undefined => { + const cloned = customClone(data); + if ( + !isRecord(cloned) || + !hasOnlyKeys( + cloned, + ["scripts", "envInfo"], + ["reconnectToken", ...(allowExtensionOrigin ? ["extensionOrigin"] : [])] + ) || + (cloned.reconnectToken !== undefined && !isExecutionToken(cloned.reconnectToken)) + ) + return undefined; + if (!Native.objectHasOwn(cloned, "scripts") || !Native.objectHasOwn(cloned, "envInfo")) return undefined; + if (!Native.arrayIsArray(cloned.scripts) || (!allowEmpty && cloned.scripts.length === 0)) return undefined; + for (let index = 0; index < cloned.scripts.length; index += 1) { + if (!isPageScriptInfo(cloned.scripts[index], envTag)) return undefined; + } + if (!isRecord(cloned.envInfo)) return undefined; + if (cloned.envInfo.sandboxMode !== "raw" || typeof cloned.envInfo.isIncognito !== "boolean") { + return undefined; + } + if (cloned.envInfo.userAgentData !== undefined && !isRecord(cloned.envInfo.userAgentData)) return undefined; + if (cloned.extensionOrigin !== undefined && !isExtensionOrigin(cloned.extensionOrigin)) return undefined; + return { + scripts: cloned.scripts, + envInfo: cloned.envInfo as unknown as GMInfoEnv, + reconnectToken: cloned.reconnectToken as string | undefined, + extensionOrigin: cloned.extensionOrigin as ExtensionOrigin | undefined, + }; +}; + +const cloneInjectPageLoad = (data: unknown): InjectPageLoadData | undefined => clonePageLoad(data, "it", false, false); export class ScriptRuntime { + // USER_SCRIPT 重连会重放同一份 bootstrap;按服务端签发的句柄去重,导航换文档时句柄也会随之更换。 + private readonly startedScriptKeys = new Native.Set(); + constructor( private readonly scripEnvTag: ScriptEnvTag, private readonly server: Server, @@ -19,11 +225,24 @@ export class ScriptRuntime { ) {} // content环境的特殊初始化 - contentInit() { - this.server.on("runtime/addElement", (data: { params: [number | null, string, Record | null] }) => { - const [parentNodeId, tagName, tmpAttr] = data.params; + contentInit(domServer: Server = this.server, domMsg: CustomEventMessage = this.msg as CustomEventMessage) { + domServer.on("runtime/addElement", (data: { params: [number | null, string, Record | null] }) => { + const safeData = customClone(data) as typeof data | undefined; + if (!safeData || !Array.isArray(safeData.params) || safeData.params.length !== 3) return undefined; + const [parentNodeId, tagName, tmpAttr] = safeData.params; + + // 此请求来自页面事件,只接受可验证的节点编号、标签名和扁平属性,避免把对象行为带入 DOM 操作。 + if ( + (parentNodeId !== null && (!Number.isInteger(parentNodeId) || parentNodeId <= 0)) || + typeof tagName !== "string" || + tagName.length === 0 || + tagName.length > 128 || + (tmpAttr !== null && (typeof tmpAttr !== "object" || Array.isArray(tmpAttr))) + ) { + return undefined; + } - const msg = this.msg as CustomEventMessage; + const msg = domMsg; // 取回 parentNode(如果存在) let parentNode: Node | undefined; @@ -33,7 +252,16 @@ export class ScriptRuntime { // 创建元素并设置属性 const el = document.createElement(tagName); - const attr = tmpAttr ? { ...tmpAttr } : {}; + const attr: Record = Object.create(null); + if (tmpAttr) { + for (const key of Object.keys(tmpAttr)) { + const descriptor = Object.getOwnPropertyDescriptor(tmpAttr, key); + if (!descriptor || !("value" in descriptor)) return undefined; + const value = descriptor.value; + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return undefined; + attr[key] = String(value); + } + } let textContent = ""; if (attr.textContent) { textContent = attr.textContent; @@ -54,19 +282,30 @@ export class ScriptRuntime { }); } + async loadPage(beforeStart?: (scripts: TScriptInfo[]) => void | Promise) { + const client = new RuntimeClient(this.msg); + const result = await client.pageLoad(this.scripEnvTag); + if (!result.ok) return; + const scripts = this.scripEnvTag === "ct" ? result.contentScriptList : result.injectScriptList; + if (scripts.length) { + await beforeStart?.(scripts); + this.startScripts(scripts, result.envInfo); + } + } + init() { this.server.on("runtime/emitEvent", (data: EmitEventRequest) => { - // 转发给脚本 - this.scriptExecutor.emitEvent(data); + this.receiveEmitEvent(data); }); this.server.on("runtime/valueUpdate", (data: ValueUpdateDataEncoded) => { - this.scriptExecutor.valueUpdate(data); + this.receiveValueUpdate(data); }); this.server.on("pageLoad", (data: { scripts: TScriptInfo[]; envInfo: GMInfoEnv }) => { - // 监听事件 - this.startScripts(data.scripts, data.envInfo); + this.receivePageLoad(data); }); + // Older MAIN worlds may receive a forward-compatible native bootstrap token but cannot open a runtime port. + this.server.on("bootstrap", () => undefined); // 用于 early-start 的扩充参数 const { inIncognitoContext } = this.extensionEnv || {}; @@ -78,10 +317,48 @@ export class ScriptRuntime { } startScripts(scripts: TScriptInfo[], envInfo: GMInfoEnv) { - this.scriptExecutor.startScripts(scripts, envInfo); + if (scripts.length === 0) { + this.scriptExecutor.startScripts(scripts, envInfo); + return; + } + const freshScripts: TScriptInfo[] = []; + for (let index = 0; index < scripts.length; index += 1) { + const script = scripts[index]; + const key = script.executionHandle || `${this.scripEnvTag}:${script.uuid}`; + if (this.startedScriptKeys.has(key)) continue; + this.startedScriptKeys.add(key); + freshScripts.push(script); + } + if (freshScripts.length > 0) this.scriptExecutor.startScripts(freshScripts, envInfo); + } + + receivePageLoad(data: unknown): string | undefined { + if (this.scripEnvTag === "it") { + const safeData = cloneInjectPageLoad(data); + if (!safeData) return undefined; + this.startScripts(safeData.scripts, safeData.envInfo); + return safeData.reconnectToken; + } + const safeData = clonePageLoad(data, "ct", true, true); + if (!safeData) return undefined; + setPageRpcExtensionOrigin(safeData.extensionOrigin); + this.startScripts(safeData.scripts, safeData.envInfo); + return safeData.reconnectToken; + } + + receiveEmitEvent(data: unknown): void { + const safeData = cloneInjectEmitEvent(data); + if (!safeData) return; + this.scriptExecutor.emitEvent(safeData); + } + + receiveValueUpdate(data: unknown): void { + const safeData = cloneInjectValueUpdate(data); + if (!safeData) return; + this.scriptExecutor.valueUpdate(safeData); } - externalMessage() { - onInjectPageLoaded(this.msg); + externalMessage(messagePrefix = "scripting", message: Message = this.msg) { + onInjectPageLoaded(message, messagePrefix); } } diff --git a/src/app/service/content/scripting.test.ts b/src/app/service/content/scripting.test.ts new file mode 100644 index 000000000..9253049b5 --- /dev/null +++ b/src/app/service/content/scripting.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import type { MessageSend } from "@Packages/message/types"; +import type { TClientPageLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; +import type { IGetSender, Server } from "@Packages/message/server"; +import { RuntimeClient } from "../service_worker/client"; +import ScriptingRuntime, { serializeDocumentResponse } from "./scripting"; + +const makeSender = () => ({ + sendMessage: vi.fn().mockResolvedValue({ code: 0, data: undefined }), + connect: vi.fn(), +}); + +const makeScript = (uuid: string): TScriptInfo => + ({ + uuid, + metadata: { grant: ["GM_getValue"] }, + resource: {}, + value: {}, + flag: `${uuid}-flag`, + code: "", + }) as unknown as TScriptInfo; + +describe("ScriptingRuntime page bootstrap", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("requests the combined page list so USER_SCRIPT content receives its bootstrap", async () => { + const injectScript = { + ...makeScript("inject-script"), + name: "Inject script", + metadata: { grant: ["GM_getValue", "GM_setValue"] }, + code: "document.documentElement.dataset.ran = 'yes'", + value: { stored: "existing-value" }, + config: { enabled: true }, + userConfig: { profile: "custom" }, + userConfigStr: '{"profile":"custom"}', + resource: { text: { content: "resource-data", contentType: "text/plain" } }, + requireCssResource: { style: { content: ".target { color: red; }", contentType: "text/css" } }, + executionHandle: "inject-execution-handle", + executionRunFlag: "inject-run-flag", + } as unknown as TScriptInfo; + const envInfo = { userAgentData: {}, sandboxMode: "raw", isIncognito: false } as const; + const pageLoad = vi.spyOn(RuntimeClient.prototype, "pageLoad").mockResolvedValue({ + ok: true, + injectScriptList: [injectScript], + contentScriptList: [makeScript("content-script")], + envInfo, + userScriptBootstrapToken: "bootstrap-token", + } as TClientPageLoadInfo); + const senderToExt = makeSender(); + const senderToContent = makeSender(); + const senderToInject = makeSender(); + const handlers = new Map unknown>(); + const server = { + on: vi.fn((action: string, handler: (data: unknown) => unknown) => handlers.set(action, handler)), + }; + const extServer = { on: vi.fn() }; + const storageLocal = chrome.storage.local as unknown as { + onChanged?: { addListener: (listener: (changes: unknown) => void) => void }; + }; + const originalOnChanged = storageLocal.onChanged; + storageLocal.onChanged = { addListener: vi.fn() }; + const runtime = new ScriptingRuntime( + extServer as unknown as Server, + server as unknown as Server, + senderToExt as unknown as MessageSend, + senderToContent as any, + senderToInject as any + ); + + try { + runtime.init(); + runtime.pageLoad(); + await Promise.resolve(); + await Promise.resolve(); + + expect(pageLoad).toHaveBeenCalledWith("it"); + expect(senderToContent.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "content/pageLoad", + data: expect.objectContaining({ + bootstrapToken: "bootstrap-token", + extensionOrigin: { + protocol: "chrome-extension:", + hostname: chrome.runtime.id, + port: "", + }, + }), + }) + ); + expect(senderToInject.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "inject/pageLoad", + data: { scripts: [injectScript], envInfo }, + }) + ); + } finally { + storageLocal.onChanged = originalOnChanged; + } + }); + + it("consumes a prefetched pageLoad result without issuing a second SW request", async () => { + const injectScript = { + ...makeScript("prefetched-inject-script"), + name: "Prefetched inject script", + metadata: { grant: ["GM_getValue"] }, + executionHandle: "prefetched-handle", + executionRunFlag: "prefetched-run-flag", + } as unknown as TScriptInfo; + const envInfo = { userAgentData: {}, sandboxMode: "raw", isIncognito: false } as const; + const pageLoad = vi.spyOn(RuntimeClient.prototype, "pageLoad"); + const prefetched = Promise.resolve({ + ok: true, + injectScriptList: [injectScript], + contentScriptList: [], + envInfo, + userScriptBootstrapToken: undefined, + } as TClientPageLoadInfo); + const senderToExt = makeSender(); + const senderToContent = makeSender(); + const senderToInject = makeSender(); + const server = { on: vi.fn() }; + const extServer = { on: vi.fn() }; + const storageLocal = chrome.storage.local as unknown as { + onChanged?: { addListener: (listener: (changes: unknown) => void) => void }; + }; + const originalOnChanged = storageLocal.onChanged; + storageLocal.onChanged = { addListener: vi.fn() }; + const runtime = new ScriptingRuntime( + extServer as unknown as Server, + server as unknown as Server, + senderToExt as unknown as MessageSend, + senderToContent as any, + senderToInject as any + ); + + try { + runtime.init(); + runtime.pageLoad(prefetched); + await Promise.resolve(); + await Promise.resolve(); + + expect(pageLoad).not.toHaveBeenCalled(); + expect(senderToInject.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "inject/pageLoad", + data: { scripts: [injectScript], envInfo }, + }) + ); + } finally { + storageLocal.onChanged = originalOnChanged; + } + }); + + it("P1-2: PageRpcRegistry grants context-menu GM_registerMenuCommand and still denies GM_setValue", async () => { + const contextMenuScript = { + ...makeScript("context-menu-script"), + name: "Context menu script", + metadata: { grant: ["none"], "run-at": ["context-menu"] }, + // v2 执行句柄必须由 service worker 签发;这里模拟 SW 已签发的 canonical 句柄。 + executionHandle: "context-menu-handle", + executionRunFlag: "context-menu-run-flag", + } as unknown as TScriptInfo; + const envInfo = { userAgentData: {}, sandboxMode: "raw", isIncognito: false } as const; + vi.spyOn(RuntimeClient.prototype, "pageLoad").mockResolvedValue({ + ok: true, + injectScriptList: [contextMenuScript], + contentScriptList: [], + envInfo, + userScriptBootstrapToken: undefined, + } as TClientPageLoadInfo); + const senderToExt = makeSender(); + const senderToContent = makeSender(); + const senderToInject = makeSender(); + const handlers = new Map unknown>(); + const server = { + on: vi.fn((action: string, handler: (data: unknown, sender: IGetSender) => unknown) => + handlers.set(action, handler) + ), + }; + const extServer = { on: vi.fn() }; + const storageLocal = chrome.storage.local as unknown as { + onChanged?: { addListener: (listener: (changes: unknown) => void) => void }; + }; + const originalOnChanged = storageLocal.onChanged; + storageLocal.onChanged = { addListener: vi.fn() }; + const runtime = new ScriptingRuntime( + extServer as unknown as Server, + server as unknown as Server, + senderToExt as unknown as MessageSend, + senderToContent as any, + senderToInject as any + ); + + try { + runtime.init(); + runtime.pageLoad(); + await Promise.resolve(); + await Promise.resolve(); + + const pageLoadMessage = senderToInject.sendMessage.mock.calls.find( + ([message]) => message.action === "inject/pageLoad" + )?.[0]; + const executionHandle = pageLoadMessage?.data.scripts[0].executionHandle; + expect(executionHandle).toBe("context-menu-handle"); + + const gmApiHandler = handlers.get("runtime/gmApi")!; + const noopSender = { getConnect: () => undefined } as unknown as IGetSender; + + // 修正前:GM_registerMenuCommand 会被 fallback registry 以 raw metadata 拒绝(RED)。 + await gmApiHandler( + { + version: 2, + sequence: 1, + handle: executionHandle, + api: "GM_registerMenuCommand", + params: [], + }, + noopSender + ); + // wire 身份只有 handle:不再重复携带 executionHandle,canonical uuid/runFlag 由 SW 解析。 + expect(senderToExt.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "serviceWorker/runtime/gmApi", + data: { version: 2, sequence: 1, handle: executionHandle, api: "GM_registerMenuCommand", params: [] }, + }) + ); + + // 同一 binding 不能借由 context-menu 隐式授权取得其他特权 API。 + expect(() => + gmApiHandler( + { + version: 2, + sequence: 2, + handle: executionHandle, + api: "GM_setValue", + params: ["a", 1], + }, + noopSender + ) + ).toThrow("API is not granted to this execution"); + } finally { + storageLocal.onChanged = originalOnChanged; + } + }); + + it("drops a script missing its authoritative execution handle and still delivers its siblings", async () => { + // v2 执行句柄必须由 service worker 签发;content 不再为缺失句柄的脚本伪造替代句柄, + // 该脚本应被丢弃,其余脚本仍正常送达,pageLoad 本身不应失败。 + const missingHandleScript = { + ...makeScript("missing-handle-script"), + executionHandle: undefined, + executionRunFlag: undefined, + } as unknown as TScriptInfo; + const boundScript = { + ...makeScript("bound-script"), + executionHandle: "bound-handle", + executionRunFlag: "bound-run-flag", + } as unknown as TScriptInfo; + const envInfo = { userAgentData: {}, sandboxMode: "raw", isIncognito: false } as const; + vi.spyOn(RuntimeClient.prototype, "pageLoad").mockResolvedValue({ + ok: true, + injectScriptList: [missingHandleScript, boundScript], + contentScriptList: [], + envInfo, + userScriptBootstrapToken: undefined, + } as TClientPageLoadInfo); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const senderToExt = makeSender(); + const senderToContent = makeSender(); + const senderToInject = makeSender(); + const handlers = new Map unknown>(); + const server = { + on: vi.fn((action: string, handler: (data: unknown, sender: IGetSender) => unknown) => + handlers.set(action, handler) + ), + }; + const extServer = { on: vi.fn() }; + const storageLocal = chrome.storage.local as unknown as { + onChanged?: { addListener: (listener: (changes: unknown) => void) => void }; + }; + const originalOnChanged = storageLocal.onChanged; + storageLocal.onChanged = { addListener: vi.fn() }; + const runtime = new ScriptingRuntime( + extServer as unknown as Server, + server as unknown as Server, + senderToExt as unknown as MessageSend, + senderToContent as any, + senderToInject as any + ); + + try { + runtime.init(); + runtime.pageLoad(); + await Promise.resolve(); + await Promise.resolve(); + + const pageLoadMessage = senderToInject.sendMessage.mock.calls.find( + ([message]) => message.action === "inject/pageLoad" + )?.[0]; + + expect(pageLoadMessage?.data.scripts).toEqual([boundScript]); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + storageLocal.onChanged = originalOnChanged; + } + }); + + it("serializes CAT_fetchDocument responses instead of returning a live document reference", () => { + const document = new DOMParser().parseFromString("
ok
", "text/html"); + expect(serializeDocumentResponse(document, "text/html")).toEqual({ + text: expect.stringContaining("
ok
"), + contentType: "text/html", + }); + }); +}); diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index 7286d82b0..86be7077a 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -2,11 +2,15 @@ import { Client, sendMessage } from "@Packages/message/client"; import { type CustomEventMessage } from "@Packages/message/custom_event_message"; import { forwardMessage, type Server } from "@Packages/message/server"; import type { MessageSend } from "@Packages/message/types"; +import type { SerializedDocumentResponse } from "./gm_api/gm_xhr"; import { RuntimeClient } from "../service_worker/client"; import { getStorageName, makeBlobURL } from "@App/pkg/utils/utils"; import type { Logger } from "@App/app/repo/logger"; import LoggerCore from "@App/app/logger/core"; import type { ValueUpdateDataEncoded } from "./types"; +import { getExtensionOrigin, getPageRpcAllowedAPIs, PageRpcRegistry, validatePageGMRequest } from "./page_rpc"; +import { getEffectiveScriptGrants } from "./utils"; +import type { TClientPageLoadInfo } from "@App/app/repo/scripts"; const PageOrContent = { PAGE: 1, @@ -16,6 +20,18 @@ const PageOrContent = { type PageOrContent = ValueOf; +export const serializeDocumentResponse = ( + response: Document | null, + contentType: string +): SerializedDocumentResponse | undefined => { + if (!response) return undefined; + try { + return { text: new XMLSerializer().serializeToString(response), contentType }; + } catch { + return undefined; + } +}; + // For Firefox, StorageArea.setAccessLevel is not implemented. // See https://bugzilla.mozilla.org/show_bug.cgi?id=1724754 // const deliveryStorage = isFirefox() ? chrome.storage.local : chrome.storage.session; @@ -23,7 +39,10 @@ const deliveryStorage = chrome.storage.local; // 日后再处理 // scripting页的处理 export default class ScriptingRuntime { - private activeStorageNames: Map | null = null; + // 只记录当前页面仍有脚本使用的 storageName,storage 广播不应唤醒无关脚本。 + private activeStorageNames = new Map(); + // 页面请求必须先在此注册句柄,再由 transform 解析为隔离 broker 可接受的身份。 + private readonly pageRpc = new PageRpcRegistry(); constructor( // 监听来自service_worker的消息 private readonly extServer: Server, @@ -34,7 +53,7 @@ export default class ScriptingRuntime { // 发送给 content的消息接口 private readonly senderToContent: CustomEventMessage, // 发送给inject的消息接口 - private readonly senderToInject: CustomEventMessage + private readonly senderToInject: MessageSend ) {} // 广播消息给 content 和 inject @@ -51,12 +70,12 @@ export default class ScriptingRuntime { init() { this.extServer.on("runtime/emitEvent", (data) => { - // 转发给inject和content - return this.broadcastToPage("runtime/emitEvent", data); + // USER_SCRIPT 的私有回调通过原生扩展端口投递。 + return this.broadcastToPage("runtime/emitEvent", data, PageOrContent.PAGE); }); this.extServer.on("runtime/valueUpdate", (data) => { - // 转发给inject和content - return this.broadcastToPage("runtime/valueUpdate", data); + // USER_SCRIPT 的私有值更新通过原生扩展端口投递。 + return this.broadcastToPage("runtime/valueUpdate", data, PageOrContent.PAGE); }); this.server.on("logger", (data: Logger) => { LoggerCore.logger().log(data.level, data.message, data.label); @@ -74,13 +93,10 @@ export default class ScriptingRuntime { const record = changes["valueUpdateDelivery"]; if (record?.newValue) { const sendData = (record.newValue as { sendData: ValueUpdateDataEncoded }).sendData; - const activeOn = - this.activeStorageNames === null - ? PageOrContent.PAGE_AND_CONTENT - : this.activeStorageNames.get(sendData.storageName); + const activeOn = this.activeStorageNames.get(sendData.storageName); if (activeOn) { // 转发给 content 和 inject - this.broadcastToPage("runtime/valueUpdate", sendData, activeOn); + this.broadcastToPage("runtime/valueUpdate", sendData, (activeOn & PageOrContent.PAGE) as PageOrContent); } } }); @@ -91,7 +107,7 @@ export default class ScriptingRuntime { "runtime/gmApi", this.server, this.senderToExt, - (data: { api: string; params: any; uuid: string }) => { + (data: { api: string; params: any }) => { // 拦截关注的 API,未命中则返回 false 交由默认转发处理 switch (data.api) { case "CAT_createBlobUrl": { @@ -111,18 +127,19 @@ export default class ScriptingRuntime { return false; // 继续转发到 SW } case "CAT_fetchDocument": { - const [url, isContent] = data.params; - // 根据来源选择不同的消息桥(content / inject) - let msg: CustomEventMessage | null = isContent ? this.senderToContent : this.senderToInject; return new Promise((resolve) => { const xhr = new XMLHttpRequest(); xhr.responseType = "document"; - xhr.open("GET", url); - xhr.onloadend = function () { - const nodeId = msg!.sendRelatedTarget(this.response); - resolve(nodeId); - msg = null; + xhr.open("GET", data.params[0]); + xhr.onloadend = () => { + resolve( + serializeDocumentResponse( + xhr.response as Document | null, + xhr.getResponseHeader("Content-Type") || "" + ) + ); }; + xhr.onerror = () => resolve(undefined); xhr.send(); }); } @@ -142,11 +159,16 @@ export default class ScriptingRuntime { break; } return false; + }, + (data) => { + // 所有来自页面的 GM RPC 都在转发前完成字段、句柄、授权和参数复制检查; + // wire 身份只带 handle,canonical uuid/runFlag/envTag 由 SW 依据 handle + 真实 sender 解析。 + return validatePageGMRequest(data, this.pageRpc); } ); } - pageLoad() { + pageLoad(prefetchedPageLoad?: Promise) { const client = new RuntimeClient(this.senderToExt); // bfcache 还原不会重新执行 content script,pageLoad 因此只发生一次; // 但页面里的脚本仍在运行,需要补一次上报,否则 Popup 会误判本页没有脚本在跑。 @@ -156,31 +178,55 @@ export default class ScriptingRuntime { if (e.persisted) client.pageShow(); }); } - // 向service_worker请求脚本列表及环境信息 - client.pageLoad().then((o) => { - if (!o.ok) return; - const { injectScriptList, contentScriptList, envInfo } = o; - const pairs = {} as Record; - for (const script of injectScriptList) { - pairs[getStorageName(script)] |= PageOrContent.PAGE; - } - for (const script of contentScriptList) { - pairs[getStorageName(script)] |= PageOrContent.CONTENT; - } - this.activeStorageNames = new Map(Object.entries(pairs)); + // 向service_worker请求脚本列表及环境信息。入口脚本可在 eventFlag negotiation + // 之前预先发起这次请求;测试/旧调用点仍可省略参数而走原本的 lazy 路径。 + const pageLoad = prefetchedPageLoad || client.pageLoad("it"); + void pageLoad + .then((o) => { + if (!o.ok) return; + const { injectScriptList, envInfo, userScriptBootstrapToken } = o; + // 每次页面加载都废弃旧句柄,避免无 documentId 的浏览器复用上一文档的授权。 + this.pageRpc.revokeAll(); + const prepareScripts = (scripts: typeof injectScriptList) => { + const prepared: typeof injectScriptList = []; + for (const script of scripts) { + const executionHandle = script.executionHandle; + if (!executionHandle) { + // v2 执行句柄必须由 service worker 签发;content 不再自行伪造替代句柄, + // 缺失时丢弃该脚本而不是让整个 pageLoad 失败。 + console.warn(`ScriptCat: script ${script.uuid} has no authoritative execution handle, skipping`); + continue; + } + const allowedAPIs = getPageRpcAllowedAPIs(getEffectiveScriptGrants(script.metadata)); + // service worker 已签发的句柄要在本页 registry 中恢复,保持跨 context 身份一致。 + this.pageRpc.register(executionHandle, allowedAPIs); + prepared.push(script); + } + return prepared; + }; + const preparedInjectScriptList = prepareScripts(injectScriptList); + const pairs = {} as Record; + for (const script of preparedInjectScriptList) { + pairs[getStorageName(script)] |= PageOrContent.PAGE; + } + this.activeStorageNames = new Map(Object.entries(pairs)); - // 向页面 发送脚本列表及环境信息 - if (contentScriptList.length) { - const contentClient = new Client(this.senderToContent, "content"); - // 根据@inject-into content过滤脚本 - contentClient.do("pageLoad", { scripts: contentScriptList, envInfo }); - } + if (typeof userScriptBootstrapToken === "string" && userScriptBootstrapToken.length > 0) { + const contentClient = new Client(this.senderToContent, "content"); + contentClient.do("pageLoad", { + bootstrapToken: userScriptBootstrapToken, + envInfo, + extensionOrigin: getExtensionOrigin(), + }); + } - if (injectScriptList.length) { - const injectClient = new Client(this.senderToInject, "inject"); - // 根据@inject-into content过滤脚本 - injectClient.do("pageLoad", { scripts: injectScriptList, envInfo }); - } - }); + if (preparedInjectScriptList.length > 0) { + const injectClient = new Client(this.senderToInject, "inject"); + injectClient.do("pageLoad", { scripts: preparedInjectScriptList, envInfo }); + } + }) + .catch((error) => { + LoggerCore.logger().debug("page bootstrap failed", { error: String(error) }); + }); } } diff --git a/src/app/service/content/types.ts b/src/app/service/content/types.ts index 30fe88e80..a4bcae614 100644 --- a/src/app/service/content/types.ts +++ b/src/app/service/content/types.ts @@ -1,6 +1,12 @@ import type { REncoded } from "@App/pkg/utils/message_value"; -export type ScriptFunc = (named: { [key: string]: any } | undefined, scriptName: string) => any; +export type ScriptFunc = ( + s: string, + ctx: any, + named: { [key: string]: any } | undefined, + scriptName: string, + call?: (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => any +) => any; // exec_script.ts @@ -36,7 +42,7 @@ export type ValueUpdateDataEncoded = { export interface ApiParam { follow?: string; - depend?: string[]; + depend?: readonly string[]; alias?: string; } diff --git a/src/app/service/content/user_script_connection.test.ts b/src/app/service/content/user_script_connection.test.ts new file mode 100644 index 000000000..5a5357eff --- /dev/null +++ b/src/app/service/content/user_script_connection.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Message, MessageConnect, TMessage } from "@Packages/message/types"; +import { connectUserScriptChannel, requestUserScriptReconnect } from "./user_script_connection"; + +const makeConnection = (): MessageConnect => ({ + onMessage: vi.fn(), + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: vi.fn(), +}); + +describe("connectUserScriptChannel", () => { + it("enables the native listener before opening the USER_SCRIPT port", async () => { + const connection = makeConnection(); + const order: string[] = []; + const message = { + sendMessage: vi.fn(async (packet: TMessage) => { + order.push(`send:${(packet as { type?: string }).type}`); + return true; + }), + connect: vi.fn(async (packet: TMessage) => { + order.push(`connect:${packet.action}`); + return connection; + }), + } as unknown as Message; + + await connectUserScriptChannel(message, "bootstrap-token", vi.fn()); + + expect(order).toEqual(["send:userScripts.LISTEN_CONNECTIONS", "connect:serviceWorker/runtime/registerUserScript"]); + expect(connection.onMessage).toHaveBeenCalledOnce(); + expect(connection.sendMessage).toHaveBeenCalledWith({ action: "userScript/bootstrap" }); + }); + + it("returns no channel when the browser cannot enable any runtime port", async () => { + const message = { + sendMessage: vi.fn().mockResolvedValue(false), + connect: vi.fn().mockRejectedValue(new Error("runtime.connect is unavailable")), + } as unknown as Message; + + await expect(connectUserScriptChannel(message, "bootstrap-token", vi.fn())).resolves.toBeUndefined(); + expect(message.connect).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/registerUserScript", + data: { world: "USER_SCRIPT", bootstrapToken: "bootstrap-token", transport: "extension" }, + }); + }); + + it("uses a constrained extension-port fallback when dedicated listeners are unavailable", async () => { + const connection = makeConnection(); + const message = { + sendMessage: vi.fn().mockResolvedValue(false), + connect: vi.fn().mockResolvedValue(connection), + } as unknown as Message; + + await connectUserScriptChannel(message, "bootstrap-token", vi.fn()); + + expect(message.connect).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/registerUserScript", + data: { world: "USER_SCRIPT", bootstrapToken: "bootstrap-token", transport: "extension" }, + }); + expect(connection.sendMessage).toHaveBeenCalledWith({ action: "userScript/bootstrap" }); + }); + + it("uses the constrained extension-port fallback when listener probing has no response", async () => { + const connection = makeConnection(); + const message = { + sendMessage: vi.fn().mockResolvedValue(undefined), + connect: vi.fn().mockResolvedValue(connection), + } as unknown as Message; + + await connectUserScriptChannel(message, "bootstrap-token", vi.fn()); + + expect(message.connect).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/registerUserScript", + data: { world: "USER_SCRIPT", bootstrapToken: "bootstrap-token", transport: "extension" }, + }); + }); + + it("reports remote disconnects so the caller can reconnect natively", async () => { + const connection = makeConnection(); + const onDisconnect = vi.fn(); + const message = { + sendMessage: vi.fn().mockResolvedValue(true), + connect: vi.fn().mockResolvedValue(connection), + } as unknown as Message; + + await connectUserScriptChannel(message, "bootstrap-token", vi.fn(), onDisconnect); + + expect(connection.onDisconnect).toHaveBeenCalledOnce(); + const disconnectHandler = (connection.onDisconnect as ReturnType).mock.calls[0][0] as ( + isSelfDisconnected: boolean + ) => void; + disconnectHandler(false); + expect(onDisconnect).toHaveBeenCalledWith(false); + }); + + it("accepts only a valid native reconnect token response", async () => { + const message = { + sendMessage: vi.fn().mockResolvedValue({ code: 0, data: { bootstrapToken: "next-token" } }), + } as unknown as Message; + + await expect(requestUserScriptReconnect(message, "current-token")).resolves.toBe("next-token"); + expect(message.sendMessage).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/reconnectUserScript", + data: { reconnectToken: "current-token" }, + }); + + (message.sendMessage as ReturnType).mockResolvedValue({ code: 0, data: {} }); + await expect(requestUserScriptReconnect(message, "current-token")).resolves.toBeUndefined(); + }); +}); diff --git a/src/app/service/content/user_script_connection.ts b/src/app/service/content/user_script_connection.ts new file mode 100644 index 000000000..59adb84b9 --- /dev/null +++ b/src/app/service/content/user_script_connection.ts @@ -0,0 +1,53 @@ +import type { Message, MessageConnect, TMessage } from "@Packages/message/types"; + +type UserScriptPacketHandler = (connection: MessageConnect, packet: TMessage) => void; +type UserScriptDisconnectHandler = (isSelfDisconnected: boolean) => void; + +type UserScriptReconnectResponse = { + code?: unknown; + data?: unknown; +}; + +/** + * 先让 service worker 开启 USER_SCRIPT 监听,再建立连接;浏览器可能立即投递端口, + * 并发执行两步会丢失首个连接。 + */ +export async function connectUserScriptChannel( + message: Message, + bootstrapToken: string, + onPacket: UserScriptPacketHandler, + onDisconnect?: UserScriptDisconnectHandler +): Promise { + const enabled = await message.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" } as unknown as TMessage); + const useExtensionFallback = enabled !== true; + let connection: MessageConnect; + try { + // 缺少专用 USER_SCRIPT 监听器时仍使用扩展原生端口;服务端会用文档绑定的令牌限制该降级路径。 + connection = await message.connect({ + action: "serviceWorker/runtime/registerUserScript", + data: useExtensionFallback + ? { world: "USER_SCRIPT", bootstrapToken, transport: "extension" } + : { world: "USER_SCRIPT", bootstrapToken }, + }); + } catch (error) { + if (!useExtensionFallback) throw error; + return undefined; + } + connection.onMessage((packet) => onPacket(connection, packet)); + if (onDisconnect) connection.onDisconnect(onDisconnect); + connection.sendMessage({ action: "userScript/bootstrap" }); + return connection; +} + +export async function requestUserScriptReconnect( + message: Message, + reconnectToken: string +): Promise { + const response = await message.sendMessage({ + action: "serviceWorker/runtime/reconnectUserScript", + data: { reconnectToken }, + }); + if (response?.code !== 0 || response.data === null || typeof response.data !== "object") return undefined; + const token = (response.data as { bootstrapToken?: unknown }).bootstrapToken; + return typeof token === "string" && token.length > 0 && token.length <= 256 ? token : undefined; +} diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index 837728eec..51b8eab89 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -3,15 +3,38 @@ import { compileScriptCode, compileScript, compileInjectScript, + compilePreInjectScript, compileScriptletCode, isScriptletUnwrap, addStyle, addStyleSheet, trimScriptInfo, + trimPreInjectScriptInfo, + getEffectiveScriptGrants, + getCompiledScriptMetadata, } from "./utils"; import type { SCMetadata, ScriptLoadInfo, ScriptRunResource } from "@App/app/repo/scripts"; import type { ScriptFunc } from "./types"; +import { nativeCall } from "./global"; import { RuleType, type URLRuleEntry } from "@App/pkg/utils/url_matcher"; +import { getPageRpcAllowedAPIs } from "./page_rpc"; + +const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; + +type GeneratedWindow = Record; + +function executeGeneratedScript( + code: string, + targetWindow: GeneratedWindow, + testPerformance: Pick = globalThis.performance +) { + const execute = new Function("window", "performance", "CustomEvent", code) as ( + window: GeneratedWindow, + performance: Pick, + customEvent: typeof CustomEvent + ) => void; + execute(targetWindow, testPerformance, globalThis.CustomEvent); +} // 设置 console mock 来避免测试输出污染 vi.spyOn(console, "error").mockImplementation(() => {}); @@ -60,7 +83,9 @@ describe("utils", () => { expect(result).toContain("try {"); expect(result).toContain("} catch (e) {"); expect(result).toContain("with(arguments[0]||this.$)"); - expect(result).toContain("return(async function(){"); + expect(result).toContain("return async function(){console.log('hello world');}"); + expect(result).not.toContain("Math.random()"); + expect(result).not.toContain("Date.now()"); }); it.concurrent("应该处理自定义脚本代码参数", () => { @@ -481,6 +506,51 @@ describe("utils", () => { contentType: "text/plain", }); }); + + it("copies public values and metadata before crossing the page boundary", () => { + const script = createScript({ grant: ["GM_getValue"] }, []); + script.value = { nested: { count: 1 } }; + script.metadata.grant!.push("GM_setValue"); + + const trimmed = trimScriptInfo(script); + (trimmed.value.nested as { count: number }).count = 9; + trimmed.metadata.grant!.push("GM_deleteValue"); + + expect(script.value.nested).toEqual({ count: 1 }); + expect(script.metadata.grant).toEqual(["GM_getValue", "GM_setValue"]); + }); + + it("binds a source revision and preloads synchronous userscript state without page capabilities", () => { + const script = createScript({ grant: ["GM_getValue"] }, []); + script.uuid = "revision-script"; + script.createtime = 123; + script.updatetime = 456; + script.value = { secret: "value" }; + script.config = { private: { secret: { title: "Private", description: "", index: 0, default: "config" } } }; + script.userConfig = { + private: { secret: { title: "Private", description: "", index: 0, default: "user config" } }, + }; + script.userConfigStr = '{"secret":"user config"}'; + + const trimmed = trimScriptInfo(script); + const preInject = trimPreInjectScriptInfo(script); + + expect(trimmed.scriptRevision).toBe("revision-script:123:456"); + expect(preInject.value).toEqual({ secret: "value" }); + expect(preInject.config).toEqual(script.config); + expect(preInject.userConfig).toEqual(script.userConfig); + expect(preInject.userConfigStr).toBe(script.userConfigStr); + expect(preInject.executionHandle).toBeUndefined(); + expect(preInject.executionEnvTag).toBeUndefined(); + expect(preInject.executionRunFlag).toBeUndefined(); + }); + + it("preserves an explicitly supplied compiled revision", () => { + const script = createScript({ grant: ["GM_getValue"] }, []); + script.scriptRevision = "compiled-revision"; + + expect(trimScriptInfo(script).scriptRevision).toBe("compiled-revision"); + }); }); describe("compileScript", () => { @@ -495,7 +565,7 @@ describe("utils", () => { const code = "return arguments[0].value + arguments[1];"; const func: ScriptFunc = compileScript(code); - const result = func({ value: 10 }, "test-script"); + const result = func(fnStrIntegrity, {}, { value: 10 }, "test-script"); expect(result).toBe("10test-script"); }); @@ -511,8 +581,8 @@ describe("utils", () => { `; const func: ScriptFunc = compileScript(code); - const result1 = func({ value: 5, multiply: 3 }, "test"); - const result2 = func({ value: 5 }, "fallback"); + const result1 = func(fnStrIntegrity, {}, { value: 5, multiply: 3 }, "test"); + const result2 = func(fnStrIntegrity, {}, { value: 5 }, "fallback"); expect(result1).toBe(15); expect(result2).toBe("fallback"); @@ -526,7 +596,7 @@ describe("utils", () => { `; const func: ScriptFunc = compileScript(code); - const result = await func({ value: 5 }, "async-test"); + const result = await func(fnStrIntegrity, {}, { value: 5 }, "async-test"); expect(result).toBe(10); }); @@ -535,7 +605,13 @@ describe("utils", () => { const code = "throw new Error('Test error');"; const func: ScriptFunc = compileScript(code); - expect(() => func({}, "error-test")).toThrow("Test error"); + expect(() => func(fnStrIntegrity, {}, {}, "error-test")).toThrow("Test error"); + }); + + it.concurrent("完整性标记不匹配时不应执行脚本", () => { + const func: ScriptFunc = compileScript("throw new Error('should not run');"); + + expect(func("invalid", {}, {}, "blocked")).toBeUndefined(); }); }); @@ -559,13 +635,48 @@ describe("utils", () => { ...overrides, }); + it("生成的腳本包裝不依賴被 require 內容改寫的 Function.prototype 调用方法", async () => { + const script = createMockScript({ + code: "return this;", + resource: { + library: { + url: "https://example.com/library.js", + content: + "Function.prototype.call = Function.prototype.apply = Function.prototype.bind = () => { throw new Error('poisoned invocation'); };", + base64: "", + hash: { md5: "", sha1: "", sha256: "", sha384: "", sha512: "" }, + type: "require", + link: {}, + contentType: "text/javascript", + createtime: Date.now(), + }, + }, + metadata: { require: ["library"] }, + }); + const func = compileScript(compileScriptCode(script), true); + const originalCall = Function.prototype.call; + const originalApply = Function.prototype.apply; + const originalBind = Function.prototype.bind; + let result: unknown; + try { + result = await func(fnStrIntegrity, globalThis, {}, script.name); + } finally { + Function.prototype.call = originalCall; + Function.prototype.apply = originalApply; + Function.prototype.bind = originalBind; + } + expect(result).toBe(globalThis); + }); + it.concurrent("应该生成基本的注入脚本代码", () => { const script = createMockScript(); const scriptCode = "console.log('injected');"; const result = compileInjectScript(script, scriptCode); - expect(result).toBe(`window['inject-test-flag'] = function(){console.log('injected');}`); + expect(result).toContain("window['inject-test-flag'] ="); + expect(result).toContain("function(){console.log('injected');}"); + expect(result).not.toContain("Object.defineProperty(f, k"); }); it.concurrent("应该包含自动删除挂载函数的代码", () => { @@ -576,9 +687,11 @@ describe("utils", () => { expect(result).toContain(`try{delete window['inject-test-flag']}catch(e){}`); expect(result).toContain("console.log('with auto delete');"); - expect(result).toBe( - `window['inject-test-flag'] = function(){try{delete window['inject-test-flag']}catch(e){}console.log('with auto delete');}` + expect(result).toContain("try{delete window['inject-test-flag']}catch(e){}"); + expect(result).toContain( + "function(){try{delete window['inject-test-flag']}catch(e){}console.log('with auto delete');}" ); + expect(result).not.toContain("Object.defineProperty(f, k"); }); it.concurrent("默认情况下不应该包含自动删除代码", () => { @@ -588,7 +701,92 @@ describe("utils", () => { const result = compileInjectScript(script, scriptCode); expect(result).not.toContain("try{delete window"); - expect(result).toBe(`window['inject-test-flag'] = function(){console.log('without auto delete');}`); + expect(result).toContain("function(){console.log('without auto delete');}"); + expect(result).not.toContain("Object.defineProperty(f, k"); + }); + + it("runs the async script body on its context without temporary context properties", async () => { + const script = createMockScript({ code: "return { context: this, argumentCount: arguments.length };" }); + const mutations: PropertyKey[] = []; + const context = new Proxy(Object.create(null), { + get(target, key, receiver) { + if (key === "$") return {}; + return Reflect.get(target, key, receiver); + }, + set(target, key, value, receiver) { + mutations.push(key); + return Reflect.set(target, key, value, receiver); + }, + deleteProperty(target, key) { + mutations.push(key); + return Reflect.deleteProperty(target, key); + }, + }); + const func = compileScript(compileScriptCode(script), true); + + await expect(func(fnStrIntegrity, context, undefined, script.name)).resolves.toEqual({ + context, + argumentCount: 0, + }); + expect(mutations).toEqual([]); + }); + + it.concurrent("生成的注入脚本应在运行时传递上下文和参数,并清理临时挂载", () => { + const script = createMockScript(); + const targetWindow: GeneratedWindow = {}; + const context = {}; + const named = { value: 42 }; + + executeGeneratedScript( + compileInjectScript( + script, + "return { thisValue: this, args: Array.from(arguments), contextKeys: Reflect.ownKeys(this) };" + ), + targetWindow + ); + + const generated = targetWindow[script.flag] as ScriptFunc; + expect(generated(fnStrIntegrity, context, named, script.name, nativeCall)).toEqual({ + thisValue: context, + args: [named, script.name], + contextKeys: [], + }); + expect(Reflect.ownKeys(context)).toEqual([]); + }); + + it.concurrent("生成的注入脚本应拒绝错误的完整性标记", () => { + const script = createMockScript(); + const targetWindow: GeneratedWindow = {}; + + executeGeneratedScript(compileInjectScript(script, "throw new Error('should not run');"), targetWindow); + + const generated = targetWindow[script.flag] as ScriptFunc; + expect(generated("invalid", {}, {}, "blocked")).toBeUndefined(); + }); + + it.concurrent("生成的注入脚本应按选项自动删除挂载函数", () => { + const script = createMockScript(); + const targetWindow: GeneratedWindow = {}; + + executeGeneratedScript(compileInjectScript(script, "return 'ran';", true), targetWindow); + + const generated = targetWindow[script.flag] as ScriptFunc; + expect(generated(fnStrIntegrity, {}, {}, script.name, nativeCall)).toBe("ran"); + // 属性描述符本身必须消失,而不只是读到 undefined 的值。 + expect(Object.getOwnPropertyDescriptor(targetWindow, script.flag)).toBeUndefined(); + expect(targetWindow[script.flag]).toBeUndefined(); + }); + + it.concurrent("生成的注入脚本默认应保留挂载函数", () => { + const script = createMockScript(); + const targetWindow: GeneratedWindow = {}; + + executeGeneratedScript(compileInjectScript(script, "return 'ran';"), targetWindow); + + const generated = targetWindow[script.flag] as ScriptFunc; + expect(generated(fnStrIntegrity, {}, {}, script.name, nativeCall)).toBe("ran"); + // 未开启自动删除时,挂载函数应可重复读取,不因读取一次而被消费。 + expect(targetWindow[script.flag]).toBe(generated); }); it.concurrent("应该处理复杂的脚本代码", () => { @@ -613,7 +811,308 @@ describe("utils", () => { const result = compileInjectScript(script, scriptCode); - expect(result).toContain(`window['flag-with-special-chars_123']`); + expect(result).toContain(`'flag-with-special-chars_123'`); + }); + }); + + describe("generated MAIN-world wrapper protocol (compaction)", () => { + const createMockScript = (overrides: Partial = {}): ScriptRunResource => ({ + uuid: "compact-wrapper-uuid", + name: "Compact Wrapper Script", + namespace: "compact.test", + type: 1, + status: 1, + sort: 0, + runStatus: "complete", + createtime: Date.now(), + checktime: Date.now(), + code: "", + value: {}, + flag: "compact-wrapper-flag", + resource: {}, + metadata: {}, + originalMetadata: {}, + ...overrides, + }); + + // 编译并挂载到一个隔离的 targetWindow,取回真正生成的 wrapper function object。 + const mountGeneratedWrapper = ( + script: ScriptRunResource, + scriptCode: string, + autoDeleteMountFunction = false + ): ScriptFunc => { + const targetWindow: GeneratedWindow = {}; + executeGeneratedScript(compileInjectScript(script, scriptCode, autoDeleteMountFunction), targetWindow); + return targetWindow[script.flag] as ScriptFunc; + }; + + // ScriptFunc 的类型签名固定为 4-5 个具名参数,但 metadata 模式和 hostile-input 场景故意只带 + // 少数几个实际参数(正是 wrapper 用 rest 参数吸收的协议)。用 Reflect.apply 调用以测试真实的 + // 运行时协议,而不被编译期签名约束。 + const callGenerated = (fn: ScriptFunc, args: readonly unknown[]): unknown => + Reflect.apply(fn as unknown as (...a: unknown[]) => unknown, undefined, args); + + it("wrapper.length === 2:保留具名参数数量,避免未来体积优化悄悄改变可观察的函数行为", () => { + const generated = mountGeneratedWrapper(createMockScript(), "return 'ran';"); + expect(generated.length).toBe(2); + }); + + it("错误的完整性标记不会执行已编译脚本,返回 undefined", () => { + const generated = mountGeneratedWrapper(createMockScript(), "throw new Error('must not run');"); + expect(generated("wrong-token", {}, {}, "blocked")).toBeUndefined(); + }); + + it("正确标记 + metadata 模式 + 捕获时的 document 应返回存储的 metadata", () => { + const script = createMockScript({ uuid: "metadata-success-uuid", flag: "metadata-success-flag" }); + const generated = mountGeneratedWrapper(script, "return 'unused';"); + + const metadata = callGenerated(generated, [fnStrIntegrity, null, document]); + + expect(metadata).toBe(JSON.stringify({ uuid: script.uuid, flag: script.flag })); + }); + + it("metadata 模式下换一个 document 必须返回 undefined(wrapper 绑定创建时捕获的 document)", () => { + const generated = mountGeneratedWrapper(createMockScript(), "return 'unused';"); + const otherDocument = new DOMParser().parseFromString("", "text/html"); + + expect(callGenerated(generated, [fnStrIntegrity, null, otherDocument])).toBeUndefined(); + }); + + it("metadata 携带 scriptRevision 时同样换一个 document 必须返回 undefined——revision 相同不能替代 document 校验", () => { + const generated = mountGeneratedWrapper( + createMockScript({ scriptRevision: "same-revision-on-both-documents" }), + "return 'unused';" + ); + const otherDocument = new DOMParser().parseFromString("", "text/html"); + + expect(callGenerated(generated, [fnStrIntegrity, null, otherDocument])).toBeUndefined(); + }); + + it("正确标记 + metadata 模式:script 带 scriptRevision 时它会出现在返回的 metadata 里", () => { + const script = createMockScript({ + uuid: "metadata-revision-uuid", + flag: "metadata-revision-flag", + scriptRevision: "compiled-revision-abc", + }); + const generated = mountGeneratedWrapper(script, "return 'unused';"); + + const metadata = callGenerated(generated, [fnStrIntegrity, null, document]); + + expect(metadata).toBe( + JSON.stringify({ uuid: script.uuid, flag: script.flag, scriptRevision: script.scriptRevision }) + ); + }); + + it("metadata 模式(无论查找成功或失败)绝不会 fall through 到脚本执行", () => { + const executed = vi.fn(); + const targetWindow: GeneratedWindow = { __executed: executed }; + executeGeneratedScript(compileInjectScript(createMockScript(), "window.__executed();"), targetWindow); + const generated = targetWindow["compact-wrapper-flag"] as ScriptFunc; + const otherDocument = new DOMParser().parseFromString("", "text/html"); + + callGenerated(generated, [fnStrIntegrity, null, otherDocument]); // 查找失败(document 不匹配) + callGenerated(generated, [fnStrIntegrity, null, document]); // 查找成功(document 匹配) + + expect(executed).not.toHaveBeenCalled(); + }); + + it("execution 模式下无效的受信 call primitive(第五参数)不会执行脚本,返回 undefined", () => { + const generated = mountGeneratedWrapper(createMockScript(), "throw new Error('must not run');"); + + expect(generated(fnStrIntegrity, {}, {}, "name", undefined)).toBeUndefined(); + expect(callGenerated(generated, [fnStrIntegrity, {}, {}, "name", "not-a-function"])).toBeUndefined(); + }); + + it("已编译函数若返回另一个函数,该函数必须用同一个受信 call primitive 和同一 context 恰好调用一次", () => { + // 受信 call primitive 的真实实现(nativeCall)语义等同 Function.prototype.call: + // 用 thisArg 调用 fn。这里的 mock 复刻该语义,而不是单纯转发参数。 + const calls: Array<{ fn: unknown; thisArg: unknown }> = []; + const trustedCall = (fn: (...args: unknown[]) => unknown, thisArg: unknown, ...args: unknown[]) => { + calls.push({ fn, thisArg }); + return fn.apply(thisArg, args); + }; + const context = { marker: "ctx" }; + const script = createMockScript({ code: "return function(){ return this; };" }); + const generated = mountGeneratedWrapper(script, script.code); + + const result = generated(fnStrIntegrity, context, {}, script.name, trustedCall); + + expect(result).toBe(context); + // trustedCall 必须被调用两次:一次执行已编译函数,一次调用其返回的函数,两次都用同一 context。 + expect(calls).toHaveLength(2); + expect(calls[0].thisArg).toBe(context); + expect(calls[1].thisArg).toBe(context); + }); + + it("Reflect.ownKeys(generatedWrapper) 不会暴露 SC_RANDOM_FNKEY", () => { + const generated = mountGeneratedWrapper(createMockScript(), "return 'ran';"); + const keys = Reflect.ownKeys(generated).map(String); + expect(keys.join(",")).not.toContain(fnStrIntegrity!); + expect(keys).not.toContain("k"); + }); + + it("体积回归:生成的 wrapper 原生源码长度必须保持在压缩后的预算内", () => { + const generated = mountGeneratedWrapper(createMockScript(), ""); + const source = Function.prototype.toString.call(generated); + expect(source.length).toBeLessThanOrEqual(180); + }); + + it("getCompiledScriptMetadata() 能识别真正挂载的 wrapper 并返回其 metadata", () => { + const script = createMockScript({ uuid: "gcsm-uuid", flag: "gcsm-flag" }); + const generated = mountGeneratedWrapper(script, "return 'unused';"); + + expect(getCompiledScriptMetadata(generated)).toBe(JSON.stringify({ uuid: script.uuid, flag: script.flag })); + }); + + it("getCompiledScriptMetadata() 对非 wrapper 的函数返回 undefined", () => { + expect(getCompiledScriptMetadata(() => "not a wrapper")).toBeUndefined(); + expect(getCompiledScriptMetadata(undefined)).toBeUndefined(); + }); + + it("体积回归:外层生成工厂不应重新引入临时 wrapper 变量等多余脚手架", () => { + const script = createMockScript({ uuid: "factory-overhead-uuid", flag: "factory-overhead-flag" }); + const mounted = compileInjectScript(script, ""); + + expect(mounted).not.toMatch(/const f = /); + expect(mounted).not.toContain("return f;"); + expect(mounted).toContain("((d,k,m,fn)=>(t,u,...a)=>{"); + }); + }); + + describe("compilePreInjectScript", () => { + it.concurrent("生成的预注入脚本应可执行并发出脚本加载事件", () => { + const script: ScriptLoadInfo = { + uuid: "pre-inject-test-uuid", + name: "Pre Inject Test Script", + namespace: "pre.inject.test", + type: 1, + status: 1, + sort: 0, + runStatus: "complete", + createtime: Date.now(), + checktime: Date.now(), + code: "", + value: {}, + flag: "pre-inject-test-flag", + resource: {}, + metadata: {}, + originalMetadata: {}, + metadataStr: "", + userConfigStr: "", + }; + const targetWindow: GeneratedWindow = {}; + const testPerformance = { + dispatchEvent: vi.fn(() => false), + addEventListener: vi.fn(), + }; + + executeGeneratedScript( + compilePreInjectScript(script, "return { thisValue: this, args: Array.from(arguments) };"), + targetWindow, + testPerformance + ); + + const generated = targetWindow[script.flag] as ScriptFunc; + expect(Reflect.ownKeys(generated)).not.toContain(fnStrIntegrity); + expect(Reflect.ownKeys(targetWindow)).toEqual([script.flag]); + const context = {}; + const named = { value: 42 }; + expect(generated(fnStrIntegrity, context, named, script.name, nativeCall)).toEqual({ + thisValue: context, + args: [named, script.name], + }); + expect(Reflect.ownKeys(context)).toEqual([]); + expect(testPerformance.dispatchEvent).toHaveBeenCalledTimes(1); + expect(testPerformance.addEventListener).not.toHaveBeenCalled(); + }); + + it.concurrent("keeps preload state in the wrapper closure while the observable event only exposes the flag", () => { + const script: ScriptLoadInfo = { + uuid: "pre-inject-private-uuid", + name: "Pre Inject Private Script", + namespace: "pre.inject.private", + type: 1, + status: 1, + sort: 0, + runStatus: "complete", + createtime: Date.now(), + checktime: Date.now(), + code: "", + value: { secret: "stored-value" }, + config: { private: { secret: { title: "Private", description: "", index: 0, default: "config" } } }, + userConfig: { + private: { secret: { title: "Private", description: "", index: 0, default: "user-config" } }, + }, + flag: "pre-inject-private-flag", + resource: {}, + metadata: {}, + originalMetadata: {}, + metadataStr: "", + userConfigStr: '{"secret":"user-config"}', + }; + let detail: Record | undefined; + const testPerformance = { + dispatchEvent: vi.fn((event: Event) => { + detail = (event as CustomEvent).detail; + return false; + }), + addEventListener: vi.fn(), + }; + const targetWindow: GeneratedWindow = {}; + + executeGeneratedScript(compilePreInjectScript(script, "return undefined;"), targetWindow, testPerformance); + + const generated = targetWindow[script.flag] as ScriptFunc; + const metadataJSON = getCompiledScriptMetadata(generated); + expect(metadataJSON).toBeTypeOf("string"); + const metadata = JSON.parse(metadataJSON!); + expect(metadata.value).toEqual({ secret: "stored-value" }); + expect(metadata.config).toEqual(script.config); + expect(metadata.userConfig).toEqual(script.userConfig); + expect(metadata.userConfigStr).toBe(script.userConfigStr); + expect(detail).toEqual({ scriptFlag: script.flag }); + expect(JSON.stringify(detail)).not.toContain("stored-value"); + expect(JSON.stringify(detail)).not.toContain("user-config"); + }); + + it.concurrent("does not mount a regex-excluded early-start script", () => { + const script: ScriptLoadInfo = { + uuid: "pre-inject-excluded-uuid", + name: "Pre Inject Excluded Script", + namespace: "pre.inject.excluded", + type: 1, + status: 1, + sort: 0, + runStatus: "complete", + createtime: Date.now(), + checktime: Date.now(), + code: "", + value: {}, + flag: "pre-inject-excluded-flag", + resource: {}, + metadata: {}, + originalMetadata: {}, + metadataStr: "", + userConfigStr: "", + scriptUrlPatterns: [ + { + ruleType: RuleType.REGEX_INCLUDE, + ruleContent: ["allowed", ""], + ruleTag: "include", + patternString: "/allowed/", + }, + ], + }; + const targetWindow: GeneratedWindow = {}; + const testPerformance = { + dispatchEvent: vi.fn(() => false), + addEventListener: vi.fn(), + }; + + executeGeneratedScript(compilePreInjectScript(script, "return undefined;"), targetWindow, testPerformance); + + expect(targetWindow[script.flag]).toBeUndefined(); + expect(testPerformance.dispatchEvent).not.toHaveBeenCalled(); }); }); @@ -943,3 +1442,43 @@ describe("utils", () => { }); }); }); + +describe("getEffectiveScriptGrants (P1-2)", () => { + it("Case A: context-menu + grant none gains GM_registerMenuCommand and drops none", () => { + const metadata = { grant: ["none"], "run-at": ["context-menu"] } as unknown as SCMetadata; + + const effective = getEffectiveScriptGrants(metadata); + + expect(effective).toContain("GM_registerMenuCommand"); + expect(effective).not.toContain("none"); + }); + + it("Case B: a normal (non context-menu) grant none script stays capability-less", () => { + const metadata = { grant: ["none"], "run-at": ["document-end"] } as unknown as SCMetadata; + + const effective = getEffectiveScriptGrants(metadata); + + expect(effective).toEqual(["none"]); + expect(getPageRpcAllowedAPIs(effective)).toEqual([]); + }); + + it("Case C: context-menu with an existing privileged grant keeps both grants", () => { + const metadata = { grant: ["GM_setValue"], "run-at": ["context-menu"] } as unknown as SCMetadata; + + const effective = getEffectiveScriptGrants(metadata); + + expect(effective).toContain("GM_setValue"); + expect(effective).toContain("GM_registerMenuCommand"); + }); + + it("Case D: context-menu + grant none allows only the menu command, no privilege escalation", () => { + const metadata = { grant: ["none"], "run-at": ["context-menu"] } as unknown as SCMetadata; + + const allowedAPIs = getPageRpcAllowedAPIs(getEffectiveScriptGrants(metadata)); + + expect(allowedAPIs).toContain("GM_registerMenuCommand"); + expect(allowedAPIs).not.toContain("GM_setValue"); + expect(allowedAPIs).not.toContain("GM_xmlhttpRequest"); + expect(allowedAPIs.some((api) => api.startsWith("CAT_"))).toBe(false); + }); +}); diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 64181c489..f59510acf 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -7,6 +7,48 @@ import { ScriptEnvTag } from "@Packages/message/consts"; import { embeddedPatternCheckerString, type EmbeddedURLRuleEntry, type URLRuleEntry } from "@App/pkg/utils/url_matcher"; import { parseResourceDeclaration } from "@App/pkg/utils/resource"; import { getGrantCandidates } from "./gm_api/grant"; +import { customClone, Native, nativeCall } from "./global"; + +const cloneTransportValue = (value: any) => { + // USER_SCRIPT 只能接收数据副本;共享 customClone 的 data-only 检查,避免 getter/Proxy 进入页面资料。 + return customClone(value); +}; + +// The generated wrapper keeps this build token in its closure for trusted execution and inspection. +const lnStrIntegrity = process.env.SC_RANDOM_FNKEY!; + +// Canonical MAIN-world wrapper protocol. This exact string is both what gets injected onto the +// page (via codeFunction()) and what getCompiledScriptMetadata() diffs a page-visible candidate's +// native toString() against — there is only one source string, never a separate handwritten +// "expected" copy. Readable equivalent of the compact form below: +// +// (t, u, ...a) => { +// if (t !== k) return; // reject without the private build token +// if (u === null) { // metadata inspection mode +// return a[0] === d ? m : undefined; // only for the document captured at creation +// } +// const c = a[2]; // caller-supplied trusted call primitive +// if (typeof c !== "function") return; +// const s = c(fn, u, a[0], a[1]); // execute the compiled userscript +// return typeof s === "function" ? c(s, u) : s; // run an async userscript's returned body +// } +const generatedScriptFunctionSource = + "(t,u,...a)=>{if(t===k){if(u===null)return a[0]===d?m:void 0;const c=a[2];if(typeof c==='function'){const s=c(fn,u,a[0],a[1]);return typeof s==='function'?c(s,u):s}}}"; + +export function getCompiledScriptMetadata(scriptFunc: unknown): string | undefined { + try { + if (typeof scriptFunc !== "function" || Native.functionToString(scriptFunc) !== generatedScriptFunctionSource) { + return undefined; + } + const metadata = Native.document + ? Native.reflectApply(scriptFunc, undefined, [lnStrIntegrity, null, Native.document]) + : undefined; + return typeof metadata === "string" ? metadata : undefined; + } catch { + // A revoked page Proxy can throw during native source inspection; it is not a compiled wrapper. + return undefined; + } +} export type CompileScriptCodeResource = { name: string; @@ -141,29 +183,40 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) // arguments = [named: Object, scriptName: string] // 使用sandboxContext时,arguments[0]为undefined, this.$则为一次性Proxy变量,用于全域拦截context // 非沙盒环境时,先读取 arguments[0],因此不会读取页面环境的 this.$ - // 在UserScripts API中,由于执行不是在物件导向里呼叫,使用arrow function的话会把this改变。须使用 .call(this) [ 或 .bind(this)() ] + // 临时方法调用保留 userscript 的 this,避免在页面解析可变的 call/apply/bind。 if (resource.isContextMenu) { // 脚本体整体延后到菜单回调里执行,它自己的 GM_registerMenuCommand 也随之推迟到点击后才注册 code = `GM_registerMenuCommand((${JSON.stringify(resource.name)}), ()=>{\n${code}\n}, {nested:false});\n`; } - const joinedCode = [ - "with(arguments[0]||this.$){", - `${preCode}`, - "return(async function(){", - `${code}`, - "}).call(this);}", - ] + const joinedCode = ["with(arguments[0]||this.$){", `${preCode}`, `return async function(){${code}};}`] .filter(Boolean) .join("\n"); const codeBody = addTryCatch(joinedCode); return `${codeBody}${sourceMapTo(`${resource.name}.user.js`)}\n`; } +const codeFunction = (code: string, scriptInfoJSON: string) => + `((d,k,m,fn)=>${generatedScriptFunctionSource})(document, ${JSON.stringify(lnStrIntegrity)}, ${JSON.stringify(scriptInfoJSON)}, function(){${code}})`; + +// ScriptExecutor authenticates the wrapper closure before passing it a GM context. +const mountCodeFunction = (flag: string, code: string, scriptInfoJSON: string) => + `window['${flag}'] = ${codeFunction(code, scriptInfoJSON)}`; + +const ZFunction = Function; + // 通过脚本代码编译脚本函数 -export function compileScript(code: string): ScriptFunc { - return new Function(code); +export function compileScript(code: string, invokeReturnedFunction: boolean = false): ScriptFunc { + const fn = new ZFunction(code); + const k = lnStrIntegrity; + return (t: any, u: any, ...args: any[]) => { + if (t === k) { + if (args[2] === nativeCall) args.length = 2; + const result = nativeCall(fn, u, args[0], args[1]); + return invokeReturnedFunction && typeof result === "function" ? nativeCall(result, u) : result; + } + }; } /** @@ -177,16 +230,25 @@ export function compileInjectScript( scriptCode: string, autoDeleteMountFunction: boolean = false ): string { - return compileInjectScriptByFlag(script.flag, scriptCode, autoDeleteMountFunction); + return compileInjectScriptByFlag( + script.flag, + scriptCode, + autoDeleteMountFunction, + script.uuid, + script.scriptRevision + ); } export function compileInjectScriptByFlag( flag: string, scriptCode: string, - autoDeleteMountFunction: boolean = false + autoDeleteMountFunction: boolean = false, + scriptUuid?: string, + scriptRevision?: string ): string { const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : ""; - return `window['${flag}'] = function(){${autoDeleteMountCode}${scriptCode}}`; + const uuid = scriptUuid ?? (flag.startsWith("#-") ? flag.slice(2) : undefined); + return `${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`, JSON.stringify({ uuid, flag, scriptRevision }))};`; } /** @@ -216,7 +278,19 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { } // --- 处理 resource --- // --- 处理 scriptInfo --- - const scriptInfo = { ...script, resource, requireCssResource, code: "" } as TScriptInfo; + const metadata = Object.fromEntries( + Object.entries(script.metadata).map(([key, values]) => [key, Array.isArray(values) ? [...values] : values]) + ); + const scriptInfo = { + ...script, + scriptRevision: script.scriptRevision ?? `${script.uuid}:${script.createtime}:${script.updatetime || 0}`, + metadata, + value: cloneTransportValue(script.value) ?? {}, + config: script.config === undefined ? undefined : cloneTransportValue(script.config), + resource, + requireCssResource, + code: "", + } as TScriptInfo; // 删除其他不需要注入的 script 信息 delete scriptInfo.originalMetadata; delete scriptInfo.selfMetadata; @@ -232,10 +306,24 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { delete scriptInfo.runStatus; // 前台脚本不用 delete scriptInfo.type; // 脚本类型总是普通脚本 delete scriptInfo.status; // 脚本状态总是启用 + delete scriptInfo.executionHandle; + delete scriptInfo.executionEnvTag; + // 这些绑定令牌只在隔离 broker 内有效,不能随脚本资料暴露给页面或 USER_SCRIPT。 + delete scriptInfo.executionRunFlag; // --- 处理 scriptInfo --- return scriptInfo; }; +/** + * early-start 的 userscript body 会在 authoritative pageLoad 前执行,因此同步 GM API + * 必须从已注册 wrapper 的 snapshot 立即取得 value/config/userConfig/resource。 + * + * 这些资料只放在 compiled wrapper 的闭包 metadata 中;页面可观察的 performance event + * 仍只携带 scriptFlag。executionHandle / executionRunFlag 等 document-bound 权限资料则继续 + * 由 trimScriptInfo() 排除,必须等当前 document 的 authoritative pageLoad 才补上。 + */ +export const trimPreInjectScriptInfo = (script: ScriptLoadInfo): TScriptInfo => trimScriptInfo(script); + /** * 将脚本函数编译为预注入脚本代码 */ @@ -247,16 +335,27 @@ export function compilePreInjectScript( const scriptEnvTag = isInjectIntoContent(script.metadata) ? ScriptEnvTag.content : ScriptEnvTag.inject; const eventNamePrefix = `evt${process.env.SC_RANDOM_KEY}.${scriptEnvTag}`; // 仅用于early-start初始化 const flag = `${script.flag}`; - const scriptInfo = trimScriptInfo(script); + const scriptInfo = trimPreInjectScriptInfo(script); const scriptInfoJSON = `${JSON.stringify(scriptInfo)}`; + const scriptUrlPatterns = script.scriptUrlPatterns?.map(({ ruleType, ruleContent }) => ({ ruleType, ruleContent })); + const urlCondition = scriptUrlPatterns + ? embeddedPatternCheckerString("location.href", JSON.stringify(scriptUrlPatterns)) + : "true"; const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : ""; const evScriptLoad = `${eventNamePrefix}${DefinedFlags.scriptLoadComplete}`; const evEnvLoad = `${eventNamePrefix}${DefinedFlags.envLoadComplete}`; - return `window['${flag}'] = function(){${autoDeleteMountCode}${scriptCode}}; -{ - let o = { cancelable: true, detail: { scriptFlag: '${flag}', scriptInfo: (${scriptInfoJSON}) } }, - c = typeof cloneInto === "function" ? cloneInto(o, performance) : o, - f = () => performance.dispatchEvent(new CustomEvent('${evScriptLoad}', c)), + return `{ + let mounted = false, + f = () => { + if (!(${urlCondition})) return false; + if (!mounted) { + ${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`, scriptInfoJSON)}; + mounted = true; + } + const o = { cancelable: true, detail: { scriptFlag: '${flag}' } }, + c = typeof cloneInto === "function" ? cloneInto(o, performance) : o; + return performance.dispatchEvent(new CustomEvent('${evScriptLoad}', c)); + }, needWait = f(); if (needWait) performance.addEventListener('${evEnvLoad}', f, { once: true }); } @@ -291,6 +390,19 @@ export function isContextMenuScript(metadata: SCMetadata): boolean { return metadata["run-at"]?.[0] === "context-menu"; } +/** + * 唯一的 raw metadata → effective execution grants 转换。ExecScript facade、SW page execution + * binding 与 content fallback PageRpcRegistry 必须共用此结果,否则三处 capability policy 会 drift。 + */ +export function getEffectiveScriptGrants(metadata: SCMetadata): string[] { + const grants = new Native.Set(metadata.grant || []); + if (isContextMenuScript(metadata)) { + grants.delete("none"); + grants.add("GM_registerMenuCommand"); + } + return [...grants]; +} + export function isEarlyStartScript(metadata: SCMetadata): boolean { return metadataBlankOrTrue(metadata, "early-start") && metadata["run-at"]?.[0] === "document-start"; } @@ -337,16 +449,32 @@ export const getScriptFlag = (uuid: string) => { // 监听属性设置 export function definePropertyListener(obj: any, prop: string, listener: (val: T) => void) { - if (obj[prop] !== undefined) { - listener(obj[prop]); - delete obj[prop]; + const sameProperty = (left: PropertyDescriptor | undefined, right: PropertyDescriptor | undefined) => + left?.configurable === right?.configurable && + left?.enumerable === right?.enumerable && + left?.value === right?.value && + left?.get === right?.get && + left?.set === right?.set; + const current = obj[prop]; + if (current !== undefined) { + const descriptor = Native.objectGetOwnPropertyDescriptor(obj, prop); + listener(current); + // 页面可能在回调里替换属性;只有描述符仍是原来的才可以清理自身监听器。 + if (sameProperty(descriptor, Native.objectGetOwnPropertyDescriptor(obj, prop)) && descriptor?.configurable) { + delete obj[prop]; + } return; } - Object.defineProperty(obj, prop, { + const setter = (val: T) => { + listener(val); + const descriptor = Native.objectGetOwnPropertyDescriptor(obj, prop); + // 不删除页面后来安装的 setter,只删除本函数仍拥有的那一个。 + if (descriptor?.configurable && descriptor.set === setter) { + delete obj[prop]; + } + }; + Native.objectDefineProperty(obj, prop, { configurable: true, - set: (val: any) => { - delete obj[prop]; // 删除 property setter - listener(val); - }, + set: setter, }); } diff --git a/src/app/service/offscreen/base.test.ts b/src/app/service/offscreen/base.test.ts index 886ae3d4c..906b75577 100644 --- a/src/app/service/offscreen/base.test.ts +++ b/src/app/service/offscreen/base.test.ts @@ -1,31 +1,23 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { initTestEnv } from "@Tests/utils"; import { MockMessage } from "@Packages/message/mock_message"; import { Server } from "@Packages/message/server"; import EventEmitter from "eventemitter3"; -import type { WindowMessage } from "@Packages/message/window_message"; +import type { SandboxChannelHost } from "@Packages/message/sandbox_message_channel"; import type { ServiceWorkerClient } from "../service_worker/client"; -import type { MessageSend, TMessage } from "@Packages/message/types"; -import { BackgroundEnvManagerBase, SANDBOX_READY_FALLBACK_MS } from "./base"; +import type { + MessageConnect, + MessageSend, + OnConnectCallback, + OnMessageCallback, + TMessage, +} from "@Packages/message/types"; +import { BackgroundEnvManagerBase } from "./base"; import { MessageQueueGroup, type IMessageQueue } from "@Packages/message/message_queue"; import { SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_BACKGROUND, type ScriptRunResource } from "@App/app/repo/scripts"; initTestEnv(); -const buildManager = () => { - const bus = new MockMessage(new EventEmitter()); - const offscreenServer = new Server("offscreen", bus); - const preparationOffscreen = vi.fn(); - const serviceWorker = { preparationOffscreen } as unknown as ServiceWorkerClient; - const manager = new BackgroundEnvManagerBase( - {} as MessageSend, - bus as unknown as WindowMessage, - offscreenServer, - serviceWorker - ); - return { bus, offscreenServer, preparationOffscreen, manager }; -}; - class LocalMessageQueue implements IMessageQueue { private readonly events = new EventEmitter(); @@ -47,108 +39,56 @@ class LocalMessageQueue implements IMessageQueue { } } -const flushAsyncHandlers = async () => { - for (let i = 0; i < 8; i += 1) { - await Promise.resolve(); - } -}; - -// 单测重点:就绪信号完全由 sandbox 主动上报(preparationSandbox),父层不 ping、不轮询、不猜测; -// sandbox 自行做的通道自检结果通过 reportSandboxChannelHealth 单独上报并记录到父层日志; -// 若 sandbox 从未上报任何消息,兜底超时仍会放行,且不会与真实握手产生重复通知 -describe("BackgroundEnvManagerBase 就绪握手", () => { - let infoSpy: ReturnType; - let errorSpy: ReturnType; - - beforeEach(() => { - infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); - errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); +const deferred = () => { + let resolve!: () => void; + const promise = new Promise((r) => { + resolve = r; }); + return { promise, resolve }; +}; - afterEach(() => { - infoSpy.mockRestore(); - errorSpy.mockRestore(); - vi.useRealTimers(); - }); - - it("sandbox 主动上报就绪后,立即通知 SW 就绪(不等待、不 ping)", () => { - const { manager, preparationOffscreen } = buildManager(); - - manager.preparationSandbox(); - - expect(preparationOffscreen).toHaveBeenCalledTimes(1); - expect(preparationOffscreen).toHaveBeenCalledWith({ verified: true }); - }); - - it("sandbox 上报通道自检成功时,记录通过日志", () => { - const { manager } = buildManager(); - - manager.reportSandboxChannelHealth({ ok: true, roundTripMs: 12 }); - - expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining("communication verified"), expect.anything()); - expect(errorSpy).not.toHaveBeenCalled(); - }); - - it("sandbox 上报通道自检失败时,记录失败日志(附带 sandbox 给出的具体原因)", () => { - const { manager } = buildManager(); - - manager.reportSandboxChannelHealth({ ok: false, error: "getExtensionEnv timed out after 5000ms" }); - - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("getExtensionEnv timed out"), expect.anything()); - }); - - it("sandbox 从未上报就绪时,兜底超时后仍通知 SW 就绪,并记录明确的错误日志", () => { - vi.useFakeTimers(); - const { manager, preparationOffscreen } = buildManager(); - - // 直接触发兜底计时器的注册逻辑(initManager 本身依赖较重的服务构造,测试只关心兜底行为) - (manager as unknown as { armReadyFallback(): void }).armReadyFallback(); - - expect(preparationOffscreen).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(SANDBOX_READY_FALLBACK_MS); +const makeSandboxChannel = (readyPromise: Promise) => { + const sendMessage = vi.fn(async (_message: TMessage) => ({ code: 0 })); + return { + ready: () => readyPromise, + isReady: vi.fn(), + connect: vi.fn(async (_message: TMessage) => ({}) as MessageConnect), + sendMessage, + onConnect: vi.fn((_callback: OnConnectCallback) => {}), + onMessage: vi.fn((_callback: OnMessageCallback) => {}), + } as unknown as SandboxChannelHost; +}; - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining("no sandbox readiness signal received"), - expect.anything() +describe("BackgroundEnvManagerBase private sandbox readiness", () => { + it("does not notify the service worker until the private port is attached", async () => { + const bus = new MockMessage(new EventEmitter()); + const offscreenServer = new Server("offscreen", bus); + const ready = deferred(); + const sandboxChannel = makeSandboxChannel(ready.promise); + const preparationOffscreen = vi.fn().mockResolvedValue(undefined); + const manager = new BackgroundEnvManagerBase( + { connect: vi.fn(), sendMessage: vi.fn() } as unknown as MessageSend, + sandboxChannel, + offscreenServer, + { preparationOffscreen } as unknown as ServiceWorkerClient ); - expect(preparationOffscreen).toHaveBeenCalledTimes(1); - expect(preparationOffscreen).toHaveBeenCalledWith({ verified: false }); - }); - it("真实握手先到达时,兜底超时不会重复通知 SW 就绪", () => { - vi.useFakeTimers(); - const { manager, preparationOffscreen } = buildManager(); - - (manager as unknown as { armReadyFallback(): void }).armReadyFallback(); - manager.preparationSandbox(); - expect(preparationOffscreen).toHaveBeenCalledTimes(1); + const initialized = manager.initManager(); + await Promise.resolve(); - vi.advanceTimersByTime(SANDBOX_READY_FALLBACK_MS); - expect(preparationOffscreen).toHaveBeenCalledTimes(1); - }); + expect(preparationOffscreen).not.toHaveBeenCalled(); - it("兜底超时先触发后,迟到的真实握手会补发一次 verified 通知", () => { - vi.useFakeTimers(); - const { manager, preparationOffscreen } = buildManager(); + ready.resolve(); + await initialized; - (manager as unknown as { armReadyFallback(): void }).armReadyFallback(); - vi.advanceTimersByTime(SANDBOX_READY_FALLBACK_MS); expect(preparationOffscreen).toHaveBeenCalledTimes(1); - expect(preparationOffscreen).toHaveBeenLastCalledWith({ verified: false }); - - manager.preparationSandbox(); - expect(preparationOffscreen).toHaveBeenCalledTimes(2); - expect(preparationOffscreen).toHaveBeenLastCalledWith({ verified: true }); - - manager.preparationSandbox(); - expect(preparationOffscreen).toHaveBeenCalledTimes(2); + expect(preparationOffscreen).toHaveBeenCalledWith({ verified: true }); }); - it("兜底初始化消息丢失后,迟到的真实握手会把后台脚本与语言各重放一次", async () => { - vi.useFakeTimers(); + it("replays background scripts and language only after MessagePort readiness", async () => { const bus = new MockMessage(new EventEmitter()); const offscreenServer = new Server("offscreen", bus); + const ready = deferred(); const messageQueue = new LocalMessageQueue(); const backgroundScript = { uuid: "background-script", @@ -168,47 +108,31 @@ describe("BackgroundEnvManagerBase 就绪握手", () => { return { code: 0 }; }), } as unknown as MessageSend; - let sandboxReady = false; - const attemptedActions: string[] = []; - const deliveredActions: string[] = []; - const windowMessage = { - connect: vi.fn(), - sendMessage: vi.fn((message: TMessage) => { - if (message.action) attemptedActions.push(message.action); - if (!sandboxReady) return new Promise(() => {}); - if (message.action) deliveredActions.push(message.action); - return Promise.resolve({ code: 0 }); - }), - } as unknown as WindowMessage; - const preparationOffscreen = vi.fn(() => { + const sandboxChannel = makeSandboxChannel(ready.promise); + const sandboxSend = vi.mocked(sandboxChannel.sendMessage); + const preparationOffscreen = vi.fn(async () => { messageQueue.publish("enableScripts", [{ uuid: backgroundScript.uuid, enable: true }]); messageQueue.publish("setSandboxLanguage", "zh-CN"); }); const manager = new BackgroundEnvManagerBase( extMsgSender, - windowMessage, + sandboxChannel, offscreenServer, { preparationOffscreen } as unknown as ServiceWorkerClient, messageQueue ); - await manager.initManager(); - - vi.advanceTimersByTime(SANDBOX_READY_FALLBACK_MS); - await flushAsyncHandlers(); - expect(attemptedActions).toEqual(expect.arrayContaining(["sandbox/enableScript", "sandbox/setSandboxLanguage"])); - expect(deliveredActions).toEqual([]); - - sandboxReady = true; - manager.preparationSandbox(); - await flushAsyncHandlers(); + const initialized = manager.initManager(); + await Promise.resolve(); + expect(sandboxSend).not.toHaveBeenCalled(); - expect(deliveredActions.filter((action) => action === "sandbox/enableScript")).toHaveLength(1); - expect(deliveredActions.filter((action) => action === "sandbox/setSandboxLanguage")).toHaveLength(1); + ready.resolve(); + await initialized; + for (let i = 0; i < 8; i += 1) await Promise.resolve(); - manager.preparationSandbox(); - await flushAsyncHandlers(); - expect(deliveredActions.filter((action) => action === "sandbox/enableScript")).toHaveLength(1); - expect(deliveredActions.filter((action) => action === "sandbox/setSandboxLanguage")).toHaveLength(1); + const actions = sandboxSend.mock.calls.map(([message]) => message.action); + expect(actions).toContain("sandbox/enableScript"); + expect(actions).toContain("sandbox/setSandboxLanguage"); + expect(preparationOffscreen).toHaveBeenCalledTimes(1); }); }); diff --git a/src/app/service/offscreen/base.ts b/src/app/service/offscreen/base.ts index 311c2ee75..f53844adf 100644 --- a/src/app/service/offscreen/base.ts +++ b/src/app/service/offscreen/base.ts @@ -3,7 +3,7 @@ import type { MessageSend } from "@Packages/message/types"; import { ScriptService } from "./script"; import { type Logger as LoggerRecord } from "@App/app/repo/logger"; import LoggerCore from "@App/app/logger/core"; -import { type WindowMessage } from "@Packages/message/window_message"; +import type { SandboxChannelHost } from "@Packages/message/sandbox_message_channel"; import { type ServiceWorkerClient } from "../service_worker/client"; import { sendMessage } from "@Packages/message/client"; import GMApi from "./gm_api"; @@ -12,25 +12,15 @@ import { VSCodeConnect } from "./vscode-connect"; import { ExternalAccessConnect } from "./external-access-connect"; import { HtmlExtractorService } from "./html_extractor"; import { makeBlobURL } from "@App/pkg/utils/utils"; -import { type SandboxChannelHealth } from "./client"; import { startChromeOffscreenKeepAliveLoop } from "./keep_alive"; -// 兜底超时:sandbox 若在此时长内从未发出就绪通知(iframe 加载失败/脚本异常等), -// 也不能让 SW 永久卡在等待 offscreen 就绪上,超时后仍放行,但打印明确的错误日志 -export const SANDBOX_READY_FALLBACK_MS = 15000; - // offscreen环境的管理器 export class BackgroundEnvManagerBase { private readonly handshakeLogger = LoggerCore.getInstance().logger({ component: "offscreen-sandbox-handshake" }); - // fallback 只能表示父层不再无限等待,不能证明 sandbox 通道可用;真实握手需要独立记录, - // 这样 fallback 先发生时,迟到的 verified 握手仍可触发一次必要的状态重放。 - private fallbackReadyNotified = false; - private sandboxReadyVerified = false; - constructor( private readonly extMsgSender: MessageSend, - private readonly windowMessage: WindowMessage, + private readonly sandboxMessage: SandboxChannelHost, private readonly offscreenServer: Server, private readonly serviceWorker: ServiceWorkerClient, // Chrome: offscreen 文档是独立进程,这里默认创建自己的 MessageQueue, @@ -54,47 +44,6 @@ export class BackgroundEnvManagerBase { }); } - preparationSandbox() { - // sandbox 主动通知自己已就绪(而非由父层猜测/轮询/ping 探测)。 - // Firefox 154+ 下 sandbox manifest 页面是跨源 iframe:contentDocument 为 null, - // contentWindow.location 不可读,父层没有别的办法探测其就绪状态,也不该去 ping sandbox—— - // 只有 sandbox 自己知道它什么时候真正就绪。sandbox 还会自行做一次通道自检, - // 结果通过 reportSandboxChannelHealth 单独上报(见下)。 - if (this.sandboxReadyVerified) return; - this.sandboxReadyVerified = true; - this.notifyOffscreenReady(true, "sandbox reported readiness"); - } - - // sandbox 自己主动做的通道连通性自检结果,记录到父层(offscreen 文档 / Firefox event page)的日志, - // 因为 sandbox 自身的控制台通常不便查看 - reportSandboxChannelHealth(health: SandboxChannelHealth) { - if (health.ok) { - this.handshakeLogger.debug(`sandbox communication verified (${health.roundTripMs}ms round trip)`); - } else { - this.handshakeLogger.error(`sandbox communication check failed: ${health.error}`); - } - } - - private notifyOffscreenReady(verified: boolean, reason: string) { - this.handshakeLogger.debug(`offscreen ready (${reason})`); - // 通知初始化好环境了 - this.serviceWorker.preparationOffscreen({ verified }); - } - - // 兜底:sandbox 若因 iframe 加载失败/脚本异常等原因从未发出就绪通知,也不能让 SW 永久 - // 卡在等待 offscreen 就绪上 —— 超时后仍然放行,只是没有经过连通性验证 - private armReadyFallback() { - setTimeout(() => { - if (!this.sandboxReadyVerified && !this.fallbackReadyNotified) { - this.fallbackReadyNotified = true; - this.handshakeLogger.error( - `no sandbox readiness signal received within ${SANDBOX_READY_FALLBACK_MS}ms; proceeding without a verified sandbox channel` - ); - this.notifyOffscreenReady(false, "fallback timeout, sandbox never reported readiness"); - } - }, SANDBOX_READY_FALLBACK_MS); - } - async getExtensionEnv(data: { requireUAD: boolean }) { return this.sendMessageToServiceWorker({ action: "getExtensionEnv", @@ -109,26 +58,23 @@ export class BackgroundEnvManagerBase { async initManager() { // 监听消息 this.offscreenServer.on("logger", this.logger.bind(this)); - this.offscreenServer.on("preparationSandbox", this.preparationSandbox.bind(this)); - this.offscreenServer.on("reportSandboxChannelHealth", this.reportSandboxChannelHealth.bind(this)); this.offscreenServer.on("getExtensionEnv", this.getExtensionEnv.bind(this)); this.offscreenServer.on("sendMessageToServiceWorker", this.sendMessageToServiceWorker.bind(this)); this.offscreenServer.on("keepAlive", startChromeOffscreenKeepAliveLoop()); - this.armReadyFallback(); const script = new ScriptService( this.offscreenServer.group("script"), this.extMsgSender, - this.windowMessage, + this.sandboxMessage, this.messageQueue ); script.init(); // 转发从sandbox来的gm api请求 forwardMessage("serviceWorker", "runtime/gmApi", this.offscreenServer, this.extMsgSender); // 转发 Skill Script 执行请求到 sandbox - forwardMessage("sandbox", "executeSkillScript", this.offscreenServer, this.windowMessage); + forwardMessage("sandbox", "executeSkillScript", this.offscreenServer, this.sandboxMessage); // 转发valueUpdate与emitEvent - forwardMessage("sandbox", "runtime/valueUpdate", this.offscreenServer, this.windowMessage); - forwardMessage("sandbox", "runtime/emitEvent", this.offscreenServer, this.windowMessage); + forwardMessage("sandbox", "runtime/valueUpdate", this.offscreenServer, this.sandboxMessage); + forwardMessage("sandbox", "runtime/emitEvent", this.offscreenServer, this.sandboxMessage); const gmApi = new GMApi(this.offscreenServer.group("gmApi")); gmApi.init(); @@ -151,5 +97,11 @@ export class BackgroundEnvManagerBase { const res = await fetch(params.url); return await res.blob(); }); + + // 接收到 sandbox transfer 的 MessagePort 本身就是唯一、已验证的 readiness 信号。 + // 不再维护 preparationSandbox RPC、额外 health ping 或“无通道也放行”的 timeout fallback。 + await this.sandboxMessage.ready(); + this.handshakeLogger.debug("offscreen ready (private sandbox MessagePort attached)"); + await this.serviceWorker.preparationOffscreen({ verified: true }); } } diff --git a/src/app/service/offscreen/client.ts b/src/app/service/offscreen/client.ts index 9ac9e1f99..ecd3ce119 100644 --- a/src/app/service/offscreen/client.ts +++ b/src/app/service/offscreen/client.ts @@ -1,4 +1,3 @@ -import { type WindowMessage } from "@Packages/message/window_message"; import type { SCRIPT_RUN_STATUS, ScriptRunResource } from "@App/app/repo/scripts"; import { Client, sendMessage } from "@Packages/message/client"; import type { MessageSend } from "@Packages/message/types"; @@ -6,38 +5,26 @@ import { type VSCodeConnectParam } from "./vscode-connect"; import { type ExternalAccessConnectParam } from "./external-access-connect"; import type { WSEnvelope } from "../service_worker/external_access/types"; -export function preparationSandbox(windowMessage: WindowMessage) { - return sendMessage(windowMessage, "offscreen/preparationSandbox"); -} - -// sandbox 自身对通道做的一次连通性自检结果(只有 sandbox 自己知道它何时就绪、何时做完这次自检, -// 因此由 sandbox 主动上报,而不是由父层去 ping sandbox) -export type SandboxChannelHealth = { ok: true; roundTripMs: number } | { ok: false; error: string }; - -export function reportSandboxChannelHealth(windowMessage: WindowMessage, health: SandboxChannelHealth) { - return sendMessage(windowMessage, "offscreen/reportSandboxChannelHealth", health); -} - -export function getExtensionEnv(windowMessage: WindowMessage) { +export function getExtensionEnv(windowMessage: MessageSend) { return sendMessage(windowMessage, "offscreen/getExtensionEnv", { requireUAD: true }); } -export function keepAlive(windowMessage: WindowMessage, val: boolean) { +export function keepAlive(windowMessage: MessageSend, val: boolean) { return sendMessage(windowMessage, "offscreen/keepAlive", val); } // 代理发送消息到ServiceWorker -export function sendMessageToServiceWorker(windowMessage: WindowMessage, action: string, data?: any) { +export function sendMessageToServiceWorker(windowMessage: MessageSend, action: string, data?: any) { return sendMessage(windowMessage, "offscreen/sendMessageToServiceWorker", { action, data }); } // 代理连接ServiceWorker -export function connectServiceWorker(windowMessage: WindowMessage) { +export function connectServiceWorker(windowMessage: MessageSend) { return sendMessage(windowMessage, "offscreen/connectServiceWorker"); } export function proxyUpdateRunStatus( - windowMessage: WindowMessage, + windowMessage: MessageSend, data: { uuid: string; runStatus: SCRIPT_RUN_STATUS; error?: any; nextruntime?: number } ) { return sendMessageToServiceWorker(windowMessage, "script/updateRunStatus", data); diff --git a/src/app/service/offscreen/event_page_manager.test.ts b/src/app/service/offscreen/event_page_manager.test.ts index c133baf71..d87b1a059 100644 --- a/src/app/service/offscreen/event_page_manager.test.ts +++ b/src/app/service/offscreen/event_page_manager.test.ts @@ -94,21 +94,17 @@ describe("EventPageOffscreenManager <-> serviceWorker 进程内桥接", () => { expect(result).toEqual({ inIncognitoContext: false }); }); - it("offscreen 发起的 preparationOffscreen 通知也通过进程内桥接真正到达 serviceWorker 端处理器", async () => { + it("构造时不挂载 sandbox;init 时才挂载,确保 parent bootstrap listener 已先建立", () => { const bridge = new InProcessMessage(); - const serviceWorkerHandler = vi.fn().mockResolvedValue(undefined); - const serviceWorkerServer = new Server("serviceWorker", bridge); - serviceWorkerServer.on("preparationOffscreen", serviceWorkerHandler); - const manager = new EventPageOffscreenManager(bridge, new MessageQueue()); + const sandboxFrame = (manager as unknown as { sandboxFrame: HTMLIFrameElement }).sandboxFrame; - // 触发 sandbox 就绪通知:会走 notifyOffscreenReady -> this.serviceWorker.preparationOffscreen() - manager.preparationSandbox(); + expect(sandboxFrame.isConnected).toBe(false); - // preparationOffscreen() 内部走 sendMessage,是异步的;等待其对应的微任务/宏任务跑完 - await new Promise((resolve) => setTimeout(resolve, 0)); + void manager.init(); - expect(serviceWorkerHandler).toHaveBeenCalledTimes(1); + expect(sandboxFrame.isConnected).toBe(true); + expect(sandboxFrame.src).toContain("/src/sandbox.html"); }); }); diff --git a/src/app/service/offscreen/event_page_manager.ts b/src/app/service/offscreen/event_page_manager.ts index ac019c1ea..541262142 100644 --- a/src/app/service/offscreen/event_page_manager.ts +++ b/src/app/service/offscreen/event_page_manager.ts @@ -7,7 +7,7 @@ import type { RuntimeMessageSender, TMessage, } from "@Packages/message/types"; -import { WindowMessage } from "@Packages/message/window_message"; +import { SandboxChannelHost } from "@Packages/message/sandbox_message_channel"; import EventEmitter from "eventemitter3"; import { type IMessageQueue } from "@Packages/message/message_queue"; import { ServiceWorkerClient } from "../service_worker/client"; @@ -86,6 +86,7 @@ export class InProcessMessage implements Message, MessageSend { export class EventPageOffscreenManager extends BackgroundEnvManagerBase implements IOffscreenSend { private readonly message: InProcessMessage; + private readonly sandboxFrame: HTMLIFrameElement; private initialized = false; constructor( @@ -102,24 +103,24 @@ export class EventPageOffscreenManager extends BackgroundEnvManagerBase implemen const sandbox = document.createElement("iframe"); sandbox.src = chrome.runtime.getURL("/src/sandbox.html"); sandbox.style.display = "none"; - document.documentElement.appendChild(sandbox); const message = new InProcessMessage(); - // iframe 创建后会从 about:blank 导航到跨源 sandbox 页面。惰性读取 contentWindow,避免 - // 在导航前固定目标引用,并在 iframe 被移除时给出明确错误。 - const windowMessage = new WindowMessage(window, () => { + // Firefox 与 Chromium 使用完全相同的 bootstrap 顺序:先监听 parent Window, + // 再挂载 iframe;收到来自该 iframe 的 transferred port 后,Window listener 永久移除。 + const sandboxChannel = new SandboxChannelHost(window, () => { const win = sandbox.contentWindow; if (!win) { throw new Error("EventPageOffscreenManager: sandbox iframe has no contentWindow (removed from DOM?)."); } return win; }); - const offscreenServer = new Server("offscreen", [message, windowMessage]); + const offscreenServer = new Server("offscreen", [message, sandboxChannel]); const serviceWorker = new ServiceWorkerClient(extMsgSender); - super(extMsgSender, windowMessage, offscreenServer, serviceWorker, messageQueue); + super(extMsgSender, sandboxChannel, offscreenServer, serviceWorker, messageQueue); this.message = message; + this.sandboxFrame = sandbox; } init() { @@ -127,7 +128,9 @@ export class EventPageOffscreenManager extends BackgroundEnvManagerBase implemen return; } this.initialized = true; - return super.initManager(); + const initialized = super.initManager(); + document.documentElement.appendChild(this.sandboxFrame); + return initialized; } connect(data: TMessage): Promise { diff --git a/src/app/service/offscreen/index.ts b/src/app/service/offscreen/index.ts index 94e21f9ff..dc9aada94 100644 --- a/src/app/service/offscreen/index.ts +++ b/src/app/service/offscreen/index.ts @@ -1,28 +1,43 @@ import { Server } from "@Packages/message/server"; -import type { MessageSend } from "@Packages/message/types"; -import { WindowMessage } from "@Packages/message/window_message"; +import type { Message } from "@Packages/message/types"; +import { SandboxChannelHost } from "@Packages/message/sandbox_message_channel"; import { ServiceWorkerClient } from "../service_worker/client"; import { BackgroundEnvManagerBase } from "./base"; // offscreen环境的管理器 export class OffscreenManager extends BackgroundEnvManagerBase { - constructor(extMsgSender: MessageSend) { - // `sandbox` 是 offscreen.html 中具名 iframe 的全局绑定(见 src/types/main.d.ts)。 - // 传入函数而非直接传值,与 Firefox 的 EventPageOffscreenManager 保持一致, - // 避免早于导航完成时缓存的 WindowProxy 引用带来的潜在身份不一致问题。 - // `sandbox` 的类型声明断言为非 null,但这只是编译期断言,运行时仍需校验。 - const windowMessage = new WindowMessage( - window, - () => { - if (!sandbox) { - throw new Error("OffscreenManager: named sandbox iframe is not available."); - } - return sandbox; - }, - true - ); - const windowServer = new Server("offscreen", windowMessage); + private readonly sandboxFrame: HTMLIFrameElement; + private sandboxAttached = false; + + constructor(extMsgSender: Message) { + // Chromium 也改为动态创建 iframe:先安装一次性 bootstrap listener,再挂载 sandbox, + // 避免静态 iframe 提前启动导致 transferred MessagePort 在 parent listener 建立前丢失。 + const sandboxFrame = document.createElement("iframe"); + sandboxFrame.src = chrome.runtime.getURL("/src/sandbox.html"); + sandboxFrame.name = "sandbox"; + sandboxFrame.style.display = "none"; + + const sandboxChannel = new SandboxChannelHost(window, () => { + const win = sandboxFrame.contentWindow; + if (!win) { + throw new Error("OffscreenManager: sandbox iframe has no contentWindow (removed from DOM?)."); + } + return win; + }); + + // SW↔Offscreen 仍使用 ServiceWorkerClientMessage;Offscreen↔Sandbox 改走 private MessagePort。 + const offscreenServer = new Server("offscreen", [extMsgSender, sandboxChannel]); const serviceWorker = new ServiceWorkerClient(extMsgSender); - super(extMsgSender, windowMessage, windowServer, serviceWorker); + super(extMsgSender, sandboxChannel, offscreenServer, serviceWorker); + this.sandboxFrame = sandboxFrame; + } + + override initManager() { + const initialized = super.initManager(); + if (!this.sandboxAttached) { + this.sandboxAttached = true; + document.documentElement.appendChild(this.sandboxFrame); + } + return initialized; } } diff --git a/src/app/service/offscreen/script.ts b/src/app/service/offscreen/script.ts index 9a52de4b5..3969092af 100644 --- a/src/app/service/offscreen/script.ts +++ b/src/app/service/offscreen/script.ts @@ -1,7 +1,6 @@ import LoggerCore from "@App/app/logger/core"; import type Logger from "@App/app/logger/logger"; import { type IMessageQueue } from "@Packages/message/message_queue"; -import { type WindowMessage } from "@Packages/message/window_message"; import { ResourceClient, ScriptClient, ValueClient } from "../service_worker/client"; import type { ScriptRunResource } from "@App/app/repo/scripts"; import { @@ -25,7 +24,7 @@ export class ScriptService { constructor( private group: Group, private msgSender: MessageSend, - private windowMessage: WindowMessage, + private windowMessage: MessageSend, private messageQueue: IMessageQueue ) { this.logger = LoggerCore.logger().with({ service: "script" }); diff --git a/src/app/service/sandbox/client.ts b/src/app/service/sandbox/client.ts index bd5ea13b6..6de323692 100644 --- a/src/app/service/sandbox/client.ts +++ b/src/app/service/sandbox/client.ts @@ -1,23 +1,23 @@ import { type ScriptRunResource } from "@App/app/repo/scripts"; import { sendMessage } from "@Packages/message/client"; -import { type WindowMessage } from "@Packages/message/window_message"; +import type { MessageSend } from "@Packages/message/types"; -export function setSandboxLanguage(msg: WindowMessage, lang: string) { +export function setSandboxLanguage(msg: MessageSend, lang: string) { return sendMessage(msg, "sandbox/setSandboxLanguage", lang); } -export function enableScript(msg: WindowMessage, data: ScriptRunResource) { +export function enableScript(msg: MessageSend, data: ScriptRunResource) { return sendMessage(msg, "sandbox/enableScript", data); } -export function disableScript(msg: WindowMessage, uuid: string) { +export function disableScript(msg: MessageSend, uuid: string) { return sendMessage(msg, "sandbox/disableScript", uuid); } -export function runScript(msg: WindowMessage, data: ScriptRunResource) { +export function runScript(msg: MessageSend, data: ScriptRunResource) { return sendMessage(msg, "sandbox/runScript", data); } -export function stopScript(msg: WindowMessage, uuid: string) { +export function stopScript(msg: MessageSend, uuid: string) { return sendMessage(msg, "sandbox/stopScript", uuid); } diff --git a/src/app/service/sandbox/index.test.ts b/src/app/service/sandbox/index.test.ts index dfd69a673..54930d073 100644 --- a/src/app/service/sandbox/index.test.ts +++ b/src/app/service/sandbox/index.test.ts @@ -1,74 +1,32 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { initTestEnv } from "@Tests/utils"; import { MockMessage } from "@Packages/message/mock_message"; import { Server } from "@Packages/message/server"; import EventEmitter from "eventemitter3"; -import type { WindowMessage } from "@Packages/message/window_message"; +import type { Message } from "@Packages/message/types"; import { SandboxManager } from "./index"; initTestEnv(); -// 单测重点:sandbox 主动、立即上报就绪(不等待任何往返请求),并在此后非阻塞地对自己发起的 -// getExtensionEnv 请求做一次连通性自检,把结果(成功/超时)上报给父层记录 —— 父层不会、也不需要 -// 反过来 ping sandbox,因为只有 sandbox 自己知道它什么时候真正就绪 -describe("SandboxManager 就绪与通道自检上报", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("初始化时立即上报就绪,不等待 getExtensionEnv 完成", () => { - const bus = new MockMessage(new EventEmitter()); - const offscreenServer = new Server("offscreen", bus); - const preparationSandbox = vi.fn(); - offscreenServer.on("preparationSandbox", preparationSandbox); - // 故意不注册 getExtensionEnv 处理器:模拟该请求一直挂起,不应阻塞就绪上报 - offscreenServer.on("reportSandboxChannelHealth", vi.fn()); - - const manager = new SandboxManager(bus as unknown as WindowMessage); - manager.initManager(); - - expect(preparationSandbox).toHaveBeenCalledTimes(1); - }); - - it("getExtensionEnv 及时响应时,上报通道自检成功", async () => { +describe("SandboxManager private-port startup", () => { + it("wires runtime first and fetches extension env without a second readiness RPC", async () => { const bus = new MockMessage(new EventEmitter()); const offscreenServer = new Server("offscreen", bus); - offscreenServer.on("preparationSandbox", vi.fn()); - offscreenServer.on("getExtensionEnv", () => ({ inIncognitoContext: false })); - const reportHealth = vi.fn(); - offscreenServer.on("reportSandboxChannelHealth", reportHealth); - - const manager = new SandboxManager(bus as unknown as WindowMessage); - manager.initManager(); - - // 让 getExtensionEnv 的响应和后续的 .then() 链有机会跑完(多层 Promise.race/finally/then) - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(reportHealth).toHaveBeenCalledWith( - expect.objectContaining({ ok: true, roundTripMs: expect.any(Number) }), - expect.anything() - ); - }); - - it("getExtensionEnv 超时未响应时,上报通道自检失败(附带具体原因)", async () => { - vi.useFakeTimers(); - const bus = new MockMessage(new EventEmitter()); - const offscreenServer = new Server("offscreen", bus); - offscreenServer.on("preparationSandbox", vi.fn()); - // 注册一个永不 resolve 的处理器,模拟父层收到请求但从未响应(通道单向不可用), - // 而不是不注册该 action —— 后者会被 Server 当作"没有这个 API"立即报错,无法模拟真正的挂起 - offscreenServer.on("getExtensionEnv", () => new Promise(() => {})); - const reportHealth = vi.fn(); - offscreenServer.on("reportSandboxChannelHealth", reportHealth); - - const manager = new SandboxManager(bus as unknown as WindowMessage); + const getExtensionEnv = vi.fn().mockReturnValue({ inIncognitoContext: false }); + const legacyPreparation = vi.fn(); + const legacyHealth = vi.fn(); + offscreenServer.on("getExtensionEnv", getExtensionEnv); + offscreenServer.on("preparationSandbox", legacyPreparation); + offscreenServer.on("reportSandboxChannelHealth", legacyHealth); + + const manager = new SandboxManager(bus as unknown as Message); manager.initManager(); - await vi.advanceTimersByTimeAsync(5000); + await Promise.resolve(); + await Promise.resolve(); - expect(reportHealth).toHaveBeenCalledWith( - expect.objectContaining({ ok: false, error: expect.stringContaining("timed out") }), - expect.anything() - ); + expect(getExtensionEnv).toHaveBeenCalledTimes(1); + expect(legacyPreparation).not.toHaveBeenCalled(); + expect(legacyHealth).not.toHaveBeenCalled(); }); }); diff --git a/src/app/service/sandbox/index.ts b/src/app/service/sandbox/index.ts index 6c6c56724..db1537143 100644 --- a/src/app/service/sandbox/index.ts +++ b/src/app/service/sandbox/index.ts @@ -1,49 +1,21 @@ import { Server } from "@Packages/message/server"; -import { type WindowMessage } from "@Packages/message/window_message"; -import { getExtensionEnv, preparationSandbox, reportSandboxChannelHealth } from "../offscreen/client"; -import { withTimeout } from "@App/pkg/utils/with_timeout"; +import type { Message } from "@Packages/message/types"; +import { getExtensionEnv } from "../offscreen/client"; import { Runtime } from "./runtime"; -// 通道自检的超时时间:只影响自检结果的上报,不阻塞 sandbox 自身的初始化或就绪通知 -const SANDBOX_CHANNEL_CHECK_TIMEOUT_MS = 5000; - // sandbox环境的管理器 export class SandboxManager { api: Server; - constructor(private windowMessage: WindowMessage) { - this.api = new Server("sandbox", this.windowMessage); + constructor(private message: Message) { + this.api = new Server("sandbox", this.message); } initManager() { - const extensionEnvAsync = getExtensionEnv(this.windowMessage); - const runtime = new Runtime(this.windowMessage, this.api, extensionEnvAsync); + // MessagePort 会在 parent 接收 transfer 后开始交付;在此之前发出的请求由 channel 自身排队, + // 因此可以先完成 Runtime wiring,再由 sandbox.ts transfer port 作为唯一 readiness 信号。 + const extensionEnvAsync = getExtensionEnv(this.message); + const runtime = new Runtime(this.message, this.api, extensionEnvAsync); runtime.init(); - // 通知初始化好环境了:由 sandbox 自己主动上报,因为只有 sandbox 自己知道它何时就绪, - // 父层(offscreen 文档 / Firefox event page)不应该、也没办法去猜测或轮询这一点。 - preparationSandbox(this.windowMessage); - // 非阻塞地对刚发起的 getExtensionEnv 请求做一次连通性自检并上报结果——不额外发起新的往返 - // 请求,只是给这次已经在途的请求包一层超时观察;结果交由父层记录/打印,因为 sandbox 自身 - // 的控制台通常不便查看。 - this.reportChannelHealth(extensionEnvAsync); - } - - private reportChannelHealth(extensionEnvAsync: Promise) { - const start = Date.now(); - withTimeout( - extensionEnvAsync, - SANDBOX_CHANNEL_CHECK_TIMEOUT_MS, - () => new Error(`getExtensionEnv timed out after ${SANDBOX_CHANNEL_CHECK_TIMEOUT_MS}ms`) - ) - .then(() => reportSandboxChannelHealth(this.windowMessage, { ok: true, roundTripMs: Date.now() - start })) - .catch((e) => - reportSandboxChannelHealth(this.windowMessage, { - ok: false, - error: e instanceof Error ? e.message : String(e), - }) - ) - .catch(() => { - // 若连上报本身都发不出去(通道确实不通),父层的兜底超时会接手,这里不需要再做什么 - }); } } diff --git a/src/app/service/sandbox/runtime.test.ts b/src/app/service/sandbox/runtime.test.ts index 21cf99929..0efe513e7 100644 --- a/src/app/service/sandbox/runtime.test.ts +++ b/src/app/service/sandbox/runtime.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { initTestEnv } from "@Tests/utils"; import type { Server } from "@Packages/message/server"; -import type { WindowMessage } from "@Packages/message/window_message"; +import type { Message } from "@Packages/message/types"; import type { ScriptLoadInfo } from "../service_worker/types"; import type { TExtensionEnv } from "../extension/extension_env"; import { SCRIPT_TYPE_BACKGROUND, SCRIPT_TYPE_CRONTAB, type SCRIPT_TYPE } from "@App/app/repo/scripts"; @@ -49,7 +49,7 @@ const buildScript = (runIn?: string, type: SCRIPT_TYPE = SCRIPT_TYPE_BACKGROUND) }) as unknown as ScriptLoadInfo; const setup = (extensionEnv: TExtensionEnv | undefined) => { - const windowMessage = {} as WindowMessage; + const windowMessage = {} as Message; const api = {} as Server; return new Runtime(windowMessage, api, Promise.resolve(extensionEnv)); }; diff --git a/src/app/service/sandbox/runtime.ts b/src/app/service/sandbox/runtime.ts index 49e215384..f02da6281 100644 --- a/src/app/service/sandbox/runtime.ts +++ b/src/app/service/sandbox/runtime.ts @@ -8,7 +8,7 @@ import { SCRIPT_TYPE_BACKGROUND, } from "@App/app/repo/scripts"; import type { Server } from "@Packages/message/server"; -import type { WindowMessage } from "@Packages/message/window_message"; +import type { Message } from "@Packages/message/types"; import { createCronJob, type CronJob } from "@App/pkg/utils/cron"; import { proxyUpdateRunStatus } from "../offscreen/client"; import { BgExecScriptWarp } from "../content/exec_warp"; @@ -41,7 +41,7 @@ export class Runtime { }[] = []; constructor( - private windowMessage: WindowMessage, + private windowMessage: Message, private api: Server, private readonly extensionEnvAsync: Promise ) { diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index d872a01f8..a2f5cc064 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -349,8 +349,9 @@ export class RuntimeClient extends Client { return this.do("stopScript", uuid); } - pageLoad(): Promise { - return this.doThrow("pageLoad"); + // envTag 让 service worker 区分主世界请求与 content-world bootstrap,分别签发/回收句柄。 + pageLoad(envTag?: "it" | "ct"): Promise { + return this.doThrow("pageLoad", envTag ? { envTag } : undefined); } /** bfcache 还原上报:只告知本页仍在运行,不请求脚本 */ diff --git a/src/app/service/service_worker/gm_api/gm_agent.ts b/src/app/service/service_worker/gm_api/gm_agent.ts index d6b5bc59b..d6dcfc89f 100644 --- a/src/app/service/service_worker/gm_api/gm_agent.ts +++ b/src/app/service/service_worker/gm_api/gm_agent.ts @@ -38,7 +38,7 @@ class GMAgentApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleConversationApi(request.params[0]); + return this.agentService.handleConversationApi({ ...request.params[0], scriptUuid: request.script.uuid }); } @PermissionVerify.API({ @@ -50,7 +50,10 @@ class GMAgentApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleConversationChatFromGmApi(request.params[0], sender); + return this.agentService.handleConversationChatFromGmApi( + { ...request.params[0], scriptUuid: request.script.uuid }, + sender + ); } @PermissionVerify.API({ @@ -62,7 +65,10 @@ class GMAgentApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleAttachToConversationFromGmApi(request.params[0], sender); + return this.agentService.handleAttachToConversationFromGmApi( + { ...request.params[0], scriptUuid: request.script.uuid }, + sender + ); } } diff --git a/src/app/service/service_worker/gm_api/gm_agent_dom.ts b/src/app/service/service_worker/gm_api/gm_agent_dom.ts index c9e087265..043f22247 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_dom.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_dom.ts @@ -36,7 +36,7 @@ class GMAgentDomApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleDomApi(request.params[0]); + return this.agentService.handleDomApi({ ...request.params[0], scriptUuid: request.script.uuid }); } } diff --git a/src/app/service/service_worker/gm_api/gm_agent_model.ts b/src/app/service/service_worker/gm_api/gm_agent_model.ts index 87eb9474b..ce4cc36de 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_model.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_model.ts @@ -38,7 +38,7 @@ class GMAgentModelApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleModelApi(request.params[0]); + return this.agentService.handleModelApi({ ...request.params[0], scriptUuid: request.script.uuid }); } } diff --git a/src/app/service/service_worker/gm_api/gm_agent_opfs.ts b/src/app/service/service_worker/gm_api/gm_agent_opfs.ts index 70d993688..9122bfeff 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_opfs.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_opfs.ts @@ -50,7 +50,7 @@ class GMAgentOPFSApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleOPFSApi(request.params[0], sender); + return this.agentService.handleOPFSApi({ ...request.params[0], scriptUuid: request.script.uuid }, sender); } } diff --git a/src/app/service/service_worker/gm_api/gm_agent_skills.ts b/src/app/service/service_worker/gm_api/gm_agent_skills.ts index 47b961793..90c8e33a0 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_skills.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_skills.ts @@ -50,7 +50,7 @@ class GMAgentSkillsApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleSkillsApi(request.params[0]); + return this.agentService.handleSkillsApi({ ...request.params[0], scriptUuid: request.script.uuid }); } } diff --git a/src/app/service/service_worker/gm_api/gm_agent_task.ts b/src/app/service/service_worker/gm_api/gm_agent_task.ts index 7de5a2880..1d942c05c 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_task.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_task.ts @@ -36,7 +36,7 @@ class GMAgentTaskApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleAgentTaskApi(request.params[0]); + return this.agentService.handleAgentTaskApi(request.params[0], request.script.uuid); } } diff --git a/src/app/service/service_worker/gm_api/gm_api.test.ts b/src/app/service/service_worker/gm_api/gm_api.test.ts index 63b937997..7cc9d14f6 100644 --- a/src/app/service/service_worker/gm_api/gm_api.test.ts +++ b/src/app/service/service_worker/gm_api/gm_api.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { type IGetSender } from "@Packages/message/server"; import { type ExtMessageSender } from "@Packages/message/types"; +import { RequestSequenceWindow, REQUEST_SEQUENCE_WINDOW_SIZE } from "@Packages/message/request_sequence_window"; import GMApi, { ConnectMatch, getConnectMatched, @@ -9,6 +10,11 @@ import GMApi, { } from "./gm_api"; import { PermissionVerifyApiGet, type ConfirmParam } from "../permission_verify"; import type { GMApiRequest } from "../types"; +import GMAgentApi from "./gm_agent"; +import GMAgentDomApi from "./gm_agent_dom"; +import GMAgentModelApi from "./gm_agent_model"; +import GMAgentOPFSApi from "./gm_agent_opfs"; +import GMAgentSkillsApi from "./gm_agent_skills"; // 触发所有 GM API 装饰器注册(与 gm_api.ts 中的 import 保持同步) import "./gm_api"; @@ -124,6 +130,367 @@ describe.concurrent("GM API 注册完整性", () => { }); }); +describe("CAT.agent.conversation identity binding", () => { + it("overrides a forged payload owner with the authenticated script", async () => { + const handleConversationApi = vi.fn().mockResolvedValue(null); + const api = { agentService: { handleConversationApi } } as unknown as GMApi; + const request = { + params: [{ action: "get", id: "conv-1", scriptUuid: "forged" }], + script: { uuid: "script-authenticated" }, + } as unknown as GMApiRequest; + + await GMAgentApi.prototype.CAT_agentConversation.call(api, request, makeSender()); + + expect(handleConversationApi).toHaveBeenCalledWith({ + action: "get", + id: "conv-1", + scriptUuid: "script-authenticated", + }); + }); + + it("binds streaming chat and background attach to the authenticated script", async () => { + const handleConversationChatFromGmApi = vi.fn().mockResolvedValue(undefined); + const handleAttachToConversationFromGmApi = vi.fn().mockResolvedValue(undefined); + const api = { + agentService: { handleConversationChatFromGmApi, handleAttachToConversationFromGmApi }, + } as unknown as GMApi; + const chatRequest = { + params: [{ conversationId: "conv-1", message: "hi", scriptUuid: "forged" }], + script: { uuid: "script-authenticated" }, + } as unknown as GMApiRequest; + const attachRequest = { + params: [{ conversationId: "conv-1", generation: "gen-1", scriptUuid: "forged" }], + script: { uuid: "script-authenticated" }, + } as unknown as GMApiRequest; + const sender = makeSender(); + + await GMAgentApi.prototype.CAT_agentConversationChat.call(api, chatRequest, sender); + await GMAgentApi.prototype.CAT_agentAttachToConversation.call(api, attachRequest, sender); + + expect(handleConversationChatFromGmApi).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: "conv-1", scriptUuid: "script-authenticated" }), + sender + ); + expect(handleAttachToConversationFromGmApi).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: "conv-1", scriptUuid: "script-authenticated" }), + sender + ); + }); +}); + +describe("CAT agent identity binding", () => { + it("overrides forged nested scriptUuid values for every agent service boundary", async () => { + const handleDomApi = vi.fn().mockResolvedValue(undefined); + const handleModelApi = vi.fn().mockResolvedValue(undefined); + const handleSkillsApi = vi.fn().mockResolvedValue(undefined); + const handleOPFSApi = vi.fn().mockResolvedValue(undefined); + const api = { + agentService: { handleDomApi, handleModelApi, handleSkillsApi, handleOPFSApi }, + } as unknown as GMApi; + const script = { uuid: "script-authenticated" }; + const sender = makeSender(); + + await GMAgentDomApi.prototype.CAT_agentDom.call( + api, + { params: [{ action: "listTabs", scriptUuid: "forged" }], script } as unknown as GMApiRequest, + sender + ); + await GMAgentModelApi.prototype.CAT_agentModel.call( + api, + { params: [{ action: "list", scriptUuid: "forged" }], script } as unknown as GMApiRequest, + sender + ); + await GMAgentSkillsApi.prototype.CAT_agentSkills.call( + api, + { params: [{ action: "list", scriptUuid: "forged" }], script } as unknown as GMApiRequest, + sender + ); + await GMAgentOPFSApi.prototype.CAT_agentOPFS.call( + api, + { params: [{ action: "list", scriptUuid: "forged" }], script } as unknown as GMApiRequest, + sender + ); + + expect(handleDomApi).toHaveBeenCalledWith({ action: "listTabs", scriptUuid: "script-authenticated" }); + expect(handleModelApi).toHaveBeenCalledWith({ action: "list", scriptUuid: "script-authenticated" }); + expect(handleSkillsApi).toHaveBeenCalledWith({ action: "list", scriptUuid: "script-authenticated" }); + expect(handleOPFSApi).toHaveBeenCalledWith({ action: "list", scriptUuid: "script-authenticated" }, sender); + }); +}); + +describe("page execution binding gate", () => { + it("rejects a page-originated request that has no binding handle", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab }); + + await expect( + api.handlerRequest({ uuid: "script-a", api: "GM_getTab", params: [], runFlag: "forged" }, sender) + ).rejects.toThrow("page execution binding is required"); + }); + + it("rejects an unknown page binding before parsing or invoking a GM API", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + const resolveBinding = vi.fn().mockReturnValue(undefined); + Object.defineProperty(api, "resolvePageExecutionBinding", { configurable: true, value: resolveBinding }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab }); + + await expect( + api.handlerRequest( + { + uuid: "forged", + api: "GM_getTab", + params: [], + runFlag: "forged", + handle: "missing", + version: 2, + sequence: 1, + }, + sender + ) + ).rejects.toThrow("page execution binding is invalid"); + expect(resolveBinding).toHaveBeenCalledTimes(1); + }); + + it("rejects a page API that is outside the binding capability set", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + const parseRequest = vi.fn(); + Object.defineProperty(api, "parseRequest", { configurable: true, value: parseRequest }); + const binding = { + handle: "handle-a", + uuid: "script-a", + envTag: "it" as const, + runFlag: "run-a", + tabId: 42, + frameId: 0, + allowedAPIs: new Set(["GM_getTab"]), + requestSequenceWindow: new RequestSequenceWindow(), + }; + Object.defineProperty(api, "resolvePageExecutionBinding", { + configurable: true, + value: vi.fn().mockReturnValue(binding), + }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab, frameId: 0 }); + + await expect( + api.handlerRequest( + { + uuid: "forged", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + handle: "handle-a", + version: 2, + sequence: 1, + }, + sender + ) + ).rejects.toThrow("API is not granted to this execution"); + expect(parseRequest).not.toHaveBeenCalled(); + }); + + it("resolves canonical identity from the binding and ignores a page-forged uuid/runFlag", async () => { + // wire 身份只有 handle;即使页面在直连 SW 的原生通道里伪造 uuid/runFlag, + // handlerRequest 也必须整体用 binding 的 canonical 值覆盖,而不是校验后放行伪造值。 + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + Object.defineProperty(api, "permissionVerify", { + configurable: true, + value: { verify: vi.fn().mockResolvedValue(undefined) }, + }); + const parseRequest = vi.fn().mockResolvedValue({ + uuid: "script-a", + api: "GM_log", + params: ["hello"], + script: { uuid: "script-a", name: "script-a" }, + }); + Object.defineProperty(api, "parseRequest", { configurable: true, value: parseRequest }); + const binding = { + handle: "handle-a", + uuid: "script-a", + envTag: "it" as const, + runFlag: "run-a", + tabId: 42, + frameId: 0, + allowedAPIs: new Set(["GM_log"]), + requestSequenceWindow: new RequestSequenceWindow(), + }; + Object.defineProperty(api, "resolvePageExecutionBinding", { + configurable: true, + value: vi.fn().mockReturnValue(binding), + }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab, frameId: 0 }); + + await expect( + api.handlerRequest( + { + uuid: "script-b", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + handle: "handle-a", + version: 2, + sequence: 1, + }, + sender + ) + ).resolves.toBe(true); + expect(parseRequest).toHaveBeenCalledWith(expect.objectContaining({ uuid: "script-a", runFlag: "run-a" })); + }); + + it("rejects a replayed page sequence before invoking the GM API", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + Object.defineProperty(api, "permissionVerify", { + configurable: true, + value: { verify: vi.fn().mockResolvedValue(undefined) }, + }); + Object.defineProperty(api, "parseRequest", { + configurable: true, + value: vi.fn().mockResolvedValue({ + uuid: "script-a", + api: "GM_log", + params: ["hello"], + script: { uuid: "script-a", name: "script-a" }, + }), + }); + const binding = { + handle: "handle-a", + uuid: "script-a", + envTag: "it" as const, + runFlag: "run-a", + tabId: 42, + frameId: 0, + allowedAPIs: new Set(["GM_log"]), + requestSequenceWindow: new RequestSequenceWindow(), + }; + Object.defineProperty(api, "resolvePageExecutionBinding", { + configurable: true, + value: vi.fn().mockReturnValue(binding), + }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab, frameId: 0 }); + + const request = { + uuid: "forged", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + handle: "handle-a", + version: 2 as const, + sequence: 1, + }; + await expect(api.handlerRequest(request, sender)).resolves.toBe(true); + await expect(api.handlerRequest(request, sender)).rejects.toThrow("page RPC sequence was already used"); + }); + + it("accepts a large forward sequence gap on a fresh binding and still rejects its replay", async () => { + // broker-only 请求(如 CAT_createBlobUrl)会消耗上下文序列号但从不到达 SW, + // 因此合法的 SW 端请求可能一次性领先超过 4096;该跳跃必须被接受, + // 但接受后的重复提交仍须被拒绝为 replay。 + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + Object.defineProperty(api, "permissionVerify", { + configurable: true, + value: { verify: vi.fn().mockResolvedValue(undefined) }, + }); + const parseRequest = vi.fn().mockResolvedValue({ + uuid: "script-a", + api: "GM_log", + params: ["hello"], + script: { uuid: "script-a", name: "script-a" }, + }); + Object.defineProperty(api, "parseRequest", { configurable: true, value: parseRequest }); + const binding = { + handle: "handle-a", + uuid: "script-a", + envTag: "it" as const, + runFlag: "run-a", + tabId: 42, + frameId: 0, + allowedAPIs: new Set(["GM_log"]), + requestSequenceWindow: new RequestSequenceWindow(), + }; + Object.defineProperty(api, "resolvePageExecutionBinding", { + configurable: true, + value: vi.fn().mockReturnValue(binding), + }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab, frameId: 0 }); + + const request = { + uuid: "forged", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + handle: "handle-a", + version: 2 as const, + sequence: REQUEST_SEQUENCE_WINDOW_SIZE + 1, + }; + + await expect(api.handlerRequest(request, sender)).resolves.toBe(true); + expect(parseRequest).toHaveBeenCalledTimes(1); + + await expect(api.handlerRequest(request, sender)).rejects.toThrow("page RPC sequence was already used"); + expect(parseRequest).toHaveBeenCalledTimes(1); + }); + + it("rejects a too-old sequence at the SW boundary once the binding advances a full window", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + Object.defineProperty(api, "permissionVerify", { + configurable: true, + value: { verify: vi.fn().mockResolvedValue(undefined) }, + }); + const parseRequest = vi.fn().mockResolvedValue({ + uuid: "script-a", + api: "GM_log", + params: ["hello"], + script: { uuid: "script-a", name: "script-a" }, + }); + Object.defineProperty(api, "parseRequest", { configurable: true, value: parseRequest }); + const binding = { + handle: "handle-a", + uuid: "script-a", + envTag: "it" as const, + runFlag: "run-a", + tabId: 42, + frameId: 0, + allowedAPIs: new Set(["GM_log"]), + requestSequenceWindow: new RequestSequenceWindow(), + }; + Object.defineProperty(api, "resolvePageExecutionBinding", { + configurable: true, + value: vi.fn().mockReturnValue(binding), + }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab, frameId: 0 }); + + const makeRequest = (sequence: number) => ({ + uuid: "forged", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + handle: "handle-a", + version: 2 as const, + sequence, + }); + + await expect(api.handlerRequest(makeRequest(1), sender)).resolves.toBe(true); + await expect(api.handlerRequest(makeRequest(REQUEST_SEQUENCE_WINDOW_SIZE + 1), sender)).resolves.toBe(true); + expect(parseRequest).toHaveBeenCalledTimes(2); + + await expect(api.handlerRequest(makeRequest(1), sender)).rejects.toThrow("replay window"); + expect(parseRequest).toHaveBeenCalledTimes(2); + }); +}); + describe("window.focus", () => { it("应同时激活标签页并将其所在窗口置于前台", async () => { const tabsUpdate = vi.fn().mockResolvedValue(undefined); diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 853ebc3aa..e0ef1f38f 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -33,6 +33,7 @@ import type { MessageRequest, NotificationMessageOption, GMApiRequest, + ServiceWorkerExecutionBinding, } from "../types"; import type { TScriptMenuRegister, TScriptMenuUnregister } from "../../queue"; import type { NotificationOptionCache } from "../utils"; @@ -361,7 +362,11 @@ export default class GMApi { private msgSender: MessageSend, private mq: IMessageQueue, private value: ValueService, - private gmExternalDependencies: IGMExternalDependencies + private gmExternalDependencies: IGMExternalDependencies, + private readonly resolvePageExecutionBinding?: ( + handle: string, + sender: IGetSender + ) => ServiceWorkerExecutionBinding | undefined ) { this.logger = LoggerCore.logger().with({ service: "runtime/gm_api" }); } @@ -373,6 +378,31 @@ export default class GMApi { // PermissionVerify.API // sendMessage from Content Script, etc async handlerRequest(data: MessageRequest, sender: IGetSender) { + const source = sender.getSender(); + const isPageRequest = typeof source?.tab?.id === "number"; + if (isPageRequest && !data.handle) { + throw new Error("page execution binding is required"); + } + if (data.handle) { + if (data.version !== 2) { + throw new Error("unsupported page execution binding version"); + } + // wire 身份只有 handle:canonical uuid/runFlag 一律由 handle + 真实 sender 解析而来, + // 页面不能预先带上这些字段来冒充身份。 + const binding = this.resolvePageExecutionBinding?.(data.handle, sender); + if (!binding) { + throw new Error("page execution binding is invalid"); + } + if (!binding.allowedAPIs.has(data.api)) { + throw new Error("API is not granted to this execution"); + } + try { + binding.requestSequenceWindow.consume(data.sequence); + } catch (error) { + throw new Error(error instanceof Error ? error.message : "page RPC sequence is invalid"); + } + data = { ...data, uuid: binding.uuid, runFlag: binding.runFlag }; + } this.logger.trace("GM API request", { api: data.api, uuid: data.uuid, param: data.params }); const api = PermissionVerifyApiGet(data.api); if (!api) { @@ -598,7 +628,7 @@ export default class GMApi { const keyValuePairs = [[key, encodeRValue(value)]] as TKeyValuePair[]; const valueSender = { runFlag: request.runFlag, - tabId: sender.getSender()?.tab?.id || -1, + tabId: sender.getSender()?.tab?.id ?? -1, }; await this.value.setValues({ uuid: request.script.uuid, id, keyValuePairs, isReplace: false, valueSender }); } @@ -611,7 +641,7 @@ export default class GMApi { const [id, keyValuePairs] = request.params; const valueSender = { runFlag: request.runFlag, - tabId: sender.getSender()?.tab?.id || -1, + tabId: sender.getSender()?.tab?.id ?? -1, }; await this.value.setValues({ uuid: request.script.uuid, id, keyValuePairs, isReplace: false, valueSender }); } @@ -1133,7 +1163,7 @@ export default class GMApi { key, name, options, - tabId: sender.getSender()?.tab?.id || -1, + tabId: sender.getSender()?.tab?.id ?? -1, frameId: sender.getSender()?.frameId, documentId: sender.getSender()?.documentId, }); @@ -1146,7 +1176,7 @@ export default class GMApi { this.mq.emit("unregisterMenuCommand", { uuid: request.script.uuid, key, - tabId: sender.getSender()?.tab?.id || -1, + tabId: sender.getSender()?.tab?.id ?? -1, frameId: sender.getSender()?.frameId, documentId: sender.getSender()?.documentId, }); diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts index dbe139672..a393a6aba 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -484,6 +484,7 @@ export default class ServiceWorkerManager { // 无视错误 } onTabRemoved(tabId); + runtime.revokePageBindingsForTab(tabId); }); } } diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 667c6643f..d18e5626a 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -20,8 +20,9 @@ import type { ResourceService } from "./resource"; import type { ScriptDAO } from "@App/app/repo/scripts"; import { LocalStorageDAO } from "@App/app/repo/localStorage"; import type { MessageConnect, TMessage } from "@Packages/message/types"; -import { obtainBlackList } from "@App/pkg/utils/utils"; -import type { CompiledResource, Resource } from "@App/app/repo/resource"; +import { getStorageName, obtainBlackList } from "@App/pkg/utils/utils"; +import { CompiledResourceNamespace, type CompiledResource, type Resource } from "@App/app/repo/resource"; +import { encodeRValue } from "@App/pkg/utils/message_value"; initTestEnv(); @@ -321,7 +322,7 @@ describe.concurrent("RuntimeService - getPageScriptMatchingResultByUrl 脚本匹 selfMetadata: { match: [] }, }); - expect(await runtime.buildAndSaveCompiledResourceFromScript(script)).toBeUndefined(); + expect(await runtime.buildCompiledResourceFromScript(script)).toBeUndefined(); }); it.concurrent("空匹配覆盖时应删除持久化 CompiledResource 并注销旧注册", async () => { @@ -344,6 +345,154 @@ describe.concurrent("RuntimeService - getPageScriptMatchingResultByUrl 脚本匹 expect(unregisterSpy).toHaveBeenCalledWith([script.uuid]); }); + it("compiled revision 应覆盖生成代码和有效执行元数据", async () => { + const { runtime, mockScriptService } = createRuntimeTestContext(); + const script = createMockScript({ + metadata: { match: ["https://www.example.com/*"], "run-at": ["document-start"], "early-start": [""] }, + }); + const scriptRunResource = createScriptRunResource(script); + mockScriptService.buildScriptRunResource.mockResolvedValue(scriptRunResource); + (runtime as any).resource = { + getScriptResourceValueByType: vi.fn().mockResolvedValue({ require: {}, "require-css": {}, resource: {} }), + }; + const first = await runtime.buildCompiledResourceFromScript(script, true); + const same = await runtime.buildCompiledResourceFromScript(script, true); + mockScriptService.buildScriptRunResource.mockResolvedValue({ + ...scriptRunResource, + value: { counter: 2 }, + }); + const changedValue = await runtime.buildCompiledResourceFromScript(script, true); + mockScriptService.buildScriptRunResource.mockResolvedValue(scriptRunResource); + const changedOriginalMetadata = await runtime.buildCompiledResourceFromScript( + { ...script, metadata: { ...script.metadata, tag: ["updated"] } }, + true + ); + mockScriptService.buildScriptRunResource.mockResolvedValue({ + ...scriptRunResource, + code: `${scriptRunResource.code}\n// changed source`, + }); + const changed = await runtime.buildCompiledResourceFromScript(script, true); + mockScriptService.buildScriptRunResource.mockResolvedValue({ + ...scriptRunResource, + metadata: { ...scriptRunResource.metadata, "run-at": ["document-end"] }, + }); + const changedMetadata = await runtime.buildCompiledResourceFromScript(script, true); + const requiredResource = (content: string): Resource => ({ + url: "https://cdn.example.com/lib.js", + content, + base64: "", + hash: { md5: "", sha1: "", sha256: "", sha384: "", sha512: "" }, + type: "require", + link: {}, + contentType: "text/javascript", + createtime: 1, + }); + const resourceRun = { + ...scriptRunResource, + resourceByType: { + require: { "https://cdn.example.com/lib.js": requiredResource("v1") }, + "require-css": {}, + resource: {}, + }, + } as ScriptRunResource; + mockScriptService.buildScriptRunResource.mockResolvedValue(resourceRun); + const firstResource = await runtime.buildCompiledResourceFromScript(script, true); + mockScriptService.buildScriptRunResource.mockResolvedValue({ + ...resourceRun, + resourceByType: { + ...resourceRun.resourceByType, + require: { "https://cdn.example.com/lib.js": requiredResource("v2") }, + }, + }); + const changedResource = await runtime.buildCompiledResourceFromScript(script, true); + + expect(first?.compiledResource.scriptRevision).toMatch(/^[a-f0-9]{64}$/); + expect(first?.apiScript.js?.[0].code).toContain(first?.compiledResource.scriptRevision); + expect(same?.compiledResource.scriptRevision).toBe(first?.compiledResource.scriptRevision); + expect(changedValue?.compiledResource.scriptRevision).toBe(first?.compiledResource.scriptRevision); + expect(changedValue?.apiScript.js?.[0].code).not.toBe(first?.apiScript.js?.[0].code); + expect(changedOriginalMetadata?.compiledResource.scriptRevision).not.toBe(first?.compiledResource.scriptRevision); + expect(changed?.compiledResource.scriptRevision).not.toBe(first?.compiledResource.scriptRevision); + expect(changedMetadata?.compiledResource.scriptRevision).not.toBe(changed?.compiledResource.scriptRevision); + expect(changedResource?.compiledResource.scriptRevision).not.toBe(firstResource?.compiledResource.scriptRevision); + }); + + it("normal(非 early-start)脚本注册代码同样携带当前 compiled revision", async () => { + // 上面那个用例特意带 early-start metadata;早期路径已经会在拿到 revision 后重新编译一次, + // 因此不能覆盖普通脚本这条路径。这里用默认(无 early-start)fixture 单独验证。 + const { runtime, mockScriptService } = createRuntimeTestContext(); + const script = createMockScript(); + const scriptRunResource = createScriptRunResource(script); + mockScriptService.buildScriptRunResource.mockResolvedValue(scriptRunResource); + (runtime as any).resource = { + getScriptResourceValueByType: vi.fn().mockResolvedValue({ require: {}, "require-css": {}, resource: {} }), + }; + + const candidate = await runtime.buildCompiledResourceFromScript(script, true); + + expect(candidate?.compiledResource.scriptRevision).toMatch(/^[a-f0-9]{64}$/); + expect(candidate?.apiScript.js?.[0].code).toContain(candidate?.compiledResource.scriptRevision); + }); + + it("browser registration failure must not publish a newly compiled revision", async () => { + const { runtime, mockScriptService } = createRuntimeTestContext(); + const script = createMockScript({ selfMetadata: { match: ["https://changed.example.com/*"] } }); + const previousScript = { ...script, selfMetadata: undefined }; + await runtime.applyScriptMatchInfo(createScriptRunResource(previousScript)); + (runtime as any).pageLoadCaches.set(script.uuid, { scriptCacheKey: "stale" }); + mockScriptService.buildScriptRunResource.mockResolvedValue(createScriptRunResource(script)); + (runtime as any).isUserScriptsAvailable = true; + (runtime as any).resource = { + getScriptResourceValueByType: vi.fn().mockResolvedValue({ require: {}, "require-css": {}, resource: {} }), + }; + vi.spyOn(chrome.userScripts, "getScripts").mockResolvedValue([{ id: script.uuid }] as any); + vi.spyOn(chrome.userScripts, "update").mockRejectedValue(new Error("registration failed")); + const saveSpy = vi.spyOn(runtime.compiledResourceDAO, "save").mockResolvedValue({} as CompiledResource); + + await runtime.updateResourceOnScriptChange(script); + + expect(saveSpy).not.toHaveBeenCalled(); + expect((runtime as any).pageLoadCaches.has(script.uuid)).toBe(false); + expect(runtime.getPageScriptMatchingResultByUrl("https://www.example.com/").has(script.uuid)).toBe(false); + expect(runtime.getPageScriptMatchingResultByUrl("https://changed.example.com/").has(script.uuid)).toBe(true); + }); + + it("script updates should refresh URL matching before compiled registration work", async () => { + const { runtime, mockScriptService } = createRuntimeTestContext(); + const script = createMockScript({ selfMetadata: { match: ["https://changed.example.com/*"] } }); + const previousScript = { ...script, selfMetadata: undefined }; + await runtime.applyScriptMatchInfo(createScriptRunResource(previousScript)); + let resolveScriptRunResource!: (resource: ScriptRunResource) => void; + mockScriptService.buildScriptRunResource.mockReturnValue( + new Promise((resolve) => { + resolveScriptRunResource = resolve; + }) + ); + + const update = runtime.updateResourceOnScriptChange(script); + + expect(runtime.getPageScriptMatchingResultByUrl("https://changed.example.com/").has(script.uuid)).toBe(true); + resolveScriptRunResource(createScriptRunResource(script)); + await update; + }); + + it("successful browser registration publishes the compiled revision afterward", async () => { + const { runtime, mockScriptService } = createRuntimeTestContext(); + const script = createMockScript(); + mockScriptService.buildScriptRunResource.mockResolvedValue(createScriptRunResource(script)); + (runtime as any).isUserScriptsAvailable = true; + vi.spyOn(chrome.userScripts, "getScripts").mockResolvedValue([] as any); + const saveSpy = vi.spyOn(runtime.compiledResourceDAO, "save").mockResolvedValue({} as CompiledResource); + vi.spyOn(chrome.userScripts, "register").mockImplementation(async () => { + expect(saveSpy).not.toHaveBeenCalled(); + }); + + await runtime.updateResourceOnScriptChange(script); + + expect(saveSpy).toHaveBeenCalledTimes(1); + expect(saveSpy.mock.calls[0][0].scriptRevision).toMatch(/^[a-f0-9]{64}$/); + }); + describe.concurrent("includeDisabled 选项", () => { it.concurrent("当 includeDisabled=false 时不返回禁用脚本;当 includeDisabled=true 时返回禁用脚本", async () => { // Arrange @@ -510,6 +659,7 @@ describe.concurrent("RuntimeService - getPageScriptMatchingResultByUrl 脚本匹 name: script.name, flag: "", uuid: script.uuid, + scriptRevision: "0".repeat(64), require: [], matches: ["https://www.example.com/*"], includeGlobs: [], @@ -546,6 +696,7 @@ describe.concurrent("RuntimeService - getPageScriptMatchingResultByUrl 脚本匹 runtime.compiledResourceDAO = mockCompiledResourceDAO as any; (runtime as any).resource = mockResourceService; (runtime as any).value = mockValueService; + vi.spyOn(runtime, "buildCompiledResourceFromScript").mockResolvedValue({ compiledResource } as any); return { runtime, @@ -599,6 +750,72 @@ describe.concurrent("RuntimeService - getPageScriptMatchingResultByUrl 脚本匹 await runtime.getScriptsForTab({ url: pageUrl, tabId: undefined, frameId: undefined }); expect(mockCompiledResourceDAO.gets).toHaveBeenCalledTimes(2); }); + + it("本地资源注册失败时保留旧页面缓存", async () => { + const { + runtime, + script, + scriptRes, + compiledResource, + mockCompiledResourceDAO, + mockResourceService, + mockValueService, + } = createCacheTestContext(); + script.metadata.require = ["file:///tmp/required.js"]; + scriptRes.metadata.require = ["file:///tmp/required.js"]; + await runtime.applyScriptMatchInfo(scriptRes); + mockValueService.getScriptValue.mockResolvedValue({ latest: "value" }); + const oldResource = { + url: "file:///tmp/required.js", + content: "old resource", + base64: "", + hash: { sha512: "old hash" }, + type: "require", + link: { [script.uuid]: true }, + contentType: "text/javascript", + createtime: 1, + } as Resource; + const newResource = { ...oldResource, content: "new resource", hash: { sha512: "new hash" } } as Resource; + (mockResourceService.getScriptResourceValueByType as any).mockResolvedValue({ + require: { [oldResource.url]: oldResource }, + "require-css": {}, + resource: {}, + }); + (mockResourceService as any).getResourceModel = vi.fn().mockResolvedValue(oldResource); + (mockResourceService as any).updateResource = vi.fn().mockResolvedValue(newResource); + mockCompiledResourceDAO.get.mockResolvedValue(compiledResource); + vi.spyOn(chrome.userScripts, "getScripts").mockResolvedValue([ + { id: script.uuid, js: [{ code: "old wrapper" }] } as chrome.userScripts.RegisteredUserScript, + ] as any); + const browserUpdateSpy = vi + .spyOn(chrome.userScripts, "update") + .mockRejectedValue(new Error("registration failed")); + + await runtime.getScriptsForTab({ url: pageUrl, tabId: undefined, frameId: undefined }); + + const cache = (runtime as any).pageLoadCaches.get(script.uuid); + expect(cache.resourceByType.require[oldResource.url].content).toBe("old resource"); + expect(cache.localResources[0].sha512).toBe("old hash"); + + browserUpdateSpy.mockResolvedValue(undefined as any); + const result = await runtime.getScriptsForTab({ url: pageUrl, tabId: undefined, frameId: undefined }); + + const updatedCache = (runtime as any).pageLoadCaches.get(script.uuid); + expect(updatedCache.resourceByType.require[oldResource.url].content).toBe("new resource"); + expect(updatedCache.localResources[0].sha512).toBe("new hash"); + expect(result!.injectScriptList[0].value).toEqual({ latest: "value" }); + expect(browserUpdateSpy).toHaveBeenLastCalledWith( + expect.arrayContaining([ + expect.objectContaining({ js: [expect.objectContaining({ code: expect.stringContaining("new resource") })] }), + ]) + ); + expect(mockCompiledResourceDAO.save).toHaveBeenCalledWith( + expect.objectContaining({ scriptRevision: expect.not.stringMatching(/^0+$/) }) + ); + expect(result!.injectScriptList[0].scriptRevision).toBe( + mockCompiledResourceDAO.save.mock.calls.at(-1)?.[0].scriptRevision + ); + }); }); describe.concurrent("黑名單測試", async () => { @@ -861,6 +1078,7 @@ describe("page-load resource cache", () => { const scriptRes = _createScriptRunResource(_createMockScript()); const cache = { scriptCacheKey: "cache-key", + scriptRevision: "compiled-revision", code: "console.log(1)", scriptUrlPatterns: [], originalUrlPatterns: null, @@ -881,6 +1099,7 @@ describe("page-load resource cache", () => { expect(pageInfo.resourceByType["require-css"][sharedKey].content).toBe("css content"); expect(pageInfo.resourceByType.resource[sharedKey].content).toBe("resource content"); expect(pageInfo.resource[sharedKey].content).toBe("resource content"); + expect(pageInfo.scriptRevision).toBe("compiled-revision"); }); }); @@ -891,7 +1110,7 @@ describe("getScriptsForTab 附加边界场景", () => { /** 带完整 mock 的测试上下文,可按需覆盖各层依赖 */ const createFullContext = (scriptOverrides: Partial