feat: 增加共享 Agent Browser 调试能力

需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。

实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
This commit is contained in:
2026-07-31 14:53:36 +08:00
parent 80e8386fa6
commit e97a7ce9df
39 changed files with 6734 additions and 9 deletions

View File

@@ -0,0 +1,348 @@
import { EventEmitter } from 'node:events';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { handleAgentBrowserRoutes } from '@electron/api/routes/agent-browser';
import {
getRendererCapability,
RENDERER_CAPABILITY_HEADER,
rotateRendererCapability,
} from '@electron/api/renderer-capability';
function createRequest(
method: string,
body?: unknown,
headers: Record<string, string> = {},
): IncomingMessage {
const req = new EventEmitter();
Object.assign(req, {
method,
headers: {
...(body === undefined ? {} : { 'content-type': 'application/json' }),
...headers,
},
[Symbol.asyncIterator]: async function* () {
if (body !== undefined) yield Buffer.from(JSON.stringify(body));
},
});
return req as IncomingMessage;
}
function createResponse() {
const chunks: string[] = [];
const res = {
statusCode: 0,
setHeader: vi.fn(),
end: vi.fn((chunk?: string) => {
if (chunk) chunks.push(chunk);
}),
} as unknown as ServerResponse;
return {
res,
json: () => JSON.parse(chunks.join('')) as Record<string, unknown>,
};
}
function snapshot(projectPath: string) {
return {
browserId: 'browser-1',
projectId: 'project-1',
projectPath,
state: 'attached' as const,
generation: 1,
url: 'http://127.0.0.1:5173',
title: 'Example',
visible: true,
bounds: { x: 0, y: 0, width: 640, height: 480 },
canGoBack: false,
canGoForward: false,
eventCursor: 0,
};
}
function context(projectPath: string, browser: Record<string, unknown>) {
return {
opencodeProjectStore: {
getActiveProject: vi.fn().mockResolvedValue({
id: 'project-1',
path: projectPath,
name: 'project',
}),
},
eventBus: { emit: vi.fn() },
mainWindow: null,
agentBrowser: browser,
} as never;
}
describe('Agent Browser Host API routes', () => {
const temporaryDirectories: string[] = [];
beforeEach(() => {
rotateRendererCapability();
});
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});
async function temporaryProject(prefix: string): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), prefix));
temporaryDirectories.push(directory);
return directory;
}
it('opens the browser for the active project and emits a visible hint', async () => {
const projectPath = await temporaryProject('niancode-agent-browser-route-');
const open = vi.fn().mockResolvedValue(snapshot(projectPath));
const ctx = context(projectPath, { open });
const response = createResponse();
const handled = await handleAgentBrowserRoutes(
createRequest('POST', {
project_path: projectPath,
url: 'http://127.0.0.1:5173',
bounds: { x: 10, y: 20, width: 640, height: 480 },
}, {
[RENDERER_CAPABILITY_HEADER]: getRendererCapability(),
}),
response.res,
new URL('http://127.0.0.1/api/agent-browser/open'),
ctx,
);
expect(handled).toBe(true);
expect(response.res.statusCode).toBe(200);
expect(response.json()).toMatchObject({ success: true, browser: { state: 'attached' } });
expect(open).toHaveBeenCalledWith(expect.objectContaining({
projectId: 'project-1',
projectPath,
url: 'http://127.0.0.1:5173',
bounds: { x: 10, y: 20, width: 640, height: 480 },
}));
expect((ctx as never as { eventBus: { emit: ReturnType<typeof vi.fn> } }).eventBus.emit)
.toHaveBeenCalledWith('agent-browser:show', expect.objectContaining({ browserId: 'browser-1' }));
});
it('rejects viewport bounds from an agent-only Host API request', async () => {
const projectPath = await temporaryProject('niancode-agent-browser-untrusted-bounds-');
const open = vi.fn();
const response = createResponse();
await handleAgentBrowserRoutes(
createRequest('POST', {
project_path: projectPath,
url: 'http://127.0.0.1:5173',
bounds: { x: 0, y: 0, width: 1920, height: 1080 },
}),
response.res,
new URL('http://127.0.0.1/api/agent-browser/open'),
context(projectPath, { open }),
);
expect(response.res.statusCode).toBe(403);
expect(response.json()).toMatchObject({
success: false,
code: 'TARGET_DENIED',
});
expect(open).not.toHaveBeenCalled();
});
it('forces an agent-only open request to remain hidden until Renderer presents it', async () => {
const projectPath = await temporaryProject('niancode-agent-browser-agent-open-');
const open = vi.fn().mockResolvedValue(snapshot(projectPath));
const response = createResponse();
await handleAgentBrowserRoutes(
createRequest('POST', {
project_path: projectPath,
url: 'http://127.0.0.1:5173',
visible: true,
}),
response.res,
new URL('http://127.0.0.1/api/agent-browser/open'),
context(projectPath, { open }),
);
expect(response.res.statusCode).toBe(200);
expect(open).toHaveBeenCalledWith(expect.objectContaining({
bounds: undefined,
visible: false,
}));
});
it('forwards full CDP commands without stripping params', async () => {
const projectPath = await temporaryProject('niancode-agent-browser-cdp-');
const sendCdp = vi.fn().mockResolvedValue({
kind: 'inline',
value: { result: { value: 'secret page value' } },
});
const response = createResponse();
await handleAgentBrowserRoutes(
createRequest('POST', {
project_path: projectPath,
method: 'Runtime.evaluate',
params: {
expression: 'window.localStorage.getItem("token")',
returnByValue: true,
},
}),
response.res,
new URL('http://127.0.0.1/api/agent-browser/cdp/send'),
context(projectPath, { sendCdp }),
);
expect(response.res.statusCode).toBe(200);
expect(sendCdp).toHaveBeenCalledWith(expect.objectContaining({
projectPath,
method: 'Runtime.evaluate',
params: {
expression: 'window.localStorage.getItem("token")',
returnByValue: true,
},
}));
expect(response.json()).toMatchObject({
success: true,
result: { kind: 'inline', value: { result: { value: 'secret page value' } } },
});
});
it('rejects a project path that is not the active project', async () => {
const activePath = await temporaryProject('niancode-agent-browser-active-');
const otherPath = await temporaryProject('niancode-agent-browser-other-');
const open = vi.fn();
const response = createResponse();
await handleAgentBrowserRoutes(
createRequest('POST', {
project_path: otherPath,
url: 'http://127.0.0.1:5173',
}),
response.res,
new URL('http://127.0.0.1/api/agent-browser/open'),
context(activePath, { open }),
);
expect(response.res.statusCode).toBe(403);
expect(response.json()).toMatchObject({
success: false,
code: 'PROJECT_MISMATCH',
});
expect(open).not.toHaveBeenCalled();
});
it('requires the calling agent to identify its project path', async () => {
const projectPath = await temporaryProject('niancode-agent-browser-required-path-');
const open = vi.fn();
const response = createResponse();
await handleAgentBrowserRoutes(
createRequest('POST', {
url: 'http://127.0.0.1:5173',
}),
response.res,
new URL('http://127.0.0.1/api/agent-browser/open'),
context(projectPath, { open }),
);
expect(response.res.statusCode).toBe(400);
expect(response.json()).toMatchObject({
success: false,
code: 'INVALID_REQUEST',
});
expect(open).not.toHaveBeenCalled();
});
it('closes a stale browser when the active project changes during open', async () => {
const projectPath = await temporaryProject('niancode-agent-browser-race-old-');
const nextProjectPath = await temporaryProject('niancode-agent-browser-race-new-');
const open = vi.fn().mockResolvedValue(snapshot(projectPath));
const close = vi.fn().mockResolvedValue(snapshot(projectPath));
const getActiveProject = vi.fn()
.mockResolvedValueOnce({ id: 'project-1', path: projectPath, name: 'old' })
.mockResolvedValueOnce({ id: 'project-2', path: nextProjectPath, name: 'new' });
const response = createResponse();
await handleAgentBrowserRoutes(
createRequest('POST', {
project_path: projectPath,
url: 'http://127.0.0.1:5173',
}),
response.res,
new URL('http://127.0.0.1/api/agent-browser/open'),
{
opencodeProjectStore: { getActiveProject },
eventBus: { emit: vi.fn() },
mainWindow: null,
agentBrowser: { open, close },
} as never,
);
expect(response.res.statusCode).toBe(403);
expect(response.json()).toMatchObject({
success: false,
code: 'PROJECT_MISMATCH',
});
expect(close).toHaveBeenCalledWith(projectPath);
});
it('rejects stale results when the active project keeps its id but changes path', async () => {
const projectPath = await temporaryProject('niancode-agent-browser-path-old-');
const nextProjectPath = await temporaryProject('niancode-agent-browser-path-new-');
const sendCdp = vi.fn().mockResolvedValue({ kind: 'inline', value: { result: 42 } });
const close = vi.fn().mockResolvedValue(snapshot(projectPath));
const getActiveProject = vi.fn()
.mockResolvedValueOnce({ id: 'project-1', path: projectPath, name: 'old' })
.mockResolvedValueOnce({ id: 'project-1', path: nextProjectPath, name: 'moved' });
const response = createResponse();
await handleAgentBrowserRoutes(
createRequest('POST', {
project_path: projectPath,
method: 'Runtime.evaluate',
params: { expression: '42' },
}),
response.res,
new URL('http://127.0.0.1/api/agent-browser/cdp/send'),
{
opencodeProjectStore: { getActiveProject },
eventBus: { emit: vi.fn() },
mainWindow: null,
agentBrowser: { sendCdp, close },
} as never,
);
expect(response.res.statusCode).toBe(403);
expect(response.json()).toMatchObject({
success: false,
code: 'PROJECT_MISMATCH',
});
expect(close).toHaveBeenCalledWith(projectPath);
});
it('does not expose an arbitrary target id in the route contract', async () => {
const projectPath = await temporaryProject('niancode-agent-browser-target-');
const sendCdp = vi.fn().mockResolvedValue({ kind: 'inline', value: {} });
const response = createResponse();
await handleAgentBrowserRoutes(
createRequest('POST', {
project_path: projectPath,
method: 'DOM.getDocument',
targetId: 'another-electron-tab',
}),
response.res,
new URL('http://127.0.0.1/api/agent-browser/cdp/send'),
context(projectPath, { sendCdp }),
);
expect(sendCdp).toHaveBeenCalledWith(expect.not.objectContaining({
targetId: expect.anything(),
}));
});
});