feat: 增加共享 Agent Browser 调试能力
需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。 实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user