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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user