Skip to content

UserShellTask流程

追踪用户 shell 命令如何选择独立或辅助执行模式、绕过沙箱、发送执行事件并写回会话历史。

基于rust-v0.150.0
CodexRustRuntime

UserShellTask流程 ​

Op::RunUserShellCommand 表示用户直接要求 Codex 运行一段 shell 脚本。它与模型调用 shell_command 工具 不是同一条安全路径:用户 shell 不进入工具审批,不使用模型命令的沙箱配置,而是作为显式 full-access 入口在所选本地环境的 shell 中执行。

这条路径还有两种生命周期形态:Session 空闲时创建独立 UserShellCommandTask;已有 Turn 正在运行时, 命令作为 auxiliary 异步执行,复用活动 Turn 的上下文和取消 token,不替换原任务。

本文假定读者已了解 Turn主循环与退出条件 和 Codex信任边界。范围只覆盖用户显式 shell 请求的 handler、 两种执行模式、事件与历史写回;模型发起的 shell 工具由信任边界专题负责。读完后应能判断命令由谁 拥有、在哪个时机消费,以及为什么它不会替换活动 RegularTask。

1. Handler ​

submission loop 将 Op::RunUserShellCommand 交给 handlers::run_user_shell_command。handler 不总是调用 spawn_task:活动 Turn 存在时,它直接 tokio::spawn 一个 auxiliary future;只有 Session 空闲时才创建 新的默认 TurnContext 和 UserShellCommandTask。

源码位置:codex-rs/core/src/session/handlers.rs :: run_user_shell_command

rust
pub async fn run_user_shell_command(sess: &Arc<Session>, sub_id: String, command: String) {
    if let Some((turn_context, cancellation_token)) =
        sess.active_turn_context_and_cancellation_token().await
    {
        let session = Arc::clone(sess);
        tokio::spawn(async move {
            execute_user_shell_command(
                session,
                turn_context,
                command,
                cancellation_token,
                UserShellCommandMode::ActiveTurnAuxiliary,
            )
            .await;
        });
        return;
    }

    let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
    sess.spawn_task(
        Arc::clone(&turn_context),
        Vec::new(),
        UserShellCommandTask::new(command),
    )
    .await;
}

auxiliary 分支不占用新的 RunningTask 槽,因此不会以 Replaced 中止当前 RegularTask。测试 user_shell_command_does_not_replace_active_turn 让模型工具仍在运行时提交用户 shell,断言没有 TurnAborted(Replaced),用户命令完成后原 Turn 仍继续下一次模型请求。

2. 两种执行模式 ​

UserShellCommandTask 实现 SessionTask,但 kind() 返回 TaskKind::Regular。它的 run 只把命令交给 统一执行器,并固定使用 StandaloneTurn。auxiliary 则绕过这个 task wrapper,直接传入 ActiveTurnAuxiliary。

源码位置:codex-rs/core/src/tasks/user_shell.rs :: UserShellCommandTask

rust
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum UserShellCommandMode {
    StandaloneTurn,
    ActiveTurnAuxiliary,
}

#[derive(Clone)]
pub(crate) struct UserShellCommandTask {
    command: String,
}

impl SessionTask for UserShellCommandTask {
    fn kind(&self) -> TaskKind {
        TaskKind::Regular
    }

    fn span_name(&self) -> &'static str {
        "session_task.user_shell"
    }

    async fn run(
        self: Arc<Self>,
        session: Arc<Session>,
        turn_context: Arc<TurnContext>,
        _input: Vec<TurnInput>,
        cancellation_token: CancellationToken,
    ) -> SessionTaskResult {
        execute_user_shell_command(
            session,
            turn_context,
            self.command.clone(),
            cancellation_token,
            UserShellCommandMode::StandaloneTurn,
        )
        .await;
        Ok(None)
    }
}

standalone 模式进入执行器后显式发送 TurnStarted;最终 TurnComplete 由公共 task 生命周期发出。 auxiliary 已位于一个开始过的 Turn 中,不能再发送第二组 Turn 起止事件。

源码位置:codex-rs/core/src/tasks/user_shell.rs :: execute_user_shell_command

rust
if mode == UserShellCommandMode::StandaloneTurn {
    let event = EventMsg::TurnStarted(TurnStartedEvent {
        turn_id: turn_context.sub_id.clone(),
        trace_id: turn_context.trace_id.clone(),
        started_at: turn_context.turn_timing_state.started_at_unix_secs().await,
        model_context_window: turn_context.model_context_window(),
        collaboration_mode_kind: turn_context.mode,
    });
    session.send_event(turn_context.as_ref(), event).await;
}

说明: 当前源码保留 TODO:standalone /shell 发出 TurnStarted 后,还没有像普通用户 Turn 那样注入 新的 model-visible context diff。测试 run_user_shell_command_does_not_set_reference_context_item 也确认 standalone shell 不会修改 Session 的 previous reference context item。

3. 环境与Shell ​

执行器只接受带本地环境且配置了 shell 的 TurnContext。没有本地环境时发送 shell is unavailable in this session;环境 cwd 不能投影为 Codex host 原生路径时发送另一条 Error。这两个 错误都发生在 CommandExecution item 创建之前。

源码位置:codex-rs/core/src/tasks/user_shell.rs :: execute_user_shell_command

rust
let Some((turn_environment, environment_shell)) = turn_context
    .environments
    .local()
    .and_then(|environment| environment.shell.as_ref().map(|shell| (environment, shell)))
else {
    send_user_shell_error(
        &session,
        turn_context.as_ref(),
        "shell is unavailable in this session",
    )
    .await;
    return;
};

let use_login_shell = true;
let display_command = environment_shell.derive_exec_args(&command, use_login_shell);
let Ok(cwd) = turn_environment.cwd().to_abs_path() else {
    send_user_shell_error(
        &session,
        turn_context.as_ref(),
        "shell working directory is not native to the Codex host",
    )
    .await;
    return;
};

use_login_shell 固定为 true,不受模型工具的 allow_login_shell 配置影响。测试 user_shell_commands_remain_login_shells_when_model_login_shells_are_disabled 直接比较 begin event 中的命令 参数,证明用户 shell 仍使用 login shell。shell snapshot 存在时,prepare_user_shell_exec_command 会把 runtime 自己追加的 PATH 项重新应用到 snapshot PATH 前面。

4. 安全边界 ​

用户 shell 在构造 ExecRequest 时明确选择 PermissionProfile::Disabled、SandboxType::None 和 network: None。源码注释称它为 explicit full-access escape hatch。这表示它不会继承当前 Turn 的文件 沙箱或 managed network,也不会进入模型工具的审批流程。

源码位置:codex-rs/core/src/tasks/user_shell.rs :: execute_user_shell_command

rust
let permission_profile = PermissionProfile::Disabled;
let exec_env = ExecRequest {
    command: exec_command.clone(),
    cwd: cwd.clone().into(),
    env: exec_env_map,
    exec_server_env_config: None,
    network: None,
    network_environment_id: None,
    expiration: USER_SHELL_TIMEOUT_MS.into(),
    capture_policy: ExecCapturePolicy::ShellTool,
    sandbox: SandboxType::None,
    windows_sandbox_policy_cwd: cwd.clone().into(),
    windows_sandbox_workspace_roots: turn_context.config.effective_workspace_roots(),
    windows_sandbox_level: turn_context.windows_sandbox_level,
    windows_sandbox_private_desktop: turn_context
        .config
        .permissions
        .windows_sandbox_private_desktop,
    permission_profile,
    windows_sandbox_filesystem_overrides: None,
    arg0: None,
    exec_server_sandbox: None,
    exec_server_enforce_managed_network: false,
    exec_server_managed_network: None,
    exec_server_network_proxy: None,
};

环境构造仍遵循 shell_environment_policy,但发现 managed proxy 标记时会调用 strip_managed_proxy_env。测试 user_shell_commands_do_not_inherit_managed_network_proxy 检查 HTTP_PROXY 没有进入进程;user_shell_command_does_not_set_network_sandbox_env_var 检查 CODEX_SANDBOX_NETWORK_DISABLED 也没有被设置。

说明: “没有 managed network”不等于“禁止联网”。这里恰好相反:network: None 与 SandboxType::None 表示该显式用户命令不受 Codex managed network 和文件沙箱约束。调用者必须把 /shell 当作直接在本机 shell 执行命令的入口。

5. 执行事件 ​

执行前生成 UUID call ID,并发送状态为 InProgress 的 CommandExecutionItem。事件映射层会把 item start 投影为 ExecCommandBegin。StdoutStream 把同一个 sub ID、call ID 和 event sender 交给执行器,读取 stdout/stderr 时产生 ExecCommandOutputDelta。最终 item completion 再投影为 ExecCommandEnd。

源码位置:codex-rs/core/src/tasks/user_shell.rs :: execute_user_shell_command

rust
let call_id = Uuid::new_v4().to_string();
let raw_command = command;
let parsed_cmd = parse_command(&display_command);
session
    .emit_turn_item_started(
        turn_context.as_ref(),
        &TurnItem::CommandExecution(CommandExecutionItem {
            id: call_id.clone(),
            plugin_id: None,
            script_path: None,
            process_id: None,
            command: display_command.clone(),
            cwd: cwd.clone().into(),
            parsed_cmd: parsed_cmd.clone(),
            source: ExecCommandSource::UserShell,
            interaction_input: None,
            status: CommandExecutionStatus::InProgress,
            stdout: None,
            stderr: None,
            aggregated_output: None,
            exit_code: None,
            duration: None,
            formatted_output: None,
        }),
    )
    .await;

let stdout_stream = Some(StdoutStream {
    sub_id: turn_context.sub_id.clone(),
    call_id: call_id.clone(),
    tx_event: session.get_tx_event(),
});
let exec_result = execute_exec_request(exec_env, stdout_stream, /*after_spawn*/ None)
    .or_cancel(&cancellation_token)
    .await;

ExecCommandSource::UserShell 让客户端能区分用户主动命令与 agent 工具命令。begin event 的 command 是 shell 派生后的 argv,历史中保存的则是用户原始 script;二者服务不同消费者,不能混用。

6. 退出结果 ​

成功执行和非零退出都进入 Ok(Ok(output)):exit code 为 0 时 item 状态是 Completed,否则是 Failed, 但两者都会保留真实 stdout、stderr、aggregated output、duration 和 exit code。底层执行错误使用合成 exit code -1;取消同样使用 -1,stderr 和 aggregated output 都是 command aborted by user。

源码位置:codex-rs/core/src/tasks/user_shell.rs :: execute_user_shell_command

rust
match exec_result {
    Err(CancelErr::Cancelled) => {
        let aborted_message = "command aborted by user".to_string();
        let exec_output = ExecToolCallOutput {
            exit_code: -1,
            stdout: StreamOutput::new(String::new()),
            stderr: StreamOutput::new(aborted_message.clone()),
            aggregated_output: StreamOutput::new(aborted_message.clone()),
            duration: Duration::ZERO,
            timed_out: false,
        };
        persist_user_shell_output(
            &session,
            turn_context.as_ref(),
            &raw_command,
            &exec_output,
            mode,
        )
        .await;
        // ...
    }
    Ok(Ok(output)) => {
        // ...
        persist_user_shell_output(
            &session,
            turn_context.as_ref(),
            &raw_command,
            &output,
            mode,
        )
        .await;
    }
    Ok(Err(err)) => {
        let message = format!("execution error: {err:?}");
        let exec_output = ExecToolCallOutput {
            exit_code: -1,
            stdout: StreamOutput::new(String::new()),
            stderr: StreamOutput::new(message.clone()),
            aggregated_output: StreamOutput::new(message.clone()),
            duration: Duration::ZERO,
            timed_out: false,
        };
        // ...
    }
}

取消分支先持久化合成输出,再发送 completed item;正常和底层错误分支则先发送 completed item,再持久化。 无论哪条分支,UserShellCommandTask::run 最终都返回 Ok(None),因此命令失败通过 CommandExecution 状态和 输出表达,不会把 SessionTaskResult 变成普通 task error。standalone 命令被 interrupt 时,公共 task 生命周期还会发送 TurnAborted(Interrupted)。

7. 历史输出路径 ​

persist_user_shell_output 先调用 user_shell_command_record_item,把原始命令和格式化结果构造成 role 为 user 的 contextual fragment。standalone 模式直接写 conversation history 并确保 rollout 已物化; auxiliary 模式调用 inject_no_new_turn,让正在运行的 Turn 在后续采样前消费这条输入,而不创建新 Turn。

源码位置:codex-rs/core/src/tasks/user_shell.rs :: persist_user_shell_output

rust
async fn persist_user_shell_output(
    session: &Session,
    turn_context: &TurnContext,
    raw_command: &str,
    exec_output: &ExecToolCallOutput,
    mode: UserShellCommandMode,
) {
    let output_item = user_shell_command_record_item(raw_command, exec_output, turn_context);

    if mode == UserShellCommandMode::StandaloneTurn {
        session
            .record_conversation_items(turn_context, std::slice::from_ref(&output_item))
            .await;
        session.ensure_rollout_materialized().await;
        return;
    }

    session
        .inject_no_new_turn(vec![output_item], Some(turn_context))
        .await;
}

fragment 的最终模型可见格式是:

源码位置:codex-rs/core/src/context/user_shell_command.rs :: ContextualUserFragment for UserShellCommand

rust
fn type_markers() -> (&'static str, &'static str) {
    ("<user_shell_command>", "</user_shell_command>")
}

fn body(&self) -> String {
    format!(
        "\n<command>\n{}\n</command>\n<result>\nExit code: {}\nDuration: {:.4} seconds\nOutput:\n{}\n</result>\n",
        self.command, self.exit_code, self.duration_seconds, self.output,
    )
}

记录使用 aggregated_output 的格式化结果,而不是简单拼接 stdout 和 stderr。格式化过程按模型 truncation policy 截断,因此事件流可以展示完整捕获结果,后续模型历史只携带受限长度。测试 user_shell_command_output_is_truncated_in_history 验证头尾截断格式;user_shell_command_is_truncated_only_once 验证后续 shell 工具不会重复添加第二个 truncation header。

8. 测试与诊断 ​

关键测试覆盖如下:

  1. user_shell_cmd_ls_and_cat_in_temp_dir:真实 cwd、stdout 和 exit code。
  2. user_shell_command_without_local_environment_emits_error:没有 local environment 时提前失败。
  3. user_shell_command_does_not_replace_active_turn:auxiliary 不替换活动 Turn。
  4. user_shell_cmd_can_be_interrupted:standalone 命令共享 task cancellation,并发出 Interrupted abort。
  5. user_shell_command_history_is_persisted_and_shared_with_model:输出写入历史并进入后续模型请求。
  6. user_shell_commands_do_not_inherit_managed_network_proxy:不继承 managed proxy。
  7. user_shell_command_does_not_set_network_sandbox_env_var:不伪装为受限网络进程。
  8. user_shell_snapshot_preserves_package_path_prepend:snapshot 恢复后仍保留 runtime PATH prepend。
  9. run_user_shell_command_does_not_set_reference_context_item:standalone shell 不改写 previous context reference。

排查时先按阶段定位:没有 begin event,检查 local environment、shell 和 native cwd;有 begin/delta 但没有 end, 检查执行器、超时和 cancellation;有 end 但后续模型看不到结果,检查 standalone 的 record_conversation_items 或 auxiliary 的 inject_no_new_turn,以及 fragment truncation。

9. 命令路径验证 ​

  1. Session 已有活动 Turn 时,为什么 handler 不能直接 spawn_task(UserShellCommandTask)?
  2. PermissionProfile::Disabled、SandboxType::None 和 network: None 在本路径中共同表达什么安全语义?
  3. begin event 的 command argv 与历史中的 raw command 为什么不同?分别由谁消费?
  4. standalone 和 auxiliary 最终如何把同一个 ResponseItem 交给不同的历史入口?
  5. 取消命令时,CommandExecution failed、合成 exit code -1 和 TurnAborted 分别属于哪一层生命周期?

可以用下面的只读搜索把本文的 user shell task 主线落回源码:

bash
rg -n "RunUserShellCommand|UserShellCommandTask|ActiveTurnAuxiliary|execute_user_shell_command" codex-rs/core/src

如果问题集中在命令启动前如何恢复本地 shell 的函数、alias、PATH 和环境覆盖,继续阅读 Shell快照与命令环境。