Skip to content

V2 FSCommandProcess协议

从主机文件、独立进程到带 sandbox 的 command/exec,理解 V2 执行接口的参数、输出和连接生命周期。

基于rust-v0.150.0
CodexRustAppServerFilesystemProcess

V2 FSCommandProcess协议 ​

本文承接V2 Turn与Item协议和V2 PluginAppsMCP协议。fs/*、process/*、command/exec 和 Turn 内的 command item 都能运行程序或访问文件,但它们的 owner 不同:FS processor 调用本地 ExecutorFileSystem,process processor 管理连接范围内的独立进程,command processor 负责带 sandbox/权限配置的一次性命令。理解这几个边界,才能解释为什么某个请求没有审批、为什么输出出现在 notification 而不是最终 response,以及连接断开时哪些资源会被终止。

1. FS协议模型 ​

源码位置:codex-rs/app-server-protocol/src/protocol/v2/fs.rs :: FsReadFileParams、FsWriteFileParams、FsReadDirectoryEntry、FsWatchParams

rust
pub struct FsReadFileParams {
    pub path: AbsolutePathBuf,
}

pub struct FsReadFileResponse {
    pub data_base64: String,
}

pub struct FsWriteFileParams {
    pub path: AbsolutePathBuf,
    pub data_base64: String,
}

pub struct FsReadDirectoryEntry {
    pub file_name: String,
    pub is_directory: bool,
    pub is_file: bool,
}

pub struct FsWatchParams {
    pub watch_id: String,
    pub path: AbsolutePathBuf,
}

FS API 的路径类型是 AbsolutePathBuf,文件内容以 base64 传输,目录接口只返回直接子项名称和类型。watch_id 是 connection-scoped 标识,后续 fs/unwatch 和 fs/changed 都依赖它;它不是文件系统 inode,也不是全局唯一 id。

2. FS处理器与错误 ​

源码位置:codex-rs/app-server/src/request_processors/fs_processor.rs :: file_system、read_file、write_file、map_fs_error

rust
fn file_system(&self) -> Result<Arc<dyn ExecutorFileSystem>, JSONRPCErrorError> {
    self.environment_manager
        .try_local_environment()
        .map(|environment| environment.get_filesystem())
        .ok_or_else(|| internal_error("local filesystem is not configured"))
}

pub(crate) async fn read_file(
    &self,
    params: FsReadFileParams,
) -> Result<FsReadFileResponse, JSONRPCErrorError> {
    let path = PathUri::from_abs_path(&params.path);
    let bytes = self
        .file_system()?
        .read_file(&path, Default::default(), /*sandbox*/ None)
        .await
        .map_err(map_fs_error)?;
    Ok(FsReadFileResponse {
        data_base64: STANDARD.encode(bytes),
    })
}

pub(crate) async fn write_file(
    &self,
    params: FsWriteFileParams,
) -> Result<FsWriteFileResponse, JSONRPCErrorError> {
    let bytes = STANDARD.decode(params.data_base64).map_err(|err| {
        invalid_request(format!(
            "fs/writeFile requires valid base64 dataBase64: {err}"
        ))
    })?;
    let path = PathUri::from_abs_path(&params.path);
    self.file_system()?
        .write_file(&path, bytes, Default::default(), /*sandbox*/ None)
        .await
        .map_err(map_fs_error)?;
    Ok(FsWriteFileResponse {})
}

fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
    if err.kind() == io::ErrorKind::InvalidInput {
        invalid_request(err.to_string())
    } else {
        internal_error(err.to_string())
    }
}

FS 调用要求本地 environment 已装配,但传给 ExecutorFileSystem 的 sandbox 参数是 None。这不是 Turn 文件工具的权限路径;它是 App Server host 文件抽象。base64 解码在写入前完成,非法输入属于 invalid request,底层 I/O 的其他失败则映射为 internal error。

3. FS目录与监控 ​

源码位置:codex-rs/app-server/src/request_processors/fs_processor.rs :: read_directory、watch、unwatch、connection_closed

rust
pub(crate) async fn read_directory(
    &self,
    params: FsReadDirectoryParams,
) -> Result<FsReadDirectoryResponse, JSONRPCErrorError> {
    let path = PathUri::from_abs_path(&params.path);
    let entries = self
        .file_system()?
        .read_directory(&path, /*sandbox*/ None)
        .await
        .map_err(map_fs_error)?;
    Ok(FsReadDirectoryResponse {
        entries: entries
            .into_iter()
            .map(|entry| FsReadDirectoryEntry {
                file_name: entry.file_name,
                is_directory: entry.is_directory,
                is_file: entry.is_file,
            })
            .collect(),
    })
}

pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) {
    self.fs_watch_manager.connection_closed(connection_id).await;
}

目录读取不会递归;需要递归复制要使用 fs/copy 并显式设置 recursive。watch 的清理由 FsWatchManager 按 connection id 执行,所以同一个字符串 watch_id 在不同连接上不会互相取消。

4. process/spawn ​

源码位置:codex-rs/app-server-protocol/src/protocol/v2/process.rs :: ProcessSpawnParams、ProcessWriteStdinParams、ProcessOutputDeltaNotification、ProcessExitedNotification

rust
pub struct ProcessSpawnParams {
    pub command: Vec<String>,
    pub process_handle: String,
    pub cwd: AbsolutePathBuf,
    pub tty: bool,
    pub stream_stdin: bool,
    pub stream_stdout_stderr: bool,
    pub output_bytes_cap: Option<Option<usize>>,
    pub timeout_ms: Option<Option<i64>>,
    pub env: Option<HashMap<String, Option<String>>>,
    pub size: Option<ProcessTerminalSize>,
}

pub struct ProcessWriteStdinParams {
    pub process_handle: String,
    pub delta_base64: Option<String>,
    pub close_stdin: bool,
}

pub struct ProcessOutputDeltaNotification {
    pub process_handle: String,
    pub stream: ProcessOutputStream,
    pub delta_base64: String,
    pub cap_reached: bool,
}

pub struct ProcessExitedNotification {
    pub process_handle: String,
    pub exit_code: i32,
    pub stdout: String,
    pub stderr: String,
    pub stdout_cap_reached: bool,
    pub stderr_cap_reached: bool,
}

process/spawn 的 process_handle 是连接范围内的客户端句柄;活动句柄不能重复,进程退出后可以复用。双层 Option 让客户端区分省略默认 cap/timeout 与显式传 null 禁用 cap/timeout。TTY 会隐含 stdin 和 stdout/stderr streaming,初始 size 只有在 tty=true 时有效。

5. process控制 ​

源码位置:codex-rs/app-server/src/request_processors/process_exec_processor.rs :: process_spawn、write_stdin、kill、resize_pty

rust
pub(crate) async fn process_spawn(
    &self,
    request_id: ConnectionRequestId,
    params: ProcessSpawnParams,
) -> Result<(), JSONRPCErrorError> {
    self.require_local_environment()?;
    let ProcessSpawnParams {
        command,
        process_handle,
        cwd,
        tty,
        stream_stdin,
        stream_stdout_stderr,
        output_bytes_cap,
        timeout_ms,
        env: env_overrides,
        size,
    } = params;
    if command.is_empty() {
        return Err(invalid_request("command must not be empty"));
    }
    if process_handle.is_empty() {
        return Err(invalid_request("processHandle must not be empty"));
    }
    if size.is_some() && !tty {
        return Err(invalid_params("process/spawn size requires tty: true"));
    }
    let mut env = std::env::vars().collect::<HashMap<_, _>>();
    if let Some(env_overrides) = env_overrides {
        for (key, value) in env_overrides {
            match value {
                Some(value) => {
                    env.insert(key, value);
                }
                None => {
                    env.remove(&key);
                }
            }
        }
    }
    env.retain(|name, _| !is_non_inheritable_env_var(name));
    let expiration = match timeout_ms {
        Some(Some(timeout_ms)) => match u64::try_from(timeout_ms) {
            Ok(timeout_ms) => timeout_ms.into(),
            Err(_) => {
                return Err(invalid_params(format!(
                    "process/spawn timeoutMs must be non-negative, got {timeout_ms}"
                )));
            }
        },
        Some(None) => ExecExpiration::Cancellation(CancellationToken::new()),
        None => ExecExpiration::DefaultTimeout,
    };
    let output_bytes_cap = output_bytes_cap.unwrap_or(Some(DEFAULT_OUTPUT_BYTES_CAP));
    let size = size.map(terminal_size_from_protocol).transpose()?;
    self.process_exec_manager
        .start(StartProcessParams {
            outgoing: self.outgoing.clone(),
            request_id,
            process_handle,
            command,
            cwd,
            env,
            expiration,
            tty,
            stream_stdin,
            stream_stdout_stderr,
            output_bytes_cap,
            size,
        })
        .await?;
    Ok(())
}

async fn write_stdin(
    &self,
    request_id: ConnectionRequestId,
    params: ProcessWriteStdinParams,
) -> Result<ProcessWriteStdinResponse, JSONRPCErrorError> {
    if params.delta_base64.is_none() && !params.close_stdin {
        return Err(invalid_params(
            "process/writeStdin requires deltaBase64 or closeStdin",
        ));
    }
    let delta = match params.delta_base64 {
        Some(delta_base64) => STANDARD
            .decode(delta_base64)
            .map_err(|err| invalid_params(format!("invalid deltaBase64: {err}")))?,
        None => Vec::new(),
    };
    self.send_control(
        request_id.connection_id,
        params.process_handle,
        ProcessControl::Write {
            delta,
            close_stdin: params.close_stdin,
        },
    )
    .await?;
    Ok(ProcessWriteStdinResponse {})
}

process 控制操作只向连接所属的 process session 发送 control message。writeStdin 可以只关闭 stdin,也可以先写 bytes 再关闭;空请求被拒绝。process/kill 和 process/resizePty 复用同一 control channel,不会创建新的进程。

6. process清理 ​

源码位置:codex-rs/app-server/src/request_processors/process_exec_processor.rs :: connection_closed、run_process

rust
async fn connection_closed(&self, connection_id: ConnectionId) {
    let controls = {
        let mut sessions = self.sessions.lock().await;
        let process_handles = sessions
            .keys()
            .filter(|process_handle| process_handle.connection_id == connection_id)
            .cloned()
            .collect::<Vec<_>>();
        let mut controls = Vec::with_capacity(process_handles.len());
        for process_handle in process_handles {
            if let Some(control) = sessions.remove(&process_handle) {
                controls.push(control);
            }
        }
        controls
    };

    for control in controls {
        let _ = control
            .control_tx
            .send(ProcessControlRequest {
                control: ProcessControl::Kill,
                response_tx: None,
            })
            .await;
    }
}

连接关闭会先从 session map 移除该连接的句柄,再发送 kill control;因此 process 资源是 connection-scoped,而不是全局后台任务。启用 streaming 时,输出通过 process/outputDelta 发送,已经发送的 bytes 不会再次出现在 process/exited;未 streaming 时,退出消息保留捕获输出。

7. command/exec ​

源码位置:codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs :: CommandExecParams、CommandExecResponse、CommandExecOutputDeltaNotification

rust
pub struct CommandExecParams {
    pub command: Vec<String>,
    pub process_id: Option<String>,
    pub tty: bool,
    pub stream_stdin: bool,
    pub stream_stdout_stderr: bool,
    pub output_bytes_cap: Option<usize>,
    pub disable_output_cap: bool,
    pub disable_timeout: bool,
    pub timeout_ms: Option<i64>,
    pub cwd: Option<PathBuf>,
    pub env: Option<HashMap<String, Option<String>>>,
    pub size: Option<CommandExecTerminalSize>,
    pub sandbox_policy: Option<SandboxPolicy>,
    pub permission_profile: Option<String>,
}

pub struct CommandExecResponse {
    pub exit_code: i32,
    pub stdout: String,
    pub stderr: String,
}

command/exec 不创建 Thread/Turn,但使用 server sandbox;process/spawn 则明确是 host 上不带 Codex sandbox 的独立进程。command 参数还支持 sandbox_policy 或 permission_profile 二选一、输出 cap/timeout 的显式禁用和可选 cwd。

源码位置:codex-rs/app-server/src/request_processors/command_exec_processor.rs :: exec_one_off_command_inner

rust
if params.command.is_empty() {
    return Err(invalid_request("command must not be empty"));
}

let CommandExecParams {
    command,
    process_id,
    tty,
    stream_stdin,
    stream_stdout_stderr,
    output_bytes_cap,
    disable_output_cap,
    disable_timeout,
    timeout_ms,
    cwd,
    env: env_overrides,
    size,
    sandbox_policy,
    permission_profile,
} = params;

if sandbox_policy.is_some() && permission_profile.is_some() {
    return Err(invalid_request(
        "`permissionProfile` cannot be combined with `sandboxPolicy`",
    ));
}
if size.is_some() && !tty {
    return Err(invalid_params("command/exec size requires tty: true"));
}
if disable_output_cap && output_bytes_cap.is_some() {
    return Err(invalid_params(
        "command/exec cannot set both outputBytesCap and disableOutputCap",
    ));
}
if disable_timeout && timeout_ms.is_some() {
    return Err(invalid_params(
        "command/exec cannot set both timeoutMs and disableTimeout",
    ));
}

command processor 在创建执行上下文前完成参数互斥检查,再计算 cwd、环境变量、超时、输出 capture policy 和 effective permission profile。后续响应要等进程退出,并保证所有 output delta 已经发送,所以它的 response 时序与 process/spawn 的“启动即返回”不同。

8. 测试与边界 ​

源码位置:codex-rs/app-server/tests/suite/v2/fs.rs :: fs_methods_cover_current_fs_utils_surface、fs_write_file_rejects_invalid_base64、fs_methods_return_error_when_local_environment_is_disabled、fs_watch_directory_reports_changed_child_paths_and_unwatch_stops_notifications

源码位置:codex-rs/app-server/tests/suite/v2/process_exec.rs :: process_spawn_returns_before_exit_and_emits_exit_notification、process_spawn_reports_buffered_output_cap_reached、process_kill_terminates_running_process

源码位置:codex-rs/app-server/tests/suite/v2/thread_shell_command.rs :: thread_shell_command_returns_error_when_local_environment_is_disabled、thread_shell_command_uses_existing_active_turn

text
cd codex-rs
cargo test -p codex-app-server fs_methods_cover_current_fs_utils_surface -- --nocapture --test-threads=1
cargo test -p codex-app-server fs_write_file_rejects_invalid_base64 -- --nocapture --test-threads=1
cargo test -p codex-app-server fs_watch_directory_reports_changed_child_paths_and_unwatch_stops_notifications -- --nocapture --test-threads=1
cargo test -p codex-app-server process_spawn_returns_before_exit_and_emits_exit_notification -- --nocapture --test-threads=1
cargo test -p codex-app-server process_spawn_reports_buffered_output_cap_reached -- --nocapture --test-threads=1
cargo test -p codex-app-server process_kill_terminates_running_process -- --nocapture --test-threads=1
cargo test -p codex-app-server thread_shell_command_returns_error_when_local_environment_is_disabled -- --nocapture --test-threads=1
cargo test -p codex-app-server thread_shell_command_uses_existing_active_turn -- --nocapture --test-threads=1

这些测试分别断言文件读写和 watch 的输入边界、独立 process 的启动/输出/终止时序,以及 Turn command 与独立命令的环境差异。它们不能证明每个操作系统的 PTY 行为、所有 sandbox backend、远程 environment 或任意进程树的终止效果。

9. 源码定位练习 ​

遇到文件请求没有审批,先确认它是否走 FsRequestProcessor;遇到进程启动后 response 立即返回,检查是否是 process/spawn;遇到必须等待退出并接收 command output delta,检查 command/exec。连接断开时,分别沿 FsWatchManager::connection_closed 和 ProcessExecManager::connection_closed 追踪清理,就能看出 watch 与 process 的资源所有权差异。