diff --git a/app/chrome-extension/common/constants.ts b/app/chrome-extension/common/constants.ts index 6cd5cc4..b8656d2 100644 --- a/app/chrome-extension/common/constants.ts +++ b/app/chrome-extension/common/constants.ts @@ -102,6 +102,7 @@ export const STORAGE_KEYS = { SEMANTIC_MODEL: 'selectedModel', USER_PREFERENCES: 'userPreferences', VECTOR_INDEX: 'vectorIndex', + USERSCRIPTS: 'userscripts', } as const; // Notification Configuration diff --git a/app/chrome-extension/entrypoints/background/tools/browser/index.ts b/app/chrome-extension/entrypoints/background/tools/browser/index.ts index d473e08..05f86bc 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/index.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/index.ts @@ -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'; diff --git a/app/chrome-extension/entrypoints/background/tools/browser/userscript.ts b/app/chrome-extension/entrypoints/background/tools/browser/userscript.ts new file mode 100644 index 0000000..d3d3fbb --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/userscript.ts @@ -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> & { 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> = new Map(); + +async function loadAllRecords(): Promise> { + const res = await chrome.storage.local.get([STORAGE_KEYS.USERSCRIPTS]); + return (res[STORAGE_KEYS.USERSCRIPTS] as Record) || {}; +} + +async function saveAllRecords(records: Record): Promise { + 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; + isTM: boolean; +} { + const meta: Record = {}; + 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(arr: T[] | undefined): T | undefined { + return arr && arr.length > 0 ? arr[0] : undefined; +} + +function deriveName(meta: Record, 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 ['']; + 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 ['']; + } +} + +// 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 === '') 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const all = await loadAllRecords(); + return { + content: [{ type: 'text', text: JSON.stringify({ ok: true, data: all }) }], + isError: false, + }; + } +} + +export const userscriptTool = new UserscriptTool(); diff --git a/app/chrome-extension/env.d.ts b/app/chrome-extension/env.d.ts new file mode 100644 index 0000000..7ad1e39 --- /dev/null +++ b/app/chrome-extension/env.d.ts @@ -0,0 +1,7 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + type Props = Record; + type RawBindings = Record; + const component: DefineComponent; + export default component; +} diff --git a/packages/shared/src/tools.ts b/packages/shared/src/tools.ts index 7bd1b42..111d282 100644 --- a/packages/shared/src/tools.ts +++ b/packages/shared/src/tools.ts @@ -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',