feat(browser): 接入 CCS browser MCP 与最小交互闭环

新增 browser MCP 安装、Chrome 复用与运行时接线,
实现 navigate、click、type、take_screenshot 四个 phase-1 工具并补齐相关单测。

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 efecd7c7d9
commit eb8149e8fa
29 files changed
+3627 -118

No files matched your search

@@ -0,0 +1,227 @@
import { afterEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
resolveBrowserRuntimeEnv,
resolveDefaultChromeUserDataDir,
} from '../../../../src/utils/browser/chrome-reuse';
describe('chrome reuse resolver', () => {
const originalLocalAppData = process.env.LOCALAPPDATA;
let tempDirs: string[] = [];
let servers: Array<{ stop: () => void }> = [];
function createTempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
function writeDevToolsActivePort(profileDir: string, content: string): void {
fs.mkdirSync(profileDir, { recursive: true });
fs.writeFileSync(path.join(profileDir, 'DevToolsActivePort'), content, 'utf8');
}
async function startDevToolsServer(versionPayload: Record<string, unknown>) {
const server = Bun.serve({
port: 0,
fetch(request: Request) {
if (new URL(request.url).pathname === '/json/version') {
return Response.json(versionPayload);
}
return new Response('not found', { status: 404 });
},
});
servers.push(server);
return server;
}
async function startFailingDevToolsServer(status: number, body = 'error') {
const server = Bun.serve({
port: 0,
fetch(request: Request) {
if (new URL(request.url).pathname === '/json/version') {
return new Response(body, { status });
}
return new Response('not found', { status: 404 });
},
});
servers.push(server);
return server;
}
async function startMalformedJsonDevToolsServer() {
const server = Bun.serve({
port: 0,
fetch(request: Request) {
if (new URL(request.url).pathname === '/json/version') {
return new Response('{invalid-json', {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response('not found', { status: 404 });
},
});
servers.push(server);
return server;
}
async function reserveClosedPort(): Promise<number> {
const server = Bun.serve({
port: 0,
fetch() {
return new Response('ok');
},
});
const port = server.port;
server.stop(true);
return port;
}
afterEach(() => {
for (const server of servers) {
server.stop(true);
}
servers = [];
for (const dir of tempDirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
tempDirs = [];
if (originalLocalAppData === undefined) {
delete process.env.LOCALAPPDATA;
} else {
process.env.LOCALAPPDATA = originalLocalAppData;
}
});
it('uses explicit profile-dir before the default path and resolves the websocket target', async () => {
const explicitProfileDir = createTempDir('ccs-chrome-explicit-');
const defaultProfileDir = createTempDir('ccs-chrome-default-');
const server = await startDevToolsServer({
Browser: 'Chrome/136.0.0.0',
webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/target-1',
});
writeDevToolsActivePort(explicitProfileDir, `${server.port}\n/devtools/browser/from-explicit`);
const runtimeEnv = await resolveBrowserRuntimeEnv({
profileDir: explicitProfileDir,
});
expect(runtimeEnv).toEqual({
CCS_BROWSER_USER_DATA_DIR: explicitProfileDir,
CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1',
CCS_BROWSER_DEVTOOLS_PORT: String(server.port),
CCS_BROWSER_DEVTOOLS_HTTP_URL: `http://127.0.0.1:${server.port}`,
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/target-1',
});
expect(fs.existsSync(path.join(defaultProfileDir, 'DevToolsActivePort'))).toBe(false);
});
it('uses an explicit devtools port override when metadata is missing', async () => {
const profileDir = createTempDir('ccs-chrome-explicit-port-');
const server = await startDevToolsServer({
Browser: 'Chrome/136.0.0.0',
webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/explicit-port',
});
const runtimeEnv = await resolveBrowserRuntimeEnv({
profileDir,
devtoolsPort: String(server.port),
});
expect(runtimeEnv.CCS_BROWSER_DEVTOOLS_PORT).toBe(String(server.port));
expect(runtimeEnv.CCS_BROWSER_DEVTOOLS_WS_URL).toBe('ws://127.0.0.1/devtools/browser/explicit-port');
});
it('throws a clear error when DevToolsActivePort metadata is missing', async () => {
const profileDir = createTempDir('ccs-chrome-missing-metadata-');
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
`Chrome reuse metadata not found: ${path.join(profileDir, 'DevToolsActivePort')}`
);
});
it('throws a clear error when DevToolsActivePort metadata is invalid', async () => {
const profileDir = createTempDir('ccs-chrome-invalid-metadata-');
writeDevToolsActivePort(profileDir, 'not-a-port\n/devtools/browser/target');
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
`Chrome reuse metadata is invalid: ${path.join(profileDir, 'DevToolsActivePort')}`
);
});
it('throws before launch fallback when the DevTools endpoint is stale or unreachable', async () => {
const profileDir = createTempDir('ccs-chrome-unreachable-');
const port = await reserveClosedPort();
writeDevToolsActivePort(profileDir, `${port}\n/devtools/browser/stale`);
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
`Chrome DevTools endpoint is unreachable: http://127.0.0.1:${port}`
);
});
it('throws a clear error when the DevTools endpoint is reachable without a websocket target', async () => {
const profileDir = createTempDir('ccs-chrome-missing-ws-');
const server = await startDevToolsServer({ Browser: 'Chrome/136.0.0.0' });
writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/no-ws`);
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
`Chrome DevTools endpoint did not provide a websocket target: http://127.0.0.1:${server.port}/json/version`
);
});
it('throws a clear error when the DevTools endpoint returns a non-200 response', async () => {
const profileDir = createTempDir('ccs-chrome-bad-status-');
const server = await startFailingDevToolsServer(500);
writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-status`);
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
`Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}`
);
});
it('throws a clear error when the DevTools endpoint returns malformed JSON', async () => {
const profileDir = createTempDir('ccs-chrome-bad-json-');
const server = await startMalformedJsonDevToolsServer();
writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-json`);
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
`Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}`
);
});
it('resolves platform default Chrome user-data-dir paths', () => {
process.env.LOCALAPPDATA = 'C:/Users/test/AppData/Local';
expect(resolveDefaultChromeUserDataDir('darwin')).toBe(
path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome')
);
expect(resolveDefaultChromeUserDataDir('linux')).toBe(
path.join(os.homedir(), '.config', 'google-chrome')
);
expect(resolveDefaultChromeUserDataDir('win32')).toBe(
path.normalize('C:/Users/test/AppData/Local/Google/Chrome/User Data')
);
});
it('throws on win32 when LOCALAPPDATA is missing', () => {
delete process.env.LOCALAPPDATA;
expect(() => resolveDefaultChromeUserDataDir('win32')).toThrow(
'LOCALAPPDATA is required to resolve the default Chrome user-data-dir on Windows'
);
});
it('throws a clear error when the resolved profile directory does not exist', async () => {
const missingProfileDir = path.join(createTempDir('ccs-chrome-missing-dir-'), 'missing-profile');
await expect(resolveBrowserRuntimeEnv({ profileDir: missingProfileDir })).rejects.toThrow(
`Chrome profile directory is invalid: ${missingProfileDir}`
);
});
});
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'bun:test';
import { appendBrowserToolArgs } from '../../../../src/utils/browser/claude-tool-args';
const BROWSER_STEERING_PROMPT =
'For DOM/screenshots/elements/page actions, prefer the CCS MCP Browser tool, reuse the configured running Chrome context whenever possible, and if the tool or context is unavailable, explain that clearly instead of pretending page state is available.';
describe('appendBrowserToolArgs', () => {
it('appends the browser steering prompt when it is missing', () => {
expect(appendBrowserToolArgs(['navigate'])).toEqual([
'navigate',
'--append-system-prompt',
BROWSER_STEERING_PROMPT,
]);
});
it('does not append the prompt when it already exists in equals form', () => {
expect(
appendBrowserToolArgs([
'navigate',
`--append-system-prompt=${BROWSER_STEERING_PROMPT}`,
])
).toEqual(['navigate', `--append-system-prompt=${BROWSER_STEERING_PROMPT}`]);
});
it('does not append the prompt when it already exists in split-flag form', () => {
expect(
appendBrowserToolArgs([
'navigate',
'--append-system-prompt',
BROWSER_STEERING_PROMPT,
])
).toEqual(['navigate', '--append-system-prompt', BROWSER_STEERING_PROMPT]);
});
it('inserts the prompt before the end-of-options terminator', () => {
expect(appendBrowserToolArgs(['--', 'take screenshot'])).toEqual([
'--append-system-prompt',
BROWSER_STEERING_PROMPT,
'--',
'take screenshot',
]);
});
});
@@ -0,0 +1,232 @@
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as browserInstaller from '../../../../src/utils/browser/mcp-installer';
import {
ensureBrowserMcp,
ensureBrowserMcpConfig,
ensureBrowserMcpOrThrow,
getBrowserMcpServerName,
getBrowserMcpServerPath,
syncBrowserMcpToConfigDir,
uninstallBrowserMcp,
} from '../../../../src/utils/browser';
describe('ensureBrowserMcp', () => {
let tempHome: string | undefined;
let originalCcsHome: string | undefined;
function setupTempHome(): string {
const nextTempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-mcp-'));
tempHome = nextTempHome;
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = nextTempHome;
return nextTempHome;
}
function getManagedConfig() {
return {
type: 'stdio',
command: 'node',
args: [getBrowserMcpServerPath()],
env: {
NODE_PATH: path.join(process.cwd(), 'node_modules'),
},
};
}
afterEach(() => {
mock.restore();
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
tempHome = undefined;
originalCcsHome = undefined;
});
it('installs the bundled browser MCP server and preserves existing user mcpServers entries', () => {
setupTempHome();
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
fs.writeFileSync(
claudeUserConfigPath,
JSON.stringify(
{
mcpServers: {
existing: { command: 'uvx', args: ['some-server'] },
},
},
null,
2
) + '\n',
'utf8'
);
expect(ensureBrowserMcp()).toBe(true);
expect(fs.existsSync(getBrowserMcpServerPath())).toBe(true);
const config = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as {
mcpServers: Record<string, unknown>;
};
expect(config.mcpServers.existing).toEqual({ command: 'uvx', args: ['some-server'] });
expect(config.mcpServers[getBrowserMcpServerName()]).toEqual(getManagedConfig());
});
it('preserves the existing ~/.claude.json permissions when provisioning browser MCP', () => {
setupTempHome();
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
fs.writeFileSync(claudeUserConfigPath, JSON.stringify({ existing: true }, null, 2) + '\n', {
encoding: 'utf8',
mode: 0o600,
});
fs.chmodSync(claudeUserConfigPath, 0o600);
expect(ensureBrowserMcpConfig()).toBe(true);
expect(fs.statSync(claudeUserConfigPath).mode & 0o777).toBe(0o600);
});
it('copies the bundled browser MCP artifact while preserving source permissions', () => {
setupTempHome();
const bundledServerPath = path.join(
process.cwd(),
'lib',
'mcp',
'ccs-browser-server.cjs'
);
const originalMode = fs.statSync(bundledServerPath).mode & 0o777;
expect(ensureBrowserMcp()).toBe(true);
expect(fs.statSync(getBrowserMcpServerPath()).mode & 0o777).toBe(originalMode);
});
it('reconciles installed browser MCP permissions when contents already match', () => {
setupTempHome();
const bundledServerPath = path.join(
process.cwd(),
'lib',
'mcp',
'ccs-browser-server.cjs'
);
const originalMode = fs.statSync(bundledServerPath).mode & 0o777;
expect(ensureBrowserMcp()).toBe(true);
fs.chmodSync(getBrowserMcpServerPath(), 0o600);
expect(ensureBrowserMcp()).toBe(true);
expect(fs.statSync(getBrowserMcpServerPath()).mode & 0o777).toBe(originalMode);
});
it('removes the managed browser runtime while preserving unrelated server entries', () => {
setupTempHome();
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
fs.writeFileSync(
claudeUserConfigPath,
JSON.stringify(
{
mcpServers: {
existing: { command: 'uvx', args: ['some-server'] },
},
},
null,
2
) + '\n',
'utf8'
);
expect(ensureBrowserMcp()).toBe(true);
const instancePath = path.join(tempHome as string, '.ccs', 'instances', 'work');
fs.mkdirSync(instancePath, { recursive: true });
fs.writeFileSync(
path.join(instancePath, '.claude.json'),
JSON.stringify(
{
mcpServers: {
existing: { command: 'uvx', args: ['instance-server'] },
[getBrowserMcpServerName()]: { command: 'node', args: ['/tmp/override.cjs'] },
},
otherKey: 'keep-me',
},
null,
2
) + '\n',
'utf8'
);
expect(uninstallBrowserMcp()).toBe(true);
expect(fs.existsSync(getBrowserMcpServerPath())).toBe(false);
const globalConfig = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as {
mcpServers: Record<string, { command: string; args: string[] }>;
};
expect(globalConfig.mcpServers).toEqual({
existing: { command: 'uvx', args: ['some-server'] },
});
const instanceConfig = JSON.parse(
fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8')
) as {
otherKey: string;
mcpServers: Record<string, { command: string; args: string[] }>;
};
expect(instanceConfig.otherKey).toBe('keep-me');
expect(instanceConfig.mcpServers).toEqual({
existing: { command: 'uvx', args: ['instance-server'] },
});
});
it('syncs the managed browser MCP entry into an instance config dir', () => {
setupTempHome();
fs.writeFileSync(
path.join(tempHome as string, '.claude.json'),
JSON.stringify(
{
mcpServers: {
[getBrowserMcpServerName()]: getManagedConfig(),
},
},
null,
2
) + '\n',
'utf8'
);
const instancePath = path.join(tempHome as string, '.ccs', 'instances', 'work');
fs.mkdirSync(instancePath, { recursive: true });
expect(syncBrowserMcpToConfigDir(instancePath)).toBe(true);
const instanceConfig = JSON.parse(
fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8')
) as {
mcpServers: Record<string, unknown>;
};
expect(instanceConfig.mcpServers[getBrowserMcpServerName()]).toEqual(getManagedConfig());
});
it('throws when the bundled browser MCP runtime cannot be prepared', () => {
setupTempHome();
const ensureSpy = spyOn(browserInstaller, 'ensureBrowserMcp').mockReturnValue(false);
expect(() => ensureBrowserMcpOrThrow()).toThrow(
'Browser MCP is enabled, but CCS could not prepare the local browser tool.'
);
expect(ensureSpy).toHaveBeenCalled();
});
});