Skip to content
12 changes: 8 additions & 4 deletions crates/launcher/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,11 @@ const USAGE: &str = "usage:
codexhost desktop-screenshot
codexhost broker install|status|stop|uninstall
codexhost delegate --help
codexhost host info ...
codexhost project list ...
codexhost harness list|inspect ...
codexhost delegate start ...
codexhost thread send|cancel|read|wait|watch|watches|list ...
codexhost thread send|cancel|read|wait|watch|watches|list|file ...
codexhost client status|on|off|create|list|revoke ...
codexhost events pull|ack ...";

Expand Down Expand Up @@ -1529,12 +1531,14 @@ fn run(arguments: &[String]) -> Result<(), Box<dyn Error>> {
Ok(())
}
Some("desktop-screenshot") => Err("desktop-screenshot accepts no arguments".into()),
Some("host") if arguments.get(1).is_some_and(|arg| arg == "info") => {
run_delegation_cli(arguments)
}
Some("host") => host_service::run(&arguments[1..]),
Some("web-service") => web_service::run(&arguments[1..]),
Some("broker") => run_native_harness_broker_cli(&arguments[1..]),
Some("harness") | Some("delegate") | Some("thread") | Some("client") | Some("events") => {
run_delegation_cli(arguments)
}
Some("project") | Some("harness") | Some("delegate") | Some("thread") | Some("client")
| Some("events") => run_delegation_cli(arguments),
_ => {
usage();
Err("invalid launcher arguments".into())
Expand Down
4 changes: 3 additions & 1 deletion crates/launcher/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,10 @@ fn help_prints_usage_to_stdout_and_succeeds() {
for command in [
"codexhost --version",
"codexhost desktop-screenshot",
"codexhost host info ...",
"codexhost project list ...",
"codexhost harness list|inspect ...",
"codexhost thread send|cancel|read|wait|watch|watches|list ...",
"codexhost thread send|cancel|read|wait|watch|watches|list|file ...",
"codexhost broker install|status|stop|uninstall",
] {
assert!(stdout.contains(command), "{argument}: {command}");
Expand Down
41 changes: 39 additions & 2 deletions docs/architecture/external-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,41 @@

## MCP

外部监听在 `/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。
外部监听在 `/mcp` 以 Streamable HTTP 提供 MCP:每个 POST 携带一条 JSON-RPC 消息并以 JSON 应答;`GET` 返回 405(不提供服务端主动流,推送用 `/v1/events`),`DELETE` 视为结束会话。工具与 CLI 一一对应:`harness_list`、`harness_inspect`、`host_info`、`project_list`、`delegate_start`、`thread_read`、`thread_file_read`、`thread_wait`(最多 60 秒)、`thread_list`、`thread_send`、`thread_cancel`、`thread_watch`、`thread_watches`、`events_poll`。工具结果同时以文本和 `structuredContent` 返回 CLI 的 JSON;委派错误作为 `isError` 结果返回相同的错误代码。实现不依赖 MCP SDK。

### 设备与项目发现

CLI 提供 `codexhost host info --refresh true|false` 和 `codexhost project list --query <文本> --offset <位置> --limit <数量> --refresh true|false`,与 MCP 使用相同的实现;内部控制连接和独立客户端连接都可使用。

每台 Host 的外部 MCP 还提供两个只读工具,供客户端在多设备之间选择执行位置:

- `host_info(refresh?)`,对应 `POST /v1/host/info`:设备标识、显示名与别名、实际 hostname、操作系统、架构、CPU、内存、开发工具版本、项目扫描根目录和可用的默认工作区。工具探测只执行固定的版本查询;不返回环境变量、账号或凭据,不执行项目脚本。安装了 CLI 不代表其服务、SDK 或账号可用。
- `project_list(query?, offset?, limit?, refresh?)`,对应 `POST /v1/project/list`:自动发现的 Git 仓库及 worktree,包含真实工作目录、项目描述、技术栈、清单文件、当前分支,以及去除用户名、密码、查询参数和 fragment 后的 origin。只读取清单和 Git 标识,不运行工作区状态扫描、Git 过滤器或项目脚本;完整工作区状态由执行 Agent 在写入前检查。项目描述属于不可信元数据,不能作为客户端指令执行。

设置从当前 Host 数据目录的 `host-inventory.json` 读取,修改后下次查询生效;缺省设备标识及显示名取 hostname;未配置 `projectRoots` 时不扫描项目,`defaultWorkspace` 仅在显式配置且目录存在时返回。配置示例:

```json
{
"id": "macpro",
"name": "MacPro",
"aliases": ["macpro", "mac-pro"],
"projectRoots": ["/path/to/repos"],
"defaultWorkspace": "/path/to/workspace"
}
```

`projectRoots` 与 `defaultWorkspace` 必须是绝对路径,扫描根目录最多 16 个。扫描最多深入三层,发现仓库后不扫描其内部;忽略符号链接子目录、隐藏目录和常见依赖/构建目录,最多检查 2,000 个目录、返回 200 个仓库。缺失或不可读的根目录由 `unavailableRoots` 报告,达到上限由 `truncated` 报告,不能把截断列表视为完整列表。项目列表每页默认 50 项、最多 100 项,使用 `nextOffset` 继续;`query` 按名称、路径、origin、描述和技术栈过滤。

工具版本和项目快照各缓存 60 秒,返回 `observedAt`;项目快照的有效期从扫描成功完成时计算。`refresh: true` 重新探测,但相同根目录仍复用进行中的扫描;扫描根目录变化使项目缓存失效。没有后台同步进程或持久环境快照。客户端对各个已配置 Host 查询这些信息,结合 `harness_list` 自行选择设备、Agent 与工作目录;Host 不维护跨设备任务分类或固定路由表。委派时可同时记录来源对话引用和 `codexhost://device/<设备标识>`,使异步回执能选择正确的 Host 读取结果。

`thread_read` 在已知时返回执行主机上的 `cwd`。MCP 另提供 `thread_file_read(thread, path, offset?, limit?, revision?)`,对应外部 HTTP 的 `POST /v1/thread/file`(使用 `threadId`)。该只读接口仅导出最新最终报告中 Markdown 链接明确引用、且位于该 Thread 实际工作区内的普通文件;解析符号链接后仍须位于工作区。没有最终结果或工作区时拒绝读取,不扫描目录,也不导出报告中的远程 URL。读取前后校验真实路径,并比较打开文件的设备及 inode 身份以拒绝检测到的目录或文件替换;这些检查依赖文件系统提供稳定身份,不是基于目录句柄的原子沙箱,不能作为抵御恶意本地进程持续替换目录的隔离边界。

文件结果含 `filename`、`mimeType`、`size`、`encoding: "base64"`、`data`、`offset`、`nextOffset`、`eof` 与 `revision`。默认每块 256 KiB,上限 1 MiB;续读必须携带首块的 `revision`,文件变化时重新开始。客户端应将工具结果直接保存为文件,在自己的电脑上解码,再以其原生附件工具交付成品,不在聊天中打印 Base64 或主机路径。源文件按用户要求读取。文件传输沿用客户端认证与网络策略,不创建公开下载链接。

## 接入示例

CLI 用 `codexhost thread file <thread> --path <文件> --offset <位置> --limit <字节数> --revision <版本>` 读取同一套经过报告引用及工作区校验的文件分块。

- Claude Code:`claude mcp add --transport http codexhost http://127.0.0.1:4398/mcp --header "Authorization: Bearer <token>"`。
- Claude Desktop:设置里的自定义 connector 由 Anthropic 云端连接,访问不到 `127.0.0.1`;本机 `claude_desktop_config.json` 的 `mcpServers` 只支持 stdio。用 `mcp-remote` 桥接:

Expand All @@ -65,7 +96,13 @@
"mcpServers": {
"codexhost": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://127.0.0.1:4398/mcp", "--header", "Authorization:${CODEXHOST_AUTH}"],
"args": [
"-y",
"mcp-remote",
"http://127.0.0.1:4398/mcp",
"--header",
"Authorization:${CODEXHOST_AUTH}"
],
"env": { "CODEXHOST_AUTH": "Bearer <token>" }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@

## ADDED Requirements

### Requirement: 客户端可依据实时设备与项目事实选择执行位置
每台 Host 的外部 MCP SHALL 提供只读的 `host_info` 和 `project_list`。设备信息 SHALL 包含实际操作系统、架构、硬件、可查询的开发工具版本、设备标识和已存在的默认工作目录。项目发现 SHALL 仅扫描配置根目录下的 Git 仓库与 worktree,返回真实工作目录、技术栈、分支和去除凭据的 origin。Host MUST NOT 维护跨设备固定任务路由;客户端依据各个已配置 Host 的事实选择设备与 Harness。

#### Scenario: 环境与项目自动刷新
- **WHEN** 客户端查询设备信息或项目列表,快照已超过 60 秒,或显式请求 `refresh: true`
- **THEN** Host SHALL 重新探测并返回 `observedAt`
- **AND** 项目扫描根目录配置变更 SHALL 在下次查询生效,无需重启

#### Scenario: 有界扫描与元数据读取
- **WHEN** 客户端调用 `project_list`
- **THEN** Host SHALL 忽略符号链接子目录、隐藏目录及常见依赖与构建目录,发现仓库后不扫描其内部
- **AND** SHALL 返回不可用目录与扫描截断标识,并支持过滤和分页
- **AND** MUST NOT 执行项目脚本或 Git 过滤器,MUST NOT 返回 origin 中的用户名、密码、查询参数或 fragment

#### Scenario: 原设备结果读取
- **WHEN** 客户端向所选 Host 派发任务,并将其设备标识作为 `codexhost://device/<设备标识>` 保存到任务引用
- **THEN** 原有 Thread 引用与事件机制 SHALL 保留该引用,使客户端可以回到原执行设备读取和交付结果

### Requirement: 外部客户端由用户显式登记并可随时吊销
系统 SHALL 提供 `codexhost client add <name>`、`client list` 与 `client remove <name>`。登记 SHALL 生成随机令牌并只在登记时输出一次;系统 MUST NOT 以可还原的形式保存令牌。名称在本机唯一,只允许小写字母、数字和连字符。删除 SHALL 立即吊销:之后携带该令牌的请求 SHALL 被拒绝,已建立的事件推送连接 SHALL 被关闭。

Expand Down Expand Up @@ -117,3 +135,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 拒绝导出文件
2 changes: 2 additions & 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,5 @@

- [ ] 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 回传。
- [ ] 4.4 外部客户端读取当前设备环境和自动发现的开发项目,供客户端选择执行设备。验证:仅扫描配置根目录、忽略符号链接目录和依赖目录、仓库及 worktree 识别、远端地址去除凭据、缓存刷新与配置变更、参数校验及已认证客户端 MCP 调用;以多设备 Bot 任务验证默认委派、设备选择和原对话交付。
17 changes: 16 additions & 1 deletion packages/host-runtime/src/delegation-cli-help.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { consumesNextArgument } from "./delegation-cli-options.js";

const COMMAND_HELP = {
"host info": `codexhost host info [--refresh true|false]
Read this Host's environment, tools and workspace defaults. Cached observations expire after 60 seconds.`,
"project list": `codexhost project list [--query <text>] [--offset <n>] [--limit <n>] [--refresh true|false]
Discover projects under this Host's configured roots. Returns live project metadata and pagination; no static project registry is required.`,
"thread file": `codexhost thread file <thread> --path <file> [--offset <n>] [--limit <bytes>] [--revision <revision>]
Read a file explicitly linked in the latest final report, inside the Thread workspace. Returns a base64 chunk, revision, nextOffset and eof. Continue with the returned revision; restart if it changes. Maximum chunk size is 1 MiB. Read through the execution Host.`,
"client add": `codexhost client add <name>
Register a named external client for HTTP/MCP or a separate CLI identity. Normal same-user CLI calls authenticate automatically and do not need registration. Prints the client token once; it is not stored in a readable form and cannot be shown again.
The client sets CODEXHOST_CLIENT_TOKEN (or CODEXHOST_CLIENT_TOKEN_FILE) and then uses the same delegate, thread and harness commands, or the MCP endpoint, against the external client listener (127.0.0.1:4398 unless CODEXHOST_EXTERNAL_CLIENT_PORT says otherwise).
Expand Down Expand Up @@ -76,6 +82,9 @@ read/wait are non-consuming. Native Codex callers need local Runtime access; RUN
Native Codex shell commands also need the Host-provided CODEXHOST_* environment variables. If shell_environment_policy filters them, prefer inherit = "all" with ignore_default_excludes = true and a narrow include_only containing "CODEXHOST_RUNTIME_ENDPOINT" and "CODEXHOST_RUNTIME_TOKEN" plus the variables required by the platform and invoked tools. Avoid unconstrained inherit = "all", which forwards unrelated ambient variables.`;

export const DELEGATION_HELP = `usage:
codexhost host info
codexhost project list
codexhost thread file <thread> --path <file>
codexhost harness list
codexhost harness inspect <harness>
codexhost delegate start --harness <id> --task <text>
Expand Down Expand Up @@ -110,7 +119,13 @@ export function delegationCliHelp(arguments_: readonly string[]): string | undef
return `${COMMAND_HELP.events}\n\n${COMMON_HELP}\n`;
if (!command || command === "--help" || command === "-h" || command === "help") {
if (group === "delegate") return DELEGATION_HELP;
if (group === "harness" || group === "thread" || group === "client") {
if (
group === "host" ||
group === "project" ||
group === "harness" ||
group === "thread" ||
group === "client"
) {
const usages = Object.entries(COMMAND_HELP)
.filter(([name]) => name.startsWith(`${group} `))
.map(([, help]) => help.split("\n")[0]);
Expand Down
3 changes: 3 additions & 0 deletions packages/host-runtime/src/delegation-cli-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ export function compactDelegationOutput(
view: "result" | "messages" = "result",
): unknown {
switch (command) {
case "host info":
case "project list":
case "thread file":
case "harness list":
return body;
case "harness inspect":
Expand Down
70 changes: 70 additions & 0 deletions packages/host-runtime/src/delegation-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,76 @@ export async function runDelegationCli(input: {
writeJson(output, await runClientCommand(command, parsed.positionals, environment));
return 0;
}
if ((group === "host" && command === "info") || (group === "project" && command === "list")) {
rejectUnknown(
parsed,
group === "host" ? ["--refresh"] : ["--refresh", "--query", "--offset", "--limit"],
);
if (parsed.positionals.length)
throw new DelegationControlError(
"INVALID_ARGUMENT",
"This command accepts no positional arguments",
);
const refresh = value(parsed, "--refresh");
if (refresh !== undefined && refresh !== "true" && refresh !== "false")
throw new DelegationControlError("INVALID_ARGUMENT", "--refresh must be true or false");
const offset = value(parsed, "--offset");
if (offset !== undefined && (!/^\d+$/u.test(offset) || !Number.isSafeInteger(Number(offset))))
throw new DelegationControlError(
"INVALID_ARGUMENT",
"--offset must be a non-negative integer",
);
writeJson(
output,
await requestRuntime({
environment,
path: group === "host" ? "/v1/host/info" : "/v1/project/list",
body: {
...(refresh !== undefined ? { refresh: refresh === "true" } : {}),
...(value(parsed, "--query") !== undefined ? { query: value(parsed, "--query") } : {}),
...(offset !== undefined ? { offset: Number(offset) } : {}),
...(value(parsed, "--limit")
? { limit: positiveInteger(value(parsed, "--limit"), "--limit", MAX_LIMIT) }
: {}),
},
...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}),
}),
);
return 0;
}
if (group === "thread" && command === "file") {
rejectUnknown(parsed, ["--path", "--offset", "--limit", "--revision"]);
const threadId = parsed.positionals[0];
if (parsed.positionals.length !== 1 || !threadId || !value(parsed, "--path"))
throw new DelegationControlError(
"INVALID_ARGUMENT",
"thread file requires one Thread and --path",
);
const offset = value(parsed, "--offset");
if (offset !== undefined && (!/^\d+$/u.test(offset) || !Number.isSafeInteger(Number(offset))))
throw new DelegationControlError(
"INVALID_ARGUMENT",
"--offset must be a non-negative integer",
);
writeJson(
output,
await requestRuntime({
environment,
path: "/v1/thread/file",
body: {
threadId: normalizeThreadId(threadId),
path: value(parsed, "--path"),
...(offset !== undefined ? { offset: Number(offset) } : {}),
...(value(parsed, "--limit")
? { limit: positiveInteger(value(parsed, "--limit"), "--limit", 1_048_576) }
: {}),
...(value(parsed, "--revision") ? { revision: value(parsed, "--revision") } : {}),
},
...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}),
}),
);
return 0;
}
if (group === "harness" && command === "list") {
rejectUnknown(parsed, []);
if (parsed.positionals.length > 0) {
Expand Down
Loading
Loading