第 10 章:Shell、Exec Server 与文件系统工具
阅读契约: 用本章把 shell execution 读成受治理的副作用,而不是一个裸 subprocess。请同时跟踪四个 owner:handler 负责塑形请求,exec policy 负责决定 approval requirement,orchestrator 负责选择 sandbox attempt,
exec-server负责 process 与 filesystem 的 placement。读完后,你应该能解释为什么process_id是逻辑协议句柄而不是 OS pid,为什么 approval 不等于 sandbox,以及为什么远端文件系统写入必须携带 sandbox context。

源码边界: 本章只有在链接到固定 Codex commit 或本章源码地图的 files、types、functions、tests、schemas、request/event shapes 时,才把说法视为 verified source。像 runtime、owner、placement boundary、contract 这类架构归纳是从可见 anchors 得出的 surrounding contract inference,不是对 OpenAI 服务内部的断言。
第 9 章解释了 Codex 如何暴露工具,同时不让模型凭空获得工具权限。本章沿着第一个真正把这件事落到运行时的工具家族继续往下看:shell 和 filesystem access。从模型能看到的表面看,shell 或 exec_command 很像一句“运行这个命令”。但在 runtime 里,这句话会变成一个受治理的请求,其权限被分散到 parsing、policy、approval、sandboxing、process management、output sequencing 和 filesystem mediation 中。
核心心智模型是:Codex 不是在运行“一条 shell 命令”;它是在接收一个 side-effect request,并逐步证明这个副作用可以在哪里发生。 一条命令最终可能变成本机 child process、一个保留给后续 stdin 的 PTY、一个经过 sandbox transform 的 execution request,也可能变成针对远端 executor 的 exec-server request。这些不是后端实现细节,而是在定义哪个文件系统是权威、哪些输出可以 replay、哪个 permission profile 必须随操作一起传递。
一、Shell 是受治理的请求
Codex 有多个 shell 相邻入口,是因为不同客户端需要不同的交互形状。经典 shell 工具返回一次捕获结果;exec_command 可以让进程保持存活,并在之后接受 write_stdin;还有一些 shell surface 用于兼容、host-local execution 或 shell-aware command routing。关键不是入口有多少,而是这些入口本身都不是权限边界。
1.1 Request Surface 不是权限边界
| 入口 | 表面需要什么 | 仍然必须治理什么 |
|---|---|---|
shell | 一条 command、cwd、timeout、可选 approval hint | command classification、approval、sandbox、output shaping |
exec_command | command、selected environment、可选 TTY | process identity、output buffering、stdin continuation、retry |
write_stdin | 和已有 process 交互 | process liveness、stdin support、output polling、exit cleanup |
| filesystem helpers | 对 selected workspace 执行 read/write/remove/copy | executor placement 与 sandbox context |
这层拆分能避免一种误导性的 API 叙事。Tool name 只说明客户端想怎样交互;它并不说明哪个 filesystem 是权威、是否需要 prompt、后续 stdin write 是否还能找到同一个 runtime object。
1.2 Handler Shaping 建立第一份 Contract
经典 shell handler 展示了第一层翻译。ShellHandler::to_exec_params 并不启动进程,而是把模型请求塑形成 ExecParams:里面有 cwd resolution、timeout policy、capture policy、environment、network、sandbox permissions 和 justification。
ExecParams {
command: params.command.clone(),
cwd: turn_context.resolve_path(params.workdir.clone()),
expiration: params.timeout_ms.into(),
capture_policy: ExecCapturePolicy::ShellTool,
env: create_env(&turn_context.shell_environment_policy, Some(thread_id)),
sandbox_permissions: params.sandbox_permissions.unwrap_or_default(),
// additional fields omitted
}
这个小对象已经是一个 contract。命令还没有被信任,但 runtime 已经知道它属于哪个 working directory、environment policy、capture mode 和 sandbox request。handler 后面的 handle 会把塑形后的请求交给 run_exec_like,由共享 shell execution path 继续处理 environment 与 approval。handler 是入口,不是最终授权者。
如果跳过这一步,后续 policy code 就只能从未结构化字符串里猜 cwd、environment、network 和 sandbox intent。Codex 的做法是先把副作用变成 typed request,再讨论它能不能运行。
二、Policy 先于 Sandbox
一个常见误读是把 sandbox 当作第一道防线。Codex 源码里的顺序更细:先把 command 变得更结构化,再匹配 policy,再转换成 approval requirement,最后才由 orchestrator 选择 sandbox attempt。

共享 shell path 会先在 run_exec_like 中抽取 command 并应用 environment facts。真正有意义的治理发生在 command policy 层:解析或 lowering 常见 shell 形式,匹配 prefix rules,在必要时参考 host executable metadata,并在没有规则命中时回退到保守 heuristics。
2.1 Policy 生成 Approval Shape
核心转换在 ExecPolicyManager::create_exec_approval_requirement_for_command。简化后,源码把 policy decision 映射成三种 approval shape:
match evaluation.decision {
Decision::Forbidden => ExecApprovalRequirement::Forbidden { /* reason */ },
Decision::Prompt => ExecApprovalRequirement::NeedsApproval { /* reason */ },
Decision::Allow => ExecApprovalRequirement::Skip { /* bypass_sandbox */ },
}
这个 shape 很重要,因为 allow 不总是“无 sandbox 自由运行”。源码会在每个 parsed command segment 都被 exec policy 显式 allow 时,才计算出 bypass_sandbox。所以一条命令可以安全到无需提示,但仍然在当前 sandbox 下执行。反过来,如果当前 approval policy 不允许对某类命令发起 prompt,一个 prompt decision 也可能变成 forbidden。
| Policy result | Runtime 含义 | 常见误读 |
|---|---|---|
Forbidden | 执行前停止并返回 policy reason。 | “是 sandbox 拦住了。”其实进程不一定会启动。 |
NeedsApproval | 在尝试副作用前询问 hooks、guardian 或用户。 | “审批通过就是全权限。”它只授权这条 operation path。 |
Skip | 这个 request shape 不需要 prompt。 | “所有控制都跳过了。”除非 policy 显式允许 bypass,否则 sandbox 仍可生效。 |
2.2 Fallback 保持保守
render_decision_for_unmatched_command 这条 fallback path 也很保守:它会区分 known-safe commands 和 dangerous commands,并把 approval policy 与 sandbox kind 一起纳入判断。所以本章必须把 command classification 和 process execution 分开讲。
这种设计的成本是:一个陌生但无害的命令也可能需要 approval。这是有意为之的边界,而不是缺陷;fallback 保护的是“未知副作用不会因为写在 shell 语法里就自动可信”这个不变量。
三、Orchestrator 拥有 Attempts
工具拿到 approval requirement 后,会进入共享的 ToolOrchestrator。Orchestrator 负责把 approval、network approval、sandbox selection、guardian review 和 retry semantics 组合起来。它不是 spawn 外面的一层薄包装。
3.1 Attempt 是 Runtime State
用形状描述,这条链路是:
tool request
-> exec approval requirement
-> hook / guardian / user decision when needed
-> initial sandbox attempt
-> runtime run
-> optional retry without sandbox when policy permits
-> tool output or structured denial
第一次 attempt 会根据 turn 的 permission profile 和 sandbox policy 在 orchestrator.rs 中构造。如果 sandbox 拒绝这次 attempt,后续分支会检查 tool 是否允许 escalation、当前 approval policy 是否允许 no-sandbox retry、是否存在 network-denial approval context,然后才会再次请求审批或直接返回 denial。第二次 attempt 是显式的:只有这些检查通过后,源码才会创建一个 SandboxType::None 的新 SandboxAttempt。
3.2 Retry 不是静默 Fallback
这就是为什么 approval 和 sandboxing 不能混在一起:
| 边界 | 源码 owner | 回答的问题 |
|---|---|---|
| approval requirement | exec policy 与 tool runtime | 这个副作用需要审批、禁止,还是可以跳过审批? |
| approval decision | hooks、guardian、cached approval 或 user request | 是否有授权决策批准这个 request? |
| sandbox attempt | ToolOrchestrator 与 SandboxAttempt | 这次 attempt 应受哪些 filesystem/network 限制? |
| retry | ToolOrchestrator | sandbox denial 后,是否允许 no-sandbox retry? |
更底层的 sandbox transform 在 SandboxAttempt::env_for 完成。这里 sandbox manager 会用当前 sandbox type、cwd、permission profile 和 network setting 转换 ExecRequest。也就是在这里,一个受治理的请求才变成可执行环境。
这个 failure boundary 很关键:命令已经被 approval,并不表示 sandbox denial 一定会被绕过。Approval 回答 runtime 是否可以尝试副作用;sandbox attempt 回答这次尝试受到怎样的约束。
四、Unified Exec 增加 Process Identity
exec_command 相比简单 shell 工具有一个关键新增概念:逻辑 process session。命令可能输出一段后立即返回,也可能继续运行。Runtime 必须持有 session、限制 output buffers、发出 events,并允许后续 write_stdin 和同一个 process 交互。

exec_command 创建 managed process session;write_stdin 后续通过逻辑 process id 定位这个 session,而不是定位某个 OS pid。4.1 Handler 绑定 Environment、Cwd 与 Process Id
unified exec handler 会先解析 selected environment、cwd、executor filesystem、shell command、TTY mode、yield time、output token limits 和 permission request,再调用 process manager。源码中可见的 handoff 发生在 ExecCommandHandler::handle:它先解析 environment 并分配 process id,再构造 request。这个形状可以在 ExecCommandRequest 里看到:
pub(crate) struct ExecCommandRequest {
pub command: Vec<String>,
pub hook_command: String,
pub process_id: i32,
pub yield_time_ms: u64,
pub max_output_tokens: Option<usize>,
pub cwd: AbsolutePathBuf,
pub environment: Arc<Environment>,
pub network: Option<NetworkProxy>,
pub tty: bool,
pub sandbox_permissions: SandboxPermissions,
pub additional_permissions: Option<AdditionalPermissionProfile>,
pub additional_permissions_preapproved: bool,
pub justification: Option<String>,
pub prefix_rule: Option<Vec<String>>,
}
handler 会通过 UnifiedExecProcessManager::allocate_process_id 分配 process id,然后把 request 交给 exec_command。这个 manager 会通过 orchestrated sandbox path 打开 process,启动 output streaming,在 initial yield wait 之前存储 live sessions,并返回 ExecCommandToolOutput:里面有 raw output、已知时的 exit code、wall time、chunk id,以及可能仍然存活的 process_id。
4.2 Manager 拥有 Continuation
所以 write_stdin 不是第二个命令启动器。WriteStdinRequest 携带 process id、input、yield time 和 output limit。manager 的 write_stdin 会为已有 process 准备 handles,只在 stdin 支持时写入 input,轮询 bounded output,刷新 process state,并清理已经退出的 sessions。
这也是 UI 必须尊重的边界。如果 tool result 返回了 process_id,说明这个 process 仍然是 managed runtime object;如果只返回 exit_code,它已经变成完成态输出。把 process_id 当作 OS pid 是错的;后面的 protocol 会把这点写得更明确。
五、exec-server 把 Placement 变成 Protocol
exec-server crate 负责把 placement 做成 protocol,而不是散落的一堆特殊分支。它的 protocol file 命名了用于 start、read、write、terminate、接收 output notifications 和执行 filesystem operations 的 JSON-RPC methods。同一套 client shape 可以面对本地 executor,也可以面对远端 executor。

exec-server 用 JSON-RPC contract 包住 process placement:Codex core 发送 process 与 filesystem request,executor 拥有 process,output 以 sequenced chunks 返回。5.1 Process 与 Filesystem Method 共用一层 Contract
这些 method constants 很小,但很关键。protocol.rs 把 process 和 filesystem methods 放在同一个协议面:
pub const INITIALIZE_METHOD: &str = "initialize";
pub const INITIALIZED_METHOD: &str = "initialized";
pub const EXEC_METHOD: &str = "process/start";
pub const EXEC_READ_METHOD: &str = "process/read";
pub const EXEC_WRITE_METHOD: &str = "process/write";
pub const EXEC_TERMINATE_METHOD: &str = "process/terminate";
pub const EXEC_OUTPUT_DELTA_METHOD: &str = "process/output";
pub const EXEC_EXITED_METHOD: &str = "process/exited";
pub const EXEC_CLOSED_METHOD: &str = "process/closed";
pub const FS_READ_FILE_METHOD: &str = "fs/readFile";
pub const FS_WRITE_FILE_METHOD: &str = "fs/writeFile";
pub const FS_CREATE_DIRECTORY_METHOD: &str = "fs/createDirectory";
pub const FS_GET_METADATA_METHOD: &str = "fs/getMetadata";
pub const FS_READ_DIRECTORY_METHOD: &str = "fs/readDirectory";
pub const FS_REMOVE_METHOD: &str = "fs/remove";
pub const FS_COPY_METHOD: &str = "fs/copy";
pub const HTTP_REQUEST_METHOD: &str = "http/request";
pub const HTTP_REQUEST_BODY_DELTA_METHOD: &str = "http/request/bodyDelta";
5.2 Output 是有序协议状态
process 参数也澄清了一个常见误会。ExecParams 说明 process_id 是“client-chosen logical process handle scoped to this connection/session”,并且明确不是 OS pid。Output 也不是 blob:ProcessOutputChunk 携带 seq、stream 和 chunk;ReadResponse 返回 chunks、next_seq、exit state、closed state 和 failure。
client 侧也镜像了这个协议。ExecServerClient 有 exec、read、write、terminate 和 filesystem operations 的 request methods。远端 process adapter 在 remote_process.rs 中先 register session,再发送 client.exec(params),并通过 session 实现 read、write 和 terminate。因此本地与远端 placement 可以共享 process semantics,而不需要 shell handler 知道 child process 实际在哪里。
六、Filesystem Operations 也经过 Executor Boundary
Filesystem access 遵循同样的 placement rule。如果 selected executor 拥有 workspace,那么 reads、writes、copies、removals 和 metadata checks 都必须经过这个 executor。否则就会出现一种危险错位:remote command 修改一个文件系统,而 patch 或 file read 却误触另一个文件系统。

6.1 Server Handler 应用 Sandbox Context
server-side handler 把可用操作列得很清楚。FileSystemHandler 包装一个 ExecutorFileSystem,并暴露 read_file、write_file、create_directory、get_metadata、read_directory、remove 和 copy。远端 client path 会在 forwarding 时保留 sandbox context。下面是 RemoteFileSystem 的简化片段:
self.client.get().await?
.fs_read_file(FsReadFileParams {
path: path.clone(),
sandbox: remote_sandbox_context(sandbox),
})
.await?;
6.2 Executor 拥有 Workspace
这里真正的 owner 不是“Codex 当前运行的机器”,而是本轮或本次操作选择的 executor environment。这也是为什么核心 shell path 会在 run_exec_like 中解析 executor filesystem,以及为什么 patch interception 可以在退回 opaque shell execution 之前处理。文件编辑也是副作用;第 11 章会把 patch path 作为自己的协议来讲。
七、不要混在一起的概念
本章的实用价值,主要是把相邻概念分开。一旦混在一起,故障就很难定位。
| 不要混在一起 | 更好的区分 | 为什么重要 |
|---|---|---|
| command text 与 command policy | raw shell text 会先 parse 或 lower,再做 policy decision | 一个字符串可能隐藏多个 command segment 或 shell semantics |
| approval 与 sandboxing | approval 授权一条 side-effect path;sandbox 限制一次 attempt | 经过审批的命令仍可能在 sandbox 内运行 |
process_id 与 OS pid | process_id 是 Codex/exec-server logical handle | remote process 与 retained session 需要协议身份 |
| output blob 与 output stream | output 有 seq、stream、exit、closed 和 failure state | UI replay 与 write_stdin polling 依赖顺序 |
| local files 与 executor files | filesystem operations 面向 selected executor | remote workspace 不能和 client machine 混淆 |
贯穿本章的不变量很简单:副作用只能作为结构化请求穿过权限边界。 Shell execution、process continuation 和 filesystem mutation 都在用不同方式维护这个不变量。
应用到实践
- 先塑形,再 spawn。 先把 tool arguments 转成 typed execution requests,不要把任何文本直接当成可运行事实。
- 让 policy 决定 approval shape。 不要用 sandbox denial 代替显式的 allow、prompt 或 forbid decision。
- 把 sandbox attempt 当成 runtime state。 无 sandbox retry 是第二次受治理 attempt,不是静默 fallback。
- 保持 process identity 的逻辑性。
process_id属于 Codex/exec-server session contract,不是 OS pid。 - 把 filesystem work 送到 executor。 Reads、writes、copies、removals 必须面向拥有 selected workspace 的文件系统,并携带 sandbox context。
第 11 章会从 shell stream 收窄到一条文件编辑路径:patch。这个分离不是形式主义。它让 Codex 可以把 edit 当作结构化 mutation 来审查和应用,而不是把编辑藏进任意 shell 文本。