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

@@ -0,0 +1,113 @@
# Task: Implement ML-06 Agent Browser pre-document injection
## Identity
- Task ID: 20260826-ml06-agent-browser-injection-2c7e91a4
- Mode: Feature
- Branch: codex/20260826-ml06-agent-browser-injection-2c7e91a4-ml06-agent-browser-injection
- Worktree: D:\Datas\OthersProjects\makelore-ml06-agent-browser-injection-2c7e91a4
- Base commit: 14fec701086f60d78466b8e1cc3fb57ca8d7e200
- Owner: codex
- Status: Ready for Integration
## Scope
- Implement ticket ML-06 against the exact ML-05 frontier
`14fec701086f60d78466b8e1cc3fb57ca8d7e200`.
- Extend Agent Browser open inputs and the Pi `agent_browser` adapter with the
opt-in `injectProjectData` capability, without changing ordinary arbitrary-URL
opens.
- Bind the accepted open to the ML-05 preview data session and install a
serialized exact-Origin CDP new-document script after debugger attach and
before the first target document load; track and remove every installed script.
- Wire synchronous session invalidation and cleanup across cross-Origin
navigation/redirect, browser close/crash/detach/generation replacement,
project transitions, logout, and Main teardown, with focused unit/Electron/
E2E coverage where the repository prerequisites permit.
## Intent And Constraints
- Follow spec sections 10.1 and 10.4, ticket ML-06, the canonical Data Service
contract, and ADR-2026-08-26-makelore-development-data-service.md. Preserve the existing Agent Browser
abstraction, attach-before-load lifecycle, child-session handling, and single
BrowserWindow/view design.
- Use only a narrow structural preview-session interface. Main remains the sole
Works credential owner; injected data contains only the local endpoint,
ephemeral token, and contract version. Do not add a generic dispatcher,
compatibility layer, preload, retry, cache, Firebase behavior, or ML-07 SDK /
Skill work.
- The exact Origin guard is mandatory even while asynchronous CDP cleanup is in
flight. Data-enabled non-loopback targets must fail before any target page
load; failed session creation or script installation must tear down the view
before the target application can execute.
## Planning Gate
- Result: Passed on 2026-08-26.
- Concurrent ownership is isolated from the occupied client coordinator and
matches this task ID, branch, absolute worktree, and exact base commit.
- Loaded MakeLore AGENTS.md, required project-memory entry documents, active
coordinator task scope, accepted Data Service ADR, canonical contract, and
spec/plan sections 10.1 and 10.4. Active planning peers are either unrelated
or have placeholder scopes; no unresolved semantic conflict overlaps the ML-06
files. The coordinator owns the integration frontier and will cherry-pick this
task's single commit.
## Implementation Plan
1. Add red tests for opt-in propagation/rejection, attach-before-load script
installation and serialization, child target sessions, invalidation/removal,
and ordinary-open preservation.
2. Add the narrow preview-session invalidation subscription and Agent Browser
capability state; install root/child scripts with tracked CDP IDs and strict
pre-load failure cleanup.
3. Wire route/Pi input validation and Main/composition lifecycle subscription;
extend focused route/preview regressions without touching SDK/Skill work.
4. Run focused tests, ML-05 regressions, typecheck/lint/build/Electron checks
available in this environment, inspect diff boundaries, update this record,
and run task-aware doc drift before returning one clean commit.
## Outcome
- Implemented ML-06 on the exact ML-05 frontier. Agent Browser now accepts an
explicit `injectProjectData` opt-in, rejects non-loopback targets before view
creation, opens a narrow preview session, and installs a serialized
exact-Origin `__MAKELORE_DATA__` new-document script after debugger attach
and before the target load. Root and document-child CDP sessions track script
identifiers; invalidation removes them and synchronously makes the token
unusable across navigation/redirect, close, project transitions, logout,
generation replacement, debugger detach, renderer crash, and Main shutdown.
Ordinary arbitrary-URL opens remain data-free, and route/Pi input propagation
is literal-true only. The child-target race is covered so a paused target is
released only after invalidation cleanup completes.
## Verification
- Focused Vitest: `pnpm exec vitest run tests/unit/agent-browser-core.test.ts
tests/unit/agent-browser-routes.test.ts tests/unit/pi-product-tools.test.ts
tests/unit/coding-core-routes.test.ts` — 4 files, 122 tests passed.
- Full repository tests: `pnpm test` — 187 files, 1,596 passed, 2 skipped;
pressure test — 1 passed.
- Electron smoke: `pnpm test:electron:windows` — 2 files, 4 tests passed.
- TypeScript: `pnpm typecheck` passed.
- Lint: `pnpm lint:check` passed with 0 errors and 5 pre-existing warnings in
`src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`.
- Build: `pnpm build:vite` passed for renderer, Main, preload, and utility
worker; existing dynamic-import and large-chunk warnings remain.
- E2E: `pnpm test:e2e` reached 25 passing tests and 1 unrelated failure in
`tests/e2e/pi-coding-first-chat.spec.ts:575`, where the existing
`当前对话模型` combobox remained disabled until timeout. Re-running that
test alone reproduced the same timeout; no ML-06 injection path was involved.
- Diff hygiene: `git diff --check` passed; no files outside the ML-06 ownership
boundary were changed.
## Follow-ups
- Parent integration must cherry-pick this commit into the client frontier,
then perform the repository review/remediation and X-01 real PostgreSQL plus
signed-in MakeLore preview acceptance. The E2E prerequisite failure above
remains an integration-environment deviation, not a claimed pass.
## Promotion Candidates
- None recorded.

View File

@@ -15,6 +15,14 @@ import type {
AgentBrowserViewPort,
PortListener,
} from './adapter';
import type {
PreviewDataInjectionValue,
PreviewDataSessionInvalidationListener,
PreviewDataSessionInvalidationReason,
PreviewDataSessionManager,
PreviewDataSessionOpenInput,
PreviewDataSessionSnapshot,
} from '../services/preview-data-session';
import { AgentBrowserCdpGuard } from './cdp-guard';
import { AgentBrowserEventBuffer } from './event-buffer';
import { AgentBrowserFault } from './fault';
@@ -111,14 +119,34 @@ interface BrowserRecord {
listener: PortListener;
}>;
interruptWaiters: Set<(fault: AgentBrowserFault) => void>;
previewDataRequested: boolean;
previewData?: PreviewDataBinding;
}
type PreviewDataSession = Pick<
PreviewDataSessionManager,
'open' | 'getInjectionValue' | 'invalidate' | 'subscribeInvalidation'
>;
type PreviewDataScript = Readonly<{
identifier: string;
sessionRef?: string;
}>;
type PreviewDataBinding = {
session: PreviewDataSessionSnapshot;
scripts: PreviewDataScript[];
invalidated: boolean;
cleanup?: Promise<void>;
};
export interface AgentBrowserOpenInput {
projectId: string;
projectPath: string;
url: string;
bounds?: AgentBrowserBounds;
visible?: boolean;
injectProjectData?: boolean;
}
export interface AgentBrowserPresentInput {
@@ -160,6 +188,7 @@ export interface AgentBrowserModuleOptions {
payloadStore?: AgentBrowserPayloadStore;
cdpGuard?: AgentBrowserCdpGuard;
onLifecycle?(event: AgentBrowserLifecycleEvent): void;
previewDataSession?: PreviewDataSession;
}
export type AgentBrowserLifecycleEvent = Readonly<{
@@ -187,6 +216,8 @@ export class AgentBrowserModule {
private readonly commandCancellers = new Set<(fault: AgentBrowserFault) => void>();
private readonly lifecycleListeners = new Set<AgentBrowserLifecycleListener>();
private record: BrowserRecord | null = null;
private previewDataSession: PreviewDataSession | undefined;
private previewDataSessionUnsubscribe: (() => void) | undefined;
private commandTail: Promise<void> = Promise.resolve();
private lifecycleBarrier: Promise<void> = Promise.resolve();
private queueEpoch = 0;
@@ -200,6 +231,21 @@ export class AgentBrowserModule {
this.payloadStore = options.payloadStore ?? new AgentBrowserPayloadStore();
this.cdpGuard = options.cdpGuard ?? new AgentBrowserCdpGuard();
if (options.onLifecycle) this.lifecycleListeners.add(options.onLifecycle);
if (options.previewDataSession) this.configurePreviewDataSession(options.previewDataSession);
}
configurePreviewDataSession(session?: PreviewDataSession): void {
this.previewDataSessionUnsubscribe?.();
this.previewDataSessionUnsubscribe = undefined;
this.previewDataSession = session;
if (!session) return;
const onInvalidated: PreviewDataSessionInvalidationListener = (_reason) => {
const record = this.record;
if (!record?.previewData || record.previewData.invalidated) return;
record.previewData.invalidated = true;
void this.removePreviewDataScripts(record, record.previewData).catch(() => undefined);
};
this.previewDataSessionUnsubscribe = session.subscribeInvalidation(onInvalidated);
}
subscribeLifecycle(listener: AgentBrowserLifecycleListener): () => void {
@@ -263,6 +309,8 @@ export class AgentBrowserModule {
const projectPath = normalizeRequiredPath(input.projectPath);
const targetUrl = normalizeUrl(input.url);
const bounds = input.bounds ? normalizeBounds(input.bounds) : null;
const injectProjectData = input.injectProjectData === true;
if (injectProjectData) assertPreviewDataTarget(targetUrl);
if (this.record && !samePath(this.record.projectPath, projectPath)) {
await this.closeInternal();
@@ -281,6 +329,17 @@ export class AgentBrowserModule {
await this.closeInternal();
}
}
if (this.record) {
const record = this.record;
const shouldReplacePreview = injectProjectData
? !record.previewData
|| record.previewData.invalidated
|| !sameOrigin(record.previewData.session.origin, targetUrl)
: Boolean(record.previewData);
if (shouldReplacePreview) {
await this.closeInternal(record);
}
}
if (this.record) {
const record = this.record;
if (bounds) {
@@ -312,6 +371,8 @@ export class AgentBrowserModule {
webContentsListeners: [],
debuggerListeners: [],
interruptWaiters: new Set(),
previewDataRequested: injectProjectData,
previewData: undefined,
};
this.record = record;
@@ -332,6 +393,7 @@ export class AgentBrowserModule {
this.attachDebugger(record, false),
OPEN_TIMEOUT_MS,
);
if (injectProjectData) await this.installPreviewData(record);
} catch (error) {
await this.closeInternal(record);
throw toFault(
@@ -541,6 +603,9 @@ export class AgentBrowserModule {
this.disposed = true;
this.preemptCommands('开发浏览器模块已关闭。');
await this.closeInternal();
this.previewDataSessionUnsubscribe?.();
this.previewDataSessionUnsubscribe = undefined;
this.previewDataSession = undefined;
this.payloadStore.clear();
}
@@ -694,6 +759,7 @@ export class AgentBrowserModule {
record.generation = ++this.generation;
record.childSessions.clear();
record.ioHandles.clear();
await this.invalidatePreviewData(record, 'browser_generation_replaced');
this.notifyLifecycle({
type: 'generation-replaced',
projectId: record.projectId,
@@ -708,7 +774,7 @@ export class AgentBrowserModule {
await port.sendCommand('Target.setAutoAttach', {
autoAttach: true,
waitForDebuggerOnStart: false,
waitForDebuggerOnStart: record.previewDataRequested,
flatten: true,
});
if (record.diagnosticsEnabled) {
@@ -729,6 +795,195 @@ export class AgentBrowserModule {
record.error = undefined;
}
private async installPreviewData(
record: BrowserRecord,
sessionRef?: string,
): Promise<void> {
if (record !== this.record || record.view.webContents.isDestroyed()) {
throw new AgentBrowserFault(
'TARGET_GONE',
'开发浏览器页面已关闭。',
true,
record.generation,
);
}
const previewDataSession = this.previewDataSession;
if (!previewDataSession) {
throw new Error('Preview data session is not configured');
}
let binding = record.previewData;
if (!sessionRef) {
const openInput: PreviewDataSessionOpenInput = {
projectPath: record.projectPath,
origin: new URL(record.url).origin,
browserGeneration: record.generation,
};
const session = await previewDataSession.open(openInput);
if (
record !== this.record
|| record.state === 'closing'
|| record.view.webContents.isDestroyed()
) {
previewDataSession.invalidate('preview_closed');
throw new AgentBrowserFault(
'CLOSED',
'开发浏览器页面在创建数据运行时期间已关闭或切换。',
true,
record.generation,
'unknown',
);
}
const value = previewDataSession.getInjectionValue();
if (!value) {
previewDataSession.invalidate('manual');
throw new Error('Preview data injection value is unavailable');
}
binding = {
session,
scripts: [],
invalidated: false,
};
record.previewData = binding;
try {
await this.addPreviewDataScript(record, binding, value);
} catch (error) {
await this.invalidatePreviewData(record, 'manual');
throw error;
}
return;
}
if (!binding || binding.invalidated) return;
const value = previewDataSession.getInjectionValue();
if (!value) return;
await this.addPreviewDataScript(record, binding, value, sessionRef);
}
private async addPreviewDataScript(
record: BrowserRecord,
binding: PreviewDataBinding,
value: PreviewDataInjectionValue,
sessionRef?: string,
): Promise<void> {
const response = await record.view.webContents.debugger.sendCommand(
'Page.addScriptToEvaluateOnNewDocument',
{ source: previewDataInjectionScript(binding.session.origin, value) },
sessionRef,
);
const identifier = isRecord(response) && typeof response.identifier === 'string'
? response.identifier
: undefined;
if (!identifier) throw new Error('Preview data injection script was not registered');
const script = {
identifier,
...(sessionRef ? { sessionRef } : {}),
};
if (
record !== this.record
|| record.view.webContents.isDestroyed()
|| binding.invalidated
|| record.previewData !== binding
) {
await record.view.webContents.debugger.sendCommand(
'Page.removeScriptToEvaluateOnNewDocument',
{ identifier },
sessionRef,
).catch(() => undefined);
throw new AgentBrowserFault(
'CLOSED',
'开发浏览器页面在安装数据运行时期间已关闭或切换。',
true,
record.generation,
'unknown',
);
}
binding.scripts.push(script);
}
private invalidatePreviewData(
record: BrowserRecord,
reason: PreviewDataSessionInvalidationReason,
): Promise<void> {
const binding = record.previewData;
if (!binding) return Promise.resolve();
if (!binding.invalidated) {
binding.invalidated = true;
this.previewDataSession?.invalidate(reason);
}
return this.removePreviewDataScripts(record, binding);
}
private removePreviewDataScripts(
record: BrowserRecord,
binding: PreviewDataBinding,
): Promise<void> {
if (binding.cleanup) return binding.cleanup;
binding.cleanup = (async () => {
record.previewDataRequested = false;
const scripts = binding.scripts.splice(0);
await Promise.all(scripts.map(async ({ identifier, sessionRef }) => {
try {
await record.view.webContents.debugger.sendCommand(
'Page.removeScriptToEvaluateOnNewDocument',
{ identifier },
sessionRef,
);
} catch {
// The debugger or child target may already be detached.
}
if (sessionRef) {
await record.view.webContents.debugger.sendCommand(
'Runtime.runIfWaitingForDebugger',
undefined,
sessionRef,
).catch(() => undefined);
}
}));
if (
record === this.record
&& !record.view.webContents.isDestroyed()
&& record.view.webContents.debugger.isAttached()
) {
await record.view.webContents.debugger.sendCommand('Target.setAutoAttach', {
autoAttach: true,
waitForDebuggerOnStart: false,
flatten: true,
}).catch(() => undefined);
}
if (record.previewData === binding) record.previewData = undefined;
})();
return binding.cleanup;
}
private isPreviewDataCrossOrigin(record: BrowserRecord, nextUrl: string): boolean {
const origin = record.previewData?.session.origin;
if (!origin) return false;
return !sameOrigin(origin, nextUrl);
}
private handleCrossOriginNavigationRequest(
record: BrowserRecord,
nextUrl: string,
eventValue: unknown,
): void {
if (!this.isPreviewDataCrossOrigin(record, nextUrl)) return;
const binding = record.previewData;
if (!binding || binding.invalidated) return;
const event = preventableEvent(eventValue);
event?.preventDefault();
const cleanup = this.invalidatePreviewData(record, 'cross_origin_navigation');
if (!event) return;
void cleanup.then(() => {
if (
record !== this.record
|| record.view.webContents.isDestroyed()
|| record.state === 'closing'
) return;
void this.navigateTo(record, nextUrl).catch(() => undefined);
});
}
private registerListeners(record: BrowserRecord): void {
const contents = record.view.webContents;
const onDebuggerMessage: PortListener = (
@@ -747,6 +1002,7 @@ export class AgentBrowserModule {
if (record !== this.record || record.state === 'closing' || record.state === 'crashed') {
return;
}
this.invalidatePreviewData(record, 'browser_detached');
record.eventBuffer.markGap('debugger-detached');
record.childSessions.clear();
record.ioHandles.clear();
@@ -770,10 +1026,22 @@ export class AgentBrowserModule {
this.addDebuggerListener(record, 'message', onDebuggerMessage);
this.addDebuggerListener(record, 'detach', onDebuggerDetach);
this.addWebContentsListener(record, 'will-navigate', (eventValue, urlValue, _isInPlace, isMainFrameValue) => {
if (isMainFrameValue === false || typeof urlValue !== 'string') return;
this.handleCrossOriginNavigationRequest(record, urlValue, eventValue);
});
this.addWebContentsListener(record, 'will-redirect', (eventValue, urlValue, _isInPlace, isMainFrameValue) => {
if (isMainFrameValue === false || typeof urlValue !== 'string') return;
this.handleCrossOriginNavigationRequest(record, urlValue, eventValue);
});
this.addWebContentsListener(record, 'did-navigate', (_event, urlValue) => {
const previousUrl = record.url;
if (typeof urlValue === 'string') {
record.url = urlValue;
if (this.isPreviewDataCrossOrigin(record, urlValue)) {
this.invalidatePreviewData(record, 'cross_origin_navigation');
}
if (crossOriginNavigation(previousUrl, urlValue)) {
this.notifyLifecycle({
type: 'cross-origin-navigation',
@@ -792,6 +1060,9 @@ export class AgentBrowserModule {
const previousUrl = record.url;
if (typeof urlValue === 'string' && isMainFrameValue !== false) {
record.url = urlValue;
if (this.isPreviewDataCrossOrigin(record, urlValue)) {
this.invalidatePreviewData(record, 'cross_origin_navigation');
}
if (crossOriginNavigation(previousUrl, urlValue)) {
this.notifyLifecycle({
type: 'cross-origin-navigation',
@@ -845,6 +1116,7 @@ export class AgentBrowserModule {
});
this.addWebContentsListener(record, 'render-process-gone', () => {
if (record !== this.record || record.state === 'closing') return;
this.invalidatePreviewData(record, 'browser_crashed');
record.state = 'crashed';
record.error = {
code: 'RENDERER_CRASHED',
@@ -864,6 +1136,7 @@ export class AgentBrowserModule {
});
this.addWebContentsListener(record, 'destroyed', () => {
if (record !== this.record || record.state === 'closing') return;
this.invalidatePreviewData(record, 'browser_crashed');
record.state = 'crashed';
record.error = {
code: 'TARGET_GONE',
@@ -949,11 +1222,63 @@ export class AgentBrowserModule {
if (!isRecord(params)) return;
if (method === 'Target.attachedToTarget' && typeof params.sessionId === 'string') {
record.childSessions.add(params.sessionId);
const previewBinding = record.previewData;
if (previewBinding && !previewBinding.invalidated && isDocumentTarget(params)) {
void this.installPreviewData(record, params.sessionId)
.then(async () => {
if (params.waitingForDebugger !== true
|| record !== this.record
|| record.view.webContents.isDestroyed()) return;
// If invalidation won the race with script registration, wait for
// removal before releasing the paused child target. It must never
// execute with a token after the session has been invalidated.
await previewBinding.cleanup;
if (record !== this.record || record.view.webContents.isDestroyed()) return;
if (record.previewData?.invalidated) return;
await record.view.webContents.debugger.sendCommand(
'Runtime.runIfWaitingForDebugger',
undefined,
params.sessionId as string,
);
})
.catch(async () => {
await this.invalidatePreviewData(record, 'manual');
if (
params.waitingForDebugger === true
&& record === this.record
&& !record.view.webContents.isDestroyed()
) {
await record.view.webContents.debugger.sendCommand(
'Runtime.runIfWaitingForDebugger',
undefined,
params.sessionId as string,
).catch(() => undefined);
}
});
} else if (params.waitingForDebugger === true) {
void record.view.webContents.debugger.sendCommand(
'Runtime.runIfWaitingForDebugger',
undefined,
params.sessionId,
).catch(() => undefined);
}
} else if (
method === 'Target.detachedFromTarget'
&& typeof params.sessionId === 'string'
) {
record.childSessions.delete(params.sessionId);
const binding = record.previewData;
if (binding) {
const detachedScripts = binding.scripts.filter((script) => script.sessionRef === params.sessionId);
binding.scripts = binding.scripts.filter((script) => script.sessionRef !== params.sessionId);
for (const script of detachedScripts) {
void record.view.webContents.debugger.sendCommand(
'Page.removeScriptToEvaluateOnNewDocument',
{ identifier: script.identifier },
script.sessionRef,
).catch(() => undefined);
}
}
}
}
@@ -992,6 +1317,9 @@ export class AgentBrowserModule {
}
private async navigateTo(record: BrowserRecord, targetUrl: string): Promise<void> {
if (this.isPreviewDataCrossOrigin(record, targetUrl)) {
await this.invalidatePreviewData(record, 'cross_origin_navigation');
}
try {
await this.loadPageUntilReady(record, targetUrl);
record.url = targetUrl;
@@ -1216,6 +1544,7 @@ export class AgentBrowserModule {
}
if (expected && this.record !== expected) return;
record.state = 'closing';
await this.invalidatePreviewData(record, 'preview_closed');
this.notifyLifecycle({
type: 'closed',
projectId: record.projectId,
@@ -1566,6 +1895,66 @@ function normalizeUrl(value: string): string {
return url.toString();
}
function assertPreviewDataTarget(value: string): void {
let url: URL;
try {
url = new URL(value);
} catch {
throw new AgentBrowserFault('TARGET_DENIED', '数据运行时只允许打开本机预览地址。', false);
}
const hostname = url.hostname.toLowerCase();
const loopback = hostname === 'localhost'
|| hostname === '127.0.0.1'
|| hostname === '[::1]';
if (
!loopback
|| (url.protocol !== 'http:' && url.protocol !== 'https:')
|| url.username
|| url.password
) {
throw new AgentBrowserFault('TARGET_DENIED', '数据运行时只允许打开本机预览地址。', false);
}
}
function sameOrigin(left: string, right: string): boolean {
try {
return new URL(left).origin === new URL(right).origin;
} catch {
return false;
}
}
function preventableEvent(value: unknown): { preventDefault(): void } | undefined {
return isRecord(value) && typeof value.preventDefault === 'function'
? value as { preventDefault(): void }
: undefined;
}
function isDocumentTarget(params: Record<string, unknown>): boolean {
const targetInfo = isRecord(params.targetInfo) ? params.targetInfo : undefined;
const type = targetInfo?.type;
return type === 'page' || type === 'iframe' || type === 'webview';
}
function previewDataInjectionScript(
origin: string,
value: PreviewDataInjectionValue,
): string {
const serializedOrigin = JSON.stringify(origin);
const serializedValue = JSON.stringify(value);
if (!serializedOrigin || !serializedValue) {
throw new Error('Preview data injection value is not serializable');
}
return `if (globalThis.location?.origin === ${serializedOrigin}) {
Object.defineProperty(globalThis, "__MAKELORE_DATA__", {
value: ${serializedValue},
enumerable: false,
writable: false,
configurable: false,
});
}`;
}
function crossOriginNavigation(previousUrl: string, nextUrl: string): boolean {
if (previousUrl === 'about:blank' || nextUrl === 'about:blank') return false;
try {

View File

@@ -213,6 +213,9 @@ export function createCodingComposition(
const dataService = createDataServiceOperations({ projects });
productTools.configureDataService(dataService);
previewDataSession = createPreviewDataSessionManager({ projects });
if (typeof options.browser.configurePreviewDataSession === 'function') {
options.browser.configurePreviewDataSession(previewDataSession);
}
const unsubscribeBrowserLifecycle = typeof options.browser.subscribeLifecycle === 'function'
? options.browser.subscribeLifecycle((event) => {
previewDataSession?.handleAgentBrowserLifecycle(event);
@@ -236,6 +239,9 @@ export function createCodingComposition(
},
async shutdown() {
previewDataSession?.dispose();
if (typeof options.browser.configurePreviewDataSession === 'function') {
options.browser.configurePreviewDataSession(undefined);
}
unsubscribeBrowserLifecycle();
await subagents.close();
await runtime.shutdown();

View File

@@ -28,6 +28,7 @@ export interface AgentBrowserService {
url: string;
bounds?: AgentBrowserBounds;
visible?: boolean;
injectProjectData?: boolean;
}): Promise<AgentBrowserSnapshot>;
present(input: {
projectPath: string;

View File

@@ -11,6 +11,7 @@ type AgentBrowserBody = {
url?: unknown;
action?: unknown;
visible?: unknown;
inject_project_data?: unknown;
enabled?: unknown;
bounds?: unknown;
method?: unknown;
@@ -221,6 +222,7 @@ export async function handleAgentBrowserRoutes(
url: targetUrl,
bounds: parseBounds(body.bounds),
visible: rendererPresentation && body.visible !== false,
...(body.inject_project_data === true ? { injectProjectData: true } : {}),
});
await ensureProjectStillActive(ctx, project);
emitState(ctx, 'agent-browser:show', browser);

View File

@@ -90,6 +90,7 @@ export class PiAgentBrowserTool {
projectPath: context.projectPath,
url: string(body.url, true) as string,
visible: false,
...(body.injectProjectData === true ? { injectProjectData: true } : {}),
});
return result(action, publicSnapshot(snapshot));
}

View File

@@ -220,7 +220,8 @@ export default function makeloreRuntime(pi) {
type: 'object', additionalProperties: true, required: ['action'],
properties: {
action: { type: 'string', enum: ['open', 'status', 'close', 'reset_profile', 'navigate', 'send_cdp', 'read_events', 'read_payload'] },
url: { type: 'string' }, navigation: { type: 'string', enum: ['url', 'back', 'forward', 'reload'] },
url: { type: 'string' }, injectProjectData: { type: 'boolean' },
navigation: { type: 'string', enum: ['url', 'back', 'forward', 'reload'] },
method: { type: 'string' }, params: { type: 'object' }, sessionRef: { type: 'string' },
timeoutMs: { type: 'number' }, after: { type: 'number' }, methods: { type: 'array', items: { type: 'string' } },
limit: { type: 'number' }, waitMs: { type: 'number' }, handle: { type: 'string' },

View File

@@ -62,6 +62,10 @@ export type PreviewDataSessionInvalidationReason =
| 'replaced'
| 'manual';
export type PreviewDataSessionInvalidationListener = (
reason: PreviewDataSessionInvalidationReason,
) => void;
export type PreviewDataSessionOpenInput = {
projectPath: string;
origin: string;
@@ -232,6 +236,7 @@ export class PreviewDataSessionManager {
private readonly getAccountBinding: typeof getWorksSquareAccountBinding;
private readonly isCurrentAccountBinding: typeof isCurrentWorksSquareAccountBinding;
private readonly unsubscribeWorksSession: () => void;
private readonly invalidationListeners = new Set<PreviewDataSessionInvalidationListener>();
private session: InternalSession | null = null;
private boundAccountBinding: WorksSquareAccountBinding | null;
private disposed = false;
@@ -285,6 +290,7 @@ export class PreviewDataSessionManager {
throw new PreviewDataSessionError('runtime_unavailable', 'Preview data runtime is unavailable');
}
const token = tokenBytes.toString('base64url');
this.invalidate('replaced');
const snapshot: PreviewDataSessionSnapshot = Object.freeze({
projectPath: active.path,
projectId: active.projectId,
@@ -307,6 +313,14 @@ export class PreviewDataSessionManager {
return this.session?.snapshot ?? null;
}
subscribeInvalidation(listener: PreviewDataSessionInvalidationListener): () => void {
if (this.disposed) return () => undefined;
this.invalidationListeners.add(listener);
return () => {
this.invalidationListeners.delete(listener);
};
}
getInjectionValue(hostPort = this.hostPort): PreviewDataInjectionValue | null {
const session = this.session;
if (!session || !Number.isSafeInteger(hostPort) || hostPort < 1 || hostPort > 65_535) {
@@ -409,8 +423,16 @@ export class PreviewDataSessionManager {
}
}
invalidate(_reason: PreviewDataSessionInvalidationReason = 'manual'): void {
invalidate(reason: PreviewDataSessionInvalidationReason = 'manual'): void {
if (!this.session) return;
this.session = null;
for (const listener of this.invalidationListeners) {
try {
listener(reason);
} catch {
// A browser cleanup observer must not interrupt token invalidation.
}
}
}
dispose(): void {
@@ -418,6 +440,7 @@ export class PreviewDataSessionManager {
this.disposed = true;
this.invalidate('main_shutdown');
this.unsubscribeWorksSession();
this.invalidationListeners.clear();
}
}

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'), [