需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。 实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
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(),
|
|
}));
|
|
});
|
|
});
|