feat: 增加共享 Agent Browser 调试能力
需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。 实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
This commit is contained in:
52
electron/agent-browser/adapter.ts
Normal file
52
electron/agent-browser/adapter.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { AgentBrowserBounds } from '../../shared/agent-browser';
|
||||
|
||||
export type PortListener = (...args: unknown[]) => void;
|
||||
|
||||
export interface AgentBrowserDebuggerPort {
|
||||
attach(protocolVersion: string): void;
|
||||
detach(): void;
|
||||
isAttached(): boolean;
|
||||
sendCommand(
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
sessionRef?: string,
|
||||
): Promise<unknown>;
|
||||
on(event: 'message' | 'detach', listener: PortListener): void;
|
||||
removeListener(event: 'message' | 'detach', listener: PortListener): void;
|
||||
}
|
||||
|
||||
export interface AgentBrowserNavigationPort {
|
||||
canGoBack(): boolean;
|
||||
canGoForward(): boolean;
|
||||
goBack(): void;
|
||||
goForward(): void;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export interface AgentBrowserWebContentsPort {
|
||||
readonly debugger: AgentBrowserDebuggerPort;
|
||||
readonly navigationHistory: AgentBrowserNavigationPort;
|
||||
loadURL(url: string): Promise<void>;
|
||||
getURL(): string;
|
||||
getTitle(): string;
|
||||
isDestroyed(): boolean;
|
||||
isDevToolsOpened(): boolean;
|
||||
reload(): void;
|
||||
denyWindowOpen(): void;
|
||||
on(event: string, listener: PortListener): void;
|
||||
removeListener(event: string, listener: PortListener): void;
|
||||
}
|
||||
|
||||
export interface AgentBrowserViewPort {
|
||||
readonly webContents: AgentBrowserWebContentsPort;
|
||||
setBounds(bounds: AgentBrowserBounds): void;
|
||||
setVisible(visible: boolean): void;
|
||||
}
|
||||
|
||||
export interface AgentBrowserAdapter {
|
||||
createView(partition: string): AgentBrowserViewPort;
|
||||
mount(view: AgentBrowserViewPort): void;
|
||||
unmount(view: AgentBrowserViewPort): void;
|
||||
destroy(view: AgentBrowserViewPort): void;
|
||||
resetPartition(partition: string): Promise<void>;
|
||||
}
|
||||
108
electron/agent-browser/cdp-guard.ts
Normal file
108
electron/agent-browser/cdp-guard.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { AgentBrowserFault } from './fault';
|
||||
|
||||
const BLOCKED_METHODS = new Set([
|
||||
'DOM.getFileInfo',
|
||||
'DOM.setFileInputFiles',
|
||||
'Page.crash',
|
||||
'Page.setDownloadBehavior',
|
||||
'Security.handleCertificateError',
|
||||
'Security.setDisableNetworkAccessForOrigins',
|
||||
'Security.setIgnoreCertificateErrors',
|
||||
'Security.setOverrideCertificateErrors',
|
||||
]);
|
||||
|
||||
const BLOCKED_DOMAINS = [
|
||||
'Cast.',
|
||||
'DeviceAccess.',
|
||||
'Extensions.',
|
||||
'Tethering.',
|
||||
] as const;
|
||||
|
||||
const URL_FIELDS_BY_METHOD = new Map<string, string[]>([
|
||||
['Fetch.continueRequest', ['url']],
|
||||
['Network.continueInterceptedRequest', ['url']],
|
||||
['Network.loadNetworkResource', ['url']],
|
||||
['Page.navigate', ['url']],
|
||||
]);
|
||||
|
||||
function hasExternalTargetReference(params: Record<string, unknown> | undefined): boolean {
|
||||
return Boolean(params && (
|
||||
typeof params.targetId === 'string'
|
||||
|| typeof params.browserContextId === 'string'
|
||||
));
|
||||
}
|
||||
|
||||
function isAllowedPageUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentBrowserCdpGuard {
|
||||
assertAllowed(
|
||||
method: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
sessionRef: string | undefined,
|
||||
childSessions: ReadonlySet<string>,
|
||||
ioHandles: ReadonlySet<string>,
|
||||
): void {
|
||||
const blockedHostDomain = (
|
||||
(method.startsWith('Browser.') && method !== 'Browser.getVersion')
|
||||
|| method.startsWith('SystemInfo.')
|
||||
|| method.startsWith('Memory.')
|
||||
|| method.startsWith('Tracing.')
|
||||
|| BLOCKED_DOMAINS.some((domain) => method.startsWith(domain))
|
||||
);
|
||||
if (BLOCKED_METHODS.has(method) || blockedHostDomain) {
|
||||
throw new AgentBrowserFault(
|
||||
'CDP_METHOD_BLOCKED',
|
||||
`CDP method ${method} 可能影响 Makelore 宿主,已阻止。`,
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (method.startsWith('Target.')) {
|
||||
throw new AgentBrowserFault(
|
||||
'TARGET_DENIED',
|
||||
'Target 域由开发浏览器托管,不能直接操作其他 Electron 页面。',
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (hasExternalTargetReference(params)) {
|
||||
throw new AgentBrowserFault(
|
||||
'TARGET_DENIED',
|
||||
'不能指定开发浏览器之外的 CDP target。',
|
||||
false,
|
||||
);
|
||||
}
|
||||
for (const field of URL_FIELDS_BY_METHOD.get(method) ?? []) {
|
||||
const value = params?.[field];
|
||||
if (typeof value === 'string' && !isAllowedPageUrl(value)) {
|
||||
throw new AgentBrowserFault(
|
||||
'CDP_METHOD_BLOCKED',
|
||||
`${method} 只允许访问 HTTP 或 HTTPS 地址。`,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (sessionRef && !childSessions.has(sessionRef)) {
|
||||
throw new AgentBrowserFault(
|
||||
'TARGET_DENIED',
|
||||
'CDP session 不属于当前开发浏览器。',
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (method.startsWith('IO.')) {
|
||||
const handle = params?.handle;
|
||||
if (typeof handle !== 'string' || !ioHandles.has(handle)) {
|
||||
throw new AgentBrowserFault(
|
||||
'TARGET_DENIED',
|
||||
'IO handle 不属于当前开发浏览器会话。',
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
191
electron/agent-browser/electron-adapter.ts
Normal file
191
electron/agent-browser/electron-adapter.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { BrowserWindow, Session, WebContents } from 'electron';
|
||||
import { WebContentsView, session } from 'electron';
|
||||
import type { AgentBrowserBounds } from '../../shared/agent-browser';
|
||||
import type {
|
||||
AgentBrowserAdapter,
|
||||
AgentBrowserDebuggerPort,
|
||||
AgentBrowserNavigationPort,
|
||||
AgentBrowserViewPort,
|
||||
AgentBrowserWebContentsPort,
|
||||
PortListener,
|
||||
} from './adapter';
|
||||
|
||||
function isAllowedTopLevelUrl(value: string): boolean {
|
||||
if (value === 'about:blank') return true;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function wrapDebugger(contents: WebContents): AgentBrowserDebuggerPort {
|
||||
const debuggerEvents = contents.debugger as unknown as {
|
||||
on(event: string, listener: PortListener): void;
|
||||
removeListener(event: string, listener: PortListener): void;
|
||||
};
|
||||
return {
|
||||
attach: (version) => contents.debugger.attach(version),
|
||||
detach: () => contents.debugger.detach(),
|
||||
isAttached: () => contents.debugger.isAttached(),
|
||||
sendCommand: (method, params, sessionRef) =>
|
||||
contents.debugger.sendCommand(method, params, sessionRef),
|
||||
on: (event, listener) => debuggerEvents.on(event, listener),
|
||||
removeListener: (event, listener) =>
|
||||
debuggerEvents.removeListener(event, listener),
|
||||
};
|
||||
}
|
||||
|
||||
function wrapNavigation(contents: WebContents): AgentBrowserNavigationPort {
|
||||
return {
|
||||
canGoBack: () => contents.navigationHistory.canGoBack(),
|
||||
canGoForward: () => contents.navigationHistory.canGoForward(),
|
||||
goBack: () => {
|
||||
contents.navigationHistory.goBack();
|
||||
},
|
||||
goForward: () => {
|
||||
contents.navigationHistory.goForward();
|
||||
},
|
||||
clear: () => {
|
||||
contents.navigationHistory.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function wrapWebContents(contents: WebContents): AgentBrowserWebContentsPort {
|
||||
const contentsEvents = contents as unknown as {
|
||||
on(event: string, listener: PortListener): void;
|
||||
removeListener(event: string, listener: PortListener): void;
|
||||
};
|
||||
return {
|
||||
debugger: wrapDebugger(contents),
|
||||
navigationHistory: wrapNavigation(contents),
|
||||
loadURL: (url) => contents.loadURL(url),
|
||||
getURL: () => contents.getURL(),
|
||||
getTitle: () => contents.getTitle(),
|
||||
isDestroyed: () => contents.isDestroyed(),
|
||||
isDevToolsOpened: () => contents.isDevToolsOpened(),
|
||||
reload: () => contents.reload(),
|
||||
denyWindowOpen: () => {
|
||||
contents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
||||
},
|
||||
on: (event: string, listener: PortListener) => {
|
||||
contentsEvents.on(event, listener);
|
||||
},
|
||||
removeListener: (event: string, listener: PortListener) => {
|
||||
contentsEvents.removeListener(event, listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class ElectronAgentBrowserAdapter implements AgentBrowserAdapter {
|
||||
private readonly nativeViews = new WeakMap<AgentBrowserViewPort, WebContentsView>();
|
||||
private readonly guardedSessions = new WeakSet<Session>();
|
||||
|
||||
constructor(private readonly mainWindow: BrowserWindow) {}
|
||||
|
||||
createView(partition: string): AgentBrowserViewPort {
|
||||
const nativeView = new WebContentsView({
|
||||
webPreferences: {
|
||||
partition,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
},
|
||||
});
|
||||
const contents = nativeView.webContents;
|
||||
contents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
||||
contents.on('will-attach-webview', (event) => event.preventDefault());
|
||||
const preventUnsafeNavigation = (
|
||||
event: Electron.Event,
|
||||
navigationUrl: string,
|
||||
) => {
|
||||
if (!isAllowedTopLevelUrl(navigationUrl)) event.preventDefault();
|
||||
};
|
||||
contents.on('will-navigate', preventUnsafeNavigation);
|
||||
contents.on('will-redirect', preventUnsafeNavigation);
|
||||
this.guardSession(contents.session);
|
||||
|
||||
const view: AgentBrowserViewPort = {
|
||||
webContents: wrapWebContents(contents),
|
||||
setBounds: (bounds: AgentBrowserBounds) =>
|
||||
nativeView.setBounds(this.toNativeBounds(bounds)),
|
||||
setVisible: (visible: boolean) => nativeView.setVisible(visible),
|
||||
};
|
||||
this.nativeViews.set(view, nativeView);
|
||||
return view;
|
||||
}
|
||||
|
||||
mount(view: AgentBrowserViewPort): void {
|
||||
const nativeView = this.requireNativeView(view);
|
||||
if (!this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.contentView.addChildView(nativeView);
|
||||
}
|
||||
}
|
||||
|
||||
unmount(view: AgentBrowserViewPort): void {
|
||||
const nativeView = this.nativeViews.get(view);
|
||||
if (!nativeView || this.mainWindow.isDestroyed()) return;
|
||||
try {
|
||||
this.mainWindow.contentView.removeChildView(nativeView);
|
||||
} catch {
|
||||
// Removing an already detached view is harmless during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
destroy(view: AgentBrowserViewPort): void {
|
||||
const nativeView = this.nativeViews.get(view);
|
||||
if (!nativeView) return;
|
||||
this.nativeViews.delete(view);
|
||||
if (!nativeView.webContents.isDestroyed()) {
|
||||
nativeView.webContents.close({ waitForBeforeUnload: false });
|
||||
}
|
||||
}
|
||||
|
||||
async resetPartition(partition: string): Promise<void> {
|
||||
const browserSession = session.fromPartition(partition);
|
||||
await browserSession.clearStorageData();
|
||||
await browserSession.clearCache();
|
||||
}
|
||||
|
||||
private requireNativeView(view: AgentBrowserViewPort): WebContentsView {
|
||||
const nativeView = this.nativeViews.get(view);
|
||||
if (!nativeView) throw new Error('Unknown Agent Browser view.');
|
||||
return nativeView;
|
||||
}
|
||||
|
||||
private guardSession(browserSession: Session): void {
|
||||
if (this.guardedSessions.has(browserSession)) return;
|
||||
this.guardedSessions.add(browserSession);
|
||||
browserSession.setPermissionCheckHandler(() => false);
|
||||
browserSession.setPermissionRequestHandler((_contents, _permission, callback) => {
|
||||
callback(false);
|
||||
});
|
||||
browserSession.on('will-download', (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
private toNativeBounds(bounds: AgentBrowserBounds): AgentBrowserBounds {
|
||||
const zoomFactor = this.mainWindow.webContents.getZoomFactor();
|
||||
const zoom = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
|
||||
const contentBounds = this.mainWindow.getContentBounds();
|
||||
const contentWidth = Math.max(0, Math.trunc(contentBounds.width));
|
||||
const contentHeight = Math.max(0, Math.trunc(contentBounds.height));
|
||||
const x = clamp(Math.round(bounds.x * zoom), 0, contentWidth);
|
||||
const y = clamp(Math.round(bounds.y * zoom), 0, contentHeight);
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: clamp(Math.round(bounds.width * zoom), 0, contentWidth - x),
|
||||
height: clamp(Math.round(bounds.height * zoom), 0, contentHeight - y),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), Math.max(min, max));
|
||||
}
|
||||
128
electron/agent-browser/event-buffer.ts
Normal file
128
electron/agent-browser/event-buffer.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type {
|
||||
AgentBrowserCdpEvent,
|
||||
AgentBrowserCdpEventPage,
|
||||
} from '../../shared/agent-browser';
|
||||
|
||||
type GapReason = NonNullable<AgentBrowserCdpEventPage['gap']>['reason'];
|
||||
|
||||
interface BufferedEvent {
|
||||
event: AgentBrowserCdpEvent;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
interface GapMarker {
|
||||
sequence: number;
|
||||
reason: GapReason;
|
||||
}
|
||||
|
||||
export interface AgentBrowserEventBufferOptions {
|
||||
maxEvents?: number;
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_EVENTS = 10_000;
|
||||
const DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
export class AgentBrowserEventBuffer {
|
||||
private readonly events: BufferedEvent[] = [];
|
||||
private readonly gaps: GapMarker[] = [];
|
||||
private readonly maxEvents: number;
|
||||
private readonly maxBytes: number;
|
||||
private bytes = 0;
|
||||
private sequence = 0;
|
||||
private evictedThrough = 0;
|
||||
|
||||
constructor(options: AgentBrowserEventBufferOptions = {}) {
|
||||
this.maxEvents = options.maxEvents ?? DEFAULT_MAX_EVENTS;
|
||||
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
}
|
||||
|
||||
get cursor(): number {
|
||||
return this.sequence;
|
||||
}
|
||||
|
||||
push(event: Omit<AgentBrowserCdpEvent, 'sequence'>): AgentBrowserCdpEvent {
|
||||
const complete = { ...event, sequence: ++this.sequence };
|
||||
const byteLength = Buffer.byteLength(JSON.stringify(complete));
|
||||
this.events.push({ event: complete, byteLength });
|
||||
this.bytes += byteLength;
|
||||
this.evict();
|
||||
return complete;
|
||||
}
|
||||
|
||||
markGap(reason: GapReason): number {
|
||||
const sequence = ++this.sequence;
|
||||
this.gaps.push({ sequence, reason });
|
||||
this.pruneGaps();
|
||||
return sequence;
|
||||
}
|
||||
|
||||
read(after = 0, methods?: string[], limit = 100): AgentBrowserCdpEventPage {
|
||||
const safeAfter = Math.max(0, Math.trunc(after));
|
||||
const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 200);
|
||||
const methodSet = methods?.length ? new Set(methods) : null;
|
||||
const start = Math.max(safeAfter, this.evictedThrough);
|
||||
const selected: AgentBrowserCdpEvent[] = [];
|
||||
let scannedThrough = start;
|
||||
|
||||
for (const buffered of this.events) {
|
||||
if (buffered.event.sequence <= start) continue;
|
||||
scannedThrough = buffered.event.sequence;
|
||||
if (!methodSet || methodSet.has(buffered.event.method)) {
|
||||
selected.push(buffered.event);
|
||||
if (selected.length >= safeLimit) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.length < safeLimit) {
|
||||
scannedThrough = Math.max(scannedThrough, this.sequence);
|
||||
}
|
||||
|
||||
const nextCursor = Math.max(safeAfter, scannedThrough);
|
||||
const hasMore = this.events.some(({ event }) =>
|
||||
event.sequence > nextCursor && (!methodSet || methodSet.has(event.method)));
|
||||
const oldestAvailable = this.events[0]?.event.sequence ?? this.sequence + 1;
|
||||
const gap = safeAfter < this.evictedThrough
|
||||
? { reason: 'evicted' as const, oldestAvailable }
|
||||
: this.gaps.find((marker) =>
|
||||
marker.sequence > safeAfter && marker.sequence <= nextCursor);
|
||||
|
||||
return {
|
||||
events: selected,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
...(gap
|
||||
? {
|
||||
gap: {
|
||||
reason: gap.reason,
|
||||
oldestAvailable,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.events.length = 0;
|
||||
this.gaps.length = 0;
|
||||
this.bytes = 0;
|
||||
this.sequence = 0;
|
||||
this.evictedThrough = 0;
|
||||
}
|
||||
|
||||
private evict(): void {
|
||||
while (this.events.length > this.maxEvents || this.bytes > this.maxBytes) {
|
||||
const removed = this.events.shift();
|
||||
if (!removed) break;
|
||||
this.bytes -= removed.byteLength;
|
||||
this.evictedThrough = Math.max(this.evictedThrough, removed.event.sequence);
|
||||
}
|
||||
this.pruneGaps();
|
||||
}
|
||||
|
||||
private pruneGaps(): void {
|
||||
while (this.gaps[0] && this.gaps[0].sequence <= this.evictedThrough) {
|
||||
this.gaps.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
18
electron/agent-browser/fault.ts
Normal file
18
electron/agent-browser/fault.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type {
|
||||
AgentBrowserErrorCode,
|
||||
AgentBrowserFaultShape,
|
||||
} from '../../shared/agent-browser';
|
||||
|
||||
export class AgentBrowserFault extends Error implements AgentBrowserFaultShape {
|
||||
readonly name = 'AgentBrowserFault';
|
||||
|
||||
constructor(
|
||||
readonly code: AgentBrowserErrorCode,
|
||||
message: string,
|
||||
readonly retryable: boolean,
|
||||
readonly generation?: number,
|
||||
readonly outcome?: 'unknown',
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
26
electron/agent-browser/index.ts
Normal file
26
electron/agent-browser/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type {
|
||||
AgentBrowserAdapter,
|
||||
AgentBrowserDebuggerPort,
|
||||
AgentBrowserNavigationPort,
|
||||
AgentBrowserViewPort,
|
||||
AgentBrowserWebContentsPort,
|
||||
} from './adapter';
|
||||
export { AgentBrowserCdpGuard } from './cdp-guard';
|
||||
export { ElectronAgentBrowserAdapter } from './electron-adapter';
|
||||
export { AgentBrowserEventBuffer } from './event-buffer';
|
||||
export { AgentBrowserFault } from './fault';
|
||||
export {
|
||||
AgentBrowserModule,
|
||||
agentBrowserPartition,
|
||||
} from './module';
|
||||
export type {
|
||||
AgentBrowserModuleOptions,
|
||||
AgentBrowserNavigateInput,
|
||||
AgentBrowserOpenInput,
|
||||
AgentBrowserPresentInput,
|
||||
AgentBrowserReadEventsInput,
|
||||
AgentBrowserReadPayloadInput,
|
||||
AgentBrowserSendCdpInput,
|
||||
} from './module';
|
||||
export { AgentBrowserPayloadStore } from './payload-store';
|
||||
export type { PayloadStoreOptions } from './payload-store';
|
||||
1305
electron/agent-browser/module.ts
Normal file
1305
electron/agent-browser/module.ts
Normal file
File diff suppressed because it is too large
Load Diff
167
electron/agent-browser/payload-store.ts
Normal file
167
electron/agent-browser/payload-store.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
AgentBrowserPayloadChunk,
|
||||
AgentBrowserPayloadRef,
|
||||
} from '../../shared/agent-browser';
|
||||
import { AgentBrowserFault } from './fault';
|
||||
|
||||
type PayloadContentType = AgentBrowserPayloadRef['contentType'];
|
||||
|
||||
interface PayloadEntry {
|
||||
data: Buffer;
|
||||
contentType: PayloadContentType;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface PayloadStoreOptions {
|
||||
maxBytes?: number;
|
||||
maxEntryBytes?: number;
|
||||
ttlMs?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
|
||||
const DEFAULT_MAX_ENTRY_BYTES = 32 * 1024 * 1024;
|
||||
const DEFAULT_TTL_MS = 60_000;
|
||||
const DEFAULT_CHUNK_BYTES = 256 * 1024;
|
||||
const MAX_CHUNK_BYTES = 1024 * 1024;
|
||||
|
||||
export class AgentBrowserPayloadStore {
|
||||
private readonly entries = new Map<string, PayloadEntry>();
|
||||
private readonly maxBytes: number;
|
||||
private readonly maxEntryBytes: number;
|
||||
private readonly ttlMs: number;
|
||||
private readonly now: () => number;
|
||||
private totalBytes = 0;
|
||||
|
||||
constructor(options: PayloadStoreOptions = {}) {
|
||||
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
this.maxEntryBytes = options.maxEntryBytes ?? DEFAULT_MAX_ENTRY_BYTES;
|
||||
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
putJson(value: unknown): AgentBrowserPayloadRef {
|
||||
let encoded: Buffer;
|
||||
try {
|
||||
encoded = Buffer.from(JSON.stringify(value) ?? 'null');
|
||||
} catch {
|
||||
throw new AgentBrowserFault(
|
||||
'CDP_PROTOCOL_ERROR',
|
||||
'CDP 返回了无法序列化的数据。',
|
||||
false,
|
||||
);
|
||||
}
|
||||
return this.put(encoded, 'application/json');
|
||||
}
|
||||
|
||||
put(data: Buffer, contentType: PayloadContentType): AgentBrowserPayloadRef {
|
||||
this.purgeExpired();
|
||||
if (data.byteLength > this.maxEntryBytes || data.byteLength > this.maxBytes) {
|
||||
throw new AgentBrowserFault(
|
||||
'PAYLOAD_TOO_LARGE',
|
||||
`CDP 数据超过 ${this.maxEntryBytes} 字节的单项上限。`,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
while (this.totalBytes + data.byteLength > this.maxBytes) {
|
||||
const oldest = this.entries.keys().next().value as string | undefined;
|
||||
if (!oldest) break;
|
||||
this.delete(oldest);
|
||||
}
|
||||
|
||||
const handle = randomUUID();
|
||||
const expiresAt = this.now() + this.ttlMs;
|
||||
this.entries.set(handle, {
|
||||
data: Buffer.from(data),
|
||||
contentType,
|
||||
expiresAt,
|
||||
});
|
||||
this.totalBytes += data.byteLength;
|
||||
return {
|
||||
kind: 'payload',
|
||||
handle,
|
||||
byteLength: data.byteLength,
|
||||
contentType,
|
||||
expiresAt: new Date(expiresAt).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
read(handle: string, offset = 0, maxBytes = DEFAULT_CHUNK_BYTES): AgentBrowserPayloadChunk {
|
||||
this.purgeExpired();
|
||||
const entry = this.entries.get(handle);
|
||||
if (!entry) {
|
||||
throw new AgentBrowserFault(
|
||||
'PAYLOAD_NOT_FOUND',
|
||||
'CDP 数据已过期或不存在。',
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(offset) || offset < 0 || offset > entry.data.byteLength) {
|
||||
throw new AgentBrowserFault('INVALID_REQUEST', 'Payload offset 无效。', false);
|
||||
}
|
||||
if (!Number.isInteger(maxBytes) || maxBytes < 1) {
|
||||
throw new AgentBrowserFault('INVALID_REQUEST', 'Payload maxBytes 无效。', false);
|
||||
}
|
||||
|
||||
const requestedBytes = Math.min(
|
||||
maxBytes,
|
||||
MAX_CHUNK_BYTES,
|
||||
);
|
||||
let nextOffset = Math.min(offset + requestedBytes, entry.data.byteLength);
|
||||
const isText = entry.contentType !== 'application/octet-stream';
|
||||
if (isText && offset > 0 && (entry.data[offset] & 0xc0) === 0x80) {
|
||||
throw new AgentBrowserFault(
|
||||
'INVALID_REQUEST',
|
||||
'文本 Payload offset 必须位于 UTF-8 字符边界。',
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (isText && nextOffset < entry.data.byteLength) {
|
||||
while (nextOffset > offset && (entry.data[nextOffset] & 0xc0) === 0x80) {
|
||||
nextOffset -= 1;
|
||||
}
|
||||
if (nextOffset === offset) {
|
||||
nextOffset = Math.min(offset + requestedBytes, entry.data.byteLength);
|
||||
while (
|
||||
nextOffset < entry.data.byteLength
|
||||
&& (entry.data[nextOffset] & 0xc0) === 0x80
|
||||
) {
|
||||
nextOffset += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chunk = entry.data.subarray(offset, nextOffset);
|
||||
return {
|
||||
handle,
|
||||
offset,
|
||||
nextOffset,
|
||||
byteLength: entry.data.byteLength,
|
||||
contentType: entry.contentType,
|
||||
data: isText ? chunk.toString('utf8') : chunk.toString('base64'),
|
||||
encoding: isText ? 'utf8' : 'base64',
|
||||
done: nextOffset >= entry.data.byteLength,
|
||||
};
|
||||
}
|
||||
|
||||
delete(handle: string): void {
|
||||
const entry = this.entries.get(handle);
|
||||
if (!entry) return;
|
||||
this.entries.delete(handle);
|
||||
this.totalBytes -= entry.data.byteLength;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear();
|
||||
this.totalBytes = 0;
|
||||
}
|
||||
|
||||
private purgeExpired(): void {
|
||||
const now = this.now();
|
||||
for (const [handle, entry] of this.entries) {
|
||||
if (entry.expiresAt <= now) this.delete(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,66 @@ import type { OpencodeProjectStore } from '../opencode/project-store';
|
||||
import type { HostEventBus } from './event-bus';
|
||||
import type { createWorksCloudDeployment } from '../services/works-cloud-deployment';
|
||||
import type { LocalImageWorkspace } from '../image-workspace/local-workspace';
|
||||
import type {
|
||||
AgentBrowserBounds,
|
||||
AgentBrowserCdpEventPage,
|
||||
AgentBrowserCdpResult,
|
||||
AgentBrowserPayloadChunk,
|
||||
AgentBrowserSnapshot,
|
||||
} from '../../shared/agent-browser';
|
||||
|
||||
export type WorksCloudDeploymentCoordinator = ReturnType<typeof createWorksCloudDeployment>;
|
||||
|
||||
export interface AgentBrowserService {
|
||||
getSnapshot(projectPath?: string): Promise<AgentBrowserSnapshot> | AgentBrowserSnapshot;
|
||||
open(input: {
|
||||
projectId: string;
|
||||
projectPath: string;
|
||||
url: string;
|
||||
bounds?: AgentBrowserBounds;
|
||||
visible?: boolean;
|
||||
}): Promise<AgentBrowserSnapshot>;
|
||||
present(input: {
|
||||
projectPath: string;
|
||||
visible: boolean;
|
||||
bounds?: AgentBrowserBounds;
|
||||
}): Promise<AgentBrowserSnapshot>;
|
||||
navigate(input: {
|
||||
projectPath: string;
|
||||
action: 'url' | 'back' | 'forward' | 'reload';
|
||||
url?: string;
|
||||
}): Promise<AgentBrowserSnapshot>;
|
||||
sendCdp(input: {
|
||||
projectPath: string;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
sessionRef?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<AgentBrowserCdpResult>;
|
||||
readEvents(input: {
|
||||
projectPath: string;
|
||||
after?: number;
|
||||
methods?: string[];
|
||||
limit?: number;
|
||||
waitMs?: number;
|
||||
}): Promise<AgentBrowserCdpEventPage>;
|
||||
readPayload(input: {
|
||||
projectPath: string;
|
||||
handle: string;
|
||||
offset?: number;
|
||||
maxBytes?: number;
|
||||
}): Promise<AgentBrowserPayloadChunk>;
|
||||
close(projectPath?: string): Promise<AgentBrowserSnapshot>;
|
||||
resetProfile(projectPath: string): Promise<AgentBrowserSnapshot>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface HostApiContext {
|
||||
opencodeManager: OpencodeManager;
|
||||
opencodeProjectStore: OpencodeProjectStore;
|
||||
eventBus: HostEventBus;
|
||||
mainWindow: BrowserWindow | null;
|
||||
agentBrowser?: AgentBrowserService;
|
||||
worksCloudDeployment?: WorksCloudDeploymentCoordinator;
|
||||
imageWorkspace?: LocalImageWorkspace;
|
||||
}
|
||||
|
||||
21
electron/api/renderer-capability.ts
Normal file
21
electron/api/renderer-capability.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
|
||||
export const RENDERER_CAPABILITY_HEADER = 'x-niancode-renderer-capability';
|
||||
|
||||
let rendererCapability = '';
|
||||
|
||||
export function rotateRendererCapability(): void {
|
||||
rendererCapability = randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
export function getRendererCapability(): string {
|
||||
return rendererCapability;
|
||||
}
|
||||
|
||||
export function hasRendererCapability(req: IncomingMessage): boolean {
|
||||
const value = req.headers[RENDERER_CAPABILITY_HEADER];
|
||||
return typeof value === 'string'
|
||||
&& rendererCapability.length > 0
|
||||
&& value === rendererCapability;
|
||||
}
|
||||
370
electron/api/routes/agent-browser.ts
Normal file
370
electron/api/routes/agent-browser.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { AgentBrowserBounds, AgentBrowserFaultShape } from '../../../shared/agent-browser';
|
||||
import { normalizeProjectPath } from '../../opencode/project-store';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { hasRendererCapability } from '../renderer-capability';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
type AgentBrowserBody = {
|
||||
project_path?: unknown;
|
||||
url?: unknown;
|
||||
action?: unknown;
|
||||
visible?: unknown;
|
||||
bounds?: unknown;
|
||||
method?: unknown;
|
||||
params?: unknown;
|
||||
session_ref?: unknown;
|
||||
timeout_ms?: unknown;
|
||||
after?: unknown;
|
||||
methods?: unknown;
|
||||
limit?: unknown;
|
||||
wait_ms?: unknown;
|
||||
handle?: unknown;
|
||||
offset?: unknown;
|
||||
max_bytes?: unknown;
|
||||
};
|
||||
|
||||
class AgentBrowserRouteError extends Error {
|
||||
constructor(
|
||||
readonly code: AgentBrowserFaultShape['code'],
|
||||
message: string,
|
||||
readonly status = 400,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function finiteInteger(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.trunc(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseBounds(value: unknown): AgentBrowserBounds | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const record = value as Record<string, unknown>;
|
||||
const x = finiteInteger(record.x);
|
||||
const y = finiteInteger(record.y);
|
||||
const width = finiteInteger(record.width);
|
||||
const height = finiteInteger(record.height);
|
||||
if (x === undefined || y === undefined || width === undefined || height === undefined) {
|
||||
throw new AgentBrowserRouteError('INVALID_REQUEST', '浏览器区域坐标不完整。');
|
||||
}
|
||||
if (width < 1 || height < 1) {
|
||||
throw new AgentBrowserRouteError('VIEWPORT_NOT_READY', '浏览器区域尚未准备好。');
|
||||
}
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
function parseStringArray(value: unknown): string[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
||||
throw new AgentBrowserRouteError('INVALID_REQUEST', 'CDP 事件过滤器格式无效。');
|
||||
}
|
||||
return value.map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
async function resolveActiveProject(ctx: HostApiContext, requestedPath?: unknown) {
|
||||
const activeProject = await ctx.opencodeProjectStore.getActiveProject();
|
||||
if (!activeProject) {
|
||||
throw new AgentBrowserRouteError('PROJECT_NOT_ACTIVE', '请先打开一个项目。', 409);
|
||||
}
|
||||
|
||||
let activeRealPath: string;
|
||||
try {
|
||||
activeRealPath = await realpath(activeProject.path);
|
||||
} catch {
|
||||
throw new AgentBrowserRouteError('PROJECT_NOT_ACTIVE', '当前项目目录不可用。', 409);
|
||||
}
|
||||
|
||||
const requested = nonEmptyString(requestedPath);
|
||||
if (!requested) {
|
||||
throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少当前项目路径。');
|
||||
}
|
||||
let requestedRealPath: string;
|
||||
try {
|
||||
requestedRealPath = await realpath(requested);
|
||||
} catch {
|
||||
throw new AgentBrowserRouteError('PROJECT_MISMATCH', '请求的项目目录不可用。', 403);
|
||||
}
|
||||
if (normalizeProjectPath(requestedRealPath) !== normalizeProjectPath(activeRealPath)) {
|
||||
throw new AgentBrowserRouteError('PROJECT_MISMATCH', '智能体只能调试当前项目。', 403);
|
||||
}
|
||||
|
||||
return {
|
||||
...activeProject,
|
||||
path: activeRealPath,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureProjectStillActive(
|
||||
ctx: HostApiContext,
|
||||
project: { id: string; path: string },
|
||||
): Promise<void> {
|
||||
const activeProject = await ctx.opencodeProjectStore.getActiveProject();
|
||||
let activeRealPath: string | null = null;
|
||||
if (activeProject?.id === project.id) {
|
||||
try {
|
||||
activeRealPath = await realpath(activeProject.path);
|
||||
} catch {
|
||||
activeRealPath = null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
activeProject?.id === project.id
|
||||
&& activeRealPath
|
||||
&& normalizeProjectPath(activeRealPath) === normalizeProjectPath(project.path)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ctx.agentBrowser?.close(project.path);
|
||||
} catch {
|
||||
// A new project's browser may already own the module; never close it.
|
||||
}
|
||||
throw new AgentBrowserRouteError(
|
||||
'PROJECT_MISMATCH',
|
||||
'项目已切换,本次浏览器操作已取消。',
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
function requireService(ctx: HostApiContext) {
|
||||
if (!ctx.agentBrowser) {
|
||||
throw new AgentBrowserRouteError('CLOSED', '开发浏览器尚未初始化。', 503);
|
||||
}
|
||||
return ctx.agentBrowser;
|
||||
}
|
||||
|
||||
function requireRendererPresentation(req: IncomingMessage): void {
|
||||
if (!hasRendererCapability(req)) {
|
||||
throw new AgentBrowserRouteError(
|
||||
'TARGET_DENIED',
|
||||
'浏览器显示区域只能由 Makelore 界面控制。',
|
||||
403,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function emitState(ctx: HostApiContext, eventName: 'agent-browser:show' | 'agent-browser:state', payload: unknown): void {
|
||||
ctx.eventBus.emit(eventName, payload);
|
||||
const webContents = ctx.mainWindow?.webContents;
|
||||
if (webContents && !webContents.isDestroyed()) {
|
||||
webContents.send(eventName, payload);
|
||||
}
|
||||
}
|
||||
|
||||
function faultFrom(error: unknown): AgentBrowserFaultShape | null {
|
||||
if (!error || typeof error !== 'object') return null;
|
||||
const record = error as Partial<AgentBrowserFaultShape>;
|
||||
if (typeof record.code !== 'string' || typeof record.message !== 'string') return null;
|
||||
return {
|
||||
code: record.code,
|
||||
message: record.message,
|
||||
retryable: record.retryable === true,
|
||||
...(typeof record.generation === 'number' ? { generation: record.generation } : {}),
|
||||
...(record.outcome === 'unknown' ? { outcome: 'unknown' } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function faultStatus(code: AgentBrowserFaultShape['code']): number {
|
||||
if (code === 'PROJECT_MISMATCH' || code === 'TARGET_DENIED' || code === 'CDP_METHOD_BLOCKED') return 403;
|
||||
if (code === 'BROWSER_NOT_OPEN' || code === 'PAYLOAD_NOT_FOUND' || code === 'TARGET_GONE') return 404;
|
||||
if (code === 'CURSOR_EXPIRED') return 410;
|
||||
if (code === 'DEVTOOLS_CONFLICT' || code === 'DEBUGGER_BUSY' || code === 'PROJECT_NOT_ACTIVE') return 409;
|
||||
if (code === 'CDP_TIMEOUT') return 504;
|
||||
if (code === 'ATTACH_FAILED' || code === 'RENDERER_CRASHED' || code === 'CLOSED') return 503;
|
||||
return 400;
|
||||
}
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<AgentBrowserBody> {
|
||||
return await parseJsonBody<AgentBrowserBody>(req);
|
||||
}
|
||||
|
||||
export async function handleAgentBrowserRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (!url.pathname.startsWith('/api/agent-browser')) return false;
|
||||
|
||||
try {
|
||||
const service = requireService(ctx);
|
||||
|
||||
if (url.pathname === '/api/agent-browser/state' && req.method === 'GET') {
|
||||
const project = await resolveActiveProject(ctx, url.searchParams.get('project_path'));
|
||||
const browser = await service.getSnapshot(project.path);
|
||||
await ensureProjectStillActive(ctx, project);
|
||||
sendJson(res, 200, { success: true, browser });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agent-browser/open' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const project = await resolveActiveProject(ctx, body.project_path);
|
||||
const rendererPresentation = hasRendererCapability(req);
|
||||
if (body.bounds !== undefined && !rendererPresentation) {
|
||||
requireRendererPresentation(req);
|
||||
}
|
||||
const targetUrl = nonEmptyString(body.url);
|
||||
if (!targetUrl) throw new AgentBrowserRouteError('INVALID_URL', '请输入要打开的网页地址。');
|
||||
const browser = await service.open({
|
||||
projectId: project.id,
|
||||
projectPath: project.path,
|
||||
url: targetUrl,
|
||||
bounds: parseBounds(body.bounds),
|
||||
visible: rendererPresentation && body.visible !== false,
|
||||
});
|
||||
await ensureProjectStillActive(ctx, project);
|
||||
emitState(ctx, 'agent-browser:show', browser);
|
||||
sendJson(res, 200, { success: true, browser });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agent-browser/present' && req.method === 'POST') {
|
||||
requireRendererPresentation(req);
|
||||
const body = await readBody(req);
|
||||
const project = await resolveActiveProject(ctx, body.project_path);
|
||||
const browser = await service.present({
|
||||
projectPath: project.path,
|
||||
visible: body.visible === true,
|
||||
bounds: parseBounds(body.bounds),
|
||||
});
|
||||
await ensureProjectStillActive(ctx, project);
|
||||
sendJson(res, 200, { success: true, browser });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agent-browser/navigate' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const project = await resolveActiveProject(ctx, body.project_path);
|
||||
const action = nonEmptyString(body.action);
|
||||
if (action !== 'url' && action !== 'back' && action !== 'forward' && action !== 'reload') {
|
||||
throw new AgentBrowserRouteError('INVALID_REQUEST', '浏览器导航动作无效。');
|
||||
}
|
||||
const browser = await service.navigate({
|
||||
projectPath: project.path,
|
||||
action,
|
||||
url: nonEmptyString(body.url),
|
||||
});
|
||||
await ensureProjectStillActive(ctx, project);
|
||||
emitState(ctx, 'agent-browser:state', browser);
|
||||
sendJson(res, 200, { success: true, browser });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agent-browser/cdp/send' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const project = await resolveActiveProject(ctx, body.project_path);
|
||||
const method = nonEmptyString(body.method);
|
||||
if (!method) throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少 CDP method。');
|
||||
const params = body.params === undefined
|
||||
? undefined
|
||||
: body.params && typeof body.params === 'object' && !Array.isArray(body.params)
|
||||
? body.params as Record<string, unknown>
|
||||
: (() => {
|
||||
throw new AgentBrowserRouteError('INVALID_REQUEST', 'CDP params 必须是对象。');
|
||||
})();
|
||||
const result = await service.sendCdp({
|
||||
projectPath: project.path,
|
||||
method,
|
||||
params,
|
||||
sessionRef: nonEmptyString(body.session_ref),
|
||||
timeoutMs: finiteInteger(body.timeout_ms),
|
||||
});
|
||||
await ensureProjectStillActive(ctx, project);
|
||||
sendJson(res, 200, { success: true, result });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agent-browser/cdp/events' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const project = await resolveActiveProject(ctx, body.project_path);
|
||||
const page = await service.readEvents({
|
||||
projectPath: project.path,
|
||||
after: finiteInteger(body.after),
|
||||
methods: parseStringArray(body.methods),
|
||||
limit: finiteInteger(body.limit),
|
||||
waitMs: finiteInteger(body.wait_ms),
|
||||
});
|
||||
await ensureProjectStillActive(ctx, project);
|
||||
sendJson(res, 200, { success: true, page });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agent-browser/payload/read' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const project = await resolveActiveProject(ctx, body.project_path);
|
||||
const handle = nonEmptyString(body.handle);
|
||||
if (!handle) throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少 payload handle。');
|
||||
const chunk = await service.readPayload({
|
||||
projectPath: project.path,
|
||||
handle,
|
||||
offset: finiteInteger(body.offset),
|
||||
maxBytes: finiteInteger(body.max_bytes),
|
||||
});
|
||||
await ensureProjectStillActive(ctx, project);
|
||||
sendJson(res, 200, { success: true, chunk });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agent-browser/close' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const project = await resolveActiveProject(ctx, body.project_path);
|
||||
const browser = await service.close(project.path);
|
||||
await ensureProjectStillActive(ctx, project);
|
||||
emitState(ctx, 'agent-browser:state', {
|
||||
...browser,
|
||||
projectId: project.id,
|
||||
projectPath: project.path,
|
||||
});
|
||||
sendJson(res, 200, { success: true, browser });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agent-browser/reset-profile' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const project = await resolveActiveProject(ctx, body.project_path);
|
||||
const browser = await service.resetProfile(project.path);
|
||||
await ensureProjectStillActive(ctx, project);
|
||||
emitState(ctx, 'agent-browser:state', {
|
||||
...browser,
|
||||
projectId: project.id,
|
||||
projectPath: project.path,
|
||||
});
|
||||
sendJson(res, 200, { success: true, browser });
|
||||
return true;
|
||||
}
|
||||
|
||||
sendJson(res, 404, {
|
||||
success: false,
|
||||
code: 'INVALID_REQUEST',
|
||||
error: `No Agent Browser route for ${req.method} ${url.pathname}`,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const routeError = error instanceof AgentBrowserRouteError ? error : null;
|
||||
const fault = routeError
|
||||
? {
|
||||
code: routeError.code,
|
||||
message: routeError.message,
|
||||
retryable: false,
|
||||
}
|
||||
: faultFrom(error);
|
||||
if (fault) {
|
||||
sendJson(res, routeError?.status ?? faultStatus(fault.code), {
|
||||
success: false,
|
||||
error: fault.message,
|
||||
...fault,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -585,6 +585,24 @@ async function findProjectById(ctx: HostApiContext, projectId: string) {
|
||||
return projects.find((project) => project.id === projectId) ?? null;
|
||||
}
|
||||
|
||||
async function closeAgentBrowserForProject(
|
||||
ctx: HostApiContext,
|
||||
projectPath: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await ctx.agentBrowser?.close(projectPath);
|
||||
} catch (error) {
|
||||
if (
|
||||
error
|
||||
&& typeof error === 'object'
|
||||
&& (error as { code?: unknown }).code === 'PROJECT_MISMATCH'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
type ProjectActivationCheck =
|
||||
| { ok: true }
|
||||
| { ok: false; status: Exclude<ProjectConfigReadResult['status'], 'valid'> | 'incomplete'; error: string };
|
||||
@@ -1161,7 +1179,14 @@ export async function handleOpencodeRoutes(
|
||||
try {
|
||||
const body = await parseJsonBody<{ projectId?: string }>(req);
|
||||
if (!body.projectId) throw new Error('Missing project id');
|
||||
const activeProject = await ctx.opencodeProjectStore.getActiveProject();
|
||||
if (activeProject?.id === body.projectId) {
|
||||
await closeAgentBrowserForProject(ctx, activeProject.path);
|
||||
}
|
||||
await ctx.opencodeProjectStore.removeProject(body.projectId);
|
||||
if (activeProject?.id === body.projectId) {
|
||||
await closeAgentBrowserForProject(ctx, activeProject.path);
|
||||
}
|
||||
await sendProjectSnapshot(res, ctx, { success: true, removedProjectId: body.projectId });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
@@ -1193,7 +1218,14 @@ export async function handleOpencodeRoutes(
|
||||
sendProjectActivationFailure(res, projectCheck);
|
||||
return true;
|
||||
}
|
||||
const activeProject = await ctx.opencodeProjectStore.getActiveProject();
|
||||
if (activeProject?.id !== body.projectId && activeProject) {
|
||||
await closeAgentBrowserForProject(ctx, activeProject.path);
|
||||
}
|
||||
const project = await ctx.opencodeProjectStore.setActiveProject(body.projectId);
|
||||
if (activeProject?.id !== body.projectId && activeProject) {
|
||||
await closeAgentBrowserForProject(ctx, activeProject.path);
|
||||
}
|
||||
await sendProjectSnapshot(res, ctx, { success: true, project });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
|
||||
@@ -16,7 +16,9 @@ import { handleLogRoutes } from './routes/logs';
|
||||
import { handleUsageRoutes } from './routes/usage';
|
||||
import { handleFileRoutes } from './routes/files';
|
||||
import { handleMeowaGameAssetsRoutes } from './routes/meowa-game-assets';
|
||||
import { handleAgentBrowserRoutes } from './routes/agent-browser';
|
||||
import { sendJson, setCorsHeaders, requireJsonContentType } from './route-utils';
|
||||
import { rotateRendererCapability } from './renderer-capability';
|
||||
|
||||
type RouteHandler = (
|
||||
req: IncomingMessage,
|
||||
@@ -31,6 +33,7 @@ const coreRouteHandlers: RouteHandler[] = [
|
||||
handleAuthRoutes,
|
||||
handleImageWorkspaceRoutes,
|
||||
handleWorksRoutes,
|
||||
handleAgentBrowserRoutes,
|
||||
handleUserSyncRoutes,
|
||||
handleOpencodeRoutes,
|
||||
handleSettingsRoutes,
|
||||
@@ -62,6 +65,7 @@ export function getHostApiToken(): string {
|
||||
export function startHostApiServer(ctx: HostApiContext, port = getPort('NIANCODE_HOST_API')): Server {
|
||||
// Generate a cryptographically random token for this session.
|
||||
hostApiToken = randomBytes(32).toString('hex');
|
||||
rotateRendererCapability();
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../opencode/playwright-mcp';
|
||||
import { resolveOpencodeRuntimePaths } from '../opencode/paths';
|
||||
import {
|
||||
resolveBundledAgentBrowserPluginPath,
|
||||
resolveBundledCourseSkillsDir,
|
||||
resolveBundledSuperpowersDir,
|
||||
} from '../opencode/superpowers';
|
||||
@@ -61,6 +62,7 @@ import { acquireProcessInstanceFileLock } from './process-instance-lock';
|
||||
|
||||
import { getHostApiToken, startHostApiServer } from '../api/server';
|
||||
import { HostEventBus } from '../api/event-bus';
|
||||
import { AgentBrowserModule, ElectronAgentBrowserAdapter } from '../agent-browser';
|
||||
import { browserOAuthManager } from '../utils/browser-oauth';
|
||||
import { createProjectProgressSync } from '../services/project-progress-sync';
|
||||
import { createWorksCloudDeployment } from '../services/works-cloud-deployment';
|
||||
@@ -148,6 +150,7 @@ let hostEventBus!: HostEventBus;
|
||||
let hostApiServer: Server | null = null;
|
||||
let projectProgressSync: ReturnType<typeof createProjectProgressSync> | null = null;
|
||||
let worksCloudDeployment: ReturnType<typeof createWorksCloudDeployment> | null = null;
|
||||
let agentBrowser: AgentBrowserModule | null = null;
|
||||
const mainWindowFocusState = createMainWindowFocusState();
|
||||
const quitLifecycleState = createQuitLifecycleState();
|
||||
const launchDeepLinkUrl = findNianCodeDeepLinkUrl(process.argv);
|
||||
@@ -309,6 +312,23 @@ function registerMakeloreProtocolClient(): void {
|
||||
function createMainWindow(): BrowserWindow {
|
||||
const win = createWindow();
|
||||
|
||||
const closeAgentBrowserForHostRenderer = (reason: string): void => {
|
||||
void agentBrowser?.close().catch((error) => {
|
||||
logger.warn(`Failed to close Agent Browser after ${reason}:`, error);
|
||||
});
|
||||
};
|
||||
win.webContents.on('render-process-gone', () => {
|
||||
closeAgentBrowserForHostRenderer('the main renderer exited');
|
||||
});
|
||||
win.webContents.on(
|
||||
'did-start-navigation',
|
||||
(_event, _url, isInPlace, isMainFrame) => {
|
||||
if (isMainFrame && !isInPlace) {
|
||||
closeAgentBrowserForHostRenderer('the main renderer started navigation');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
if (mainWindow !== win) {
|
||||
return;
|
||||
@@ -331,6 +351,11 @@ function createMainWindow(): BrowserWindow {
|
||||
});
|
||||
|
||||
win.on('closed', () => {
|
||||
const browser = agentBrowser;
|
||||
agentBrowser = null;
|
||||
void browser?.dispose().catch((error) => {
|
||||
logger.warn('Failed to dispose Agent Browser after the main window closed:', error);
|
||||
});
|
||||
if (mainWindow === win) {
|
||||
mainWindow = null;
|
||||
}
|
||||
@@ -403,6 +428,7 @@ async function initialize(): Promise<void> {
|
||||
|
||||
// Create the main window
|
||||
const window = createMainWindow();
|
||||
agentBrowser = new AgentBrowserModule(new ElectronAgentBrowserAdapter(window));
|
||||
|
||||
// Create system tray
|
||||
if (!isE2EMode) {
|
||||
@@ -417,6 +443,7 @@ async function initialize(): Promise<void> {
|
||||
opencodeProjectStore,
|
||||
eventBus: hostEventBus,
|
||||
mainWindow: window,
|
||||
agentBrowser,
|
||||
worksCloudDeployment: worksCloudDeployment ?? undefined,
|
||||
imageWorkspace,
|
||||
});
|
||||
@@ -505,12 +532,18 @@ if (gotTheLock) {
|
||||
resourcesPath: process.resourcesPath,
|
||||
appPath: app.getAppPath(),
|
||||
});
|
||||
const bundledAgentBrowserPluginPath = resolveBundledAgentBrowserPluginPath({
|
||||
isPackaged: app.isPackaged,
|
||||
resourcesPath: process.resourcesPath,
|
||||
appPath: app.getAppPath(),
|
||||
});
|
||||
opencodeManager = new OpencodeManager({
|
||||
port: 4096,
|
||||
binPath: opencodePaths.binPath,
|
||||
userDataDir: app.getPath('userData'),
|
||||
bundledSuperpowersDir,
|
||||
bundledCourseSkillsDir,
|
||||
bundledAgentBrowserPluginPath,
|
||||
pythonRuntime,
|
||||
runtimeConfigProvider: async () => {
|
||||
const runtime = await buildOpencodeRuntimeConfigFromNianCodeProviders({
|
||||
@@ -608,7 +641,13 @@ if (gotTheLock) {
|
||||
const stopOpencodePromise = opencodeManager.stop().catch((err) => {
|
||||
logger.warn('opencodeManager.stop() error during quit:', err);
|
||||
});
|
||||
const stopPromise = Promise.allSettled([stopOpencodePromise]);
|
||||
const stopAgentBrowserPromise = agentBrowser?.dispose().catch((err) => {
|
||||
logger.warn('agentBrowser.dispose() error during quit:', err);
|
||||
}) ?? Promise.resolve();
|
||||
const stopPromise = Promise.allSettled([
|
||||
stopOpencodePromise,
|
||||
stopAgentBrowserPromise,
|
||||
]);
|
||||
const timeoutPromise = new Promise<'timeout'>((resolve) => {
|
||||
setTimeout(() => resolve('timeout'), 5000);
|
||||
});
|
||||
@@ -629,6 +668,11 @@ if (gotTheLock) {
|
||||
logger.error(`${reason}:`, error);
|
||||
projectProgressSync?.stop();
|
||||
worksCloudDeployment?.stop();
|
||||
try {
|
||||
void agentBrowser?.dispose().catch(() => { /* ignore */ });
|
||||
} catch {
|
||||
// ignore — dispose() may not be callable if state is corrupted
|
||||
}
|
||||
try {
|
||||
void opencodeManager?.stop().catch(() => { /* ignore */ });
|
||||
} catch {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { getPort } from '../../utils/config';
|
||||
import { getHostApiToken } from '../../api/server';
|
||||
import {
|
||||
getRendererCapability,
|
||||
RENDERER_CAPABILITY_HEADER,
|
||||
} from '../../api/renderer-capability';
|
||||
|
||||
type HostApiFetchRequest = {
|
||||
path: string;
|
||||
@@ -28,6 +32,7 @@ export function registerHostApiProxyHandlers(): void {
|
||||
const headers: Record<string, string> = { ...(request.headers || {}) };
|
||||
// Inject the per-session auth token so the Host API server accepts this request.
|
||||
headers['Authorization'] = `Bearer ${getHostApiToken()}`;
|
||||
headers[RENDERER_CAPABILITY_HEADER] = getRendererCapability();
|
||||
let body: string | undefined;
|
||||
|
||||
if (request.body !== undefined && request.body !== null) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type SpawnOptionsWithoutStdio,
|
||||
} from 'node:child_process';
|
||||
import {
|
||||
ensureBundledAgentBrowserPlugin,
|
||||
ensureBundledCourseSkills,
|
||||
ensureBundledSuperpowersPlugin,
|
||||
getManagedOpencodeConfigDir,
|
||||
@@ -163,6 +164,7 @@ export interface OpencodeManagerOptions {
|
||||
userDataDir?: string;
|
||||
bundledSuperpowersDir?: string;
|
||||
bundledCourseSkillsDir?: string;
|
||||
bundledAgentBrowserPluginPath?: string;
|
||||
pythonRuntime?: PythonRuntime;
|
||||
spawn?: SpawnFn;
|
||||
configProvider?: ConfigProvider;
|
||||
@@ -580,6 +582,10 @@ export class OpencodeManager extends EventEmitter {
|
||||
managedConfigDir,
|
||||
sourceDir: this.options.bundledCourseSkillsDir,
|
||||
});
|
||||
ensureBundledAgentBrowserPlugin({
|
||||
managedConfigDir,
|
||||
sourcePath: this.options.bundledAgentBrowserPluginPath,
|
||||
});
|
||||
const environment = {
|
||||
...runtimeEnv,
|
||||
OPENCODE_CONFIG_DIR: managedConfigDir,
|
||||
|
||||
@@ -34,6 +34,7 @@ const SUPERPOWERS_REPO_DIR = 'superpowers';
|
||||
const SUPERPOWERS_BUNDLES_DIR = 'superpowers-bundles';
|
||||
const SUPERPOWERS_ACTIVE_MANIFEST = 'superpowers-active.json';
|
||||
export const BUNDLED_COURSE_SKILL_IDS = [
|
||||
'agent-browser',
|
||||
'deploy-publish-check',
|
||||
'designer-design-spec',
|
||||
'dev-build-test',
|
||||
@@ -63,6 +64,16 @@ export function resolveBundledCourseSkillsDir(input: BundledSuperpowersPathInput
|
||||
: join(input.appPath, '.opencode', 'skills');
|
||||
}
|
||||
|
||||
export function resolveBundledAgentBrowserPluginPath(input: BundledSuperpowersPathInput): string {
|
||||
return join(
|
||||
resolveBundledCourseSkillsDir(input),
|
||||
'agent-browser',
|
||||
'.opencode',
|
||||
'plugins',
|
||||
'niancode-agent-browser.js',
|
||||
);
|
||||
}
|
||||
|
||||
export function getManagedOpencodeConfigDir(userDataDir: string): string {
|
||||
return join(userDataDir, 'opencode', 'niancode-config');
|
||||
}
|
||||
@@ -266,3 +277,15 @@ export function ensureBundledCourseSkills(options: EnsureBundledCourseSkillsOpti
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ensureBundledAgentBrowserPlugin(options: {
|
||||
managedConfigDir: string;
|
||||
sourcePath?: string;
|
||||
}): boolean {
|
||||
const sourcePath = options.sourcePath?.trim();
|
||||
if (!sourcePath || !existsSync(sourcePath)) return false;
|
||||
const targetPluginsDir = join(options.managedConfigDir, 'plugins');
|
||||
mkdirSync(targetPluginsDir, { recursive: true });
|
||||
cpSync(sourcePath, join(targetPluginsDir, 'niancode-agent-browser.js'), { force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ const validEventChannels = [
|
||||
'oauth:code',
|
||||
'oauth:success',
|
||||
'oauth:error',
|
||||
'agent-browser:show',
|
||||
'agent-browser:state',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user