非交互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
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
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
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
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
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 时跳过这一步,因为凭据来源不再是普通交互登录。
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
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
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
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
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
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
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
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
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
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
if let ServerNotification::Error(payload) = ¬ification {
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) = ¬ification
&& 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.rscodex-rs/exec/src/lib_tests.rs :: decode_prompt_bytes_*
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=17.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_persistencecodex-rs/exec/tests/suite/resume.rs :: exec_falls_back_to_legacy_history_when_thread_store_cannot_paginate
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=17.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_flagscodex-rs/exec/tests/suite/resume.rs :: exec_fork_creates_distinct_threads_with_and_without_a_prompt
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=17.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
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. 源码排查
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 事件模型。
