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 = {}, ): 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, }; } 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) { 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 { 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 } }).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(), })); }); });