feat(agent-browser): add opt-in preview data injection
This commit is contained in:
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface AgentBrowserService {
|
||||
url: string;
|
||||
bounds?: AgentBrowserBounds;
|
||||
visible?: boolean;
|
||||
injectProjectData?: boolean;
|
||||
}): Promise<AgentBrowserSnapshot>;
|
||||
present(input: {
|
||||
projectPath: string;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
AgentBrowserAdapter,
|
||||
@@ -237,6 +238,52 @@ class FakeAdapter implements AgentBrowserAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
class FakePreviewDataSession {
|
||||
readonly opens: Array<{ projectPath: string; origin: string; browserGeneration: number }> = [];
|
||||
readonly invalidations: string[] = [];
|
||||
private current: {
|
||||
projectPath: string;
|
||||
projectId: string;
|
||||
origin: string;
|
||||
browserGeneration: number;
|
||||
createdAt: number;
|
||||
} | null = null;
|
||||
private token = 'preview-token';
|
||||
private readonly listeners = new Set<(reason: string) => void>();
|
||||
openHandler?: (input: { projectPath: string; origin: string; browserGeneration: number }) => Promise<void>;
|
||||
|
||||
async open(input: { projectPath: string; origin: string; browserGeneration: number }) {
|
||||
this.opens.push(input);
|
||||
await this.openHandler?.(input);
|
||||
this.current = {
|
||||
...input,
|
||||
projectId: 'clock',
|
||||
createdAt: 1,
|
||||
};
|
||||
return this.current;
|
||||
}
|
||||
|
||||
getInjectionValue() {
|
||||
if (!this.current) return null;
|
||||
return {
|
||||
endpoint: 'http://127.0.0.1:13210/api/runtime/data/v1',
|
||||
token: this.token,
|
||||
contractVersion: 1 as const,
|
||||
};
|
||||
}
|
||||
|
||||
invalidate(reason = 'manual'): void {
|
||||
this.invalidations.push(reason);
|
||||
this.current = null;
|
||||
for (const listener of this.listeners) listener(reason);
|
||||
}
|
||||
|
||||
subscribeInvalidation(listener: (reason: string) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
}
|
||||
|
||||
const projectPath = 'D:\\student\\clock';
|
||||
|
||||
async function openBrowser(adapter = new FakeAdapter()) {
|
||||
@@ -483,6 +530,378 @@ describe('AgentBrowserModule', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects data-enabled non-loopback targets before creating a view or loading a page', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
const preview = new FakePreviewDataSession();
|
||||
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
|
||||
|
||||
await expect(module.open({
|
||||
projectId: 'clock',
|
||||
projectPath,
|
||||
url: 'https://example.com/app',
|
||||
injectProjectData: true,
|
||||
})).rejects.toMatchObject({ code: 'TARGET_DENIED' });
|
||||
|
||||
expect(adapter.views).toHaveLength(0);
|
||||
expect(preview.opens).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('installs serialized preview data after attach and before the first target document load', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
const preview = new FakePreviewDataSession();
|
||||
adapter.onCreate = (view) => {
|
||||
view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({
|
||||
identifier: 'script-root',
|
||||
}));
|
||||
};
|
||||
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
|
||||
|
||||
const snapshot = await module.open({
|
||||
projectId: 'clock',
|
||||
projectPath,
|
||||
url: 'http://127.0.0.1:4173/',
|
||||
injectProjectData: true,
|
||||
visible: true,
|
||||
bounds: { x: 10, y: 20, width: 800, height: 600 },
|
||||
});
|
||||
const view = adapter.views[0];
|
||||
const addScript = view.webContents.debugger.commands.find(
|
||||
(command) => command.method === 'Page.addScriptToEvaluateOnNewDocument',
|
||||
);
|
||||
|
||||
expect(snapshot).toMatchObject({ state: 'attached', url: 'http://127.0.0.1:4173/' });
|
||||
expect(preview.opens).toEqual([{
|
||||
projectPath,
|
||||
origin: 'http://127.0.0.1:4173',
|
||||
browserGeneration: 1,
|
||||
}]);
|
||||
expect(view.webContents.debugger.operationLog).toEqual([
|
||||
'load:about:blank',
|
||||
'attach:1.3',
|
||||
'command:Target.setAutoAttach',
|
||||
'command:Page.addScriptToEvaluateOnNewDocument',
|
||||
'load:http://127.0.0.1:4173/',
|
||||
]);
|
||||
expect(addScript?.params?.sessionRef).toBeUndefined();
|
||||
expect(addScript?.params?.source).toContain('globalThis.location?.origin');
|
||||
expect(addScript?.params?.source).toContain('http://127.0.0.1:13210/api/runtime/data/v1');
|
||||
expect(addScript?.params?.source).toContain('preview-token');
|
||||
expect(addScript?.params?.source).toContain('contractVersion');
|
||||
|
||||
const source = addScript?.params?.source;
|
||||
expect(typeof source).toBe('string');
|
||||
const sameOriginGlobal: Record<string, unknown> = {
|
||||
location: { origin: 'http://127.0.0.1:4173' },
|
||||
};
|
||||
runInNewContext(source as string, sameOriginGlobal);
|
||||
expect(sameOriginGlobal.__MAKELORE_DATA__).toEqual({
|
||||
endpoint: 'http://127.0.0.1:13210/api/runtime/data/v1',
|
||||
token: 'preview-token',
|
||||
contractVersion: 1,
|
||||
});
|
||||
const descriptor = Object.getOwnPropertyDescriptor(sameOriginGlobal, '__MAKELORE_DATA__');
|
||||
expect(descriptor).toMatchObject({ enumerable: false, writable: false, configurable: false });
|
||||
|
||||
const externalGlobal: Record<string, unknown> = {
|
||||
location: { origin: 'https://external.example' },
|
||||
};
|
||||
runInNewContext(source as string, externalGlobal);
|
||||
expect(externalGlobal.__MAKELORE_DATA__).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fails and tears down before target load when preview session setup fails', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
const preview = new FakePreviewDataSession();
|
||||
preview.openHandler = async () => {
|
||||
throw new Error('preview session unavailable');
|
||||
};
|
||||
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
|
||||
|
||||
await expect(module.open({
|
||||
projectId: 'clock',
|
||||
projectPath,
|
||||
url: 'http://127.0.0.1:4173/',
|
||||
injectProjectData: true,
|
||||
})).rejects.toMatchObject({ code: 'ATTACH_FAILED' });
|
||||
|
||||
expect(adapter.views[0].webContents.loadCalls).toEqual(['about:blank']);
|
||||
expect(adapter).toMatchObject({ unmounted: 1, destroyed: 1 });
|
||||
});
|
||||
|
||||
it('fails and tears down before target load when script installation fails', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
const preview = new FakePreviewDataSession();
|
||||
adapter.onCreate = (view) => {
|
||||
view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => {
|
||||
throw new Error('CDP script registration failed');
|
||||
});
|
||||
};
|
||||
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
|
||||
|
||||
await expect(module.open({
|
||||
projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true,
|
||||
})).rejects.toMatchObject({ code: 'ATTACH_FAILED' });
|
||||
|
||||
expect(adapter.views[0].webContents.loadCalls).toEqual(['about:blank']);
|
||||
expect(adapter).toMatchObject({ unmounted: 1, destroyed: 1 });
|
||||
expect(preview.invalidations).toContain('manual');
|
||||
});
|
||||
|
||||
it('installs the same exact-Origin script for a document child session and resumes it after setup', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
const preview = new FakePreviewDataSession();
|
||||
let scriptNumber = 0;
|
||||
adapter.onCreate = (view) => {
|
||||
view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({
|
||||
identifier: `script-${scriptNumber++}`,
|
||||
}));
|
||||
};
|
||||
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
|
||||
await module.open({
|
||||
projectId: 'clock',
|
||||
projectPath,
|
||||
url: 'http://127.0.0.1:4173/',
|
||||
injectProjectData: true,
|
||||
visible: true,
|
||||
bounds: { x: 10, y: 20, width: 800, height: 600 },
|
||||
});
|
||||
const view = adapter.views[0];
|
||||
|
||||
view.webContents.debugger.message('Target.attachedToTarget', {
|
||||
sessionId: 'child-session',
|
||||
waitingForDebugger: true,
|
||||
targetInfo: { type: 'iframe' },
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(view.webContents.debugger.commands).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
method: 'Page.addScriptToEvaluateOnNewDocument',
|
||||
sessionRef: 'child-session',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
method: 'Runtime.runIfWaitingForDebugger',
|
||||
sessionRef: 'child-session',
|
||||
}),
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
it('removes preview scripts before a requested cross-Origin navigation and invalidates the token', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
const preview = new FakePreviewDataSession();
|
||||
adapter.onCreate = (view) => {
|
||||
view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({
|
||||
identifier: 'script-root',
|
||||
}));
|
||||
};
|
||||
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
|
||||
await module.open({
|
||||
projectId: 'clock',
|
||||
projectPath,
|
||||
url: 'http://127.0.0.1:4173/',
|
||||
injectProjectData: true,
|
||||
visible: true,
|
||||
bounds: { x: 10, y: 20, width: 800, height: 600 },
|
||||
});
|
||||
const view = adapter.views[0];
|
||||
const initialCommands = view.webContents.debugger.commands.length;
|
||||
|
||||
await module.navigate({
|
||||
projectPath,
|
||||
action: 'url',
|
||||
url: 'https://example.com/app',
|
||||
});
|
||||
|
||||
expect(preview.invalidations).toContain('cross_origin_navigation');
|
||||
expect(view.webContents.debugger.commands.slice(initialCommands)).toEqual([
|
||||
expect.objectContaining({
|
||||
method: 'Page.removeScriptToEvaluateOnNewDocument',
|
||||
params: { identifier: 'script-root' },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
method: 'Target.setAutoAttach',
|
||||
params: { autoAttach: true, waitForDebuggerOnStart: false, flatten: true },
|
||||
}),
|
||||
]);
|
||||
expect(view.webContents.loadCalls.at(-1)).toBe('https://example.com/app');
|
||||
});
|
||||
|
||||
it('retains the preview session and new-document script across same-Origin navigation', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
const preview = new FakePreviewDataSession();
|
||||
adapter.onCreate = (view) => {
|
||||
view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({
|
||||
identifier: 'script-root',
|
||||
}));
|
||||
};
|
||||
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
|
||||
await module.open({
|
||||
projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true,
|
||||
visible: true, bounds: { x: 0, y: 0, width: 800, height: 600 },
|
||||
});
|
||||
|
||||
await module.navigate({
|
||||
projectPath,
|
||||
action: 'url',
|
||||
url: 'http://127.0.0.1:4173/next',
|
||||
});
|
||||
|
||||
expect(preview.invalidations).toHaveLength(0);
|
||||
expect(adapter.views[0].webContents.debugger.commands).not.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ method: 'Page.removeScriptToEvaluateOnNewDocument' }),
|
||||
]));
|
||||
});
|
||||
|
||||
it('blocks a cross-Origin main-frame redirect until script cleanup completes but ignores cross-Origin frames', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
const preview = new FakePreviewDataSession();
|
||||
adapter.onCreate = (view) => {
|
||||
view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({
|
||||
identifier: 'script-root',
|
||||
}));
|
||||
};
|
||||
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
|
||||
await module.open({
|
||||
projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true,
|
||||
});
|
||||
const view = adapter.views[0];
|
||||
const preventDefault = vi.fn();
|
||||
|
||||
view.webContents.emit(
|
||||
'will-navigate',
|
||||
{ preventDefault },
|
||||
'https://external.example/app',
|
||||
false,
|
||||
false,
|
||||
);
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
expect(preview.invalidations).toHaveLength(0);
|
||||
|
||||
view.webContents.emit(
|
||||
'will-redirect',
|
||||
{ preventDefault },
|
||||
'https://external.example/app',
|
||||
false,
|
||||
true,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(preview.invalidations).toContain('cross_origin_navigation');
|
||||
expect(view.webContents.debugger.commands).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
method: 'Page.removeScriptToEvaluateOnNewDocument',
|
||||
params: { identifier: 'script-root' },
|
||||
}),
|
||||
]));
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(view.webContents.loadCalls.some((url) => url.startsWith('https://external.example/app'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('removes scripts when the session manager invalidates externally and requires an explicit reopen', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
const preview = new FakePreviewDataSession();
|
||||
let scriptNumber = 0;
|
||||
adapter.onCreate = (view) => {
|
||||
view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({
|
||||
identifier: `script-${scriptNumber++}`,
|
||||
}));
|
||||
};
|
||||
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
|
||||
await module.open({
|
||||
projectId: 'clock',
|
||||
projectPath,
|
||||
url: 'http://127.0.0.1:4173/',
|
||||
injectProjectData: true,
|
||||
});
|
||||
preview.invalidate('session_cleared');
|
||||
await vi.waitFor(() => expect(adapter.views[0].webContents.debugger.commands).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
method: 'Page.removeScriptToEvaluateOnNewDocument',
|
||||
params: { identifier: 'script-0' },
|
||||
}),
|
||||
])));
|
||||
|
||||
await module.open({
|
||||
projectId: 'clock',
|
||||
projectPath,
|
||||
url: 'http://127.0.0.1:4173/',
|
||||
injectProjectData: true,
|
||||
});
|
||||
expect(preview.opens).toHaveLength(2);
|
||||
expect(adapter.views).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('replaces a data-enabled browser when a later ordinary open omits the opt-in', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
const preview = new FakePreviewDataSession();
|
||||
let scriptNumber = 0;
|
||||
adapter.onCreate = (view) => {
|
||||
view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({
|
||||
identifier: `script-${scriptNumber++}`,
|
||||
}));
|
||||
};
|
||||
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
|
||||
|
||||
await module.open({
|
||||
projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true,
|
||||
});
|
||||
await module.open({
|
||||
projectId: 'clock', projectPath, url: 'https://example.com/',
|
||||
});
|
||||
|
||||
expect(preview.invalidations).toContain('preview_closed');
|
||||
expect(adapter.views).toHaveLength(2);
|
||||
expect(adapter.views[1].webContents.debugger.commands).not.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ method: 'Page.addScriptToEvaluateOnNewDocument' }),
|
||||
]));
|
||||
});
|
||||
|
||||
it.each([
|
||||
['close', async (module: AgentBrowserModule, view: FakeView) => {
|
||||
await module.close(projectPath);
|
||||
return view.webContents.debugger.commands;
|
||||
}, 'preview_closed'],
|
||||
['detach', async (_module: AgentBrowserModule, view: FakeView) => {
|
||||
view.webContents.debugger.detached();
|
||||
await vi.waitFor(() => expect(view.webContents.debugger.commands).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ method: 'Page.removeScriptToEvaluateOnNewDocument' }),
|
||||
])));
|
||||
return view.webContents.debugger.commands;
|
||||
}, 'browser_detached'],
|
||||
['crash', async (_module: AgentBrowserModule, view: FakeView) => {
|
||||
view.webContents.emit('render-process-gone', {}, { reason: 'crashed' });
|
||||
await vi.waitFor(() => expect(view.webContents.debugger.commands).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ method: 'Page.removeScriptToEvaluateOnNewDocument' }),
|
||||
])));
|
||||
return view.webContents.debugger.commands;
|
||||
}, 'browser_crashed'],
|
||||
])('invalidates and removes preview scripts on browser %s', async (_name, action, reason) => {
|
||||
const adapter = new FakeAdapter();
|
||||
const preview = new FakePreviewDataSession();
|
||||
adapter.onCreate = (view) => {
|
||||
view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({
|
||||
identifier: 'script-root',
|
||||
}));
|
||||
};
|
||||
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
|
||||
await module.open({
|
||||
projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true,
|
||||
});
|
||||
const view = adapter.views[0];
|
||||
|
||||
await action(module, view);
|
||||
|
||||
expect(preview.invalidations).toContain(reason);
|
||||
expect(view.webContents.debugger.commands).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
method: 'Page.removeScriptToEvaluateOnNewDocument',
|
||||
params: { identifier: 'script-root' },
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it('finishes navigation when the main document is DOM-ready even if loadURL stays pending', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -128,6 +128,45 @@ describe('Agent Browser Host API routes', () => {
|
||||
.toHaveBeenCalledWith('agent-browser:show', expect.objectContaining({ browserId: 'browser-1' }));
|
||||
});
|
||||
|
||||
it('forwards only an explicit data-injection opt-in to the browser service', async () => {
|
||||
const projectPath = await temporaryProject('niancode-agent-browser-preview-route-');
|
||||
const open = vi.fn().mockResolvedValue(snapshot(projectPath));
|
||||
const response = createResponse();
|
||||
|
||||
await handleAgentBrowserRoutes(
|
||||
createRequest('POST', {
|
||||
project_path: projectPath,
|
||||
url: 'http://127.0.0.1:5173',
|
||||
inject_project_data: true,
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/agent-browser/open'),
|
||||
context(projectPath, { open }),
|
||||
);
|
||||
|
||||
expect(response.res.statusCode).toBe(200);
|
||||
expect(open).toHaveBeenCalledWith(expect.objectContaining({
|
||||
injectProjectData: true,
|
||||
}));
|
||||
|
||||
const ordinaryResponse = createResponse();
|
||||
await handleAgentBrowserRoutes(
|
||||
createRequest('POST', {
|
||||
project_path: projectPath,
|
||||
url: 'http://127.0.0.1:5173',
|
||||
inject_project_data: 'true',
|
||||
}),
|
||||
ordinaryResponse.res,
|
||||
new URL('http://127.0.0.1/api/agent-browser/open'),
|
||||
context(projectPath, { open }),
|
||||
);
|
||||
|
||||
expect(ordinaryResponse.res.statusCode).toBe(200);
|
||||
expect(open).toHaveBeenLastCalledWith(expect.not.objectContaining({
|
||||
injectProjectData: expect.anything(),
|
||||
}));
|
||||
});
|
||||
|
||||
it('rejects viewport bounds from an agent-only Host API request', async () => {
|
||||
const projectPath = await temporaryProject('niancode-agent-browser-untrusted-bounds-');
|
||||
const open = vi.fn();
|
||||
|
||||
@@ -296,6 +296,33 @@ describe('PI-090 product tools', () => {
|
||||
expect((await attachments.read('attachment-a')).data.toString()).toBe('png-data');
|
||||
});
|
||||
|
||||
it('forwards the explicit preview data opt-in from the agent browser tool', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-browser-preview-tool-');
|
||||
const attachments = new CodingAttachmentStore(path.join(root, 'attachments'));
|
||||
const open = vi.fn().mockResolvedValue({
|
||||
browserId: 'browser-a', projectId: 'project-a', projectPath: root,
|
||||
state: 'attached', generation: 1, url: 'http://127.0.0.1:4173/', title: 'App',
|
||||
visible: false, bounds: null, canGoBack: false, canGoForward: false, eventCursor: 0,
|
||||
});
|
||||
const browser = { open } as unknown as AgentBrowserModule;
|
||||
const tools = new PiProductTools({
|
||||
browser,
|
||||
attachments,
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
});
|
||||
|
||||
await tools.execute('agent_browser', {
|
||||
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'browser-a',
|
||||
projectId: 'project-a', projectPath: root, skillIds: [],
|
||||
}, { action: 'open', url: 'http://127.0.0.1:4173/', injectProjectData: true });
|
||||
|
||||
expect(open).toHaveBeenCalledWith(expect.objectContaining({
|
||||
projectId: 'project-a', projectPath: root,
|
||||
url: 'http://127.0.0.1:4173/', visible: false,
|
||||
injectProjectData: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('loads game asset review state through the vendor-neutral product module', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-game-tool-');
|
||||
await writeFile(path.join(root, 'ASSET_PLAN.md'), [
|
||||
|
||||
Reference in New Issue
Block a user