import { promises as fs } from 'node:fs' import { join } from 'node:path' import { createHash, randomUUID } from 'node:crypto' import { StringDecoder } from 'node:string_decoder' import type { SandboxCallIdentity } from './transactions' import { NativeWorkspaceReader, SandboxReadError } from './filesystem-reader' import { assertActive } from './policy' import { assertPolicyCurrent } from './policy-store' export const READ_BRIDGE_PREFIX = '\x1eYCODE_WORKSPACE_READ_V1 ' const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/ const chunkBytes = 16 * 1024 type Json = Record interface Operation extends Json { version: 1; requestId: string; operation: string; owner: SandboxCallIdentity & { sandboxRunId: string } state: 'pending' | 'running' | 'succeeded' | 'denied' | 'failed' | 'cancelled' createdAt: number; completedAt?: number; result?: Json; error?: { code: string; message: string } } interface ReadSnapshot { bytes: Buffer; identity: string; sha256: string; accessedAt: number } /** The inherited channel grants one Job a read-only workspace scope, not per-PID authority. */ export class WorkspaceReadBroker { private readonly decoder = new StringDecoder('utf8') private buffered = '' private lineStart = true private closed = false private readonly stopSignal = new AbortController() private readonly operations = new Map() private readonly work = new Set>() private readonly snapshots = new Map() private snapshotBytes = 0 failure?: Error constructor(private readonly reader: NativeWorkspaceReader, private readonly directory: string, private readonly owner: Operation['owner'], private readonly output: (bytes: Buffer) => void, private readonly reply: (line: string) => Promise, private readonly stop: () => void) {} private fail(message: string): void { if (this.failure || this.closed) return this.failure = new Error(`沙盒文件桥协议:${message}`) this.stopSignal.abort(); this.stop() } accept(bytes: Buffer): void { if (this.closed || this.failure) return this.buffered += this.decoder.write(bytes) while (this.buffered) { if (this.lineStart) { if (READ_BRIDGE_PREFIX.startsWith(this.buffered)) return if (this.buffered.startsWith(READ_BRIDGE_PREFIX)) { const end = this.buffered.indexOf('\n') if (Buffer.byteLength(end < 0 ? this.buffered : this.buffered.slice(0, end)) > 8192) { this.fail('请求帧超过上限'); return } if (end < 0) return const raw = this.buffered.slice(READ_BRIDGE_PREFIX.length, end).replace(/\r$/, '') this.buffered = this.buffered.slice(end + 1) this.request(raw) if (this.failure) return continue } } const end = this.buffered.indexOf('\n') if (end < 0) { this.output(Buffer.from(this.buffered)); this.buffered = ''; this.lineStart = false; return } this.output(Buffer.from(this.buffered.slice(0, end + 1))) this.buffered = this.buffered.slice(end + 1); this.lineStart = true } } private request(raw: string): void { if (this.work.size >= 8 || this.operations.size >= 1024) { this.fail('并发或总请求数量超过上限'); return } let request: Json try { request = JSON.parse(raw) if (!request || Array.isArray(request) || request.version !== 1 || typeof request.requestId !== 'string' || !uuid.test(request.requestId) || this.operations.has(request.requestId) || typeof request.operation !== 'string' || request.operation.length > 64 || !request.args || typeof request.args !== 'object' || Array.isArray(request.args) || Object.keys(request).some(key => !['version', 'requestId', 'operation', 'args'].includes(key))) throw new Error('invalid request') } catch { this.fail('格式、归属字段或 request ID 无效/重放'); return } const operation: Operation = { version: 1, requestId: String(request.requestId), operation: String(request.operation), owner: this.owner, state: 'pending', createdAt: Date.now() } this.operations.set(operation.requestId, operation) const pending = this.execute(operation, request.args as Json).catch(error => this.fail(error instanceof Error ? error.message : String(error))) this.work.add(pending) void pending.finally(() => this.work.delete(pending)) } private async save(operation: Operation): Promise { const temporary = join(this.directory, `${operation.requestId}.tmp`), destination = join(this.directory, `${operation.requestId}.json`) const handle = await fs.open(temporary, 'wx', 0o600) try { await handle.writeFile(JSON.stringify(operation) + '\n'); await handle.sync() } finally { await handle.close() } await fs.rename(temporary, destination) } private async execute(operation: Operation, args: Json): Promise { const signal = AbortSignal.any([this.stopSignal.signal, AbortSignal.timeout(5000)]) let value: Json | undefined try { await this.save(operation) assertActive(signal); await assertPolicyCurrent(this.reader.policy) operation.state = 'running'; await this.save(operation) value = await this.dispatch(operation.operation, args, signal) assertActive(signal); await assertPolicyCurrent(this.reader.policy) if (Buffer.byteLength(JSON.stringify(value)) > 24 * 1024) throw new SandboxReadError('E2BIG', '文件桥结果帧超过上限') operation.state = 'succeeded' // Journals retain metadata only. File contents and read tokens are confined to the live Job. operation.result = Object.fromEntries(Object.entries(value).filter(([key]) => !['data', 'snapshotId', 'entries'].includes(key))) } catch (error) { const code = signal.aborted ? 'ECANCELED' : error instanceof SandboxReadError ? error.code : 'EACCES' operation.state = signal.aborted ? 'cancelled' : code === 'EACCES' ? 'denied' : 'failed' operation.error = { code, message: error instanceof SandboxReadError ? error.message : '文件桥操作已取消或策略不再有效' } } operation.completedAt = Date.now(); await this.save(operation) if (this.closed || this.failure) return await this.reply(JSON.stringify({ version: 1, requestId: operation.requestId, ok: operation.state === 'succeeded', ...(operation.error ? { error: operation.error } : { value }) }) + '\n') } private exact(args: Json, keys: string[]): void { if (Object.keys(args).some(key => !keys.includes(key)) || keys.some(key => !Object.hasOwn(args, key))) throw new SandboxReadError('EACCES', '文件桥参数或归属无效') } private part(snapshotId: string, snapshot: ReadSnapshot, offset: number): Json { snapshot.accessedAt = Date.now() const data = snapshot.bytes.subarray(offset, offset + chunkBytes) return { snapshotId, identity: snapshot.identity, sha256: snapshot.sha256, size: snapshot.bytes.length, offset, data: data.toString('base64'), eof: offset + data.length >= snapshot.bytes.length } } private async dispatch(operation: string, args: Json, signal: AbortSignal): Promise { for (const [id, snapshot] of this.snapshots) if (Date.now() - snapshot.accessedAt > 60_000) { this.snapshotBytes -= snapshot.bytes.length; this.snapshots.delete(id) } switch (operation) { case 'workspace.readFile': { this.exact(args, ['path']) if (this.snapshots.size >= 8) throw new SandboxReadError('EBUSY', '打开的读取快照超过上限') const read = await this.reader.read('read', args.path, signal), bytes = Buffer.from(read.data!, 'base64') if (this.snapshots.size >= 8) throw new SandboxReadError('EBUSY', '打开的读取快照超过上限') if (this.snapshotBytes + bytes.length > Math.min(this.reader.policy.limits.bytes, 16 * 1024 * 1024)) throw new SandboxReadError('E2BIG', '文件桥快照内存超过上限') const snapshot = { bytes, identity: read.identity, sha256: createHash('sha256').update(bytes).digest('hex'), accessedAt: Date.now() }, id = randomUUID() this.snapshots.set(id, snapshot); this.snapshotBytes += bytes.length return { ...this.part(id, snapshot, 0), path: read.path } } case 'workspace.readChunk': { this.exact(args, ['snapshotId', 'offset']) const snapshot = typeof args.snapshotId === 'string' ? this.snapshots.get(args.snapshotId) : undefined if (!snapshot) throw new SandboxReadError('ENOENT', '读取快照不存在、已过期或属于其他运行') if (!Number.isSafeInteger(args.offset) || Number(args.offset) < 0 || Number(args.offset) > snapshot.bytes.length) throw new SandboxReadError('EACCES', '读取位置无效') return this.part(String(args.snapshotId), snapshot, Number(args.offset)) } case 'workspace.closeRead': { this.exact(args, ['snapshotId']) const snapshot = typeof args.snapshotId === 'string' ? this.snapshots.get(args.snapshotId) : undefined if (snapshot) { this.snapshots.delete(String(args.snapshotId)); this.snapshotBytes -= snapshot.bytes.length } return { closed: !!snapshot } } case 'workspace.stat': case 'workspace.readdir': { this.exact(args, ['path']) const { data: _data, protocol: _protocol, rootIdentity: _root, ...value } = await this.reader.read(operation === 'workspace.stat' ? 'stat' : 'list', args.path, signal) return value } case 'operation.query': { this.exact(args, ['requestId']) const target = typeof args.requestId === 'string' ? this.operations.get(args.requestId) : undefined if (!target) throw new SandboxReadError('ENOENT', '此运行没有对应的文件桥操作') return { requestId: target.requestId, operation: target.operation, state: target.state, createdAt: target.createdAt, completedAt: target.completedAt, result: target.result, error: target.error } } default: throw new SandboxReadError('EACCES', '文件桥只开放工作区读取、元数据、目录和结果查询') } } async finish(cancelled: boolean): Promise { if (this.closed) return if (!cancelled && (this.work.size || this.lineStart && this.buffered && (this.buffered.startsWith(READ_BRIDGE_PREFIX) || READ_BRIDGE_PREFIX.startsWith(this.buffered)))) this.fail('进程在操作完成或帧结束前退出') this.closed = true; this.stopSignal.abort() this.buffered += this.decoder.end() if (this.buffered && !READ_BRIDGE_PREFIX.startsWith(this.buffered) && !this.buffered.startsWith(READ_BRIDGE_PREFIX)) this.output(Buffer.from(this.buffered)) await Promise.allSettled(this.work) this.snapshots.clear(); this.snapshotBytes = 0 } }