ARTICLE · 1240759
Codex 源码-Cloud Tasks + External Agent Migration
Codex 源码解析系列
第 30 讲:Cloud Tasks + External Agent Migration
基于 OpenAI Codex 源码 · 2026-10-24
💡 本讲一句话:Codex 有两条"把活干完"的暗线——一条是 codex cloud:任务丢到云端环境跑,best-of-N 出多个 diff,本地挑一个 apply;另一条是 external-agent-migration:把你从 Claude Code / Cursor 带来的配置、技能、插件、会话安全地搬进 Codex。本讲把这两套子系统的真实源码逐行拆开。
一、codex cloud:五个动词的 CLI 面
cloud-tasks crate(约 5K LOC)是 codex cloud 子命令的实现。整个 CLI 面只有五个动词,全部定义在 cli.rs:
📄 codex-rs/cloud-tasks/src/cli.rs (第 15-27 行)
#[derive(Debug, clap::Subcommand)] // 派生 clap 子命令枚举,五个动词就是全部 CLI 面pub enum Command { // 每个变体对应一个云端任务生命周期动作 /// Submit a new Codex Cloud task without launching the TUI. // exec:提交新任务,纯命令行不拉起 TUI Exec(ExecCommand), // 入参含 query/env/attempts/branch(见下文) /// Show the status of a Codex Cloud task. // status:查单个任务状态 Status(StatusCommand), // 非 READY 时进程以退出码 1 结束,方便脚本判断 /// List Codex Cloud tasks. // list:分页列出任务,支持 --json 给机器消费 List(ListCommand), // cursor 游标翻页,limit 上限 20 /// Apply the diff for a Codex Cloud task locally. // apply:把云端产出的 diff 落到本地工作区 Apply(ApplyCommand), // 可选 --attempt 指定 best-of-N 中的第几个 /// Show the unified diff for a Codex Cloud task. // diff:只打印统一 diff,不落盘 Diff(DiffCommand), // apply 之前的"先看一眼"动作}为什么这样设计:五个动词恰好覆盖云端任务的完整生命周期——提交(exec)、观察(status/list/diff)、落地(apply)。注意 diff 和 apply 都接受 --attempt N:因为一个任务可以跑 N 次(best-of-N),产出 N 个候选 diff,用户有权挑。这个"多候选 + 显式选择"的模型贯穿整个 crate。
五个子命令的行为对照:
| exec | ||
| status | ||
| list | ||
| diff | ||
| apply |
二、init_backend:认证是硬门槛,mock 只给调试构建
每个子命令的第一步都是 init_backend()。它做三件事:解析 base_url、加载 ChatGPT 登录态、组装 HTTP 客户端。注意 mock 分支被 #[cfg(debug_assertions)] 包着——release 构建里根本不存在这条路径:
📄 codex-rs/cloud-tasks/src/lib.rs (第 48-71 行)
async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext> { // 每个子命令的入口:组装后端上下文 #[cfg(debug_assertions)] // mock 开关只在调试构建编译进二进制,release 里不存在这条路径 let use_mock = matches!( // 读环境变量判断是否走 MockClient(CI/本地联调用) std::env::var("CODEX_CLOUD_TASKS_MODE").ok().as_deref(), // 取不到变量时 ok()=None,matches! 自然为 false Some("mock") | Some("MOCK") // 大小写两种写法都认,降低使用摩擦 ); let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") // 允许整体换后端(私有部署/测试环境) .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); // 默认指向 ChatGPT 的 backend-api let base_url = util::validate_chatgpt_base_url(&base_url)?; // 校验 URL 合法性,非法直接报错而不是带病运行 set_user_agent_suffix(user_agent_suffix); // 给 UA 追加子命令标识(exec/status/...),服务端可区分调用来源 #[cfg(debug_assertions)] // mock 分支同样只在调试构建存在 if use_mock { // 命中 mock:返回 MockClient,不发任何真实网络请求 let http_client_factory = HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault); // 默认代理策略的工厂 return Ok(BackendContext { // 提前返回,跳过后面全部认证逻辑 backend: Arc::new(codex_cloud_tasks_mock_client::MockClient), // Mock 实现与真实客户端同 trait,调用方无感 base_url, // 保留 URL 用于拼任务链接展示 environment_http: RouteAwareClientPool::new_without_redirects_or_request_logging( // 环境列表请求走独立连接池 http_client_factory, // 复用同一工厂 ClientRouteClass::Api, // 标记为 API 级路由(区别于静态资源等) ), }); }为什么这样设计:CloudBackend 是 trait,真实 HttpClient 和 MockClient 都实现它——调用方代码对"后端是谁"完全无感。把 mock 用编译期开关隔离,保证发布二进制里连 MockClient 的代码都不存在,攻击面更小。
📄 codex-rs/cloud-tasks/src/lib.rs (第 73-124 行)
let ua = get_codex_user_agent(); // 取 Codex 统一 UA,服务端据此识别客户端版本 let (auth_manager, http_client_factory) = util::load_auth_manager(Some(base_url.clone())).await; // 加载登录态管理器(ChatGPT 凭证) let environment_http = RouteAwareClientPool::new_without_redirects_or_request_logging( // 环境列表接口单独一个池:禁重定向、不记请求日志 http_client_factory.clone(), // 与任务 API 共享底层工厂,但路由策略独立 ClientRouteClass::Api, // API 级路由分类 ); let mut http = codex_cloud_tasks_client::HttpClient::new(base_url.clone(), http_client_factory) // 构造云任务 HTTP 客户端 .with_user_agent(ua); // 注入 UA let style = if base_url.contains("/backend-api") { // 按 URL 形态判断后端风格,只用于日志 "wham" // backend-api 路径走 wham 风格路由 } else { "codex-api" // 否则是 codex-api 风格(两种前缀在 env_detect 里都会拼) }; append_error_log(format!("startup: base_url={base_url} path_style={style}")); // 启动信息落错误日志,出问题时能还原现场 let auth = match auth_manager.as_ref() { // 从管理器取当前登录凭证 Some(manager) => manager.auth().await, // 有管理器就异步取 auth None => None, // 没有管理器(未配置)直接给 None,走下面的报错分支 }; let auth = match auth { // 二次匹配:把"没登录"变成硬失败 Some(auth) => auth, // 有凭证继续 None => { // 没登录:明确提示 + 退出码 1,fail loud 而不是发一堆 401 eprintln!( "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." ); std::process::exit(1); // 直接终止进程,避免后续每个请求都失败 } }; if let Some(acc) = auth.get_account_id() { // 有账号 ID 就记日志(脱敏排查用) append_error_log(format!("auth: mode=ChatGPT account_id={acc}")); } if !auth.uses_codex_backend() { // 云任务后端只认 ChatGPT 登录态,其他凭证类型一律拒绝 eprintln!( "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." ); std::process::exit(1); // 同样硬失败:宁可报错也不发无效请求 } let auth_provider = codex_model_provider::auth_provider_from_auth(&auth); // 把登录态转成 HTTP 层的认证提供者(自动带 token) http = http.with_auth_provider(auth_provider); // 挂到客户端上,后续所有请求自动携带凭证 if let Some(acc) = auth.get_account_id() { // 再记一条:确认 ChatGPT-Account-Id 头已设置 append_error_log(format!("auth: set ChatGPT-Account-Id header: {acc}")); } Ok(BackendContext { // 组装完成:后端 + URL + 环境请求池三件套 backend: Arc::new(http), // trait object 装进 Arc,TUI/CLI 多处共享同一实例 base_url, // 用于拼任务 URL(exec 成功后打印给用户) environment_http, // 环境列表专用连接池 })为什么这样设计:"未登录"被处理成 exit(1) 而不是返回错误让调用方处理——因为 CLI 场景下,没登录就没有任何后续动作有意义,fail loud + 明确指引(codex login)比层层传播 Option 更直接。另外注意环境列表请求和任务 API 用了两个连接池:前者是"浏览型"低频请求,后者是核心链路,隔离后互不拖累。
三、环境自动检测:git origin → by-repo → 全量列表的三级降级
--env 参数允许用户只给一个 label,甚至不给。env_detect.rs 的策略是:先用本地 git origin 查"这个仓库专属的环境",查不到再退回全量列表:
📄 codex-rs/cloud-tasks/src/env_detect.rs (第 50-93 行)
// 1) Try repo-specific environments based on local git origins (GitHub only, like VSCode) crate::append_error_log(format!("env: git origins: {origins:?}")); // 把解析出的 origin 记日志,排查"为什么选了错环境" let mut by_repo_envs: Vec<CodeEnvironment> = Vec::new(); // 汇总所有 origin 命中的仓库专属环境 for origin in origins { // 逐个 origin 尝试(一个仓库可能有多个 remote) if let Some((owner, repo)) = parse_owner_repo(origin.as_str()) { // 解析出 owner/repo;非 GitHub 形态直接跳过 let url = if base_url.contains("/backend-api") { // 按后端风格拼 by-repo 查询 URL format!( "{}/wham/environments/by-repo/{}/{}/{}", // wham 风格:/wham/environments/by-repo/github/owner/repo base_url, "github", owner, repo ) } else { format!( "{}/api/codex/environments/by-repo/{}/{}/{}", // codex-api 风格的等价路径 base_url, "github", owner, repo ) }; crate::append_error_log(format!("env: GET {url}")); // 记录实际请求的 URL match get_json::<Vec<CodeEnvironment>>(http, &url, headers).await { // 发起请求并反序列化环境列表 Ok(mut list) => { // 成功:把该 origin 的环境并入汇总 crate::append_error_log(format!( "env: by-repo returned {} env(s) for {owner}/{repo}", // 记录命中数量,便于确认走了哪条路径 list.len(), )); by_repo_envs.append(&mut list); // 追加到汇总列表(多个 origin 可能指向同一仓库) } Err(e) => crate::append_error_log(format!( "env: by-repo fetch failed for {owner}/{repo}: {e}" // 单个 origin 失败不致命:记日志后继续下一个 )), } } } if let Some(env) = pick_environment_row(&by_repo_envs, desired_label.as_deref()) { // 仓库专属列表里能选出环境就直接返回 return Ok(AutodetectSelection { // 命中即短路,不再请求全量列表(省一次网络往返) id: env.id.clone(), // 环境 ID label: env.label.as_deref().map(str::to_owned), // 标签(可能没有) }); } // 2) Fallback to the full list // 仓库专属没命中,退回全量环境列表 let list_url = if base_url.contains("/backend-api") { // 同样按后端风格拼 URL format!("{base_url}/wham/environments") // wham:/wham/environments } else { format!("{base_url}/api/codex/environments") // codex-api:/api/codex/environments }; crate::append_error_log(format!("env: GET {list_url}")); // 记录请求 URL为什么这样设计:"仓库专属环境优先"意味着同一个账号在不同仓库里,--env prod 可以指向不同的云端沙箱——环境是跟着代码走的。单个 origin 查询失败只记日志不中断:多 remote 场景下任何一个成功就够了。
拿到候选列表后,pick_environment_row()(env_detect.rs 第 123-158 行)按固定优先级选择:
环境选择优先级(pick_environment_row)
① label 精确匹配(忽略大小写)
用户显式给了 --env 标签时,先按 label 找;命中即返回。
▼
② 唯一候选直接选
列表里只有一个环境时无需犹豫,直接用它。
▼
③ pinned 环境优先
is_pinned=true 的环境是用户"钉住"的默认项,多个候选时它胜出。
▼
④ task_count 最高者兜底
都没有标记时,用历史任务数最多的环境当启发式默认;再不行取第一个。
为什么这样设计:四级优先级把"用户意图 > 唯一事实 > 显式标记 > 统计启发"排得清清楚楚,每一步都 append_error_log——自动选错环境是这类工具最常见的投诉,日志让事后能精确还原"当时为什么选了它"。
四、提交任务:branch 解析 + best-of-N 候选收集
exec 的分支解析很克制——显式参数 > 当前分支 > 默认分支 > "main",四级兜底:
📄 codex-rs/cloud-tasks/src/lib.rs (第 154-176 行)
async fn resolve_git_ref_with_git_info( // 把"跑哪个分支"解析成一个确定值 branch_override: Option<&String>, // CLI --branch 显式指定(可能没有) git_info: &impl GitInfoProvider, // trait 注入:测试可塞假实现,生产用 RealGitInfo) -> String { if let Some(branch) = branch_override { // 用户显式给了分支名就优先用它 let branch = branch.trim(); // 去掉首尾空白(手误多敲空格很常见) if !branch.is_empty() { // trim 后非空才采纳,防止 --branch "" 这种边界值 return branch.to_string(); // 直接返回,不再查 git } } if let Ok(cwd) = std::env::current_dir() { // 取当前工作目录(失败走最外层兜底) if let Some(branch) = git_info.current_branch_name(&cwd).await { // 先问"现在在哪个分支" branch // 有活跃分支就用它——大多数场景用户就是想在当前分支上跑 } else if let Some(branch) = git_info.default_branch_name(&cwd).await { // detached HEAD 等无分支状态:退回默认分支 branch // main/master 这类仓库主分支 } else { "main".to_string() // 连默认分支都探测不到(非 git 目录)就硬编码 main } } else { "main".to_string() // current_dir 都拿不到的极端情况,同样兜底 main }}为什么这样设计:GitInfoProvider trait(lib.rs 第 126-148 行)把 git 查询从函数里抽出来,测试可以注入假实现——这是整个 crate 里"可测性优先"的典型手法。兜底链保证 exec 在任何目录下都能给出一个分支值,不会卡在参数解析上。
📄 codex-rs/cloud-tasks/src/lib.rs (第 178-201 行)
async fn run_exec_command(args: crate::cli::ExecCommand) -> anyhow::Result<()> { // exec 子命令入口 let crate::cli::ExecCommand { // 解构入参:query/env/branch/attempts 四件套 query, environment, branch, attempts, } = args; let ctx = init_backend("codex_cloud_tasks_exec").await?; // 初始化后端(UA 后缀标记来源是 exec) let prompt = resolve_query_input(query)?; // 解析任务描述:参数优先,否则读 stdin(支持管道输入长 prompt) let env_id = resolve_environment_id(&ctx, &environment).await?; // 把用户给的 env id/label 解析成确定的环境 ID let git_ref = resolve_git_ref(branch.as_ref()).await; // 解析分支(见上一节) let created = codex_cloud_tasks_client::CloudBackend::create_task( // 调后端创建任务——核心网络请求就这一发 &*ctx.backend, // trait object 解引用后传入 &env_id, // 目标环境 &prompt, // 任务描述(给云端 Agent 的指令) &git_ref, // 要跑的分支 /*qa_mode*/ false, // CLI 提交不走 QA 模式(TUI 里另有开关) attempts, // best-of-N:1~4,服务端并行跑 N 次产出 N 个候选 diff ) .await?; let url = util::task_url(&ctx.base_url, &created.id.0); // 拼出任务详情页 URL(浏览器里可看进度) println!("{url}"); // exec 的全部输出就这一行:URL。其余交给 status/list/diff/apply Ok(())为什么这样设计:exec 成功后只打印 URL、不轮询——提交和观察彻底解耦。用户可以 codex cloud exec ... && sleep 600 && codex cloud status $ID,也可以开 TUI 盯着看。异步任务的生命周期管理交给用户自己的节奏。
📄 codex-rs/cloud-tasks/src/lib.rs (第 318-359 行)
async fn collect_attempt_diffs( // 收集一个任务的全部候选 diff(best-of-N 的 N 个) backend: &dyn codex_cloud_tasks_client::CloudBackend, // trait object:真实/Mock 客户端通用 task_id: &codex_cloud_tasks_client::TaskId, // 目标任务) -> anyhow::Result<Vec<AttemptDiffData>> { let text = // 先取任务文本元数据(含 turn_id、attempt_placement) codex_cloud_tasks_client::CloudBackend::get_task_text(backend, task_id.clone()).await?; let mut attempts = Vec::new(); // 候选 diff 列表,稍后统一排序 if let Some(diff) = // 取"当前/基础 attempt"的 diff(可能还没有) codex_cloud_tasks_client::CloudBackend::get_task_diff(backend, task_id.clone()).await? { attempts.push(AttemptDiffData { // 有就入列:placement 用于排序,created_at 未知给 None placement: text.attempt_placement, // 服务端给的位次(best-of-N 中的第几位) created_at: None, // 基础 attempt 没有独立时间戳 diff, // diff 文本本体 }); } if let Some(turn_id) = text.turn_id { // 有 turn_id 说明存在同批次的兄弟 attempt let siblings = codex_cloud_tasks_client::CloudBackend::list_sibling_attempts( // 拉取同一 turn 的其他 N-1 个候选 backend, task_id.clone(), turn_id, ) .await?; for sibling in siblings { // 逐个处理兄弟 attempt if let Some(diff) = sibling.diff { // 只收有 diff 的(还在跑的没有) attempts.push(AttemptDiffData { // 入列,带完整元数据 placement: sibling.attempt_placement, // 位次 created_at: sibling.created_at, // 完成时间(placement 缺失时的排序依据) diff, // diff 文本 }); } } } attempts.sort_by(cmp_attempt); // 按 placement 升序排(同值再比 created_at),保证 --attempt N 语义稳定 if attempts.is_empty() { // 一个候选都没有:任务多半还在跑 anyhow::bail!( "No diff available for task {}; it may still be running.", // 明确提示"可能还在运行",而不是干巴巴的 not found task_id.0 ); } Ok(attempts)为什么这样设计:"基础 attempt + 兄弟 attempts"两段式收集,对应服务端的数据模型:当前展示的是其中一个候选,其余 N-1 个挂在同一 turn_id 下。cmp_attempt(lib.rs 第 304-316 行)先比 placement、再比 created_at——排序键有兜底,--attempt 2 在任何数据形态下都指向确定的候选。
📄 codex-rs/cloud-tasks/src/lib.rs (第 361-379 行)
fn select_attempt( // 从候选列表里挑出 --attempt N 指定的那一个 attempts: &[AttemptDiffData], // 已排序的候选列表 attempt: Option<usize>, // CLI --attempt(1-based);None 表示用默认第 1 个) -> anyhow::Result<&AttemptDiffData> { if attempts.is_empty() { // 空列表直接报错(collect 阶段通常已拦截,这里是双保险) anyhow::bail!("No attempts available"); } let desired = attempt.unwrap_or(1); // 没指定就默认第 1 个候选——最常见的"就用最好的那个"场景 let idx = desired // 转成 0-based 下标 .checked_sub(1) // checked_sub:desired=0 时返回 None,而不是 usize 下溢 panic .ok_or_else(|| anyhow!("attempt must be at least 1"))?; // 明确报错"至少是 1" if idx >= attempts.len() { // 越界检查:用户要第 5 个但只有 3 个候选 anyhow::bail!( "Attempt {desired} not available; only {} attempt(s) found", // 报错里带上实际数量,用户立刻知道该选几 attempts.len() ); } Ok(&attempts[idx]) // 命中:返回引用(diff 文本可能很大,避免拷贝)为什么这样设计:checked_sub(1) 是 Rust 里防 usize 下溢的标准手法——--attempt 0 这种非法输入变成一条可读错误,而不是 panic。报错信息带上"实际有几个候选",把用户的下一步动作(改 --attempt)直接写进了错误消息。
五、TUI apply:preflight 预检 + 双 inflight 互斥
CLI 的 apply 直接落盘;TUI 里多了一步 preflight——先问后端"这个 diff 现在 apply 会怎样",拿到 skipped/conflict 路径列表再让用户确认。spawn_preflight(lib.rs 第 636-696 行)展示了并发防护的完整写法:
📄 codex-rs/cloud-tasks/src/lib.rs (第 636-696 行)
fn spawn_preflight( // 发起 apply 预检(不真正落盘,只问"会怎样") app: &mut app::App, // TUI 全局状态:inflight 标志都挂在这里 backend: &Arc<dyn codex_cloud_tasks_client::CloudBackend>, // 共享后端客户端 tx: &UnboundedSender<app::AppEvent>, // 事件通道:异步结果通过它回到主循环 frame_tx: &UnboundedSender<Instant>, // 帧调度通道:请求 UI 重绘 title: String, // 任务标题(回显到事件里) job: ApplyJob, // task_id + diff_override(用户改过的 diff 文本)) -> bool { if app.apply_inflight { // 已有 apply 在跑:拒绝并发,状态栏给出人话提示 app.status = "An apply is already running; wait for it to finish first.".to_string(); return false; // 不 spawn,调用方据此不再处理按键 } if app.apply_preflight_inflight { // preflight 和 apply 互斥:两个写路径不能同时动同一个任务 app.status = "A preflight is already running; wait for it to finish first.".to_string(); return false; } app.apply_preflight_inflight = true; // 置位 inflight(事件回来时复位)——双标志互斥的核心 let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); // 请求 100ms 后重绘:让 spinner 立刻转起来 let backend = backend.clone(); // Arc clone:异步任务持有独立引用,不借用 app let tx = tx.clone(); // 通道 clone:结果从 spawn 的 task 发回主循环 tokio::spawn(async move { // 预检是网络请求,必须离开 UI 线程 let ApplyJob { // 解构 job(move 进闭包) task_id, diff_override, } = job; let result = codex_cloud_tasks_client::CloudBackend::apply_task_preflight( // 调后端预检接口 &*backend, task_id.clone(), diff_override, ) .await; let event = match result { // Ok/Err 都转成同一个事件类型,UI 侧只需处理一种 Ok(outcome) => { // 成功:携带 skipped/conflict 路径列表供 UI 展示 let level = level_from_status(outcome.status); // Success/Partial/Error → UI 级别(决定颜色) app::AppEvent::ApplyPreflightFinished { id: task_id, title, message: outcome.message, // 回显信息 level, skipped: outcome.skipped_paths, conflicts: outcome.conflict_paths, // 预检明细:哪些被跳过、哪些冲突 } } Err(e) => app::AppEvent::ApplyPreflightFinished { // 失败也走同一事件,level=Error id: task_id, title, message: format!("Preflight failed: {e}"), // 错误信息进 message level: app::ApplyResultLevel::Error, skipped: Vec::new(), conflicts: Vec::new(), // 无明细可给 }, }; let _ = tx.send(event); // 发回主循环;UI 线程收到后复位 inflight 并刷新界面 }); true // spawn 成功:调用方知道这次按键已被受理为什么这样设计:TUI 是单线程 UI + 异步后台的模型,apply_inflight/apply_preflight_inflight 两个布尔标志就是全部并发控制——没有锁、没有 channel 背压,因为被保护的资源("当前任务状态")只有一个。Ok/Err 归一成同一个 ApplyPreflightFinished 事件,UI 侧的 match 分支少一半。preflight 返回 skipped/conflict 路径列表,让用户在真正 apply 前就知道哪些文件会被跳过、哪些会冲突——把"apply 之后才发现失败"变成"apply 之前就看到结果"。
六、External Agent Migration:把别家的家当搬进 Codex
换一条线。external-agent-migration crate(约 9K LOC)解决一个很实际的问题:用户从 Claude Code / Cursor 迁移过来,~/.claude、.cursor 里攒的配置、技能、插件、会话怎么进 Codex?整个 crate 分 detect(发现可迁项)和 import(执行迁移)两半。
先定边界——MigrationScope(scope.rs 第 21-55 行)决定"在哪个文件系统范围内找东西":
📄 codex-rs/external-agent-migration/src/scope.rs (第 21-55 行)
pub(super) fn from_cwd(cwd: Option<&Path>) -> io::Result<Option<Self>> { // 从 cwd 推断迁移作用域 let Some(cwd) = cwd.filter(|cwd| !cwd.as_os_str().is_empty()) else { // 空路径视为"没给",直接按 Home 处理 return Ok(Some(Self::Home)); // 全局范围:~/.codex vs ~/.claude 这类 home 级配置 }; let mut current = if cwd.is_absolute() { // 绝对路径直接用 cwd.to_path_buf() } else { std::env::current_dir()?.join(cwd) // 相对路径拼上进程 cwd }; if !current.exists() { // 目录不存在:无法确定作用域,返回 None(调用方跳过) return Ok(None); } if current.is_file() { // 传进来的是文件(比如某个 .md):退回它的父目录再判断 let Some(parent) = current.parent() else { return Ok(None); }; current = parent.to_path_buf(); } let fallback = current.clone(); // 记住起点:向上找不到仓库时用它兜底 loop { // 从 cwd 逐级向上找 .git,确定仓库根 let git_path = current.join(".git"); // .git 可能是目录(普通仓库)也可能是文件(worktree/submodule) if git_path.is_dir() || git_path.is_file() { // 两种形态都认 → 命中仓库根 return Self::repository(current); // 进入仓库作用域判定(见下) } if !current.pop() { // pop 失败说明已到文件系统根,停止上爬 break; } } Self::repository(fallback) // 没找到 .git:把原始目录当"伪仓库根"——项目级配置照样能迁为什么这样设计:Home vs Repository 的区分决定了源/目标路径:home 范围是 ~/.claude → ~/.codex,仓库范围是 .cursor → .codex(项目内)。向上爬找 .git 而不是要求用户显式声明——在哪个目录里跑迁移命令,就自动迁那个项目的东西。注意 repository()(scope.rs 第 57-72 行)还会检查目标路径是否被符号链接重定向过(is_redirected_destination),是则放弃该仓库——防止把配置写进用户刻意隔离的位置。
📄 codex-rs/external-agent-migration/src/detect/mod.rs (第 39-73 行)
pub async fn detect( // 发现"可迁移项":只读扫描,不写任何文件 &self, params: ExternalAgentConfigDetectOptions, // include_home / cwds / include_memory 等开关 ) -> io::Result<Vec<ExternalAgentConfigMigrationItem>> { let mut items = Vec::new(); // 迁移项列表:每项带类型 + 描述 + 明细,供 UI 勾选 if params.include_home { // home 范围开关打开才扫全局配置 self.detect_migrations(&MigrationScope::home(), &mut items) // 跑一遍完整检测(config/mcp/hooks/skills/...) .await?; } for cwd in params.cwds.as_deref().unwrap_or(&[]) { // 逐个仓库范围扫描(调用方传入的项目目录列表) let Some(scope) = MigrationScope::from_cwd(Some(cwd))? else { // 解析作用域;失败/重定向则跳过该目录 continue; }; if scope.is_home() { // 去重:cwd 恰好落在 home 时不重复扫(上面已扫过) continue; } self.detect_migrations(&scope, &mut items).await?; // 仓库级检测 } if params.include_home // memory 迁移只在 home 范围 + 显式开关下发生 && params.include_memory // 双重条件:防止意外把记忆文件搬走 && self.source.supports_memory() // 源 Agent 支持才做(Cursor 没有对应物,见 source.rs) && let Some(item) = memory::detect(&self.codex_home, &self.external_agent_home)? { items.push(item); // 记忆文件迁移项入列 emit_migration_metric( // 上报指标:detect 阶段就计数,产品侧能看到"多少人被提示迁了什么" EXTERNAL_AGENT_CONFIG_DETECT_METRIC, ExternalAgentConfigMigrationItemType::Memory, /*skills_count*/ None, ); } Ok(items) // 返回全部候选——注意 detect 到此为止,一个字节都没写为什么这样设计:detect/import 彻底分离是这个 crate 最重要的架构决策:detect 是纯只读扫描,可以随便跑、反复跑;import 才动文件。用户看到的"迁移向导"就是 detect 结果的勾选列表——先看清要搬什么,再决定搬哪些。
📄 codex-rs/external-agent-migration/src/detect/mod.rs (第 88-122 行)
if let Some(settings) = settings.as_ref() { // 源 Agent 有有效配置文件才进入 config 迁移 let migrated = self.source.build_config(settings)?; // 把源配置(JSON)翻译成 Codex 的 TOML 形态 if !is_empty_toml_table(&migrated) { // 翻译结果是空表就没东西可迁,跳过 let mut should_include = true; // 默认要生成迁移项 if target_config.exists() { // 目标 config.toml 已存在:进入"只补缺"模式 let existing_raw = fs::read_to_string(&target_config)?; // 读现有配置原文 let mut existing = if existing_raw.trim().is_empty() { // 空文件按空表处理(避免 toml 解析报错) TomlValue::Table(Default::default()) } else { toml::from_str::<TomlValue>(&existing_raw).map_err(|err| { // 现有配置必须能解析,否则拒绝继续 invalid_data_error(format!("invalid existing config.toml: {err}")) })? }; should_include = merge_missing_toml_values(&mut existing, &migrated)?; // 只补缺失键;返回"是否真的补了东西" } if should_include { // 有新增内容才生成迁移项(全是重复配置就不打扰用户) items.push(ExternalAgentConfigMigrationItem { // 构造迁移项:类型 + 人话描述 + 作用域 item_type: ExternalAgentConfigMigrationItemType::Config, description: format!("Migrate {} into {}", source_settings.display(), target_config.display()), cwd: cwd.clone(), details: None, }); emit_migration_metric( // detect 指标:按类型计数,skills_count 位对 config 无意义给 None EXTERNAL_AGENT_CONFIG_DETECT_METRIC, ExternalAgentConfigMigrationItemType::Config, /*skills_count*/ None, ); } } }为什么这样设计:"只补缺不覆盖"(merge-missing-only)是迁移工具的生命线——用户现有的 Codex 配置一定比从别家搬来的更可信。如果目标文件已存在但解析失败,直接报错而不是猜着写:静默生成一份坏配置比什么都不做危险得多。
📄 codex-rs/external-agent-migration/src/config_values.rs (第 8-38 行)
pub(super) fn merge_missing_toml_values( // 递归"只补缺"合并:incoming 里 existing 没有的键才写入 existing: &mut TomlValue, // 目标配置(原地修改) incoming: &TomlValue, // 从源 Agent 翻译来的配置) -> io::Result<bool> { // 返回 true = 确实补了新内容(调用方据此决定是否生成迁移项) match (existing, incoming) { // 只处理"两边都是表"的情况,其他形态一律拒绝 (TomlValue::Table(existing_table), TomlValue::Table(incoming_table)) => { let mut changed = false; // 变更标记:任何一次插入/递归补全都会置位 for (key, incoming_value) in incoming_table { // 遍历源配置每个键 match existing_table.get_mut(key) { // 目标里有没有这个键? Some(existing_value) => { // 已有同名键:绝不覆盖,但表对表可以递归补子键 if matches!( (&*existing_value, incoming_value), (TomlValue::Table(_), TomlValue::Table(_)) ) && merge_missing_toml_values(existing_value, incoming_value)? // 两边都是表 → 递归合并(深层同样只补缺) { changed = true; // 子层补了东西,向上传播"有变更" } } None => { // 目标没有这个键:插入——这是唯一会写数据的路径 existing_table.insert(key.clone(), incoming_value.clone()); changed = true; // 标记变更 } } } Ok(changed) // 汇总本层是否有新增,供上层判断 } _ => Err(invalid_data_error( // 类型不匹配(比如目标是字符串):数据形态异常,fail loud "expected TOML table while merging migrated config values", )), }}为什么这样设计:递归只补缺的语义是:[mcp_servers.foo] 已存在就整个跳过,但 [features] 表里缺的子键可以补——粒度到"叶子键"为止。返回 bool 而不是直接写文件,让 detect 阶段能精确回答"这次迁移到底有没有内容",避免给用户生成一堆空迁移项。
七、术语重写:带词边界的字符串替换
迁移来的技能文档里可能写着 "CLAUDE.md"、"Claude Code"——这些术语必须改写成 Codex 的对应物,但不能误伤(比如不能把 claudebench 里的子串也替换掉)。rewrite.rs 的实现是手写的词边界扫描:
📄 codex-rs/external-agent-migration/src/rewrite.rs (第 39-50 行)
/// Rewrites source-specific documentation names and product terms to their Codex forms. pub fn rewrite(self, content: &str) -> String { // 把迁移产物里的源 Agent 术语改写成 Codex 形态 let mut rewritten = // 第一步:文档文件名替换(如 CLAUDE.md → AGENTS.md),大小写不敏感 + 词边界 replace_case_insensitive_with_boundaries(content, self.doc_file_name, "AGENTS.md"); for from in self.term_variants { // 第二步:普通术语变体逐个替换(同样大小写不敏感) rewritten = replace_case_insensitive_with_boundaries(&rewritten, from, "Codex"); // 统一改写成 "Codex" } for from in self.case_sensitive_term_variants { // 第三步:大小写敏感的变体(避免误伤同形小写词) rewritten = replace_with_boundaries(&rewritten, from, "Codex"); // 精确匹配才替换 } rewritten // 三轮替换后的最终文本📄 codex-rs/external-agent-migration/src/rewrite.rs (第 52-79 行)
fn replace_with_boundaries(input: &str, needle: &str, replacement: &str) -> String { // 带词边界的替换(大小写敏感版) if needle.is_empty() { // 空 needle 直接原样返回,避免 find("") 的无限命中陷阱 return input.to_string(); } let bytes = input.as_bytes(); // 字节视图:边界判断要看到"前后一个字节是不是单词字符" let mut output = String::with_capacity(input.len()); // 预分配容量(替换后长度变化不大) let mut last_emitted = 0usize; // 上次输出到的位置:未命中区间原样拷贝 let mut search_start = 0usize; // 搜索起点 while let Some(relative_pos) = input[search_start..].find(needle) { // 从当前位置找下一个 needle let start = search_start + relative_pos; // 命中的起始下标 let end = start + needle.len(); // 命中的结束下标(不含) let boundary_before = start == 0 || !is_word_byte(bytes[start - 1]); // 前边界:开头,或前一字节不是字母/数字/_ let boundary_after = end == bytes.len() || !is_word_byte(bytes[end]); // 后边界:结尾,或后一字节不是单词字符 if boundary_before && boundary_after { // 前后都是边界 → 这是一个"完整的词",才允许替换 output.push_str(&input[last_emitted..start]); // 先把未命中的前缀原样拷出 output.push_str(replacement); // 写入替换文本(如 "Codex") last_emitted = end; // 推进输出指针到命中区间之后 } search_start = end; // 从命中末尾继续找下一个(不重叠搜索) } if last_emitted == 0 { // 一次都没替换:直接返回原文的拷贝,省掉拼接开销 return input.to_string(); } output.push_str(&input[last_emitted..]); // 补上最后一段未命中的尾部 output为什么这样设计:is_word_byte(rewrite.rs 第 124-126 行)只认 ASCII 字母数字和下划线——所以 claudebench、my-claude-notes 这类词不会被误伤,而独立的 "Claude" 会。不用正则库而是手写扫描:替换规则是编译期常量(RewriteProfile),性能路径上少一层依赖;同时 last_emitted == 0 的短路让"无命中"场景零拼接开销。
八、插件迁移:marketplace 注册表 + enabledPlugins 双重校验
Claude Code 的插件体系是"marketplace(市场)+ plugin(插件)"两层。source_cla.rs 的 marketplace_import_sources() 负责把用户已启用的插件映射成 Codex 可导入的市场源,其中对 extraKnownMarketplaces 的处理值得细看:
📄 codex-rs/external-agent-migration/src/source_cla.rs (第 49-76 行)
let known_marketplaces_path = external_agent_home.join(KNOWN_MARKETPLACES_PATH); // 全局市场注册表:~/.claude/plugins/known_marketplaces.json let known_marketplaces = match crate::service::read_external_settings(&known_marketplaces_path) // 读注册表(JSON) { Ok(known_marketplaces) => known_marketplaces, // 成功:拿到 name → marketplace 定义的映射 Err(err) => { // 文件损坏/不存在:降级为"没有全局注册表",不中断整个迁移 tracing::warn!( path = %known_marketplaces_path.display(), error = %err, // 结构化日志:路径 + 原因都留下 "ignoring invalid external agent marketplace registry" ); None // 后续逻辑按空注册表处理 } }; let mut import_sources = known_marketplaces // 有注册表就从中收集可导入源(github/git/directory 等形态) .as_ref() .map(|known_marketplaces| { collect_marketplace_import_sources(known_marketplaces, external_agent_home) // name → MarketplaceImportSource{source, ref_name} }) .unwrap_or_default(); // 没有注册表就从空 map 起步 if let Some(extra_known_marketplaces) = settings // 项目级设置里可能声明"额外市场"(覆盖全局) .as_object() .and_then(|settings| settings.get("extraKnownMarketplaces")) { let mut scoped_marketplaces = extra_known_marketplaces.clone(); // 克隆一份做修补(补全 installLocation) if let Some(scoped_marketplaces) = scoped_marketplaces.as_object_mut() { for (name, scoped_marketplace) in scoped_marketplaces { // 逐个处理项目级市场声明 import_sources.remove(name); // 先移除同名全局条目:项目级声明优先(覆盖语义) let Some(known_marketplace) = known_marketplaces // 去全局注册表找同名的"权威定义" .as_ref() .and_then(JsonValue::as_object) .and_then(|known_marketplaces| known_marketplaces.get(name)) else { continue; // 全局没有这个市场:项目级声明无法补全,跳过(不猜) }; if scoped_marketplace.get("source") != known_marketplace.get("source") { // source 不一致 = 被改过/伪造 continue; // 拒绝采纳——防止把插件装进非预期来源为什么这样设计:这段代码的安全假设是:extraKnownMarketplaces 可能来自项目仓库(即"被迁移的代码库自己写的配置"),而全局注册表在用户 home 下。两者 source 不一致时,宁可跳过也不导入——这是典型的"仓库内配置不可信"防御:插件安装等于执行第三方代码,来源必须与用户已确认的权威定义一致。
九、import:逐项隔离的错误处理
detect 产出的迁移项列表交给 import()(service.rs 第 192-248 行)执行。它的错误处理策略是"逐项隔离"——一项失败不拖垮整批:
📄 codex-rs/external-agent-migration/src/service.rs (第 192-248 行)
pub async fn import( // 执行迁移:按 detect 产出的项列表逐项落地 &self, migration_items: Vec<ExternalAgentConfigMigrationItem>, // 用户勾选后的迁移项(可能只是全集的子集) ) -> ExternalAgentConfigImportOutcome { // 返回逐条结果(成功/失败 + 明细),而不是 Result——部分成功是常态 let mut outcome = ExternalAgentConfigImportOutcome::default(); // 汇总容器:每项一个 item_result for migration_item in migration_items { // 顺序处理每一项(各项之间无依赖,但保持确定性顺序) let item_type = migration_item.item_type; // 类型决定走哪个 import_* 分支 let description = migration_item.description.clone(); // 描述进结果记录(日志/UI 回显用) let cwd_for_log = migration_item.cwd.clone(); // 作用域目录也留档 let mut item_result = ExternalAgentConfigImportItemResult::new( // 每项独立的结果记录器:失败只污染自己 item_type, description.clone(), cwd_for_log.clone(), ); let import_result = match migration_item.item_type { // 按类型分派到具体导入逻辑 ExternalAgentConfigMigrationItemType::Config => (|| { // Config:翻译 + 只补缺合并(见第七节) if let Some((source, target)) = self.import_config(migration_item.cwd.as_deref())? // 执行配置迁移,返回源/目标路径 { item_result.record_success(Some(source), Some(target), /*title*/ None); // 成功明细入记录 } emit_migration_metric( // import 指标:与 detect 指标分开计数(漏斗分析用) EXTERNAL_AGENT_CONFIG_IMPORT_METRIC, ExternalAgentConfigMigrationItemType::Config, /*skills_count*/ None, ); Ok(()) })(), ExternalAgentConfigMigrationItemType::Skills => (|| { // Skills:逐个技能目录拷贝 + 术语重写 let imported_skills = self.import_skills(migration_item.cwd.as_deref())?; // 返回实际导入的技能名列表 emit_migration_metric( // 指标带数量:skills_count 位在这里才有值 EXTERNAL_AGENT_CONFIG_IMPORT_METRIC, ExternalAgentConfigMigrationItemType::Skills, Some(imported_skills.len()), ); for skill_name in imported_skills { // 每个技能单独记一条成功明细(UI 可逐条展示) item_result.record_success(Some(skill_name.clone()), Some(skill_name), /*title*/ None); } Ok(()) })(), ExternalAgentConfigMigrationItemType::AgentsMd => (|| { // AGENTS.md:源指令文件 → 目标 AGENTS.md(同样只补缺) if let Some((source, target)) = self.import_agents_md(migration_item.cwd.as_deref())? { item_result.record_success(Some(source), Some(target), /*title*/ None); } emit_migration_metric( // 指标照发 EXTERNAL_AGENT_CONFIG_IMPORT_METRIC, ExternalAgentConfigMigrationItemType::AgentsMd, /*skills_count*/ None, ); Ok(()) })(),为什么这样设计:返回值是 ExternalAgentConfigImportOutcome(逐条结果)而不是 Result<()>——迁移天然是"部分成功"场景:config 迁了、某个插件市场拉不下来,这两件事必须同时如实呈现。每个分支都是独立闭包 + 独立的 item_result,错误被 record_import_error(service.rs 第 258-264 行等)捕获进该项记录,循环继续——一项失败不会让后面九项陪葬。
detect 阶段能发现的迁移项类型全景(detect/mod.rs + source_cla.rs 汇总):
迁移项类型 × 源 → 目标(以 Claude Code 为例)
① Config:settings.json → config.toml
build_config 翻译成 TOML;目标已存在时只补缺失键,绝不覆盖。
② McpServerConfig:mcpServers → [mcp_servers]
按 server 名去重合并;明细里列出每个被补入的 server。
③ Hooks:hooks.json → hooks.json
仅当目标缺失或为空才迁;命令里的源 Agent 路径会被重写。
④ Skills / Commands:skills、commands → .agents/skills
按目录名去重;命令走双 profile(frontmatter 优先,源名兜底)。
⑤ Subagents:agents/ → .codex/agents
只迁目标里缺失的子代理定义。
⑥ AgentsMd / Plugins / Sessions / Memory
🔹 AGENTS.md:目标缺失才迁;Plugins:marketplace+enabledPlugins 双重校验(仅 home 范围)🔹 Sessions:近期会话导入(带数量上限);Memory:双开关 + 源支持才启用
十、数据流总览
云端任务路径(codex cloud exec → apply)
① init_backend:认证 + 客户端组装
ChatGPT 登录态硬校验;未登录 exit(1);mock 仅调试构建存在。
▼
② 环境解析:label → env_id
git origin by-repo 优先,全量列表兜底;四级选择优先级 + 全程日志。
▼
③ create_task:prompt + branch + attempts
best-of-N(1~4)并行跑;CLI 只打印任务 URL,提交与观察解耦。
▼
④ collect + select:N 个候选 diff
基础 attempt + turn_id 兄弟;placement/created_at 双键排序,--attempt N 语义稳定。
▼
⑤ apply:CLI 直落 / TUI preflight 先行
🔹 CLI:apply_task → Success/Partial/Error,非成功 exit(1)🔹 TUI:双 inflight 互斥;preflight 先回 skipped/conflict 路径再确认
迁移路径(detect → import)
① MigrationScope:Home vs Repository
向上爬 .git 定仓库根;符号链接重定向的目录直接放弃。
▼
② detect:只读扫描出迁移项
config/mcp/hooks/skills/commands/subagents/AGENTS.md/plugins/sessions/memory;一个字节不写。
▼
③ 安全闸门:只补缺 + 来源校验
🔹 merge_missing_toml_values:递归只补缺失键,现有配置永不覆盖🔹 marketplace source 与全局注册表不一致即拒绝;仓库内配置不可信
▼
④ import:逐项隔离执行
🔹 每项独立 item_result,失败不中断后续项🔹 术语重写(词边界替换)+ detect/import 双指标漏斗
📚 系列导航
← 第 29 讲:State Runtime Store
→ 第 31 讲:Skills 技能系统
关注公众号「AI技术推荐官」获取更多源码解析内容