feat: 增加共享 Agent Browser 调试能力
需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。 实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
This commit is contained in:
348
tests/unit/agent-browser-routes.test.ts
Normal file
348
tests/unit/agent-browser-routes.test.ts
Normal 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(),
|
||||
}));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user