diff --git a/.project-docs/30-worklog/tasks/20260826-ml06-agent-browser-injection-2c7e91a4.md b/.project-docs/30-worklog/tasks/20260826-ml06-agent-browser-injection-2c7e91a4.md new file mode 100644 index 0000000..f48e3cf --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260826-ml06-agent-browser-injection-2c7e91a4.md @@ -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. diff --git a/electron/agent-browser/module.ts b/electron/agent-browser/module.ts index ea7e321..d1273e3 100644 --- a/electron/agent-browser/module.ts +++ b/electron/agent-browser/module.ts @@ -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; +}; + 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(); private record: BrowserRecord | null = null; + private previewDataSession: PreviewDataSession | undefined; + private previewDataSessionUnsubscribe: (() => void) | undefined; private commandTail: Promise = Promise.resolve(); private lifecycleBarrier: Promise = 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 { + 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 { + 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 { + 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 { + 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 { + 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): 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 { diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index e9749a6..e3c057e 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -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(); diff --git a/electron/api/context.ts b/electron/api/context.ts index 9865e48..1b385ab 100644 --- a/electron/api/context.ts +++ b/electron/api/context.ts @@ -28,6 +28,7 @@ export interface AgentBrowserService { url: string; bounds?: AgentBrowserBounds; visible?: boolean; + injectProjectData?: boolean; }): Promise; present(input: { projectPath: string; diff --git a/electron/api/routes/agent-browser.ts b/electron/api/routes/agent-browser.ts index be24f38..2091af9 100644 --- a/electron/api/routes/agent-browser.ts +++ b/electron/api/routes/agent-browser.ts @@ -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); diff --git a/electron/coding-runtime/pi/extensions/agent-browser.ts b/electron/coding-runtime/pi/extensions/agent-browser.ts index c05e6de..2eadc71 100644 --- a/electron/coding-runtime/pi/extensions/agent-browser.ts +++ b/electron/coding-runtime/pi/extensions/agent-browser.ts @@ -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)); } diff --git a/electron/coding-runtime/pi/extensions/makelore-runtime.ts b/electron/coding-runtime/pi/extensions/makelore-runtime.ts index a2f0d91..f6eaa7b 100644 --- a/electron/coding-runtime/pi/extensions/makelore-runtime.ts +++ b/electron/coding-runtime/pi/extensions/makelore-runtime.ts @@ -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' }, diff --git a/electron/services/preview-data-session.ts b/electron/services/preview-data-session.ts index ac04ad4..856454c 100644 --- a/electron/services/preview-data-session.ts +++ b/electron/services/preview-data-session.ts @@ -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(); 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(); } } diff --git a/tests/unit/agent-browser-core.test.ts b/tests/unit/agent-browser-core.test.ts index 40fe619..0003119 100644 --- a/tests/unit/agent-browser-core.test.ts +++ b/tests/unit/agent-browser-core.test.ts @@ -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; + + 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 = { + 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 = { + 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 { diff --git a/tests/unit/agent-browser-routes.test.ts b/tests/unit/agent-browser-routes.test.ts index e5c8086..7a86354 100644 --- a/tests/unit/agent-browser-routes.test.ts +++ b/tests/unit/agent-browser-routes.test.ts @@ -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(); diff --git a/tests/unit/pi-product-tools.test.ts b/tests/unit/pi-product-tools.test.ts index 0c6cbc2..b7c1596 100644 --- a/tests/unit/pi-product-tools.test.ts +++ b/tests/unit/pi-product-tools.test.ts @@ -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'), [