Files
ycode/src/main/sandbox/operation-broker.ts
T

166 lines
11 KiB
TypeScript

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<string, unknown>
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<string, Operation>()
private readonly work = new Set<Promise<void>>()
private readonly snapshots = new Map<string, ReadSnapshot>()
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<void>, 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<void> {
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<void> {
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<Json> {
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<void> {
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
}
}