Skip to content

非交互Exec CLI

从 codex exec 的参数、stdin与配置引导,追踪新建、恢复、分叉和审查线程如何进入 in-process app-server,并形成 JSONL、最终消息与退出码。

基于rust-v0.150.0
CodexRustExecutionCLI

非交互Exec CLI ​

codex exec 不是把交互式 TUI 隐藏起来,而是一个明确的 headless 前端:解析 prompt 和 stdin,加载配置与执行环境,启动 in-process app-server,再选择新建、恢复、分叉或审查线程。它按 --json 选择事件投影,并在不可重试错误、失败或中断时以非零状态退出。

本文面向已掌握 Rust CLI、Clap 和异步 channel 的读者,承接Codex执行体系总览和Unified Exec创建进程。范围是 codex-rs/exec crate 的入口、prompt 输入、配置引导、线程生命周期、事件循环和输出模式,不展开 app-server 内部 turn 实现。读完后,你应能定位“参数如何变成线程请求”“为什么 fork 可以不启动 turn”“stdout 为什么必须是 JSONL”“服务器错误如何影响退出码”。

1. 参数契约 ​

1.1 Cli结构 ​

Cli 同时承载普通 prompt、subcommand、线程来源、JSON 输出、最后消息文件、schema、ephemeral、配置隔离和共享模型/权限参数。--json 的注释直接规定 stdout 每行必须是 JSONL,其他诊断写 stderr。

源码位置:codex-rs/exec/src/cli.rs :: Cli

rust
pub struct Cli {
    #[command(subcommand)]
    pub command: Option<Command>,
    #[arg(long = "strict-config", global = true, default_value_t = false)]
    pub strict_config: bool,
    #[clap(flatten)]
    pub shared: ExecSharedCliOptions,
    #[arg(long = "thread-source", value_name = "SOURCE", global = true)]
    pub thread_source: Option<ThreadSource>,
    #[arg(long = "ignore-user-config", global = true, default_value_t = false)]
    pub ignore_user_config: bool,
    #[arg(long = "ignore-rules", global = true, default_value_t = false)]
    pub ignore_rules: bool,
    #[arg(long = "ephemeral", global = true, default_value_t = false)]
    pub ephemeral: bool,
    #[arg(long = "output-schema", value_name = "FILE", global = true)]
    pub output_schema: Option<PathBuf>,
    #[arg(long = "json", alias = "experimental-json", default_value_t = false, global = true)]
    pub json: bool,
    #[arg(long = "output-last-message", short = 'o', global = true)]
    pub last_message_file: Option<PathBuf>,
    #[arg(value_name = "PROMPT")]
    pub prompt: Option<String>,
}

thread_source 会写入新建或分叉线程的公开元数据,用于区分 user 与 feature 发起的任务;ignore_user_config 和 ignore_rules 分别隔离用户配置与 execpolicy 规则,而不是清空认证或所有运行参数。

1.2 四种入口 ​

源码位置:codex-rs/exec/src/cli.rs :: Command、ForkArgs

rust
pub enum Command {
    Resume(ResumeArgs),
    Fork(ForkArgs),
    Review(ReviewArgs),
}

pub struct ForkArgs {
    pub session_id: String,
    pub images: Vec<PathBuf>,
    pub prompt: Option<String>,
}

没有 subcommand 表示新建线程并提交普通 turn;resume 复用已有线程;fork 从已有历史创建新线程;review 提交专门的 review request。fork 没有 prompt 时只创建线程,不启动模型 turn,因此图片、output schema、last-message 文件和 ephemeral 都被拒绝:这些选项只有发生 turn 时才有消费者。

2. Prompt输入 ​

2.1 stdin三态 ​

源码把 stdin 行为分成 RequiredIfPiped、Forced、OptionalAppend。没有 prompt 时,管道 stdin 成为 prompt;显式 - 强制 stdin;prompt 与管道同时存在时,stdin 被包在 <stdin> 块追加。

源码位置:codex-rs/exec/src/lib.rs :: StdinPromptBehavior、read_prompt_from_stdin

rust
enum StdinPromptBehavior {
    RequiredIfPiped,
    Forced,
    OptionalAppend,
}

fn prompt_with_stdin_context(prompt: &str, stdin_text: &str) -> String {
    let mut combined = format!("{prompt}\n\n<stdin>\n{stdin_text}");
    if !stdin_text.ends_with('\n') {
        combined.push('\n');
    }
    combined.push_str("</stdin>");
    combined
}

空管道输入会退出并提示缺少 prompt;stdin 读取失败也直接写 stderr 并以失败状态结束,不会提交空 turn。

2.2 编码边界 ​

stdin 现在先读为原始字节,再由 decode_prompt_bytes 处理编码。UTF-8 BOM 会被移除;UTF-16LE/BE BOM 会触发对应解码;UTF-32 BOM 被明确拒绝;无 BOM 的非法 UTF-8 会报告第一个无效字节偏移。

源码位置:codex-rs/exec/src/lib.rs :: decode_prompt_bytes、decode_utf16

rust
fn decode_prompt_bytes(input: &[u8]) -> Result<String, PromptDecodeError> {
    let input = input.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(input);

    if input.starts_with(&[0xFF, 0xFE, 0x00, 0x00]) {
        return Err(PromptDecodeError::UnsupportedBom {
            encoding: "UTF-32LE",
        });
    }
    if input.starts_with(&[0x00, 0x00, 0xFE, 0xFF]) {
        return Err(PromptDecodeError::UnsupportedBom {
            encoding: "UTF-32BE",
        });
    }
    if let Some(rest) = input.strip_prefix(&[0xFF, 0xFE]) {
        return decode_utf16(rest, "UTF-16LE", u16::from_le_bytes);
    }
    if let Some(rest) = input.strip_prefix(&[0xFE, 0xFF]) {
        return decode_utf16(rest, "UTF-16BE", u16::from_be_bytes);
    }

    std::str::from_utf8(input)
        .map(str::to_string)
        .map_err(|e| PromptDecodeError::InvalidUtf8 {
            valid_up_to: e.valid_up_to(),
        })
}

因此 prompt 的“空”判断发生在解码后并使用 trim();可解码但只含空白的 Forced/Required 输入仍被拒绝,OptionalAppend 则忽略它并保留位置参数 prompt。

3. 配置与宿主 ​

3.1 Bootstrap身份 ​

run_main 先加载 bootstrap config,再用已有 ChatGPT 身份获取 workspace-managed cloud config。这里刻意禁止 CODEX_API_KEY 参与 cloud config 引导:API key 可以用于后续模型请求,但不能替代可访问 workspace 配置的登录身份。

源码位置:codex-rs/exec/src/lib.rs :: run_exec_session

rust
let bootstrap_auth_config = bootstrap_auth_config(&codex_home, &bootstrap_config)?;
let cloud_config_bundle = cloud_config_bundle_loader_for_storage(
    bootstrap_auth_config,
    /*enable_codex_api_key_env*/ false,
)
.await?;

配置构建后还会检查 execpolicy。认证限制通常由 enforce_login_restrictions 执行,但已经选择 workload identity 时跳过这一步,因为凭据来源不再是普通交互登录。

rust
if !is_workload_identity_selected()
    && let Err(err) = enforce_login_restrictions(&config.auth_config()).await
{
    eprintln!("{err}");
    std::process::exit(1);
}

3.2 执行环境装配 ​

如果启用 --ignore-user-config,环境只从进程环境构造;否则从 CODEX_HOME 发现本地与远程执行环境。构造完成的 EnvironmentManager 和 state DB 一起注入 in-process app-server。

源码位置:codex-rs/exec/src/lib.rs :: run_main

rust
let state_db = codex_core::init_state_db(&config).await;
let environment_manager = if run_loader_overrides.ignore_user_config {
    EnvironmentManager::from_env(Some(local_runtime_paths), config.http_client_factory())
        .await?
} else {
    EnvironmentManager::from_codex_home(
        config.codex_home.clone(),
        Some(local_runtime_paths),
        config.http_client_factory(),
    )
    .await?
};
let in_process_start_args = InProcessClientStartArgs {
    arg0_paths,
    config: std::sync::Arc::new(config.clone()),
    cli_overrides: run_cli_overrides,
    loader_overrides: run_loader_overrides,
    strict_config,
    cloud_config_bundle: run_cloud_config_bundle,
    feedback: CodexFeedback::new(),
    log_db: None,
    state_db: state_db.clone(),
    environment_manager: std::sync::Arc::new(environment_manager),
    config_warnings,
    session_source: SessionSource::Exec,
    enable_codex_api_key_env: true,
    client_name: "codex_exec".to_string(),
    client_version: env!("CARGO_PKG_VERSION").to_string(),
    experimental_api: true,
    mcp_server_openai_form_elicitation: false,
    opt_out_notification_methods: Vec::new(),
    channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
};

前面的 cloud config 禁用 API-key env,后面的 app-server 却启用它,二者并不矛盾:前者保护 workspace 配置身份,后者允许实际模型请求使用 API key。

3.3 Headless审批 ​

非交互模式先把 approval policy 设为 Never,防止命令挂起等待用户操作。但如果完整配置解析出 ApprovalsReviewer::AutoReview,build_exec_config 会重新构建一次,移除 headless Never 覆盖,让自动审查器承担审批。若第一次构建失败,它也只在第二次确实得到 AutoReview 时接受重建结果,否则保留原始错误。

源码位置:codex-rs/exec/src/lib.rs :: build_exec_config

rust
let build_without_headless_approval_policy = || {
    build_config(ConfigOverrides {
        approval_policy: None,
        ..overrides.clone()
    })
};
match build_config(overrides.clone()).await {
    Ok(config)
        if config.approvals_reviewer == ApprovalsReviewer::AutoReview
            && !preserve_headless_approval_policy =>
    {
        build_without_headless_approval_policy().await
    }
    Ok(config) => Ok(config),
    Err(headless_error) if !preserve_headless_approval_policy => {
        match build_without_headless_approval_policy().await {
            Ok(config) if config.approvals_reviewer == ApprovalsReviewer::AutoReview => Ok(config),
            Ok(_) | Err(_) => Err(headless_error),
        }
    }
    Err(headless_error) => Err(headless_error),
}

Exec 对交互式 approval server request 仍明确拒绝;自动审查是配置内的 reviewer,不是 stdin 对话框。

4. 线程生命周期 ​

4.1 启动与降级 ​

run_main 选择 JSON 或人类事件处理器,解析初始操作,然后启动 InProcessAppServerClient。普通执行通过 thread/start 获取 thread,再提交 turn/start;resume 使用 thread/resume 或无 ID 时退回 thread/start。

源码位置:codex-rs/exec/src/lib.rs :: run_main

rust
let mut event_processor: Box<dyn EventProcessor> = match json_mode {
    true => Box::new(EventProcessorWithJsonOutput::new(last_message_file.clone())),
    _ => Box::new(EventProcessorWithHumanOutput::create_with_ansi(
        stderr_with_ansi,
        &config,
        last_message_file.clone(),
    )),
};
let mut client = InProcessAppServerClient::start(in_process_start_args).await?;

持久线程默认请求 ThreadHistoryMode::Paginated,ephemeral 线程不请求历史持久化。若 thread store 返回特定 -32600,说明它不能提供 paginated history 所需的 turns/items API,start_thread 仅清空 history_mode 后重试一次旧模式。

源码位置:codex-rs/exec/src/lib.rs :: start_thread、thread_start_params_from_config

rust
let mut params = thread_start_params_from_config(config, thread_source);
loop {
    match client
        .request_typed(ClientRequest::ThreadStart {
            request_id: request_ids.next(),
            params: params.clone(),
        })
        .await
    {
        Ok(response) => return Ok(response),
        Err(TypedRequestError::Server { source, .. })
            if params.history_mode.is_some()
                && source.code == -32600
                && source.message
                    == "paginated threads require thread/turns/list and thread/items/list support" =>
        {
            params.history_mode = None;
        }
        Err(err) => return Err(format!("thread/start: {err}")),
    }
}

4.2 Resume解析 ​

resume --last 优先从 state DB 分页寻找最新候选,并按 cwd 过滤;数据库完全未命中时才退回 rollout 扫描修复。显式 UUID 直接使用;线程名称先查 state DB 精确标题,再查 rollout metadata,最后通过 thread/list 搜索。

源码位置:codex-rs/exec/src/lib.rs :: resolve_resume_thread_id

rust
if Uuid::parse_str(session_id).is_ok() {
    return Ok(Some(session_id.to_string()));
}
if let Some(state_db) = state_db {
    let cwd = (!args.all).then_some(config.cwd.as_path());
    let resolved = state_db
        .find_thread_by_exact_title(
            session_id,
            &[],
            /*model_providers*/ None,
            /*archived_only*/ false,
            cwd,
        )
        .await?;
    if let Some(thread) = resolved {
        return Ok(Some(thread.id.to_string()));
    }
}

4.3 Fork事务 ​

fork 先用 resume 的解析器找到 source thread,再发送 thread/fork。请求携带当前模型、cwd、workspace roots、权限、thread source,并设置 exclude_turns: true 与 defer_goal_continuation。返回的新 thread ID 与 forked_from_id 用于构造 authoritative SessionConfiguredEvent。

源码位置:codex-rs/exec/src/lib.rs :: run_exec_session

rust
let response: ThreadForkResponse = send_request_with_response(
    &client,
    ClientRequest::ThreadFork {
        request_id: request_ids.next(),
        params: ThreadForkParams {
            thread_id: source_thread_id,
            model: config.model.clone(),
            cwd: Some(config.cwd.to_string_lossy().to_string()),
            runtime_workspace_roots: Some(config.workspace_roots.clone()),
            permissions,
            ephemeral: config.ephemeral,
            thread_source: Some(thread_source.clone()),
            exclude_turns: true,
            defer_goal_continuation: !config.ephemeral,
            ..ThreadForkParams::default()
        },
    },
    "thread/fork",
)
.await?;

ForkOnly 收到 fork response 后不发送 turn/start,而是 unsubscribe、关闭 client,并只输出新 thread 的配置/thread.started 事件。带 prompt 的 fork 才继续普通 UserTurn。

5. 事件与输出 ​

5.1 JSONL边界 ​

EventProcessorWithJsonOutput::emit 用 println! 输出序列化事件;库顶层禁止其他 stdout 打印。警告和日志走 stderr,保证管道消费者可以逐行解析 stdout。

源码位置:codex-rs/exec/src/event_processor_with_jsonl_output.rs :: EventProcessorWithJsonOutput::emit

rust
fn emit(&self, event: ThreadEvent) {
    println!(
        "{}",
        serde_json::to_string(&event).unwrap_or_else(|err| {
            json!({"type": "error", "message": format!("failed to serialize exec json event: {err}")}).to_string()
        })
    );
}

JSON processor 不只是透传 notification。它把 app-server item 映射成稳定的 ThreadEvent,维护 synthetic item ID、todo list、累计 usage、最后 critical error 和 final message。turn/completed 会补齐未结束 item,并依据 Completed/Failed/Interrupted 决定输出 turn.completed、turn.failed 或直接结束。

源码位置:codex-rs/exec/src/event_processor_with_jsonl_output.rs :: collect_thread_events

rust
match notification.turn.status {
    TurnStatus::Completed => {
        if let Some(final_message) =
            Self::final_message_from_turn_items(notification.turn.items.as_slice())
        {
            self.final_message = Some(final_message);
        }
        self.emit_final_message_on_shutdown = true;
        events.push(ThreadEvent::TurnCompleted(TurnCompletedEvent {
            usage: self.usage_from_last_total(),
        }));
        CodexStatus::InitiateShutdown
    }
    TurnStatus::Failed => {
        self.final_message = None;
        self.emit_final_message_on_shutdown = false;
        events.push(ThreadEvent::TurnFailed(TurnFailedEvent { error }));
        CodexStatus::InitiateShutdown
    }
    TurnStatus::Interrupted => {
        self.final_message = None;
        self.emit_final_message_on_shutdown = false;
        CodexStatus::InitiateShutdown
    }
    TurnStatus::InProgress => CodexStatus::Running,
}

5.2 Backfill与Lag ​

in-process channel 可能在背压下丢失非终态 item notification,但保证 turn/completed。对于非 ephemeral 线程,如果 completion 的 items_view 不是 Full,Exec 会在关闭前调用 thread/read(include_turns=true),把目标 turn 的 items 填回 notification,恢复最终消息和仍未闭合的 item。

源码位置:codex-rs/exec/src/lib.rs :: maybe_backfill_turn_completed_items

rust
if !should_backfill_turn_completed_items(thread_ephemeral, notification) {
    return;
}

let response = send_request_with_response::<ThreadReadResponse>(
    client,
    ClientRequest::ThreadRead {
        request_id: request_ids.next(),
        params: ThreadReadParams {
            thread_id: payload.thread_id.clone(),
            include_turns: true,
        },
    },
    "thread/read",
)
.await;

match response {
    Ok(response) => {
        if let Some(items) = turn_items_for_thread(&response.thread, &payload.turn.id) {
            payload.turn.items = items;
        }
    }
    Err(err) => {
        warn!("thread/read failed while backfilling turn items for turn completion: {err}");
    }
}

若 in-process receiver 自己报告 Lagged { skipped },CLI 会把它转成 warning:human 模式写 stderr,JSON 模式形成非致命 error item。ephemeral 线程没有 rollout-backed history,因此不会尝试 backfill。

6. 结束与退出码 ​

事件循环监听 Ctrl-C、server request、notification 和 channel lag。只有属于 primary thread/turn 且 will_retry == false 的 Error notification 才设置 error_seen;匹配的 TurnCompleted 若状态为 Failed 或 Interrupted,也设置失败。其他线程和可重试错误仍可输出事件,但不改变最终退出码。

源码位置:codex-rs/exec/src/lib.rs :: run_main

rust
if let ServerNotification::Error(payload) = &notification {
    if payload.thread_id == primary_thread_id_for_requests
        && payload.turn_id == task_id
        && !payload.will_retry
    {
        error_seen = true;
    }
} else if let ServerNotification::TurnCompleted(payload) = &notification
    && payload.thread_id == primary_thread_id_for_requests
    && payload.turn.id == task_id
    && matches!(
        payload.turn.status,
        TurnStatus::Failed | TurnStatus::Interrupted
    )
{
    error_seen = true;
}

if let Err(err) = client.shutdown().await {
    warn!("in-process app-server shutdown failed: {err}");
}
event_processor.print_final_output();
if error_seen {
    std::process::exit(1);
}

Ctrl-C 只发送 turn/interrupt;最终非零状态来自随后匹配的 Interrupted completion。headless 模式无法处理 exec/patch/permissions 等交互式 approval request,handle_server_request 会拒绝请求,并在响应失败时设置 error_seen。

7. 验证 ​

7.1 stdin与编码 ​

prompt_stdin 的 6 项测试覆盖 prompt + 管道追加、空管道忽略、显式 -、无 prompt 管道输入和两种空 stdin 拒绝,断言最终模型请求中的文本及 stderr 错误。另 6 项解码测试覆盖 UTF-8 BOM、UTF-16LE/BE、UTF-32LE/BE 拒绝和非法 UTF-8 偏移。

源码位置:

  • codex-rs/exec/tests/suite/prompt_stdin.rs
  • codex-rs/exec/src/lib_tests.rs :: decode_prompt_bytes_*
text
cd codex-rs
cargo test -p codex-exec --test all prompt_stdin -- --test-threads=1
cargo test -p codex-exec --lib decode_prompt_bytes_ -- --test-threads=1

7.2 History降级 ​

thread_start_params_match_history_to_persistence 断言持久线程请求 paginated history 并保留 thread source,而 ephemeral 不请求 history。集成测试使用不支持分页的 in-memory store,断言 CLI 收到特定错误后降级旧 history 并成功运行。

源码位置:

  • codex-rs/exec/src/lib_tests.rs :: thread_start_params_match_history_to_persistence
  • codex-rs/exec/tests/suite/resume.rs :: exec_falls_back_to_legacy_history_when_thread_store_cannot_paginate
text
cd codex-rs
cargo test -p codex-exec --lib thread_start_params_match_history_to_persistence -- --test-threads=1
cargo test -p codex-exec --test all exec_falls_back_to_legacy_history_when_thread_store_cannot_paginate -- --test-threads=1

7.3 Fork事务 ​

CLI 单测证明 fork 后仍能解析 global JSON/model/thread-source/ephemeral flags。集成测试创建 source thread,检查 promptless fork 只输出新 thread ID且不请求模型;带 prompt 的 named fork 生成独立 rollout,记录 forked_from_id、history base 和新 thread source,同时 source rollout 保持不变。

源码位置:

  • codex-rs/exec/src/cli_tests.rs :: fork_parses_prompt_after_global_flags
  • codex-rs/exec/tests/suite/resume.rs :: exec_fork_creates_distinct_threads_with_and_without_a_prompt
text
cd codex-rs
cargo test -p codex-exec --lib fork_parses_prompt_after_global_flags -- --test-threads=1
cargo test -p codex-exec --test all exec_fork_creates_distinct_threads_with_and_without_a_prompt -- --test-threads=1

7.4 JSON与失败 ​

JSON processor 的 3 项测试断言失败 turn 不覆盖 last-message 文件、runtime warning 成为非致命 error item、MCP result meta 在 JSONL 中保留。server error 集成测试让 Responses 返回 response.failed,断言 CLI 退出码为 1。

源码位置:codex-rs/exec/tests/suite/server_error_exit.rs、codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs

text
cd codex-rs
cargo test -p codex-exec --test all server_error_exit -- --test-threads=1
cargo test -p codex-exec --lib event_processor_with_jsonl_output::tests -- --test-threads=1

这组测试不连接真实 workspace cloud config,也不切换 workload identity。相关 bootstrap 分支来自当前配置与认证源码,不能由本地 mock Responses 测试外推外部身份服务可用性。

8. 源码排查 ​

text
rg -n "struct Cli|enum Command|ForkArgs|StdinPromptBehavior|decode_prompt_bytes" codex-rs/exec/src
rg -n "start_thread|ThreadHistoryMode|ThreadFork|resolve_resume_thread_id" codex-rs/exec/src/lib.rs
rg -n "maybe_backfill_turn_completed_items|error_seen|print_final_output" codex-rs/exec/src

非交互 CLI 的主线是:参数、stdin 和 bootstrap config 形成执行宿主;新建、恢复、分叉或审查决定线程请求;in-process client 提交可选 turn;事件处理器通过 backfill 把通知投影为人类输出或 JSONL;匹配主任务的错误状态最终转换为进程退出码。下一篇将继续分析 Exec CLI 的 JSONL 事件模型。