feat(extension): add unified chrome_userscript tool

- Implement UserscriptTool with create/list/get/enable/disable/update/remove/send_command/export
- Auto strategy selection (insertCSS, persistent ISOLATED/MAIN) with CSP-aware fallback
- Persistence via chrome.storage; tab re-injection on navigation
- Register tool and schema; add USERSCRIPTS storage key
- Add Vue SFC typings (env.d.ts); fix lint/typecheck
This commit is contained in:
hangwin
2025-10-09 09:18:31 +00:00
parent f6cb4c0cef
commit 25f416635b
5 changed files with 651 additions and 0 deletions
+1
View File
@@ -102,6 +102,7 @@ export const STORAGE_KEYS = {
SEMANTIC_MODEL: 'selectedModel',
USER_PREFERENCES: 'userPreferences',
VECTOR_INDEX: 'vectorIndex',
USERSCRIPTS: 'userscripts',
} as const;
// Notification Configuration
@@ -16,3 +16,4 @@ export { fileUploadTool } from './file-upload';
export { readPageTool } from './read-page';
export { computerTool } from './computer';
export { handleDialogTool } from './dialog';
export { userscriptTool } from './userscript';
@@ -0,0 +1,620 @@
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { ExecutionWorld, STORAGE_KEYS } from '@/common/constants';
type UserscriptAction =
| 'create'
| 'list'
| 'get'
| 'enable'
| 'disable'
| 'update'
| 'remove'
| 'send_command'
| 'export';
interface UserscriptArgsBase {
action: UserscriptAction;
args?: any;
}
interface CreateArgs {
script: string;
name?: string;
description?: string;
matches?: string[];
excludes?: string[];
persist?: boolean; // default true
runAt?: 'document_start' | 'document_end' | 'document_idle' | 'auto'; // default auto(document_idle)
world?: 'auto' | 'ISOLATED' | 'MAIN'; // default auto(ISOLATED)
allFrames?: boolean; // default true
mode?: 'auto' | 'css' | 'persistent' | 'once'; // default auto
dnrFallback?: boolean; // default true
tags?: string[];
}
type UpdateArgs = Partial<Omit<CreateArgs, 'script'>> & { id: string; script?: string };
interface UserscriptRecord {
id: string;
name?: string;
description?: string;
script: string;
sourceType: 'JS' | 'CSS' | 'TM';
matches: string[];
excludes: string[];
runAt: 'document_start' | 'document_end' | 'document_idle';
world: 'ISOLATED' | 'MAIN';
allFrames: boolean;
persist: boolean;
dnrFallback: boolean;
tags?: string[];
enabled: boolean;
createdAt: number;
updatedAt: number;
installedBy?: string;
lastError?: string;
applyCount?: number;
}
// In-memory tracking of active injections per tab
type ActiveInjection = { kind: 'css' | 'js'; world?: 'ISOLATED' | 'MAIN' };
const activeInjections: Map<number, Map<string, ActiveInjection>> = new Map();
async function loadAllRecords(): Promise<Record<string, UserscriptRecord>> {
const res = await chrome.storage.local.get([STORAGE_KEYS.USERSCRIPTS]);
return (res[STORAGE_KEYS.USERSCRIPTS] as Record<string, UserscriptRecord>) || {};
}
async function saveAllRecords(records: Record<string, UserscriptRecord>): Promise<void> {
await chrome.storage.local.set({ [STORAGE_KEYS.USERSCRIPTS]: records });
}
// Simple FNV-1a hash for deterministic IDs
function fnv1a(str: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24);
}
// Force to unsigned and hex
return (h >>> 0).toString(16);
}
function now(): number {
return Date.now();
}
// Basic TM header parser (subset)
function parseUserscriptMeta(source: string): {
meta: Record<string, string[]>;
isTM: boolean;
} {
const meta: Record<string, string[]> = {};
const start = source.indexOf('==UserScript==');
const end = source.indexOf('==/UserScript==');
if (start !== -1 && end !== -1 && end > start) {
const block = source.slice(start, end).split(/\r?\n/);
for (const line of block) {
const m = line.match(/@([\w-]+)\s+(.+)/);
if (m) {
const k = m[1].trim();
const v = m[2].trim();
if (!meta[k]) meta[k] = [];
meta[k].push(v);
}
}
return { meta, isTM: true };
}
return { meta: {}, isTM: false };
}
function pick<T>(arr: T[] | undefined): T | undefined {
return arr && arr.length > 0 ? arr[0] : undefined;
}
function deriveName(meta: Record<string, string[]>, fallback?: string): string | undefined {
return pick(meta['name']) || fallback;
}
function toBoolean(val: any, d: boolean): boolean {
return typeof val === 'boolean' ? val : d;
}
// Very light CSS heuristic
function isLikelyCSS(source: string): boolean {
const trimmed = source.trim();
if (trimmed.startsWith('/*') && trimmed.includes('==UserStyle')) return true;
if (/^[.#\w\-\s*,:>+~\n\r{}();'"%!@/]+$/.test(trimmed)) {
// no obvious JS keywords
if (
!/(function|=>|var\s|let\s|const\s|document\.|window\.|\beval\b|new\s+Function)/.test(trimmed)
) {
// has CSS braces and colons
const colon = (trimmed.match(/:/g) || []).length;
const brace = (trimmed.match(/[{}]/g) || []).length;
return colon > 0 && brace >= 2;
}
}
return false;
}
function normalizeMatches(matches?: string[], currentUrl?: string): string[] {
if (matches && matches.length > 0) return matches;
if (!currentUrl) return ['<all_urls>'];
try {
const u = new URL(currentUrl);
const host = u.hostname;
const base = host.startsWith('www.') ? host.slice(4) : host;
return [`${u.protocol}//*.${base}/*`, `${u.protocol}//${host}/*`];
} catch {
return ['<all_urls>'];
}
}
// Simple URL match using chrome match patterns subset
function matchUrl(patterns: string[], url?: string): boolean {
if (!url) return false;
try {
const u = new URL(url);
for (const p of patterns) {
if (p === '<all_urls>') return true;
const m = p.match(/^(\*|https?:)\/\/([^/]+)\/(.*)$/);
if (!m) continue;
const proto = m[1];
const host = m[2];
const path = m[3];
if (proto !== '*' && proto !== u.protocol.replace(':', '')) continue;
// host wildcard
const hostRegex = new RegExp(
'^' +
host
.split('.')
.map((h) => (h === '*' ? '[^.]+' : h.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')))
.join('\\.') +
'$',
);
if (!hostRegex.test(u.hostname)) continue;
// path wildcard
const pathRegex = new RegExp(
'^' + path.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$',
);
const testPath = (u.pathname + (u.search || '') + (u.hash || '')).replace(/^\//, '');
if (pathRegex.test(testPath)) return true;
}
} catch {
return false;
}
return false;
}
async function getActiveTab(): Promise<chrome.tabs.Tab | null> {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
return tabs[0] || null;
}
async function insertCssToTab(tabId: number, css: string, allFrames: boolean) {
await chrome.scripting.insertCSS({ target: { tabId, allFrames }, css });
}
async function removeCssFromTab(tabId: number, css: string, allFrames: boolean) {
try {
await chrome.scripting.removeCSS({ target: { tabId, allFrames }, css });
} catch (e) {
// ignore if not present
}
}
async function injectJsPersistent(
tabId: number,
code: string,
world: 'ISOLATED' | 'MAIN',
allFrames: boolean,
) {
if (world === ExecutionWorld.MAIN) {
// Ensure bridge is present in ISOLATED
await chrome.scripting.executeScript({
target: { tabId, allFrames },
files: ['inject-scripts/inject-bridge.js'],
world: ExecutionWorld.ISOLATED,
});
// MAIN world code with command handler wrapper
const wrapped = `(() => {
try {
// Optional command API: window.__userscript_onCommand(action, payload)
window.addEventListener('chrome-mcp:execute', (ev) => {
const { action, payload, requestId } = ev.detail || {};
try {
let result;
const handler = (window as any).__userscript_onCommand;
if (typeof handler === 'function') {
result = handler(action, payload);
}
window.dispatchEvent(new CustomEvent('chrome-mcp:response', { detail: { requestId, data: result } }));
} catch (err) {
window.dispatchEvent(new CustomEvent('chrome-mcp:response', { detail: { requestId, error: String(err && (err as any).message || err) } }));
}
});
(new Function(${JSON.stringify(code)}))();
} catch (e) {
console.warn('Userscript MAIN injection error:', e);
}
})();`;
await chrome.scripting.executeScript({
target: { tabId, allFrames },
func: (src) => {
try {
// Using Function constructor intentionally to evaluate user-provided script
new Function(src)();
} catch (e) {
console.warn('Userscript MAIN wrapper execution error:', e);
}
},
args: [wrapped],
world: ExecutionWorld.MAIN,
});
} else {
// ISOLATED world code with message handler
await chrome.scripting.executeScript({
target: { tabId, allFrames },
func: (userCode) => {
try {
const handlerName = '__userscript_onCommand__';
(chrome.runtime.onMessage as any).addListener(
(req: any, _sender: any, sendResponse: any) => {
if (!req || req.type !== 'userscript:command') return;
const { action, payload, scriptId } = req;
try {
const handler = (globalThis as any)[handlerName];
let result;
if (typeof handler === 'function') {
result = handler(action, payload, scriptId);
}
sendResponse({ data: result });
} catch (err) {
sendResponse({ error: String((err && (err as any).message) || err) });
}
return true;
},
);
// Using Function constructor intentionally to evaluate user-provided script
new Function(userCode)();
} catch (e) {
console.warn('Userscript ISOLATED injection error:', e);
}
},
args: [code],
world: ExecutionWorld.ISOLATED,
});
}
}
function setActiveInjection(tabId: number, id: string, inj: ActiveInjection) {
let m = activeInjections.get(tabId);
if (!m) {
m = new Map();
activeInjections.set(tabId, m);
}
m.set(id, inj);
}
function clearActiveInjection(tabId: number, id: string) {
const m = activeInjections.get(tabId);
if (m) m.delete(id);
}
async function reinjectForTab(tabId: number, url?: string) {
const all = await loadAllRecords();
for (const rec of Object.values(all)) {
if (!rec.enabled || !rec.persist) continue;
if (!matchUrl(rec.matches, url)) continue;
try {
if (rec.sourceType === 'CSS') {
await insertCssToTab(tabId, rec.script, rec.allFrames);
setActiveInjection(tabId, rec.id, { kind: 'css' });
} else {
await injectJsPersistent(tabId, rec.script, rec.world, rec.allFrames);
setActiveInjection(tabId, rec.id, { kind: 'js', world: rec.world });
}
} catch (e) {
console.warn('Reinject failed for tab', tabId, rec.id, e);
}
}
}
// Tab update listener: re-apply enabled persistent scripts
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status === 'complete') {
reinjectForTab(tabId, tab.url).catch(() => {});
}
});
class UserscriptTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.USERSCRIPT;
async execute(params: UserscriptArgsBase): Promise<ToolResult> {
try {
const { action } = params;
const args = params.args || {};
switch (action) {
case 'create':
return await this.create(args as CreateArgs);
case 'list':
return await this.list(args);
case 'get':
return await this.get(args);
case 'enable':
return await this.enable(args, true);
case 'disable':
return await this.enable(args, false);
case 'update':
return await this.update(args as UpdateArgs);
case 'remove':
return await this.remove(args);
case 'send_command':
return await this.sendCommand(args);
case 'export':
return await this.exportAll();
default:
return createErrorResponse(`Unknown action: ${String(action)}`);
}
} catch (error) {
console.error('Userscript tool error:', error);
return createErrorResponse(
`Userscript error: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
private async create(args: CreateArgs): Promise<ToolResult> {
const active = await getActiveTab();
if (!active || !active.id) return createErrorResponse('No active tab found');
const currentUrl = active.url;
const { meta, isTM } = parseUserscriptMeta(args.script);
const name = args.name || deriveName(meta, undefined);
const description = args.description || pick(meta['description']);
const matches = normalizeMatches(args.matches || meta['match'] || meta['include'], currentUrl);
const excludes = args.excludes || meta['exclude'] || [];
const runAt: UserscriptRecord['runAt'] =
(args.runAt && args.runAt !== 'auto' ? args.runAt : (pick(meta['run-at']) as any)) ||
'document_idle';
const requestedWorld =
(args.world && args.world !== 'auto' ? args.world : (pick(meta['inject-into']) as any)) ||
'ISOLATED';
const allFrames = toBoolean(args.allFrames, true);
const persist = toBoolean(args.persist, true);
const dnrFallback = toBoolean(args.dnrFallback, true);
const mode = args.mode || 'auto';
const sourceType: UserscriptRecord['sourceType'] = isTM
? 'TM'
: mode === 'css' || isLikelyCSS(args.script)
? 'CSS'
: 'JS';
const id = `us_${fnv1a((name || '') + '|' + args.script)}`;
const record: UserscriptRecord = {
id,
name,
description,
script: args.script,
sourceType,
matches,
excludes,
runAt,
world: requestedWorld === 'MAIN' ? 'MAIN' : 'ISOLATED',
allFrames,
persist,
dnrFallback,
tags: args.tags,
enabled: true,
createdAt: now(),
updatedAt: now(),
applyCount: 0,
};
const all = await loadAllRecords();
all[id] = record;
await saveAllRecords(all);
// Apply to current tab immediately if matches
let applied = false;
const fallbacks: string[] = [];
try {
if (sourceType === 'CSS') {
await insertCssToTab(active.id!, record.script, record.allFrames);
setActiveInjection(active.id!, id, { kind: 'css' });
applied = true;
} else {
try {
await injectJsPersistent(active.id!, record.script, record.world, record.allFrames);
setActiveInjection(active.id!, id, { kind: 'js', world: record.world });
applied = true;
} catch (e) {
// Try fallback to ISOLATED if MAIN blocked by CSP
if (record.world === 'MAIN') {
fallbacks.push('MAIN->ISOLATED');
await injectJsPersistent(active.id!, record.script, 'ISOLATED', record.allFrames);
setActiveInjection(active.id!, id, { kind: 'js', world: 'ISOLATED' });
applied = true;
} else {
throw e;
}
}
}
} catch (e) {
all[id].lastError = e instanceof Error ? e.message : String(e);
await saveAllRecords(all);
}
const result = {
id,
status: all[id].lastError ? 'queued' : applied ? 'applied' : 'queued',
strategy: {
kind: sourceType === 'CSS' ? 'insertCSS' : `persistent_${all[id].world.toLowerCase()}`,
runAt: all[id].runAt,
world: all[id].world,
allFrames: all[id].allFrames,
fallbacksTried: fallbacks,
},
warnings: [],
};
return {
content: [{ type: 'text', text: JSON.stringify(result) }],
isError: false,
};
}
private async list(_args: any): Promise<ToolResult> {
const all = await loadAllRecords();
const items = Object.values(all).map((r) => ({
id: r.id,
name: r.name,
status: r.enabled ? 'enabled' : 'disabled',
sourceType: r.sourceType,
matches: r.matches,
world: r.world,
runAt: r.runAt,
tags: r.tags || [],
lastError: r.lastError,
updatedAt: r.updatedAt,
}));
return {
content: [{ type: 'text', text: JSON.stringify({ ok: true, items }) }],
isError: false,
};
}
private async get(args: any): Promise<ToolResult> {
const { id } = args || {};
if (!id) return createErrorResponse('id is required');
const all = await loadAllRecords();
const rec = all[id];
if (!rec) return createErrorResponse('userscript not found');
return {
content: [{ type: 'text', text: JSON.stringify({ ok: true, record: rec }) }],
isError: false,
};
}
private async enable(args: any, enabled: boolean): Promise<ToolResult> {
const { id } = args || {};
if (!id) return createErrorResponse('id is required');
const all = await loadAllRecords();
const rec = all[id];
if (!rec) return createErrorResponse('userscript not found');
rec.enabled = enabled;
rec.updatedAt = now();
await saveAllRecords(all);
return { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }], isError: false };
}
private async update(args: UpdateArgs): Promise<ToolResult> {
const { id, ...rest } = args;
if (!id) return createErrorResponse('id is required');
const all = await loadAllRecords();
const rec = all[id];
if (!rec) return createErrorResponse('userscript not found');
if (rest.name !== undefined) rec.name = rest.name;
if (rest.description !== undefined) rec.description = rest.description;
if (rest.matches) rec.matches = rest.matches;
if (rest.excludes) rec.excludes = rest.excludes;
if (rest.runAt && rest.runAt !== 'auto') rec.runAt = rest.runAt;
if (rest.world && rest.world !== 'auto') rec.world = rest.world as any;
if (typeof rest.allFrames === 'boolean') rec.allFrames = rest.allFrames;
if (typeof rest.persist === 'boolean') rec.persist = rest.persist;
if (typeof rest.dnrFallback === 'boolean') rec.dnrFallback = rest.dnrFallback;
if (rest.tags) rec.tags = rest.tags;
if (typeof rest.script === 'string') rec.script = rest.script;
rec.updatedAt = now();
await saveAllRecords(all);
return { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }], isError: false };
}
private async remove(args: any): Promise<ToolResult> {
const { id } = args || {};
if (!id) return createErrorResponse('id is required');
const all = await loadAllRecords();
const rec = all[id];
if (!rec) return createErrorResponse('userscript not found');
delete all[id];
await saveAllRecords(all);
// Attempt cleanup on active tab
const active = await getActiveTab();
if (active && active.id) {
try {
if (rec.sourceType === 'CSS') {
await removeCssFromTab(active.id, rec.script, rec.allFrames);
} else {
// Send cleanup signal via bridge (MAIN) or ignore if isolated
chrome.tabs.sendMessage(active.id, { type: 'chrome-mcp:cleanup' }).catch(() => {});
}
clearActiveInjection(active.id, rec.id);
} catch (err) {
console.warn('Userscript cleanup failed:', err);
}
}
return { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }], isError: false };
}
private async sendCommand(args: any): Promise<ToolResult> {
const { id, payload, tabId } = args || {};
if (!id) return createErrorResponse('id is required');
const tab = tabId ? await chrome.tabs.get(tabId).catch(() => null) : await getActiveTab();
if (!tab || !tab.id) return createErrorResponse('No active tab found');
const all = await loadAllRecords();
const rec = all[id];
if (!rec) return createErrorResponse('userscript not found');
try {
if (rec.world === 'MAIN') {
// Use bridge
const result = await chrome.tabs.sendMessage(tab.id, {
action: 'userscript:command',
payload,
targetWorld: 'MAIN',
});
return {
content: [{ type: 'text', text: JSON.stringify({ ok: true, result }) }],
isError: false,
};
} else {
// ISOLATED handler
const result = await chrome.tabs.sendMessage(tab.id, {
type: 'userscript:command',
action: 'userscript:command',
payload,
scriptId: id,
});
return {
content: [{ type: 'text', text: JSON.stringify({ ok: true, result }) }],
isError: false,
};
}
} catch (e) {
return createErrorResponse(
`send_command failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
private async exportAll(): Promise<ToolResult> {
const all = await loadAllRecords();
return {
content: [{ type: 'text', text: JSON.stringify({ ok: true, data: all }) }],
isError: false,
};
}
}
export const userscriptTool = new UserscriptTool();
+7
View File
@@ -0,0 +1,7 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
type Props = Record<string, never>;
type RawBindings = Record<string, never>;
const component: DefineComponent<Props, RawBindings, any>;
export default component;
}
+22
View File
@@ -30,6 +30,7 @@ export const TOOL_NAMES = {
READ_PAGE: 'chrome_read_page',
COMPUTER: 'chrome_computer',
HANDLE_DIALOG: 'chrome_handle_dialog',
USERSCRIPT: 'chrome_userscript',
},
};
@@ -151,6 +152,27 @@ export const TOOL_SCHEMAS: Tool[] = [
required: ['action'],
},
},
{
name: TOOL_NAMES.BROWSER.USERSCRIPT,
description:
'Unified userscript tool (create/list/get/enable/disable/update/remove/send_command/export). Paste JS/CSS/Tampermonkey script and the system will auto-select the best strategy (insertCSS / persistent script in ISOLATED or MAIN world / once by CDP) with CSP-aware fallbacks.',
inputSchema: {
type: 'object',
properties: {
action: {
type: 'string',
description:
'Operation to perform: create | list | get | enable | disable | update | remove | send_command | export',
},
args: {
type: 'object',
description:
'Arguments for the specified action. For create: { script, name?, description?, matches?, excludes?, persist?, runAt?, world?, allFrames?, mode?, dnrFallback?, tags? }',
},
},
required: ['action'],
},
},
{
name: TOOL_NAMES.BROWSER.NAVIGATE,
description: 'Navigate to a URL or refresh the current tab',