fix(browser): 加固 browser_click 激活语义并修复相关验证阻塞

- 将 browser_click 调整为 mousedown/mouseup 后走 native click
- 在取消激活和中途 detached 场景下避免强制触发 native activation
- 补齐 browser_click 的回归测试与取消激活场景覆盖
- 修复 cliproxy 本地代理、Droid 环境隔离、图像分析 hook 与 proxy 集成测试的验证阻塞

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
walkerandClaude Opus 4.6 authored and walker1211 committed 2026-04-13 11:19:16 +08:00
1 parent eb8149e8fa
commit 74f028168d
7 files changed
+400 -15

No files matched your search

@@ -53,6 +53,11 @@ function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
env: {
...process.env,
CCS_IMAGE_ANALYSIS_SKIP: '', // clear any inherited skip flag
CCS_IMAGE_ANALYSIS_SKIP_HOOK: '', // clear any inherited MCP-ready bypass
CCS_IMAGE_ANALYSIS_MODEL: '', // let each test control explicit model fallback behavior
CCS_IMAGE_ANALYSIS_BACKEND_ID: '',
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '',
CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '',
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
CCS_CLIPROXY_PORT: String(mockPort),
CCS_IMAGE_ANALYSIS_ENABLED: '1',
@@ -774,6 +774,11 @@ describe('ToolSanitizationProxy Integration', () => {
});
it('handles upstream connection errors gracefully', async () => {
const originalNoProxy = process.env.NO_PROXY;
const originalNoProxyLower = process.env.no_proxy;
process.env.NO_PROXY = '127.0.0.1,localhost';
process.env.no_proxy = '127.0.0.1,localhost';
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: 'http://127.0.0.1:1', // Invalid port
timeoutMs: 1000,
@@ -790,6 +795,16 @@ describe('ToolSanitizationProxy Integration', () => {
expect(response.status).toBe(502);
} finally {
proxy.stop();
if (originalNoProxy === undefined) {
delete process.env.NO_PROXY;
} else {
process.env.NO_PROXY = originalNoProxy;
}
if (originalNoProxyLower === undefined) {
delete process.env.no_proxy;
} else {
process.env.no_proxy = originalNoProxyLower;
}
}
});
});
+302 -2
View File
@@ -16,7 +16,22 @@ type MockPageState = {
visibleText?: string;
domSnapshot?: string;
navigate?: Record<string, { finalUrl: string; readyStates?: string[]; errorText?: string }>;
click?: Record<string, { error?: string; disabled?: boolean }>;
click?: Record<
string,
{
error?: string;
disabled?: boolean;
detached?: boolean;
hidden?: boolean;
requireMouseSequence?: boolean;
requireNativeClick?: boolean;
forbidSyntheticClickEvent?: boolean;
cancelMouseDown?: boolean;
cancelMouseUp?: boolean;
detachAfterMouseDown?: boolean;
mouseSequenceError?: string;
}
>;
screenshot?: {
data?: string;
lastCaptureBeyondViewport?: boolean;
@@ -267,14 +282,77 @@ function createMockBrowser(pagesInput: MockPageState[]) {
if (expression.includes('scrollIntoView') && expression.includes('.click()')) {
const selector = parseJsonArgument(expression, 'selector') || '';
const clickPlan = page.click?.[selector];
const attemptedMouseDown = expression.includes("dispatchMouseEvent('mousedown'");
const attemptedMouseUp = expression.includes("dispatchMouseEvent('mouseup'");
const attemptedMouseSequence = attemptedMouseDown && attemptedMouseUp;
const attemptedClickEvent = expression.includes("dispatchMouseEvent('click'");
const readsDispatchResult = expression.includes('const dispatchResult = {');
const gatesNativeClickOnDispatchResult = expression.includes('if (!dispatchResult.shouldActivate)');
const checksIsConnectedBeforeNativeClick = expression.includes('if (!element.isConnected)');
const catchIndex = expression.indexOf('catch (mouseError) {');
const catchBlockEnd = catchIndex === -1 ? -1 : expression.indexOf('\n }', catchIndex);
const nativeClickIndexes = Array.from(expression.matchAll(/element\.click\(\)/g)).map(
(match) => match.index ?? -1
);
const attemptedFallbackClick = nativeClickIndexes.some(
(index) => catchIndex !== -1 && catchBlockEnd !== -1 && index > catchIndex && index < catchBlockEnd
);
const attemptedNativeClickOutsideCatch = nativeClickIndexes.some(
(index) =>
catchIndex === -1 || catchBlockEnd === -1 || index < catchIndex || index > catchBlockEnd
);
if (!clickPlan) {
replyError(`element not found for selector: ${selector}`);
return;
}
if (clickPlan.detached && expression.includes('element.isConnected')) {
replyError(`element is detached for selector: ${selector}`);
return;
}
if (clickPlan.disabled) {
replyError(`element is disabled for selector: ${selector}`);
return;
}
if (clickPlan.hidden && expression.includes('getBoundingClientRect')) {
replyError(`element is hidden or not interactable for selector: ${selector}`);
return;
}
if (clickPlan.requireMouseSequence && !attemptedMouseSequence) {
replyError(`mousedown/mouseup required for selector: ${selector}`);
return;
}
if (clickPlan.forbidSyntheticClickEvent && attemptedClickEvent) {
replyError(`synthetic click event forbidden for selector: ${selector}`);
return;
}
if ((clickPlan.cancelMouseDown || clickPlan.cancelMouseUp) && !readsDispatchResult) {
replyError(`dispatch result must be checked for selector: ${selector}`);
return;
}
if ((clickPlan.cancelMouseDown || clickPlan.cancelMouseUp) && !gatesNativeClickOnDispatchResult) {
replyError(`native click must be gated for selector: ${selector}`);
return;
}
if (clickPlan.detachAfterMouseDown && !checksIsConnectedBeforeNativeClick) {
replyError(`connected state must be rechecked for selector: ${selector}`);
return;
}
if (clickPlan.requireNativeClick && !attemptedNativeClickOutsideCatch) {
replyError(`native click required for selector: ${selector}`);
return;
}
if (clickPlan.mouseSequenceError) {
if (!attemptedMouseSequence) {
replyError(`mousedown/mouseup required for selector: ${selector}`);
return;
}
if (attemptedFallbackClick || attemptedNativeClickOutsideCatch) {
reply({ result: { type: 'string', value: 'ok' } });
return;
}
replyError(clickPlan.mouseSequenceError);
return;
}
if (clickPlan.error) {
replyError(clickPlan.error);
return;
@@ -402,7 +480,11 @@ describe('ccs-browser MCP server', () => {
);
const tools = (responses.find((message) => message.id === 2)?.result as {
tools: Array<{ name: string; inputSchema?: { properties?: Record<string, { type?: string; minimum?: number }> } }>;
tools: Array<{
name: string;
description?: string;
inputSchema?: { properties?: Record<string, { type?: string; minimum?: number }> };
}>;
}).tools;
expect(tools.map((tool) => tool.name)).toEqual([
@@ -416,6 +498,10 @@ describe('ccs-browser MCP server', () => {
'browser_take_screenshot',
]);
const clickTool = tools.find((tool) => tool.name === 'browser_click');
expect(clickTool?.description).toContain('mouse event chain');
expect(clickTool?.description).not.toContain('synthetic element.click()');
for (const tool of tools.filter((candidate) => candidate.inputSchema?.properties?.pageIndex)) {
expect(tool.inputSchema?.properties?.pageIndex).toMatchObject({
type: 'integer',
@@ -662,6 +748,220 @@ describe('ccs-browser MCP server', () => {
expect(getResponseText(pageSideError)).toContain('click exploded');
});
it('reports detached and hidden click targets as handled errors', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Example Page',
currentUrl: 'https://example.com/',
click: {
'#hidden': { hidden: true },
'#detached': { detached: true },
},
},
],
[
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'browser_click', arguments: { selector: '#hidden' } },
},
{
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: { name: 'browser_click', arguments: { selector: '#detached' } },
},
]
);
const hiddenError = responses.find((message) => message.id === 2);
expect((hiddenError?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(hiddenError)).toContain('element is hidden or not interactable for selector: #hidden');
const detachedError = responses.find((message) => message.id === 3);
expect((detachedError?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(detachedError)).toContain('element is detached for selector: #detached');
});
it('uses a mouse sequence when the target requires it', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Example Page',
currentUrl: 'https://example.com/',
click: {
'#menu-trigger': { requireMouseSequence: true },
},
},
],
[
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'browser_click', arguments: { selector: '#menu-trigger' } },
},
]
);
expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked');
expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #menu-trigger');
});
it('preserves mouse sequence preparation without dispatching a synthetic click event', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Example Page',
currentUrl: 'https://example.com/',
click: {
'#click-event': {
requireMouseSequence: true,
forbidSyntheticClickEvent: true,
requireNativeClick: true,
},
},
},
],
[
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'browser_click', arguments: { selector: '#click-event' } },
},
]
);
expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked');
expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #click-event');
});
it('preserves native click activation after the mouse sequence', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Example Page',
currentUrl: 'https://example.com/',
click: {
'#native-click': {
requireMouseSequence: true,
requireNativeClick: true,
},
},
},
],
[
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'browser_click', arguments: { selector: '#native-click' } },
},
]
);
const clickResponse = responses.find((message) => message.id === 2);
expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true);
expect(getResponseText(clickResponse)).toContain('status: clicked');
expect(getResponseText(clickResponse)).toContain('selector: #native-click');
});
it('does not force activation when mousedown cancels the interaction', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Example Page',
currentUrl: 'https://example.com/',
click: {
'#cancel-mousedown': {
requireMouseSequence: true,
cancelMouseDown: true,
},
},
},
],
[
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'browser_click', arguments: { selector: '#cancel-mousedown' } },
},
]
);
const clickResponse = responses.find((message) => message.id === 2);
expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true);
expect(getResponseText(clickResponse)).toContain('status: clicked');
expect(getResponseText(clickResponse)).toContain('selector: #cancel-mousedown');
});
it('rechecks connectivity before native activation after the mouse sequence', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Example Page',
currentUrl: 'https://example.com/',
click: {
'#detached-during-click': {
requireMouseSequence: true,
requireNativeClick: true,
detachAfterMouseDown: true,
},
},
},
],
[
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'browser_click', arguments: { selector: '#detached-during-click' } },
},
]
);
const clickResponse = responses.find((message) => message.id === 2);
expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true);
expect(getResponseText(clickResponse)).toContain('status: clicked');
expect(getResponseText(clickResponse)).toContain('selector: #detached-during-click');
});
it('falls back to click when mouse sequence dispatch fails', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Example Page',
currentUrl: 'https://example.com/',
click: {
'#fallback': { mouseSequenceError: 'mouse dispatch exploded' },
},
},
],
[
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'browser_click', arguments: { selector: '#fallback' } },
},
]
);
expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked');
expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #fallback');
});
it('captures screenshots and reports empty payload failures', async () => {
const screenshotPlan: MockPageState['screenshot'] = { data: 'c2NyZWVuc2hvdA==' };
const responses = await runMcpRequests(