feat(agent-browser): add opt-in preview data injection
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'), [
|
||||
|
||||
Reference in New Issue
Block a user