diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index d4339369..1cc92e38 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -149,6 +149,10 @@ const DEFAULT_WAIT_TIMEOUT_MS = 2000; const DEFAULT_WAIT_POLL_INTERVAL_MS = 100; const DEFAULT_DRAG_STEPS = 5; const MAX_POINTER_ACTIONS = 25; +const SESSION_START_SETTLE_WINDOW_MS = 250; +const MAX_ARTIFACT_FILE_BYTES = 5 * 1024 * 1024; +const SAFE_ARTIFACT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const SESSION_CANCELED_ERROR_CODE = 'SESSION_CANCELED'; let inputBuffer = Buffer.alloc(0); let requestCounter = 0; @@ -161,9 +165,11 @@ let activeRecordingSession = null; let latestRecordingSession = null; let nextReplayCounter = 1; let activeReplaySession = null; +let activeReplayTask = null; let latestReplaySession = null; let nextOrchestrationCounter = 1; let activeOrchestrationSession = null; +let activeOrchestrationTask = null; let latestOrchestrationSession = null; const interceptRules = []; const recentRequests = []; @@ -251,6 +257,21 @@ function writeError(id, code, message) { writeMessage({ jsonrpc: '2.0', id, error: { code, message } }); } +function getAvailableTools() { + const tools = getTools(); + if (getBrowserEvalMode() === 'disabled') { + return tools.filter((tool) => tool.name !== TOOL_EVAL); + } + return tools; +} + +function getAvailableToolNames() { + if (getBrowserEvalMode() === 'disabled') { + return TOOL_NAMES.filter((toolName) => toolName !== TOOL_EVAL); + } + return [...TOOL_NAMES]; +} + function getTools() { if (!shouldExposeTools()) { return []; @@ -1483,7 +1504,7 @@ function resolveTargetPage(pages, toolArgs, defaultSelectedId = selectedPageId, } const selectedIndex = findPageIndexById(pages, defaultSelectedId); if (selectedIndex === -1) { - if (!allowImplicitFallback) { + if (!allowImplicitFallback && defaultSelectedId) { throw new Error('Selected page is no longer available; specify pageIndex or pageId explicitly.'); } const fallbackPage = pages[0]; @@ -2102,6 +2123,59 @@ function getBrowserEvalMode() { return 'readonly'; } +function createSessionCanceledError(kind) { + const error = new Error(`${kind} canceled`); + error.code = SESSION_CANCELED_ERROR_CODE; + return error; +} + +function isSessionCanceledError(error) { + return Boolean(error && typeof error === 'object' && error.code === SESSION_CANCELED_ERROR_CODE); +} + +function throwIfSessionCanceled(session, kind) { + if (session?.cancelRequested === true) { + throw createSessionCanceledError(kind); + } +} + +function markOrchestrationFailureRecorded(error) { + if (error && typeof error === 'object') { + error.orchestrationFailureRecorded = true; + } + return error; +} + +function wasOrchestrationFailureRecorded(error) { + return Boolean(error && typeof error === 'object' && error.orchestrationFailureRecorded === true); +} + +function normalizeDevtoolsResultValue(result) { + if (!result || typeof result !== 'object') { + return { serializable: false, value: undefined }; + } + if (Object.prototype.hasOwnProperty.call(result, 'value')) { + return { serializable: true, value: result.value }; + } + if (Object.prototype.hasOwnProperty.call(result, 'unserializableValue')) { + return { serializable: true, value: result.unserializableValue }; + } + if (result.type === 'undefined') { + return { serializable: true, value: undefined }; + } + return { serializable: false, value: undefined }; +} + +function requireArtifactName(value, label = 'name') { + const name = requireNonEmptyString(value, label); + if (!SAFE_ARTIFACT_NAME_PATTERN.test(name)) { + throw new Error( + 'artifact name must start with a letter or number and contain only letters, numbers, dots, underscores, or hyphens' + ); + } + return name; +} + function parseOptionalNonEmptyString(value) { return typeof value === 'string' && value.trim() !== '' ? value.trim() : ''; } @@ -2389,13 +2463,28 @@ function buildDropFilesExpression(selector, nth, frameSelector, pierceShadow, fi dataTransfer.items.add(file); } - element.dispatchEvent(new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer })); - element.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer })); - const accepted = element.dispatchEvent( - new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer }) - ); + const dragEnterEvent = new DragEvent('dragenter', { + bubbles: true, + cancelable: true, + dataTransfer, + }); + const dragOverEvent = new DragEvent('dragover', { + bubbles: true, + cancelable: true, + dataTransfer, + }); + const dropEvent = new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer, + }); - return { accepted }; + element.dispatchEvent(dragEnterEvent); + const dragOverCanceled = + element.dispatchEvent(dragOverEvent) === false || dragOverEvent.defaultPrevented; + const dropCanceled = element.dispatchEvent(dropEvent) === false || dropEvent.defaultPrevented; + + return { accepted: dragOverCanceled || dropCanceled }; })()`; } @@ -2450,13 +2539,19 @@ function getArtifactDir() { const scoped = Buffer.from(process.cwd()).toString('base64url'); const baseDir = path.join(os.tmpdir(), 'ccs-browser-artifacts', scoped); if (!fs.existsSync(baseDir)) { - fs.mkdirSync(baseDir, { recursive: true }); + fs.mkdirSync(baseDir, { recursive: true, mode: 0o700 }); + } + try { + fs.chmodSync(baseDir, 0o700); + } catch { + // Best-effort on platforms/filesystems that ignore permission changes. } return baseDir; } function getArtifactPath(name) { - return path.join(getArtifactDir(), `${name}.json`); + const safeName = requireArtifactName(name); + return path.join(getArtifactDir(), `${safeName}.json`); } function buildArtifact(kind, name, payload) { @@ -2473,6 +2568,15 @@ function readArtifactFile(filePath) { if (!fs.existsSync(filePath)) { throw new Error('artifact file not found'); } + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + throw new Error('artifact file is not a regular file'); + } + if (stat.size > MAX_ARTIFACT_FILE_BYTES) { + throw new Error( + `artifact file exceeds maximum size of ${MAX_ARTIFACT_FILE_BYTES} bytes` + ); + } const raw = fs.readFileSync(filePath, 'utf8'); let parsed; try { @@ -2562,6 +2666,48 @@ function formatOrchestrationSummary(session) { return lines.join('\n'); } +async function waitForSessionToSettle(task) { + await Promise.race([ + task.then(() => undefined), + sleep(SESSION_START_SETTLE_WINDOW_MS), + ]); +} + +function createReplaySession(page, pageIndex, steps) { + return { + replayId: createReplayId(), + pageId: page.id, + pageIndex, + status: 'running', + stepCount: steps.length, + completedSteps: 0, + currentStepIndex: 0, + failedStepIndex: null, + error: null, + cancelRequested: false, + steps, + }; +} + +function createOrchestrationSession(page, pageIndex, blocks) { + return { + orchestrationId: createOrchestrationId(), + pageId: page.id, + pageIndex, + status: 'running', + blockCount: blocks.length, + completedBlocks: 0, + currentBlockIndex: 0, + failedBlockIndex: null, + failedSequenceStepIndex: null, + error: null, + failedCount: 0, + failures: [], + cancelRequested: false, + blocks, + }; +} + function formatRecordingDetail(session) { const lines = [formatRecordingSummary(session), 'steps:']; if (session.steps.length === 0) { @@ -3805,12 +3951,12 @@ async function handleEval(toolArgs) { } const result = response.result; - const value = Object.prototype.hasOwnProperty.call(result, 'value') ? result.value : undefined; - if (value === undefined && result.type !== 'undefined') { + const normalizedResult = normalizeDevtoolsResultValue(result); + if (!normalizedResult.serializable) { throw new Error('evaluation result is not JSON-serializable'); } - return `pageIndex: ${pageIndex}\nmode: ${mode}\nvalue: ${JSON.stringify(value)}`; + return `pageIndex: ${pageIndex}\nmode: ${mode}\nvalue: ${JSON.stringify(normalizedResult.value)}`; } function getButtonMask(button) { @@ -3867,7 +4013,9 @@ async function handlePointerAction(toolArgs) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } - const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId); + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); if (!page.webSocketDebuggerUrl) { throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); } @@ -3942,9 +4090,13 @@ async function handlePointerAction(toolArgs) { if (!pressedButton) { throw new Error('pointer state error'); } - const releaseButton = requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || pressedButton; - await dispatchMousePointerEvent(page, 'mouseReleased', pointerX, pointerY, releaseButton, false); - pressedButton = null; + const releaseButton = + requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || pressedButton; + if (releaseButton !== pressedButton) { + throw new Error('pointer state error'); + } + await dispatchMousePointerEvent(page, 'mouseReleased', pointerX, pointerY, releaseButton, false); + pressedButton = null; } if (pressedButton) { @@ -3960,7 +4112,9 @@ async function handleDragElement(toolArgs) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } - const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId); + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); if (!page.webSocketDebuggerUrl) { throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); } @@ -4548,7 +4702,9 @@ async function handleSelectPage(toolArgs) { if (pages.length === 0) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } - const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId); + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); if (!page.webSocketDebuggerUrl) { throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); } @@ -4809,7 +4965,9 @@ async function handleSetFileInput(toolArgs) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } - const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId); + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); const selector = requireNonEmptyString(toolArgs.selector, 'selector'); const files = validateLocalFiles(requireNonEmptyStringArray(toolArgs.files, 'files')); const nth = toolArgs.nth === undefined ? 0 : requireNonNegativeInteger(toolArgs.nth, 'nth'); @@ -4843,7 +5001,9 @@ async function handleDragFiles(toolArgs) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } - const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId); + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); const selector = requireNonEmptyString(toolArgs.selector, 'selector'); const files = readLocalFilesForDrop(requireNonEmptyStringArray(toolArgs.files, 'files')); const nth = toolArgs.nth === undefined ? 0 : requireNonNegativeInteger(toolArgs.nth, 'nth'); @@ -5026,7 +5186,9 @@ async function handleStartRecording(toolArgs) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } - const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId); + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); if (!page.webSocketDebuggerUrl) { throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); } @@ -5057,13 +5219,24 @@ async function handleStopRecording() { if (!activeRecordingSession) { throw new Error('no active recording'); } - await finalizeRecordingCapture(activeRecordingSession); - activeRecordingSession.status = 'stopped'; - activeRecordingSession.stoppedAt = new Date().toISOString(); - const stopped = activeRecordingSession; + const session = activeRecordingSession; + let finalizeError = null; + try { + await finalizeRecordingCapture(session); + } catch (error) { + finalizeError = error instanceof Error ? error : new Error(String(error)); + session.warnings.push( + `recording capture finalization failed: ${finalizeError.message}` + ); + } + session.status = 'stopped'; + session.stoppedAt = new Date().toISOString(); activeRecordingSession = null; - latestRecordingSession = stopped; - return formatRecordingSummary(stopped); + latestRecordingSession = session; + if (finalizeError) { + throw finalizeError; + } + return formatRecordingSummary(session); } async function handleGetRecording() { @@ -5175,6 +5348,7 @@ function buildReplayToolArgs(step, replayPage) { async function executeReplaySteps(session) { for (let index = 0; index < session.steps.length; index += 1) { + throwIfSessionCanceled(session, 'replay'); const step = session.steps[index]; session.currentStepIndex = index; validateReplayStep(step, session.pageId); @@ -5201,9 +5375,39 @@ async function executeReplaySteps(session) { } session.completedSteps = index + 1; + throwIfSessionCanceled(session, 'replay'); } } +async function runReplaySession(session, options = {}) { + try { + await executeReplaySteps(session); + if (session.cancelRequested) { + session.status = 'canceled'; + } else { + session.status = 'completed'; + } + } catch (error) { + if (isSessionCanceledError(error) || session.cancelRequested) { + session.status = 'canceled'; + session.error = null; + } else { + session.status = 'failed'; + session.failedStepIndex = session.currentStepIndex; + session.error = error instanceof Error ? error.message : String(error); + } + } finally { + latestReplaySession = session; + if (options.manageActiveState === true) { + if (activeReplaySession === session) { + activeReplaySession = null; + } + activeReplayTask = null; + } + } + return session; +} + async function handleStartReplay(toolArgs) { if (activeReplaySession) { throw new Error('replay already active'); @@ -5214,39 +5418,26 @@ async function handleStartReplay(toolArgs) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } - const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId); + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); if (!page.webSocketDebuggerUrl) { throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); } const steps = requireReplaySteps(toolArgs); - const session = { - replayId: createReplayId(), - pageId: page.id, - pageIndex, - status: 'running', - stepCount: steps.length, - completedSteps: 0, - currentStepIndex: 0, - failedStepIndex: null, - error: null, - steps, - }; + const session = createReplaySession(page, pageIndex, steps); latestReplaySession = session; activeReplaySession = session; - - try { - await executeReplaySteps(session); - session.status = 'completed'; - } catch (error) { - session.status = 'failed'; - session.failedStepIndex = session.currentStepIndex; - session.error = error instanceof Error ? error.message : String(error); - } - - activeReplaySession = null; - latestReplaySession = session; + const task = Promise.resolve().then(() => + runReplaySession(session, { manageActiveState: true }) + ); + activeReplayTask = task; + task.catch(() => { + // Session state is recorded in runReplaySession. + }); + await waitForSessionToSettle(task); return formatReplaySummary(session); } @@ -5259,11 +5450,10 @@ async function handleCancelReplay() { if (!activeReplaySession) { throw new Error('no active replay'); } + activeReplaySession.cancelRequested = true; activeReplaySession.status = 'canceled'; - const canceled = activeReplaySession; - activeReplaySession = null; - latestReplaySession = canceled; - return formatReplaySummary(canceled); + latestReplaySession = activeReplaySession; + return formatReplaySummary(activeReplaySession); } async function executeOrchestrationBlock(session, block) { @@ -5311,9 +5501,15 @@ async function executeOrchestrationBlock(session, block) { } if (block.type === 'run_replay_sequence') { - const replayResultText = await handleStartReplay({ pageId: session.pageId, steps: block.args.steps }); - if (replayResultText.includes('status: failed')) { - throw new Error(replayResultText); + const replaySteps = requireReplaySteps({ steps: block.args.steps }); + const replaySession = createReplaySession( + { id: session.pageId }, + session.pageIndex, + replaySteps + ); + await runReplaySession(replaySession); + if (replaySession.status === 'failed' || replaySession.status === 'canceled') { + throw new Error(formatReplaySummary(replaySession)); } return; } @@ -5346,7 +5542,9 @@ async function executeOrchestrationBlock(session, block) { async function executeSequenceSteps(session, block) { const steps = requireSequenceSteps(block); + session.failedSequenceStepIndex = null; for (let index = 0; index < steps.length; index += 1) { + throwIfSessionCanceled(session, 'orchestration'); const step = steps[index]; validateSequenceStep(step); try { @@ -5363,22 +5561,24 @@ async function executeSequenceSteps(session, block) { if (step.continueOnError === true) { continue; } - throw error; + throw markOrchestrationFailureRecorded(error); } } } async function executeOrchestrationBlocks(session) { for (let index = 0; index < session.blocks.length; index += 1) { + throwIfSessionCanceled(session, 'orchestration'); const block = session.blocks[index]; session.currentBlockIndex = index; + session.failedSequenceStepIndex = null; validateOrchestrationBlock(block); try { await executeOrchestrationBlock(session, block); session.completedBlocks = index + 1; } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (session.failedSequenceStepIndex === null || session.failedSequenceStepIndex === undefined) { + if (!wasOrchestrationFailureRecorded(error)) { pushOrchestrationFailure(session, { blockIndex: index, sequenceStepIndex: null, @@ -5387,7 +5587,6 @@ async function executeOrchestrationBlocks(session) { }); } if (block.continueOnError === true) { - session.failedSequenceStepIndex = null; session.completedBlocks = index + 1; continue; } @@ -5396,6 +5595,40 @@ async function executeOrchestrationBlocks(session) { } } +async function runOrchestrationSession(session, options = {}) { + try { + await executeOrchestrationBlocks(session); + if (session.cancelRequested) { + session.status = 'canceled'; + } else if ((session.failedCount || 0) > 0) { + session.status = 'completed_with_failures'; + } else { + session.status = 'completed'; + } + } catch (error) { + if (isSessionCanceledError(error) || session.cancelRequested) { + session.status = 'canceled'; + session.error = null; + session.errorDetails = undefined; + } else { + session.status = 'failed'; + session.failedBlockIndex = session.currentBlockIndex; + session.errorDetails = formatOrchestrationError(error); + session.error = + session.errorDetails.message || (error instanceof Error ? error.message : String(error)); + } + } finally { + latestOrchestrationSession = session; + if (options.manageActiveState === true) { + if (activeOrchestrationSession === session) { + activeOrchestrationSession = null; + } + activeOrchestrationTask = null; + } + } + return session; +} + async function handleStartOrchestration(toolArgs) { if (activeOrchestrationSession) { throw new Error('orchestration already active'); @@ -5406,41 +5639,26 @@ async function handleStartOrchestration(toolArgs) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } - const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId); + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); if (!page.webSocketDebuggerUrl) { throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); } const blocks = requireOrchestrationBlocks(toolArgs); - const session = { - orchestrationId: createOrchestrationId(), - pageId: page.id, - pageIndex, - status: 'running', - blockCount: blocks.length, - completedBlocks: 0, - currentBlockIndex: 0, - failedBlockIndex: null, - failedSequenceStepIndex: null, - error: null, - blocks, - }; + const session = createOrchestrationSession(page, pageIndex, blocks); latestOrchestrationSession = session; activeOrchestrationSession = session; - - try { - await executeOrchestrationBlocks(session); - session.status = 'completed'; - } catch (error) { - session.status = 'failed'; - session.failedBlockIndex = session.currentBlockIndex; - session.errorDetails = formatOrchestrationError(error); - session.error = session.errorDetails.message || (error instanceof Error ? error.message : String(error)); - } - - activeOrchestrationSession = null; - latestOrchestrationSession = session; + const task = Promise.resolve().then(() => + runOrchestrationSession(session, { manageActiveState: true }) + ); + activeOrchestrationTask = task; + task.catch(() => { + // Session state is recorded in runOrchestrationSession. + }); + await waitForSessionToSettle(task); return formatOrchestrationSummary(session); } @@ -5449,6 +5667,16 @@ async function handleGetOrchestration() { return formatOrchestrationSummary(session); } +async function handleCancelOrchestration() { + if (!activeOrchestrationSession) { + throw new Error('no active orchestration'); + } + activeOrchestrationSession.cancelRequested = true; + activeOrchestrationSession.status = 'canceled'; + latestOrchestrationSession = activeOrchestrationSession; + return formatOrchestrationSummary(activeOrchestrationSession); +} + function getArtifactPayloadForKind(kind) { if (kind === 'recording') { return requireLatestRecording(); @@ -5467,7 +5695,7 @@ function resolveArtifactPath(reference) { if (value.startsWith('artifact:')) { return getArtifactPath(value.slice('artifact:'.length)); } - return value; + return path.resolve(value); } function deleteArtifactByName(name) { @@ -5481,14 +5709,20 @@ function deleteArtifactByName(name) { async function handleExportArtifact(toolArgs) { const kind = requireNonEmptyString(toolArgs.kind, 'kind'); - const name = requireNonEmptyString(toolArgs.name, 'name'); + const name = requireArtifactName(toolArgs.name); const filePath = getArtifactPath(name); if (fs.existsSync(filePath)) { throw new Error('artifact already exists'); } const payload = getArtifactPayloadForKind(kind); const artifact = buildArtifact(kind, name, payload); - fs.writeFileSync(filePath, JSON.stringify(artifact, null, 2), 'utf8'); + const serialized = JSON.stringify(artifact, null, 2); + if (Buffer.byteLength(serialized, 'utf8') > MAX_ARTIFACT_FILE_BYTES) { + throw new Error( + `artifact file exceeds maximum size of ${MAX_ARTIFACT_FILE_BYTES} bytes` + ); + } + fs.writeFileSync(filePath, serialized, { encoding: 'utf8', mode: 0o600 }); return `name: ${name}\nkind: ${kind}\npath: ${filePath}\nstatus: exported`; } @@ -5529,29 +5763,18 @@ async function handleImportArtifact(toolArgs) { } async function handleDeleteArtifact(toolArgs) { - const name = requireNonEmptyString(toolArgs.name, 'name'); + const name = requireArtifactName(toolArgs.name); deleteArtifactByName(name); return `name: ${name}\nstatus: deleted`; } -async function handleCancelOrchestration() { - if (!activeOrchestrationSession) { - throw new Error('no active orchestration'); - } - activeOrchestrationSession.status = 'canceled'; - const canceled = activeOrchestrationSession; - activeOrchestrationSession = null; - latestOrchestrationSession = canceled; - return formatOrchestrationSummary(canceled); -} - async function handleToolCall(message) { const id = message.id; const params = message.params || {}; const toolName = params.name || ''; const toolArgs = params.arguments || {}; - if (!TOOL_NAMES.includes(toolName)) { + if (!getAvailableToolNames().includes(toolName)) { writeError(id, -32602, `Unknown tool: ${toolName}`); return; } @@ -5961,7 +6184,7 @@ async function handleMessage(message) { writeResponse(message.id, {}); return; case 'tools/list': - writeResponse(message.id, { tools: getTools() }); + writeResponse(message.id, { tools: getAvailableTools() }); return; case 'tools/call': await handleToolCall(message); diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 97fa747d..99493228 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'bun:test'; import { spawn } from 'child_process'; -import { cpSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import * as http from 'node:http'; import { WebSocketServer } from 'ws'; import { tmpdir } from 'node:os'; @@ -114,6 +114,7 @@ type MockFileInputPlan = MockFileInputState | MockFileInputState[]; type MockDropzoneState = { accepted?: boolean; + acceptedByCancel?: boolean; requireFiles?: boolean; receivedEventTypes?: string[]; receivedFiles?: Array<{ name: string; size: number; type: string }>; @@ -203,6 +204,7 @@ type MockRecordingPlan = { events?: MockRecordedEvent[]; warnings?: MockRecordingWarning[]; injectionError?: string; + finalizeError?: string; }; type MockFrameState = { @@ -226,6 +228,7 @@ type MockEvalPlan = Record< result?: unknown; error?: string; nonSerializable?: boolean; + unserializableValue?: string; } >; @@ -1121,6 +1124,15 @@ function createMockBrowser(pagesInput: MockPageState[]) { if (expression.includes('globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] }')) { const plan = getMockRecordingPlan(page); + if (plan.finalizeError) { + socket.send( + JSON.stringify({ + id: message.id, + result: { exceptionDetails: { text: plan.finalizeError } }, + }) + ); + return; + } reply({ result: { type: 'object', @@ -1174,7 +1186,14 @@ function createMockBrowser(pagesInput: MockPageState[]) { size: file.size, type: file.mimeType, })); - reply({ result: { type: 'object', value: { accepted: target.accepted !== false } } }); + reply({ + result: { + type: 'object', + value: { + accepted: target.acceptedByCancel === true ? true : target.accepted !== false, + }, + }, + }); return; } @@ -1193,6 +1212,20 @@ function createMockBrowser(pagesInput: MockPageState[]) { socket.send(JSON.stringify({ id: message.id, result: { result: { type: 'object' } } })); return; } + if (typeof evalPlan.unserializableValue === 'string') { + socket.send( + JSON.stringify({ + id: message.id, + result: { + result: { + type: 'number', + unserializableValue: evalPlan.unserializableValue, + }, + }, + }) + ); + return; + } socket.send( JSON.stringify({ id: message.id, @@ -5039,6 +5072,9 @@ describe('ccs-browser MCP server', () => { 'throw new Error("boom")': { error: 'boom', }, + Infinity: { + unserializableValue: 'Infinity', + }, window: { nonSerializable: true, }, @@ -5086,6 +5122,15 @@ describe('ccs-browser MCP server', () => { jsonrpc: '2.0', id: 54, method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'Infinity' }, + }, + }, + { + jsonrpc: '2.0', + id: 55, + method: 'tools/call', params: { name: 'browser_eval', arguments: { expression: 'window' }, @@ -5111,6 +5156,9 @@ describe('ccs-browser MCP server', () => { 'Browser MCP failed: boom' ); expect(getResponseText(responses.find((message) => message.id === 54))).toContain( + 'value: "Infinity"' + ); + expect(getResponseText(responses.find((message) => message.id === 55))).toContain( 'Browser MCP failed: evaluation result is not JSON-serializable' ); }); @@ -5151,6 +5199,37 @@ describe('ccs-browser MCP server', () => { ); }); + it('hides browser_eval when eval mode is disabled', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Eval Disabled Page', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 56, + method: 'tools/list', + params: {}, + }, + { + jsonrpc: '2.0', + id: 57, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: '1 + 1' }, + }, + }, + ], + { childEnv: { ...process.env, CCS_BROWSER_EVAL_MODE: 'disabled' } } + ); + + const tools = (responses.find((message) => message.id === 56)?.result as { tools?: Array<{ name?: string }> }) + ?.tools || []; + expect(tools.some((tool) => tool.name === 'browser_eval')).toBe(false); + expect((responses.find((message) => message.id === 57)?.error as { message?: string })?.message).toBe( + 'Unknown tool: browser_eval' + ); + }); + it('queries inside an iframe selected by frameSelector', async () => { const responses = await runMcpRequests( [ @@ -5763,6 +5842,48 @@ describe('ccs-browser MCP server', () => { expect(getResponseText(responses.find((message) => message.id === 1019))).toContain('pageIndex and pageId cannot be used together'); }); + it('cleans up recording state when stop finalization fails', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Broken Stop Recording Page', + currentUrl: 'https://example.com/broken-stop-recording', + recording: { + finalizeError: 'recording finalize failed', + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1019_1, + method: 'tools/call', + params: { name: 'browser_start_recording', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1019_2, + method: 'tools/call', + params: { name: 'browser_stop_recording', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1019_3, + method: 'tools/call', + params: { name: 'browser_start_recording', arguments: {} }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1019_2))).toContain( + 'recording finalize failed' + ); + expect(getResponseText(responses.find((message) => message.id === 1019_3))).toContain( + 'status: recording' + ); + }); + it('rolls back recording state when recorder injection fails', async () => { const responses = await runMcpRequests( [ @@ -6069,6 +6190,105 @@ describe('ccs-browser MCP server', () => { expect(text).toContain('element index 0 is out of range for selector: #missing'); }); + it('cancels a replay before later steps run', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Replay Cancel Page', + currentUrl: 'https://example.com/replay-cancel', + click: { + '#submit': {}, + }, + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 40, + height: 40, + top: 30, + right: 60, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#submit': { + exists: true, + connected: true, + rect: { + x: 120, + y: 30, + width: 100, + height: 40, + top: 30, + right: 220, + bottom: 70, + left: 120, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1116, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [ + createReplayStep({ + type: 'pointer_action', + pageId: 'page-1', + args: { + actions: [ + { type: 'move', selector: '#handle' }, + { type: 'pause', durationMs: 400 }, + ], + }, + }), + createReplayStep({ + type: 'click', + pageId: 'page-1', + selector: '#submit', + args: {}, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1117, + method: 'tools/call', + params: { name: 'browser_cancel_replay', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1118, + method: 'tools/call', + params: { name: 'browser_get_replay', arguments: {} }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1116))).toContain( + 'status: running' + ); + const text = getResponseText(responses.find((message) => message.id === 1118)); + expect(text).toContain('status: canceled'); + expect(text).not.toContain('completedSteps: 2'); + }); + it('replays drag_element and pointer_action steps successfully', async () => { const pages: MockPageState[] = [ { @@ -6244,6 +6464,112 @@ describe('ccs-browser MCP server', () => { expect(getResponseText(responses.find((message) => message.id === 1202))).toContain('status: completed'); }); + it('cancels orchestration before later blocks run', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Orchestration Cancel Page', + currentUrl: 'https://example.com/orchestration-cancel', + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 40, + height: 40, + top: 30, + right: 60, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#status': { + exists: true, + connected: true, + innerText: 'ready', + textContent: 'ready', + rect: { + x: 80, + y: 30, + width: 80, + height: 20, + top: 30, + right: 160, + bottom: 50, + left: 80, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1202_1, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'run_replay_sequence', + args: { + steps: [ + createReplayStep({ + type: 'pointer_action', + pageId: 'page-1', + args: { + actions: [ + { type: 'move', selector: '#handle' }, + { type: 'pause', durationMs: 400 }, + ], + }, + }), + ], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'equals', value: 'ready' }], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1202_2, + method: 'tools/call', + params: { name: 'browser_cancel_orchestration', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1202_3, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1202_1))).toContain( + 'status: running' + ); + const text = getResponseText(responses.find((message) => message.id === 1202_3)); + expect(text).toContain('status: canceled'); + expect(text).not.toContain('completedBlocks: 2'); + }); + it('completes a wait_then_type flow', async () => { const pages: MockPageState[] = [ { @@ -7108,6 +7434,7 @@ describe('ccs-browser MCP server', () => { ]); const text = getResponseText(responses.find((message) => message.id === 1502)); + expect(text).toContain('status: completed_with_failures'); expect(text).toContain('completedBlocks: 2'); expect(text).toContain('failedCount: 1'); expect(text).toContain('failure[0].blockIndex: 0'); @@ -7187,6 +7514,7 @@ describe('ccs-browser MCP server', () => { ]); const text = getResponseText(responses.find((message) => message.id === 1504)); + expect(text).toContain('status: completed_with_failures'); expect(text).toContain('completedBlocks: 1'); expect(text).toContain('failedCount: 1'); expect(text).toContain('failure[0].sequenceStepIndex: 0'); @@ -7305,6 +7633,7 @@ describe('ccs-browser MCP server', () => { ]); const text = getResponseText(responses.find((message) => message.id === 1507)); + expect(text).toContain('status: completed_with_failures'); expect(text).toContain('completedBlocks: 2'); expect(text).toContain('failedCount: 1'); expect(text).toContain('failure[0].sequenceStepIndex: 0'); @@ -7500,6 +7829,10 @@ describe('ccs-browser MCP server', () => { }); it('rejects duplicate export, invalid import payload, and missing artifact delete target', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-artifact-failures-')); + const oversizedArtifactPath = join(tempDir, 'oversized-artifact.json'); + writeFileSync(oversizedArtifactPath, Buffer.alloc(5 * 1024 * 1024 + 1, 'a')); + const responses = await runMcpRequests( [ { @@ -7518,12 +7851,24 @@ describe('ccs-browser MCP server', () => { { jsonrpc: '2.0', id: 1615, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'dup-artifact' } } }, { jsonrpc: '2.0', id: 1616, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: 'artifact:invalid-json' } } }, { jsonrpc: '2.0', id: 1617, method: 'tools/call', params: { name: 'browser_delete_artifact', arguments: { name: 'missing-artifact' } } }, + { jsonrpc: '2.0', id: 1618, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: '../escape' } } }, + { jsonrpc: '2.0', id: 1619, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: 'artifact:../escape' } } }, + { jsonrpc: '2.0', id: 1620, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: oversizedArtifactPath } } }, ] ); expect(getResponseText(responses.find((message) => message.id === 1615))).toContain('artifact already exists'); expect(getResponseText(responses.find((message) => message.id === 1616))).toContain('artifact file not found'); expect(getResponseText(responses.find((message) => message.id === 1617))).toContain('artifact not found'); + expect(getResponseText(responses.find((message) => message.id === 1618))).toContain( + 'artifact name must start with a letter or number' + ); + expect(getResponseText(responses.find((message) => message.id === 1619))).toContain( + 'artifact name must start with a letter or number' + ); + expect(getResponseText(responses.find((message) => message.id === 1620))).toContain( + 'artifact file exceeds maximum size' + ); }); it('runs select_page_then_run and executes the inner single-page block on the selected page', async () => { @@ -7798,6 +8143,7 @@ describe('ccs-browser MCP server', () => { currentUrl: 'https://example.com/uploads', dropzones: { '#dropzone': { accepted: true }, + '#cancel-dropzone': { acceptedByCancel: true }, }, frames: [ { @@ -7854,6 +8200,18 @@ describe('ccs-browser MCP server', () => { }, }, }, + { + jsonrpc: '2.0', + id: 904, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { + selector: '#cancel-dropzone', + files: [invoicePath], + }, + }, + }, ]); expect(getResponseText(responses.find((message) => message.id === 901))).toContain('status: files-dropped'); @@ -7872,6 +8230,12 @@ describe('ccs-browser MCP server', () => { expect( getMockDropzoneState(pages[0]!, '#shadow-dropzone', { pierceShadow: true })?.receivedFiles ).toEqual([{ name: 'receipt.png', size: 7, type: 'image/png' }]); + expect(getResponseText(responses.find((message) => message.id === 904))).toContain( + 'status: files-dropped' + ); + expect(getMockDropzoneState(pages[0]!, '#cancel-dropzone')?.receivedFiles).toEqual([ + { name: 'invoice.pdf', size: 7, type: 'application/pdf' }, + ]); }); it('rejects browser_drag_files for missing files, page conflicts, missing targets, and rejected drops', async () => { @@ -8337,6 +8701,21 @@ describe('ccs-browser MCP server', () => { }, }, }, + { + jsonrpc: '2.0', + id: 920, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + actions: [ + { type: 'move', selector: '#handle' }, + { type: 'down', button: 'left' }, + { type: 'up', button: 'right' }, + ], + }, + }, + }, ]); expect(getResponseText(responses.find((message) => message.id === 914))).toContain('pointer state error'); @@ -8344,6 +8723,68 @@ describe('ccs-browser MCP server', () => { expect(getResponseText(responses.find((message) => message.id === 919))).toContain( 'pageIndex and pageId cannot be used together' ); + expect(getResponseText(responses.find((message) => message.id === 920))).toContain( + 'pointer state error' + ); + }); + + it('rejects mutating browser tools when the selected page becomes stale', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-stale-selection-')); + const patchedServerPath = join(tempDir, 'ccs-browser-server.cjs'); + writeFileSync( + patchedServerPath, + readFileSync(bundledServerPath, 'utf8').replace( + "let selectedPageId = '';", + "let selectedPageId = 'page-2';" + ) + ); + + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Primary Page', + currentUrl: 'https://example.com/primary', + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 30, + y: 50, + width: 40, + height: 20, + top: 50, + right: 70, + bottom: 70, + left: 30, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 923, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + actions: [{ type: 'move', selector: '#handle' }], + }, + }, + }, + ], + { serverPath: patchedServerPath } + ); + + expect(getResponseText(responses.find((message) => message.id === 923))).toContain( + 'Selected page is no longer available; specify pageIndex or pageId explicitly.' + ); }); it('waits for a matching navigation event with browser_wait_for_event', async () => {