diff --git a/docs/architecture/external-clients.md b/docs/architecture/external-clients.md index 3b7d215be..6c5cdf36e 100644 --- a/docs/architecture/external-clients.md +++ b/docs/architecture/external-clients.md @@ -53,6 +53,10 @@ 外部监听在 `/mcp` 以 Streamable HTTP 提供 MCP:每个 POST 携带一条 JSON-RPC 消息并以 JSON 应答;`GET` 返回 405(不提供服务端主动流,推送用 `/v1/events`),`DELETE` 视为结束会话。工具与 CLI 一一对应:`harness_list`、`harness_inspect`、`delegate_start`、`thread_read`、`thread_wait`(最多 60 秒)、`thread_list`、`thread_send`、`thread_cancel`、`thread_watch`、`thread_watches`、`events_poll`。工具结果同时以文本和 `structuredContent` 返回 CLI 的 JSON;委派错误作为 `isError` 结果返回相同的错误代码。实现不依赖 MCP SDK。 +`thread_read` 在已知时返回执行主机上的 `cwd`。MCP 另提供 `thread_file_read(thread, path, offset?, limit?, revision?)`,对应外部 HTTP 的 `POST /v1/thread/file`(使用 `threadId`)。该只读接口仅导出最新最终报告中 Markdown 链接明确引用、且位于该 Thread 实际工作区内的普通文件;解析符号链接后仍须位于工作区。没有最终结果或工作区时拒绝读取,不扫描目录,也不导出报告中的远程 URL。 + +文件结果含 `filename`、`mimeType`、`size`、`encoding: "base64"`、`data`、`offset`、`nextOffset`、`eof` 与 `revision`。默认每块 256 KiB,上限 1 MiB;续读必须携带首块的 `revision`,文件变化时重新开始。客户端应将工具结果直接保存为文件,在自己的电脑上解码,再以其原生附件工具交付成品,不在聊天中打印 Base64 或主机路径。源文件按用户要求读取。文件传输沿用客户端认证与网络策略,不创建公开下载链接。 + ## 接入示例 - Claude Code:`claude mcp add --transport http codexhost http://127.0.0.1:4398/mcp --header "Authorization: Bearer "`。 diff --git a/openspec/changes/add-external-client-access/specs/external-client-access/spec.md b/openspec/changes/add-external-client-access/specs/external-client-access/spec.md index f15f138a4..6d8fee642 100644 --- a/openspec/changes/add-external-client-access/specs/external-client-access/spec.md +++ b/openspec/changes/add-external-client-access/specs/external-client-access/spec.md @@ -117,3 +117,15 @@ Host Runtime SHALL 在 `127.0.0.1` 的固定端口上提供外部客户端监听 #### Scenario: MCP 读取事件 - **WHEN** MCP 客户端以游标调用事件工具 - **THEN** 结果 SHALL 与 long-poll 的结果一致 + +### Requirement: MCP 客户端可以读取任务最终报告中的文件 +系统 SHALL 提供按 Thread 与文件路径读取二进制成品的只读接口,并在已知时返回 Thread 的执行工作目录。文件 MUST 是最新最终报告通过 Markdown 链接引用的普通文件,且实际路径 MUST 位于该 Thread 工作区内。无最终结果、工作目录未知、未引用或越界的路径 SHALL 被拒绝。传输 SHALL 使用现有认证连接,MUST NOT 创建公开下载链接。 + +#### Scenario: 读取与续读成品 +- **WHEN** 客户端读取被最终报告引用的工作区内文件 +- **THEN** 系统 SHALL 返回 Base64 字节块、文件名、媒体类型、总字节数、下一偏移和文件版本 +- **AND** 每块 MUST NOT 超过 1 MiB;续读 SHALL 校验首块版本,文件变化时拒绝拼接 + +#### Scenario: 拒绝符号链接逃逸 +- **WHEN** 报告引用的工作区内路径经符号链接指向工作区外 +- **THEN** 系统 SHALL 拒绝导出文件 diff --git a/openspec/changes/add-external-client-access/tasks.md b/openspec/changes/add-external-client-access/tasks.md index 1d3092b6b..9529197b9 100644 --- a/openspec/changes/add-external-client-access/tasks.md +++ b/openspec/changes/add-external-client-access/tasks.md @@ -24,3 +24,4 @@ - [ ] 4.1 `/mcp` 工具集,参数与结果复用 CLI 结构。验证:官方 MCP SDK 客户端完成列举、派发、等待上限 60 秒、事件读取与错误代码(已完成);真实 Claude Desktop 或 Claude Code 作为 MCP 客户端完成一次派发、等待与读取(待在 Mac 上验收)。 - [x] 4.2 更新 `codexhost delegate --help`(`client`、`events`、`--ref`、`--client`)与 `docs/architecture/external-clients.md`、`thread-watch.md`、`docs/index.md`。验证:帮助与 CLI 测试。 +- [ ] 4.3 MCP 读取任务最终结果引用的文件。验证:二进制逐字节往返、分块与文件变更检测;拒绝未引用文件、目录、工作区外路径和符号链接逃逸;缺少工作目录或最终结果时不读取;以真实 Bot 私聊验证成品附件及来源 Bot 回传。 diff --git a/packages/host-runtime/src/delegation-snapshot.ts b/packages/host-runtime/src/delegation-snapshot.ts index 6473e2b68..774f292ed 100644 --- a/packages/host-runtime/src/delegation-snapshot.ts +++ b/packages/host-runtime/src/delegation-snapshot.ts @@ -201,6 +201,7 @@ export function projectDelegationThreadSnapshot(input: { return { threadId: input.threadId, harnessId: input.harnessId, + ...(typeof input.thread.cwd === "string" && input.thread.cwd ? { cwd: input.thread.cwd } : {}), ...(title ? { title } : {}), status, turn: diff --git a/packages/host-runtime/src/delegation-types.ts b/packages/host-runtime/src/delegation-types.ts index 7da49e677..8147aa612 100644 --- a/packages/host-runtime/src/delegation-types.ts +++ b/packages/host-runtime/src/delegation-types.ts @@ -48,6 +48,7 @@ export interface DelegationThreadSnapshot { hostId?: string; threadId: string; harnessId: RoutedHarnessId; + cwd?: string; title?: string; status: DelegationThreadStatus; turn: { turnId: string; status: DelegationThreadStatus } | null; @@ -281,6 +282,10 @@ export type DelegationControlErrorCode = | "RESPONSE_TOO_LARGE" | "RUNTIME_UNREACHABLE" | "DELEGATION_FAILED" + /** A result file is outside the Thread's exportable files. */ + | "FORBIDDEN" + /** The operation requires a known local execution workspace. */ + | "UNSUPPORTED" /** The external client token is missing, unknown, or revoked. */ | "UNAUTHORIZED" /** The event cursor is older than the oldest retained event. */ diff --git a/packages/host-runtime/src/external-client-mcp.ts b/packages/host-runtime/src/external-client-mcp.ts index a0a9ec8bc..a22eec23c 100644 --- a/packages/host-runtime/src/external-client-mcp.ts +++ b/packages/host-runtime/src/external-client-mcp.ts @@ -147,6 +147,32 @@ const TOOLS: Tool[] = [ }, }), }, + { + name: "thread_file_read", + description: + "Read a binary result file linked in a Thread's latest final report, inside that Thread's workspace. Returns base64 chunks, never a public URL. Save the tool response to a file (write_to_file when supported), decode data on your computer, then attach the real file to the user's original conversation. Continue with nextOffset and the same revision until eof=true; do not paste base64 into chat. Fetch final deliverables only unless the user asks for sources.", + inputSchema: { + type: "object", + properties: { + thread, + path: string( + "File path linked in the final report; absolute or relative to the Thread workspace", + ), + offset: integer("Byte offset; default 0"), + limit: integer("Bytes per chunk; default 262144, at most 1048576"), + revision: string("Revision from the first chunk; required when offset is greater than 0"), + }, + required: ["thread", "path"], + }, + call: (args) => ({ + route: "/v1/thread/file", + body: { + threadId: threadId(args), + path: text(args, "path", true), + ...pick(args, ["offset", "limit", "revision"]), + }, + }), + }, { name: "thread_wait", description: diff --git a/packages/host-runtime/src/external-client-routes.ts b/packages/host-runtime/src/external-client-routes.ts index ad1c00ca5..828ecccc1 100644 --- a/packages/host-runtime/src/external-client-routes.ts +++ b/packages/host-runtime/src/external-client-routes.ts @@ -12,6 +12,7 @@ import type { ExternalClientStreamHandler, } from "./external-client-server.js"; import type { ExternalClientRegistry } from "./external-clients.js"; +import { readThreadResultFile } from "./thread-result-file.js"; const MAX_POLL_MS = 60_000; const MAX_EVENTS_PER_READ = 100; @@ -86,6 +87,7 @@ export function externalClientRouter(handlers: { throw new DelegationControlError("INVALID_ARGUMENT", "This Runtime keeps no client events"); return pollEvents(handlers.events, call.client, body); } + if (route === "/v1/thread/file") return readThreadResultFile(handlers.api, body); return routeDelegationRequest(handlers, route, forClient(call.client, route, body)); }; } diff --git a/packages/host-runtime/src/thread-result-file.ts b/packages/host-runtime/src/thread-result-file.ts new file mode 100644 index 000000000..e2a746db6 --- /dev/null +++ b/packages/host-runtime/src/thread-result-file.ts @@ -0,0 +1,211 @@ +import { createHash } from "node:crypto"; +import { constants, type Stats } from "node:fs"; +import { open, realpath, stat } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { DelegationControlError, type DelegationControlApi } from "./delegation-types.js"; + +export const DEFAULT_RESULT_FILE_CHUNK_BYTES = 256 * 1024; +export const MAX_RESULT_FILE_CHUNK_BYTES = 1024 * 1024; + +const MIME_TYPES: Readonly> = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".pdf": "application/pdf", + ".txt": "text/plain", + ".md": "text/markdown", + ".csv": "text/csv", + ".json": "application/json", + ".html": "text/html", + ".zip": "application/zip", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".mp4": "video/mp4", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", +}; + +function within(directory: string, file: string): boolean { + const relative = path.relative(directory, file); + return ( + relative !== "" && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +/** Only explicit file links in the latest final report are exportable. */ +function linkedFiles(text: string, cwd: string): Set { + const files = new Set(); + for (const match of text.matchAll(/\[[^\]\r\n]*\]\((?:<([^>\r\n]+)>|([^\s)\r\n]+))\)/gu)) { + const target = match[1] ?? match[2]; + if (!target) continue; + try { + let file: string; + if (target.startsWith("file:")) { + const url = new URL(target); + if (url.username || url.password || url.search || url.hash) continue; + file = fileURLToPath(url); + } else { + if (!path.isAbsolute(target) && /^[a-z][a-z0-9+.-]*:/iu.test(target)) continue; + file = decodeURIComponent(target); + } + const resolved = path.resolve(cwd, file); + if (within(cwd, resolved)) files.add(resolved); + } catch { + // Remote URLs and malformed file links do not authorize a filesystem read. + } + } + return files; +} + +function integer(value: unknown, name: string, fallback: number, maximum?: number): number { + const resolved = value ?? fallback; + if ( + !Number.isSafeInteger(resolved) || + (resolved as number) < 0 || + (maximum !== undefined && (resolved as number) > maximum) + ) { + throw new DelegationControlError("INVALID_ARGUMENT", `${name} is out of range`); + } + return resolved as number; +} + +function revision(stat: Stats): string { + return createHash("sha256") + .update(JSON.stringify([stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs])) + .digest("hex"); +} + +/** Reads on this Host, within the Thread's real workspace; no directory or arbitrary-path access. */ +export async function readThreadResultFile( + api: DelegationControlApi, + input: Record, +) { + if ( + typeof input.threadId !== "string" || + !input.threadId.trim() || + typeof input.path !== "string" || + !input.path.trim() + ) { + throw new DelegationControlError("INVALID_ARGUMENT", "threadId and path are required"); + } + const offset = integer(input.offset, "offset", 0); + const limit = integer( + input.limit, + "limit", + DEFAULT_RESULT_FILE_CHUNK_BYTES, + MAX_RESULT_FILE_CHUNK_BYTES, + ); + if (limit === 0) throw new DelegationControlError("INVALID_ARGUMENT", "limit must be positive"); + if ( + (input.revision !== undefined && typeof input.revision !== "string") || + (offset > 0 && !input.revision) + ) { + throw new DelegationControlError("INVALID_ARGUMENT", "Continuing a file requires its revision"); + } + const snapshot = await api.read({ threadId: input.threadId, view: "result" }); + if (snapshot.hostId && snapshot.hostId !== "local") { + throw new DelegationControlError( + "UNSUPPORTED", + "Read result files through their execution Host", + ); + } + if ( + !["completed", "failed", "interrupted"].includes(snapshot.status) || + snapshot.result.availability !== "available" || + !snapshot.result.text + ) { + throw new DelegationControlError("INVALID_ARGUMENT", "Thread has no final result to export"); + } + if (!snapshot.cwd || !path.isAbsolute(snapshot.cwd)) { + throw new DelegationControlError("UNSUPPORTED", "Thread workspace is unavailable"); + } + const cwd = path.resolve(snapshot.cwd); + const file = path.resolve(cwd, input.path); + if (!linkedFiles(snapshot.result.text, cwd).has(file)) { + throw new DelegationControlError( + "FORBIDDEN", + "File must be linked in the latest final result and inside its workspace", + ); + } + try { + const [workspace, resolved] = await Promise.all([realpath(cwd), realpath(file)]); + if (!within(workspace, resolved)) { + throw new DelegationControlError( + "FORBIDDEN", + "Result file resolves outside the Thread workspace", + ); + } + if (!(await stat(resolved)).isFile()) { + throw new DelegationControlError("INVALID_ARGUMENT", "Result must be a regular file"); + } + const handle = await open( + resolved, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), + ); + try { + const before = await handle.stat(); + if (!before.isFile()) { + throw new DelegationControlError("INVALID_ARGUMENT", "Result must be a regular file"); + } + const currentRevision = revision(before); + if (input.revision !== undefined && input.revision !== currentRevision) { + throw new DelegationControlError( + "DELEGATION_FAILED", + "Result file changed; restart the download", + ); + } + if (offset > before.size) { + throw new DelegationControlError("INVALID_ARGUMENT", "offset exceeds the file size"); + } + const buffer = Buffer.alloc(Math.min(limit, before.size - offset)); + let bytesRead = 0; + while (bytesRead < buffer.length) { + const read = await handle.read( + buffer, + bytesRead, + buffer.length - bytesRead, + offset + bytesRead, + ); + if (read.bytesRead === 0) break; + bytesRead += read.bytesRead; + } + if (revision(await handle.stat()) !== currentRevision || bytesRead !== buffer.length) { + throw new DelegationControlError( + "DELEGATION_FAILED", + "Result file changed; restart the download", + ); + } + const nextOffset = offset + bytesRead; + return { + threadId: snapshot.threadId, + path: path.relative(cwd, file), + filename: path.basename(file), + mimeType: MIME_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream", + size: before.size, + revision: currentRevision, + offset, + nextOffset, + eof: nextOffset === before.size, + encoding: "base64", + data: buffer.toString("base64"), + }; + } finally { + await handle.close(); + } + } catch (error) { + if (error instanceof DelegationControlError) throw error; + throw new DelegationControlError( + "DELEGATION_FAILED", + "Result file is unavailable or unreadable", + ); + } +} diff --git a/packages/host-runtime/test/delegation-snapshot.test.ts b/packages/host-runtime/test/delegation-snapshot.test.ts index 50f668c0a..902414922 100644 --- a/packages/host-runtime/test/delegation-snapshot.test.ts +++ b/packages/host-runtime/test/delegation-snapshot.test.ts @@ -92,13 +92,14 @@ describe("delegation snapshot", () => { const snapshot = projectDelegationThreadSnapshot({ threadId: "thread-1", harnessId: "pi", - thread: { status: { type: "idle" } }, + thread: { status: { type: "idle" }, cwd: "/workspace" }, turns: completedTurns, running: false, view: "result", }); expect(snapshot).toMatchObject({ + cwd: "/workspace", status: "completed", progress: [{ id: "progress-1", text: "Checking auth." }], result: { availability: "available", text: "Found two issues." }, diff --git a/packages/host-runtime/test/external-client-mcp.test.ts b/packages/host-runtime/test/external-client-mcp.test.ts index 7272baa9b..fedf3bf42 100644 --- a/packages/host-runtime/test/external-client-mcp.test.ts +++ b/packages/host-runtime/test/external-client-mcp.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -21,6 +21,13 @@ describe("external client MCP endpoint", () => { let server: ExternalClientServer | undefined; let token: string; const api = { + read: vi.fn(async () => ({ + threadId: "t1", + harnessId: "codex", + cwd: directory, + status: "completed", + result: { availability: "available", text: "[result](result.bin)" }, + })), listHarnesses: vi.fn(async () => ({ harnesses: ["codex", "pi"] })), start: vi.fn(async (input: Record) => ({ delegationId: "d1", @@ -82,6 +89,7 @@ describe("external client MCP endpoint", () => { "harness_inspect", "delegate_start", "thread_read", + "thread_file_read", "thread_wait", "thread_list", "thread_send", @@ -134,6 +142,32 @@ describe("external client MCP endpoint", () => { await client.close(); }); + it("transfers linked result bytes through the authenticated MCP endpoint", async () => { + const bytes = Buffer.from([0, 255, 10, 128]); + await writeFile(path.join(directory, "result.bin"), bytes); + const client = await connect(); + const result = await client.callTool({ + name: "thread_file_read", + arguments: { thread: "codex://threads/t1", path: "result.bin" }, + }); + expect(result).toMatchObject({ + isError: false, + structuredContent: { + filename: "result.bin", + encoding: "base64", + data: bytes.toString("base64"), + eof: true, + }, + }); + const denied = await client.callTool({ + name: "thread_file_read", + arguments: { thread: "t1", path: "clients.json" }, + }); + expect(denied.isError).toBe(true); + expect(denied.structuredContent).toBeUndefined(); + await client.close(); + }); + it("reports delegation errors as tool errors with the CLI's codes", async () => { const client = await connect(); const result = await client.callTool({ diff --git a/packages/host-runtime/test/thread-result-file.test.ts b/packages/host-runtime/test/thread-result-file.test.ts new file mode 100644 index 000000000..40fe65099 --- /dev/null +++ b/packages/host-runtime/test/thread-result-file.test.ts @@ -0,0 +1,154 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { DelegationControlApi, DelegationThreadSnapshot } from "../src/delegation-types.js"; +import { readThreadResultFile } from "../src/thread-result-file.js"; + +describe("Thread result files", () => { + let directory: string; + let workspace: string; + let snapshot: DelegationThreadSnapshot; + const read = vi.fn(async () => snapshot); + const api = { read } as unknown as DelegationControlApi; + + beforeEach(async () => { + directory = await mkdtemp(path.join(os.tmpdir(), "result-files-")); + workspace = path.join(directory, "workspace"); + await mkdir(workspace); + snapshot = { + threadId: "task-1", + harnessId: "codex", + cwd: workspace, + status: "completed", + turn: { turnId: "turn-1", status: "completed" }, + progress: [], + result: { availability: "available", text: "" }, + nextCursor: null, + }; + }); + + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + it("exports linked binary files losslessly in revision-bound chunks", async () => { + const filename = "骑鹈鹕 (final).png"; + const bytes = Buffer.from(Array.from({ length: 513 }, (_, index) => index % 256)); + await writeFile(path.join(workspace, filename), bytes); + snapshot.result.text = `![成品](<${path.join(workspace, filename)}>)`; + const chunks: Buffer[] = []; + let offset = 0; + let revision: string | undefined; + for (;;) { + const chunk = await readThreadResultFile(api, { + threadId: "task-1", + path: filename, + offset, + limit: 200, + ...(revision ? { revision } : {}), + }); + expect(chunk).toMatchObject({ + filename, + size: bytes.length, + mimeType: "image/png", + encoding: "base64", + offset, + }); + chunks.push(Buffer.from(chunk.data, "base64")); + offset = chunk.nextOffset; + revision = chunk.revision; + if (chunk.eof) break; + } + expect(Buffer.concat(chunks)).toEqual(bytes); + }); + + it("accepts relative links, file URIs and empty files", async () => { + await writeFile(path.join(workspace, "empty.txt"), ""); + for (const link of ["empty.txt", pathToFileURL(path.join(workspace, "empty.txt")).href]) { + snapshot.result.text = `[result](${link})`; + await expect( + readThreadResultFile(api, { threadId: "task-1", path: "empty.txt" }), + ).resolves.toMatchObject({ size: 0, data: "", eof: true, nextOffset: 0 }); + } + }); + + it("rejects unlinked files and links outside the workspace", async () => { + await writeFile(path.join(directory, "private.txt"), "private"); + await writeFile(path.join(workspace, "draft.txt"), "draft"); + snapshot.result.text = `[outside](../private.txt)`; + for (const file of ["../private.txt", "draft.txt", path.join(directory, "private.txt")]) { + await expect( + readThreadResultFile(api, { threadId: "task-1", path: file }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + } + }); + + it("rejects workspace symlinks that escape and directories", async () => { + const outside = path.join(directory, "outside"); + await mkdir(outside); + await writeFile(path.join(outside, "private.txt"), "private"); + await symlink( + outside, + path.join(workspace, "escape"), + process.platform === "win32" ? "junction" : "dir", + ); + snapshot.result.text = `[outside](escape/private.txt) [directory](escape)`; + await expect( + readThreadResultFile(api, { threadId: "task-1", path: "escape/private.txt" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + snapshot.result.text = `[folder](folder)`; + await mkdir(path.join(workspace, "folder")); + await expect( + readThreadResultFile(api, { threadId: "task-1", path: "folder" }), + ).rejects.toMatchObject({ code: "INVALID_ARGUMENT" }); + }); + + it("refuses downloads without a final result or a known workspace", async () => { + snapshot.result = { availability: "pending" }; + await expect( + readThreadResultFile(api, { threadId: "task-1", path: "result.png" }), + ).rejects.toMatchObject({ code: "INVALID_ARGUMENT" }); + snapshot.result = { availability: "available", text: "[result](result.png)" }; + delete snapshot.cwd; + await expect( + readThreadResultFile(api, { threadId: "task-1", path: "result.png" }), + ).rejects.toMatchObject({ code: "UNSUPPORTED" }); + snapshot.cwd = workspace; + snapshot.hostId = "other-host"; + await expect( + readThreadResultFile(api, { threadId: "task-1", path: "result.png" }), + ).rejects.toMatchObject({ code: "UNSUPPORTED" }); + }); + + it("refuses to join chunks from different file revisions", async () => { + const file = path.join(workspace, "report.txt"); + await writeFile(file, "first version"); + snapshot.result.text = "[report](report.txt)"; + const first = await readThreadResultFile(api, { + threadId: "task-1", + path: "report.txt", + limit: 3, + }); + await writeFile(file, "replacement version"); + await expect( + readThreadResultFile(api, { + threadId: "task-1", + path: "report.txt", + offset: first.nextOffset, + revision: first.revision, + }), + ).rejects.toMatchObject({ code: "DELEGATION_FAILED" }); + }); + + it("rejects invalid ranges and continuation without a revision", async () => { + for (const options of [{ offset: -1 }, { limit: 0 }, { limit: 1048577 }, { offset: 1 }]) { + await expect( + readThreadResultFile(api, { threadId: "task-1", path: "result.png", ...options }), + ).rejects.toMatchObject({ code: "INVALID_ARGUMENT" }); + } + }); +});