Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/architecture/external-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>"`。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 拒绝导出文件
1 change: 1 addition & 0 deletions openspec/changes/add-external-client-access/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 回传。
1 change: 1 addition & 0 deletions packages/host-runtime/src/delegation-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions packages/host-runtime/src/delegation-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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. */
Expand Down
26 changes: 26 additions & 0 deletions packages/host-runtime/src/external-client-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions packages/host-runtime/src/external-client-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
};
}
Expand Down
211 changes: 211 additions & 0 deletions packages/host-runtime/src/thread-result-file.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> = {
".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<string> {
const files = new Set<string>();
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<string, unknown>,
) {
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",
);
}
}
3 changes: 2 additions & 1 deletion packages/host-runtime/test/delegation-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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." },
Expand Down
36 changes: 35 additions & 1 deletion packages/host-runtime/test/external-client-mcp.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<string, unknown>) => ({
delegationId: "d1",
Expand Down Expand Up @@ -82,6 +89,7 @@ describe("external client MCP endpoint", () => {
"harness_inspect",
"delegate_start",
"thread_read",
"thread_file_read",
"thread_wait",
"thread_list",
"thread_send",
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading