diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml index 5b2a534550..3538f2ec40 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md -2026-09-15-bounded-office-conversion.md: 6a86fc8d24185d8f4d327e0407f21d55dc860a53 -2026-09-15-bounded-office-conversion.zh.md: d267c9ccb2b388c87c3823a9c998074ff9f01df8 +2026-09-15-bounded-office-conversion.md: af24cee4bc73c40135a15f70cff9201e59280da7 +2026-09-15-bounded-office-conversion.zh.md: 29411e117653755e2fd7ddce8e2fe516ffdf226f diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md index 6a86fc8d24..af24cee4bc 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md @@ -30,8 +30,10 @@ Foreground preview and explicit QA requests precede background work. Disabling b **Separate service-definition and provider packages for the sole LibreOffice implementation.** They evolve together and have no independent alternative implementation. One `office-to-pdf` package supplies the mountable service without duplicated package, dependency, and release configuration. Native and WASM engine selection remains inside the kit; a second independent implementation can justify extracting an interface from actual consumer needs. +Host workspace-file reads return raw bytes within the reserved capacity and delegate complete bounded reading to `fs.readBytes`. Base64 encoding belongs to Remote responses, so Host conversion does not allocate an encoded source string or a decoded copy. + ## Consequences -The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish. +The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; Remote base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish. Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and converter replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers. diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md index d267c9ccb2..29411e1176 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md @@ -30,8 +30,10 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成 **为唯一的 LibreOffice 实现拆分服务定义和提供方包。** 两者共同演化,没有独立的替代实现。单个 `office-to-pdf` 包直接提供可挂载服务,避免重复维护包、依赖和发布配置。原生与 WASM 引擎选择仍由 kit 负责;若出现独立的第二种实现,再根据实际消费者提取接口。 +Host 工作区文件读取在预留容量内返回原始字节,并将完整有界读取交给 `fs.readBytes`。base64 编码由 Remote 响应负责,因此 Host 转换无需分配编码后的源字符串或解码副本。 + ## 后果 -缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。 +缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;Remote base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。 受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及转换器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。 diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index d933a9055e..f69e27abee 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workspace.md -workspace.md: 50db26be228bf39880891c3c4ca7d47c097b9d3e -workspace.zh.md: fd4110041fe1b22e580b17835e4a896b96d062c8 +workspace.md: 9633bd74b6997554e0d25824746117b4760fac29 +workspace.zh.md: fd749cec14383ea746efb096437f841f57291962 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 50db26be22..9633bd74b6 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -392,9 +392,9 @@ Host Remote file reads and workspace directory observations over the composed fi * @param path - absolute or workspace-relative file path. * @param maxBytes - positive reserved capacity; the configured full-file cap still applies. * @param signal - caller cancellation. - * @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel. + * @returns complete raw bytes and metadata from before the read; the filesystem enforces the effective limit. */ -async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise +async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise /** * Read a complete file relative to another file's directory, including outside the workspace. diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index fd4110041f..fd749cec14 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -392,9 +392,9 @@ Host Remote file reads and workspace directory observations over the composed fi * @param path - absolute or workspace-relative file path. * @param maxBytes - positive reserved capacity; the configured full-file cap still applies. * @param signal - caller cancellation. - * @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel. + * @returns complete raw bytes and metadata from before the read; the filesystem enforces the effective limit. */ -async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise +async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise /** * Read a complete file relative to another file's directory, including outside the workspace. diff --git a/packages/api/workspace-files/README.i18n.yaml b/packages/api/workspace-files/README.i18n.yaml index 7c954c743a..1d33d436a8 100644 --- a/packages/api/workspace-files/README.i18n.yaml +++ b/packages/api/workspace-files/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/workspace-files/README.md -README.md: 9da773d65220e355c5dc41e372bc2bb0ca868d91 -README.zh.md: 56276e5da266b3ba81bfa83358df94d19e103ee4 +README.md: a3ccbc24ca37b5b933c34390773d10a223e99d36 +README.zh.md: a7e59c07d5d99216a2e8f6659ed3dc3201846a62 diff --git a/packages/api/workspace-files/README.md b/packages/api/workspace-files/README.md index 9da773d652..a3ccbc24ca 100644 --- a/packages/api/workspace-files/README.md +++ b/packages/api/workspace-files/README.md @@ -82,7 +82,7 @@ The provider waits for the Host's `ready` frame before its first `stat`, queues One supervised `changes` stream serves every followed file in a Session. Followers match absolute paths with backslashes normalized to slashes. Carrier loss reconnects through the Gateway supervisor; a Host-ended or terminally failed feed ends its followers and leaves their last metadata readable until reopened. The last follower leaving disposes the stream, a successor waits for that disposal, and plugin teardown awaits all pending closes. The provider declares `ResourceProtocolMap.file`; the text preview declares its Sidebar line-navigation parameters. -Host consumers can call `readAllBounded(scope, path, maxBytes, signal)` after reserving input capacity. The smaller of that reservation and `maxFileBytes` applies; the read allocates at most one additional overflow sentinel byte. +Host consumers can call `readAllBounded(scope, path, maxBytes, signal)` after reserving input capacity. It returns raw `Uint8Array` data with file metadata. The filesystem enforces the smaller of that reservation and `maxFileBytes`; `readAll()` encodes the result as base64 for Remote callers. ----- diff --git a/packages/api/workspace-files/README.zh.md b/packages/api/workspace-files/README.zh.md index 56276e5da2..a7e59c07d5 100644 --- a/packages/api/workspace-files/README.zh.md +++ b/packages/api/workspace-files/README.zh.md @@ -82,7 +82,7 @@ kind: "package-reference" 每个 Session 的所有被跟随文件共用一条受监督的 `changes` 流。跟随者按反斜杠归一为斜杠的绝对路径匹配。载体掉线由 Gateway 监督器重连;Host 结束或终态失败的流会结束其跟随者,最后的元数据仍可读取,直到重新打开。最后一个跟随者离开时释放流,后继流等待该释放完成,插件拆除等待所有在途关闭。提供者声明 `ResourceProtocolMap.file`;文本预览声明其 Sidebar 行号导航参数。 -Host 消费者可在预留输入容量后调用 `readAllBounded(scope, path, maxBytes, signal)`。采用预留值与 `maxFileBytes` 的较小值;读取最多额外分配一个超限哨兵字节。 +Host 消费者可在预留输入容量后调用 `readAllBounded(scope, path, maxBytes, signal)`。它返回原始 `Uint8Array` 数据及文件元数据。文件系统执行预留值与 `maxFileBytes` 中较小的上限;`readAll()` 为 Remote 调用方将结果编码为 base64。 ----- diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts index e099056cd4..a22b16ae54 100644 --- a/packages/api/workspace-files/src/index.ts +++ b/packages/api/workspace-files/src/index.ts @@ -276,7 +276,8 @@ export class WorkspaceFiles extends TypertRemoteService { */ @Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise { - return this.readAllBounded(workspaceFileScope, path, this.config.maxFileBytes, signal) + const { data, ...stat } = await this.readAllBounded(workspaceFileScope, path, this.config.maxFileBytes, signal) + return { ...stat, offset: 0, data: Buffer.from(data).toString('base64'), eof: true } } /** @@ -285,21 +286,20 @@ export class WorkspaceFiles extends TypertRemoteService { * @param path - absolute or workspace-relative file path. * @param maxBytes - positive reserved capacity; the configured full-file cap still applies. * @param signal - caller cancellation. - * @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel. + * @returns complete raw bytes and metadata from before the read; the filesystem enforces the effective limit. */ async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, - ): Promise { + ): Promise { const { target, info } = await this.locateFile(workspaceFileScope, path, signal) const limit = Math.min(this.config.maxFileBytes, maxBytes) - if (info.size !== undefined && info.size > limit) { - throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit }) - } - const data = await this.ctx.fs.readByteRange(target, { offset: 0, length: limit + 1 }, signal) - if (data.length > limit) { - throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit }) - } - return { ...this.statOf(target, info), offset: 0, data: Buffer.from(data).toString('base64'), eof: true } + const data = await this.ctx.fs.readBytes(target, signal, limit).catch((cause: unknown) => { + if (typeof cause === 'object' && cause !== null && 'code' in cause && cause.code === 'FS_TOO_LARGE') { + throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit }, { cause }) + } + throw cause + }) + return { ...this.statOf(target, info), data } } /** diff --git a/packages/api/workspace-files/tests/read-all.spec.ts b/packages/api/workspace-files/tests/read-all.spec.ts index 8bf2470241..f610936d78 100644 --- a/packages/api/workspace-files/tests/read-all.spec.ts +++ b/packages/api/workspace-files/tests/read-all.spec.ts @@ -24,13 +24,11 @@ describe('workspaceFiles.readAll', () => { expect(await harness.endpoint().readAll(harness.scope, 'empty', signal())).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 }) }) - it.each(['workspace', 'outside'] as const)('rejects a known oversized %s file before reading bytes', async (location) => { + it.each(['workspace', 'outside'] as const)('maps the filesystem size refusal for a %s file', async (location) => { const path = join(harness[location], 'large') await writeFile(path, 'abcde') - const read = vi.spyOn(harness.ctx.fs, 'readByteRange') expect(await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, path, signal()))) .toEqual({ code: 'workspace-file/too-large', details: { path, limit: 4 } }) - expect(read).not.toHaveBeenCalled() }) it.each([undefined, 1])('checks the actual bytes when stat reports %s', async (size) => { @@ -51,15 +49,41 @@ describe('workspaceFiles.readAll', () => { }) }) -it('applies the smaller Host reservation before reading a complete file', async () => { +it.each([[8, 3], [3, 8]])('applies the smaller cap from deployment %s and Host reservation %s', async (maxFileBytes, reservation) => { await writeFile(join(harness.workspace, 'reserved'), '1234') - const read = vi.spyOn(harness.ctx.fs, 'readByteRange') - const files = harness.endpoint({ maxFileBytes: 8 }) - expect(await failureOf(files.readAllBounded(harness.scope, 'reserved', 3, signal()))) + const read = vi.spyOn(harness.ctx.fs, 'readBytes') + const files = harness.endpoint({ maxFileBytes }) + const caller = signal() + expect(await failureOf(files.readAllBounded(harness.scope, 'reserved', reservation, caller))) .toEqual({ code: 'workspace-file/too-large', details: { path: 'reserved', limit: 3 } }) - expect(read).not.toHaveBeenCalled() - expect((await files.readAllBounded(harness.scope, 'reserved', 4, signal())).bytes).toBe(4) - expect(read.mock.calls[0]![1]).toEqual({ offset: 0, length: 5 }) + expect(read).toHaveBeenCalledExactlyOnceWith(expect.anything(), caller, 3) +}) + +it('returns the filesystem byte array directly to Host consumers at the exact cap', async () => { + await writeFile(join(harness.workspace, 'reserved'), '1234') + const read = vi.spyOn(harness.ctx.fs, 'readBytes') + const result = await harness.endpoint({ maxFileBytes: 8 }).readAllBounded(harness.scope, 'reserved', 4, signal()) + expect(result.data).toBe(await read.mock.results[0]!.value) + expect(result.data).toEqual(Buffer.from('1234')) + expect(result.bytes).toBe(4) + expect(result).not.toHaveProperty('offset') + expect(result).not.toHaveProperty('eof') +}) + +it.each([new Error('Read denied'), new DOMException('Cancelled', 'AbortError'), null, 'backend failure', { code: 'FS_NOT_FOUND' }])( + 'preserves non-size filesystem failures: %s', async (failure) => { + await writeFile(join(harness.workspace, 'file'), '1234') + vi.spyOn(harness.ctx.fs, 'readBytes').mockRejectedValueOnce(failure) + await expect(harness.endpoint().readAllBounded(harness.scope, 'file', 4, signal())).rejects.toBe(failure) + }, +) + +it('maps a size refusal by code without requiring a shared error class', async () => { + await writeFile(join(harness.workspace, 'file'), '1234') + const failure = { code: 'FS_TOO_LARGE' } + vi.spyOn(harness.ctx.fs, 'readBytes').mockRejectedValueOnce(failure) + await expect(harness.endpoint().readAllBounded(harness.scope, 'file', 4, signal())) + .rejects.toMatchObject({ code: 'workspace-file/too-large', details: { path: 'file', limit: 4 }, cause: failure }) }) describe('workspaceFiles.readRelated', () => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index d0bfd9637b..dea56d0e15 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3241,10 +3241,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'one complete base64 window with offset zero and eof true; oversized files fail with too-large.', }, { - signature: 'async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise', + signature: 'async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise', description: 'Read a complete authorized file within a Host consumer\'s reserved byte capacity.', parameters: [{ name: 'workspaceFileScope', description: 'Session authorization and execution scope.' }, { name: 'path', description: 'absolute or workspace-relative file path.' }, { name: 'maxBytes', description: 'positive reserved capacity; the configured full-file cap still applies.' }, { name: 'signal', description: 'caller cancellation.' }], - returns: 'complete base64 bytes; reads at most the effective limit plus one overflow sentinel.', + returns: 'complete raw bytes and metadata from before the read; the filesystem enforces the effective limit.', }, { signature: '@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise',