hangwin--mcp-chrome
526 行
15 KiB
TypeScript
526 行
15 KiB
TypeScript
/**
|
|
* JavaScript Tool - CDP Runtime.evaluate with fallback
|
|
*
|
|
* Execute JavaScript in the browser tab and return the result.
|
|
* - Primary: CDP Runtime.evaluate (supports awaitPromise + returnByValue)
|
|
* - Fallback: chrome.scripting.executeScript (when debugger is busy)
|
|
*
|
|
* Features:
|
|
* - Async code support (top-level await via async wrapper)
|
|
* - Output sanitization (sensitive data redaction)
|
|
* - Output truncation (configurable max bytes)
|
|
* - Timeout handling
|
|
* - Detailed error classification
|
|
*/
|
|
|
|
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
|
|
import { BaseBrowserToolExecutor } from '../base-browser';
|
|
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
|
import { cdpSessionManager } from '@/utils/cdp-session-manager';
|
|
import {
|
|
DEFAULT_MAX_OUTPUT_BYTES,
|
|
sanitizeAndLimitOutput,
|
|
sanitizeText,
|
|
} from '@/utils/output-sanitizer';
|
|
|
|
// ============================================================================
|
|
// Constants
|
|
// ============================================================================
|
|
|
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
const CDP_SESSION_KEY = 'javascript';
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
type ExecutionEngine = 'cdp' | 'scripting';
|
|
|
|
type ErrorKind =
|
|
| 'debugger_conflict'
|
|
| 'timeout'
|
|
| 'syntax_error'
|
|
| 'runtime_error'
|
|
| 'cdp_error'
|
|
| 'scripting_error';
|
|
|
|
interface JavaScriptToolParams {
|
|
code: string;
|
|
tabId?: number;
|
|
timeoutMs?: number;
|
|
maxOutputBytes?: number;
|
|
}
|
|
|
|
interface ExecutionError {
|
|
kind: ErrorKind;
|
|
message: string;
|
|
details?: {
|
|
url?: string;
|
|
lineNumber?: number;
|
|
columnNumber?: number;
|
|
};
|
|
}
|
|
|
|
interface ExecutionMetrics {
|
|
elapsedMs: number;
|
|
}
|
|
|
|
interface JavaScriptToolResult {
|
|
success: boolean;
|
|
tabId: number;
|
|
engine: ExecutionEngine;
|
|
result?: string;
|
|
truncated?: boolean;
|
|
redacted?: boolean;
|
|
warnings?: string[];
|
|
error?: ExecutionError;
|
|
metrics?: ExecutionMetrics;
|
|
}
|
|
|
|
interface ExecutionOptions {
|
|
timeoutMs: number;
|
|
maxOutputBytes: number;
|
|
}
|
|
|
|
// Discriminated union for execution results
|
|
type ExecutionSuccess = {
|
|
ok: true;
|
|
engine: ExecutionEngine;
|
|
output: string;
|
|
truncated: boolean;
|
|
redacted: boolean;
|
|
};
|
|
|
|
type ExecutionFailure = {
|
|
ok: false;
|
|
engine: ExecutionEngine;
|
|
error: ExecutionError;
|
|
};
|
|
|
|
type ExecutionResult = ExecutionSuccess | ExecutionFailure;
|
|
|
|
// ============================================================================
|
|
// Timeout Error
|
|
// ============================================================================
|
|
|
|
class TimeoutError extends Error {
|
|
constructor(timeoutMs: number) {
|
|
super(`Execution timed out after ${timeoutMs}ms`);
|
|
this.name = 'TimeoutError';
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Utility Functions
|
|
// ============================================================================
|
|
|
|
function normalizePositiveInt(value: unknown, fallback: number): number {
|
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
return fallback;
|
|
}
|
|
return Math.max(1, Math.floor(value));
|
|
}
|
|
|
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
return new Promise<T>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
reject(new TimeoutError(timeoutMs));
|
|
}, timeoutMs);
|
|
|
|
promise
|
|
.then(resolve)
|
|
.catch(reject)
|
|
.finally(() => clearTimeout(timer));
|
|
});
|
|
}
|
|
|
|
function isTimeoutError(error: unknown): error is TimeoutError {
|
|
return error instanceof Error && error.name === 'TimeoutError';
|
|
}
|
|
|
|
function isDebuggerConflictError(error: unknown): boolean {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return /Debugger is already attached|Another debugger is already attached|Cannot attach to this target/i.test(
|
|
message,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Wrap user code in an async IIFE to support top-level await and return statements.
|
|
*/
|
|
function wrapUserCode(code: string): string {
|
|
return `(async () => {\n${code}\n})()`;
|
|
}
|
|
|
|
// ============================================================================
|
|
// CDP Execution
|
|
// ============================================================================
|
|
|
|
interface CDPRemoteObject {
|
|
type?: string;
|
|
subtype?: string;
|
|
value?: unknown;
|
|
unserializableValue?: string;
|
|
description?: string;
|
|
}
|
|
|
|
interface CDPExceptionDetails {
|
|
text?: string;
|
|
url?: string;
|
|
lineNumber?: number;
|
|
columnNumber?: number;
|
|
exception?: {
|
|
className?: string;
|
|
description?: string;
|
|
value?: string;
|
|
};
|
|
}
|
|
|
|
interface CDPEvaluateResult {
|
|
result?: CDPRemoteObject;
|
|
exceptionDetails?: CDPExceptionDetails;
|
|
}
|
|
|
|
function extractReturnValue(remoteObject?: CDPRemoteObject): unknown {
|
|
if (!remoteObject) return undefined;
|
|
|
|
if ('value' in remoteObject) return remoteObject.value;
|
|
if ('unserializableValue' in remoteObject) return remoteObject.unserializableValue;
|
|
if (typeof remoteObject.description === 'string') return remoteObject.description;
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function parseExceptionDetails(details: CDPExceptionDetails): ExecutionError {
|
|
const exceptionClassName = details.exception?.className ?? '';
|
|
const exceptionDescription = details.exception?.description ?? '';
|
|
const exceptionValue = details.exception?.value ?? '';
|
|
const text = details.text ?? '';
|
|
|
|
// Determine the raw error message
|
|
const rawMessage =
|
|
exceptionDescription || exceptionValue || text || 'JavaScript execution failed';
|
|
|
|
// Sanitize the message
|
|
const message = sanitizeText(rawMessage).text;
|
|
|
|
// Classify the error kind
|
|
const isSyntaxError = exceptionClassName === 'SyntaxError' || /SyntaxError/i.test(rawMessage);
|
|
|
|
return {
|
|
kind: isSyntaxError ? 'syntax_error' : 'runtime_error',
|
|
message,
|
|
details: {
|
|
url: details.url,
|
|
lineNumber: details.lineNumber,
|
|
columnNumber: details.columnNumber,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function executeViaCdp(
|
|
tabId: number,
|
|
code: string,
|
|
options: ExecutionOptions,
|
|
): Promise<ExecutionResult> {
|
|
try {
|
|
const expression = wrapUserCode(code);
|
|
|
|
const response = await withTimeout(
|
|
cdpSessionManager.withSession(tabId, CDP_SESSION_KEY, async () => {
|
|
return (await cdpSessionManager.sendCommand(tabId, 'Runtime.evaluate', {
|
|
expression,
|
|
returnByValue: true,
|
|
awaitPromise: true,
|
|
// CDP 内置超时(毫秒),与外层 withTimeout 双重保障
|
|
timeout: options.timeoutMs,
|
|
})) as CDPEvaluateResult;
|
|
}),
|
|
// 外层超时稍长,给 CDP 一点余量处理超时响应
|
|
options.timeoutMs + 1000,
|
|
);
|
|
|
|
// Check for exception
|
|
if (response?.exceptionDetails) {
|
|
return {
|
|
ok: false,
|
|
engine: 'cdp',
|
|
error: parseExceptionDetails(response.exceptionDetails),
|
|
};
|
|
}
|
|
|
|
// Extract and sanitize the result
|
|
const value = extractReturnValue(response?.result);
|
|
const sanitized = sanitizeAndLimitOutput(value, { maxBytes: options.maxOutputBytes });
|
|
|
|
return {
|
|
ok: true,
|
|
engine: 'cdp',
|
|
output: sanitized.text,
|
|
truncated: sanitized.truncated,
|
|
redacted: sanitized.redacted,
|
|
};
|
|
} catch (error) {
|
|
if (isTimeoutError(error)) {
|
|
return {
|
|
ok: false,
|
|
engine: 'cdp',
|
|
error: { kind: 'timeout', message: error.message },
|
|
};
|
|
}
|
|
|
|
if (isDebuggerConflictError(error)) {
|
|
const message = sanitizeText(error instanceof Error ? error.message : String(error)).text;
|
|
return {
|
|
ok: false,
|
|
engine: 'cdp',
|
|
error: { kind: 'debugger_conflict', message },
|
|
};
|
|
}
|
|
|
|
const message = sanitizeText(error instanceof Error ? error.message : String(error)).text;
|
|
return {
|
|
ok: false,
|
|
engine: 'cdp',
|
|
error: { kind: 'cdp_error', message },
|
|
};
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// chrome.scripting.executeScript Fallback
|
|
// ============================================================================
|
|
|
|
interface ScriptingExecutionResult {
|
|
ok: boolean;
|
|
value?: unknown;
|
|
error?: {
|
|
name?: string;
|
|
message?: string;
|
|
stack?: string;
|
|
};
|
|
}
|
|
|
|
async function executeViaScripting(
|
|
tabId: number,
|
|
code: string,
|
|
options: ExecutionOptions,
|
|
): Promise<ExecutionResult> {
|
|
const innerExecute = async (): Promise<ExecutionResult> => {
|
|
const results = await chrome.scripting.executeScript({
|
|
target: { tabId },
|
|
world: 'ISOLATED',
|
|
func: async (userCode: string): Promise<ScriptingExecutionResult> => {
|
|
try {
|
|
// Use AsyncFunction constructor to support top-level await
|
|
|
|
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
const fn = new AsyncFunction(userCode);
|
|
const value = await fn();
|
|
return { ok: true, value };
|
|
} catch (err: unknown) {
|
|
const error = err as Error;
|
|
return {
|
|
ok: false,
|
|
error: {
|
|
name: error?.name ?? undefined,
|
|
message: error?.message ?? String(err),
|
|
stack: error?.stack ?? undefined,
|
|
},
|
|
};
|
|
}
|
|
},
|
|
args: [code],
|
|
});
|
|
|
|
// Extract the first result
|
|
const firstFrame = results?.[0];
|
|
const result = (firstFrame as { result?: ScriptingExecutionResult })?.result;
|
|
|
|
if (!result || typeof result !== 'object') {
|
|
return {
|
|
ok: false,
|
|
engine: 'scripting',
|
|
error: { kind: 'scripting_error', message: 'No result returned from executeScript' },
|
|
};
|
|
}
|
|
|
|
if (!result.ok) {
|
|
const rawMessage = result.error?.message ?? 'JavaScript execution failed';
|
|
const rawStack = result.error?.stack;
|
|
|
|
const message = sanitizeText(rawMessage).text;
|
|
const sanitizedStack = rawStack ? sanitizeText(rawStack).text : undefined;
|
|
|
|
const isSyntaxError = result.error?.name === 'SyntaxError' || /SyntaxError/i.test(rawMessage);
|
|
|
|
return {
|
|
ok: false,
|
|
engine: 'scripting',
|
|
error: {
|
|
kind: isSyntaxError ? 'syntax_error' : 'runtime_error',
|
|
message: sanitizedStack ? `${message}\n${sanitizedStack}` : message,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Sanitize the successful result
|
|
const sanitized = sanitizeAndLimitOutput(result.value, { maxBytes: options.maxOutputBytes });
|
|
|
|
return {
|
|
ok: true,
|
|
engine: 'scripting',
|
|
output: sanitized.text,
|
|
truncated: sanitized.truncated,
|
|
redacted: sanitized.redacted,
|
|
};
|
|
};
|
|
|
|
try {
|
|
return await withTimeout(innerExecute(), options.timeoutMs);
|
|
} catch (error) {
|
|
if (isTimeoutError(error)) {
|
|
return {
|
|
ok: false,
|
|
engine: 'scripting',
|
|
error: { kind: 'timeout', message: error.message },
|
|
};
|
|
}
|
|
|
|
const message = sanitizeText(error instanceof Error ? error.message : String(error)).text;
|
|
return {
|
|
ok: false,
|
|
engine: 'scripting',
|
|
error: { kind: 'scripting_error', message },
|
|
};
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tool Implementation
|
|
// ============================================================================
|
|
|
|
class JavaScriptTool extends BaseBrowserToolExecutor {
|
|
name = TOOL_NAMES.BROWSER.JAVASCRIPT;
|
|
|
|
async execute(args: JavaScriptToolParams): Promise<ToolResult> {
|
|
const startTime = performance.now();
|
|
|
|
try {
|
|
// Validate required parameter
|
|
const code = typeof args?.code === 'string' ? args.code.trim() : '';
|
|
if (!code) {
|
|
return createErrorResponse('Parameter [code] is required');
|
|
}
|
|
|
|
// Resolve target tab
|
|
const tab = await this.resolveTargetTab(args.tabId);
|
|
if (!tab) {
|
|
return createErrorResponse(
|
|
typeof args.tabId === 'number' ? `Tab not found: ${args.tabId}` : 'No active tab found',
|
|
);
|
|
}
|
|
|
|
if (!tab.id) {
|
|
return createErrorResponse('Tab has no ID');
|
|
}
|
|
const tabId = tab.id;
|
|
|
|
// Normalize options
|
|
const options: ExecutionOptions = {
|
|
timeoutMs: normalizePositiveInt(args.timeoutMs, DEFAULT_TIMEOUT_MS),
|
|
maxOutputBytes: normalizePositiveInt(args.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES),
|
|
};
|
|
|
|
const warnings: string[] = [];
|
|
|
|
// Try CDP execution first
|
|
const cdpResult = await executeViaCdp(tabId, code, options);
|
|
|
|
if (cdpResult.ok) {
|
|
return this.buildSuccessResponse(tabId, cdpResult, startTime);
|
|
}
|
|
|
|
// If not a debugger conflict, return the CDP error
|
|
if (cdpResult.error.kind !== 'debugger_conflict') {
|
|
return this.buildErrorResponse(tabId, cdpResult, startTime);
|
|
}
|
|
|
|
// Debugger conflict - fallback to scripting API
|
|
warnings.push(
|
|
'Debugger is busy (DevTools or another extension attached). Falling back to chrome.scripting.executeScript (runs in ISOLATED world, not page context).',
|
|
);
|
|
|
|
const scriptingResult = await executeViaScripting(tabId, code, options);
|
|
|
|
if (scriptingResult.ok) {
|
|
return this.buildSuccessResponse(tabId, scriptingResult, startTime, warnings);
|
|
}
|
|
|
|
return this.buildErrorResponse(tabId, scriptingResult, startTime, warnings);
|
|
} catch (error) {
|
|
console.error('JavaScriptTool.execute error:', error);
|
|
return createErrorResponse(
|
|
`JavaScript tool error: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async resolveTargetTab(tabId?: number): Promise<chrome.tabs.Tab | null> {
|
|
if (typeof tabId === 'number') {
|
|
return this.tryGetTab(tabId);
|
|
}
|
|
try {
|
|
return await this.getActiveTabOrThrow();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private buildSuccessResponse(
|
|
tabId: number,
|
|
result: ExecutionSuccess,
|
|
startTime: number,
|
|
warnings?: string[],
|
|
): ToolResult {
|
|
const payload: JavaScriptToolResult = {
|
|
success: true,
|
|
tabId,
|
|
engine: result.engine,
|
|
result: result.output,
|
|
truncated: result.truncated || undefined,
|
|
redacted: result.redacted || undefined,
|
|
warnings: warnings?.length ? warnings : undefined,
|
|
metrics: { elapsedMs: Math.round(performance.now() - startTime) },
|
|
};
|
|
|
|
return {
|
|
content: [{ type: 'text', text: JSON.stringify(payload) }],
|
|
isError: false,
|
|
};
|
|
}
|
|
|
|
private buildErrorResponse(
|
|
tabId: number,
|
|
result: ExecutionFailure,
|
|
startTime: number,
|
|
warnings?: string[],
|
|
): ToolResult {
|
|
const payload: JavaScriptToolResult = {
|
|
success: false,
|
|
tabId,
|
|
engine: result.engine,
|
|
error: result.error,
|
|
warnings: warnings?.length ? warnings : undefined,
|
|
metrics: { elapsedMs: Math.round(performance.now() - startTime) },
|
|
};
|
|
|
|
return {
|
|
content: [{ type: 'text', text: JSON.stringify(payload) }],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
export const javascriptTool = new JavaScriptTool();
|