fix(review): bound E2B readBytes at the seam, prove conditional-registration disposal, and align read_image contracts

- E2BFileSystem.readBytes now short-circuits on the stat size before any
  content transfer and streams the remote object, cancelling at the first
  chunk past the cap, honoring the seam's bounded-buffering contract; the
  fs-e2b README pair documents the new primitive.
- The tool-fs HMR test now proves the attachments-scoped registration:
  disposing the store withdraws read_image while read/write/edit stay,
  remounting restores it, and disposing the plugin withdraws everything.
- read_image caps reads at the smaller of maxImageBytes and
  maxMessageImageBytes, records an absent observation for a missing
  target like its sibling read, widens the mismatch remedy to cover
  out-of-family formats, and renames the gate's parameter to
  requestedPath; module/apply/registration JSDoc now match the shipped
  composition. zh terminology aligned; the examples manifest keeps its
  literal arrow.
This commit is contained in:
creatixchu
2026-08-10 15:35:33 +08:00
parent 1861a3fc7c
commit 97a9ec5a0e
12 changed files with 141 additions and 39 deletions
+50 -10
View File
@@ -229,20 +229,59 @@ export class E2BFileSystem extends FileSystem {
override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
const sandbox = await this.ctx.e2b.getSandbox()
await this.requireRegular(target, signal)
let bytes: Uint8Array
const info = await this.requireRegular(target, signal)
if (info.size !== undefined && info.size > maxBytes) {
throw new FsError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
}
let stream: ReadableStream<Uint8Array>
try {
// The E2B files API returns the whole object; the remote sandbox owns
// that buffering, so the seam bound is enforced on the complete result.
bytes = await sandbox.files.read(String(target.targetKey), { format: 'bytes', ...signalOpts(signal) })
// Same pinned-SDK quirk as streamText: content-length 0 returns ''
// instead of a ReadableStream.
const read = await sandbox.files.read(String(target.targetKey), { format: 'stream', ...signalOpts(signal) }) as
ReadableStream<Uint8Array> | string
stream = typeof read === 'string'
? new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
: read
} catch (error: unknown) {
throw mapError(error, 'read', target.displayPath, signal)
}
assertNotAborted(signal, 'read')
if (bytes.byteLength > maxBytes) {
throw new FsError(`cannot read "${target.displayPath}": ${bytes.byteLength} bytes exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
const reader = stream.getReader()
const chunks: Uint8Array[] = []
let bytes = 0
let completed = false
try {
while (true) {
assertNotAborted(signal, 'read')
const next = await reader.read()
if (next.done) break
// The stat preflight covers the at-rest case; this streamed bound stops
// a post-stat grower without transferring past the first overflowing chunk.
bytes += next.value.byteLength
if (bytes > maxBytes) {
throw new FsError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
}
chunks.push(next.value)
}
completed = true
} catch (error: unknown) {
throw mapError(error, 'read', target.displayPath, signal)
} finally {
if (!completed) {
try {
await reader.cancel()
} catch (_streamCancellationFailure) {
// The read already failed; a cancellation failure on the abandoned
// remote stream adds nothing actionable for the caller.
}
}
}
return bytes
const whole = new Uint8Array(bytes)
let offset = 0
for (const chunk of chunks) {
whole.set(chunk, offset)
offset += chunk.byteLength
}
return whole
}
override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
@@ -430,10 +469,11 @@ export class E2BFileSystem extends FileSystem {
}
}
private async requireRegular(target: FsTarget, signal?: AbortSignal): Promise<void> {
private async requireRegular(target: FsTarget, signal?: AbortSignal): Promise<FsInfo> {
const info = await this.stat(target, signal)
if (info === undefined) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
return info
}
private checkWriteIntent(existing: EntryInfo | undefined, expected: FsWriteIntent | undefined, target: FsTarget): void {