From c0444dc1106e44e1b536afe9041f8b4067253918 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:20:41 +0900 Subject: [PATCH 001/103] =?UTF-8?q?=F0=9F=94=92=20harden=20MAIN-world=20GM?= =?UTF-8?q?=20RPC=20capability=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/server.ts | 12 +- src/app/repo/scripts.ts | 7 + .../service/content/create_context.test.ts | 34 +++ src/app/service/content/create_context.ts | 18 +- src/app/service/content/exec_script.ts | 10 +- .../service/content/gm_api/cat_agent.test.ts | 10 + src/app/service/content/gm_api/cat_agent.ts | 138 ++++++----- .../service/content/gm_api/cat_agent_task.ts | 34 ++- src/app/service/content/gm_api/gm_api.test.ts | 106 ++++++++- src/app/service/content/gm_api/gm_api.ts | 69 ++++-- src/app/service/content/gm_api/gm_context.ts | 10 +- src/app/service/content/gm_api/gm_xhr.ts | 2 +- src/app/service/content/listener_manager.ts | 37 ++- src/app/service/content/page_rpc.test.ts | 138 +++++++++++ src/app/service/content/page_rpc.ts | 217 ++++++++++++++++++ .../service/content/script_executor.test.ts | 67 ++++++ src/app/service/content/script_executor.ts | 69 ++++-- src/app/service/content/scripting.ts | 47 +++- src/app/service/content/utils.ts | 9 +- .../service_worker/gm_api/gm_api.test.ts | 30 +++ .../service/service_worker/gm_api/gm_api.ts | 28 ++- src/app/service/service_worker/index.ts | 1 + .../service/service_worker/runtime.test.ts | 53 +++++ src/app/service/service_worker/runtime.ts | 87 ++++++- src/app/service/service_worker/types.ts | 17 ++ src/app/service/service_worker/utils.test.ts | 33 ++- 26 files changed, 1133 insertions(+), 150 deletions(-) create mode 100644 src/app/service/content/page_rpc.test.ts create mode 100644 src/app/service/content/page_rpc.ts diff --git a/packages/message/server.ts b/packages/message/server.ts index 3df109f80..4a62f824f 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -293,7 +293,8 @@ 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(); @@ -308,7 +309,7 @@ export function forwardMessage( return sendMessage(senderTo, prefix + "/" + path, params); } }; - receiverFrom.on(path, (params, sender) => { + const process = (params: any, sender: IGetSender) => { if (middleware) { // 此处是为了处理CustomEventMessage的同步消息情况 const resp = middleware(params, sender) as any; @@ -324,5 +325,12 @@ export function forwardMessage( } } return handler(params, sender); + }; + receiverFrom.on(path, (params, sender) => { + if (!transform) return process(params, sender); + const transformed = transform(params, sender); + return transformed instanceof Promise + ? transformed.then((data) => process(data, sender)) + : process(transformed, sender); }); } diff --git a/src/app/repo/scripts.ts b/src/app/repo/scripts.ts index cf99f6de4..2171c4710 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,12 @@ export interface ScriptRunResource extends Script { resourceByType?: ScriptResourceByType; metadata: SCMetadata; // 经自定义覆盖的 Metadata originalMetadata: SCMetadata; // 原本的 Metadata (目前只需要 match, include, exclude) + /** 页面执行环境绑定的能力句柄。 */ + executionHandle?: string; + /** 执行脚本所在的页面环境。 */ + executionEnvTag?: ScriptEnvTag; + /** 页面执行绑定使用的值更新关联标识。 */ + executionRunFlag?: string; } /** diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 058e59c95..a5eb5bc00 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -2,6 +2,7 @@ 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"; type AnyRecord = Record; @@ -304,6 +305,39 @@ describe("shouldFnBind", () => { }); describe("createContext: capability and lifecycle contract", () => { + it("uses the service-worker execution run flag for value acknowledgments", () => { + const script = { + ...createScriptInfo({ grant: ["GM_getValue"] }), + executionRunFlag: "canonical-run", + } as TScriptInfo; + const context = createContext( + script, + { script: { name: "create-context-test" }, scriptMetaStr: "" }, + "vitest", + undefined as any, + undefined as any, + new Set(["GM_getValue"]) + ); + + expect((context as unknown as { runFlag: string }).runFlag).toBe("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 (this: 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; + } + }); + const resourceGrantChecks: Array<{ grant: string; read: (context: ReturnType) => unknown; diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 44d4ccf20..b9877fd6d 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -9,6 +9,14 @@ import { ListenerManager } from "./listener_manager"; import { createGMBase } from "./gm_api/gm_api"; import { attachNavigateHandler, type UrlChangeEvent } from "./gm_api/navigation_handle"; +const nativeReflectApply = Reflect.apply; + +const createCapability = (api: (...args: any[]) => any, receiver: object) => { + const capability = (...args: any[]) => nativeReflectApply(api, receiver, args); + Object.defineProperty(capability, "name", { configurable: true, value: `bound ${api.name}` }); + return capability; +}; + // 不要使用 {}, 改使用 Object.create(null) - 避免在页面生成沙盒时,受到 Object.prototype 被注入的影响 // 构建沙盒上下文 @@ -41,7 +49,7 @@ export const createContext = ( scriptRes, valueChangeListener, EE, - runFlag: uuidv4(), + runFlag: scriptRes.executionRunFlag || uuidv4(), eventId: 10000, GM: GM, GM_info: GMInfo, @@ -73,7 +81,7 @@ export const createContext = ( if (grantSet.has(grant)) return true; // 重复的@grant,略过 (返回 true 表示 @grant 存在) grantSet.add(grant); for (const { fnKey, api, param } of s) { - grantedAPIs[fnKey] = api.bind(context); + grantedAPIs[fnKey] = createCapability(api, context); const depend = param?.depend; if (depend) { for (const grant of depend) { @@ -185,14 +193,14 @@ const materializeDescriptor = (descriptor: PropertyDescriptor, receiver: Descrip if (typeof descriptor.value !== "function" || isConstructorOrInterface(descriptor.value)) return descriptor; return { ...descriptor, - value: bindFn.call(descriptor.value, receiver), + value: nativeReflectApply(bindFn, 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 ? nativeReflectApply(bindFn, descriptor.get, [receiver]) : undefined, + set: descriptor.set ? nativeReflectApply(bindFn, descriptor.set, [receiver]) : undefined, }; }; diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index 6c5b93730..dc76d462b 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -92,7 +92,15 @@ export default class ExecScript { }; // 早期启动的脚本,处理GM API - updateEarlyScriptGMInfo(envInfo: GMInfoEnv) { + updateEarlyScriptGMInfo(envInfo: GMInfoEnv, scriptInfo?: TScriptInfo) { + if (scriptInfo?.executionHandle && scriptInfo.executionEnvTag) { + this.scriptRes.executionHandle = scriptInfo.executionHandle; + this.scriptRes.executionEnvTag = scriptInfo.executionEnvTag; + this.scriptRes.executionRunFlag = scriptInfo.executionRunFlag; + if (this.sandboxContext && scriptInfo.executionRunFlag) { + this.sandboxContext.runFlag = scriptInfo.executionRunFlag; + } + } let GM_info; if (this.sandboxContext) { // 触发loadScriptResolve 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..6bec37b49 100644 --- a/src/app/service/content/gm_api/cat_agent.test.ts +++ b/src/app/service/content/gm_api/cat_agent.test.ts @@ -65,6 +65,16 @@ 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"); + }); + it("内置 /new 命令清空消息历史", async () => { const { instance, gmSendMessage } = createInstance(); diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index 8f63e06cd..fbdc67ee2 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -19,6 +19,8 @@ import type { } from "@App/app/service/agent/core/types"; import { getTextContent } from "@App/app/service/agent/core/content_utils"; +const nativeReflectApply = Reflect.apply; + export type ConversationStreamChunk = | StreamChunk | { @@ -81,13 +83,31 @@ 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; +}; + +const conversationStates = new WeakMap(); +const weakMapGet = WeakMap.prototype.get; +const weakMapSet = WeakMap.prototype.set; + +const getConversationState = (instance: ConversationInstance): ConversationPrivateState => { + const state = nativeReflectApply(weakMapGet, conversationStates, [instance]); + if (!state) throw new Error("conversation instance is invalid"); + return state; +}; + 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<{ role: MessageRole; content: MessageContent; @@ -95,13 +115,11 @@ export class ConversationInstance { 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,10 +127,18 @@ export class ConversationInstance { cache?: boolean, background?: boolean ) { + const state: ConversationPrivateState = { + conv, + gmSendMessage, + gmConnect, + scriptUuid, + commandHandlers: new Map(), + cache, + systemPrompt: system, + background: background || false, + }; + nativeReflectApply(weakMapSet, conversationStates, [this, state]); this.ephemeral = ephemeral || false; - this.background = background || false; - this.cache = cache; - this.systemPrompt = system; if (initialTools) { for (const tool of initialTools) { this.toolHandlers.set(tool.name, tool.handler); @@ -121,7 +147,7 @@ export class ConversationInstance { } // 注册内置 /new 命令 - this.commandHandlers.set("/new", async () => { + state.commandHandlers.set("/new", async () => { await this.clear(); return "对话已清空"; }); @@ -129,21 +155,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 getConversationState(this).conv.id; } get title() { - return this.conv.title; + return getConversationState(this).conv.title; } get modelId() { - return this.conv.modelId; + return getConversationState(this).conv.modelId; } // 发送消息并获取回复(内置 tool calling 循环) @@ -154,6 +180,7 @@ export class ConversationInstance { if (cmdResult !== undefined) return cmdResult; const { toolDefs, handlers } = this.mergeTools(options?.tools); + const state = getConversationState(this); // ephemeral 模式:追加 user message 到内存历史 if (this.ephemeral) { @@ -162,27 +189,27 @@ export class ConversationInstance { // 通过 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) { connectParams.ephemeral = true; connectParams.messages = this.messageHistory; - connectParams.system = this.systemPrompt; - connectParams.modelId = this.conv.modelId; + 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); @@ -221,6 +248,7 @@ export class ConversationInstance { } const { toolDefs, handlers } = this.mergeTools(options?.tools); + const state = getConversationState(this); // ephemeral 模式:追加 user message 到内存历史 if (this.ephemeral) { @@ -228,27 +256,27 @@ export class ConversationInstance { } 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) { connectParams.ephemeral = true; connectParams.messages = this.messageHistory; - connectParams.system = this.systemPrompt; - connectParams.modelId = this.conv.modelId; + 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 @@ -274,7 +302,7 @@ export class ConversationInstance { const parsed = this.parseCommand(content); if (!parsed) return undefined; - const handler = this.commandHandlers.get(parsed.name); + const handler = getConversationState(this).commandHandlers.get(parsed.name); if (!handler) return undefined; const result = await handler(parsed.args, this); @@ -302,11 +330,12 @@ export class ConversationInstance { // 获取对话历史 async getMessages(): Promise { + const state = getConversationState(this); if (this.ephemeral) { // ephemeral 模式:从内存历史转换为 ChatMessage 格式 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 +343,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 || []; @@ -331,32 +360,35 @@ export class ConversationInstance { this.messageHistory = []; return; } - await this.gmSendMessage("CAT_agentConversation", [ + const state = getConversationState(this); + 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 = getConversationState(this); + 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 = getConversationState(this); + const conn = await state.gmConnect("CAT_agentAttachToConversation", [ + { conversationId: state.conv.id, generation: state.conv.generation, scriptUuid: state.scriptUuid }, ]); return this.processStream(conn, new Map()); } @@ -895,8 +927,8 @@ function buildInstance( ): ConversationInstance { return new ConversationInstance( conv, - ctx.sendMessage.bind(ctx), - ctx.connect.bind(ctx), + (api, params) => nativeReflectApply(ctx.sendMessage, ctx, [api, params]), + (api, params) => nativeReflectApply(ctx.connect, ctx, [api, params]), ctx.scriptRes?.uuid || "", options?.tools, options?.commands, 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..83fe76a40 100644 --- a/src/app/service/content/gm_api/cat_agent_task.ts +++ b/src/app/service/content/gm_api/cat_agent_task.ts @@ -17,8 +17,20 @@ 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 WeakMap(); +const nativeReflectApply = Reflect.apply; +const weakMapGet = WeakMap.prototype.get; +const weakMapSet = WeakMap.prototype.set; + +const getListenerRecords = (owner: object): ListenerRecord[] => { + let records = nativeReflectApply(weakMapGet, listenerMaps, [owner]); + if (!records) { + records = []; + nativeReflectApply(weakMapSet, listenerMaps, [owner, records]); + } + return records; +}; // CAT.agent.task API,注入到脚本上下文 export default class CATAgentTaskApi { @@ -112,7 +124,8 @@ export default class CATAgentTaskApi { }; ctx.EE.on(eventName, wrappedCallback); - listenerMap.set(listenerId, { eventName, callback: wrappedCallback }); + const records = getListenerRecords(ctx); + records[records.length] = { id: listenerId, eventName, callback: wrappedCallback }; return listenerId; } @@ -122,10 +135,19 @@ export default class CATAgentTaskApi { const ctx = this as unknown as GMBaseContext; if (!ctx.EE) return; - const entry = listenerMap.get(listenerId); - if (entry) { + const records = getListenerRecords(ctx); + let index = -1; + for (let i = 0; i < records.length; i += 1) { + if (records[i]?.id === listenerId) { + index = i; + break; + } + } + if (index >= 0) { + const entry = records[index]; + for (let i = index + 1; i < records.length; i += 1) records[i - 1] = records[i]; + records.length -= 1; ctx.EE.off(entry.eventName, entry.callback); - listenerMap.delete(listenerId); } } } 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..48f9cbce2 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -3,7 +3,7 @@ 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"; @@ -32,6 +32,86 @@ const envInfo: GMInfoEnv = { isIncognito: false, }; +describe("early-start page RPC", () => { + 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", + envTag: "it", + }), + }); + }); + + it("uses the authoritative run flag for early-start async value acknowledgments", async () => { + const script = { + ...scriptRes, + uuid: "early-start-value-script", + 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 (this: any) { + return this.GM.setValue("a", 123); + } as unknown as ScriptFunc; + const result = exec.exec(); + await Promise.resolve(); + expect(mockSendMessage).not.toHaveBeenCalled(); + + exec.updateEarlyScriptGMInfo(envInfo, { + ...script, + executionHandle: "page-binding", + executionEnvTag: "it", + executionRunFlag: "canonical-run", + }); + 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(); + }); +}); + const makeResource = (url: string, content: string, type: "require" | "require-css" | "resource") => ({ url, content, @@ -1137,6 +1217,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)=>{ @@ -1211,6 +1294,27 @@ 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("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("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("snapshot")).toEqual({ nested: { value: 1 } }); + }); it.concurrent("异步GM.setValue,等待回调", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM.getValue", "GM.setValue"]; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index d9c52c8e4..450f23ced 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -12,6 +12,7 @@ import type { MessageRequest, } from "@App/app/service/service_worker/types"; import { base64ToBlob, randNum, randomMessageFlag, strToBase64 } from "@App/pkg/utils/utils"; +import { uuidv4 } from "@App/pkg/utils/uuid"; import LoggerCore from "@App/app/logger/core"; import EventEmitter from "eventemitter3"; import GMContext from "./gm_context"; @@ -59,7 +60,21 @@ let valChangeCounterId = 0; let valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; -const valueChangePromiseMap = new Map(); +const valueChangePromiseMap: Record void> = Object.create(null); + +const notificationTagMaps = new WeakMap>(); +const nativeReflectApply = Reflect.apply; +const weakMapGet = WeakMap.prototype.get; +const weakMapSet = WeakMap.prototype.set; + +const getNotificationTagMap = (owner: object): Map => { + let map = nativeReflectApply(weakMapGet, notificationTagMaps, [owner]); + if (!map) { + map = new Map(); + nativeReflectApply(weakMapSet, notificationTagMaps, [owner, map]); + } + return map; +}; const execEnvInit = (execEnv: GMApi) => { if (!execEnv.contentEnvKey) { @@ -138,12 +153,21 @@ class GM_Base implements IGM_Base { } let ret; try { - ret = await sendMessage(this.message, `${this.prefix}/runtime/gmApi`, { + const request = { uuid: this.scriptRes.uuid, api, params, runFlag: this.runFlag, - } as MessageRequest); + ...(this.scriptRes.executionHandle && this.scriptRes.executionEnvTag + ? { + version: 1 as const, + requestId: uuidv4(), + handle: this.scriptRes.executionHandle, + envTag: this.scriptRes.executionEnvTag, + } + : {}), + } as MessageRequest; + 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,14 +181,27 @@ class GM_Base implements IGM_Base { // 长连接使用,connect只用于接受消息,不发送消息 @GMContext.protected() - public connect(api: string, params: any[]) { + public async connect(api: string, params: any[]) { if (!this.message || !this.scriptRes) return new Promise(() => {}); - return connect(this.message, `${this.prefix}/runtime/gmApi`, { + if (this.loadScriptPromise) { + await this.loadScriptPromise; + } + if (!this.message || !this.scriptRes) return new Promise(() => {}); + const request = { uuid: this.scriptRes.uuid, api, params, runFlag: this.runFlag, - } as MessageRequest); + ...(this.scriptRes.executionHandle && this.scriptRes.executionEnvTag + ? { + version: 1 as const, + requestId: uuidv4(), + handle: this.scriptRes.executionHandle, + envTag: this.scriptRes.executionEnvTag, + } + : {}), + } as MessageRequest; + return connect(this.message, `${this.prefix}/runtime/gmApi`, request); } @GMContext.protected() @@ -176,9 +213,9 @@ class GM_Base implements IGM_Base { const valueStore = scriptRes.value; const remote = sender.runFlag !== this.runFlag; if (!remote && id) { - const fn = valueChangePromiseMap.get(id); + const fn = valueChangePromiseMap[id]; if (fn) { - valueChangePromiseMap.delete(id); + delete valueChangePromiseMap[id]; fn(); } } @@ -195,7 +232,9 @@ class GM_Base implements IGM_Base { } else { valueStore[key] = value; } - this.valueChangeListener.execute(key, oldValue, value, remote, sender.tabId); + 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); } } } @@ -210,11 +249,6 @@ class GM_Base implements IGM_Base { // GMApi 定义 外部用API函数。不使用@protected export default class GMApi extends GM_Base { - /** - * - */ - notificationTagMap?: Map; - constructor( public prefix: string, public message: Message, @@ -232,7 +266,6 @@ export default class GMApi extends GM_Base { scriptRes, valueChangeListener, EE, - notificationTagMap: new Map(), eventId: 0, setInvalidContext() { if (invalid) return; @@ -290,7 +323,7 @@ export default class GMApi extends GM_Base { } const id = `${valChangeRandomId}::${++valChangeCounterId}`; if (promise) { - valueChangePromiseMap.set(id, promise); + valueChangePromiseMap[id] = promise; } if (value === undefined) { delete a.scriptRes.value[key]; @@ -320,7 +353,7 @@ export default class GMApi extends GM_Base { } const id = `${valChangeRandomId}::${++valChangeCounterId}`; if (promise) { - valueChangePromiseMap.set(id, promise); + valueChangePromiseMap[id] = promise; } const valueStore = a.scriptRes.value; const keyValuePairs = [] as [string, REncoded][]; @@ -1243,7 +1276,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") { diff --git a/src/app/service/content/gm_api/gm_context.ts b/src/app/service/content/gm_api/gm_context.ts index 25bfaa109..c604b7019 100644 --- a/src/app/service/content/gm_api/gm_context.ts +++ b/src/app/service/content/gm_api/gm_context.ts @@ -1,17 +1,17 @@ import type { ApiParam, ApiValue } from "../types"; -const apis: Map = new Map(); +const apis: Record = Object.create(null); export function GMContextApiGet(name: string): ApiValue[] | undefined { // 回传 Api 列表 - return apis.get(name); + return apis[name]; } function GMContextApiSet(grant: string, fnKey: string, api: any, param: ApiParam): void { // 一个 @grant 可以扩充多个 API 函数 - let m: ApiValue[] | undefined = apis.get(grant); - if (!m) apis.set(grant, (m = [])); - m.push({ fnKey, api, param }); + let m: ApiValue[] | undefined = apis[grant]; + if (!m) apis[grant] = m = []; + m[m.length] = { fnKey, api, param }; } export const protect: { [key: string]: any } = {}; diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index d3b147f88..598406983 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -509,13 +509,13 @@ export function GM_xmlhttpRequest( finalResultBuffers = null; finalResultText = null; const xhrResponse = makeXHRCallbackParam?.(data) ?? {}; - details.onloadend?.(xhrResponse); if (errorOccur === null) { retPromiseResolve?.(xhrResponse); } else { retPromiseReject?.(errorOccur); } refCleanup?.(); + details.onloadend?.(xhrResponse); } }; doAbort = (data: TXhrCallBackArg) => { diff --git a/src/app/service/content/listener_manager.ts b/src/app/service/content/listener_manager.ts index cc4de0f9e..39f180dda 100644 --- a/src/app/service/content/listener_manager.ts +++ b/src/app/service/content/listener_manager.ts @@ -2,46 +2,43 @@ // 删除会较慢但执行会较快 export class ListenerManager void> { private counterId = 0; - private readonly listeners = new Map>(); + private readonly listeners: Array<{ key: string; id: number; handler: T }> = []; public add(key: string, handler: T): number { const id = ++this.counterId; - let listenrMap = this.listeners.get(key); - if (!listenrMap) { - this.listeners.set(key, (listenrMap = new Map())); - } - listenrMap.set(id, handler); + this.listeners[this.listeners.length] = { key, 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); + for (let i = 0; i < this.listeners.length; ) { + const listener = this.listeners[i]; + if (listener?.key !== key) { + i += 1; + continue; } + const listenerId = listener.id; + listener.handler?.(key, ...args); + if (this.listeners[i]?.id === listenerId) i += 1; } } 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; + for (let i = 0; i < this.listeners.length; i += 1) { + if (this.listeners[i]?.id !== idNum) continue; + for (let j = i + 1; j < this.listeners.length; j += 1) { + this.listeners[j - 1] = this.listeners[j]; } + this.listeners.length -= 1; + return true; } } return false; } public clear(): void { - for (const [_key, handlers] of this.listeners) { - handlers.clear(); - } - this.listeners.clear(); + this.listeners.length = 0; } } 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..39cb74802 --- /dev/null +++ b/src/app/service/content/page_rpc.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { getPageRpcAllowedAPIs, 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", + "CAT_fetchDocument", + ]) + ); + 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("accepts a request for the active execution binding and clones parameters", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"], undefined, "canonical-run"); + const params = { nested: { value: 1 } }; + + const request = validatePageGMRequest( + { + version: 1, + requestId: "request-a", + handle, + uuid: "script-a", + envTag: "it", + api: "GM_getValue", + params: [params], + }, + registry + ); + + expect(request).toEqual({ + version: 1, + requestId: "request-a", + handle, + uuid: "script-a", + envTag: "it", + api: "GM_getValue", + params: [params], + runFlag: "canonical-run", + }); + expect(request.params[0]).not.toBe(params); + }); + + it("rejects an unknown, stale, or mismatched execution binding", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + + expect(() => + validatePageGMRequest( + { + version: 1, + requestId: "a", + handle: "missing", + uuid: "script-a", + envTag: "it", + api: "GM_getValue", + params: [], + }, + registry + ) + ).toThrow(PageRpcError); + + registry.revoke(handle); + expect(() => + validatePageGMRequest( + { version: 1, requestId: "b", handle, uuid: "script-a", envTag: "it", api: "GM_getValue", params: [] }, + registry + ) + ).toThrow(PageRpcError); + + const activeHandle = registry.register("script-a", "it", ["GM_getValue"]); + expect(() => + validatePageGMRequest( + { + version: 1, + requestId: "c", + handle: activeHandle, + uuid: "script-b", + envTag: "it", + api: "GM_getValue", + params: [], + }, + registry + ) + ).toThrow(PageRpcError); + }); + + it("rejects APIs outside the binding and packets with accessors or unsupported values", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + const accessorRequest = { + version: 1, + requestId: "a", + handle, + uuid: "script-a", + envTag: "it", + api: "GM_getValue", + params: [], + }; + Object.defineProperty(accessorRequest, "api", { get: () => "GM_getValue" }); + + expect(() => validatePageGMRequest(accessorRequest, registry)).toThrow(PageRpcError); + expect(() => + validatePageGMRequest( + { version: 1, requestId: "b", handle, uuid: "script-a", envTag: "it", api: "GM_setValue", params: [] }, + registry + ) + ).toThrow(PageRpcError); + expect(() => + validatePageGMRequest( + { + version: 1, + requestId: "c", + handle, + uuid: "script-a", + envTag: "it", + api: "GM_getValue", + params: [() => undefined], + }, + registry + ) + ).toThrow(PageRpcError); + }); +}); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts new file mode 100644 index 000000000..2c6e18e9b --- /dev/null +++ b/src/app/service/content/page_rpc.ts @@ -0,0 +1,217 @@ +import { uuidv4 } from "@App/pkg/utils/uuid"; +import type { ScriptEnvTag } from "@Packages/message/consts"; +import { getGrantCandidates } from "./gm_api/grant"; + +export const PAGE_RPC_VERSION = 1 as const; +const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; + +export type PageExecutionBinding = { + readonly handle: string; + readonly uuid: string; + readonly envTag: ScriptEnvTag; + readonly allowedAPIs: ReadonlySet; + readonly runFlag: string; + active: boolean; +}; + +export type PageGMRequest = { + readonly version: typeof PAGE_RPC_VERSION; + readonly requestId: string; + readonly handle: string; + readonly uuid: string; + readonly envTag: ScriptEnvTag; + readonly api: string; + readonly params: readonly unknown[]; + readonly runFlag: string; +}; + +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: ["CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], + "GM.xmlhttpRequest": ["CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], + "GM.xmlHttpRequest": ["CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], +}; + +// ScriptingRuntime does not load the GM implementation module, so mirror its small dependency graph here. +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_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"], +}; + +export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { + const allowed = new Set(); + const visited = new Set(); + const visitGrant = (grant: string): void => { + for (const candidate of getGrantCandidates(grant)) { + if (visited.has(candidate)) continue; + visited.add(candidate); + allowed.add(candidate); + for (const api of INTERNAL_APIS_BY_GRANT[candidate] || []) allowed.add(api); + for (const dependency of API_DEPENDENCIES[candidate] || []) visitGrant(dependency); + } + }; + for (const grant of grants) visitGrant(grant); + return [...allowed]; +}; + +export class PageRpcError extends Error { + constructor(message: string) { + super(message); + this.name = "PageRpcError"; + } +} + +const ownData = (value: object, key: PropertyKey): unknown => { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + throw new PageRpcError(`page RPC field ${String(key)} must be a data property`); + } + return descriptor.value; +}; + +const assertDataOnly = (value: unknown, seen: Set): void => { + if (value === null || typeof value !== "object") return; + if (seen.has(value)) return; + seen.add(value); + + let keys: (string | symbol)[]; + try { + keys = Reflect.ownKeys(value); + } catch { + throw new PageRpcError("page RPC value cannot be inspected"); + } + for (const key of keys) { + 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[] => { + if (!Array.isArray(params)) throw new PageRpcError("page RPC params must be an array"); + assertDataOnly(params, new 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"); + } +}; + +export class PageRpcRegistry { + private readonly bindings = new Map(); + + register( + uuid: string, + envTag: ScriptEnvTag, + allowedAPIs: readonly string[], + handle = uuidv4(), + runFlag = uuidv4() + ): string { + if (!uuid || !handle || this.bindings.has(handle)) { + throw new PageRpcError("invalid page execution binding"); + } + this.bindings.set(handle, { + handle, + uuid, + envTag, + allowedAPIs: new Set(allowedAPIs), + runFlag, + active: true, + }); + return handle; + } + + revoke(handle: string): void { + const binding = this.bindings.get(handle); + if (binding) binding.active = false; + } + + revokeAll(): void { + for (const binding of this.bindings.values()) binding.active = false; + } + + resolve(handle: string, uuid: string, envTag: ScriptEnvTag, api: string): PageExecutionBinding { + const binding = this.bindings.get(handle); + if (!binding?.active) throw new PageRpcError("page execution binding is inactive"); + if (binding.uuid !== uuid || binding.envTag !== envTag) { + throw new PageRpcError("page execution binding does not match the request"); + } + if (!binding.allowedAPIs.has(api)) throw new PageRpcError("API is not granted to this execution"); + return binding; + } +} + +const REQUEST_KEYS = ["version", "requestId", "handle", "uuid", "envTag", "api", "params", "runFlag"] 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 = Reflect.ownKeys(value); + } catch { + throw new PageRpcError("page RPC request cannot be inspected"); + } + const hasRunFlag = keys.includes("runFlag"); + if ( + keys.length !== REQUEST_KEYS.length - (hasRunFlag ? 0 : 1) || + keys.some((key) => typeof key !== "string" || !REQUEST_KEYS.includes(key as never)) + ) { + throw new PageRpcError("page RPC request has unexpected fields"); + } + + const version = ownData(value, "version"); + const requestId = ownData(value, "requestId"); + const handle = ownData(value, "handle"); + const uuid = ownData(value, "uuid"); + const envTag = ownData(value, "envTag"); + const api = ownData(value, "api"); + const params = ownData(value, "params"); + const suppliedRunFlag = keys.includes("runFlag") ? ownData(value, "runFlag") : undefined; + + if (version !== PAGE_RPC_VERSION) throw new PageRpcError("unsupported page RPC version"); + if (typeof requestId !== "string" || !requestId) throw new PageRpcError("page RPC requestId is invalid"); + if (typeof handle !== "string" || typeof uuid !== "string" || typeof envTag !== "string" || typeof api !== "string") { + throw new PageRpcError("page RPC identity fields are invalid"); + } + if (envTag !== "it" && envTag !== "ct") throw new PageRpcError("page RPC environment is invalid"); + if (suppliedRunFlag !== undefined && typeof suppliedRunFlag !== "string") { + throw new PageRpcError("page RPC runFlag is invalid"); + } + + const binding = registry.resolve(handle, uuid, envTag, api); + return { + version: PAGE_RPC_VERSION, + requestId, + handle, + uuid, + envTag, + api, + params: cloneParams(params), + runFlag: binding.runFlag, + }; +}; diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index a4fe6c02a..e8ea976e1 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -2,6 +2,7 @@ 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 type { GMInfoEnv } from "./types"; import { initEnvInfo, ScriptExecutor } from "./script_executor"; const styleUrl = "https://example.com/style.css"; @@ -31,6 +32,72 @@ function makeScript(overrides: Partial { + 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 = makeScript({ metadata: { "early-start": [""], "run-at": ["document-start"] } }); + const executor = new ScriptExecutor({} as Message, {} as Message); + + executor.execScriptEntry({ + scriptLoadInfo: initial, + scriptFlag: initial.flag, + envInfo: initEnvInfo, + scriptFunc: () => undefined, + }); + + const exec = ( + executor as unknown as { + execScripts: Array<{ + exec: { + scriptRes: TScriptInfo; + updateEarlyScriptGMInfo: (envInfo: GMInfoEnv, scriptInfo?: TScriptInfo) => void; + }; + }>; + } + ).execScripts[0].exec; + expect(exec.scriptRes.executionHandle).toBeUndefined(); + + exec.updateEarlyScriptGMInfo(initEnvInfo, { + ...initial, + executionHandle: "page-binding", + executionEnvTag: "it", + }); + + expect(exec.scriptRes.executionHandle).toBe("page-binding"); + expect(exec.scriptRes.executionEnvTag).toBe("it"); + }); + 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..02df2320e 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -30,8 +30,8 @@ export const initEnvInfo: GMInfoEnv = { // 脚本执行器 export class ScriptExecutor { - earlyScriptFlag: Set = new Set(); - execScriptMap: Map = new Map(); + private readonly earlyScriptFlags: string[] = []; + private readonly execScripts: Array<{ uuid: string; exec: ExecScript }> = []; constructor( private msg: Message, @@ -40,18 +40,22 @@ export class ScriptExecutor { emitEvent(data: EmitEventRequest) { // 转发给脚本 - const exec = this.execScriptMap.get(data.uuid); - if (exec) { - exec.emitEvent(data.event, data.eventId, data.data); + for (let i = 0; i < this.execScripts.length; i += 1) { + const entry = this.execScripts[i]; + if (entry?.uuid === data.uuid) { + entry.exec.emitEvent(data.event, data.eventId, data.data); + return; + } } } 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); + for (let i = 0; i < this.execScripts.length; i += 1) { + const exec = this.execScripts[i]?.exec; + if (exec && (exec.scriptRes.uuid === uuid || getStorageName(exec.scriptRes) === storageName)) { + exec.valueUpdate(data); } } } @@ -69,11 +73,19 @@ export class ScriptExecutor { scripts.forEach((script) => { const flag = script.flag; // 如果是EarlyScriptFlag,处理沙盒环境 - if (this.earlyScriptFlag.has(flag)) { - for (const val of this.execScriptMap.values()) { - if (val.scriptRes.flag === flag) { + let isEarlyScript = false; + for (let i = 0; i < this.earlyScriptFlags.length; i += 1) { + if (this.earlyScriptFlags[i] === flag) { + isEarlyScript = true; + break; + } + } + if (isEarlyScript) { + for (let i = 0; i < this.execScripts.length; i += 1) { + const exec = this.execScripts[i]?.exec; + if (exec?.scriptRes.flag === flag) { // 处理早期脚本的沙盒环境 - val.updateEarlyScriptGMInfo(envInfo); + exec.updateEarlyScriptGMInfo(envInfo, script); return; } } @@ -96,10 +108,16 @@ export class ScriptExecutor { scriptInfo: ScriptLoadInfo; }; const scriptFlag = detail?.scriptFlag; - if (typeof scriptFlag === "string") { + const scriptInfo = detail?.scriptInfo; + if ( + typeof scriptFlag === "string" && + scriptInfo && + typeof scriptInfo === "object" && + scriptInfo.flag === scriptFlag + ) { ev.preventDefault(); // dispatchEvent 会回传 false -> 分离环境也能得知环境加载代码已执行 // 检查是否有 urlPattern,有则执行匹配再决定是否略过注入 - if (detail.scriptInfo.scriptUrlPatterns) { + if (scriptInfo.scriptUrlPatterns) { // 以 REGEX 情况为例 // "@include /REGEX/" 的情况下,MV3 UserScripts API 基础匹配范围扩大,会比实际需要的广阔,然后在 earlyScript 把不符合 REGEX 的除去 // (All @include = false -> 除去) @@ -109,7 +127,7 @@ export class ScriptExecutor { // (Any @exclude = true -> 除去) // 注:如果一早已被除排,根本不会被 MV3 UserScripts API 注入。所以只考虑排除「多余的匹配」。(略过注入) try { - if (isUrlExcluded(window.location.href, detail.scriptInfo.scriptUrlPatterns)) { + if (isUrlExcluded(window.location.href, scriptInfo.scriptUrlPatterns)) { // 「多余的匹配」-> 略过注入 return; } @@ -117,7 +135,14 @@ export class ScriptExecutor { console.warn("Unexpected match error", e); } } - this.execEarlyScript(scriptFlag, detail.scriptInfo, envInfo); + let alreadyExecuted = false; + for (let i = 0; i < this.earlyScriptFlags.length; i += 1) { + if (this.earlyScriptFlags[i] === scriptFlag) { + alreadyExecuted = true; + break; + } + } + if (!alreadyExecuted) this.execEarlyScript(scriptFlag, scriptInfo, envInfo); } }; pageAddEventListener(scriptLoadCompleteEvtName, scriptLoadCompleteHandler); @@ -135,7 +160,7 @@ export class ScriptExecutor { scriptFlag: flag, envInfo: envInfo, }); - this.earlyScriptFlag.add(flag); + this.earlyScriptFlags[this.earlyScriptFlags.length] = flag; } execScriptEntry(scriptEntry: ExecScriptEntry) { @@ -150,7 +175,15 @@ export class ScriptExecutor { code: scriptFunc, envInfo, }); - this.execScriptMap.set(scriptLoadInfo.uuid, execScript); + let replaced = false; + for (let i = 0; i < this.execScripts.length; i += 1) { + if (this.execScripts[i]?.uuid === scriptLoadInfo.uuid) { + this.execScripts[i] = { uuid: scriptLoadInfo.uuid, exec: execScript }; + replaced = true; + break; + } + } + if (!replaced) this.execScripts[this.execScripts.length] = { uuid: scriptLoadInfo.uuid, exec: execScript }; const metadata = scriptLoadInfo.metadata || {}; const resource = scriptLoadInfo.requireCssResource ?? scriptLoadInfo.resource; // 注入css diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index 7286d82b0..46a3f151f 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -7,6 +7,8 @@ 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 { getPageRpcAllowedAPIs, PageRpcRegistry, validatePageGMRequest } from "./page_rpc"; +import { uuidv4 } from "@App/pkg/utils/uuid"; const PageOrContent = { PAGE: 1, @@ -23,7 +25,8 @@ const deliveryStorage = chrome.storage.local; // 日后再处理 // scripting页的处理 export default class ScriptingRuntime { - private activeStorageNames: Map | null = null; + private activeStorageNames = new Map(); + private readonly pageRpc = new PageRpcRegistry(); constructor( // 监听来自service_worker的消息 private readonly extServer: Server, @@ -74,10 +77,7 @@ 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); @@ -142,6 +142,16 @@ export default class ScriptingRuntime { break; } return false; + }, + (data) => { + const request = validatePageGMRequest(data, this.pageRpc); + return { + uuid: request.uuid, + api: request.api, + params: [...request.params], + runFlag: request.runFlag, + executionHandle: request.handle, + }; } ); } @@ -160,26 +170,41 @@ export default class ScriptingRuntime { client.pageLoad().then((o) => { if (!o.ok) return; const { injectScriptList, contentScriptList, envInfo } = o; + this.pageRpc.revokeAll(); + const prepareScripts = (scripts: typeof injectScriptList, envTag: "it" | "ct") => + scripts.map((script) => { + const allowedAPIs = getPageRpcAllowedAPIs(script.metadata.grant || []); + const executionRunFlag = script.executionRunFlag || uuidv4(); + const executionHandle = + script.executionHandle || + this.pageRpc.register(script.uuid, envTag, allowedAPIs, undefined, executionRunFlag); + if (script.executionHandle) { + this.pageRpc.register(script.uuid, envTag, allowedAPIs, script.executionHandle, executionRunFlag); + } + return { ...script, executionHandle, executionEnvTag: envTag, executionRunFlag }; + }); + const preparedInjectScriptList = prepareScripts(injectScriptList, "it"); + const preparedContentScriptList = prepareScripts(contentScriptList, "ct"); const pairs = {} as Record; - for (const script of injectScriptList) { + for (const script of preparedInjectScriptList) { pairs[getStorageName(script)] |= PageOrContent.PAGE; } - for (const script of contentScriptList) { + for (const script of preparedContentScriptList) { pairs[getStorageName(script)] |= PageOrContent.CONTENT; } this.activeStorageNames = new Map(Object.entries(pairs)); // 向页面 发送脚本列表及环境信息 - if (contentScriptList.length) { + if (preparedContentScriptList.length) { const contentClient = new Client(this.senderToContent, "content"); // 根据@inject-into content过滤脚本 - contentClient.do("pageLoad", { scripts: contentScriptList, envInfo }); + contentClient.do("pageLoad", { scripts: preparedContentScriptList, envInfo }); } - if (injectScriptList.length) { + if (preparedInjectScriptList.length) { const injectClient = new Client(this.senderToInject, "inject"); // 根据@inject-into content过滤脚本 - injectClient.do("pageLoad", { scripts: injectScriptList, envInfo }); + injectClient.do("pageLoad", { scripts: preparedInjectScriptList, envInfo }); } }); } diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 64181c489..fe2e51a06 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -232,6 +232,9 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { delete scriptInfo.runStatus; // 前台脚本不用 delete scriptInfo.type; // 脚本类型总是普通脚本 delete scriptInfo.status; // 脚本状态总是启用 + delete scriptInfo.executionHandle; + delete scriptInfo.executionEnvTag; + delete scriptInfo.executionRunFlag; // --- 处理 scriptInfo --- return scriptInfo; }; @@ -249,6 +252,10 @@ export function compilePreInjectScript( const flag = `${script.flag}`; const scriptInfo = trimScriptInfo(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}`; @@ -256,7 +263,7 @@ export function compilePreInjectScript( { let o = { cancelable: true, detail: { scriptFlag: '${flag}', scriptInfo: (${scriptInfoJSON}) } }, c = typeof cloneInto === "function" ? cloneInto(o, performance) : o, - f = () => performance.dispatchEvent(new CustomEvent('${evScriptLoad}', c)), + f = () => ${urlCondition} && performance.dispatchEvent(new CustomEvent('${evScriptLoad}', c)), needWait = f(); if (needWait) performance.addEventListener('${evEnvLoad}', f, { once: true }); } 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..c2b811bfe 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 @@ -124,6 +124,36 @@ describe.concurrent("GM API 注册完整性", () => { }); }); +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: "script-a", api: "GM_getTab", params: [], runFlag: "forged", executionHandle: "missing" }, + sender + ) + ).rejects.toThrow("page execution binding is invalid"); + expect(resolveBinding).toHaveBeenCalledTimes(1); + }); +}); + 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..dcf4314bc 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" }); } @@ -374,6 +379,27 @@ export default class GMApi { // sendMessage from Content Script, etc async handlerRequest(data: MessageRequest, sender: IGetSender) { this.logger.trace("GM API request", { api: data.api, uuid: data.uuid, param: data.params }); + const source = sender.getSender(); + const isPageRequest = typeof source?.tab?.id === "number"; + if (isPageRequest && !data.executionHandle) { + throw new Error("page execution binding is required"); + } + if (data.executionHandle) { + if (data.version !== undefined && data.version !== 1) { + throw new Error("unsupported page execution binding version"); + } + if (data.handle !== undefined && data.handle !== data.executionHandle) { + throw new Error("page execution binding is invalid"); + } + const binding = this.resolvePageExecutionBinding?.(data.executionHandle, sender); + if (!binding || (data.uuid && data.uuid !== binding.uuid)) { + throw new Error("page execution binding is invalid"); + } + if (data.envTag !== undefined && data.envTag !== binding.envTag) { + throw new Error("page execution binding is invalid"); + } + data = { ...data, uuid: binding.uuid, runFlag: binding.runFlag }; + } const api = PermissionVerifyApiGet(data.api); if (!api) { throw new Error("gm api is not found"); 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 fc272def4..51b9f948a 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1109,6 +1109,59 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { url: "https://www.example.com/page", }); }); + + it("为每个页面文档签发绑定,并拒绝跨标签页、跨 frame 和旧文档复用", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "bound-script", metadata: { grant: ["GM_getTab"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const rawSender = { + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const sender = new SenderRuntime(rawSender); + const first = await runtime.pageLoad(undefined, sender); + const firstHandle = first.ok ? first.injectScriptList[0].executionHandle : undefined; + const firstRunFlag = first.ok ? first.injectScriptList[0].executionRunFlag : undefined; + expect(firstHandle).toEqual(expect.any(String)); + expect(firstRunFlag).toEqual(expect.any(String)); + expect(runtime.resolvePageExecutionBinding(firstHandle!, sender)).toMatchObject({ + uuid: "bound-script", + envTag: "it", + tabId: 41, + frameId: 0, + documentId: "doc-a", + }); + + const otherTab = new SenderRuntime({ ...rawSender, tab: { ...rawSender.tab, id: 42 } as chrome.tabs.Tab }); + const otherFrame = new SenderRuntime({ ...rawSender, frameId: 1 }); + expect(runtime.resolvePageExecutionBinding(firstHandle!, otherTab)).toBeUndefined(); + expect(runtime.resolvePageExecutionBinding(firstHandle!, otherFrame)).toBeUndefined(); + + const secondSender = new SenderRuntime({ ...rawSender, documentId: "doc-b" }); + const second = await runtime.pageLoad(undefined, secondSender); + const secondHandle = second.ok ? second.injectScriptList[0].executionHandle : undefined; + const secondRunFlag = second.ok ? second.injectScriptList[0].executionRunFlag : undefined; + expect(secondHandle).toEqual(expect.any(String)); + expect(secondRunFlag).toEqual(expect.any(String)); + expect(secondHandle).not.toBe(firstHandle); + expect(secondRunFlag).not.toBe(firstRunFlag); + expect(runtime.resolvePageExecutionBinding(firstHandle!, sender)).toBeDefined(); + expect(runtime.resolvePageExecutionBinding(secondHandle!, secondSender)).toBeDefined(); + + runtime.revokePageBindingsForTab(41); + expect(runtime.resolvePageExecutionBinding(firstHandle!, sender)).toBeUndefined(); + expect(runtime.resolvePageExecutionBinding(secondHandle!, secondSender)).toBeUndefined(); + }); }); describe("sandbox verified 初始化重放", () => { diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index d44ef0215..77228c91b 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -1,4 +1,10 @@ -import type { EmitEventRequest, ScriptLoadInfo, ScriptMatchInfo, ScriptMenu } from "./types"; +import type { + EmitEventRequest, + ScriptLoadInfo, + ScriptMatchInfo, + ScriptMenu, + ServiceWorkerExecutionBinding, +} from "./types"; import type { IMessageQueue } from "@Packages/message/message_queue"; import type { Group, IGetSender } from "@Packages/message/server"; import type { ExtMessageSender, MessageSend } from "@Packages/message/types"; @@ -60,6 +66,7 @@ import { CompiledResourceDAO, CompiledResourceNamespace } from "@App/app/repo/re import { setOnTabURLChanged } from "./url_monitor"; import { scriptToMenu, type TPopupPageLoadInfo, type TPopupPageRestoreInfo } from "./popup_scriptmenu"; import { getExtensionUserAgentData } from "../extension/extension_env"; +import { uuidv4 } from "@App/pkg/utils/uuid"; const ORIGINAL_URLMATCH_SUFFIX = "{ORIGINAL}"; // 用于标记原始URLPatterns的后缀 @@ -134,11 +141,67 @@ export class RuntimeService { scriptMatchEnable: UrlMatch = new UrlMatch(); blackMatch: UrlMatch = new UrlMatch(); private gmApi?: GMApi; + private readonly pageExecutionBindings = new Map(); getGMApi(): GMApi | undefined { return this.gmApi; } + private revokePageBindings(sender: IGetSender): void { + const source = sender.getSender(); + const tabId = source?.tab?.id; + const frameId = source?.frameId; + const documentId = source?.documentId; + for (const [handle, binding] of this.pageExecutionBindings) { + if ( + binding.tabId === tabId && + binding.frameId === frameId && + (documentId === undefined || binding.documentId === documentId) + ) { + this.pageExecutionBindings.delete(handle); + } + } + } + + revokePageBindingsForTab(tabId: number): void { + for (const [handle, binding] of this.pageExecutionBindings) { + if (binding.tabId === tabId) this.pageExecutionBindings.delete(handle); + } + } + + private revokePageBindingsForScript(uuid: string): void { + for (const [handle, binding] of this.pageExecutionBindings) { + if (binding.uuid === uuid) this.pageExecutionBindings.delete(handle); + } + } + + private issuePageBinding(uuid: string, envTag: "it" | "ct", sender: IGetSender): ServiceWorkerExecutionBinding { + const source = sender.getSender(); + const tabId = source?.tab?.id; + if (typeof tabId !== "number") throw new Error("page execution binding requires a tab"); + const handle = uuidv4(); + const binding = { + handle, + uuid, + envTag, + runFlag: uuidv4(), + tabId, + frameId: source?.frameId, + documentId: source?.documentId, + } satisfies ServiceWorkerExecutionBinding; + this.pageExecutionBindings.set(handle, binding); + return binding; + } + + resolvePageExecutionBinding(handle: string, sender: IGetSender): ServiceWorkerExecutionBinding | undefined { + const binding = this.pageExecutionBindings.get(handle); + const source = sender.getSender(); + if (!binding || !source?.tab || source.tab.id !== binding.tabId || source.frameId !== binding.frameId) + return undefined; + if (binding.documentId !== undefined && source.documentId !== binding.documentId) return undefined; + return binding; + } + private readonly disabledMatcherTaskKey = `runtime_disabled_matcher:${Math.random()}`; private disabledMatcher: UrlMatch | null = null; private disabledMatcherVersion = 0; @@ -527,7 +590,8 @@ export class RuntimeService { this.msgSender, this.mq, this.value, - new GMExternalDependencies(this) + new GMExternalDependencies(this), + this.resolvePageExecutionBinding.bind(this) ); permission.init(); this.gmApi.start(); @@ -548,6 +612,7 @@ export class RuntimeService { const unregisterUuids = [] as string[]; for (const { uuid, enable } of data) { + this.revokePageBindingsForScript(uuid); const script = await this.scriptDAO.get(uuid); if (!script) { this.logger.error("script enable failed, script not found", { @@ -582,6 +647,7 @@ export class RuntimeService { // 监听脚本安装 this.mq.subscribe("installScript", async (data) => { const uuid = data.script.uuid; + this.revokePageBindingsForScript(uuid); this.invalidateDisabledMatcher(); this.deleteScriptRuntimeCache(uuid); @@ -620,6 +686,7 @@ export class RuntimeService { const unregisterUuids = [] as string[]; this.updateSorter((next) => { for (const { uuid } of data) { + this.revokePageBindingsForScript(uuid); unregisterUuids.push(uuid); this.deleteScriptRuntimeCache(uuid); this.deleteScriptSort(next, uuid); @@ -844,6 +911,7 @@ export class RuntimeService { // 取消脚本注册 async unregisterUserscripts() { + this.pageExecutionBindings.clear(); // 检查 registered 避免重复操作增加系统开支 // 已成功注册(true)或是未知有无注册(null)的情况下执行 if (runtimeGlobal.registerState !== RuntimeRegisterCode.UNREGISTER_DONE) { @@ -1286,11 +1354,22 @@ export class RuntimeService { }); if (res) { + this.revokePageBindings(sender); + const prepareScripts = (scripts: TScriptInfo[], envTag: "it" | "ct") => + scripts.map((script) => { + const binding = this.issuePageBinding(script.uuid, envTag, sender); + return { + ...script, + executionHandle: binding.handle, + executionEnvTag: envTag, + executionRunFlag: binding.runFlag, + }; + }); // 返回脚本资料,在页面加载 return { ok: true, - injectScriptList: res.injectScriptList, - contentScriptList: res.contentScriptList, + injectScriptList: prepareScripts(res.injectScriptList, "it"), + contentScriptList: prepareScripts(res.contentScriptList, "ct"), envInfo: res.envInfo, }; } else { diff --git a/src/app/service/service_worker/types.ts b/src/app/service/service_worker/types.ts index 5197fa039..00d686bdd 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -46,6 +46,23 @@ export type MessageRequest = { api: string; runFlag: string; params: T; + /** 页面执行环境绑定的能力句柄;后台脚本不携带此字段。 */ + executionHandle?: string; + /** 页面 GM RPC 的版本和请求关联字段。 */ + version?: 1; + requestId?: string; + handle?: string; + envTag?: "it" | "ct"; +}; + +export type ServiceWorkerExecutionBinding = { + handle: string; + uuid: string; + envTag: "it" | "ct"; + runFlag: string; + tabId: number; + frameId?: number; + documentId?: string; }; export type GMApiRequest = MessageRequest & { diff --git a/src/app/service/service_worker/utils.test.ts b/src/app/service/service_worker/utils.test.ts index 94c31b627..57d2df7e6 100644 --- a/src/app/service/service_worker/utils.test.ts +++ b/src/app/service/service_worker/utils.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { isBase64, parseUrlSRI, @@ -6,14 +6,16 @@ import { selfMetadataUpdate, getUserScriptRegister, compileInjectionCode, + parseScriptLoadInfo, shouldAutoOpenChangelog, scriptURLPatternResults, } from "./utils"; -import type { SCMetadata, Script, ScriptRunResource } from "@App/app/repo/scripts"; +import type { SCMetadata, ScriptLoadInfo, Script } from "@App/app/repo/scripts"; import { SELF_METADATA_ONLY_RUN_ON_URL } from "@App/app/repo/metadata"; import { SCRIPT_TYPE_NORMAL, SCRIPT_STATUS_ENABLE, SCRIPT_RUN_STATUS_COMPLETE } from "@App/app/repo/scripts"; import type { ScriptMatchInfo } from "./types"; import { extractUrlPatterns, RuleTypeBit } from "@App/pkg/utils/url_matcher"; +import { compilePreInjectScript } from "../content/utils"; describe.concurrent("parseUrlSRI", () => { it.concurrent("should parse URL SRI", () => { @@ -311,7 +313,7 @@ describe.concurrent("getUserScriptRegister", () => { }); describe.concurrent("compileInjectionCode", () => { - const createMockScriptRes = (overrides: Partial = {}): ScriptRunResource => ({ + const createMockScriptRes = (overrides: Partial = {}): ScriptLoadInfo => ({ uuid: "test-uuid", name: "Test Script", namespace: "test.namespace", @@ -327,6 +329,8 @@ describe.concurrent("compileInjectionCode", () => { resource: {}, metadata: {}, originalMetadata: {}, + metadataStr: "", + userConfigStr: "", ...overrides, }); @@ -358,6 +362,29 @@ describe.concurrent("compileInjectionCode", () => { // 使用 compileInjectScript 包裹(window[flag] = function(){...}) expect(result).toContain("window['#-test-uuid']"); }); + + it.concurrent("预注入脚本在派发事件前执行精确 URL 规则", () => { + const scriptRes = createMockScriptRes({ + metadata: { "early-start": [""], "run-at": ["document-start"] }, + scriptUrlPatterns: extractUrlPatterns(["@include /example\\.com/"]), + }); + const result = compilePreInjectScript(parseScriptLoadInfo(scriptRes, scriptRes.scriptUrlPatterns ?? []), "", false); + const dispatchEvent = vi.fn(() => true); + const performance = { dispatchEvent, addEventListener: vi.fn() }; + const customEvent = class { + constructor( + readonly type: string, + readonly init: unknown + ) {} + }; + const run = new Function("window", "performance", "CustomEvent", "location", result); + + run(Object.create(null), performance, customEvent, { href: "https://other.example/" }); + expect(dispatchEvent).not.toHaveBeenCalled(); + + run(Object.create(null), performance, customEvent, { href: "https://example.com/" }); + expect(dispatchEvent).toHaveBeenCalledTimes(1); + }); }); describe.concurrent("scriptURLPatternResults", () => { From a6e69ed64420507d8c68714647cabb69983d3a88 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:39:01 +0900 Subject: [PATCH 002/103] =?UTF-8?q?=F0=9F=90=9B=20allow=20async=20GM=20XHR?= =?UTF-8?q?=20page=20RPC=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/page_rpc.test.ts | 6 ++++++ src/app/service/content/page_rpc.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 39cb74802..66586d1a2 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -24,6 +24,12 @@ describe("page GM RPC", () => { expect(allowed).toEqual(expect.arrayContaining(["GM.openInTab", "GM_openInTab", "GM_closeInTab"])); }); + it("allows the internal request name used by the GM.xmlHttpRequest wrapper", () => { + const allowed = getPageRpcAllowedAPIs(["GM.xmlHttpRequest"]); + + expect(allowed).toContain("GM_xmlhttpRequest"); + }); + it("accepts a request for the active execution binding and clones parameters", () => { const registry = new PageRpcRegistry(); const handle = registry.register("script-a", "it", ["GM_getValue"], undefined, "canonical-run"); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index 2c6e18e9b..c3c83f3ee 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -35,7 +35,7 @@ const INTERNAL_APIS_BY_GRANT: Readonly> = { CAT_fileStorage: ["CAT_fetchBlob", "CAT_createBlobUrl"], GM_xmlhttpRequest: ["CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], "GM.xmlhttpRequest": ["CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], - "GM.xmlHttpRequest": ["CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], + "GM.xmlHttpRequest": ["GM_xmlhttpRequest", "CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], }; // ScriptingRuntime does not load the GM implementation module, so mirror its small dependency graph here. From ef945e820e5cbf3973b05d95049f818c04e04206 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:24:18 +0900 Subject: [PATCH 003/103] =?UTF-8?q?=F0=9F=94=92=20harden=20userscript=20in?= =?UTF-8?q?vocation=20against=20page=20substitution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/common.ts | 11 +- packages/message/server.ts | 13 +- rspack.config.ts | 2 + .../service/content/create_context.test.ts | 19 ++ src/app/service/content/create_context.ts | 21 +- src/app/service/content/exec_script.test.ts | 16 ++ src/app/service/content/exec_script.ts | 4 +- src/app/service/content/global.ts | 27 ++- src/app/service/content/gm_api/gm_api.test.ts | 4 +- src/app/service/content/gm_api/gm_api.ts | 8 +- .../content/gm_api/navigation_handle.test.ts | 17 ++ .../content/gm_api/navigation_handle.ts | 4 +- .../service/content/script_executor.test.ts | 52 +++++ src/app/service/content/script_executor.ts | 29 ++- src/app/service/content/types.ts | 2 +- src/app/service/content/utils.test.ts | 184 +++++++++++++++++- src/app/service/content/utils.ts | 61 ++++-- src/app/service/service_worker/utils.test.ts | 6 +- vitest.config.ts | 2 + 19 files changed, 416 insertions(+), 66 deletions(-) diff --git a/packages/message/common.ts b/packages/message/common.ts index 009fd8a51..33b3e6757 100644 --- a/packages/message/common.ts +++ b/packages/message/common.ts @@ -8,9 +8,14 @@ export const CustomEventClone = CustomEvent; 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); diff --git a/packages/message/server.ts b/packages/message/server.ts index 4a62f824f..0c4682c23 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -4,6 +4,11 @@ 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 bindNative = any>(fn: T, receiver: any): T => + nativeReflectApply(nativeFunctionBind, fn, [receiver]) as T; + export const enum GetSenderType { CONNECT = 1, EXTCONNECT = 1 | 2, @@ -300,10 +305,10 @@ export function forwardMessage( 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); diff --git a/rspack.config.ts b/rspack.config.ts index f9e8ae578..35d02137b 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -138,6 +138,8 @@ export default { new rspack.DefinePlugin({ "process.env.VI_TESTING": "'false'", "process.env.SC_RANDOM_KEY": `'${uuidv4()}'`, + "process.env.SC_RANDOM_FNKEY": `'${uuidv4()}'`, + "process.env.SC_ZN_RAND": `'$${uuidv4()}'`, "process.env.SC_DISABLE_AGENT": `'${enableAgent ? "false" : "true"}'`, }), new rspack.CopyRspackPlugin({ diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index a5eb5bc00..f5ccee32b 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -938,6 +938,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 b9877fd6d..0fb81cbf2 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -8,11 +8,10 @@ import { isEarlyStartScript } from "./utils"; import { ListenerManager } from "./listener_manager"; import { createGMBase } from "./gm_api/gm_api"; import { attachNavigateHandler, type UrlChangeEvent } from "./gm_api/navigation_handle"; - -const nativeReflectApply = Reflect.apply; +import { Native } from "./global"; const createCapability = (api: (...args: any[]) => any, receiver: object) => { - const capability = (...args: any[]) => nativeReflectApply(api, receiver, args); + const capability = (...args: any[]) => Native.reflectApply(api, receiver, args); Object.defineProperty(capability, "name", { configurable: true, value: `bound ${api.name}` }); return capability; }; @@ -186,21 +185,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: nativeReflectApply(bindFn, descriptor.value, [receiver]), + value: Native.bind(descriptor.value, receiver), }; } if (!descriptor.get && !descriptor.set) return descriptor; return { ...descriptor, - get: descriptor.get ? nativeReflectApply(bindFn, descriptor.get, [receiver]) : undefined, - set: descriptor.set ? nativeReflectApply(bindFn, descriptor.set, [receiver]) : undefined, + get: descriptor.get ? Native.bind(descriptor.get, receiver) : undefined, + set: descriptor.set ? Native.bind(descriptor.set, receiver) : undefined, }; }; @@ -371,8 +368,8 @@ export const createProxyContext = ( // 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 @@ -387,7 +384,7 @@ export const createProxyContext = ( hostRemoveEventListener(eventName, eventObject); this.fn = null; } else { - fn.call(mySandbox, event); + Native.call(fn, mySandbox, event); } }, }; @@ -525,7 +522,7 @@ 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 diff --git a/src/app/service/content/exec_script.test.ts b/src/app/service/content/exec_script.test.ts index 528999b35..01d9e9299 100644 --- a/src/app/service/content/exec_script.test.ts +++ b/src/app/service/content/exec_script.test.ts @@ -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", () => { diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index dc76d462b..febd9f01e 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -9,6 +9,8 @@ import { evaluateGMInfo } from "./gm_api/gm_info"; import type { IGM_Base } from "./gm_api/gm_api"; import type { TScriptInfo } from "@App/app/repo/scripts"; +const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; + // 执行脚本,控制脚本执行与停止 export default class ExecScript { scriptRes: TScriptInfo; @@ -88,7 +90,7 @@ 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); }; // 早期启动的脚本,处理GM API diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 3b78911ff..a431a025c 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -3,16 +3,31 @@ const unsupportedAPI = () => { throw "unsupportedAPI"; }; +// 在页面或用户脚本替换调用内建函数前完成捕获。 +export const nativeReflectApply = Reflect.apply; +const nativeFunctionBind = Function.prototype.bind; + +export const nativeApply = (fn: (...args: any[]) => any, receiver: any, args: any[]) => + nativeReflectApply(fn, receiver, args); +export const nativeCall = (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => + nativeReflectApply(fn, receiver, args); +export const nativeBind = (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => + nativeReflectApply(nativeFunctionBind, fn, [receiver, ...args]); + export const Native = { + apply: nativeApply, + call: nativeCall, + bind: nativeBind, + reflectApply: nativeReflectApply, structuredClone: typeof structuredClone === "function" ? structuredClone : unsupportedAPI, - jsonStringify: JSON.stringify.bind(JSON), - jsonParse: JSON.parse.bind(JSON), + jsonStringify: nativeBind(JSON.stringify, JSON), + jsonParse: nativeBind(JSON.parse, 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), + objectCreate: nativeBind(Object.create, Object), + objectGetOwnPropertyDescriptors: nativeBind(Object.getOwnPropertyDescriptors, Object), + objectGetOwnPropertyDescriptor: nativeBind(Object.getOwnPropertyDescriptor, Object), + objectGetPrototypeOf: nativeBind(Object.getPrototypeOf, Object), } as const; export const customClone = (o: any) => { 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 48f9cbce2..8be773dad 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -82,8 +82,8 @@ describe("early-start page RPC", () => { envInfo, }); - exec.scriptFunc = function (this: any) { - return this.GM.setValue("a", 123); + exec.scriptFunc = function (_token: string, context: any) { + return context.GM.setValue("a", 123); } as unknown as ScriptFunc; const result = exec.exec(); await Promise.resolve(); diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 450f23ced..29e811bd7 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -1321,7 +1321,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]); + Native.apply(create, { id }, [id]); } if (typeof data.tag === "string") { notificationTagMap.set(data.tag, id); @@ -1358,8 +1358,8 @@ export default class GMApi extends GM_Base { title: data.title, url: data.url, }; - click && click.apply({ id }, [clickEvent]); - done && done.apply({ id }, []); + click && Native.apply(click, { id }, [clickEvent]); + done && Native.apply(done, { id }, []); if (!isPreventDefault) { if (typeof data.url === "string") { @@ -1372,7 +1372,7 @@ export default class GMApi extends GM_Base { break; } case "close": { - done && done.apply({ id }, [resp.params.byUser]); + done && Native.apply(done, { id }, [resp.params.byUser]); clearNotificationIdMap(); gmApi.EE.removeAllListeners("GM_notification:" + gmApi.eventId); break; 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..5058be5d6 100644 --- a/src/app/service/content/gm_api/navigation_handle.test.ts +++ b/src/app/service/content/gm_api/navigation_handle.test.ts @@ -94,6 +94,23 @@ describe("attachNavigateHandler", () => { expect(ev.url).toBe("https://example.com/new"); }); + 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..d05beebe5 100644 --- a/src/app/service/content/gm_api/navigation_handle.ts +++ b/src/app/service/content/gm_api/navigation_handle.ts @@ -19,7 +19,7 @@ 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,7 +33,7 @@ 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 handler = async (ev: Event): Promise => { diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index e8ea976e1..1d4b8ff97 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -7,6 +7,7 @@ import { initEnvInfo, ScriptExecutor } from "./script_executor"; 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 { @@ -98,6 +99,57 @@ describe("ScriptExecutor", () => { expect(exec.scriptRes.executionEnvTag).toBe("it"); }); + it("ignores a counterfeit mount and keeps listening for the genuine wrapper", () => { + const script = makeScript({ flag: "executor-counterfeit-flag" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const attackerTarget = vi.fn(); + const attacker = new Proxy(attackerTarget, { + getOwnPropertyDescriptor(target, property) { + if (property === fnStrIntegrity) { + return { configurable: true, enumerable: false, value: true, writable: true }; + } + return Object.getOwnPropertyDescriptor(target, property); + }, + }); + const genuine = vi.fn(); + const pageWindow = window as unknown as Record; + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + + try { + executor.startScripts([script], initEnvInfo); + pageWindow[script.flag] = attacker; + + expect(attackerTarget).not.toHaveBeenCalled(); + + pageWindow[script.flag] = genuine; + + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); + } finally { + delete pageWindow[script.flag]; + } + }); + + it("rejects a counterfeit early-start wrapper before execution", () => { + const script = makeScript({ flag: "executor-counterfeit-early-flag" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const attacker = vi.fn(); + const genuine = vi.fn(); + const pageWindow = window as unknown as Record; + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + + try { + pageWindow[script.flag] = attacker; + executor.execEarlyScript(script.flag, script, initEnvInfo); + expect(attacker).not.toHaveBeenCalled(); + + pageWindow[script.flag] = genuine; + executor.execEarlyScript(script.flag, script, initEnvInfo); + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); + } finally { + delete pageWindow[script.flag]; + } + }); + 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 02df2320e..32e9cb7d1 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -9,7 +9,9 @@ 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"; + +const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; export type ExecScriptEntry = { scriptLoadInfo: TScriptInfo; @@ -61,6 +63,7 @@ export class ScriptExecutor { } startScripts(scripts: TScriptInfo[], envInfo: GMInfoEnv) { + const pageWindow = window as unknown as Record; const loadExec = (script: TScriptInfo, scriptFunc: any) => { this.execScriptEntry({ scriptLoadInfo: script, @@ -90,9 +93,22 @@ export class ScriptExecutor { } } } - definePropertyListener(window, flag, (val: ScriptFunc) => { - loadExec(script, val); - }); + const listenForScript = () => { + definePropertyListener(window, flag, (val: ScriptFunc) => { + const descriptor = + typeof val === "function" ? Native.objectGetOwnPropertyDescriptor(val, fnStrIntegrity) : undefined; + if (descriptor?.value !== true || descriptor.configurable || descriptor.writable) { + const mountDescriptor = Native.objectGetOwnPropertyDescriptor(pageWindow, flag); + if (mountDescriptor?.configurable) { + delete pageWindow[flag]; + listenForScript(); + } + return; + } + loadExec(script, val); + }); + }; + listenForScript(); }); } @@ -153,7 +169,10 @@ export class ScriptExecutor { } execEarlyScript(flag: string, scriptInfo: TScriptInfo, envInfo: GMInfoEnv) { - const scriptFunc = (window as any)[flag] as ScriptFunc; + const scriptFunc = (window as unknown as Record)[flag] as ScriptFunc; + const descriptor = + typeof scriptFunc === "function" ? Native.objectGetOwnPropertyDescriptor(scriptFunc, fnStrIntegrity) : undefined; + if (descriptor?.value !== true || descriptor.configurable || descriptor.writable) return; this.execScriptEntry({ scriptLoadInfo: scriptInfo, scriptFunc: scriptFunc, diff --git a/src/app/service/content/types.ts b/src/app/service/content/types.ts index 30fe88e80..61d0f060f 100644 --- a/src/app/service/content/types.ts +++ b/src/app/service/content/types.ts @@ -1,6 +1,6 @@ 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) => any; // exec_script.ts diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index 837728eec..51ca3b255 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -3,6 +3,7 @@ import { compileScriptCode, compileScript, compileInjectScript, + compilePreInjectScript, compileScriptletCode, isScriptletUnwrap, addStyle, @@ -13,6 +14,24 @@ import type { SCMetadata, ScriptLoadInfo, ScriptRunResource } from "@App/app/rep import type { ScriptFunc } from "./types"; import { RuleType, type URLRuleEntry } from "@App/pkg/utils/url_matcher"; +const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; +const znRand = process.env.SC_ZN_RAND!; + +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(() => {}); vi.spyOn(console, "log").mockImplementation(() => {}); @@ -60,7 +79,8 @@ 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("this[arguments[0]='$$'+Date.now()/Math.random()]=async function(){"); + expect(result).toContain("return this[arguments[0]](...((delete this[arguments[0]]),[]));"); }); it.concurrent("应该处理自定义脚本代码参数", () => { @@ -495,7 +515,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 +531,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 +546,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 +555,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 +585,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)); + 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).toBe( + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, 'inject-test-flag', ((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true }); return f; })('${fnStrIntegrity}', '${znRand}' + Math.random(), function(){console.log('injected');}));` + ); }); it.concurrent("应该包含自动删除挂载函数的代码", () => { @@ -577,7 +638,7 @@ 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');}` + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, 'inject-test-flag', ((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true }); return f; })('${fnStrIntegrity}', '${znRand}' + Math.random(), function(){try{delete window['inject-test-flag']}catch(e){}console.log('with auto delete');}));` ); }); @@ -588,7 +649,64 @@ 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).toBe( + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, 'inject-test-flag', ((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true }); return f; })('${fnStrIntegrity}', '${znRand}' + Math.random(), function(){console.log('without auto delete');}));` + ); + }); + + 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)).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)).toBe("ran"); + 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)).toBe("ran"); + expect(targetWindow[script.flag]).toBeUndefined(); }); it.concurrent("应该处理复杂的脚本代码", () => { @@ -613,7 +731,53 @@ 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("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; + const context = {}; + const named = { value: 42 }; + expect(generated(fnStrIntegrity, context, named, script.name)).toEqual({ + thisValue: context, + args: [named, script.name], + }); + expect(Reflect.ownKeys(context)).toEqual([]); + expect(testPerformance.dispatchEvent).toHaveBeenCalledTimes(1); + expect(testPerformance.addEventListener).not.toHaveBeenCalled(); }); }); diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index fe2e51a06..d07a2161c 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -8,6 +8,9 @@ import { embeddedPatternCheckerString, type EmbeddedURLRuleEntry, type URLRuleEn import { parseResourceDeclaration } from "@App/pkg/utils/resource"; import { getGrantCandidates } from "./gm_api/grant"; +const lnStrIntegrity = process.env.SC_RANDOM_FNKEY; +const znRand = process.env.SC_ZN_RAND; + export type CompileScriptCodeResource = { name: string; code: string; @@ -141,7 +144,7 @@ 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 也随之推迟到点击后才注册 @@ -151,9 +154,9 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) const joinedCode = [ "with(arguments[0]||this.$){", `${preCode}`, - "return(async function(){", + "this[arguments[0]='$$'+Date.now()/Math.random()]=async function(){", `${code}`, - "}).call(this);}", + "};return this[arguments[0]](...((delete this[arguments[0]]),[]));}", ] .filter(Boolean) .join("\n"); @@ -161,9 +164,27 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) return `${codeBody}${sourceMapTo(`${resource.name}.user.js`)}\n`; } +const codeFunction = (code: string) => { + // 临时方法调用不依赖页面改写的 call、apply、bind。 + return `((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true }); return f; })('${lnStrIntegrity}', '${znRand}' + Math.random(), function(){${code}})`; +}; + +const mountCodeFunction = (flag: string, code: string) => + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, '${flag}', ${codeFunction(code)})`; + +const ZFunction = Function; + // 通过脚本代码编译脚本函数 export function compileScript(code: string): ScriptFunc { - return new Function(code); + const fn = new ZFunction(code); + const k = lnStrIntegrity; + const y = `${znRand}` + Math.random(); + return (t: any, u: any, ...args: any[]) => { + if (t === k) { + u[y] = fn; + return u[y](...(delete u[y], args)); + } + }; } /** @@ -186,7 +207,7 @@ export function compileInjectScriptByFlag( autoDeleteMountFunction: boolean = false ): string { const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : ""; - return `window['${flag}'] = function(){${autoDeleteMountCode}${scriptCode}}`; + return `${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`)};`; } /** @@ -259,7 +280,7 @@ export function compilePreInjectScript( 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}}; + return `${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`)}; { let o = { cancelable: true, detail: { scriptFlag: '${flag}', scriptInfo: (${scriptInfoJSON}) } }, c = typeof cloneInto === "function" ? cloneInto(o, performance) : o, @@ -344,16 +365,30 @@ 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 = Object.getOwnPropertyDescriptor(obj, prop); + listener(current); + if (sameProperty(descriptor, Object.getOwnPropertyDescriptor(obj, prop)) && descriptor?.configurable) { + delete obj[prop]; + } return; } + const setter = (val: T) => { + listener(val); + const descriptor = Object.getOwnPropertyDescriptor(obj, prop); + if (descriptor?.configurable && descriptor.set === setter) { + delete obj[prop]; + } + }; Object.defineProperty(obj, prop, { configurable: true, - set: (val: any) => { - delete obj[prop]; // 删除 property setter - listener(val); - }, + set: setter, }); } diff --git a/src/app/service/service_worker/utils.test.ts b/src/app/service/service_worker/utils.test.ts index 57d2df7e6..f856b1954 100644 --- a/src/app/service/service_worker/utils.test.ts +++ b/src/app/service/service_worker/utils.test.ts @@ -358,9 +358,9 @@ describe.concurrent("compileInjectionCode", () => { // 包含沙箱封装 expect(result).toContain("with(arguments[0]||this.$)"); - expect(result).toContain("return(async function(){"); - // 使用 compileInjectScript 包裹(window[flag] = function(){...}) - expect(result).toContain("window['#-test-uuid']"); + expect(result).toContain("this[arguments[0]='$$'+Date.now()/Math.random()]=async function(){"); + // 使用 compileInjectScript 包裹并挂载脚本标志 + expect(result).toContain("window, '#-test-uuid'"); }); it.concurrent("预注入脚本在派发事件前执行精确 URL 规则", () => { diff --git a/vitest.config.ts b/vitest.config.ts index cb248d264..0ef021b56 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -45,6 +45,8 @@ const sharedTest = { env: { VI_TESTING: "true", SC_RANDOM_KEY: "005a7deb-3a6e-4337-83ea-b9626c02ea38", + SC_RANDOM_FNKEY: "843078d2-403b-4ec0-a6e0-358488e135ec", + SC_ZN_RAND: "4622da29-026c-47d1-a8f8-ee52bad37129", }, }; From 1eb1839c6033f6f70d11a69b89400bec453bfabf Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:32:54 +0900 Subject: [PATCH 004/103] =?UTF-8?q?=F0=9F=94=92=20isolate=20USER=5FSCRIPT?= =?UTF-8?q?=20GM=20transport=20and=20binding=20rotation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 2 +- docs/references/architecture-execution.md | 8 +- docs/references/architecture-gm-api.md | 7 +- src/app/service/content/gm_api/gm_api.test.ts | 3 +- src/app/service/content/gm_api/gm_api.ts | 80 ++++++++++++------- src/app/service/content/gm_api/gm_xhr.test.ts | 73 +++++++++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 57 ++++++++----- src/app/service/content/page_rpc.test.ts | 46 +++++------ src/app/service/content/page_rpc.ts | 53 +++++++----- src/app/service/content/script_runtime.ts | 37 ++++++++- src/app/service/content/scripting.ts | 24 +++--- src/app/service/content/utils.test.ts | 13 +++ src/app/service/content/utils.ts | 42 +++++++++- src/app/service/service_worker/client.ts | 4 +- .../service/service_worker/runtime.test.ts | 57 +++++++++++++ src/app/service/service_worker/runtime.ts | 76 ++++++++++++++++-- src/content.ts | 27 ++++++- 17 files changed, 474 insertions(+), 135 deletions(-) create mode 100644 src/app/service/content/gm_api/gm_xhr.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 06436d77f..bc812f73f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,7 +86,7 @@ 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` | +| **Content** | [`src/content.ts`](../src/content.ts) | `USER_SCRIPT` world. Uses a native extension channel for bootstrap, GM RPC, value updates, and callbacks; retains a narrow DOM channel for synchronous node helpers. | `ExtensionMessage` + native callback port → `Server("content")` → `ScriptRuntime`; `CustomEventMessage` only for DOM handles | | **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` | diff --git a/docs/references/architecture-execution.md b/docs/references/architecture-execution.md index 717ea0f12..782c08492 100644 --- a/docs/references/architecture-execution.md +++ b/docs/references/architecture-execution.md @@ -24,8 +24,8 @@ go through a controlled context object instead of the page's real globals: 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. + 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. - 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 @@ -38,7 +38,9 @@ patterns and registers the compiled payload (the `scripting` bundle) with `chrom `MAIN` or `USER_SCRIPT` world as required. 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`; the isolated `scripting` bundle keeps the page-observable event bridge for `MAIN` execution and +the synchronous DOM helper only. ### Path B — Background scripts → Offscreen → Sandbox diff --git a/docs/references/architecture-gm-api.md b/docs/references/architecture-gm-api.md index 8282aae48..1b83aad43 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 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 8be773dad..5f0629f39 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -59,7 +59,8 @@ describe("early-start page RPC", () => { data: expect.objectContaining({ api: "GM_xmlhttpRequest", handle: "page-binding", - envTag: "it", + version: 1, + requestId: expect.any(String), }), }); }); diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 29e811bd7..154a27529 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -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 { uuidv4 } from "@App/pkg/utils/uuid"; @@ -151,22 +150,33 @@ class GM_Base implements IGM_Base { if (this.loadScriptPromise) { await this.loadScriptPromise; } + // USER_SCRIPT has DOM and fetch access in its own realm. Keep these helper + // operations local instead of sending an internal CAT operation to the SW, + // where only the isolated scripting broker has an implementation. + if (this.scriptRes.executionEnvTag === ScriptEnvTag.content) { + if (api === "CAT_fetchBlob") 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 { - const request = { - uuid: this.scriptRes.uuid, - api, - params, - runFlag: this.runFlag, - ...(this.scriptRes.executionHandle && this.scriptRes.executionEnvTag - ? { - version: 1 as const, - requestId: uuidv4(), - handle: this.scriptRes.executionHandle, - envTag: this.scriptRes.executionEnvTag, - } - : {}), - } as MessageRequest; + const request = this.scriptRes.executionHandle + ? { + version: 1 as const, + requestId: uuidv4(), + handle: this.scriptRes.executionHandle, + ...(this.scriptRes.executionEnvTag === "ct" ? { executionHandle: this.scriptRes.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.")) { @@ -187,20 +197,21 @@ class GM_Base implements IGM_Base { await this.loadScriptPromise; } if (!this.message || !this.scriptRes) return new Promise(() => {}); - const request = { - uuid: this.scriptRes.uuid, - api, - params, - runFlag: this.runFlag, - ...(this.scriptRes.executionHandle && this.scriptRes.executionEnvTag - ? { - version: 1 as const, - requestId: uuidv4(), - handle: this.scriptRes.executionHandle, - envTag: this.scriptRes.executionEnvTag, - } - : {}), - } as MessageRequest; + const request = this.scriptRes.executionHandle + ? { + version: 1 as const, + requestId: uuidv4(), + handle: this.scriptRes.executionHandle, + ...(this.scriptRes.executionEnvTag === "ct" ? { executionHandle: this.scriptRes.executionHandle } : {}), + api, + params, + } + : { + uuid: this.scriptRes.uuid, + api, + params, + runFlag: this.runFlag, + }; return connect(this.message, `${this.prefix}/runtime/gmApi`, request); } @@ -577,6 +588,17 @@ export default class GMApi extends GM_Base { // 上下文已失效时直接返回,避免访问已释放的 message 造成异常 if (this.isInvalidContext()) return undefined; + if (this.scriptRes?.executionEnvTag === ScriptEnvTag.content) { + 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); 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..cf75a3a61 --- /dev/null +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -0,0 +1,73 @@ +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 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"); + }, + 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: "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(onloadend).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 598406983..e65152ecb 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -6,6 +6,7 @@ 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, @@ -499,6 +500,16 @@ export function GM_xmlhttpRequest( return makeResponseRet(retParam, addGetters, res.contentType); }; + 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 makeXHRCallbackParam: typeof makeXHRCallbackParam_ | null = makeXHRCallbackParam_; let loadendCalled = false; const doLoadEnd = (data: TXhrCallBackArg) => { @@ -515,18 +526,29 @@ export function GM_xmlhttpRequest( retPromiseReject?.(errorOccur); } refCleanup?.(); - details.onloadend?.(xhrResponse); + invokeCallback("onloadend", details.onloadend, xhrResponse); } }; + const scheduleSyntheticLoadEnd = () => { + 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; + // Mark the request settled before user code runs. A throwing abort + // callback must not leave the broker connection and loadend cleanup pending. + 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 +579,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 +662,14 @@ export function GM_xmlhttpRequest( break; } case "onload": - details.onload?.(makeXHRCallbackParam?.(data) ?? {}); + 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 +681,7 @@ export function GM_xmlhttpRequest( done: data.loaded, totalSize: data.total, }; - details.onprogress?.(res); + invokeCallback("onprogress", details.onprogress, res); } break; } @@ -678,14 +694,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,8 +711,12 @@ export function GM_xmlhttpRequest( if (!reqDone) { data.error ||= "Unknown Error"; errorOccur = data.error; - details.onerror?.((makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError); reqDone = true; + invokeCallback( + "onerror", + details.onerror, + (makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError + ); // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); } diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 66586d1a2..10c78f5c8 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -40,8 +40,6 @@ describe("page GM RPC", () => { version: 1, requestId: "request-a", handle, - uuid: "script-a", - envTag: "it", api: "GM_getValue", params: [params], }, @@ -52,16 +50,19 @@ describe("page GM RPC", () => { version: 1, requestId: "request-a", handle, - uuid: "script-a", - envTag: "it", api: "GM_getValue", params: [params], + uuid: "script-a", + envTag: "it", runFlag: "canonical-run", }); expect(request.params[0]).not.toBe(params); + expect(() => + validatePageGMRequest({ version: 1, requestId: "request-a", handle, api: "GM_getValue", params: [] }, registry) + ).toThrow("already used"); }); - it("rejects an unknown, stale, or mismatched execution binding", () => { + it("rejects an unknown or stale execution binding and supplies canonical identity", () => { const registry = new PageRpcRegistry(); const handle = registry.register("script-a", "it", ["GM_getValue"]); @@ -71,8 +72,6 @@ describe("page GM RPC", () => { version: 1, requestId: "a", handle: "missing", - uuid: "script-a", - envTag: "it", api: "GM_getValue", params: [], }, @@ -82,24 +81,22 @@ describe("page GM RPC", () => { registry.revoke(handle); expect(() => - validatePageGMRequest( - { version: 1, requestId: "b", handle, uuid: "script-a", envTag: "it", api: "GM_getValue", params: [] }, - registry - ) + validatePageGMRequest({ version: 1, requestId: "b", handle, api: "GM_getValue", params: [] }, registry) ).toThrow(PageRpcError); const activeHandle = registry.register("script-a", "it", ["GM_getValue"]); + expect( + validatePageGMRequest( + { version: 1, requestId: "c", handle: activeHandle, api: "GM_getValue", params: [] }, + registry + ) + ).toMatchObject({ + uuid: "script-a", + envTag: "it", + }); expect(() => validatePageGMRequest( - { - version: 1, - requestId: "c", - handle: activeHandle, - uuid: "script-b", - envTag: "it", - api: "GM_getValue", - params: [], - }, + { version: 1, requestId: "d", handle: activeHandle, uuid: "script-b", api: "GM_getValue", params: [] }, registry ) ).toThrow(PageRpcError); @@ -112,8 +109,6 @@ describe("page GM RPC", () => { version: 1, requestId: "a", handle, - uuid: "script-a", - envTag: "it", api: "GM_getValue", params: [], }; @@ -121,10 +116,7 @@ describe("page GM RPC", () => { expect(() => validatePageGMRequest(accessorRequest, registry)).toThrow(PageRpcError); expect(() => - validatePageGMRequest( - { version: 1, requestId: "b", handle, uuid: "script-a", envTag: "it", api: "GM_setValue", params: [] }, - registry - ) + validatePageGMRequest({ version: 1, requestId: "b", handle, api: "GM_setValue", params: [] }, registry) ).toThrow(PageRpcError); expect(() => validatePageGMRequest( @@ -132,8 +124,6 @@ describe("page GM RPC", () => { version: 1, requestId: "c", handle, - uuid: "script-a", - envTag: "it", api: "GM_getValue", params: [() => undefined], }, diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index c3c83f3ee..fe65d6dbb 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -12,17 +12,28 @@ export type PageExecutionBinding = { readonly allowedAPIs: ReadonlySet; readonly runFlag: string; active: boolean; + requestIds: Set; }; export type PageGMRequest = { readonly version: typeof PAGE_RPC_VERSION; readonly requestId: string; readonly handle: string; + readonly api: string; + readonly params: readonly unknown[]; + /** Canonical identity filled by the isolated broker after handle resolution. */ readonly uuid: string; readonly envTag: ScriptEnvTag; + readonly runFlag: string; +}; + +/** The untrusted packet accepted from a MAIN-world script. */ +export type PageGMRequestPacket = { + readonly version: typeof PAGE_RPC_VERSION; + readonly requestId: string; + readonly handle: string; readonly api: string; readonly params: readonly unknown[]; - readonly runFlag: string; }; const INTERNAL_APIS_BY_GRANT: Readonly> = { @@ -141,6 +152,7 @@ export class PageRpcRegistry { allowedAPIs: new Set(allowedAPIs), runFlag, active: true, + requestIds: new Set(), }); return handle; } @@ -154,18 +166,25 @@ export class PageRpcRegistry { for (const binding of this.bindings.values()) binding.active = false; } - resolve(handle: string, uuid: string, envTag: ScriptEnvTag, api: string): PageExecutionBinding { + resolve(handle: string, api: string): PageExecutionBinding { const binding = this.bindings.get(handle); if (!binding?.active) throw new PageRpcError("page execution binding is inactive"); - if (binding.uuid !== uuid || binding.envTag !== envTag) { - throw new PageRpcError("page execution binding does not match the request"); - } if (!binding.allowedAPIs.has(api)) throw new PageRpcError("API is not granted to this execution"); return binding; } + + consumeRequestId(binding: PageExecutionBinding, requestId: string): void { + if (binding.requestIds.has(requestId)) throw new PageRpcError("page RPC requestId was already used"); + binding.requestIds.add(requestId); + // Keep a bounded replay window for long-lived documents. + if (binding.requestIds.size > 4096) { + const oldest = binding.requestIds.values().next().value; + if (oldest) binding.requestIds.delete(oldest); + } + } } -const REQUEST_KEYS = ["version", "requestId", "handle", "uuid", "envTag", "api", "params", "runFlag"] as const; +const REQUEST_KEYS = ["version", "requestId", "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"); @@ -176,9 +195,8 @@ export const validatePageGMRequest = (value: unknown, registry: PageRpcRegistry) } catch { throw new PageRpcError("page RPC request cannot be inspected"); } - const hasRunFlag = keys.includes("runFlag"); if ( - keys.length !== REQUEST_KEYS.length - (hasRunFlag ? 0 : 1) || + keys.length !== REQUEST_KEYS.length || keys.some((key) => typeof key !== "string" || !REQUEST_KEYS.includes(key as never)) ) { throw new PageRpcError("page RPC request has unexpected fields"); @@ -187,31 +205,26 @@ export const validatePageGMRequest = (value: unknown, registry: PageRpcRegistry) const version = ownData(value, "version"); const requestId = ownData(value, "requestId"); const handle = ownData(value, "handle"); - const uuid = ownData(value, "uuid"); - const envTag = ownData(value, "envTag"); const api = ownData(value, "api"); const params = ownData(value, "params"); - const suppliedRunFlag = keys.includes("runFlag") ? ownData(value, "runFlag") : undefined; if (version !== PAGE_RPC_VERSION) throw new PageRpcError("unsupported page RPC version"); if (typeof requestId !== "string" || !requestId) throw new PageRpcError("page RPC requestId is invalid"); - if (typeof handle !== "string" || typeof uuid !== "string" || typeof envTag !== "string" || typeof api !== "string") { + if (typeof handle !== "string" || typeof api !== "string") { throw new PageRpcError("page RPC identity fields are invalid"); } - if (envTag !== "it" && envTag !== "ct") throw new PageRpcError("page RPC environment is invalid"); - if (suppliedRunFlag !== undefined && typeof suppliedRunFlag !== "string") { - throw new PageRpcError("page RPC runFlag is invalid"); - } - const binding = registry.resolve(handle, uuid, envTag, api); + const binding = registry.resolve(handle, api); + const clonedParams = cloneParams(params); + registry.consumeRequestId(binding, requestId); return { version: PAGE_RPC_VERSION, requestId, handle, - uuid, - envTag, api, - params: cloneParams(params), + params: clonedParams, + uuid: binding.uuid, + envTag: binding.envTag, runFlag: binding.runFlag, }; }; diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 817dd2a23..46dcfaa22 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -8,6 +8,7 @@ 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"; export class ScriptRuntime { constructor( @@ -19,11 +20,22 @@ export class ScriptRuntime { ) {} // content环境的特殊初始化 - contentInit() { - this.server.on("runtime/addElement", (data: { params: [number | null, string, Record | null] }) => { + contentInit(domServer: Server = this.server, domMsg: CustomEventMessage = this.msg as CustomEventMessage) { + domServer.on("runtime/addElement", (data: { params: [number | null, string, Record | null] }) => { + if (!data || !Array.isArray(data.params) || data.params.length !== 3) return undefined; const [parentNodeId, tagName, tmpAttr] = data.params; - const msg = this.msg as CustomEventMessage; + 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 = domMsg; // 取回 parentNode(如果存在) let parentNode: Node | undefined; @@ -33,7 +45,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,6 +75,14 @@ export class ScriptRuntime { }); } + async loadPage() { + 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) this.startScripts(scripts, result.envInfo); + } + init() { this.server.on("runtime/emitEvent", (data: EmitEventRequest) => { // 转发给脚本 diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index 46a3f151f..1f73d12cf 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -54,12 +54,12 @@ export default class ScriptingRuntime { init() { this.extServer.on("runtime/emitEvent", (data) => { - // 转发给inject和content - return this.broadcastToPage("runtime/emitEvent", data); + // USER_SCRIPT receives private callbacks over its native extension port. + return this.broadcastToPage("runtime/emitEvent", data, PageOrContent.PAGE); }); this.extServer.on("runtime/valueUpdate", (data) => { - // 转发给inject和content - return this.broadcastToPage("runtime/valueUpdate", data); + // USER_SCRIPT receives private updates over its native extension port. + return this.broadcastToPage("runtime/valueUpdate", data, PageOrContent.PAGE); }); this.server.on("logger", (data: Logger) => { LoggerCore.logger().log(data.level, data.message, data.label); @@ -80,7 +80,7 @@ export default class ScriptingRuntime { 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 +91,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": { @@ -151,6 +151,10 @@ export default class ScriptingRuntime { params: [...request.params], runFlag: request.runFlag, executionHandle: request.handle, + version: 1 as const, + requestId: request.requestId, + handle: request.handle, + envTag: request.envTag, }; } ); @@ -167,7 +171,7 @@ export default class ScriptingRuntime { }); } // 向service_worker请求脚本列表及环境信息 - client.pageLoad().then((o) => { + client.pageLoad("it").then((o) => { if (!o.ok) return; const { injectScriptList, contentScriptList, envInfo } = o; this.pageRpc.revokeAll(); @@ -195,12 +199,6 @@ export default class ScriptingRuntime { this.activeStorageNames = new Map(Object.entries(pairs)); // 向页面 发送脚本列表及环境信息 - if (preparedContentScriptList.length) { - const contentClient = new Client(this.senderToContent, "content"); - // 根据@inject-into content过滤脚本 - contentClient.do("pageLoad", { scripts: preparedContentScriptList, envInfo }); - } - if (preparedInjectScriptList.length) { const injectClient = new Client(this.senderToInject, "inject"); // 根据@inject-into content过滤脚本 diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index 51ca3b255..0dd9ec2df 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -501,6 +501,19 @@ 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"]); + }); }); describe("compileScript", () => { diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index d07a2161c..3caef5952 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -8,6 +8,26 @@ import { embeddedPatternCheckerString, type EmbeddedURLRuleEntry, type URLRuleEn import { parseResourceDeclaration } from "@App/pkg/utils/resource"; import { getGrantCandidates } from "./gm_api/grant"; +const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; +const nativeJSONStringify = JSON.stringify.bind(JSON); +const nativeJSONParse = JSON.parse.bind(JSON); + +const cloneTransportValue = (value: any) => { + if (value === null || typeof value !== "object") return value; + if (nativeStructuredClone) { + try { + return nativeStructuredClone(value); + } catch { + // Fall through for objects such as proxies that structuredClone rejects. + } + } + try { + return nativeJSONParse(nativeJSONStringify(value)); + } catch { + return undefined; + } +}; + const lnStrIntegrity = process.env.SC_RANDOM_FNKEY; const znRand = process.env.SC_ZN_RAND; @@ -237,7 +257,18 @@ 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, + 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; @@ -282,9 +313,12 @@ export function compilePreInjectScript( const evEnvLoad = `${eventNamePrefix}${DefinedFlags.envLoadComplete}`; return `${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`)}; { - let o = { cancelable: true, detail: { scriptFlag: '${flag}', scriptInfo: (${scriptInfoJSON}) } }, - c = typeof cloneInto === "function" ? cloneInto(o, performance) : o, - f = () => ${urlCondition} && performance.dispatchEvent(new CustomEvent('${evScriptLoad}', c)), + let f = () => { + if (!(${urlCondition})) return false; + const o = { cancelable: true, detail: { scriptFlag: '${flag}', scriptInfo: (${scriptInfoJSON}) } }, + 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 }); } diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 07ea7b6fb..c70c1b806 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -339,8 +339,8 @@ export class RuntimeClient extends Client { return this.do("stopScript", uuid); } - pageLoad(): Promise { - return this.doThrow("pageLoad"); + pageLoad(envTag?: "it" | "ct"): Promise { + return this.doThrow("pageLoad", envTag ? { envTag } : undefined); } /** bfcache 还原上报:只告知本页仍在运行,不请求脚本 */ diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 51b9f948a..8b1b01436 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1162,6 +1162,63 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { expect(runtime.resolvePageExecutionBinding(firstHandle!, sender)).toBeUndefined(); expect(runtime.resolvePageExecutionBinding(secondHandle!, secondSender)).toBeUndefined(); }); + + it("content USER_SCRIPT 的 pageLoad 只轮换 content 绑定", async () => { + const { runtime } = _createRuntimeContext(); + const inject = _createScriptRunResource(_createMockScript({ uuid: "inject-script" })); + const content = _createScriptRunResource(_createMockScript({ uuid: "content-script" })); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [inject], + contentScriptList: [content], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + const sender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const first = await runtime.pageLoad(undefined, sender); + expect(first.ok).toBe(true); + if (!first.ok) return; + const injectHandle = first.injectScriptList[0].executionHandle; + const contentLoad = await runtime.pageLoad({ envTag: "ct" }, sender); + + expect(contentLoad.ok).toBe(true); + if (!contentLoad.ok) return; + expect(contentLoad.injectScriptList).toEqual([]); + expect(contentLoad.contentScriptList[0].executionHandle).toEqual(expect.any(String)); + expect(runtime.resolvePageExecutionBinding(injectHandle!, sender)).toBeDefined(); + }); + + it("isolated scripting 的 pageLoad 不会撤销已建立的 content 绑定", async () => { + const { runtime } = _createRuntimeContext(); + const inject = _createScriptRunResource(_createMockScript({ uuid: "inject-script" })); + const content = _createScriptRunResource(_createMockScript({ uuid: "content-script" })); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [inject], + contentScriptList: [content], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + const sender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const contentLoad = await runtime.pageLoad({ envTag: "ct" }, sender); + expect(contentLoad.ok).toBe(true); + if (!contentLoad.ok) return; + const contentHandle = contentLoad.contentScriptList[0].executionHandle; + const injectLoad = await runtime.pageLoad({ envTag: "it" }, sender); + + expect(injectLoad.ok).toBe(true); + expect(runtime.resolvePageExecutionBinding(contentHandle!, sender)).toBeDefined(); + }); }); describe("sandbox verified 初始化重放", () => { diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 77228c91b..5e5d4cd11 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -6,8 +6,8 @@ import type { ServiceWorkerExecutionBinding, } from "./types"; import type { IMessageQueue } from "@Packages/message/message_queue"; -import type { Group, IGetSender } from "@Packages/message/server"; -import type { ExtMessageSender, MessageSend } from "@Packages/message/types"; +import { GetSenderType, type Group, type IGetSender } from "@Packages/message/server"; +import type { ExtMessageSender, MessageConnect, MessageSend } from "@Packages/message/types"; import type { TClientPageLoadInfo } from "@App/app/repo/scripts"; import type { Script, ScriptDAO, ScriptRunResource, ScriptSite, TScriptInfo, UserConfig } from "@App/app/repo/scripts"; import { SCRIPT_STATUS_DISABLE, SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts"; @@ -142,12 +142,16 @@ export class RuntimeService { blackMatch: UrlMatch = new UrlMatch(); private gmApi?: GMApi; private readonly pageExecutionBindings = new Map(); + private readonly userScriptConnections = new Map< + string, + { connection: MessageConnect; tabId: number; frameId?: number; documentId?: string } + >(); getGMApi(): GMApi | undefined { return this.gmApi; } - private revokePageBindings(sender: IGetSender): void { + private revokePageBindings(sender: IGetSender, envTag?: "it" | "ct"): void { const source = sender.getSender(); const tabId = source?.tab?.id; const frameId = source?.frameId; @@ -156,6 +160,7 @@ export class RuntimeService { if ( binding.tabId === tabId && binding.frameId === frameId && + (envTag === undefined || binding.envTag === envTag) && (documentId === undefined || binding.documentId === documentId) ) { this.pageExecutionBindings.delete(handle); @@ -167,6 +172,54 @@ export class RuntimeService { for (const [handle, binding] of this.pageExecutionBindings) { if (binding.tabId === tabId) this.pageExecutionBindings.delete(handle); } + for (const [key, entry] of this.userScriptConnections) { + if (entry.tabId === tabId) { + entry.connection.disconnect(true); + this.userScriptConnections.delete(key); + } + } + } + + private userScriptConnectionKey(tabId: number, frameId?: number, documentId?: string): string { + return `${tabId}:${frameId ?? -1}:${documentId ?? ""}`; + } + + /** Register the native USER_SCRIPT channel used for private bootstrap and callbacks. */ + registerUserScriptConnection(_: unknown, sender: IGetSender): boolean { + if (!sender.isType(GetSenderType.EXTCONNECT)) return false; + const source = sender.getSender(); + const connection = sender.getConnect(); + const tabId = source?.tab?.id; + if (!source || typeof tabId !== "number" || !connection) return false; + const frameId = source.frameId; + const documentId = source.documentId; + const key = this.userScriptConnectionKey(tabId, frameId, documentId); + const previous = this.userScriptConnections.get(key); + if (previous) previous.connection.disconnect(true); + const entry = { connection, tabId, frameId, documentId }; + this.userScriptConnections.set(key, entry); + connection.onDisconnect(() => { + if (this.userScriptConnections.get(key)?.connection === connection) this.userScriptConnections.delete(key); + }); + return true; + } + + private sendUserScriptMessage(to: ExtMessageSender | undefined, action: string, data: unknown): void { + for (const [key, entry] of this.userScriptConnections) { + if ( + to && + (entry.tabId !== to.tabId || + (to.frameId !== undefined && entry.frameId !== to.frameId) || + (to.documentId !== undefined && entry.documentId !== to.documentId)) + ) { + continue; + } + try { + entry.connection.sendMessage({ action: `content/${action}`, data }); + } catch { + this.userScriptConnections.delete(key); + } + } } private revokePageBindingsForScript(uuid: string): void { @@ -541,6 +594,9 @@ export class RuntimeService { sendData, }, }); + // USER_SCRIPT cannot observe the scripting world's page broadcast. Deliver the + // same encoded DTO over its native extension connection instead. + this.sendUserScriptMessage(undefined, "runtime/valueUpdate", sendData); // 後台腳本 if (bgScriptStorageNames.has(sendData.storageName)) { @@ -600,6 +656,7 @@ export class RuntimeService { this.group.on("runScript", this.runScript.bind(this)); this.group.on("pageLoad", this.pageLoad.bind(this)); this.group.on("pageShow", this.pageShow.bind(this)); + this.group.on("registerUserScript", this.registerUserScriptConnection.bind(this)); // 监听脚本开启 this.mq.subscribe("enableScripts", async (data) => { @@ -912,6 +969,10 @@ export class RuntimeService { // 取消脚本注册 async unregisterUserscripts() { this.pageExecutionBindings.clear(); + for (const [key, entry] of this.userScriptConnections) { + entry.connection.disconnect(true); + this.userScriptConnections.delete(key); + } // 检查 registered 避免重复操作增加系统开支 // 已成功注册(true)或是未知有无注册(null)的情况下执行 if (runtimeGlobal.registerState !== RuntimeRegisterCode.UNREGISTER_DONE) { @@ -1228,6 +1289,7 @@ export class RuntimeService { // 如果是-1, 代表给offscreen发送消息 return sendMessage(this.msgSender, "offscreen/runtime/emitEvent", req); } + this.sendUserScriptMessage(to, "runtime/emitEvent", req); return sendMessage( new ExtensionContentMessageSend(to.tabId, { documentId: to.documentId, @@ -1334,7 +1396,7 @@ export class RuntimeService { } } - async pageLoad(_: any, sender: IGetSender): Promise { + async pageLoad(data: { envTag?: "it" | "ct" } | undefined, sender: IGetSender): Promise { const chromeSender = sender.getSender(); const url = chromeSender?.url; if (!url) { @@ -1354,7 +1416,7 @@ export class RuntimeService { }); if (res) { - this.revokePageBindings(sender); + this.revokePageBindings(sender, data?.envTag); const prepareScripts = (scripts: TScriptInfo[], envTag: "it" | "ct") => scripts.map((script) => { const binding = this.issuePageBinding(script.uuid, envTag, sender); @@ -1368,8 +1430,8 @@ export class RuntimeService { // 返回脚本资料,在页面加载 return { ok: true, - injectScriptList: prepareScripts(res.injectScriptList, "it"), - contentScriptList: prepareScripts(res.contentScriptList, "ct"), + injectScriptList: data?.envTag === "ct" ? [] : prepareScripts(res.injectScriptList, "it"), + contentScriptList: data?.envTag === "it" ? [] : prepareScripts(res.contentScriptList, "ct"), envInfo: res.envInfo, }; } else { diff --git a/src/content.ts b/src/content.ts index 094f345ac..1523872ad 100644 --- a/src/content.ts +++ b/src/content.ts @@ -1,5 +1,6 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; +import { ExtensionMessage } from "@Packages/message/extension_message"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; import { Server } from "@Packages/message/server"; import { ScriptExecutor } from "./app/service/content/script_executor"; @@ -14,7 +15,11 @@ const messageFlag = process.env.SC_RANDOM_KEY!; getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | undefined) => { const scriptEnvTag = ScriptEnvTag.content; - const msg: Message = new CustomEventMessage(eventFlag, false, scriptEnvTag); + // USER_SCRIPT has a native extension messaging channel. Keep the DOM channel only + // for the synchronous element helper, whose node references must remain in this realm. + const msg: Message = new ExtensionMessage(false); + const domMsg = new CustomEventMessage(eventFlag, false, scriptEnvTag); + const domContentMsg = new CustomEventMessage(eventFlag, true, scriptEnvTag); // 初始化日志组件 const logger = new LoggerCore({ @@ -26,8 +31,24 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde logger.logger().debug("content start"); const server = new Server("content", msg); - const scriptExecutor = new ScriptExecutor(msg, new CustomEventMessage(eventFlag, true, scriptEnvTag)); + const domServer = new Server("content", domMsg); + const scriptExecutor = new ScriptExecutor(msg, domContentMsg); const runtime = new ScriptRuntime(scriptEnvTag, server, msg, scriptExecutor, extensionEnv); - runtime.contentInit(); + runtime.contentInit(domServer, domMsg); runtime.init(); + // Keep a native port for callbacks and value updates. The page-observable event + // channel remains limited to the synchronous DOM helper. + void chrome.runtime.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" }); + void msg + .connect({ action: "serviceWorker/runtime/registerUserScript", data: { world: "USER_SCRIPT" } }) + .then((connection) => { + connection.onMessage((packet) => { + if (packet.action === "content/runtime/valueUpdate") { + scriptExecutor.valueUpdate(packet.data as any); + } else if (packet.action === "content/runtime/emitEvent") { + scriptExecutor.emitEvent(packet.data as any); + } + }); + }); + void runtime.loadPage(); }); From 3037623a47b83b99cbded17981a701978d836bd3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:34:27 +0900 Subject: [PATCH 005/103] =?UTF-8?q?=F0=9F=90=9B=20route=20USER=5FSCRIPT=20?= =?UTF-8?q?GM=20calls=20to=20service=20worker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/script_executor.test.ts | 22 +++++++++++++++++++ src/app/service/content/script_executor.ts | 5 +++-- src/content.ts | 2 +- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index 1d4b8ff97..a43855e32 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -33,6 +33,28 @@ function makeScript(overrides: Partial { + 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; diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 32e9cb7d1..216df2b06 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -37,7 +37,8 @@ export class ScriptExecutor { constructor( private msg: Message, - private contentMsg: Message // 用于 content <-> content/inject 通讯 + private contentMsg: Message, // 用于 content <-> content/inject 通讯 + private readonly envPrefix = "scripting" ) {} emitEvent(data: EmitEventRequest) { @@ -188,7 +189,7 @@ 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, diff --git a/src/content.ts b/src/content.ts index 1523872ad..f6e05a5c1 100644 --- a/src/content.ts +++ b/src/content.ts @@ -32,7 +32,7 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde const server = new Server("content", msg); const domServer = new Server("content", domMsg); - const scriptExecutor = new ScriptExecutor(msg, domContentMsg); + const scriptExecutor = new ScriptExecutor(msg, domContentMsg, "serviceWorker"); const runtime = new ScriptRuntime(scriptEnvTag, server, msg, scriptExecutor, extensionEnv); runtime.contentInit(domServer, domMsg); runtime.init(); From 25714ff6edc4d5b8d6e475cf0253b70cb72f4faa Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:35:20 +0900 Subject: [PATCH 006/103] =?UTF-8?q?=F0=9F=A7=B9=20format=20USER=5FSCRIPT?= =?UTF-8?q?=20transport=20regression=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/script_executor.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index a43855e32..1497ec3e4 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -36,11 +36,7 @@ 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" - ); + const executor = new ScriptExecutor({ sendMessage } as unknown as Message, {} as Message, "serviceWorker"); executor.execScriptEntry({ scriptLoadInfo: script, From a46b04285ba818d80f35e1d4b04734ae36e4459f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:29:35 +0900 Subject: [PATCH 007/103] =?UTF-8?q?=F0=9F=94=92=20harden=20page=20and=20US?= =?UTF-8?q?ER=5FSCRIPT=20execution=20bindings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/references/architecture-execution.md | 4 +- packages/message/extension_message.ts | 23 +++++-- packages/message/server.ts | 9 +++ .../service/content/create_context.test.ts | 33 +++++++++ src/app/service/content/create_context.ts | 59 ++++++++-------- src/app/service/content/exec_script.ts | 7 +- src/app/service/content/global.ts | 8 +++ src/app/service/content/gm_api/gm_api.ts | 5 +- src/app/service/content/page_rpc.test.ts | 16 +++++ src/app/service/content/page_rpc.ts | 49 ++++++++++--- .../service/content/script_executor.test.ts | 17 +++++ src/app/service/content/script_executor.ts | 9 +++ src/app/service/content/script_runtime.ts | 7 +- .../content/user_script_connection.test.ts | 42 ++++++++++++ .../service/content/user_script_connection.ts | 22 ++++++ .../service_worker/gm_api/gm_api.test.ts | 45 ++++++++++++ .../service/service_worker/gm_api/gm_api.ts | 7 ++ .../service/service_worker/runtime.test.ts | 68 ++++++++++++++++++- src/app/service/service_worker/runtime.ts | 59 ++++++++++++++-- src/app/service/service_worker/types.ts | 4 ++ src/content.ts | 17 ++--- 21 files changed, 442 insertions(+), 68 deletions(-) create mode 100644 src/app/service/content/user_script_connection.test.ts create mode 100644 src/app/service/content/user_script_connection.ts diff --git a/docs/references/architecture-execution.md b/docs/references/architecture-execution.md index 782c08492..ed4d51fee 100644 --- a/docs/references/architecture-execution.md +++ b/docs/references/architecture-execution.md @@ -28,8 +28,8 @@ Key points: view of globals — not the raw page scope. It is a compatibility projection rather than a security membrane. - 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`). +- The wrapper installs the body as a temporary method and removes it in the same expression. This preserves the + userscript `this` without resolving mutable page `call`, `apply`, or `bind` properties. ### Path A — Page scripts → `chrome.userScripts` diff --git a/packages/message/extension_message.ts b/packages/message/extension_message.ts index e0702c98f..71869a9f6 100644 --- a/packages/message/extension_message.ts +++ b/packages/message/extension_message.ts @@ -50,7 +50,7 @@ export class ExtensionMessage implements Message { if (port !== null) { myPort = null; port.onMessage.removeListener(handler); - callback(msg, new ExtensionMessageConnect(port)); + callback(msg, new ExtensionMessageConnect(port, "extension")); } }; myPort.onMessage.addListener(handler); @@ -71,7 +71,7 @@ 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); @@ -130,12 +130,18 @@ 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; } + if ((msg as any)?.type === "userScripts.LISTEN_CONNECTIONS" && this.backgroundPrimary) { + this.tryEnableUserScriptConnectionListener(); + this.tryEnableUserScriptMessageListener(); + sendResponse(true); + return false; + } + if (typeof msg.action !== "string") return; return callback(msg, sendResponse, sender); }); addUserScriptMessageListener = null; @@ -160,7 +166,10 @@ export class ExtensionMessageConnect implements MessageConnect { private con: chrome.runtime.Port | null; private isSelfDisconnected = false; - constructor(con: chrome.runtime.Port) { + constructor( + con: chrome.runtime.Port, + private readonly origin: "extension" | "userScript" = "extension" + ) { this.con = con; // 强引用 const handler = (msg: TMessage, _con: chrome.runtime.Port) => { listenerMgr.emit(`onMessage:${this.listenerId}`, msg); @@ -229,6 +238,10 @@ export class ExtensionMessageConnect implements MessageConnect { } return this.con; } + + getOrigin(): "extension" | "userScript" { + return this.origin; + } } export class ExtensionContentMessageSend implements MessageSend { @@ -269,7 +282,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/server.ts b/packages/message/server.ts index 0c4682c23..fa16871f5 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -20,6 +20,7 @@ export interface IGetSender { getSender(): RuntimeMessageSender | undefined; getExtMessageSender(): ExtMessageSender; getConnect(): MessageConnect | undefined; + getConnectOrigin?(): "extension" | "userScript" | undefined; } export class SenderConnect { @@ -70,6 +71,10 @@ export class SenderConnect { getConnect(): MessageConnect { return this.sender; } + + getConnectOrigin(): "extension" | "userScript" | undefined { + return this.sender instanceof ExtensionMessageConnect ? this.sender.getOrigin() : undefined; + } } export class SenderRuntime { @@ -112,6 +117,10 @@ export class SenderRuntime { getConnect(): undefined { return undefined; } + + getConnectOrigin(): undefined { + return undefined; + } } type ApiFunction = (params: any, con: IGetSender) => Promise | any | void; diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index f5ccee32b..702cb00d9 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -338,6 +338,22 @@ describe("createContext: capability and lifecycle contract", () => { } }); + 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; @@ -487,6 +503,23 @@ describe("createContext: capability and lifecycle contract", () => { 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", () => { diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 0fb81cbf2..5afe54997 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -12,7 +12,7 @@ import { Native } from "./global"; const createCapability = (api: (...args: any[]) => any, receiver: object) => { const capability = (...args: any[]) => Native.reflectApply(api, receiver, args); - Object.defineProperty(capability, "name", { configurable: true, value: `bound ${api.name}` }); + Native.objectDefineProperty(capability, "name", { configurable: true, value: `bound ${api.name}` }); return capability; }; @@ -39,7 +39,7 @@ export const createContext = ( }); } let invalid = false; - const GM = Object.create(null); + const GM = Native.objectCreate(null); GM.info = GMInfo; const context = createGMBase({ prefix: envPrefix, @@ -52,8 +52,8 @@ export const createContext = ( eventId: 10000, GM: GM, GM_info: GMInfo, - window: Object.create(null), - grantSet: new Set(), + window: Native.objectCreate(null), + grantSet: new Native.Set(), loadScriptPromise, loadScriptResolve, setInvalidContext() { @@ -72,7 +72,7 @@ export const createContext = ( return invalid; }, }); - const grantedAPIs: { [key: string]: any } = Object.create(null); + const grantedAPIs: { [key: string]: any } = Native.objectCreate(null); const __methodInject__ = (grant: string): boolean => { const grantSet: Set = context.grantSet; const s = GMContextApiGet(grant); @@ -96,7 +96,7 @@ export const createContext = ( } } // 兼容GM.Cookie.* - for (const fnKey of Object.keys(grantedAPIs)) { + for (const fnKey of Native.objectKeys(grantedAPIs)) { const fnKeyArray = fnKey.split("."); const m = fnKeyArray.length; let g = context; @@ -104,7 +104,7 @@ export const createContext = ( 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; @@ -168,8 +168,8 @@ 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); + for (const key of Native.reflectOwnKeys(descs)) { callback(key, descs[key as keyof typeof descs]); } obj = Object.getPrototypeOf(obj); @@ -214,25 +214,25 @@ export type RealmRoots = { 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); + const protoBaseDescs: DescriptorMap = Native.objectCreate(null); const collectRealmDescriptors = () => { // 只读取 realmGlobal own descriptors,避免混合 Firefox 的两个 realm。 - const descriptors = Object.getOwnPropertyDescriptors(realmGlobal); - for (const key of Object.keys(descriptors)) { + const descriptors = Native.objectGetOwnPropertyDescriptors(realmGlobal); + for (const key of Native.objectKeys(descriptors)) { const desc = descriptors[key]; if (descsCache.has(key)) continue; descsCache.add(key); // realm own descriptors take precedence over host descriptors @@ -278,7 +278,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; @@ -330,13 +330,13 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn }); const sharedInitCopy = USE_PSEUDO_WINDOW - ? Object.create(null, { + ? Native.objectCreate(null, { ...protoBaseDescs, // 较快的 @unwrap 注入时有机会改变 EventTarget.prototype - ...Object.getOwnPropertyDescriptors(PseudoWindowPrototype), + ...Native.objectGetOwnPropertyDescriptors(PseudoWindowPrototype), ...initOwnDescs, ...overriddenDescs, }) - : Object.create(Object.getPrototypeOf(realmGlobal), { + : Native.objectCreate(Native.objectGetPrototypeOf(realmGlobal), { ...initOwnDescs, ...overriddenDescs, }); @@ -347,7 +347,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn const defaultGlobalSnapshot = createGlobalSnapshot({ realmGlobal: global, hostWindow: window }); // 把沙盒的 console 和网页的 console 隔离 -const initConsoleDescs = Object.getOwnPropertyDescriptors(console); +const initConsoleDescs = Native.objectGetOwnPropertyDescriptors(console); const ConsolePrototype = Object.getPrototypeOf(console); type GMWorldContext = typeof globalThis & Record; @@ -364,7 +364,7 @@ export const createProxyContext = ( const { sharedInitCopy, eventKeys } = roots.realmGlobal === global && roots.hostWindow === window ? defaultGlobalSnapshot : createGlobalSnapshot(roots); - const ownDescs = Object.getOwnPropertyDescriptors(sharedInitCopy); + const ownDescs = Native.objectGetOwnPropertyDescriptors(sharedInitCopy); // mySandbox: ScriptCat各脚本独自使用 let mySandbox: typeof sharedInitCopy | undefined = undefined; @@ -436,7 +436,7 @@ export const createProxyContext = ( } for (const key of ["top", "parent", "frames"]) { 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] = { @@ -444,7 +444,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, @@ -477,16 +477,15 @@ 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 & + mySandbox = Native.objectCreate(Native.objectGetPrototypeOf(sharedInitCopy), ownDescs) as typeof globalThis & Record; // 处理特殊关键字,不能穿越出沙盒,也不能被外部修改 @@ -498,7 +497,7 @@ export const createProxyContext = ( // 把 GM Api (或其他全域API) 复制到 脚本window // 请手动检查避开key,防止与window的属性setter有冲突 或 属性名重复 - for (const key of Object.keys(context)) { + for (const key of Native.objectKeys(context)) { if (key in protect || key === "window") continue; mySandbox[key] = context[key]; // window以外 } @@ -526,7 +525,7 @@ export const createProxyContext = ( } // 从网页 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.ts b/src/app/service/content/exec_script.ts index febd9f01e..3213398a7 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -8,6 +8,7 @@ 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 { Native } from "./global"; const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; @@ -50,7 +51,7 @@ export default class ExecScript { } else { this.scriptFunc = code; } - const grantSet = new Set(scriptRes.metadata.grant || []); + const grantSet = new Native.Set(scriptRes.metadata.grant || []); if (isContextMenuScript(scriptRes.metadata)) { grantSet.add("GM_registerMenuCommand"); grantSet.delete("none"); @@ -59,14 +60,14 @@ export default class ExecScript { // 不注入任何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); if (globalInjection) { - Object.assign(this.sandboxContext, globalInjection); + Native.objectAssign(this.sandboxContext, globalInjection); } } } diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index a431a025c..ada53358d 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -15,6 +15,8 @@ export const nativeBind = (fn: (...args: any[]) => any, receiver: any, ...args: nativeReflectApply(nativeFunctionBind, fn, [receiver, ...args]); export const Native = { + Set, + Map, apply: nativeApply, call: nativeCall, bind: nativeBind, @@ -25,9 +27,15 @@ export const Native = { createElement: Document.prototype.createElement, ownFragment: new DocumentFragment(), objectCreate: nativeBind(Object.create, Object), + objectAssign: nativeBind(Object.assign, Object), + 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) => { diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 154a27529..a9b2c4369 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -129,7 +129,7 @@ class GM_Base implements IGM_Base { constructor(options: any = null, obj: any = null) { if (obj !== integrity) throw new TypeError("Illegal invocation"); - Object.assign(this, options); + Native.objectAssign(this, options); } @GMContext.protected() @@ -254,7 +254,8 @@ 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); + const callbackData = data && typeof data === "object" ? customClone(data) : data; + this.EE.emit(`${event}:${eventId}`, callbackData); } } diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 10c78f5c8..111fb85e2 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -131,4 +131,20 @@ describe("page GM RPC", () => { ) ).toThrow(PageRpcError); }); + + it("rejects malformed parameters for privileged helper operations", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["CAT_fetchBlob"]); + + expect(() => + validatePageGMRequest({ version: 1, requestId: "a", handle, api: "CAT_fetchBlob", params: [42] }, registry) + ).toThrow("CAT_fetchBlob expects a URL string"); + + expect( + validatePageGMRequest( + { version: 1, requestId: "a", handle, api: "CAT_fetchBlob", params: ["https://example.com/file"] }, + registry + ).params + ).toEqual(["https://example.com/file"]); + }); }); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index fe65d6dbb..a2dca1636 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -3,6 +3,7 @@ import type { ScriptEnvTag } from "@Packages/message/consts"; import { getGrantCandidates } from "./gm_api/grant"; export const PAGE_RPC_VERSION = 1 as const; +const MAX_REQUEST_ID_LENGTH = 256; const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; export type PageExecutionBinding = { @@ -132,6 +133,39 @@ const cloneParams = (params: unknown): readonly unknown[] => { } }; +const validateOperationParams = (api: string, params: readonly unknown[]): void => { + switch (api) { + case "CAT_fetchBlob": + if (params.length !== 1 || typeof params[0] !== "string") { + throw new PageRpcError("CAT_fetchBlob expects a URL string"); + } + return; + case "CAT_createBlobUrl": + if (params.length !== 1 || params[0] === null || typeof params[0] !== "object") { + 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" || + Array.isArray(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 Map(); @@ -158,12 +192,11 @@ export class PageRpcRegistry { } revoke(handle: string): void { - const binding = this.bindings.get(handle); - if (binding) binding.active = false; + this.bindings.delete(handle); } revokeAll(): void { - for (const binding of this.bindings.values()) binding.active = false; + this.bindings.clear(); } resolve(handle: string, api: string): PageExecutionBinding { @@ -176,11 +209,6 @@ export class PageRpcRegistry { consumeRequestId(binding: PageExecutionBinding, requestId: string): void { if (binding.requestIds.has(requestId)) throw new PageRpcError("page RPC requestId was already used"); binding.requestIds.add(requestId); - // Keep a bounded replay window for long-lived documents. - if (binding.requestIds.size > 4096) { - const oldest = binding.requestIds.values().next().value; - if (oldest) binding.requestIds.delete(oldest); - } } } @@ -209,13 +237,16 @@ export const validatePageGMRequest = (value: unknown, registry: PageRpcRegistry) const params = ownData(value, "params"); if (version !== PAGE_RPC_VERSION) throw new PageRpcError("unsupported page RPC version"); - if (typeof requestId !== "string" || !requestId) throw new PageRpcError("page RPC requestId is invalid"); + if (typeof requestId !== "string" || !requestId || requestId.length > MAX_REQUEST_ID_LENGTH) { + throw new PageRpcError("page RPC requestId is invalid"); + } if (typeof handle !== "string" || typeof api !== "string") { throw new PageRpcError("page RPC identity fields are invalid"); } const binding = registry.resolve(handle, api); const clonedParams = cloneParams(params); + validateOperationParams(api, clonedParams); registry.consumeRequestId(binding, requestId); return { version: PAGE_RPC_VERSION, diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index 1497ec3e4..8c1816cff 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -168,6 +168,23 @@ describe("ScriptExecutor", () => { } }); + 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 genuine = vi.fn(); + const pageWindow = window as unknown as Record; + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + + try { + pageWindow[script.flag] = genuine; + executor.execEarlyScript(script.flag, { ...script, uuid: "other-script" }, initEnvInfo); + executor.execEarlyScript(script.flag, { ...script, executionHandle: "other-binding" }, initEnvInfo); + expect(genuine).not.toHaveBeenCalled(); + } finally { + delete pageWindow[script.flag]; + } + }); + 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 216df2b06..64aab20f6 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -170,6 +170,15 @@ export class ScriptExecutor { } execEarlyScript(flag: string, scriptInfo: TScriptInfo, envInfo: GMInfoEnv) { + const expectedUuid = flag.startsWith("#-") ? flag.slice(2) : undefined; + if ( + (expectedUuid && scriptInfo.uuid !== expectedUuid) || + scriptInfo.executionHandle !== undefined || + scriptInfo.executionEnvTag !== undefined || + scriptInfo.executionRunFlag !== undefined + ) { + return; + } const scriptFunc = (window as unknown as Record)[flag] as ScriptFunc; const descriptor = typeof scriptFunc === "function" ? Native.objectGetOwnPropertyDescriptor(scriptFunc, fnStrIntegrity) : undefined; diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 46dcfaa22..87fba6f5b 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -75,12 +75,15 @@ export class ScriptRuntime { }); } - async loadPage() { + 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) this.startScripts(scripts, result.envInfo); + if (scripts.length) { + await beforeStart?.(scripts); + this.startScripts(scripts, result.envInfo); + } } init() { 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..dc78b5cb4 --- /dev/null +++ b/src/app/service/content/user_script_connection.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Message, MessageConnect, TMessage } from "@Packages/message/types"; +import { connectUserScriptChannel } 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, ["handle-a"], vi.fn()); + + expect(order).toEqual(["send:userScripts.LISTEN_CONNECTIONS", "connect:serviceWorker/runtime/registerUserScript"]); + expect(connection.onMessage).toHaveBeenCalledOnce(); + }); + + it("does not open a port when the browser cannot enable USER_SCRIPT listeners", async () => { + const message = { + sendMessage: vi.fn().mockResolvedValue(false), + connect: vi.fn(), + } as unknown as Message; + + await expect(connectUserScriptChannel(message, ["handle-a"], vi.fn())).resolves.toBeUndefined(); + expect(message.connect).not.toHaveBeenCalled(); + }); +}); 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..2933b383b --- /dev/null +++ b/src/app/service/content/user_script_connection.ts @@ -0,0 +1,22 @@ +import type { Message, MessageConnect, TMessage } from "@Packages/message/types"; + +type UserScriptPacketHandler = (connection: MessageConnect, packet: TMessage) => void; + +/** + * 先让 service worker 开启 USER_SCRIPT 监听,再建立连接;浏览器可能立即投递端口, + * 并发执行两步会丢失首个连接。 + */ +export async function connectUserScriptChannel( + message: Message, + executionHandles: readonly string[], + onPacket: UserScriptPacketHandler +): Promise { + const enabled = await message.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" } as unknown as TMessage); + if (enabled === false) return undefined; + const connection = await message.connect({ + action: "serviceWorker/runtime/registerUserScript", + data: { world: "USER_SCRIPT", executionHandles }, + }); + connection.onMessage((packet) => onPacket(connection, packet)); + return connection; +} 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 c2b811bfe..696f764c0 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 @@ -152,6 +152,51 @@ describe("page execution binding gate", () => { ).rejects.toThrow("page execution binding is invalid"); expect(resolveBinding).toHaveBeenCalledTimes(1); }); + + it("rejects a replayed page request id 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, + requestIds: new Set(), + }; + 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: "script-a", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + executionHandle: "handle-a", + requestId: "request-a", + version: 1 as const, + }; + await expect(api.handlerRequest(request, sender)).resolves.toBe(true); + await expect(api.handlerRequest(request, sender)).rejects.toThrow("page RPC requestId was already used"); + }); }); describe("window.focus", () => { 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 dcf4314bc..b3654e1e9 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -395,6 +395,13 @@ export default class GMApi { if (!binding || (data.uuid && data.uuid !== binding.uuid)) { throw new Error("page execution binding is invalid"); } + if (typeof data.requestId !== "string" || !data.requestId || data.requestId.length > 256) { + throw new Error("page RPC requestId is invalid"); + } + if (binding.requestIds.has(data.requestId)) { + throw new Error("page RPC requestId was already used"); + } + binding.requestIds.add(data.requestId); if (data.envTag !== undefined && data.envTag !== binding.envTag) { throw new Error("page execution binding is invalid"); } diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 8b1b01436..18514c687 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -20,7 +20,7 @@ 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 { getStorageName, obtainBlackList } from "@App/pkg/utils/utils"; import type { CompiledResource, Resource } from "@App/app/repo/resource"; initTestEnv(); @@ -1221,6 +1221,72 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { }); }); +describe("USER_SCRIPT native callbacks", () => { + it("只向当前文档中声明了对应脚本或 storageName 的连接投递更新", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "content-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [], + contentScriptList: [script], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const rawSender = { + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const sendMessage = vi.fn(); + const connection = { + onMessage: vi.fn(), + sendMessage, + disconnect: vi.fn(), + onDisconnect: vi.fn(), + } as unknown as MessageConnect; + const connectionSender = { + getType: () => 3, + isType: () => true, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => connection, + getConnectOrigin: () => "userScript" as const, + }; + + await runtime.pageLoad({ envTag: "ct" }, new SenderRuntime(rawSender)); + const contentBindings = [...(runtime as any).pageExecutionBindings.values()] as Array<{ handle: string }>; + const handles = contentBindings.map(({ handle }) => handle); + expect(handles).toHaveLength(1); + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", executionHandles: handles }, + { ...connectionSender, getConnectOrigin: () => "extension" as const } + ) + ).toBe(false); + expect(runtime.registerUserScriptConnection({ world: "USER_SCRIPT" }, connectionSender)).toBe(false); + expect( + runtime.registerUserScriptConnection({ world: "USER_SCRIPT", executionHandles: handles }, connectionSender) + ).toBe(true); + + const sendUserScriptMessage = (runtime as any).sendUserScriptMessage.bind(runtime); + sendUserScriptMessage(undefined, "runtime/valueUpdate", { + uuid: "other-script", + storageName: getStorageName(script), + }); + expect(sendMessage).toHaveBeenCalledTimes(1); + + sendMessage.mockClear(); + sendUserScriptMessage(undefined, "runtime/valueUpdate", { + uuid: "content-script", + storageName: "unrelated-storage", + }); + expect(sendMessage).not.toHaveBeenCalled(); + }); +}); + describe("sandbox verified 初始化重放", () => { it("忽略 fallback 通知,并且真实握手与重复握手只初始化一次脚本和语言监听", async () => { const { runtime, mockSystemConfig, mockScriptDAO } = _createRuntimeContext(); diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 5e5d4cd11..0f7cdd318 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -144,7 +144,7 @@ export class RuntimeService { private readonly pageExecutionBindings = new Map(); private readonly userScriptConnections = new Map< string, - { connection: MessageConnect; tabId: number; frameId?: number; documentId?: string } + { connection: MessageConnect; handles: Set; tabId: number; frameId?: number; documentId?: string } >(); getGMApi(): GMApi | undefined { @@ -185,18 +185,45 @@ export class RuntimeService { } /** Register the native USER_SCRIPT channel used for private bootstrap and callbacks. */ - registerUserScriptConnection(_: unknown, sender: IGetSender): boolean { - if (!sender.isType(GetSenderType.EXTCONNECT)) return false; + registerUserScriptConnection(data: unknown, sender: IGetSender): boolean { + if (!sender.isType(GetSenderType.EXTCONNECT) || sender.getConnectOrigin?.() !== "userScript") return false; + if (data === null || typeof data !== "object") return false; + const handshake = data as { world?: unknown; executionHandles?: unknown }; + if ( + Object.keys(data).length !== 2 || + handshake.world !== "USER_SCRIPT" || + !Array.isArray(handshake.executionHandles) || + handshake.executionHandles.length === 0 || + handshake.executionHandles.length > 256 || + handshake.executionHandles.some( + (handle) => typeof handle !== "string" || handle.length === 0 || handle.length > 256 + ) + ) { + return false; + } const source = sender.getSender(); const connection = sender.getConnect(); const tabId = source?.tab?.id; if (!source || typeof tabId !== "number" || !connection) return false; + const handles = new Set(handshake.executionHandles as string[]); + for (const handle of handles) { + const binding = this.pageExecutionBindings.get(handle); + if ( + !binding || + binding.envTag !== "ct" || + binding.tabId !== tabId || + binding.frameId !== source.frameId || + binding.documentId !== source.documentId + ) { + return false; + } + } const frameId = source.frameId; const documentId = source.documentId; const key = this.userScriptConnectionKey(tabId, frameId, documentId); const previous = this.userScriptConnections.get(key); if (previous) previous.connection.disconnect(true); - const entry = { connection, tabId, frameId, documentId }; + const entry = { connection, handles, tabId, frameId, documentId }; this.userScriptConnections.set(key, entry); connection.onDisconnect(() => { if (this.userScriptConnections.get(key)?.connection === connection) this.userScriptConnections.delete(key); @@ -214,6 +241,19 @@ export class RuntimeService { ) { continue; } + const bindingMatches = [...this.pageExecutionBindings.values()].some( + (binding) => + entry.handles.has(binding.handle) && + ((action === "runtime/emitEvent" && + typeof data === "object" && + data !== null && + (data as { uuid?: unknown }).uuid === binding.uuid) || + (action === "runtime/valueUpdate" && + typeof data === "object" && + data !== null && + (data as { storageName?: unknown }).storageName === binding.storageName)) + ); + if (!bindingMatches) continue; try { entry.connection.sendMessage({ action: `content/${action}`, data }); } catch { @@ -228,7 +268,12 @@ export class RuntimeService { } } - private issuePageBinding(uuid: string, envTag: "it" | "ct", sender: IGetSender): ServiceWorkerExecutionBinding { + private issuePageBinding( + uuid: string, + envTag: "it" | "ct", + storageName: string, + sender: IGetSender + ): ServiceWorkerExecutionBinding { const source = sender.getSender(); const tabId = source?.tab?.id; if (typeof tabId !== "number") throw new Error("page execution binding requires a tab"); @@ -241,6 +286,8 @@ export class RuntimeService { tabId, frameId: source?.frameId, documentId: source?.documentId, + storageName, + requestIds: new Set(), } satisfies ServiceWorkerExecutionBinding; this.pageExecutionBindings.set(handle, binding); return binding; @@ -1419,7 +1466,7 @@ export class RuntimeService { this.revokePageBindings(sender, data?.envTag); const prepareScripts = (scripts: TScriptInfo[], envTag: "it" | "ct") => scripts.map((script) => { - const binding = this.issuePageBinding(script.uuid, envTag, sender); + const binding = this.issuePageBinding(script.uuid, envTag, getStorageName(script), sender); return { ...script, executionHandle: binding.handle, diff --git a/src/app/service/service_worker/types.ts b/src/app/service/service_worker/types.ts index 00d686bdd..977f6f0cc 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -63,6 +63,10 @@ export type ServiceWorkerExecutionBinding = { tabId: number; frameId?: number; documentId?: string; + /** 用于只向运行该脚本的文档投递值更新的存储命名空间。 */ + storageName: string; + /** 已接受的页面请求 ID;绑定销毁时一并释放,确保绑定存续期间拒绝重放。 */ + requestIds: Set; }; export type GMApiRequest = MessageRequest & { diff --git a/src/content.ts b/src/content.ts index f6e05a5c1..aa5249ffc 100644 --- a/src/content.ts +++ b/src/content.ts @@ -9,6 +9,7 @@ import { getEventFlag } from "@Packages/message/common"; import { ScriptRuntime } from "./app/service/content/script_runtime"; import { ScriptEnvTag } from "@Packages/message/consts"; import { type TExtensionEnv } from "./app/service/extension/extension_env"; +import { connectUserScriptChannel } from "./app/service/content/user_script_connection"; const messageFlag = process.env.SC_RANDOM_KEY!; @@ -38,17 +39,17 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde runtime.init(); // Keep a native port for callbacks and value updates. The page-observable event // channel remains limited to the synchronous DOM helper. - void chrome.runtime.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" }); - void msg - .connect({ action: "serviceWorker/runtime/registerUserScript", data: { world: "USER_SCRIPT" } }) - .then((connection) => { - connection.onMessage((packet) => { + void runtime.loadPage(async (scripts) => { + await connectUserScriptChannel( + msg, + scripts.map((script) => script.executionHandle).filter((handle): handle is string => Boolean(handle)), + (_connection, packet) => { if (packet.action === "content/runtime/valueUpdate") { scriptExecutor.valueUpdate(packet.data as any); } else if (packet.action === "content/runtime/emitEvent") { scriptExecutor.emitEvent(packet.data as any); } - }); - }); - void runtime.loadPage(); + } + ); + }); }); From d65bcf3e3513b57d50df6d15990a57d445aec1d9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 17 Sep 2026 23:56:33 +0900 Subject: [PATCH 008/103] =?UTF-8?q?=F0=9F=94=92=20secure=20USER=5FSCRIPT?= =?UTF-8?q?=20bootstrap=20and=20page=20capability=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind USER_SCRIPT bootstrap data to the service-worker-issued document token, preserve sender provenance, and enforce per-execution GM capabilities across page RPC and native message paths. Harden captured collection operations and exact-match early startup handling. --- packages/message/extension_message.test.ts | 67 ++++++++++ packages/message/extension_message.ts | 118 +++++++++------- packages/message/server.test.ts | 92 ++++++++++++- packages/message/server.ts | 71 +++++++--- packages/message/types.ts | 4 +- src/app/repo/scripts.ts | 2 + .../service/content/create_context.test.ts | 43 ++++++ src/app/service/content/create_context.ts | 80 ++++++----- src/app/service/content/exec_script.ts | 8 +- src/app/service/content/global.ts | 37 ++++- src/app/service/content/gm_api/gm_api.ts | 19 +-- src/app/service/content/gm_api/gm_xhr.ts | 18 ++- src/app/service/content/page_rpc.test.ts | 105 ++++++++++++++- src/app/service/content/page_rpc.ts | 89 ++++++++++++- src/app/service/content/script_executor.ts | 9 +- src/app/service/content/scripting.test.ts | 67 ++++++++++ src/app/service/content/scripting.ts | 17 ++- .../content/user_script_connection.test.ts | 5 +- .../service/content/user_script_connection.ts | 5 +- src/app/service/content/utils.test.ts | 40 ++++++ src/app/service/content/utils.ts | 10 +- .../service_worker/gm_api/gm_api.test.ts | 41 ++++++ .../service/service_worker/gm_api/gm_api.ts | 10 ++ .../service/service_worker/runtime.test.ts | 63 +++++++-- src/app/service/service_worker/runtime.ts | 126 ++++++++++++++++-- src/app/service/service_worker/types.ts | 2 + src/content.ts | 52 ++++++-- 27 files changed, 1021 insertions(+), 179 deletions(-) create mode 100644 packages/message/extension_message.test.ts create mode 100644 src/app/service/content/scripting.test.ts diff --git a/packages/message/extension_message.test.ts b/packages/message/extension_message.test.ts new file mode 100644 index 000000000..efae4b4fc --- /dev/null +++ b/packages/message/extension_message.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import { ExtensionMessage, ExtensionMessageConnect } from "./extension_message"; + +describe("ExtensionMessage USER_SCRIPT compatibility", () => { + 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); + }); +}); diff --git a/packages/message/extension_message.ts b/packages/message/extension_message.ts index 71869a9f6..1e519470a 100644 --- a/packages/message/extension_message.ts +++ b/packages/message/extension_message.ts @@ -1,24 +1,42 @@ 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(); // 单一管理器 +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 { 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 +56,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, "extension")); + 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 = () => { @@ -97,32 +117,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(true); + } else { + sendResponse(false); + } + return false; + } + if (typeof msg.action !== "string") return; + return callback(msg, sendResponse, sender, "extension"); + }); + } if (this.backgroundPrimary) { let addUserScriptMessageListener: (() => void) | null = () => { @@ -142,7 +165,7 @@ export class ExtensionMessage implements Message { return false; } if (typeof msg.action !== "string") return; - return callback(msg, sendResponse, sender); + return callback(msg, sendResponse, sender, "userScript"); }); addUserScriptMessageListener = null; } catch { @@ -164,6 +187,7 @@ 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( @@ -171,6 +195,8 @@ export class ExtensionMessageConnect implements MessageConnect { private readonly origin: "extension" | "userScript" = "extension" ) { this.con = con; // 强引用 + if (typeof con.postMessage !== "function") throw new TypeError("Invalid runtime port"); + this.postMessage = con.postMessage.bind(con); const handler = (msg: TMessage, _con: chrome.runtime.Port) => { listenerMgr.emit(`onMessage:${this.listenerId}`, msg); }; @@ -197,7 +223,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) { diff --git a/packages/message/server.test.ts b/packages/message/server.test.ts index b7b1ff007..3b572c0ac 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,49 @@ afterEach(() => { }); describe("Server", () => { + 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 +532,19 @@ describe("Server", () => { expect(extSender.documentId).toBe("doc-123"); }); + 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 +590,40 @@ 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" }); + }); + }); + describe("Connect 功能测试", () => { it("应该能够处理连接消息", async () => { const mockHandler = vi.fn(); diff --git a/packages/message/server.ts b/packages/message/server.ts index fa16871f5..45612d1c1 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -1,4 +1,12 @@ -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"; @@ -20,7 +28,7 @@ export interface IGetSender { getSender(): RuntimeMessageSender | undefined; getExtMessageSender(): ExtMessageSender; getConnect(): MessageConnect | undefined; - getConnectOrigin?(): "extension" | "userScript" | undefined; + getConnectOrigin?(): MessageOrigin | undefined; } export class SenderConnect { @@ -79,7 +87,10 @@ export class SenderConnect { export class SenderRuntime { private readonly mType; - constructor(private sender: RuntimeMessageSender) { + constructor( + private sender: RuntimeMessageSender, + private readonly origin?: MessageOrigin + ) { this.mType = GetSenderType.RUNTIME; } @@ -118,8 +129,8 @@ export class SenderRuntime { return undefined; } - getConnectOrigin(): undefined { - return undefined; + getConnectOrigin(): MessageOrigin | undefined { + return this.origin; } } @@ -146,7 +157,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 ) { @@ -156,8 +167,8 @@ export class Server { 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); + if (msg.action?.startsWith(this.prefix)) { + return this.connectHandle(msg.action.slice(this.prefix.length + 1), msg.data, con); } return false; }); @@ -165,11 +176,11 @@ export class Server { } msgReceiverList.forEach((msg) => { - msg.onMessage((msg: TMessage, sendResponse, sender) => { + msg.onMessage((msg: TMessage, sendResponse, sender, origin) => { 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); + if (msg.action?.startsWith(this.prefix)) { + return this.messageHandle(msg.action.slice(this.prefix.length + 1), msg.data, sendResponse, sender, origin); } }); return false; @@ -185,9 +196,15 @@ export class Server { } private connectHandle(msg: string, params: any, con: MessageConnect) { + const sender = new SenderConnect(con); + if (!this.isUserScriptActionAllowed(msg, sender.getConnectOrigin(), true)) { + con.sendMessage({ 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 @@ -211,12 +228,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) => { @@ -243,6 +266,13 @@ export class Server { this.logger.error("no such api", { action: action }); } } + + private isUserScriptActionAllowed(action: string, origin: MessageOrigin | undefined, isConnect: boolean): boolean { + if (this.prefix !== "serviceWorker" || origin !== "userScript") return true; + return isConnect + ? action === "runtime/registerUserScript" || action === "runtime/gmApi" + : action === "runtime/gmApi"; + } } export class Group { @@ -323,7 +353,7 @@ export function forwardMessage( return sendMessage(senderTo, prefix + "/" + path, params); } }; - const process = (params: any, sender: IGetSender) => { + const processTransformed = (params: any, sender: IGetSender) => { if (middleware) { // 此处是为了处理CustomEventMessage的同步消息情况 const resp = middleware(params, sender) as any; @@ -340,11 +370,12 @@ export function forwardMessage( } return handler(params, sender); }; - receiverFrom.on(path, (params, sender) => { - if (!transform) return process(params, sender); + const process = (params: any, sender: IGetSender) => { + if (!transform) return processTransformed(params, sender); const transformed = transform(params, sender); return transformed instanceof Promise - ? transformed.then((data) => process(data, sender)) - : process(transformed, sender); - }); + ? transformed.then((data) => processTransformed(data, sender)) + : processTransformed(transformed, sender); + }; + 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/src/app/repo/scripts.ts b/src/app/repo/scripts.ts index 2171c4710..285de420a 100644 --- a/src/app/repo/scripts.ts +++ b/src/app/repo/scripts.ts @@ -158,6 +158,8 @@ export type TClientPageLoadInfo = injectScriptList: TScriptInfo[]; contentScriptList: TScriptInfo[]; envInfo: GMInfoEnv; + /** One-use token that lets the USER_SCRIPT world request its private bootstrap. */ + userScriptBootstrapToken?: string; } | { ok: false }; diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 702cb00d9..c5f9f8b70 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -305,6 +305,49 @@ describe("shouldFnBind", () => { }); describe("createContext: capability and lifecycle contract", () => { + it("keeps grant construction on captured Set and iterator intrinsics", () => { + const NativeSet = Set; + 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 { + 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 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 { + 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("uses the service-worker execution run flag for value acknowledgments", () => { const script = { ...createScriptInfo({ grant: ["GM_getValue"] }), diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 5afe54997..99db340ae 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -53,7 +53,7 @@ export const createContext = ( GM: GM, GM_info: GMInfo, window: Native.objectCreate(null), - grantSet: new Native.Set(), + grantSet: Native.createSet(), loadScriptPromise, loadScriptResolve, setInvalidContext() { @@ -77,26 +77,29 @@ export const createContext = ( 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) { + if (Native.setHas(grantSet, grant)) return true; // 重复的@grant,略过 (返回 true 表示 @grant 存在) + Native.setAdd(grantSet, grant); + 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)) { + Native.setForEach(scriptGrants, (grant) => { + const candidates = getGrantCandidates(String(grant)); + for (let i = 0; i < candidates.length; i += 1) { + const candidate = candidates[i]; __methodInject__(candidate); } - } + }); // 兼容GM.Cookie.* - for (const fnKey of Native.objectKeys(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; @@ -108,7 +111,7 @@ export const createContext = ( } } context.unsafeWindow = window; - if (scriptGrants.has("window.onurlchange") && context.onurlchange === undefined) { + if (Native.setHas(scriptGrants, "window.onurlchange") && context.onurlchange === undefined) { context.onurlchange = null; attachNavigateHandler(window as any); } @@ -169,10 +172,12 @@ const getAllPropertyDescriptors = ( ) => { while (obj && obj !== Object) { const descs = Native.objectGetOwnPropertyDescriptors(obj); - for (const key of Native.reflectOwnKeys(descs)) { + 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); } }; @@ -214,7 +219,7 @@ export type RealmRoots = { const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSnapshot => { // 在 CacheSet 加入的 propKeys 将会在 mySandbox 实装阶段时设置。 // 先处理的 descriptor 覆盖后续父类。 - const descsCache: Set = new Native.Set(["eval", "window", "self", "globalThis", "top", "parent"]); + const descsCache: Set = Native.createSet(["eval", "window", "self", "globalThis", "top", "parent"]); // realmGlobal own descriptor 优先,hostWindow descriptor 只补足 host 成员。 const initOwnDescs = Native.objectGetOwnPropertyDescriptors(realmGlobal); @@ -224,7 +229,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn const overriddenDescs: DescriptorMap = Native.objectCreate(null); // 记录原生 onxxxxx 的 property key。 - const eventKeys = new Native.Set(); + const eventKeys = Native.createSet(); // 在 USE_PSEUDO_WINDOW 情况下,由于没有类的 prototype,父类的成员要手动传下去。 const protoBaseDescs: DescriptorMap = Native.objectCreate(null); @@ -232,10 +237,12 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn const collectRealmDescriptors = () => { // 只读取 realmGlobal own descriptors,避免混合 Firefox 的两个 realm。 const descriptors = Native.objectGetOwnPropertyDescriptors(realmGlobal); - for (const key of Native.objectKeys(descriptors)) { + 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 + if (Native.setHas(descsCache, key)) continue; + Native.setAdd(descsCache, key); // realm own descriptors take precedence over host descriptors if ("value" in desc) { // 替换 function 的 this 为实际的 realm global。 @@ -248,7 +255,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (desc.configurable && desc.get && desc.set && desc.enumerable && key.startsWith("on")) { // 替换 onxxxxx 事件赋值操作。 // 例:(window.)onload, (window.)onerror。 - eventKeys.add(key); + Native.setAdd(eventKeys, key); continue; } if (desc.get || desc.set) { @@ -268,16 +275,16 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (desc.configurable && desc.get && desc.set && key.startsWith("on")) { // 替换 onxxxxx 事件赋值操作。 // 例:(window.)onload, (window.)onerror。 - eventKeys.add(key); + Native.setAdd(eventKeys, key); return; } - if (descsCache.has(key)) return; + if (Native.setHas(descsCache, key)) return; if ("value" in desc) { // 替换 function 的 this 为实际的 host window。 if (shouldFnBind(desc.value)) { overriddenDescs[key] = materializeDescriptor(desc, hostWindow); - descsCache.add(key); + Native.setAdd(descsCache, key); } else if (!(key in initOwnDescs) && !Native.objectHasOwn(realmGlobal, key) && !protoBaseDescs[key]) { protoBaseDescs[key] = materializeDescriptor(desc, hostWindow); } @@ -287,7 +294,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn // 替换 getter setter 的 this 为实际的 host window。 // 例:(window.)location, (window.)document。 overriddenDescs[key] = materializeDescriptor(desc, hostWindow); - descsCache.add(key); + Native.setAdd(descsCache, key); } }); }; @@ -296,7 +303,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn collectRealmDescriptors(); // 第二趟 hostWindow:补齐 Firefox split-realm 的 host 成员。 collectHostWindowDescriptors(); - descsCache.clear(); // 内存释放 + Native.setClear(descsCache); // 内存释放 // sharedInitCopy: 完全继承Window.prototype 及 自定义 OwnPropertyDescriptor // OwnPropertyDescriptor定义 为 原OwnPropertyDescriptor定义 (DragEvent, MouseEvent, RegExp, EventTarget, JSON等) @@ -416,7 +423,12 @@ export const createProxyContext = ( }; }; - for (const key of eventKeys) { + const eventKeyList: string[] = []; + Native.setForEach(eventKeys, (key) => { + eventKeyList[eventKeyList.length] = String(key); + }); + for (let i = 0; i < eventKeyList.length; i += 1) { + const key = eventKeyList[i]; const eventSetterGetter = createEventProp(key); ownDescs[key] = { ...ownDescs[key], @@ -425,7 +437,9 @@ export const createProxyContext = ( } // 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, @@ -434,7 +448,9 @@ 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 = Native.reflectGet(roots.hostWindow, key, roots.hostWindow); if (hostValue === undefined && !descriptor) continue; @@ -489,7 +505,9 @@ export const createProxyContext = ( 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; } @@ -497,7 +515,9 @@ export const createProxyContext = ( // 把 GM Api (或其他全域API) 复制到 脚本window // 请手动检查避开key,防止与window的属性setter有冲突 或 属性名重复 - for (const key of Native.objectKeys(context)) { + const contextKeys = Native.objectKeys(context); + for (let i = 0; i < contextKeys.length; i += 1) { + const key = contextKeys[i]; if (key in protect || key === "window") continue; mySandbox[key] = context[key]; // window以外 } diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index 3213398a7..34fd9b75d 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -51,12 +51,12 @@ export default class ExecScript { } else { this.scriptFunc = code; } - const grantSet = new Native.Set(scriptRes.metadata.grant || []); + const grantSet = Native.createSet(scriptRes.metadata.grant || []); if (isContextMenuScript(scriptRes.metadata)) { - grantSet.add("GM_registerMenuCommand"); - grantSet.delete("none"); + Native.setAdd(grantSet, "GM_registerMenuCommand"); + Native.setDelete(grantSet, "none"); } - if (grantSet.has("none")) { + if (Native.setHas(grantSet, "none")) { // 不注入任何GM api // ScriptCat行为:GM.info 和 GM_info 同时注入 // 在不改变 Context 的情况下,以 named 传入多个全域变量 diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index ada53358d..53896b47f 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -6,6 +6,16 @@ const unsupportedAPI = () => { // 在页面或用户脚本替换调用内建函数前完成捕获。 export const nativeReflectApply = Reflect.apply; const nativeFunctionBind = Function.prototype.bind; +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 nativeMapConstructor = Map; +const nativeWeakMapConstructor = WeakMap; +const nativeWeakMapGet = WeakMap.prototype.get; +const nativeWeakMapSet = WeakMap.prototype.set; export const nativeApply = (fn: (...args: any[]) => any, receiver: any, args: any[]) => nativeReflectApply(fn, receiver, args); @@ -14,9 +24,20 @@ export const nativeCall = (fn: (...args: any[]) => any, receiver: any, ...args: export const nativeBind = (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => nativeReflectApply(nativeFunctionBind, fn, [receiver, ...args]); +const createNativeSet = (values?: readonly T[]): Set => { + const set = new nativeSetConstructor(); + if (values) { + for (let i = 0; i < values.length; i += 1) nativeReflectApply(nativeSetAdd, set, [values[i]]); + } + return set; +}; + +const createNativeWeakMap = (): WeakMap => new nativeWeakMapConstructor(); + export const Native = { - Set, - Map, + Set: nativeSetConstructor, + Map: nativeMapConstructor, + WeakMap: nativeWeakMapConstructor, apply: nativeApply, call: nativeCall, bind: nativeBind, @@ -36,6 +57,18 @@ export const Native = { objectGetPrototypeOf: nativeBind(Object.getPrototypeOf, Object), reflectOwnKeys: nativeBind(Reflect.ownKeys, Reflect), reflectGet: nativeBind(Reflect.get, Reflect), + setAdd: (set: Set, value: unknown) => nativeReflectApply(nativeSetAdd, set, [value]), + setHas: (set: Set, value: unknown) => nativeReflectApply(nativeSetHas, set, [value]), + setDelete: (set: Set, value: unknown) => nativeReflectApply(nativeSetDelete, set, [value]), + setClear: (set: Set) => nativeReflectApply(nativeSetClear, set, []), + setForEach: (set: Set, callback: (value: unknown, value2: unknown, set: Set) => void) => + nativeReflectApply(nativeSetForEach, set, [callback]), + createSet: createNativeSet, + weakMapGet: (map: WeakMap, key: K) => + nativeReflectApply(nativeWeakMapGet, map, [key]) as V | undefined, + weakMapSet: (map: WeakMap, key: K, value: V) => + nativeReflectApply(nativeWeakMapSet, map, [key, value]) as WeakMap, + createWeakMap: createNativeWeakMap, } as const; export const customClone = (o: any) => { diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index a9b2c4369..b46488713 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -19,6 +19,7 @@ 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"; @@ -61,16 +62,13 @@ let valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; const valueChangePromiseMap: Record void> = Object.create(null); -const notificationTagMaps = new WeakMap>(); -const nativeReflectApply = Reflect.apply; -const weakMapGet = WeakMap.prototype.get; -const weakMapSet = WeakMap.prototype.set; +const notificationTagMaps = Native.createWeakMap>(); const getNotificationTagMap = (owner: object): Map => { - let map = nativeReflectApply(weakMapGet, notificationTagMaps, [owner]); + let map = Native.weakMapGet(notificationTagMaps, owner); if (!map) { - map = new Map(); - nativeReflectApply(weakMapSet, notificationTagMaps, [owner, map]); + map = new Native.Map(); + Native.weakMapSet(notificationTagMaps, owner, map); } return map; }; @@ -78,7 +76,7 @@ const getNotificationTagMap = (owner: object): Map => { const execEnvInit = (execEnv: GMApi) => { if (!execEnv.contentEnvKey) { execEnv.contentEnvKey = randomMessageFlag(); // 不重复识别字串。用于区分 mainframe subframe 等执行环境 - execEnv.menuKeyRegistered = new Set(); + execEnv.menuKeyRegistered = Native.createSet(); execEnv.menuIdCounter = 0; execEnv.regMenuCounter = 0; } @@ -154,7 +152,10 @@ class GM_Base implements IGM_Base { // operations local instead of sending an internal CAT operation to the SW, // where only the isolated scripting broker has an implementation. if (this.scriptRes.executionEnvTag === ScriptEnvTag.content) { - if (api === "CAT_fetchBlob") return fetch(`${params[0]}`).then((response) => response.blob()); + 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); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index e65152ecb..cb3220ef4 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -127,9 +127,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 = Native.createSet([ + "application/xhtml+xml", + "application/xml", + "image/svg+xml", + "text/html", + "text/xml", +]); -const retStateFnMap = new WeakMap, RetStateFnRecord>(); +const retStateFnMap = Native.createWeakMap(); interface RetStateFnRecord { getResponseText(): string | undefined; @@ -142,7 +148,7 @@ interface RetStateFnRecord { const xhrResponseGetters = { response: { get() { - const retTemp = retStateFnMap.get(this); + const retTemp = Native.weakMapGet(retStateFnMap, this); return retTemp?.getResponse(); }, enumerable: false, @@ -150,7 +156,7 @@ const xhrResponseGetters = { }, responseXML: { get() { - const retTemp = retStateFnMap.get(this); + const retTemp = Native.weakMapGet(retStateFnMap, this); return retTemp?.getResponseXML(); }, enumerable: false, @@ -158,7 +164,7 @@ const xhrResponseGetters = { }, responseText: { get() { - const retTemp = retStateFnMap.get(this); + const retTemp = Native.weakMapGet(retStateFnMap, this); return retTemp?.getResponseText(); }, enumerable: false, @@ -429,7 +435,7 @@ export function GM_xmlhttpRequest( const retParamObject: GMXHRResponseType = Native.objectCreate(null, descriptors); // 外部没引用 retParamObject 时,retTemp 会被自动GC const retTemp = makeRetTemp(contentType); - retStateFnMap.set(retParamObject, retTemp); + Native.weakMapSet(retStateFnMap, retParamObject, retTemp); return retParamObject; }; diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 111fb85e2..eee9bc780 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { getPageRpcAllowedAPIs, PageRpcError, PageRpcRegistry, validatePageGMRequest } from "./page_rpc"; +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", () => { @@ -12,9 +19,10 @@ describe("page GM RPC", () => { "CAT_fetchBlob", "GM_xmlhttpRequest", "GM.xmlhttpRequest", - "CAT_fetchDocument", ]) ); + expect(allowed).not.toContain("CAT_fetchDocument"); + expect(allowed).not.toContain("CAT_createBlobUrl"); expect(allowed).not.toContain("CAT_agentSkills"); }); @@ -24,10 +32,59 @@ describe("page GM RPC", () => { expect(allowed).toEqual(expect.arrayContaining(["GM.openInTab", "GM_openInTab", "GM_closeInTab"])); }); + it("includes storage APIs used by delete wrappers", () => { + expect(getPageRpcAllowedAPIs(["GM_deleteValue"])).toEqual( + expect.arrayContaining(["GM_deleteValue", "GM_setValue"]) + ); + expect(getPageRpcAllowedAPIs(["GM.deleteValues"])).toEqual( + expect.arrayContaining(["GM.deleteValues", "GM_setValues"]) + ); + }); + + it("includes the nested cookie methods exposed by both cookie grant spellings", () => { + expect(getPageRpcAllowedAPIs(["GM.cookie"])).toEqual( + expect.arrayContaining(["GM.cookie.set", "GM.cookie.list", "GM.cookie.delete"]) + ); + expect(getPageRpcAllowedAPIs(["GM_cookie"])).toEqual( + expect.arrayContaining(["GM_cookie.set", "GM_cookie.list", "GM_cookie.delete"]) + ); + }); + + it("does not create a GM capability set for a none grant", () => { + expect(getPageRpcAllowedAPIs(["none", "GM_getValue", "CAT.agent.dom"])).toEqual([]); + }); + 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("rejects direct internal fetch helpers from a GM XHR binding", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", getPageRpcAllowedAPIs(["GM_xmlhttpRequest"])); + + expect(() => + validatePageGMRequest( + { version: 1, requestId: "fetch", handle, api: "CAT_fetchBlob", params: ["https://example.com/file"] }, + registry + ) + ).toThrow("API is not granted"); + expect(() => + validatePageGMRequest( + { + version: 1, + requestId: "document", + handle, + api: "CAT_fetchDocument", + params: ["https://example.com/file", false], + }, + registry + ) + ).toThrow("API is not granted"); }); it("accepts a request for the active execution binding and clones parameters", () => { @@ -138,13 +195,51 @@ describe("page GM RPC", () => { expect(() => validatePageGMRequest({ version: 1, requestId: "a", handle, api: "CAT_fetchBlob", params: [42] }, registry) - ).toThrow("CAT_fetchBlob expects a URL string"); + ).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: 1, requestId: "a", handle, api: "CAT_fetchBlob", params: ["https://example.com/file"] }, + { version: 1, requestId: "b", handle, api: "CAT_fetchBlob", params: [extensionBlobUrl] }, registry ).params - ).toEqual(["https://example.com/file"]); + ).toEqual([extensionBlobUrl]); + expect(isExtensionBlobUrl("blob:https://example.com/internal")).toBe(false); + expect(isExtensionBlobUrl("blob:chrome-extension://other/internal")).toBe(false); + }); + + 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("bounds the replay window for each execution binding", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + + for (let index = 0; index <= 4096; index += 1) { + validatePageGMRequest( + { version: 1, requestId: `request-${index}`, handle, api: "GM_getValue", params: [] }, + registry + ); + } + + // The oldest ID leaves the bounded replay window once newer requests arrive. + expect(() => + validatePageGMRequest({ version: 1, requestId: "request-0", handle, api: "GM_getValue", params: [] }, registry) + ).not.toThrow(); }); }); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index a2dca1636..74337dfda 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -4,7 +4,71 @@ import { getGrantCandidates } from "./gm_api/grant"; export const PAGE_RPC_VERSION = 1 as const; const MAX_REQUEST_ID_LENGTH = 256; +const MAX_REQUEST_IDS_PER_BINDING = 4096; const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; +const EXTENSION_PROTOCOLS = new Set(["chrome-extension:", "moz-extension:"]); + +export type ExtensionOrigin = Pick; + +export const getExtensionOrigin = (): ExtensionOrigin | undefined => { + if (typeof chrome === "undefined" || typeof chrome.runtime?.getURL !== "function") return undefined; + try { + const url = new URL(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; +}; + +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 = Object.getOwnPropertyDescriptor(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 URL(value); + if (url.protocol !== "blob:") return false; + const creatorOrigin = new URL(value.slice("blob:".length)); + return ( + creatorOrigin.protocol === extensionOrigin.protocol && + creatorOrigin.hostname === extensionOrigin.hostname && + creatorOrigin.port === extensionOrigin.port + ); + } catch { + return false; + } +}; export type PageExecutionBinding = { readonly handle: string; @@ -45,17 +109,26 @@ const INTERNAL_APIS_BY_GRANT: Readonly> = { "CAT.agent.skills": ["CAT_agentSkills"], "CAT.agent.task": ["CAT_agentTask"], CAT_fileStorage: ["CAT_fetchBlob", "CAT_createBlobUrl"], - GM_xmlhttpRequest: ["CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], - "GM.xmlhttpRequest": ["CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], - "GM.xmlHttpRequest": ["GM_xmlhttpRequest", "CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], + "GM.xmlHttpRequest": ["GM_xmlhttpRequest"], }; // ScriptingRuntime does not load the GM implementation module, so mirror its small dependency graph here. const API_DEPENDENCIES: Readonly> = { "GM.getValues": ["GM_getValues"], + "GM.cookie": ["GM.cookie.set", "GM.cookie.list", "GM.cookie.delete"], + GM_cookie: ["GM_cookie.set", "GM_cookie.list", "GM_cookie.delete"], + "GM.setValue": ["GM_setValue"], + "GM.setValues": ["GM_setValues"], + "GM.listValues": ["GM_listValues"], + "GM.download": ["GM_download"], + "GM.notification": ["GM_notification"], "GM.addValueChangeListener": ["GM_addValueChangeListener"], "GM.removeValueChangeListener": ["GM_removeValueChangeListener"], "GM.log": ["GM_log"], + "GM.deleteValue": ["GM_setValue"], + GM_deleteValue: ["GM_setValue"], + "GM.deleteValues": ["GM_setValues"], + GM_deleteValues: ["GM_setValues"], "GM.registerMenuCommand": ["GM_registerMenuCommand"], CAT_registerMenuInput: ["GM_registerMenuCommand"], "GM.addStyle": ["GM_addStyle"], @@ -74,6 +147,7 @@ const API_DEPENDENCIES: Readonly> = { }; export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { + if (grants.some((grant) => grant === "none")) return []; const allowed = new Set(); const visited = new Set(); const visitGrant = (grant: string): void => { @@ -136,8 +210,8 @@ const cloneParams = (params: unknown): readonly unknown[] => { const validateOperationParams = (api: string, params: readonly unknown[]): void => { switch (api) { case "CAT_fetchBlob": - if (params.length !== 1 || typeof params[0] !== "string") { - throw new PageRpcError("CAT_fetchBlob expects a URL string"); + if (params.length !== 1 || !isExtensionBlobUrl(params[0])) { + throw new PageRpcError("CAT_fetchBlob expects an extension blob URL"); } return; case "CAT_createBlobUrl": @@ -209,6 +283,11 @@ export class PageRpcRegistry { consumeRequestId(binding: PageExecutionBinding, requestId: string): void { if (binding.requestIds.has(requestId)) throw new PageRpcError("page RPC requestId was already used"); binding.requestIds.add(requestId); + while (binding.requestIds.size > MAX_REQUEST_IDS_PER_BINDING) { + const oldest = binding.requestIds.values().next().value as string | undefined; + if (oldest === undefined) break; + binding.requestIds.delete(oldest); + } } } diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 64aab20f6..3e8d62a2c 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -74,7 +74,8 @@ export class ScriptExecutor { }); }; // 监听脚本加载 - scripts.forEach((script) => { + for (let scriptIndex = 0; scriptIndex < scripts.length; scriptIndex += 1) { + const script = scripts[scriptIndex]; const flag = script.flag; // 如果是EarlyScriptFlag,处理沙盒环境 let isEarlyScript = false; @@ -110,7 +111,7 @@ export class ScriptExecutor { }); }; listenForScript(); - }); + } } checkEarlyStartScript(scriptEnvTag: ScriptEnvTag, envInfo: GMInfoEnv) { @@ -217,7 +218,9 @@ export class ScriptExecutor { 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/scripting.test.ts b/src/app/service/content/scripting.test.ts new file mode 100644 index 000000000..960eb6b27 --- /dev/null +++ b/src/app/service/content/scripting.test.ts @@ -0,0 +1,67 @@ +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 { Server } from "@Packages/message/server"; +import { RuntimeClient } from "../service_worker/client"; +import ScriptingRuntime 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 pageLoad = vi.spyOn(RuntimeClient.prototype, "pageLoad").mockResolvedValue({ + ok: true, + injectScriptList: [makeScript("inject-script")], + contentScriptList: [makeScript("content-script")], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + userScriptBootstrapToken: "bootstrap-token", + } as TClientPageLoadInfo); + const senderToExt = makeSender(); + const senderToContent = makeSender(); + const senderToInject = makeSender(); + const runtime = new ScriptingRuntime( + {} as Server, + {} as Server, + senderToExt as unknown as MessageSend, + senderToContent as any, + senderToInject as any + ); + + 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" })); + }); +}); diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index 1f73d12cf..b465ac641 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -7,7 +7,7 @@ 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 { getPageRpcAllowedAPIs, PageRpcRegistry, validatePageGMRequest } from "./page_rpc"; +import { getExtensionOrigin, getPageRpcAllowedAPIs, PageRpcRegistry, validatePageGMRequest } from "./page_rpc"; import { uuidv4 } from "@App/pkg/utils/uuid"; const PageOrContent = { @@ -173,7 +173,7 @@ export default class ScriptingRuntime { // 向service_worker请求脚本列表及环境信息 client.pageLoad("it").then((o) => { if (!o.ok) return; - const { injectScriptList, contentScriptList, envInfo } = o; + const { injectScriptList, envInfo, userScriptBootstrapToken } = o; this.pageRpc.revokeAll(); const prepareScripts = (scripts: typeof injectScriptList, envTag: "it" | "ct") => scripts.map((script) => { @@ -188,16 +188,21 @@ export default class ScriptingRuntime { return { ...script, executionHandle, executionEnvTag: envTag, executionRunFlag }; }); const preparedInjectScriptList = prepareScripts(injectScriptList, "it"); - const preparedContentScriptList = prepareScripts(contentScriptList, "ct"); const pairs = {} as Record; for (const script of preparedInjectScriptList) { pairs[getStorageName(script)] |= PageOrContent.PAGE; } - for (const script of preparedContentScriptList) { - pairs[getStorageName(script)] |= PageOrContent.CONTENT; - } this.activeStorageNames = new Map(Object.entries(pairs)); + if (typeof userScriptBootstrapToken === "string" && userScriptBootstrapToken.length > 0) { + const contentClient = new Client(this.senderToContent, "content"); + contentClient.do("pageLoad", { + bootstrapToken: userScriptBootstrapToken, + envInfo, + extensionOrigin: getExtensionOrigin(), + }); + } + // 向页面 发送脚本列表及环境信息 if (preparedInjectScriptList.length) { const injectClient = new Client(this.senderToInject, "inject"); diff --git a/src/app/service/content/user_script_connection.test.ts b/src/app/service/content/user_script_connection.test.ts index dc78b5cb4..170e5fd2f 100644 --- a/src/app/service/content/user_script_connection.test.ts +++ b/src/app/service/content/user_script_connection.test.ts @@ -24,10 +24,11 @@ describe("connectUserScriptChannel", () => { }), } as unknown as Message; - await connectUserScriptChannel(message, ["handle-a"], vi.fn()); + 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("does not open a port when the browser cannot enable USER_SCRIPT listeners", async () => { @@ -36,7 +37,7 @@ describe("connectUserScriptChannel", () => { connect: vi.fn(), } as unknown as Message; - await expect(connectUserScriptChannel(message, ["handle-a"], vi.fn())).resolves.toBeUndefined(); + await expect(connectUserScriptChannel(message, "bootstrap-token", vi.fn())).resolves.toBeUndefined(); expect(message.connect).not.toHaveBeenCalled(); }); }); diff --git a/src/app/service/content/user_script_connection.ts b/src/app/service/content/user_script_connection.ts index 2933b383b..c11a2cd80 100644 --- a/src/app/service/content/user_script_connection.ts +++ b/src/app/service/content/user_script_connection.ts @@ -8,15 +8,16 @@ type UserScriptPacketHandler = (connection: MessageConnect, packet: TMessage) => */ export async function connectUserScriptChannel( message: Message, - executionHandles: readonly string[], + bootstrapToken: string, onPacket: UserScriptPacketHandler ): Promise { const enabled = await message.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" } as unknown as TMessage); if (enabled === false) return undefined; const connection = await message.connect({ action: "serviceWorker/runtime/registerUserScript", - data: { world: "USER_SCRIPT", executionHandles }, + data: { world: "USER_SCRIPT", bootstrapToken }, }); connection.onMessage((packet) => onPacket(connection, packet)); + connection.sendMessage({ action: "userScript/bootstrap" }); return connection; } diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index 0dd9ec2df..0933854e1 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -792,6 +792,46 @@ describe("utils", () => { expect(testPerformance.dispatchEvent).toHaveBeenCalledTimes(1); expect(testPerformance.addEventListener).not.toHaveBeenCalled(); }); + + 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(); + }); }); describe("addStyle", () => { diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 3caef5952..5d9910e54 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -311,10 +311,14 @@ export function compilePreInjectScript( const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : ""; const evScriptLoad = `${eventNamePrefix}${DefinedFlags.scriptLoadComplete}`; const evEnvLoad = `${eventNamePrefix}${DefinedFlags.envLoadComplete}`; - return `${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`)}; -{ - let f = () => { + return `{ + let mounted = false, + f = () => { if (!(${urlCondition})) return false; + if (!mounted) { + ${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`)}; + mounted = true; + } const o = { cancelable: true, detail: { scriptFlag: '${flag}', scriptInfo: (${scriptInfoJSON}) } }, c = typeof cloneInto === "function" ? cloneInto(o, performance) : o; return performance.dispatchEvent(new CustomEvent('${evScriptLoad}', c)); 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 696f764c0..1fd0b82db 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 @@ -153,6 +153,46 @@ describe("page execution binding gate", () => { 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"]), + requestIds: new Set(), + }; + 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-a", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + executionHandle: "handle-a", + requestId: "request-a", + version: 1, + }, + sender + ) + ).rejects.toThrow("API is not granted to this execution"); + expect(parseRequest).not.toHaveBeenCalled(); + expect(binding.requestIds.size).toBe(0); + }); + it("rejects a replayed page request id 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() } }); @@ -176,6 +216,7 @@ describe("page execution binding gate", () => { runFlag: "run-a", tabId: 42, frameId: 0, + allowedAPIs: new Set(["GM_log"]), requestIds: new Set(), }; Object.defineProperty(api, "resolvePageExecutionBinding", { 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 b3654e1e9..095ab64d1 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -141,6 +141,8 @@ const cleanupOnAPIError = (requestId: string) => { headersSettled(markerID); // 处理完毕 }; +const MAX_PAGE_RPC_REQUEST_IDS = 4096; + // GMExternalDependencies接口定义 // 为了支持外部依赖注入,方便测试和扩展 interface IGMExternalDependencies { @@ -395,6 +397,9 @@ export default class GMApi { if (!binding || (data.uuid && data.uuid !== binding.uuid)) { throw new Error("page execution binding is invalid"); } + if (!binding.allowedAPIs.has(data.api)) { + throw new Error("API is not granted to this execution"); + } if (typeof data.requestId !== "string" || !data.requestId || data.requestId.length > 256) { throw new Error("page RPC requestId is invalid"); } @@ -402,6 +407,11 @@ export default class GMApi { throw new Error("page RPC requestId was already used"); } binding.requestIds.add(data.requestId); + while (binding.requestIds.size > MAX_PAGE_RPC_REQUEST_IDS) { + const oldest = binding.requestIds.values().next().value as string | undefined; + if (oldest === undefined) break; + binding.requestIds.delete(oldest); + } if (data.envTag !== undefined && data.envTag !== binding.envTag) { throw new Error("page execution binding is invalid"); } diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 18514c687..581bf8a67 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1077,6 +1077,16 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { }, }); + it("拒绝 USER_SCRIPT 来源直接请求页面脚本清单", async () => { + const { runtime } = _createRuntimeContext(); + const getScriptsForTab = vi.spyOn(runtime, "getScriptsForTab"); + + const result = await runtime.pageLoad(undefined, new SenderRuntime(createSender(false), "userScript")); + + expect(result).toEqual({ ok: false }); + expect(getScriptsForTab).not.toHaveBeenCalled(); + }); + it.each([ ["普通", false], ["隐身", true], @@ -1193,7 +1203,7 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { expect(runtime.resolvePageExecutionBinding(injectHandle!, sender)).toBeDefined(); }); - it("isolated scripting 的 pageLoad 不会撤销已建立的 content 绑定", async () => { + it("isolated scripting 的 pageLoad 会撤销上一轮 content 绑定", async () => { const { runtime } = _createRuntimeContext(); const inject = _createScriptRunResource(_createMockScript({ uuid: "inject-script" })); const content = _createScriptRunResource(_createMockScript({ uuid: "content-script" })); @@ -1217,7 +1227,34 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { const injectLoad = await runtime.pageLoad({ envTag: "it" }, sender); expect(injectLoad.ok).toBe(true); - expect(runtime.resolvePageExecutionBinding(contentHandle!, sender)).toBeDefined(); + expect(runtime.resolvePageExecutionBinding(contentHandle!, sender)).toBeUndefined(); + }); + + it("没有匹配脚本时也会撤销当前页面的旧绑定", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource(_createMockScript({ uuid: "stale-script" })); + const getScriptsForTab = vi.spyOn(runtime, "getScriptsForTab"); + getScriptsForTab.mockResolvedValueOnce({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + getScriptsForTab.mockResolvedValueOnce(null); + const sender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const first = await runtime.pageLoad(undefined, sender); + expect(first.ok).toBe(true); + if (!first.ok) return; + const handle = first.injectScriptList[0].executionHandle; + expect(runtime.resolvePageExecutionBinding(handle!, sender)).toBeDefined(); + + await runtime.pageLoad(undefined, sender); + expect(runtime.resolvePageExecutionBinding(handle!, sender)).toBeUndefined(); }); }); @@ -1241,8 +1278,9 @@ describe("USER_SCRIPT native callbacks", () => { tab: { id: 41, incognito: false } as chrome.tabs.Tab, } as chrome.runtime.MessageSender; const sendMessage = vi.fn(); + const onMessage = vi.fn(); const connection = { - onMessage: vi.fn(), + onMessage, sendMessage, disconnect: vi.fn(), onDisconnect: vi.fn(), @@ -1256,22 +1294,31 @@ describe("USER_SCRIPT native callbacks", () => { getConnectOrigin: () => "userScript" as const, }; - await runtime.pageLoad({ envTag: "ct" }, new SenderRuntime(rawSender)); + const pageLoad = await runtime.pageLoad({ envTag: "it" }, new SenderRuntime(rawSender)); const contentBindings = [...(runtime as any).pageExecutionBindings.values()] as Array<{ handle: string }>; const handles = contentBindings.map(({ handle }) => handle); expect(handles).toHaveLength(1); + expect(pageLoad.ok && pageLoad.userScriptBootstrapToken).toEqual(expect.any(String)); + const bootstrapToken = pageLoad.ok ? pageLoad.userScriptBootstrapToken : undefined; expect( runtime.registerUserScriptConnection( - { world: "USER_SCRIPT", executionHandles: handles }, + { world: "USER_SCRIPT", bootstrapToken }, { ...connectionSender, getConnectOrigin: () => "extension" as const } ) ).toBe(false); expect(runtime.registerUserScriptConnection({ world: "USER_SCRIPT" }, connectionSender)).toBe(false); - expect( - runtime.registerUserScriptConnection({ world: "USER_SCRIPT", executionHandles: handles }, connectionSender) - ).toBe(true); + expect(runtime.registerUserScriptConnection({ world: "USER_SCRIPT", bootstrapToken }, connectionSender)).toBe(true); + const bootstrapHandler = onMessage.mock.calls[0]?.[0] as ((packet: TMessage) => void) | undefined; + bootstrapHandler?.({ action: "userScript/bootstrap" }); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "content/pageLoad", + data: expect.objectContaining({ scripts: expect.any(Array) }), + }) + ); const sendUserScriptMessage = (runtime as any).sendUserScriptMessage.bind(runtime); + sendMessage.mockClear(); sendUserScriptMessage(undefined, "runtime/valueUpdate", { uuid: "other-script", storageName: getStorageName(script), diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 0f7cdd318..3e1bc47ac 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -40,6 +40,7 @@ import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import { ExtensionContentMessageSend } from "@Packages/message/extension_message"; import { sendMessage } from "@Packages/message/client"; import type { CompileScriptCodeResource } from "../content/utils"; +import { getExtensionOrigin, getPageRpcAllowedAPIs, type ExtensionOrigin } from "../content/page_rpc"; import { compileInjectScriptByFlag, compileScriptCodeByResource, @@ -146,6 +147,17 @@ export class RuntimeService { string, { connection: MessageConnect; handles: Set; tabId: number; frameId?: number; documentId?: string } >(); + private readonly userScriptBootstraps = new Map< + string, + { + scripts: TScriptInfo[]; + envInfo: GMInfoEnv; + extensionOrigin?: ExtensionOrigin; + tabId: number; + frameId?: number; + documentId?: string; + } + >(); getGMApi(): GMApi | undefined { return this.gmApi; @@ -160,12 +172,29 @@ export class RuntimeService { if ( binding.tabId === tabId && binding.frameId === frameId && - (envTag === undefined || binding.envTag === envTag) && + (envTag === undefined || binding.envTag === envTag || (envTag === "it" && binding.envTag === "ct")) && (documentId === undefined || binding.documentId === documentId) ) { this.pageExecutionBindings.delete(handle); } } + if (envTag === "it") { + for (const [key, entry] of this.userScriptConnections) { + if ( + entry.tabId === tabId && + entry.frameId === frameId && + (documentId === undefined || entry.documentId === documentId) + ) { + entry.connection.disconnect(true); + this.userScriptConnections.delete(key); + } + } + } + if (envTag !== "ct") { + for (const [token, bootstrap] of this.userScriptBootstraps) { + if (bootstrap.tabId === tabId && bootstrap.frameId === frameId) this.userScriptBootstraps.delete(token); + } + } } revokePageBindingsForTab(tabId: number): void { @@ -178,6 +207,9 @@ export class RuntimeService { this.userScriptConnections.delete(key); } } + for (const [token, bootstrap] of this.userScriptBootstraps) { + if (bootstrap.tabId === tabId) this.userScriptBootstraps.delete(token); + } } private userScriptConnectionKey(tabId: number, frameId?: number, documentId?: string): string { @@ -188,16 +220,13 @@ export class RuntimeService { registerUserScriptConnection(data: unknown, sender: IGetSender): boolean { if (!sender.isType(GetSenderType.EXTCONNECT) || sender.getConnectOrigin?.() !== "userScript") return false; if (data === null || typeof data !== "object") return false; - const handshake = data as { world?: unknown; executionHandles?: unknown }; + const handshake = data as { world?: unknown; bootstrapToken?: unknown }; if ( Object.keys(data).length !== 2 || handshake.world !== "USER_SCRIPT" || - !Array.isArray(handshake.executionHandles) || - handshake.executionHandles.length === 0 || - handshake.executionHandles.length > 256 || - handshake.executionHandles.some( - (handle) => typeof handle !== "string" || handle.length === 0 || handle.length > 256 - ) + typeof handshake.bootstrapToken !== "string" || + handshake.bootstrapToken.length === 0 || + handshake.bootstrapToken.length > 256 ) { return false; } @@ -205,8 +234,19 @@ export class RuntimeService { const connection = sender.getConnect(); const tabId = source?.tab?.id; if (!source || typeof tabId !== "number" || !connection) return false; - const handles = new Set(handshake.executionHandles as string[]); - for (const handle of handles) { + const bootstrap = this.userScriptBootstraps.get(handshake.bootstrapToken); + if ( + !bootstrap || + bootstrap.tabId !== tabId || + bootstrap.frameId !== source.frameId || + bootstrap.documentId !== source.documentId + ) { + return false; + } + const handles = new Set(); + for (const script of bootstrap.scripts) { + const handle = script.executionHandle; + if (typeof handle !== "string" || handle.length === 0 || handle.length > 256) return false; const binding = this.pageExecutionBindings.get(handle); if ( !binding || @@ -217,7 +257,10 @@ export class RuntimeService { ) { return false; } + handles.add(handle); } + if (handles.size === 0) return false; + this.userScriptBootstraps.delete(handshake.bootstrapToken); const frameId = source.frameId; const documentId = source.documentId; const key = this.userScriptConnectionKey(tabId, frameId, documentId); @@ -228,6 +271,31 @@ export class RuntimeService { connection.onDisconnect(() => { if (this.userScriptConnections.get(key)?.connection === connection) this.userScriptConnections.delete(key); }); + let bootstrapped = false; + connection.onMessage((packet) => { + if ( + bootstrapped || + packet === null || + typeof packet !== "object" || + Object.keys(packet).length !== 1 || + packet.action !== "userScript/bootstrap" + ) { + return; + } + bootstrapped = true; + try { + connection.sendMessage({ + action: "content/pageLoad", + data: { + scripts: bootstrap.scripts, + envInfo: bootstrap.envInfo, + extensionOrigin: bootstrap.extensionOrigin, + }, + }); + } catch { + this.userScriptConnections.delete(key); + } + }); return true; } @@ -266,12 +334,16 @@ export class RuntimeService { for (const [handle, binding] of this.pageExecutionBindings) { if (binding.uuid === uuid) this.pageExecutionBindings.delete(handle); } + for (const [token, bootstrap] of this.userScriptBootstraps) { + if (bootstrap.scripts.some((script) => script.uuid === uuid)) this.userScriptBootstraps.delete(token); + } } private issuePageBinding( uuid: string, envTag: "it" | "ct", storageName: string, + allowedAPIs: readonly string[], sender: IGetSender ): ServiceWorkerExecutionBinding { const source = sender.getSender(); @@ -287,6 +359,7 @@ export class RuntimeService { frameId: source?.frameId, documentId: source?.documentId, storageName, + allowedAPIs: new Set(allowedAPIs), requestIds: new Set(), } satisfies ServiceWorkerExecutionBinding; this.pageExecutionBindings.set(handle, binding); @@ -1444,6 +1517,7 @@ export class RuntimeService { } async pageLoad(data: { envTag?: "it" | "ct" } | undefined, sender: IGetSender): Promise { + if (sender.getConnectOrigin?.() === "userScript") return { ok: false }; const chromeSender = sender.getSender(); const url = chromeSender?.url; if (!url) { @@ -1455,6 +1529,10 @@ export class RuntimeService { const incognito = chromeSender.tab?.incognito ?? false; const res = await this.getScriptsForTab({ url, tabId, frameId, incognito }); + // Retire bindings even when the new URL has no matching scripts. This closes + // the reuse window on browsers that do not provide documentId. + this.revokePageBindings(sender, data?.envTag); + this.mq.emit("popupPageLoadUpdate", { tabId: tabId, frameId: frameId, @@ -1463,10 +1541,15 @@ export class RuntimeService { }); if (res) { - this.revokePageBindings(sender, data?.envTag); const prepareScripts = (scripts: TScriptInfo[], envTag: "it" | "ct") => scripts.map((script) => { - const binding = this.issuePageBinding(script.uuid, envTag, getStorageName(script), sender); + const binding = this.issuePageBinding( + script.uuid, + envTag, + getStorageName(script), + getPageRpcAllowedAPIs(script.metadata.grant || []), + sender + ); return { ...script, executionHandle: binding.handle, @@ -1474,12 +1557,27 @@ export class RuntimeService { executionRunFlag: binding.runFlag, }; }); + const injectScriptList = data?.envTag === "ct" ? [] : prepareScripts(res.injectScriptList, "it"); + const contentScriptList = prepareScripts(res.contentScriptList, "ct"); + let userScriptBootstrapToken: string | undefined; + if (data?.envTag === "it" && contentScriptList.length > 0) { + userScriptBootstrapToken = uuidv4(); + this.userScriptBootstraps.set(userScriptBootstrapToken, { + scripts: contentScriptList, + envInfo: res.envInfo, + extensionOrigin: getExtensionOrigin(), + tabId, + frameId, + documentId: chromeSender.documentId, + }); + } // 返回脚本资料,在页面加载 return { ok: true, - injectScriptList: data?.envTag === "ct" ? [] : prepareScripts(res.injectScriptList, "it"), - contentScriptList: data?.envTag === "it" ? [] : prepareScripts(res.contentScriptList, "ct"), + injectScriptList, + contentScriptList: data?.envTag === "it" ? [] : contentScriptList, envInfo: res.envInfo, + userScriptBootstrapToken, }; } else { // 没有脚本资料,不需要加载 diff --git a/src/app/service/service_worker/types.ts b/src/app/service/service_worker/types.ts index 977f6f0cc..951370783 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -65,6 +65,8 @@ export type ServiceWorkerExecutionBinding = { documentId?: string; /** 用于只向运行该脚本的文档投递值更新的存储命名空间。 */ storageName: string; + /** Capability names accepted by the isolated GM API broker for this page execution. */ + allowedAPIs: ReadonlySet; /** 已接受的页面请求 ID;绑定销毁时一并释放,确保绑定存续期间拒绝重放。 */ requestIds: Set; }; diff --git a/src/content.ts b/src/content.ts index aa5249ffc..6722c19bc 100644 --- a/src/content.ts +++ b/src/content.ts @@ -10,6 +10,9 @@ import { ScriptRuntime } from "./app/service/content/script_runtime"; import { ScriptEnvTag } from "@Packages/message/consts"; import { type TExtensionEnv } from "./app/service/extension/extension_env"; import { connectUserScriptChannel } from "./app/service/content/user_script_connection"; +import type { TScriptInfo } from "./app/repo/scripts"; +import type { GMInfoEnv } from "./app/service/content/types"; +import { setPageRpcExtensionOrigin, type ExtensionOrigin } from "./app/service/content/page_rpc"; const messageFlag = process.env.SC_RANDOM_KEY!; @@ -36,20 +39,45 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde const scriptExecutor = new ScriptExecutor(msg, domContentMsg, "serviceWorker"); const runtime = new ScriptRuntime(scriptEnvTag, server, msg, scriptExecutor, extensionEnv); runtime.contentInit(domServer, domMsg); - runtime.init(); - // Keep a native port for callbacks and value updates. The page-observable event - // channel remains limited to the synchronous DOM helper. - void runtime.loadPage(async (scripts) => { - await connectUserScriptChannel( - msg, - scripts.map((script) => script.executionHandle).filter((handle): handle is string => Boolean(handle)), - (_connection, packet) => { - if (packet.action === "content/runtime/valueUpdate") { + domServer.on( + "pageLoad", + (data: { bootstrapToken?: unknown; envInfo?: GMInfoEnv; extensionOrigin?: ExtensionOrigin }) => { + if (typeof data?.bootstrapToken !== "string" || data.bootstrapToken.length === 0) return; + void connectUserScriptChannel(msg, data.bootstrapToken, (_connection, packet) => { + if (packet.action === "content/pageLoad") { + const packetData = packet.data as { + scripts?: TScriptInfo[]; + envInfo?: GMInfoEnv; + extensionOrigin?: ExtensionOrigin; + }; + if ( + !packetData || + !Array.isArray(packetData.scripts) || + packetData.scripts.length === 0 || + !packetData.envInfo + ) { + return; + } + for (let i = 0; i < packetData.scripts.length; i += 1) { + const script = packetData.scripts[i]; + if ( + !script || + typeof script !== "object" || + script.executionEnvTag !== scriptEnvTag || + typeof script.executionHandle !== "string" + ) { + return; + } + } + setPageRpcExtensionOrigin(packetData.extensionOrigin); + runtime.startScripts(packetData.scripts, packetData.envInfo); + } else if (packet.action === "content/runtime/valueUpdate") { scriptExecutor.valueUpdate(packet.data as any); } else if (packet.action === "content/runtime/emitEvent") { scriptExecutor.emitEvent(packet.data as any); } - } - ); - }); + }); + } + ); + runtime.init(); }); From e1ccf0715affb3ca9761192eae457dc49cb8037a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:04:17 +0900 Subject: [PATCH 009/103] =?UTF-8?q?=F0=9F=90=9B=20preserve=20GM=20API=20re?= =?UTF-8?q?gistry=20enumeration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_context.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/service/content/gm_api/gm_context.ts b/src/app/service/content/gm_api/gm_context.ts index c604b7019..0a1bfcd88 100644 --- a/src/app/service/content/gm_api/gm_context.ts +++ b/src/app/service/content/gm_api/gm_context.ts @@ -1,4 +1,5 @@ import type { ApiParam, ApiValue } from "../types"; +import { Native } from "../global"; const apis: Record = Object.create(null); @@ -7,6 +8,11 @@ export function GMContextApiGet(name: string): ApiValue[] | undefined { return apis[name]; } +// 注册表由装饰器在模块载入时填充,供安装页的支持表守卫枚举全部能力。 +export function GMContextApiNames(): string[] { + return Native.objectKeys(apis); +} + function GMContextApiSet(grant: string, fnKey: string, api: any, param: ApiParam): void { // 一个 @grant 可以扩充多个 API 函数 let m: ApiValue[] | undefined = apis[grant]; From 9f98995ef15d5513f1c9808e83bd585a82137b40 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:10:50 +0900 Subject: [PATCH 010/103] =?UTF-8?q?=F0=9F=90=9B=20make=20GM=20API=20regist?= =?UTF-8?q?ry=20merge-compatible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_context.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/app/service/content/gm_api/gm_context.ts b/src/app/service/content/gm_api/gm_context.ts index 0a1bfcd88..87d873357 100644 --- a/src/app/service/content/gm_api/gm_context.ts +++ b/src/app/service/content/gm_api/gm_context.ts @@ -1,22 +1,30 @@ import type { ApiParam, ApiValue } from "../types"; import { Native } from "../global"; -const apis: Record = Object.create(null); +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 列表 - return apis[name]; + return apis.get(name); } -// 注册表由装饰器在模块载入时填充,供安装页的支持表守卫枚举全部能力。 +// 已注册的全部 @grant 名。注册表由装饰器在模块载入时填充,无法静态推导, +// 供 script_compat.ts 的静态支持表做一致性守卫(新增 GM API 若漏进表会被测出来)。 export function GMContextApiNames(): string[] { - return Native.objectKeys(apis); + return [...apis.keys()]; } function GMContextApiSet(grant: string, fnKey: string, api: any, param: ApiParam): void { // 一个 @grant 可以扩充多个 API 函数 - let m: ApiValue[] | undefined = apis[grant]; - if (!m) apis[grant] = m = []; + let m: ApiValue[] | undefined = apis.get(grant); + if (!m) apis.set(grant, (m = [])); m[m.length] = { fnKey, api, param }; } From 98685945385d2e816784d9b5552959aa57db0727 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:22:04 +0900 Subject: [PATCH 011/103] =?UTF-8?q?=E2=9A=A1=20reduce=20native=20reflectio?= =?UTF-8?q?n=20overhead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/create_context.ts | 30 +++--- src/app/service/content/exec_script.ts | 6 +- src/app/service/content/global.ts | 91 +++++++++++++++---- src/app/service/content/gm_api/cat_agent.ts | 17 ++-- .../service/content/gm_api/cat_agent_task.ts | 10 +- src/app/service/content/gm_api/gm_api.ts | 6 +- src/app/service/content/gm_api/gm_xhr.ts | 8 +- 7 files changed, 111 insertions(+), 57 deletions(-) diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 99db340ae..414bee3b2 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -11,8 +11,9 @@ import { attachNavigateHandler, type UrlChangeEvent } from "./gm_api/navigation_ import { Native } from "./global"; const createCapability = (api: (...args: any[]) => any, receiver: object) => { - const capability = (...args: any[]) => Native.reflectApply(api, receiver, args); + const capability = Native.bind(api, receiver); Native.objectDefineProperty(capability, "name", { configurable: true, value: `bound ${api.name}` }); + Native.objectDefineProperty(capability, "length", { configurable: true, value: 0 }); return capability; }; @@ -27,6 +28,7 @@ export const createContext = ( contentMsg: Message, scriptGrants: Set ) => { + const scriptGrantSet = Native.createSet(scriptGrants); // 按照GMApi构建 const valueChangeListener = new ListenerManager(); const EE = new EventEmitter(); @@ -77,8 +79,8 @@ export const createContext = ( const grantSet: Set = context.grantSet; const s = GMContextApiGet(grant); if (!s) return false; // @grant 的定义未实现,略过 (返回 false 表示 @grant 不存在) - if (Native.setHas(grantSet, grant)) return true; // 重复的@grant,略过 (返回 true 表示 @grant 存在) - Native.setAdd(grantSet, grant); + if (grantSet.has(grant)) return true; // 重复的@grant,略过 (返回 true 表示 @grant 存在) + grantSet.add(grant); for (let i = 0; i < s.length; i += 1) { const { fnKey, api, param } = s[i]; grantedAPIs[fnKey] = createCapability(api, context); @@ -89,7 +91,7 @@ export const createContext = ( } return true; }; - Native.setForEach(scriptGrants, (grant) => { + scriptGrantSet.forEach((grant) => { const candidates = getGrantCandidates(String(grant)); for (let i = 0; i < candidates.length; i += 1) { const candidate = candidates[i]; @@ -111,7 +113,7 @@ export const createContext = ( } } context.unsafeWindow = window; - if (Native.setHas(scriptGrants, "window.onurlchange") && context.onurlchange === undefined) { + if (scriptGrantSet.has("window.onurlchange") && context.onurlchange === undefined) { context.onurlchange = null; attachNavigateHandler(window as any); } @@ -241,8 +243,8 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn for (let i = 0; i < keys.length; i += 1) { const key = keys[i]; const desc = descriptors[key]; - if (Native.setHas(descsCache, key)) continue; - Native.setAdd(descsCache, key); // realm own descriptors take precedence over host descriptors + if (descsCache.has(key)) continue; + descsCache.add(key); // realm own descriptors take precedence over host descriptors if ("value" in desc) { // 替换 function 的 this 为实际的 realm global。 @@ -255,7 +257,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (desc.configurable && desc.get && desc.set && desc.enumerable && key.startsWith("on")) { // 替换 onxxxxx 事件赋值操作。 // 例:(window.)onload, (window.)onerror。 - Native.setAdd(eventKeys, key); + eventKeys.add(key); continue; } if (desc.get || desc.set) { @@ -275,16 +277,16 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (desc.configurable && desc.get && desc.set && key.startsWith("on")) { // 替换 onxxxxx 事件赋值操作。 // 例:(window.)onload, (window.)onerror。 - Native.setAdd(eventKeys, key); + eventKeys.add(key); return; } - if (Native.setHas(descsCache, key)) return; + if (descsCache.has(key)) return; if ("value" in desc) { // 替换 function 的 this 为实际的 host window。 if (shouldFnBind(desc.value)) { overriddenDescs[key] = materializeDescriptor(desc, hostWindow); - Native.setAdd(descsCache, key); + descsCache.add(key); } else if (!(key in initOwnDescs) && !Native.objectHasOwn(realmGlobal, key) && !protoBaseDescs[key]) { protoBaseDescs[key] = materializeDescriptor(desc, hostWindow); } @@ -294,7 +296,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn // 替换 getter setter 的 this 为实际的 host window。 // 例:(window.)location, (window.)document。 overriddenDescs[key] = materializeDescriptor(desc, hostWindow); - Native.setAdd(descsCache, key); + descsCache.add(key); } }); }; @@ -303,7 +305,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn collectRealmDescriptors(); // 第二趟 hostWindow:补齐 Firefox split-realm 的 host 成员。 collectHostWindowDescriptors(); - Native.setClear(descsCache); // 内存释放 + descsCache.clear(); // 内存释放 // sharedInitCopy: 完全继承Window.prototype 及 自定义 OwnPropertyDescriptor // OwnPropertyDescriptor定义 为 原OwnPropertyDescriptor定义 (DragEvent, MouseEvent, RegExp, EventTarget, JSON等) @@ -424,7 +426,7 @@ export const createProxyContext = ( }; const eventKeyList: string[] = []; - Native.setForEach(eventKeys, (key) => { + eventKeys.forEach((key) => { eventKeyList[eventKeyList.length] = String(key); }); for (let i = 0; i < eventKeyList.length; i += 1) { diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index 34fd9b75d..bd805ec1d 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -53,10 +53,10 @@ export default class ExecScript { } const grantSet = Native.createSet(scriptRes.metadata.grant || []); if (isContextMenuScript(scriptRes.metadata)) { - Native.setAdd(grantSet, "GM_registerMenuCommand"); - Native.setDelete(grantSet, "none"); + grantSet.add("GM_registerMenuCommand"); + grantSet.delete("none"); } - if (Native.setHas(grantSet, "none")) { + if (grantSet.has("none")) { // 不注入任何GM api // ScriptCat行为:GM.info 和 GM_info 同时注入 // 在不改变 Context 的情况下,以 named 传入多个全域变量 diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 53896b47f..f71c7e2c0 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -13,26 +13,90 @@ const nativeSetDelete = Set.prototype.delete; const nativeSetClear = Set.prototype.clear; const nativeSetForEach = Set.prototype.forEach; 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 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 = (fn: (...args: any[]) => any, receiver: any, args: any[]) => - nativeReflectApply(fn, receiver, args); + nativeFunctionApply(fn, receiver, args); export const nativeCall = (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => - nativeReflectApply(fn, receiver, args); + nativeFunctionCall(fn, receiver, ...args); export const nativeBind = (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => - nativeReflectApply(nativeFunctionBind, fn, [receiver, ...args]); + nativeFunctionCall(nativeFunctionBind, fn, receiver, ...args); + +type SafeSet = Set & { + add: Set["add"]; + has: Set["has"]; + delete: Set["delete"]; + clear: Set["clear"]; + forEach: Set["forEach"]; +}; + +type SafeMap = Map & { + get: Map["get"]; + set: Map["set"]; + has: Map["has"]; + delete: Map["delete"]; + clear: Map["clear"]; + forEach: Map["forEach"]; +}; -const createNativeSet = (values?: readonly T[]): Set => { - const set = new nativeSetConstructor(); - if (values) { - for (let i = 0; i < values.length; i += 1) nativeReflectApply(nativeSetAdd, set, [values[i]]); +type SafeWeakMap = WeakMap & { + get: WeakMap["get"]; + set: WeakMap["set"]; + has: WeakMap["has"]; + delete: WeakMap["delete"]; +}; + +const createNativeSet = (values?: readonly T[] | Set | null): SafeSet => { + const set = new nativeSetConstructor() as SafeSet; + set.add = nativeSetAdd as SafeSet["add"]; + set.has = nativeSetHas as SafeSet["has"]; + set.delete = nativeSetDelete as SafeSet["delete"]; + set.clear = nativeSetClear as SafeSet["clear"]; + set.forEach = nativeSetForEach as SafeSet["forEach"]; + if (Array.isArray(values)) { + for (let i = 0; i < values.length; i += 1) set.add(values[i]); + } else if (values) { + nativeReflectApply(nativeSetForEach, values, [(value: T) => set.add(value)]); } return set; }; -const createNativeWeakMap = (): WeakMap => new nativeWeakMapConstructor(); +const createNativeMap = (): SafeMap => { + const map = new nativeMapConstructor() as SafeMap; + map.get = nativeMapGet as SafeMap["get"]; + map.set = nativeMapSet as SafeMap["set"]; + map.has = nativeMapHas as SafeMap["has"]; + map.delete = nativeMapDelete as SafeMap["delete"]; + map.clear = nativeMapClear as SafeMap["clear"]; + map.forEach = nativeMapForEach as SafeMap["forEach"]; + return map; +}; + +const createNativeWeakMap = (): SafeWeakMap => { + const map = new nativeWeakMapConstructor() as SafeWeakMap; + map.get = nativeWeakMapGet as SafeWeakMap["get"]; + map.set = nativeWeakMapSet as SafeWeakMap["set"]; + map.has = nativeWeakMapHas as SafeWeakMap["has"]; + map.delete = nativeWeakMapDelete as SafeWeakMap["delete"]; + return map; +}; export const Native = { Set: nativeSetConstructor, @@ -57,17 +121,8 @@ export const Native = { objectGetPrototypeOf: nativeBind(Object.getPrototypeOf, Object), reflectOwnKeys: nativeBind(Reflect.ownKeys, Reflect), reflectGet: nativeBind(Reflect.get, Reflect), - setAdd: (set: Set, value: unknown) => nativeReflectApply(nativeSetAdd, set, [value]), - setHas: (set: Set, value: unknown) => nativeReflectApply(nativeSetHas, set, [value]), - setDelete: (set: Set, value: unknown) => nativeReflectApply(nativeSetDelete, set, [value]), - setClear: (set: Set) => nativeReflectApply(nativeSetClear, set, []), - setForEach: (set: Set, callback: (value: unknown, value2: unknown, set: Set) => void) => - nativeReflectApply(nativeSetForEach, set, [callback]), createSet: createNativeSet, - weakMapGet: (map: WeakMap, key: K) => - nativeReflectApply(nativeWeakMapGet, map, [key]) as V | undefined, - weakMapSet: (map: WeakMap, key: K, value: V) => - nativeReflectApply(nativeWeakMapSet, map, [key, value]) as WeakMap, + createMap: createNativeMap, createWeakMap: createNativeWeakMap, } as const; diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index fbdc67ee2..42fcd5e9c 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -18,8 +18,7 @@ import type { MessageContent, } from "@App/app/service/agent/core/types"; import { getTextContent } from "@App/app/service/agent/core/content_utils"; - -const nativeReflectApply = Reflect.apply; +import { Native } from "../global"; export type ConversationStreamChunk = | StreamChunk @@ -94,12 +93,10 @@ type ConversationPrivateState = { background: boolean; }; -const conversationStates = new WeakMap(); -const weakMapGet = WeakMap.prototype.get; -const weakMapSet = WeakMap.prototype.set; +const conversationStates = Native.createWeakMap(); const getConversationState = (instance: ConversationInstance): ConversationPrivateState => { - const state = nativeReflectApply(weakMapGet, conversationStates, [instance]); + const state = conversationStates.get(instance); if (!state) throw new Error("conversation instance is invalid"); return state; }; @@ -137,7 +134,7 @@ export class ConversationInstance { systemPrompt: system, background: background || false, }; - nativeReflectApply(weakMapSet, conversationStates, [this, state]); + conversationStates.set(this, state); this.ephemeral = ephemeral || false; if (initialTools) { for (const tool of initialTools) { @@ -925,10 +922,12 @@ function buildInstance( conv: Conversation, options?: ConversationCreateOptions ): ConversationInstance { + const sendMessage = Native.bind(ctx.sendMessage, ctx); + const connect = Native.bind(ctx.connect, ctx); return new ConversationInstance( conv, - (api, params) => nativeReflectApply(ctx.sendMessage, ctx, [api, params]), - (api, params) => nativeReflectApply(ctx.connect, ctx, [api, params]), + sendMessage, + connect, ctx.scriptRes?.uuid || "", options?.tools, options?.commands, 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 83fe76a40..bca205877 100644 --- a/src/app/service/content/gm_api/cat_agent_task.ts +++ b/src/app/service/content/gm_api/cat_agent_task.ts @@ -7,6 +7,7 @@ import type { EventAgentTask, } from "@App/app/service/agent/core/types"; import type EventEmitter from "eventemitter3"; +import { Native } from "../global"; // 运行时 this 是 GM_Base 实例 interface GMBaseContext { @@ -18,16 +19,13 @@ interface GMBaseContext { // 内部 listener 计数器 let listenerCounter = 0; type ListenerRecord = { id: number; eventName: string; callback: (...args: any[]) => void }; -const listenerMaps = new WeakMap(); -const nativeReflectApply = Reflect.apply; -const weakMapGet = WeakMap.prototype.get; -const weakMapSet = WeakMap.prototype.set; +const listenerMaps = Native.createWeakMap(); const getListenerRecords = (owner: object): ListenerRecord[] => { - let records = nativeReflectApply(weakMapGet, listenerMaps, [owner]); + let records = listenerMaps.get(owner); if (!records) { records = []; - nativeReflectApply(weakMapSet, listenerMaps, [owner, records]); + listenerMaps.set(owner, records); } return records; }; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index b46488713..b3df05a08 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -65,10 +65,10 @@ const valueChangePromiseMap: Record void> = Object.create(null); const notificationTagMaps = Native.createWeakMap>(); const getNotificationTagMap = (owner: object): Map => { - let map = Native.weakMapGet(notificationTagMaps, owner); + let map = notificationTagMaps.get(owner); if (!map) { - map = new Native.Map(); - Native.weakMapSet(notificationTagMaps, owner, map); + map = Native.createMap(); + notificationTagMaps.set(owner, map); } return map; }; diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index cb3220ef4..8eb758feb 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -148,7 +148,7 @@ interface RetStateFnRecord { const xhrResponseGetters = { response: { get() { - const retTemp = Native.weakMapGet(retStateFnMap, this); + const retTemp = retStateFnMap.get(this); return retTemp?.getResponse(); }, enumerable: false, @@ -156,7 +156,7 @@ const xhrResponseGetters = { }, responseXML: { get() { - const retTemp = Native.weakMapGet(retStateFnMap, this); + const retTemp = retStateFnMap.get(this); return retTemp?.getResponseXML(); }, enumerable: false, @@ -164,7 +164,7 @@ const xhrResponseGetters = { }, responseText: { get() { - const retTemp = Native.weakMapGet(retStateFnMap, this); + const retTemp = retStateFnMap.get(this); return retTemp?.getResponseText(); }, enumerable: false, @@ -435,7 +435,7 @@ export function GM_xmlhttpRequest( const retParamObject: GMXHRResponseType = Native.objectCreate(null, descriptors); // 外部没引用 retParamObject 时,retTemp 会被自动GC const retTemp = makeRetTemp(contentType); - Native.weakMapSet(retStateFnMap, retParamObject, retTemp); + retStateFnMap.set(retParamObject, retTemp); return retParamObject; }; From f49053afa7b35f6c6a99d882673f55cd1c4b11bf Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:29:33 +0900 Subject: [PATCH 012/103] =?UTF-8?q?=E2=9A=A1=20reduce=20content=20callback?= =?UTF-8?q?=20overhead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/create_context.ts | 4 +-- src/app/service/content/global.ts | 6 ++-- src/app/service/content/gm_api/gm_api.ts | 10 +++--- src/app/service/content/gm_api/gm_xhr.ts | 39 ++++++++++++----------- 4 files changed, 29 insertions(+), 30 deletions(-) diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 414bee3b2..8bf3e9520 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -8,7 +8,7 @@ import { isEarlyStartScript } from "./utils"; import { ListenerManager } from "./listener_manager"; import { createGMBase } from "./gm_api/gm_api"; import { attachNavigateHandler, type UrlChangeEvent } from "./gm_api/navigation_handle"; -import { Native } from "./global"; +import { nativeCall, Native } from "./global"; const createCapability = (api: (...args: any[]) => any, receiver: object) => { const capability = Native.bind(api, receiver); @@ -393,7 +393,7 @@ export const createProxyContext = ( hostRemoveEventListener(eventName, eventObject); this.fn = null; } else { - Native.call(fn, mySandbox, event); + nativeCall(fn, mySandbox, event); } }, }; diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index f71c7e2c0..51ae2c95c 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -32,10 +32,8 @@ const nativeFunctionCall = nativeReflectApply(nativeFunctionBind, Function.proto Function.prototype.call, ]) as (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => any; -export const nativeApply = (fn: (...args: any[]) => any, receiver: any, args: any[]) => - nativeFunctionApply(fn, receiver, args); -export const nativeCall = (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => - nativeFunctionCall(fn, receiver, ...args); +export const nativeApply = nativeFunctionApply; +export const nativeCall = nativeFunctionCall; export const nativeBind = (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => nativeFunctionCall(nativeFunctionBind, fn, receiver, ...args); diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index b3df05a08..b85c7dbe5 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, nativeApply, Native } from "../global"; import type { Message, MessageConnect } from "@Packages/message/types"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import type { @@ -1345,7 +1345,7 @@ export default class GMApi extends GM_Base { gmApi.sendMessage("GM_notification", [customClone(data), notificationId]).then((id) => { if (!gmApi.EE) return; if (create) { - Native.apply(create, { id }, [id]); + nativeApply(create, { id }, [id]); } if (typeof data.tag === "string") { notificationTagMap.set(data.tag, id); @@ -1382,8 +1382,8 @@ export default class GMApi extends GM_Base { title: data.title, url: data.url, }; - click && Native.apply(click, { id }, [clickEvent]); - done && Native.apply(done, { id }, []); + click && nativeApply(click, { id }, [clickEvent]); + done && nativeApply(done, { id }, []); if (!isPreventDefault) { if (typeof data.url === "string") { @@ -1396,7 +1396,7 @@ export default class GMApi extends GM_Base { break; } case "close": { - done && Native.apply(done, { id }, [resp.params.byUser]); + done && nativeApply(done, { id }, [resp.params.byUser]); clearNotificationIdMap(); gmApi.EE.removeAllListeners("GM_notification:" + gmApi.eventId); break; diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 8eb758feb..28d00a620 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -137,6 +137,17 @@ const docParseTypes = Native.createSet([ const retStateFnMap = Native.createWeakMap(); +const invokeXHRCallback = (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) }); + } +}; + interface RetStateFnRecord { getResponseText(): string | undefined; getResponseXML(): Document | null | undefined; @@ -506,16 +517,6 @@ export function GM_xmlhttpRequest( return makeResponseRet(retParam, addGetters, res.contentType); }; - 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 makeXHRCallbackParam: typeof makeXHRCallbackParam_ | null = makeXHRCallbackParam_; let loadendCalled = false; const doLoadEnd = (data: TXhrCallBackArg) => { @@ -532,7 +533,7 @@ export function GM_xmlhttpRequest( retPromiseReject?.(errorOccur); } refCleanup?.(); - invokeCallback("onloadend", details.onloadend, xhrResponse); + invokeXHRCallback("onloadend", details.onloadend, xhrResponse); } }; const scheduleSyntheticLoadEnd = () => { @@ -550,7 +551,7 @@ export function GM_xmlhttpRequest( reqDone = true; // Mark the request settled before user code runs. A throwing abort // callback must not leave the broker connection and loadend cleanup pending. - invokeCallback("onabort", details.onabort, makeXHRCallbackParam?.(data) ?? {}); + invokeXHRCallback("onabort", details.onabort, makeXHRCallbackParam?.(data) ?? {}); // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); // doAbort 不是由通讯管控 onloadend. 需要手动处理. 排程在下一个 microTask 避免影响 Abort 流程 @@ -586,7 +587,7 @@ export function GM_xmlhttpRequest( if (!reqDone) { errorOccur = message; reqDone = true; - invokeCallback("onerror", details.onerror, { + invokeXHRCallback("onerror", details.onerror, { readyState: ReadyStateCode.DONE, error: message, }); @@ -668,14 +669,14 @@ export function GM_xmlhttpRequest( break; } case "onload": - invokeCallback("onload", details.onload, makeXHRCallbackParam?.(data) ?? {}); + invokeXHRCallback("onload", details.onload, makeXHRCallbackParam?.(data) ?? {}); break; case "onloadend": { doLoadEnd(data); break; } case "onloadstart": - invokeCallback("onloadstart", details.onloadstart, makeXHRCallbackParam?.(data) ?? {}); + invokeXHRCallback("onloadstart", details.onloadstart, makeXHRCallbackParam?.(data) ?? {}); break; case "onprogress": { if (details.onprogress) { @@ -687,7 +688,7 @@ export function GM_xmlhttpRequest( done: data.loaded, totalSize: data.total, }; - invokeCallback("onprogress", details.onprogress, res); + invokeXHRCallback("onprogress", details.onprogress, res); } break; } @@ -700,14 +701,14 @@ export function GM_xmlhttpRequest( // readable stream 的 controller 可以释放 controller = undefined; // GC用 } - invokeCallback("onreadystatechange", details.onreadystatechange, makeXHRCallbackParam?.(data) ?? {}); + invokeXHRCallback("onreadystatechange", details.onreadystatechange, makeXHRCallbackParam?.(data) ?? {}); break; } case "ontimeout": if (!reqDone) { errorOccur = "TimeoutError"; reqDone = true; - invokeCallback("ontimeout", details.ontimeout, makeXHRCallbackParam?.(data) ?? {}); + invokeXHRCallback("ontimeout", details.ontimeout, makeXHRCallbackParam?.(data) ?? {}); scheduleSyntheticLoadEnd(); // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); @@ -718,7 +719,7 @@ export function GM_xmlhttpRequest( data.error ||= "Unknown Error"; errorOccur = data.error; reqDone = true; - invokeCallback( + invokeXHRCallback( "onerror", details.onerror, (makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError From 47c27049cfa0c7e1138571d3418228f5e0fe7311 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:45:22 +0900 Subject: [PATCH 013/103] =?UTF-8?q?=E2=9A=A1=20avoid=20receiver=20binding?= =?UTF-8?q?=20for=20pure=20agent=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/create_context.ts | 11 ++++--- .../content/gm_api/cat_agent_model.test.ts | 9 +++--- .../service/content/gm_api/cat_agent_model.ts | 20 +++++-------- .../content/gm_api/cat_agent_opfs.test.ts | 20 ++++++------- .../service/content/gm_api/cat_agent_opfs.ts | 30 +++++++++++-------- src/app/service/content/types.ts | 2 ++ 6 files changed, 49 insertions(+), 43 deletions(-) diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 8bf3e9520..31724ad3a 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -10,9 +10,12 @@ import { createGMBase } 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) => { - const capability = Native.bind(api, receiver); - Native.objectDefineProperty(capability, "name", { configurable: true, value: `bound ${api.name}` }); +const createCapability = (api: (...args: any[]) => any, receiver: object, bind = true) => { + const capability = bind ? Native.bind(api, receiver) : (...args: any[]) => api(receiver, ...args); + Native.objectDefineProperty(capability, "name", { + configurable: true, + value: `${bind ? "bound " : ""}${api.name}`, + }); Native.objectDefineProperty(capability, "length", { configurable: true, value: 0 }); return capability; }; @@ -83,7 +86,7 @@ export const createContext = ( grantSet.add(grant); for (let i = 0; i < s.length; i += 1) { const { fnKey, api, param } = s[i]; - grantedAPIs[fnKey] = createCapability(api, context); + grantedAPIs[fnKey] = createCapability(api, context, param?.bind !== false); const depend = param?.depend; if (depend) { for (let j = 0; j < depend.length; j += 1) __methodInject__(depend[j]); 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..94a90dd2e 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 @@ -15,6 +15,7 @@ describe.concurrent("CATAgentModelApi", () => { expect(fnKeys).toContain("CAT.agent.model.list"); expect(fnKeys).toContain("CAT.agent.model.get"); expect(fnKeys).toContain("CAT.agent.model.getDefault"); + expect(apis!.every((api) => api.param.bind === false)).toBe(true); }); it.concurrent("list 方法调用 sendMessage 并传递正确的请求", async () => { @@ -31,7 +32,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 +58,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 +76,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 +94,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..e876dea9b 100644 --- a/src/app/service/content/gm_api/cat_agent_model.ts +++ b/src/app/service/content/gm_api/cat_agent_model.ts @@ -22,33 +22,29 @@ export default class CATAgentModelApi { @GMContext.protected() protected scriptRes?: { uuid: string }; - @GMContext.API({ follow: "CAT.agent.model" }) - public "CAT.agent.model.list"(): Promise { - const ctx = this as unknown as GMBaseContext; + @GMContext.API({ follow: "CAT.agent.model", bind: false }) + 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; + @GMContext.API({ follow: "CAT.agent.model", bind: false }) + 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; + @GMContext.API({ follow: "CAT.agent.model", bind: false }) + 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; + @GMContext.API({ follow: "CAT.agent.model", bind: false }) + 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..d3cfc5e81 100644 --- a/src/app/service/content/gm_api/cat_agent_opfs.ts +++ b/src/app/service/content/gm_api/cat_agent_opfs.ts @@ -16,20 +16,23 @@ export default class CATAgentOPFSApi { @GMContext.protected() 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; + @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) + 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 }>; } - @GMContext.API({ follow: "CAT.agent.opfs" }) + @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) 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, ]); @@ -41,19 +44,21 @@ export default class CATAgentOPFSApi { return result; } - @GMContext.API({ follow: "CAT.agent.opfs" }) - public "CAT.agent.opfs.list"(path?: string): Promise> { - const ctx = this as unknown as GMBaseContext; + @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) + 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>; } - @GMContext.API({ follow: "CAT.agent.opfs" }) + @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) 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, ]); @@ -65,9 +70,8 @@ export default class CATAgentOPFSApi { return result; } - @GMContext.API({ follow: "CAT.agent.opfs" }) - public "CAT.agent.opfs.delete"(path: string): Promise<{ success: true }> { - const ctx = this as unknown as GMBaseContext; + @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) + 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/types.ts b/src/app/service/content/types.ts index 61d0f060f..0f50887a5 100644 --- a/src/app/service/content/types.ts +++ b/src/app/service/content/types.ts @@ -38,6 +38,8 @@ export interface ApiParam { follow?: string; depend?: string[]; alias?: string; + /** API receives its GM context as the first argument instead of via `this`. */ + bind?: boolean; } export interface ApiValue { From 8bcad36c9cedd2f46c95b5a67a6d97144113382c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:48:07 +0900 Subject: [PATCH 014/103] =?UTF-8?q?=E2=9A=A1=20capture=20native=20collecti?= =?UTF-8?q?on=20methods=20on=20subclasses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/create_context.test.ts | 17 ++++++ src/app/service/content/global.ts | 57 +++++++++++-------- 2 files changed, 51 insertions(+), 23 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index c5f9f8b70..f90a5dd7f 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -4,6 +4,7 @@ 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"; type AnyRecord = Record; @@ -305,6 +306,22 @@ describe("shouldFnBind", () => { }); describe("createContext: capability and lifecycle contract", () => { + it("creates collection instances from frozen captured-method subclasses", () => { + const set = Native.createSet(["grant"]); + const map = Native.createMap(); + const weakMap = Native.createWeakMap(); + + 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 nativeArrayIterator = Array.prototype[Symbol.iterator]; diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 51ae2c95c..9b43fcc10 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -24,6 +24,34 @@ const nativeWeakMapGet = WeakMap.prototype.get; const nativeWeakMapSet = WeakMap.prototype.set; const nativeWeakMapHas = WeakMap.prototype.has; const nativeWeakMapDelete = WeakMap.prototype.delete; +const nativeObjectFreeze = Object.freeze; + +// 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 {}; +NativeSetConstructor.prototype.add = nativeSetAdd; +NativeSetConstructor.prototype.has = nativeSetHas; +NativeSetConstructor.prototype.delete = nativeSetDelete; +NativeSetConstructor.prototype.clear = nativeSetClear; +NativeSetConstructor.prototype.forEach = nativeSetForEach; +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, @@ -62,12 +90,7 @@ type SafeWeakMap = WeakMap & { }; const createNativeSet = (values?: readonly T[] | Set | null): SafeSet => { - const set = new nativeSetConstructor() as SafeSet; - set.add = nativeSetAdd as SafeSet["add"]; - set.has = nativeSetHas as SafeSet["has"]; - set.delete = nativeSetDelete as SafeSet["delete"]; - set.clear = nativeSetClear as SafeSet["clear"]; - set.forEach = nativeSetForEach as SafeSet["forEach"]; + const set = new NativeSetConstructor() as SafeSet; if (Array.isArray(values)) { for (let i = 0; i < values.length; i += 1) set.add(values[i]); } else if (values) { @@ -77,29 +100,17 @@ const createNativeSet = (values?: readonly T[] | Set | null): SafeSet = }; const createNativeMap = (): SafeMap => { - const map = new nativeMapConstructor() as SafeMap; - map.get = nativeMapGet as SafeMap["get"]; - map.set = nativeMapSet as SafeMap["set"]; - map.has = nativeMapHas as SafeMap["has"]; - map.delete = nativeMapDelete as SafeMap["delete"]; - map.clear = nativeMapClear as SafeMap["clear"]; - map.forEach = nativeMapForEach as SafeMap["forEach"]; - return map; + return new NativeMapConstructor() as SafeMap; }; const createNativeWeakMap = (): SafeWeakMap => { - const map = new nativeWeakMapConstructor() as SafeWeakMap; - map.get = nativeWeakMapGet as SafeWeakMap["get"]; - map.set = nativeWeakMapSet as SafeWeakMap["set"]; - map.has = nativeWeakMapHas as SafeWeakMap["has"]; - map.delete = nativeWeakMapDelete as SafeWeakMap["delete"]; - return map; + return new NativeWeakMapConstructor() as SafeWeakMap; }; export const Native = { - Set: nativeSetConstructor, - Map: nativeMapConstructor, - WeakMap: nativeWeakMapConstructor, + Set: NativeSetConstructor, + Map: NativeMapConstructor, + WeakMap: NativeWeakMapConstructor, apply: nativeApply, call: nativeCall, bind: nativeBind, From f2aad91956b7d439e6e928d711bb1e393830a84f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:00:00 +0900 Subject: [PATCH 015/103] =?UTF-8?q?=E2=9A=A1=20remove=20GM=20API=20receive?= =?UTF-8?q?r=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/create_context.test.ts | 2 +- src/app/service/content/create_context.ts | 9 +- src/app/service/content/gm_api/cat_agent.ts | 26 +- .../service/content/gm_api/cat_agent_dom.ts | 59 +-- .../content/gm_api/cat_agent_model.test.ts | 1 - .../service/content/gm_api/cat_agent_model.ts | 10 +- .../service/content/gm_api/cat_agent_opfs.ts | 12 +- .../content/gm_api/cat_agent_skills.ts | 15 +- .../service/content/gm_api/cat_agent_task.ts | 33 +- src/app/service/content/gm_api/gm_api.test.ts | 70 +-- src/app/service/content/gm_api/gm_api.ts | 420 ++++++++++-------- .../gm_api/related_target_lifecycle.test.ts | 6 +- src/app/service/content/types.ts | 2 - tests/runtime/gm_api.test.ts | 24 +- 14 files changed, 361 insertions(+), 328 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index f90a5dd7f..717d5d62a 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -385,7 +385,7 @@ describe("createContext: capability and lifecycle contract", () => { 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 (this: unknown, key: string, fallback?: unknown) { + const replacement = function (_ctx: unknown, key: string, fallback?: unknown) { return fallback; }; Object.defineProperty(replacement, "bind", { configurable: true, value: undefined }); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 31724ad3a..22c3960e9 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -10,11 +10,12 @@ import { createGMBase } 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, bind = true) => { - const capability = bind ? Native.bind(api, receiver) : (...args: any[]) => api(receiver, ...args); +const createCapability = (api: (...args: any[]) => any, receiver: object) => { + // 由闭包提供上下文,脚本侧只传 API 自身的参数。 + const capability = (...args: any[]) => api(receiver, ...args); Native.objectDefineProperty(capability, "name", { configurable: true, - value: `${bind ? "bound " : ""}${api.name}`, + value: api.name, }); Native.objectDefineProperty(capability, "length", { configurable: true, value: 0 }); return capability; @@ -86,7 +87,7 @@ export const createContext = ( grantSet.add(grant); for (let i = 0; i < s.length; i += 1) { const { fnKey, api, param } = s[i]; - grantedAPIs[fnKey] = createCapability(api, context, param?.bind !== false); + grantedAPIs[fnKey] = createCapability(api, context); const depend = param?.depend; if (depend) { for (let j = 0; j < depend.length; j += 1) __methodInject__(depend[j]); diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index 42fcd5e9c..e32f911c8 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -908,15 +908,14 @@ 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, @@ -953,7 +952,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,直接在脚本端构造 @@ -965,26 +967,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 94a90dd2e..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 @@ -15,7 +15,6 @@ describe.concurrent("CATAgentModelApi", () => { expect(fnKeys).toContain("CAT.agent.model.list"); expect(fnKeys).toContain("CAT.agent.model.get"); expect(fnKeys).toContain("CAT.agent.model.getDefault"); - expect(apis!.every((api) => api.param.bind === false)).toBe(true); }); it.concurrent("list 方法调用 sendMessage 并传递正确的请求", async () => { 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 e876dea9b..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, @@ -22,28 +22,28 @@ export default class CATAgentModelApi { @GMContext.protected() protected scriptRes?: { uuid: string }; - @GMContext.API({ follow: "CAT.agent.model", bind: false }) + @GMContext.API({ follow: "CAT.agent.model" }) 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", bind: false }) + @GMContext.API({ follow: "CAT.agent.model" }) 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", bind: false }) + @GMContext.API({ follow: "CAT.agent.model" }) 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", bind: false }) + @GMContext.API({ follow: "CAT.agent.model" }) public "CAT.agent.model.getSummary"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "getSummary", scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, 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 d3cfc5e81..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 }; @@ -16,7 +16,7 @@ export default class CATAgentOPFSApi { @GMContext.protected() protected scriptRes?: { uuid: string }; - @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) + @GMContext.API({ follow: "CAT.agent.opfs" }) public "CAT.agent.opfs.write"( ctx: GMBaseContext, path: string, @@ -27,7 +27,7 @@ export default class CATAgentOPFSApi { ]) as Promise<{ path: string; size: number }>; } - @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) + @GMContext.API({ follow: "CAT.agent.opfs" }) public async "CAT.agent.opfs.read"( ctx: GMBaseContext, path: string, @@ -44,7 +44,7 @@ export default class CATAgentOPFSApi { return result; } - @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) + @GMContext.API({ follow: "CAT.agent.opfs" }) public "CAT.agent.opfs.list"( ctx: GMBaseContext, path?: string @@ -54,7 +54,7 @@ export default class CATAgentOPFSApi { ]) as Promise>; } - @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) + @GMContext.API({ follow: "CAT.agent.opfs" }) public async "CAT.agent.opfs.readAttachment"( ctx: GMBaseContext, id: string @@ -70,7 +70,7 @@ export default class CATAgentOPFSApi { return result; } - @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) + @GMContext.API({ follow: "CAT.agent.opfs" }) 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, 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 bca205877..3ef602884 100644 --- a/src/app/service/content/gm_api/cat_agent_task.ts +++ b/src/app/service/content/gm_api/cat_agent_task.ts @@ -9,7 +9,7 @@ import type { 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 }; @@ -43,11 +43,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 }; @@ -60,14 +60,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 >; @@ -76,8 +74,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." @@ -89,8 +86,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()." @@ -102,16 +102,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; @@ -129,8 +131,7 @@ export default class CATAgentTaskApi { } @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 records = getListenerRecords(ctx); 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 5f0629f39..94f3dfb70 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -140,10 +140,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, @@ -152,7 +152,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"); }); }); @@ -198,23 +198,23 @@ describe.concurrent("@grant GM", () => { exec.scriptFunc = compileScript(compileScriptCode(script)); 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"); @@ -260,23 +260,23 @@ describe.concurrent("@grant GM", () => { exec.scriptFunc = compileScript(compileScriptCode(script)); 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"); @@ -1301,7 +1301,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 script.metadata.grant = ["GM_getValue", "GM_addValueChangeListener"]; script.value = {}; const api = new GMApi("test", {} as Message, {} as Message, script); - api.GM_addValueChangeListener("snapshot", (_name, _oldValue, newValue) => { + api.GM_addValueChangeListener(api, "snapshot", (_name, _oldValue, newValue) => { const snapshot = newValue as { nested: { value: number } }; snapshot.nested.value = 99; }); @@ -1314,7 +1314,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 valueUpdated: true, }); - expect(api.GM_getValue("snapshot")).toEqual({ nested: { value: 1 } }); + expect(api.GM_getValue(api, "snapshot")).toEqual({ nested: { value: 1 } }); }); it.concurrent("异步GM.setValue,等待回调", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index b85c7dbe5..a1d47cc09 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -313,15 +313,15 @@ export default class GMApi extends GM_Base { // 获取脚本的值,可以通过@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); }); } @@ -390,62 +390,62 @@ export default class GMApi extends GM_Base { } @GMContext.API() - public GM_setValue(key: string, value: any) { - _GM_setValue(this, null, key, value); + public GM_setValue(ctx: GMApi, key: string, value: any) { + _GM_setValue(ctx, null, key, value); } @GMContext.API() - public "GM.setValue"(key: string, value: any): Promise { + public "GM.setValue"(ctx: GMApi, key: string, value: any): Promise { // Asynchronous wrapper for GM_setValue to support GM.setValue return new Promise((resolve) => { - _GM_setValue(this, resolve, key, value); + _GM_setValue(ctx, resolve, key, value); }); } @GMContext.API() - public GM_deleteValue(key: string): void { - _GM_setValue(this, null, key, undefined); + public GM_deleteValue(ctx: GMApi, key: string): void { + _GM_setValue(ctx, null, key, undefined); } @GMContext.API() - public "GM.deleteValue"(key: string): Promise { + public "GM.deleteValue"(ctx: GMApi, key: string): Promise { // Asynchronous wrapper for GM_deleteValue to support GM.deleteValue return new Promise((resolve) => { - _GM_setValue(this, resolve, key, undefined); + _GM_setValue(ctx, resolve, 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 = Object.keys(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 = Object.keys(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); + _GM_setValues(ctx, null, 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 {}; if (!keysOrDefaults) { // Returns all values - return customClone(this.scriptRes.value)!; + return customClone(ctx.scriptRes.value)!; } const result: TGMKeyValue = {}; if (Array.isArray(keysOrDefaults)) { @@ -453,9 +453,9 @@ export default class GMApi extends GM_Base { // 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 (key in ctx.scriptRes.value) { // 对object的value进行一次转化 - let value = this.scriptRes.value[key]; + let value = ctx.scriptRes.value[key]; if (value && typeof value === "object") { value = customClone(value)!; } @@ -467,7 +467,7 @@ export default class GMApi extends GM_Base { // Handle object with default values (e.g., { foo: 1, bar: 2, baz: 3 }) for (const key of Object.keys(keysOrDefaults)) { const defaultValue = keysOrDefaults[key]; - result[key] = _GM_getValue(this, key, defaultValue); + result[key] = _GM_getValue(ctx, key, defaultValue); } } return result; @@ -475,28 +475,28 @@ export default class GMApi extends GM_Base { // 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(() => {}); + public "GM.getValues"(ctx: GMApi, keysOrDefaults: TGMKeyValue | string[] | null | undefined): Promise { + if (!ctx.scriptRes) return 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(() => {}); + public "GM.setValues"(ctx: GMApi, values: { [key: string]: any }): Promise { + if (!ctx.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); + _GM_setValues(ctx, resolve, values); }); } @GMContext.API() - public GM_deleteValues(keys: string[]) { - if (!this.scriptRes) return; + public GM_deleteValues(ctx: GMApi, keys: string[]) { + if (!ctx.scriptRes) return; if (!Array.isArray(keys)) { console.warn("GM_deleteValues: keys must be string[]"); return; @@ -505,13 +505,13 @@ export default class GMApi extends GM_Base { for (const key of keys) { req[key] = undefined; } - _GM_setValues(this, null, req); + _GM_setValues(ctx, null, req); } // Asynchronous wrapper for GM.deleteValues @GMContext.API() - public "GM.deleteValues"(keys: string[]): Promise { - if (!this.scriptRes) return new Promise(() => {}); + public "GM.deleteValues"(ctx: GMApi, keys: string[]): Promise { + if (!ctx.scriptRes) return new Promise(() => {}); return new Promise((resolve) => { if (!Array.isArray(keys)) { throw new Error("GM.deleteValues: keys must be string[]"); @@ -520,77 +520,83 @@ export default class GMApi extends GM_Base { for (const key of keys) { req[key] = undefined; } - _GM_setValues(this, resolve, req); + _GM_setValues(ctx, resolve, 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 { + 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 { + 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"] }) 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; - if (this.scriptRes?.executionEnvTag === ScriptEnvTag.content) { + if (ctx.scriptRes?.executionEnvTag === ScriptEnvTag.content) { return new Promise((resolve) => { const xhr = new XMLHttpRequest(); xhr.responseType = "document"; @@ -601,9 +607,9 @@ export default class GMApi extends GM_Base { }); } - const message = this.message as CustomEventMessage | null; + const message = ctx.message as CustomEventMessage | null; const isContentEnv = !!message && message.envTag === ScriptEnvTag.content; - return urlToDocumentInContentPage(this, url, isContentEnv); + return urlToDocumentInContentPage(ctx, url, isContentEnv); } static _GM_cookie( @@ -640,36 +646,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); }); }); @@ -677,35 +683,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); } // 已注册的「菜单唯一键」集合,用于去重与解除绑定。 @@ -727,13 +737,14 @@ 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; @@ -752,7 +763,7 @@ export default class GMApi extends GM_Base { 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; } @@ -769,52 +780,56 @@ export default class GMApi extends GM_Base { } let providedId: string | number | undefined = typeof options_or_accessKey === "object" ? options_or_accessKey.id : undefined; - if (providedId === undefined) providedId = this.menuIdCounter! += 1; // 如无指定,使用累计器id + 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"] }) 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); + 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: [ @@ -829,24 +844,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 { + 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 无异 @@ -856,7 +872,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; @@ -885,7 +901,7 @@ export default class GMApi extends GM_Base { // 使用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], @@ -895,7 +911,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; @@ -907,34 +923,35 @@ export default class GMApi extends GM_Base { @GMContext.API({ depend: ["GM_addElement"] }) 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 { + public "GM.unregisterMenuCommand"(ctx: GMApi, menuId: TScriptMenuItemID): Promise { return new Promise((resolve) => { - this.GM_unregisterMenuCommand(menuId); + GMApi.prototype.GM_unregisterMenuCommand(ctx, menuId); resolve(); }); } @@ -942,21 +959,21 @@ export default class GMApi extends GM_Base { @GMContext.API({ depend: ["GM_unregisterMenuCommand"], }) - public CAT_unregisterMenuInput(...args: Parameters): void { - this.GM_unregisterMenuCommand(...args); + 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) { + 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 = { @@ -966,44 +983,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; @@ -1277,16 +1292,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; @@ -1414,39 +1429,41 @@ 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; + 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 @@ -1470,19 +1487,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; @@ -1490,7 +1507,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", { @@ -1509,74 +1526,78 @@ export default class GMApi extends GM_Base { } @GMContext.API({ depend: ["GM_openInTab", "GM_closeInTab"] }) - public "GM.openInTab"(url: string, param?: GMTypes.OpenTabOptions | boolean): Promise { + 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 { + 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 { + 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 }> { + 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 @@ -1589,7 +1610,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(); @@ -1603,18 +1625,22 @@ 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(() => {}); + 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; } @@ -1622,17 +1648,17 @@ export default class GMApi extends GM_Base { } @GMContext.API({ depend: ["GM_getResourceText"] }) - public "GM.getResourceText"(name: string): Promise { + 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) { @@ -1648,39 +1674,39 @@ export default class GMApi extends GM_Base { } @GMContext.API({ depend: ["GM_getResourceURL"] }) - public "GM.getResourceURL"(name: string, isBlobUrl?: boolean): Promise { + 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 { + 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/related_target_lifecycle.test.ts b/src/app/service/content/gm_api/related_target_lifecycle.test.ts index 722111552..e2af52e95 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 @@ -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); diff --git a/src/app/service/content/types.ts b/src/app/service/content/types.ts index 0f50887a5..61d0f060f 100644 --- a/src/app/service/content/types.ts +++ b/src/app/service/content/types.ts @@ -38,8 +38,6 @@ export interface ApiParam { follow?: string; depend?: string[]; alias?: string; - /** API receives its GM context as the first argument instead of via `this`. */ - bind?: boolean; } export interface ApiValue { diff --git a/tests/runtime/gm_api.test.ts b/tests/runtime/gm_api.test.ts index e1840b447..ef407a2c9 100644 --- a/tests/runtime/gm_api.test.ts +++ b/tests/runtime/gm_api.test.ts @@ -146,7 +146,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { }); const onload = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: testUrl, onload: (res) => { resolve(true); @@ -169,7 +169,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { }); const onload = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { fetch: true, url: testUrl, onload: (res) => { @@ -209,7 +209,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { }); const onload = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: testUrl, responseType: "blob", onload: (res) => { @@ -250,7 +250,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { const fn1 = vitest.fn(); const fn2 = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { fetch: true, responseType: "blob", url: "https://mock-xmlhttprequest.test/", @@ -288,7 +288,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { const fn1 = vitest.fn(); const fn2 = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: testUrl, responseType: "json", onload: (res) => { @@ -319,7 +319,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { const fn1 = vitest.fn(); const fn2 = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { fetch: true, url: testUrl, responseType: "json", @@ -346,7 +346,7 @@ describe.concurrent("GM xmlHttpRequest", () => { }); it.concurrent("get", () => { return new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://www.example.com", onreadystatechange: (resp) => { if (resp.readyState === 4 && resp.status === 200) { @@ -361,7 +361,7 @@ describe.concurrent("GM xmlHttpRequest", () => { // xml原版是没有responseText的,但是tampermonkey有,恶心的兼容性 it.concurrent("json", async () => { await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://example.com/json", method: "GET", responseType: "json", @@ -375,7 +375,7 @@ describe.concurrent("GM xmlHttpRequest", () => { }); // bad json await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://www.example.com/", method: "GET", responseType: "json", @@ -389,7 +389,7 @@ describe.concurrent("GM xmlHttpRequest", () => { }); it.concurrent("header", async () => { await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://www.example.com/header", method: "GET", headers: { @@ -409,7 +409,7 @@ describe.concurrent("GM xmlHttpRequest", () => { }); it.concurrent("404", async () => { await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://www.example.com/notexist", method: "GET", onload: (resp) => { @@ -441,7 +441,7 @@ describe("GM download", () => { const onprogress = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_download({ + gmApi.GM_download(gmApi, { url: "https://download.test/", name: "example.txt", onprogress: onprogress, From f13f7e2f732f62a6153db962477d7930092db83a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:00:03 +0900 Subject: [PATCH 016/103] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20reduce=20runtime?= =?UTF-8?q?=20function=20overhead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/server.ts | 15 ++--- src/app/service/content/create_context.ts | 23 ++++++- src/app/service/content/gm_api/cat_agent.ts | 32 ++++------ .../service/content/gm_api/cat_agent_task.ts | 23 +++---- .../service/content/script_executor.test.ts | 11 ++-- src/app/service/content/script_executor.ts | 60 +++++-------------- src/app/service/content/scripting.ts | 2 +- src/app/service/service_worker/runtime.ts | 25 ++++---- 8 files changed, 87 insertions(+), 104 deletions(-) diff --git a/packages/message/server.ts b/packages/message/server.ts index 45612d1c1..225fbbc56 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -370,12 +370,13 @@ export function forwardMessage( } return handler(params, sender); }; - const process = (params: any, sender: IGetSender) => { - if (!transform) return processTransformed(params, sender); - const transformed = transform(params, sender); - return transformed instanceof Promise - ? transformed.then((data) => processTransformed(data, sender)) - : processTransformed(transformed, 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/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 22c3960e9..380320bd5 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -12,7 +12,28 @@ import { nativeCall, Native } from "./global"; const createCapability = (api: (...args: any[]) => any, receiver: object) => { // 由闭包提供上下文,脚本侧只传 API 自身的参数。 - const capability = (...args: any[]) => api(receiver, ...args); + /* 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: { + const args = new Array(arguments.length + 1); + args[0] = receiver; + for (let i = 0; i < arguments.length; i += 1) args[i + 1] = arguments[i]; + return Native.reflectApply(api, undefined, args); + } + } + }; + /* eslint-enable prefer-rest-params */ Native.objectDefineProperty(capability, "name", { configurable: true, value: api.name, diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index e32f911c8..b18cfc711 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -93,15 +93,9 @@ type ConversationPrivateState = { background: boolean; }; -const conversationStates = Native.createWeakMap(); - -const getConversationState = (instance: ConversationInstance): ConversationPrivateState => { - const state = conversationStates.get(instance); - if (!state) throw new Error("conversation instance is invalid"); - return state; -}; - export class ConversationInstance { + #state: ConversationPrivateState; + public toolHandlers: Map = new Map(); public toolDefs: ToolDefinition[] = []; public ephemeral: boolean; @@ -134,7 +128,7 @@ export class ConversationInstance { systemPrompt: system, background: background || false, }; - conversationStates.set(this, state); + this.#state = state; this.ephemeral = ephemeral || false; if (initialTools) { for (const tool of initialTools) { @@ -158,15 +152,15 @@ export class ConversationInstance { } get id() { - return getConversationState(this).conv.id; + return this.#state.conv.id; } get title() { - return getConversationState(this).conv.title; + return this.#state.conv.title; } get modelId() { - return getConversationState(this).conv.modelId; + return this.#state.conv.modelId; } // 发送消息并获取回复(内置 tool calling 循环) @@ -177,7 +171,7 @@ export class ConversationInstance { if (cmdResult !== undefined) return cmdResult; const { toolDefs, handlers } = this.mergeTools(options?.tools); - const state = getConversationState(this); + const state = this.#state; // ephemeral 模式:追加 user message 到内存历史 if (this.ephemeral) { @@ -245,7 +239,7 @@ export class ConversationInstance { } const { toolDefs, handlers } = this.mergeTools(options?.tools); - const state = getConversationState(this); + const state = this.#state; // ephemeral 模式:追加 user message 到内存历史 if (this.ephemeral) { @@ -299,7 +293,7 @@ export class ConversationInstance { const parsed = this.parseCommand(content); if (!parsed) return undefined; - const handler = getConversationState(this).commandHandlers.get(parsed.name); + const handler = this.#state.commandHandlers.get(parsed.name); if (!handler) return undefined; const result = await handler(parsed.args, this); @@ -327,7 +321,7 @@ export class ConversationInstance { // 获取对话历史 async getMessages(): Promise { - const state = getConversationState(this); + const state = this.#state; if (this.ephemeral) { // ephemeral 模式:从内存历史转换为 ChatMessage 格式 return this.messageHistory.map((msg, idx) => ({ @@ -357,7 +351,7 @@ export class ConversationInstance { this.messageHistory = []; return; } - const state = getConversationState(this); + const state = this.#state; await state.gmSendMessage("CAT_agentConversation", [ { action: "clearMessages", @@ -370,7 +364,7 @@ export class ConversationInstance { // 持久化对话 async save(): Promise { - const state = getConversationState(this); + const state = this.#state; await state.gmSendMessage("CAT_agentConversation", [ { action: "save", @@ -383,7 +377,7 @@ export class ConversationInstance { // 附加到后台运行中的会话,返回流式事件(首个 chunk 为 sync 快照) async attach(): Promise> { - const state = getConversationState(this); + const state = this.#state; const conn = await state.gmConnect("CAT_agentAttachToConversation", [ { conversationId: state.conv.id, generation: state.conv.generation, scriptUuid: state.scriptUuid }, ]); 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 3ef602884..f13432461 100644 --- a/src/app/service/content/gm_api/cat_agent_task.ts +++ b/src/app/service/content/gm_api/cat_agent_task.ts @@ -19,12 +19,12 @@ interface GMBaseContext { // 内部 listener 计数器 let listenerCounter = 0; type ListenerRecord = { id: number; eventName: string; callback: (...args: any[]) => void }; -const listenerMaps = Native.createWeakMap(); +const listenerMaps = Native.createWeakMap>(); -const getListenerRecords = (owner: object): ListenerRecord[] => { +const getListenerRecords = (owner: object): Map => { let records = listenerMaps.get(owner); if (!records) { - records = []; + records = Native.createMap(); listenerMaps.set(owner, records); } return records; @@ -124,8 +124,7 @@ export default class CATAgentTaskApi { }; ctx.EE.on(eventName, wrappedCallback); - const records = getListenerRecords(ctx); - records[records.length] = { id: listenerId, eventName, callback: wrappedCallback }; + getListenerRecords(ctx).set(listenerId, { id: listenerId, eventName, callback: wrappedCallback }); return listenerId; } @@ -135,17 +134,9 @@ export default class CATAgentTaskApi { if (!ctx.EE) return; const records = getListenerRecords(ctx); - let index = -1; - for (let i = 0; i < records.length; i += 1) { - if (records[i]?.id === listenerId) { - index = i; - break; - } - } - if (index >= 0) { - const entry = records[index]; - for (let i = index + 1; i < records.length; i += 1) records[i - 1] = records[i]; - records.length -= 1; + const entry = records.get(listenerId); + if (entry) { + records.delete(listenerId); ctx.EE.off(entry.eventName, entry.callback); } } diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index 8c1816cff..969243ab1 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -97,14 +97,15 @@ describe("ScriptExecutor", () => { const exec = ( executor as unknown as { - execScripts: Array<{ - exec: { + execScripts: Map< + string, + { scriptRes: TScriptInfo; updateEarlyScriptGMInfo: (envInfo: GMInfoEnv, scriptInfo?: TScriptInfo) => void; - }; - }>; + } + >; } - ).execScripts[0].exec; + ).execScripts.get(initial.uuid)!; expect(exec.scriptRes.executionHandle).toBeUndefined(); exec.updateEarlyScriptGMInfo(initEnvInfo, { diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 3e8d62a2c..1cea9add0 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -32,8 +32,8 @@ export const initEnvInfo: GMInfoEnv = { // 脚本执行器 export class ScriptExecutor { - private readonly earlyScriptFlags: string[] = []; - private readonly execScripts: Array<{ uuid: string; exec: ExecScript }> = []; + private readonly earlyScriptFlags = Native.createSet(); + private readonly execScripts = Native.createMap(); constructor( private msg: Message, @@ -43,24 +43,17 @@ export class ScriptExecutor { emitEvent(data: EmitEventRequest) { // 转发给脚本 - for (let i = 0; i < this.execScripts.length; i += 1) { - const entry = this.execScripts[i]; - if (entry?.uuid === data.uuid) { - entry.exec.emitEvent(data.event, data.eventId, data.data); - return; - } - } + this.execScripts.get(data.uuid)?.emitEvent(data.event, data.eventId, data.data); } valueUpdate(data: ValueUpdateDataEncoded) { // runtime/valueUpdate const { uuid, storageName } = data; - for (let i = 0; i < this.execScripts.length; i += 1) { - const exec = this.execScripts[i]?.exec; - if (exec && (exec.scriptRes.uuid === uuid || getStorageName(exec.scriptRes) === storageName)) { + this.execScripts.forEach((exec) => { + if (exec.scriptRes.uuid === uuid || getStorageName(exec.scriptRes) === storageName) { exec.valueUpdate(data); } - } + }); } startScripts(scripts: TScriptInfo[], envInfo: GMInfoEnv) { @@ -78,22 +71,16 @@ export class ScriptExecutor { const script = scripts[scriptIndex]; const flag = script.flag; // 如果是EarlyScriptFlag,处理沙盒环境 - let isEarlyScript = false; - for (let i = 0; i < this.earlyScriptFlags.length; i += 1) { - if (this.earlyScriptFlags[i] === flag) { - isEarlyScript = true; - break; - } - } - if (isEarlyScript) { - for (let i = 0; i < this.execScripts.length; i += 1) { - const exec = this.execScripts[i]?.exec; - if (exec?.scriptRes.flag === flag) { + if (this.earlyScriptFlags.has(flag)) { + let updated = false; + this.execScripts.forEach((exec) => { + if (!updated && exec.scriptRes.flag === flag) { // 处理早期脚本的沙盒环境 exec.updateEarlyScriptGMInfo(envInfo, script); - return; + updated = true; } - } + }); + if (updated) return; } const listenForScript = () => { definePropertyListener(window, flag, (val: ScriptFunc) => { @@ -153,14 +140,7 @@ export class ScriptExecutor { console.warn("Unexpected match error", e); } } - let alreadyExecuted = false; - for (let i = 0; i < this.earlyScriptFlags.length; i += 1) { - if (this.earlyScriptFlags[i] === scriptFlag) { - alreadyExecuted = true; - break; - } - } - if (!alreadyExecuted) this.execEarlyScript(scriptFlag, scriptInfo, envInfo); + if (!this.earlyScriptFlags.has(scriptFlag)) this.execEarlyScript(scriptFlag, scriptInfo, envInfo); } }; pageAddEventListener(scriptLoadCompleteEvtName, scriptLoadCompleteHandler); @@ -190,7 +170,7 @@ export class ScriptExecutor { scriptFlag: flag, envInfo: envInfo, }); - this.earlyScriptFlags[this.earlyScriptFlags.length] = flag; + this.earlyScriptFlags.add(flag); } execScriptEntry(scriptEntry: ExecScriptEntry) { @@ -205,15 +185,7 @@ export class ScriptExecutor { code: scriptFunc, envInfo, }); - let replaced = false; - for (let i = 0; i < this.execScripts.length; i += 1) { - if (this.execScripts[i]?.uuid === scriptLoadInfo.uuid) { - this.execScripts[i] = { uuid: scriptLoadInfo.uuid, exec: execScript }; - replaced = true; - break; - } - } - if (!replaced) this.execScripts[this.execScripts.length] = { uuid: scriptLoadInfo.uuid, exec: execScript }; + this.execScripts.set(scriptLoadInfo.uuid, execScript); const metadata = scriptLoadInfo.metadata || {}; const resource = scriptLoadInfo.requireCssResource ?? scriptLoadInfo.resource; // 注入css diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index b465ac641..419d899cc 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -148,7 +148,7 @@ export default class ScriptingRuntime { return { uuid: request.uuid, api: request.api, - params: [...request.params], + params: request.params, runFlag: request.runFlag, executionHandle: request.handle, version: 1 as const, diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 3e1bc47ac..3e773c6b8 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -300,6 +300,10 @@ export class RuntimeService { } private sendUserScriptMessage(to: ExtMessageSender | undefined, action: string, data: unknown): void { + const dataRecord = + typeof data === "object" && data !== null ? (data as { uuid?: unknown; storageName?: unknown }) : undefined; + const targetUuid = action === "runtime/emitEvent" ? dataRecord?.uuid : undefined; + const targetStorageName = action === "runtime/valueUpdate" ? dataRecord?.storageName : undefined; for (const [key, entry] of this.userScriptConnections) { if ( to && @@ -309,18 +313,17 @@ export class RuntimeService { ) { continue; } - const bindingMatches = [...this.pageExecutionBindings.values()].some( - (binding) => + let bindingMatches = false; + for (const binding of this.pageExecutionBindings.values()) { + if ( entry.handles.has(binding.handle) && - ((action === "runtime/emitEvent" && - typeof data === "object" && - data !== null && - (data as { uuid?: unknown }).uuid === binding.uuid) || - (action === "runtime/valueUpdate" && - typeof data === "object" && - data !== null && - (data as { storageName?: unknown }).storageName === binding.storageName)) - ); + ((targetUuid !== undefined && targetUuid === binding.uuid) || + (targetStorageName !== undefined && targetStorageName === binding.storageName)) + ) { + bindingMatches = true; + break; + } + } if (!bindingMatches) continue; try { entry.connection.sendMessage({ action: `content/${action}`, data }); From edc3ab3c5e9fbf440fb19e5571ab6f0a616f80d0 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:10:58 +0900 Subject: [PATCH 017/103] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20remove=20redundant?= =?UTF-8?q?=20native=20collection=20factories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/create_context.test.ts | 6 +- src/app/service/content/create_context.ts | 8 +-- src/app/service/content/exec_script.ts | 2 +- src/app/service/content/global.ts | 60 ++++--------------- .../service/content/gm_api/cat_agent_task.ts | 4 +- src/app/service/content/gm_api/gm_api.ts | 6 +- src/app/service/content/gm_api/gm_xhr.ts | 4 +- src/app/service/content/script_executor.ts | 4 +- 8 files changed, 29 insertions(+), 65 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 717d5d62a..13495e87a 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -307,9 +307,9 @@ describe("shouldFnBind", () => { describe("createContext: capability and lifecycle contract", () => { it("creates collection instances from frozen captured-method subclasses", () => { - const set = Native.createSet(["grant"]); - const map = Native.createMap(); - const weakMap = Native.createWeakMap(); + 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); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 380320bd5..cd280b3c7 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -53,7 +53,7 @@ export const createContext = ( contentMsg: Message, scriptGrants: Set ) => { - const scriptGrantSet = Native.createSet(scriptGrants); + const scriptGrantSet = new Native.Set(scriptGrants); // 按照GMApi构建 const valueChangeListener = new ListenerManager(); const EE = new EventEmitter(); @@ -80,7 +80,7 @@ export const createContext = ( GM: GM, GM_info: GMInfo, window: Native.objectCreate(null), - grantSet: Native.createSet(), + grantSet: new Native.Set(), loadScriptPromise, loadScriptResolve, setInvalidContext() { @@ -246,7 +246,7 @@ export type RealmRoots = { const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSnapshot => { // 在 CacheSet 加入的 propKeys 将会在 mySandbox 实装阶段时设置。 // 先处理的 descriptor 覆盖后续父类。 - const descsCache: Set = Native.createSet(["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 = Native.objectGetOwnPropertyDescriptors(realmGlobal); @@ -256,7 +256,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn const overriddenDescs: DescriptorMap = Native.objectCreate(null); // 记录原生 onxxxxx 的 property key。 - const eventKeys = Native.createSet(); + const eventKeys = new Native.Set(); // 在 USE_PSEUDO_WINDOW 情况下,由于没有类的 prototype,父类的成员要手动传下去。 const protoBaseDescs: DescriptorMap = Native.objectCreate(null); diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index bd805ec1d..3213398a7 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -51,7 +51,7 @@ export default class ExecScript { } else { this.scriptFunc = code; } - const grantSet = Native.createSet(scriptRes.metadata.grant || []); + const grantSet = new Native.Set(scriptRes.metadata.grant || []); if (isContextMenuScript(scriptRes.metadata)) { grantSet.add("GM_registerMenuCommand"); grantSet.delete("none"); diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 9b43fcc10..6c94d9e7c 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -29,7 +29,18 @@ const nativeObjectFreeze = Object.freeze; // 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 {}; +const NativeSetConstructor = class extends nativeSetConstructor { + constructor(values?: readonly T[] | Set | null) { + super(); + if (Array.isArray(values)) { + for (let i = 0; i < values.length; i += 1) { + nativeReflectApply(nativeSetAdd, this, [values[i]]); + } + } else if (values) { + nativeReflectApply(nativeSetForEach, values, [(value: T) => nativeReflectApply(nativeSetAdd, this, [value])]); + } + } +}; NativeSetConstructor.prototype.add = nativeSetAdd; NativeSetConstructor.prototype.has = nativeSetHas; NativeSetConstructor.prototype.delete = nativeSetDelete; @@ -65,54 +76,10 @@ export const nativeCall = nativeFunctionCall; export const nativeBind = (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => nativeFunctionCall(nativeFunctionBind, fn, receiver, ...args); -type SafeSet = Set & { - add: Set["add"]; - has: Set["has"]; - delete: Set["delete"]; - clear: Set["clear"]; - forEach: Set["forEach"]; -}; - -type SafeMap = Map & { - get: Map["get"]; - set: Map["set"]; - has: Map["has"]; - delete: Map["delete"]; - clear: Map["clear"]; - forEach: Map["forEach"]; -}; - -type SafeWeakMap = WeakMap & { - get: WeakMap["get"]; - set: WeakMap["set"]; - has: WeakMap["has"]; - delete: WeakMap["delete"]; -}; - -const createNativeSet = (values?: readonly T[] | Set | null): SafeSet => { - const set = new NativeSetConstructor() as SafeSet; - if (Array.isArray(values)) { - for (let i = 0; i < values.length; i += 1) set.add(values[i]); - } else if (values) { - nativeReflectApply(nativeSetForEach, values, [(value: T) => set.add(value)]); - } - return set; -}; - -const createNativeMap = (): SafeMap => { - return new NativeMapConstructor() as SafeMap; -}; - -const createNativeWeakMap = (): SafeWeakMap => { - return new NativeWeakMapConstructor() as SafeWeakMap; -}; - export const Native = { Set: NativeSetConstructor, Map: NativeMapConstructor, WeakMap: NativeWeakMapConstructor, - apply: nativeApply, - call: nativeCall, bind: nativeBind, reflectApply: nativeReflectApply, structuredClone: typeof structuredClone === "function" ? structuredClone : unsupportedAPI, @@ -130,9 +97,6 @@ export const Native = { objectGetPrototypeOf: nativeBind(Object.getPrototypeOf, Object), reflectOwnKeys: nativeBind(Reflect.ownKeys, Reflect), reflectGet: nativeBind(Reflect.get, Reflect), - createSet: createNativeSet, - createMap: createNativeMap, - createWeakMap: createNativeWeakMap, } as const; export const customClone = (o: any) => { 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 f13432461..6002c4a9e 100644 --- a/src/app/service/content/gm_api/cat_agent_task.ts +++ b/src/app/service/content/gm_api/cat_agent_task.ts @@ -19,12 +19,12 @@ interface GMBaseContext { // 内部 listener 计数器 let listenerCounter = 0; type ListenerRecord = { id: number; eventName: string; callback: (...args: any[]) => void }; -const listenerMaps = Native.createWeakMap>(); +const listenerMaps = new Native.WeakMap>(); const getListenerRecords = (owner: object): Map => { let records = listenerMaps.get(owner); if (!records) { - records = Native.createMap(); + records = new Native.Map(); listenerMaps.set(owner, records); } return records; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index a1d47cc09..804609c30 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -62,12 +62,12 @@ let valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; const valueChangePromiseMap: Record void> = Object.create(null); -const notificationTagMaps = Native.createWeakMap>(); +const notificationTagMaps = new Native.WeakMap>(); const getNotificationTagMap = (owner: object): Map => { let map = notificationTagMaps.get(owner); if (!map) { - map = Native.createMap(); + map = new Native.Map(); notificationTagMaps.set(owner, map); } return map; @@ -76,7 +76,7 @@ const getNotificationTagMap = (owner: object): Map => { const execEnvInit = (execEnv: GMApi) => { if (!execEnv.contentEnvKey) { execEnv.contentEnvKey = randomMessageFlag(); // 不重复识别字串。用于区分 mainframe subframe 等执行环境 - execEnv.menuKeyRegistered = Native.createSet(); + execEnv.menuKeyRegistered = new Native.Set(); execEnv.menuIdCounter = 0; execEnv.regMenuCounter = 0; } diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 28d00a620..17ec61fae 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -127,7 +127,7 @@ const getMimeType = (contentType: string) => { return mime; }; -const docParseTypes = Native.createSet([ +const docParseTypes = new Native.Set([ "application/xhtml+xml", "application/xml", "image/svg+xml", @@ -135,7 +135,7 @@ const docParseTypes = Native.createSet([ "text/xml", ]); -const retStateFnMap = Native.createWeakMap(); +const retStateFnMap = new Native.WeakMap(); const invokeXHRCallback = (name: string, callback: ((value: any) => void) | undefined, value: any) => { if (!callback) return; diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 1cea9add0..0e3892acc 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -32,8 +32,8 @@ export const initEnvInfo: GMInfoEnv = { // 脚本执行器 export class ScriptExecutor { - private readonly earlyScriptFlags = Native.createSet(); - private readonly execScripts = Native.createMap(); + private readonly earlyScriptFlags = new Native.Set(); + private readonly execScripts = new Native.Map(); constructor( private msg: Message, From b5007dfad4c659387ae675df5782b7cb0f6e536b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:31:20 +0900 Subject: [PATCH 018/103] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20simplify=20native?= =?UTF-8?q?=20set=20initialization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 6c94d9e7c..1e4b56b67 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -33,11 +33,9 @@ const NativeSetConstructor = class extends nativeSetConstructor { constructor(values?: readonly T[] | Set | null) { super(); if (Array.isArray(values)) { - for (let i = 0; i < values.length; i += 1) { - nativeReflectApply(nativeSetAdd, this, [values[i]]); - } + for (let i = 0; i < values.length; i += 1) this.add(values[i]); } else if (values) { - nativeReflectApply(nativeSetForEach, values, [(value: T) => nativeReflectApply(nativeSetAdd, this, [value])]); + nativeReflectApply(nativeSetForEach, values, [(value: T) => this.add(value)]); } } }; From 4d194e11c5b4e0ffc7e3077ab41c8cfdfe28184a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:43:45 +0900 Subject: [PATCH 019/103] =?UTF-8?q?=F0=9F=93=84=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E8=B7=A8=E4=B8=96=E7=95=8C=E5=AE=89=E5=85=A8=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E7=BB=B4=E6=8A=A4=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 说明原生方法捕获、USER_SCRIPT 来源校验、页面 RPC 句柄生命周期、脚本包装完整性和回调收尾约束,降低后续维护时误改安全契约的风险。 --- packages/message/extension_message.ts | 4 ++++ packages/message/server.ts | 3 +++ rspack.config.ts | 1 + src/app/repo/scripts.ts | 2 +- src/app/service/content/create_context.ts | 3 +++ src/app/service/content/exec_script.ts | 2 ++ src/app/service/content/global.ts | 1 + src/app/service/content/gm_api/cat_agent.ts | 1 + src/app/service/content/gm_api/cat_agent_task.ts | 2 ++ src/app/service/content/gm_api/gm_api.ts | 12 +++++++++--- src/app/service/content/gm_api/gm_xhr.ts | 7 +++---- src/app/service/content/listener_manager.ts | 1 + src/app/service/content/page_rpc.ts | 9 +++++++-- src/app/service/content/script_executor.ts | 3 +++ src/app/service/content/script_runtime.ts | 1 + src/app/service/content/scripting.ts | 9 +++++++-- src/app/service/content/utils.ts | 8 +++++++- src/app/service/service_worker/client.ts | 1 + src/app/service/service_worker/runtime.ts | 13 +++++++++---- src/app/service/service_worker/types.ts | 2 +- src/content.ts | 4 ++-- 21 files changed, 69 insertions(+), 20 deletions(-) diff --git a/packages/message/extension_message.ts b/packages/message/extension_message.ts index 1e519470a..2fcd610a6 100644 --- a/packages/message/extension_message.ts +++ b/packages/message/extension_message.ts @@ -11,6 +11,8 @@ import type { 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; @@ -192,10 +194,12 @@ export class ExtensionMessageConnect implements MessageConnect { 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); diff --git a/packages/message/server.ts b/packages/message/server.ts index 225fbbc56..71f2f8415 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -14,6 +14,7 @@ import Logger from "@App/app/logger/logger"; const nativeReflectApply = Reflect.apply; const nativeFunctionBind = Function.prototype.bind; +// 转发监听器会跨 context 保存一段时间,绑定时固定原生 bind,避免页面改写原型。 const bindNative = any>(fn: T, receiver: any): T => nativeReflectApply(nativeFunctionBind, fn, [receiver]) as T; @@ -268,6 +269,7 @@ export class Server { } 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" @@ -372,6 +374,7 @@ export function forwardMessage( }; const process = transform ? (params: any, sender: IGetSender) => { + // 转换先于中间件和转发执行,使跨世界输入只在一个受控位置完成校验/复制。 const transformed = transform(params, sender); return transformed instanceof Promise ? transformed.then((data) => processTransformed(data, sender)) diff --git a/rspack.config.ts b/rspack.config.ts index 35d02137b..3848a05aa 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -138,6 +138,7 @@ export default { new rspack.DefinePlugin({ "process.env.VI_TESTING": "'false'", "process.env.SC_RANDOM_KEY": `'${uuidv4()}'`, + // 每次构建都生成独立标记,脚本包装器只接受扩展内部传入的完整性密钥。 "process.env.SC_RANDOM_FNKEY": `'${uuidv4()}'`, "process.env.SC_ZN_RAND": `'$${uuidv4()}'`, "process.env.SC_DISABLE_AGENT": `'${enableAgent ? "false" : "true"}'`, diff --git a/src/app/repo/scripts.ts b/src/app/repo/scripts.ts index 285de420a..27cdfbf24 100644 --- a/src/app/repo/scripts.ts +++ b/src/app/repo/scripts.ts @@ -158,7 +158,7 @@ export type TClientPageLoadInfo = injectScriptList: TScriptInfo[]; contentScriptList: TScriptInfo[]; envInfo: GMInfoEnv; - /** One-use token that lets the USER_SCRIPT world request its private bootstrap. */ + /** 一次性令牌,供 USER_SCRIPT world 请求私有 bootstrap。 */ userScriptBootstrapToken?: string; } | { ok: false }; diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index cd280b3c7..990d3da3d 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -53,6 +53,7 @@ export const createContext = ( contentMsg: Message, scriptGrants: Set ) => { + // 复制授权集合并使用捕获的 Set 实现,避免页面改写迭代器后影响 API 注入。 const scriptGrantSet = new Native.Set(scriptGrants); // 按照GMApi构建 const valueChangeListener = new ListenerManager(); @@ -116,6 +117,7 @@ export const createContext = ( } return true; }; + // 只能调用捕获的 forEach;此处不依赖页面提供的 Set iterator。 scriptGrantSet.forEach((grant) => { const candidates = getGrantCandidates(String(grant)); for (let i = 0; i < candidates.length; i += 1) { @@ -450,6 +452,7 @@ export const createProxyContext = ( }; }; + // 事件键只需传入沙盒属性;先用捕获的 forEach 转成数组,避免跨 realm 读取 iterator。 const eventKeyList: string[] = []; eventKeys.forEach((key) => { eventKeyList[eventKeyList.length] = String(key); diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index 3213398a7..dedcce66d 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -10,6 +10,7 @@ import type { IGM_Base } from "./gm_api/gm_api"; import type { TScriptInfo } from "@App/app/repo/scripts"; import { Native } from "./global"; +// 编译函数只在收到本次构建的密钥时执行,避免页面直接复用包装器。 const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; // 执行脚本,控制脚本执行与停止 @@ -97,6 +98,7 @@ export default class ExecScript { // 早期启动的脚本,处理GM API updateEarlyScriptGMInfo(envInfo: GMInfoEnv, scriptInfo?: TScriptInfo) { if (scriptInfo?.executionHandle && scriptInfo.executionEnvTag) { + // early-start 先执行后取得绑定;此处补写同一绑定,使后续 RPC 与首次注册一致。 this.scriptRes.executionHandle = scriptInfo.executionHandle; this.scriptRes.executionEnvTag = scriptInfo.executionEnvTag; this.scriptRes.executionRunFlag = scriptInfo.executionRunFlag; diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 1e4b56b67..8e7702946 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -31,6 +31,7 @@ const nativeObjectFreeze = Object.freeze; // the page's mutable built-in prototypes. const NativeSetConstructor = class extends nativeSetConstructor { constructor(values?: readonly T[] | Set | null) { + // 不把 values 传给 Set 构造器:它会读取 values 的 @@iterator,而页面可改写该方法。 super(); if (Array.isArray(values)) { for (let i = 0; i < values.length; i += 1) this.add(values[i]); diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index b18cfc711..06126d5cd 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -94,6 +94,7 @@ type ConversationPrivateState = { }; export class ConversationInstance { + // 私有状态包含跨 context 的发送函数;用 private field 隐藏它,避免脚本读取或替换传输入口。 #state: ConversationPrivateState; public toolHandlers: Map = new Map(); 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 6002c4a9e..4a0d9cd73 100644 --- a/src/app/service/content/gm_api/cat_agent_task.ts +++ b/src/app/service/content/gm_api/cat_agent_task.ts @@ -20,6 +20,7 @@ interface GMBaseContext { let listenerCounter = 0; 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); @@ -136,6 +137,7 @@ export default class CATAgentTaskApi { const records = getListenerRecords(ctx); const entry = records.get(listenerId); if (entry) { + // 记录事件名和包装回调后可直接移除,不必扫描所有任务监听器。 records.delete(listenerId); ctx.EE.off(entry.eventName, entry.callback); } diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 804609c30..95e312659 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -60,8 +60,10 @@ let valChangeCounterId = 0; let valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; +// 回调表不暴露 Map 原型,避免页面改写 Map 方法后影响值更新确认。 const valueChangePromiseMap: Record void> = Object.create(null); +// 通知 ID 只属于对应 GM context;WeakMap 不让脚本结束后残留监听状态。 const notificationTagMaps = new Native.WeakMap>(); const getNotificationTagMap = (owner: object): Map => { @@ -148,9 +150,8 @@ class GM_Base implements IGM_Base { if (this.loadScriptPromise) { await this.loadScriptPromise; } - // USER_SCRIPT has DOM and fetch access in its own realm. Keep these helper - // operations local instead of sending an internal CAT operation to the SW, - // where only the isolated scripting broker has an implementation. + // 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"); @@ -163,6 +164,7 @@ class GM_Base implements IGM_Base { } let ret; try { + // 有页面句柄时走版本化 RPC;后台脚本和未迁移上下文继续使用旧请求形状。 const request = this.scriptRes.executionHandle ? { version: 1 as const, @@ -198,6 +200,7 @@ class GM_Base implements IGM_Base { await this.loadScriptPromise; } if (!this.message || !this.scriptRes) return new Promise(() => {}); + // 长连接也必须携带同一页面句柄,否则 broker 无法把连接绑定回脚本和文档。 const request = this.scriptRes.executionHandle ? { version: 1 as const, @@ -244,6 +247,7 @@ class GM_Base implements IGM_Base { } else { valueStore[key] = value; } + // 监听器属于脚本,传副本避免回调修改 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); @@ -255,6 +259,7 @@ class GM_Base implements IGM_Base { @GMContext.protected() emitEvent(event: string, eventId: string, data: any) { if (!this.EE) return; + // 事件回调同样不能拿到 broker 内部对象的可变引用。 const callbackData = data && typeof data === "object" ? customClone(data) : data; this.EE.emit(`${event}:${eventId}`, callbackData); } @@ -597,6 +602,7 @@ export default class GMApi extends GM_Base { if (ctx.isInvalidContext()) return undefined; if (ctx.scriptRes?.executionEnvTag === ScriptEnvTag.content) { + // USER_SCRIPT 可直接在 content realm 创建 Document;跨到 scripting 只会丢失节点引用。 return new Promise((resolve) => { const xhr = new XMLHttpRequest(); xhr.responseType = "document"; diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 17ec61fae..96eb9f23f 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -142,8 +142,7 @@ const invokeXHRCallback = (name: string, callback: ((value: any) => void) | unde 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) }); } }; @@ -537,6 +536,7 @@ export function GM_xmlhttpRequest( } }; const scheduleSyntheticLoadEnd = () => { + // abort/error/timeout 可能没有 broker 的 onloadend,补发一次以释放连接和引用。 Promise.resolve({ error: "loadend", responseHeaders: "", @@ -549,8 +549,7 @@ export function GM_xmlhttpRequest( if (!reqDone) { errorOccur = "AbortError"; reqDone = true; - // Mark the request settled before user code runs. A throwing abort - // callback must not leave the broker connection and loadend cleanup pending. + // 先标记完成再调用用户代码;回调抛错也不能留下未收尾的连接。 invokeXHRCallback("onabort", details.onabort, makeXHRCallbackParam?.(data) ?? {}); // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); diff --git a/src/app/service/content/listener_manager.ts b/src/app/service/content/listener_manager.ts index 39f180dda..ef9aa23ed 100644 --- a/src/app/service/content/listener_manager.ts +++ b/src/app/service/content/listener_manager.ts @@ -11,6 +11,7 @@ export class ListenerManager void> { } public execute(key: string, ...args: T extends (key: string, ...a: infer A) => any ? A : never): void { + // handler 可能在执行期间移除自身;按当前下标复查 id,避免跳过紧邻监听器。 for (let i = 0; i < this.listeners.length; ) { const listener = this.listeners[i]; if (listener?.key !== key) { diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index 74337dfda..8d9b93403 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -22,6 +22,7 @@ export const getExtensionOrigin = (): ExtensionOrigin | undefined => { return undefined; }; +// USER_SCRIPT 的 blob URL 必须回指当前扩展 origin,origin 由隔离 context 提供并缓存。 let configuredExtensionOrigin: ExtensionOrigin | undefined; export const setPageRpcExtensionOrigin = (value: unknown): void => { @@ -92,7 +93,7 @@ export type PageGMRequest = { readonly runFlag: string; }; -/** The untrusted packet accepted from a MAIN-world script. */ +/** MAIN world 脚本可提交的不可信数据包。 */ export type PageGMRequestPacket = { readonly version: typeof PAGE_RPC_VERSION; readonly requestId: string; @@ -112,7 +113,7 @@ const INTERNAL_APIS_BY_GRANT: Readonly> = { "GM.xmlHttpRequest": ["GM_xmlhttpRequest"], }; -// ScriptingRuntime does not load the GM implementation module, so mirror its small dependency graph here. +// ScriptingRuntime 不加载 GM 实现模块,因此在此镜像一份精简依赖图。 const API_DEPENDENCIES: Readonly> = { "GM.getValues": ["GM_getValues"], "GM.cookie": ["GM.cookie.set", "GM.cookie.list", "GM.cookie.delete"], @@ -179,6 +180,7 @@ const ownData = (value: object, key: PropertyKey): unknown => { }; 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); @@ -197,6 +199,7 @@ const assertDataOnly = (value: unknown, seen: Set): void => { }; const cloneParams = (params: unknown): readonly unknown[] => { + // 复制发生在交给 service worker 之前,后续 broker 只处理隔离后的普通值。 if (!Array.isArray(params)) throw new PageRpcError("page RPC params must be an array"); assertDataOnly(params, new Set()); if (!nativeStructuredClone) throw new PageRpcError("structured clone is unavailable"); @@ -281,6 +284,7 @@ export class PageRpcRegistry { } consumeRequestId(binding: PageExecutionBinding, requestId: string): void { + // requestId 只在每个绑定内去重,并保留有限窗口,避免页面长期占用内存。 if (binding.requestIds.has(requestId)) throw new PageRpcError("page RPC requestId was already used"); binding.requestIds.add(requestId); while (binding.requestIds.size > MAX_REQUEST_IDS_PER_BINDING) { @@ -324,6 +328,7 @@ export const validatePageGMRequest = (value: unknown, registry: PageRpcRegistry) } const binding = registry.resolve(handle, api); + // resolve 同时执行句柄、授权和活跃状态检查;不要把页面传来的 api 直接转发给后端。 const clonedParams = cloneParams(params); validateOperationParams(api, clonedParams); registry.consumeRequestId(binding, requestId); diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 0e3892acc..75df76f12 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -11,6 +11,7 @@ import { isUrlExcluded } from "@App/pkg/utils/match"; import type { ScriptEnvTag } from "@Packages/message/consts"; import { localizeObject, Native } from "./global"; +// 与编译器相同的构建级标记,用来拒绝页面伪造的脚本挂载函数。 const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; export type ExecScriptEntry = { @@ -84,6 +85,7 @@ export class ScriptExecutor { } const listenForScript = () => { definePropertyListener(window, flag, (val: ScriptFunc) => { + // 只有扩展生成且不可改写的完整性标记才算有效挂载,页面自建同名函数必须忽略。 const descriptor = typeof val === "function" ? Native.objectGetOwnPropertyDescriptor(val, fnStrIntegrity) : undefined; if (descriptor?.value !== true || descriptor.configurable || descriptor.writable) { @@ -152,6 +154,7 @@ export class ScriptExecutor { execEarlyScript(flag: string, scriptInfo: TScriptInfo, envInfo: GMInfoEnv) { const expectedUuid = flag.startsWith("#-") ? flag.slice(2) : undefined; + // early-start 事件来自页面,需同时确认脚本身份和未绑定状态,避免旧事件重放到新文档。 if ( (expectedUuid && scriptInfo.uuid !== expectedUuid) || scriptInfo.executionHandle !== undefined || diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 87fba6f5b..5c76cbfcf 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -25,6 +25,7 @@ export class ScriptRuntime { if (!data || !Array.isArray(data.params) || data.params.length !== 3) return undefined; const [parentNodeId, tagName, tmpAttr] = data.params; + // 此请求来自页面事件,只接受可验证的节点编号、标签名和扁平属性,避免把对象行为带入 DOM 操作。 if ( (parentNodeId !== null && (!Number.isInteger(parentNodeId) || parentNodeId <= 0)) || typeof tagName !== "string" || diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index 419d899cc..304fc7a5c 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -25,7 +25,9 @@ const deliveryStorage = chrome.storage.local; // 日后再处理 // scripting页的处理 export default class ScriptingRuntime { + // 只记录当前页面仍有脚本使用的 storageName,storage 广播不应唤醒无关脚本。 private activeStorageNames = new Map(); + // 页面请求必须先在此注册句柄,再由 transform 解析为隔离 broker 可接受的身份。 private readonly pageRpc = new PageRpcRegistry(); constructor( // 监听来自service_worker的消息 @@ -54,11 +56,11 @@ export default class ScriptingRuntime { init() { this.extServer.on("runtime/emitEvent", (data) => { - // USER_SCRIPT receives private callbacks over its native extension port. + // USER_SCRIPT 的私有回调通过原生扩展端口投递。 return this.broadcastToPage("runtime/emitEvent", data, PageOrContent.PAGE); }); this.extServer.on("runtime/valueUpdate", (data) => { - // USER_SCRIPT receives private updates over its native extension port. + // USER_SCRIPT 的私有值更新通过原生扩展端口投递。 return this.broadcastToPage("runtime/valueUpdate", data, PageOrContent.PAGE); }); this.server.on("logger", (data: Logger) => { @@ -144,6 +146,7 @@ export default class ScriptingRuntime { return false; }, (data) => { + // 所有来自页面的 GM RPC 都在转发前完成字段、句柄、授权和参数复制检查。 const request = validatePageGMRequest(data, this.pageRpc); return { uuid: request.uuid, @@ -174,6 +177,7 @@ export default class ScriptingRuntime { client.pageLoad("it").then((o) => { if (!o.ok) return; const { injectScriptList, envInfo, userScriptBootstrapToken } = o; + // 每次页面加载都废弃旧句柄,避免无 documentId 的浏览器复用上一文档的授权。 this.pageRpc.revokeAll(); const prepareScripts = (scripts: typeof injectScriptList, envTag: "it" | "ct") => scripts.map((script) => { @@ -183,6 +187,7 @@ export default class ScriptingRuntime { script.executionHandle || this.pageRpc.register(script.uuid, envTag, allowedAPIs, undefined, executionRunFlag); if (script.executionHandle) { + // service worker 已签发的句柄要在本页 registry 中恢复,保持跨 context 身份一致。 this.pageRpc.register(script.uuid, envTag, allowedAPIs, script.executionHandle, executionRunFlag); } return { ...script, executionHandle, executionEnvTag: envTag, executionRunFlag }; diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 5d9910e54..32f8f6c62 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -13,6 +13,7 @@ const nativeJSONStringify = JSON.stringify.bind(JSON); const nativeJSONParse = JSON.parse.bind(JSON); const cloneTransportValue = (value: any) => { + // USER_SCRIPT 只能接收数据副本;先去掉 Proxy、getter 和原型引用,避免把页面对象带过边界。 if (value === null || typeof value !== "object") return value; if (nativeStructuredClone) { try { @@ -28,6 +29,7 @@ const cloneTransportValue = (value: any) => { } }; +// 与 rspack 注入的构建级密钥配对;页面只能看到包装函数,拿不到正确的调用标记。 const lnStrIntegrity = process.env.SC_RANDOM_FNKEY; const znRand = process.env.SC_ZN_RAND; @@ -185,10 +187,11 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) } const codeFunction = (code: string) => { - // 临时方法调用不依赖页面改写的 call、apply、bind。 + // 临时方法调用不依赖页面改写的 call、apply、bind;完整性标记也阻止页面直接调用包装器。 return `((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true }); return f; })('${lnStrIntegrity}', '${znRand}' + Math.random(), function(){${code}})`; }; +// 有 setter 时沿用页面属性语义;否则用不可配置的一次性 getter,避免挂载函数被页面再次取走。 const mountCodeFunction = (flag: string, code: string) => `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, '${flag}', ${codeFunction(code)})`; @@ -286,6 +289,7 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { delete scriptInfo.status; // 脚本状态总是启用 delete scriptInfo.executionHandle; delete scriptInfo.executionEnvTag; + // 这些绑定令牌只在隔离 broker 内有效,不能随脚本资料暴露给页面或 USER_SCRIPT。 delete scriptInfo.executionRunFlag; // --- 处理 scriptInfo --- return scriptInfo; @@ -413,6 +417,7 @@ export function definePropertyListener(obj: any, prop: string, listener: (val if (current !== undefined) { const descriptor = Object.getOwnPropertyDescriptor(obj, prop); listener(current); + // 页面可能在回调里替换属性;只有描述符仍是原来的才可以清理自身监听器。 if (sameProperty(descriptor, Object.getOwnPropertyDescriptor(obj, prop)) && descriptor?.configurable) { delete obj[prop]; } @@ -421,6 +426,7 @@ export function definePropertyListener(obj: any, prop: string, listener: (val const setter = (val: T) => { listener(val); const descriptor = Object.getOwnPropertyDescriptor(obj, prop); + // 不删除页面后来安装的 setter,只删除本函数仍拥有的那一个。 if (descriptor?.configurable && descriptor.set === setter) { delete obj[prop]; } diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index c70c1b806..e585c7bf8 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -339,6 +339,7 @@ export class RuntimeClient extends Client { return this.do("stopScript", uuid); } + // envTag 让 service worker 区分主世界请求与 content-world bootstrap,分别签发/回收句柄。 pageLoad(envTag?: "it" | "ct"): Promise { return this.doThrow("pageLoad", envTag ? { envTag } : undefined); } diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 3e773c6b8..bb6784168 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -142,7 +142,9 @@ export class RuntimeService { scriptMatchEnable: UrlMatch = new UrlMatch(); blackMatch: UrlMatch = new UrlMatch(); private gmApi?: GMApi; + // 句柄绑定到 tab/frame/document;页面导航、脚本变更或窗口关闭时必须整体撤销。 private readonly pageExecutionBindings = new Map(); + // USER_SCRIPT 连接只保存它获准使用的 content-world 句柄,回调按句柄再做一次归属匹配。 private readonly userScriptConnections = new Map< string, { connection: MessageConnect; handles: Set; tabId: number; frameId?: number; documentId?: string } @@ -164,6 +166,7 @@ export class RuntimeService { } private revokePageBindings(sender: IGetSender, envTag?: "it" | "ct"): void { + // documentId 缺失时仍按 tab/frame 退休旧绑定,避免新页面继承上一文档的授权。 const source = sender.getSender(); const tabId = source?.tab?.id; const frameId = source?.frameId; @@ -218,6 +221,7 @@ export class RuntimeService { /** Register the native USER_SCRIPT channel used for private bootstrap and callbacks. */ registerUserScriptConnection(data: unknown, sender: IGetSender): boolean { + // bootstrap token 只允许对应 tab/frame/document 使用一次,并且必须覆盖本次下发的全部句柄。 if (!sender.isType(GetSenderType.EXTCONNECT) || sender.getConnectOrigin?.() !== "userScript") return false; if (data === null || typeof data !== "object") return false; const handshake = data as { world?: unknown; bootstrapToken?: unknown }; @@ -304,6 +308,7 @@ export class RuntimeService { typeof data === "object" && data !== null ? (data as { uuid?: unknown; storageName?: unknown }) : undefined; const targetUuid = action === "runtime/emitEvent" ? dataRecord?.uuid : undefined; const targetStorageName = action === "runtime/valueUpdate" ? dataRecord?.storageName : undefined; + // 先按页面定位,再按句柄对应的脚本或 storageName 过滤,避免跨脚本广播私有回调。 for (const [key, entry] of this.userScriptConnections) { if ( to && @@ -352,6 +357,7 @@ export class RuntimeService { const source = sender.getSender(); const tabId = source?.tab?.id; if (typeof tabId !== "number") throw new Error("page execution binding requires a tab"); + // 每次 pageLoad 都签发新句柄和 runFlag;它们共同绑定当前文档的授权生命周期。 const handle = uuidv4(); const binding = { handle, @@ -717,8 +723,7 @@ export class RuntimeService { sendData, }, }); - // USER_SCRIPT cannot observe the scripting world's page broadcast. Deliver the - // same encoded DTO over its native extension connection instead. + // USER_SCRIPT 看不到 scripting world 的页面广播,改经原生扩展连接投递同一份编码 DTO。 this.sendUserScriptMessage(undefined, "runtime/valueUpdate", sendData); // 後台腳本 @@ -1520,6 +1525,7 @@ export class RuntimeService { } async pageLoad(data: { envTag?: "it" | "ct" } | undefined, sender: IGetSender): Promise { + // USER_SCRIPT 只能通过一次性 bootstrap 获取 content-world 资料,不能自行请求 pageLoad。 if (sender.getConnectOrigin?.() === "userScript") return { ok: false }; const chromeSender = sender.getSender(); const url = chromeSender?.url; @@ -1532,8 +1538,7 @@ export class RuntimeService { const incognito = chromeSender.tab?.incognito ?? false; const res = await this.getScriptsForTab({ url, tabId, frameId, incognito }); - // Retire bindings even when the new URL has no matching scripts. This closes - // the reuse window on browsers that do not provide documentId. + // 即使新 URL 没有匹配脚本也要退休旧绑定,关闭不提供 documentId 的浏览器复用窗口。 this.revokePageBindings(sender, data?.envTag); this.mq.emit("popupPageLoadUpdate", { diff --git a/src/app/service/service_worker/types.ts b/src/app/service/service_worker/types.ts index 951370783..ab8cd169c 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -65,7 +65,7 @@ export type ServiceWorkerExecutionBinding = { documentId?: string; /** 用于只向运行该脚本的文档投递值更新的存储命名空间。 */ storageName: string; - /** Capability names accepted by the isolated GM API broker for this page execution. */ + /** 隔离 GM API broker 为本次页面执行接受的能力名称。 */ allowedAPIs: ReadonlySet; /** 已接受的页面请求 ID;绑定销毁时一并释放,确保绑定存续期间拒绝重放。 */ requestIds: Set; diff --git a/src/content.ts b/src/content.ts index 6722c19bc..cba8cb9ff 100644 --- a/src/content.ts +++ b/src/content.ts @@ -19,8 +19,8 @@ const messageFlag = process.env.SC_RANDOM_KEY!; getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | undefined) => { const scriptEnvTag = ScriptEnvTag.content; - // USER_SCRIPT has a native extension messaging channel. Keep the DOM channel only - // for the synchronous element helper, whose node references must remain in this realm. + // USER_SCRIPT 使用浏览器原生扩展通道;DOM 通道只保留同步元素辅助 API, + // 因为节点引用必须留在当前 content realm。 const msg: Message = new ExtensionMessage(false); const domMsg = new CustomEventMessage(eventFlag, false, scriptEnvTag); const domContentMsg = new CustomEventMessage(eventFlag, true, scriptEnvTag); From 9d0de585ce4d236ac630bc6c73daacc64d54f3f3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:07:36 +0900 Subject: [PATCH 020/103] =?UTF-8?q?=F0=9F=94=92=20separate=20asynchronous?= =?UTF-8?q?=20page=20RPC=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/page_message.test.ts | 70 ++++++++ packages/message/page_message.ts | 210 ++++++++++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 5 +- src/app/service/content/scripting.test.ts | 1 + src/app/service/content/scripting.ts | 6 +- src/inject.ts | 3 +- src/scripting.ts | 6 +- 7 files changed, 293 insertions(+), 8 deletions(-) create mode 100644 packages/message/page_message.test.ts create mode 100644 packages/message/page_message.ts diff --git a/packages/message/page_message.test.ts b/packages/message/page_message.test.ts new file mode 100644 index 000000000..7a2a4a72e --- /dev/null +++ b/packages/message/page_message.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PageMessage } from "./page_message"; + +type FakeWindow = Window & { + handlers: Set<(event: MessageEvent) => void>; +}; + +const createWindow = (): FakeWindow => { + const handlers = new Set<(event: MessageEvent) => void>(); + const target = { + handlers, + addEventListener: vi.fn((_type: string, handler: (event: MessageEvent) => void) => { + handlers.add(handler); + }), + removeEventListener: vi.fn((_type: string, handler: (event: MessageEvent) => void) => { + handlers.delete(handler); + }), + postMessage: vi.fn((data: unknown) => { + queueMicrotask(() => { + for (const handler of handlers) handler({ source: target, data } as unknown as MessageEvent); + }); + }), + } as unknown as FakeWindow; + return target; +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("PageMessage", () => { + it("routes structured-clone messages only to the opposite role", async () => { + const target = createWindow(); + const scripting = new PageMessage("page-message-test", "scripting", target); + const inject = new PageMessage("page-message-test", "inject", target); + 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) + ); + expect(target.postMessage).toHaveBeenCalledTimes(2); + + scripting.dispose(); + inject.dispose(); + }); + + it("supports scoped connections and removes its listener on dispose", async () => { + const target = createWindow(); + const scripting = new PageMessage("page-message-test", "scripting", target); + const inject = new PageMessage("page-message-test", "inject", target); + 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 }); + await new Promise((resolve) => queueMicrotask(resolve)); + + expect(received).toHaveBeenCalledWith({ action: "inject/message", data: 1 }); + const handlerCount = target.handlers.size; + scripting.dispose(); + expect(target.handlers.size).toBe(handlerCount - 1); + inject.dispose(); + }); +}); diff --git a/packages/message/page_message.ts b/packages/message/page_message.ts new file mode 100644 index 000000000..c1be57325 --- /dev/null +++ b/packages/message/page_message.ts @@ -0,0 +1,210 @@ +import EventEmitter from "eventemitter3"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import type { + Message, + MessageConnect, + OnConnectCallback, + OnMessageCallback, + RuntimeMessageSender, + TMessage, +} from "./types"; + +export type PageMessageRole = "scripting" | "inject"; + +type PageMessageType = "sendMessage" | "respMessage" | "connect" | "disconnect" | "connectMessage"; + +type PageMessageBody = { + readonly channel: string; + readonly source: PageMessageRole; + readonly target: PageMessageRole; + readonly messageId: string; + readonly type: PageMessageType; + readonly data: TMessage | null; +}; + +const nativeReflectApply = Reflect.apply; +const nativeFunctionBind = Function.prototype.bind; + +const bindNative = any>(fn: T, receiver: any): T => + nativeReflectApply(nativeFunctionBind, fn, [receiver]) as T; + +const listenerMgr = new EventEmitter(); + +const otherRole = (role: PageMessageRole): PageMessageRole => (role === "scripting" ? "inject" : "scripting"); + +class PageMessageConnect implements MessageConnect { + private readonly listenerId = uuidv4(); + private target: (() => void) | null; + private isSelfDisconnected = false; + + constructor( + private readonly messageId: string, + private readonly targetRole: PageMessageRole, + private readonly send: ( + target: PageMessageRole, + body: Omit + ) => void, + private readonly EE: EventEmitter + ) { + const handler = (message: TMessage) => { + listenerMgr.emit(`onMessage:${this.listenerId}`, message); + }; + const cleanup = () => { + if (!this.target) return; + this.target = null; + listenerMgr.removeAllListeners(`cleanup:${this.listenerId}`); + this.EE.removeAllListeners(`connectMessage:${this.messageId}`); + this.EE.removeAllListeners(`disconnect:${this.messageId}`); + listenerMgr.emit(`onDisconnect:${this.listenerId}`, this.isSelfDisconnected); + listenerMgr.removeAllListeners(`onDisconnect:${this.listenerId}`); + listenerMgr.removeAllListeners(`onMessage:${this.listenerId}`); + }; + this.target = cleanup; + this.EE.addListener(`connectMessage:${this.messageId}`, handler); + this.EE.addListener(`disconnect:${this.messageId}`, cleanup); + listenerMgr.once(`cleanup:${this.listenerId}`, cleanup); + } + + sendMessage(data: TMessage): void { + if (!this.target) throw new Error("Attempted to sendMessage on a disconnected page channel."); + this.send(this.targetRole, { + messageId: this.messageId, + type: "connectMessage", + data, + }); + } + + onMessage(callback: (data: TMessage) => void): void { + if (!this.target) throw new Error("onMessage on a disconnected page channel."); + listenerMgr.addListener(`onMessage:${this.listenerId}`, callback); + } + + disconnect(ignoreAlreadyDisconnected = false): void { + if (!this.target) { + if (ignoreAlreadyDisconnected) return; + throw new Error("Attempted to disconnect a disconnected page channel."); + } + this.isSelfDisconnected = true; + this.send(this.targetRole, { + messageId: this.messageId, + type: "disconnect", + data: null, + }); + listenerMgr.emit(`cleanup:${this.listenerId}`); + } + + onDisconnect(callback: (isSelfDisconnected: boolean) => void): void { + if (!this.target) throw new Error("onDisconnect on a disconnected page channel."); + listenerMgr.once(`onDisconnect:${this.listenerId}`, callback); + } +} + +/** + * 页面异步 RPC 专用通道。 + * + * role 与 channel 只负责传输路由;调用方仍必须验证每个请求,并把权限绑定到隔离执行记录。 + */ +export class PageMessage implements Message { + readonly EE = new EventEmitter(); + private readonly postMessage: (message: unknown, targetOrigin: string) => void; + private readonly messageHandler: (event: MessageEvent) => void; + private readonly targetRole: PageMessageRole; + + constructor( + private readonly channel: string, + private readonly role: PageMessageRole, + private readonly sourceWindow: Window = window + ) { + if (typeof sourceWindow.postMessage !== "function") throw new TypeError("window.postMessage is unavailable"); + this.postMessage = bindNative(sourceWindow.postMessage, sourceWindow); + this.targetRole = otherRole(role); + this.messageHandler = (event: MessageEvent) => { + if (event.source !== null && event.source !== sourceWindow) return; + const body = event.data as Partial | null; + if ( + !body || + body.channel !== this.channel || + body.target !== this.role || + body.source !== this.targetRole || + typeof body.messageId !== "string" || + typeof body.type !== "string" + ) { + return; + } + this.messageHandle(body as PageMessageBody); + }; + sourceWindow.addEventListener("message", this.messageHandler); + } + + private sendEnvelope(target: PageMessageRole, body: Omit): void { + this.postMessage( + { + channel: this.channel, + source: this.role, + target, + ...body, + } satisfies PageMessageBody, + "*" + ); + } + + private messageHandle(body: PageMessageBody): void { + if (body.type === "sendMessage") { + this.EE.emit( + "message", + body.data, + (response: TMessage) => { + this.sendEnvelope(body.source, { + 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 PageMessageConnect(body.messageId, body.source, this.sendEnvelope.bind(this), this.EE) + ); + } 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 PageMessageConnect(messageId, this.targetRole, this.sendEnvelope.bind(this), this.EE)); + } + + sendMessage(data: TMessage): Promise { + return new Promise((resolve) => { + const messageId = uuidv4(); + const eventId = `response:${messageId}`; + this.EE.addListener(eventId, (body: PageMessageBody) => { + this.EE.removeAllListeners(eventId); + resolve(body.data as T); + }); + this.sendEnvelope(this.targetRole, { messageId, type: "sendMessage", data }); + }); + } + + dispose(): void { + this.sourceWindow.removeEventListener("message", this.messageHandler); + this.EE.removeAllListeners(); + } +} diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 95e312659..576ee1890 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -601,7 +601,8 @@ export default class GMApi extends GM_Base { // 上下文已失效时直接返回,避免访问已释放的 message 造成异常 if (ctx.isInvalidContext()) return undefined; - if (ctx.scriptRes?.executionEnvTag === ScriptEnvTag.content) { + const isContentEnv = ctx.scriptRes?.executionEnvTag === ScriptEnvTag.content; + if (isContentEnv) { // USER_SCRIPT 可直接在 content realm 创建 Document;跨到 scripting 只会丢失节点引用。 return new Promise((resolve) => { const xhr = new XMLHttpRequest(); @@ -613,8 +614,6 @@ export default class GMApi extends GM_Base { }); } - const message = ctx.message as CustomEventMessage | null; - const isContentEnv = !!message && message.envTag === ScriptEnvTag.content; return urlToDocumentInContentPage(ctx, url, isContentEnv); } diff --git a/src/app/service/content/scripting.test.ts b/src/app/service/content/scripting.test.ts index 960eb6b27..2cf1c6329 100644 --- a/src/app/service/content/scripting.test.ts +++ b/src/app/service/content/scripting.test.ts @@ -41,6 +41,7 @@ describe("ScriptingRuntime page bootstrap", () => { {} as Server, senderToExt as unknown as MessageSend, senderToContent as any, + senderToInject as any, senderToInject as any ); diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index 304fc7a5c..caf88b916 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -39,7 +39,9 @@ export default class ScriptingRuntime { // 发送给 content的消息接口 private readonly senderToContent: CustomEventMessage, // 发送给inject的消息接口 - private readonly senderToInject: CustomEventMessage + private readonly senderToInject: MessageSend, + // 仅用于同步 DOM 节点引用;异步脚本 RPC 使用 senderToInject 的结构化消息。 + private readonly domSenderToInject: CustomEventMessage ) {} // 广播消息给 content 和 inject @@ -115,7 +117,7 @@ export default class ScriptingRuntime { case "CAT_fetchDocument": { const [url, isContent] = data.params; // 根据来源选择不同的消息桥(content / inject) - let msg: CustomEventMessage | null = isContent ? this.senderToContent : this.senderToInject; + let msg: CustomEventMessage | null = isContent ? this.senderToContent : this.domSenderToInject; return new Promise((resolve) => { const xhr = new XMLHttpRequest(); xhr.responseType = "document"; diff --git a/src/inject.ts b/src/inject.ts index 0d13290da..878f2426c 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -1,6 +1,7 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; +import { PageMessage } from "@Packages/message/page_message"; import { Server } from "@Packages/message/server"; import { ScriptExecutor } from "./app/service/content/script_executor"; import type { Message } from "@Packages/message/types"; @@ -14,7 +15,7 @@ const messageFlag = process.env.SC_RANDOM_KEY!; getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | undefined) => { const scriptEnvTag = ScriptEnvTag.inject; - const msg: Message = new CustomEventMessage(eventFlag, false, scriptEnvTag); + const msg: Message = new PageMessage(eventFlag, "inject"); // 初始化日志组件 const logger = new LoggerCore({ diff --git a/src/scripting.ts b/src/scripting.ts index fa943e7ba..2f4a88ce1 100644 --- a/src/scripting.ts +++ b/src/scripting.ts @@ -3,6 +3,7 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; import type { Message } from "@Packages/message/types"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; +import { PageMessage } from "@Packages/message/page_message"; import { ScriptEnvTag } from "@Packages/message/consts"; import { Server } from "@Packages/message/server"; import ScriptingRuntime from "./app/service/content/scripting"; @@ -24,7 +25,8 @@ negotiateEventFlag(messageFlag, extensionEnv, 2, (eventFlag) => { logger.logger().debug("scripting start"); const contentMsg = new CustomEventMessage(eventFlag, true, ScriptEnvTag.content); - const injectMsg = new CustomEventMessage(eventFlag, true, ScriptEnvTag.inject); + const injectMsg = new PageMessage(eventFlag, "scripting"); + const domInjectMsg = new CustomEventMessage(eventFlag, true, ScriptEnvTag.inject); const server = new Server("scripting", [contentMsg, injectMsg]); @@ -33,7 +35,7 @@ negotiateEventFlag(messageFlag, extensionEnv, 2, (eventFlag) => { const extServer = new Server("scripting", extMsgComm, false); // scriptExecutor的消息接口 // 初始化运行环境 - const runtime = new ScriptingRuntime(extServer, server, extMsgComm, contentMsg, injectMsg); + const runtime = new ScriptingRuntime(extServer, server, extMsgComm, contentMsg, injectMsg, domInjectMsg); runtime.init(); // 页面加载,注入脚本 runtime.pageLoad(); From 34487280190ad0ed529c07b26fe3b151f45d4898 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:14:51 +0900 Subject: [PATCH 021/103] =?UTF-8?q?=F0=9F=94=92=20hide=20GM=20broker=20sta?= =?UTF-8?q?te=20behind=20script=20facade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/create_context.test.ts | 42 +++++++------ src/app/service/content/create_context.ts | 61 +++++++++++++++++-- src/app/service/content/exec_script.ts | 9 ++- src/app/service/content/gm_api/gm_api.test.ts | 4 +- 4 files changed, 86 insertions(+), 30 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 13495e87a..b22232d8f 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -306,6 +306,16 @@ 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("creates collection instances from frozen captured-method subclasses", () => { const set = new Native.Set(["grant"]); const map = new Native.Map(); @@ -365,21 +375,29 @@ describe("createContext: capability and lifecycle contract", () => { } }); - it("uses the service-worker execution run flag for value acknowledgments", () => { + it("uses the service-worker execution run flag for value acknowledgments", async () => { const script = { - ...createScriptInfo({ grant: ["GM_getValue"] }), + ...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, - undefined as any, - new Set(["GM_getValue"]) + new Set(["GM_setValue"]) ); - expect((context as unknown as { runFlag: string }).runFlag).toBe("canonical-run"); + 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", () => { @@ -465,9 +483,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) => { @@ -481,8 +496,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", () => { @@ -509,8 +522,7 @@ 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); }); @@ -549,16 +561,10 @@ 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); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 990d3da3d..8b572ba14 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -6,7 +6,7 @@ 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"; @@ -44,6 +44,18 @@ const createCapability = (api: (...args: any[]) => any, receiver: object) => { // 不要使用 {}, 改使用 Object.create(null) - 避免在页面生成沙盒时,受到 Object.prototype 被注入的影响 +export type ScriptContext = IGM_Base & { + [key: string]: any; + setExecutionRunFlag(runFlag: string): void; + resolveLoadScript(): void; +}; + +type InternalScriptContext = IGM_Base & { + [key: string]: any; + runFlag: string; + loadScriptResolve?: () => void; +}; + // 构建沙盒上下文 export const createContext = ( scriptRes: TScriptInfo, @@ -99,7 +111,47 @@ export const createContext = ( isInvalidContext() { return invalid; }, + }) 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; + + // 生命周期方法只供隔离执行器使用,不进入脚本可枚举的 facade。 + Native.objectDefineProperty(publicContext, "valueUpdate", { + configurable: false, + enumerable: false, + value: (data: any) => context.valueUpdate(data), + }); + Native.objectDefineProperty(publicContext, "emitEvent", { + configurable: false, + enumerable: false, + value: (event: string, eventId: string, data: any) => context.emitEvent(event, eventId, data), + }); + Native.objectDefineProperty(publicContext, "setInvalidContext", { + configurable: false, + enumerable: false, + value: () => context.setInvalidContext(), }); + Native.objectDefineProperty(publicContext, "isInvalidContext", { + configurable: false, + enumerable: false, + value: () => context.isInvalidContext(), + }); + Native.objectDefineProperty(publicContext, "setExecutionRunFlag", { + configurable: false, + enumerable: false, + value: (runFlag: string) => { + context.runFlag = runFlag; + }, + }); + Native.objectDefineProperty(publicContext, "resolveLoadScript", { + configurable: false, + enumerable: false, + value: () => context.loadScriptResolve?.(), + }); + const grantedAPIs: { [key: string]: any } = Native.objectCreate(null); const __methodInject__ = (grant: string): boolean => { const grantSet: Set = context.grantSet; @@ -131,7 +183,7 @@ export const createContext = ( 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]; @@ -139,12 +191,11 @@ export const createContext = ( g = g[part] || (g[part] = grantedAPIs[s] || Native.objectCreate(null)); } } - context.unsafeWindow = window; if (scriptGrantSet.has("window.onurlchange") && context.onurlchange === undefined) { - context.onurlchange = null; + publicContext.onurlchange = null; attachNavigateHandler(window as any); } - return context; + return publicContext; }; const noEval = false; diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index dedcce66d..e0acb4c0c 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -1,12 +1,11 @@ import LoggerCore from "@App/app/logger/core"; import type Logger from "@App/app/logger/logger"; -import { createContext, createProxyContext } from "./create_context"; +import { createContext, createProxyContext, type ScriptContext } from "./create_context"; import type { GMInfoEnv, ScriptFunc } from "./types"; import { compileScript, 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 { Native } from "./global"; @@ -23,7 +22,7 @@ export default class ExecScript { // proxyContext: typeof globalThis; - sandboxContext?: IGM_Base & { [key: string]: any }; + sandboxContext?: ScriptContext; named?: { [key: string]: any }; @@ -103,13 +102,13 @@ export default class ExecScript { this.scriptRes.executionEnvTag = scriptInfo.executionEnvTag; this.scriptRes.executionRunFlag = scriptInfo.executionRunFlag; if (this.sandboxContext && scriptInfo.executionRunFlag) { - this.sandboxContext.runFlag = scriptInfo.executionRunFlag; + this.sandboxContext.setExecutionRunFlag(scriptInfo.executionRunFlag); } } let GM_info; if (this.sandboxContext) { // 触发loadScriptResolve - this.sandboxContext["loadScriptResolve"]?.(); + this.sandboxContext.resolveLoadScript(); GM_info = this.execContext["GM_info"]; } else { GM_info = this.named?.GM_info; 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 94f3dfb70..d73add0b5 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -1249,7 +1249,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; @@ -1350,7 +1350,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 entries: [["a", encodeRValue(123), encodeRValue(undefined)]], uuid: script.uuid, storageName: script.uuid, - sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, + sender: { runFlag: actualCall.data.runFlag, tabId: -2 }, valueUpdated: true, }); From 6a014bfb4b2a7519ee49614182464ac9a0f6039e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:22:09 +0900 Subject: [PATCH 022/103] =?UTF-8?q?=F0=9F=94=92=20reject=20executable=20va?= =?UTF-8?q?lues=20at=20GM=20clone=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 48 +++++++++++++++---- src/app/service/content/gm_api/gm_api.test.ts | 26 ++++++++-- src/app/service/content/utils.ts | 21 ++------ 3 files changed, 65 insertions(+), 30 deletions(-) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 8e7702946..95a9808e5 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -25,6 +25,9 @@ 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 hasNativeStructuredClone = typeof structuredClone === "function"; // Keep the captured methods on private subclasses. Instances can then be created // without reassigning every method, while the subclass prototypes remain outside @@ -103,17 +106,46 @@ export const customClone = (o: any) => { // 接受参数:阵列、物件、null if (typeof o !== "object") return 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); + 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 (descriptor.value !== null && typeof descriptor.value === "object" && !isDataOnly(descriptor.value)) { + return false; + } + } + return true; + }; + if (!isDataOnly(o)) return undefined; + + if (hasNativeStructuredClone) { + try { + // 优先使用 structuredClone,支持大多数可克隆对象 + return Native.structuredClone(o); + } catch { + // structuredClone 拒绝的值不再退回会执行 getter 的 JSON 序列化。 + return undefined; + } } try { - // 退而求其次,使用 JSON 序列化方式进行深拷贝 - // 仅适用于可被 JSON 表示的普通对象 + // 旧浏览器没有 structuredClone 时,只复制已验证的数据属性。 return Native.jsonParse(Native.jsonStringify(o)); } catch { // 序列化失败,忽略错误 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 d73add0b5..a64cbd1b6 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -740,7 +740,7 @@ describe.concurrent("GM_value", () => { action: "scripting/runtime/gmApi", data: { api: "GM_setValue", - params: [expect.any(String), "proxy-key", {}], // Proxy 会被转换为空对象 + params: [expect.any(String), "proxy-key"], // Proxy 无法通过 data-only clone,按删除处理 runFlag: expect.any(String), uuid: undefined, }, @@ -764,7 +764,7 @@ describe.concurrent("GM_value", () => { expect(ret).toEqual({ ret1: 123, ret2: 456, - ret3: {}, + ret3: undefined, ret4: undefined, }); }); @@ -927,6 +927,24 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 }); }); + it("拒绝带 getter 的值,且不会在克隆时执行 getter", () => { + const script = Object.assign({}, scriptRes) 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); + const getter = vi.fn(() => "secret"); + const payload = {} as Record; + Object.defineProperty(payload, "secret", { configurable: true, enumerable: true, get: getter }); + + api.GM_setValue(api, "hostile", payload); + + expect(getter).not.toHaveBeenCalled(); + expect(script.value.hostile).toBeUndefined(); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ params: [expect.any(String), "hostile"] }) }) + ); + }); + it.concurrent("GM_setValues", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValues", "GM_setValues"]; @@ -1020,7 +1038,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 // event id expect.stringMatching(/^.+::\d+$/), // the object payload - [["proxy-key", encodeRValue({})]], + [["proxy-key", encodeRValue(undefined)]], ], runFlag: expect.any(String), uuid: undefined, @@ -1055,7 +1073,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 expect(ret).toEqual({ ret1: { a: 123, b: 456, c: "789" }, ret2: { b: 456 }, - ret3: { "proxy-key": {} }, + ret3: { "proxy-key": undefined }, ret4: { window: undefined }, }); }); diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 32f8f6c62..f8c779e11 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -7,26 +7,11 @@ 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"; - -const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; -const nativeJSONStringify = JSON.stringify.bind(JSON); -const nativeJSONParse = JSON.parse.bind(JSON); +import { customClone } from "./global"; const cloneTransportValue = (value: any) => { - // USER_SCRIPT 只能接收数据副本;先去掉 Proxy、getter 和原型引用,避免把页面对象带过边界。 - if (value === null || typeof value !== "object") return value; - if (nativeStructuredClone) { - try { - return nativeStructuredClone(value); - } catch { - // Fall through for objects such as proxies that structuredClone rejects. - } - } - try { - return nativeJSONParse(nativeJSONStringify(value)); - } catch { - return undefined; - } + // USER_SCRIPT 只能接收数据副本;共享 customClone 的 data-only 检查,避免 getter/Proxy 进入页面资料。 + return customClone(value); }; // 与 rspack 注入的构建级密钥配对;页面只能看到包装函数,拿不到正确的调用标记。 From eb66888815196db291ab823738c5ca35e21198dd Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:23:36 +0900 Subject: [PATCH 023/103] =?UTF-8?q?=F0=9F=90=9B=20settle=20GM=20XHR=20abor?= =?UTF-8?q?ts=20without=20callbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.test.ts | 30 +++++++++++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index cf75a3a61..8daeb7ea5 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -70,4 +70,34 @@ describe("GM_xmlhttpRequest callback cleanup", () => { 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)); + }); }); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 96eb9f23f..e2065f3bf 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -752,7 +752,7 @@ export function GM_xmlhttpRequest( 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?.({ From 1d0dd8504b53c613177f56e6235f2330e91dc9ef Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:25:24 +0900 Subject: [PATCH 024/103] =?UTF-8?q?=F0=9F=90=9B=20continue=20page=20script?= =?UTF-8?q?=20loading=20after=20early=20reconciliation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/script_executor.test.ts | 36 +++++++++++++++++++ src/app/service/content/script_executor.ts | 2 +- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index 969243ab1..31dcd154c 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -186,6 +186,42 @@ describe("ScriptExecutor", () => { } }); + it("continues loading later scripts after reconciling an early-start entry", () => { + const early = makeScript({ + uuid: "early-script", + flag: "executor-early-batch", + metadata: { "early-start": [""], "run-at": ["document-start"] }, + }); + const later = makeScript({ uuid: "later-script", flag: "executor-later-batch" }); + 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 void }>; + }; + internal.earlyScriptFlags.add(early.flag); + const updateEarlyScriptGMInfo = vi.spyOn(internal.execScripts.get(early.uuid)!, "updateEarlyScriptGMInfo"); + const genuine = vi.fn(); + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + const pageWindow = window as unknown as Record; + + try { + executor.startScripts([early, later], initEnvInfo); + pageWindow[later.flag] = genuine; + + expect(updateEarlyScriptGMInfo).toHaveBeenCalledWith(initEnvInfo, early); + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, later.name); + } finally { + delete pageWindow[later.flag]; + } + }); + 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 75df76f12..68b9b980f 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -81,7 +81,7 @@ export class ScriptExecutor { updated = true; } }); - if (updated) return; + if (updated) continue; } const listenForScript = () => { definePropertyListener(window, flag, (val: ScriptFunc) => { From 7780934cec327fa540e2b79359e14f71839e63e6 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:27:08 +0900 Subject: [PATCH 025/103] =?UTF-8?q?=F0=9F=94=92=20clone=20DOM=20bridge=20p?= =?UTF-8?q?ayloads=20before=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/script_runtime.test.ts | 48 +++++++++++++++++++ src/app/service/content/script_runtime.ts | 6 ++- 2 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 src/app/service/content/script_runtime.test.ts 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..217399ffb --- /dev/null +++ b/src/app/service/content/script_runtime.test.ts @@ -0,0 +1,48 @@ +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"; + +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"); + }); +}); diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 5c76cbfcf..cd0434094 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -9,6 +9,7 @@ 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 } from "./global"; export class ScriptRuntime { constructor( @@ -22,8 +23,9 @@ export class ScriptRuntime { // content环境的特殊初始化 contentInit(domServer: Server = this.server, domMsg: CustomEventMessage = this.msg as CustomEventMessage) { domServer.on("runtime/addElement", (data: { params: [number | null, string, Record | null] }) => { - if (!data || !Array.isArray(data.params) || data.params.length !== 3) return undefined; - const [parentNodeId, tagName, tmpAttr] = data.params; + 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 ( From 2993bbc3ecc26de559f3e1bfa2b1762db3a232b0 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:30:07 +0900 Subject: [PATCH 026/103] =?UTF-8?q?=F0=9F=94=92=20validate=20Blob=20page?= =?UTF-8?q?=20RPC=20payloads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/page_rpc.test.ts | 23 +++++++++++++++++++++++ src/app/service/content/page_rpc.ts | 10 +++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index eee9bc780..1b80a697f 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { Blob as NodeBlob } from "node:buffer"; import { getPageRpcAllowedAPIs, setPageRpcExtensionOrigin, @@ -211,6 +212,28 @@ describe("page GM RPC", () => { 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("script-a", "it", ["CAT_createBlobUrl"]); + + expect(() => + validatePageGMRequest( + { version: 1, requestId: "object", 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: 1, requestId: "blob", 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; diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index 8d9b93403..3f49debaf 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -6,6 +6,7 @@ export const PAGE_RPC_VERSION = 1 as const; const MAX_REQUEST_ID_LENGTH = 256; const MAX_REQUEST_IDS_PER_BINDING = 4096; const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; +const nativeObjectToString = Object.prototype.toString; const EXTENSION_PROTOCOLS = new Set(["chrome-extension:", "moz-extension:"]); export type ExtensionOrigin = Pick; @@ -182,6 +183,9 @@ const ownData = (value: object, key: PropertyKey): unknown => { const assertDataOnly = (value: unknown, seen: Set): void => { // 先检查自有数据描述符,再做 structuredClone;这样页面 getter/Proxy 不会在 broker 中执行。 if (value === null || typeof value !== "object") return; + // Blob 的内部槽由浏览器管理,不能把其 symbol/accessor 细节当作 DTO 字段遍历。 + if (typeof Blob === "function" && (value instanceof Blob || nativeObjectToString.call(value) === "[object Blob]")) + return; if (seen.has(value)) return; seen.add(value); @@ -218,7 +222,11 @@ const validateOperationParams = (api: string, params: readonly unknown[]): void } return; case "CAT_createBlobUrl": - if (params.length !== 1 || params[0] === null || typeof params[0] !== "object") { + if ( + params.length !== 1 || + typeof Blob !== "function" || + (!(params[0] instanceof Blob) && nativeObjectToString.call(params[0]) !== "[object Blob]") + ) { throw new PageRpcError("CAT_createBlobUrl expects one Blob value"); } return; From 9c0650d159da1113f1dfa43eeb955e7afe31a621 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:36:37 +0900 Subject: [PATCH 027/103] =?UTF-8?q?=F0=9F=94=92=20reject=20executable=20GM?= =?UTF-8?q?=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 9 ++++++--- src/app/service/content/gm_api/gm_api.test.ts | 15 +++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 8 ++++---- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 95a9808e5..f12dcdb2a 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -102,9 +102,9 @@ export const Native = { } as const; export const customClone = (o: any) => { - // 非对象类型直接返回(包含 Symbol、undefined、基本类型等) + // 非对象类型直接返回(包含 Symbol、undefined、基本类型等);函数不可跨边界传输。 // 接受参数:阵列、物件、null - if (typeof o !== "object") return o; + if (o === null || typeof o !== "object") return typeof o === "function" ? undefined : o; // 先验证自有字段都是数据描述符,避免 JSON fallback 执行页面 getter 或 Proxy trap。 const seen = new Native.WeakMap(); @@ -126,7 +126,10 @@ export const customClone = (o: any) => { return false; } if (!descriptor || !("value" in descriptor)) return false; - if (descriptor.value !== null && typeof descriptor.value === "object" && !isDataOnly(descriptor.value)) { + if ( + typeof descriptor.value === "function" || + (descriptor.value !== null && typeof descriptor.value === "object" && !isDataOnly(descriptor.value)) + ) { return false; } } 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 a64cbd1b6..3d4c32b56 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -945,6 +945,21 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 ); }); + it("拒绝可执行值,且不会把函数写入本地存储或传输层", () => { + const script = Object.assign({}, scriptRes) 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); + const executable = () => "secret"; + + api.GM_setValue(api, "executable", executable); + + expect(script.value.executable).toBeUndefined(); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ params: [expect.any(String), "executable"] }) }) + ); + }); + it.concurrent("GM_setValues", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValues", "GM_setValues"]; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 576ee1890..fd05120de 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -347,8 +347,8 @@ export default class GMApi extends GM_Base { delete a.scriptRes.value[key]; a.sendMessage("GM_setValue", [id, key]); } else { - // 对object的value进行一次转化 - if (value && typeof value === "object") { + // 对对象或函数值进行一次转化 + if (value !== null && (typeof value === "object" || typeof value === "function")) { value = customClone(value); } // customClone 可能返回 undefined @@ -380,8 +380,8 @@ export default class GMApi extends GM_Base { if (value_ === undefined) { if (valueStore[key]) delete valueStore[key]; } else { - // 对object的value进行一次转化 - if (value_ && typeof value_ === "object") { + // 对对象或函数值进行一次转化 + if (value_ !== null && (typeof value_ === "object" || typeof value_ === "function")) { value_ = customClone(value_); } // customClone 可能返回 undefined From 398c8845027597228c71d6329500573e47d0c5d1 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:38:37 +0900 Subject: [PATCH 028/103] =?UTF-8?q?=F0=9F=94=92=20reject=20non-cloneable?= =?UTF-8?q?=20GM=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 6 ++++-- src/app/service/content/gm_api/gm_api.test.ts | 14 ++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 8 ++++++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index f12dcdb2a..b1d9701ef 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -102,9 +102,11 @@ export const Native = { } as const; export const customClone = (o: any) => { - // 非对象类型直接返回(包含 Symbol、undefined、基本类型等);函数不可跨边界传输。 + // 非对象类型直接返回(包含 undefined、基本类型等);函数和 Symbol 不可跨边界传输。 // 接受参数:阵列、物件、null - if (o === null || typeof o !== "object") return typeof o === "function" ? undefined : o; + if (o === null || typeof o !== "object") { + return typeof o === "function" || typeof o === "symbol" ? undefined : o; + } // 先验证自有字段都是数据描述符,避免 JSON fallback 执行页面 getter 或 Proxy trap。 const seen = new Native.WeakMap(); 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 3d4c32b56..8c76153b2 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -960,6 +960,20 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 ); }); + it("拒绝 Symbol 值,避免把不可结构化克隆的数据写入本地存储", () => { + const script = Object.assign({}, scriptRes) 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, "symbol", Symbol("secret")); + + expect(script.value.symbol).toBeUndefined(); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ params: [expect.any(String), "symbol"] }) }) + ); + }); + it.concurrent("GM_setValues", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValues", "GM_setValues"]; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index fd05120de..eb3ea9fb2 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -348,7 +348,7 @@ export default class GMApi extends GM_Base { a.sendMessage("GM_setValue", [id, key]); } else { // 对对象或函数值进行一次转化 - if (value !== null && (typeof value === "object" || typeof value === "function")) { + if (typeof value === "function" || typeof value === "symbol" || (value !== null && typeof value === "object")) { value = customClone(value); } // customClone 可能返回 undefined @@ -381,7 +381,11 @@ export default class GMApi extends GM_Base { if (valueStore[key]) delete valueStore[key]; } else { // 对对象或函数值进行一次转化 - if (value_ !== null && (typeof value_ === "object" || typeof value_ === "function")) { + if ( + typeof value_ === "function" || + typeof value_ === "symbol" || + (value_ !== null && typeof value_ === "object") + ) { value_ = customClone(value_); } // customClone 可能返回 undefined From 06bbf29190f9b600fb5382fb0aca5b4a0dfe1cb8 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:44:10 +0900 Subject: [PATCH 029/103] =?UTF-8?q?=F0=9F=94=92=20harden=20page=20RPC=20in?= =?UTF-8?q?trinsic=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/page_rpc.test.ts | 25 ++++++++- src/app/service/content/page_rpc.ts | 66 +++++++++++++++--------- 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 1b80a697f..16b74a999 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { Blob as NodeBlob } from "node:buffer"; import { getPageRpcAllowedAPIs, @@ -190,6 +190,29 @@ describe("page GM RPC", () => { ).toThrow(PageRpcError); }); + it("keeps validation on captured intrinsics after page prototype hooks", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["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: 1, requestId: "hooked", handle, api: "GM_getValue", params: [] }, + registry + ); + } finally { + ownKeysSpy.mockRestore(); + descriptorSpy.mockRestore(); + } + expect(result).toMatchObject({ uuid: "script-a", envTag: "it" }); + }); + it("rejects malformed parameters for privileged helper operations", () => { const registry = new PageRpcRegistry(); const handle = registry.register("script-a", "it", ["CAT_fetchBlob"]); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index 3f49debaf..089afca36 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -1,20 +1,27 @@ import { uuidv4 } from "@App/pkg/utils/uuid"; import type { ScriptEnvTag } from "@Packages/message/consts"; import { getGrantCandidates } from "./gm_api/grant"; +import { Native, nativeReflectApply } from "./global"; export const PAGE_RPC_VERSION = 1 as const; const MAX_REQUEST_ID_LENGTH = 256; const MAX_REQUEST_IDS_PER_BINDING = 4096; const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; const nativeObjectToString = Object.prototype.toString; -const EXTENSION_PROTOCOLS = new Set(["chrome-extension:", "moz-extension:"]); +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 URL(chrome.runtime.getURL("/")); + 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 { @@ -33,7 +40,7 @@ export const setPageRpcExtensionOrigin = (value: unknown): void => { } try { const read = (key: keyof ExtensionOrigin): unknown => { - const descriptor = Object.getOwnPropertyDescriptor(value, key); + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); return descriptor && "value" in descriptor ? descriptor.value : undefined; }; const protocol = read("protocol"); @@ -59,9 +66,9 @@ export const isExtensionBlobUrl = (value: unknown): value is string => { const extensionOrigin = configuredExtensionOrigin || getExtensionOrigin(); if (!extensionOrigin) return false; try { - const url = new URL(value); + const url = new nativeURL(value); if (url.protocol !== "blob:") return false; - const creatorOrigin = new URL(value.slice("blob:".length)); + const creatorOrigin = new nativeURL(nativeReflectApply(nativeStringSlice, value, ["blob:".length])); return ( creatorOrigin.protocol === extensionOrigin.protocol && creatorOrigin.hostname === extensionOrigin.hostname && @@ -150,8 +157,8 @@ const API_DEPENDENCIES: Readonly> = { export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { if (grants.some((grant) => grant === "none")) return []; - const allowed = new Set(); - const visited = new Set(); + const allowed = new Native.Set(); + const visited = new Native.Set(); const visitGrant = (grant: string): void => { for (const candidate of getGrantCandidates(grant)) { if (visited.has(candidate)) continue; @@ -162,7 +169,9 @@ export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { } }; for (const grant of grants) visitGrant(grant); - return [...allowed]; + const result: string[] = []; + allowed.forEach((value) => result.push(value)); + return result; }; export class PageRpcError extends Error { @@ -173,7 +182,7 @@ export class PageRpcError extends Error { } const ownData = (value: object, key: PropertyKey): unknown => { - const descriptor = Object.getOwnPropertyDescriptor(value, key); + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); if (!descriptor || !("value" in descriptor)) { throw new PageRpcError(`page RPC field ${String(key)} must be a data property`); } @@ -191,7 +200,7 @@ const assertDataOnly = (value: unknown, seen: Set): void => { let keys: (string | symbol)[]; try { - keys = Reflect.ownKeys(value); + keys = nativeReflectOwnKeys(value); } catch { throw new PageRpcError("page RPC value cannot be inspected"); } @@ -204,8 +213,8 @@ const assertDataOnly = (value: unknown, seen: Set): void => { const cloneParams = (params: unknown): readonly unknown[] => { // 复制发生在交给 service worker 之前,后续 broker 只处理隔离后的普通值。 - if (!Array.isArray(params)) throw new PageRpcError("page RPC params must be an array"); - assertDataOnly(params, new Set()); + 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[]; @@ -224,8 +233,8 @@ const validateOperationParams = (api: string, params: readonly unknown[]): void case "CAT_createBlobUrl": if ( params.length !== 1 || - typeof Blob !== "function" || - (!(params[0] instanceof Blob) && nativeObjectToString.call(params[0]) !== "[object Blob]") + !nativeBlob || + (!(params[0] instanceof nativeBlob) && nativeObjectToString.call(params[0]) !== "[object Blob]") ) { throw new PageRpcError("CAT_createBlobUrl expects one Blob value"); } @@ -240,7 +249,7 @@ const validateOperationParams = (api: string, params: readonly unknown[]): void params.length !== 1 || params[0] === null || typeof params[0] !== "object" || - Array.isArray(params[0]) || + nativeArrayIsArray(params[0]) || typeof (params[0] as { action?: unknown }).action !== "string" ) { throw new PageRpcError("CAT_agentOPFS expects an operation object"); @@ -252,7 +261,7 @@ const validateOperationParams = (api: string, params: readonly unknown[]): void }; export class PageRpcRegistry { - private readonly bindings = new Map(); + private readonly bindings = new Native.Map(); register( uuid: string, @@ -268,10 +277,10 @@ export class PageRpcRegistry { handle, uuid, envTag, - allowedAPIs: new Set(allowedAPIs), + allowedAPIs: new Native.Set(allowedAPIs), runFlag, active: true, - requestIds: new Set(), + requestIds: new Native.Set(), }); return handle; } @@ -310,16 +319,27 @@ export const validatePageGMRequest = (value: unknown, registry: PageRpcRegistry) let keys: (string | symbol)[]; try { - keys = Reflect.ownKeys(value); + keys = nativeReflectOwnKeys(value); } catch { throw new PageRpcError("page RPC request cannot be inspected"); } - if ( - keys.length !== REQUEST_KEYS.length || - keys.some((key) => typeof key !== "string" || !REQUEST_KEYS.includes(key as never)) - ) { + if (keys.length !== REQUEST_KEYS.length) { throw new PageRpcError("page RPC request has unexpected fields"); } + for (const key of keys) { + let knownKey = false; + if (typeof key === "string") { + for (const expected of REQUEST_KEYS) { + if (expected === key) { + knownKey = true; + break; + } + } + } + if (!knownKey) { + throw new PageRpcError("page RPC request has unexpected fields"); + } + } const version = ownData(value, "version"); const requestId = ownData(value, "requestId"); From 4a4a1271a1aa04b5853ecd74254f41a09e937ece Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:47:41 +0900 Subject: [PATCH 030/103] =?UTF-8?q?=F0=9F=90=9B=20preserve=20zero-valued?= =?UTF-8?q?=20tab=20identities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/server.test.ts | 17 +++++++++++++ packages/message/server.ts | 8 +++---- .../service/service_worker/gm_api/gm_api.ts | 8 +++---- .../service/service_worker/runtime.test.ts | 24 +++++++++++++++++++ src/app/service/service_worker/runtime.ts | 4 ++-- 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/packages/message/server.test.ts b/packages/message/server.test.ts index 3b572c0ac..e10861fb6 100644 --- a/packages/message/server.test.ts +++ b/packages/message/server.test.ts @@ -532,6 +532,23 @@ 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) => { diff --git a/packages/message/server.ts b/packages/message/server.ts index 71f2f8415..511451a73 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -62,8 +62,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, }; @@ -119,8 +119,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, }; 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 095ab64d1..28a051041 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -641,7 +641,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 }); } @@ -654,7 +654,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 }); } @@ -1176,7 +1176,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, }); @@ -1189,7 +1189,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/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 581bf8a67..99e47b59f 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1104,6 +1104,30 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { }); }); + it("preserves tab ID zero for page matching and BFCache reporting", async () => { + const { runtime, mockGroup } = _createRuntimeContext(); + const getScriptsForTab = vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue(null); + const sender = new SenderRuntime({ + ...createSender(false), + tab: { ...(createSender(false).tab as chrome.tabs.Tab), id: 0 } as chrome.tabs.Tab, + }); + + await runtime.pageLoad(undefined, sender); + await runtime.pageShow(undefined, sender); + + expect(getScriptsForTab).toHaveBeenCalledWith({ + url: "https://www.example.com/page", + tabId: 0, + frameId: 0, + incognito: false, + }); + expect(mockGroup.emit).toHaveBeenCalledWith("popupPageRestored", { + tabId: 0, + frameId: 0, + url: "https://www.example.com/page", + }); + }); + // bfcache 还原不会重新注入 content script,页面里的脚本却还活着; // 这条上报只用来重新确认「本页扩展触及得到」,绝不能顺带重放脚本。 it("bfcache 还原上报只广播 popupPageRestored,不重新下发脚本", async () => { diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index bb6784168..7415f97ac 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -1533,7 +1533,7 @@ export class RuntimeService { // 异常加载 return { ok: false }; } - const tabId = chromeSender.tab?.id || -1; + const tabId = chromeSender.tab?.id ?? -1; const frameId = chromeSender.frameId; const incognito = chromeSender.tab?.incognito ?? false; const res = await this.getScriptsForTab({ url, tabId, frameId, incognito }); @@ -1602,7 +1602,7 @@ export class RuntimeService { const url = chromeSender?.url; if (!url) return; this.mq.emit("popupPageRestored", { - tabId: chromeSender.tab?.id || -1, + tabId: chromeSender.tab?.id ?? -1, frameId: chromeSender.frameId, url, }); From a5a9b8491a93452772f0dd44929a1f9d0053baec Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:49:50 +0900 Subject: [PATCH 031/103] =?UTF-8?q?=F0=9F=90=9B=20delete=20falsy=20GM=20va?= =?UTF-8?q?lues=20consistently?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_api.test.ts | 13 +++++++++++++ src/app/service/content/gm_api/gm_api.ts | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) 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 8c76153b2..6dc943b2c 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -974,6 +974,19 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 ); }); + 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"]; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index eb3ea9fb2..09e98d229 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -378,7 +378,7 @@ export default class GMApi extends GM_Base { for (const [key, value] of Object.entries(values)) { let value_ = value; if (value_ === undefined) { - if (valueStore[key]) delete valueStore[key]; + if (Native.objectHasOwn(valueStore, key)) delete valueStore[key]; } else { // 对对象或函数值进行一次转化 if ( From 340fea94abd623ba37ebd92eacde4e5d64f0a98e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:51:17 +0900 Subject: [PATCH 032/103] =?UTF-8?q?=F0=9F=94=92=20avoid=20accessor=20execu?= =?UTF-8?q?tion=20in=20GM=5FsetValues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_api.test.ts | 17 +++++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 12 +++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) 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 6dc943b2c..dbb91e9f7 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -945,6 +945,23 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 ); }); + it("GM_setValues skips accessor fields without invoking them", () => { + 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).not.toHaveBeenCalled(); + expect(script.value).toEqual({ valid: 1 }); + }); + it("拒绝可执行值,且不会把函数写入本地存储或传输层", () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_setValue"]; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 09e98d229..714efcd3a 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -375,7 +375,17 @@ export default class GMApi extends GM_Base { } const valueStore = a.scriptRes.value; const keyValuePairs = [] as [string, REncoded][]; - for (const [key, value] of Object.entries(values)) { + 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 || !("value" in descriptor)) continue; + valueEntries.push([key, descriptor.value]); + } + for (let index = 0; index < valueEntries.length; index += 1) { + const [key, value] = valueEntries[index]; let value_ = value; if (value_ === undefined) { if (Native.objectHasOwn(valueStore, key)) delete valueStore[key]; From 884642ad6a4fb038344b60e88ac8d78d90b58cbd Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:52:39 +0900 Subject: [PATCH 033/103] =?UTF-8?q?=F0=9F=94=92=20preserve=20none=20grant?= =?UTF-8?q?=20capability=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/page_rpc.test.ts | 13 +++++++++++++ src/app/service/content/page_rpc.ts | 6 ++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 16b74a999..f8848649d 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -55,6 +55,19 @@ describe("page GM RPC", () => { 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("allows the internal request name used by the GM.xmlHttpRequest wrapper", () => { const allowed = getPageRpcAllowedAPIs(["GM.xmlHttpRequest"]); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index 089afca36..8c64482ca 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -156,7 +156,9 @@ const API_DEPENDENCIES: Readonly> = { }; export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { - if (grants.some((grant) => grant === "none")) return []; + 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 => { @@ -168,7 +170,7 @@ export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { for (const dependency of API_DEPENDENCIES[candidate] || []) visitGrant(dependency); } }; - for (const grant of grants) visitGrant(grant); + for (let index = 0; index < grants.length; index += 1) visitGrant(grants[index]); const result: string[] = []; allowed.forEach((value) => result.push(value)); return result; From 3d45436f1bfae7ada3b16088fd80e523e18718f5 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:53:50 +0900 Subject: [PATCH 034/103] =?UTF-8?q?=F0=9F=94=92=20capture=20grant=20alias?= =?UTF-8?q?=20intrinsics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/grant.ts | 12 ++++++++---- src/app/service/content/page_rpc.test.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) 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/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index f8848649d..1e478524e 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -68,6 +68,20 @@ describe("page GM RPC", () => { 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("allows the internal request name used by the GM.xmlHttpRequest wrapper", () => { const allowed = getPageRpcAllowedAPIs(["GM.xmlHttpRequest"]); From 764a5505a6d0c973fd64b87abd933ffeffa98490 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:54:56 +0900 Subject: [PATCH 035/103] =?UTF-8?q?=F0=9F=94=92=20isolate=20grant=20capabi?= =?UTF-8?q?lity=20map=20lookups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/page_rpc.test.ts | 4 ++++ src/app/service/content/page_rpc.ts | 10 ++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 1e478524e..9421c8e3c 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -82,6 +82,10 @@ describe("page GM RPC", () => { expect(allowed!).not.toContain("GM_xmlhttpRequest"); }); + it("ignores inherited capability-map properties for unknown grant names", () => { + expect(getPageRpcAllowedAPIs(["constructor", "toString"])).toEqual(["constructor", "toString"]); + }); + it("allows the internal request name used by the GM.xmlHttpRequest wrapper", () => { const allowed = getPageRpcAllowedAPIs(["GM.xmlHttpRequest"]); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index 8c64482ca..e68fa55b8 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -166,8 +166,14 @@ export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { if (visited.has(candidate)) continue; visited.add(candidate); allowed.add(candidate); - for (const api of INTERNAL_APIS_BY_GRANT[candidate] || []) allowed.add(api); - for (const dependency of API_DEPENDENCIES[candidate] || []) visitGrant(dependency); + 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]); + } + if (Native.objectHasOwn(API_DEPENDENCIES, candidate)) { + const dependencies = API_DEPENDENCIES[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]); From 525b2ed9fbdaa7295f0f0ac8aad08f04b7960002 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:57:22 +0900 Subject: [PATCH 036/103] =?UTF-8?q?=F0=9F=94=92=20harden=20capability=20co?= =?UTF-8?q?llection=20construction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/create_context.test.ts | 6 ++++++ src/app/service/content/global.ts | 3 ++- src/app/service/content/page_rpc.test.ts | 15 +++++++++++++++ src/app/service/content/page_rpc.ts | 8 ++++++-- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index b22232d8f..8d6f723e9 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -334,6 +334,7 @@ describe("createContext: capability and lifecycle contract", () => { 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(); @@ -349,6 +350,7 @@ describe("createContext: capability and lifecycle contract", () => { }; }; 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 { @@ -357,6 +359,9 @@ describe("createContext: capability and lifecycle contract", () => { } } 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: "" }, @@ -369,6 +374,7 @@ describe("createContext: capability and lifecycle contract", () => { 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; diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index b1d9701ef..72caf3414 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -12,6 +12,7 @@ const nativeSetHas = Set.prototype.has; const nativeSetDelete = Set.prototype.delete; const nativeSetClear = Set.prototype.clear; const nativeSetForEach = Set.prototype.forEach; +const nativeArrayIsArray = Array.isArray; const nativeMapConstructor = Map; const nativeMapGet = Map.prototype.get; const nativeMapSet = Map.prototype.set; @@ -36,7 +37,7 @@ const NativeSetConstructor = class extends nativeSetConstructor { constructor(values?: readonly T[] | Set | null) { // 不把 values 传给 Set 构造器:它会读取 values 的 @@iterator,而页面可改写该方法。 super(); - if (Array.isArray(values)) { + 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)]); diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 9421c8e3c..9274e5d70 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -86,6 +86,21 @@ describe("page GM RPC", () => { 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"]); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index e68fa55b8..525383497 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -162,7 +162,9 @@ export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { const allowed = new Native.Set(); const visited = new Native.Set(); const visitGrant = (grant: string): void => { - for (const candidate of getGrantCandidates(grant)) { + 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); @@ -178,7 +180,9 @@ export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { }; for (let index = 0; index < grants.length; index += 1) visitGrant(grants[index]); const result: string[] = []; - allowed.forEach((value) => result.push(value)); + allowed.forEach((value) => { + result[result.length] = value; + }); return result; }; From c37446a337259d6686d0291337681d3a324b137c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:59:00 +0900 Subject: [PATCH 037/103] =?UTF-8?q?=F0=9F=94=92=20seal=20page=20RPC=20coll?= =?UTF-8?q?ection=20iteration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 2 ++ src/app/service/content/page_rpc.ts | 12 +++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 72caf3414..cd1c0942e 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -12,6 +12,7 @@ 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; @@ -49,6 +50,7 @@ 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 {}; diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index 525383497..4106e0fdb 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -205,8 +205,7 @@ const assertDataOnly = (value: unknown, seen: Set): void => { // 先检查自有数据描述符,再做 structuredClone;这样页面 getter/Proxy 不会在 broker 中执行。 if (value === null || typeof value !== "object") return; // Blob 的内部槽由浏览器管理,不能把其 symbol/accessor 细节当作 DTO 字段遍历。 - if (typeof Blob === "function" && (value instanceof Blob || nativeObjectToString.call(value) === "[object Blob]")) - return; + if (nativeBlob && (value instanceof nativeBlob || nativeObjectToString.call(value) === "[object Blob]")) return; if (seen.has(value)) return; seen.add(value); @@ -216,7 +215,8 @@ const assertDataOnly = (value: unknown, seen: Set): void => { } catch { throw new PageRpcError("page RPC value cannot be inspected"); } - for (const key of keys) { + 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); @@ -338,10 +338,12 @@ export const validatePageGMRequest = (value: unknown, registry: PageRpcRegistry) if (keys.length !== REQUEST_KEYS.length) { throw new PageRpcError("page RPC request has unexpected fields"); } - for (const key of keys) { + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; let knownKey = false; if (typeof key === "string") { - for (const expected of REQUEST_KEYS) { + for (let expectedIndex = 0; expectedIndex < REQUEST_KEYS.length; expectedIndex += 1) { + const expected = REQUEST_KEYS[expectedIndex]; if (expected === key) { knownKey = true; break; From 18255a04bb3eca19b7734e1c3e186cdc4a17cee9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:01:09 +0900 Subject: [PATCH 038/103] =?UTF-8?q?=F0=9F=90=9B=20honor=20early=20GM=20XHR?= =?UTF-8?q?=20aborts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.test.ts | 29 +++++++++++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 11 +++++++ 2 files changed, 40 insertions(+) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index 8daeb7ea5..44d3f1015 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -100,4 +100,33 @@ describe("GM_xmlhttpRequest callback cleanup", () => { 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)); + }); }); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index e2065f3bf..e1e6850b3 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -189,6 +189,7 @@ export function GM_xmlhttpRequest( isDownload: boolean = false ) { let reqDone = false; + let abortRequested = false; if (a.isInvalidContext()) { return { retPromise: requirePromise ? Promise.reject("GM_xmlhttpRequest: Invalid Context") : null, @@ -743,11 +744,21 @@ export function GM_xmlhttpRequest( }; connect?.onMessage((msgData) => onMessageHandler?.(msgData)); + if (abortRequested && !reqDone) { + doAbort?.({ + error: "aborted", + responseHeaders: "", + readyState: 0, + status: 0, + statusText: "", + } as TXhrCallBackArg); + } })(); // 由于需要同步返回一个abort,但是一些操作是异步的,所以需要在这里处理 return { retPromise, abort: () => { + abortRequested = true; if (connect) { connect.disconnect(true); // 断开连结(容忍已断开) connect = null; From ad61560ba8c18a756624790b10b72c0ed2a20d4c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:03:59 +0900 Subject: [PATCH 039/103] =?UTF-8?q?=F0=9F=90=9B=20settle=20GM=20XHR=20conn?= =?UTF-8?q?ection=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.test.ts | 23 +++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 39 ++++++++++++------- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index 44d3f1015..e3692ad39 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -129,4 +129,27 @@ describe("GM_xmlhttpRequest callback cleanup", () => { 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); + }); }); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index e1e6850b3..09f8cd7a0 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -196,8 +196,8 @@ export function GM_xmlhttpRequest( 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; @@ -278,19 +278,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 }; + invokeXHRCallback("onerror", details.onerror, response); + retPromiseReject?.(message); + invokeXHRCallback("onloadend", details.onloadend, response); + return; } - param = null; // GC - connect = await connectMessage; const resultTexts = [] as string[]; // 函数参考清掉后,变数会被GC const resultBuffers = [] as Uint8Array[]; // 函数参考清掉后,变数会被GC From 68f4d3c1d7c72efb705fe4b0204460e77b928f15 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:05:56 +0900 Subject: [PATCH 040/103] =?UTF-8?q?=F0=9F=90=9B=20settle=20GM=20XHR=20setu?= =?UTF-8?q?p=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.test.ts | 25 +++++++++++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 10 +++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index e3692ad39..945df925c 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -152,4 +152,29 @@ describe("GM_xmlhttpRequest callback cleanup", () => { 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); + }); }); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 09f8cd7a0..32c7dd940 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -764,7 +764,15 @@ export function GM_xmlhttpRequest( statusText: "", } as TXhrCallBackArg); } - })(); + })().catch((error) => { + if (reqDone) return; + reqDone = true; + const message = error instanceof Error ? error.message : `${error}`; + const response = { readyState: ReadyStateCode.DONE, error: message }; + invokeXHRCallback("onerror", details.onerror, response); + retPromiseReject?.(message); + invokeXHRCallback("onloadend", details.onloadend, response); + }); // 由于需要同步返回一个abort,但是一些操作是异步的,所以需要在这里处理 return { retPromise, From f4edd72e33e37f2f0ba83cb040eb2b2e842d9172 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:08:08 +0900 Subject: [PATCH 041/103] =?UTF-8?q?=F0=9F=94=92=20discard=20stale=20page?= =?UTF-8?q?=20load=20bindings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/runtime.test.ts | 34 +++++++++++++++++++ src/app/service/service_worker/runtime.ts | 19 +++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 99e47b59f..8168b8257 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1128,6 +1128,40 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { }); }); + it("discards an older same-frame pageLoad response that resolves after a newer one", async () => { + const { runtime } = _createRuntimeContext(); + const firstScript = _createScriptRunResource(_createMockScript({ uuid: "first-page-load" })); + const secondScript = _createScriptRunResource(_createMockScript({ uuid: "second-page-load" })); + const loadResult = (script: ScriptRunResource) => + ({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + }) as unknown as Awaited>; + let resolveFirst!: (result: Awaited>) => void; + let resolveSecond!: (result: Awaited>) => void; + vi.spyOn(runtime, "getScriptsForTab") + .mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve))) + .mockImplementationOnce(() => new Promise((resolve) => (resolveSecond = resolve))); + const sender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const firstLoad = runtime.pageLoad(undefined, sender); + const secondLoad = runtime.pageLoad(undefined, sender); + resolveSecond(loadResult(secondScript)); + const second = await secondLoad; + expect(second.ok).toBe(true); + const secondHandle = second.ok ? second.injectScriptList[0].executionHandle : undefined; + + resolveFirst(loadResult(firstScript)); + await expect(firstLoad).resolves.toEqual({ ok: false }); + expect(runtime.resolvePageExecutionBinding(secondHandle!, sender)).toBeDefined(); + }); + // bfcache 还原不会重新注入 content script,页面里的脚本却还活着; // 这条上报只用来重新确认「本页扩展触及得到」,绝不能顺带重放脚本。 it("bfcache 还原上报只广播 popupPageRestored,不重新下发脚本", async () => { diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 7415f97ac..1e7578be7 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -160,6 +160,8 @@ export class RuntimeService { documentId?: string; } >(); + // Only the newest load for a tab/frame/environment may issue bindings; navigation can resolve old requests late. + private readonly pageLoadSequences = new Map(); getGMApi(): GMApi | undefined { return this.gmApi; @@ -213,6 +215,19 @@ export class RuntimeService { for (const [token, bootstrap] of this.userScriptBootstraps) { if (bootstrap.tabId === tabId) this.userScriptBootstraps.delete(token); } + const prefix = `${tabId}:`; + for (const key of this.pageLoadSequences.keys()) { + if (key.startsWith(prefix)) this.pageLoadSequences.delete(key); + } + } + + private beginPageLoadSequence(sender: IGetSender, envTag: "it" | "ct" | undefined): [string, number] | undefined { + const tabId = sender.getSender()?.tab?.id; + if (typeof tabId !== "number") return undefined; + const key = `${tabId}:${sender.getSender()?.frameId ?? -1}:${envTag ?? "it"}`; + const sequence = (this.pageLoadSequences.get(key) ?? 0) + 1; + this.pageLoadSequences.set(key, sequence); + return [key, sequence]; } private userScriptConnectionKey(tabId: number, frameId?: number, documentId?: string): string { @@ -1536,7 +1551,11 @@ export class RuntimeService { const tabId = chromeSender.tab?.id ?? -1; const frameId = chromeSender.frameId; const incognito = chromeSender.tab?.incognito ?? false; + const pageLoadSequence = this.beginPageLoadSequence(sender, data?.envTag); const res = await this.getScriptsForTab({ url, tabId, frameId, incognito }); + if (pageLoadSequence && this.pageLoadSequences.get(pageLoadSequence[0]) !== pageLoadSequence[1]) { + return { ok: false }; + } // 即使新 URL 没有匹配脚本也要退休旧绑定,关闭不提供 documentId 的浏览器复用窗口。 this.revokePageBindings(sender, data?.envTag); From 842d5eedc86d3582bf6cfa1441f5d866fc274d2e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:10:22 +0900 Subject: [PATCH 042/103] =?UTF-8?q?=F0=9F=94=92=20harden=20GM=20value=20tr?= =?UTF-8?q?ansport=20collection=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_api.test.ts | 24 +++++++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) 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 dbb91e9f7..6768c66de 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -962,6 +962,30 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 expect(script.value).toEqual({ valid: 1 }); }); + 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("拒绝可执行值,且不会把函数写入本地存储或传输层", () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_setValue"]; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 714efcd3a..f5b29b929 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -382,7 +382,7 @@ export default class GMApi extends GM_Base { if (typeof key !== "string") continue; const descriptor = Native.objectGetOwnPropertyDescriptor(values, key); if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; - valueEntries.push([key, descriptor.value]); + valueEntries[valueEntries.length] = [key, descriptor.value]; } for (let index = 0; index < valueEntries.length; index += 1) { const [key, value] = valueEntries[index]; @@ -402,7 +402,7 @@ export default class GMApi extends GM_Base { valueStore[key] = value_; } // 避免undefined 等空值流失,先进行映射处理 - keyValuePairs.push([key, encodeRValue(value_)]); + keyValuePairs[keyValuePairs.length] = [key, encodeRValue(value_)]; } a.sendMessage("GM_setValues", [id, keyValuePairs]); return id; From fa26a1ba66747c58fc26136ff0448af55551819c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:15:49 +0900 Subject: [PATCH 043/103] =?UTF-8?q?=F0=9F=94=92=20bind=20page=20execution?= =?UTF-8?q?=20handles=20to=20navigation=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/runtime.test.ts | 31 +++++++++++++++++++ src/app/service/service_worker/runtime.ts | 14 +++++++-- src/app/service/service_worker/types.ts | 2 ++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 8168b8257..b138ed7f1 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1231,6 +1231,37 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { expect(runtime.resolvePageExecutionBinding(secondHandle!, secondSender)).toBeUndefined(); }); + it("rejects a stale URL when the browser omits documentId", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "url-bound-script", metadata: { grant: ["GM_getTab"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + const initialSender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const load = await runtime.pageLoad(undefined, initialSender); + expect(load.ok).toBe(true); + if (!load.ok) return; + const handle = load.injectScriptList[0].executionHandle; + expect(runtime.resolvePageExecutionBinding(handle!, initialSender)).toBeDefined(); + + const navigatedSender = new SenderRuntime({ + url: "https://www.example.com/next", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + expect(runtime.resolvePageExecutionBinding(handle!, navigatedSender)).toBeUndefined(); + }); + it("content USER_SCRIPT 的 pageLoad 只轮换 content 绑定", async () => { const { runtime } = _createRuntimeContext(); const inject = _createScriptRunResource(_createMockScript({ uuid: "inject-script" })); diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 1e7578be7..cef58b4bc 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -371,7 +371,10 @@ export class RuntimeService { ): ServiceWorkerExecutionBinding { const source = sender.getSender(); const tabId = source?.tab?.id; - if (typeof tabId !== "number") throw new Error("page execution binding requires a tab"); + const url = source?.url; + if (typeof tabId !== "number" || typeof url !== "string" || url.length === 0) { + throw new Error("page execution binding requires a tab and URL"); + } // 每次 pageLoad 都签发新句柄和 runFlag;它们共同绑定当前文档的授权生命周期。 const handle = uuidv4(); const binding = { @@ -379,6 +382,7 @@ export class RuntimeService { uuid, envTag, runFlag: uuidv4(), + url, tabId, frameId: source?.frameId, documentId: source?.documentId, @@ -393,7 +397,13 @@ export class RuntimeService { resolvePageExecutionBinding(handle: string, sender: IGetSender): ServiceWorkerExecutionBinding | undefined { const binding = this.pageExecutionBindings.get(handle); const source = sender.getSender(); - if (!binding || !source?.tab || source.tab.id !== binding.tabId || source.frameId !== binding.frameId) + if ( + !binding || + !source?.tab || + source.tab.id !== binding.tabId || + source.frameId !== binding.frameId || + (binding.documentId === undefined && source.url !== binding.url) + ) return undefined; if (binding.documentId !== undefined && source.documentId !== binding.documentId) return undefined; return binding; diff --git a/src/app/service/service_worker/types.ts b/src/app/service/service_worker/types.ts index ab8cd169c..00397d794 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -60,6 +60,8 @@ export type ServiceWorkerExecutionBinding = { uuid: string; envTag: "it" | "ct"; runFlag: string; + /** The URL observed when this execution binding was issued. */ + url: string; tabId: number; frameId?: number; documentId?: string; From 86193c06f59f5d92b6dc95ff7b1a1a20d9097548 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:19:34 +0900 Subject: [PATCH 044/103] =?UTF-8?q?=F0=9F=94=92=20hide=20CAT=20conversatio?= =?UTF-8?q?n=20state=20behind=20private=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/gm_api/cat_agent.test.ts | 17 ++++++ src/app/service/content/gm_api/cat_agent.ts | 58 +++++++++---------- 2 files changed, 46 insertions(+), 29 deletions(-) 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 6bec37b49..18dfa728f 100644 --- a/src/app/service/content/gm_api/cat_agent.test.ts +++ b/src/app/service/content/gm_api/cat_agent.test.ts @@ -73,6 +73,23 @@ describe("ConversationInstance 命令机制", () => { 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 () => { diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index 06126d5cd..4c6911def 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -97,10 +97,10 @@ export class ConversationInstance { // 私有状态包含跨 context 的发送函数;用 private field 隐藏它,避免脚本读取或替换传输入口。 #state: ConversationPrivateState; - public toolHandlers: Map = new Map(); - public toolDefs: ToolDefinition[] = []; - public ephemeral: boolean; - public messageHistory: Array<{ + #toolHandlers: Map = new Map(); + #toolDefs: ToolDefinition[] = []; + #ephemeral: boolean; + #messageHistory: Array<{ role: MessageRole; content: MessageContent; toolCallId?: string; @@ -130,11 +130,11 @@ export class ConversationInstance { background: background || false, }; this.#state = state; - this.ephemeral = ephemeral || false; + 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 }); } } @@ -175,8 +175,8 @@ export class ConversationInstance { 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 建立流式连接 @@ -194,9 +194,9 @@ export class ConversationInstance { if (state.background) { connectParams.background = true; } - if (this.ephemeral) { + if (this.#ephemeral) { connectParams.ephemeral = true; - connectParams.messages = this.messageHistory; + connectParams.messages = this.#messageHistory; connectParams.system = state.systemPrompt; connectParams.modelId = state.conv.modelId; } @@ -207,8 +207,8 @@ export class ConversationInstance { // 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; @@ -243,8 +243,8 @@ export class ConversationInstance { 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 = { @@ -261,9 +261,9 @@ export class ConversationInstance { if (state.background) { connectParams.background = true; } - if (this.ephemeral) { + if (this.#ephemeral) { connectParams.ephemeral = true; - connectParams.messages = this.messageHistory; + connectParams.messages = this.#messageHistory; connectParams.system = state.systemPrompt; connectParams.modelId = state.conv.modelId; } @@ -273,7 +273,7 @@ export class ConversationInstance { // 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; } @@ -304,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, @@ -323,9 +323,9 @@ export class ConversationInstance { // 获取对话历史 async getMessages(): Promise { const state = this.#state; - if (this.ephemeral) { + if (this.#ephemeral) { // ephemeral 模式:从内存历史转换为 ChatMessage 格式 - return this.messageHistory.map((msg, idx) => ({ + return this.#messageHistory.map((msg, idx) => ({ id: `ephemeral-${idx}`, conversationId: state.conv.id, role: msg.role, @@ -348,8 +348,8 @@ export class ConversationInstance { // 清空对话消息历史 async clear(): Promise { - if (this.ephemeral) { - this.messageHistory = []; + if (this.#ephemeral) { + this.#messageHistory = []; return; } const state = this.#state; @@ -405,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, @@ -799,13 +799,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, From 0bc23a6c48a1a63152351b2f3a96bc97642744f4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:21:01 +0900 Subject: [PATCH 045/103] =?UTF-8?q?=F0=9F=94=92=20close=20GM=20XHR=20conne?= =?UTF-8?q?ctions=20on=20setup=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.test.ts | 32 +++++++++++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 3 ++ 2 files changed, 35 insertions(+) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index 945df925c..c0b84ed51 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -177,4 +177,36 @@ describe("GM_xmlhttpRequest callback cleanup", () => { 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); + }); }); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 32c7dd940..f009e88d5 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -765,6 +765,9 @@ export function GM_xmlhttpRequest( } 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}`; From 35b6766621e32c4dcff798f00b5e877f37c25cd1 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:22:46 +0900 Subject: [PATCH 046/103] =?UTF-8?q?=F0=9F=94=92=20avoid=20accessor=20execu?= =?UTF-8?q?tion=20in=20GM=20DOM=20attributes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_api.ts | 28 ++++++++++++------- .../gm_api/related_target_lifecycle.test.ts | 19 ++++++++++++- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index f5b29b929..e12ba06c3 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -905,16 +905,24 @@ 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; + } } } 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 e2af52e95..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"; @@ -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(); + } + }); }); From cb49bf8a3c954cfa9bfc91d98e8efe07e602bd51 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:24:51 +0900 Subject: [PATCH 047/103] =?UTF-8?q?=F0=9F=94=92=20copy=20GM=20menu=20optio?= =?UTF-8?q?ns=20without=20accessors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_api.test.ts | 33 +++++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 37 ++++++++++++++----- 2 files changed, 60 insertions(+), 10 deletions(-) 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 6768c66de..648e19daa 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -551,6 +551,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)); + + 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"]; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index e12ba06c3..00b15a511 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -770,15 +770,33 @@ export default class GMApi extends GM_Base { 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 = Native.objectCreate(null) as Record; + const keys = Native.reflectOwnKeys(options_or_accessKey); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key !== "string") continue; + const descriptor = Native.objectGetOwnPropertyDescriptor(options_or_accessKey, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; + safeOptions[key] = descriptor.value; + } + 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; } @@ -797,8 +815,7 @@ 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; + 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 注释 From ef3adf5d55f632892d5f12a889a0115c87f07474 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:27:16 +0900 Subject: [PATCH 048/103] =?UTF-8?q?=F0=9F=94=92=20copy=20GM=20XHR=20header?= =?UTF-8?q?s=20without=20accessors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.test.ts | 23 +++++++++++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 19 +++++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index c0b84ed51..c4bca8065 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -209,4 +209,27 @@ describe("GM_xmlhttpRequest callback cleanup", () => { 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 f009e88d5..f06988b5d 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -207,11 +207,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; } } } @@ -224,8 +233,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, From 5acba1ba9fb64b0d010aeecb7c1be069e84677ce Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:29:53 +0900 Subject: [PATCH 049/103] =?UTF-8?q?=F0=9F=94=92=20copy=20GM=20tab=20option?= =?UTF-8?q?s=20without=20accessors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_api.test.ts | 19 ++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 25 +++++++++++-------- 2 files changed, 34 insertions(+), 10 deletions(-) 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 648e19daa..f723d20f3 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -1493,6 +1493,25 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 }); }); +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("@grant GM_download", () => { it("空 url 应触发 onerror 而不是发起下载(GM_download)", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 00b15a511..4103c6473 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -60,6 +60,19 @@ let valChangeCounterId = 0; let valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; +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; +}; + // 回调表不暴露 Map 原型,避免页面改写 Map 方法后影响值更新确认。 const valueChangePromiseMap: Record void> = Object.create(null); @@ -777,15 +790,7 @@ export default class GMApi extends GM_Base { if (typeof options_or_accessKey === "string") { options = { accessKey: options_or_accessKey }; } else if (optionObject) { - const safeOptions = Native.objectCreate(null) as Record; - const keys = Native.reflectOwnKeys(options_or_accessKey); - for (let index = 0; index < keys.length; index += 1) { - const key = keys[index]; - if (typeof key !== "string") continue; - const descriptor = Native.objectGetOwnPropertyDescriptor(options_or_accessKey, key); - if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; - safeOptions[key] = descriptor.value; - } + 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 操作) @@ -1512,7 +1517,7 @@ export default class GMApi extends GM_Base { 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 优先 ) From 13d6645a712464f044689178d2ea1f1ad3e907df Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:31:05 +0900 Subject: [PATCH 050/103] =?UTF-8?q?=F0=9F=94=92=20copy=20notification=20de?= =?UTF-8?q?tails=20without=20accessors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_api.test.ts | 20 +++++++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) 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 f723d20f3..932a02b57 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -1512,6 +1512,26 @@ describe("GM_openInTab DTO", () => { }); }); +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; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 4103c6473..1b9b94da0 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -1384,7 +1384,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; From 6bf76fd90c712c696da70ed208ec05a901e5f964 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:36:04 +0900 Subject: [PATCH 051/103] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20reduce=20USER=5FSC?= =?UTF-8?q?RIPT=20callback=20dispatch=20scans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/runtime.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index cef58b4bc..fff568d06 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -334,9 +334,10 @@ export class RuntimeService { continue; } let bindingMatches = false; - for (const binding of this.pageExecutionBindings.values()) { + for (const handle of entry.handles) { + const binding = this.pageExecutionBindings.get(handle); if ( - entry.handles.has(binding.handle) && + binding && ((targetUuid !== undefined && targetUuid === binding.uuid) || (targetStorageName !== undefined && targetStorageName === binding.storageName)) ) { From 714008ea8b3477ca64233bacb773346a13071cd5 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:38:15 +0900 Subject: [PATCH 052/103] =?UTF-8?q?=F0=9F=94=92=20prune=20revoked=20USER?= =?UTF-8?q?=5FSCRIPT=20connections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/runtime.test.ts | 4 ++++ src/app/service/service_worker/runtime.ts | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index b138ed7f1..51370e6c2 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1420,6 +1420,10 @@ describe("USER_SCRIPT native callbacks", () => { storageName: "unrelated-storage", }); expect(sendMessage).not.toHaveBeenCalled(); + + (runtime as any).revokePageBindingsForScript("content-script"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + expect((runtime as any).userScriptConnections.size).toBe(0); }); }); diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index fff568d06..70d119832 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -358,6 +358,17 @@ export class RuntimeService { for (const [handle, binding] of this.pageExecutionBindings) { if (binding.uuid === uuid) this.pageExecutionBindings.delete(handle); } + for (const [key, entry] of this.userScriptConnections) { + // 脚本撤销后同步裁剪句柄集;没有任何有效句柄的端口必须关闭,避免残留授权接收器。 + for (const handle of entry.handles) { + const binding = this.pageExecutionBindings.get(handle); + if (!binding || binding.uuid === uuid) entry.handles.delete(handle); + } + if (entry.handles.size === 0) { + entry.connection.disconnect(true); + this.userScriptConnections.delete(key); + } + } for (const [token, bootstrap] of this.userScriptBootstraps) { if (bootstrap.scripts.some((script) => script.uuid === uuid)) this.userScriptBootstraps.delete(token); } From 63b01b380ac31ee5a2b72d04d225c83fc072b2d8 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:54:45 +0900 Subject: [PATCH 053/103] =?UTF-8?q?=F0=9F=94=92=20harden=20USER=5FSCRIPT?= =?UTF-8?q?=20reconnect=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/server.test.ts | 19 ++++ packages/message/server.ts | 4 +- .../content/user_script_connection.test.ts | 35 ++++++- .../service/content/user_script_connection.ts | 23 ++++- .../service/service_worker/runtime.test.ts | 27 ++++++ src/app/service/service_worker/runtime.ts | 89 +++++++++++++++--- src/content.ts | 92 +++++++++++-------- 7 files changed, 237 insertions(+), 52 deletions(-) diff --git a/packages/message/server.test.ts b/packages/message/server.test.ts index e10861fb6..7da2268ee 100644 --- a/packages/message/server.test.ts +++ b/packages/message/server.test.ts @@ -639,6 +639,25 @@ describe("Server", () => { 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 功能测试", () => { diff --git a/packages/message/server.ts b/packages/message/server.ts index 511451a73..70a34d28d 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -269,11 +269,11 @@ export class Server { } private isUserScriptActionAllowed(action: string, origin: MessageOrigin | undefined, isConnect: boolean): boolean { - // USER_SCRIPT 只应取得注册握手和 GM RPC;其他 serviceWorker API 仍只接受扩展通道。 + // 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/gmApi" || action === "runtime/reconnectUserScript"; } } diff --git a/src/app/service/content/user_script_connection.test.ts b/src/app/service/content/user_script_connection.test.ts index 170e5fd2f..d1174e86e 100644 --- a/src/app/service/content/user_script_connection.test.ts +++ b/src/app/service/content/user_script_connection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { Message, MessageConnect, TMessage } from "@Packages/message/types"; -import { connectUserScriptChannel } from "./user_script_connection"; +import { connectUserScriptChannel, requestUserScriptReconnect } from "./user_script_connection"; const makeConnection = (): MessageConnect => ({ onMessage: vi.fn(), @@ -40,4 +40,37 @@ describe("connectUserScriptChannel", () => { await expect(connectUserScriptChannel(message, "bootstrap-token", vi.fn())).resolves.toBeUndefined(); expect(message.connect).not.toHaveBeenCalled(); }); + + 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 index c11a2cd80..7aee3cf15 100644 --- a/src/app/service/content/user_script_connection.ts +++ b/src/app/service/content/user_script_connection.ts @@ -1,6 +1,12 @@ 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 监听,再建立连接;浏览器可能立即投递端口, @@ -9,7 +15,8 @@ type UserScriptPacketHandler = (connection: MessageConnect, packet: TMessage) => export async function connectUserScriptChannel( message: Message, bootstrapToken: string, - onPacket: UserScriptPacketHandler + onPacket: UserScriptPacketHandler, + onDisconnect?: UserScriptDisconnectHandler ): Promise { const enabled = await message.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" } as unknown as TMessage); if (enabled === false) return undefined; @@ -18,6 +25,20 @@ export async function connectUserScriptChannel( data: { world: "USER_SCRIPT", bootstrapToken }, }); 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/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 51370e6c2..51ba5b0be 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1421,6 +1421,33 @@ describe("USER_SCRIPT native callbacks", () => { }); expect(sendMessage).not.toHaveBeenCalled(); + const reconnect = runtime.reconnectUserScript( + { reconnectToken: bootstrapToken }, + { + getType: () => 4, + isType: (type: number) => type === 4, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => undefined, + getConnectOrigin: () => "userScript" as const, + } + ); + expect(reconnect).toEqual({ bootstrapToken: expect.any(String) }); + expect((runtime as any).userScriptBootstraps.size).toBe(1); + expect( + runtime.reconnectUserScript( + { reconnectToken: bootstrapToken }, + { + getType: () => 4, + isType: (type: number) => type === 4, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => undefined, + getConnectOrigin: () => "userScript" as const, + } + ) + ).toBeUndefined(); + (runtime as any).revokePageBindingsForScript("content-script"); expect(connection.disconnect).toHaveBeenCalledWith(true); expect((runtime as any).userScriptConnections.size).toBe(0); diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 70d119832..b65af7f5b 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -131,6 +131,16 @@ export type TScriptsForTab = { scriptmenus: ScriptMenu[]; } | null; +type UserScriptSession = { + scripts: TScriptInfo[]; + envInfo: GMInfoEnv; + extensionOrigin?: ExtensionOrigin; + reconnectToken: string; + tabId: number; + frameId?: number; + documentId?: string; +}; + const bgScriptStorageNames = new Set(); // For Firefox, StorageArea.setAccessLevel is not implemented. @@ -149,17 +159,9 @@ export class RuntimeService { string, { connection: MessageConnect; handles: Set; tabId: number; frameId?: number; documentId?: string } >(); - private readonly userScriptBootstraps = new Map< - string, - { - scripts: TScriptInfo[]; - envInfo: GMInfoEnv; - extensionOrigin?: ExtensionOrigin; - tabId: number; - frameId?: number; - documentId?: string; - } - >(); + private readonly userScriptBootstraps = new Map(); + // 连接断开后保留当前文档的已验证资料,供 USER_SCRIPT 通过原生消息重连;导航或脚本撤销会同步清除。 + private readonly userScriptSessions = new Map(); // Only the newest load for a tab/frame/environment may issue bindings; navigation can resolve old requests late. private readonly pageLoadSequences = new Map(); @@ -192,6 +194,7 @@ export class RuntimeService { ) { entry.connection.disconnect(true); this.userScriptConnections.delete(key); + this.userScriptSessions.delete(key); } } } @@ -210,11 +213,15 @@ export class RuntimeService { if (entry.tabId === tabId) { entry.connection.disconnect(true); this.userScriptConnections.delete(key); + this.userScriptSessions.delete(key); } } for (const [token, bootstrap] of this.userScriptBootstraps) { if (bootstrap.tabId === tabId) this.userScriptBootstraps.delete(token); } + for (const [key, session] of this.userScriptSessions) { + if (session.tabId === tabId) this.userScriptSessions.delete(key); + } const prefix = `${tabId}:`; for (const key of this.pageLoadSequences.keys()) { if (key.startsWith(prefix)) this.pageLoadSequences.delete(key); @@ -279,10 +286,11 @@ export class RuntimeService { handles.add(handle); } if (handles.size === 0) return false; - this.userScriptBootstraps.delete(handshake.bootstrapToken); const frameId = source.frameId; const documentId = source.documentId; const key = this.userScriptConnectionKey(tabId, frameId, documentId); + this.userScriptSessions.set(key, bootstrap); + this.userScriptBootstraps.delete(handshake.bootstrapToken); const previous = this.userScriptConnections.get(key); if (previous) previous.connection.disconnect(true); const entry = { connection, handles, tabId, frameId, documentId }; @@ -309,6 +317,7 @@ export class RuntimeService { scripts: bootstrap.scripts, envInfo: bootstrap.envInfo, extensionOrigin: bootstrap.extensionOrigin, + reconnectToken: bootstrap.reconnectToken, }, }); } catch { @@ -318,6 +327,56 @@ export class RuntimeService { return true; } + reconnectUserScript(data: unknown, sender: IGetSender): { bootstrapToken: string } | undefined { + if (!sender.isType(GetSenderType.RUNTIME) || sender.getConnectOrigin?.() !== "userScript") { + return undefined; + } + if ( + data === null || + typeof data !== "object" || + Object.keys(data).length !== 1 || + typeof (data as { reconnectToken?: unknown }).reconnectToken !== "string" || + (data as { reconnectToken: string }).reconnectToken.length === 0 || + (data as { reconnectToken: string }).reconnectToken.length > 256 + ) { + return undefined; + } + const source = sender.getSender(); + const tabId = source?.tab?.id; + if (!source || typeof tabId !== "number") return undefined; + const key = this.userScriptConnectionKey(tabId, source.frameId, source.documentId); + const session = this.userScriptSessions.get(key); + if (!session || (data as { reconnectToken: string }).reconnectToken !== session.reconnectToken) return undefined; + for (const script of session.scripts) { + const handle = script.executionHandle; + const binding = typeof handle === "string" ? this.pageExecutionBindings.get(handle) : undefined; + if ( + !binding || + binding.envTag !== "ct" || + binding.tabId !== tabId || + binding.frameId !== source.frameId || + binding.documentId !== source.documentId + ) { + this.userScriptSessions.delete(key); + return undefined; + } + } + const bootstrapToken = uuidv4(); + const nextSession = { ...session, reconnectToken: uuidv4() }; + for (const [token, bootstrap] of this.userScriptBootstraps) { + if ( + bootstrap.tabId === session.tabId && + bootstrap.frameId === session.frameId && + bootstrap.documentId === session.documentId + ) { + this.userScriptBootstraps.delete(token); + } + } + this.userScriptSessions.set(key, nextSession); + this.userScriptBootstraps.set(bootstrapToken, nextSession); + return { bootstrapToken }; + } + private sendUserScriptMessage(to: ExtMessageSender | undefined, action: string, data: unknown): void { const dataRecord = typeof data === "object" && data !== null ? (data as { uuid?: unknown; storageName?: unknown }) : undefined; @@ -372,6 +431,9 @@ export class RuntimeService { for (const [token, bootstrap] of this.userScriptBootstraps) { if (bootstrap.scripts.some((script) => script.uuid === uuid)) this.userScriptBootstraps.delete(token); } + for (const [key, session] of this.userScriptSessions) { + if (session.scripts.some((script) => script.uuid === uuid)) this.userScriptSessions.delete(key); + } } private issuePageBinding( @@ -822,6 +884,7 @@ export class RuntimeService { this.group.on("pageLoad", this.pageLoad.bind(this)); this.group.on("pageShow", this.pageShow.bind(this)); this.group.on("registerUserScript", this.registerUserScriptConnection.bind(this)); + this.group.on("reconnectUserScript", this.reconnectUserScript.bind(this)); // 监听脚本开启 this.mq.subscribe("enableScripts", async (data) => { @@ -1134,6 +1197,7 @@ export class RuntimeService { // 取消脚本注册 async unregisterUserscripts() { this.pageExecutionBindings.clear(); + this.userScriptSessions.clear(); for (const [key, entry] of this.userScriptConnections) { entry.connection.disconnect(true); this.userScriptConnections.delete(key); @@ -1615,6 +1679,7 @@ export class RuntimeService { scripts: contentScriptList, envInfo: res.envInfo, extensionOrigin: getExtensionOrigin(), + reconnectToken: userScriptBootstrapToken, tabId, frameId, documentId: chromeSender.documentId, diff --git a/src/content.ts b/src/content.ts index cba8cb9ff..8eb1713f6 100644 --- a/src/content.ts +++ b/src/content.ts @@ -4,12 +4,12 @@ import { ExtensionMessage } from "@Packages/message/extension_message"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; import { Server } from "@Packages/message/server"; import { ScriptExecutor } from "./app/service/content/script_executor"; -import type { Message } from "@Packages/message/types"; +import type { Message, MessageConnect, TMessage } from "@Packages/message/types"; import { getEventFlag } from "@Packages/message/common"; import { ScriptRuntime } from "./app/service/content/script_runtime"; import { ScriptEnvTag } from "@Packages/message/consts"; import { type TExtensionEnv } from "./app/service/extension/extension_env"; -import { connectUserScriptChannel } from "./app/service/content/user_script_connection"; +import { connectUserScriptChannel, requestUserScriptReconnect } from "./app/service/content/user_script_connection"; import type { TScriptInfo } from "./app/repo/scripts"; import type { GMInfoEnv } from "./app/service/content/types"; import { setPageRpcExtensionOrigin, type ExtensionOrigin } from "./app/service/content/page_rpc"; @@ -39,44 +39,64 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde const scriptExecutor = new ScriptExecutor(msg, domContentMsg, "serviceWorker"); const runtime = new ScriptRuntime(scriptEnvTag, server, msg, scriptExecutor, extensionEnv); runtime.contentInit(domServer, domMsg); + let reconnecting = false; + let reconnectToken: string | undefined; + const handleUserScriptPacket = (_connection: MessageConnect, packet: TMessage) => { + if (packet.action === "content/pageLoad") { + const packetData = packet.data as { + scripts?: TScriptInfo[]; + envInfo?: GMInfoEnv; + extensionOrigin?: ExtensionOrigin; + reconnectToken?: unknown; + }; + if (!packetData || !Array.isArray(packetData.scripts) || packetData.scripts.length === 0 || !packetData.envInfo) { + return; + } + for (let i = 0; i < packetData.scripts.length; i += 1) { + const script = packetData.scripts[i]; + if ( + !script || + typeof script !== "object" || + script.executionEnvTag !== scriptEnvTag || + typeof script.executionHandle !== "string" + ) { + return; + } + } + if (typeof packetData.reconnectToken === "string" && packetData.reconnectToken.length > 0) { + reconnectToken = packetData.reconnectToken; + } + setPageRpcExtensionOrigin(packetData.extensionOrigin); + runtime.startScripts(packetData.scripts, packetData.envInfo); + } else if (packet.action === "content/runtime/valueUpdate") { + scriptExecutor.valueUpdate(packet.data as any); + } else if (packet.action === "content/runtime/emitEvent") { + scriptExecutor.emitEvent(packet.data as any); + } + }; + const openUserScriptChannel = async (bootstrapToken: string): Promise => { + try { + await connectUserScriptChannel(msg, bootstrapToken, handleUserScriptPacket, (isSelfDisconnected) => { + if (isSelfDisconnected || reconnecting) return; + if (!reconnectToken) return; + reconnecting = true; + void requestUserScriptReconnect(msg, reconnectToken) + .then((nextToken) => (nextToken ? openUserScriptChannel(nextToken) : undefined)) + .catch((error) => logger.logger().debug("USER_SCRIPT reconnect failed", { error: String(error) })) + .finally(() => { + reconnecting = false; + }); + }); + } catch (error) { + logger.logger().debug("USER_SCRIPT channel failed", { error: String(error) }); + } + }; domServer.on( "pageLoad", (data: { bootstrapToken?: unknown; envInfo?: GMInfoEnv; extensionOrigin?: ExtensionOrigin }) => { if (typeof data?.bootstrapToken !== "string" || data.bootstrapToken.length === 0) return; - void connectUserScriptChannel(msg, data.bootstrapToken, (_connection, packet) => { - if (packet.action === "content/pageLoad") { - const packetData = packet.data as { - scripts?: TScriptInfo[]; - envInfo?: GMInfoEnv; - extensionOrigin?: ExtensionOrigin; - }; - if ( - !packetData || - !Array.isArray(packetData.scripts) || - packetData.scripts.length === 0 || - !packetData.envInfo - ) { - return; - } - for (let i = 0; i < packetData.scripts.length; i += 1) { - const script = packetData.scripts[i]; - if ( - !script || - typeof script !== "object" || - script.executionEnvTag !== scriptEnvTag || - typeof script.executionHandle !== "string" - ) { - return; - } - } - setPageRpcExtensionOrigin(packetData.extensionOrigin); - runtime.startScripts(packetData.scripts, packetData.envInfo); - } else if (packet.action === "content/runtime/valueUpdate") { - scriptExecutor.valueUpdate(packet.data as any); - } else if (packet.action === "content/runtime/emitEvent") { - scriptExecutor.emitEvent(packet.data as any); - } - }); + reconnectToken = data.bootstrapToken; + void openUserScriptChannel(data.bootstrapToken); } ); runtime.init(); From 3a8c561ace8cd471ce4c2d6c567ad0859cdd4b59 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:10:11 +0900 Subject: [PATCH 054/103] =?UTF-8?q?=F0=9F=94=92=20validate=20inject=20page?= =?UTF-8?q?=20bootstrap=20DTOs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 1 + .../service/content/script_runtime.test.ts | 118 ++++++++++++++++++ src/app/service/content/script_runtime.ts | 87 ++++++++++++- 3 files changed, 204 insertions(+), 2 deletions(-) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index cd1c0942e..f3439aab9 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -94,6 +94,7 @@ export const Native = { ownFragment: new DocumentFragment(), 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), diff --git a/src/app/service/content/script_runtime.test.ts b/src/app/service/content/script_runtime.test.ts index 217399ffb..54ed41563 100644 --- a/src/app/service/content/script_runtime.test.ts +++ b/src/app/service/content/script_runtime.test.ts @@ -3,6 +3,7 @@ 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", () => { @@ -46,3 +47,120 @@ describe("ScriptRuntime DOM bridge", () => { 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", + 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 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 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("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); + }); +}); diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index cd0434094..4183b3106 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -9,7 +9,84 @@ 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 } from "./global"; +import { customClone, Native } from "./global"; + +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 isInjectScriptInfo = (value: unknown): value is TScriptInfo => { + if (!isRecord(value)) return false; + if ( + typeof value.uuid !== "string" || + value.uuid.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 !== "it" || + !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 cloneInjectPageLoad = (data: unknown): { scripts: TScriptInfo[]; envInfo: GMInfoEnv } | undefined => { + const cloned = customClone(data); + if (!isRecord(cloned) || Native.objectKeys(cloned).length !== 2) return undefined; + if (!Native.objectHasOwn(cloned, "scripts") || !Native.objectHasOwn(cloned, "envInfo")) return undefined; + if (!Native.arrayIsArray(cloned.scripts) || cloned.scripts.length === 0) return undefined; + for (let index = 0; index < cloned.scripts.length; index += 1) { + if (!isInjectScriptInfo(cloned.scripts[index])) 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; + return { scripts: cloned.scripts, envInfo: cloned.envInfo as unknown as GMInfoEnv }; +}; export class ScriptRuntime { constructor( @@ -99,7 +176,13 @@ export class ScriptRuntime { }); this.server.on("pageLoad", (data: { scripts: TScriptInfo[]; envInfo: GMInfoEnv }) => { - // 监听事件 + if (this.scripEnvTag === "it") { + const safeData = cloneInjectPageLoad(data); + if (!safeData) return; + this.startScripts(safeData.scripts, safeData.envInfo); + return; + } + // content/native channels already carry the service-worker response directly. this.startScripts(data.scripts, data.envInfo); }); From cf9ee7b8b4da65f3e837ac47201f952926094eee Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:16:47 +0900 Subject: [PATCH 055/103] =?UTF-8?q?=F0=9F=94=92=20validate=20inject=20runt?= =?UTF-8?q?ime=20callback=20DTOs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/script_runtime.test.ts | 42 +++++++++ src/app/service/content/script_runtime.ts | 91 +++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/src/app/service/content/script_runtime.test.ts b/src/app/service/content/script_runtime.test.ts index 54ed41563..423ea8421 100644 --- a/src/app/service/content/script_runtime.test.ts +++ b/src/app/service/content/script_runtime.test.ts @@ -118,6 +118,48 @@ describe("ScriptRuntime inject page bootstrap", () => { 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("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(); diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 4183b3106..4711cb1b3 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -72,6 +72,85 @@ const isInjectScriptInfo = (value: unknown): value is TScriptInfo => { 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; +}; + const cloneInjectPageLoad = (data: unknown): { scripts: TScriptInfo[]; envInfo: GMInfoEnv } | undefined => { const cloned = customClone(data); if (!isRecord(cloned) || Native.objectKeys(cloned).length !== 2) return undefined; @@ -169,9 +248,21 @@ export class ScriptRuntime { init() { this.server.on("runtime/emitEvent", (data: EmitEventRequest) => { // 转发给脚本 + if (this.scripEnvTag === "it") { + const safeData = cloneInjectEmitEvent(data); + if (!safeData) return; + this.scriptExecutor.emitEvent(safeData); + return; + } this.scriptExecutor.emitEvent(data); }); this.server.on("runtime/valueUpdate", (data: ValueUpdateDataEncoded) => { + if (this.scripEnvTag === "it") { + const safeData = cloneInjectValueUpdate(data); + if (!safeData) return; + this.scriptExecutor.valueUpdate(safeData); + return; + } this.scriptExecutor.valueUpdate(data); }); From d2b2d84a68f1b7a9862279c79b11dc2d6e9e0d73 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:25:34 +0900 Subject: [PATCH 056/103] =?UTF-8?q?=F0=9F=94=92=20avoid=20duplicate=20USER?= =?UTF-8?q?=5FSCRIPT=20bootstrap=20execution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/script_runtime.test.ts | 14 ++++++++++++++ src/app/service/content/script_runtime.ts | 17 ++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/app/service/content/script_runtime.test.ts b/src/app/service/content/script_runtime.test.ts index 423ea8421..69b80b6f6 100644 --- a/src/app/service/content/script_runtime.test.ts +++ b/src/app/service/content/script_runtime.test.ts @@ -194,6 +194,20 @@ describe("ScriptRuntime inject page bootstrap", () => { 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(); diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 4711cb1b3..af3faa60d 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -168,6 +168,9 @@ const cloneInjectPageLoad = (data: unknown): { scripts: TScriptInfo[]; envInfo: }; export class ScriptRuntime { + // USER_SCRIPT 重连会重放同一份 bootstrap;按服务端签发的句柄去重,导航换文档时句柄也会随之更换。 + private readonly startedScriptKeys = new Native.Set(); + constructor( private readonly scripEnvTag: ScriptEnvTag, private readonly server: Server, @@ -287,7 +290,19 @@ 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); } externalMessage() { From a64a1cba73a62bf38b6c202d5e88eb04f4b272ad Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:39:58 +0900 Subject: [PATCH 057/103] =?UTF-8?q?=F0=9F=94=92=20route=20MAIN=20privilege?= =?UTF-8?q?d=20traffic=20over=20native=20channel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/repo/scripts.ts | 2 + src/app/service/content/external.ts | 10 +- src/app/service/content/script_runtime.ts | 87 ++++++++++------ src/app/service/content/scripting.test.ts | 7 ++ src/app/service/content/scripting.ts | 7 +- .../content/user_script_connection.test.ts | 15 +++ .../service/content/user_script_connection.ts | 6 +- .../service/service_worker/runtime.test.ts | 84 ++++++++++++++++ src/app/service/service_worker/runtime.ts | 99 +++++++++++++------ src/inject.ts | 77 ++++++++++++++- 10 files changed, 324 insertions(+), 70 deletions(-) diff --git a/src/app/repo/scripts.ts b/src/app/repo/scripts.ts index 27cdfbf24..d2065f773 100644 --- a/src/app/repo/scripts.ts +++ b/src/app/repo/scripts.ts @@ -160,6 +160,8 @@ export type TClientPageLoadInfo = envInfo: GMInfoEnv; /** 一次性令牌,供 USER_SCRIPT world 请求私有 bootstrap。 */ userScriptBootstrapToken?: string; + /** 一次性令牌,供 MAIN world 的 inject 环境请求私有 bootstrap。 */ + userScriptInjectBootstrapToken?: string; } | { ok: false }; 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/script_runtime.ts b/src/app/service/content/script_runtime.ts index af3faa60d..61926f985 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -151,9 +151,20 @@ const cloneInjectEmitEvent = (data: unknown): EmitEventRequest | undefined => { return cloned as unknown as EmitEventRequest; }; -const cloneInjectPageLoad = (data: unknown): { scripts: TScriptInfo[]; envInfo: GMInfoEnv } | undefined => { +type InjectPageLoadData = { + scripts: TScriptInfo[]; + envInfo: GMInfoEnv; + reconnectToken?: string; +}; + +const cloneInjectPageLoad = (data: unknown): InjectPageLoadData | undefined => { const cloned = customClone(data); - if (!isRecord(cloned) || Native.objectKeys(cloned).length !== 2) return undefined; + if ( + !isRecord(cloned) || + !hasOnlyKeys(cloned, ["scripts", "envInfo"], ["reconnectToken"]) || + (cloned.reconnectToken !== undefined && !isExecutionToken(cloned.reconnectToken)) + ) + return undefined; if (!Native.objectHasOwn(cloned, "scripts") || !Native.objectHasOwn(cloned, "envInfo")) return undefined; if (!Native.arrayIsArray(cloned.scripts) || cloned.scripts.length === 0) return undefined; for (let index = 0; index < cloned.scripts.length; index += 1) { @@ -164,7 +175,11 @@ const cloneInjectPageLoad = (data: unknown): { scripts: TScriptInfo[]; envInfo: return undefined; } if (cloned.envInfo.userAgentData !== undefined && !isRecord(cloned.envInfo.userAgentData)) return undefined; - return { scripts: cloned.scripts, envInfo: cloned.envInfo as unknown as GMInfoEnv }; + return { + scripts: cloned.scripts, + envInfo: cloned.envInfo as unknown as GMInfoEnv, + reconnectToken: cloned.reconnectToken as string | undefined, + }; }; export class ScriptRuntime { @@ -250,34 +265,14 @@ export class ScriptRuntime { init() { this.server.on("runtime/emitEvent", (data: EmitEventRequest) => { - // 转发给脚本 - if (this.scripEnvTag === "it") { - const safeData = cloneInjectEmitEvent(data); - if (!safeData) return; - this.scriptExecutor.emitEvent(safeData); - return; - } - this.scriptExecutor.emitEvent(data); + this.receiveEmitEvent(data); }); this.server.on("runtime/valueUpdate", (data: ValueUpdateDataEncoded) => { - if (this.scripEnvTag === "it") { - const safeData = cloneInjectValueUpdate(data); - if (!safeData) return; - this.scriptExecutor.valueUpdate(safeData); - return; - } - this.scriptExecutor.valueUpdate(data); + this.receiveValueUpdate(data); }); this.server.on("pageLoad", (data: { scripts: TScriptInfo[]; envInfo: GMInfoEnv }) => { - if (this.scripEnvTag === "it") { - const safeData = cloneInjectPageLoad(data); - if (!safeData) return; - this.startScripts(safeData.scripts, safeData.envInfo); - return; - } - // content/native channels already carry the service-worker response directly. - this.startScripts(data.scripts, data.envInfo); + this.receivePageLoad(data); }); // 用于 early-start 的扩充参数 @@ -305,7 +300,43 @@ export class ScriptRuntime { if (freshScripts.length > 0) this.scriptExecutor.startScripts(freshScripts, envInfo); } - externalMessage() { - onInjectPageLoaded(this.msg); + 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; + } + if (!isRecord(data) || !Native.objectHasOwn(data, "scripts") || !Native.objectHasOwn(data, "envInfo")) + return undefined; + const scripts = data.scripts; + const envInfo = data.envInfo; + if (!Native.arrayIsArray(scripts) || !isRecord(envInfo)) return undefined; + this.startScripts(scripts as TScriptInfo[], envInfo as unknown as GMInfoEnv); + return undefined; + } + + receiveEmitEvent(data: unknown): void { + if (this.scripEnvTag === "it") { + const safeData = cloneInjectEmitEvent(data); + if (!safeData) return; + this.scriptExecutor.emitEvent(safeData); + return; + } + this.scriptExecutor.emitEvent(data as EmitEventRequest); + } + + receiveValueUpdate(data: unknown): void { + if (this.scripEnvTag === "it") { + const safeData = cloneInjectValueUpdate(data); + if (!safeData) return; + this.scriptExecutor.valueUpdate(safeData); + return; + } + this.scriptExecutor.valueUpdate(data as ValueUpdateDataEncoded); + } + + 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 index 2cf1c6329..4f341cd02 100644 --- a/src/app/service/content/scripting.test.ts +++ b/src/app/service/content/scripting.test.ts @@ -32,6 +32,7 @@ describe("ScriptingRuntime page bootstrap", () => { contentScriptList: [makeScript("content-script")], envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, userScriptBootstrapToken: "bootstrap-token", + userScriptInjectBootstrapToken: "inject-bootstrap-token", } as TClientPageLoadInfo); const senderToExt = makeSender(); const senderToContent = makeSender(); @@ -63,6 +64,12 @@ describe("ScriptingRuntime page bootstrap", () => { }), }) ); + expect(senderToInject.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "inject/bootstrap", + data: { bootstrapToken: "inject-bootstrap-token" }, + }) + ); expect(senderToInject.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ action: "inject/pageLoad" })); }); }); diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index caf88b916..dc0bc2e27 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -178,7 +178,7 @@ export default class ScriptingRuntime { // 向service_worker请求脚本列表及环境信息 client.pageLoad("it").then((o) => { if (!o.ok) return; - const { injectScriptList, envInfo, userScriptBootstrapToken } = o; + const { injectScriptList, envInfo, userScriptBootstrapToken, userScriptInjectBootstrapToken } = o; // 每次页面加载都废弃旧句柄,避免无 documentId 的浏览器复用上一文档的授权。 this.pageRpc.revokeAll(); const prepareScripts = (scripts: typeof injectScriptList, envTag: "it" | "ct") => @@ -210,6 +210,11 @@ export default class ScriptingRuntime { }); } + if (typeof userScriptInjectBootstrapToken === "string" && userScriptInjectBootstrapToken.length > 0) { + const injectClient = new Client(this.senderToInject, "inject"); + injectClient.do("bootstrap", { bootstrapToken: userScriptInjectBootstrapToken }); + } + // 向页面 发送脚本列表及环境信息 if (preparedInjectScriptList.length) { const injectClient = new Client(this.senderToInject, "inject"); diff --git a/src/app/service/content/user_script_connection.test.ts b/src/app/service/content/user_script_connection.test.ts index d1174e86e..9cfc3ea8b 100644 --- a/src/app/service/content/user_script_connection.test.ts +++ b/src/app/service/content/user_script_connection.test.ts @@ -31,6 +31,21 @@ describe("connectUserScriptChannel", () => { expect(connection.sendMessage).toHaveBeenCalledWith({ action: "userScript/bootstrap" }); }); + it("preserves the MAIN world identity when opening the inject port", async () => { + const connection = makeConnection(); + const message = { + sendMessage: vi.fn().mockResolvedValue(true), + connect: vi.fn().mockResolvedValue(connection), + } as unknown as Message; + + await connectUserScriptChannel(message, "inject-bootstrap", vi.fn(), undefined, "MAIN"); + + expect(message.connect).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/registerUserScript", + data: { world: "MAIN", bootstrapToken: "inject-bootstrap" }, + }); + }); + it("does not open a port when the browser cannot enable USER_SCRIPT listeners", async () => { const message = { sendMessage: vi.fn().mockResolvedValue(false), diff --git a/src/app/service/content/user_script_connection.ts b/src/app/service/content/user_script_connection.ts index 7aee3cf15..6a4466341 100644 --- a/src/app/service/content/user_script_connection.ts +++ b/src/app/service/content/user_script_connection.ts @@ -7,6 +7,7 @@ type UserScriptReconnectResponse = { code?: unknown; data?: unknown; }; +type UserScriptWorld = "USER_SCRIPT" | "MAIN"; /** * 先让 service worker 开启 USER_SCRIPT 监听,再建立连接;浏览器可能立即投递端口, @@ -16,13 +17,14 @@ export async function connectUserScriptChannel( message: Message, bootstrapToken: string, onPacket: UserScriptPacketHandler, - onDisconnect?: UserScriptDisconnectHandler + onDisconnect?: UserScriptDisconnectHandler, + world: UserScriptWorld = "USER_SCRIPT" ): Promise { const enabled = await message.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" } as unknown as TMessage); if (enabled === false) return undefined; const connection = await message.connect({ action: "serviceWorker/runtime/registerUserScript", - data: { world: "USER_SCRIPT", bootstrapToken }, + data: { world, bootstrapToken }, }); connection.onMessage((packet) => onPacket(connection, packet)); if (onDisconnect) connection.onDisconnect(onDisconnect); diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 51ba5b0be..c751b49dc 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1348,6 +1348,90 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { }); describe("USER_SCRIPT native callbacks", () => { + it("issues a separate MAIN bootstrap and routes its private callbacks over the native port", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "inject-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + const contentScript = _createScriptRunResource( + _createMockScript({ uuid: "content-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [contentScript], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const rawSender = { + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-main", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const sendMessage = vi.fn(); + const connection = { + onMessage: vi.fn(), + sendMessage, + disconnect: vi.fn(), + onDisconnect: vi.fn(), + } as unknown as MessageConnect; + const connectionSender = { + getType: () => 3, + isType: () => true, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-main" }), + getConnect: () => connection, + getConnectOrigin: () => "userScript" as const, + }; + + const pageLoad = await runtime.pageLoad({ envTag: "it" }, new SenderRuntime(rawSender)); + expect(pageLoad.ok && pageLoad.userScriptInjectBootstrapToken).toEqual(expect.any(String)); + const bootstrapToken = pageLoad.ok ? pageLoad.userScriptInjectBootstrapToken : undefined; + expect(runtime.registerUserScriptConnection({ world: "USER_SCRIPT", bootstrapToken }, connectionSender)).toBe( + false + ); + expect(runtime.registerUserScriptConnection({ world: "MAIN", bootstrapToken }, connectionSender)).toBe(true); + + const contentConnection = { + onMessage: vi.fn(), + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: vi.fn(), + } as unknown as MessageConnect; + const contentSender = { ...connectionSender, getConnect: () => contentConnection }; + const contentBootstrapToken = pageLoad.ok ? pageLoad.userScriptBootstrapToken : undefined; + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", bootstrapToken: contentBootstrapToken }, + contentSender + ) + ).toBe(true); + expect((runtime as any).userScriptConnections.size).toBe(2); + + const bootstrapHandler = (connection.onMessage as ReturnType).mock.calls[0]?.[0] as + | ((packet: TMessage) => void) + | undefined; + bootstrapHandler?.({ action: "userScript/bootstrap" }); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "inject/pageLoad", + data: expect.objectContaining({ scripts: expect.any(Array) }), + }) + ); + + sendMessage.mockClear(); + (runtime as any).sendUserScriptMessage(undefined, "runtime/emitEvent", { + uuid: "inject-script", + event: "click", + eventId: "1", + }); + expect(sendMessage).toHaveBeenCalledWith({ + action: "inject/runtime/emitEvent", + data: { uuid: "inject-script", event: "click", eventId: "1" }, + }); + }); + it("只向当前文档中声明了对应脚本或 storageName 的连接投递更新", async () => { const { runtime } = _createRuntimeContext(); const script = _createScriptRunResource( diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index b65af7f5b..6e48ab886 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -136,6 +136,7 @@ type UserScriptSession = { envInfo: GMInfoEnv; extensionOrigin?: ExtensionOrigin; reconnectToken: string; + envTag: "it" | "ct"; tabId: number; frameId?: number; documentId?: string; @@ -154,10 +155,17 @@ export class RuntimeService { private gmApi?: GMApi; // 句柄绑定到 tab/frame/document;页面导航、脚本变更或窗口关闭时必须整体撤销。 private readonly pageExecutionBindings = new Map(); - // USER_SCRIPT 连接只保存它获准使用的 content-world 句柄,回调按句柄再做一次归属匹配。 + // 原生 page/content 端口只保留各自签发的句柄,回调发送前再按该集合过滤一次。 private readonly userScriptConnections = new Map< string, - { connection: MessageConnect; handles: Set; tabId: number; frameId?: number; documentId?: string } + { + connection: MessageConnect; + handles: Set; + envTag: "it" | "ct"; + tabId: number; + frameId?: number; + documentId?: string; + } >(); private readonly userScriptBootstraps = new Map(); // 连接断开后保留当前文档的已验证资料,供 USER_SCRIPT 通过原生消息重连;导航或脚本撤销会同步清除。 @@ -237,8 +245,13 @@ export class RuntimeService { return [key, sequence]; } - private userScriptConnectionKey(tabId: number, frameId?: number, documentId?: string): string { - return `${tabId}:${frameId ?? -1}:${documentId ?? ""}`; + private userScriptConnectionKey( + tabId: number, + frameId: number | undefined, + documentId: string | undefined, + envTag: "it" | "ct" + ): string { + return `${tabId}:${frameId ?? -1}:${documentId ?? ""}:${envTag}`; } /** Register the native USER_SCRIPT channel used for private bootstrap and callbacks. */ @@ -249,7 +262,6 @@ export class RuntimeService { const handshake = data as { world?: unknown; bootstrapToken?: unknown }; if ( Object.keys(data).length !== 2 || - handshake.world !== "USER_SCRIPT" || typeof handshake.bootstrapToken !== "string" || handshake.bootstrapToken.length === 0 || handshake.bootstrapToken.length > 256 @@ -269,6 +281,9 @@ export class RuntimeService { ) { return false; } + // bootstrap 令牌决定唯一可消费这些句柄的 world,调用方不能借握手字段改投其他环境。 + const expectedWorld = bootstrap.envTag === "it" ? "MAIN" : "USER_SCRIPT"; + if (handshake.world !== expectedWorld) return false; const handles = new Set(); for (const script of bootstrap.scripts) { const handle = script.executionHandle; @@ -276,7 +291,7 @@ export class RuntimeService { const binding = this.pageExecutionBindings.get(handle); if ( !binding || - binding.envTag !== "ct" || + binding.envTag !== bootstrap.envTag || binding.tabId !== tabId || binding.frameId !== source.frameId || binding.documentId !== source.documentId @@ -288,12 +303,12 @@ export class RuntimeService { if (handles.size === 0) return false; const frameId = source.frameId; const documentId = source.documentId; - const key = this.userScriptConnectionKey(tabId, frameId, documentId); + const key = this.userScriptConnectionKey(tabId, frameId, documentId, bootstrap.envTag); this.userScriptSessions.set(key, bootstrap); this.userScriptBootstraps.delete(handshake.bootstrapToken); const previous = this.userScriptConnections.get(key); if (previous) previous.connection.disconnect(true); - const entry = { connection, handles, tabId, frameId, documentId }; + const entry = { connection, handles, envTag: bootstrap.envTag, tabId, frameId, documentId }; this.userScriptConnections.set(key, entry); connection.onDisconnect(() => { if (this.userScriptConnections.get(key)?.connection === connection) this.userScriptConnections.delete(key); @@ -311,14 +326,15 @@ export class RuntimeService { } bootstrapped = true; try { + const pageLoadData = { + scripts: bootstrap.scripts, + envInfo: bootstrap.envInfo, + reconnectToken: bootstrap.reconnectToken, + ...(bootstrap.envTag === "ct" ? { extensionOrigin: bootstrap.extensionOrigin } : {}), + }; connection.sendMessage({ - action: "content/pageLoad", - data: { - scripts: bootstrap.scripts, - envInfo: bootstrap.envInfo, - extensionOrigin: bootstrap.extensionOrigin, - reconnectToken: bootstrap.reconnectToken, - }, + action: `${bootstrap.envTag === "it" ? "inject" : "content"}/pageLoad`, + data: pageLoadData, }); } catch { this.userScriptConnections.delete(key); @@ -344,15 +360,27 @@ export class RuntimeService { const source = sender.getSender(); const tabId = source?.tab?.id; if (!source || typeof tabId !== "number") return undefined; - const key = this.userScriptConnectionKey(tabId, source.frameId, source.documentId); - const session = this.userScriptSessions.get(key); - if (!session || (data as { reconnectToken: string }).reconnectToken !== session.reconnectToken) return undefined; + let key: string | undefined; + let session: UserScriptSession | undefined; + for (const [candidateKey, candidateSession] of this.userScriptSessions) { + if ( + candidateSession.tabId === tabId && + candidateSession.frameId === source.frameId && + candidateSession.documentId === source.documentId && + candidateSession.reconnectToken === (data as { reconnectToken: string }).reconnectToken + ) { + key = candidateKey; + session = candidateSession; + break; + } + } + if (!key || !session) return undefined; for (const script of session.scripts) { const handle = script.executionHandle; const binding = typeof handle === "string" ? this.pageExecutionBindings.get(handle) : undefined; if ( !binding || - binding.envTag !== "ct" || + binding.envTag !== session.envTag || binding.tabId !== tabId || binding.frameId !== source.frameId || binding.documentId !== source.documentId @@ -406,7 +434,7 @@ export class RuntimeService { } if (!bindingMatches) continue; try { - entry.connection.sendMessage({ action: `content/${action}`, data }); + entry.connection.sendMessage({ action: `${entry.envTag === "it" ? "inject" : "content"}/${action}`, data }); } catch { this.userScriptConnections.delete(key); } @@ -1673,17 +1701,25 @@ export class RuntimeService { const injectScriptList = data?.envTag === "ct" ? [] : prepareScripts(res.injectScriptList, "it"); const contentScriptList = prepareScripts(res.contentScriptList, "ct"); let userScriptBootstrapToken: string | undefined; - if (data?.envTag === "it" && contentScriptList.length > 0) { - userScriptBootstrapToken = uuidv4(); - this.userScriptBootstraps.set(userScriptBootstrapToken, { - scripts: contentScriptList, - envInfo: res.envInfo, - extensionOrigin: getExtensionOrigin(), - reconnectToken: userScriptBootstrapToken, - tabId, - frameId, - documentId: chromeSender.documentId, - }); + let userScriptInjectBootstrapToken: string | undefined; + if (data?.envTag === "it") { + const createBootstrap = (scripts: TScriptInfo[], envTag: "it" | "ct"): string | undefined => { + if (scripts.length === 0) return undefined; + const token = uuidv4(); + this.userScriptBootstraps.set(token, { + scripts, + envInfo: res.envInfo, + extensionOrigin: getExtensionOrigin(), + reconnectToken: token, + envTag, + tabId, + frameId, + documentId: chromeSender.documentId, + }); + return token; + }; + userScriptInjectBootstrapToken = createBootstrap(injectScriptList, "it"); + userScriptBootstrapToken = createBootstrap(contentScriptList, "ct"); } // 返回脚本资料,在页面加载 return { @@ -1692,6 +1728,7 @@ export class RuntimeService { contentScriptList: data?.envTag === "it" ? [] : contentScriptList, envInfo: res.envInfo, userScriptBootstrapToken, + userScriptInjectBootstrapToken, }; } else { // 没有脚本资料,不需要加载 diff --git a/src/inject.ts b/src/inject.ts index 878f2426c..f3fb02ba0 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -2,6 +2,7 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; import { PageMessage } from "@Packages/message/page_message"; +import { ExtensionMessage } from "@Packages/message/extension_message"; import { Server } from "@Packages/message/server"; import { ScriptExecutor } from "./app/service/content/script_executor"; import type { Message } from "@Packages/message/types"; @@ -9,17 +10,26 @@ import { getEventFlag } from "@Packages/message/common"; import { ScriptRuntime } from "./app/service/content/script_runtime"; import { ScriptEnvTag } from "@Packages/message/consts"; import { type TExtensionEnv } from "./app/service/extension/extension_env"; +import { connectUserScriptChannel, requestUserScriptReconnect } from "./app/service/content/user_script_connection"; +import type { MessageConnect, TMessage } from "@Packages/message/types"; const messageFlag = process.env.SC_RANDOM_KEY!; getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | undefined) => { const scriptEnvTag = ScriptEnvTag.inject; - const msg: Message = new PageMessage(eventFlag, "inject"); + const pageMsg: Message = new PageMessage(eventFlag, "inject"); + const nativeMsg: Message = new ExtensionMessage(false); + // 特权 GM RPC 使用浏览器标记的 USER_SCRIPT 来源;页面桥只保留 bootstrap 与 DOM 引用辅助。 + const canUseNativeChannel = + typeof chrome !== "undefined" && + typeof chrome.runtime?.connect === "function" && + typeof chrome.runtime?.sendMessage === "function"; + const msg: Message = canUseNativeChannel ? nativeMsg : pageMsg; // 初始化日志组件 const logger = new LoggerCore({ - writer: new MessageWriter(msg, "scripting/logger"), + writer: new MessageWriter(msg, canUseNativeChannel ? "serviceWorker/logger" : "scripting/logger"), consoleLevel: process.env.NODE_ENV === "development" ? "debug" : "none", // 只让日志在scripting环境中打印 labels: { env: "inject", href: window.location.href }, }); @@ -27,10 +37,69 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde logger.logger().debug("inject start"); const server = new Server("inject", msg); - const scriptExecutor = new ScriptExecutor(msg, new CustomEventMessage(eventFlag, true, ScriptEnvTag.content)); + const scriptExecutor = new ScriptExecutor( + msg, + new CustomEventMessage(eventFlag, true, ScriptEnvTag.content), + canUseNativeChannel ? "serviceWorker" : "scripting" + ); const runtime = new ScriptRuntime(scriptEnvTag, server, msg, scriptExecutor, extensionEnv); + const pageServer = canUseNativeChannel ? new Server("inject", pageMsg) : undefined; + let reconnecting = false; + let openingNative = false; + let nativeConnection: MessageConnect | undefined; + let reconnectToken: string | undefined; + + const handleNativePacket = (_connection: MessageConnect, packet: TMessage) => { + if (packet.action === "inject/pageLoad") { + const nextToken = runtime.receivePageLoad(packet.data); + if (nextToken) reconnectToken = nextToken; + } else if (packet.action === "inject/runtime/valueUpdate") { + runtime.receiveValueUpdate(packet.data); + } else if (packet.action === "inject/runtime/emitEvent") { + runtime.receiveEmitEvent(packet.data); + } + }; + + const openNativeChannel = async (bootstrapToken: string): Promise => { + if (openingNative || nativeConnection) return; + openingNative = true; + let connection: MessageConnect | undefined; + try { + connection = await connectUserScriptChannel( + nativeMsg, + bootstrapToken, + handleNativePacket, + (isSelfDisconnected) => { + if (nativeConnection === connection) nativeConnection = undefined; + if (isSelfDisconnected || reconnecting || !reconnectToken) return; + reconnecting = true; + void requestUserScriptReconnect(nativeMsg, reconnectToken) + .then((nextToken) => (nextToken ? openNativeChannel(nextToken) : undefined)) + .catch((error) => logger.logger().debug("MAIN USER_SCRIPT reconnect failed", { error: String(error) })) + .finally(() => { + reconnecting = false; + }); + }, + "MAIN" + ); + nativeConnection = connection; + } catch (error) { + logger.logger().debug("MAIN USER_SCRIPT channel failed", { error: String(error) }); + } finally { + openingNative = false; + } + }; + + pageServer?.on("bootstrap", (data: { bootstrapToken?: unknown }) => { + if (typeof data?.bootstrapToken !== "string" || data.bootstrapToken.length === 0) return; + reconnectToken = data.bootstrapToken; + void openNativeChannel(data.bootstrapToken); + }); + pageServer?.on("pageLoad", (data) => { + runtime.receivePageLoad(data); + }); runtime.init(); // inject环境,直接判断白名单,注入对外接口 - runtime.externalMessage(); + runtime.externalMessage("scripting", pageMsg); }); From 281e449a29c765c5f1030de4146f1df85c681ef3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:43:52 +0900 Subject: [PATCH 058/103] =?UTF-8?q?=F0=9F=94=92=20redact=20early-start=20p?= =?UTF-8?q?reload=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/exec_script.ts | 8 +++++ .../service/content/script_executor.test.ts | 8 +++++ src/app/service/content/utils.test.ts | 36 +++++++++++++++++++ src/app/service/content/utils.ts | 13 ++++++- 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index e0acb4c0c..aca46a871 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -96,6 +96,14 @@ export default class ExecScript { // 早期启动的脚本,处理GM API updateEarlyScriptGMInfo(envInfo: GMInfoEnv, scriptInfo?: TScriptInfo) { + if (scriptInfo) { + // 预注入事件可被页面观察,只携带空的用户值和配置;pageLoad 到达后再补回权威副本。 + this.scriptRes.value = scriptInfo.value; + this.scriptRes.config = scriptInfo.config; + this.scriptRes.metadata = scriptInfo.metadata; + this.scriptRes.resource = scriptInfo.resource; + this.scriptRes.requireCssResource = scriptInfo.requireCssResource; + } if (scriptInfo?.executionHandle && scriptInfo.executionEnvTag) { // early-start 先执行后取得绑定;此处补写同一绑定,使后续 RPC 与首次注册一致。 this.scriptRes.executionHandle = scriptInfo.executionHandle; diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index 31dcd154c..d2da26d4f 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -110,12 +110,20 @@ describe("ScriptExecutor", () => { exec.updateEarlyScriptGMInfo(initEnvInfo, { ...initial, + value: { secret: "authoritative-value" }, + config: { + private: { secret: { title: "Private", description: "", index: 0, default: "authoritative" } }, + }, executionHandle: "page-binding", executionEnvTag: "it", }); expect(exec.scriptRes.executionHandle).toBe("page-binding"); expect(exec.scriptRes.executionEnvTag).toBe("it"); + expect(exec.scriptRes.value).toEqual({ secret: "authoritative-value" }); + expect(exec.scriptRes.config).toEqual({ + private: { secret: { title: "Private", description: "", index: 0, default: "authoritative" } }, + }); }); it("ignores a counterfeit mount and keeps listening for the genuine wrapper", () => { diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index 0933854e1..e73da126c 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -793,6 +793,42 @@ describe("utils", () => { expect(testPerformance.addEventListener).not.toHaveBeenCalled(); }); + it.concurrent("does not expose stored values or user config in the observable preload event", () => { + 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: "secret" } } }, + flag: "pre-inject-private-flag", + resource: {}, + metadata: {}, + originalMetadata: {}, + metadataStr: "", + userConfigStr: "", + }; + let detail: Record | undefined; + const testPerformance = { + dispatchEvent: vi.fn((event: Event) => { + detail = (event as CustomEvent).detail; + return false; + }), + addEventListener: vi.fn(), + }; + + executeGeneratedScript(compilePreInjectScript(script, "return undefined;"), {}, testPerformance); + + expect(detail?.scriptInfo.value).toEqual({}); + expect(detail?.scriptInfo.config).toBeUndefined(); + }); + it.concurrent("does not mount a regex-excluded early-start script", () => { const script: ScriptLoadInfo = { uuid: "pre-inject-excluded-uuid", diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index f8c779e11..23b165f1f 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -280,6 +280,17 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { return scriptInfo; }; +/** + * 预注入事件会经过页面可观察的 performance 通道;不要把用户值或配置放进它的 detail。 + * 资源仍需在脚本最早执行时可用,后续 pageLoad 会补回权威的值与配置。 + */ +export const trimPreInjectScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { + const scriptInfo = trimScriptInfo(script); + scriptInfo.value = {}; + scriptInfo.config = undefined; + return scriptInfo; +}; + /** * 将脚本函数编译为预注入脚本代码 */ @@ -291,7 +302,7 @@ 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 From 50a08b2239b9dba6dfac7a21f51e86b1b41993d7 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:47:24 +0900 Subject: [PATCH 059/103] =?UTF-8?q?=F0=9F=94=92=20settle=20XHR=20errors=20?= =?UTF-8?q?without=20broker=20loadend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.test.ts | 67 +++++++++++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 7 +- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index c4bca8065..16b095d12 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -16,6 +16,7 @@ describe("GM_xmlhttpRequest callback cleanup", () => { onDisconnect: vi.fn(), }; const onloadend = vi.fn(); + const onload = vi.fn(); const api = { isInvalidContext: () => false, connect: vi.fn().mockResolvedValue(connection), @@ -28,6 +29,7 @@ describe("GM_xmlhttpRequest callback cleanup", () => { onerror: () => { throw new Error("user callback failed"); }, + onload, onloadend, }, true @@ -50,6 +52,21 @@ describe("GM_xmlhttpRequest callback cleanup", () => { 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", @@ -66,6 +83,56 @@ describe("GM_xmlhttpRequest callback cleanup", () => { }, }); + 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); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index f06988b5d..e24a7f113 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -539,6 +539,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; @@ -689,6 +690,8 @@ export function GM_xmlhttpRequest( break; } case "onload": + if (loadCalled || reqDone) break; + loadCalled = true; invokeXHRCallback("onload", details.onload, makeXHRCallbackParam?.(data) ?? {}); break; case "onloadend": { @@ -744,8 +747,8 @@ export function GM_xmlhttpRequest( details.onerror, (makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError ); - // 不要进行 refCleanup !要等待最后的 onloadend - // refCleanup?.(); + // 错误消息可能没有对应的 onloadend,补发一次以收尾并释放连接。 + scheduleSyntheticLoadEnd(); } break; case "onabort": From b84ccaf112c7a5c3c4a41656df0595ca68251eea Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:49:36 +0900 Subject: [PATCH 060/103] =?UTF-8?q?=F0=9F=94=92=20isolate=20protected=20GM?= =?UTF-8?q?=20facade=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/create_context.test.ts | 17 +++++++++++++++++ src/app/service/content/create_context.ts | 2 +- src/app/service/content/gm_api/gm_context.ts | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 8d6f723e9..b727499c9 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -316,6 +316,23 @@ describe("createContext: capability and lifecycle contract", () => { 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(); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 8b572ba14..75333db34 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -599,7 +599,7 @@ export const createProxyContext = ( const contextKeys = Native.objectKeys(context); for (let i = 0; i < contextKeys.length; i += 1) { const key = contextKeys[i]; - if (key in protect || key === "window") continue; + if (Native.objectHasOwn(protect, key) || key === "window") continue; mySandbox[key] = context[key]; // window以外 } diff --git a/src/app/service/content/gm_api/gm_context.ts b/src/app/service/content/gm_api/gm_context.ts index 87d873357..d8ebc4a40 100644 --- a/src/app/service/content/gm_api/gm_context.ts +++ b/src/app/service/content/gm_api/gm_context.ts @@ -28,7 +28,7 @@ function GMContextApiSet(grant: string, fnKey: string, api: any, param: ApiParam 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) { From cfb698c6ff92b40c213d70b0ef941722164e08d1 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:00:42 +0900 Subject: [PATCH 061/103] =?UTF-8?q?=F0=9F=94=92=20keep=20service=20worker?= =?UTF-8?q?=20bridge=20bundle=20DOM-free?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 6 ++++-- src/app/service/content/script_runtime.ts | 2 ++ src/inject.ts | 18 ++++++++++-------- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index f3439aab9..e05fd5073 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -30,6 +30,8 @@ const nativeObjectFreeze = Object.freeze; const nativeReflectOwnKeys = Reflect.ownKeys; const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const hasNativeStructuredClone = typeof structuredClone === "function"; +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 @@ -90,8 +92,8 @@ export const Native = { structuredClone: typeof structuredClone === "function" ? structuredClone : unsupportedAPI, jsonStringify: nativeBind(JSON.stringify, JSON), jsonParse: nativeBind(JSON.parse, JSON), - createElement: Document.prototype.createElement, - ownFragment: new DocumentFragment(), + createElement: nativeDocumentCreateElement, + ownFragment: nativeOwnFragment, objectCreate: nativeBind(Object.create, Object), objectAssign: nativeBind(Object.assign, Object), arrayIsArray: nativeArrayIsArray, diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 61926f985..d06c1973f 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -274,6 +274,8 @@ export class ScriptRuntime { this.server.on("pageLoad", (data: { scripts: TScriptInfo[]; envInfo: GMInfoEnv }) => { 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 || {}; diff --git a/src/inject.ts b/src/inject.ts index f3fb02ba0..b00e5257a 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -90,14 +90,16 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde } }; - pageServer?.on("bootstrap", (data: { bootstrapToken?: unknown }) => { - if (typeof data?.bootstrapToken !== "string" || data.bootstrapToken.length === 0) return; - reconnectToken = data.bootstrapToken; - void openNativeChannel(data.bootstrapToken); - }); - pageServer?.on("pageLoad", (data) => { - runtime.receivePageLoad(data); - }); + if (pageServer) { + pageServer.on("bootstrap", (data: { bootstrapToken?: unknown }) => { + if (typeof data?.bootstrapToken !== "string" || data.bootstrapToken.length === 0) return; + reconnectToken = data.bootstrapToken; + void openNativeChannel(data.bootstrapToken); + }); + pageServer.on("pageLoad", (data) => { + runtime.receivePageLoad(data); + }); + } runtime.init(); // inject环境,直接判断白名单,注入对外接口 From 8df243d904c16315e3f3f4f75587e683ba617500 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:09:01 +0900 Subject: [PATCH 062/103] =?UTF-8?q?=F0=9F=94=92=20validate=20USER=5FSCRIPT?= =?UTF-8?q?=20bridge=20DTOs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/script_runtime.test.ts | 50 +++++++++++++ src/app/service/content/script_runtime.ts | 72 ++++++++++++------- src/content.ts | 34 ++------- 3 files changed, 100 insertions(+), 56 deletions(-) diff --git a/src/app/service/content/script_runtime.test.ts b/src/app/service/content/script_runtime.test.ts index 69b80b6f6..d52b7ae59 100644 --- a/src/app/service/content/script_runtime.test.ts +++ b/src/app/service/content/script_runtime.test.ts @@ -219,4 +219,54 @@ describe("ScriptRuntime inject page bootstrap", () => { 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 d06c1973f..72faa7d27 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -10,6 +10,7 @@ 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; @@ -45,7 +46,7 @@ const isPageResourceMap = (value: unknown): boolean => { return true; }; -const isInjectScriptInfo = (value: unknown): value is TScriptInfo => { +const isPageScriptInfo = (value: unknown, envTag: "it" | "ct"): value is TScriptInfo => { if (!isRecord(value)) return false; if ( typeof value.uuid !== "string" || @@ -59,7 +60,7 @@ const isInjectScriptInfo = (value: unknown): value is TScriptInfo => { !isPageResourceMap(value.resource) || (value.requireCssResource !== undefined && !isPageResourceMap(value.requireCssResource)) || !isExecutionToken(value.executionHandle) || - value.executionEnvTag !== "it" || + value.executionEnvTag !== envTag || !isExecutionToken(value.executionRunFlag) ) { return false; @@ -157,31 +158,58 @@ type InjectPageLoadData = { reconnectToken?: string; }; -const cloneInjectPageLoad = (data: unknown): InjectPageLoadData | undefined => { +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"]) || + !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) || cloned.scripts.length === 0) 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 (!isInjectScriptInfo(cloned.scripts[index])) return undefined; + 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(); @@ -309,33 +337,23 @@ export class ScriptRuntime { this.startScripts(safeData.scripts, safeData.envInfo); return safeData.reconnectToken; } - if (!isRecord(data) || !Native.objectHasOwn(data, "scripts") || !Native.objectHasOwn(data, "envInfo")) - return undefined; - const scripts = data.scripts; - const envInfo = data.envInfo; - if (!Native.arrayIsArray(scripts) || !isRecord(envInfo)) return undefined; - this.startScripts(scripts as TScriptInfo[], envInfo as unknown as GMInfoEnv); - return undefined; + 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 { - if (this.scripEnvTag === "it") { - const safeData = cloneInjectEmitEvent(data); - if (!safeData) return; - this.scriptExecutor.emitEvent(safeData); - return; - } - this.scriptExecutor.emitEvent(data as EmitEventRequest); + const safeData = cloneInjectEmitEvent(data); + if (!safeData) return; + this.scriptExecutor.emitEvent(safeData); } receiveValueUpdate(data: unknown): void { - if (this.scripEnvTag === "it") { - const safeData = cloneInjectValueUpdate(data); - if (!safeData) return; - this.scriptExecutor.valueUpdate(safeData); - return; - } - this.scriptExecutor.valueUpdate(data as ValueUpdateDataEncoded); + const safeData = cloneInjectValueUpdate(data); + if (!safeData) return; + this.scriptExecutor.valueUpdate(safeData); } externalMessage(messagePrefix = "scripting", message: Message = this.msg) { diff --git a/src/content.ts b/src/content.ts index 8eb1713f6..3f68af55c 100644 --- a/src/content.ts +++ b/src/content.ts @@ -10,9 +10,8 @@ import { ScriptRuntime } from "./app/service/content/script_runtime"; import { ScriptEnvTag } from "@Packages/message/consts"; import { type TExtensionEnv } from "./app/service/extension/extension_env"; import { connectUserScriptChannel, requestUserScriptReconnect } from "./app/service/content/user_script_connection"; -import type { TScriptInfo } from "./app/repo/scripts"; import type { GMInfoEnv } from "./app/service/content/types"; -import { setPageRpcExtensionOrigin, type ExtensionOrigin } from "./app/service/content/page_rpc"; +import type { ExtensionOrigin } from "./app/service/content/page_rpc"; const messageFlag = process.env.SC_RANDOM_KEY!; @@ -43,35 +42,12 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde let reconnectToken: string | undefined; const handleUserScriptPacket = (_connection: MessageConnect, packet: TMessage) => { if (packet.action === "content/pageLoad") { - const packetData = packet.data as { - scripts?: TScriptInfo[]; - envInfo?: GMInfoEnv; - extensionOrigin?: ExtensionOrigin; - reconnectToken?: unknown; - }; - if (!packetData || !Array.isArray(packetData.scripts) || packetData.scripts.length === 0 || !packetData.envInfo) { - return; - } - for (let i = 0; i < packetData.scripts.length; i += 1) { - const script = packetData.scripts[i]; - if ( - !script || - typeof script !== "object" || - script.executionEnvTag !== scriptEnvTag || - typeof script.executionHandle !== "string" - ) { - return; - } - } - if (typeof packetData.reconnectToken === "string" && packetData.reconnectToken.length > 0) { - reconnectToken = packetData.reconnectToken; - } - setPageRpcExtensionOrigin(packetData.extensionOrigin); - runtime.startScripts(packetData.scripts, packetData.envInfo); + const nextToken = runtime.receivePageLoad(packet.data); + if (nextToken) reconnectToken = nextToken; } else if (packet.action === "content/runtime/valueUpdate") { - scriptExecutor.valueUpdate(packet.data as any); + runtime.receiveValueUpdate(packet.data); } else if (packet.action === "content/runtime/emitEvent") { - scriptExecutor.emitEvent(packet.data as any); + runtime.receiveEmitEvent(packet.data); } }; const openUserScriptChannel = async (bootstrapToken: string): Promise => { From beb742c1f14f503b5dcbac5021d9ef03e8ef4294 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:14:37 +0900 Subject: [PATCH 063/103] =?UTF-8?q?=F0=9F=94=92=20bind=20CAT=20tasks=20to?= =?UTF-8?q?=20script=20owners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/agent/core/types.ts | 2 + src/app/service/agent/service_worker/agent.ts | 4 +- .../agent/service_worker/task_service.test.ts | 51 ++++++++++++++++++ .../agent/service_worker/task_service.ts | 52 ++++++++++++++----- .../service_worker/gm_api/gm_agent_task.ts | 2 +- 5 files changed, 94 insertions(+), 17 deletions(-) diff --git a/src/app/service/agent/core/types.ts b/src/app/service/agent/core/types.ts index c2f197d5e..408bd8139 100644 --- a/src/app/service/agent/core/types.ts +++ b/src/app/service/agent/core/types.ts @@ -640,6 +640,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. */ diff --git a/src/app/service/agent/service_worker/agent.ts b/src/app/service/agent/service_worker/agent.ts index 4ef1420f7..68afc9d84 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 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/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); } } From e2738c98ad7e4a24c1bb646a57e8e734056b05e1 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:30:04 +0900 Subject: [PATCH 064/103] =?UTF-8?q?=F0=9F=94=92=20bind=20CAT=20conversatio?= =?UTF-8?q?ns=20to=20script=20owners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/agent/core/types.ts | 5 +- src/app/service/agent/service_worker/agent.ts | 4 +- .../background_session_manager.test.ts | 45 ++++++++++ .../background_session_manager.ts | 14 ++- .../service/agent/service_worker/chat.test.ts | 86 ++++++++++++++++++- .../agent/service_worker/chat_service.ts | 55 +++++++++--- .../service/service_worker/gm_api/gm_agent.ts | 12 ++- .../service_worker/gm_api/gm_api.test.ts | 49 +++++++++++ 8 files changed, 251 insertions(+), 19 deletions(-) create mode 100644 src/app/service/agent/service_worker/background_session_manager.test.ts diff --git a/src/app/service/agent/core/types.ts b/src/app/service/agent/core/types.ts index 408bd8139..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. */ @@ -735,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 68afc9d84..507e694f9 100644 --- a/src/app/service/agent/service_worker/agent.ts +++ b/src/app/service/agent/service_worker/agent.ts @@ -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/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_api.test.ts b/src/app/service/service_worker/gm_api/gm_api.test.ts index 1fd0b82db..1c40f3404 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 @@ -9,6 +9,7 @@ import GMApi, { } from "./gm_api"; import { PermissionVerifyApiGet, type ConfirmParam } from "../permission_verify"; import type { GMApiRequest } from "../types"; +import GMAgentApi from "./gm_agent"; // 触发所有 GM API 装饰器注册(与 gm_api.ts 中的 import 保持同步) import "./gm_api"; @@ -124,6 +125,54 @@ 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("page execution binding gate", () => { it("rejects a page-originated request that has no binding handle", async () => { const api = Object.create(GMApi.prototype) as GMApi; From 283edd2394d0afa7bf056dbcc491c141f15dc3c5 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:35:57 +0900 Subject: [PATCH 065/103] =?UTF-8?q?=F0=9F=94=92=20bind=20USER=5FSCRIPT=20t?= =?UTF-8?q?okens=20to=20page=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/runtime.test.ts | 53 +++++++++++++++++++ src/app/service/service_worker/runtime.ts | 10 +++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index c751b49dc..04c8cc555 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1348,6 +1348,59 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { }); describe("USER_SCRIPT native callbacks", () => { + it("rejects bootstrap and reconnect tokens from a different URL when documentId is missing", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "url-bound-user-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const originalSender = { + url: "https://www.example.com/page", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const connection = { + onMessage: vi.fn(), + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: vi.fn(), + } as unknown as MessageConnect; + const bootstrapSender = { + getType: () => 3, + isType: (type: number) => type === 3, + getSender: () => originalSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0 }), + getConnect: () => connection, + getConnectOrigin: () => "userScript" as const, + }; + const pageLoad = await runtime.pageLoad({ envTag: "it" }, new SenderRuntime(originalSender)); + const bootstrapToken = pageLoad.ok ? pageLoad.userScriptInjectBootstrapToken : undefined; + expect(bootstrapToken).toEqual(expect.any(String)); + + const navigatedSender = { + ...bootstrapSender, + getSender: () => ({ ...originalSender, url: "https://www.example.com/next" }), + }; + expect(runtime.registerUserScriptConnection({ world: "MAIN", bootstrapToken }, navigatedSender)).toBe(false); + expect( + runtime.reconnectUserScript( + { reconnectToken: bootstrapToken }, + { + ...navigatedSender, + getType: () => 4, + isType: (type: number) => type === 4, + getConnect: () => undefined, + } + ) + ).toBeUndefined(); + }); + it("issues a separate MAIN bootstrap and routes its private callbacks over the native port", async () => { const { runtime } = _createRuntimeContext(); const script = _createScriptRunResource( diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 6e48ab886..4d369b33f 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -137,6 +137,7 @@ type UserScriptSession = { extensionOrigin?: ExtensionOrigin; reconnectToken: string; envTag: "it" | "ct"; + url: string; tabId: number; frameId?: number; documentId?: string; @@ -256,7 +257,7 @@ export class RuntimeService { /** Register the native USER_SCRIPT channel used for private bootstrap and callbacks. */ registerUserScriptConnection(data: unknown, sender: IGetSender): boolean { - // bootstrap token 只允许对应 tab/frame/document 使用一次,并且必须覆盖本次下发的全部句柄。 + // bootstrap token 只允许对应 tab/frame/document 使用一次;documentId 缺失时以 URL 作为文档身份,并且必须覆盖本次下发的全部句柄。 if (!sender.isType(GetSenderType.EXTCONNECT) || sender.getConnectOrigin?.() !== "userScript") return false; if (data === null || typeof data !== "object") return false; const handshake = data as { world?: unknown; bootstrapToken?: unknown }; @@ -277,7 +278,9 @@ export class RuntimeService { !bootstrap || bootstrap.tabId !== tabId || bootstrap.frameId !== source.frameId || - bootstrap.documentId !== source.documentId + bootstrap.documentId !== source.documentId || + (bootstrap.documentId === undefined && + (typeof source.url !== "string" || source.url.length === 0 || bootstrap.url !== source.url)) ) { return false; } @@ -367,6 +370,8 @@ export class RuntimeService { candidateSession.tabId === tabId && candidateSession.frameId === source.frameId && candidateSession.documentId === source.documentId && + (candidateSession.documentId !== undefined || + (typeof source.url === "string" && source.url.length > 0 && candidateSession.url === source.url)) && candidateSession.reconnectToken === (data as { reconnectToken: string }).reconnectToken ) { key = candidateKey; @@ -1712,6 +1717,7 @@ export class RuntimeService { extensionOrigin: getExtensionOrigin(), reconnectToken: token, envTag, + url, tabId, frameId, documentId: chromeSender.documentId, From c8516c94fd9ebabf9f09d64c50f3397f9c52acc3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:50:57 +0900 Subject: [PATCH 066/103] =?UTF-8?q?=F0=9F=94=92=20bind=20early-start=20met?= =?UTF-8?q?adata=20to=20injected=20wrappers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/script_executor.test.ts | 95 +++++++++++++++++-- src/app/service/content/script_executor.ts | 88 ++++++++--------- src/app/service/content/utils.test.ts | 9 +- src/app/service/content/utils.ts | 17 ++-- 4 files changed, 150 insertions(+), 59 deletions(-) diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index d2da26d4f..96955d226 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -4,6 +4,9 @@ import type { ScriptLoadInfo } from "../service_worker/types"; import type { TScriptInfo } from "@App/app/repo/scripts"; import type { GMInfoEnv } from "./types"; import { initEnvInfo, ScriptExecutor } from "./script_executor"; +import { compilePreInjectScript, preInjectScriptInfoKey } 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"; @@ -166,11 +169,12 @@ describe("ScriptExecutor", () => { try { pageWindow[script.flag] = attacker; - executor.execEarlyScript(script.flag, script, initEnvInfo); + executor.execEarlyScript(script.flag, initEnvInfo); expect(attacker).not.toHaveBeenCalled(); pageWindow[script.flag] = genuine; - executor.execEarlyScript(script.flag, script, initEnvInfo); + Object.defineProperty(genuine, preInjectScriptInfoKey, { value: JSON.stringify(script) }); + executor.execEarlyScript(script.flag, initEnvInfo); expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); } finally { delete pageWindow[script.flag]; @@ -180,15 +184,90 @@ describe("ScriptExecutor", () => { 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 genuine = vi.fn(); + const wrongUuid = vi.fn(); + const bound = vi.fn(); const pageWindow = window as unknown as Record; - Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + Object.defineProperty(wrongUuid, fnStrIntegrity, { value: true }); + Object.defineProperty(wrongUuid, preInjectScriptInfoKey, { + value: JSON.stringify({ ...script, uuid: "other-script" }), + }); + Object.defineProperty(bound, fnStrIntegrity, { value: true }); + Object.defineProperty(bound, preInjectScriptInfoKey, { + value: JSON.stringify({ ...script, executionHandle: "other-binding" }), + }); try { - pageWindow[script.flag] = genuine; - executor.execEarlyScript(script.flag, { ...script, uuid: "other-script" }, initEnvInfo); - executor.execEarlyScript(script.flag, { ...script, executionHandle: "other-binding" }, initEnvInfo); - expect(genuine).not.toHaveBeenCalled(); + 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"] }, + 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]; } diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 68b9b980f..28b7781e8 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -3,13 +3,13 @@ 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, preInjectScriptInfoKey, 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, Native } from "./global"; +import { customClone, localizeObject, Native } from "./global"; // 与编译器相同的构建级标记,用来拒绝页面伪造的脚本挂载函数。 const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; @@ -110,39 +110,18 @@ export class ScriptExecutor { // 监听 脚本加载 // 适用于此「通知环境加载完成」代码执行后的脚本加载 const scriptLoadCompleteHandler: EventListener = (ev: Event) => { - const detail = (ev as CustomEvent).detail as { - scriptFlag: string; - scriptInfo: ScriptLoadInfo; - }; - const scriptFlag = detail?.scriptFlag; - const scriptInfo = detail?.scriptInfo; - if ( - typeof scriptFlag === "string" && - scriptInfo && - typeof scriptInfo === "object" && - scriptInfo.flag === scriptFlag - ) { - ev.preventDefault(); // dispatchEvent 会回传 false -> 分离环境也能得知环境加载代码已执行 - // 检查是否有 urlPattern,有则执行匹配再决定是否略过注入 - if (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, scriptInfo.scriptUrlPatterns)) { - // 「多余的匹配」-> 略过注入 - return; - } - } catch (e) { - console.warn("Unexpected match error", e); - } - } - if (!this.earlyScriptFlags.has(scriptFlag)) this.execEarlyScript(scriptFlag, 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); @@ -152,21 +131,43 @@ export class ScriptExecutor { pageDispatchEvent(ev); } - execEarlyScript(flag: string, scriptInfo: TScriptInfo, envInfo: GMInfoEnv) { + execEarlyScript(flag: string, envInfo: GMInfoEnv) { + const scriptFunc = (window as unknown as Record)[flag] as ScriptFunc; + const descriptor = + typeof scriptFunc === "function" ? Native.objectGetOwnPropertyDescriptor(scriptFunc, fnStrIntegrity) : undefined; + if (descriptor?.value !== true || descriptor.configurable || descriptor.writable) return; + // 事件在页面可见,只用预注入函数上的不可改写清单作为脚本资料来源。 + const scriptInfoDescriptor = + typeof scriptFunc === "function" + ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptInfoKey) + : undefined; + if (!scriptInfoDescriptor || scriptInfoDescriptor.configurable || scriptInfoDescriptor.writable) return; + const scriptInfoJSON = scriptInfoDescriptor.value; + if (typeof scriptInfoJSON !== "string") return; + let scriptInfo: TScriptInfo | undefined; + try { + scriptInfo = customClone(Native.jsonParse(scriptInfoJSON)) as TScriptInfo | undefined; + } catch { + return; + } + if (!scriptInfo || scriptInfo.flag !== flag) return; const expectedUuid = flag.startsWith("#-") ? flag.slice(2) : undefined; - // early-start 事件来自页面,需同时确认脚本身份和未绑定状态,避免旧事件重放到新文档。 + if (expectedUuid && scriptInfo.uuid !== expectedUuid) return; if ( - (expectedUuid && scriptInfo.uuid !== expectedUuid) || scriptInfo.executionHandle !== undefined || scriptInfo.executionEnvTag !== undefined || scriptInfo.executionRunFlag !== undefined ) { return; } - const scriptFunc = (window as unknown as Record)[flag] as ScriptFunc; - const descriptor = - typeof scriptFunc === "function" ? Native.objectGetOwnPropertyDescriptor(scriptFunc, fnStrIntegrity) : undefined; - if (descriptor?.value !== true || descriptor.configurable || descriptor.writable) 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, @@ -174,6 +175,7 @@ export class ScriptExecutor { envInfo: envInfo, }); this.earlyScriptFlags.add(flag); + return true; } execScriptEntry(scriptEntry: ExecScriptEntry) { diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index e73da126c..cf1dd2d53 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -8,6 +8,7 @@ import { isScriptletUnwrap, addStyle, addStyleSheet, + preInjectScriptInfoKey, trimScriptInfo, } from "./utils"; import type { SCMetadata, ScriptLoadInfo, ScriptRunResource } from "@App/app/repo/scripts"; @@ -782,6 +783,11 @@ describe("utils", () => { ); const generated = targetWindow[script.flag] as ScriptFunc; + expect(Object.getOwnPropertyDescriptor(generated, preInjectScriptInfoKey)).toMatchObject({ + configurable: false, + writable: false, + value: expect.any(String), + }); const context = {}; const named = { value: 42 }; expect(generated(fnStrIntegrity, context, named, script.name)).toEqual({ @@ -825,8 +831,7 @@ describe("utils", () => { executeGeneratedScript(compilePreInjectScript(script, "return undefined;"), {}, testPerformance); - expect(detail?.scriptInfo.value).toEqual({}); - expect(detail?.scriptInfo.config).toBeUndefined(); + expect(detail).toEqual({ scriptFlag: script.flag }); }); it.concurrent("does not mount a regex-excluded early-start script", () => { diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 23b165f1f..47450ac82 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -17,6 +17,7 @@ const cloneTransportValue = (value: any) => { // 与 rspack 注入的构建级密钥配对;页面只能看到包装函数,拿不到正确的调用标记。 const lnStrIntegrity = process.env.SC_RANDOM_FNKEY; const znRand = process.env.SC_ZN_RAND; +export const preInjectScriptInfoKey = `${lnStrIntegrity}:scriptInfo`; export type CompileScriptCodeResource = { name: string; @@ -171,14 +172,18 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) return `${codeBody}${sourceMapTo(`${resource.name}.user.js`)}\n`; } -const codeFunction = (code: string) => { +const codeFunction = (code: string, scriptInfoJSON?: string) => { // 临时方法调用不依赖页面改写的 call、apply、bind;完整性标记也阻止页面直接调用包装器。 - return `((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true }); return f; })('${lnStrIntegrity}', '${znRand}' + Math.random(), function(){${code}})`; + const infoProperty = + scriptInfoJSON === undefined + ? "" + : ` Object.defineProperty(f, '${preInjectScriptInfoKey}', { value: ${JSON.stringify(scriptInfoJSON)} });`; + return `((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true });${infoProperty} return f; })('${lnStrIntegrity}', '${znRand}' + Math.random(), function(){${code}})`; }; // 有 setter 时沿用页面属性语义;否则用不可配置的一次性 getter,避免挂载函数被页面再次取走。 -const mountCodeFunction = (flag: string, code: string) => - `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, '${flag}', ${codeFunction(code)})`; +const mountCodeFunction = (flag: string, code: string, scriptInfoJSON?: string) => + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, '${flag}', ${codeFunction(code, scriptInfoJSON)})`; const ZFunction = Function; @@ -316,10 +321,10 @@ export function compilePreInjectScript( f = () => { if (!(${urlCondition})) return false; if (!mounted) { - ${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`)}; + ${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`, scriptInfoJSON)}; mounted = true; } - const o = { cancelable: true, detail: { scriptFlag: '${flag}', scriptInfo: (${scriptInfoJSON}) } }, + const o = { cancelable: true, detail: { scriptFlag: '${flag}' } }, c = typeof cloneInto === "function" ? cloneInto(o, performance) : o; return performance.dispatchEvent(new CustomEvent('${evScriptLoad}', c)); }, From 20d39afaf97550af51c128142b5f7239a6593cff Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:11:40 +0900 Subject: [PATCH 067/103] =?UTF-8?q?=F0=9F=94=92=20restore=20early-start=20?= =?UTF-8?q?manifest=20execution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/script_executor.test.ts | 17 +++++++++++++++++ src/app/service/content/script_executor.ts | 13 +++++++++---- src/app/service/content/utils.ts | 2 +- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index 96955d226..fc74a2277 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -273,6 +273,23 @@ describe("ScriptExecutor", () => { } }); + it("accepts the immutable early manifest through the wrapper name fallback", () => { + const script = makeScript({ uuid: "executor-early-name-uuid", flag: "#-executor-early-name-uuid" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const genuine = vi.fn(); + const pageWindow = window as unknown as Record; + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + Object.defineProperty(genuine, "name", { configurable: false, value: JSON.stringify(script) }); + + try { + pageWindow[script.flag] = genuine; + executor.execEarlyScript(script.flag, initEnvInfo); + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); + } finally { + delete pageWindow[script.flag]; + } + }); + it("continues loading later scripts after reconciling an early-start entry", () => { const early = makeScript({ uuid: "early-script", diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 28b7781e8..3fc61df2c 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -9,7 +9,7 @@ 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 { customClone, localizeObject, Native } from "./global"; +import { localizeObject, Native } from "./global"; // 与编译器相同的构建级标记,用来拒绝页面伪造的脚本挂载函数。 const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; @@ -141,12 +141,17 @@ export class ScriptExecutor { typeof scriptFunc === "function" ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptInfoKey) : undefined; - if (!scriptInfoDescriptor || scriptInfoDescriptor.configurable || scriptInfoDescriptor.writable) return; - const scriptInfoJSON = scriptInfoDescriptor.value; + if (scriptInfoDescriptor?.configurable || scriptInfoDescriptor?.writable) return; + const scriptInfoJSON = + typeof scriptInfoDescriptor?.value === "string" + ? scriptInfoDescriptor.value + : typeof scriptFunc.name === "string" + ? scriptFunc.name + : undefined; if (typeof scriptInfoJSON !== "string") return; let scriptInfo: TScriptInfo | undefined; try { - scriptInfo = customClone(Native.jsonParse(scriptInfoJSON)) as TScriptInfo | undefined; + scriptInfo = Native.jsonParse(scriptInfoJSON) as TScriptInfo | undefined; } catch { return; } diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 47450ac82..622911dcc 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -177,7 +177,7 @@ const codeFunction = (code: string, scriptInfoJSON?: string) => { const infoProperty = scriptInfoJSON === undefined ? "" - : ` Object.defineProperty(f, '${preInjectScriptInfoKey}', { value: ${JSON.stringify(scriptInfoJSON)} });`; + : ` Object.defineProperty(f, '${preInjectScriptInfoKey}', { value: ${JSON.stringify(scriptInfoJSON)} }); Object.defineProperty(f, 'name', { configurable: false, value: ${JSON.stringify(scriptInfoJSON)} });`; return `((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true });${infoProperty} return f; })('${lnStrIntegrity}', '${znRand}' + Math.random(), function(){${code}})`; }; From 3372fbe72929c2e37a524201fed54b388d28d9b7 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:17:44 +0900 Subject: [PATCH 068/103] =?UTF-8?q?=F0=9F=94=92=20retire=20stale=20page=20?= =?UTF-8?q?bindings=20on=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/runtime.test.ts | 33 ++++++++++++++++++- src/app/service/service_worker/runtime.ts | 15 ++++----- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 04c8cc555..1e696b3eb 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1223,7 +1223,7 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { expect(secondRunFlag).toEqual(expect.any(String)); expect(secondHandle).not.toBe(firstHandle); expect(secondRunFlag).not.toBe(firstRunFlag); - expect(runtime.resolvePageExecutionBinding(firstHandle!, sender)).toBeDefined(); + expect(runtime.resolvePageExecutionBinding(firstHandle!, sender)).toBeUndefined(); expect(runtime.resolvePageExecutionBinding(secondHandle!, secondSender)).toBeDefined(); runtime.revokePageBindingsForTab(41); @@ -1231,6 +1231,37 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { expect(runtime.resolvePageExecutionBinding(secondHandle!, secondSender)).toBeUndefined(); }); + it("新文档加载时撤销上一文档的执行绑定", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource(_createMockScript({ uuid: "navigation-bound-script" })); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + const firstRawSender = { + url: "https://www.example.com/first", + frameId: 0, + documentId: "doc-first", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const firstSender = new SenderRuntime(firstRawSender); + const firstLoad = await runtime.pageLoad(undefined, firstSender); + expect(firstLoad.ok).toBe(true); + if (!firstLoad.ok) return; + const firstHandle = firstLoad.injectScriptList[0].executionHandle; + expect(runtime.resolvePageExecutionBinding(firstHandle!, firstSender)).toBeDefined(); + + const secondRawSender = { ...firstRawSender, url: "https://www.example.com/second", documentId: "doc-second" }; + const secondSender = new SenderRuntime(secondRawSender); + const secondLoad = await runtime.pageLoad(undefined, secondSender); + expect(secondLoad.ok).toBe(true); + if (!secondLoad.ok) return; + + expect(runtime.resolvePageExecutionBinding(firstHandle!, firstSender)).toBeUndefined(); + }); + it("rejects a stale URL when the browser omits documentId", async () => { const { runtime } = _createRuntimeContext(); const script = _createScriptRunResource( diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 4d369b33f..6d654e04c 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -179,33 +179,30 @@ export class RuntimeService { } private revokePageBindings(sender: IGetSender, envTag?: "it" | "ct"): void { - // documentId 缺失时仍按 tab/frame 退休旧绑定,避免新页面继承上一文档的授权。 + // pageLoad 是文档切换信号;按 tab/frame 退休旧句柄,避免旧文档继续使用上一页的权限。 const source = sender.getSender(); const tabId = source?.tab?.id; const frameId = source?.frameId; - const documentId = source?.documentId; for (const [handle, binding] of this.pageExecutionBindings) { if ( binding.tabId === tabId && binding.frameId === frameId && - (envTag === undefined || binding.envTag === envTag || (envTag === "it" && binding.envTag === "ct")) && - (documentId === undefined || binding.documentId === documentId) + (envTag === undefined || binding.envTag === envTag || (envTag === "it" && binding.envTag === "ct")) ) { this.pageExecutionBindings.delete(handle); } } if (envTag === "it") { for (const [key, entry] of this.userScriptConnections) { - if ( - entry.tabId === tabId && - entry.frameId === frameId && - (documentId === undefined || entry.documentId === documentId) - ) { + if (entry.tabId === tabId && entry.frameId === frameId) { entry.connection.disconnect(true); this.userScriptConnections.delete(key); this.userScriptSessions.delete(key); } } + for (const [key, session] of this.userScriptSessions) { + if (session.tabId === tabId && session.frameId === frameId) this.userScriptSessions.delete(key); + } } if (envTag !== "ct") { for (const [token, bootstrap] of this.userScriptBootstraps) { From f371261aa74f3cf0461114be1ce62688fd1f6340 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:22:54 +0900 Subject: [PATCH 069/103] =?UTF-8?q?=F0=9F=94=92=20bind=20CAT=20agent=20ser?= =?UTF-8?q?vice=20identities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/gm_api/gm_agent_dom.ts | 2 +- .../service_worker/gm_api/gm_agent_model.ts | 2 +- .../service_worker/gm_api/gm_agent_opfs.ts | 2 +- .../service_worker/gm_api/gm_agent_skills.ts | 2 +- .../service_worker/gm_api/gm_api.test.ts | 44 +++++++++++++++++++ 5 files changed, 48 insertions(+), 4 deletions(-) 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_api.test.ts b/src/app/service/service_worker/gm_api/gm_api.test.ts index 1c40f3404..094a9f46d 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 @@ -10,6 +10,10 @@ import GMApi, { 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"; @@ -173,6 +177,46 @@ describe("CAT.agent.conversation identity binding", () => { }); }); +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; From 3ab6b2dcb276541377d9df2f33f1b681dd49a290 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:26:41 +0900 Subject: [PATCH 070/103] =?UTF-8?q?=F0=9F=94=92=20target=20private=20callb?= =?UTF-8?q?acks=20to=20frame=20zero?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/extension_message.test.ts | 19 ++++++++++++++++++- packages/message/extension_message.ts | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/message/extension_message.test.ts b/packages/message/extension_message.test.ts index efae4b4fc..9b510a051 100644 --- a/packages/message/extension_message.test.ts +++ b/packages/message/extension_message.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { ExtensionMessage, ExtensionMessageConnect } from "./extension_message"; +import { ExtensionContentMessageSend, ExtensionMessage, ExtensionMessageConnect } from "./extension_message"; describe("ExtensionMessage USER_SCRIPT compatibility", () => { it("does not require unavailable runtime event listeners", () => { @@ -64,4 +64,21 @@ describe("ExtensionMessage USER_SCRIPT compatibility", () => { 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 2fcd610a6..06fac671b 100644 --- a/packages/message/extension_message.ts +++ b/packages/message/extension_message.ts @@ -285,7 +285,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; From 2f5b1fb424940283269a6219c26652c62e2d83e9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:05:51 +0900 Subject: [PATCH 071/103] =?UTF-8?q?=F0=9F=94=92=20bind=20DOM=20monitors=20?= =?UTF-8?q?to=20script=20owners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/agent/service_worker/dom.test.ts | 8 ++++++ src/app/service/agent/service_worker/dom.ts | 20 ++++++++------- .../agent/service_worker/dom_cdp.test.ts | 25 ++++++++++++++++++- .../service/agent/service_worker/dom_cdp.ts | 25 +++++++++++++------ 4 files changed, 61 insertions(+), 17 deletions(-) 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..3a84003b5 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,27 @@ 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 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..0a0334f30 100644 --- a/src/app/service/agent/service_worker/dom_cdp.ts +++ b/src/app/service/agent/service_worker/dom_cdp.ts @@ -16,6 +16,7 @@ type CapturedNode = { }; type MonitorSession = { + ownerScriptUuid?: string; dialogs: Array<{ type: string; message: string }>; capturedNodes: CapturedNode[]; // 从事件中直接提取的节点信息 listener: MonitorEventListener; @@ -239,10 +240,14 @@ export async function cdpScreenshot(tabId: number, options?: ScreenshotOptions): // ---- 页面监控(startMonitor / stopMonitor) ---- // 启动页面监控:attach debugger,纯 CDP 事件监听(dialog + DOM 变化),零注入 -export async function cdpStartMonitor(tabId: number): Promise { +export async function cdpStartMonitor(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 cdpStopMonitor(tabId, ownerScriptUuid); } const dialogs: Array<{ type: string; message: string }> = []; @@ -292,13 +297,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 +323,11 @@ function stripHtmlTags(html: string): string { } // 停止监控:纯 CDP 解析新增节点 → 收集结果 → detach -export async function cdpStopMonitor(tabId: number): Promise { +export async function cdpStopMonitor(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: [], From 1f769a6c26764375b5bda24bf283fcb70eb364b0 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:14:18 +0900 Subject: [PATCH 072/103] =?UTF-8?q?=F0=9F=94=92=20gate=20MAIN=20page=20boo?= =?UTF-8?q?tstrap=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/extension_message.ts | 1 + .../content/main_world_page_load_gate.test.ts | 60 +++++++++++++++++++ .../content/main_world_page_load_gate.ts | 42 +++++++++++++ src/inject.ts | 21 ++++--- 4 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 src/app/service/content/main_world_page_load_gate.test.ts create mode 100644 src/app/service/content/main_world_page_load_gate.ts diff --git a/packages/message/extension_message.ts b/packages/message/extension_message.ts index 06fac671b..6d39dcf50 100644 --- a/packages/message/extension_message.ts +++ b/packages/message/extension_message.ts @@ -18,6 +18,7 @@ const nativeRuntimeConnect = typeof runtimeApi?.connect === "function" ? runtimeApi.connect.bind(runtimeApi) : undefined; const nativeRuntimeSendMessage = typeof runtimeApi?.sendMessage === "function" ? runtimeApi.sendMessage.bind(runtimeApi) : undefined; +export const hasNativeRuntimeChannel = nativeRuntimeConnect !== undefined && nativeRuntimeSendMessage !== undefined; export class ExtensionMessage implements Message { constructor(private backgroundPrimary = false) {} diff --git a/src/app/service/content/main_world_page_load_gate.test.ts b/src/app/service/content/main_world_page_load_gate.test.ts new file mode 100644 index 000000000..9a273a4ed --- /dev/null +++ b/src/app/service/content/main_world_page_load_gate.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import { createMainWorldPageLoadGate } from "./main_world_page_load_gate"; + +describe("createMainWorldPageLoadGate", () => { + it("does not deliver the page-visible payload while the native channel is opening", async () => { + let resolveNative!: (connected: boolean) => void; + const openNativeChannel = vi.fn( + () => + new Promise((resolve) => { + resolveNative = resolve; + }) + ); + const receivePageLoad = vi.fn(); + const gate = createMainWorldPageLoadGate(openNativeChannel, receivePageLoad); + + gate.onPageLoad({ source: "page" }); + gate.onBootstrap("bootstrap-token"); + + expect(openNativeChannel).toHaveBeenCalledWith("bootstrap-token"); + expect(receivePageLoad).not.toHaveBeenCalled(); + + resolveNative(true); + await Promise.resolve(); + expect(receivePageLoad).not.toHaveBeenCalled(); + + gate.onPageLoad({ source: "replay" }); + expect(receivePageLoad).not.toHaveBeenCalled(); + }); + + it("releases one queued payload only when native transport is unavailable", async () => { + const receivePageLoad = vi.fn(); + const gate = createMainWorldPageLoadGate(async () => false, receivePageLoad); + const first = { source: "page" }; + const second = { source: "page-after-fallback" }; + + gate.onPageLoad(first); + gate.onBootstrap("bootstrap-token"); + await Promise.resolve(); + + expect(receivePageLoad).toHaveBeenCalledWith(first); + + gate.onPageLoad(second); + expect(receivePageLoad).toHaveBeenLastCalledWith(second); + expect(receivePageLoad).toHaveBeenCalledTimes(2); + }); + + it("does not reopen or fall back after the native channel has been selected", async () => { + const receivePageLoad = vi.fn(); + const openNativeChannel = vi.fn(async () => true); + const gate = createMainWorldPageLoadGate(openNativeChannel, receivePageLoad); + + gate.onBootstrap("first-token"); + await Promise.resolve(); + gate.onBootstrap("second-token"); + gate.onPageLoad({ source: "replay" }); + + expect(openNativeChannel).toHaveBeenCalledOnce(); + expect(receivePageLoad).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/service/content/main_world_page_load_gate.ts b/src/app/service/content/main_world_page_load_gate.ts new file mode 100644 index 000000000..9d937cc70 --- /dev/null +++ b/src/app/service/content/main_world_page_load_gate.ts @@ -0,0 +1,42 @@ +type MainWorldPageLoadGateState = "waiting" | "opening" | "native" | "fallback"; + +export type MainWorldPageLoadGate = { + onBootstrap: (bootstrapToken: string) => void; + onPageLoad: (data: unknown) => void; +}; + +export const createMainWorldPageLoadGate = ( + openNativeChannel: (bootstrapToken: string) => Promise, + receivePageLoad: (data: unknown) => void +): MainWorldPageLoadGate => { + let state: MainWorldPageLoadGateState = "waiting"; + let pendingPageLoad: unknown; + let hasPendingPageLoad = false; + + const finishOpening = (connected: boolean): void => { + if (state !== "opening") return; + state = connected ? "native" : "fallback"; + if (state === "fallback" && hasPendingPageLoad) { + receivePageLoad(pendingPageLoad); + } + pendingPageLoad = undefined; + hasPendingPageLoad = false; + }; + + return { + onBootstrap(bootstrapToken) { + if (state !== "waiting") return; + state = "opening"; + void openNativeChannel(bootstrapToken).then(finishOpening, () => finishOpening(false)); + }, + onPageLoad(data) { + if (state === "fallback") { + receivePageLoad(data); + return; + } + if (state === "native") return; + pendingPageLoad = data; + hasPendingPageLoad = true; + }, + }; +}; diff --git a/src/inject.ts b/src/inject.ts index b00e5257a..44619cc9c 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -2,7 +2,7 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; import { PageMessage } from "@Packages/message/page_message"; -import { ExtensionMessage } from "@Packages/message/extension_message"; +import { ExtensionMessage, hasNativeRuntimeChannel } from "@Packages/message/extension_message"; import { Server } from "@Packages/message/server"; import { ScriptExecutor } from "./app/service/content/script_executor"; import type { Message } from "@Packages/message/types"; @@ -12,6 +12,7 @@ import { ScriptEnvTag } from "@Packages/message/consts"; import { type TExtensionEnv } from "./app/service/extension/extension_env"; import { connectUserScriptChannel, requestUserScriptReconnect } from "./app/service/content/user_script_connection"; import type { MessageConnect, TMessage } from "@Packages/message/types"; +import { createMainWorldPageLoadGate } from "./app/service/content/main_world_page_load_gate"; const messageFlag = process.env.SC_RANDOM_KEY!; @@ -21,10 +22,7 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde const pageMsg: Message = new PageMessage(eventFlag, "inject"); const nativeMsg: Message = new ExtensionMessage(false); // 特权 GM RPC 使用浏览器标记的 USER_SCRIPT 来源;页面桥只保留 bootstrap 与 DOM 引用辅助。 - const canUseNativeChannel = - typeof chrome !== "undefined" && - typeof chrome.runtime?.connect === "function" && - typeof chrome.runtime?.sendMessage === "function"; + const canUseNativeChannel = hasNativeRuntimeChannel; const msg: Message = canUseNativeChannel ? nativeMsg : pageMsg; // 初始化日志组件 @@ -60,8 +58,8 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde } }; - const openNativeChannel = async (bootstrapToken: string): Promise => { - if (openingNative || nativeConnection) return; + const openNativeChannel = async (bootstrapToken: string): Promise => { + if (openingNative || nativeConnection) return Boolean(nativeConnection); openingNative = true; let connection: MessageConnect | undefined; try { @@ -83,22 +81,23 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde "MAIN" ); nativeConnection = connection; + return connection !== undefined; } catch (error) { logger.logger().debug("MAIN USER_SCRIPT channel failed", { error: String(error) }); + return false; } finally { openingNative = false; } }; if (pageServer) { + const pageLoadGate = createMainWorldPageLoadGate(openNativeChannel, (data) => runtime.receivePageLoad(data)); pageServer.on("bootstrap", (data: { bootstrapToken?: unknown }) => { if (typeof data?.bootstrapToken !== "string" || data.bootstrapToken.length === 0) return; reconnectToken = data.bootstrapToken; - void openNativeChannel(data.bootstrapToken); - }); - pageServer.on("pageLoad", (data) => { - runtime.receivePageLoad(data); + pageLoadGate.onBootstrap(data.bootstrapToken); }); + pageServer.on("pageLoad", pageLoadGate.onPageLoad); } runtime.init(); From 2f8dcd698dd01bafedf980ca6a07485fc06248cb Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:31:44 +0900 Subject: [PATCH 073/103] =?UTF-8?q?=F0=9F=94=92=20preserve=20USER=5FSCRIPT?= =?UTF-8?q?=20native=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 4 +- packages/message/extension_message.test.ts | 39 +++++++++++++++++++ packages/message/extension_message.ts | 17 ++++++-- .../content/user_script_connection.test.ts | 25 ++++++++++-- .../service/content/user_script_connection.ts | 16 +++++--- .../service/service_worker/runtime.test.ts | 15 ++++++- src/app/service/service_worker/runtime.ts | 21 ++++++---- 7 files changed, 114 insertions(+), 23 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index bc812f73f..3b33262f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,7 +86,7 @@ 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) | `USER_SCRIPT` world. Uses a native extension channel for bootstrap, GM RPC, value updates, and callbacks; retains a narrow DOM channel for synchronous node helpers. | `ExtensionMessage` + native callback port → `Server("content")` → `ScriptRuntime`; `CustomEventMessage` only for DOM handles | +| **Content** | [`src/content.ts`](../src/content.ts) | `USER_SCRIPT` world. Uses a native extension channel for bootstrap, GM RPC, value updates, and callbacks; dedicated USER_SCRIPT listeners are preferred, with a token-bound regular port fallback when they cannot be registered. Retains a narrow DOM channel for synchronous node helpers. | `ExtensionMessage` + native callback port → `Server("content")` → `ScriptRuntime`; `CustomEventMessage` only for DOM handles | | **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` | @@ -167,7 +167,7 @@ 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) | +| `ExtensionMessage` | [`extension_message.ts`](../packages/message/extension_message.ts) | SW ↔ Content / Inject / Offscreen | `chrome.runtime.sendMessage` / `onConnect` (+ `onUserScript*` on Firefox; token-bound regular-port fallback when dedicated listeners are unavailable) | | `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` | | `ServiceWorkerMessageSend` | [`window_message.ts`](../packages/message/window_message.ts) | SW → Offscreen (Chrome) | `clients.matchAll()` + `postMessage` | diff --git a/packages/message/extension_message.test.ts b/packages/message/extension_message.test.ts index 9b510a051..a547f7eb3 100644 --- a/packages/message/extension_message.test.ts +++ b/packages/message/extension_message.test.ts @@ -2,6 +2,45 @@ 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; diff --git a/packages/message/extension_message.ts b/packages/message/extension_message.ts index 6d39dcf50..4d4646705 100644 --- a/packages/message/extension_message.ts +++ b/packages/message/extension_message.ts @@ -21,6 +21,9 @@ const nativeRuntimeSendMessage = export const hasNativeRuntimeChannel = nativeRuntimeConnect !== undefined && nativeRuntimeSendMessage !== undefined; export class ExtensionMessage implements Message { + private userScriptConnectionListenerReady = false; + private userScriptMessageListenerReady = false; + constructor(private backgroundPrimary = false) {} connect(data: TMessage): Promise { @@ -100,14 +103,17 @@ export class ExtensionMessage implements Message { 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 在初始化时就能监听 @@ -139,7 +145,7 @@ export class ExtensionMessage implements Message { ) { this.tryEnableUserScriptConnectionListener(); this.tryEnableUserScriptMessageListener(); - sendResponse(true); + sendResponse(this.userScriptConnectionListenerReady && this.userScriptMessageListenerReady); } else { sendResponse(false); } @@ -164,21 +170,24 @@ export class ExtensionMessage implements Message { if ((msg as any)?.type === "userScripts.LISTEN_CONNECTIONS" && this.backgroundPrimary) { this.tryEnableUserScriptConnectionListener(); this.tryEnableUserScriptMessageListener(); - sendResponse(true); + 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 在初始化时就能监听 diff --git a/src/app/service/content/user_script_connection.test.ts b/src/app/service/content/user_script_connection.test.ts index 9cfc3ea8b..8689a7424 100644 --- a/src/app/service/content/user_script_connection.test.ts +++ b/src/app/service/content/user_script_connection.test.ts @@ -46,14 +46,33 @@ describe("connectUserScriptChannel", () => { }); }); - it("does not open a port when the browser cannot enable USER_SCRIPT listeners", async () => { + it("returns no channel when the browser cannot enable any runtime port", async () => { const message = { sendMessage: vi.fn().mockResolvedValue(false), - connect: vi.fn(), + 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).not.toHaveBeenCalled(); + 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("reports remote disconnects so the caller can reconnect natively", async () => { diff --git a/src/app/service/content/user_script_connection.ts b/src/app/service/content/user_script_connection.ts index 6a4466341..b3fdb30a4 100644 --- a/src/app/service/content/user_script_connection.ts +++ b/src/app/service/content/user_script_connection.ts @@ -21,11 +21,17 @@ export async function connectUserScriptChannel( world: UserScriptWorld = "USER_SCRIPT" ): Promise { const enabled = await message.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" } as unknown as TMessage); - if (enabled === false) return undefined; - const connection = await message.connect({ - action: "serviceWorker/runtime/registerUserScript", - data: { world, bootstrapToken }, - }); + let connection: MessageConnect; + try { + // 缺少专用 USER_SCRIPT 监听器时仍使用扩展原生端口;服务端会用文档绑定的令牌限制该降级路径。 + connection = await message.connect({ + action: "serviceWorker/runtime/registerUserScript", + data: enabled === false ? { world, bootstrapToken, transport: "extension" } : { world, bootstrapToken }, + }); + } catch (error) { + if (enabled !== false) throw error; + return undefined; + } connection.onMessage((packet) => onPacket(connection, packet)); if (onDisconnect) connection.onDisconnect(onDisconnect); connection.sendMessage({ action: "userScript/bootstrap" }); diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 1e696b3eb..b338c03d4 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1563,8 +1563,19 @@ describe("USER_SCRIPT native callbacks", () => { { ...connectionSender, getConnectOrigin: () => "extension" as const } ) ).toBe(false); + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", bootstrapToken, transport: "extension" }, + connectionSender + ) + ).toBe(false); + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", bootstrapToken, transport: "extension" }, + { ...connectionSender, getConnectOrigin: () => "extension" as const } + ) + ).toBe(true); expect(runtime.registerUserScriptConnection({ world: "USER_SCRIPT" }, connectionSender)).toBe(false); - expect(runtime.registerUserScriptConnection({ world: "USER_SCRIPT", bootstrapToken }, connectionSender)).toBe(true); const bootstrapHandler = onMessage.mock.calls[0]?.[0] as ((packet: TMessage) => void) | undefined; bootstrapHandler?.({ action: "userScript/bootstrap" }); expect(sendMessage).toHaveBeenCalledWith( @@ -1597,7 +1608,7 @@ describe("USER_SCRIPT native callbacks", () => { getSender: () => rawSender, getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), getConnect: () => undefined, - getConnectOrigin: () => "userScript" as const, + getConnectOrigin: () => "extension" as const, } ); expect(reconnect).toEqual({ bootstrapToken: expect.any(String) }); diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 6d654e04c..486b2f021 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -141,7 +141,9 @@ type UserScriptSession = { tabId: number; frameId?: number; documentId?: string; + transport: "userScript" | "extension"; }; +type UserScriptBootstrap = Omit; const bgScriptStorageNames = new Set(); @@ -168,7 +170,7 @@ export class RuntimeService { documentId?: string; } >(); - private readonly userScriptBootstraps = new Map(); + private readonly userScriptBootstraps = new Map(); // 连接断开后保留当前文档的已验证资料,供 USER_SCRIPT 通过原生消息重连;导航或脚本撤销会同步清除。 private readonly userScriptSessions = new Map(); // Only the newest load for a tab/frame/environment may issue bindings; navigation can resolve old requests late. @@ -252,14 +254,17 @@ export class RuntimeService { return `${tabId}:${frameId ?? -1}:${documentId ?? ""}:${envTag}`; } - /** Register the native USER_SCRIPT channel used for private bootstrap and callbacks. */ + /** Register the native USER_SCRIPT channel used for private bootstrap and callbacks; fallback ports remain token-bound. */ registerUserScriptConnection(data: unknown, sender: IGetSender): boolean { // bootstrap token 只允许对应 tab/frame/document 使用一次;documentId 缺失时以 URL 作为文档身份,并且必须覆盖本次下发的全部句柄。 - if (!sender.isType(GetSenderType.EXTCONNECT) || sender.getConnectOrigin?.() !== "userScript") return false; + if (!sender.isType(GetSenderType.EXTCONNECT)) return false; if (data === null || typeof data !== "object") return false; - const handshake = data as { world?: unknown; bootstrapToken?: unknown }; + const handshake = data as { world?: unknown; bootstrapToken?: unknown; transport?: unknown }; + const origin = sender.getConnectOrigin?.(); + const isExtensionFallback = origin === "extension" && handshake.transport === "extension"; + if (origin === "userScript" ? handshake.transport !== undefined : !isExtensionFallback) return false; if ( - Object.keys(data).length !== 2 || + Object.keys(data).length !== (isExtensionFallback ? 3 : 2) || typeof handshake.bootstrapToken !== "string" || handshake.bootstrapToken.length === 0 || handshake.bootstrapToken.length > 256 @@ -304,7 +309,8 @@ export class RuntimeService { const frameId = source.frameId; const documentId = source.documentId; const key = this.userScriptConnectionKey(tabId, frameId, documentId, bootstrap.envTag); - this.userScriptSessions.set(key, bootstrap); + const session = { ...bootstrap, transport: isExtensionFallback ? ("extension" as const) : ("userScript" as const) }; + this.userScriptSessions.set(key, session); this.userScriptBootstraps.delete(handshake.bootstrapToken); const previous = this.userScriptConnections.get(key); if (previous) previous.connection.disconnect(true); @@ -344,7 +350,7 @@ export class RuntimeService { } reconnectUserScript(data: unknown, sender: IGetSender): { bootstrapToken: string } | undefined { - if (!sender.isType(GetSenderType.RUNTIME) || sender.getConnectOrigin?.() !== "userScript") { + if (!sender.isType(GetSenderType.RUNTIME)) { return undefined; } if ( @@ -377,6 +383,7 @@ export class RuntimeService { } } if (!key || !session) return undefined; + if (sender.getConnectOrigin?.() !== session.transport) return undefined; for (const script of session.scripts) { const handle = script.executionHandle; const binding = typeof handle === "string" ? this.pageExecutionBindings.get(handle) : undefined; From 901016d6b78af4941abafc48816c68cadc0d1fe3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:38:57 +0900 Subject: [PATCH 074/103] =?UTF-8?q?=F0=9F=94=92=20queue=20USER=5FSCRIPT=20?= =?UTF-8?q?value=20updates=20across=20reconnects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/runtime.test.ts | 112 ++++++++++++++++++ src/app/service/service_worker/runtime.ts | 87 +++++++++++++- 2 files changed, 197 insertions(+), 2 deletions(-) diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index b338c03d4..4a07fbe65 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1516,6 +1516,118 @@ describe("USER_SCRIPT native callbacks", () => { }); }); + it("queues USER_SCRIPT value updates until a reconnect finishes its bootstrap", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "queued-content-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [], + contentScriptList: [script], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const rawSender = { + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const makeConnection = () => + ({ + onMessage: vi.fn(), + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: vi.fn(), + }) as unknown as MessageConnect; + const firstConnection = makeConnection(); + const sender = { + getType: () => 3, + isType: () => true, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => firstConnection, + getConnectOrigin: () => "userScript" as const, + }; + const pageLoad = await runtime.pageLoad({ envTag: "it" }, new SenderRuntime(rawSender)); + const contentBootstrapToken = pageLoad.ok ? pageLoad.userScriptBootstrapToken : undefined; + expect(contentBootstrapToken).toEqual(expect.any(String)); + expect( + runtime.registerUserScriptConnection({ world: "USER_SCRIPT", bootstrapToken: contentBootstrapToken }, sender) + ).toBe(true); + + const update = { + uuid: script.uuid, + storageName: getStorageName(script), + entries: [["beforeReconnect", [0, "new"], [0, "old"]]], + sender: { runFlag: "remote", tabId: 42 }, + valueUpdated: true, + }; + (runtime as any).sendUserScriptMessage(undefined, "runtime/valueUpdate", update); + expect(firstConnection.sendMessage).not.toHaveBeenCalled(); + + const firstBootstrapHandler = (firstConnection.onMessage as ReturnType).mock.calls[0][0] as ( + packet: TMessage + ) => void; + firstBootstrapHandler({ action: "userScript/bootstrap" }); + expect(firstConnection.sendMessage).toHaveBeenCalledTimes(2); + expect(firstConnection.sendMessage).toHaveBeenLastCalledWith({ + action: "content/runtime/valueUpdate", + data: update, + }); + + const disconnectHandler = (firstConnection.onDisconnect as ReturnType).mock.calls[0][0] as ( + isSelfDisconnected: boolean + ) => void; + disconnectHandler(false); + (runtime as any).sendUserScriptMessage(undefined, "runtime/valueUpdate", { + ...update, + entries: [["afterReconnect", [0, "next"], [0, "old-next"]]], + }); + (runtime as any).sendUserScriptMessage(undefined, "runtime/valueUpdate", { + ...update, + entries: [["afterReconnectAgain", [0, "latest"], [0, "old-latest"]]], + }); + const reconnect = runtime.reconnectUserScript( + { reconnectToken: contentBootstrapToken }, + { + getType: () => 4, + isType: (type: number) => type === 4, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => undefined, + getConnectOrigin: () => "userScript" as const, + } + ); + expect(reconnect).toEqual({ bootstrapToken: expect.any(String) }); + + const secondConnection = makeConnection(); + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", bootstrapToken: reconnect?.bootstrapToken }, + { ...sender, getConnect: () => secondConnection } + ) + ).toBe(true); + const secondBootstrapHandler = (secondConnection.onMessage as ReturnType).mock.calls[0][0] as ( + packet: TMessage + ) => void; + secondBootstrapHandler({ action: "userScript/bootstrap" }); + + expect(secondConnection.sendMessage).toHaveBeenCalledTimes(2); + expect(secondConnection.sendMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + action: "content/runtime/valueUpdate", + data: expect.objectContaining({ + entries: [ + ["afterReconnect", [0, "next"], [0, "old-next"]], + ["afterReconnectAgain", [0, "latest"], [0, "old-latest"]], + ], + }), + }) + ); + }); + it("只向当前文档中声明了对应脚本或 storageName 的连接投递更新", async () => { const { runtime } = _createRuntimeContext(); const script = _createScriptRunResource( diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 486b2f021..d0233c5b8 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -142,6 +142,8 @@ type UserScriptSession = { frameId?: number; documentId?: string; transport: "userScript" | "extension"; + // 断线窗口内按 storageName 合并值更新,重连握手完成后再投递。 + pendingValueUpdates: Map; }; type UserScriptBootstrap = Omit; @@ -168,10 +170,11 @@ export class RuntimeService { tabId: number; frameId?: number; documentId?: string; + ready: boolean; } >(); private readonly userScriptBootstraps = new Map(); - // 连接断开后保留当前文档的已验证资料,供 USER_SCRIPT 通过原生消息重连;导航或脚本撤销会同步清除。 + // 连接断开后保留当前文档的已验证资料与待投递值更新,供 USER_SCRIPT 通过原生消息重连;导航或脚本撤销会同步清除。 private readonly userScriptSessions = new Map(); // Only the newest load for a tab/frame/environment may issue bindings; navigation can resolve old requests late. private readonly pageLoadSequences = new Map(); @@ -314,7 +317,7 @@ export class RuntimeService { this.userScriptBootstraps.delete(handshake.bootstrapToken); const previous = this.userScriptConnections.get(key); if (previous) previous.connection.disconnect(true); - const entry = { connection, handles, envTag: bootstrap.envTag, tabId, frameId, documentId }; + const entry = { connection, handles, envTag: bootstrap.envTag, tabId, frameId, documentId, ready: false }; this.userScriptConnections.set(key, entry); connection.onDisconnect(() => { if (this.userScriptConnections.get(key)?.connection === connection) this.userScriptConnections.delete(key); @@ -342,6 +345,8 @@ export class RuntimeService { action: `${bootstrap.envTag === "it" ? "inject" : "content"}/pageLoad`, data: pageLoadData, }); + entry.ready = true; + this.flushPendingUserScriptValueUpdates(key, entry); } catch { this.userScriptConnections.delete(key); } @@ -414,11 +419,57 @@ export class RuntimeService { return { bootstrapToken }; } + private queuePendingUserScriptValueUpdate(key: string, data: ValueUpdateDataEncoded): void { + const session = this.userScriptSessions.get(key); + if (!session) return; + const previous = session.pendingValueUpdates.get(data.storageName); + if (!previous) { + session.pendingValueUpdates.set(data.storageName, data); + return; + } + const entries: ValueUpdateDataEncoded["entries"] = previous.entries.map((entry) => [entry[0], entry[1], entry[2]]); + const entryIndexes = new Map(); + for (let index = 0; index < entries.length; index += 1) entryIndexes.set(entries[index][0], index); + for (const entry of data.entries) { + const index = entryIndexes.get(entry[0]); + if (index === undefined) { + entryIndexes.set(entry[0], entries.length); + entries.push([entry[0], entry[1], entry[2]]); + } else { + entries[index] = [entry[0], entry[1], entries[index][2]]; + } + } + session.pendingValueUpdates.set(data.storageName, { + ...data, + entries, + valueUpdated: previous.valueUpdated || data.valueUpdated, + }); + } + + private flushPendingUserScriptValueUpdates( + key: string, + entry: { connection: MessageConnect; envTag: "it" | "ct" } + ): void { + const session = this.userScriptSessions.get(key); + if (!session) return; + for (const [storageName, data] of session.pendingValueUpdates) { + entry.connection.sendMessage({ + action: `${entry.envTag === "it" ? "inject" : "content"}/runtime/valueUpdate`, + data, + }); + session.pendingValueUpdates.delete(storageName); + } + } + private sendUserScriptMessage(to: ExtMessageSender | undefined, action: string, data: unknown): void { const dataRecord = typeof data === "object" && data !== null ? (data as { uuid?: unknown; storageName?: unknown }) : undefined; const targetUuid = action === "runtime/emitEvent" ? dataRecord?.uuid : undefined; const targetStorageName = action === "runtime/valueUpdate" ? dataRecord?.storageName : undefined; + const valueUpdate = + action === "runtime/valueUpdate" && typeof dataRecord?.storageName === "string" + ? (data as ValueUpdateDataEncoded) + : undefined; // 先按页面定位,再按句柄对应的脚本或 storageName 过滤,避免跨脚本广播私有回调。 for (const [key, entry] of this.userScriptConnections) { if ( @@ -442,11 +493,42 @@ export class RuntimeService { } } if (!bindingMatches) continue; + if (!entry.ready) { + if (valueUpdate) this.queuePendingUserScriptValueUpdate(key, valueUpdate); + continue; + } try { entry.connection.sendMessage({ action: `${entry.envTag === "it" ? "inject" : "content"}/${action}`, data }); } catch { this.userScriptConnections.delete(key); + if (valueUpdate) this.queuePendingUserScriptValueUpdate(key, valueUpdate); + } + } + if (!valueUpdate) return; + for (const [key, session] of this.userScriptSessions) { + if (this.userScriptConnections.has(key)) continue; + if ( + to && + (session.tabId !== to.tabId || + (to.frameId !== undefined && session.frameId !== to.frameId) || + (to.documentId !== undefined && session.documentId !== to.documentId)) + ) { + continue; + } + let bindingMatches = false; + for (const script of session.scripts) { + const handle = script.executionHandle; + const binding = typeof handle === "string" ? this.pageExecutionBindings.get(handle) : undefined; + if ( + binding && + ((targetUuid !== undefined && targetUuid === binding.uuid) || + (targetStorageName !== undefined && targetStorageName === binding.storageName)) + ) { + bindingMatches = true; + break; + } } + if (bindingMatches) this.queuePendingUserScriptValueUpdate(key, valueUpdate); } } @@ -1725,6 +1807,7 @@ export class RuntimeService { tabId, frameId, documentId: chromeSender.documentId, + pendingValueUpdates: new Map(), }); return token; }; From 643af56bca0f5b8ee5c3634334967b2587534e0f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:45:57 +0900 Subject: [PATCH 075/103] =?UTF-8?q?=F0=9F=94=92=20protect=20GM=20value=20s?= =?UTF-8?q?tores=20from=20prototype=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_api.test.ts | 17 +++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 21 +++++++++++++------ src/app/service/service_worker/value.test.ts | 21 +++++++++++++++++++ src/app/service/service_worker/value.ts | 13 ++++++++++-- 4 files changed, 64 insertions(+), 8 deletions(-) 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 932a02b57..7deda703d 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -705,6 +705,23 @@ describe.concurrent("GM_menu", () => { }); describe.concurrent("GM_value", () => { + it("stores __proto__ as a value key instead of changing the value store prototype", () => { + 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, "__proto__", stored); + + expect(Object.prototype.hasOwnProperty.call(script.value, "__proto__")).toBe(true); + expect(Object.getPrototypeOf(script.value)).toBe(Object.prototype); + expect(api.GM_getValue(api, "__proto__")).toEqual(stored); + expect(api.GM_getValue(api, "leaked")).toBeUndefined(); + }); + it.concurrent("GM_setValue", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValue", "GM_setValue"]; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 1b9b94da0..9c3a7ee4f 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -76,6 +76,15 @@ const copyOwnEnumerableDataProperties = (value: object): Record // 回调表不暴露 Map 原型,避免页面改写 Map 方法后影响值更新确认。 const valueChangePromiseMap: Record void> = Object.create(null); +const setOwnValue = (store: Record, key: string, value: any): void => { + Native.objectDefineProperty(store, key, { + configurable: true, + enumerable: true, + writable: true, + value, + }); +}; + // 通知 ID 只属于对应 GM context;WeakMap 不让脚本结束后残留监听状态。 const notificationTagMaps = new Native.WeakMap>(); @@ -254,11 +263,11 @@ 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); } // 监听器属于脚本,传副本避免回调修改 GM 存储或跨 context 共享对象。 const listenerValue = value && typeof value === "object" ? customClone(value) : value; @@ -319,7 +328,7 @@ export default class GMApi extends GM_Base { static _GM_getValue(a: GMApi, key: string, defaultValue?: any) { if (!a.scriptRes) return undefined; - const ret = a.scriptRes.value[key]; + const ret = Native.objectHasOwn(a.scriptRes.value, key) ? a.scriptRes.value[key] : undefined; if (ret !== undefined) { if (ret && typeof ret === "object") { return customClone(ret)!; @@ -365,7 +374,7 @@ export default class GMApi extends GM_Base { value = customClone(value); } // customClone 可能返回 undefined - a.scriptRes.value[key] = value; + setOwnValue(a.scriptRes.value, key, value); if (value === undefined) { a.sendMessage("GM_setValue", [id, key]); } else { @@ -412,7 +421,7 @@ export default class GMApi extends GM_Base { value_ = customClone(value_); } // customClone 可能返回 undefined - valueStore[key] = value_; + setOwnValue(valueStore, key, value_); } // 避免undefined 等空值流失,先进行映射处理 keyValuePairs[keyValuePairs.length] = [key, encodeRValue(value_)]; @@ -485,7 +494,7 @@ export default class GMApi extends GM_Base { // Handle array of keys (e.g., ['foo', 'bar']) for (let index = 0; index < keysOrDefaults.length; index++) { const key = keysOrDefaults[index]; - if (key in ctx.scriptRes.value) { + if (Native.objectHasOwn(ctx.scriptRes.value, key)) { // 对object的value进行一次转化 let value = ctx.scriptRes.value[key]; if (value && typeof value === "object") { diff --git a/src/app/service/service_worker/value.test.ts b/src/app/service/service_worker/value.test.ts index 7dcc2ae9a..c42ff5a2e 100644 --- a/src/app/service/service_worker/value.test.ts +++ b/src/app/service/service_worker/value.test.ts @@ -100,6 +100,27 @@ describe("ValueService - setValue 方法测试", () => { vi.restoreAllMocks(); }); + it("persists __proto__ as an own value key without polluting inherited values", async () => { + const mockScript = createMockScript(); + const stored = { leaked: "secret" }; + vi.mocked(mockScriptDAO.get).mockResolvedValue(mockScript); + vi.mocked(mockValueDAO.get).mockResolvedValue(undefined); + vi.mocked(mockValueDAO.save).mockResolvedValue({} as any); + + await valueService.setValues({ + uuid: mockScript.uuid, + keyValuePairs: [["__proto__", encodeRValue(stored)]], + valueSender: createMockValueSender(), + isReplace: false, + }); + + const savedData = vi.mocked(mockValueDAO.save).mock.calls[0][1].data; + expect(Object.prototype.hasOwnProperty.call(savedData, "__proto__")).toBe(true); + expect(Object.getPrototypeOf(savedData)).toBe(Object.prototype); + expect(savedData.__proto__).toEqual(stored); + expect((savedData as Record).leaked).toBeUndefined(); + }); + it("应该成功设置新脚本的值", async () => { // 准备测试数据 const mockScript = createMockScript(); diff --git a/src/app/service/service_worker/value.ts b/src/app/service/service_worker/value.ts index 6f5af7ba5..5855cc43a 100644 --- a/src/app/service/service_worker/value.ts +++ b/src/app/service/service_worker/value.ts @@ -15,6 +15,15 @@ import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import type { TKeyValuePair } from "@App/pkg/utils/message_value"; import { decodeRValue, R_UNDEFINED, encodeRValue } from "@App/pkg/utils/message_value"; +const setOwnValue = (store: Record, key: string, value: any): void => { + Object.defineProperty(store, key, { + configurable: true, + enumerable: true, + writable: true, + value, + }); +}; + export type TSetValuesParams = { uuid: string; id?: string; @@ -107,7 +116,7 @@ export class ValueService { for (const [key, rTyped1] of keyValuePairs) { const value = decodeRValue(rTyped1); if (value !== undefined) { - dataModel[key] = value; + setOwnValue(dataModel, key, value); entries.push([key, rTyped1, R_UNDEFINED]); } } @@ -134,7 +143,7 @@ export class ValueService { if (value === undefined) { delete dataModel[key]; } else { - dataModel[key] = value; + setOwnValue(dataModel, key, value); } const rTyped2 = encodeRValue(oldValue); entries.push([key, rTyped1, rTyped2]); From 8f4cf28e239063404986f980bd899c03ff350384 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:50:38 +0900 Subject: [PATCH 076/103] =?UTF-8?q?=F0=9F=94=92=20reject=20hostile=20page?= =?UTF-8?q?=20message=20envelopes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/page_message.test.ts | 57 ++++++++++++++++++++++ packages/message/page_message.ts | 68 +++++++++++++++++++++++++-- 2 files changed, 122 insertions(+), 3 deletions(-) diff --git a/packages/message/page_message.test.ts b/packages/message/page_message.test.ts index 7a2a4a72e..39ea01396 100644 --- a/packages/message/page_message.test.ts +++ b/packages/message/page_message.test.ts @@ -67,4 +67,61 @@ describe("PageMessage", () => { expect(target.handlers.size).toBe(handlerCount - 1); inject.dispose(); }); + + it("ignores envelopes with accessor fields without executing the accessor", () => { + const target = createWindow(); + const inject = new PageMessage("page-message-test", "inject", target); + const received = vi.fn(); + inject.onMessage(received); + const envelope: Record = { + channel: "page-message-test", + source: "scripting", + target: "inject", + 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"); + }, + }); + + const handler = [...target.handlers][0]; + expect(() => handler({ source: target, data: envelope } as unknown as MessageEvent)).not.toThrow(); + expect(accessed).toBe(false); + expect(received).not.toHaveBeenCalled(); + inject.dispose(); + }); + + it("ignores proxy envelopes whose own-key inspection is hostile", () => { + const target = createWindow(); + const inject = new PageMessage("page-message-test", "inject", target); + const received = vi.fn(); + inject.onMessage(received); + const envelope = new Proxy( + { + channel: "page-message-test", + source: "scripting", + target: "inject", + messageId: "hostile", + type: "sendMessage", + data: { action: "inject/ping" }, + }, + { + ownKeys() { + throw new Error("page proxy executed"); + }, + } + ); + + const handler = [...target.handlers][0]; + expect(() => handler({ source: target, data: envelope } as unknown as MessageEvent)).not.toThrow(); + expect(received).not.toHaveBeenCalled(); + inject.dispose(); + }); }); diff --git a/packages/message/page_message.ts b/packages/message/page_message.ts index c1be57325..14c8ed308 100644 --- a/packages/message/page_message.ts +++ b/packages/message/page_message.ts @@ -30,6 +30,69 @@ const bindNative = any>(fn: T, receiver: any): T const listenerMgr = new EventEmitter(); +const nativeReflectOwnKeys = Reflect.ownKeys; +const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const PAGE_MESSAGE_KEYS = ["channel", "source", "target", "messageId", "type", "data"] as const; + +const parsePageMessageBody = (value: unknown): PageMessageBody | undefined => { + if (value === null || typeof value !== "object") return undefined; + + let keys: (string | symbol)[]; + try { + keys = nativeReflectOwnKeys(value); + } catch { + return undefined; + } + if (keys.length !== PAGE_MESSAGE_KEYS.length) return undefined; + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + let known = false; + if (typeof key === "string") { + for (let expectedIndex = 0; expectedIndex < PAGE_MESSAGE_KEYS.length; expectedIndex += 1) { + if (PAGE_MESSAGE_KEYS[expectedIndex] === key) { + known = true; + break; + } + } + } + if (!known) { + return undefined; + } + } + + let fields: PropertyDescriptor[]; + try { + fields = []; + for (let index = 0; index < PAGE_MESSAGE_KEYS.length; index += 1) { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, PAGE_MESSAGE_KEYS[index]); + if (!descriptor || !("value" in descriptor)) return undefined; + fields[fields.length] = descriptor; + } + } catch { + return undefined; + } + const channel = fields[0].value; + const source = fields[1].value; + const target = fields[2].value; + const messageId = fields[3].value; + const type = fields[4].value; + const data = fields[5].value; + if ( + typeof channel !== "string" || + (source !== "scripting" && source !== "inject") || + (target !== "scripting" && target !== "inject") || + typeof messageId !== "string" || + (type !== "sendMessage" && + type !== "respMessage" && + type !== "connect" && + type !== "disconnect" && + type !== "connectMessage") + ) { + return undefined; + } + return { channel, source, target, messageId, type, data } as PageMessageBody; +}; + const otherRole = (role: PageMessageRole): PageMessageRole => (role === "scripting" ? "inject" : "scripting"); class PageMessageConnect implements MessageConnect { @@ -120,14 +183,13 @@ export class PageMessage implements Message { this.targetRole = otherRole(role); this.messageHandler = (event: MessageEvent) => { if (event.source !== null && event.source !== sourceWindow) return; - const body = event.data as Partial | null; + const body = parsePageMessageBody(event.data); if ( !body || body.channel !== this.channel || body.target !== this.role || body.source !== this.targetRole || - typeof body.messageId !== "string" || - typeof body.type !== "string" + typeof body.messageId !== "string" ) { return; } From 5b253102eeb3f84bbf4b06dd711cbf5d527a7f66 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:53:25 +0900 Subject: [PATCH 077/103] =?UTF-8?q?=F0=9F=94=92=20validate=20sandbox=20mes?= =?UTF-8?q?sage=20envelopes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/window_message.test.ts | 57 ++++++++++++++++++++++ packages/message/window_message.ts | 63 +++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/packages/message/window_message.test.ts b/packages/message/window_message.test.ts index 00be4f8d7..0262c8800 100644 --- a/packages/message/window_message.test.ts +++ b/packages/message/window_message.test.ts @@ -210,6 +210,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..aba07fd61 100644 --- a/packages/message/window_message.ts +++ b/packages/message/window_message.ts @@ -32,6 +32,60 @@ export type WindowMessageBody = { data: T | null; // 消息数据 }; +const nativeReflectOwnKeys = Reflect.ownKeys; +const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const WINDOW_MESSAGE_KEYS = ["messageId", "type", "data"] as const; + +const parseWindowMessageBody = (value: unknown): WindowMessageBody | undefined => { + if (value === null || typeof value !== "object") return undefined; + + let keys: (string | symbol)[]; + try { + keys = nativeReflectOwnKeys(value); + } catch { + return undefined; + } + if (keys.length !== WINDOW_MESSAGE_KEYS.length) return undefined; + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + let known = false; + if (typeof key === "string") { + for (let expectedIndex = 0; expectedIndex < WINDOW_MESSAGE_KEYS.length; expectedIndex += 1) { + if (WINDOW_MESSAGE_KEYS[expectedIndex] === key) { + known = true; + break; + } + } + } + if (!known) return undefined; + } + + let fields: PropertyDescriptor[]; + try { + fields = []; + for (let index = 0; index < WINDOW_MESSAGE_KEYS.length; index += 1) { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, WINDOW_MESSAGE_KEYS[index]); + if (!descriptor || !("value" in descriptor)) return undefined; + fields[fields.length] = descriptor; + } + } catch { + return undefined; + } + const messageId = fields[0].value; + const type = fields[1].value; + if ( + typeof messageId !== "string" || + (type !== "sendMessage" && + type !== "respMessage" && + type !== "connect" && + type !== "disconnect" && + type !== "connectMessage") + ) { + return undefined; + } + return { messageId, type, data: fields[2].value } as WindowMessageBody; +}; + export class WindowMessage implements Message { EE = new EventEmitter(); @@ -78,6 +132,9 @@ export class WindowMessage implements Message { } messageHandle(data: WindowMessageBody, target: PostMessage) { + const safeData = parseWindowMessageBody(data); + if (!safeData) return; + data = safeData; // 处理消息 if (data.type === "sendMessage") { // 接收到消息 @@ -257,6 +314,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 +418,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( From 898fcc440f295b55f49378f69b2e8d10fa061064 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:55:10 +0900 Subject: [PATCH 078/103] =?UTF-8?q?=F0=9F=94=92=20validate=20custom=20even?= =?UTF-8?q?t=20envelopes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/custom_event_message.test.ts | 26 ++++++++++++++++++- packages/message/custom_event_message.ts | 10 ++++++- packages/message/window_message.ts | 2 +- 3 files changed, 35 insertions(+), 3 deletions(-) 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/window_message.ts b/packages/message/window_message.ts index aba07fd61..40cd2d8e5 100644 --- a/packages/message/window_message.ts +++ b/packages/message/window_message.ts @@ -36,7 +36,7 @@ const nativeReflectOwnKeys = Reflect.ownKeys; const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const WINDOW_MESSAGE_KEYS = ["messageId", "type", "data"] as const; -const parseWindowMessageBody = (value: unknown): WindowMessageBody | undefined => { +export const parseWindowMessageBody = (value: unknown): WindowMessageBody | undefined => { if (value === null || typeof value !== "object") return undefined; let keys: (string | symbol)[]; From 35ce58184082945a741595444653a21bd3fd4efa Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:58:22 +0900 Subject: [PATCH 079/103] =?UTF-8?q?=F0=9F=94=92=20guard=20server=20action?= =?UTF-8?q?=20dispatch=20inputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/server.test.ts | 38 +++++++++++++++++++++++++++++ packages/message/server.ts | 42 ++++++++++++++++++++++++++------- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/packages/message/server.test.ts b/packages/message/server.test.ts index 7da2268ee..ac6959811 100644 --- a/packages/message/server.test.ts +++ b/packages/message/server.test.ts @@ -35,6 +35,44 @@ 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`; diff --git a/packages/message/server.ts b/packages/message/server.ts index 70a34d28d..485fb9014 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -14,10 +14,28 @@ 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, @@ -166,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(this.prefix)) { - return this.connectHandle(msg.action.slice(this.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; }); @@ -178,10 +197,17 @@ export class Server { msgReceiverList.forEach((msg) => { msg.onMessage((msg: TMessage, sendResponse, sender, origin) => { - if (typeof msg.action !== "string") return; - this.logger.trace("server onMessage", { msg: msg as any }); - if (msg.action?.startsWith(this.prefix)) { - return this.messageHandle(msg.action.slice(this.prefix.length + 1), msg.data, 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; From 276196c7c53d32514d6f848f7232d39519e20d31 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:04:22 +0900 Subject: [PATCH 080/103] =?UTF-8?q?=F0=9F=94=92=20inspect=20collection=20p?= =?UTF-8?q?ayload=20entries=20before=20cloning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 29 ++++++++++++ .../service/content/script_runtime.test.ts | 44 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index e05fd5073..ee2e73c6b 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -119,6 +119,31 @@ export const customClone = (o: any) => { 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); @@ -143,6 +168,10 @@ export const customClone = (o: any) => { } return true; }; + const isDataOnlyValue = (value: unknown): boolean => { + if (value === null || typeof value !== "object") return true; + return isDataOnly(value); + }; if (!isDataOnly(o)) return undefined; if (hasNativeStructuredClone) { diff --git a/src/app/service/content/script_runtime.test.ts b/src/app/service/content/script_runtime.test.ts index d52b7ae59..3945f3772 100644 --- a/src/app/service/content/script_runtime.test.ts +++ b/src/app/service/content/script_runtime.test.ts @@ -134,6 +134,50 @@ describe("ScriptRuntime inject page bootstrap", () => { 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(); From c783a7a20109624b4ffc5599f04505cf224b3514 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:10:53 +0900 Subject: [PATCH 081/103] =?UTF-8?q?=F0=9F=94=92=20validate=20collection=20?= =?UTF-8?q?RPC=20payloads=20without=20accessors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/page_rpc.test.ts | 45 +++++++++++++++++ src/app/service/content/page_rpc.ts | 62 +++++++++++++++++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 9274e5d70..2d7dee361 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -236,6 +236,51 @@ describe("page GM RPC", () => { ).toThrow(PageRpcError); }); + it("rejects accessors nested in collection RPC parameters", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + const getter = vi.fn(() => "secret"); + const nested = {} as Record; + Object.defineProperty(nested, "value", { configurable: true, enumerable: true, get: getter }); + + expect(() => + validatePageGMRequest( + { version: 1, requestId: "collection", 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("script-a", "it", ["GM_getValue"]); + const getter = vi.fn(() => "secret"); + const nested = {} as Record; + Object.defineProperty(nested, "value", { configurable: true, enumerable: true, get: getter }); + + expect(() => + validatePageGMRequest( + { version: 1, requestId: "set", 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("script-a", "it", ["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: 1, requestId: "tag", 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("script-a", "it", ["GM_getValue"]); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index 4106e0fdb..813b84277 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -8,6 +8,8 @@ const MAX_REQUEST_ID_LENGTH = 256; const MAX_REQUEST_IDS_PER_BINDING = 4096; 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; @@ -201,14 +203,70 @@ const ownData = (value: object, key: PropertyKey): unknown => { 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; - // Blob 的内部槽由浏览器管理,不能把其 symbol/accessor 细节当作 DTO 字段遍历。 - if (nativeBlob && (value instanceof nativeBlob || nativeObjectToString.call(value) === "[object Blob]")) 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); From 671c4453d091fb939c67825dd0082cbd481debf8 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:22:46 +0900 Subject: [PATCH 082/103] =?UTF-8?q?=F0=9F=94=92=20remove=20live=20document?= =?UTF-8?q?=20references=20from=20CAT=20fetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_api.test.ts | 47 +++++++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 4 +- src/app/service/content/gm_api/gm_xhr.ts | 32 +++++++++++-- src/app/service/content/scripting.test.ts | 11 ++++- src/app/service/content/scripting.ts | 34 +++++++++----- src/scripting.ts | 3 +- 6 files changed, 109 insertions(+), 22 deletions(-) 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 7deda703d..d9b748321 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -8,6 +8,7 @@ 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"; const nilFn: ScriptFunc = () => {}; const scriptRes = { @@ -113,6 +114,52 @@ describe("early-start page RPC", () => { }); }); +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, diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 9c3a7ee4f..b81f363ca 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -25,7 +25,7 @@ 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"; @@ -650,7 +650,7 @@ export default class GMApi extends GM_Base { }); } - return urlToDocumentInContentPage(ctx, url, isContentEnv); + return parseSerializedDocumentResponse(await ctx.sendMessage("CAT_fetchDocument", [`${url}`, isContentEnv])); } static _GM_cookie( diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index e24a7f113..42a2ce6e7 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -1,5 +1,4 @@ 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"; @@ -113,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) => { diff --git a/src/app/service/content/scripting.test.ts b/src/app/service/content/scripting.test.ts index 4f341cd02..4a77badf9 100644 --- a/src/app/service/content/scripting.test.ts +++ b/src/app/service/content/scripting.test.ts @@ -3,7 +3,7 @@ import type { MessageSend } from "@Packages/message/types"; import type { TClientPageLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; import type { Server } from "@Packages/message/server"; import { RuntimeClient } from "../service_worker/client"; -import ScriptingRuntime from "./scripting"; +import ScriptingRuntime, { serializeDocumentResponse } from "./scripting"; const makeSender = () => ({ sendMessage: vi.fn().mockResolvedValue({ code: 0, data: undefined }), @@ -42,7 +42,6 @@ describe("ScriptingRuntime page bootstrap", () => { {} as Server, senderToExt as unknown as MessageSend, senderToContent as any, - senderToInject as any, senderToInject as any ); @@ -72,4 +71,12 @@ describe("ScriptingRuntime page bootstrap", () => { ); expect(senderToInject.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ action: "inject/pageLoad" })); }); + + 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 dc0bc2e27..34e118737 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -2,6 +2,7 @@ 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"; @@ -18,6 +19,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; @@ -39,9 +52,7 @@ export default class ScriptingRuntime { // 发送给 content的消息接口 private readonly senderToContent: CustomEventMessage, // 发送给inject的消息接口 - private readonly senderToInject: MessageSend, - // 仅用于同步 DOM 节点引用;异步脚本 RPC 使用 senderToInject 的结构化消息。 - private readonly domSenderToInject: CustomEventMessage + private readonly senderToInject: MessageSend ) {} // 广播消息给 content 和 inject @@ -115,18 +126,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.domSenderToInject; 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(); }); } diff --git a/src/scripting.ts b/src/scripting.ts index 2f4a88ce1..0d14a8eaa 100644 --- a/src/scripting.ts +++ b/src/scripting.ts @@ -26,7 +26,6 @@ negotiateEventFlag(messageFlag, extensionEnv, 2, (eventFlag) => { const contentMsg = new CustomEventMessage(eventFlag, true, ScriptEnvTag.content); const injectMsg = new PageMessage(eventFlag, "scripting"); - const domInjectMsg = new CustomEventMessage(eventFlag, true, ScriptEnvTag.inject); const server = new Server("scripting", [contentMsg, injectMsg]); @@ -35,7 +34,7 @@ negotiateEventFlag(messageFlag, extensionEnv, 2, (eventFlag) => { const extServer = new Server("scripting", extMsgComm, false); // scriptExecutor的消息接口 // 初始化运行环境 - const runtime = new ScriptingRuntime(extServer, server, extMsgComm, contentMsg, injectMsg, domInjectMsg); + const runtime = new ScriptingRuntime(extServer, server, extMsgComm, contentMsg, injectMsg); runtime.init(); // 页面加载,注入脚本 runtime.pageLoad(); From 284b968626e60c3ce7f1c24aa4a864b69e932033 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:33:22 +0900 Subject: [PATCH 083/103] =?UTF-8?q?=F0=9F=94=92=20bind=20early-start=20wra?= =?UTF-8?q?ppers=20to=20document=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/script_executor.test.ts | 22 ++++++++++++++++++- src/app/service/content/script_executor.ts | 22 ++++++++++++++++++- src/app/service/content/utils.test.ts | 6 +++++ src/app/service/content/utils.ts | 15 ++++++++----- 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index fc74a2277..a69b59c5b 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -4,7 +4,7 @@ import type { ScriptLoadInfo } from "../service_worker/types"; import type { TScriptInfo } from "@App/app/repo/scripts"; import type { GMInfoEnv } from "./types"; import { initEnvInfo, ScriptExecutor } from "./script_executor"; -import { compilePreInjectScript, preInjectScriptInfoKey } from "./utils"; +import { compilePreInjectScript, preInjectScriptDocumentUrlKey, preInjectScriptInfoKey } from "./utils"; import { DefinedFlags } from "../service_worker/runtime.consts"; import { pageDispatchEvent } from "@Packages/message/common"; @@ -174,6 +174,7 @@ describe("ScriptExecutor", () => { pageWindow[script.flag] = genuine; Object.defineProperty(genuine, preInjectScriptInfoKey, { value: JSON.stringify(script) }); + Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: window.location.href }); executor.execEarlyScript(script.flag, initEnvInfo); expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); } finally { @@ -273,12 +274,31 @@ describe("ScriptExecutor", () => { } }); + it("rejects an early-start wrapper mounted for a different document URL", () => { + const script = makeScript({ uuid: "executor-early-document-uuid", flag: "#-executor-early-document-uuid" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const genuine = vi.fn(); + const pageWindow = window as unknown as Record; + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + Object.defineProperty(genuine, preInjectScriptInfoKey, { value: JSON.stringify(script) }); + Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: `${window.location.href}#stale` }); + + try { + pageWindow[script.flag] = genuine; + executor.execEarlyScript(script.flag, initEnvInfo); + expect(genuine).not.toHaveBeenCalled(); + } finally { + delete pageWindow[script.flag]; + } + }); + it("accepts the immutable early manifest through the wrapper name fallback", () => { const script = makeScript({ uuid: "executor-early-name-uuid", flag: "#-executor-early-name-uuid" }); const executor = new ScriptExecutor({} as Message, {} as Message); const genuine = vi.fn(); const pageWindow = window as unknown as Record; Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: window.location.href }); Object.defineProperty(genuine, "name", { configurable: false, value: JSON.stringify(script) }); try { diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 3fc61df2c..513340ff1 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -3,7 +3,13 @@ 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, preInjectScriptInfoKey, waitBody } from "./utils"; +import { + addStyleSheet, + definePropertyListener, + preInjectScriptDocumentUrlKey, + preInjectScriptInfoKey, + 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"; @@ -142,6 +148,20 @@ export class ScriptExecutor { ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptInfoKey) : undefined; if (scriptInfoDescriptor?.configurable || scriptInfoDescriptor?.writable) return; + // 隔离环境可能在页面导航后才取回预注入函数,必须拒绝挂载于旧 URL 的函数。 + const documentUrlDescriptor = + typeof scriptFunc === "function" + ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptDocumentUrlKey) + : undefined; + if ( + !documentUrlDescriptor || + documentUrlDescriptor.configurable || + documentUrlDescriptor.writable || + typeof documentUrlDescriptor.value !== "string" || + documentUrlDescriptor.value !== window.location.href + ) { + return; + } const scriptInfoJSON = typeof scriptInfoDescriptor?.value === "string" ? scriptInfoDescriptor.value diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index cf1dd2d53..774ff747c 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -8,6 +8,7 @@ import { isScriptletUnwrap, addStyle, addStyleSheet, + preInjectScriptDocumentUrlKey, preInjectScriptInfoKey, trimScriptInfo, } from "./utils"; @@ -788,6 +789,11 @@ describe("utils", () => { writable: false, value: expect.any(String), }); + expect(Object.getOwnPropertyDescriptor(generated, preInjectScriptDocumentUrlKey)).toMatchObject({ + configurable: false, + writable: false, + value: window.location.href, + }); const context = {}; const named = { value: 42 }; expect(generated(fnStrIntegrity, context, named, script.name)).toEqual({ diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 622911dcc..625718c6f 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -18,6 +18,7 @@ const cloneTransportValue = (value: any) => { const lnStrIntegrity = process.env.SC_RANDOM_FNKEY; const znRand = process.env.SC_ZN_RAND; export const preInjectScriptInfoKey = `${lnStrIntegrity}:scriptInfo`; +export const preInjectScriptDocumentUrlKey = `${lnStrIntegrity}:documentUrl`; export type CompileScriptCodeResource = { name: string; @@ -172,18 +173,22 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) return `${codeBody}${sourceMapTo(`${resource.name}.user.js`)}\n`; } -const codeFunction = (code: string, scriptInfoJSON?: string) => { +const codeFunction = (code: string, scriptInfoJSON?: string, documentUrlExpression?: string) => { // 临时方法调用不依赖页面改写的 call、apply、bind;完整性标记也阻止页面直接调用包装器。 const infoProperty = scriptInfoJSON === undefined ? "" - : ` Object.defineProperty(f, '${preInjectScriptInfoKey}', { value: ${JSON.stringify(scriptInfoJSON)} }); Object.defineProperty(f, 'name', { configurable: false, value: ${JSON.stringify(scriptInfoJSON)} });`; + : ` Object.defineProperty(f, '${preInjectScriptInfoKey}', { value: ${JSON.stringify(scriptInfoJSON)} }); Object.defineProperty(f, 'name', { configurable: false, value: ${JSON.stringify(scriptInfoJSON)} });${ + documentUrlExpression === undefined + ? "" + : ` Object.defineProperty(f, '${preInjectScriptDocumentUrlKey}', { value: ${documentUrlExpression} });` + }`; return `((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true });${infoProperty} return f; })('${lnStrIntegrity}', '${znRand}' + Math.random(), function(){${code}})`; }; // 有 setter 时沿用页面属性语义;否则用不可配置的一次性 getter,避免挂载函数被页面再次取走。 -const mountCodeFunction = (flag: string, code: string, scriptInfoJSON?: string) => - `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, '${flag}', ${codeFunction(code, scriptInfoJSON)})`; +const mountCodeFunction = (flag: string, code: string, scriptInfoJSON?: string, documentUrlExpression?: string) => + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, '${flag}', ${codeFunction(code, scriptInfoJSON, documentUrlExpression)})`; const ZFunction = Function; @@ -321,7 +326,7 @@ export function compilePreInjectScript( f = () => { if (!(${urlCondition})) return false; if (!mounted) { - ${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`, scriptInfoJSON)}; + ${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`, scriptInfoJSON, "location.href")}; mounted = true; } const o = { cancelable: true, detail: { scriptFlag: '${flag}' } }, From 3693b5894d4766d09ea7f39eb9eeebbe9806e9c9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:35:12 +0900 Subject: [PATCH 084/103] =?UTF-8?q?=F0=9F=94=92=20reject=20page=20RPC=20re?= =?UTF-8?q?play=20window=20overflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/page_rpc.test.ts | 10 ++++++---- src/app/service/content/page_rpc.ts | 10 ++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 2d7dee361..81555b7a4 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -363,20 +363,22 @@ describe("page GM RPC", () => { } }); - it("bounds the replay window for each execution binding", () => { + it("fails closed when the replay window reaches its bound", () => { const registry = new PageRpcRegistry(); const handle = registry.register("script-a", "it", ["GM_getValue"]); - for (let index = 0; index <= 4096; index += 1) { + for (let index = 0; index < 4096; index += 1) { validatePageGMRequest( { version: 1, requestId: `request-${index}`, handle, api: "GM_getValue", params: [] }, registry ); } - // The oldest ID leaves the bounded replay window once newer requests arrive. + expect(() => + validatePageGMRequest({ version: 1, requestId: "request-4096", handle, api: "GM_getValue", params: [] }, registry) + ).toThrow("replay window is exhausted"); expect(() => validatePageGMRequest({ version: 1, requestId: "request-0", handle, api: "GM_getValue", params: [] }, registry) - ).not.toThrow(); + ).toThrow("already used"); }); }); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index 813b84277..d4533bee9 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -371,14 +371,12 @@ export class PageRpcRegistry { } consumeRequestId(binding: PageExecutionBinding, requestId: string): void { - // requestId 只在每个绑定内去重,并保留有限窗口,避免页面长期占用内存。 + // requestId 在每个绑定内只接受一次;达到上限后拒绝新请求,不能遗忘旧 ID 让请求重放。 if (binding.requestIds.has(requestId)) throw new PageRpcError("page RPC requestId was already used"); - binding.requestIds.add(requestId); - while (binding.requestIds.size > MAX_REQUEST_IDS_PER_BINDING) { - const oldest = binding.requestIds.values().next().value as string | undefined; - if (oldest === undefined) break; - binding.requestIds.delete(oldest); + if (binding.requestIds.size >= MAX_REQUEST_IDS_PER_BINDING) { + throw new PageRpcError("page RPC requestId replay window is exhausted"); } + binding.requestIds.add(requestId); } } From 9cf16de49ce1ea4153c99a7a3eccd7458e410770 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:45:31 +0900 Subject: [PATCH 085/103] =?UTF-8?q?=F0=9F=94=92=20close=20service=20worker?= =?UTF-8?q?=20page=20RPC=20replay=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/gm_api/gm_api.test.ts | 80 +++++++++++++++++++ .../service/service_worker/gm_api/gm_api.ts | 8 +- 2 files changed, 83 insertions(+), 5 deletions(-) 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 094a9f46d..df0ec6b7d 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 @@ -331,6 +331,86 @@ describe("page execution binding gate", () => { await expect(api.handlerRequest(request, sender)).resolves.toBe(true); await expect(api.handlerRequest(request, sender)).rejects.toThrow("page RPC requestId was already used"); }); + + it("keeps the page RPC replay window closed after the request-id cap", 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"]), + requestIds: new Set(), + }; + 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 }); + + for (let index = 0; index < 4096; index += 1) { + await expect( + api.handlerRequest( + { + uuid: "script-a", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + executionHandle: "handle-a", + requestId: `request-${index}`, + version: 1, + }, + sender + ) + ).resolves.toBe(true); + } + + await expect( + api.handlerRequest( + { + uuid: "script-a", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + executionHandle: "handle-a", + requestId: "request-4096", + version: 1, + }, + sender + ) + ).rejects.toThrow("page RPC requestId replay window is exhausted"); + await expect( + api.handlerRequest( + { + uuid: "script-a", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + executionHandle: "handle-a", + requestId: "request-0", + version: 1, + }, + sender + ) + ).rejects.toThrow("page RPC requestId was already used"); + }); }); describe("window.focus", () => { 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 28a051041..e82bcec60 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -406,12 +406,10 @@ export default class GMApi { if (binding.requestIds.has(data.requestId)) { throw new Error("page RPC requestId was already used"); } - binding.requestIds.add(data.requestId); - while (binding.requestIds.size > MAX_PAGE_RPC_REQUEST_IDS) { - const oldest = binding.requestIds.values().next().value as string | undefined; - if (oldest === undefined) break; - binding.requestIds.delete(oldest); + if (binding.requestIds.size >= MAX_PAGE_RPC_REQUEST_IDS) { + throw new Error("page RPC requestId replay window is exhausted"); } + binding.requestIds.add(data.requestId); if (data.envTag !== undefined && data.envTag !== binding.envTag) { throw new Error("page execution binding is invalid"); } From 9dd323e15b64e4c677326289ebc32eb501be422c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:47:20 +0900 Subject: [PATCH 086/103] =?UTF-8?q?=F0=9F=94=92=20validate=20page=20RPC=20?= =?UTF-8?q?identity=20before=20replay=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/gm_api/gm_api.test.ts | 9 +++++++-- src/app/service/service_worker/gm_api/gm_api.ts | 6 +++--- 2 files changed, 10 insertions(+), 5 deletions(-) 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 df0ec6b7d..6150e2be3 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 @@ -327,9 +327,14 @@ describe("page execution binding gate", () => { executionHandle: "handle-a", requestId: "request-a", version: 1 as const, + envTag: "ct" as const, }; - await expect(api.handlerRequest(request, sender)).resolves.toBe(true); - await expect(api.handlerRequest(request, sender)).rejects.toThrow("page RPC requestId was already used"); + await expect(api.handlerRequest(request, sender)).rejects.toThrow("page execution binding is invalid"); + expect(binding.requestIds).toHaveLength(0); + + const validRequest = { ...request, envTag: "it" as const }; + await expect(api.handlerRequest(validRequest, sender)).resolves.toBe(true); + await expect(api.handlerRequest(validRequest, sender)).rejects.toThrow("page RPC requestId was already used"); }); it("keeps the page RPC replay window closed after the request-id cap", async () => { 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 e82bcec60..97920e902 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -400,6 +400,9 @@ export default class GMApi { if (!binding.allowedAPIs.has(data.api)) { throw new Error("API is not granted to this execution"); } + if (data.envTag !== undefined && data.envTag !== binding.envTag) { + throw new Error("page execution binding is invalid"); + } if (typeof data.requestId !== "string" || !data.requestId || data.requestId.length > 256) { throw new Error("page RPC requestId is invalid"); } @@ -410,9 +413,6 @@ export default class GMApi { throw new Error("page RPC requestId replay window is exhausted"); } binding.requestIds.add(data.requestId); - if (data.envTag !== undefined && data.envTag !== binding.envTag) { - throw new Error("page execution binding is invalid"); - } data = { ...data, uuid: binding.uuid, runFlag: binding.runFlag }; } const api = PermissionVerifyApiGet(data.api); From 2acb238c11e3f6f6ea8f13b7facd461afd07dfc3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:57:32 +0900 Subject: [PATCH 087/103] =?UTF-8?q?=F0=9F=94=92=20harden=20GM=20value=20re?= =?UTF-8?q?sult=20records?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_api.test.ts | 24 +++++++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 18 +++++++------- 2 files changed, 33 insertions(+), 9 deletions(-) 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 d9b748321..47fc63e33 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -769,6 +769,30 @@ describe.concurrent("GM_value", () => { expect(api.GM_getValue(api, "leaked")).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"]; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index b81f363ca..2e517987a 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -459,7 +459,7 @@ export default class GMApi extends GM_Base { @GMContext.API() public GM_listValues(ctx: GMApi): string[] { if (!ctx.scriptRes) return []; - const keys = Object.keys(ctx.scriptRes.value); + const keys = Native.objectKeys(ctx.scriptRes.value); return keys; } @@ -468,7 +468,7 @@ export default class GMApi extends GM_Base { // Asynchronous wrapper for GM_listValues to support GM.listValues return new Promise((resolve) => { if (!ctx.scriptRes) return resolve([]); - const keys = Object.keys(ctx.scriptRes.value); + const keys = Native.objectKeys(ctx.scriptRes.value); resolve(keys); }); } @@ -488,8 +488,8 @@ export default class GMApi extends GM_Base { // Returns all values return customClone(ctx.scriptRes.value)!; } - const result: TGMKeyValue = {}; - if (Array.isArray(keysOrDefaults)) { + const result: TGMKeyValue = Native.objectCreate(null); + if (Native.arrayIsArray(keysOrDefaults)) { // 键名数组 // Handle array of keys (e.g., ['foo', 'bar']) for (let index = 0; index < keysOrDefaults.length; index++) { @@ -500,15 +500,15 @@ export default class GMApi extends GM_Base { if (value && typeof value === "object") { value = customClone(value)!; } - result[key] = value; + setOwnValue(result, key, value); } } } 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(ctx, key, defaultValue); + setOwnValue(result, key, _GM_getValue(ctx, key, defaultValue)); } } return result; @@ -538,7 +538,7 @@ export default class GMApi extends GM_Base { @GMContext.API() public GM_deleteValues(ctx: GMApi, keys: string[]) { if (!ctx.scriptRes) return; - if (!Array.isArray(keys)) { + if (!Native.arrayIsArray(keys)) { console.warn("GM_deleteValues: keys must be string[]"); return; } @@ -554,7 +554,7 @@ export default class GMApi extends GM_Base { public "GM.deleteValues"(ctx: GMApi, keys: string[]): Promise { if (!ctx.scriptRes) return new Promise(() => {}); return new Promise((resolve) => { - if (!Array.isArray(keys)) { + if (!Native.arrayIsArray(keys)) { throw new Error("GM.deleteValues: keys must be string[]"); } else { const req = {} as Record; From 39b695dc2ec54954ce03022ca1a1145aa63cf481 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:04:21 +0900 Subject: [PATCH 088/103] =?UTF-8?q?=F0=9F=94=92=20restrict=20script=20atta?= =?UTF-8?q?chment=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/repo/agent_chat.test.ts | 38 +++++++++++++++++++ src/app/repo/agent_chat.ts | 14 +++++++ .../service/agent/service_worker/opfs.test.ts | 18 +++++++++ .../agent/service_worker/opfs_service.ts | 3 ++ .../agent/service_worker/test-helpers.ts | 1 + 5 files changed, 74 insertions(+) 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/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/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), From c46dfe1c7e9120aad5aa3aa74b16ba41c270e6d6 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:19:28 +0900 Subject: [PATCH 089/103] =?UTF-8?q?=F0=9F=94=92=20keep=20MAIN=20script=20p?= =?UTF-8?q?ayload=20off=20page=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/main_world_page_load_gate.test.ts | 14 +++- .../content/main_world_page_load_gate.ts | 4 +- src/app/service/content/scripting.test.ts | 73 ++++++++++++------- src/app/service/content/scripting.ts | 19 +++-- src/inject.ts | 14 +++- 5 files changed, 87 insertions(+), 37 deletions(-) diff --git a/src/app/service/content/main_world_page_load_gate.test.ts b/src/app/service/content/main_world_page_load_gate.test.ts index 9a273a4ed..53a948ae2 100644 --- a/src/app/service/content/main_world_page_load_gate.test.ts +++ b/src/app/service/content/main_world_page_load_gate.test.ts @@ -29,7 +29,8 @@ describe("createMainWorldPageLoadGate", () => { it("releases one queued payload only when native transport is unavailable", async () => { const receivePageLoad = vi.fn(); - const gate = createMainWorldPageLoadGate(async () => false, receivePageLoad); + const requestFallbackPageLoad = vi.fn(); + const gate = createMainWorldPageLoadGate(async () => false, receivePageLoad, requestFallbackPageLoad); const first = { source: "page" }; const second = { source: "page-after-fallback" }; @@ -38,12 +39,23 @@ describe("createMainWorldPageLoadGate", () => { await Promise.resolve(); expect(receivePageLoad).toHaveBeenCalledWith(first); + expect(requestFallbackPageLoad).toHaveBeenCalledOnce(); gate.onPageLoad(second); expect(receivePageLoad).toHaveBeenLastCalledWith(second); expect(receivePageLoad).toHaveBeenCalledTimes(2); }); + it("does not request a page-visible fallback after native transport succeeds", async () => { + const requestFallbackPageLoad = vi.fn(); + const gate = createMainWorldPageLoadGate(async () => true, vi.fn(), requestFallbackPageLoad); + + gate.onBootstrap("bootstrap-token"); + await Promise.resolve(); + + expect(requestFallbackPageLoad).not.toHaveBeenCalled(); + }); + it("does not reopen or fall back after the native channel has been selected", async () => { const receivePageLoad = vi.fn(); const openNativeChannel = vi.fn(async () => true); diff --git a/src/app/service/content/main_world_page_load_gate.ts b/src/app/service/content/main_world_page_load_gate.ts index 9d937cc70..35652ab7e 100644 --- a/src/app/service/content/main_world_page_load_gate.ts +++ b/src/app/service/content/main_world_page_load_gate.ts @@ -7,7 +7,8 @@ export type MainWorldPageLoadGate = { export const createMainWorldPageLoadGate = ( openNativeChannel: (bootstrapToken: string) => Promise, - receivePageLoad: (data: unknown) => void + receivePageLoad: (data: unknown) => void, + requestFallbackPageLoad: () => void = () => undefined ): MainWorldPageLoadGate => { let state: MainWorldPageLoadGateState = "waiting"; let pendingPageLoad: unknown; @@ -16,6 +17,7 @@ export const createMainWorldPageLoadGate = ( const finishOpening = (connected: boolean): void => { if (state !== "opening") return; state = connected ? "native" : "fallback"; + if (state === "fallback") requestFallbackPageLoad(); if (state === "fallback" && hasPendingPageLoad) { receivePageLoad(pendingPageLoad); } diff --git a/src/app/service/content/scripting.test.ts b/src/app/service/content/scripting.test.ts index 4a77badf9..2b75f8fe1 100644 --- a/src/app/service/content/scripting.test.ts +++ b/src/app/service/content/scripting.test.ts @@ -37,39 +37,60 @@ describe("ScriptingRuntime page bootstrap", () => { 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( - {} as Server, - {} as Server, + extServer as unknown as Server, + server as unknown as Server, senderToExt as unknown as MessageSend, senderToContent as any, senderToInject as any ); - runtime.pageLoad(); - await Promise.resolve(); - await Promise.resolve(); + 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/bootstrap", - data: { bootstrapToken: "inject-bootstrap-token" }, - }) - ); - expect(senderToInject.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ action: "inject/pageLoad" })); + 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/bootstrap", + data: { bootstrapToken: "inject-bootstrap-token" }, + }) + ); + expect(senderToInject.sendMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ action: "inject/pageLoad" }) + ); + + handlers.get("pageLoadFallback")?.({}); + await Promise.resolve(); + expect(senderToInject.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ action: "inject/pageLoad" })); + } finally { + storageLocal.onChanged = originalOnChanged; + } }); it("serializes CAT_fetchDocument responses instead of returning a live document reference", () => { diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index 34e118737..4baddadb8 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -2,12 +2,13 @@ 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 { TScriptInfo } from "@App/app/repo/scripts"; 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 type { GMInfoEnv, ValueUpdateDataEncoded } from "./types"; import { getExtensionOrigin, getPageRpcAllowedAPIs, PageRpcRegistry, validatePageGMRequest } from "./page_rpc"; import { uuidv4 } from "@App/pkg/utils/uuid"; @@ -40,6 +41,8 @@ const deliveryStorage = chrome.storage.local; // 日后再处理 export default class ScriptingRuntime { // 只记录当前页面仍有脚本使用的 storageName,storage 广播不应唤醒无关脚本。 private activeStorageNames = new Map(); + // MAIN world 的完整脚本资料只在原生通道失败时才走页面桥;原生成功时由 service worker 直接投递。 + private fallbackInjectPageLoad?: { scripts: TScriptInfo[]; envInfo: GMInfoEnv }; // 页面请求必须先在此注册句柄,再由 transform 解析为隔离 broker 可接受的身份。 private readonly pageRpc = new PageRpcRegistry(); constructor( @@ -76,6 +79,12 @@ export default class ScriptingRuntime { // USER_SCRIPT 的私有值更新通过原生扩展端口投递。 return this.broadcastToPage("runtime/valueUpdate", data, PageOrContent.PAGE); }); + this.server.on("pageLoadFallback", () => { + const pageLoad = this.fallbackInjectPageLoad; + if (!pageLoad) return undefined; + this.fallbackInjectPageLoad = undefined; + return new Client(this.senderToInject, "inject").do("pageLoad", pageLoad); + }); this.server.on("logger", (data: Logger) => { LoggerCore.logger().log(data.level, data.message, data.label); }); @@ -223,16 +232,10 @@ export default class ScriptingRuntime { } if (typeof userScriptInjectBootstrapToken === "string" && userScriptInjectBootstrapToken.length > 0) { + this.fallbackInjectPageLoad = { scripts: preparedInjectScriptList, envInfo }; const injectClient = new Client(this.senderToInject, "inject"); injectClient.do("bootstrap", { bootstrapToken: userScriptInjectBootstrapToken }); } - - // 向页面 发送脚本列表及环境信息 - if (preparedInjectScriptList.length) { - const injectClient = new Client(this.senderToInject, "inject"); - // 根据@inject-into content过滤脚本 - injectClient.do("pageLoad", { scripts: preparedInjectScriptList, envInfo }); - } }); } } diff --git a/src/inject.ts b/src/inject.ts index 44619cc9c..ff8049211 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -4,6 +4,7 @@ import { CustomEventMessage } from "@Packages/message/custom_event_message"; import { PageMessage } from "@Packages/message/page_message"; import { ExtensionMessage, hasNativeRuntimeChannel } from "@Packages/message/extension_message"; import { Server } from "@Packages/message/server"; +import { Client } from "@Packages/message/client"; import { ScriptExecutor } from "./app/service/content/script_executor"; import type { Message } from "@Packages/message/types"; import { getEventFlag } from "@Packages/message/common"; @@ -91,13 +92,24 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde }; if (pageServer) { - const pageLoadGate = createMainWorldPageLoadGate(openNativeChannel, (data) => runtime.receivePageLoad(data)); + const pageLoadGate = createMainWorldPageLoadGate( + openNativeChannel, + (data) => runtime.receivePageLoad(data), + () => { + void new Client(pageMsg, "scripting").do("pageLoadFallback"); + } + ); pageServer.on("bootstrap", (data: { bootstrapToken?: unknown }) => { if (typeof data?.bootstrapToken !== "string" || data.bootstrapToken.length === 0) return; reconnectToken = data.bootstrapToken; pageLoadGate.onBootstrap(data.bootstrapToken); }); pageServer.on("pageLoad", pageLoadGate.onPageLoad); + } else { + // 没有原生 runtime 通道时,bootstrap 只作为页面桥上的兼容握手,随后请求完整 pageLoad。 + server.on("bootstrap", () => { + void new Client(pageMsg, "scripting").do("pageLoadFallback"); + }); } runtime.init(); From deea129f8467a1c2d47a6c619566e63f40d76fc3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:00:37 +0900 Subject: [PATCH 090/103] =?UTF-8?q?=F0=9F=94=92=20isolate=20service=20work?= =?UTF-8?q?er=20value=20records?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/value.test.ts | 24 ++++++++++++++++++++ src/app/service/service_worker/value.ts | 15 ++++++++---- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/app/service/service_worker/value.test.ts b/src/app/service/service_worker/value.test.ts index c42ff5a2e..a73e0a486 100644 --- a/src/app/service/service_worker/value.test.ts +++ b/src/app/service/service_worker/value.test.ts @@ -121,6 +121,30 @@ describe("ValueService - setValue 方法测试", () => { expect((savedData as Record).leaked).toBeUndefined(); }); + it("does not let a bound config key change the returned value object's prototype", async () => { + const mockScript = createMockScript({ + config: { + settings: { + setting: { + bind: "$__proto__", + default: { polluted: true }, + index: 0, + }, + }, + } as any, + }); + const stored = {}; + vi.mocked(mockScriptDAO.get).mockResolvedValue(mockScript); + vi.mocked(mockValueDAO.get).mockResolvedValue({ data: stored } as any); + + const values = await valueService.getScriptValue(mockScript); + + expect(Object.getPrototypeOf(values)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(values, "__proto__")).toBe(true); + expect(values.__proto__).toBeUndefined(); + expect((values as Record).polluted).toBeUndefined(); + }); + it("应该成功设置新脚本的值", async () => { // 准备测试数据 const mockScript = createMockScript(); diff --git a/src/app/service/service_worker/value.ts b/src/app/service/service_worker/value.ts index 5855cc43a..fd741d9c8 100644 --- a/src/app/service/service_worker/value.ts +++ b/src/app/service/service_worker/value.ts @@ -50,10 +50,12 @@ export class ValueService { } async getScriptValueDetails(script: Script) { - let data: { [key: string]: any } = {}; + const data: { [key: string]: any } = Object.create(null); const ret = await this.valueDAO.get(getStorageName(script)); if (ret) { - data = ret.data; + for (const key of Object.keys(ret.data)) { + setOwnValue(data, key, ret.data[key]); + } } const newValues = data; // 和userconfig组装 @@ -71,10 +73,13 @@ export class ValueService { // 动态变量 if (tab[key].bind) { const bindKey = tab[key].bind!.substring(1); - newValues[bindKey] = data[bindKey] === undefined ? undefined : data[bindKey]; + setOwnValue(newValues, bindKey, data[bindKey] === undefined ? undefined : data[bindKey]); } - newValues[`${tabKey}.${key}`] = - data[`${tabKey}.${key}`] === undefined ? tab[key].default : data[`${tabKey}.${key}`]; + setOwnValue( + newValues, + `${tabKey}.${key}`, + data[`${tabKey}.${key}`] === undefined ? tab[key].default : data[`${tabKey}.${key}`] + ); } } } From 71022c7155aae9ec478fbd502da49b873a70527a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:17:17 +0900 Subject: [PATCH 091/103] =?UTF-8?q?=F0=9F=94=92=20serialize=20CDP=20monito?= =?UTF-8?q?r=20transitions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/service_worker/dom_cdp.test.ts | 13 ++++++++ .../service/agent/service_worker/dom_cdp.ts | 33 +++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) 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 3a84003b5..c7f97ddec 100644 --- a/src/app/service/agent/service_worker/dom_cdp.test.ts +++ b/src/app/service/agent/service_worker/dom_cdp.test.ts @@ -103,6 +103,19 @@ describe("agent_dom_cdp", () => { 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 } }); diff --git a/src/app/service/agent/service_worker/dom_cdp.ts b/src/app/service/agent/service_worker/dom_cdp.ts index 0a0334f30..26d08229d 100644 --- a/src/app/service/agent/service_worker/dom_cdp.ts +++ b/src/app/service/agent/service_worker/dom_cdp.ts @@ -23,6 +23,25 @@ type MonitorSession = { }; 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 @@ -240,14 +259,18 @@ export async function cdpScreenshot(tabId: number, options?: ScreenshotOptions): // ---- 页面监控(startMonitor / stopMonitor) ---- // 启动页面监控:attach debugger,纯 CDP 事件监听(dialog + DOM 变化),零注入 -export async function cdpStartMonitor(tabId: number, ownerScriptUuid?: string): Promise { +export function cdpStartMonitor(tabId: number, ownerScriptUuid?: string): Promise { + return withMonitorOperation(tabId, () => startMonitor(tabId, ownerScriptUuid)); +} + +async function startMonitor(tabId: number, ownerScriptUuid?: string): Promise { // 如果已有 monitor,先停止 const current = activeMonitors.get(tabId); if (current) { if (current.ownerScriptUuid !== ownerScriptUuid) { throw new Error("Monitor belongs to another script"); } - await cdpStopMonitor(tabId, ownerScriptUuid); + await stopMonitor(tabId, ownerScriptUuid); } const dialogs: Array<{ type: string; message: string }> = []; @@ -323,7 +346,11 @@ function stripHtmlTags(html: string): string { } // 停止监控:纯 CDP 解析新增节点 → 收集结果 → detach -export async function cdpStopMonitor(tabId: number, ownerScriptUuid?: string): 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"); From 8528137104d5e091da53771b4833f864740aa825 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 06:35:17 +0900 Subject: [PATCH 092/103] =?UTF-8?q?=F0=9F=94=92=20allow=20long-lived=20pag?= =?UTF-8?q?e=20RPC=20bindings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/page_rpc.test.ts | 6 +++--- src/app/service/content/page_rpc.ts | 6 +----- src/app/service/service_worker/gm_api/gm_api.test.ts | 4 ++-- src/app/service/service_worker/gm_api/gm_api.ts | 5 ----- 4 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 81555b7a4..2aa749caf 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -363,7 +363,7 @@ describe("page GM RPC", () => { } }); - it("fails closed when the replay window reaches its bound", () => { + it("accepts more than 4096 requests while still rejecting replay", () => { const registry = new PageRpcRegistry(); const handle = registry.register("script-a", "it", ["GM_getValue"]); @@ -374,9 +374,9 @@ describe("page GM RPC", () => { ); } - expect(() => + expect( validatePageGMRequest({ version: 1, requestId: "request-4096", handle, api: "GM_getValue", params: [] }, registry) - ).toThrow("replay window is exhausted"); + ).toMatchObject({ requestId: "request-4096" }); expect(() => validatePageGMRequest({ version: 1, requestId: "request-0", handle, api: "GM_getValue", params: [] }, registry) ).toThrow("already used"); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index d4533bee9..f17eec44b 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -5,7 +5,6 @@ import { Native, nativeReflectApply } from "./global"; export const PAGE_RPC_VERSION = 1 as const; const MAX_REQUEST_ID_LENGTH = 256; -const MAX_REQUEST_IDS_PER_BINDING = 4096; const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; const nativeObjectToString = Object.prototype.toString; const nativeMapForEach = Map.prototype.forEach; @@ -371,11 +370,8 @@ export class PageRpcRegistry { } consumeRequestId(binding: PageExecutionBinding, requestId: string): void { - // requestId 在每个绑定内只接受一次;达到上限后拒绝新请求,不能遗忘旧 ID 让请求重放。 + // requestId 在每个绑定内只接受一次;绑定销毁时一并释放,避免重放而不截断长时间运行的脚本。 if (binding.requestIds.has(requestId)) throw new PageRpcError("page RPC requestId was already used"); - if (binding.requestIds.size >= MAX_REQUEST_IDS_PER_BINDING) { - throw new PageRpcError("page RPC requestId replay window is exhausted"); - } binding.requestIds.add(requestId); } } 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 6150e2be3..000064bc6 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 @@ -337,7 +337,7 @@ describe("page execution binding gate", () => { await expect(api.handlerRequest(validRequest, sender)).rejects.toThrow("page RPC requestId was already used"); }); - it("keeps the page RPC replay window closed after the request-id cap", async () => { + it("accepts more than 4096 page RPC requests while still rejecting replay", 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", { @@ -400,7 +400,7 @@ describe("page execution binding gate", () => { }, sender ) - ).rejects.toThrow("page RPC requestId replay window is exhausted"); + ).resolves.toBe(true); await expect( api.handlerRequest( { 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 97920e902..eb8619c32 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -141,8 +141,6 @@ const cleanupOnAPIError = (requestId: string) => { headersSettled(markerID); // 处理完毕 }; -const MAX_PAGE_RPC_REQUEST_IDS = 4096; - // GMExternalDependencies接口定义 // 为了支持外部依赖注入,方便测试和扩展 interface IGMExternalDependencies { @@ -409,9 +407,6 @@ export default class GMApi { if (binding.requestIds.has(data.requestId)) { throw new Error("page RPC requestId was already used"); } - if (binding.requestIds.size >= MAX_PAGE_RPC_REQUEST_IDS) { - throw new Error("page RPC requestId replay window is exhausted"); - } binding.requestIds.add(data.requestId); data = { ...data, uuid: binding.uuid, runFlag: binding.runFlag }; } From 96c577c0cdab3fcc4fff04dd91cd58b815a40818 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 06:35:43 +0900 Subject: [PATCH 093/103] =?UTF-8?q?=F0=9F=94=92=20preserve=20early=20wrapp?= =?UTF-8?q?ers=20across=20same-document=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/script_executor.test.ts | 29 ++++++++++++++++--- src/app/service/content/script_executor.ts | 24 +++++++++++++-- src/app/service/content/utils.test.ts | 6 ++++ src/app/service/content/utils.ts | 27 +++++++++++++---- 4 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index a69b59c5b..65ecfc42a 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -4,7 +4,12 @@ import type { ScriptLoadInfo } from "../service_worker/types"; import type { TScriptInfo } from "@App/app/repo/scripts"; import type { GMInfoEnv } from "./types"; import { initEnvInfo, ScriptExecutor } from "./script_executor"; -import { compilePreInjectScript, preInjectScriptDocumentUrlKey, preInjectScriptInfoKey } from "./utils"; +import { + compilePreInjectScript, + preInjectScriptDocumentIdKey, + preInjectScriptDocumentUrlKey, + preInjectScriptInfoKey, +} from "./utils"; import { DefinedFlags } from "../service_worker/runtime.consts"; import { pageDispatchEvent } from "@Packages/message/common"; @@ -12,6 +17,16 @@ const styleUrl = "https://example.com/style.css"; const secondStyleUrl = "https://example.com/second-style.css"; const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; +beforeEach(() => { + if (!Object.prototype.hasOwnProperty.call(window, preInjectScriptDocumentIdKey)) { + Object.defineProperty(window, preInjectScriptDocumentIdKey, { + configurable: false, + writable: false, + value: "script-executor-test-document", + }); + } +}); + function makeScript(overrides: Partial> = {}): ScriptLoadInfo { return { uuid: "executor-test-uuid", @@ -175,6 +190,7 @@ describe("ScriptExecutor", () => { pageWindow[script.flag] = genuine; Object.defineProperty(genuine, preInjectScriptInfoKey, { value: JSON.stringify(script) }); Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: window.location.href }); + Object.defineProperty(genuine, preInjectScriptDocumentIdKey, { value: "script-executor-test-document" }); executor.execEarlyScript(script.flag, initEnvInfo); expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); } finally { @@ -274,20 +290,24 @@ describe("ScriptExecutor", () => { } }); - it("rejects an early-start wrapper mounted for a different document URL", () => { + 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" }); const executor = new ScriptExecutor({} as Message, {} as Message); const genuine = vi.fn(); const pageWindow = window as unknown as Record; + const initialUrl = window.location.href; Object.defineProperty(genuine, fnStrIntegrity, { value: true }); Object.defineProperty(genuine, preInjectScriptInfoKey, { value: JSON.stringify(script) }); - Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: `${window.location.href}#stale` }); + Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: initialUrl }); + Object.defineProperty(genuine, preInjectScriptDocumentIdKey, { value: "script-executor-test-document" }); try { + window.history.pushState({}, "", `${initialUrl}#same-document-change`); pageWindow[script.flag] = genuine; executor.execEarlyScript(script.flag, initEnvInfo); - expect(genuine).not.toHaveBeenCalled(); + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); } finally { + window.history.replaceState({}, "", initialUrl); delete pageWindow[script.flag]; } }); @@ -299,6 +319,7 @@ describe("ScriptExecutor", () => { const pageWindow = window as unknown as Record; Object.defineProperty(genuine, fnStrIntegrity, { value: true }); Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: window.location.href }); + Object.defineProperty(genuine, preInjectScriptDocumentIdKey, { value: "script-executor-test-document" }); Object.defineProperty(genuine, "name", { configurable: false, value: JSON.stringify(script) }); try { diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 513340ff1..377e22a67 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -6,6 +6,7 @@ import type { GMInfoEnv, ScriptFunc, ValueUpdateDataEncoded } from "./types"; import { addStyleSheet, definePropertyListener, + preInjectScriptDocumentIdKey, preInjectScriptDocumentUrlKey, preInjectScriptInfoKey, waitBody, @@ -148,7 +149,8 @@ export class ScriptExecutor { ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptInfoKey) : undefined; if (scriptInfoDescriptor?.configurable || scriptInfoDescriptor?.writable) return; - // 隔离环境可能在页面导航后才取回预注入函数,必须拒绝挂载于旧 URL 的函数。 + // The wrapper is installed on this document's window. Same-document history changes must not invalidate it; + // a full navigation creates a new window and cannot retain the old function. const documentUrlDescriptor = typeof scriptFunc === "function" ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptDocumentUrlKey) @@ -157,8 +159,24 @@ export class ScriptExecutor { !documentUrlDescriptor || documentUrlDescriptor.configurable || documentUrlDescriptor.writable || - typeof documentUrlDescriptor.value !== "string" || - documentUrlDescriptor.value !== window.location.href + typeof documentUrlDescriptor.value !== "string" + ) { + return; + } + const documentIdDescriptor = + typeof scriptFunc === "function" + ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptDocumentIdKey) + : undefined; + const currentDocumentIdDescriptor = Native.objectGetOwnPropertyDescriptor(window, preInjectScriptDocumentIdKey); + if ( + !documentIdDescriptor || + documentIdDescriptor.configurable || + documentIdDescriptor.writable || + typeof documentIdDescriptor.value !== "string" || + !currentDocumentIdDescriptor || + currentDocumentIdDescriptor.configurable || + currentDocumentIdDescriptor.writable || + currentDocumentIdDescriptor.value !== documentIdDescriptor.value ) { return; } diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index 774ff747c..3cac0cfba 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -8,6 +8,7 @@ import { isScriptletUnwrap, addStyle, addStyleSheet, + preInjectScriptDocumentIdKey, preInjectScriptDocumentUrlKey, preInjectScriptInfoKey, trimScriptInfo, @@ -794,6 +795,11 @@ describe("utils", () => { writable: false, value: window.location.href, }); + expect(Object.getOwnPropertyDescriptor(generated, preInjectScriptDocumentIdKey)).toMatchObject({ + configurable: false, + writable: false, + value: expect.any(String), + }); const context = {}; const named = { value: 42 }; expect(generated(fnStrIntegrity, context, named, script.name)).toEqual({ diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 625718c6f..7caa009c8 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -19,6 +19,7 @@ const lnStrIntegrity = process.env.SC_RANDOM_FNKEY; const znRand = process.env.SC_ZN_RAND; export const preInjectScriptInfoKey = `${lnStrIntegrity}:scriptInfo`; export const preInjectScriptDocumentUrlKey = `${lnStrIntegrity}:documentUrl`; +export const preInjectScriptDocumentIdKey = `${lnStrIntegrity}:documentId`; export type CompileScriptCodeResource = { name: string; @@ -173,7 +174,12 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) return `${codeBody}${sourceMapTo(`${resource.name}.user.js`)}\n`; } -const codeFunction = (code: string, scriptInfoJSON?: string, documentUrlExpression?: string) => { +const codeFunction = ( + code: string, + scriptInfoJSON?: string, + documentUrlExpression?: string, + documentIdExpression?: string +) => { // 临时方法调用不依赖页面改写的 call、apply、bind;完整性标记也阻止页面直接调用包装器。 const infoProperty = scriptInfoJSON === undefined @@ -181,14 +187,24 @@ const codeFunction = (code: string, scriptInfoJSON?: string, documentUrlExpressi : ` Object.defineProperty(f, '${preInjectScriptInfoKey}', { value: ${JSON.stringify(scriptInfoJSON)} }); Object.defineProperty(f, 'name', { configurable: false, value: ${JSON.stringify(scriptInfoJSON)} });${ documentUrlExpression === undefined ? "" - : ` Object.defineProperty(f, '${preInjectScriptDocumentUrlKey}', { value: ${documentUrlExpression} });` + : ` Object.defineProperty(f, '${preInjectScriptDocumentUrlKey}', { value: ${documentUrlExpression} });${ + documentIdExpression === undefined + ? "" + : ` Object.defineProperty(f, '${preInjectScriptDocumentIdKey}', { value: ${documentIdExpression} });` + }` }`; return `((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true });${infoProperty} return f; })('${lnStrIntegrity}', '${znRand}' + Math.random(), function(){${code}})`; }; // 有 setter 时沿用页面属性语义;否则用不可配置的一次性 getter,避免挂载函数被页面再次取走。 -const mountCodeFunction = (flag: string, code: string, scriptInfoJSON?: string, documentUrlExpression?: string) => - `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, '${flag}', ${codeFunction(code, scriptInfoJSON, documentUrlExpression)})`; +const mountCodeFunction = ( + flag: string, + code: string, + scriptInfoJSON?: string, + documentUrlExpression?: string, + documentIdExpression?: string +) => + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, '${flag}', ${codeFunction(code, scriptInfoJSON, documentUrlExpression, documentIdExpression)})`; const ZFunction = Function; @@ -319,6 +335,7 @@ export function compilePreInjectScript( ? embeddedPatternCheckerString("location.href", JSON.stringify(scriptUrlPatterns)) : "true"; const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : ""; + const documentIdExpression = `(()=>{const k='${preInjectScriptDocumentIdKey}',d=Object.getOwnPropertyDescriptor(window,k);if(d&&'value'in d&&typeof d.value==='string')return d.value;const v=Date.now().toString(36)+'-'+Math.random().toString(36).slice(2);Object.defineProperty(window,k,{configurable:false,writable:false,value:v});return v})()`; const evScriptLoad = `${eventNamePrefix}${DefinedFlags.scriptLoadComplete}`; const evEnvLoad = `${eventNamePrefix}${DefinedFlags.envLoadComplete}`; return `{ @@ -326,7 +343,7 @@ export function compilePreInjectScript( f = () => { if (!(${urlCondition})) return false; if (!mounted) { - ${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`, scriptInfoJSON, "location.href")}; + ${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`, scriptInfoJSON, "location.href", documentIdExpression)}; mounted = true; } const o = { cancelable: true, detail: { scriptFlag: '${flag}' } }, From 6ee8ba4b36a752b4f0eb3c5c94c4017b84db6e0c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 06:36:09 +0900 Subject: [PATCH 094/103] =?UTF-8?q?=F0=9F=94=92=20require=20a=20native=20M?= =?UTF-8?q?AIN=20page-load=20handshake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/inject.ts | 68 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 13 deletions(-) diff --git a/src/inject.ts b/src/inject.ts index ff8049211..fe6f198db 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -17,6 +17,8 @@ import { createMainWorldPageLoadGate } from "./app/service/content/main_world_pa const messageFlag = process.env.SC_RANDOM_KEY!; +const NATIVE_BOOTSTRAP_TIMEOUT_MS = 1000; + getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | undefined) => { const scriptEnvTag = ScriptEnvTag.inject; @@ -46,10 +48,27 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde let reconnecting = false; let openingNative = false; let nativeConnection: MessageConnect | undefined; + let pendingNativeReady: + | { + resolve: (connected: boolean) => void; + timer: ReturnType; + } + | undefined; let reconnectToken: string | undefined; + const settleNativeReady = (connected: boolean): void => { + const pending = pendingNativeReady; + if (!pending) return; + pendingNativeReady = undefined; + clearTimeout(pending.timer); + pending.resolve(connected); + }; + const handleNativePacket = (_connection: MessageConnect, packet: TMessage) => { if (packet.action === "inject/pageLoad") { + if (!pendingNativeReady) return; + nativeConnection = _connection; + settleNativeReady(true); const nextToken = runtime.receivePageLoad(packet.data); if (nextToken) reconnectToken = nextToken; } else if (packet.action === "inject/runtime/valueUpdate") { @@ -62,14 +81,26 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde const openNativeChannel = async (bootstrapToken: string): Promise => { if (openingNative || nativeConnection) return Boolean(nativeConnection); openingNative = true; - let connection: MessageConnect | undefined; - try { - connection = await connectUserScriptChannel( + return new Promise((resolve) => { + const timer = setTimeout(() => { + const connection = nativeConnection; + nativeConnection = undefined; + settleNativeReady(false); + try { + connection?.disconnect(true); + } catch (error) { + logger.logger().debug("MAIN USER_SCRIPT channel cleanup failed", { error: String(error) }); + } + }, NATIVE_BOOTSTRAP_TIMEOUT_MS); + pendingNativeReady = { resolve, timer }; + + void connectUserScriptChannel( nativeMsg, bootstrapToken, handleNativePacket, (isSelfDisconnected) => { - if (nativeConnection === connection) nativeConnection = undefined; + nativeConnection = undefined; + settleNativeReady(false); if (isSelfDisconnected || reconnecting || !reconnectToken) return; reconnecting = true; void requestUserScriptReconnect(nativeMsg, reconnectToken) @@ -80,15 +111,26 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde }); }, "MAIN" - ); - nativeConnection = connection; - return connection !== undefined; - } catch (error) { - logger.logger().debug("MAIN USER_SCRIPT channel failed", { error: String(error) }); - return false; - } finally { - openingNative = false; - } + ) + .then((connection) => { + if (!connection) { + settleNativeReady(false); + return; + } + if (pendingNativeReady || nativeConnection === connection) { + nativeConnection = connection; + return; + } + connection.disconnect(true); + }) + .catch((error) => { + logger.logger().debug("MAIN USER_SCRIPT channel failed", { error: String(error) }); + settleNativeReady(false); + }) + .finally(() => { + openingNative = false; + }); + }); }; if (pageServer) { From babadbc917d3f3608df4fa2be6d951303a269b3f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:22:32 +0900 Subject: [PATCH 095/103] =?UTF-8?q?=E2=9C=85=20bound=20E2E=20waits=20and?= =?UTF-8?q?=20replay=20invariant=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace replay-cap loops with constant-time full-state invariant checks and enforce a 40-second E2E budget across Playwright configuration, helper waits, and CI. Make retry helpers side-effect bounded and preserve failure logs while closing pages on all paths. --- .github/workflows/test.yaml | 3 + e2e/agent-conversation.spec.ts | 8 +- e2e/agent-error-handling.spec.ts | 4 +- e2e/gm-api.spec.ts | 64 ++++++++-------- e2e/gm-xhr-site-access.spec.ts | 37 +++++---- e2e/options.spec.ts | 2 +- e2e/resource-update.spec.ts | 40 +++++----- e2e/storage-name.spec.ts | 2 +- e2e/utils.ts | 20 +++-- package.json | 1 + playwright.config.ts | 2 +- scripts/check-e2e-budgets.mjs | 75 +++++++++++++++++++ src/app/service/content/page_rpc.test.ts | 17 ++--- .../service_worker/gm_api/gm_api.test.ts | 26 ++----- 14 files changed, 192 insertions(+), 109 deletions(-) create mode 100644 scripts/check-e2e-budgets.mjs diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 586cbafe9..172734b9a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -211,6 +211,9 @@ jobs: - name: Install dependencies run: pnpm i --frozen-lockfile + - name: Check E2E test budgets + run: pnpm run check:e2e-budgets + - name: Cache Playwright browsers id: playwright-cache uses: actions/cache@v6 diff --git a/e2e/agent-conversation.spec.ts b/e2e/agent-conversation.spec.ts index d47957da0..ee44c2d7b 100644 --- a/e2e/agent-conversation.spec.ts +++ b/e2e/agent-conversation.spec.ts @@ -5,7 +5,7 @@ import { runInlineTestScript } from "./utils"; const TARGET_URL = "https://content-security-policy.com/"; test.describe("Agent Conversation API", () => { - test.setTimeout(300_000); + test.setTimeout(40_000); test("basic chat — send message and receive text reply", async ({ context, extensionId, mockLLMResponse }) => { mockLLMResponse(() => makeTextSSE("1+1等于2。")); @@ -47,7 +47,7 @@ test.describe("Agent Conversation API", () => { })(); `; - const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 60_000); + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 30_000); console.log(`[agent-basic-chat] passed=${passed}, failed=${failed}`); if (failed !== 0) console.log("[agent-basic-chat] logs:", logs.join("\n")); @@ -131,7 +131,7 @@ test.describe("Agent Conversation API", () => { })(); `; - const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 60_000); + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 30_000); console.log(`[agent-tool-calling] passed=${passed}, failed=${failed}`); if (failed !== 0) console.log("[agent-tool-calling] logs:", logs.join("\n")); @@ -190,7 +190,7 @@ test.describe("Agent Conversation API", () => { })(); `; - const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 60_000); + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 30_000); console.log(`[agent-multi-turn] passed=${passed}, failed=${failed}`); if (failed !== 0) console.log("[agent-multi-turn] logs:", logs.join("\n")); diff --git a/e2e/agent-error-handling.spec.ts b/e2e/agent-error-handling.spec.ts index 3b76a23e6..9593af202 100644 --- a/e2e/agent-error-handling.spec.ts +++ b/e2e/agent-error-handling.spec.ts @@ -5,7 +5,7 @@ import { runInlineTestScript } from "./utils"; const TARGET_URL = "https://content-security-policy.com/"; test.describe("Agent Error Handling", () => { - test.setTimeout(300_000); + test.setTimeout(40_000); test("LLM returns 500 then retries and succeeds", async ({ context, extensionId, mockLLMResponse }) => { let callCount = 0; @@ -79,7 +79,7 @@ test.describe("Agent Error Handling", () => { })(); `; - const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 90_000); + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 30_000); console.log(`[error-retry] passed=${passed}, failed=${failed}`); if (failed !== 0) console.log("[error-retry] logs:", logs.join("\n")); diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 2a8194060..ee66afd34 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -563,6 +563,7 @@ async function runTestScript( beforeCollect?: (page: Page) => Promise; } ): Promise<{ summary: SCTestSummary; logs: string[] }> { + if (timeoutMs > 40_000) throw new RangeError("SCTest E2E wait exceeds 40000ms"); let code = fs.readFileSync(path.join(__dirname, `../example/tests/${scriptFile}`), "utf-8"); code = patchScriptCode(code); if (options?.requireOrigin) code = patchRequireCode(code, options.requireOrigin); @@ -577,6 +578,11 @@ async function runTestScript( let summary: SCTestSummary | null = null; let summaryCount = 0; + const deadline = Date.now() + timeoutMs; + const waitFor = async (predicate: () => boolean): Promise => { + const remaining = Math.max(1, deadline - Date.now()); + await expect.poll(predicate, { timeout: remaining, intervals: [100, 250, 500, 1_000] }).toBe(true); + }; page.on("console", (msg) => { const text = msg.text(); @@ -592,30 +598,24 @@ async function runTestScript( } }); - await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); - - 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); + try { + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + if (options?.beforeCollect) { + // 顺序很重要:先等页面加载时那组汇总打完(那时 auto:false 的用例还全是 skip, + // 汇总是 "通过: 0 / 失败: 0"),再点按钮,最后等下一组汇总。 + // 若在 goto 之后立刻取快照,首次汇总往往还没打,会让第二个轮询被它立即满足而读到 0/0。 + await waitFor(() => summaryCount > 0); + const seenBefore = summaryCount; + await options.beforeCollect(page); + await waitFor(() => summaryCount > seenBefore); + } else { + await waitFor(() => summary !== null); + } + } catch (error) { + throw new Error(`No valid SCTest summary found for ${scriptFile}:\n${logs.join("\n")}`, { cause: error }); + } finally { + await page.close(); } - - await page.close(); expect(summary, `No valid SCTest summary found for ${scriptFile}:\n${logs.join("\n")}`).not.toBeNull(); return { summary: summary!, logs }; } @@ -653,7 +653,7 @@ test.describe("GM API", () => { return patchGMApiTestCode(code, gmApiMockServer.origin); } - test.setTimeout(300_000); + test.setTimeout(40_000); test("local CSP target blocks page inline scripts", async ({ context }) => { const page = await context.newPage(); @@ -857,7 +857,7 @@ test.describe("GM API", () => { extensionId, "gm_api_sync_test.js", `${gmApiMockServer.cspOrigin}/?gm_api_sync`, - 90_000, + 30_000, { patchCode, requireOrigin: gmApiMockServer.origin } ); @@ -875,7 +875,7 @@ test.describe("GM API", () => { extensionId, "gm_api_async_test.js", `${gmApiMockServer.cspOrigin}/?gm_api_async`, - 90_000, + 30_000, { patchCode, requireOrigin: gmApiMockServer.origin } ); @@ -893,7 +893,7 @@ test.describe("GM API", () => { extensionId, "inject_content_test.js", `${gmApiMockServer.cspOrigin}/?inject_content`, - 60_000, + 30_000, { requireOrigin: gmApiMockServer.origin } ); @@ -911,7 +911,7 @@ test.describe("GM API", () => { extensionId, "early_inject_page_test.js", `${gmApiMockServer.cspOrigin}/?early_inject_page`, - 60_000, + 30_000, { requireOrigin: gmApiMockServer.origin } ); @@ -926,7 +926,7 @@ test.describe("GM API", () => { extensionId, "early_inject_content_test.js", `${gmApiMockServer.cspOrigin}/?early_inject_content`, - 60_000, + 30_000, { requireOrigin: gmApiMockServer.origin } ); @@ -941,7 +941,7 @@ test.describe("GM API", () => { extensionId, "unwrap_e2e_test.js", `${gmApiMockServer.cspOrigin}/?unwrap_e2e_test`, - 60_000, + 30_000, { requireOrigin: gmApiMockServer.origin } ); @@ -995,7 +995,7 @@ test.describe("GM API", () => { extensionId, "gm_xhr_redirect_test.js", `${gmApiMockServer.origin}/?GM_XHR_REDIRECT_TEST_SC`, - 90_000, + 30_000, { patchCode, requireOrigin: gmApiMockServer.origin } ); @@ -1014,7 +1014,7 @@ test.describe("GM API", () => { "gm_xhr_test.js", `${gmApiMockServer.origin}/?GM_XHR_TEST_SC`, // 138 个用例(69 个基础用例 × xhr/fetch 两轮),其中含多个秒级的 delay/drip 端点。 - 180_000, + 30_000, { patchCode, requireOrigin: gmApiMockServer.origin, diff --git a/e2e/gm-xhr-site-access.spec.ts b/e2e/gm-xhr-site-access.spec.ts index f9b0d2a0d..000b8fddc 100644 --- a/e2e/gm-xhr-site-access.spec.ts +++ b/e2e/gm-xhr-site-access.spec.ts @@ -46,21 +46,28 @@ async function runXhr( } }); - await page.goto(targetPageUrl, { waitUntil: "domcontentloaded" }); - await expect - .poll( - async () => { - if (resolved) return true; - await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {}); - return !!resolved; - }, - { timeout: timeoutMs, intervals: [500, 1_000, 1_500] } - ) - .toBe(true) - .catch(() => undefined); - await page.close(); - if (!resolved) throw new Error(`no sentinel from ${targetPageUrl}\nlogs:\n${logs.join("\n")}`); - return { data: resolved, logs }; + try { + await page.goto(targetPageUrl, { waitUntil: "domcontentloaded" }); + const firstAttemptTimeout = Math.max(1_000, Math.floor(timeoutMs / 2)); + try { + await expect + .poll(() => Boolean(resolved), { timeout: firstAttemptTimeout, intervals: [100, 250, 500] }) + .toBe(true); + } catch (error) { + if (resolved) throw error; + try { + await page.reload({ waitUntil: "domcontentloaded" }); + await expect + .poll(() => Boolean(resolved), { timeout: firstAttemptTimeout, intervals: [100, 250, 500] }) + .toBe(true); + } catch (retryError) { + throw new Error(`no sentinel from ${targetPageUrl}\nlogs:\n${logs.join("\n")}`, { cause: retryError }); + } + } + return { data: resolved!, logs }; + } finally { + await page.close(); + } } function xhrScript(opts: { diff --git a/e2e/options.spec.ts b/e2e/options.spec.ts index 4016e4a03..16bd0111f 100644 --- a/e2e/options.spec.ts +++ b/e2e/options.spec.ts @@ -91,7 +91,7 @@ test.describe("Options 选项页 · 触摸设备", () => { viewport: { width: 1200, height: 800 }, hasTouch: true, isMobile: true, - timeout: 60_000, + timeout: 40_000, }); try { await context.addInitScript(() => { diff --git a/e2e/resource-update.spec.ts b/e2e/resource-update.spec.ts index c73409a06..dbe82b91b 100644 --- a/e2e/resource-update.spec.ts +++ b/e2e/resource-update.spec.ts @@ -30,7 +30,7 @@ async function waitForHit(server: MockServer, pathname: string, timeoutMs = 15_0 /** * 打开目标页面并等待脚本输出哨兵 JSON 行。脚本注入相对安装存在异步窗口, - * 因此在拿不到结果时重新加载页面重试。 + * 因此只允许一次显式重载;轮询本身保持无副作用,避免重复导航放大等待时间。 */ async function runAndCapture( context: BrowserContext, @@ -53,22 +53,28 @@ async function runAndCapture( } }); - await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); - await expect - .poll( - async () => { - if (resolved) return true; - // 脚本可能尚未注册完成,重载重试 - await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {}); - return !!resolved; - }, - { timeout: timeoutMs, intervals: [500, 1_000, 2_000] } - ) - .toBe(true) - .catch(() => undefined); - await page.close(); - if (!resolved) throw new Error(`no sentinel captured from ${targetUrl}\nlogs:\n${logs.join("\n")}`); - return { data: resolved, logs }; + try { + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + const firstAttemptTimeout = Math.max(1_000, Math.floor(timeoutMs / 2)); + try { + await expect + .poll(() => Boolean(resolved), { timeout: firstAttemptTimeout, intervals: [100, 250, 500] }) + .toBe(true); + } catch (error) { + if (resolved) throw error; + try { + await page.reload({ waitUntil: "domcontentloaded" }); + await expect + .poll(() => Boolean(resolved), { timeout: firstAttemptTimeout, intervals: [100, 250, 500] }) + .toBe(true); + } catch (retryError) { + throw new Error(`no sentinel captured from ${targetUrl}\nlogs:\n${logs.join("\n")}`, { cause: retryError }); + } + } + return { data: resolved!, logs }; + } finally { + await page.close(); + } } function selfTestScript(opts: { diff --git a/e2e/storage-name.spec.ts b/e2e/storage-name.spec.ts index 9855fabb4..6e1731177 100644 --- a/e2e/storage-name.spec.ts +++ b/e2e/storage-name.spec.ts @@ -427,7 +427,7 @@ async function runScriptAction(page: Page, action: "deletes" | "purges" | "re } test.describe("@storageName 真实浏览器共享存储", () => { - test.setTimeout(180_000); + test.setTimeout(40_000); test("普通脚本应按 storageName 共享或隔离值与变更事件", async ({ context, extensionId }) => { await serveTargetPage(context); diff --git a/e2e/utils.ts b/e2e/utils.ts index fc54e4c46..17ee2287e 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -1,5 +1,7 @@ import { expect, type BrowserContext, type Frame, type Page } from "@playwright/test"; +const MAX_E2E_WAIT_MS = 40_000; + /** * Auto-approve permission confirm dialogs opened by the extension. * Listens for new pages matching confirm.html (new-ui / shadcn) and grants the request: @@ -61,6 +63,7 @@ export async function runInlineTestScript( targetUrl: string, timeoutMs: number ): Promise<{ passed: number; failed: number; logs: string[] }> { + if (timeoutMs > MAX_E2E_WAIT_MS) throw new RangeError(`Inline E2E wait exceeds ${MAX_E2E_WAIT_MS}ms`); await installScriptByCode(context, extensionId, code); autoApprovePermissions(context); @@ -78,13 +81,16 @@ export async function runInlineTestScript( if (failMatch) failed = parseInt(failMatch[1], 10); }); - await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); - await expect - .poll(() => passed >= 0 && failed >= 0, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) - .toBe(true) - .catch(() => undefined); - - await page.close(); + try { + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + await expect + .poll(() => passed >= 0 && failed >= 0, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) + .toBe(true); + } catch (error) { + throw new Error(`Inline E2E script did not report a result:\n${logs.join("\n")}`, { cause: error }); + } finally { + await page.close(); + } return { passed, failed, logs }; } diff --git a/package.json b/package.json index 527e03682..c0fc5b7da 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:e2e:install": "pnpm exec playwright install chromium", "test:e2e": "pnpm exec playwright test", "test:e2e:ui": "pnpm exec playwright test --ui", + "check:e2e-budgets": "node ./scripts/check-e2e-budgets.mjs", "validate:yaml": "node ./scripts/validate-yaml.mjs", "validate:yaml:all": "node ./scripts/validate-yaml.mjs --all", "check:i18n": "node ./scripts/check-i18n.mjs", diff --git a/playwright.config.ts b/playwright.config.ts index 1fe7b7e7b..fefd413b1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ // 一次性验证脚本放在 e2e/scratch/(已 gitignore),不纳入正式 E2E 套件/CI。 // 单跑请用 playwright.scratch.config.ts:见 docs/verification.md。 testIgnore: ["**/scratch/**"], - timeout: 60_000, + timeout: 40_000, expect: { timeout: 10_000, }, diff --git a/scripts/check-e2e-budgets.mjs b/scripts/check-e2e-budgets.mjs new file mode 100644 index 000000000..9a5e5c0b3 --- /dev/null +++ b/scripts/check-e2e-budgets.mjs @@ -0,0 +1,75 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import ts from "typescript"; + +const root = process.cwd(); +const maxTestTimeoutMs = 40_000; +const checkedHelpers = new Map([ + ["runInlineTestScript", 4], + ["runTestScript", 4], +]); +const violations = []; + +const numericValue = (node) => { + if (ts.isNumericLiteral(node)) return Number(node.text.replaceAll("_", "")); + if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.PlusToken) return numericValue(node.operand); + return undefined; +}; + +const callName = (expression) => { + if (ts.isIdentifier(expression)) return expression.text; + if (!ts.isPropertyAccessExpression(expression)) return undefined; + const owner = callName(expression.expression); + return owner ? `${owner}.${expression.name.text}` : expression.name.text; +}; + +const report = (sourceFile, node, label, value) => { + const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + violations.push( + `${path.relative(root, sourceFile.fileName)}:${position.line + 1}: ${label} is ${value}ms (maximum ${maxTestTimeoutMs}ms)` + ); +}; + +const checkFile = (fileName) => { + const source = fs.readFileSync(fileName, "utf8"); + const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const visit = (node) => { + if (ts.isCallExpression(node)) { + const name = callName(node.expression); + if (name === "test.setTimeout") { + const value = numericValue(node.arguments[0]); + if (value !== undefined && value > maxTestTimeoutMs) report(sourceFile, node, "test timeout", value); + } + const helperArgument = checkedHelpers.get(name); + if (helperArgument !== undefined) { + const value = numericValue(node.arguments[helperArgument]); + if (value !== undefined && value > maxTestTimeoutMs) report(sourceFile, node, `${name} timeout`, value); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); +}; + +const e2eDir = path.join(root, "e2e"); +for (const entry of fs.readdirSync(e2eDir, { withFileTypes: true })) { + if (entry.isFile() && entry.name.endsWith(".spec.ts")) checkFile(path.join(e2eDir, entry.name)); +} + +const configFile = path.join(root, "playwright.config.ts"); +const configSource = fs.readFileSync(configFile, "utf8"); +const configMatch = configSource.match(/\btimeout\s*:\s*([0-9][0-9_]*)/); +const configTimeout = configMatch ? Number(configMatch[1].replaceAll("_", "")) : undefined; +if (configTimeout === undefined) { + violations.push("playwright.config.ts: missing a numeric global timeout"); +} else if (configTimeout > maxTestTimeoutMs) { + violations.push(`playwright.config.ts: global timeout is ${configTimeout}ms (maximum ${maxTestTimeoutMs}ms)`); +} + +if (violations.length > 0) { + console.error(violations.join("\n")); + process.exitCode = 1; +} else { + console.log(`E2E test budgets are capped at ${maxTestTimeoutMs}ms.`); +} diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 2aa749caf..40cb5c58e 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -363,20 +363,19 @@ describe("page GM RPC", () => { } }); - it("accepts more than 4096 requests while still rejecting replay", () => { + it("accepts a unique request when replay state is full while still rejecting replay", () => { const registry = new PageRpcRegistry(); const handle = registry.register("script-a", "it", ["GM_getValue"]); + const binding = registry.resolve(handle, "GM_getValue"); - for (let index = 0; index < 4096; index += 1) { - validatePageGMRequest( - { version: 1, requestId: `request-${index}`, handle, api: "GM_getValue", params: [] }, - registry - ); - } + // 请求 ID 必须严格只消费一次,与集合已保存的条目数量无关。 + // 直接模拟满集合,避免 CI 为构造状态发送数千个请求。 + Object.defineProperty(binding.requestIds, "size", { configurable: true, value: 4096 }); + binding.requestIds.add("request-0"); expect( - validatePageGMRequest({ version: 1, requestId: "request-4096", handle, api: "GM_getValue", params: [] }, registry) - ).toMatchObject({ requestId: "request-4096" }); + validatePageGMRequest({ version: 1, requestId: "request-4097", handle, api: "GM_getValue", params: [] }, registry) + ).toMatchObject({ requestId: "request-4097" }); expect(() => validatePageGMRequest({ version: 1, requestId: "request-0", handle, api: "GM_getValue", params: [] }, registry) ).toThrow("already used"); 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 000064bc6..dc1d00fff 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 @@ -337,7 +337,7 @@ describe("page execution binding gate", () => { await expect(api.handlerRequest(validRequest, sender)).rejects.toThrow("page RPC requestId was already used"); }); - it("accepts more than 4096 page RPC requests while still rejecting replay", async () => { + it("accepts a unique request when replay state is full while still rejecting replay", 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", { @@ -353,6 +353,9 @@ describe("page execution binding gate", () => { script: { uuid: "script-a", name: "script-a" }, }), }); + const requestIds = new Set(["request-0"]); + // 直接模拟满集合,验证唯一 ID 仍可用且重放仍被拒绝,避免 CI 发送数千个请求。 + Object.defineProperty(requestIds, "size", { configurable: true, value: 4096 }); const binding = { handle: "handle-a", uuid: "script-a", @@ -361,7 +364,7 @@ describe("page execution binding gate", () => { tabId: 42, frameId: 0, allowedAPIs: new Set(["GM_log"]), - requestIds: new Set(), + requestIds, }; Object.defineProperty(api, "resolvePageExecutionBinding", { configurable: true, @@ -370,23 +373,6 @@ describe("page execution binding gate", () => { const sender = makeSender(); sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab, frameId: 0 }); - for (let index = 0; index < 4096; index += 1) { - await expect( - api.handlerRequest( - { - uuid: "script-a", - api: "GM_log", - params: ["hello"], - runFlag: "forged", - executionHandle: "handle-a", - requestId: `request-${index}`, - version: 1, - }, - sender - ) - ).resolves.toBe(true); - } - await expect( api.handlerRequest( { @@ -395,7 +381,7 @@ describe("page execution binding gate", () => { params: ["hello"], runFlag: "forged", executionHandle: "handle-a", - requestId: "request-4096", + requestId: "request-4097", version: 1, }, sender From 0541cdbcb40b6da9fa0b06e20d912cc02a3719d9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:26:26 +0900 Subject: [PATCH 096/103] =?UTF-8?q?=F0=9F=94=A7=20restore=20PR=20E2E=20tim?= =?UTF-8?q?eout=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep CI and helper timeout behavior outside this PR. Retain the constant-time replay invariant tests while restoring the existing E2E configuration and retry semantics. --- .github/workflows/test.yaml | 3 -- e2e/agent-conversation.spec.ts | 8 ++-- e2e/agent-error-handling.spec.ts | 4 +- e2e/gm-api.spec.ts | 64 +++++++++++++-------------- e2e/gm-xhr-site-access.spec.ts | 37 +++++++--------- e2e/options.spec.ts | 2 +- e2e/resource-update.spec.ts | 40 ++++++++--------- e2e/storage-name.spec.ts | 2 +- e2e/utils.ts | 20 +++------ package.json | 1 - playwright.config.ts | 2 +- scripts/check-e2e-budgets.mjs | 75 -------------------------------- 12 files changed, 80 insertions(+), 178 deletions(-) delete mode 100644 scripts/check-e2e-budgets.mjs diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 172734b9a..586cbafe9 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -211,9 +211,6 @@ jobs: - name: Install dependencies run: pnpm i --frozen-lockfile - - name: Check E2E test budgets - run: pnpm run check:e2e-budgets - - name: Cache Playwright browsers id: playwright-cache uses: actions/cache@v6 diff --git a/e2e/agent-conversation.spec.ts b/e2e/agent-conversation.spec.ts index ee44c2d7b..d47957da0 100644 --- a/e2e/agent-conversation.spec.ts +++ b/e2e/agent-conversation.spec.ts @@ -5,7 +5,7 @@ import { runInlineTestScript } from "./utils"; const TARGET_URL = "https://content-security-policy.com/"; test.describe("Agent Conversation API", () => { - test.setTimeout(40_000); + test.setTimeout(300_000); test("basic chat — send message and receive text reply", async ({ context, extensionId, mockLLMResponse }) => { mockLLMResponse(() => makeTextSSE("1+1等于2。")); @@ -47,7 +47,7 @@ test.describe("Agent Conversation API", () => { })(); `; - const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 30_000); + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 60_000); console.log(`[agent-basic-chat] passed=${passed}, failed=${failed}`); if (failed !== 0) console.log("[agent-basic-chat] logs:", logs.join("\n")); @@ -131,7 +131,7 @@ test.describe("Agent Conversation API", () => { })(); `; - const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 30_000); + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 60_000); console.log(`[agent-tool-calling] passed=${passed}, failed=${failed}`); if (failed !== 0) console.log("[agent-tool-calling] logs:", logs.join("\n")); @@ -190,7 +190,7 @@ test.describe("Agent Conversation API", () => { })(); `; - const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 30_000); + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 60_000); console.log(`[agent-multi-turn] passed=${passed}, failed=${failed}`); if (failed !== 0) console.log("[agent-multi-turn] logs:", logs.join("\n")); diff --git a/e2e/agent-error-handling.spec.ts b/e2e/agent-error-handling.spec.ts index 9593af202..3b76a23e6 100644 --- a/e2e/agent-error-handling.spec.ts +++ b/e2e/agent-error-handling.spec.ts @@ -5,7 +5,7 @@ import { runInlineTestScript } from "./utils"; const TARGET_URL = "https://content-security-policy.com/"; test.describe("Agent Error Handling", () => { - test.setTimeout(40_000); + test.setTimeout(300_000); test("LLM returns 500 then retries and succeeds", async ({ context, extensionId, mockLLMResponse }) => { let callCount = 0; @@ -79,7 +79,7 @@ test.describe("Agent Error Handling", () => { })(); `; - const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 30_000); + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 90_000); console.log(`[error-retry] passed=${passed}, failed=${failed}`); if (failed !== 0) console.log("[error-retry] logs:", logs.join("\n")); diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index ee66afd34..2a8194060 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -563,7 +563,6 @@ async function runTestScript( beforeCollect?: (page: Page) => Promise; } ): Promise<{ summary: SCTestSummary; logs: string[] }> { - if (timeoutMs > 40_000) throw new RangeError("SCTest E2E wait exceeds 40000ms"); let code = fs.readFileSync(path.join(__dirname, `../example/tests/${scriptFile}`), "utf-8"); code = patchScriptCode(code); if (options?.requireOrigin) code = patchRequireCode(code, options.requireOrigin); @@ -578,11 +577,6 @@ async function runTestScript( let summary: SCTestSummary | null = null; let summaryCount = 0; - const deadline = Date.now() + timeoutMs; - const waitFor = async (predicate: () => boolean): Promise => { - const remaining = Math.max(1, deadline - Date.now()); - await expect.poll(predicate, { timeout: remaining, intervals: [100, 250, 500, 1_000] }).toBe(true); - }; page.on("console", (msg) => { const text = msg.text(); @@ -598,24 +592,30 @@ async function runTestScript( } }); - try { - await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); - if (options?.beforeCollect) { - // 顺序很重要:先等页面加载时那组汇总打完(那时 auto:false 的用例还全是 skip, - // 汇总是 "通过: 0 / 失败: 0"),再点按钮,最后等下一组汇总。 - // 若在 goto 之后立刻取快照,首次汇总往往还没打,会让第二个轮询被它立即满足而读到 0/0。 - await waitFor(() => summaryCount > 0); - const seenBefore = summaryCount; - await options.beforeCollect(page); - await waitFor(() => summaryCount > seenBefore); - } else { - await waitFor(() => summary !== null); - } - } catch (error) { - throw new Error(`No valid SCTest summary found for ${scriptFile}:\n${logs.join("\n")}`, { cause: error }); - } finally { - await page.close(); + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + + 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); } + + await page.close(); expect(summary, `No valid SCTest summary found for ${scriptFile}:\n${logs.join("\n")}`).not.toBeNull(); return { summary: summary!, logs }; } @@ -653,7 +653,7 @@ test.describe("GM API", () => { return patchGMApiTestCode(code, gmApiMockServer.origin); } - test.setTimeout(40_000); + test.setTimeout(300_000); test("local CSP target blocks page inline scripts", async ({ context }) => { const page = await context.newPage(); @@ -857,7 +857,7 @@ test.describe("GM API", () => { extensionId, "gm_api_sync_test.js", `${gmApiMockServer.cspOrigin}/?gm_api_sync`, - 30_000, + 90_000, { patchCode, requireOrigin: gmApiMockServer.origin } ); @@ -875,7 +875,7 @@ test.describe("GM API", () => { extensionId, "gm_api_async_test.js", `${gmApiMockServer.cspOrigin}/?gm_api_async`, - 30_000, + 90_000, { patchCode, requireOrigin: gmApiMockServer.origin } ); @@ -893,7 +893,7 @@ test.describe("GM API", () => { extensionId, "inject_content_test.js", `${gmApiMockServer.cspOrigin}/?inject_content`, - 30_000, + 60_000, { requireOrigin: gmApiMockServer.origin } ); @@ -911,7 +911,7 @@ test.describe("GM API", () => { extensionId, "early_inject_page_test.js", `${gmApiMockServer.cspOrigin}/?early_inject_page`, - 30_000, + 60_000, { requireOrigin: gmApiMockServer.origin } ); @@ -926,7 +926,7 @@ test.describe("GM API", () => { extensionId, "early_inject_content_test.js", `${gmApiMockServer.cspOrigin}/?early_inject_content`, - 30_000, + 60_000, { requireOrigin: gmApiMockServer.origin } ); @@ -941,7 +941,7 @@ test.describe("GM API", () => { extensionId, "unwrap_e2e_test.js", `${gmApiMockServer.cspOrigin}/?unwrap_e2e_test`, - 30_000, + 60_000, { requireOrigin: gmApiMockServer.origin } ); @@ -995,7 +995,7 @@ test.describe("GM API", () => { extensionId, "gm_xhr_redirect_test.js", `${gmApiMockServer.origin}/?GM_XHR_REDIRECT_TEST_SC`, - 30_000, + 90_000, { patchCode, requireOrigin: gmApiMockServer.origin } ); @@ -1014,7 +1014,7 @@ test.describe("GM API", () => { "gm_xhr_test.js", `${gmApiMockServer.origin}/?GM_XHR_TEST_SC`, // 138 个用例(69 个基础用例 × xhr/fetch 两轮),其中含多个秒级的 delay/drip 端点。 - 30_000, + 180_000, { patchCode, requireOrigin: gmApiMockServer.origin, diff --git a/e2e/gm-xhr-site-access.spec.ts b/e2e/gm-xhr-site-access.spec.ts index 000b8fddc..f9b0d2a0d 100644 --- a/e2e/gm-xhr-site-access.spec.ts +++ b/e2e/gm-xhr-site-access.spec.ts @@ -46,28 +46,21 @@ async function runXhr( } }); - try { - await page.goto(targetPageUrl, { waitUntil: "domcontentloaded" }); - const firstAttemptTimeout = Math.max(1_000, Math.floor(timeoutMs / 2)); - try { - await expect - .poll(() => Boolean(resolved), { timeout: firstAttemptTimeout, intervals: [100, 250, 500] }) - .toBe(true); - } catch (error) { - if (resolved) throw error; - try { - await page.reload({ waitUntil: "domcontentloaded" }); - await expect - .poll(() => Boolean(resolved), { timeout: firstAttemptTimeout, intervals: [100, 250, 500] }) - .toBe(true); - } catch (retryError) { - throw new Error(`no sentinel from ${targetPageUrl}\nlogs:\n${logs.join("\n")}`, { cause: retryError }); - } - } - return { data: resolved!, logs }; - } finally { - await page.close(); - } + await page.goto(targetPageUrl, { waitUntil: "domcontentloaded" }); + await expect + .poll( + async () => { + if (resolved) return true; + await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {}); + return !!resolved; + }, + { timeout: timeoutMs, intervals: [500, 1_000, 1_500] } + ) + .toBe(true) + .catch(() => undefined); + await page.close(); + if (!resolved) throw new Error(`no sentinel from ${targetPageUrl}\nlogs:\n${logs.join("\n")}`); + return { data: resolved, logs }; } function xhrScript(opts: { diff --git a/e2e/options.spec.ts b/e2e/options.spec.ts index 16bd0111f..4016e4a03 100644 --- a/e2e/options.spec.ts +++ b/e2e/options.spec.ts @@ -91,7 +91,7 @@ test.describe("Options 选项页 · 触摸设备", () => { viewport: { width: 1200, height: 800 }, hasTouch: true, isMobile: true, - timeout: 40_000, + timeout: 60_000, }); try { await context.addInitScript(() => { diff --git a/e2e/resource-update.spec.ts b/e2e/resource-update.spec.ts index dbe82b91b..c73409a06 100644 --- a/e2e/resource-update.spec.ts +++ b/e2e/resource-update.spec.ts @@ -30,7 +30,7 @@ async function waitForHit(server: MockServer, pathname: string, timeoutMs = 15_0 /** * 打开目标页面并等待脚本输出哨兵 JSON 行。脚本注入相对安装存在异步窗口, - * 因此只允许一次显式重载;轮询本身保持无副作用,避免重复导航放大等待时间。 + * 因此在拿不到结果时重新加载页面重试。 */ async function runAndCapture( context: BrowserContext, @@ -53,28 +53,22 @@ async function runAndCapture( } }); - try { - await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); - const firstAttemptTimeout = Math.max(1_000, Math.floor(timeoutMs / 2)); - try { - await expect - .poll(() => Boolean(resolved), { timeout: firstAttemptTimeout, intervals: [100, 250, 500] }) - .toBe(true); - } catch (error) { - if (resolved) throw error; - try { - await page.reload({ waitUntil: "domcontentloaded" }); - await expect - .poll(() => Boolean(resolved), { timeout: firstAttemptTimeout, intervals: [100, 250, 500] }) - .toBe(true); - } catch (retryError) { - throw new Error(`no sentinel captured from ${targetUrl}\nlogs:\n${logs.join("\n")}`, { cause: retryError }); - } - } - return { data: resolved!, logs }; - } finally { - await page.close(); - } + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + await expect + .poll( + async () => { + if (resolved) return true; + // 脚本可能尚未注册完成,重载重试 + await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {}); + return !!resolved; + }, + { timeout: timeoutMs, intervals: [500, 1_000, 2_000] } + ) + .toBe(true) + .catch(() => undefined); + await page.close(); + if (!resolved) throw new Error(`no sentinel captured from ${targetUrl}\nlogs:\n${logs.join("\n")}`); + return { data: resolved, logs }; } function selfTestScript(opts: { diff --git a/e2e/storage-name.spec.ts b/e2e/storage-name.spec.ts index 6e1731177..9855fabb4 100644 --- a/e2e/storage-name.spec.ts +++ b/e2e/storage-name.spec.ts @@ -427,7 +427,7 @@ async function runScriptAction(page: Page, action: "deletes" | "purges" | "re } test.describe("@storageName 真实浏览器共享存储", () => { - test.setTimeout(40_000); + test.setTimeout(180_000); test("普通脚本应按 storageName 共享或隔离值与变更事件", async ({ context, extensionId }) => { await serveTargetPage(context); diff --git a/e2e/utils.ts b/e2e/utils.ts index 17ee2287e..fc54e4c46 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -1,7 +1,5 @@ import { expect, type BrowserContext, type Frame, type Page } from "@playwright/test"; -const MAX_E2E_WAIT_MS = 40_000; - /** * Auto-approve permission confirm dialogs opened by the extension. * Listens for new pages matching confirm.html (new-ui / shadcn) and grants the request: @@ -63,7 +61,6 @@ export async function runInlineTestScript( targetUrl: string, timeoutMs: number ): Promise<{ passed: number; failed: number; logs: string[] }> { - if (timeoutMs > MAX_E2E_WAIT_MS) throw new RangeError(`Inline E2E wait exceeds ${MAX_E2E_WAIT_MS}ms`); await installScriptByCode(context, extensionId, code); autoApprovePermissions(context); @@ -81,16 +78,13 @@ export async function runInlineTestScript( if (failMatch) failed = parseInt(failMatch[1], 10); }); - try { - await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); - await expect - .poll(() => passed >= 0 && failed >= 0, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) - .toBe(true); - } catch (error) { - throw new Error(`Inline E2E script did not report a result:\n${logs.join("\n")}`, { cause: error }); - } finally { - await page.close(); - } + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + await expect + .poll(() => passed >= 0 && failed >= 0, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) + .toBe(true) + .catch(() => undefined); + + await page.close(); return { passed, failed, logs }; } diff --git a/package.json b/package.json index c0fc5b7da..527e03682 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "test:e2e:install": "pnpm exec playwright install chromium", "test:e2e": "pnpm exec playwright test", "test:e2e:ui": "pnpm exec playwright test --ui", - "check:e2e-budgets": "node ./scripts/check-e2e-budgets.mjs", "validate:yaml": "node ./scripts/validate-yaml.mjs", "validate:yaml:all": "node ./scripts/validate-yaml.mjs --all", "check:i18n": "node ./scripts/check-i18n.mjs", diff --git a/playwright.config.ts b/playwright.config.ts index fefd413b1..1fe7b7e7b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ // 一次性验证脚本放在 e2e/scratch/(已 gitignore),不纳入正式 E2E 套件/CI。 // 单跑请用 playwright.scratch.config.ts:见 docs/verification.md。 testIgnore: ["**/scratch/**"], - timeout: 40_000, + timeout: 60_000, expect: { timeout: 10_000, }, diff --git a/scripts/check-e2e-budgets.mjs b/scripts/check-e2e-budgets.mjs deleted file mode 100644 index 9a5e5c0b3..000000000 --- a/scripts/check-e2e-budgets.mjs +++ /dev/null @@ -1,75 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import process from "node:process"; -import ts from "typescript"; - -const root = process.cwd(); -const maxTestTimeoutMs = 40_000; -const checkedHelpers = new Map([ - ["runInlineTestScript", 4], - ["runTestScript", 4], -]); -const violations = []; - -const numericValue = (node) => { - if (ts.isNumericLiteral(node)) return Number(node.text.replaceAll("_", "")); - if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.PlusToken) return numericValue(node.operand); - return undefined; -}; - -const callName = (expression) => { - if (ts.isIdentifier(expression)) return expression.text; - if (!ts.isPropertyAccessExpression(expression)) return undefined; - const owner = callName(expression.expression); - return owner ? `${owner}.${expression.name.text}` : expression.name.text; -}; - -const report = (sourceFile, node, label, value) => { - const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); - violations.push( - `${path.relative(root, sourceFile.fileName)}:${position.line + 1}: ${label} is ${value}ms (maximum ${maxTestTimeoutMs}ms)` - ); -}; - -const checkFile = (fileName) => { - const source = fs.readFileSync(fileName, "utf8"); - const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); - const visit = (node) => { - if (ts.isCallExpression(node)) { - const name = callName(node.expression); - if (name === "test.setTimeout") { - const value = numericValue(node.arguments[0]); - if (value !== undefined && value > maxTestTimeoutMs) report(sourceFile, node, "test timeout", value); - } - const helperArgument = checkedHelpers.get(name); - if (helperArgument !== undefined) { - const value = numericValue(node.arguments[helperArgument]); - if (value !== undefined && value > maxTestTimeoutMs) report(sourceFile, node, `${name} timeout`, value); - } - } - ts.forEachChild(node, visit); - }; - visit(sourceFile); -}; - -const e2eDir = path.join(root, "e2e"); -for (const entry of fs.readdirSync(e2eDir, { withFileTypes: true })) { - if (entry.isFile() && entry.name.endsWith(".spec.ts")) checkFile(path.join(e2eDir, entry.name)); -} - -const configFile = path.join(root, "playwright.config.ts"); -const configSource = fs.readFileSync(configFile, "utf8"); -const configMatch = configSource.match(/\btimeout\s*:\s*([0-9][0-9_]*)/); -const configTimeout = configMatch ? Number(configMatch[1].replaceAll("_", "")) : undefined; -if (configTimeout === undefined) { - violations.push("playwright.config.ts: missing a numeric global timeout"); -} else if (configTimeout > maxTestTimeoutMs) { - violations.push(`playwright.config.ts: global timeout is ${configTimeout}ms (maximum ${maxTestTimeoutMs}ms)`); -} - -if (violations.length > 0) { - console.error(violations.join("\n")); - process.exitCode = 1; -} else { - console.log(`E2E test budgets are capped at ${maxTestTimeoutMs}ms.`); -} From 3217aca128ef2d7366fb3d3be205f3eeb6e0950c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:59:29 +0900 Subject: [PATCH 097/103] =?UTF-8?q?=F0=9F=94=92=20route=20MAIN=20bootstrap?= =?UTF-8?q?=20over=20extension=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/user_script_connection.test.ts | 19 +++++++++++++++++-- .../service/content/user_script_connection.ts | 5 +++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/app/service/content/user_script_connection.test.ts b/src/app/service/content/user_script_connection.test.ts index 8689a7424..d68ef656d 100644 --- a/src/app/service/content/user_script_connection.test.ts +++ b/src/app/service/content/user_script_connection.test.ts @@ -31,7 +31,7 @@ describe("connectUserScriptChannel", () => { expect(connection.sendMessage).toHaveBeenCalledWith({ action: "userScript/bootstrap" }); }); - it("preserves the MAIN world identity when opening the inject port", async () => { + it("uses the constrained extension transport for the MAIN world port", async () => { const connection = makeConnection(); const message = { sendMessage: vi.fn().mockResolvedValue(true), @@ -42,7 +42,7 @@ describe("connectUserScriptChannel", () => { expect(message.connect).toHaveBeenCalledWith({ action: "serviceWorker/runtime/registerUserScript", - data: { world: "MAIN", bootstrapToken: "inject-bootstrap" }, + data: { world: "MAIN", bootstrapToken: "inject-bootstrap", transport: "extension" }, }); }); @@ -75,6 +75,21 @@ describe("connectUserScriptChannel", () => { expect(connection.sendMessage).toHaveBeenCalledWith({ action: "userScript/bootstrap" }); }); + it("uses the extension fallback when listener capability 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(), undefined, "MAIN"); + + expect(message.connect).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/registerUserScript", + data: { world: "MAIN", bootstrapToken: "bootstrap-token", transport: "extension" }, + }); + }); + it("reports remote disconnects so the caller can reconnect natively", async () => { const connection = makeConnection(); const onDisconnect = vi.fn(); diff --git a/src/app/service/content/user_script_connection.ts b/src/app/service/content/user_script_connection.ts index b3fdb30a4..61bf2933a 100644 --- a/src/app/service/content/user_script_connection.ts +++ b/src/app/service/content/user_script_connection.ts @@ -21,15 +21,16 @@ export async function connectUserScriptChannel( world: UserScriptWorld = "USER_SCRIPT" ): Promise { const enabled = await message.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" } as unknown as TMessage); + const useExtensionFallback = world === "MAIN" || enabled !== true; let connection: MessageConnect; try { // 缺少专用 USER_SCRIPT 监听器时仍使用扩展原生端口;服务端会用文档绑定的令牌限制该降级路径。 connection = await message.connect({ action: "serviceWorker/runtime/registerUserScript", - data: enabled === false ? { world, bootstrapToken, transport: "extension" } : { world, bootstrapToken }, + data: useExtensionFallback ? { world, bootstrapToken, transport: "extension" } : { world, bootstrapToken }, }); } catch (error) { - if (enabled !== false) throw error; + if (!useExtensionFallback) throw error; return undefined; } connection.onMessage((packet) => onPacket(connection, packet)); From dbad9926c4a6baf790e2877c215ef28e84712ec4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:57:32 +0900 Subject: [PATCH 098/103] =?UTF-8?q?=F0=9F=94=92=20restore=20MAIN=20bootstr?= =?UTF-8?q?ap=20page=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 7 +++++-- src/inject.ts | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index ee2e73c6b..9b2392227 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -6,6 +6,10 @@ const unsupportedAPI = () => { // 在页面或用户脚本替换调用内建函数前完成捕获。 export const nativeReflectApply = Reflect.apply; const nativeFunctionBind = Function.prototype.bind; +const hasNativeStructuredClone = typeof structuredClone === "function"; +const nativeStructuredClone = hasNativeStructuredClone + ? nativeReflectApply(nativeFunctionBind, structuredClone, [globalThis]) + : unsupportedAPI; const nativeSetConstructor = Set; const nativeSetAdd = Set.prototype.add; const nativeSetHas = Set.prototype.has; @@ -29,7 +33,6 @@ const nativeWeakMapDelete = WeakMap.prototype.delete; const nativeObjectFreeze = Object.freeze; const nativeReflectOwnKeys = Reflect.ownKeys; const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; -const hasNativeStructuredClone = typeof structuredClone === "function"; const nativeDocumentCreateElement = typeof Document === "undefined" ? undefined : Document.prototype.createElement; const nativeOwnFragment = typeof DocumentFragment === "undefined" ? undefined : new DocumentFragment(); @@ -89,7 +92,7 @@ export const Native = { WeakMap: NativeWeakMapConstructor, bind: nativeBind, reflectApply: nativeReflectApply, - structuredClone: typeof structuredClone === "function" ? structuredClone : unsupportedAPI, + structuredClone: nativeStructuredClone, jsonStringify: nativeBind(JSON.stringify, JSON), jsonParse: nativeBind(JSON.parse, JSON), createElement: nativeDocumentCreateElement, diff --git a/src/inject.ts b/src/inject.ts index fe6f198db..ec44e611c 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -147,14 +147,14 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde pageLoadGate.onBootstrap(data.bootstrapToken); }); pageServer.on("pageLoad", pageLoadGate.onPageLoad); - } else { + } + runtime.init(); + if (!pageServer) { // 没有原生 runtime 通道时,bootstrap 只作为页面桥上的兼容握手,随后请求完整 pageLoad。 server.on("bootstrap", () => { void new Client(pageMsg, "scripting").do("pageLoadFallback"); }); } - runtime.init(); - // inject环境,直接判断白名单,注入对外接口 runtime.externalMessage("scripting", pageMsg); }); From 0057ee83abb0b8201aa9214953ecdf637cad6576 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:14:33 +0900 Subject: [PATCH 099/103] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=B7=A8=E4=B8=8A=E4=B8=8B=E6=96=87=E6=B6=88=E6=81=AF=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E7=83=AD=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/page_message.ts | 129 +++++++++++++++++++++-------- packages/message/window_message.ts | 82 +++++++++++------- 2 files changed, 148 insertions(+), 63 deletions(-) diff --git a/packages/message/page_message.ts b/packages/message/page_message.ts index 14c8ed308..807fe96bf 100644 --- a/packages/message/page_message.ts +++ b/packages/message/page_message.ts @@ -32,51 +32,112 @@ const listenerMgr = new EventEmitter(); const nativeReflectOwnKeys = Reflect.ownKeys; const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; -const PAGE_MESSAGE_KEYS = ["channel", "source", "target", "messageId", "type", "data"] as const; const parsePageMessageBody = (value: unknown): PageMessageBody | undefined => { if (value === null || typeof value !== "object") return undefined; - let keys: (string | symbol)[]; + let channel: unknown; + let source: unknown; + let target: unknown; + let messageId: unknown; + let type: unknown; + let data: unknown; try { - keys = nativeReflectOwnKeys(value); - } catch { - return undefined; - } - if (keys.length !== PAGE_MESSAGE_KEYS.length) return undefined; - for (let index = 0; index < keys.length; index += 1) { - const key = keys[index]; - let known = false; - if (typeof key === "string") { - for (let expectedIndex = 0; expectedIndex < PAGE_MESSAGE_KEYS.length; expectedIndex += 1) { - if (PAGE_MESSAGE_KEYS[expectedIndex] === key) { - known = true; - break; + const keys = nativeReflectOwnKeys(value); + if (keys.length !== 6) return undefined; + // sendEnvelope 按该字面量顺序构造;保留乱序校验作为兼容路径。 + if ( + keys[0] === "channel" && + keys[1] === "source" && + keys[2] === "target" && + keys[3] === "messageId" && + keys[4] === "type" && + keys[5] === "data" + ) { + const channelDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "channel"); + const sourceDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "source"); + const targetDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "target"); + const messageIdDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "messageId"); + const typeDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "type"); + const dataDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "data"); + if ( + !channelDescriptor || + !("value" in channelDescriptor) || + !sourceDescriptor || + !("value" in sourceDescriptor) || + !targetDescriptor || + !("value" in targetDescriptor) || + !messageIdDescriptor || + !("value" in messageIdDescriptor) || + !typeDescriptor || + !("value" in typeDescriptor) || + !dataDescriptor || + !("value" in dataDescriptor) + ) { + return undefined; + } + channel = channelDescriptor.value; + source = sourceDescriptor.value; + target = targetDescriptor.value; + messageId = messageIdDescriptor.value; + type = typeDescriptor.value; + data = dataDescriptor.value; + } else { + let seen = 0; + for (let index = 0; index < 6; index += 1) { + const key = keys[index]; + switch (key) { + case "channel": { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + channel = descriptor.value; + seen |= 1; + break; + } + case "source": { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + source = descriptor.value; + seen |= 2; + break; + } + case "target": { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + target = descriptor.value; + seen |= 4; + break; + } + case "messageId": { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + messageId = descriptor.value; + seen |= 8; + break; + } + case "type": { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + type = descriptor.value; + seen |= 16; + break; + } + case "data": { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + data = descriptor.value; + seen |= 32; + break; + } + default: + return undefined; } } - } - if (!known) { - return undefined; - } - } - - let fields: PropertyDescriptor[]; - try { - fields = []; - for (let index = 0; index < PAGE_MESSAGE_KEYS.length; index += 1) { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, PAGE_MESSAGE_KEYS[index]); - if (!descriptor || !("value" in descriptor)) return undefined; - fields[fields.length] = descriptor; + if (seen !== 63) return undefined; } } catch { return undefined; } - const channel = fields[0].value; - const source = fields[1].value; - const target = fields[2].value; - const messageId = fields[3].value; - const type = fields[4].value; - const data = fields[5].value; if ( typeof channel !== "string" || (source !== "scripting" && source !== "inject") || diff --git a/packages/message/window_message.ts b/packages/message/window_message.ts index 40cd2d8e5..e4c841ded 100644 --- a/packages/message/window_message.ts +++ b/packages/message/window_message.ts @@ -34,45 +34,69 @@ export type WindowMessageBody = { 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; - let keys: (string | symbol)[]; + let messageId: unknown; + let type: unknown; + let data: unknown; try { - keys = nativeReflectOwnKeys(value); - } catch { - return undefined; - } - if (keys.length !== WINDOW_MESSAGE_KEYS.length) return undefined; - for (let index = 0; index < keys.length; index += 1) { - const key = keys[index]; - let known = false; - if (typeof key === "string") { - for (let expectedIndex = 0; expectedIndex < WINDOW_MESSAGE_KEYS.length; expectedIndex += 1) { - if (WINDOW_MESSAGE_KEYS[expectedIndex] === key) { - known = true; - break; + const keys = nativeReflectOwnKeys(value); + if (keys.length !== 3) return undefined; + // 项目内消息按该字面量顺序构造;保留乱序校验作为兼容路径。 + if (keys[0] === "messageId" && keys[1] === "type" && keys[2] === "data") { + const messageIdDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "messageId"); + const typeDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "type"); + const dataDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "data"); + if ( + !messageIdDescriptor || + !("value" in messageIdDescriptor) || + !typeDescriptor || + !("value" in typeDescriptor) || + !dataDescriptor || + !("value" in dataDescriptor) + ) { + return undefined; + } + messageId = messageIdDescriptor.value; + type = typeDescriptor.value; + data = dataDescriptor.value; + } else { + let seen = 0; + for (let index = 0; index < 3; index += 1) { + const key = keys[index]; + switch (key) { + case "messageId": { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + messageId = descriptor.value; + seen |= 1; + break; + } + case "type": { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + type = descriptor.value; + seen |= 2; + break; + } + case "data": { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + data = descriptor.value; + seen |= 4; + break; + } + default: + return undefined; } } - } - if (!known) return undefined; - } - - let fields: PropertyDescriptor[]; - try { - fields = []; - for (let index = 0; index < WINDOW_MESSAGE_KEYS.length; index += 1) { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, WINDOW_MESSAGE_KEYS[index]); - if (!descriptor || !("value" in descriptor)) return undefined; - fields[fields.length] = descriptor; + if (seen !== 7) return undefined; } } catch { return undefined; } - const messageId = fields[0].value; - const type = fields[1].value; if ( typeof messageId !== "string" || (type !== "sendMessage" && @@ -83,7 +107,7 @@ export const parseWindowMessageBody = (value: unknown): WindowMessageBody | unde ) { return undefined; } - return { messageId, type, data: fields[2].value } as WindowMessageBody; + return { messageId, type, data } as WindowMessageBody; }; export class WindowMessage implements Message { From cf6cf21954c040b46df8508be88ea0d8e30bcd53 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:27:58 +0900 Subject: [PATCH 100/103] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20=E7=B2=BE=E7=AE=80?= =?UTF-8?q?=E8=B7=A8=E4=B8=8A=E4=B8=8B=E6=96=87=E6=B6=88=E6=81=AF=E4=BD=93?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/page_message.ts | 154 +++++++++-------------------- packages/message/window_message.ts | 86 ++++------------ 2 files changed, 66 insertions(+), 174 deletions(-) diff --git a/packages/message/page_message.ts b/packages/message/page_message.ts index 807fe96bf..9fe9b73d7 100644 --- a/packages/message/page_message.ts +++ b/packages/message/page_message.ts @@ -36,122 +36,58 @@ const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const parsePageMessageBody = (value: unknown): PageMessageBody | undefined => { if (value === null || typeof value !== "object") return undefined; - let channel: unknown; - let source: unknown; - let target: unknown; - let messageId: unknown; - let type: unknown; - let data: unknown; try { - const keys = nativeReflectOwnKeys(value); - if (keys.length !== 6) return undefined; - // sendEnvelope 按该字面量顺序构造;保留乱序校验作为兼容路径。 + if (nativeReflectOwnKeys(value).length !== 6) return undefined; + + const channel = nativeObjectGetOwnPropertyDescriptor(value, "channel"); + const source = nativeObjectGetOwnPropertyDescriptor(value, "source"); + const target = nativeObjectGetOwnPropertyDescriptor(value, "target"); + const messageId = nativeObjectGetOwnPropertyDescriptor(value, "messageId"); + const type = nativeObjectGetOwnPropertyDescriptor(value, "type"); + const data = nativeObjectGetOwnPropertyDescriptor(value, "data"); if ( - keys[0] === "channel" && - keys[1] === "source" && - keys[2] === "target" && - keys[3] === "messageId" && - keys[4] === "type" && - keys[5] === "data" + !channel || + !("value" in channel) || + !source || + !("value" in source) || + !target || + !("value" in target) || + !messageId || + !("value" in messageId) || + !type || + !("value" in type) || + !data || + !("value" in data) ) { - const channelDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "channel"); - const sourceDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "source"); - const targetDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "target"); - const messageIdDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "messageId"); - const typeDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "type"); - const dataDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "data"); - if ( - !channelDescriptor || - !("value" in channelDescriptor) || - !sourceDescriptor || - !("value" in sourceDescriptor) || - !targetDescriptor || - !("value" in targetDescriptor) || - !messageIdDescriptor || - !("value" in messageIdDescriptor) || - !typeDescriptor || - !("value" in typeDescriptor) || - !dataDescriptor || - !("value" in dataDescriptor) - ) { - return undefined; - } - channel = channelDescriptor.value; - source = sourceDescriptor.value; - target = targetDescriptor.value; - messageId = messageIdDescriptor.value; - type = typeDescriptor.value; - data = dataDescriptor.value; - } else { - let seen = 0; - for (let index = 0; index < 6; index += 1) { - const key = keys[index]; - switch (key) { - case "channel": { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) return undefined; - channel = descriptor.value; - seen |= 1; - break; - } - case "source": { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) return undefined; - source = descriptor.value; - seen |= 2; - break; - } - case "target": { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) return undefined; - target = descriptor.value; - seen |= 4; - break; - } - case "messageId": { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) return undefined; - messageId = descriptor.value; - seen |= 8; - break; - } - case "type": { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) return undefined; - type = descriptor.value; - seen |= 16; - break; - } - case "data": { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) return undefined; - data = descriptor.value; - seen |= 32; - break; - } - default: - return undefined; - } - } - if (seen !== 63) return undefined; + return undefined; } + + const messageType = type.value; + if ( + typeof channel.value !== "string" || + (source.value !== "scripting" && source.value !== "inject") || + (target.value !== "scripting" && target.value !== "inject") || + typeof messageId.value !== "string" || + (messageType !== "sendMessage" && + messageType !== "respMessage" && + messageType !== "connect" && + messageType !== "disconnect" && + messageType !== "connectMessage") + ) { + return undefined; + } + + return { + channel: channel.value, + source: source.value, + target: target.value, + messageId: messageId.value, + type: messageType, + data: data.value, + } as PageMessageBody; } catch { return undefined; } - if ( - typeof channel !== "string" || - (source !== "scripting" && source !== "inject") || - (target !== "scripting" && target !== "inject") || - typeof messageId !== "string" || - (type !== "sendMessage" && - type !== "respMessage" && - type !== "connect" && - type !== "disconnect" && - type !== "connectMessage") - ) { - return undefined; - } - return { channel, source, target, messageId, type, data } as PageMessageBody; }; const otherRole = (role: PageMessageRole): PageMessageRole => (role === "scripting" ? "inject" : "scripting"); diff --git a/packages/message/window_message.ts b/packages/message/window_message.ts index e4c841ded..f844100bc 100644 --- a/packages/message/window_message.ts +++ b/packages/message/window_message.ts @@ -38,76 +38,32 @@ const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; export const parseWindowMessageBody = (value: unknown): WindowMessageBody | undefined => { if (value === null || typeof value !== "object") return undefined; - let messageId: unknown; - let type: unknown; - let data: unknown; try { - const keys = nativeReflectOwnKeys(value); - if (keys.length !== 3) return undefined; - // 项目内消息按该字面量顺序构造;保留乱序校验作为兼容路径。 - if (keys[0] === "messageId" && keys[1] === "type" && keys[2] === "data") { - const messageIdDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "messageId"); - const typeDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "type"); - const dataDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "data"); - if ( - !messageIdDescriptor || - !("value" in messageIdDescriptor) || - !typeDescriptor || - !("value" in typeDescriptor) || - !dataDescriptor || - !("value" in dataDescriptor) - ) { - return undefined; - } - messageId = messageIdDescriptor.value; - type = typeDescriptor.value; - data = dataDescriptor.value; - } else { - let seen = 0; - for (let index = 0; index < 3; index += 1) { - const key = keys[index]; - switch (key) { - case "messageId": { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) return undefined; - messageId = descriptor.value; - seen |= 1; - break; - } - case "type": { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) return undefined; - type = descriptor.value; - seen |= 2; - break; - } - case "data": { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) return undefined; - data = descriptor.value; - seen |= 4; - break; - } - default: - return undefined; - } - } - if (seen !== 7) return undefined; + if (nativeReflectOwnKeys(value).length !== 3) 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; } - if ( - typeof messageId !== "string" || - (type !== "sendMessage" && - type !== "respMessage" && - type !== "connect" && - type !== "disconnect" && - type !== "connectMessage") - ) { - return undefined; - } - return { messageId, type, data } as WindowMessageBody; }; export class WindowMessage implements Message { From 2d8a75a7af1c0cfdb6b324a8a502367ca9c7fa92 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:02:42 +0900 Subject: [PATCH 101/103] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20reuse=20PageMessag?= =?UTF-8?q?e=20connection=20sender?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/page_message.test.ts | 31 +++++++++++++++++++++++++++ packages/message/page_message.ts | 21 +++++++++--------- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/message/page_message.test.ts b/packages/message/page_message.test.ts index 39ea01396..0479243e8 100644 --- a/packages/message/page_message.test.ts +++ b/packages/message/page_message.test.ts @@ -124,4 +124,35 @@ describe("PageMessage", () => { expect(received).not.toHaveBeenCalled(); inject.dispose(); }); + + it("keeps page connections working when the page patches Function.prototype.bind", async () => { + const target = createWindow(); + const scripting = new PageMessage("page-message-test", "scripting", target); + const originalBind = Function.prototype.bind; + let connection: Awaited> | undefined; + + try { + let connectionPromise: ReturnType | undefined; + try { + Function.prototype.bind = (() => { + throw new Error("patched bind"); + }) as typeof Function.prototype.bind; + connectionPromise = scripting.connect({ action: "inject/connect" }); + } finally { + Function.prototype.bind = originalBind; + } + + connection = await connectionPromise!; + connection.sendMessage({ action: "inject/message" }); + + expect(target.postMessage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ type: "connectMessage", data: { action: "inject/message" } }), + "*" + ); + } finally { + connection?.disconnect(true); + scripting.dispose(); + } + }); }); diff --git a/packages/message/page_message.ts b/packages/message/page_message.ts index 9fe9b73d7..011f9cbcd 100644 --- a/packages/message/page_message.ts +++ b/packages/message/page_message.ts @@ -169,6 +169,9 @@ export class PageMessage implements Message { private readonly postMessage: (message: unknown, targetOrigin: string) => void; private readonly messageHandler: (event: MessageEvent) => void; private readonly targetRole: PageMessageRole; + private sendEnvelopeBound: + | ((target: PageMessageRole, body: Omit) => void) + | undefined; constructor( private readonly channel: string, @@ -181,16 +184,10 @@ export class PageMessage implements Message { this.messageHandler = (event: MessageEvent) => { if (event.source !== null && event.source !== sourceWindow) return; const body = parsePageMessageBody(event.data); - if ( - !body || - body.channel !== this.channel || - body.target !== this.role || - body.source !== this.targetRole || - typeof body.messageId !== "string" - ) { + if (!body || body.channel !== this.channel || body.target !== this.role || body.source !== this.targetRole) { return; } - this.messageHandle(body as PageMessageBody); + this.messageHandle(body); }; sourceWindow.addEventListener("message", this.messageHandler); } @@ -207,6 +204,10 @@ export class PageMessage implements Message { ); } + private getSendEnvelopeBound() { + return (this.sendEnvelopeBound ??= bindNative(this.sendEnvelope, this)); + } + private messageHandle(body: PageMessageBody): void { if (body.type === "sendMessage") { this.EE.emit( @@ -227,7 +228,7 @@ export class PageMessage implements Message { this.EE.emit( "connect", body.data, - new PageMessageConnect(body.messageId, body.source, this.sendEnvelope.bind(this), this.EE) + new PageMessageConnect(body.messageId, body.source, this.getSendEnvelopeBound(), this.EE) ); } else if (body.type === "disconnect") { this.EE.emit(`disconnect:${body.messageId}`); @@ -247,7 +248,7 @@ export class PageMessage implements Message { connect(data: TMessage): Promise { const messageId = uuidv4(); this.sendEnvelope(this.targetRole, { messageId, type: "connect", data }); - return Promise.resolve(new PageMessageConnect(messageId, this.targetRole, this.sendEnvelope.bind(this), this.EE)); + return Promise.resolve(new PageMessageConnect(messageId, this.targetRole, this.getSendEnvelopeBound(), this.EE)); } sendMessage(data: TMessage): Promise { From 5b07c7e00321e54ae77c2230971e57b230ffe883 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:31:35 +0900 Subject: [PATCH 102/103] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=20structuredClone=20=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 9b2392227..12c16d2e3 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -1,15 +1,10 @@ // 避免在全局页面环境中,内置处理函数被篡改或重写 -const unsupportedAPI = () => { - throw "unsupportedAPI"; -}; // 在页面或用户脚本替换调用内建函数前完成捕获。 export const nativeReflectApply = Reflect.apply; const nativeFunctionBind = Function.prototype.bind; -const hasNativeStructuredClone = typeof structuredClone === "function"; -const nativeStructuredClone = hasNativeStructuredClone - ? nativeReflectApply(nativeFunctionBind, structuredClone, [globalThis]) - : unsupportedAPI; +// structuredClone 不用 bind globalThis; nativeStructuredClone 在初期化時捕获。 +const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; const nativeSetConstructor = Set; const nativeSetAdd = Set.prototype.add; const nativeSetHas = Set.prototype.has; @@ -177,10 +172,10 @@ export const customClone = (o: any) => { }; if (!isDataOnly(o)) return undefined; - if (hasNativeStructuredClone) { + if (nativeStructuredClone) { try { // 优先使用 structuredClone,支持大多数可克隆对象 - return Native.structuredClone(o); + return nativeStructuredClone(o); } catch { // structuredClone 拒绝的值不再退回会执行 getter 的 JSON 序列化。 return undefined; From dcd220cf699722ddf2b75b84664cce6b30bc99c5 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:32:29 +0900 Subject: [PATCH 103/103] =?UTF-8?q?=F0=9F=93=9A=20=E6=9B=B4=E6=96=B0=20Age?= =?UTF-8?q?nt=20=E4=B8=8E=E6=B6=88=E6=81=AF=E4=BC=A0=E8=BE=93=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 17 ++++++++++--- docs/architecture.md | 19 +++++++++++--- docs/references/architecture-agent.md | 31 ++++++++++++++++++++++- docs/references/architecture-execution.md | 9 +++++-- docs/references/architecture-gm-api.md | 4 ++- packages/message/README.md | 3 +++ 6 files changed, 72 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c46b70f4b..005dca2ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,6 +232,13 @@ Service Worker (src/service_worker.ts) > SW → Offscreen uses `ServiceWorkerMessageSend` (`clients.matchAll()` + `postMessage`) on Chrome and > `EventPageOffscreenManager` on Firefox MV3; Offscreen replies to SW over `ExtensionMessage`. `WindowMessage` > is the Offscreen ↔ Sandbox channel. +> +> USER_SCRIPT content and MAIN inject runtimes normally use native extension channels directly to the SW. +> 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. Those bridges carry the content bootstrap handoff, MAIN bootstrap/fallback and runtime update packets, +> synchronous DOM handles, and the whitelisted `external.Scriptcat` API. When MAIN GM RPC falls back through +> `PageMessage`, the scripting runtime validates its execution handle and grant before forwarding it to the SW. - **Service Worker** — central hub for script CRUD, Chrome APIs, permission verification, resource caching, and message routing. - **Content** — bridges SW and inject script. @@ -244,9 +251,13 @@ Sandbox. ### Message Passing (`packages/message/`) -`ExtensionMessage` (chrome.runtime — SW ↔ Content / Inject / Offscreen), `WindowMessage` (postMessage — Offscreen ↔ -Sandbox), `ServiceWorkerMessageSend` (`clients.matchAll()` + `postMessage` — SW → Offscreen on Chrome), -`CustomEventMessage` (CustomEvent — Content ↔ Inject), and `MessageQueue` (cross-context broadcast). +`ExtensionMessage` (chrome.runtime — SW ↔ Content / Inject / Offscreen), `PageMessage` (`window.postMessage` — +scripting ↔ Inject page bridge, including validated MAIN RPC fallback), `CustomEventMessage` (CustomEvent — +bootstrap handoff and DOM handles), +`WindowMessage` (`postMessage` — Offscreen ↔ Sandbox), `ServiceWorkerMessageSend` (`clients.matchAll()` + +`postMessage` — SW → Offscreen on Chrome), and `MessageQueue` (cross-context broadcast). Page-visible bridges do +not establish an authenticated extension origin; MAIN requests relayed through `PageMessage` must pass the +`PageRpcRegistry` checks before forwarding. ### Service & Data Layers diff --git a/docs/architecture.md b/docs/architecture.md index 3b33262f8..58808bc79 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -77,6 +77,16 @@ 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 and MAIN inject runtimes also connect directly +to the Service Worker through native extension channels on the preferred path. 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; `PageMessage` carries MAIN bootstrap/fallback traffic, runtime event/value +updates, and the whitelisted `external.Scriptcat` API. When MAIN GM RPC uses the page bridge fallback, the scripting +runtime validates the execution handle and grant before forwarding it to the Service Worker; the page bridge +itself is not an authenticated extension origin. + --- ## The Five Contexts (Process Model) @@ -86,8 +96,8 @@ 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) | `USER_SCRIPT` world. Uses a native extension channel for bootstrap, GM RPC, value updates, and callbacks; dedicated USER_SCRIPT listeners are preferred, with a token-bound regular port fallback when they cannot be registered. Retains a narrow DOM channel for synchronous node helpers. | `ExtensionMessage` + native callback port → `Server("content")` → `ScriptRuntime`; `CustomEventMessage` only for DOM handles | -| **Inject** | [`src/inject.ts`](../src/inject.ts) | Page (`MAIN`) world. Has `unsafeWindow`; runs page userscripts. | `CustomEventMessage` to content + `Server("inject")` | +| **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. | Native extension port for the preferred GM RPC path; `PageMessage` for bootstrap/fallback, whitelisted external API, and validated GM RPC fallback; `CustomEventMessage` for synchronous DOM handles | | **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` | @@ -167,8 +177,9 @@ 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; token-bound regular-port fallback when dedicated listeners are unavailable) | -| `CustomEventMessage` | [`custom_event_message.ts`](../packages/message/custom_event_message.ts) | Content ↔ Inject | DOM `CustomEvent` dispatch (bypasses page tampering) | +| `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 | +| `PageMessage` | [`page_message.ts`](../packages/message/page_message.ts) | `scripting` ↔ Inject | `window.postMessage`; page-visible MAIN bootstrap/fallback, runtime updates, whitelisted external API, and GM RPC fallback 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 | | `WindowMessage` | [`window_message.ts`](../packages/message/window_message.ts) | Offscreen ↔ Sandbox | `window.postMessage` | | `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` | 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-execution.md b/docs/references/architecture-execution.md index ed4d51fee..c5c054a30 100644 --- a/docs/references/architecture-execution.md +++ b/docs/references/architecture-execution.md @@ -39,8 +39,13 @@ patterns and registers the compiled payload (the `scripting` bundle) with `chrom ([`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. The `USER_SCRIPT` content path obtains its matched scripts directly from the service worker over -`ExtensionMessage`; the isolated `scripting` bundle keeps the page-observable event bridge for `MAIN` execution and -the synchronous DOM helper only. +`ExtensionMessage` after a bootstrap-token handoff. The MAIN `inject` path uses a native extension port for GM RPC +when available; `PageMessage` carries page-visible bootstrap/fallback traffic, MAIN event/value updates, the +whitelisted `external.Scriptcat` API, and the MAIN GM RPC fallback through the `scripting` bundle. That fallback +is checked against the current `PageRpcRegistry` execution handle and grant before it is forwarded to the service +worker. `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 → Sandbox diff --git a/docs/references/architecture-gm-api.md b/docs/references/architecture-gm-api.md index 1b83aad43..9850cdc57 100644 --- a/docs/references/architecture-gm-api.md +++ b/docs/references/architecture-gm-api.md @@ -85,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/packages/message/README.md b/packages/message/README.md index 7e15272ea..a4206d28e 100644 --- a/packages/message/README.md +++ b/packages/message/README.md @@ -19,3 +19,6 @@ document),细节见 - service_worker 和 offscreen 之间可以使用 postMessage 的方式进行通信,避免同时监听 message 与 connect 导致冲突的问题。 - service_worker 会在空闲后进入不活动状态;与它建立的 `connect()` 长连接会在此时中断,因此需要长连接的场景要考虑 重连/状态恢复,而不是假定连接一直存活——这不是禁止在 service_worker 上使用 `connect`,只是需要为其生命周期设计容错。 +- USER_SCRIPT content 和 MAIN inject 优先使用 `ExtensionMessage` 原生扩展通道;服务端区分浏览器提供的 USER_SCRIPT 来源,并把 MAIN 或专用监听器不可用时的普通端口绑定到文档 bootstrap token。 +- `Server("serviceWorker")` 对浏览器标记的 `userScript` 来源仅允许 `connect()` 使用 `runtime/registerUserScript` 或 `runtime/gmApi`,仅允许 `sendMessage()` 使用 `runtime/gmApi` 或 `runtime/reconnectUserScript`;普通 extension 端口不带该来源标记,USER_SCRIPT / MAIN 的注册回退路径会在 `runtime/registerUserScript` 握手中校验文档 bootstrap token。 +- `CustomEventMessage` 和 `PageMessage` 是页面可见的桥:前者承载 content bootstrap 交接与同步 DOM 节点引用,后者承载 MAIN bootstrap/fallback、事件/值更新、白名单 `external.Scriptcat` API,以及经 `scripting` 中转的 GM RPC fallback。它们不提供已认证的扩展来源;`PageMessage` 的 GM RPC 在转发前必须通过 `PageRpcRegistry` 的执行句柄与 grant 校验。