feat(agent-browser): add opt-in preview data injection

This commit is contained in:
2026-08-26 22:36:54 +08:00
parent 14fec70108
commit 38d63a9b7b
11 changed files with 1024 additions and 3 deletions

View File

@@ -1,4 +1,5 @@
import { EventEmitter } from 'node:events';
import { runInNewContext } from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
import type {
AgentBrowserAdapter,
@@ -237,6 +238,52 @@ class FakeAdapter implements AgentBrowserAdapter {
}
}
class FakePreviewDataSession {
readonly opens: Array<{ projectPath: string; origin: string; browserGeneration: number }> = [];
readonly invalidations: string[] = [];
private current: {
projectPath: string;
projectId: string;
origin: string;
browserGeneration: number;
createdAt: number;
} | null = null;
private token = 'preview-token';
private readonly listeners = new Set<(reason: string) => void>();
openHandler?: (input: { projectPath: string; origin: string; browserGeneration: number }) => Promise<void>;
async open(input: { projectPath: string; origin: string; browserGeneration: number }) {
this.opens.push(input);
await this.openHandler?.(input);
this.current = {
...input,
projectId: 'clock',
createdAt: 1,
};
return this.current;
}
getInjectionValue() {
if (!this.current) return null;
return {
endpoint: 'http://127.0.0.1:13210/api/runtime/data/v1',
token: this.token,
contractVersion: 1 as const,
};
}
invalidate(reason = 'manual'): void {
this.invalidations.push(reason);
this.current = null;
for (const listener of this.listeners) listener(reason);
}
subscribeInvalidation(listener: (reason: string) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
const projectPath = 'D:\\student\\clock';
async function openBrowser(adapter = new FakeAdapter()) {
@@ -483,6 +530,378 @@ describe('AgentBrowserModule', () => {
]);
});
it('rejects data-enabled non-loopback targets before creating a view or loading a page', async () => {
const adapter = new FakeAdapter();
const preview = new FakePreviewDataSession();
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
await expect(module.open({
projectId: 'clock',
projectPath,
url: 'https://example.com/app',
injectProjectData: true,
})).rejects.toMatchObject({ code: 'TARGET_DENIED' });
expect(adapter.views).toHaveLength(0);
expect(preview.opens).toHaveLength(0);
});
it('installs serialized preview data after attach and before the first target document load', async () => {
const adapter = new FakeAdapter();
const preview = new FakePreviewDataSession();
adapter.onCreate = (view) => {
view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({
identifier: 'script-root',
}));
};
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
const snapshot = await module.open({
projectId: 'clock',
projectPath,
url: 'http://127.0.0.1:4173/',
injectProjectData: true,
visible: true,
bounds: { x: 10, y: 20, width: 800, height: 600 },
});
const view = adapter.views[0];
const addScript = view.webContents.debugger.commands.find(
(command) => command.method === 'Page.addScriptToEvaluateOnNewDocument',
);
expect(snapshot).toMatchObject({ state: 'attached', url: 'http://127.0.0.1:4173/' });
expect(preview.opens).toEqual([{
projectPath,
origin: 'http://127.0.0.1:4173',
browserGeneration: 1,
}]);
expect(view.webContents.debugger.operationLog).toEqual([
'load:about:blank',
'attach:1.3',
'command:Target.setAutoAttach',
'command:Page.addScriptToEvaluateOnNewDocument',
'load:http://127.0.0.1:4173/',
]);
expect(addScript?.params?.sessionRef).toBeUndefined();
expect(addScript?.params?.source).toContain('globalThis.location?.origin');
expect(addScript?.params?.source).toContain('http://127.0.0.1:13210/api/runtime/data/v1');
expect(addScript?.params?.source).toContain('preview-token');
expect(addScript?.params?.source).toContain('contractVersion');
const source = addScript?.params?.source;
expect(typeof source).toBe('string');
const sameOriginGlobal: Record<string, unknown> = {
location: { origin: 'http://127.0.0.1:4173' },
};
runInNewContext(source as string, sameOriginGlobal);
expect(sameOriginGlobal.__MAKELORE_DATA__).toEqual({
endpoint: 'http://127.0.0.1:13210/api/runtime/data/v1',
token: 'preview-token',
contractVersion: 1,
});
const descriptor = Object.getOwnPropertyDescriptor(sameOriginGlobal, '__MAKELORE_DATA__');
expect(descriptor).toMatchObject({ enumerable: false, writable: false, configurable: false });
const externalGlobal: Record<string, unknown> = {
location: { origin: 'https://external.example' },
};
runInNewContext(source as string, externalGlobal);
expect(externalGlobal.__MAKELORE_DATA__).toBeUndefined();
});
it('fails and tears down before target load when preview session setup fails', async () => {
const adapter = new FakeAdapter();
const preview = new FakePreviewDataSession();
preview.openHandler = async () => {
throw new Error('preview session unavailable');
};
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
await expect(module.open({
projectId: 'clock',
projectPath,
url: 'http://127.0.0.1:4173/',
injectProjectData: true,
})).rejects.toMatchObject({ code: 'ATTACH_FAILED' });
expect(adapter.views[0].webContents.loadCalls).toEqual(['about:blank']);
expect(adapter).toMatchObject({ unmounted: 1, destroyed: 1 });
});
it('fails and tears down before target load when script installation fails', async () => {
const adapter = new FakeAdapter();
const preview = new FakePreviewDataSession();
adapter.onCreate = (view) => {
view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => {
throw new Error('CDP script registration failed');
});
};
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
await expect(module.open({
projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true,
})).rejects.toMatchObject({ code: 'ATTACH_FAILED' });
expect(adapter.views[0].webContents.loadCalls).toEqual(['about:blank']);
expect(adapter).toMatchObject({ unmounted: 1, destroyed: 1 });
expect(preview.invalidations).toContain('manual');
});
it('installs the same exact-Origin script for a document child session and resumes it after setup', async () => {
const adapter = new FakeAdapter();
const preview = new FakePreviewDataSession();
let scriptNumber = 0;
adapter.onCreate = (view) => {
view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({
identifier: `script-${scriptNumber++}`,
}));
};
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
await module.open({
projectId: 'clock',
projectPath,
url: 'http://127.0.0.1:4173/',
injectProjectData: true,
visible: true,
bounds: { x: 10, y: 20, width: 800, height: 600 },
});
const view = adapter.views[0];
view.webContents.debugger.message('Target.attachedToTarget', {
sessionId: 'child-session',
waitingForDebugger: true,
targetInfo: { type: 'iframe' },
});
await vi.waitFor(() => {
expect(view.webContents.debugger.commands).toEqual(expect.arrayContaining([
expect.objectContaining({
method: 'Page.addScriptToEvaluateOnNewDocument',
sessionRef: 'child-session',
}),
expect.objectContaining({
method: 'Runtime.runIfWaitingForDebugger',
sessionRef: 'child-session',
}),
]));
});
});
it('removes preview scripts before a requested cross-Origin navigation and invalidates the token', async () => {
const adapter = new FakeAdapter();
const preview = new FakePreviewDataSession();
adapter.onCreate = (view) => {
view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({
identifier: 'script-root',
}));
};
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
await module.open({
projectId: 'clock',
projectPath,
url: 'http://127.0.0.1:4173/',
injectProjectData: true,
visible: true,
bounds: { x: 10, y: 20, width: 800, height: 600 },
});
const view = adapter.views[0];
const initialCommands = view.webContents.debugger.commands.length;
await module.navigate({
projectPath,
action: 'url',
url: 'https://example.com/app',
});
expect(preview.invalidations).toContain('cross_origin_navigation');
expect(view.webContents.debugger.commands.slice(initialCommands)).toEqual([
expect.objectContaining({
method: 'Page.removeScriptToEvaluateOnNewDocument',
params: { identifier: 'script-root' },
}),
expect.objectContaining({
method: 'Target.setAutoAttach',
params: { autoAttach: true, waitForDebuggerOnStart: false, flatten: true },
}),
]);
expect(view.webContents.loadCalls.at(-1)).toBe('https://example.com/app');
});
it('retains the preview session and new-document script across same-Origin navigation', async () => {
const adapter = new FakeAdapter();
const preview = new FakePreviewDataSession();
adapter.onCreate = (view) => {
view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({
identifier: 'script-root',
}));
};
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
await module.open({
projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true,
visible: true, bounds: { x: 0, y: 0, width: 800, height: 600 },
});
await module.navigate({
projectPath,
action: 'url',
url: 'http://127.0.0.1:4173/next',
});
expect(preview.invalidations).toHaveLength(0);
expect(adapter.views[0].webContents.debugger.commands).not.toEqual(expect.arrayContaining([
expect.objectContaining({ method: 'Page.removeScriptToEvaluateOnNewDocument' }),
]));
});
it('blocks a cross-Origin main-frame redirect until script cleanup completes but ignores cross-Origin frames', async () => {
const adapter = new FakeAdapter();
const preview = new FakePreviewDataSession();
adapter.onCreate = (view) => {
view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({
identifier: 'script-root',
}));
};
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
await module.open({
projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true,
});
const view = adapter.views[0];
const preventDefault = vi.fn();
view.webContents.emit(
'will-navigate',
{ preventDefault },
'https://external.example/app',
false,
false,
);
expect(preventDefault).not.toHaveBeenCalled();
expect(preview.invalidations).toHaveLength(0);
view.webContents.emit(
'will-redirect',
{ preventDefault },
'https://external.example/app',
false,
true,
);
await vi.waitFor(() => {
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(preview.invalidations).toContain('cross_origin_navigation');
expect(view.webContents.debugger.commands).toEqual(expect.arrayContaining([
expect.objectContaining({
method: 'Page.removeScriptToEvaluateOnNewDocument',
params: { identifier: 'script-root' },
}),
]));
});
await vi.waitFor(() => {
expect(view.webContents.loadCalls.some((url) => url.startsWith('https://external.example/app'))).toBe(true);
});
});
it('removes scripts when the session manager invalidates externally and requires an explicit reopen', async () => {
const adapter = new FakeAdapter();
const preview = new FakePreviewDataSession();
let scriptNumber = 0;
adapter.onCreate = (view) => {
view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({
identifier: `script-${scriptNumber++}`,
}));
};
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
await module.open({
projectId: 'clock',
projectPath,
url: 'http://127.0.0.1:4173/',
injectProjectData: true,
});
preview.invalidate('session_cleared');
await vi.waitFor(() => expect(adapter.views[0].webContents.debugger.commands).toEqual(expect.arrayContaining([
expect.objectContaining({
method: 'Page.removeScriptToEvaluateOnNewDocument',
params: { identifier: 'script-0' },
}),
])));
await module.open({
projectId: 'clock',
projectPath,
url: 'http://127.0.0.1:4173/',
injectProjectData: true,
});
expect(preview.opens).toHaveLength(2);
expect(adapter.views).toHaveLength(2);
});
it('replaces a data-enabled browser when a later ordinary open omits the opt-in', async () => {
const adapter = new FakeAdapter();
const preview = new FakePreviewDataSession();
let scriptNumber = 0;
adapter.onCreate = (view) => {
view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({
identifier: `script-${scriptNumber++}`,
}));
};
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
await module.open({
projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true,
});
await module.open({
projectId: 'clock', projectPath, url: 'https://example.com/',
});
expect(preview.invalidations).toContain('preview_closed');
expect(adapter.views).toHaveLength(2);
expect(adapter.views[1].webContents.debugger.commands).not.toEqual(expect.arrayContaining([
expect.objectContaining({ method: 'Page.addScriptToEvaluateOnNewDocument' }),
]));
});
it.each([
['close', async (module: AgentBrowserModule, view: FakeView) => {
await module.close(projectPath);
return view.webContents.debugger.commands;
}, 'preview_closed'],
['detach', async (_module: AgentBrowserModule, view: FakeView) => {
view.webContents.debugger.detached();
await vi.waitFor(() => expect(view.webContents.debugger.commands).toEqual(expect.arrayContaining([
expect.objectContaining({ method: 'Page.removeScriptToEvaluateOnNewDocument' }),
])));
return view.webContents.debugger.commands;
}, 'browser_detached'],
['crash', async (_module: AgentBrowserModule, view: FakeView) => {
view.webContents.emit('render-process-gone', {}, { reason: 'crashed' });
await vi.waitFor(() => expect(view.webContents.debugger.commands).toEqual(expect.arrayContaining([
expect.objectContaining({ method: 'Page.removeScriptToEvaluateOnNewDocument' }),
])));
return view.webContents.debugger.commands;
}, 'browser_crashed'],
])('invalidates and removes preview scripts on browser %s', async (_name, action, reason) => {
const adapter = new FakeAdapter();
const preview = new FakePreviewDataSession();
adapter.onCreate = (view) => {
view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({
identifier: 'script-root',
}));
};
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
await module.open({
projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true,
});
const view = adapter.views[0];
await action(module, view);
expect(preview.invalidations).toContain(reason);
expect(view.webContents.debugger.commands).toEqual(expect.arrayContaining([
expect.objectContaining({
method: 'Page.removeScriptToEvaluateOnNewDocument',
params: { identifier: 'script-root' },
}),
]));
});
it('finishes navigation when the main document is DOM-ready even if loadURL stays pending', async () => {
vi.useFakeTimers();
try {

View File

@@ -128,6 +128,45 @@ describe('Agent Browser Host API routes', () => {
.toHaveBeenCalledWith('agent-browser:show', expect.objectContaining({ browserId: 'browser-1' }));
});
it('forwards only an explicit data-injection opt-in to the browser service', async () => {
const projectPath = await temporaryProject('niancode-agent-browser-preview-route-');
const open = vi.fn().mockResolvedValue(snapshot(projectPath));
const response = createResponse();
await handleAgentBrowserRoutes(
createRequest('POST', {
project_path: projectPath,
url: 'http://127.0.0.1:5173',
inject_project_data: 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({
injectProjectData: true,
}));
const ordinaryResponse = createResponse();
await handleAgentBrowserRoutes(
createRequest('POST', {
project_path: projectPath,
url: 'http://127.0.0.1:5173',
inject_project_data: 'true',
}),
ordinaryResponse.res,
new URL('http://127.0.0.1/api/agent-browser/open'),
context(projectPath, { open }),
);
expect(ordinaryResponse.res.statusCode).toBe(200);
expect(open).toHaveBeenLastCalledWith(expect.not.objectContaining({
injectProjectData: expect.anything(),
}));
});
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();

View File

@@ -296,6 +296,33 @@ describe('PI-090 product tools', () => {
expect((await attachments.read('attachment-a')).data.toString()).toBe('png-data');
});
it('forwards the explicit preview data opt-in from the agent browser tool', async () => {
const root = await temporaryRoot('makelore-pi-browser-preview-tool-');
const attachments = new CodingAttachmentStore(path.join(root, 'attachments'));
const open = vi.fn().mockResolvedValue({
browserId: 'browser-a', projectId: 'project-a', projectPath: root,
state: 'attached', generation: 1, url: 'http://127.0.0.1:4173/', title: 'App',
visible: false, bounds: null, canGoBack: false, canGoForward: false, eventCursor: 0,
});
const browser = { open } as unknown as AgentBrowserModule;
const tools = new PiProductTools({
browser,
attachments,
bundledSkillsDir: path.resolve('resources/coding-skills'),
});
await tools.execute('agent_browser', {
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'browser-a',
projectId: 'project-a', projectPath: root, skillIds: [],
}, { action: 'open', url: 'http://127.0.0.1:4173/', injectProjectData: true });
expect(open).toHaveBeenCalledWith(expect.objectContaining({
projectId: 'project-a', projectPath: root,
url: 'http://127.0.0.1:4173/', visible: false,
injectProjectData: true,
}));
});
it('loads game asset review state through the vendor-neutral product module', async () => {
const root = await temporaryRoot('makelore-pi-game-tool-');
await writeFile(path.join(root, 'ASSET_PLAN.md'), [