From 37c8aefa14e1a8bb817ee63ddc4561da04741082 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Thu, 4 Jun 2026 14:38:11 -0700 Subject: [PATCH 01/59] Use Winget release environment secret (#26466) ## Why `WINGET_PUBLISH_PAT` now lives as a GitHub environment secret under `mainline-release-winget`. The WinGet release job needs to enter that environment so `secrets.WINGET_PUBLISH_PAT` resolves during stable/mainline Rust releases. ## What Changed - Attach the `winget` job in `.github/workflows/rust-release.yml` to the `mainline-release-winget` environment. - Set `deployment: false` so the job can read environment secrets without creating GitHub deployment records. ## Operational Note The `mainline-release-winget` environment must allow `rust-v*.*.*` tag refs before this can run on release tags. The live environment currently has a custom policy named `rust-v*.*.*` with type `branch`; add the corresponding `tag` policy before relying on this path for a release. ## Validation - `git diff --check origin/main...HEAD -- .github/workflows/rust-release.yml` - `ruby -e 'require "yaml"; ARGV.each { |f| YAML.load_file(f); puts "yaml ok: #{f}" }' .github/workflows/rust-release.yml` --- .github/workflows/rust-release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 2888ea6ce83f..03a0cc57ffae 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -1946,6 +1946,9 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + environment: + name: mainline-release-winget + deployment: false steps: - name: Publish to WinGet From 59ca34206b4606a8800a4e565d6006f02fa37206 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 4 Jun 2026 15:08:52 -0700 Subject: [PATCH 02/59] [codex] Preserve logical paths during AGENTS.md discovery (#26465) ## Intent Follow up on #26205 by avoiding unnecessary filesystem canonicalization during `AGENTS.md` discovery. The configured working directory is already absolute, and canonicalization incorrectly switches symlinked workspaces from their logical parent hierarchy to the target's hierarchy. ## User-facing behavior For a symlinked working directory such as: ```text test-root/ |-- logical-repo/ | |-- AGENTS.md ("logical parent doc") | `-- workspace ------------> physical-repo/workspace/ `-- physical-repo/ |-- AGENTS.md ("physical parent doc") `-- workspace/ `-- AGENTS.md ("workspace doc") ``` Before this change, Codex canonicalized `logical-repo/workspace` to `physical-repo/workspace` before discovery. It therefore loaded `physical-repo/AGENTS.md` and `physical-repo/workspace/AGENTS.md`, ignoring the instructions from the repository through which the user entered the workspace. After this change, ancestor discovery walks the configured logical path, so Codex loads `logical-repo/AGENTS.md`. Opening `logical-repo/workspace/AGENTS.md` still follows the symlink through the host filesystem, so the workspace document is also loaded. `physical-repo/AGENTS.md` is not loaded. ## Implementation Use the logical absolute working directory when discovering project instructions and reporting instruction sources. Filesystem reads still follow the working-directory symlink, so an `AGENTS.md` in the target workspace continues to load while ancestor discovery uses the symlink's parents. ## Validation Added integration coverage proving that discovery loads the logical parent's instructions and the target workspace's instructions, but not the target parent's instructions. --- .../tests/suite/v2/thread_resume.rs | 2 +- .../app-server/tests/suite/v2/thread_start.rs | 2 +- codex-rs/core/src/agents_md.rs | 5 +- codex-rs/core/src/agents_md_tests.rs | 59 ++++++------ codex-rs/core/tests/common/lib.rs | 15 ++++ codex-rs/core/tests/suite/agents_md.rs | 90 +++++++++++++++++-- 6 files changed, 130 insertions(+), 43 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 4e052041ad34..4ae68b0e7141 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -284,7 +284,7 @@ async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Resul instruction_sources, .. } = to_response::(start_resp)?; - let project_agents = AbsolutePathBuf::try_from(std::fs::canonicalize(project_agents)?)?; + let project_agents = AbsolutePathBuf::try_from(project_agents)?; assert_eq!(instruction_sources, vec![project_agents.clone()]); let turn_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 208af14299cf..a2da95545a34 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -348,7 +348,7 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<( .collect::>(); let expected_instruction_sources = vec![ std::fs::canonicalize(global_agents_path)?, - std::fs::canonicalize(project_agents_path)?, + project_agents_path, ] .into_iter() .map(normalize_path_for_comparison) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index bc3740a46fd5..5739e42f309f 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -206,10 +206,7 @@ impl<'a> AgentsMdManager<'a> { return Ok(Vec::new()); } - let mut dir = self.config.cwd.clone(); - if let Ok(canonical_dir) = fs.canonicalize(&dir, /*sandbox*/ None).await { - dir = canonical_dir; - } + let dir = self.config.cwd.clone(); let mut merged = TomlValue::Table(toml::map::Map::new()); for layer in self.config.config_layer_stack.get_layers( diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 95659511b01d..d8298dfd7dcb 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -5,6 +5,7 @@ use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathBufExt; use core_test_support::TempDirExt; +use core_test_support::create_directory_symlink; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; @@ -223,8 +224,7 @@ async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() { .text(); assert_eq!(res, "project\u{FFFD} doc"); - let canonical_path = dunce::canonicalize(&path).expect("canonical doc path"); - assert_invalid_utf8_warning(&warnings, "Project", &canonical_path); + assert_invalid_utf8_warning(&warnings, "Project", config.cwd.join("AGENTS.md").as_path()); } /// Oversize file is truncated to `project_doc_max_bytes`. @@ -330,10 +330,7 @@ async fn sourceless_user_instructions_preserve_separator_without_reporting_a_sou .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await .expect("instructions expected"); - let project_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), - ) - .expect("absolute project doc path"); + let project_agents = cfg.cwd.join("AGENTS.md"); assert_eq!( loaded.text(), @@ -385,14 +382,8 @@ async fn concatenates_root_and_cwd_docs() { .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await .expect("doc expected"); - let root_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(repo.path().join("AGENTS.md")).expect("canonical root doc path"), - ) - .expect("absolute root doc path"); - let crate_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical crate doc path"), - ) - .expect("absolute crate doc path"); + let root_agents = repo.path().join("AGENTS.md").abs(); + let crate_agents = cfg.cwd.join("AGENTS.md"); let expected = LoadedAgentsMd { entries: vec![ InstructionEntry { @@ -434,14 +425,8 @@ async fn project_root_markers_are_honored_for_agents_discovery() { cfg.cwd = nested.abs(); let discovery = agents_md_paths(&cfg).await.expect("discover paths"); - let expected_parent = AbsolutePathBuf::try_from( - dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path"), - ) - .expect("absolute parent doc path"); - let expected_child = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical child doc path"), - ) - .expect("absolute child doc path"); + let expected_parent = root.path().join("AGENTS.md").abs(); + let expected_child = cfg.cwd.join("AGENTS.md"); assert_eq!(discovery.len(), 2); assert_eq!(discovery[0], expected_parent); assert_eq!(discovery[1], expected_child); @@ -450,6 +435,26 @@ async fn project_root_markers_are_honored_for_agents_discovery() { assert_eq!(res, "parent doc\n\nchild doc"); } +#[tokio::test] +async fn agents_md_paths_preserve_symlinked_cwd() { + let tmp = tempfile::tempdir().expect("tempdir"); + let target = tmp.path().join("target"); + fs::create_dir(&target).unwrap(); + fs::write(target.join("AGENTS.md"), "project doc").unwrap(); + + let linked_cwd = tmp.path().join("linked"); + create_directory_symlink(&target, &linked_cwd); + + let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + cfg.cwd = linked_cwd.abs(); + + let discovery = agents_md_paths(&cfg).await.expect("discover paths"); + assert_eq!(discovery, vec![cfg.cwd.join("AGENTS.md")]); + + let res = get_user_instructions(&cfg).await.expect("doc expected"); + assert_eq!(res, "project doc"); +} + #[tokio::test] async fn child_agents_message_after_global_instructions_uses_plain_separator() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -497,10 +502,7 @@ async fn instruction_sources_include_global_before_agents_md_docs() { .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await .expect("instructions expected"); - let project_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), - ) - .expect("absolute project doc path"); + let project_agents = cfg.cwd.join("AGENTS.md"); let expected = LoadedAgentsMd { entries: vec![ @@ -541,10 +543,7 @@ async fn child_agents_message_after_project_docs_is_not_an_instruction_source() .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await .expect("instructions expected"); - let project_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), - ) - .expect("absolute project doc path"); + let project_agents = cfg.cwd.join("AGENTS.md"); let expected = LoadedAgentsMd { entries: vec![ diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index be07b803f853..94d72d619b0a 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -19,6 +19,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; pub use codex_utils_absolute_path::test_support::PathBufExt; pub use codex_utils_absolute_path::test_support::PathExt; use regex_lite::Regex; +use std::path::Path; use std::path::PathBuf; pub mod apps_test_server; @@ -110,6 +111,20 @@ pub fn test_absolute_path(unix_path: &str) -> AbsolutePathBuf { test_absolute_path_with_windows(unix_path, /*windows_path*/ None) } +#[cfg(unix)] +#[allow(clippy::expect_used)] +pub fn create_directory_symlink(source: &Path, link: &Path) { + std::os::unix::fs::symlink(source, link).expect("create directory symlink"); +} + +#[cfg(windows)] +#[allow(clippy::expect_used)] +pub fn create_directory_symlink(source: &Path, link: &Path) { + // Running this test locally may require Windows Developer Mode or an elevated process. + std::os::windows::fs::symlink_dir(source, link) + .expect("create directory symlink; enable Developer Mode or run the test elevated"); +} + pub trait TempDirExt { fn abs(&self) -> AbsolutePathBuf; } diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 318c288dc6f6..3fa2136ecbd9 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -1,6 +1,7 @@ use anyhow::Result; use codex_exec_server::CreateDirectoryOptions; use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::create_directory_symlink; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; @@ -8,6 +9,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; +use pretty_assertions::assert_eq; use std::sync::Arc; use tempfile::TempDir; @@ -143,6 +145,86 @@ async fn agents_docs_are_concatenated_from_project_root_to_cwd() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn symlinked_cwd_uses_logical_parent_for_agents_discovery() -> Result<()> { + let server = start_mock_server().await; + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + + let mut builder = test_codex() + .with_config(|config| { + config.cwd = config.cwd.join("logical-repo/workspace"); + }) + .with_workspace_setup(|cwd, _fs| async move { + // Construct two sibling repositories with the configured cwd as a + // directory symlink from the logical repository into the physical + // repository: + // + // test-root/ + // |-- logical-repo/ + // | |-- .git + // | |-- AGENTS.md ("logical parent doc") + // | `-- workspace ------------> physical-repo/workspace/ + // `-- physical-repo/ + // |-- .git + // |-- AGENTS.md ("physical parent doc") + // `-- workspace/ + // `-- AGENTS.md ("workspace doc") + // + // Discovery should walk the lexical path through logical-repo, + // while opening logical-repo/workspace/AGENTS.md still follows the + // symlink into physical-repo/workspace. + let logical_root = cwd.parent().expect("symlink should have a parent"); + let test_root = logical_root + .parent() + .expect("logical repository should have a parent"); + let physical_root = test_root.join("physical-repo"); + let physical_workspace = physical_root.join("workspace"); + + std::fs::create_dir_all(logical_root.as_path())?; + std::fs::write(logical_root.join(".git"), "")?; + std::fs::write(logical_root.join("AGENTS.md"), "logical parent doc")?; + + std::fs::create_dir_all(physical_workspace.as_path())?; + std::fs::write(physical_root.join(".git"), "")?; + std::fs::write(physical_root.join("AGENTS.md"), "physical parent doc")?; + std::fs::write(physical_workspace.join("AGENTS.md"), "workspace doc")?; + + create_directory_symlink(physical_workspace.as_path(), cwd.as_path()); + Ok(()) + }); + let test = builder.build(&server).await?; + let logical_root = test + .config + .cwd + .parent() + .expect("symlink should have a parent"); + + assert_eq!( + test.codex.instruction_sources().await, + vec![ + logical_root.join("AGENTS.md"), + test.config.cwd.join("AGENTS.md") + ] + ); + + test.submit_turn("hello").await?; + let instructions = resp_mock + .single_request() + .message_input_texts("user") + .into_iter() + .find(|text| text.starts_with("# AGENTS.md instructions for ")) + .expect("instructions message"); + assert!(instructions.contains("logical parent doc")); + assert!(instructions.contains("workspace doc")); + assert!(!instructions.contains("physical parent doc")); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn selected_environment_sources_match_model_visible_instructions() -> Result<()> { let server = start_mock_server().await; @@ -167,13 +249,7 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu Ok::<(), anyhow::Error>(()) }); let test = builder.build_with_remote_env(&server).await?; - let project_agents = test - .fs() - .canonicalize( - &test.executor_environment().cwd().join("AGENTS.md"), - /*sandbox*/ None, - ) - .await?; + let project_agents = test.config.cwd.join("AGENTS.md"); let global_agents = AbsolutePathBuf::try_from(global_agents).expect("absolute path"); assert_eq!( From 769c231aa1d69e2482fda2a51444d7f7a2158e3a Mon Sep 17 00:00:00 2001 From: Eric Ning Date: Thu, 4 Jun 2026 15:10:24 -0700 Subject: [PATCH 03/59] fix(app-server): expose remote MCP servers in plugin read (#26453) ## Why Remote plugin detail responses include MCP server metadata under `release.mcp_servers`, but Codex did not deserialize or propagate that field. As a result, `plugin/read` always returned an empty `mcpServers` list for remote plugins, so the plugin details pane omitted the MCP Servers section even when the remote plugin declares one. This affects uninstalled plugins as well: the remote detail API is the source of truth and returns MCP server keys without requiring a local plugin bundle. ## What changed - Deserialize MCP server entries from remote plugin detail responses. - Normalize their keys into a sorted, deduplicated list on `RemotePluginDetail`. - Return those keys from app-server `plugin/read` instead of hardcoding an empty list. - Add regression coverage proving an uninstalled remote plugin returns its MCP server names. ## Test plan - `just test -p codex-core-plugins` - `just test -p codex-app-server plugin_read` --- .../src/request_processors/plugins.rs | 2 +- .../app-server/tests/suite/v2/plugin_read.rs | 36 +++++++++++++------ codex-rs/core-plugins/src/remote.rs | 17 +++++++++ 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 23356948567b..316353244972 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -2026,7 +2026,7 @@ fn remote_plugin_detail_to_info( .collect(), hooks: Vec::new(), apps, - mcp_servers: Vec::new(), + mcp_servers: detail.mcp_servers, } } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index f78cd2a0881e..9e9e8442280c 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -122,7 +122,7 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { } #[tokio::test] -async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_is_disabled() -> Result<()> { +async fn plugin_read_returns_remote_mcp_servers_when_uninstalled() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; std::fs::write( @@ -148,22 +148,31 @@ plugins = true let detail_body = r#"{ "id": "plugins~Plugin_00000000000000000000000000000000", - "name": "linear", + "name": "example-plugin", "scope": "GLOBAL", "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": { - "display_name": "Linear", - "description": "Track work in Linear", + "version": "1.2.1", + "display_name": "Example Plugin", + "description": "Example plugin", "app_ids": [], "keywords": [], "interface": { - "short_description": "Plan and track work", + "short_description": "Example plugin", "capabilities": [], - "default_prompt": "Use the legacy Linear prompt", + "default_prompt": "Use the legacy example prompt", "default_prompts": [] }, - "skills": [] + "skills": [], + "mcp_servers": [ + { + "key": "example-server", + "metadata": { + "command": "example-mcp" + } + } + ] } }"#; let installed_body = r#"{ @@ -211,12 +220,15 @@ plugins = true let response: PluginReadResponse = to_response(response)?; assert_eq!(response.plugin.marketplace_name, "openai-curated-remote"); - assert_eq!(response.plugin.summary.id, "linear@openai-curated-remote"); + assert_eq!( + response.plugin.summary.id, + "example-plugin@openai-curated-remote" + ); assert_eq!( response.plugin.summary.remote_plugin_id.as_deref(), Some("plugins~Plugin_00000000000000000000000000000000") ); - assert_eq!(response.plugin.summary.name, "linear"); + assert_eq!(response.plugin.summary.name, "example-plugin"); assert_eq!(response.plugin.summary.source, PluginSource::Remote); assert_eq!(response.plugin.summary.share_context, None); assert_eq!( @@ -226,7 +238,11 @@ plugins = true .interface .as_ref() .and_then(|interface| interface.default_prompt.clone()), - Some(vec!["Use the legacy Linear prompt".to_string()]) + Some(vec!["Use the legacy example prompt".to_string()]) + ); + assert_eq!( + response.plugin.mcp_servers, + vec!["example-server".to_string()] ); Ok(()) } diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 045ad8e18423..9b34fd293cc6 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -167,6 +167,7 @@ pub struct RemotePluginDetail { pub app_manifest: Option, pub skills: Vec, pub app_ids: Vec, + pub mcp_servers: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -417,6 +418,13 @@ struct RemotePluginReleaseResponse { interface: RemotePluginReleaseInterfaceResponse, #[serde(default)] skills: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + mcp_servers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +struct RemotePluginMcpServerResponse { + key: String, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] @@ -948,6 +956,14 @@ async fn build_remote_plugin_detail( enabled: !disabled_skill_names.contains(&skill.name), }) .collect(); + let mut mcp_servers = plugin + .release + .mcp_servers + .iter() + .map(|server| server.key.clone()) + .collect::>(); + mcp_servers.sort_unstable(); + mcp_servers.dedup(); Ok(RemotePluginDetail { marketplace_name, @@ -959,6 +975,7 @@ async fn build_remote_plugin_detail( app_manifest: plugin.release.app_manifest, skills, app_ids: plugin.release.app_ids, + mcp_servers, }) } From e695ec8ec612f61f6150299cc5c0ec39138dc902 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 4 Jun 2026 15:40:16 -0700 Subject: [PATCH 04/59] Add skill for pushing CI configuration changes (#26473) ## Why Codex agents that modify GitHub Actions configuration need clear guidance when repository push protections require temporary approval. Without it, an agent may pursue an unavailable exemption or stop before checking whether the user already has access. ## What Add a `pushing-ci-changes` skill that explains the restriction, directs agents to attempt the push first, and tells them how to involve the user when approval is required. ## Validation Not run; this change only adds skill documentation. --- .codex/skills/pushing-ci-changes/SKILL.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .codex/skills/pushing-ci-changes/SKILL.md diff --git a/.codex/skills/pushing-ci-changes/SKILL.md b/.codex/skills/pushing-ci-changes/SKILL.md new file mode 100644 index 000000000000..cb91bdd981dc --- /dev/null +++ b/.codex/skills/pushing-ci-changes/SKILL.md @@ -0,0 +1,17 @@ +--- +name: pushing-ci-changes +description: Pushing GitHub Actions changes, resolving push rejection, requesting upload exceptions. +--- + +The Codex repository prevents anyone from uploading changes to its CI configuration unless they've +been granted a temporary role. + +In order to push changes to `.github/**/*.yml` and related files you'll need the user to read +go/workflow-approvals and request an approval through that flow. It is not possible for you to +make progress by requesting an exemption yourself. + +If you know you're about to push changes that would fail due to these restrictions you should still +try to push anyways to confirm the user's account doesn't already have the necessary approval. + +If you encounter a failed push due to these restrictions, share the go/workflow-approvals link with +the user and ask them to return control to you once the approval has propagated to GitHub. From b9ff4509029b40a0c10964aa751a1a006ab46359 Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Thu, 4 Jun 2026 16:42:27 -0700 Subject: [PATCH 05/59] [codex] Expose unavailable app templates in plugin detail (#26317) ## Summary - Adds `unavailable_app_templates` to the app-server protocol and generated schemas/types. - Parses plugin-service `release.unavailable_app_templates` in the remote plugin client. - Maps remote unavailable templates into app-server `PluginDetail`. - Defaults local plugins to an empty unavailable app template list. ## Validation - `just write-app-server-schema` - `cargo +1.95.0 fmt --manifest-path codex-rs/Cargo.toml --all --check` - `cargo +1.95.0 test --manifest-path codex-rs/Cargo.toml -p codex-app-server-protocol schema_fixtures` - `cargo +1.95.0 check --manifest-path codex-rs/Cargo.toml -p codex-app-server-protocol -p codex-core-plugins -p codex-app-server` - `git diff --check` Note: default `cargo check` uses rustc 1.89 locally and failed because dependencies require newer Rust, so validation was rerun with installed Rust 1.95. --- .../codex_app_server_protocol.schemas.json | 76 ++++++++++++++++++- .../codex_app_server_protocol.v2.schemas.json | 70 +++++++++++++++++ .../schema/json/v2/PluginReadResponse.json | 70 +++++++++++++++++ .../typescript/v2/AppTemplateSummary.ts | 6 ++ .../v2/AppTemplateUnavailableReason.ts | 5 ++ .../schema/typescript/v2/PluginDetail.ts | 3 +- .../schema/typescript/v2/index.ts | 2 + .../src/protocol/v2/plugin.rs | 23 ++++++ codex-rs/app-server/src/request_processors.rs | 2 + .../src/request_processors/plugins.rs | 25 ++++++ .../app-server/tests/suite/v2/plugin_read.rs | 49 ++++++++++++ codex-rs/core-plugins/src/remote.rs | 55 ++++++++++++++ codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + 13 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateSummary.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateUnavailableReason.ts diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 81617aa93c4c..4802a52300a9 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -3786,8 +3786,8 @@ "environmentId": { "default": null, "type": [ - "null", - "string" + "string", + "null" ] }, "itemId": { @@ -6278,6 +6278,69 @@ ], "type": "object" }, + "AppTemplateSummary": { + "properties": { + "canonicalConnectorId": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "logoUrl": { + "type": [ + "string", + "null" + ] + }, + "logoUrlDark": { + "type": [ + "string", + "null" + ] + }, + "materializedAppIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppTemplateUnavailableReason" + }, + { + "type": "null" + } + ] + }, + "templateId": { + "type": "string" + } + }, + "required": [ + "materializedAppIds", + "name", + "templateId" + ], + "type": "object" + }, + "AppTemplateUnavailableReason": { + "enum": [ + "NOT_CONFIGURED_FOR_WORKSPACE", + "NO_ACTIVE_WORKSPACE" + ], + "type": "string" + }, "AppToolApproval": { "enum": [ "auto", @@ -12168,6 +12231,12 @@ }, "PluginDetail": { "properties": { + "appTemplates": { + "items": { + "$ref": "#/definitions/v2/AppTemplateSummary" + }, + "type": "array" + }, "apps": { "items": { "$ref": "#/definitions/v2/AppSummary" @@ -12216,6 +12285,7 @@ } }, "required": [ + "appTemplates", "apps", "hooks", "marketplaceName", @@ -19232,4 +19302,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 336350bcec5f..2b9e30bae72a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -643,6 +643,69 @@ ], "type": "object" }, + "AppTemplateSummary": { + "properties": { + "canonicalConnectorId": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "logoUrl": { + "type": [ + "string", + "null" + ] + }, + "logoUrlDark": { + "type": [ + "string", + "null" + ] + }, + "materializedAppIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/definitions/AppTemplateUnavailableReason" + }, + { + "type": "null" + } + ] + }, + "templateId": { + "type": "string" + } + }, + "required": [ + "materializedAppIds", + "name", + "templateId" + ], + "type": "object" + }, + "AppTemplateUnavailableReason": { + "enum": [ + "NOT_CONFIGURED_FOR_WORKSPACE", + "NO_ACTIVE_WORKSPACE" + ], + "type": "string" + }, "AppToolApproval": { "enum": [ "auto", @@ -8690,6 +8753,12 @@ }, "PluginDetail": { "properties": { + "appTemplates": { + "items": { + "$ref": "#/definitions/AppTemplateSummary" + }, + "type": "array" + }, "apps": { "items": { "$ref": "#/definitions/AppSummary" @@ -8738,6 +8807,7 @@ } }, "required": [ + "appTemplates", "apps", "hooks", "marketplaceName", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index c9fe43c34f9f..5a4c2e5b0972 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -37,6 +37,69 @@ ], "type": "object" }, + "AppTemplateSummary": { + "properties": { + "canonicalConnectorId": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "logoUrl": { + "type": [ + "string", + "null" + ] + }, + "logoUrlDark": { + "type": [ + "string", + "null" + ] + }, + "materializedAppIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/definitions/AppTemplateUnavailableReason" + }, + { + "type": "null" + } + ] + }, + "templateId": { + "type": "string" + } + }, + "required": [ + "materializedAppIds", + "name", + "templateId" + ], + "type": "object" + }, + "AppTemplateUnavailableReason": { + "enum": [ + "NOT_CONFIGURED_FOR_WORKSPACE", + "NO_ACTIVE_WORKSPACE" + ], + "type": "string" + }, "HookEventName": { "enum": [ "preToolUse", @@ -78,6 +141,12 @@ }, "PluginDetail": { "properties": { + "appTemplates": { + "items": { + "$ref": "#/definitions/AppTemplateSummary" + }, + "type": "array" + }, "apps": { "items": { "$ref": "#/definitions/AppSummary" @@ -126,6 +195,7 @@ } }, "required": [ + "appTemplates", "apps", "hooks", "marketplaceName", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateSummary.ts new file mode 100644 index 000000000000..dd5f76229f05 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateSummary.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason"; + +export type AppTemplateSummary = { templateId: string, name: string, description: string | null, canonicalConnectorId: string | null, logoUrl: string | null, logoUrlDark: string | null, materializedAppIds: Array, reason: AppTemplateUnavailableReason | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateUnavailableReason.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateUnavailableReason.ts new file mode 100644 index 000000000000..563056798ec5 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateUnavailableReason.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AppTemplateUnavailableReason = "NOT_CONFIGURED_FOR_WORKSPACE" | "NO_ACTIVE_WORKSPACE"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts index 64836c87f7cc..cc2042dd5dd5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts @@ -3,8 +3,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { AppSummary } from "./AppSummary"; +import type { AppTemplateSummary } from "./AppTemplateSummary"; import type { PluginHookSummary } from "./PluginHookSummary"; import type { PluginSummary } from "./PluginSummary"; import type { SkillSummary } from "./SkillSummary"; -export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array, hooks: Array, apps: Array, mcpServers: Array, }; +export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array, hooks: Array, apps: Array, appTemplates: Array, mcpServers: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 48e1f9580b7f..b4c65946d536 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -21,6 +21,8 @@ export type { AppMetadata } from "./AppMetadata"; export type { AppReview } from "./AppReview"; export type { AppScreenshot } from "./AppScreenshot"; export type { AppSummary } from "./AppSummary"; +export type { AppTemplateSummary } from "./AppTemplateSummary"; +export type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason"; export type { AppToolApproval } from "./AppToolApproval"; export type { AppToolsConfig } from "./AppToolsConfig"; export type { ApprovalsReviewer } from "./ApprovalsReviewer"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 1b7e8ba8ffb5..6222eb6e73bb 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -642,9 +642,32 @@ pub struct PluginDetail { pub skills: Vec, pub hooks: Vec, pub apps: Vec, + pub app_templates: Vec, pub mcp_servers: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[ts(export_to = "v2/")] +pub enum AppTemplateUnavailableReason { + NotConfiguredForWorkspace, + NoActiveWorkspace, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppTemplateSummary { + pub template_id: String, + pub name: String, + pub description: Option, + pub canonical_connector_id: Option, + pub logo_url: Option, + pub logo_url_dark: Option, + pub materialized_app_ids: Vec, + pub reason: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 2f3c87d8687a..0e94528a7ae1 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -30,6 +30,8 @@ use codex_app_server_protocol::AdditionalContextKind; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppListUpdatedNotification; use codex_app_server_protocol::AppSummary; +use codex_app_server_protocol::AppTemplateSummary; +use codex_app_server_protocol::AppTemplateUnavailableReason; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::AskForApproval; diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 316353244972..79791c142a8e 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -8,6 +8,7 @@ use codex_app_server_protocol::PluginShareTargetRole; use codex_config::types::McpServerConfig; use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; +use codex_core_plugins::remote::RemoteAppTemplateUnavailableReason; use codex_core_plugins::remote::RemotePluginScope; use codex_core_plugins::remote::is_valid_remote_plugin_id; use codex_core_plugins::remote::validate_remote_plugin_id; @@ -1062,6 +1063,7 @@ impl PluginRequestProcessor { }) .collect(), apps: app_summaries, + app_templates: Vec::new(), mcp_servers: outcome.plugin.mcp_server_names, } } @@ -2007,6 +2009,28 @@ fn remote_plugin_detail_to_info( detail: RemoteCatalogPluginDetail, apps: Vec, ) -> PluginDetail { + let app_templates = detail + .app_templates + .into_iter() + .map(|template| AppTemplateSummary { + template_id: template.template_id, + name: template.name, + description: template.description, + canonical_connector_id: template.canonical_connector_id, + logo_url: template.logo_url, + logo_url_dark: template.logo_url_dark, + materialized_app_ids: template.materialized_app_ids, + reason: template.reason.map(|reason| match reason { + RemoteAppTemplateUnavailableReason::NotConfiguredForWorkspace => { + AppTemplateUnavailableReason::NotConfiguredForWorkspace + } + RemoteAppTemplateUnavailableReason::NoActiveWorkspace => { + AppTemplateUnavailableReason::NoActiveWorkspace + } + }), + }) + .collect(); + PluginDetail { marketplace_name: detail.marketplace_name, marketplace_path: None, @@ -2026,6 +2050,7 @@ fn remote_plugin_detail_to_info( .collect(), hooks: Vec::new(), apps, + app_templates, mcp_servers: detail.mcp_servers, } } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 9e9e8442280c..e675a6172ae1 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -17,6 +17,8 @@ use axum::http::Uri; use axum::http::header::AUTHORIZATION; use axum::routing::get; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::AppTemplateSummary; +use codex_app_server_protocol::AppTemplateUnavailableReason; use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; @@ -427,6 +429,28 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> "display_name": "Linear", "description": "Track work in Linear", "app_ids": [], + "app_templates": [ + { + "template_id": "templated_apps_GitHubEnterprise", + "name": "GitHub Enterprise", + "description": "Connect GitHub Enterprise", + "canonical_connector_id": "github_enterprise", + "logo_url": "https://example.com/ghe-light.png", + "logo_url_dark": "https://example.com/ghe-dark.png", + "materialized_app_ids": ["asdk_app_ghe"], + "reason": null + }, + { + "template_id": "templated_apps_Databricks", + "name": "Databricks", + "description": null, + "canonical_connector_id": null, + "logo_url": null, + "logo_url_dark": null, + "materialized_app_ids": [], + "reason": "NOT_CONFIGURED_FOR_WORKSPACE" + } + ], "keywords": ["issue-tracking", "project management"], "interface": { "short_description": "Plan and track work", @@ -564,6 +588,31 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> assert_eq!(response.plugin.skills[0].path, None); assert_eq!(response.plugin.skills[0].enabled, false); assert_eq!(response.plugin.apps.len(), 0); + assert_eq!( + response.plugin.app_templates, + vec![ + AppTemplateSummary { + template_id: "templated_apps_GitHubEnterprise".to_string(), + name: "GitHub Enterprise".to_string(), + description: Some("Connect GitHub Enterprise".to_string()), + canonical_connector_id: Some("github_enterprise".to_string()), + logo_url: Some("https://example.com/ghe-light.png".to_string()), + logo_url_dark: Some("https://example.com/ghe-dark.png".to_string()), + materialized_app_ids: vec!["asdk_app_ghe".to_string()], + reason: None, + }, + AppTemplateSummary { + template_id: "templated_apps_Databricks".to_string(), + name: "Databricks".to_string(), + description: None, + canonical_connector_id: None, + logo_url: None, + logo_url_dark: None, + materialized_app_ids: Vec::new(), + reason: Some(AppTemplateUnavailableReason::NotConfiguredForWorkspace), + }, + ] + ); Ok(()) } diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 9b34fd293cc6..7daf1da31fa4 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -167,9 +167,29 @@ pub struct RemotePluginDetail { pub app_manifest: Option, pub skills: Vec, pub app_ids: Vec, + pub app_templates: Vec, pub mcp_servers: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteAppTemplate { + pub template_id: String, + pub name: String, + pub description: Option, + pub canonical_connector_id: Option, + pub logo_url: Option, + pub logo_url_dark: Option, + pub materialized_app_ids: Vec, + pub reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RemoteAppTemplateUnavailableReason { + NotConfiguredForWorkspace, + NoActiveWorkspace, +} + #[derive(Debug, Clone, PartialEq)] pub struct RemotePluginSkill { pub name: String, @@ -413,6 +433,8 @@ struct RemotePluginReleaseResponse { app_ids: Vec, #[serde(default)] app_manifest: Option, + #[serde(default, alias = "unavailable_app_templates")] + app_templates: Vec, #[serde(default)] keywords: Vec, interface: RemotePluginReleaseInterfaceResponse, @@ -427,6 +449,24 @@ struct RemotePluginMcpServerResponse { key: String, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +struct RemoteAppTemplateResponse { + template_id: String, + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + canonical_connector_id: Option, + #[serde(default)] + logo_url: Option, + #[serde(default)] + logo_url_dark: Option, + #[serde(default)] + materialized_app_ids: Vec, + #[serde(default)] + reason: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginDirectoryItem { id: String, @@ -975,6 +1015,21 @@ async fn build_remote_plugin_detail( app_manifest: plugin.release.app_manifest, skills, app_ids: plugin.release.app_ids, + app_templates: plugin + .release + .app_templates + .into_iter() + .map(|template| RemoteAppTemplate { + template_id: template.template_id, + name: template.name, + description: template.description, + canonical_connector_id: template.canonical_connector_id, + logo_url: template.logo_url, + logo_url_dark: template.logo_url_dark, + materialized_app_ids: template.materialized_app_ids, + reason: template.reason, + }) + .collect(), mcp_servers, }) } diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index cfc9278ded35..b058b47cca39 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1429,6 +1429,7 @@ pub(super) fn plugins_test_detail( needs_auth: *needs_auth, }) .collect(), + app_templates: Vec::new(), mcp_servers: mcp_servers.iter().map(|name| (*name).to_string()).collect(), } } From 0b2e7b5eb1cfa74e5807a84b291e6c900eeb197d Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Thu, 4 Jun 2026 16:52:10 -0700 Subject: [PATCH 06/59] Improve Windows sandbox setup refresh diagnostics (#26471) ## Why Users have been seeing opaque Windows sandbox setup refresh failures such as `windows sandbox: spawn setup refresh`, including reports in #24391 and #21208. The setup refresh path already runs the Windows sandbox setup helper, but it was not using the same structured `setup_error.json` reporting path that elevated setup uses. As a result, when the helper exited non-zero, Codex only surfaced a generic refresh status instead of the helper's `SetupFailure` code and message. ## What changed - Clear stale `setup_error.json` before non-elevated setup refresh launches the helper. - When the refresh helper exits non-zero, read the helper-written report through the existing `report_helper_failure` path. - Keep a parent-side launch diagnostic for cases where the helper never starts, including the helper path, cwd, sandbox log path, and spawn error. - Clear the setup error report after a successful refresh. - Add regression coverage for report consumption and stale-report avoidance. ## Verification - `cargo test -p codex-windows-sandbox setup::tests::` --- codex-rs/windows-sandbox-rs/src/setup.rs | 110 +++++++++++++++--- .../windows-sandbox-rs/src/setup_error.rs | 2 +- 2 files changed, 97 insertions(+), 15 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index c1881b55c86f..428b5ff6a131 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -15,6 +15,7 @@ use crate::allow::compute_allow_paths_for_permissions; use crate::helper_materialization::bundled_executable_path_for_exe; use crate::helper_materialization::helper_bin_dir; use crate::identity::sandbox_setup_is_complete; +use crate::logging::current_log_file_path; use crate::logging::log_note; use crate::path_normalization::canonical_path_key; use crate::path_normalization::canonicalize_path; @@ -25,7 +26,6 @@ use crate::setup_error::clear_setup_error_report; use crate::setup_error::failure; use crate::setup_error::read_setup_error_report; use crate::ssh_config_dependencies::ssh_config_dependency_paths; -use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use base64::Engine; @@ -210,6 +210,18 @@ fn run_setup_refresh_inner( let json = serde_json::to_vec(&payload)?; let b64 = BASE64_STANDARD.encode(json); let exe = find_setup_exe(); + let sbx_dir = sandbox_dir(request.codex_home); + let log_path = current_log_file_path(&sbx_dir); + let cleared_report = match clear_setup_error_report(request.codex_home) { + Ok(()) => true, + Err(err) => { + log_note( + &format!("setup refresh: failed to clear setup_error.json before launch: {err}"), + Some(&sbx_dir), + ); + false + } + }; // Refresh should never request elevation; ensure verb isn't set and we don't trigger UAC. let mut cmd = Command::new(&exe); cmd.arg(&b64).stdout(Stdio::null()).stderr(Stdio::null()); @@ -221,24 +233,34 @@ fn run_setup_refresh_inner( cwd.display(), b64.len() ), - Some(&sandbox_dir(request.codex_home)), + Some(&sbx_dir), ); - let status = cmd - .status() - .map_err(|e| { - log_note( - &format!("setup refresh: failed to spawn {}: {e}", exe.display()), - Some(&sandbox_dir(request.codex_home)), - ); - e - }) - .context("spawn setup refresh")?; + let status = cmd.status().map_err(|err| { + let message = format!( + "setup refresh failed to launch helper: helper={}, cwd={}, log={}, error={err}", + exe.display(), + cwd.display(), + log_path.display() + ); + log_note(&format!("setup refresh: {message}"), Some(&sbx_dir)); + failure(SetupErrorCode::OrchestratorHelperLaunchFailed, message) + })?; if !status.success() { log_note( &format!("setup refresh: exited with status {status:?}"), - Some(&sandbox_dir(request.codex_home)), + Some(&sbx_dir), + ); + return Err(report_helper_failure( + request.codex_home, + cleared_report, + status.code(), + )); + } + if let Err(err) = clear_setup_error_report(request.codex_home) { + log_note( + &format!("setup refresh: failed to clear setup_error.json after success: {err}"), + Some(&sbx_dir), ); - return Err(anyhow!("setup refresh failed with status {status}")); } Ok(()) } @@ -1087,7 +1109,9 @@ mod tests { use crate::helper_materialization::helper_bin_dir; use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; use crate::setup_error::SetupErrorCode; + use crate::setup_error::SetupErrorReport; use crate::setup_error::extract_failure; + use crate::setup_error::write_setup_error_report; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; @@ -1146,6 +1170,64 @@ mod tests { ) } + #[test] + fn report_helper_failure_uses_setup_error_report_when_clear_succeeded() { + let tmp = TempDir::new().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + write_setup_error_report( + codex_home.as_path(), + &SetupErrorReport { + code: super::SetupErrorCode::HelperFirewallPolicyAccessFailed, + message: "firewall policy unavailable".to_string(), + }, + ) + .expect("write setup error report"); + + let err = super::report_helper_failure( + codex_home.as_path(), + /*cleared_report*/ true, + /*exit_code*/ Some(1), + ); + + let failure = extract_failure(&err).expect("structured setup failure"); + assert_eq!( + &super::SetupFailure::new( + super::SetupErrorCode::HelperFirewallPolicyAccessFailed, + "firewall policy unavailable", + ), + failure + ); + } + + #[test] + fn report_helper_failure_ignores_setup_error_report_when_clear_failed() { + let tmp = TempDir::new().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + write_setup_error_report( + codex_home.as_path(), + &SetupErrorReport { + code: super::SetupErrorCode::HelperFirewallPolicyAccessFailed, + message: "stale report".to_string(), + }, + ) + .expect("write setup error report"); + + let err = super::report_helper_failure( + codex_home.as_path(), + /*cleared_report*/ false, + /*exit_code*/ Some(1), + ); + + let failure = extract_failure(&err).expect("structured setup failure"); + assert_eq!( + &super::SetupFailure::new( + super::SetupErrorCode::OrchestratorHelperExitNonzero, + "setup helper exited with status Some(1)", + ), + failure + ); + } + #[test] fn setup_refresh_skips_profiles_without_managed_filesystem_permissions() { let tmp = TempDir::new().expect("tempdir"); diff --git a/codex-rs/windows-sandbox-rs/src/setup_error.rs b/codex-rs/windows-sandbox-rs/src/setup_error.rs index 0f759ef879b4..d9104602bc19 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_error.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_error.rs @@ -117,7 +117,7 @@ pub struct SetupErrorReport { pub message: String, } -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] pub struct SetupFailure { pub code: SetupErrorCode, pub message: String, From 72d0bfb6ba4b59ad78fbe012052a06a2c0874fc3 Mon Sep 17 00:00:00 2001 From: beggers-openai Date: Thu, 4 Jun 2026 17:47:58 -0700 Subject: [PATCH 07/59] Pull plugin service less frequently (#26431) # Summary Reduce download traffic to `github.com/openai/plugins` while continuing to check for updates on every Codex startup. # Root cause The startup sync replaced the local repository with a fresh shallow clone whenever the remote revision changed. At Codex's global scale, repeatedly downloading the repository created excessive GitHub traffic. # Changes - Run `git ls-remote` on each startup to read the remote HEAD SHA. - Skip all repository downloads when the local and remote SHAs match. - Update existing checkouts with an exact-SHA shallow `git fetch`, followed by reset and clean. - Bootstrap new installations with `git init` plus the same shallow fetch, rather than cloning. - Keep the existing file lock so concurrent Codex processes serialize updates and do not duplicate fetches. - Preserve the existing GitHub HTTP and export archive fallback behavior. # Impact Each startup makes one lightweight remote HEAD check. Repository objects are downloaded only when the revision changes, and existing Git objects are reused during updates. # Validation - `just test -p codex-core-plugins startup_sync` (15 tests passed) - `just test -p codex-core-plugins` (201 tests passed) - `just clippy -p codex-core-plugins` (passes with one pre-existing `large_enum_variant` warning) - Production app-server smoke test against GitHub: - Fresh home: `ls-remote`, `git init`, one exact-SHA shallow fetch - Unchanged restart: `ls-remote` and local `rev-parse` only; no fetch or clone - Bench smoke passed --- codex-rs/core-plugins/src/startup_sync.rs | 141 ++++++- .../core-plugins/src/startup_sync_tests.rs | 351 +++++++++++++++--- 2 files changed, 419 insertions(+), 73 deletions(-) diff --git a/codex-rs/core-plugins/src/startup_sync.rs b/codex-rs/core-plugins/src/startup_sync.rs index f03aff93f632..c5965f2212eb 100644 --- a/codex-rs/core-plugins/src/startup_sync.rs +++ b/codex-rs/core-plugins/src/startup_sync.rs @@ -1,3 +1,4 @@ +use std::fs::File; use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -22,8 +23,11 @@ const CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL: &str = "https://chatgpt.com/backend-api/plugins/export/curated"; const OPENAI_PLUGINS_OWNER: &str = "openai"; const OPENAI_PLUGINS_REPO: &str = "plugins"; +const OPENAI_PLUGINS_GIT_URL: &str = "https://github.com/openai/plugins.git"; +const CURATED_PLUGINS_FETCH_REF: &str = "refs/codex/curated-sync"; const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins"; const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha"; +const CURATED_PLUGINS_SYNC_LOCK_FILE: &str = ".tmp/plugins.sync.lock"; const CURATED_PLUGINS_BACKUP_ARCHIVE_FALLBACK_VERSION: &str = "export-backup"; const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30); const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30); @@ -78,6 +82,8 @@ fn sync_openai_plugins_repo_with_transport_overrides( api_base_url: &str, backup_archive_api_url: &str, ) -> Result { + let _file_guard = lock_curated_plugins_startup_sync(codex_home)?; + match sync_openai_plugins_repo_via_git(codex_home, git_binary) { Ok(remote_sha) => { emit_curated_plugins_startup_sync_metric("git", "success"); @@ -135,6 +141,22 @@ fn sync_openai_plugins_repo_with_transport_overrides( } } +fn lock_curated_plugins_startup_sync(codex_home: &Path) -> Result { + let lock_path = codex_home.join(CURATED_PLUGINS_SYNC_LOCK_FILE); + std::fs::create_dir_all(codex_home.join(".tmp")) + .map_err(|err| format!("failed to create curated plugins sync directory: {err}"))?; + let lock_file = File::options() + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(|err| format!("failed to open curated plugins sync lock: {err}"))?; + lock_file + .lock() + .map_err(|err| format!("failed to lock curated plugins sync: {err}"))?; + Ok(lock_file) +} + fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Result { let repo_path = curated_plugins_repo_path(codex_home); let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE); @@ -146,23 +168,30 @@ fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Resu } let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; - let clone_output = run_git_command_with_timeout( - Command::new(git_binary) - .env("GIT_OPTIONAL_LOCKS", "0") - .arg("clone") - .arg("--depth") - .arg("1") - .arg("https://github.com/openai/plugins.git") - .arg(staged_repo_dir.path()), - "git clone curated plugins repo", - CURATED_PLUGINS_GIT_TIMEOUT, + run_git_in_repo( + staged_repo_dir.path(), + git_binary, + &["init"], + "git init curated plugins repo", )?; - ensure_git_success(&clone_output, "git clone curated plugins repo")?; - let cloned_sha = git_head_sha(staged_repo_dir.path(), git_binary)?; - if cloned_sha != remote_sha { + if repo_path.join(".git").is_dir() { + fetch_curated_plugins_commit(&repo_path, &remote_sha, git_binary)?; + fetch_curated_plugins_commit_from_source( + staged_repo_dir.path(), + &repo_path, + CURATED_PLUGINS_FETCH_REF, + git_binary, + )?; + } else { + fetch_curated_plugins_commit(staged_repo_dir.path(), &remote_sha, git_binary)?; + } + + reset_curated_plugins_checkout(staged_repo_dir.path(), git_binary)?; + let fetched_sha = git_head_sha(staged_repo_dir.path(), git_binary)?; + if fetched_sha != remote_sha { return Err(format!( - "curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}" + "curated plugins fetch HEAD mismatch: expected {remote_sha}, got {fetched_sha}" )); } @@ -172,6 +201,90 @@ fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Resu Ok(remote_sha) } +fn fetch_curated_plugins_commit( + repo_path: &Path, + remote_sha: &str, + git_binary: &str, +) -> Result<(), String> { + fetch_curated_plugins_commit_from( + repo_path, + OPENAI_PLUGINS_GIT_URL.as_ref(), + remote_sha, + git_binary, + "git fetch curated plugins repo", + ) +} + +fn fetch_curated_plugins_commit_from_source( + repo_path: &Path, + source_repo_path: &Path, + remote_sha: &str, + git_binary: &str, +) -> Result<(), String> { + fetch_curated_plugins_commit_from( + repo_path, + source_repo_path, + remote_sha, + git_binary, + "git copy fetched curated plugins commit", + ) +} + +fn fetch_curated_plugins_commit_from( + repo_path: &Path, + source: &Path, + source_revision: &str, + git_binary: &str, + context: &str, +) -> Result<(), String> { + let fetch_refspec = format!("+{source_revision}:{CURATED_PLUGINS_FETCH_REF}"); + let output = run_git_command_with_timeout( + Command::new(git_binary) + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("-C") + .arg(repo_path) + .args(["fetch", "--depth", "1", "--no-tags"]) + .arg(source) + .arg(fetch_refspec), + context, + CURATED_PLUGINS_GIT_TIMEOUT, + )?; + ensure_git_success(&output, context) +} + +fn reset_curated_plugins_checkout(repo_path: &Path, git_binary: &str) -> Result<(), String> { + run_git_in_repo( + repo_path, + git_binary, + &["reset", "--hard", CURATED_PLUGINS_FETCH_REF], + "git reset curated plugins repo", + )?; + run_git_in_repo( + repo_path, + git_binary, + &["clean", "-fdx"], + "git clean curated plugins repo", + ) +} + +fn run_git_in_repo( + repo_path: &Path, + git_binary: &str, + args: &[&str], + context: &str, +) -> Result<(), String> { + let output = run_git_command_with_timeout( + Command::new(git_binary) + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("-C") + .arg(repo_path) + .args(args), + context, + CURATED_PLUGINS_GIT_TIMEOUT, + )?; + ensure_git_success(&output, context) +} + fn sync_openai_plugins_repo_via_http( codex_home: &Path, api_base_url: &str, diff --git a/codex-rs/core-plugins/src/startup_sync_tests.rs b/codex-rs/core-plugins/src/startup_sync_tests.rs index a9388e3fce82..f73e4ad9d129 100644 --- a/codex-rs/core-plugins/src/startup_sync_tests.rs +++ b/codex-rs/core-plugins/src/startup_sync_tests.rs @@ -3,6 +3,8 @@ use pretty_assertions::assert_eq; use std::io::Write; use std::path::Path; use std::path::PathBuf; +#[cfg(unix)] +use std::sync::Barrier; use tempfile::tempdir; use wiremock::Mock; use wiremock::MockServer; @@ -95,6 +97,22 @@ fn write_executable_script(path: &Path, contents: &str) { } } +#[cfg(unix)] +fn run_git(repo: &Path, args: &[&str]) -> std::process::Output { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + output +} + async fn mount_github_repo_and_ref(server: &MockServer, sha: &str) { Mock::given(method("GET")) .and(path("/repos/openai/plugins")) @@ -253,30 +271,42 @@ fn remove_stale_curated_repo_temp_dirs_removes_only_matching_directories() { #[cfg(unix)] #[test] -fn sync_openai_plugins_repo_prefers_git_when_available() { +fn concurrent_syncs_serialize_fetches_without_skipping_remote_checks() { let tmp = tempdir().expect("tempdir"); let bin_dir = tempfile::Builder::new() .prefix("fake-git-") .tempdir() .expect("tempdir"); let git_path = bin_dir.path().join("git"); + let invocation_log = bin_dir.path().join("invocations.log"); let sha = "0123456789abcdef0123456789abcdef01234567"; write_executable_script( &git_path, &format!( r#"#!/bin/sh +printf '%s\n' "$*" >> '{}' if [ "$1" = "ls-remote" ]; then + sleep 1 printf '%s\tHEAD\n' "{sha}" exit 0 fi -if [ "$1" = "clone" ]; then - dest="$5" - mkdir -p "$dest/.git" "$dest/.agents/plugins" "$dest/plugins/gmail/.codex-plugin" - cat > "$dest/.agents/plugins/marketplace.json" <<'EOF' +if [ "$1" = "-C" ] && [ "$3" = "init" ]; then + mkdir -p "$2/.git" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "fetch" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "reset" ]; then + mkdir -p "$2/.agents/plugins" "$2/plugins/gmail/.codex-plugin" + cat > "$2/.agents/plugins/marketplace.json" <<'EOF' {{"name":"openai-curated","plugins":[{{"name":"gmail","source":{{"source":"local","path":"./plugins/gmail"}}}}]}} EOF - printf '%s\n' '{{"name":"gmail"}}' > "$dest/plugins/gmail/.codex-plugin/plugin.json" + printf '%s\n' '{{"name":"gmail"}}' > "$2/plugins/gmail/.codex-plugin/plugin.json" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "clean" ]; then exit 0 fi if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then @@ -285,23 +315,55 @@ if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then fi echo "unexpected git invocation: $@" >&2 exit 1 -"# +"#, + invocation_log.display() ), ); - let synced_sha = sync_openai_plugins_repo_with_transport_overrides( - tmp.path(), - git_path.to_str().expect("utf8 path"), - "http://127.0.0.1:9", - "http://127.0.0.1:9/backend-api/plugins/export/curated", - ) - .expect("git sync should succeed"); - - assert_eq!(synced_sha, sha); + let barrier = Barrier::new(2); + let results = std::thread::scope(|scope| { + let run_sync = || { + barrier.wait(); + sync_openai_plugins_repo_with_transport_overrides( + tmp.path(), + git_path.to_str().expect("utf8 path"), + "http://127.0.0.1:9", + "http://127.0.0.1:9/backend-api/plugins/export/curated", + ) + }; + let first = scope.spawn(run_sync); + let second = scope.spawn(run_sync); + [ + first.join().expect("first sync thread"), + second.join().expect("second sync thread"), + ] + }); + + assert_eq!(results, [Ok(sha.to_string()), Ok(sha.to_string())]); let repo_path = curated_plugins_repo_path(tmp.path()); assert!(repo_path.join(".git").is_dir()); assert_curated_gmail_repo(&repo_path); assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); + let invocations = std::fs::read_to_string(invocation_log).expect("read invocation log"); + assert_eq!( + invocations + .lines() + .filter(|invocation| invocation.starts_with("ls-remote ")) + .count(), + 2 + ); + assert_eq!( + invocations + .lines() + .filter(|invocation| invocation.contains(" fetch --depth 1 --no-tags ")) + .count(), + 1 + ); + assert!( + !invocations + .lines() + .any(|invocation| invocation.split_whitespace().any(|arg| arg == "clone")) + ); } #[cfg(unix)] @@ -328,36 +390,20 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { ) .expect("write plugin manifest"); - let init_status = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("init") - .status() - .expect("run git init"); - assert!(init_status.success()); - - let add_status = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("add") - .arg(".") - .status() - .expect("run git add"); - assert!(add_status.success()); - - let commit_status = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("-c") - .arg("user.name=Codex Test") - .arg("-c") - .arg("user.email=codex@example.com") - .arg("commit") - .arg("-m") - .arg("init") - .status() - .expect("run git commit"); - assert!(commit_status.success()); + run_git(&work_repo, &["init"]); + run_git(&work_repo, &["add", "."]); + run_git( + &work_repo, + &[ + "-c", + "user.name=Codex Test", + "-c", + "user.email=codex@example.com", + "commit", + "-m", + "init", + ], + ); std::fs::create_dir_all(remote_repo.parent().expect("remote parent")) .expect("create remote parent"); @@ -370,14 +416,7 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { .expect("run git clone --bare"); assert!(clone_status.success()); - let sha_output = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("rev-parse") - .arg("HEAD") - .output() - .expect("run git rev-parse"); - assert!(sha_output.status.success()); + let sha_output = run_git(&work_repo, &["rev-parse", "HEAD"]); let sha = String::from_utf8_lossy(&sha_output.stdout) .trim() .to_string(); @@ -397,10 +436,12 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { .tempdir() .expect("tempdir"); let git_wrapper = bin_dir.path().join("git"); + let invocation_log = bin_dir.path().join("invocations.log"); write_executable_script( &git_wrapper, &format!( - "#!/bin/sh\nGIT_CONFIG_GLOBAL='{}' exec git \"$@\"\n", + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nGIT_CONFIG_GLOBAL='{}' exec git \"$@\"\n", + invocation_log.display(), git_config_path.display() ), ); @@ -416,6 +457,125 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { Some(sha.as_str()) ); assert!(!has_plugins_clone_dirs(tmp.path())); + + let first_sync_invocation_count = std::fs::read_to_string(&invocation_log) + .expect("read first sync invocations") + .lines() + .count(); + let first_sync_invocations = + std::fs::read_to_string(&invocation_log).expect("read first sync invocations"); + assert!( + first_sync_invocations + .lines() + .any(|invocation| invocation.contains(" fetch --depth 1 --no-tags ")) + ); + assert!( + !first_sync_invocations + .lines() + .any(|invocation| invocation.split_whitespace().any(|arg| arg == "clone")) + ); + write_openai_curated_marketplace(&work_repo, &["gmail", "linear"]); + run_git(&work_repo, &["add", "."]); + run_git( + &work_repo, + &[ + "-c", + "user.name=Codex Test", + "-c", + "user.email=codex@example.com", + "commit", + "-m", + "update", + ], + ); + let branch_output = run_git(&work_repo, &["symbolic-ref", "--short", "HEAD"]); + let branch = String::from_utf8_lossy(&branch_output.stdout) + .trim() + .to_string(); + let remote_repo = remote_repo.to_str().expect("utf8 remote repo"); + let push_ref = format!("HEAD:refs/heads/{branch}"); + run_git(&work_repo, &["push", remote_repo, &push_ref]); + let updated_sha_output = run_git(&work_repo, &["rev-parse", "HEAD"]); + let updated_sha = String::from_utf8_lossy(&updated_sha_output.stdout) + .trim() + .to_string(); + + let synced_sha = + sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path")) + .expect("incremental git sync should succeed"); + + assert_eq!(synced_sha, updated_sha); + assert!( + curated_plugins_repo_path(tmp.path()) + .join("plugins/linear/.codex-plugin/plugin.json") + .is_file() + ); + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some(updated_sha.as_str()) + ); + assert!( + !curated_plugins_repo_path(tmp.path()) + .join(".git/objects/info/alternates") + .exists() + ); + let invocation_log_contents = + std::fs::read_to_string(&invocation_log).expect("read sync invocations"); + let incremental_sync_invocations = invocation_log_contents + .lines() + .skip(first_sync_invocation_count) + .collect::>(); + let curated_repo_path = curated_plugins_repo_path(tmp.path()); + assert!(incremental_sync_invocations.iter().any(|invocation| { + invocation.starts_with(&format!("-C {} fetch ", curated_repo_path.display())) + && invocation.contains(" https://github.com/openai/plugins.git ") + && invocation.contains(updated_sha.as_str()) + && invocation.ends_with(CURATED_PLUGINS_FETCH_REF) + })); + assert!(incremental_sync_invocations.iter().any(|invocation| { + invocation.contains(" fetch --depth 1 --no-tags ") + && invocation.contains(&format!(" {} ", curated_repo_path.display())) + && invocation.ends_with(&format!( + "{CURATED_PLUGINS_FETCH_REF}:{CURATED_PLUGINS_FETCH_REF}" + )) + })); + assert!( + incremental_sync_invocations + .iter() + .any(|invocation| invocation.ends_with(" init")) + ); + assert!( + !incremental_sync_invocations + .iter() + .any(|invocation| invocation.split_whitespace().any(|arg| arg == "clone")) + ); + assert!(!incremental_sync_invocations.iter().any(|invocation| { + invocation.starts_with(&format!("-C {} reset ", curated_repo_path.display())) + || invocation.starts_with(&format!("-C {} clean ", curated_repo_path.display())) + })); + assert!(!has_plugins_clone_dirs(tmp.path())); + + let unchanged_sync_invocation_count = invocation_log_contents.lines().count(); + let synced_sha = + sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path")) + .expect("unchanged git sync should succeed"); + + assert_eq!(synced_sha, updated_sha); + let invocation_log = std::fs::read_to_string(&invocation_log).expect("read sync invocations"); + let unchanged_sync_invocations = invocation_log + .lines() + .skip(unchanged_sync_invocation_count) + .collect::>(); + assert!( + unchanged_sync_invocations + .iter() + .any(|invocation| invocation.starts_with("ls-remote ")) + ); + assert!( + !unchanged_sync_invocations + .iter() + .any(|invocation| invocation.contains(" fetch ")) + ); } #[tokio::test] @@ -482,7 +642,7 @@ exit 1 #[cfg(unix)] #[test] -fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_clone_failure() { +fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_fetch_failure() { let tmp = tempdir().expect("tempdir"); let bin_dir = tempfile::Builder::new() .prefix("fake-git-partial-fail-") @@ -499,9 +659,11 @@ if [ "$1" = "ls-remote" ]; then printf '%s\tHEAD\n' "{sha}" exit 0 fi -if [ "$1" = "clone" ]; then - dest="$5" - mkdir -p "$dest/.git" +if [ "$1" = "-C" ] && [ "$3" = "init" ]; then + mkdir -p "$2/.git" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "fetch" ]; then echo "fatal: early EOF" >&2 exit 128 fi @@ -518,6 +680,77 @@ exit 1 assert!(!has_plugins_clone_dirs(tmp.path())); } +#[cfg(unix)] +#[test] +fn sync_openai_plugins_repo_via_git_preserves_existing_snapshot_on_validation_failure() { + let tmp = tempdir().expect("tempdir"); + let repo_path = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&repo_path, &["gmail"]); + std::fs::create_dir_all(repo_path.join(".git")).expect("create git dir"); + write_curated_plugin_sha(tmp.path()); + + let bin_dir = tempfile::Builder::new() + .prefix("fake-git-invalid-update-") + .tempdir() + .expect("tempdir"); + let git_path = bin_dir.path().join("git"); + let remote_sha = "fedcba9876543210fedcba9876543210fedcba98"; + + write_executable_script( + &git_path, + &format!( + r#"#!/bin/sh +if [ "$1" = "ls-remote" ]; then + printf '%s\tHEAD\n' "{remote_sha}" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$2" = "{}" ] && [ "$3" = "rev-parse" ]; then + printf '%s\n' "{TEST_CURATED_PLUGIN_SHA}" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$2" = "{}" ] && [ "$3" = "fetch" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "init" ]; then + mkdir -p "$2/.git" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "fetch" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "reset" ]; then + mkdir -p "$2/plugins/linear/.codex-plugin" + printf '%s\n' '{{"name":"linear"}}' > "$2/plugins/linear/.codex-plugin/plugin.json" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "clean" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ]; then + printf '%s\n' "{remote_sha}" + exit 0 +fi +echo "unexpected git invocation: $@" >&2 +exit 1 +"#, + repo_path.display(), + repo_path.display(), + ), + ); + + let err = sync_openai_plugins_repo_via_git(tmp.path(), git_path.to_str().expect("utf8 path")) + .expect_err("invalid staged checkout should fail"); + + assert!(err.contains("curated plugins archive missing marketplace manifest")); + assert_curated_gmail_repo(&repo_path); + assert!(!repo_path.join("plugins/linear").exists()); + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some(TEST_CURATED_PLUGIN_SHA) + ); + assert!(!has_plugins_clone_dirs(tmp.path())); +} + #[tokio::test] async fn sync_openai_plugins_repo_via_http_cleans_up_staged_dir_on_extract_failure() { let tmp = tempdir().expect("tempdir"); From 4be1a168fc7ed3313ddc62031da036f0c50209b7 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Thu, 4 Jun 2026 17:51:13 -0700 Subject: [PATCH 08/59] ci: test windows cross build (#25000) We cross build when using bazel for windows. This causes a couple hiccups in that v8 does a mksnapshot step that is expecting to snapshot on the host arch which wasn't matching when we were doing the crossbuild. This was causing segfault failiures when starting up codemode from a cross built artifact. This changes things such that we cross build the library and then run and link a snapshot on the host machine/arch which is windows. This gives us a functional snapshot and library that can start code-mode on windows. This fixes the build and then fixes two test regressions we had. --- .bazelrc | 14 ++++--- .github/workflows/bazel.yml | 24 +++++------ codex-rs/core/tests/suite/code_mode.rs | 16 +++++-- patches/v8_bazel_rules.patch | 58 +++++++++++++++++++++++++- 4 files changed, 90 insertions(+), 22 deletions(-) diff --git a/.bazelrc b/.bazelrc index e39a3aff2285..c8b8682c4b1c 100644 --- a/.bazelrc +++ b/.bazelrc @@ -105,9 +105,9 @@ common:ci --disk_cache= # Shared config for the main Bazel CI workflow. common:ci-bazel --config=ci common:ci-bazel --build_metadata=TAG_workflow=bazel -# Bazel CI cross-compiles in several legs, and the V8-backed code-mode tests -# are not stable in that setup yet. Keep running the rest of the Rust -# integration suites through the workspace-root launcher. +# Keep code-mode integration cases out of ordinary Bazel legs. The +# Windows-cross config below re-enables them after generating its Windows V8 +# snapshot on the Windows runner. common:ci-bazel --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode:: # Shared config for Bazel-backed Rust linting. @@ -186,12 +186,16 @@ common:ci-windows-cross --config=ci-windows common:ci-windows-cross --build_metadata=TAG_windows_cross_compile=true common:ci-windows-cross --host_platform=//:rbe common:ci-windows-cross --strategy=TestRunner=local +# V8 embeds IsolateData offsets in snapshot builtins; Windows snapshots must be +# generated by a Windows mksnapshot binary rather than the Linux RBE host tool. +common:ci-windows-cross --strategy=V8Mksnapshot=local common:ci-windows-cross --local_test_jobs=4 common:ci-windows-cross --test_env=RUST_TEST_THREADS=1 # Native Windows CI still covers the PowerShell tests. The cross-built gnullvm # binaries currently hang in PowerShell AST parser tests when those binaries are -# run on the Windows runner. -common:ci-windows-cross --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode::,powershell +# run on the Windows runner. Keep V8-backed code-mode tests enabled except for +# the hidden dynamic-tool callback test, which currently times out on Windows. +common:ci-windows-cross --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=powershell,suite::code_mode::code_mode_can_call_hidden_dynamic_tools common:ci-windows-cross --platforms=//:windows_x86_64_gnullvm common:ci-windows-cross --extra_execution_platforms=//:rbe,//:windows_x86_64_msvc common:ci-windows-cross --extra_toolchains=//:windows_gnullvm_tests_on_msvc_host_toolchain diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index c6dfd60231d9..bddbb5adc99c 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -20,8 +20,9 @@ jobs: test: # PRs use the sharded Windows cross-compiled test jobs below. Post-merge # pushes to main also run the native Windows test job for broader Windows - # signal without putting PR latency back on the critical path. Cargo CI - # owns V8/code-mode test coverage for now. + # signal without putting PR latency back on the critical path. When + # authenticated RBE is available, the Windows-cross shards exercise the + # source-built V8/code-mode targets. timeout-minutes: 30 strategy: fail-fast: false @@ -91,9 +92,9 @@ jobs: # path. V8 consumers under `//codex-rs/...` still participate # transitively through `//...`. -//third_party/v8:all - # V8-backed code-mode tests are covered by Cargo CI. Bazel CI - # cross-compiles in several legs, and those tests are not stable in - # that setup yet. + # Keep V8-backed code-mode tests out of the ordinary macOS/Linux + # legs; authenticated Windows-cross shards below exercise the + # source-built gnullvm V8 path. -//codex-rs/code-mode:code-mode-unit-tests -//codex-rs/v8-poc:v8-poc-unit-tests ) @@ -135,9 +136,9 @@ jobs: key: ${{ steps.prepare_bazel.outputs.repository-cache-key }} test-windows-shard: - # Split the Windows Bazel test leg across separate Windows - # hosts. Each shard still uses Linux RBE for build actions, but the test - # execution itself happens on its own Windows runner. + # Split the Windows Bazel test leg across separate Windows hosts. Jobs with + # BuildBuddy credentials use Linux RBE for build actions; test execution + # remains on a Windows runner. timeout-minutes: 30 strategy: fail-fast: false @@ -183,7 +184,7 @@ jobs: run: | set -euo pipefail - bazel_test_query='tests(//...) except tests(//third_party/v8:all) except //codex-rs/code-mode:code-mode-unit-tests except //codex-rs/v8-poc:v8-poc-unit-tests except attr(tags, "manual", tests(//...))' + bazel_test_query='tests(//...) except tests(//third_party/v8:all) except attr(tags, "manual", tests(//...))' mapfile -t bazel_targets < <( MSYS2_ARG_CONV_EXCL='*' bazel query --output=label "${bazel_test_query}" \ | LC_ALL=C sort @@ -289,9 +290,8 @@ jobs: # path. V8 consumers under `//codex-rs/...` still participate # transitively through `//...`. -//third_party/v8:all - # Keep this aligned with the main Bazel job. The native Windows - # job preserves broad post-merge coverage, but code-mode/V8 tests - # are covered by Cargo CI rather than Bazel for now. + # Keep this job broad and cheap; authenticated Windows-cross jobs + # add source-built V8-backed code-mode coverage. -//codex-rs/code-mode:code-mode-unit-tests -//codex-rs/v8-poc:v8-poc-unit-tests ) diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index ffe23bff5678..bc311d424553 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -375,7 +375,11 @@ async fn code_mode_only_restricts_prompt_tools() -> Result<()> { let first_body = resp_mock.single_request().body_json(); assert_eq!( tool_names(&first_body), - vec!["exec".to_string(), "wait".to_string()] + vec![ + "exec".to_string(), + "wait".to_string(), + "web_search".to_string() + ] ); Ok(()) @@ -457,7 +461,12 @@ if (!tool) { let first_body = resp_mock.single_request().body_json(); assert_eq!( tool_names(&first_body), - vec!["exec".to_string(), "wait".to_string()] + vec![ + "exec".to_string(), + "wait".to_string(), + "web_search".to_string(), + "image_generation".to_string() + ] ); let exec_description = first_body @@ -2898,6 +2907,7 @@ text(JSON.stringify(Object.getOwnPropertyNames(globalThis).sort())); "escape", "exit", "eval", + "generatedImage", "globalThis", "image", "isFinite", @@ -2953,7 +2963,7 @@ text(JSON.stringify(tool)); parsed, serde_json::json!({ "name": "view_image", - "description": "View a local image file from the filesystem when visual inspection is needed. Use this for images already available on disk.\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```", + "description": "View a local image file from the filesystem when visual inspection is needed. Use this for images already available on disk.\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file.\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```", }) ); diff --git a/patches/v8_bazel_rules.patch b/patches/v8_bazel_rules.patch index 1c49d06b093c..97e07d9d4713 100644 --- a/patches/v8_bazel_rules.patch +++ b/patches/v8_bazel_rules.patch @@ -106,6 +106,55 @@ index 9648e4a..88efd41 100644 }) + select({ ":should_add_rdynamic": ["-rdynamic"], "//conditions:default": [], +@@ -459,6 +488,11 @@ + def _mksnapshot(ctx): + prefix = ctx.attr.prefix + suffix = ctx.attr.suffix ++ # Windows cross-builds use Linux for the exec configuration, but the ++ # snapshot generator must match the target ABI and run on the Windows ++ # runner. Action strategies only choose where an action runs; they cannot ++ # change this executable from the exec to the target configuration. ++ tool = ctx.executable.target_tool if ctx.attr.target_os == "win" else ctx.executable.tool + outs = [ + ctx.actions.declare_file(prefix + "/snapshot" + suffix + ".cc"), + ctx.actions.declare_file(prefix + "/embedded" + suffix + ".S"), +@@ -476,7 +510,7 @@ + outs[1].path, + ] + ctx.attr.args, +- executable = ctx.executable.tool, ++ executable = tool, + progress_message = "Running mksnapshot", + ) + return [DefaultInfo(files = depset(outs))] +@@ -491,6 +525,12 @@ + executable = True, + cfg = "exec", + ), ++ "target_tool": attr.label( ++ mandatory = True, ++ allow_files = True, ++ executable = True, ++ cfg = "target", ++ ), + "target_os": attr.string(mandatory = True), + "prefix": attr.string(mandatory = True), + "suffix": attr.string(mandatory = True), +@@ -504,6 +544,7 @@ + args = args, + prefix = "noicu", + tool = ":noicu/mksnapshot" + suffix, ++ target_tool = ":noicu/mksnapshot" + suffix, + suffix = suffix, + target_os = select({ + "@v8//bazel/config:is_macos": "mac", +@@ -516,6 +557,7 @@ + args = args, + prefix = "icu", + tool = ":icu/mksnapshot" + suffix, ++ target_tool = ":icu/mksnapshot" + suffix, + suffix = suffix, + target_os = select({ + "@v8//bazel/config:is_macos": "mac", diff --git a/orig/v8-14.6.202.11/BUILD.bazel b/mod/v8-14.6.202.11/BUILD.bazel index 421ebcd..52283ea 100644 --- a/orig/v8-14.6.202.11/BUILD.bazel @@ -309,7 +358,7 @@ index 421ebcd..52283ea 100644 ], ) -@@ -4772,9 +4784,15 @@ v8_binary( +@@ -4772,9 +4784,20 @@ v8_binary( ":icu/generated_torque_initializers", ":icu/v8_initializers_files", ], @@ -317,11 +366,16 @@ index 421ebcd..52283ea 100644 + # external-reference helpers together while producing the snapshot, the + # final embedder binary may not fold the same pair and startup + # deserialization will reject the snapshot. ++ # GN also increases the x64 Windows stack reserve because constructing the ++ # snapshot overflows the linker's 1 MiB default. linkopts = select({ "@v8//bazel/config:is_android": ["-llog"], - "//conditions:default": [], + "@v8//bazel/config:is_macos": ["-Wl,-no_deduplicate"], -+ "@v8//bazel/config:is_windows": ["/OPT:NOICF"], ++ "@v8//bazel/config:is_windows": [ ++ "-Wl,/OPT:NOICF", ++ "-Wl,/STACK:2097152", ++ ], + "//conditions:default": ["-Wl,--icf=none"], }), noicu_deps = [":v8_libshared_noicu"], From ecae41274091252f454eb5ed0507036ed4e6fc19 Mon Sep 17 00:00:00 2001 From: rreichel3-oai Date: Thu, 4 Jun 2026 20:58:14 -0400 Subject: [PATCH 09/59] [codex] Emit sandbox outcome telemetry event (#25955) ## Summary Adds a dedicated `codex.sandbox_outcome` telemetry event so we can query sandbox edge outcomes without threading sandbox metadata through tool-result output types. This is meant to make sandbox failures and approved escalation retries visible in OTEL while keeping the existing `codex.tool_result` event shape focused on tool completion data. ## What changed - Adds `SessionTelemetry::sandbox_outcome(...)`, which emits `codex.sandbox_outcome` as both a log and trace event. - Records the tool name, call id, sandbox outcome, initial attempt duration, and escalated attempt duration when a retry runs. - Emits `denied` when the sandbox blocks execution and no retry is run. - Emits `timed_out` and `signal` when those sandbox errors surface from tool execution. - Emits `escalated` when the initial sandboxed attempt fails and the approved unsandboxed retry succeeds. - Adds OTEL coverage for the new event payload, including timing fields. ## Validation - `RUST_MIN_STACK=8388608 just test -p codex-core sandbox_outcome_event_records_outcome handle_sandbox_error_user_approves_retry_records_tool_decision` - `just test -p codex-otel otel_export_routing_policy_routes_tool_result_log_and_trace_events runtime_metrics_summary_collects_tool_api_and_streaming_metrics` - `just fix -p codex-core` - `just fix -p codex-otel` --- codex-rs/core/src/tools/orchestrator.rs | 86 +++++++++++++++++-- codex-rs/core/tests/suite/otel.rs | 69 +++++++++++++++ codex-rs/otel/src/events/session_telemetry.rs | 31 +++++++ 3 files changed, 181 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 235f8c4e96a5..77cbffde328c 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -39,6 +39,7 @@ use codex_protocol::protocol::NetworkPolicyRuleAction; use codex_protocol::protocol::ReviewDecision; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxType; +use std::time::Instant; pub(crate) struct ToolOrchestrator { sandbox: SandboxManager, @@ -256,6 +257,7 @@ impl ToolOrchestrator { network_denial_cancellation_token: None, }; + let initial_attempt_start = Instant::now(); let (first_result, first_deferred_network_approval) = Self::run_attempt( tool, req, @@ -264,6 +266,7 @@ impl ToolOrchestrator { managed_network_active, ) .await; + let initial_duration = initial_attempt_start.elapsed(); match first_result { Ok(out) => { // We have a successful initial result @@ -284,12 +287,26 @@ impl ToolOrchestrator { None }; if network_policy_decision.is_some() && network_approval_context.is_none() { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, }))); } if !tool.escalate_on_failure() { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, @@ -312,6 +329,13 @@ impl ToolOrchestrator { ExecApprovalRequirement::NeedsApproval { .. } ); if !allow_on_request_network_prompt { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, @@ -319,6 +343,13 @@ impl ToolOrchestrator { } } if !unsandboxed_allowed && network_approval_context.is_none() { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, @@ -400,15 +431,51 @@ impl ToolOrchestrator { }; // Second attempt. + let escalated_attempt_start = Instant::now(); let (retry_result, retry_deferred_network_approval) = Self::run_attempt(tool, req, tool_ctx, &retry_attempt, managed_network_active) .await; - retry_result.map(|output| OrchestratorRunResult { - output, - deferred_network_approval: retry_deferred_network_approval, - }) + let escalated_duration = escalated_attempt_start.elapsed(); + match retry_result { + Ok(output) => { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "escalated", + initial_duration, + Some(escalated_duration), + ); + Ok(OrchestratorRunResult { + output, + deferred_network_approval: retry_deferred_network_approval, + }) + } + Err(err) => { + if let Some(outcome) = sandbox_outcome_from_tool_error(&err) { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + outcome, + initial_duration, + Some(escalated_duration), + ); + } + Err(err) + } + } + } + Err(err) => { + if let Some(outcome) = sandbox_outcome_from_tool_error(&err) { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + outcome, + initial_duration, + /*escalated_duration*/ None, + ); + } + Err(err) } - Err(err) => Err(err), } } @@ -509,6 +576,15 @@ impl ToolOrchestrator { } } +fn sandbox_outcome_from_tool_error(err: &ToolError) -> Option<&'static str> { + match err { + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { .. })) => Some("denied"), + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { .. })) => Some("timed_out"), + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Signal(_))) => Some("signal"), + ToolError::Rejected(_) | ToolError::Codex(_) => None, + } +} + fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String { // Keep approval reason terse and stable for UX/tests, but accept the // output so we can evolve heuristics later without touching call sites. diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 7f5fa260cd62..d6c7577a7f60 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -1,11 +1,15 @@ use codex_core::config::Constrained; use codex_features::Feature; +use codex_otel::SessionTelemetry; +use codex_otel::TelemetryAuthMode; +use codex_protocol::ThreadId; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -27,6 +31,7 @@ use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use std::sync::Mutex; +use std::time::Duration; use tracing::Level; use tracing_test::traced_test; @@ -1145,6 +1150,70 @@ fn tool_decision_assertion<'a>( } } +fn sandbox_outcome_assertion<'a>( + call_id: &'a str, + expected_outcome: &'a str, +) -> impl Fn(&[&str]) -> Result<(), String> + 'a { + let call_id = call_id.to_string(); + let expected_outcome = expected_outcome.to_string(); + + move |lines: &[&str]| { + let line = lines + .iter() + .find(|line| { + line.contains("codex.sandbox_outcome") + && line.contains(&format!("call_id={call_id}")) + }) + .ok_or_else(|| format!("missing codex.sandbox_outcome event for {call_id}"))?; + + let lower = line.to_lowercase(); + if !lower.contains("tool_name=shell_command") { + return Err("missing tool_name for shell_command".to_string()); + } + if !lower.contains(&format!("outcome={expected_outcome}")) { + return Err(format!("unexpected sandbox outcome for {call_id}")); + } + if !lower.contains("initial_duration_ms=12") { + return Err("missing initial_duration_ms field".to_string()); + } + if !lower.contains("escalated_duration_ms=34") { + return Err("missing escalated_duration_ms field".to_string()); + } + + Ok(()) + } +} + +#[test] +#[traced_test] +fn sandbox_outcome_event_records_outcome() { + let telemetry = SessionTelemetry::new( + ThreadId::new(), + "gpt-5.5", + "gpt-5.5", + /*account_id*/ None, + /*account_email*/ None, + Some(TelemetryAuthMode::ApiKey), + "Codex_Desktop".to_string(), + /*log_user_prompts*/ false, + "tty".to_string(), + SessionSource::Cli, + ); + + telemetry.sandbox_outcome( + "shell_command", + "sandbox-outcome-call", + "escalated", + Duration::from_millis(/*millis*/ 12), + Some(Duration::from_millis(/*millis*/ 34)), + ); + + logs_assert(sandbox_outcome_assertion( + "sandbox-outcome-call", + "escalated", + )); +} + #[tokio::test] #[traced_test] async fn handle_shell_command_autoapprove_from_config_records_tool_decision() { diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index f4534a0791af..0003cb04a5e7 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -998,6 +998,37 @@ impl SessionTelemetry { ); } + pub fn sandbox_outcome( + &self, + tool_name: &str, + call_id: &str, + outcome: &str, + initial_duration: Duration, + escalated_duration: Option, + ) { + let initial_duration_ms = initial_duration.as_millis().min(i64::MAX as u128) as i64; + let escalated_duration_ms = + escalated_duration.map(|duration| duration.as_millis().min(i64::MAX as u128) as i64); + log_event!( + self, + event.name = "codex.sandbox_outcome", + tool_name = %tool_name, + call_id = %call_id, + outcome = %outcome, + initial_duration_ms = initial_duration_ms, + escalated_duration_ms = escalated_duration_ms, + ); + trace_event!( + self, + event.name = "codex.sandbox_outcome", + tool_name = %tool_name, + call_id = %call_id, + outcome = %outcome, + initial_duration_ms = initial_duration_ms, + escalated_duration_ms = escalated_duration_ms, + ); + } + #[allow(clippy::too_many_arguments)] pub async fn log_tool_result_with_tags( &self, From e0096db6dc8a2f89394986123802a676a1be20c5 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Thu, 4 Jun 2026 18:49:51 -0700 Subject: [PATCH 10/59] [codex] Add use_responses_lite 'override' logic (#26487) ## Summary - add a defaulted `ModelInfo.use_responses_lite` catalog field - support serializing `reasoning.context` while preserving the existing effort and summary path - has not been turned on for any models yet I've added an override to parallel tools if responses_lite is on. I've also forced persistent reasoning when using responses_lite. It would be ideal if we could centralize all the responses_lite plumbing, but I think this is best for now to keep the plumbing & diffs small. ## Testing - `cargo test -p codex-protocol model_info_defaults_availability_nux_to_none_when_omitted` - `RUST_MIN_STACK=8388608 cargo test -p codex-core responses_lite_sets_all_turns_context_and_disables_parallel_tool_calls` - `RUST_MIN_STACK=8388608 cargo test -p codex-core configured_reasoning_summary_is_sent` - `cargo check -p codex-core --tests` - `RUST_MIN_STACK=8388608 cargo clippy -p codex-core --tests` (passes with pre-existing warnings in `codex-code-mode` and `codex-core-plugins`) --- .../app-server/tests/common/models_cache.rs | 1 + codex-rs/codex-api/src/common.rs | 10 +++ codex-rs/codex-api/src/lib.rs | 1 + .../codex-api/tests/models_integration.rs | 1 + codex-rs/core/src/client.rs | 9 ++- codex-rs/core/tests/suite/auto_review.rs | 1 + codex-rs/core/tests/suite/client.rs | 65 +++++++++++++++++++ codex-rs/core/tests/suite/model_switching.rs | 2 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + codex-rs/core/tests/suite/personality.rs | 2 + codex-rs/core/tests/suite/remote_models.rs | 3 + codex-rs/core/tests/suite/rmcp_client.rs | 1 + .../tests/suite/spawn_agent_description.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 1 + codex-rs/models-manager/src/model_info.rs | 1 + codex-rs/protocol/src/openai_models.rs | 4 ++ codex-rs/tools/src/tool_config_tests.rs | 1 + 17 files changed, 104 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 127c14bbb435..8233b1b2966e 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -52,6 +52,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 50ac2685b447..12f8cc55da09 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -110,12 +110,22 @@ pub enum ResponseEvent { ModelsEtag(String), } +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ReasoningContext { + Auto, + CurrentTurn, + AllTurns, +} + #[derive(Debug, Serialize, Clone, PartialEq)] pub struct Reasoning { #[serde(skip_serializing_if = "Option::is_none")] pub effort: Option, #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, } #[derive(Debug, Serialize, Default, Clone, PartialEq)] diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 8efebf076c73..d2d767940647 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -30,6 +30,7 @@ pub use crate::common::OpenAiVerbosity; pub use crate::common::RawMemory; pub use crate::common::RawMemoryMetadata; pub use crate::common::Reasoning; +pub use crate::common::ReasoningContext; pub use crate::common::ResponseCreateWsRequest; pub use crate::common::ResponseEvent; pub use crate::common::ResponseStream; diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 9ff273211ff7..4ee6069898b7 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -98,6 +98,7 @@ async fn models_client_hits_models_endpoint() { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 9c133d85e401..ee8ea3619f44 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -44,6 +44,7 @@ use codex_api::RawMemory as ApiRawMemory; use codex_api::RealtimeCallClient as ApiRealtimeCallClient; use codex_api::RealtimeSessionConfig as ApiRealtimeSessionConfig; use codex_api::Reasoning; +use codex_api::ReasoningContext; use codex_api::RequestTelemetry; use codex_api::ReqwestTransport; use codex_api::ResponseCreateWsRequest; @@ -609,6 +610,7 @@ impl ModelClient { reasoning: effort.map(|effort| Reasoning { effort: Some(effort), summary: None, + context: None, }), }; @@ -727,6 +729,11 @@ impl ModelClient { } else { Some(summary) }, + // When Responses Lite is disabled, omit context so Responses uses the default, + // which is currently `current_turn`. + context: model_info + .use_responses_lite + .then_some(ReasoningContext::AllTurns), }) } else { None @@ -775,7 +782,7 @@ impl ModelClient { input, tools, tool_choice: "auto".to_string(), - parallel_tool_calls: prompt.parallel_tool_calls, + parallel_tool_calls: prompt.parallel_tool_calls && !model_info.use_responses_lite, reasoning, store: provider.is_azure_responses_endpoint(), stream: true, diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index 570f97008f9b..64ae10d6887d 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -231,6 +231,7 @@ fn remote_model_with_auto_review_override(slug: &str, review_model: &str) -> Mod input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: Some(review_model.to_string()), tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index ad8ff17cdc02..72e415e2be76 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1872,6 +1872,71 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { .and_then(|value| value.as_str()), Some("concise") ); + pretty_assertions::assert_eq!( + request_body + .get("reasoning") + .and_then(|reasoning| reasoning.get("context")), + None + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_sets_all_turns_context_and_disables_parallel_tool_calls() +-> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let server = MockServer::start().await; + + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + + let mut model_catalog = bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); + let model = model_catalog + .models + .iter_mut() + .find(|model| model.slug == "gpt-5.4") + .expect("gpt-5.4 exists in bundled models.json"); + model.use_responses_lite = true; + model.supports_parallel_tool_calls = true; + + let TestCodex { codex, .. } = test_codex() + .with_model("gpt-5.4") + .with_config(move |config| { + config.model_catalog = Some(model_catalog); + }) + .build(&server) + .await?; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request_body = resp_mock.single_request().body_json(); + pretty_assertions::assert_eq!( + request_body + .get("reasoning") + .and_then(|reasoning| reasoning.get("context")) + .and_then(|value| value.as_str()), + Some("all_turns") + ); + pretty_assertions::assert_eq!(request_body.get("parallel_tool_calls"), Some(&json!(false))); Ok(()) } diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 111ce5043688..a80c43660c45 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -112,6 +112,7 @@ fn test_model_info( input_modalities, used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -932,6 +933,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 34fc98b59f95..d27c43cfc611 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -370,6 +370,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index cd264c0734f3..bf411d9cd171 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -592,6 +592,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -705,6 +706,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 608558d36545..0ed39216d04c 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -478,6 +478,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -730,6 +731,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -1216,6 +1218,7 @@ fn test_remote_model_with_policy( input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 36ea7d4b2c18..552c4ed52989 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1349,6 +1349,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re input_modalities: vec![InputModality::Text], used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 3b581c905122..ba29b4f00c14 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -60,6 +60,7 @@ fn test_model_info( input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 2e34475387c2..19758592c3f8 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1355,6 +1355,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an input_modalities: vec![InputModality::Text], used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index 58137a3f5002..81a5c6e5edcd 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -99,6 +99,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: true, // this is the fallback model metadata supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 4c04edbb5662..1553ef3435ee 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -404,6 +404,8 @@ pub struct ModelInfo { pub used_fallback_model_metadata: bool, #[serde(default)] pub supports_search_tool: bool, + #[serde(default)] + pub use_responses_lite: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_review_model_override: Option, #[serde( @@ -674,6 +676,7 @@ mod tests { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -936,6 +939,7 @@ mod tests { assert!(!model.supports_image_detail_original); assert_eq!(model.web_search_tool_type, WebSearchToolType::Text); assert!(!model.supports_search_tool); + assert!(!model.use_responses_lite); assert_eq!(model.auto_review_model_override, None); assert_eq!(model.tool_mode, None); } diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs index 1b4d789394a6..0e3917d69773 100644 --- a/codex-rs/tools/src/tool_config_tests.rs +++ b/codex-rs/tools/src/tool_config_tests.rs @@ -44,6 +44,7 @@ fn model_with_shell_type(shell_type: ConfigShellToolType) -> ModelInfo { input_modalities: codex_protocol::openai_models::default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, From 4de7a2b9d8eae19e00ca7f744647fa1aabdc204f Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 4 Jun 2026 19:31:06 -0700 Subject: [PATCH 11/59] fix(rmcp): refresh expired OAuth tokens before startup (#26482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Codex persists OAuth expiry as an absolute `expires_at`, then reconstructs RMCP’s relative `expires_in` when credentials are loaded. For an already-expired token, Codex reconstructed `expires_in` as missing. [RMCP 0.15 treated a missing `expires_in` as zero when a refresh token was present](https://github.com/modelcontextprotocol/rust-sdk/blob/9cfc905a9ef17c8bba6748dc0a9bdd2452681733/crates/rmcp/src/transport/auth.rs#L704-L723), so this still triggered a refresh. [RMCP 1.7 treats missing expiry information as unknown and uses the access token as-is](https://github.com/modelcontextprotocol/rust-sdk/blob/3529c3675ff64db805bd947ca6ece6090809e43d/crates/rmcp/src/transport/auth.rs#L1233-L1265), causing the stale token to be sent during `initialize`. ## What changed - Represent a known-expired persisted token as `expires_in = 0`, preserving `None` for genuinely unknown expiry. - Add Streamable HTTP coverage requiring the token to refresh before the startup handshake. ## Validation - The new regression test fails on RMCP 1.7 before the fix and passes afterward. - The same scenario passes on the commit immediately before the RMCP 1.7 update, using RMCP 0.15. - `just test -p codex-rmcp-client` (63 passed). --- codex-rs/Cargo.lock | 1 + codex-rs/rmcp-client/Cargo.toml | 1 + codex-rs/rmcp-client/src/oauth.rs | 12 +- .../tests/streamable_http_oauth_startup.rs | 155 ++++++++++++++++++ .../tests/streamable_http_test_support.rs | 9 +- 5 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 codex-rs/rmcp-client/tests/streamable_http_oauth_startup.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1e79865d4373..6da768843504 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3597,6 +3597,7 @@ dependencies = [ "urlencoding", "webbrowser", "which 8.0.0", + "wiremock", ] [[package]] diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index e3417de70ecb..1006ad74e80a 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -70,6 +70,7 @@ codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } serial_test = { workspace = true } tempfile = { workspace = true } +wiremock = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] keyring = { workspace = true, features = ["linux-native-async-persistent"] } diff --git a/codex-rs/rmcp-client/src/oauth.rs b/codex-rs/rmcp-client/src/oauth.rs index e23eee84bee9..c348460795de 100644 --- a/codex-rs/rmcp-client/src/oauth.rs +++ b/codex-rs/rmcp-client/src/oauth.rs @@ -113,7 +113,13 @@ fn refresh_expires_in_from_timestamp(tokens: &mut StoredOAuthTokens) { tokens.token_response.0.set_expires_in(Some(&duration)); } None => { - tokens.token_response.0.set_expires_in(None); + // RMCP treats a missing expiry as unknown and uses the access token + // as-is. Treat a known-expired timestamp as an explicit zero so + // startup refreshes the token before the first request. + tokens + .token_response + .0 + .set_expires_in(Some(&Duration::ZERO)); } } } @@ -830,7 +836,7 @@ mod tests { } #[test] - fn refresh_expires_in_from_timestamp_clears_expired_tokens() { + fn refresh_expires_in_from_timestamp_marks_expired_tokens() { let mut tokens = sample_tokens(); let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -843,7 +849,7 @@ mod tests { super::refresh_expires_in_from_timestamp(&mut tokens); - assert!(tokens.token_response.0.expires_in().is_none()); + assert_eq!(tokens.token_response.0.expires_in(), Some(Duration::ZERO)); } fn assert_tokens_match_without_expiry( diff --git a/codex-rs/rmcp-client/tests/streamable_http_oauth_startup.rs b/codex-rs/rmcp-client/tests/streamable_http_oauth_startup.rs new file mode 100644 index 000000000000..1c18f2c98bda --- /dev/null +++ b/codex-rs/rmcp-client/tests/streamable_http_oauth_startup.rs @@ -0,0 +1,155 @@ +mod streamable_http_test_support; + +use std::time::Duration; + +use codex_config::types::OAuthCredentialsStoreMode; +use codex_exec_server::Environment; +use codex_rmcp_client::RmcpClient; +use codex_rmcp_client::StoredOAuthTokens; +use codex_rmcp_client::WrappedOAuthTokenResponse; +use codex_rmcp_client::save_oauth_tokens; +use oauth2::AccessToken; +use oauth2::RefreshToken; +use oauth2::basic::BasicTokenType; +use rmcp::transport::auth::OAuthTokenResponse; +use rmcp::transport::auth::VendorExtraTokenFields; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; +use tokio::process::Command; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::Request; +use wiremock::ResponseTemplate; +use wiremock::matchers::body_string_contains; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use streamable_http_test_support::initialize_client; + +const SERVER_NAME: &str = "test-streamable-http-oauth-startup"; +const EXPIRED_ACCESS_TOKEN: &str = "expired-access-token"; +const REFRESH_TOKEN: &str = "valid-refresh-token"; +const REFRESHED_ACCESS_TOKEN: &str = "refreshed-access-token"; +const CHILD_SERVER_URL_ENV: &str = "MCP_TEST_OAUTH_STARTUP_SERVER_URL"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn refreshes_expired_persisted_token_before_initialize() -> anyhow::Result<()> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/.well-known/oauth-authorization-server/mcp")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "authorization_endpoint": format!("{}/oauth/authorize", server.uri()), + "token_endpoint": format!("{}/oauth/token", server.uri()), + "scopes_supported": [""], + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .and(body_string_contains("grant_type=refresh_token")) + .and(body_string_contains(format!( + "refresh_token={REFRESH_TOKEN}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": REFRESHED_ACCESS_TOKEN, + "token_type": "Bearer", + "expires_in": 7200, + "refresh_token": REFRESH_TOKEN, + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/mcp")) + .and(header( + "authorization", + format!("Bearer {REFRESHED_ACCESS_TOKEN}"), + )) + .respond_with(|request: &Request| { + let body: Value = request.body_json().expect("valid JSON-RPC request"); + match body.get("method").and_then(Value::as_str) { + Some("initialize") => ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": body.get("id").cloned().unwrap_or(Value::Null), + "result": { + "protocolVersion": body + .pointer("/params/protocolVersion") + .cloned() + .unwrap_or_else(|| json!("2025-06-18")), + "capabilities": {}, + "serverInfo": { + "name": "oauth-startup-test", + "version": "0.0.0-test", + }, + }, + })), + Some("notifications/initialized") => ResponseTemplate::new(202), + method => ResponseTemplate::new(400) + .set_body_string(format!("unexpected JSON-RPC method: {method:?}")), + } + }) + .expect(2) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + let server_url = format!("{}/mcp", server.uri()); + + // Credential storage resolves CODEX_HOME from the process environment. + // Run the client half of the test in an ignored helper test so it can use + // an isolated home without mutating the parent test runner's environment. + let status = Command::new(std::env::current_exe()?) + .args(["oauth_startup_child", "--exact", "--ignored", "--nocapture"]) + .env("CODEX_HOME", codex_home.path()) + .env(CHILD_SERVER_URL_ENV, server_url) + .status() + .await?; + assert!(status.success(), "OAuth startup child failed: {status}"); + server.verify().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[ignore = "spawned by refreshes_expired_persisted_token_before_initialize"] +async fn oauth_startup_child() -> anyhow::Result<()> { + let server_url = std::env::var(CHILD_SERVER_URL_ENV)?; + + // Save an expired access token with a valid refresh token so startup must + // refresh before sending the initialize request. + let mut response = OAuthTokenResponse::new( + AccessToken::new(EXPIRED_ACCESS_TOKEN.to_string()), + BasicTokenType::Bearer, + VendorExtraTokenFields::default(), + ); + response.set_refresh_token(Some(RefreshToken::new(REFRESH_TOKEN.to_string()))); + response.set_expires_in(Some(&Duration::from_secs(7200))); + let tokens = StoredOAuthTokens { + server_name: SERVER_NAME.to_string(), + url: server_url.clone(), + client_id: "test-client-id".to_string(), + token_response: WrappedOAuthTokenResponse(response), + expires_at: Some(0), + }; + save_oauth_tokens(SERVER_NAME, &tokens, OAuthCredentialsStoreMode::File)?; + + // This mirrors create_client's transport and initialization setup, except + // it omits the direct bearer token. Supplying that token would bypass the + // persisted OAuth credentials and the startup refresh under test. + let client = RmcpClient::new_streamable_http_client( + SERVER_NAME, + &server_url, + /*bearer_token*/ None, + /*http_headers*/ None, + /*env_http_headers*/ None, + OAuthCredentialsStoreMode::File, + Environment::default_for_tests().get_http_client(), + /*auth_provider*/ None, + ) + .await?; + + initialize_client(&client).await?; + Ok(()) +} diff --git a/codex-rs/rmcp-client/tests/streamable_http_test_support.rs b/codex-rs/rmcp-client/tests/streamable_http_test_support.rs index 822acef1a26b..03ee79392cd9 100644 --- a/codex-rs/rmcp-client/tests/streamable_http_test_support.rs +++ b/codex-rs/rmcp-client/tests/streamable_http_test_support.rs @@ -86,6 +86,12 @@ pub(crate) async fn create_client(base_url: &str) -> anyhow::Result ) .await?; + initialize_client(&client).await?; + + Ok(client) +} + +pub(crate) async fn initialize_client(client: &RmcpClient) -> anyhow::Result<()> { client .initialize( init_params(), @@ -102,8 +108,7 @@ pub(crate) async fn create_client(base_url: &str) -> anyhow::Result }), ) .await?; - - Ok(client) + Ok(()) } /// Creates a Streamable HTTP RMCP client that sends traffic through the remote From 1d9c9c9f33735223cc564ec942001c9141a11eb1 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 4 Jun 2026 20:23:37 -0700 Subject: [PATCH 12/59] [codex] Keep Bazel startup options stable across commands (#26256) ## Why `just bazel-clippy` ran target discovery with `--noexperimental_remote_repo_contents_cache`, then ran the build with the workspace default `--experimental_remote_repo_contents_cache`. Bazel therefore killed and restarted its server on each transition, slowing repeated commands and discarding the in-memory analysis cache. An audit found the same class of startup-option variation in several CI command sequences. ## What changed - Keep local lint target-discovery queries on the workspace-default Bazel server, while making CI target discovery explicitly use the CI startup options. - Normalize GitHub Actions launches through the BuildBuddy wrapper to share `BAZEL_OUTPUT_USER_ROOT` and `--noexperimental_remote_repo_contents_cache`. - Route the CI lockfile check and Windows test-shard query through the same startup configuration. - Document the startup-option invariant and add wrapper regression coverage. ## Validation - Confirmed consecutive local clippy target-discovery runs retained the same Bazel server PID. --- .github/scripts/run-bazel-query-ci.sh | 20 +++------ .github/scripts/run_bazel_with_buildbuddy.py | 43 ++++++++++++++++++- .../scripts/test_run_bazel_with_buildbuddy.py | 34 +++++++++++++++ .github/workflows/bazel.yml | 2 +- codex-rs/docs/bazel.md | 5 ++- scripts/check-module-bazel-lock.sh | 3 +- 6 files changed, 89 insertions(+), 18 deletions(-) diff --git a/.github/scripts/run-bazel-query-ci.sh b/.github/scripts/run-bazel-query-ci.sh index f5d4f56f49cc..39fa04c47de0 100755 --- a/.github/scripts/run-bazel-query-ci.sh +++ b/.github/scripts/run-bazel-query-ci.sh @@ -4,7 +4,8 @@ set -euo pipefail # Run target-discovery queries with the same startup settings as the main # build/test invocation so they can reuse the same Bazel server. Queries only -# enumerate labels, so they intentionally do not select CI or remote configs. +# enumerate labels, so they intentionally do not select a CI build/test config +# or remote execution. if [[ $# -lt 2 || "${@: -2:1}" != "--" ]]; then echo "Usage: $0 [...] -- " >&2 @@ -14,21 +15,16 @@ fi query_args=("${@:1:$#-2}") query_expression="${@: -1}" -bazel_startup_args=() -if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then - bazel_startup_args+=("--output_user_root=${BAZEL_OUTPUT_USER_ROOT}") -fi - run_bazel() { if [[ "${RUNNER_OS:-}" == "Windows" ]]; then - MSYS2_ARG_CONV_EXCL='*' bazel "$@" + MSYS2_ARG_CONV_EXCL='*' "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@" return fi - bazel "$@" + "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@" } -bazel_query_args=(--noexperimental_remote_repo_contents_cache query) +bazel_query_args=(query) if [[ -n "${BAZEL_REPO_CONTENTS_CACHE:-}" ]]; then bazel_query_args+=("--repo_contents_cache=${BAZEL_REPO_CONTENTS_CACHE}") @@ -43,8 +39,4 @@ if (( ${#query_args[@]} > 0 )); then fi bazel_query_args+=("$query_expression") -if (( ${#bazel_startup_args[@]} > 0 )); then - run_bazel "${bazel_startup_args[@]}" "${bazel_query_args[@]}" -else - run_bazel "${bazel_query_args[@]}" -fi +run_bazel "${bazel_query_args[@]}" diff --git a/.github/scripts/run_bazel_with_buildbuddy.py b/.github/scripts/run_bazel_with_buildbuddy.py index f9f329d6869e..4503b4fda38c 100755 --- a/.github/scripts/run_bazel_with_buildbuddy.py +++ b/.github/scripts/run_bazel_with_buildbuddy.py @@ -22,6 +22,47 @@ "--config=ci-v8", "--config=ci-windows-cross", } +# Honor either explicit setting so the wrapper never overrides the caller's +# choice when it supplies the CI default below. +REMOTE_REPO_CONTENTS_CACHE_STARTUP_OPTIONS = { + "--experimental_remote_repo_contents_cache", + "--noexperimental_remote_repo_contents_cache", +} + + +def startup_args(args: Sequence[str], env: Mapping[str, str]) -> list[str]: + """Return shared startup options that are missing from a Bazel invocation. + + Bazel startup options must precede the command, and changing them restarts + the server and discards its analysis cache. GitHub Actions invokes Bazel + through several helpers, so normalize their startup options here while + preserving any explicit choice made by the caller. + """ + command_idx = next( + (idx for idx, arg in enumerate(args) if not arg.startswith("-")), + len(args), + ) + configured_startup_args = args[:command_idx] + injected_args = [] + + output_user_root = env.get("BAZEL_OUTPUT_USER_ROOT") + if output_user_root and not any( + arg.startswith("--output_user_root=") for arg in configured_startup_args + ): + injected_args.append(f"--output_user_root={output_user_root}") + + if env.get("GITHUB_ACTIONS") == "true" and not any( + arg in REMOTE_REPO_CONTENTS_CACHE_STARTUP_OPTIONS + for arg in configured_startup_args + ): + # Work around Bazel 9 overlay materialization failures seen in CI. This + # disables only the startup-level repo contents cache; keyed runs still + # use BuildBuddy. + injected_args.append("--noexperimental_remote_repo_contents_cache") + + return injected_args + + # Only authenticated workflow runs executing trusted upstream code may use the # OpenAI BuildBuddy host. A pull request event without proof that its head is # in the upstream repository fails closed to the generic host. @@ -114,7 +155,7 @@ def bazel_args_with_remote_config( def bazel_command(*args: str, env: Mapping[str, str] | None = None) -> list[str]: env = os.environ if env is None else env bazel = env.get("CODEX_BAZEL_BIN", "bazel") - return [bazel, *bazel_args_with_remote_config(args, env)] + return [bazel, *startup_args(args, env), *bazel_args_with_remote_config(args, env)] def main() -> None: diff --git a/.github/scripts/test_run_bazel_with_buildbuddy.py b/.github/scripts/test_run_bazel_with_buildbuddy.py index 0f594b7947e7..f06e34b89588 100644 --- a/.github/scripts/test_run_bazel_with_buildbuddy.py +++ b/.github/scripts/test_run_bazel_with_buildbuddy.py @@ -182,6 +182,38 @@ def test_bazel_command_uses_configured_binary_locally(self) -> None: ["fake-bazel", "info", "execution_root"], ) + def test_bazel_command_normalizes_github_actions_startup_options(self) -> None: + env = { + "BAZEL_OUTPUT_USER_ROOT": "/tmp/bazel-output", + "GITHUB_ACTIONS": "true", + } + + self.assertEqual( + run_bazel_with_buildbuddy.bazel_command("build", "//codex-rs/...", env=env), + [ + "bazel", + "--output_user_root=/tmp/bazel-output", + "--noexperimental_remote_repo_contents_cache", + "build", + "//codex-rs/...", + ], + ) + self.assertEqual( + run_bazel_with_buildbuddy.bazel_command( + "--experimental_remote_repo_contents_cache", + "build", + "//codex-rs/...", + env=env, + ), + [ + "bazel", + "--output_user_root=/tmp/bazel-output", + "--experimental_remote_repo_contents_cache", + "build", + "//codex-rs/...", + ], + ) + def test_main_preserves_spaced_argument_and_child_exit_status(self) -> None: spaced_arg = ( r"--test_env=PATH=C:\Program Files\PowerShell\7;C:\Program Files\Git\bin" @@ -191,7 +223,9 @@ def test_main_preserves_spaced_argument_and_child_exit_status(self) -> None: ) env = os.environ.copy() env["CODEX_BAZEL_BIN"] = sys.executable + env.pop("BAZEL_OUTPUT_USER_ROOT", None) env.pop("BUILDBUDDY_API_KEY", None) + env.pop("GITHUB_ACTIONS", None) result = subprocess.run( [ diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index bddbb5adc99c..cc1ef0924553 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -186,7 +186,7 @@ jobs: bazel_test_query='tests(//...) except tests(//third_party/v8:all) except attr(tags, "manual", tests(//...))' mapfile -t bazel_targets < <( - MSYS2_ARG_CONV_EXCL='*' bazel query --output=label "${bazel_test_query}" \ + ./.github/scripts/run-bazel-query-ci.sh --output=label -- "${bazel_test_query}" \ | LC_ALL=C sort ) diff --git a/codex-rs/docs/bazel.md b/codex-rs/docs/bazel.md index 085c15992f28..f12eeefd7092 100644 --- a/codex-rs/docs/bazel.md +++ b/codex-rs/docs/bazel.md @@ -81,7 +81,10 @@ GitHub Actions routes Bazel build and output-resolution commands through `.github/scripts/run-bazel-ci.sh` and `.github/scripts/rusty_v8_bazel.py` delegate remote configuration selection to that wrapper. The wrapper reads the GitHub Actions repository and event payload rather than relying on workflow -files to duplicate tenant-selection logic. +files to duplicate tenant-selection logic. It also normalizes GitHub Actions +startup options so all Bazel launches in a job reuse the same server and +in-memory analysis cache. Target-discovery and lockfile helpers delegate to the +same wrapper so their callers do not need to select CI-specific startup options. Loading-phase target-discovery `bazel query` commands run locally because they only enumerate labels and do not need remote caches or execution. diff --git a/scripts/check-module-bazel-lock.sh b/scripts/check-module-bazel-lock.sh index 1a148896437a..49ddaaef2bfc 100755 --- a/scripts/check-module-bazel-lock.sh +++ b/scripts/check-module-bazel-lock.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -if ! bazel mod deps --lockfile_mode=error; then +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if ! "${repo_root}/.github/scripts/run_bazel_with_buildbuddy.py" mod deps --lockfile_mode=error; then echo "MODULE.bazel.lock is out of date." echo "Run 'just bazel-lock-update' and commit the updated lockfile." exit 1 From a2f5874b7a2db79b46c60a73858ba4c4b4807c05 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 4 Jun 2026 21:48:45 -0700 Subject: [PATCH 13/59] core: derive exec policy filesystem policy from profile (#26499) ## Why `PermissionProfile` already owns the runtime filesystem sandbox policy through `file_system_sandbox_policy()`. Keeping a separate `FileSystemSandboxPolicy` on exec-policy fallback contexts made it possible for callers and tests to construct split states that the production permission model should not rely on. ## What changed - Removed `file_system_sandbox_policy` from `UnmatchedCommandContext`, `ExecApprovalRequest`, and the intercepted Unix exec-policy context. - Derived filesystem sandbox policy inside unmatched-command decision logic from `PermissionProfile::file_system_sandbox_policy()`. - Simplified shell/unified-exec callers and tests that were only plumbing the duplicate policy through. ## Testing Local tests not run per request; relying on remote CI. --- codex-rs/core/src/exec_policy.rs | 17 +--- codex-rs/core/src/exec_policy_tests.rs | 79 +------------------ .../core/src/exec_policy_windows_tests.rs | 4 - codex-rs/core/src/session/tests.rs | 2 - codex-rs/core/src/tools/handlers/shell.rs | 2 - .../tools/runtimes/shell/unix_escalation.rs | 4 - .../runtimes/shell/unix_escalation_tests.rs | 7 -- .../core/src/unified_exec/process_manager.rs | 2 - 8 files changed, 7 insertions(+), 110 deletions(-) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index a44ffbe3ab66..cc2f18c4fb18 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -22,7 +22,6 @@ use codex_execpolicy::blocking_append_network_rule; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxKind; -use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_shell_command::is_dangerous_command::command_might_be_dangerous; use codex_shell_command::is_safe_command::is_known_safe_command; @@ -121,7 +120,6 @@ pub(crate) enum ExecPolicyCommandOrigin { pub(crate) struct UnmatchedCommandContext<'a> { pub(crate) approval_policy: AskForApproval, pub(crate) permission_profile: &'a PermissionProfile, - pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy, pub(crate) sandbox_cwd: &'a Path, pub(crate) sandbox_permissions: SandboxPermissions, pub(crate) used_complex_parsing: bool, @@ -242,7 +240,6 @@ pub(crate) struct ExecApprovalRequest<'a> { pub(crate) command: &'a [String], pub(crate) approval_policy: AskForApproval, pub(crate) permission_profile: PermissionProfile, - pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy, pub(crate) sandbox_cwd: &'a Path, pub(crate) sandbox_permissions: SandboxPermissions, pub(crate) prefix_rule: Option>, @@ -277,7 +274,6 @@ impl ExecPolicyManager { command, approval_policy, permission_profile, - file_system_sandbox_policy, sandbox_cwd, sandbox_permissions, prefix_rule, @@ -298,7 +294,6 @@ impl ExecPolicyManager { UnmatchedCommandContext { approval_policy, permission_profile: &permission_profile, - file_system_sandbox_policy, sandbox_cwd, sandbox_permissions, used_complex_parsing, @@ -636,12 +631,12 @@ pub(crate) fn render_decision_for_unmatched_command( let UnmatchedCommandContext { approval_policy, permission_profile, - file_system_sandbox_policy, sandbox_cwd, sandbox_permissions, used_complex_parsing, command_origin, } = context; + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); let is_known_safe = match command_origin { ExecPolicyCommandOrigin::Generic => is_known_safe_command(command), #[cfg(windows)] @@ -652,12 +647,8 @@ pub(crate) fn render_decision_for_unmatched_command( // On Windows, ReadOnly sandbox is not a real sandbox, so special-case it // here. - let environment_lacks_sandbox_protections = cfg!(windows) - && profile_is_managed_read_only( - permission_profile, - file_system_sandbox_policy, - sandbox_cwd, - ); + let environment_lacks_sandbox_protections = + cfg!(windows) && profile_is_managed_read_only(permission_profile, sandbox_cwd); if is_known_safe && !used_complex_parsing @@ -751,9 +742,9 @@ pub(crate) fn render_decision_for_unmatched_command( fn profile_is_managed_read_only( permission_profile: &PermissionProfile, - file_system_sandbox_policy: &FileSystemSandboxPolicy, sandbox_cwd: &Path, ) -> bool { + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); matches!(permission_profile, PermissionProfile::Managed { .. }) && matches!( file_system_sandbox_policy.kind, diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index 7b0883db2a90..1adf6d471b1c 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -19,6 +19,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; @@ -104,31 +105,6 @@ async fn write_project_trust_config( .await } -fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }]) -} - -fn workspace_write_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::workspace_write( - &[], - /*exclude_tmpdir_env_var*/ false, - /*exclude_slash_tmp*/ false, - ) -} - -fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::unrestricted() -} - -fn external_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::external_sandbox() -} - async fn test_config() -> (TempDir, Config) { let home = TempDir::new().expect("create temp dir"); let config = ConfigBuilder::without_managed_config_for_tests() @@ -665,7 +641,6 @@ async fn evaluates_bash_lc_inner_commands() { ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -761,7 +736,6 @@ async fn evaluates_heredoc_script_against_prefix_rules() { command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -785,7 +759,6 @@ async fn omits_auto_amendment_for_heredoc_fallback_prompts() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -809,7 +782,6 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_mat ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec![ "python3".to_string(), @@ -837,7 +809,6 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_matches( ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec!["python3".to_string()]), }, @@ -862,7 +833,6 @@ async fn heredoc_with_variable_assignment_is_not_reduced_to_allowed_prefix() { ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -893,7 +863,6 @@ EOF"# ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -927,7 +896,6 @@ EOF"# ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -967,7 +935,6 @@ prefix_rule( ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -986,7 +953,6 @@ async fn exec_approval_requirement_prefers_execpolicy_match() { command: vec!["rm".to_string()], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1014,7 +980,6 @@ prefix_rule(pattern=["git"], decision="allow") command: vec![git_path, "status".to_string()], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1048,7 +1013,6 @@ prefix_rule(pattern=["git"], decision="prompt") command: vec![disallowed_git_path.clone(), "status".to_string()], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1075,7 +1039,6 @@ async fn requested_prefix_rule_can_approve_absolute_path_commands() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), }, @@ -1098,7 +1061,6 @@ async fn exec_approval_requirement_respects_approval_policy() { command: vec!["rm".to_string()], approval_policy: AskForApproval::Never, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1126,7 +1088,6 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() { mcp_elicitations: true, }), permission_profile: &PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, @@ -1137,9 +1098,8 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() { } #[test] -fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() { +fn unmatched_on_request_uses_permission_profile_file_system_policy_for_escalation_prompts() { let command = vec!["madeup-cmd".to_string()]; - let restricted_file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); assert_eq!( Decision::Prompt, @@ -1147,8 +1107,7 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() { &command, UnmatchedCommandContext { approval_policy: AskForApproval::OnRequest, - permission_profile: &PermissionProfile::Disabled, - file_system_sandbox_policy: &restricted_file_system_policy, + permission_profile: &PermissionProfile::read_only(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, @@ -1169,7 +1128,6 @@ fn known_safe_on_request_still_prompts_for_restricted_sandbox_escalation() { UnmatchedCommandContext { approval_policy: AskForApproval::OnRequest, permission_profile: &PermissionProfile::workspace_write(), - file_system_sandbox_policy: &workspace_write_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, @@ -1202,7 +1160,6 @@ fn managed_cwd_write_profile_is_not_read_only() { assert!(!profile_is_managed_read_only( &permission_profile, - &file_system_sandbox_policy, Path::new("/tmp/project") )); } @@ -1233,7 +1190,6 @@ fn managed_unresolvable_write_profile_is_still_read_only() { assert!(profile_is_managed_read_only( &permission_profile, - &file_system_sandbox_policy, Path::new("/tmp/project") )); } @@ -1250,7 +1206,6 @@ async fn exec_approval_requirement_prompts_for_inline_additional_permissions_und ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, prefix_rule: None, }, @@ -1273,7 +1228,6 @@ async fn exec_approval_requirement_prompts_for_known_safe_escalation_under_on_re command: vec!["echo".to_string(), "hello".to_string()], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -1303,7 +1257,6 @@ async fn exec_approval_requirement_rejects_known_safe_escalation_when_granular_s mcp_elicitations: true, }), permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -1329,7 +1282,6 @@ async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_gra mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -1365,7 +1317,6 @@ async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision() mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, @@ -1403,7 +1354,6 @@ async fn mixed_rule_and_sandbox_prompt_rejects_when_granular_rules_are_disabled( mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, @@ -1428,7 +1378,6 @@ async fn exec_approval_requirement_falls_back_to_heuristics() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1454,7 +1403,6 @@ async fn empty_bash_lc_script_falls_back_to_original_command() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1484,7 +1432,6 @@ async fn whitespace_bash_lc_script_falls_back_to_original_command() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1514,7 +1461,6 @@ async fn request_rule_uses_prefix_rule() { command: &command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), @@ -1547,7 +1493,6 @@ async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands( command: &command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), @@ -1587,7 +1532,6 @@ async fn heuristics_apply_when_other_commands_match_policy() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1663,7 +1607,6 @@ async fn proposed_execpolicy_amendment_is_present_for_single_command_without_pol command: command.clone(), approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1683,7 +1626,6 @@ async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() { command: vec!["rm".to_string()], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1707,7 +1649,6 @@ async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1737,7 +1678,6 @@ async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scri command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1761,7 +1701,6 @@ async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() { command: command.clone(), approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1785,7 +1724,6 @@ async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1816,7 +1754,6 @@ prefix_rule(pattern=["cat"], decision="allow") command: command.clone(), approval_policy, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1848,7 +1785,6 @@ prefix_rule(pattern=["bash"], decision="allow") ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2014,7 +1950,6 @@ async fn dangerous_rm_rf_requires_approval_in_danger_full_access() { command: command.clone(), approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2078,7 +2013,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &sneaky_command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, prefix_rule: None, @@ -2103,7 +2037,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &dangerous_command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, prefix_rule: None, @@ -2124,7 +2057,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &dangerous_command, approval_policy: AskForApproval::Never, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, prefix_rule: None, @@ -2146,7 +2078,6 @@ async fn dangerous_command_allowed_when_sandbox_is_explicitly_disabled() { permission_profile: PermissionProfile::External { network: NetworkSandboxPolicy::Restricted, }, - file_system_sandbox_policy: external_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2171,7 +2102,6 @@ async fn dangerous_command_forbidden_in_external_sandbox_when_policy_matches() { permission_profile: PermissionProfile::External { network: NetworkSandboxPolicy::Restricted, }, - file_system_sandbox_policy: external_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2188,7 +2118,6 @@ struct ExecApprovalRequirementScenario { command: Vec, approval_policy: AskForApproval, permission_profile: PermissionProfile, - file_system_sandbox_policy: FileSystemSandboxPolicy, sandbox_permissions: SandboxPermissions, prefix_rule: Option>, } @@ -2212,7 +2141,6 @@ async fn exec_approval_requirement_for_command( command, approval_policy, permission_profile, - file_system_sandbox_policy, sandbox_permissions, prefix_rule, } = test; @@ -2224,7 +2152,6 @@ async fn exec_approval_requirement_for_command( command: &command, approval_policy, permission_profile, - file_system_sandbox_policy: &file_system_sandbox_policy, sandbox_cwd: Path::new("/tmp"), sandbox_permissions, prefix_rule, diff --git a/codex-rs/core/src/exec_policy_windows_tests.rs b/codex-rs/core/src/exec_policy_windows_tests.rs index 1d14d938137a..3fba240b4155 100644 --- a/codex-rs/core/src/exec_policy_windows_tests.rs +++ b/codex-rs/core/src/exec_policy_windows_tests.rs @@ -15,7 +15,6 @@ async fn evaluates_powershell_inner_commands_against_prompt_rules() { ], approval_policy: AskForApproval::Never, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -39,7 +38,6 @@ async fn evaluates_powershell_inner_commands_against_allow_rules() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -81,7 +79,6 @@ fn unmatched_safe_powershell_words_are_allowed() { UnmatchedCommandContext { approval_policy: AskForApproval::UnlessTrusted, permission_profile: &PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, used_complex_parsing: false, @@ -110,7 +107,6 @@ async fn unmatched_dangerous_powershell_inner_commands_require_approval() { ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index c5e37fca0e6a..a2023ea378cc 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -10868,7 +10868,6 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique thread settings Arc"); turn_context_mut.permission_profile = PermissionProfile::Disabled; - let file_system_sandbox_policy = turn_context.file_system_sandbox_policy(); let command = session.user_shell().derive_exec_args( command_script, turn_context.config.permissions.allow_login_shell, @@ -10880,7 +10879,6 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { command: &command, approval_policy: turn_context.approval_policy.value(), permission_profile: turn_context.permission_profile(), - file_system_sandbox_policy: &file_system_sandbox_policy, #[allow(deprecated)] sandbox_cwd: turn_context.cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 504653cf7672..b3c955595aa3 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -160,7 +160,6 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result { approval_policy: AskForApproval, permission_profile: PermissionProfile, - file_system_sandbox_policy: &'a FileSystemSandboxPolicy, sandbox_cwd: &'a Path, sandbox_permissions: SandboxPermissions, enable_shell_wrapper_parsing: bool, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 6e7a1ced546f..ecdf015fd519 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -522,7 +522,6 @@ fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_pars InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing, @@ -575,7 +574,6 @@ fn evaluate_intercepted_exec_policy_matches_inner_shell_commands_when_enabled() InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing, @@ -619,7 +617,6 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: false, @@ -747,7 +744,6 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( let argv = ["printf".to_string(), "hello".to_string()]; let approval_policy = AskForApproval::OnRequest; let permission_profile = PermissionProfile::workspace_write(); - let file_system_sandbox_policy = read_only_file_system_sandbox_policy(); let sandbox_cwd = test_sandbox_cwd(); let preapproved = evaluate_intercepted_exec_policy( @@ -757,7 +753,6 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( InterceptedExecPolicyContext { approval_policy, permission_profile: permission_profile.clone(), - file_system_sandbox_policy: &file_system_sandbox_policy, sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: super::approval_sandbox_permissions( SandboxPermissions::WithAdditionalPermissions, @@ -773,7 +768,6 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( InterceptedExecPolicyContext { approval_policy, permission_profile, - file_system_sandbox_policy: &file_system_sandbox_policy, sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, enable_shell_wrapper_parsing: false, @@ -808,7 +802,6 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"]) InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: false, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 66b8b49a8098..2c86deafafca 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1011,7 +1011,6 @@ impl UnifiedExecProcessManager { }; let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self, request.shell_mode.clone()); - let file_system_sandbox_policy = context.turn.file_system_sandbox_policy(); let exec_approval_requirement = context .session .services @@ -1020,7 +1019,6 @@ impl UnifiedExecProcessManager { command: &request.command, approval_policy: context.turn.approval_policy.value(), permission_profile: context.turn.permission_profile(), - file_system_sandbox_policy: &file_system_sandbox_policy, // The process cwd may be model-controlled. Policy resolution // stays anchored to the selected turn environment cwd instead. sandbox_cwd: request.sandbox_cwd.as_path(), From 64e0829cab9dccb14b8273a2ebea2eee9e06c12c Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Thu, 4 Jun 2026 22:12:23 -0700 Subject: [PATCH 14/59] feat(remote-control): allow pairing while disabled (#26215) ## Why `remoteControl/pairing/start` creates authorization for future remote-control connections, so it should not require the live websocket to already be enabled. Requiring enable first made pairing depend on presence instead of the persisted server enrollment that pairing actually uses. Pairing also needs to recover when that persisted server row is stale. If `/server/pair` returns `404`, making the first pairing attempt fail forces a manual retry even though the client can clear the stale row and create a replacement enrollment immediately. ## What Changed - Allow `remoteControl/pairing/start` to reuse or create the persisted remote-control server enrollment while remote control is disabled. - Keep the selected in-memory enrollment across disable and share it with websocket connect so a later enable uses the same selected server. - Thread the app-server client name through pairing so stdio persistence keeps using the websocket-owned enrollment key. - Recover pairing server-token auth failures through the existing refresh/auth-recovery path. - Recover stale pairing enrollment on `/server/pair` `404` by clearing the stale selected enrollment, re-enrolling once, and retrying pairing once. - Add focused disabled-pairing and stale-pairing recovery coverage. ## Verification - `remote_control_pairing_start_returns_pairing_artifacts_while_disabled` exercises pairing before enable. - `remote_control_handle_reenrolls_after_stale_pairing_enrollment` exercises stale `/server/pair` `404` recovery without a manual retry. Related: N/A --- .../src/transport/remote_control/mod.rs | 353 +++++++++++++++--- .../src/transport/remote_control/tests.rs | 30 +- .../remote_control/tests/clients_tests.rs | 5 +- .../remote_control/tests/pairing_tests.rs | 156 +++++++- .../src/transport/remote_control/websocket.rs | 348 +++++++++-------- codex-rs/app-server/src/message_processor.rs | 2 +- .../remote_control_processor.rs | 3 +- .../remote_control_processor_tests.rs | 5 +- .../tests/suite/v2/remote_control.rs | 44 ++- 9 files changed, 702 insertions(+), 244 deletions(-) diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index 38e34e6d435b..443dcc897eaa 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -9,7 +9,10 @@ mod websocket; use self::auth::load_remote_control_auth; use self::auth::recover_remote_control_auth; use self::enroll::RemoteControlEnrollment; +use self::enroll::enroll_remote_control_server; +use self::enroll::load_persisted_remote_control_enrollment; use self::enroll::refresh_remote_control_server; +use self::enroll::update_persisted_remote_control_enrollment; use crate::transport::remote_control::websocket::RemoteControlChannels; use crate::transport::remote_control::websocket::RemoteControlStatusPublisher; use crate::transport::remote_control::websocket::RemoteControlWebsocket; @@ -36,9 +39,13 @@ use gethostname::gethostname; use std::error::Error; use std::fmt; use std::io; +use std::ops::Deref; +use std::ops::DerefMut; use std::panic::AssertUnwindSafe; use std::sync::Arc; use std::sync::Mutex as StdMutex; +use tokio::sync::Semaphore; +use tokio::sync::SemaphorePermit; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::sync::watch; @@ -65,12 +72,81 @@ pub struct RemoteControlHandle { enabled_tx: Arc>, status_tx: Arc>, state_db_available: bool, + state_db: Option>, remote_control_url: String, current_enrollment: CurrentRemoteControlEnrollment, + pairing_persistence_key: RemoteControlPairingPersistenceKey, + pairing_persistence_key_required: bool, auth_manager: Arc, } -type CurrentRemoteControlEnrollment = Arc>>; +// Pairing and websocket connect share one selected server so they cannot enroll or clear +// different persisted rows while either path is awaiting backend I/O. +type CurrentRemoteControlEnrollment = Arc; +type RemoteControlPairingPersistenceKey = watch::Sender>; + +struct RemoteControlEnrollmentState { + enrollment: StdMutex>, + lock: Semaphore, +} + +impl RemoteControlEnrollmentState { + fn new(enrollment: Option) -> Self { + Self { + enrollment: StdMutex::new(enrollment), + lock: Semaphore::new(1), + } + } + + async fn lock(&self) -> RemoteControlEnrollmentLease<'_> { + let permit = match self.lock.acquire().await { + Ok(permit) => permit, + Err(_) => unreachable!("remote control enrollment lock should stay open"), + }; + RemoteControlEnrollmentLease { + state: self, + enrollment: self.snapshot(), + _permit: permit, + } + } + + fn snapshot(&self) -> Option { + self.enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +struct RemoteControlEnrollmentLease<'a> { + state: &'a RemoteControlEnrollmentState, + enrollment: Option, + _permit: SemaphorePermit<'a>, +} + +impl Deref for RemoteControlEnrollmentLease<'_> { + type Target = Option; + + fn deref(&self) -> &Self::Target { + &self.enrollment + } +} + +impl DerefMut for RemoteControlEnrollmentLease<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.enrollment + } +} + +impl Drop for RemoteControlEnrollmentLease<'_> { + fn drop(&mut self) { + *self + .state + .enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = self.enrollment.take(); + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RemoteControlUnavailable; @@ -126,8 +202,6 @@ impl RemoteControlHandle { *state = false; changed }); - clear_current_enrollment(&self.current_enrollment); - let status = self.status(); info!( enabled_changed, @@ -151,28 +225,30 @@ impl RemoteControlHandle { pub async fn start_pairing( &self, params: RemoteControlPairingStartParams, + app_server_client_name: Option<&str>, ) -> io::Result { - if !*self.enabled_tx.borrow() { - return Err(Self::pairing_disabled_error()); - } let mut auth = load_remote_control_auth(&self.auth_manager) .await .map_err(|_| pairing_unavailable_error())?; - let mut enrollment = { - let current_enrollment = self - .current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - current_enrollment - .as_ref() - .filter(|enrollment| enrollment.account_id == auth.account_id) - .cloned() - } - .ok_or_else(pairing_unavailable_error)?; - let installation_id = self.status().installation_id; + let status = self.status(); + let installation_id = status.installation_id; + let app_server_client_name = self.pairing_persistence_key(app_server_client_name)?; + let app_server_client_name = app_server_client_name.as_deref(); + let mut current_enrollment = self.current_enrollment.lock().await; + let mut enrollment = self + .load_or_enroll_pairing_server( + &mut current_enrollment, + &mut auth, + &installation_id, + &status.server_name, + app_server_client_name, + ) + .await?; if enrollment.should_refresh_server_token() { refresh_pairing_enrollment( - &self.current_enrollment, + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, &self.auth_manager, &mut auth, &installation_id, @@ -185,9 +261,11 @@ impl RemoteControlHandle { }; let pairing_response = match enrollment.start_pairing(pairing_request()).await { Err(err) if err.kind() == io::ErrorKind::PermissionDenied => { - clear_pairing_server_token(&self.current_enrollment, &mut enrollment)?; + clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?; refresh_pairing_enrollment( - &self.current_enrollment, + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, &self.auth_manager, &mut auth, &installation_id, @@ -196,24 +274,46 @@ impl RemoteControlHandle { .await?; enrollment.start_pairing(pairing_request()).await } + Err(err) if err.kind() == io::ErrorKind::NotFound => { + clear_pairing_enrollment( + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, + &enrollment, + ) + .await; + enrollment = self + .load_or_enroll_pairing_server( + &mut current_enrollment, + &mut auth, + &installation_id, + &status.server_name, + app_server_client_name, + ) + .await?; + enrollment.start_pairing(pairing_request()).await + } pairing_response => pairing_response, }; if let Err(err) = &pairing_response { match err.kind() { io::ErrorKind::NotFound => { - clear_current_enrollment_if_matches(&self.current_enrollment, &enrollment); + clear_pairing_enrollment( + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, + &enrollment, + ) + .await; return Err(pairing_unavailable_error()); } io::ErrorKind::PermissionDenied => { - clear_pairing_server_token(&self.current_enrollment, &mut enrollment)?; + clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?; return Err(pairing_unavailable_error()); } _ => {} } } - if !*self.enabled_tx.borrow() { - return Err(Self::pairing_disabled_error()); - } let current_auth = load_remote_control_auth(&self.auth_manager) .await .map_err(|_| pairing_unavailable_error())?; @@ -223,6 +323,74 @@ impl RemoteControlHandle { pairing_response } + async fn load_or_enroll_pairing_server( + &self, + current_enrollment: &mut Option, + auth: &mut auth::RemoteControlConnectionAuth, + installation_id: &str, + server_name: &str, + app_server_client_name: Option<&str>, + ) -> io::Result { + if let Some(enrollment) = current_enrollment + .as_ref() + .filter(|enrollment| enrollment.account_id == auth.account_id) + .cloned() + { + return Ok(enrollment); + } + + let remote_control_target = normalize_remote_control_url(&self.remote_control_url)?; + let state_db = self + .state_db + .as_deref() + .ok_or_else(pairing_unavailable_error)?; + if let Some(mut enrollment) = load_persisted_remote_control_enrollment( + Some(state_db), + &remote_control_target, + &auth.account_id, + app_server_client_name, + ) + .await? + { + enrollment.server_name = server_name.to_string(); + publish_current_enrollment(current_enrollment, &enrollment); + return Ok(enrollment); + } + + let enrollment = enroll_pairing_server( + &self.auth_manager, + auth, + &remote_control_target, + installation_id, + server_name, + ) + .await?; + update_persisted_remote_control_enrollment( + Some(state_db), + &remote_control_target, + &auth.account_id, + app_server_client_name, + Some(&enrollment), + ) + .await?; + publish_current_enrollment(current_enrollment, &enrollment); + Ok(enrollment) + } + + fn pairing_persistence_key( + &self, + app_server_client_name: Option<&str>, + ) -> io::Result> { + if self.pairing_persistence_key_required && self.pairing_persistence_key.borrow().is_none() + { + let app_server_client_name = + app_server_client_name.ok_or_else(pairing_unavailable_error)?; + self.pairing_persistence_key + .send_replace(Some(app_server_client_name.to_string())); + } + Ok(self.pairing_persistence_key.borrow().clone()) + } + pub async fn list_clients( &self, params: RemoteControlClientsListParams, @@ -239,13 +407,6 @@ impl RemoteControlHandle { .await } - fn pairing_disabled_error() -> io::Error { - io::Error::new( - io::ErrorKind::InvalidInput, - "remote control pairing requires remote control to be enabled", - ) - } - fn publish_status( &self, connection_status: RemoteControlConnectionStatus, @@ -277,8 +438,36 @@ impl RemoteControlHandle { } } +async fn enroll_pairing_server( + auth_manager: &Arc, + auth: &mut auth::RemoteControlConnectionAuth, + remote_control_target: &protocol::RemoteControlTarget, + installation_id: &str, + server_name: &str, +) -> io::Result { + match enroll_remote_control_server(remote_control_target, auth, installation_id, server_name) + .await + { + Ok(enrollment) => return Ok(enrollment), + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => { + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); + if !recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await { + return Err(err); + } + *auth = load_remote_control_auth(auth_manager) + .await + .map_err(|_| pairing_unavailable_error())?; + } + Err(err) => return Err(err), + } + enroll_remote_control_server(remote_control_target, auth, installation_id, server_name).await +} + async fn refresh_pairing_enrollment( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, + state_db: Option<&StateRuntime>, + app_server_client_name: Option<&str>, auth_manager: &Arc, auth: &mut auth::RemoteControlConnectionAuth, installation_id: &str, @@ -286,7 +475,14 @@ async fn refresh_pairing_enrollment( ) -> io::Result<()> { if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await { if err.kind() != io::ErrorKind::PermissionDenied { - return handle_pairing_refresh_error(current_enrollment, enrollment, err); + return handle_pairing_refresh_error( + current_enrollment, + state_db, + app_server_client_name, + enrollment, + err, + ) + .await; } let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); @@ -300,7 +496,14 @@ async fn refresh_pairing_enrollment( return Err(pairing_unavailable_error()); } if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await { - return handle_pairing_refresh_error(current_enrollment, enrollment, err); + return handle_pairing_refresh_error( + current_enrollment, + state_db, + app_server_client_name, + enrollment, + err, + ) + .await; } } if replace_current_enrollment(current_enrollment, enrollment) { @@ -310,21 +513,54 @@ async fn refresh_pairing_enrollment( } } -fn handle_pairing_refresh_error( - current_enrollment: &CurrentRemoteControlEnrollment, +async fn handle_pairing_refresh_error( + current_enrollment: &mut Option, + state_db: Option<&StateRuntime>, + app_server_client_name: Option<&str>, enrollment: &RemoteControlEnrollment, err: io::Error, ) -> io::Result<()> { if err.kind() == io::ErrorKind::NotFound { - clear_current_enrollment_if_matches(current_enrollment, enrollment); + clear_pairing_enrollment( + current_enrollment, + state_db, + app_server_client_name, + enrollment, + ) + .await; Err(pairing_unavailable_error()) } else { Err(err) } } +async fn clear_pairing_enrollment( + current_enrollment: &mut Option, + state_db: Option<&StateRuntime>, + app_server_client_name: Option<&str>, + enrollment: &RemoteControlEnrollment, +) { + if !clear_current_enrollment_if_matches(current_enrollment, enrollment) { + return; + } + let Some(state_db) = state_db else { + return; + }; + if let Err(err) = update_persisted_remote_control_enrollment( + Some(state_db), + &enrollment.remote_control_target, + &enrollment.account_id, + app_server_client_name, + /*enrollment*/ None, + ) + .await + { + warn!("failed to clear stale pairing enrollment: {err}"); + } +} + fn clear_pairing_server_token( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, enrollment: &mut RemoteControlEnrollment, ) -> io::Result<()> { enrollment.clear_server_token(); @@ -359,27 +595,16 @@ fn remote_control_status_with_connection_status( } fn publish_current_enrollment( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, enrollment: &RemoteControlEnrollment, ) { - *current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(enrollment.clone()); -} - -fn clear_current_enrollment(current_enrollment: &CurrentRemoteControlEnrollment) { - *current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + *current_enrollment = Some(enrollment.clone()); } fn replace_current_enrollment( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, enrollment: &RemoteControlEnrollment, ) -> bool { - let mut current_enrollment = current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); if !current_enrollment .as_ref() .is_some_and(|current| same_remote_control_enrollment(current, enrollment)) @@ -391,17 +616,17 @@ fn replace_current_enrollment( } fn clear_current_enrollment_if_matches( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, enrollment: &RemoteControlEnrollment, -) { - let mut current_enrollment = current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); +) -> bool { if current_enrollment .as_ref() .is_some_and(|current| same_remote_control_enrollment(current, enrollment)) { *current_enrollment = None; + true + } else { + false } } @@ -438,9 +663,13 @@ pub async fn start_remote_control( }; let (enabled_tx, enabled_rx) = watch::channel(initial_enabled); - let current_enrollment = Arc::new(StdMutex::new(None)); + let current_enrollment = Arc::new(RemoteControlEnrollmentState::new(/*enrollment*/ None)); let websocket_current_enrollment = current_enrollment.clone(); + let pairing_persistence_key_required = app_server_client_name_rx.is_some(); + let (pairing_persistence_key, _pairing_persistence_key_rx) = watch::channel(None); + let websocket_pairing_persistence_key = pairing_persistence_key.clone(); let handle_auth_manager = auth_manager.clone(); + let handle_state_db = state_db.clone(); let server_name = gethostname().to_string_lossy().trim().to_string(); let remote_control_url = config.remote_control_url; let installation_id = config.installation_id; @@ -490,6 +719,7 @@ pub async fn start_remote_control( transport_event_tx, status_publisher, current_enrollment: websocket_current_enrollment, + pairing_persistence_key: websocket_pairing_persistence_key, }, shutdown_token, enabled_rx, @@ -534,8 +764,11 @@ pub async fn start_remote_control( enabled_tx: Arc::new(enabled_tx), status_tx: Arc::new(status_tx), state_db_available, + state_db: handle_state_db, remote_control_url: handle_remote_control_url, current_enrollment, + pairing_persistence_key, + pairing_persistence_key_required, auth_manager: handle_auth_manager, }, )) diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index a1a284da9b78..3e9a911b2d3d 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -40,7 +40,6 @@ use pretty_assertions::assert_eq; use serde_json::json; use std::collections::BTreeMap; use std::sync::Arc; -use std::sync::Mutex as StdMutex; use tempfile::TempDir; use time::OffsetDateTime; use tokio::io::AsyncBufReadExt; @@ -148,24 +147,29 @@ fn remote_control_handle_with_current_enrollment( }); let remote_control_target = normalize_remote_control_url(remote_control_url) .expect("remote control target should normalize"); - let current_enrollment = Arc::new(StdMutex::new(Some(RemoteControlEnrollment { - remote_control_target, - account_id: "account_id".to_string(), - environment_id: "env_test".to_string(), - server_id: "srv_e_test".to_string(), - server_name: test_server_name(), - remote_control_token: Some(TEST_REMOTE_CONTROL_SERVER_TOKEN.to_string()), - expires_at: Some( - OffsetDateTime::from_unix_timestamp(33_336_362_096) - .expect("future timestamp should parse"), - ), - }))); + let current_enrollment = Arc::new(RemoteControlEnrollmentState::new(Some( + RemoteControlEnrollment { + remote_control_target, + account_id: "account_id".to_string(), + environment_id: "env_test".to_string(), + server_id: "srv_e_test".to_string(), + server_name: test_server_name(), + remote_control_token: Some(TEST_REMOTE_CONTROL_SERVER_TOKEN.to_string()), + expires_at: Some( + OffsetDateTime::from_unix_timestamp(33_336_362_096) + .expect("future timestamp should parse"), + ), + }, + ))); RemoteControlHandle { enabled_tx: Arc::new(enabled_tx), status_tx: Arc::new(status_tx), state_db_available: true, + state_db: None, remote_control_url: remote_control_url.to_string(), current_enrollment, + pairing_persistence_key: watch::channel(None).0, + pairing_persistence_key_required: false, auth_manager, } } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs index 22aa290762b1..060063909cfa 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs @@ -24,8 +24,11 @@ fn client_management_handle( enabled_tx: Arc::new(enabled_tx), status_tx: Arc::new(status_tx), state_db_available: false, + state_db: None, remote_control_url, - current_enrollment: Arc::new(StdMutex::new(None)), + current_enrollment: Arc::new(RemoteControlEnrollmentState::new(/*enrollment*/ None)), + pairing_persistence_key: watch::channel(None).0, + pairing_persistence_key_required: false, auth_manager, } } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs index 176aeb4ed5c4..7586f0b4d1a9 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs @@ -131,13 +131,16 @@ async fn remote_control_handle_starts_pairing_before_websocket_connects() { remote_handle .current_enrollment .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) + .await .as_mut() .expect("current enrollment should exist") .expires_at = Some(OffsetDateTime::now_utc() + time::Duration::seconds(29)); let response = remote_handle - .start_pairing(RemoteControlPairingStartParams { manual_code: true }) + .start_pairing( + RemoteControlPairingStartParams { manual_code: true }, + /*app_server_client_name*/ None, + ) .await .expect("pairing should use the current server before websocket connect"); server_task.await.expect("server task should finish"); @@ -219,7 +222,10 @@ async fn remote_control_handle_refreshes_after_pairing_auth_failure() { ); let response = remote_handle - .start_pairing(RemoteControlPairingStartParams::default()) + .start_pairing( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) .await .expect("pairing should refresh after server token auth failure"); server_task.await.expect("server task should finish"); @@ -332,13 +338,16 @@ async fn remote_control_handle_recovers_auth_before_refreshing_pairing() { remote_handle .current_enrollment .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) + .await .as_mut() .expect("current enrollment should exist") .expires_at = Some(OffsetDateTime::now_utc() + time::Duration::seconds(29)); let response = remote_handle - .start_pairing(RemoteControlPairingStartParams::default()) + .start_pairing( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) .await .expect("pairing should refresh after auth recovery"); server_task.await.expect("server task should finish"); @@ -414,21 +423,137 @@ async fn start_remote_control_pairing_preserves_expiry_parse_error_context() { } #[tokio::test] -async fn remote_control_handle_disable_clears_current_enrollment() { +async fn remote_control_handle_disable_keeps_current_enrollment() { let remote_handle = remote_control_handle_with_current_enrollment( TEST_REMOTE_CONTROL_URL, remote_control_auth_manager(), ); remote_handle.disable(); - remote_handle.enable().expect("enable should succeed"); + assert!( + remote_handle.current_enrollment.lock().await.is_some(), + "disabled remote control should keep the selected pairing server" + ); +} + +#[tokio::test] +async fn remote_control_handle_reenrolls_after_stale_pairing_enrollment() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let mut remote_handle = remote_control_handle_with_current_enrollment( + &remote_control_url, + remote_control_auth_manager_with_home(&codex_home), + ); + remote_handle.state_db = Some(state_db.clone()); + remote_handle.disable(); + let stale_enrollment = remote_handle + .current_enrollment + .lock() + .await + .clone() + .expect("current enrollment should exist"); + let remote_control_target = stale_enrollment.remote_control_target.clone(); + let refreshed_enrollment = RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), + account_id: "account_id".to_string(), + environment_id: "env_refreshed".to_string(), + server_id: "srv_e_refreshed".to_string(), + server_name: test_server_name(), + remote_control_token: None, + expires_at: None, + }; + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + Some(&stale_enrollment), + ) + .await + .expect("stale enrollment should save"); + let server_refreshed_enrollment = refreshed_enrollment.clone(); + let server_task = tokio::spawn(async move { + let stale_pairing_request = accept_http_request(&listener).await; + assert_eq!( + stale_pairing_request.request_line, + "POST /backend-api/wham/remote/control/server/pair HTTP/1.1" + ); + assert_eq!( + stale_pairing_request.headers.get("authorization"), + Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}")) + ); + respond_with_status(stale_pairing_request.stream, "404 Not Found", "").await; + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + remote_control_server_token_response( + &server_refreshed_enrollment.server_id, + &server_refreshed_enrollment.environment_id, + TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; + + let refreshed_pairing_request = accept_http_request(&listener).await; + assert_eq!( + refreshed_pairing_request.request_line, + "POST /backend-api/wham/remote/control/server/pair HTTP/1.1" + ); + assert_eq!( + refreshed_pairing_request.headers.get("authorization"), + Some(&format!( + "Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}" + )) + ); + respond_with_json( + refreshed_pairing_request.stream, + json!({ + "pairing_code": "pairing-code", + "manual_pairing_code": "ABCD-EFGH", + "server_id": server_refreshed_enrollment.server_id, + "environment_id": server_refreshed_enrollment.environment_id, + "expires_at": "3026-05-22T12:34:56Z", + }), + ) + .await; + }); + let response = remote_handle + .start_pairing( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) + .await + .expect("pairing should re-enroll after stale enrollment"); + server_task.await.expect("server task should finish"); + assert_eq!( - remote_handle - .start_pairing(RemoteControlPairingStartParams::default()) - .await - .expect_err("re-enabled remote control should wait for a current server") - .to_string(), - "remote control pairing is unavailable until enrollment completes" + response, + RemoteControlPairingStartResponse { + pairing_code: "pairing-code".to_string(), + manual_pairing_code: Some("ABCD-EFGH".to_string()), + environment_id: "env_refreshed".to_string(), + expires_at: 33_336_362_096, + } + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + ) + .await + .expect("refreshed enrollment should load"), + Some(refreshed_enrollment) ); } @@ -458,7 +583,10 @@ async fn remote_control_handle_discards_pairing_response_after_auth_change() { let remote_handle = remote_handle.clone(); async move { remote_handle - .start_pairing(RemoteControlPairingStartParams::default()) + .start_pairing( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) .await } }); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 60792df54c19..43fce8ed3605 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -1,13 +1,13 @@ use super::CurrentRemoteControlEnrollment; -use super::clear_current_enrollment; +use super::RemoteControlPairingPersistenceKey; use super::protocol::ClientEnvelope; use super::protocol::ClientEvent; use super::protocol::ClientId; use super::protocol::RemoteControlTarget; use super::protocol::ServerEnvelope; use super::protocol::StreamId; -use super::publish_current_enrollment; use super::remote_control_status_with_connection_status; +use super::same_remote_control_enrollment; use super::segment::ClientSegmentObservation; use super::segment::ClientSegmentReassembler; use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES; @@ -55,6 +55,9 @@ use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_util::sync::CancellationToken; + +#[cfg(test)] +use super::RemoteControlEnrollmentState; use tracing::error; use tracing::info; use tracing::warn; @@ -252,10 +255,10 @@ pub(crate) struct RemoteControlWebsocket { status_publisher: RemoteControlStatusPublisher, shutdown_token: CancellationToken, reconnect_attempt: u64, - enrollment: Option, auth_recovery: UnauthorizedRecovery, auth_change_rx: watch::Receiver, current_enrollment: CurrentRemoteControlEnrollment, + pairing_persistence_key: RemoteControlPairingPersistenceKey, client_tracker: Arc>, state: Arc>, server_event_rx: Arc>>, @@ -294,6 +297,7 @@ pub(super) struct RemoteControlChannels { pub(super) transport_event_tx: mpsc::Sender, pub(super) status_publisher: RemoteControlStatusPublisher, pub(super) current_enrollment: CurrentRemoteControlEnrollment, + pub(super) pairing_persistence_key: RemoteControlPairingPersistenceKey, } #[derive(Clone)] @@ -407,10 +411,10 @@ impl RemoteControlWebsocket { status_publisher: channels.status_publisher, shutdown_token, reconnect_attempt: 0, - enrollment: None, auth_recovery, auth_change_rx, current_enrollment: channels.current_enrollment, + pairing_persistence_key: channels.pairing_persistence_key, client_tracker: Arc::new(Mutex::new(client_tracker)), state: Arc::new(Mutex::new(WebsocketState { outbound_buffer, @@ -457,6 +461,8 @@ impl RemoteControlWebsocket { return; } }; + self.pairing_persistence_key + .send_replace(app_server_client_name.clone()); loop { if !self.wait_until_enabled().await { @@ -579,15 +585,15 @@ impl RemoteControlWebsocket { loop { let subscribe_cursor = self.state.lock().await.subscribe_cursor.clone(); - let enrollment = self.enrollment.as_ref(); + let enrollment = self.current_enrollment.snapshot(); info!( websocket_url = %remote_control_target.websocket_url, installation_id = %self.installation_id, server_name = %self.server_name, reconnect_attempt = self.reconnect_attempt.saturating_add(1), has_enrollment = enrollment.is_some(), - server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()), - environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()), + server_id = ?enrollment.as_ref().map(|enrollment| enrollment.server_id.as_str()), + environment_id = ?enrollment.as_ref().map(|enrollment| enrollment.environment_id.as_str()), subscribe_cursor_present = subscribe_cursor.is_some(), app_server_client_name = ?app_server_client_name, "connecting to app-server remote control websocket" @@ -611,15 +617,17 @@ impl RemoteControlWebsocket { } return ConnectOutcome::Disabled; } - connect_result = connect_remote_control_websocket( - &remote_control_target, - self.state_db.as_deref(), - auth_context, - &mut self.enrollment, - connect_options, - &self.status_publisher, - &self.current_enrollment, - ) => connect_result, + connect_result = async { + connect_remote_control_websocket( + &remote_control_target, + self.state_db.as_deref(), + auth_context, + &self.current_enrollment, + connect_options, + &self.status_publisher, + ) + .await + } => connect_result, }; match connect_result { @@ -631,13 +639,13 @@ impl RemoteControlWebsocket { self.auth_recovery = self.auth_manager.unauthorized_recovery(); self.status_publisher .publish_status(RemoteControlConnectionStatus::Connected); - let enrollment = self.enrollment.as_ref(); + let enrollment = self.current_enrollment.snapshot(); info!( websocket_url = %remote_control_target.websocket_url, installation_id = %self.installation_id, server_name = %self.server_name, - server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()), - environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()), + server_id = ?enrollment.as_ref().map(|enrollment| enrollment.server_id.as_str()), + environment_id = ?enrollment.as_ref().map(|enrollment| enrollment.environment_id.as_str()), subscribe_cursor_present = subscribe_cursor.is_some(), response_headers = %format_headers(response.headers()), "connected to app-server remote control websocket" @@ -656,7 +664,7 @@ impl RemoteControlWebsocket { let reconnect_attempt = self.reconnect_attempt.saturating_add(1); let (reconnect_delay, reconnect_backoff_reset) = next_reconnect_delay(&mut self.reconnect_attempt); - let enrollment = self.enrollment.as_ref(); + let enrollment = self.current_enrollment.snapshot(); warn!( websocket_url = %remote_control_target.websocket_url, installation_id = %self.installation_id, @@ -667,8 +675,8 @@ impl RemoteControlWebsocket { reconnect_delay = ?reconnect_delay, reconnect_backoff_reset, has_enrollment = enrollment.is_some(), - server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()), - environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()), + server_id = ?enrollment.as_ref().map(|enrollment| enrollment.server_id.as_str()), + environment_id = ?enrollment.as_ref().map(|enrollment| enrollment.environment_id.as_str()), subscribe_cursor_present = subscribe_cursor.is_some(), "failed to connect to app-server remote control websocket" ); @@ -1189,19 +1197,108 @@ pub(super) async fn connect_remote_control_websocket( remote_control_target: &RemoteControlTarget, state_db: Option<&StateRuntime>, mut auth_context: RemoteControlAuthContext<'_>, - enrollment: &mut Option, + current_enrollment: &CurrentRemoteControlEnrollment, connect_options: RemoteControlConnectOptions<'_>, status_publisher: &RemoteControlStatusPublisher, - current_enrollment: &CurrentRemoteControlEnrollment, ) -> io::Result<( WebSocketStream>, tungstenite::http::Response<()>, )> { ensure_rustls_crypto_provider(); + let (auth, enrollment) = { + let mut current_enrollment = current_enrollment.lock().await; + let auth = prepare_remote_control_enrollment( + remote_control_target, + state_db, + &mut auth_context, + &mut current_enrollment, + connect_options, + status_publisher, + ) + .await?; + let enrollment = current_enrollment.as_ref().cloned().ok_or_else(|| { + io::Error::other("missing remote control enrollment after enrollment step") + })?; + (auth, enrollment) + }; + let request = build_remote_control_websocket_request( + &remote_control_target.websocket_url, + &enrollment, + connect_options.installation_id, + connect_options.subscribe_cursor, + )?; + + let websocket_connect_result = tokio::time::timeout( + REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT, + connect_async(request), + ) + .await + .map_err(|_| { + io::Error::new( + ErrorKind::TimedOut, + format!( + "timed out connecting to remote control websocket at `{}` after {:?}", + remote_control_target.websocket_url, REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT + ), + ) + })?; + + match websocket_connect_result { + Ok((websocket_stream, response)) => Ok((websocket_stream, response.map(|_| ()))), + Err(err) => { + match &err { + tungstenite::Error::Http(response) if response.status().as_u16() == 404 => { + info!( + "remote control websocket returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}", + remote_control_target.websocket_url, + auth.account_id, + enrollment.server_id, + enrollment.environment_id + ); + clear_remote_control_enrollment_if_matches( + state_db, + remote_control_target, + &auth.account_id, + connect_options.app_server_client_name, + current_enrollment, + &enrollment, + status_publisher, + ) + .await; + } + tungstenite::Error::Http(response) + if matches!(response.status().as_u16(), 401 | 403) => + { + clear_remote_control_server_token_if_matches(current_enrollment, &enrollment) + .await?; + return Err(io::Error::other(format!( + "remote control websocket auth failed with HTTP {}; refreshing server token before reconnect", + response.status() + ))); + } + _ => {} + } + Err(io::Error::other( + format_remote_control_websocket_connect_error( + &remote_control_target.websocket_url, + &err, + ), + )) + } + } +} + +async fn prepare_remote_control_enrollment( + remote_control_target: &RemoteControlTarget, + state_db: Option<&StateRuntime>, + auth_context: &mut RemoteControlAuthContext<'_>, + enrollment: &mut Option, + connect_options: RemoteControlConnectOptions<'_>, + status_publisher: &RemoteControlStatusPublisher, +) -> io::Result { let Some(state_db) = state_db else { *enrollment = None; - clear_current_enrollment(current_enrollment); return Err(io::Error::new( ErrorKind::NotFound, "remote control requires sqlite state db", @@ -1214,7 +1311,6 @@ pub(super) async fn connect_remote_control_websocket( if err.kind() == ErrorKind::PermissionDenied { *enrollment = None; status_publisher.publish_environment_id(/*environment_id*/ None); - clear_current_enrollment(current_enrollment); } return Err(err); } @@ -1231,7 +1327,6 @@ pub(super) async fn connect_remote_control_websocket( ); *enrollment = None; status_publisher.publish_environment_id(/*environment_id*/ None); - clear_current_enrollment(current_enrollment); } if let Some(enrollment) = enrollment.as_mut() { enrollment.remote_control_target = remote_control_target.clone(); @@ -1262,7 +1357,7 @@ pub(super) async fn connect_remote_control_websocket( remote_control_target, state_db, &auth, - &mut auth_context, + auth_context, enrollment, connect_options, status_publisher, @@ -1307,14 +1402,13 @@ pub(super) async fn connect_remote_control_websocket( connect_options.app_server_client_name, enrollment, status_publisher, - current_enrollment, ) .await; enroll_remote_control_server_if_missing( remote_control_target, state_db, &auth, - &mut auth_context, + auth_context, enrollment, connect_options, status_publisher, @@ -1337,82 +1431,7 @@ pub(super) async fn connect_remote_control_websocket( } } - let enrollment_ref = enrollment.as_ref().ok_or_else(|| { - io::Error::other("missing remote control enrollment after enrollment step") - })?; - publish_current_enrollment(current_enrollment, enrollment_ref); - let request = build_remote_control_websocket_request( - &remote_control_target.websocket_url, - enrollment_ref, - connect_options.installation_id, - connect_options.subscribe_cursor, - )?; - - let websocket_connect_result = tokio::time::timeout( - REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT, - connect_async(request), - ) - .await - .map_err(|_| { - io::Error::new( - ErrorKind::TimedOut, - format!( - "timed out connecting to remote control websocket at `{}` after {:?}", - remote_control_target.websocket_url, REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT - ), - ) - })?; - - match websocket_connect_result { - Ok((websocket_stream, response)) => Ok((websocket_stream, response.map(|_| ()))), - Err(err) => { - match &err { - tungstenite::Error::Http(response) if response.status().as_u16() == 404 => { - info!( - "remote control websocket returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}", - remote_control_target.websocket_url, - auth.account_id, - enrollment_ref.server_id, - enrollment_ref.environment_id - ); - clear_remote_control_enrollment( - state_db, - remote_control_target, - &auth.account_id, - connect_options.app_server_client_name, - enrollment, - status_publisher, - current_enrollment, - ) - .await; - } - tungstenite::Error::Http(response) - if matches!(response.status().as_u16(), 401 | 403) => - { - enrollment - .as_mut() - .ok_or_else(|| { - io::Error::other( - "missing remote control enrollment after websocket auth failure", - ) - })? - .clear_server_token(); - clear_current_enrollment(current_enrollment); - return Err(io::Error::other(format!( - "remote control websocket auth failed with HTTP {}; refreshing server token before reconnect", - response.status() - ))); - } - _ => {} - } - Err(io::Error::other( - format_remote_control_websocket_connect_error( - &remote_control_target.websocket_url, - &err, - ), - )) - } - } + Ok(auth) } async fn clear_remote_control_enrollment( @@ -1422,7 +1441,6 @@ async fn clear_remote_control_enrollment( app_server_client_name: Option<&str>, enrollment: &mut Option, status_publisher: &RemoteControlStatusPublisher, - current_enrollment: &CurrentRemoteControlEnrollment, ) { if let Err(clear_err) = update_persisted_remote_control_enrollment( Some(state_db), @@ -1437,7 +1455,51 @@ async fn clear_remote_control_enrollment( } *enrollment = None; status_publisher.publish_environment_id(/*environment_id*/ None); - clear_current_enrollment(current_enrollment); +} + +async fn clear_remote_control_enrollment_if_matches( + state_db: Option<&StateRuntime>, + remote_control_target: &RemoteControlTarget, + account_id: &str, + app_server_client_name: Option<&str>, + current_enrollment: &CurrentRemoteControlEnrollment, + enrollment: &RemoteControlEnrollment, + status_publisher: &RemoteControlStatusPublisher, +) { + let Some(state_db) = state_db else { + return; + }; + let mut current_enrollment = current_enrollment.lock().await; + if !current_enrollment + .as_ref() + .is_some_and(|current| same_remote_control_enrollment(current, enrollment)) + { + return; + } + clear_remote_control_enrollment( + state_db, + remote_control_target, + account_id, + app_server_client_name, + &mut current_enrollment, + status_publisher, + ) + .await; +} + +async fn clear_remote_control_server_token_if_matches( + current_enrollment: &CurrentRemoteControlEnrollment, + enrollment: &RemoteControlEnrollment, +) -> io::Result<()> { + let mut current_enrollment = current_enrollment.lock().await; + current_enrollment + .as_mut() + .filter(|current| same_remote_control_enrollment(current, enrollment)) + .ok_or_else(|| { + io::Error::other("missing remote control enrollment after websocket auth failure") + })? + .clear_server_token(); + Ok(()) } async fn enroll_remote_control_server_if_missing( @@ -1585,8 +1647,10 @@ mod tests { } } - fn test_current_enrollment() -> CurrentRemoteControlEnrollment { - Arc::new(std::sync::Mutex::new(None)) + fn test_current_enrollment( + enrollment: Option, + ) -> CurrentRemoteControlEnrollment { + Arc::new(RemoteControlEnrollmentState::new(enrollment)) } #[test] @@ -1739,10 +1803,9 @@ mod tests { let auth_manager = remote_control_auth_manager(); let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment(Some( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, - ))); - let current_enrollment = test_current_enrollment(); + )))); let (status_publisher, status_rx) = remote_control_status_channel(); let err = match connect_remote_control_websocket( @@ -1753,7 +1816,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -1761,7 +1824,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - ¤t_enrollment, ) .await { @@ -1771,12 +1833,7 @@ mod tests { server_task.await.expect("server task should succeed"); assert_eq!(err.to_string(), expected_error); - assert!( - current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .is_some() - ); + assert!(current_enrollment.lock().await.is_some()); assert_eq!( status_rx.borrow().clone(), RemoteControlStatusChangedNotification { @@ -1801,10 +1858,9 @@ mod tests { let auth_manager = remote_control_auth_manager(); let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment(Some( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, - ))); - let current_enrollment = test_current_enrollment(); + )))); let (status_publisher, status_rx) = remote_control_status_channel(); let server_task = tokio::spawn(async move { @@ -1824,7 +1880,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -1832,7 +1888,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - ¤t_enrollment, ) .await .expect_err("unauthorized response should fail the websocket connect"); @@ -1853,13 +1908,7 @@ mod tests { ); let mut expected_enrollment = remote_control_enrollment(/*remote_control_token*/ None); expected_enrollment.remote_control_target = remote_control_target; - assert_eq!(enrollment, Some(expected_enrollment)); - assert!( - current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .is_none() - ); + assert_eq!(*current_enrollment.lock().await, Some(expected_enrollment)); } #[tokio::test] @@ -1896,7 +1945,7 @@ mod tests { .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = None; + let current_enrollment = test_current_enrollment(/*enrollment*/ None); let (status_publisher, status_rx) = remote_control_status_channel(); save_auth( codex_home.path(), @@ -1913,7 +1962,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -1921,7 +1970,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - &test_current_enrollment(), ) .await .expect_err("unauthorized enrollment should fail the websocket connect"); @@ -1989,9 +2037,9 @@ mod tests { .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment( /*remote_control_token*/ None, - )); + ))); let (status_publisher, status_rx) = remote_control_status_channel(); save_auth( codex_home.path(), @@ -2008,7 +2056,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -2016,7 +2064,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - &test_current_enrollment(), ) .await .expect_err("unauthorized refresh should fail the websocket connect"); @@ -2061,9 +2108,9 @@ mod tests { let auth_manager = remote_control_auth_manager(); let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment(Some( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, - ))); + )))); let (status_publisher, _status_rx) = remote_control_status_channel(); let err = connect_remote_control_websocket( @@ -2074,7 +2121,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -2082,14 +2129,13 @@ mod tests { app_server_client_name: None, }, &status_publisher, - &test_current_enrollment(), ) .await .expect_err("missing sqlite state db should fail remote control"); assert_eq!(err.kind(), ErrorKind::NotFound); assert_eq!(err.to_string(), "remote control requires sqlite state db"); - assert_eq!(enrollment, None); + assert_eq!(*current_enrollment.lock().await, None); } #[tokio::test] @@ -2107,9 +2153,9 @@ mod tests { .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment(Some( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, - ))); + )))); let (status_publisher, mut status_rx) = remote_control_status_channel(); status_publisher.publish_environment_id(Some("env_test".to_string())); status_rx @@ -2125,7 +2171,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -2133,7 +2179,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - &test_current_enrollment(), ) .await .expect_err("missing auth should fail remote control"); @@ -2143,7 +2188,7 @@ mod tests { err.to_string(), "remote control requires ChatGPT authentication" ); - assert_eq!(enrollment, None); + assert_eq!(*current_enrollment.lock().await, None); assert_eq!( status_rx.borrow().clone(), RemoteControlStatusChangedNotification { @@ -2185,7 +2230,8 @@ mod tests { RemoteControlChannels { transport_event_tx, status_publisher, - current_enrollment: test_current_enrollment(), + current_enrollment: test_current_enrollment(/*enrollment*/ None), + pairing_persistence_key: watch::channel(None).0, }, shutdown_token, enabled_rx, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index d89f0b064536..6c578ce8e53c 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -918,7 +918,7 @@ impl MessageProcessor { .map(|response| Some(response.into())), ClientRequest::RemoteControlPairingStart { params, .. } => self .remote_control_processor - .pairing_start(params) + .pairing_start(params, app_server_client_name.as_deref()) .await .map(|response| Some(response.into())), ClientRequest::RemoteControlClientsList { params, .. } => self diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor.rs b/codex-rs/app-server/src/request_processors/remote_control_processor.rs index 804339c17a5e..22223687bc13 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor.rs @@ -52,9 +52,10 @@ impl RemoteControlRequestProcessor { pub(crate) async fn pairing_start( &self, params: RemoteControlPairingStartParams, + app_server_client_name: Option<&str>, ) -> Result { self.handle()? - .start_pairing(params) + .start_pairing(params, app_server_client_name) .await .map_err(map_pairing_start_error) } diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs index 4381e48959da..e336491cd3ba 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs @@ -6,7 +6,10 @@ use pretty_assertions::assert_eq; #[tokio::test] async fn pairing_start_returns_internal_error_when_remote_control_is_unavailable() { let err = RemoteControlRequestProcessor::new(/*remote_control_handle*/ None) - .pairing_start(RemoteControlPairingStartParams::default()) + .pairing_start( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) .await .expect_err("missing remote control should fail pairing"); diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index b5384d3bad9b..c48ffda0f634 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -193,6 +193,42 @@ async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()> Ok(()) } +#[tokio::test] +async fn remote_control_pairing_start_returns_pairing_artifacts_while_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let mut backend = PairingRemoteControlBackend::start(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_remote_control_pairing_start_request(RemoteControlPairingStartParams { + manual_code: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!( + timeout(DEFAULT_TIMEOUT, backend.wait_for_enroll_request()).await??, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + assert_eq!(response.result.get("serverId"), None); + let received: RemoteControlPairingStartResponse = to_response(response)?; + + assert_eq!( + received, + RemoteControlPairingStartResponse { + pairing_code: "pairing-code".to_string(), + manual_pairing_code: Some("ABCD-EFGH".to_string()), + environment_id: "environment-id".to_string(), + expires_at: 33_336_362_096, + } + ); + Ok(()) +} + #[tokio::test] async fn remote_control_client_management_works_while_disabled() -> Result<()> { let codex_home = TempDir::new()?; @@ -376,8 +412,12 @@ impl PairingRemoteControlBackend { ) .await?; - let _websocket_request = read_http_request(&listener).await?; - let pair_http_request = read_http_request(&listener).await?; + let request_after_enroll = read_http_request(&listener).await?; + let pair_http_request = if request_after_enroll.request_line.starts_with("GET ") { + read_http_request(&listener).await? + } else { + request_after_enroll + }; respond_with_json( pair_http_request.reader.into_inner(), serde_json::json!({ From 6a6a5f925e97bee23d5d31938b5f9f9f38becb8a Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 4 Jun 2026 22:36:25 -0700 Subject: [PATCH 15/59] [codex] Add environment shell info (#26480) ## Why Shell detection needs to be available through the `Environment` abstraction so callers can ask the selected local or remote environment for shell metadata without adding a separate HTTP endpoint or parallel info-source path. This keeps shell metadata shaped like the existing environment-owned filesystem capability and lets remote environments answer through exec-server JSON-RPC. ## What changed - Added `environment/info` to the exec-server protocol/client/server and exposed `Environment::info()`. - Added local and remote environment info providers on `Environment`, following the existing capability-provider pattern used for filesystem access. - Moved the shared shell detection logic into `codex-shell-command` and kept core shell APIs as wrappers around that implementation. - Returned shell metadata as `EnvironmentInfo { shell: ShellInfo }` using the existing shell detection path. - Added a remote environment test that calls `Environment::info()` through an exec-server-backed environment. ## Validation - `git diff --check` - `just test -p codex-shell-command` - `just test -p codex-core -E 'test(/shell::tests::/)'`\n- `just test -p codex-exec-server environment` --- codex-rs/Cargo.lock | 2 + codex-rs/core/src/lib.rs | 1 - codex-rs/core/src/shell.rs | 328 +---------------- codex-rs/core/src/shell_detect.rs | 24 -- codex-rs/core/src/shell_snapshot.rs | 8 +- .../src/tools/handlers/shell/shell_command.rs | 2 +- .../core/src/tools/handlers/unified_exec.rs | 2 +- .../core/src/unified_exec/process_manager.rs | 2 +- codex-rs/exec-server/Cargo.toml | 1 + codex-rs/exec-server/src/client.rs | 12 + codex-rs/exec-server/src/environment.rs | 62 ++++ codex-rs/exec-server/src/lib.rs | 2 + codex-rs/exec-server/src/protocol.rs | 18 + codex-rs/exec-server/src/server/handler.rs | 6 + codex-rs/exec-server/src/server/registry.rs | 5 + codex-rs/exec-server/tests/health.rs | 15 + codex-rs/shell-command/Cargo.toml | 1 + codex-rs/shell-command/src/bash.rs | 2 +- codex-rs/shell-command/src/lib.rs | 2 +- codex-rs/shell-command/src/powershell.rs | 2 +- codex-rs/shell-command/src/shell_detect.rs | 346 +++++++++++++++++- 21 files changed, 488 insertions(+), 355 deletions(-) delete mode 100644 codex-rs/core/src/shell_detect.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6da768843504..b89922238492 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2789,6 +2789,7 @@ dependencies = [ "codex-file-system", "codex-protocol", "codex-sandboxing", + "codex-shell-command", "codex-test-binary-support", "codex-utils-absolute-path", "codex-utils-pty", @@ -3692,6 +3693,7 @@ dependencies = [ "base64 0.22.1", "codex-protocol", "codex-utils-absolute-path", + "libc", "once_cell", "pretty_assertions", "regex", diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index b77f96cdb1a6..4dc19de7e674 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -83,7 +83,6 @@ mod sandbox_tags; pub mod sandboxing; mod session_prefix; mod session_startup_prewarm; -mod shell_detect; pub mod skills; pub(crate) use skills::SkillInjections; pub(crate) use skills::SkillLoadOutcome; diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 48c760d6677a..9843d4cea587 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -1,19 +1,12 @@ -use crate::shell_detect::detect_shell_type; use crate::shell_snapshot::ShellSnapshot; +use codex_shell_command::shell_detect::DetectedShell; use serde::Deserialize; use serde::Serialize; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::watch; -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] -pub enum ShellType { - Zsh, - Bash, - PowerShell, - Sh, - Cmd, -} +pub use codex_shell_command::shell_detect::ShellType; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Shell { @@ -29,13 +22,7 @@ pub struct Shell { impl Shell { pub fn name(&self) -> &'static str { - match self.shell_type { - ShellType::Zsh => "zsh", - ShellType::Bash => "bash", - ShellType::PowerShell => "powershell", - ShellType::Sh => "sh", - ShellType::Cmd => "cmd", - } + self.shell_type.name() } /// Takes a string of shell and returns the full list of command args to @@ -88,319 +75,36 @@ impl PartialEq for Shell { impl Eq for Shell {} -#[cfg(unix)] -fn get_user_shell_path() -> Option { - let uid = unsafe { libc::getuid() }; - use std::ffi::CStr; - use std::mem::MaybeUninit; - use std::ptr; - - let mut passwd = MaybeUninit::::uninit(); - - // We cannot use getpwuid here: it returns pointers into libc-managed - // storage, which is not safe to read concurrently on all targets (the musl - // static build used by the CLI can segfault when parallel callers race on - // that buffer). getpwuid_r keeps the passwd data in caller-owned memory. - let suggested_buffer_len = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) }; - let buffer_len = usize::try_from(suggested_buffer_len) - .ok() - .filter(|len| *len > 0) - .unwrap_or(1024); - let mut buffer = vec![0; buffer_len]; - - loop { - let mut result = ptr::null_mut(); - let status = unsafe { - libc::getpwuid_r( - uid, - passwd.as_mut_ptr(), - buffer.as_mut_ptr().cast(), - buffer.len(), - &mut result, - ) - }; - - if status == 0 { - if result.is_null() { - return None; - } - - let passwd = unsafe { passwd.assume_init_ref() }; - if passwd.pw_shell.is_null() { - return None; - } - - let shell_path = unsafe { CStr::from_ptr(passwd.pw_shell) } - .to_string_lossy() - .into_owned(); - return Some(PathBuf::from(shell_path)); - } - - if status != libc::ERANGE { - return None; - } - - // Retry with a larger buffer until libc can materialize the passwd entry. - let new_len = buffer.len().checked_mul(2)?; - if new_len > 1024 * 1024 { - return None; - } - buffer.resize(new_len, 0); - } -} - -#[cfg(not(unix))] -fn get_user_shell_path() -> Option { - None -} - -fn file_exists(path: &PathBuf) -> Option { - if std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) { - Some(PathBuf::from(path)) - } else { - None - } -} - -fn get_shell_path( - shell_type: ShellType, - provided_path: Option<&PathBuf>, - binary_name: &str, - fallback_paths: &[&str], -) -> Option { - // If exact provided path exists, use it - if provided_path.and_then(file_exists).is_some() { - return provided_path.cloned(); - } - - // Check if the shell we are trying to load is user's default shell - // if just use it - let default_shell_path = get_user_shell_path(); - if let Some(default_shell_path) = default_shell_path - && detect_shell_type(&default_shell_path) == Some(shell_type) - && file_exists(&default_shell_path).is_some() - { - return Some(default_shell_path); - } - - if let Ok(path) = which::which(binary_name) { - return Some(path); - } - - for path in fallback_paths { - //check exists - if let Some(path) = file_exists(&PathBuf::from(path)) { - return Some(path); +impl From for Shell { + fn from(detected: DetectedShell) -> Self { + Self { + shell_type: detected.shell_type, + shell_path: detected.shell_path, + shell_snapshot: empty_shell_snapshot_receiver(), } } - - None -} - -const ZSH_FALLBACK_PATHS: &[&str] = &["/bin/zsh"]; - -fn get_zsh_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::Zsh, path, "zsh", ZSH_FALLBACK_PATHS); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::Zsh, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) -} - -const BASH_FALLBACK_PATHS: &[&str] = &["/bin/bash"]; - -fn get_bash_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::Bash, path, "bash", BASH_FALLBACK_PATHS); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::Bash, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) -} - -const SH_FALLBACK_PATHS: &[&str] = &["/bin/sh"]; - -fn get_sh_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::Sh, path, "sh", SH_FALLBACK_PATHS); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::Sh, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) -} - -// Note the `pwsh` and `powershell` fallback paths are where the respective -// shells are commonly installed on GitHub Actions Windows runners, but may not -// be present on all Windows machines: -// https://docs.github.com/en/actions/tutorials/build-and-test-code/powershell - -#[cfg(windows)] -const PWSH_FALLBACK_PATHS: &[&str] = &[r#"C:\Program Files\PowerShell\7\pwsh.exe"#]; -#[cfg(not(windows))] -const PWSH_FALLBACK_PATHS: &[&str] = &["/usr/local/bin/pwsh"]; - -#[cfg(windows)] -const POWERSHELL_FALLBACK_PATHS: &[&str] = - &[r#"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"#]; -#[cfg(not(windows))] -const POWERSHELL_FALLBACK_PATHS: &[&str] = &[]; - -fn get_powershell_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::PowerShell, path, "pwsh", PWSH_FALLBACK_PATHS) - .or_else(|| { - get_shell_path( - ShellType::PowerShell, - path, - "powershell", - POWERSHELL_FALLBACK_PATHS, - ) - }); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::PowerShell, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) -} - -fn get_cmd_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::Cmd, path, "cmd", &[]); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::Cmd, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) } +#[cfg(all(test, unix))] fn ultimate_fallback_shell() -> Shell { - if cfg!(windows) { - Shell { - shell_type: ShellType::Cmd, - shell_path: PathBuf::from("cmd.exe"), - shell_snapshot: empty_shell_snapshot_receiver(), - } - } else { - Shell { - shell_type: ShellType::Sh, - shell_path: PathBuf::from("/bin/sh"), - shell_snapshot: empty_shell_snapshot_receiver(), - } - } + codex_shell_command::shell_detect::ultimate_fallback_shell().into() } pub fn get_shell_by_model_provided_path(shell_path: &PathBuf) -> Shell { - detect_shell_type(shell_path) - .and_then(|shell_type| get_shell(shell_type, Some(shell_path))) - .unwrap_or(ultimate_fallback_shell()) + codex_shell_command::shell_detect::get_shell_by_model_provided_path(shell_path).into() } pub fn get_shell(shell_type: ShellType, path: Option<&PathBuf>) -> Option { - match shell_type { - ShellType::Zsh => get_zsh_shell(path), - ShellType::Bash => get_bash_shell(path), - ShellType::PowerShell => get_powershell_shell(path), - ShellType::Sh => get_sh_shell(path), - ShellType::Cmd => get_cmd_shell(path), - } + codex_shell_command::shell_detect::get_shell(shell_type, path).map(Into::into) } pub fn default_user_shell() -> Shell { - default_user_shell_from_path(get_user_shell_path()) + codex_shell_command::shell_detect::default_user_shell().into() } +#[cfg(all(test, target_os = "macos"))] fn default_user_shell_from_path(user_shell_path: Option) -> Shell { - if cfg!(windows) { - get_shell(ShellType::PowerShell, /*path*/ None).unwrap_or(ultimate_fallback_shell()) - } else { - let user_default_shell = user_shell_path - .and_then(|shell| detect_shell_type(&shell)) - .and_then(|shell_type| get_shell(shell_type, /*path*/ None)); - - let shell_with_fallback = if cfg!(target_os = "macos") { - user_default_shell - .or_else(|| get_shell(ShellType::Zsh, /*path*/ None)) - .or_else(|| get_shell(ShellType::Bash, /*path*/ None)) - } else { - user_default_shell - .or_else(|| get_shell(ShellType::Bash, /*path*/ None)) - .or_else(|| get_shell(ShellType::Zsh, /*path*/ None)) - }; - - shell_with_fallback.unwrap_or(ultimate_fallback_shell()) - } -} - -#[cfg(test)] -mod detect_shell_type_tests { - use super::*; - - #[test] - fn test_detect_shell_type() { - assert_eq!( - detect_shell_type(&PathBuf::from("zsh")), - Some(ShellType::Zsh) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("bash")), - Some(ShellType::Bash) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("pwsh")), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("powershell")), - Some(ShellType::PowerShell) - ); - assert_eq!(detect_shell_type(&PathBuf::from("fish")), None); - assert_eq!(detect_shell_type(&PathBuf::from("other")), None); - assert_eq!( - detect_shell_type(&PathBuf::from("/bin/zsh")), - Some(ShellType::Zsh) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("/bin/bash")), - Some(ShellType::Bash) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("powershell.exe")), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from(if cfg!(windows) { - "C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" - } else { - "/usr/local/bin/pwsh" - })), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("pwsh.exe")), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("/usr/local/bin/pwsh")), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("/bin/sh")), - Some(ShellType::Sh) - ); - assert_eq!(detect_shell_type(&PathBuf::from("sh")), Some(ShellType::Sh)); - assert_eq!( - detect_shell_type(&PathBuf::from("cmd")), - Some(ShellType::Cmd) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("cmd.exe")), - Some(ShellType::Cmd) - ); - } + codex_shell_command::shell_detect::default_user_shell_from_path(user_shell_path).into() } #[cfg(test)] diff --git a/codex-rs/core/src/shell_detect.rs b/codex-rs/core/src/shell_detect.rs deleted file mode 100644 index 3595ab3469bb..000000000000 --- a/codex-rs/core/src/shell_detect.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::shell::ShellType; -use std::path::Path; -use std::path::PathBuf; - -pub(crate) fn detect_shell_type(shell_path: &PathBuf) -> Option { - match shell_path.as_os_str().to_str() { - Some("zsh") => Some(ShellType::Zsh), - Some("sh") => Some(ShellType::Sh), - Some("cmd") => Some(ShellType::Cmd), - Some("bash") => Some(ShellType::Bash), - Some("pwsh") => Some(ShellType::PowerShell), - Some("powershell") => Some(ShellType::PowerShell), - _ => { - let shell_name = shell_path.file_stem(); - if let Some(shell_name) = shell_name { - let shell_name_path = Path::new(shell_name); - if shell_name_path != Path::new(shell_path) { - return detect_shell_type(&shell_name_path.to_path_buf()); - } - } - None - } - } -} diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index b328a977d7e3..1cbd53866769 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -151,9 +151,7 @@ impl ShellSnapshot { }); // Make the new snapshot. - if let Err(err) = - write_shell_snapshot(shell.shell_type.clone(), &temp_path, session_cwd).await - { + if let Err(err) = write_shell_snapshot(shell.shell_type, &temp_path, session_cwd).await { tracing::warn!( "Failed to create shell snapshot for {}: {err:?}", shell.name() @@ -203,7 +201,7 @@ async fn write_shell_snapshot( if shell_type == ShellType::PowerShell || shell_type == ShellType::Cmd { bail!("Shell snapshot not supported yet for {shell_type:?}"); } - let shell = get_shell(shell_type.clone(), /*path*/ None) + let shell = get_shell(shell_type, /*path*/ None) .with_context(|| format!("No available shell for {shell_type:?}"))?; let raw_snapshot = capture_snapshot(&shell, cwd).await?; @@ -225,7 +223,7 @@ async fn write_shell_snapshot( } async fn capture_snapshot(shell: &Shell, cwd: &AbsolutePathBuf) -> Result { - let shell_type = shell.shell_type.clone(); + let shell_type = shell.shell_type; match shell_type { ShellType::Zsh => run_shell_script(shell, &zsh_snapshot_script(), cwd).await, ShellType::Bash => run_shell_script(shell, &bash_snapshot_script(), cwd).await, diff --git a/codex-rs/core/src/tools/handlers/shell/shell_command.rs b/codex-rs/core/src/tools/handlers/shell/shell_command.rs index b4b60997d6e7..7c492b3646db 100644 --- a/codex-rs/core/src/tools/handlers/shell/shell_command.rs +++ b/codex-rs/core/src/tools/handlers/shell/shell_command.rs @@ -182,7 +182,7 @@ impl ToolExecutor for ShellCommandHandler { session.thread_id, turn.config.permissions.allow_login_shell, )?; - let shell_type = Some(session.user_shell().shell_type.clone()); + let shell_type = Some(session.user_shell().shell_type); run_exec_like(RunExecLikeArgs { tool_name, exec_params, diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 6fa2e40685b7..ca6b2a4b3e44 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -122,7 +122,7 @@ pub(crate) fn get_command( let shell = model_shell.as_ref().unwrap_or(session_shell.as_ref()); Ok(ResolvedCommand { command: shell.derive_exec_args(&args.cmd, use_login_shell), - shell_type: shell.shell_type.clone(), + shell_type: shell.shell_type, }) } UnifiedExecShellMode::ZshFork(zsh_fork_config) => { diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 2c86deafafca..a4493cf11458 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1032,7 +1032,7 @@ impl UnifiedExecProcessManager { .await; let req = UnifiedExecToolRequest { command: request.command.clone(), - shell_type: request.shell_type.clone(), + shell_type: request.shell_type, hook_command: request.hook_command.clone(), process_id: request.process_id, cwd, diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index d842094a1621..eadab83fe703 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -22,6 +22,7 @@ codex-client = { workspace = true } codex-file-system = { workspace = true } codex-protocol = { workspace = true } codex-sandboxing = { workspace = true } +codex-shell-command = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-rustls-provider = { workspace = true } diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index b6c56e9f5e27..8cf18e7d1f43 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -29,6 +29,7 @@ use crate::connection::JsonRpcConnection; use crate::process::ExecProcessEvent; use crate::process::ExecProcessEventLog; use crate::process::ExecProcessEventReceiver; +use crate::protocol::ENVIRONMENT_INFO_METHOD; use crate::protocol::EXEC_CLOSED_METHOD; use crate::protocol::EXEC_EXITED_METHOD; use crate::protocol::EXEC_METHOD; @@ -36,6 +37,7 @@ use crate::protocol::EXEC_OUTPUT_DELTA_METHOD; use crate::protocol::EXEC_READ_METHOD; use crate::protocol::EXEC_TERMINATE_METHOD; use crate::protocol::EXEC_WRITE_METHOD; +use crate::protocol::EnvironmentInfo; use crate::protocol::ExecClosedNotification; use crate::protocol::ExecExitedNotification; use crate::protocol::ExecOutputDeltaNotification; @@ -279,6 +281,12 @@ impl HttpClient for LazyRemoteExecServerClient { } } +impl LazyRemoteExecServerClient { + pub(crate) async fn environment_info(&self) -> Result { + self.get().await?.environment_info().await + } +} + #[derive(Debug, thiserror::Error)] pub enum ExecServerError { #[error("failed to spawn exec-server: {0}")] @@ -363,6 +371,10 @@ impl ExecServerClient { self.call(EXEC_METHOD, ¶ms).await } + pub async fn environment_info(&self) -> Result { + self.call(ENVIRONMENT_INFO_METHOD, &()).await + } + pub async fn read(&self, params: ReadParams) -> Result { self.call(EXEC_READ_METHOD, ¶ms).await } diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 55dc031273e2..1dfd49d3fdbd 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -2,6 +2,9 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::RwLock; +use futures::FutureExt; +use futures::future::BoxFuture; + use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::ExecutorFileSystem; @@ -18,8 +21,11 @@ use crate::environment_toml::environment_provider_from_codex_home; use crate::local_file_system::LocalFileSystem; use crate::local_process::LocalProcess; use crate::process::ExecBackend; +use crate::protocol::EnvironmentInfo; +use crate::protocol::ShellInfo; use crate::remote_file_system::RemoteFileSystem; use crate::remote_process::RemoteProcess; +use codex_shell_command::shell_detect::DetectedShell; pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; @@ -286,18 +292,49 @@ impl EnvironmentManager { pub struct Environment { exec_server_url: Option, remote_transport: Option, + info_provider: Arc, exec_backend: Arc, filesystem: Arc, http_client: Arc, local_runtime_paths: Option, } +/// Provides environment metadata from either a local environment or a remote exec-server. +trait EnvironmentInfoProvider: Send + Sync { + fn info(&self) -> BoxFuture<'_, Result>; +} + +struct LocalEnvironmentInfoProvider; + +impl EnvironmentInfoProvider for LocalEnvironmentInfoProvider { + fn info(&self) -> BoxFuture<'_, Result> { + std::future::ready(Ok(EnvironmentInfo::local())).boxed() + } +} + +struct RemoteEnvironmentInfoProvider { + client: LazyRemoteExecServerClient, +} + +impl RemoteEnvironmentInfoProvider { + fn new(client: LazyRemoteExecServerClient) -> Self { + Self { client } + } +} + +impl EnvironmentInfoProvider for RemoteEnvironmentInfoProvider { + fn info(&self) -> BoxFuture<'_, Result> { + async move { self.client.environment_info().await }.boxed() + } +} + impl Environment { /// Builds a test-only local environment without configured sandbox helper paths. pub fn default_for_tests() -> Self { Self { exec_server_url: None, remote_transport: None, + info_provider: Arc::new(LocalEnvironmentInfoProvider), exec_backend: Arc::new(LocalProcess::default()), filesystem: Arc::new(LocalFileSystem::unsandboxed()), http_client: Arc::new(ReqwestHttpClient), @@ -354,6 +391,7 @@ impl Environment { Self { exec_server_url: None, remote_transport: None, + info_provider: Arc::new(LocalEnvironmentInfoProvider), exec_backend: Arc::new(LocalProcess::default()), filesystem: Arc::new(LocalFileSystem::with_runtime_paths( local_runtime_paths.clone(), @@ -392,6 +430,7 @@ impl Environment { Self { exec_server_url, remote_transport: Some(remote_transport), + info_provider: Arc::new(RemoteEnvironmentInfoProvider::new(client.clone())), exec_backend, filesystem, http_client: Arc::new(client), @@ -412,6 +451,11 @@ impl Environment { self.local_runtime_paths.as_ref() } + /// Returns environment information from the selected execution/filesystem environment. + pub async fn info(&self) -> Result { + self.info_provider.info().await + } + pub fn get_exec_backend(&self) -> Arc { Arc::clone(&self.exec_backend) } @@ -425,6 +469,23 @@ impl Environment { } } +impl EnvironmentInfo { + pub(crate) fn local() -> Self { + Self { + shell: codex_shell_command::shell_detect::default_user_shell().into(), + } + } +} + +impl From for ShellInfo { + fn from(shell: DetectedShell) -> Self { + Self { + name: shell.name().to_string(), + path: shell.shell_path.to_string_lossy().into_owned(), + } + } +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -458,6 +519,7 @@ mod tests { assert_eq!(environment.exec_server_url(), None); assert!(!environment.is_remote()); + assert!(environment.info().await.is_ok()); } #[tokio::test] diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index dea039c28836..e306ec395e7b 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -57,6 +57,7 @@ pub use process::ExecProcessEvent; pub use process::ExecProcessEventReceiver; pub use process::StartedExecProcess; pub use process_id::ProcessId; +pub use protocol::EnvironmentInfo; pub use protocol::ExecClosedNotification; pub use protocol::ExecEnvPolicy; pub use protocol::ExecExitedNotification; @@ -94,6 +95,7 @@ pub use protocol::InitializeResponse; pub use protocol::ProcessOutputChunk; pub use protocol::ReadParams; pub use protocol::ReadResponse; +pub use protocol::ShellInfo; pub use protocol::TerminateParams; pub use protocol::TerminateResponse; pub use protocol::WriteParams; diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 9ac80c188ed3..6eeeeefdd73f 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -19,6 +19,7 @@ pub const EXEC_TERMINATE_METHOD: &str = "process/terminate"; pub const EXEC_OUTPUT_DELTA_METHOD: &str = "process/output"; pub const EXEC_EXITED_METHOD: &str = "process/exited"; pub const EXEC_CLOSED_METHOD: &str = "process/closed"; +pub const ENVIRONMENT_INFO_METHOD: &str = "environment/info"; pub const FS_READ_FILE_METHOD: &str = "fs/readFile"; pub const FS_WRITE_FILE_METHOD: &str = "fs/writeFile"; pub const FS_CREATE_DIRECTORY_METHOD: &str = "fs/createDirectory"; @@ -64,6 +65,23 @@ pub struct InitializeResponse { pub session_id: String, } +/// Information about an execution/filesystem environment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentInfo { + pub shell: ShellInfo, +} + +/// Shell detected for an execution/filesystem environment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShellInfo { + /// Stable shell name, for example `zsh`, `bash`, `powershell`, `sh`, or `cmd`. + pub name: String, + /// Path the exec server would use for that shell. + pub path: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExecParams { diff --git a/codex-rs/exec-server/src/server/handler.rs b/codex-rs/exec-server/src/server/handler.rs index 5456ce41c0b2..b8934705d2b8 100644 --- a/codex-rs/exec-server/src/server/handler.rs +++ b/codex-rs/exec-server/src/server/handler.rs @@ -14,6 +14,7 @@ use tokio_util::task::TaskTracker; use crate::ExecServerRuntimePaths; use crate::client::http_client::PendingReqwestHttpBodyStream; use crate::client::http_client::ReqwestHttpRequestRunner; +use crate::protocol::EnvironmentInfo; use crate::protocol::ExecParams; use crate::protocol::ExecResponse; use crate::protocol::FsCanonicalizeParams; @@ -147,6 +148,11 @@ impl ExecServerHandler { session.process().exec(params).await } + pub(crate) fn environment_info(&self) -> Result { + self.require_initialized_for("environment info")?; + Ok(EnvironmentInfo::local()) + } + pub(crate) async fn exec_read( &self, params: ReadParams, diff --git a/codex-rs/exec-server/src/server/registry.rs b/codex-rs/exec-server/src/server/registry.rs index 74adf905805d..26d2876c6383 100644 --- a/codex-rs/exec-server/src/server/registry.rs +++ b/codex-rs/exec-server/src/server/registry.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use crate::protocol::ENVIRONMENT_INFO_METHOD; use crate::protocol::EXEC_METHOD; use crate::protocol::EXEC_READ_METHOD; use crate::protocol::EXEC_TERMINATE_METHOD; @@ -60,6 +61,10 @@ pub(crate) fn build_router() -> RpcRouter { EXEC_METHOD, |handler: Arc, params: ExecParams| async move { handler.exec(params).await }, ); + router.request( + ENVIRONMENT_INFO_METHOD, + |handler: Arc, _params: ()| async move { handler.environment_info() }, + ); router.request( EXEC_READ_METHOD, |handler: Arc, params: ReadParams| async move { diff --git a/codex-rs/exec-server/tests/health.rs b/codex-rs/exec-server/tests/health.rs index 91b3806a22b2..1fcfb049b5ed 100644 --- a/codex-rs/exec-server/tests/health.rs +++ b/codex-rs/exec-server/tests/health.rs @@ -2,6 +2,7 @@ mod common; +use codex_exec_server::Environment; use common::exec_server::exec_server; use pretty_assertions::assert_eq; @@ -19,3 +20,17 @@ async fn exec_server_serves_readyz_alongside_websocket_endpoint() -> anyhow::Res server.shutdown().await?; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_environment_fetches_info_from_exec_server() -> anyhow::Result<()> { + let mut server = exec_server().await?; + let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?; + assert!(environment.is_remote()); + + let remote_info = environment.info().await?; + let local_info = Environment::default_for_tests().info().await?; + assert_eq!(remote_info, local_info); + + server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/shell-command/Cargo.toml b/codex-rs/shell-command/Cargo.toml index cc33d3621c1e..e918488f826a 100644 --- a/codex-rs/shell-command/Cargo.toml +++ b/codex-rs/shell-command/Cargo.toml @@ -11,6 +11,7 @@ workspace = true base64 = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } +libc = { workspace = true } once_cell = { workspace = true } regex = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/shell-command/src/bash.rs b/codex-rs/shell-command/src/bash.rs index 60ee5c420c51..b25d3fd37c12 100644 --- a/codex-rs/shell-command/src/bash.rs +++ b/codex-rs/shell-command/src/bash.rs @@ -100,7 +100,7 @@ pub fn extract_bash_command(command: &[String]) -> Option<(&str, &str)> { }; if !matches!(flag.as_str(), "-lc" | "-c") || !matches!( - detect_shell_type(&PathBuf::from(shell)), + detect_shell_type(PathBuf::from(shell)), Some(ShellType::Zsh) | Some(ShellType::Bash) | Some(ShellType::Sh) ) { diff --git a/codex-rs/shell-command/src/lib.rs b/codex-rs/shell-command/src/lib.rs index 1d9e302a4e70..947de5e645a7 100644 --- a/codex-rs/shell-command/src/lib.rs +++ b/codex-rs/shell-command/src/lib.rs @@ -1,6 +1,6 @@ //! Command parsing and safety utilities shared across Codex crates. -mod shell_detect; +pub mod shell_detect; pub mod bash; pub(crate) mod command_safety; diff --git a/codex-rs/shell-command/src/powershell.rs b/codex-rs/shell-command/src/powershell.rs index e8c9a500c407..9730439bea31 100644 --- a/codex-rs/shell-command/src/powershell.rs +++ b/codex-rs/shell-command/src/powershell.rs @@ -47,7 +47,7 @@ pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> { let shell = &command[0]; if !matches!( - detect_shell_type(&PathBuf::from(shell)), + detect_shell_type(PathBuf::from(shell)), Some(ShellType::PowerShell) ) { return None; diff --git a/codex-rs/shell-command/src/shell_detect.rs b/codex-rs/shell-command/src/shell_detect.rs index 34322a06d003..7068463830b0 100644 --- a/codex-rs/shell-command/src/shell_detect.rs +++ b/codex-rs/shell-command/src/shell_detect.rs @@ -1,8 +1,10 @@ -use std::path::Path; use std::path::PathBuf; -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -pub(crate) enum ShellType { +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)] +pub enum ShellType { Zsh, Bash, PowerShell, @@ -10,7 +12,32 @@ pub(crate) enum ShellType { Cmd, } -pub(crate) fn detect_shell_type(shell_path: &PathBuf) -> Option { +impl ShellType { + pub fn name(self) -> &'static str { + match self { + Self::Zsh => "zsh", + Self::Bash => "bash", + Self::PowerShell => "powershell", + Self::Sh => "sh", + Self::Cmd => "cmd", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DetectedShell { + pub shell_type: ShellType, + pub shell_path: PathBuf, +} + +impl DetectedShell { + pub fn name(&self) -> &'static str { + self.shell_type.name() + } +} + +pub fn detect_shell_type(shell_path: impl AsRef) -> Option { + let shell_path = shell_path.as_ref(); match shell_path.as_os_str().to_str() { Some("zsh") => Some(ShellType::Zsh), Some("sh") => Some(ShellType::Sh), @@ -21,12 +48,317 @@ pub(crate) fn detect_shell_type(shell_path: &PathBuf) -> Option { _ => { let shell_name = shell_path.file_stem(); if let Some(shell_name) = shell_name { - let shell_name_path = Path::new(shell_name); - if shell_name_path != Path::new(shell_path) { - return detect_shell_type(&shell_name_path.to_path_buf()); + let shell_name_path = std::path::Path::new(shell_name); + if shell_name_path != shell_path { + return detect_shell_type(shell_name_path); } } None } } } + +#[cfg(unix)] +fn get_user_shell_path() -> Option { + let uid = unsafe { libc::getuid() }; + use std::ffi::CStr; + use std::mem::MaybeUninit; + use std::ptr; + + let mut passwd = MaybeUninit::::uninit(); + + // We cannot use getpwuid here: it returns pointers into libc-managed + // storage, which is not safe to read concurrently on all targets (the musl + // static build used by the CLI can segfault when parallel callers race on + // that buffer). getpwuid_r keeps the passwd data in caller-owned memory. + let suggested_buffer_len = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) }; + let buffer_len = usize::try_from(suggested_buffer_len) + .ok() + .filter(|len| *len > 0) + .unwrap_or(1024); + let mut buffer = vec![0; buffer_len]; + + loop { + let mut result = ptr::null_mut(); + let status = unsafe { + libc::getpwuid_r( + uid, + passwd.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut result, + ) + }; + + if status == 0 { + if result.is_null() { + return None; + } + + let passwd = unsafe { passwd.assume_init_ref() }; + if passwd.pw_shell.is_null() { + return None; + } + + let shell_path = unsafe { CStr::from_ptr(passwd.pw_shell) } + .to_string_lossy() + .into_owned(); + return Some(PathBuf::from(shell_path)); + } + + if status != libc::ERANGE { + return None; + } + + // Retry with a larger buffer until libc can materialize the passwd entry. + let new_len = buffer.len().checked_mul(2)?; + if new_len > 1024 * 1024 { + return None; + } + buffer.resize(new_len, 0); + } +} + +#[cfg(not(unix))] +fn get_user_shell_path() -> Option { + None +} + +fn file_exists(path: &std::path::Path) -> Option { + if std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) { + Some(PathBuf::from(path)) + } else { + None + } +} + +fn get_shell_path( + shell_type: ShellType, + provided_path: Option<&PathBuf>, + binary_name: &str, + fallback_paths: &[&str], +) -> Option { + if let Some(path) = provided_path.and_then(|path| file_exists(path)) { + return Some(path); + } + + let default_shell_path = get_user_shell_path(); + if let Some(default_shell_path) = default_shell_path + && detect_shell_type(&default_shell_path) == Some(shell_type) + && file_exists(&default_shell_path).is_some() + { + return Some(default_shell_path); + } + + if let Ok(path) = which::which(binary_name) { + return Some(path); + } + + for path in fallback_paths { + if let Some(path) = file_exists(std::path::Path::new(path)) { + return Some(path); + } + } + + None +} + +const ZSH_FALLBACK_PATHS: &[&str] = &["/bin/zsh"]; + +fn get_zsh_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::Zsh, path, "zsh", ZSH_FALLBACK_PATHS); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::Zsh, + shell_path, + }) +} + +const BASH_FALLBACK_PATHS: &[&str] = &["/bin/bash"]; + +fn get_bash_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::Bash, path, "bash", BASH_FALLBACK_PATHS); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::Bash, + shell_path, + }) +} + +const SH_FALLBACK_PATHS: &[&str] = &["/bin/sh"]; + +fn get_sh_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::Sh, path, "sh", SH_FALLBACK_PATHS); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::Sh, + shell_path, + }) +} + +// Note the `pwsh` and `powershell` fallback paths are where the respective +// shells are commonly installed on GitHub Actions Windows runners, but may not +// be present on all Windows machines: +// https://docs.github.com/en/actions/tutorials/build-and-test-code/powershell + +#[cfg(windows)] +const PWSH_FALLBACK_PATHS: &[&str] = &[r#"C:\Program Files\PowerShell\7\pwsh.exe"#]; +#[cfg(not(windows))] +const PWSH_FALLBACK_PATHS: &[&str] = &["/usr/local/bin/pwsh"]; + +#[cfg(windows)] +const POWERSHELL_FALLBACK_PATHS: &[&str] = + &[r#"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"#]; +#[cfg(not(windows))] +const POWERSHELL_FALLBACK_PATHS: &[&str] = &[]; + +fn get_powershell_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::PowerShell, path, "pwsh", PWSH_FALLBACK_PATHS) + .or_else(|| { + get_shell_path( + ShellType::PowerShell, + path, + "powershell", + POWERSHELL_FALLBACK_PATHS, + ) + }); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::PowerShell, + shell_path, + }) +} + +fn get_cmd_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::Cmd, path, "cmd", &[]); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::Cmd, + shell_path, + }) +} + +pub fn ultimate_fallback_shell() -> DetectedShell { + if cfg!(windows) { + DetectedShell { + shell_type: ShellType::Cmd, + shell_path: PathBuf::from("cmd.exe"), + } + } else { + DetectedShell { + shell_type: ShellType::Sh, + shell_path: PathBuf::from("/bin/sh"), + } + } +} + +pub fn get_shell_by_model_provided_path(shell_path: &PathBuf) -> DetectedShell { + detect_shell_type(shell_path) + .and_then(|shell_type| get_shell(shell_type, Some(shell_path))) + .unwrap_or_else(ultimate_fallback_shell) +} + +pub fn get_shell(shell_type: ShellType, path: Option<&PathBuf>) -> Option { + match shell_type { + ShellType::Zsh => get_zsh_shell(path), + ShellType::Bash => get_bash_shell(path), + ShellType::PowerShell => get_powershell_shell(path), + ShellType::Sh => get_sh_shell(path), + ShellType::Cmd => get_cmd_shell(path), + } +} + +pub fn default_user_shell() -> DetectedShell { + default_user_shell_from_path(get_user_shell_path()) +} + +pub fn default_user_shell_from_path(user_shell_path: Option) -> DetectedShell { + if cfg!(windows) { + get_shell(ShellType::PowerShell, /*path*/ None).unwrap_or_else(ultimate_fallback_shell) + } else { + let user_default_shell = user_shell_path + .and_then(|shell| detect_shell_type(&shell)) + .and_then(|shell_type| get_shell(shell_type, /*path*/ None)); + + let shell_with_fallback = if cfg!(target_os = "macos") { + user_default_shell + .or_else(|| get_shell(ShellType::Zsh, /*path*/ None)) + .or_else(|| get_shell(ShellType::Bash, /*path*/ None)) + } else { + user_default_shell + .or_else(|| get_shell(ShellType::Bash, /*path*/ None)) + .or_else(|| get_shell(ShellType::Zsh, /*path*/ None)) + }; + + shell_with_fallback.unwrap_or_else(ultimate_fallback_shell) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_detect_shell_type() { + assert_eq!( + detect_shell_type(PathBuf::from("zsh")), + Some(ShellType::Zsh) + ); + assert_eq!( + detect_shell_type(PathBuf::from("bash")), + Some(ShellType::Bash) + ); + assert_eq!( + detect_shell_type(PathBuf::from("pwsh")), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from("powershell")), + Some(ShellType::PowerShell) + ); + assert_eq!(detect_shell_type(PathBuf::from("fish")), None); + assert_eq!(detect_shell_type(PathBuf::from("other")), None); + assert_eq!( + detect_shell_type(PathBuf::from("/bin/zsh")), + Some(ShellType::Zsh) + ); + assert_eq!( + detect_shell_type(PathBuf::from("/bin/bash")), + Some(ShellType::Bash) + ); + assert_eq!( + detect_shell_type(PathBuf::from("powershell.exe")), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from(if cfg!(windows) { + "C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" + } else { + "/usr/local/bin/pwsh" + })), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from("pwsh.exe")), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from("/usr/local/bin/pwsh")), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from("/bin/sh")), + Some(ShellType::Sh) + ); + assert_eq!(detect_shell_type(PathBuf::from("sh")), Some(ShellType::Sh)); + assert_eq!( + detect_shell_type(PathBuf::from("cmd")), + Some(ShellType::Cmd) + ); + assert_eq!( + detect_shell_type(PathBuf::from("cmd.exe")), + Some(ShellType::Cmd) + ); + } +} From 5f4d06ef186b896d316620556e561d59206c3ebf Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 5 Jun 2026 10:25:57 +0200 Subject: [PATCH 16/59] Encrypt multi-agent v2 message payloads (#26210) ## Why Multi-agent v2 currently routes agent instructions through normal tool arguments and inter-agent context. That means the parent model can emit plaintext task text, Codex can persist it in history/rollouts, and the recipient can receive it as ordinary assistant-message JSON. This changes the v2 path so agent instructions stay encrypted between model calls: Responses encrypts the `message` argument returned by the model, Codex forwards only that ciphertext, and Responses decrypts it internally for the recipient model. ## What changed - Mark the v2 `message` parameter as encrypted for `spawn_agent`, `send_message`, and `followup_task`. - Treat multi-agent v2 tool `message` values as ciphertext unconditionally. - Store v2 inter-agent task text in `InterAgentCommunication.encrypted_content` with empty plaintext `content`. - Convert encrypted inter-agent communications into the Responses `agent_message` input item before sending the child request. - Preserve `agent_message` items across history, rollout, compaction, telemetry, and app-server schema paths. - Leave multi-agent v1 unchanged. ## Message shape The model still calls the v2 tools with a `message` argument, but that value is now ciphertext: ```json { "name": "spawn_agent", "arguments": { "task_name": "worker", "message": "" } } ``` Codex stores the task as encrypted inter-agent communication: ```json { "author": "/root", "recipient": "/root/worker", "content": "", "encrypted_content": "", "trigger_turn": true } ``` When Codex builds the recipient request, it forwards the ciphertext using the new Responses input item: ```json { "type": "agent_message", "author": "/root", "recipient": "/root/worker", "content": [ { "type": "encrypted_content", "encrypted_content": "" } ] } ``` Responses decrypts that item internally for the recipient model. ## Context impact - Parent context no longer carries plaintext v2 agent task instructions from these tool arguments. - Codex rollout/history stores ciphertext for v2 agent instructions. - Recipient requests receive an `agent_message` item instead of assistant commentary JSON for encrypted task delivery. - Plaintext completion/status notifications are still plaintext because they are Codex-generated status messages, not encrypted model tool arguments. ## Validation - `just test -p codex-tools` - `just test -p codex-protocol` - `just test -p codex-rollout` - `just test -p codex-rollout-trace` - `just test -p codex-otel` - `just write-app-server-schema` --- .../schema/json/ClientRequest.json | 55 ++++++++++++++ .../PermissionsRequestApprovalParams.json | 6 +- .../schema/json/ServerRequest.json | 6 +- .../codex_app_server_protocol.schemas.json | 55 ++++++++++++++ .../codex_app_server_protocol.v2.schemas.json | 55 ++++++++++++++ .../RawResponseItemCompletedNotification.json | 55 ++++++++++++++ .../schema/json/v2/ThreadResumeParams.json | 55 ++++++++++++++ .../typescript/AgentMessageInputContent.ts | 5 ++ .../schema/typescript/ResponseItem.ts | 3 +- .../schema/typescript/index.ts | 1 + codex-rs/core/src/agent/control.rs | 39 ++++++++-- codex-rs/core/src/agent/control_tests.rs | 56 ++++++++++++++ codex-rs/core/src/agent/registry.rs | 14 ++++ codex-rs/core/src/client_common.rs | 18 ++++- codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/context_manager/history.rs | 8 +- codex-rs/core/src/session/turn.rs | 1 + .../src/tools/handlers/multi_agents_spec.rs | 11 ++- .../tools/handlers/multi_agents_spec_tests.rs | 24 ++++++ .../src/tools/handlers/multi_agents_tests.rs | 25 ++++--- .../src/tools/handlers/multi_agents_v2.rs | 15 ++++ .../handlers/multi_agents_v2/message_tool.rs | 21 +++--- .../tools/handlers/multi_agents_v2/spawn.rs | 22 ++---- codex-rs/core/src/tools/spec_plan_tests.rs | 24 ++++++ codex-rs/core/src/turn_timing.rs | 1 + .../tests/suite/subagent_notifications.rs | 75 +++++++++++++++++++ codex-rs/ext/image-generation/src/tool.rs | 1 + codex-rs/otel/src/events/session_telemetry.rs | 1 + codex-rs/protocol/src/models.rs | 11 +++ codex-rs/protocol/src/protocol.rs | 36 +++++++++ .../rollout-trace/src/reducer/tool/agents.rs | 7 +- codex-rs/rollout/src/policy.rs | 4 +- codex-rs/tools/src/json_schema.rs | 8 ++ codex-rs/tools/src/json_schema_tests.rs | 14 ++++ 34 files changed, 674 insertions(+), 59 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/AgentMessageInputContent.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 861ae1dcbdf9..c6e1aa330271 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -34,6 +34,30 @@ ], "type": "string" }, + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -2120,6 +2144,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json index 961e24c2c28a..f2ab78334200 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -288,8 +288,8 @@ "environmentId": { "default": null, "type": [ - "null", - "string" + "string", + "null" ] }, "itemId": { @@ -326,4 +326,4 @@ ], "title": "PermissionsRequestApprovalParams", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 13bac2e118a8..dbfca64f4cf8 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -1593,8 +1593,8 @@ "environmentId": { "default": null, "type": [ - "null", - "string" + "string", + "null" ] }, "itemId": { @@ -2005,4 +2005,4 @@ } ], "title": "ServerRequest" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 4802a52300a9..bbcbb20be44f 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -5900,6 +5900,30 @@ "title": "AgentMessageDeltaNotification", "type": "object" }, + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "AgentPath": { "type": "string" }, @@ -14050,6 +14074,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/v2/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 2b9e30bae72a..65d999b334b5 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -265,6 +265,30 @@ "title": "AgentMessageDeltaNotification", "type": "object" }, + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "AgentPath": { "type": "string" }, @@ -10572,6 +10596,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index f82acbae64cb..c18f9aeb1597 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -1,6 +1,30 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "ContentItem": { "oneOf": [ { @@ -369,6 +393,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 364cb36617d4..a6abcc566576 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1,6 +1,30 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -436,6 +460,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { diff --git a/codex-rs/app-server-protocol/schema/typescript/AgentMessageInputContent.ts b/codex-rs/app-server-protocol/schema/typescript/AgentMessageInputContent.ts new file mode 100644 index 000000000000..a3bb645597fc --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/AgentMessageInputContent.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AgentMessageInputContent = { "type": "encrypted_content", encrypted_content: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index e5e960ff81a0..b90963db8020 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgentMessageInputContent } from "./AgentMessageInputContent"; import type { ContentItem } from "./ContentItem"; import type { FunctionCallOutputBody } from "./FunctionCallOutputBody"; import type { LocalShellAction } from "./LocalShellAction"; @@ -10,7 +11,7 @@ import type { ReasoningItemContent } from "./ReasoningItemContent"; import type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary"; import type { WebSearchAction } from "./WebSearchAction"; -export type ResponseItem = { "type": "message", role: string, content: Array, phase?: MessagePhase, } | { "type": "reasoning", summary: Array, content?: Array, encrypted_content: string | null, } | { "type": "local_shell_call", +export type ResponseItem = { "type": "message", role: string, content: Array, phase?: MessagePhase, } | { "type": "agent_message", author: string, recipient: string, content: Array, } | { "type": "reasoning", summary: Array, content?: Array, encrypted_content: string | null, } | { "type": "local_shell_call", /** * Set when using the Responses API. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 458d2e43b9a7..149b3aec0d62 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! export type { AbsolutePathBuf } from "./AbsolutePathBuf"; +export type { AgentMessageInputContent } from "./AgentMessageInputContent"; export type { AgentPath } from "./AgentPath"; export type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams"; export type { ApplyPatchApprovalResponse } from "./ApplyPatchApprovalResponse"; diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 549852e3602a..6317dff3b59e 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -109,7 +109,8 @@ fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: _ => false, }, RolloutItem::ResponseItem( - ResponseItem::Reasoning { .. } + ResponseItem::AgentMessage { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } @@ -715,7 +716,12 @@ impl AgentControl { agent_id: ThreadId, initial_operation: Op, ) -> CodexResult { - let last_task_message = render_input_preview(&initial_operation); + let last_task_message = match &initial_operation { + Op::InterAgentCommunication { communication } => { + last_task_message_from_communication(communication) + } + _ => non_empty_task_message(render_input_preview(&initial_operation)), + }; let state = self.upgrade()?; let result = self .handle_thread_request_result( @@ -725,8 +731,12 @@ impl AgentControl { ) .await; if result.is_ok() { - self.state - .update_last_task_message(agent_id, last_task_message); + match last_task_message { + Some(last_task_message) => self + .state + .update_last_task_message(agent_id, last_task_message), + None => self.state.clear_last_task_message(agent_id), + } } result } @@ -736,7 +746,7 @@ impl AgentControl { agent_id: ThreadId, communication: InterAgentCommunication, ) -> CodexResult { - let last_task_message = communication.content.clone(); + let last_task_message = last_task_message_from_communication(&communication); let state = self.upgrade()?; let result = self .handle_thread_request_result( @@ -748,8 +758,12 @@ impl AgentControl { ) .await; if result.is_ok() { - self.state - .update_last_task_message(agent_id, last_task_message); + match last_task_message { + Some(last_task_message) => self + .state + .update_last_task_message(agent_id, last_task_message), + None => self.state.clear_last_task_message(agent_id), + } } result } @@ -1329,6 +1343,17 @@ pub(crate) fn render_input_preview(initial_operation: &Op) -> String { } } +fn last_task_message_from_communication(communication: &InterAgentCommunication) -> Option { + if communication.encrypted_content.is_some() { + return None; + } + non_empty_task_message(communication.content.clone()) +} + +fn non_empty_task_message(message: String) -> Option { + (!message.is_empty()).then_some(message) +} + fn thread_spawn_depth(session_source: &SessionSource) -> Option { match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => Some(*depth), diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index f9462e94f653..3866d22013d0 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -527,6 +527,62 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig )); } +#[tokio::test] +async fn encrypted_inter_agent_communication_clears_existing_last_task_message() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, _) = harness.start_thread().await; + let agent_path = AgentPath::try_from("/root/worker").expect("agent path"); + let spawned_agent = harness + .control + .spawn_agent_with_metadata( + harness.config.clone(), + text_input("old plaintext task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: Some(agent_path.clone()), + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + parent_thread_id: Some(parent_thread_id), + ..Default::default() + }, + ) + .await + .expect("spawn_agent should succeed"); + assert_eq!( + harness + .control + .state + .agent_metadata_for_thread(spawned_agent.thread_id) + .and_then(|metadata| metadata.last_task_message), + Some("old plaintext task".to_string()) + ); + + let communication = InterAgentCommunication::new_encrypted( + AgentPath::root(), + agent_path, + Vec::new(), + "encrypted-task".to_string(), + /*trigger_turn*/ true, + ); + harness + .control + .send_inter_agent_communication(spawned_agent.thread_id, communication) + .await + .expect("send_inter_agent_communication should succeed"); + + assert_eq!( + harness + .control + .state + .agent_metadata_for_thread(spawned_agent.thread_id) + .and_then(|metadata| metadata.last_task_message), + None + ); +} + #[tokio::test] async fn spawn_agent_creates_thread_and_sends_prompt() { let harness = AgentControlHarness::new().await; diff --git a/codex-rs/core/src/agent/registry.rs b/codex-rs/core/src/agent/registry.rs index 1acd73085f44..43aca201bfa2 100644 --- a/codex-rs/core/src/agent/registry.rs +++ b/codex-rs/core/src/agent/registry.rs @@ -180,6 +180,20 @@ impl AgentRegistry { } } + pub(crate) fn clear_last_task_message(&self, thread_id: ThreadId) { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(metadata) = active_agents + .agent_tree + .values_mut() + .find(|metadata| metadata.agent_id == Some(thread_id)) + { + metadata.last_task_message = None; + } + } + fn register_spawned_thread(&self, agent_metadata: AgentMetadata) { let Some(thread_id) = agent_metadata.agent_id else { return; diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index d775f9c01cfd..48d0523df42b 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -3,6 +3,7 @@ use codex_config::types::Personality; use codex_protocol::error::Result; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::InterAgentCommunication; use codex_tools::ToolSpec; use futures::Stream; use serde_json::Value; @@ -53,7 +54,22 @@ impl Default for Prompt { impl Prompt { pub(crate) fn get_formatted_input(&self) -> Vec { - self.input.clone() + self.input + .iter() + .cloned() + .map(|item| { + let ResponseItem::Message { role, content, .. } = &item else { + return item; + }; + if role != "assistant" { + return item; + } + InterAgentCommunication::from_message_content(content) + .filter(|communication| communication.encrypted_content.is_some()) + .map(|communication| communication.to_model_input_item()) + .unwrap_or(item) + }) + .collect() } } diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index a480c1eba815..c7730ddfb616 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -348,6 +348,7 @@ pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { } ResponseItem::Message { role, .. } if role == "assistant" => true, ResponseItem::Message { .. } => false, + ResponseItem::AgentMessage { .. } => true, ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, ResponseItem::CompactionTrigger => false, ResponseItem::Reasoning { .. } diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 0be9bbc18e18..b8bcf9c8467d 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -386,6 +386,7 @@ impl ContextManager { output: truncate_function_output_payload(output, policy_with_serialization_budget), }, ResponseItem::Message { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } @@ -474,7 +475,8 @@ pub(crate) fn truncate_function_output_payload( fn is_api_message(message: &ResponseItem) -> bool { match message { ResponseItem::Message { role, .. } => role.as_str() != "system", - ResponseItem::FunctionCallOutput { .. } + ResponseItem::AgentMessage { .. } + | ResponseItem::FunctionCallOutput { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } | ResponseItem::ToolSearchOutput { .. } @@ -720,11 +722,15 @@ fn is_model_generated_item(item: &ResponseItem) -> bool { ResponseItem::FunctionCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Other => false, } } pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool { + if matches!(item, ResponseItem::AgentMessage { .. }) { + return true; + } let ResponseItem::Message { role, content, .. } = item else { return false; }; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 4a9316496cfd..996e59187ebf 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1924,6 +1924,7 @@ async fn try_run_sampling_request( role == "assistant" && matches!(phase, Some(MessagePhase::Commentary)) } ResponseItem::Reasoning { .. } => true, + ResponseItem::AgentMessage { .. } => false, ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index a15464d9df1f..891ac5a5927a 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -167,7 +167,8 @@ pub fn create_send_message_tool() -> ToolSpec { "message".to_string(), JsonSchema::string(Some( "Message text to queue on the target agent.".to_string(), - )), + )) + .with_encrypted(), ), ]); @@ -199,7 +200,8 @@ pub fn create_followup_task_tool() -> ToolSpec { "message".to_string(), JsonSchema::string(Some( "Message text to send to the target agent.".to_string(), - )), + )) + .with_encrypted(), ), ]); @@ -595,7 +597,10 @@ fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap InterAgentCommunication { + InterAgentCommunication::new_encrypted( + author, + recipient, + Vec::new(), + message, + /*trigger_turn*/ true, + ) +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index 258d80c85b2d..f23d700e9e49 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -1,4 +1,4 @@ -//! Shared argument parsing and dispatch for the v2 text-only agent messaging tools. +//! Shared argument parsing and dispatch for the v2 agent messaging tools. //! //! `send_message` and `followup_task` share the same submission path and differ only in whether the //! resulting `InterAgentCommunication` should wake the target immediately. @@ -55,20 +55,21 @@ fn message_content(message: String) -> Result { Ok(message) } -/// Handles the shared MultiAgentV2 plain-text message flow for both `send_message` and `followup_task`. +/// Handles the shared MultiAgentV2 message flow for both `send_message` and `followup_task`. pub(crate) async fn handle_message_string_tool( invocation: ToolInvocation, mode: MessageDeliveryMode, target: String, message: String, ) -> Result { - let prompt = message_content(message)?; + let message = message_content(message)?; let ToolInvocation { session, turn, call_id, .. } = invocation; + let prompt = String::new(); let receiver_thread_id = resolve_agent_target(&session, &turn, &target).await?; let receiver_agent = session .services @@ -101,15 +102,11 @@ pub(crate) async fn handle_message_string_tool( let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string()) })?; - let communication = InterAgentCommunication::new( - turn.session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root), - receiver_agent_path, - Vec::new(), - prompt.clone(), - /*trigger_turn*/ true, - ); + let author = turn + .session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root); + let communication = communication_from_tool_message(author, receiver_agent_path, message); let result = session .services .agent_control diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 7d5b469a4506..b5b7d210f590 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -1,7 +1,6 @@ use super::*; use crate::agent::control::SpawnAgentForkMode; use crate::agent::control::SpawnAgentOptions; -use crate::agent::control::render_input_preview; use crate::agent::next_thread_spawn_depth; use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::apply_role_to_config; @@ -9,7 +8,6 @@ use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions; use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2; use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::AgentPath; -use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::Op; use codex_tools::ToolSpec; @@ -61,8 +59,9 @@ async fn handle_spawn_agent( .map(str::trim) .filter(|role| !role.is_empty()); + let message = args.message.clone(); let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?; - let prompt = render_input_preview(&initial_operation); + let prompt = String::new(); let session_source = turn.session_source.clone(); let child_depth = next_thread_spawn_depth(&session_source); @@ -129,17 +128,12 @@ async fn handle_spawn_agent( .iter() .all(|item| matches!(item, UserInput::Text { .. })) => { - Op::InterAgentCommunication { - communication: InterAgentCommunication::new( - turn.session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root), - recipient, - Vec::new(), - prompt.clone(), - /*trigger_turn*/ true, - ), - } + let author = turn + .session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root); + let communication = communication_from_tool_message(author, recipient, message); + Op::InterAgentCommunication { communication } } (_, initial_operation) => initial_operation, }, diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index bf5b1c01bbc9..381accb2f642 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1047,6 +1047,30 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { ); } +#[tokio::test] +async fn multi_agent_v2_message_schemas_are_encrypted() { + let plan = probe(|turn| { + set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); + }) + .await; + for tool_name in ["spawn_agent", "send_message", "followup_task"] { + let ToolSpec::Function(tool) = plan.visible_spec(tool_name) else { + panic!("expected {tool_name} function spec"); + }; + let properties = tool + .parameters + .properties + .as_ref() + .expect("tool should use object params"); + assert_eq!( + properties + .get("message") + .and_then(|schema| schema.encrypted), + Some(true) + ); + } +} + #[tokio::test] async fn tool_mode_selector_overrides_feature_flags() { let direct = probe(|turn| { diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 570e4a6e3222..bcb3cb8b9ec5 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -177,6 +177,7 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool { }) }) } + ResponseItem::AgentMessage { .. } => false, ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::CustomToolCall { .. } diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index c39a6bb39db7..97d60d67c909 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -17,6 +17,7 @@ use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_tool_search_call; @@ -1026,6 +1027,80 @@ async fn spawned_multi_agent_v2_child_inherits_parent_developer_context() -> Res Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result<()> { + let server = start_mock_server().await; + let encrypted_message = "opaque-encrypted-message"; + let spawn_args = serde_json::to_string(&json!({ + "message": encrypted_message, + "task_name": "worker", + }))?; + mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), + sse(vec![ + ev_response_created("resp-parent-1"), + ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_completed("resp-parent-1"), + ]), + ) + .await; + let child_request_log = mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, "\"type\":\"agent_message\""), + sse(vec![ + ev_response_created("resp-child-1"), + ev_completed("resp-child-1"), + ]), + ) + .await; + mount_sse_once_match( + &server, + |req: &wiremock::Request| { + body_contains(req, SPAWN_CALL_ID) && !body_contains(req, "\"type\":\"agent_message\"") + }, + sse(vec![ + ev_response_created("resp-parent-2"), + ev_assistant_message("msg-parent-2", "done"), + ev_completed("resp-parent-2"), + ]), + ) + .await; + + let mut builder = test_codex().with_model("koffing").with_config(|config| { + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + test.submit_turn(TURN_1_PROMPT).await?; + + let child_request = wait_for_requests(&child_request_log) + .await? + .pop() + .expect("child request"); + assert_eq!( + child_request.inputs_of_type("agent_message"), + vec![json!({ + "type": "agent_message", + "author": "/root", + "recipient": "/root/worker", + "content": [{ + "type": "encrypted_content", + "encrypted_content": encrypted_message, + }], + })] + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn skills_toggle_skips_instructions_for_parent_and_spawned_child() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 9c73e96b23cf..41c06c8007c2 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -242,6 +242,7 @@ fn edit_images(history: &[ResponseItem]) -> Vec { )); } ResponseItem::Message { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 0003cb04a5e7..65ca0c453068 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -1217,6 +1217,7 @@ impl SessionTelemetry { fn responses_item_type(item: &ResponseItem) -> String { match item { ResponseItem::Message { role, .. } => format!("message_from_{role}"), + ResponseItem::AgentMessage { .. } => "agent_message".into(), ResponseItem::Reasoning { .. } => "reasoning".into(), ResponseItem::LocalShellCall { .. } => "local_shell_call".into(), ResponseItem::FunctionCall { .. } => "function_call".into(), diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index d95370c0614b..517c33cac982 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -715,6 +715,12 @@ pub enum ContentItem { }, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AgentMessageInputContent { + EncryptedContent { encrypted_content: String }, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "lowercase")] pub enum ImageDetail { @@ -758,6 +764,11 @@ pub enum ResponseItem { #[ts(optional)] phase: Option, }, + AgentMessage { + author: String, + recipient: String, + content: Vec, + }, Reasoning { #[serde(default, skip_serializing)] #[ts(skip)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 91ef624fa7ab..b3538a27a1e9 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -33,6 +33,7 @@ use crate::mcp::CallToolResult; use crate::mcp::RequestId; use crate::memory_citation::MemoryCitation; use crate::models::ActivePermissionProfile; +use crate::models::AgentMessageInputContent; use crate::models::BaseInstructions; use crate::models::ContentItem; use crate::models::ImageDetail; @@ -690,6 +691,9 @@ pub struct InterAgentCommunication { #[serde(default)] pub other_recipients: Vec, pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub encrypted_content: Option, pub trigger_turn: bool, } @@ -706,6 +710,24 @@ impl InterAgentCommunication { recipient, other_recipients, content, + encrypted_content: None, + trigger_turn, + } + } + + pub fn new_encrypted( + author: AgentPath, + recipient: AgentPath, + other_recipients: Vec, + encrypted_content: String, + trigger_turn: bool, + ) -> Self { + Self { + author, + recipient, + other_recipients, + content: String::new(), + encrypted_content: Some(encrypted_content), trigger_turn, } } @@ -720,6 +742,19 @@ impl InterAgentCommunication { } } + pub fn to_model_input_item(&self) -> ResponseItem { + match &self.encrypted_content { + Some(encrypted_content) => ResponseItem::AgentMessage { + author: self.author.to_string(), + recipient: self.recipient.to_string(), + content: vec![AgentMessageInputContent::EncryptedContent { + encrypted_content: encrypted_content.clone(), + }], + }, + None => self.to_response_input_item().into(), + } + } + pub fn is_message_content(content: &[ContentItem]) -> bool { Self::from_message_content(content).is_some() } @@ -4063,6 +4098,7 @@ mod tests { recipient: AgentPath::root().join("reviewer").expect("recipient path"), other_recipients: vec![AgentPath::root().join("worker").expect("recipient path")], content: "review the diff".to_string(), + encrypted_content: None, trigger_turn: true, }; diff --git a/codex-rs/rollout-trace/src/reducer/tool/agents.rs b/codex-rs/rollout-trace/src/reducer/tool/agents.rs index a49b794d1470..37f3220c2c11 100644 --- a/codex-rs/rollout-trace/src/reducer/tool/agents.rs +++ b/codex-rs/rollout-trace/src/reducer/tool/agents.rs @@ -613,7 +613,12 @@ fn inter_agent_message_fields(item: &ConversationItem) -> Option<(String, String return None; }; let communication = serde_json::from_str::(text).ok()?; - Some((communication.recipient.to_string(), communication.content)) + Some(( + communication.recipient.to_string(), + communication + .encrypted_content + .unwrap_or(communication.content), + )) } #[cfg(test)] diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 92e5f5690b14..d1a0045cd9d9 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -30,6 +30,7 @@ pub fn persisted_rollout_items(items: &[RolloutItem]) -> Vec { pub fn should_persist_response_item(item: &ResponseItem) -> bool { match item { ResponseItem::Message { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } @@ -60,7 +61,8 @@ pub fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool { | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } => true, - ResponseItem::Reasoning { .. } + ResponseItem::AgentMessage { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::CompactionTrigger diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tools/src/json_schema.rs index 1b53ad980340..5352eaa91d2b 100644 --- a/codex-rs/tools/src/json_schema.rs +++ b/codex-rs/tools/src/json_schema.rs @@ -43,6 +43,9 @@ pub struct JsonSchema { pub schema_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// Responses-only marker for reviewed encrypted tool parameters. + #[serde(skip_serializing_if = "Option::is_none")] + pub encrypted: Option, #[serde(rename = "enum", skip_serializing_if = "Option::is_none")] pub enum_values: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -90,6 +93,11 @@ impl JsonSchema { Self::typed(JsonSchemaPrimitiveType::String, description) } + pub fn with_encrypted(mut self) -> Self { + self.encrypted = Some(true); + self + } + pub fn number(description: Option) -> Self { Self::typed(JsonSchemaPrimitiveType::Number, description) } diff --git a/codex-rs/tools/src/json_schema_tests.rs b/codex-rs/tools/src/json_schema_tests.rs index 9315d43c004b..6973d06c0052 100644 --- a/codex-rs/tools/src/json_schema_tests.rs +++ b/codex-rs/tools/src/json_schema_tests.rs @@ -24,6 +24,20 @@ fn parse_tool_input_schema_coerces_boolean_schemas() { assert_eq!(schema, JsonSchema::string(/*description*/ None)); } +#[test] +fn json_schema_serializes_encrypted_marker() { + let schema = JsonSchema::string(Some("Secret value".to_string())).with_encrypted(); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "string", + "description": "Secret value", + "encrypted": true, + }) + ); +} + #[test] fn parse_tool_input_schema_infers_object_shape_and_defaults_properties() { // Example schema shape: From 6dc28ba6e0acd391a1a006af8bd0237b61fd54a6 Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 5 Jun 2026 11:10:32 +0200 Subject: [PATCH 17/59] nit: doc (#26566) Matching CBv9 --- codex-rs/core/src/tools/handlers/multi_agents_spec.rs | 2 +- codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index 891ac5a5927a..1dcb55f436fd 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -207,7 +207,7 @@ pub fn create_followup_task_tool() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: "followup_task".to_string(), - description: "Send a follow-up task to an existing non-root target agent and trigger a turn in that target. If the target is currently mid-turn, the message is queued and will be used to start the target's next turn, after the current turn completes." + description: "Send a follow-up task to an existing non-root target agent and trigger a turn if it is idle. If the target is already running, deliver the task promptly at message boundaries while sampling, or after the pending tool call completes." .to_string(), strict: false, defer_loading: None, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index 0ae94079e902..512e318a8225 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -296,6 +296,7 @@ fn send_message_tool_requires_message_and_has_no_output_schema() { fn followup_task_tool_requires_message_and_has_no_output_schema() { let ToolSpec::Function(ResponsesApiTool { name, + description, parameters, output_schema, .. @@ -304,6 +305,10 @@ fn followup_task_tool_requires_message_and_has_no_output_schema() { panic!("followup_task should be a function tool"); }; assert_eq!(name, "followup_task"); + assert_eq!( + description, + "Send a follow-up task to an existing non-root target agent and trigger a turn if it is idle. If the target is already running, deliver the task promptly at message boundaries while sampling, or after the pending tool call completes." + ); assert_eq!( parameters.schema_type, Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)) From 55aa071b17c825bdb66fac99cde2e7a7acfbdee7 Mon Sep 17 00:00:00 2001 From: carlc-oai Date: Fri, 5 Jun 2026 02:41:06 -0700 Subject: [PATCH 18/59] [codex] Forward turn moderation metadata through app-server (#25710) ## Why First-party backends can supply turn-scoped moderation metadata that app-server clients need for client-side presentation. Exposing this as an experimental typed notification lets opted-in clients consume it without interpreting raw Responses API events. ## What changed - forward `response.metadata.openai_chatgpt_moderation_metadata` from Responses API SSE and WebSocket streams as turn-scoped moderation metadata - emit the experimental app-server v2 `turn/moderationMetadata` notification with `{ threadId, turnId, metadata }` - add app-server integration coverage for the typed moderation metadata notification ## Testing - `just test -p codex-core build_ws_client_metadata_includes_window_lineage_and_turn_metadata` - `just test -p codex-core` (fails locally: 46 failures and 1 timeout, primarily missing `test_stdio_server` and shell snapshot timeouts) - `just test -p codex-app-server-protocol` - `just test -p codex-app-server turn_moderation_metadata_emits_typed_notification_v2` - `just test -p codex-app-server` (fails locally: 792 passed, 10 failed, and 5 timed out; failures are in existing environment-sensitive tests, primarily because nested macOS `sandbox-exec` is not permitted) - `just write-app-server-schema --experimental --schema-root /tmp/codex-app-server-schema-experimental` --- .../schema/json/ServerNotification.json | 37 +++++++++ .../codex_app_server_protocol.schemas.json | 39 +++++++++ .../codex_app_server_protocol.v2.schemas.json | 39 +++++++++ .../TurnModerationMetadataNotification.json | 19 +++++ .../schema/typescript/ServerNotification.ts | 3 +- .../v2/TurnModerationMetadataNotification.ts | 6 ++ .../schema/typescript/v2/index.ts | 1 + .../src/protocol/common.rs | 17 ++++ .../src/protocol/v2/model.rs | 10 +++ codex-rs/app-server/README.md | 1 + .../app-server/src/bespoke_event_handling.rs | 11 +++ codex-rs/app-server/src/outgoing_message.rs | 26 ++++++ .../tests/suite/v2/safety_check_downgrade.rs | 82 +++++++++++++++++++ codex-rs/codex-api/src/common.rs | 3 + .../src/endpoint/responses_websocket.rs | 11 +++ codex-rs/codex-api/src/sse/responses.rs | 57 +++++++++++++ codex-rs/core/src/session/mod.rs | 10 +++ codex-rs/core/src/session/turn.rs | 5 ++ codex-rs/core/src/turn_timing.rs | 1 + codex-rs/mcp-server/src/codex_tool_runner.rs | 3 +- codex-rs/otel/src/events/session_telemetry.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 ++ codex-rs/rollout-trace/src/protocol_event.rs | 2 + codex-rs/rollout/src/policy.rs | 1 + .../tui/src/app/app_server_event_targets.rs | 3 + codex-rs/tui/src/chatwidget/protocol.rs | 1 + 26 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/TurnModerationMetadataNotification.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/TurnModerationMetadataNotification.ts diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 16656e3a5544..010ec33e0862 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -4909,6 +4909,23 @@ } ] }, + "TurnModerationMetadataNotification": { + "properties": { + "metadata": true, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "metadata", + "threadId", + "turnId" + ], + "type": "object" + }, "TurnPlanStep": { "properties": { "status": { @@ -6248,6 +6265,26 @@ "title": "Model/verificationNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "turn/moderationMetadata" + ], + "title": "Turn/moderationMetadataNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnModerationMetadataNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/moderationMetadataNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index bbcbb20be44f..10bde055974a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -4904,6 +4904,26 @@ "title": "Model/verificationNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "turn/moderationMetadata" + ], + "title": "Turn/moderationMetadataNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/TurnModerationMetadataNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/moderationMetadataNotification", + "type": "object" + }, { "properties": { "method": { @@ -18601,6 +18621,25 @@ } ] }, + "TurnModerationMetadataNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "metadata": true, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "metadata", + "threadId", + "turnId" + ], + "title": "TurnModerationMetadataNotification", + "type": "object" + }, "TurnPlanStep": { "properties": { "status": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 65d999b334b5..e4c91e75869e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12420,6 +12420,26 @@ "title": "Model/verificationNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "turn/moderationMetadata" + ], + "title": "Turn/moderationMetadataNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnModerationMetadataNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/moderationMetadataNotification", + "type": "object" + }, { "properties": { "method": { @@ -16418,6 +16438,25 @@ } ] }, + "TurnModerationMetadataNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "metadata": true, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "metadata", + "threadId", + "turnId" + ], + "title": "TurnModerationMetadataNotification", + "type": "object" + }, "TurnPlanStep": { "properties": { "status": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnModerationMetadataNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnModerationMetadataNotification.json new file mode 100644 index 000000000000..273bd4106196 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnModerationMetadataNotification.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "metadata": true, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "metadata", + "threadId", + "turnId" + ], + "title": "TurnModerationMetadataNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 3ed710efc0a3..2520c71c3ca6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -61,6 +61,7 @@ import type { ThreadTokenUsageUpdatedNotification } from "./v2/ThreadTokenUsageU import type { ThreadUnarchivedNotification } from "./v2/ThreadUnarchivedNotification"; import type { TurnCompletedNotification } from "./v2/TurnCompletedNotification"; import type { TurnDiffUpdatedNotification } from "./v2/TurnDiffUpdatedNotification"; +import type { TurnModerationMetadataNotification } from "./v2/TurnModerationMetadataNotification"; import type { TurnPlanUpdatedNotification } from "./v2/TurnPlanUpdatedNotification"; import type { TurnStartedNotification } from "./v2/TurnStartedNotification"; import type { WarningNotification } from "./v2/WarningNotification"; @@ -70,4 +71,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "turn/moderationMetadata", "params": TurnModerationMetadataNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnModerationMetadataNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnModerationMetadataNotification.ts new file mode 100644 index 000000000000..1d46d1b4b833 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnModerationMetadataNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "../serde_json/JsonValue"; + +export type TurnModerationMetadataNotification = { threadId: string, turnId: string, metadata: JsonValue, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index b4c65946d536..bb19c92bdfd3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -447,6 +447,7 @@ export type { TurnError } from "./TurnError"; export type { TurnInterruptParams } from "./TurnInterruptParams"; export type { TurnInterruptResponse } from "./TurnInterruptResponse"; export type { TurnItemsView } from "./TurnItemsView"; +export type { TurnModerationMetadataNotification } from "./TurnModerationMetadataNotification"; export type { TurnPlanStep } from "./TurnPlanStep"; export type { TurnPlanStepStatus } from "./TurnPlanStepStatus"; export type { TurnPlanUpdatedNotification } from "./TurnPlanUpdatedNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 5a60d8d002fe..47410551cf4c 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1549,6 +1549,8 @@ server_notification_definitions! { ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification), ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification), ModelVerification => "model/verification" (v2::ModelVerificationNotification), + #[experimental("turn/moderationMetadata")] + TurnModerationMetadata => "turn/moderationMetadata" (v2::TurnModerationMetadataNotification), Warning => "warning" (v2::WarningNotification), GuardianWarning => "guardianWarning" (v2::GuardianWarningNotification), DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification), @@ -3222,6 +3224,21 @@ mod tests { ); } + #[test] + fn turn_moderation_metadata_notification_is_marked_experimental() { + let notification = + ServerNotification::TurnModerationMetadata(v2::TurnModerationMetadataNotification { + thread_id: "thr_123".to_string(), + turn_id: "turn_123".to_string(), + metadata: json!({"presentation": "inline"}), + }); + + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¬ification), + Some("turn/moderationMetadata") + ); + } + #[test] fn thread_realtime_started_notification_is_marked_experimental() { let notification = diff --git a/codex-rs/app-server-protocol/src/protocol/v2/model.rs b/codex-rs/app-server-protocol/src/protocol/v2/model.rs index 7f97d791a603..d8fa24958252 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/model.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/model.rs @@ -8,6 +8,7 @@ use codex_protocol::protocol::ModelVerification as CoreModelVerification; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use serde_json::Value as JsonValue; use ts_rs::TS; v2_enum_from_core!( @@ -152,3 +153,12 @@ pub struct ModelVerificationNotification { pub turn_id: String, pub verifications: Vec, } + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnModerationMetadataNotification { + pub thread_id: String, + pub turn_id: String, + pub metadata: JsonValue, +} diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 6d705afa985f..d112eeff9d42 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1244,6 +1244,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn - `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. - `model/rerouted` — `{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks). - `model/verification` — `{ threadId, turnId, verifications }` when the backend flags additional account verification, such as `trustedAccessForCyber`. +- `turn/moderationMetadata` — experimental; `{ threadId, turnId, metadata }` when a first-party backend supplies turn-scoped moderation metadata for client-side presentation. Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f4a5f3b45f01..0cec31b6f253 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -75,6 +75,7 @@ use codex_app_server_protocol::TurnDiffUpdatedNotification; use codex_app_server_protocol::TurnError; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnItemsView; +use codex_app_server_protocol::TurnModerationMetadataNotification; use codex_app_server_protocol::TurnPlanStep; use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStartedNotification; @@ -337,6 +338,16 @@ pub(crate) async fn apply_bespoke_event_handling( .send_server_notification(ServerNotification::ModelVerification(notification)) .await; } + EventMsg::TurnModerationMetadata(event) => { + let notification = TurnModerationMetadataNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + metadata: event.metadata, + }; + outgoing + .send_server_notification(ServerNotification::TurnModerationMetadata(notification)) + .await; + } EventMsg::RealtimeConversationStarted(event) => { let notification = ThreadRealtimeStartedNotification { thread_id: conversation_id.to_string(), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index ac7e87741a5d..2e67e233981a 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -725,6 +725,7 @@ mod tests { use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::ServerResponse; use codex_app_server_protocol::ToolRequestUserInputParams; + use codex_app_server_protocol::TurnModerationMetadataNotification; use codex_protocol::ThreadId; use pretty_assertions::assert_eq; use serde_json::json; @@ -951,6 +952,31 @@ mod tests { ); } + #[test] + fn verify_turn_moderation_metadata_notification_serialization() { + let notification = + ServerNotification::TurnModerationMetadata(TurnModerationMetadataNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + metadata: json!({"presentation": "inline"}), + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "turn/moderationMetadata", + "params": { + "threadId": "thread-1", + "turnId": "turn-1", + "metadata": {"presentation": "inline"}, + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + #[test] fn server_request_response_from_result_decodes_typed_response() { let request = ServerRequest::CommandExecutionRequestApproval { diff --git a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs index c5b70b2334bc..ea3cd8dada08 100644 --- a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs +++ b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs @@ -15,6 +15,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnModerationMetadataNotification; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; @@ -309,6 +310,87 @@ async fn model_verification_emits_typed_notification_and_warning_v2() -> Result< Ok(()) } +#[tokio::test] +async fn turn_moderation_metadata_emits_typed_notification_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + serde_json::json!({ + "type": "response.metadata", + "sequence_number": 1, + "response_id": "resp-1", + "metadata": { + "openai_chatgpt_moderation_metadata": { + "presentation": "inline" + } + } + }), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response = responses::sse_response(body); + let _response_mock = responses::mount_response_once(&server, response).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some(REQUESTED_MODEL.to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![UserInput::Text { + text: "trigger moderation metadata".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let turn_start: TurnStartResponse = to_response(turn_resp)?; + + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/moderationMetadata"), + ) + .await??; + let metadata: TurnModerationMetadataNotification = + serde_json::from_value(notification.params.ok_or_else(|| { + anyhow::anyhow!("turn/moderationMetadata notifications must include params") + })?)?; + assert_eq!( + metadata, + TurnModerationMetadataNotification { + thread_id: thread.id, + turn_id: turn_start.turn.id, + metadata: serde_json::json!({"presentation": "inline"}), + } + ); + + Ok(()) +} + async fn collect_turn_notifications_and_validate_no_warning_item( mcp: &mut TestAppServer, ) -> Result { diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 12f8cc55da09..17753e799ad8 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -6,6 +6,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::ModelVerification; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TurnModerationMetadataEvent; use codex_protocol::protocol::W3cTraceContext; use futures::Stream; use serde::Deserialize; @@ -78,6 +79,8 @@ pub enum ResponseEvent { ServerModel(String), /// Emitted when the server recommends additional account verification. ModelVerifications(Vec), + /// Emitted when the server includes moderation metadata for first-party turn presentation. + TurnModerationMetadata(TurnModerationMetadataEvent), /// Emitted when `X-Reasoning-Included: true` is present on the response, /// meaning the server already accounted for past reasoning tokens and the /// client should not re-estimate them. diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index a7366a98717d..44cb6a544beb 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -683,6 +683,7 @@ async fn run_websocket_response_stream( } }; let model_verifications = event.model_verifications(); + let turn_moderation_metadata = event.turn_moderation_metadata(); if event.kind() == "codex.rate_limits" { if let Some(snapshot) = parse_rate_limit_event(&text) { let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await; @@ -707,6 +708,16 @@ async fn run_websocket_response_stream( "response event consumer dropped".to_string(), )); } + if let Some(metadata) = turn_moderation_metadata + && tx_event + .send(Ok(ResponseEvent::TurnModerationMetadata(metadata))) + .await + .is_err() + { + return Err(ApiError::Stream( + "response event consumer dropped".to_string(), + )); + } match process_responses_event(event) { Ok(Some(event)) => { let is_completed = matches!(event, ResponseEvent::Completed { .. }); diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index c9f35e5a4ec8..ab1be640df9b 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -8,6 +8,7 @@ use codex_client::StreamResponse; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ModelVerification; use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TurnModerationMetadataEvent; use eventsource_stream::Eventsource; use futures::StreamExt; use serde::Deserialize; @@ -193,6 +194,18 @@ impl ResponsesStreamEvent { .and_then(|metadata| metadata.get("openai_verification_recommendation")) .and_then(model_verifications_from_json_value) } + + pub(crate) fn turn_moderation_metadata(&self) -> Option { + if self.kind() != "response.metadata" { + return None; + } + + self.metadata + .as_ref() + .and_then(|metadata| metadata.get("openai_chatgpt_moderation_metadata")) + .cloned() + .map(|metadata| TurnModerationMetadataEvent { metadata }) + } } fn header_openai_model_value_from_json(value: &Value) -> Option { @@ -444,6 +457,7 @@ pub async fn process_sse( } }; let model_verifications = event.model_verifications(); + let turn_moderation_metadata = event.turn_moderation_metadata(); if let Some(model) = event.response_model() && last_server_model.as_deref() != Some(model.as_str()) @@ -465,6 +479,14 @@ pub async fn process_sse( { return; } + if let Some(metadata) = turn_moderation_metadata + && tx_event + .send(Ok(ResponseEvent::TurnModerationMetadata(metadata))) + .await + .is_err() + { + return; + } match process_responses_event(event) { Ok(Some(event)) => { @@ -1215,6 +1237,41 @@ mod tests { ); } + #[tokio::test] + async fn process_sse_emits_turn_moderation_metadata_field() { + let events = run_sse(vec![ + json!({ + "type": "response.metadata", + "metadata": { + "openai_chatgpt_moderation_metadata": { + "presentation": "inline" + } + } + }), + json!({ + "type": "response.completed", + "response": { + "id": "resp-1" + } + }), + ]) + .await; + + assert_matches!( + &events[0], + ResponseEvent::TurnModerationMetadata(result) + if result.metadata == json!({"presentation": "inline"}) + ); + assert_matches!( + &events[1], + ResponseEvent::Completed { + response_id, + token_usage: None, + end_turn: None, + } if response_id == "resp-1" + ); + } + #[test] fn responses_stream_event_response_model_reads_top_level_headers() { let ev: ResponsesStreamEvent = serde_json::from_value(json!({ diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9aeca1991b81..1b40bd765697 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -359,6 +359,7 @@ use codex_protocol::protocol::ThreadMemoryMode; use codex_protocol::protocol::TokenCountEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; +use codex_protocol::protocol::TurnModerationMetadataEvent; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; use codex_tools::ToolEnvironmentMode; @@ -2637,6 +2638,15 @@ impl Session { .await; } + pub(crate) async fn emit_turn_moderation_metadata( + self: &Arc, + turn_context: &Arc, + metadata: TurnModerationMetadataEvent, + ) { + self.send_event(turn_context, EventMsg::TurnModerationMetadata(metadata)) + .await; + } + pub(crate) async fn replace_history( &self, items: Vec, diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 996e59187ebf..63195d9fa768 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1392,6 +1392,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::RealtimeConversationClosed(_) | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) + | EventMsg::TurnModerationMetadata(_) | EventMsg::ContextCompacted(_) | EventMsg::ThreadRolledBack(_) | EventMsg::TurnStarted(_) @@ -2057,6 +2058,10 @@ async fn try_run_sampling_request( .await; } } + ResponseEvent::TurnModerationMetadata(metadata) => { + sess.emit_turn_moderation_metadata(&turn_context, metadata) + .await; + } ResponseEvent::ServerReasoningIncluded(included) => { sess.set_server_reasoning_included(included).await; } diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index bcb3cb8b9ec5..445fe16214ad 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -147,6 +147,7 @@ fn response_event_records_turn_ttft(event: &ResponseEvent) -> bool { ResponseEvent::Created | ResponseEvent::ServerModel(_) | ResponseEvent::ModelVerifications(_) + | ResponseEvent::TurnModerationMetadata(_) | ResponseEvent::ServerReasoningIncluded(_) | ResponseEvent::ToolCallInputDelta { .. } | ResponseEvent::Completed { .. } diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 05b995e72157..fd56805d4ee0 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -266,7 +266,8 @@ async fn run_codex_tool_session_inner( } EventMsg::Warning(_) | EventMsg::GuardianWarning(_) - | EventMsg::ModelVerification(_) => { + | EventMsg::ModelVerification(_) + | EventMsg::TurnModerationMetadata(_) => { continue; } EventMsg::GuardianAssessment(_) => { diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 65ca0c453068..00c13046dd81 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -1208,6 +1208,7 @@ impl SessionTelemetry { } ResponseEvent::ServerModel(_) => "server_model".into(), ResponseEvent::ModelVerifications(_) => "model_verifications".into(), + ResponseEvent::TurnModerationMetadata(_) => "turn_moderation_metadata".into(), ResponseEvent::ServerReasoningIncluded(_) => "server_reasoning_included".into(), ResponseEvent::RateLimits(_) => "rate_limits".into(), ResponseEvent::ModelsEtag(_) => "models_etag".into(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b3538a27a1e9..ed278d1843de 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1221,6 +1221,9 @@ pub enum EventMsg { /// Backend recommends additional account verification for this turn. ModelVerification(ModelVerificationEvent), + /// Backend moderation metadata intended for first-party turn presentation. + TurnModerationMetadata(TurnModerationMetadataEvent), + /// Conversation history was compacted (either automatically or manually). ContextCompacted(ContextCompactedEvent), @@ -1885,6 +1888,11 @@ pub struct ModelVerificationEvent { pub verifications: Vec, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +pub struct TurnModerationMetadataEvent { + pub metadata: Value, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ContextCompactedEvent; diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index a862af116c1b..a3e1f184ecdf 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -224,6 +224,7 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Option<&'static s | EventMsg::RealtimeConversationSdp(_) | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) + | EventMsg::TurnModerationMetadata(_) | EventMsg::ContextCompacted(_) | EventMsg::ThreadSettingsApplied(_) | EventMsg::TokenCount(_) diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index d1a0045cd9d9..17da9e62038e 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -117,6 +117,7 @@ pub fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::RealtimeConversationClosed(_) | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) + | EventMsg::TurnModerationMetadata(_) | EventMsg::AgentReasoningSectionBreak(_) | EventMsg::RawResponseItem(_) | EventMsg::SessionConfigured(_) diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index 0b2143b429b9..ea9fce90fb25 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -118,6 +118,9 @@ pub(super) fn server_notification_thread_target( ServerNotification::ModelVerification(notification) => { Some(notification.thread_id.as_str()) } + ServerNotification::TurnModerationMetadata(notification) => { + Some(notification.thread_id.as_str()) + } ServerNotification::ThreadRealtimeStarted(notification) => { Some(notification.thread_id.as_str()) } diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index 6d56330367f8..1ddbe40d54b2 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -233,6 +233,7 @@ impl ChatWidget { | ServerNotification::RemoteControlStatusChanged(_) | ServerNotification::ExternalAgentConfigImportCompleted(_) | ServerNotification::FsChanged(_) + | ServerNotification::TurnModerationMetadata(_) | ServerNotification::FuzzyFileSearchSessionUpdated(_) | ServerNotification::FuzzyFileSearchSessionCompleted(_) | ServerNotification::ThreadRealtimeTranscriptDelta(_) From 66232220e21712604b0066f496e966c9a2c1bda8 Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 5 Jun 2026 14:52:51 +0200 Subject: [PATCH 19/59] [codex] Keep v1 spawn metadata visible (#26599) ## Summary - keep the legacy v1 `spawn_agent` role and model selectors visible - add regression coverage for the default v1 tool plan ## Why `hide_spawn_agent_metadata` is a multi-agent v2 setting, but the v1 planning branch also consumed it. After the default changed to `true`, v1 stopped advertising `agent_type`, `model`, `reasoning_effort`, and `service_tier`, preventing configured agents from being selected. This keeps the hidden-metadata default for v2 while opting v1 out of that behavior. Fixes #26363. ## Validation Not run locally, per request; CI will validate the change. --- codex-rs/core/src/tools/spec_plan.rs | 5 +---- codex-rs/core/src/tools/spec_plan_tests.rs | 24 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index eae0644b83ff..47d2ac6726c5 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -762,10 +762,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu SpawnAgentHandler::new(SpawnAgentToolOptions { available_models: turn_context.available_models.clone(), agent_type_description, - hide_agent_type_model_reasoning: turn_context - .config - .multi_agent_v2 - .hide_spawn_agent_metadata, + hide_agent_type_model_reasoning: false, include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled, usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(), max_concurrent_threads_per_session: max_concurrent_threads_per_session( diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 381accb2f642..17dfdc7a74f0 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1003,6 +1003,30 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { "wait_agent".to_string(), ] ); + let ToolSpec::Namespace(namespace) = v1.visible_spec(MULTI_AGENT_V1_NAMESPACE) else { + panic!("expected v1 multi-agent namespace"); + }; + let Some(ResponsesApiNamespaceTool::Function(spawn_agent)) = + namespace.tools.iter().find(|tool| { + matches!( + tool, + ResponsesApiNamespaceTool::Function(tool) if tool.name == "spawn_agent" + ) + }) + else { + panic!("expected v1 spawn_agent function"); + }; + let properties = spawn_agent + .parameters + .properties + .as_ref() + .expect("spawn_agent should use object params"); + for property in ["agent_type", "model", "reasoning_effort", "service_tier"] { + assert!( + properties.contains_key(property), + "expected v1 spawn_agent to expose `{property}`" + ); + } let v2 = probe(|turn| { set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); From 0b1512c2c83935af4eaa096ac7e434992f15b066 Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 5 Jun 2026 16:24:22 +0200 Subject: [PATCH 20/59] refactor: split agent control modules (#26610) ## Summary Mechanically splits `AgentControl` into focused modules so later agent runtime changes are easier to review. The shared lookup, messaging, and completion logic remains in `control.rs`, while spawn-specific code and V1 legacy close/resume behavior move into dedicated files. ## Changes - Extract spawn-agent code into `agent/control/spawn.rs`. - Extract V1-only legacy close/resume behavior into `agent/control/legacy.rs`. - Keep shared control-plane behavior in `agent/control.rs`. - Preserve existing behavior; this PR is intended to be mechanical. ## Stack 1. This PR - Mechanical `AgentControl` split: extracts spawn and V1 legacy code without behavior changes. 2. #26614 - Execution slot accounting: separates logical agents from active execution slots. 3. #26611 - Residency and reload runtime: adds resident-agent LRU, eviction/reload, durable lookup, and V2 delivery through reload. 4. #26612 - V2 tool semantics: narrows `close_agent` to interrupt-only and updates V2 tool coverage. --- codex-rs/core/src/agent/control.rs | 687 +--------------------- codex-rs/core/src/agent/control/legacy.rs | 83 +++ codex-rs/core/src/agent/control/spawn.rs | 606 +++++++++++++++++++ 3 files changed, 693 insertions(+), 683 deletions(-) create mode 100644 codex-rs/core/src/agent/control/legacy.rs create mode 100644 codex-rs/core/src/agent/control/spawn.rs diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 6317dff3b59e..6c6650a55933 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -42,9 +42,11 @@ use std::sync::Weak; use tokio::sync::watch; use tracing::warn; -const AGENT_NAMES: &str = include_str!("agent_names.txt"); const ROOT_LAST_TASK_MESSAGE: &str = "Main thread"; +mod legacy; +mod spawn; + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum SpawnAgentForkMode { FullHistory, @@ -59,11 +61,6 @@ pub(crate) struct SpawnAgentOptions { pub(crate) environments: Option>, } -struct SpawnAgentThreadInheritance { - shell_snapshot: Option>, - exec_policy: Option>, -} - #[derive(Clone, Debug)] pub(crate) struct LiveAgent { pub(crate) thread_id: ThreadId, @@ -78,77 +75,6 @@ pub(crate) struct ListedAgent { pub(crate) last_task_message: Option, } -fn default_agent_nickname_list() -> Vec<&'static str> { - AGENT_NAMES - .lines() - .map(str::trim) - .filter(|name| !name.is_empty()) - .collect() -} - -fn agent_nickname_candidates(config: &Config, role_name: Option<&str>) -> Vec { - let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME); - if let Some(candidates) = - resolve_role_config(config, role_name).and_then(|role| role.nickname_candidates.clone()) - { - return candidates; - } - - default_agent_nickname_list() - .into_iter() - .map(ToOwned::to_owned) - .collect() -} - -fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: bool) -> bool { - match item { - RolloutItem::ResponseItem(ResponseItem::Message { role, phase, .. }) => match role.as_str() - { - "system" | "developer" | "user" => true, - "assistant" => *phase == Some(MessagePhase::FinalAnswer), - _ => false, - }, - RolloutItem::ResponseItem( - ResponseItem::AgentMessage { .. } - | ResponseItem::Reasoning { .. } - | ResponseItem::LocalShellCall { .. } - | ResponseItem::FunctionCall { .. } - | ResponseItem::ToolSearchCall { .. } - | ResponseItem::FunctionCallOutput { .. } - | ResponseItem::CustomToolCall { .. } - | ResponseItem::CustomToolCallOutput { .. } - | ResponseItem::ToolSearchOutput { .. } - | ResponseItem::WebSearchCall { .. } - | ResponseItem::ImageGenerationCall { .. } - | ResponseItem::Compaction { .. } - | ResponseItem::CompactionTrigger - | ResponseItem::ContextCompaction { .. } - | ResponseItem::Other, - ) => false, - // Full-history forks preserve the cached prompt prefix and can keep diffing - // from the parent's durable baseline. Truncated forks drop part of that prompt, - // so they must rebuild context on their first child turn. - RolloutItem::TurnContext(_) => preserve_reference_context_item, - RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => true, - } -} - -fn is_multi_agent_v2_usage_hint_message(item: &ResponseItem, usage_hint_texts: &[String]) -> bool { - let ResponseItem::Message { role, content, .. } = item else { - return false; - }; - if role != "developer" { - return false; - } - let [ContentItem::InputText { text }] = content.as_slice() else { - return false; - }; - - usage_hint_texts - .iter() - .any(|usage_hint_text| usage_hint_text == text) -} - /// Control-plane handle for multi-agent operations. /// `AgentControl` is held by each session (via `SessionServices`). It provides capability to /// spawn new agents and the inter-agent communication layer. @@ -185,531 +111,6 @@ impl AgentControl { self.session_id } - /// Spawn a new agent thread and submit the initial prompt. - #[cfg(test)] - pub(crate) async fn spawn_agent( - &self, - config: Config, - initial_operation: Op, - session_source: Option, - ) -> CodexResult { - let spawned_agent = Box::pin(self.spawn_agent_internal( - config, - initial_operation, - session_source, - SpawnAgentOptions::default(), - )) - .await?; - Ok(spawned_agent.thread_id) - } - - /// Spawn an agent thread with some metadata. - pub(crate) async fn spawn_agent_with_metadata( - &self, - config: Config, - initial_operation: Op, - session_source: Option, - options: SpawnAgentOptions, // TODO(jif) drop with new fork. - ) -> CodexResult { - Box::pin(self.spawn_agent_internal(config, initial_operation, session_source, options)) - .await - } - - async fn spawn_agent_internal( - &self, - config: Config, - initial_operation: Op, - session_source: Option, - options: SpawnAgentOptions, - ) -> CodexResult { - let state = self.upgrade()?; - let multi_agent_version = state - .effective_multi_agent_version_for_spawn( - &InitialHistory::New, - session_source.as_ref(), - options.parent_thread_id, - /*forked_from_thread_id*/ None, - &config, - ) - .await; - let agent_max_threads = config.effective_agent_max_threads(multi_agent_version); - let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?; - let inheritance = SpawnAgentThreadInheritance { - shell_snapshot: self - .inherited_shell_snapshot_for_source(&state, session_source.as_ref()) - .await, - exec_policy: self - .inherited_exec_policy_for_source(&state, session_source.as_ref(), &config) - .await, - }; - let (session_source, mut agent_metadata) = match session_source { - Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth, - agent_path, - agent_role, - .. - })) => { - let (session_source, agent_metadata) = self.prepare_thread_spawn( - &mut reservation, - &config, - parent_thread_id, - depth, - agent_path, - agent_role, - /*preferred_agent_nickname*/ None, - )?; - (Some(session_source), agent_metadata) - } - other => (other, AgentMetadata::default()), - }; - let notification_source = session_source.clone(); - - // The same `AgentControl` is sent to spawn the thread. - let new_thread = match (session_source, options.fork_mode.as_ref(), inheritance) { - (Some(session_source), Some(_), inheritance) => { - Box::pin(self.spawn_forked_thread( - &state, - config, - session_source, - &options, - inheritance, - multi_agent_version, - )) - .await? - } - (Some(session_source), None, inheritance) => { - Box::pin(state.spawn_new_thread_with_source( - config.clone(), - self.clone(), - session_source, - options.parent_thread_id, - /*forked_from_thread_id*/ None, - /*thread_source*/ Some(ThreadSource::Subagent), - /*metrics_service_name*/ None, - inheritance.shell_snapshot, - inheritance.exec_policy, - options.environments.clone(), - )) - .await? - } - (None, _, _) => Box::pin(state.spawn_new_thread(config.clone(), self.clone())).await?, - }; - agent_metadata.agent_id = Some(new_thread.thread_id); - reservation.commit(agent_metadata.clone()); - - if let Some(SessionSource::SubAgent( - subagent_source @ SubAgentSource::ThreadSpawn { - parent_thread_id, .. - }, - )) = notification_source.as_ref() - { - let client_metadata = match state.get_thread(*parent_thread_id).await { - Ok(parent_thread) => { - parent_thread - .codex - .session - .app_server_client_metadata() - .await - } - Err(error) => { - tracing::warn!( - error = %error, - parent_thread_id = %parent_thread_id, - "skipping subagent thread analytics: failed to load parent thread metadata" - ); - crate::session::session::AppServerClientMetadata { - client_name: None, - client_version: None, - } - } - }; - let thread_config = new_thread.thread.codex.thread_config_snapshot().await; - let parent_thread_id = thread_config.parent_thread_id; - emit_subagent_session_started( - &new_thread - .thread - .codex - .session - .services - .analytics_events_client, - client_metadata, - new_thread.thread.codex.session.session_id(), - new_thread.thread_id, - parent_thread_id, - thread_config, - subagent_source.clone(), - ); - } - - // Notify a new thread has been created. This notification will be processed by clients - // to subscribe or drain this newly created thread. - // TODO(jif) add helper for drain - state.notify_thread_created(new_thread.thread_id); - - self.persist_thread_spawn_edge_for_source( - new_thread.thread.as_ref(), - new_thread.thread_id, - notification_source.as_ref(), - ) - .await; - - self.send_input(new_thread.thread_id, initial_operation) - .await?; - if multi_agent_version != MultiAgentVersion::V2 { - let child_reference = agent_metadata - .agent_path - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| new_thread.thread_id.to_string()); - self.maybe_start_completion_watcher( - new_thread.thread_id, - notification_source, - child_reference, - agent_metadata.agent_path.clone(), - ); - } - - Ok(LiveAgent { - thread_id: new_thread.thread_id, - metadata: agent_metadata, - status: self.get_status(new_thread.thread_id).await, - }) - } - - async fn spawn_forked_thread( - &self, - state: &Arc, - config: Config, - session_source: SessionSource, - options: &SpawnAgentOptions, - inheritance: SpawnAgentThreadInheritance, - multi_agent_version: MultiAgentVersion, - ) -> CodexResult { - let SpawnAgentThreadInheritance { - shell_snapshot: inherited_shell_snapshot, - exec_policy: inherited_exec_policy, - } = inheritance; - if options.fork_parent_spawn_call_id.is_none() { - return Err(CodexErr::Fatal( - "spawn_agent fork requires a parent spawn call id".to_string(), - )); - } - let Some(fork_mode) = options.fork_mode.as_ref() else { - return Err(CodexErr::Fatal( - "spawn_agent fork requires a fork mode".to_string(), - )); - }; - let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, .. - }) = &session_source - else { - return Err(CodexErr::Fatal( - "spawn_agent fork requires a thread-spawn session source".to_string(), - )); - }; - - let parent_thread_id = *parent_thread_id; - let parent_thread = state.get_thread(parent_thread_id).await.ok(); - if let Some(parent_thread) = parent_thread.as_ref() { - // `record_conversation_items` only queues persistence writes asynchronously. - // Flush before snapshotting store history for a fork. - parent_thread.ensure_rollout_materialized().await; - parent_thread.flush_rollout().await?; - } - - let parent_history = state - .read_stored_thread(ReadThreadParams { - thread_id: parent_thread_id, - include_archived: true, - include_history: true, - }) - .await? - .history - .ok_or_else(|| { - CodexErr::Fatal(format!( - "parent thread history unavailable for fork: {parent_thread_id}" - )) - })?; - - let mut forked_rollout_items = parent_history.items; - if let SpawnAgentForkMode::LastNTurns(last_n_turns) = fork_mode { - forked_rollout_items = - truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns); - } - let multi_agent_v2_usage_hint_texts_to_filter: Vec = - if let Some(parent_thread) = parent_thread.as_ref() { - if multi_agent_version == MultiAgentVersion::V2 { - let parent_config = parent_thread.codex.session.get_config().await; - [ - parent_config - .multi_agent_v2 - .root_agent_usage_hint_text - .clone(), - parent_config - .multi_agent_v2 - .subagent_usage_hint_text - .clone(), - ] - .into_iter() - .flatten() - .collect() - } else { - Vec::new() - } - } else if multi_agent_version == MultiAgentVersion::V2 { - [ - config.multi_agent_v2.root_agent_usage_hint_text.clone(), - config.multi_agent_v2.subagent_usage_hint_text.clone(), - ] - .into_iter() - .flatten() - .collect() - } else { - Vec::new() - }; - let preserve_reference_context_item = matches!(fork_mode, SpawnAgentForkMode::FullHistory); - forked_rollout_items.retain(|item| { - keep_forked_rollout_item(item, preserve_reference_context_item) - && !matches!( - item, - RolloutItem::ResponseItem(response_item) - if is_multi_agent_v2_usage_hint_message( - response_item, - &multi_agent_v2_usage_hint_texts_to_filter, - ) - ) - }); - for item in &mut forked_rollout_items { - if let RolloutItem::Compacted(compacted) = item - && let Some(replacement_history) = compacted.replacement_history.as_mut() - { - replacement_history.retain(|response_item| { - !is_multi_agent_v2_usage_hint_message( - response_item, - &multi_agent_v2_usage_hint_texts_to_filter, - ) - }); - } - } - if preserve_reference_context_item - && multi_agent_version == MultiAgentVersion::V2 - && config.multi_agent_v2.usage_hint_enabled - && let Some(subagent_usage_hint_text) = - config.multi_agent_v2.subagent_usage_hint_text.clone() - && let Some(subagent_usage_hint_message) = - crate::context_manager::updates::build_developer_update_item(vec![ - subagent_usage_hint_text, - ]) - { - forked_rollout_items.push(RolloutItem::ResponseItem(subagent_usage_hint_message)); - } - - state - .fork_thread_with_source( - config.clone(), - InitialHistory::Forked(forked_rollout_items), - self.clone(), - session_source, - /*thread_source*/ Some(ThreadSource::Subagent), - /*parent_thread_id*/ Some(parent_thread_id), - /*forked_from_thread_id*/ Some(parent_thread_id), - inherited_shell_snapshot, - inherited_exec_policy, - options.environments.clone(), - ) - .await - } - - /// Resume an existing agent thread from a recorded rollout file. - pub(crate) async fn resume_agent_from_rollout( - &self, - config: Config, - thread_id: ThreadId, - session_source: SessionSource, - ) -> CodexResult { - let root_depth = thread_spawn_depth(&session_source).unwrap_or(0); - let resumed_thread_id = Box::pin(self.resume_single_agent_from_rollout( - config.clone(), - thread_id, - session_source, - )) - .await?; - let state = self.upgrade()?; - let Ok(resumed_thread) = state.get_thread(resumed_thread_id).await else { - return Ok(resumed_thread_id); - }; - let Some(state_db_ctx) = resumed_thread.state_db() else { - return Ok(resumed_thread_id); - }; - - let mut resume_queue = VecDeque::from([(thread_id, root_depth)]); - while let Some((parent_thread_id, parent_depth)) = resume_queue.pop_front() { - let child_ids = match state_db_ctx - .list_thread_spawn_children_with_status( - parent_thread_id, - DirectionalThreadSpawnEdgeStatus::Open, - ) - .await - { - Ok(child_ids) => child_ids, - Err(err) => { - warn!( - "failed to load persisted thread-spawn children for {parent_thread_id}: {err}" - ); - continue; - } - }; - - for child_thread_id in child_ids { - let child_depth = parent_depth + 1; - let child_resumed = if state.get_thread(child_thread_id).await.is_ok() { - true - } else { - let child_session_source = - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth: child_depth, - agent_path: None, - agent_nickname: None, - agent_role: None, - }); - match Box::pin(self.resume_single_agent_from_rollout( - config.clone(), - child_thread_id, - child_session_source, - )) - .await - { - Ok(_) => true, - Err(err) => { - warn!("failed to resume descendant thread {child_thread_id}: {err}"); - false - } - } - }; - if child_resumed { - resume_queue.push_back((child_thread_id, child_depth)); - } - } - } - - Ok(resumed_thread_id) - } - - async fn resume_single_agent_from_rollout( - &self, - config: Config, - thread_id: ThreadId, - session_source: SessionSource, - ) -> CodexResult { - let state = self.upgrade()?; - let state_db_ctx = state.state_db(); - let stored_thread = state - .read_stored_thread(ReadThreadParams { - thread_id, - include_archived: true, - include_history: true, - }) - .await?; - let history = stored_thread - .history - .ok_or_else(|| CodexErr::ThreadNotFound(thread_id))? - .items; - let initial_history = InitialHistory::Resumed(ResumedHistory { - conversation_id: thread_id, - history, - rollout_path: stored_thread.rollout_path, - }); - let parent_thread_id = stored_thread.parent_thread_id; - let multi_agent_version = state - .effective_multi_agent_version_for_spawn( - &initial_history, - Some(&session_source), - parent_thread_id, - /*forked_from_thread_id*/ None, - &config, - ) - .await; - let agent_max_threads = config.effective_agent_max_threads(multi_agent_version); - let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?; - let (session_source, agent_metadata) = match session_source { - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth, - agent_path, - agent_role: _, - agent_nickname: _, - }) => { - let (resumed_agent_nickname, resumed_agent_role) = - if let Some(state_db_ctx) = state_db_ctx.as_ref() { - match state_db_ctx.get_thread(thread_id).await { - Ok(Some(metadata)) => (metadata.agent_nickname, metadata.agent_role), - Ok(None) | Err(_) => (None, None), - } - } else { - (None, None) - }; - self.prepare_thread_spawn( - &mut reservation, - &config, - parent_thread_id, - depth, - agent_path, - resumed_agent_role, - resumed_agent_nickname, - )? - } - other => (other, AgentMetadata::default()), - }; - let notification_source = session_source.clone(); - let inherited_shell_snapshot = self - .inherited_shell_snapshot_for_source(&state, Some(&session_source)) - .await; - let inherited_exec_policy = self - .inherited_exec_policy_for_source(&state, Some(&session_source), &config) - .await; - - let resumed_thread = state - .resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions { - config: config.clone(), - initial_history, - agent_control: self.clone(), - session_source, - parent_thread_id, - inherited_shell_snapshot, - inherited_exec_policy, - }) - .await?; - let mut agent_metadata = agent_metadata; - agent_metadata.agent_id = Some(resumed_thread.thread_id); - reservation.commit(agent_metadata.clone()); - // Resumed threads are re-registered in-memory and need the same listener - // attachment path as freshly spawned threads. - state.notify_thread_created(resumed_thread.thread_id); - if multi_agent_version != MultiAgentVersion::V2 { - let child_reference = agent_metadata - .agent_path - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| resumed_thread.thread_id.to_string()); - self.maybe_start_completion_watcher( - resumed_thread.thread_id, - Some(notification_source.clone()), - child_reference, - agent_metadata.agent_path.clone(), - ); - } - self.persist_thread_spawn_edge_for_source( - resumed_thread.thread.as_ref(), - resumed_thread.thread_id, - Some(¬ification_source), - ) - .await; - - Ok(resumed_thread.thread_id) - } - /// Send rich user input items to an existing agent thread. pub(crate) async fn send_input( &self, @@ -787,86 +188,6 @@ impl AgentControl { result } - /// Submit a shutdown request for a live agent without marking it explicitly closed in - /// persisted spawn-edge state. - pub(crate) async fn shutdown_live_agent(&self, agent_id: ThreadId) -> CodexResult { - let state = self.upgrade()?; - let result = if let Ok(thread) = state.get_thread(agent_id).await { - thread.codex.session.ensure_rollout_materialized().await; - thread.codex.session.flush_rollout().await?; - let result = if matches!(thread.agent_status().await, AgentStatus::Shutdown) { - Ok(String::new()) - } else { - state.send_op(agent_id, Op::Shutdown {}).await - }; - thread.wait_until_terminated().await; - result - } else { - state.send_op(agent_id, Op::Shutdown {}).await - }; - let _ = state.remove_thread(&agent_id).await; - self.state.release_spawned_thread(agent_id); - result - } - - /// Mark `agent_id` as explicitly closed in persisted spawn-edge state, then shut down the - /// agent and any live descendants reached from the in-memory tree. - pub(crate) async fn close_agent(&self, agent_id: ThreadId) -> CodexResult { - let state = self.upgrade()?; - let known_agent = self.state.agent_metadata_for_thread(agent_id).is_some(); - match state.get_thread(agent_id).await { - Ok(thread) => { - if let Some(state_db_ctx) = thread.state_db() - && let Err(err) = state_db_ctx - .set_thread_spawn_edge_status( - agent_id, - DirectionalThreadSpawnEdgeStatus::Closed, - ) - .await - { - warn!("failed to persist thread-spawn edge status for {agent_id}: {err}"); - } - } - Err(CodexErr::ThreadNotFound(_)) if known_agent => { - if let Some(state_db_ctx) = state.state_db() - && let Err(err) = state_db_ctx - .set_thread_spawn_edge_status( - agent_id, - DirectionalThreadSpawnEdgeStatus::Closed, - ) - .await - { - return Err(CodexErr::Fatal(format!( - "failed to persist stale thread-spawn edge status for {agent_id}: {err}" - ))); - } - } - Err(CodexErr::ThreadNotFound(_)) => {} - Err(err) => { - warn!("failed to inspect agent before close {agent_id}: {err}"); - } - } - match Box::pin(self.shutdown_agent_tree(agent_id)).await { - Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) if known_agent => { - Ok(String::new()) - } - result => result, - } - } - - /// Shut down `agent_id` and any live descendants reachable from the in-memory spawn tree. - async fn shutdown_agent_tree(&self, agent_id: ThreadId) -> CodexResult { - let descendant_ids = self.live_thread_spawn_descendants(agent_id).await?; - let result = self.shutdown_live_agent(agent_id).await; - for descendant_id in descendant_ids { - match self.shutdown_live_agent(descendant_id).await { - Ok(_) | Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) => {} - Err(err) => return Err(err), - } - } - result - } - /// Fetch the last known status for `agent_id`, returning `NotFound` when unavailable. pub(crate) async fn get_status(&self, agent_id: ThreadId) -> AgentStatus { let Ok(state) = self.upgrade() else { @@ -1140,7 +461,7 @@ impl AgentControl { if let Some(agent_path) = agent_path.as_ref() { reservation.reserve_agent_path(agent_path)?; } - let candidate_names = agent_nickname_candidates(config, agent_role.as_deref()); + let candidate_names = spawn::agent_nickname_candidates(config, agent_role.as_deref()); let candidate_name_refs: Vec<&str> = candidate_names.iter().map(String::as_str).collect(); let agent_nickname = Some(reservation.reserve_agent_nickname_with_preference( &candidate_name_refs, diff --git a/codex-rs/core/src/agent/control/legacy.rs b/codex-rs/core/src/agent/control/legacy.rs new file mode 100644 index 000000000000..e85090b90555 --- /dev/null +++ b/codex-rs/core/src/agent/control/legacy.rs @@ -0,0 +1,83 @@ +use super::*; + +impl AgentControl { + /// Submit a shutdown request for a live agent without marking it explicitly closed in + /// persisted spawn-edge state. + pub(crate) async fn shutdown_live_agent(&self, agent_id: ThreadId) -> CodexResult { + let state = self.upgrade()?; + let result = if let Ok(thread) = state.get_thread(agent_id).await { + thread.codex.session.ensure_rollout_materialized().await; + thread.codex.session.flush_rollout().await?; + let result = if matches!(thread.agent_status().await, AgentStatus::Shutdown) { + Ok(String::new()) + } else { + state.send_op(agent_id, Op::Shutdown {}).await + }; + thread.wait_until_terminated().await; + result + } else { + state.send_op(agent_id, Op::Shutdown {}).await + }; + let _ = state.remove_thread(&agent_id).await; + self.state.release_spawned_thread(agent_id); + result + } + + /// Mark `agent_id` as explicitly closed in persisted spawn-edge state, then shut down the + /// agent and any live descendants reached from the in-memory tree. + pub(crate) async fn close_agent(&self, agent_id: ThreadId) -> CodexResult { + let state = self.upgrade()?; + let known_agent = self.state.agent_metadata_for_thread(agent_id).is_some(); + match state.get_thread(agent_id).await { + Ok(thread) => { + if let Some(state_db_ctx) = thread.state_db() + && let Err(err) = state_db_ctx + .set_thread_spawn_edge_status( + agent_id, + DirectionalThreadSpawnEdgeStatus::Closed, + ) + .await + { + warn!("failed to persist thread-spawn edge status for {agent_id}: {err}"); + } + } + Err(CodexErr::ThreadNotFound(_)) if known_agent => { + if let Some(state_db_ctx) = state.state_db() + && let Err(err) = state_db_ctx + .set_thread_spawn_edge_status( + agent_id, + DirectionalThreadSpawnEdgeStatus::Closed, + ) + .await + { + return Err(CodexErr::Fatal(format!( + "failed to persist stale thread-spawn edge status for {agent_id}: {err}" + ))); + } + } + Err(CodexErr::ThreadNotFound(_)) => {} + Err(err) => { + warn!("failed to inspect agent before close {agent_id}: {err}"); + } + } + match Box::pin(self.shutdown_agent_tree(agent_id)).await { + Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) if known_agent => { + Ok(String::new()) + } + result => result, + } + } + + /// Shut down `agent_id` and any live descendants reachable from the in-memory spawn tree. + pub(crate) async fn shutdown_agent_tree(&self, agent_id: ThreadId) -> CodexResult { + let descendant_ids = self.live_thread_spawn_descendants(agent_id).await?; + let result = self.shutdown_live_agent(agent_id).await; + for descendant_id in descendant_ids { + match self.shutdown_live_agent(descendant_id).await { + Ok(_) | Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) => {} + Err(err) => return Err(err), + } + } + result + } +} diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs new file mode 100644 index 000000000000..7cf5d00b4d40 --- /dev/null +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -0,0 +1,606 @@ +use super::*; + +const AGENT_NAMES: &str = include_str!("../agent_names.txt"); + +struct SpawnAgentThreadInheritance { + shell_snapshot: Option>, + exec_policy: Option>, +} + +fn default_agent_nickname_list() -> Vec<&'static str> { + AGENT_NAMES + .lines() + .map(str::trim) + .filter(|name| !name.is_empty()) + .collect() +} + +pub(super) fn agent_nickname_candidates(config: &Config, role_name: Option<&str>) -> Vec { + let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME); + if let Some(candidates) = + resolve_role_config(config, role_name).and_then(|role| role.nickname_candidates.clone()) + { + return candidates; + } + + default_agent_nickname_list() + .into_iter() + .map(ToOwned::to_owned) + .collect() +} + +fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: bool) -> bool { + match item { + RolloutItem::ResponseItem(ResponseItem::Message { role, phase, .. }) => match role.as_str() + { + "system" | "developer" | "user" => true, + "assistant" => *phase == Some(MessagePhase::FinalAnswer), + _ => false, + }, + RolloutItem::ResponseItem( + ResponseItem::AgentMessage { .. } + | ResponseItem::Reasoning { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger + | ResponseItem::ContextCompaction { .. } + | ResponseItem::Other, + ) => false, + // Full-history forks preserve the cached prompt prefix and can keep diffing + // from the parent's durable baseline. Truncated forks drop part of that prompt, + // so they must rebuild context on their first child turn. + RolloutItem::TurnContext(_) => preserve_reference_context_item, + RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => true, + } +} + +fn is_multi_agent_v2_usage_hint_message(item: &ResponseItem, usage_hint_texts: &[String]) -> bool { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "developer" { + return false; + } + let [ContentItem::InputText { text }] = content.as_slice() else { + return false; + }; + + usage_hint_texts + .iter() + .any(|usage_hint_text| usage_hint_text == text) +} + +impl AgentControl { + /// Spawn a new agent thread and submit the initial prompt. + #[cfg(test)] + pub(crate) async fn spawn_agent( + &self, + config: Config, + initial_operation: Op, + session_source: Option, + ) -> CodexResult { + let spawned_agent = Box::pin(self.spawn_agent_internal( + config, + initial_operation, + session_source, + SpawnAgentOptions::default(), + )) + .await?; + Ok(spawned_agent.thread_id) + } + + /// Spawn an agent thread with some metadata. + pub(crate) async fn spawn_agent_with_metadata( + &self, + config: Config, + initial_operation: Op, + session_source: Option, + options: SpawnAgentOptions, // TODO(jif) drop with new fork. + ) -> CodexResult { + Box::pin(self.spawn_agent_internal(config, initial_operation, session_source, options)) + .await + } + + async fn spawn_agent_internal( + &self, + config: Config, + initial_operation: Op, + session_source: Option, + options: SpawnAgentOptions, + ) -> CodexResult { + let state = self.upgrade()?; + let multi_agent_version = state + .effective_multi_agent_version_for_spawn( + &InitialHistory::New, + session_source.as_ref(), + options.parent_thread_id, + /*forked_from_thread_id*/ None, + &config, + ) + .await; + let agent_max_threads = config.effective_agent_max_threads(multi_agent_version); + let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?; + let inheritance = SpawnAgentThreadInheritance { + shell_snapshot: self + .inherited_shell_snapshot_for_source(&state, session_source.as_ref()) + .await, + exec_policy: self + .inherited_exec_policy_for_source(&state, session_source.as_ref(), &config) + .await, + }; + let (session_source, mut agent_metadata) = match session_source { + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path, + agent_role, + .. + })) => { + let (session_source, agent_metadata) = self.prepare_thread_spawn( + &mut reservation, + &config, + parent_thread_id, + depth, + agent_path, + agent_role, + /*preferred_agent_nickname*/ None, + )?; + (Some(session_source), agent_metadata) + } + other => (other, AgentMetadata::default()), + }; + let notification_source = session_source.clone(); + + // The same `AgentControl` is sent to spawn the thread. + let new_thread = match (session_source, options.fork_mode.as_ref(), inheritance) { + (Some(session_source), Some(_), inheritance) => { + Box::pin(self.spawn_forked_thread( + &state, + config, + session_source, + &options, + inheritance, + multi_agent_version, + )) + .await? + } + (Some(session_source), None, inheritance) => { + Box::pin(state.spawn_new_thread_with_source( + config.clone(), + self.clone(), + session_source, + options.parent_thread_id, + /*forked_from_thread_id*/ None, + /*thread_source*/ Some(ThreadSource::Subagent), + /*metrics_service_name*/ None, + inheritance.shell_snapshot, + inheritance.exec_policy, + options.environments.clone(), + )) + .await? + } + (None, _, _) => Box::pin(state.spawn_new_thread(config.clone(), self.clone())).await?, + }; + agent_metadata.agent_id = Some(new_thread.thread_id); + reservation.commit(agent_metadata.clone()); + + if let Some(SessionSource::SubAgent( + subagent_source @ SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }, + )) = notification_source.as_ref() + { + let client_metadata = match state.get_thread(*parent_thread_id).await { + Ok(parent_thread) => { + parent_thread + .codex + .session + .app_server_client_metadata() + .await + } + Err(error) => { + tracing::warn!( + error = %error, + parent_thread_id = %parent_thread_id, + "skipping subagent thread analytics: failed to load parent thread metadata" + ); + crate::session::session::AppServerClientMetadata { + client_name: None, + client_version: None, + } + } + }; + let thread_config = new_thread.thread.codex.thread_config_snapshot().await; + let parent_thread_id = thread_config.parent_thread_id; + emit_subagent_session_started( + &new_thread + .thread + .codex + .session + .services + .analytics_events_client, + client_metadata, + new_thread.thread.codex.session.session_id(), + new_thread.thread_id, + parent_thread_id, + thread_config, + subagent_source.clone(), + ); + } + + // Notify a new thread has been created. This notification will be processed by clients + // to subscribe or drain this newly created thread. + // TODO(jif) add helper for drain + state.notify_thread_created(new_thread.thread_id); + + self.persist_thread_spawn_edge_for_source( + new_thread.thread.as_ref(), + new_thread.thread_id, + notification_source.as_ref(), + ) + .await; + + self.send_input(new_thread.thread_id, initial_operation) + .await?; + if multi_agent_version != MultiAgentVersion::V2 { + let child_reference = agent_metadata + .agent_path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| new_thread.thread_id.to_string()); + self.maybe_start_completion_watcher( + new_thread.thread_id, + notification_source, + child_reference, + agent_metadata.agent_path.clone(), + ); + } + + Ok(LiveAgent { + thread_id: new_thread.thread_id, + metadata: agent_metadata, + status: self.get_status(new_thread.thread_id).await, + }) + } + + async fn spawn_forked_thread( + &self, + state: &Arc, + config: Config, + session_source: SessionSource, + options: &SpawnAgentOptions, + inheritance: SpawnAgentThreadInheritance, + multi_agent_version: MultiAgentVersion, + ) -> CodexResult { + let SpawnAgentThreadInheritance { + shell_snapshot: inherited_shell_snapshot, + exec_policy: inherited_exec_policy, + } = inheritance; + if options.fork_parent_spawn_call_id.is_none() { + return Err(CodexErr::Fatal( + "spawn_agent fork requires a parent spawn call id".to_string(), + )); + } + let Some(fork_mode) = options.fork_mode.as_ref() else { + return Err(CodexErr::Fatal( + "spawn_agent fork requires a fork mode".to_string(), + )); + }; + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }) = &session_source + else { + return Err(CodexErr::Fatal( + "spawn_agent fork requires a thread-spawn session source".to_string(), + )); + }; + + let parent_thread_id = *parent_thread_id; + let parent_thread = state.get_thread(parent_thread_id).await.ok(); + if let Some(parent_thread) = parent_thread.as_ref() { + // `record_conversation_items` only queues persistence writes asynchronously. + // Flush before snapshotting store history for a fork. + parent_thread.ensure_rollout_materialized().await; + parent_thread.flush_rollout().await?; + } + + let parent_history = state + .read_stored_thread(ReadThreadParams { + thread_id: parent_thread_id, + include_archived: true, + include_history: true, + }) + .await? + .history + .ok_or_else(|| { + CodexErr::Fatal(format!( + "parent thread history unavailable for fork: {parent_thread_id}" + )) + })?; + + let mut forked_rollout_items = parent_history.items; + if let SpawnAgentForkMode::LastNTurns(last_n_turns) = fork_mode { + forked_rollout_items = + truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns); + } + let multi_agent_v2_usage_hint_texts_to_filter: Vec = + if let Some(parent_thread) = parent_thread.as_ref() { + if multi_agent_version == MultiAgentVersion::V2 { + let parent_config = parent_thread.codex.session.get_config().await; + [ + parent_config + .multi_agent_v2 + .root_agent_usage_hint_text + .clone(), + parent_config + .multi_agent_v2 + .subagent_usage_hint_text + .clone(), + ] + .into_iter() + .flatten() + .collect() + } else { + Vec::new() + } + } else if multi_agent_version == MultiAgentVersion::V2 { + [ + config.multi_agent_v2.root_agent_usage_hint_text.clone(), + config.multi_agent_v2.subagent_usage_hint_text.clone(), + ] + .into_iter() + .flatten() + .collect() + } else { + Vec::new() + }; + let preserve_reference_context_item = matches!(fork_mode, SpawnAgentForkMode::FullHistory); + forked_rollout_items.retain(|item| { + keep_forked_rollout_item(item, preserve_reference_context_item) + && !matches!( + item, + RolloutItem::ResponseItem(response_item) + if is_multi_agent_v2_usage_hint_message( + response_item, + &multi_agent_v2_usage_hint_texts_to_filter, + ) + ) + }); + for item in &mut forked_rollout_items { + if let RolloutItem::Compacted(compacted) = item + && let Some(replacement_history) = compacted.replacement_history.as_mut() + { + replacement_history.retain(|response_item| { + !is_multi_agent_v2_usage_hint_message( + response_item, + &multi_agent_v2_usage_hint_texts_to_filter, + ) + }); + } + } + if preserve_reference_context_item + && multi_agent_version == MultiAgentVersion::V2 + && config.multi_agent_v2.usage_hint_enabled + && let Some(subagent_usage_hint_text) = + config.multi_agent_v2.subagent_usage_hint_text.clone() + && let Some(subagent_usage_hint_message) = + crate::context_manager::updates::build_developer_update_item(vec![ + subagent_usage_hint_text, + ]) + { + forked_rollout_items.push(RolloutItem::ResponseItem(subagent_usage_hint_message)); + } + + state + .fork_thread_with_source( + config.clone(), + InitialHistory::Forked(forked_rollout_items), + self.clone(), + session_source, + /*thread_source*/ Some(ThreadSource::Subagent), + /*parent_thread_id*/ Some(parent_thread_id), + /*forked_from_thread_id*/ Some(parent_thread_id), + inherited_shell_snapshot, + inherited_exec_policy, + options.environments.clone(), + ) + .await + } + + /// Resume an existing agent thread from a recorded rollout file. + pub(crate) async fn resume_agent_from_rollout( + &self, + config: Config, + thread_id: ThreadId, + session_source: SessionSource, + ) -> CodexResult { + let root_depth = thread_spawn_depth(&session_source).unwrap_or(0); + let resumed_thread_id = Box::pin(self.resume_single_agent_from_rollout( + config.clone(), + thread_id, + session_source, + )) + .await?; + let state = self.upgrade()?; + let Ok(resumed_thread) = state.get_thread(resumed_thread_id).await else { + return Ok(resumed_thread_id); + }; + let Some(state_db_ctx) = resumed_thread.state_db() else { + return Ok(resumed_thread_id); + }; + + let mut resume_queue = VecDeque::from([(thread_id, root_depth)]); + while let Some((parent_thread_id, parent_depth)) = resume_queue.pop_front() { + let child_ids = match state_db_ctx + .list_thread_spawn_children_with_status( + parent_thread_id, + DirectionalThreadSpawnEdgeStatus::Open, + ) + .await + { + Ok(child_ids) => child_ids, + Err(err) => { + warn!( + "failed to load persisted thread-spawn children for {parent_thread_id}: {err}" + ); + continue; + } + }; + + for child_thread_id in child_ids { + let child_depth = parent_depth + 1; + let child_resumed = if state.get_thread(child_thread_id).await.is_ok() { + true + } else { + let child_session_source = + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: child_depth, + agent_path: None, + agent_nickname: None, + agent_role: None, + }); + match Box::pin(self.resume_single_agent_from_rollout( + config.clone(), + child_thread_id, + child_session_source, + )) + .await + { + Ok(_) => true, + Err(err) => { + warn!("failed to resume descendant thread {child_thread_id}: {err}"); + false + } + } + }; + if child_resumed { + resume_queue.push_back((child_thread_id, child_depth)); + } + } + } + + Ok(resumed_thread_id) + } + + async fn resume_single_agent_from_rollout( + &self, + config: Config, + thread_id: ThreadId, + session_source: SessionSource, + ) -> CodexResult { + let state = self.upgrade()?; + let state_db_ctx = state.state_db(); + let stored_thread = state + .read_stored_thread(ReadThreadParams { + thread_id, + include_archived: true, + include_history: true, + }) + .await?; + let history = stored_thread + .history + .ok_or_else(|| CodexErr::ThreadNotFound(thread_id))? + .items; + let initial_history = InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history, + rollout_path: stored_thread.rollout_path, + }); + let parent_thread_id = stored_thread.parent_thread_id; + let multi_agent_version = state + .effective_multi_agent_version_for_spawn( + &initial_history, + Some(&session_source), + parent_thread_id, + /*forked_from_thread_id*/ None, + &config, + ) + .await; + let agent_max_threads = config.effective_agent_max_threads(multi_agent_version); + let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?; + let (session_source, agent_metadata) = match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path, + agent_role: _, + agent_nickname: _, + }) => { + let (resumed_agent_nickname, resumed_agent_role) = + if let Some(state_db_ctx) = state_db_ctx.as_ref() { + match state_db_ctx.get_thread(thread_id).await { + Ok(Some(metadata)) => (metadata.agent_nickname, metadata.agent_role), + Ok(None) | Err(_) => (None, None), + } + } else { + (None, None) + }; + self.prepare_thread_spawn( + &mut reservation, + &config, + parent_thread_id, + depth, + agent_path, + resumed_agent_role, + resumed_agent_nickname, + )? + } + other => (other, AgentMetadata::default()), + }; + let notification_source = session_source.clone(); + let inherited_shell_snapshot = self + .inherited_shell_snapshot_for_source(&state, Some(&session_source)) + .await; + let inherited_exec_policy = self + .inherited_exec_policy_for_source(&state, Some(&session_source), &config) + .await; + + let resumed_thread = state + .resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions { + config: config.clone(), + initial_history, + agent_control: self.clone(), + session_source, + parent_thread_id, + inherited_shell_snapshot, + inherited_exec_policy, + }) + .await?; + let mut agent_metadata = agent_metadata; + agent_metadata.agent_id = Some(resumed_thread.thread_id); + reservation.commit(agent_metadata.clone()); + // Resumed threads are re-registered in-memory and need the same listener + // attachment path as freshly spawned threads. + state.notify_thread_created(resumed_thread.thread_id); + if multi_agent_version != MultiAgentVersion::V2 { + let child_reference = agent_metadata + .agent_path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| resumed_thread.thread_id.to_string()); + self.maybe_start_completion_watcher( + resumed_thread.thread_id, + Some(notification_source.clone()), + child_reference, + agent_metadata.agent_path.clone(), + ); + } + self.persist_thread_spawn_edge_for_source( + resumed_thread.thread.as_ref(), + resumed_thread.thread_id, + Some(¬ification_source), + ) + .await; + + Ok(resumed_thread.thread_id) + } +} From 5e62c735b2b261e897276b4df544a197c49081d4 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 5 Jun 2026 11:43:44 -0300 Subject: [PATCH 21/59] feat(app-server): expose account token usage [1 of 2] (#25344) ## Why Token activity is useful account-level context, but terminal clients need a supported app-server path to fetch it without reaching into ChatGPT backend details directly. The API should also live under the broader account usage umbrella so future usage surfaces can be added without proliferating user-facing concepts. ## What Changed - Add `codex-backend-client` support for the ChatGPT profile token-usage payload. - Add the v2 `account/usage/read` app-server RPC. - Map lifetime usage, peak daily usage, streak, longest task duration, and daily buckets into app-server protocol types. - Gate the request on Codex-backend auth, which supports ChatGPT auth tokens and AgentIdentity. - Regenerate the app-server JSON and TypeScript schema fixtures. ## Token Count Source `account/usage/read` returns the token-usage aggregate supplied by the ChatGPT profile backend. App-server maps that backend-owned aggregate into protocol fields; it does not recompute cached-token treatment, usage multipliers, or raw input/output totals locally. ## Stack 1. feat(app-server): expose account token usage [1 of 2] (this PR) 2. [#25345](https://github.com/openai/codex/pull/25345) feat(tui): add token activity command [2 of 2] ## How to Test 1. Start an app-server client from this branch while authenticated with ChatGPT or AgentIdentity. 2. Call `account/usage/read`. 3. Confirm the response includes `summary` and `dailyUsageBuckets`. 4. Also verify a session without Codex-backend auth receives the existing auth error path. Targeted tests: - `just test -p codex-backend-client -p codex-app-server-protocol -p codex-app-server` - `just write-app-server-schema` --- .../schema/json/ClientRequest.json | 23 ++++ .../codex_app_server_protocol.schemas.json | 101 ++++++++++++++++++ .../codex_app_server_protocol.v2.schemas.json | 101 ++++++++++++++++++ .../json/v2/GetAccountTokenUsageResponse.json | 80 ++++++++++++++ .../schema/typescript/ClientRequest.ts | 2 +- .../v2/AccountTokenUsageDailyBucket.ts | 5 + .../typescript/v2/AccountTokenUsageSummary.ts | 5 + .../v2/GetAccountTokenUsageResponse.ts | 7 ++ .../schema/typescript/v2/index.ts | 3 + .../src/protocol/common.rs | 24 +++++ .../src/protocol/v2/account.rs | 27 +++++ codex-rs/app-server/README.md | 1 + codex-rs/app-server/src/message_processor.rs | 3 + codex-rs/app-server/src/request_processors.rs | 4 + .../request_processors/account_processor.rs | 100 +++++++++++++++++ codex-rs/backend-client/src/client.rs | 62 +++++++---- codex-rs/backend-client/src/lib.rs | 3 + codex-rs/backend-client/src/types.rs | 21 ++++ 18 files changed, 553 insertions(+), 19 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageDailyBucket.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageSummary.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/GetAccountTokenUsageResponse.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index c6e1aa330271..6d9be4ec026c 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -5969,6 +5969,29 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/usage/read" + ], + "title": "Account/usage/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/usage/readRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 10bde055974a..a5578e007266 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -1833,6 +1833,29 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "account/usage/read" + ], + "title": "Account/usage/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/usage/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -5759,6 +5782,62 @@ "title": "AccountRateLimitsUpdatedNotification", "type": "object" }, + "AccountTokenUsageDailyBucket": { + "properties": { + "startDate": { + "type": "string" + }, + "tokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "startDate", + "tokens" + ], + "type": "object" + }, + "AccountTokenUsageSummary": { + "properties": { + "currentStreakDays": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "lifetimeTokens": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "longestRunningTurnSec": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "longestStreakDays": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "peakDailyTokens": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, "AccountUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -9626,6 +9705,28 @@ "title": "GetAccountResponse", "type": "object" }, + "GetAccountTokenUsageResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "dailyUsageBuckets": { + "items": { + "$ref": "#/definitions/v2/AccountTokenUsageDailyBucket" + }, + "type": [ + "array", + "null" + ] + }, + "summary": { + "$ref": "#/definitions/v2/AccountTokenUsageSummary" + } + }, + "required": [ + "summary" + ], + "title": "GetAccountTokenUsageResponse", + "type": "object" + }, "GitInfo": { "properties": { "branch": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e4c91e75869e..36b95b4f641a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -104,6 +104,62 @@ "title": "AccountRateLimitsUpdatedNotification", "type": "object" }, + "AccountTokenUsageDailyBucket": { + "properties": { + "startDate": { + "type": "string" + }, + "tokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "startDate", + "tokens" + ], + "type": "object" + }, + "AccountTokenUsageSummary": { + "properties": { + "currentStreakDays": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "lifetimeTokens": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "longestRunningTurnSec": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "longestStreakDays": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "peakDailyTokens": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, "AccountUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -2679,6 +2735,29 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/usage/read" + ], + "title": "Account/usage/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/usage/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -6079,6 +6158,28 @@ "title": "GetAccountResponse", "type": "object" }, + "GetAccountTokenUsageResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "dailyUsageBuckets": { + "items": { + "$ref": "#/definitions/AccountTokenUsageDailyBucket" + }, + "type": [ + "array", + "null" + ] + }, + "summary": { + "$ref": "#/definitions/AccountTokenUsageSummary" + } + }, + "required": [ + "summary" + ], + "title": "GetAccountTokenUsageResponse", + "type": "object" + }, "GitInfo": { "properties": { "branch": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json new file mode 100644 index 000000000000..557a297ce119 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AccountTokenUsageDailyBucket": { + "properties": { + "startDate": { + "type": "string" + }, + "tokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "startDate", + "tokens" + ], + "type": "object" + }, + "AccountTokenUsageSummary": { + "properties": { + "currentStreakDays": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "lifetimeTokens": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "longestRunningTurnSec": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "longestStreakDays": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "peakDailyTokens": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "properties": { + "dailyUsageBuckets": { + "items": { + "$ref": "#/definitions/AccountTokenUsageDailyBucket" + }, + "type": [ + "array", + "null" + ] + }, + "summary": { + "$ref": "#/definitions/AccountTokenUsageSummary" + } + }, + "required": [ + "summary" + ], + "title": "GetAccountTokenUsageResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index e19ac2dfde3d..c91792c72656 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -86,4 +86,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageDailyBucket.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageDailyBucket.ts new file mode 100644 index 000000000000..a92c6c00c479 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageDailyBucket.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AccountTokenUsageDailyBucket = { startDate: string, tokens: bigint, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageSummary.ts new file mode 100644 index 000000000000..6f87acdaffaa --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageSummary.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AccountTokenUsageSummary = { lifetimeTokens: bigint | null, peakDailyTokens: bigint | null, longestRunningTurnSec: bigint | null, currentStreakDays: bigint | null, longestStreakDays: bigint | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountTokenUsageResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountTokenUsageResponse.ts new file mode 100644 index 000000000000..175c9152722e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountTokenUsageResponse.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AccountTokenUsageDailyBucket } from "./AccountTokenUsageDailyBucket"; +import type { AccountTokenUsageSummary } from "./AccountTokenUsageSummary"; + +export type GetAccountTokenUsageResponse = { summary: AccountTokenUsageSummary, dailyUsageBuckets: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index bb19c92bdfd3..727c049d10be 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -3,6 +3,8 @@ export type { Account } from "./Account"; export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedNotification"; export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification"; +export type { AccountTokenUsageDailyBucket } from "./AccountTokenUsageDailyBucket"; +export type { AccountTokenUsageSummary } from "./AccountTokenUsageSummary"; export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { ActivePermissionProfile } from "./ActivePermissionProfile"; export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; @@ -141,6 +143,7 @@ export type { FsWriteFileResponse } from "./FsWriteFileResponse"; export type { GetAccountParams } from "./GetAccountParams"; export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse"; export type { GetAccountResponse } from "./GetAccountResponse"; +export type { GetAccountTokenUsageResponse } from "./GetAccountTokenUsageResponse"; export type { GitInfo } from "./GitInfo"; export type { GrantedPermissionProfile } from "./GrantedPermissionProfile"; export type { GuardianApprovalReview } from "./GuardianApprovalReview"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 47410551cf4c..a624387e4d2b 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -949,6 +949,12 @@ client_request_definitions! { response: v2::GetAccountRateLimitsResponse, }, + GetAccountTokenUsage => "account/usage/read" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: None, + response: v2::GetAccountTokenUsageResponse, + }, + SendAddCreditsNudgeEmail => "account/sendAddCreditsNudgeEmail" { params: v2::SendAddCreditsNudgeEmailParams, serialization: global("account-auth"), @@ -2372,6 +2378,24 @@ mod tests { Ok(()) } + #[test] + fn serialize_get_account_token_usage() -> Result<()> { + let request = ClientRequest::GetAccountTokenUsage { + request_id: RequestId::Integer(1), + params: None, + }; + assert_eq!(request.id(), &RequestId::Integer(1)); + assert_eq!(request.method(), "account/usage/read"); + assert_eq!( + json!({ + "method": "account/usage/read", + "id": 1, + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_client_response() -> Result<()> { let cwd = absolute_path("/tmp"); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 83f1bc02b875..58fc93cc76d2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -258,6 +258,33 @@ pub struct GetAccountRateLimitsResponse { pub rate_limits_by_limit_id: Option>, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountTokenUsageResponse { + pub summary: AccountTokenUsageSummary, + pub daily_usage_buckets: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountTokenUsageSummary { + pub lifetime_tokens: Option, + pub peak_daily_tokens: Option, + pub longest_running_turn_sec: Option, + pub current_streak_days: Option, + pub longest_streak_days: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountTokenUsageDailyBucket { + pub start_date: String, + pub tokens: i64, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d112eeff9d42..b7fc6b82a3ed 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1779,6 +1779,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `account/logout` — sign out; triggers `account/updated`. - `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available. - `account/rateLimits/read` — fetch ChatGPT rate limits and an optional effective monthly credit limit; updates arrive via `account/rateLimits/updated` (notify). +- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets. - `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. - `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit. - `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 6c578ce8e53c..07bc06218e2b 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1294,6 +1294,9 @@ impl MessageProcessor { ClientRequest::GetAccountRateLimits { .. } => { self.account_processor.get_account_rate_limits().await } + ClientRequest::GetAccountTokenUsage { .. } => { + self.account_processor.get_account_token_usage().await + } ClientRequest::SendAddCreditsNudgeEmail { params, .. } => { self.account_processor .send_add_credits_nudge_email(params) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 0e94528a7ae1..00468b0a999a 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -22,6 +22,8 @@ use codex_analytics::InputError; use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; +use codex_app_server_protocol::AccountTokenUsageDailyBucket; +use codex_app_server_protocol::AccountTokenUsageSummary; use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AddCreditsNudgeEmailStatus; @@ -64,6 +66,7 @@ use codex_app_server_protocol::FeedbackUploadResponse; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::GetAccountResponse; +use codex_app_server_protocol::GetAccountTokenUsageResponse; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::GetConversationSummaryParams; @@ -266,6 +269,7 @@ use codex_app_server_protocol::WindowsSandboxSetupStartResponse; use codex_arg0::Arg0DispatchPaths; use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType; use codex_backend_client::Client as BackendClient; +use codex_backend_client::TokenUsageProfile; use codex_chatgpt::connectors; use codex_chatgpt::workspace_settings; use codex_config::CloudConfigBundleLoadError; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 8ebfc037e1a1..21160d6810b4 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -2,6 +2,7 @@ use super::*; // Duration before a browser ChatGPT login attempt is abandoned. const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10); // The override is intentionally available only in debug builds, matching the login path below. #[cfg(debug_assertions)] const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; @@ -131,6 +132,14 @@ impl AccountRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn get_account_token_usage( + &self, + ) -> Result, JSONRPCErrorError> { + self.get_account_token_usage_response() + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn send_add_credits_nudge_email( &self, params: SendAddCreditsNudgeEmailParams, @@ -848,6 +857,55 @@ impl AccountRequestProcessor { ) } + async fn get_account_token_usage_response( + &self, + ) -> Result { + let Some(auth) = self.auth_manager.auth().await else { + return Err(invalid_request( + "codex account authentication required to read token usage", + )); + }; + + if !auth.uses_codex_backend() { + return Err(invalid_request( + "chatgpt authentication required to read token usage", + )); + } + + let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) + .map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?; + let profile = tokio::time::timeout( + ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT, + client.get_token_usage_profile(), + ) + .await + .map_err(|_| internal_error("token usage profile fetch timed out"))? + .map_err(|err| internal_error(format!("failed to fetch token usage profile: {err}")))?; + Ok(Self::account_token_usage_response(profile)) + } + + fn account_token_usage_response(profile: TokenUsageProfile) -> GetAccountTokenUsageResponse { + let stats = profile.stats; + GetAccountTokenUsageResponse { + summary: AccountTokenUsageSummary { + lifetime_tokens: stats.lifetime_tokens, + peak_daily_tokens: stats.peak_daily_tokens, + longest_running_turn_sec: stats.longest_running_turn_sec, + current_streak_days: stats.current_streak_days, + longest_streak_days: stats.longest_streak_days, + }, + daily_usage_buckets: stats.daily_usage_buckets.map(|buckets| { + buckets + .into_iter() + .map(|bucket| AccountTokenUsageDailyBucket { + start_date: bucket.start_date, + tokens: bucket.tokens, + }) + .collect() + }), + } + } + async fn send_add_credits_nudge_email_response( &self, params: SendAddCreditsNudgeEmailParams, @@ -952,3 +1010,45 @@ impl AccountRequestProcessor { Ok((primary, rate_limits_by_limit_id)) } } + +#[cfg(test)] +mod tests { + use super::*; + use codex_backend_client::TokenUsageProfileDailyBucket; + use codex_backend_client::TokenUsageProfileStats; + use pretty_assertions::assert_eq; + + #[test] + fn account_token_usage_response_maps_profile_stats_and_daily_buckets() { + let response = AccountRequestProcessor::account_token_usage_response(TokenUsageProfile { + stats: TokenUsageProfileStats { + lifetime_tokens: Some(123), + peak_daily_tokens: Some(45), + longest_running_turn_sec: Some(67), + current_streak_days: Some(8), + longest_streak_days: Some(9), + daily_usage_buckets: Some(vec![TokenUsageProfileDailyBucket { + start_date: "2026-05-29".to_string(), + tokens: 10, + }]), + }, + }); + + assert_eq!( + response, + GetAccountTokenUsageResponse { + summary: AccountTokenUsageSummary { + lifetime_tokens: Some(123), + peak_daily_tokens: Some(45), + longest_running_turn_sec: Some(67), + current_streak_days: Some(8), + longest_streak_days: Some(9), + }, + daily_usage_buckets: Some(vec![AccountTokenUsageDailyBucket { + start_date: "2026-05-29".to_string(), + tokens: 10, + }]), + } + ); + } +} diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 24d277659096..52275eb4be52 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -4,6 +4,7 @@ use crate::types::ConfigBundleResponse; use crate::types::PaginatedListTaskListItem; use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; use crate::types::RateLimitStatusPayload; +use crate::types::TokenUsageProfile; use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; use codex_api::SharedAuthProvider; @@ -313,6 +314,20 @@ impl Client { self.decode_json(&url, &ct, &body) } + pub async fn get_token_usage_profile(&self) -> Result { + let url = self.token_usage_profile_url(); + let req = self.http.get(&url).headers(self.headers()); + let (body, ct) = self.exec_request(req, "GET", &url).await?; + self.decode_json(&url, &ct, &body) + } + + fn token_usage_profile_url(&self) -> String { + match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/profiles/me", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/profiles/me", self.base_url), + } + } + pub async fn send_add_credits_nudge_email( &self, credit_type: AddCreditsNudgeCreditType, @@ -883,29 +898,13 @@ mod tests { #[test] fn add_credits_nudge_email_uses_expected_paths_and_bodies() { - let codex_client = Client { - base_url: "https://example.test".to_string(), - http: reqwest::Client::new(), - auth_provider: codex_model_provider::unauthenticated_auth_provider(), - user_agent: None, - chatgpt_account_id: None, - chatgpt_account_is_fedramp: false, - path_style: PathStyle::CodexApi, - }; + let codex_client = test_client("https://example.test", PathStyle::CodexApi); assert_eq!( codex_client.send_add_credits_nudge_email_url(), "https://example.test/api/codex/accounts/send_add_credits_nudge_email" ); - let chatgpt_client = Client { - base_url: "https://chatgpt.com/backend-api".to_string(), - http: reqwest::Client::new(), - auth_provider: codex_model_provider::unauthenticated_auth_provider(), - user_agent: None, - chatgpt_account_id: None, - chatgpt_account_is_fedramp: false, - path_style: PathStyle::ChatGptApi, - }; + let chatgpt_client = test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi); assert_eq!( chatgpt_client.send_add_credits_nudge_email_url(), "https://chatgpt.com/backend-api/wham/accounts/send_add_credits_nudge_email" @@ -926,4 +925,31 @@ mod tests { serde_json::json!({ "credit_type": "usage_limit" }) ); } + + #[test] + fn token_usage_profile_uses_expected_paths() { + let codex_client = test_client("https://example.test", PathStyle::CodexApi); + assert_eq!( + codex_client.token_usage_profile_url(), + "https://example.test/api/codex/profiles/me" + ); + + let chatgpt_client = test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi); + assert_eq!( + chatgpt_client.token_usage_profile_url(), + "https://chatgpt.com/backend-api/wham/profiles/me" + ); + } + + fn test_client(base_url: &str, path_style: PathStyle) -> Client { + Client { + base_url: base_url.to_string(), + http: reqwest::Client::new(), + auth_provider: codex_model_provider::unauthenticated_auth_provider(), + user_agent: None, + chatgpt_account_id: None, + chatgpt_account_is_fedramp: false, + path_style, + } + } } diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index dfd7e816f662..e50fd8db40cb 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -14,4 +14,7 @@ pub use types::DeliveredRequirementsToml; pub use types::DeliveredTomlFragment; pub use types::PaginatedListTaskListItem; pub use types::TaskListItem; +pub use types::TokenUsageProfile; +pub use types::TokenUsageProfileDailyBucket; +pub use types::TokenUsageProfileStats; pub use types::TurnAttemptsSiblingTurnsResponse; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 06989241fa44..3ccacbc8c948 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -409,6 +409,27 @@ pub struct TurnAttemptsSiblingTurnsResponse { pub sibling_turns: Vec>, } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct TokenUsageProfile { + pub stats: TokenUsageProfileStats, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct TokenUsageProfileStats { + pub lifetime_tokens: Option, + pub peak_daily_tokens: Option, + pub longest_running_turn_sec: Option, + pub current_streak_days: Option, + pub longest_streak_days: Option, + pub daily_usage_buckets: Option>, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct TokenUsageProfileDailyBucket { + pub start_date: String, + pub tokens: i64, +} + #[cfg(test)] mod tests { use super::*; From a14a73b54af26af4a5428ba2eb482a1ce54f8974 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 5 Jun 2026 08:00:46 -0700 Subject: [PATCH 22/59] [codex] Fix long proxy socket paths (#26553) ## Summary - avoid generating host proxy bridge Unix socket paths that exceed Linux's `sockaddr_un.sun_path` limit - fall back from a long `$CODEX_HOME/tmp` path to the system temp directory, then `/tmp` - add focused unit coverage for short and overlong parent paths ## Root cause With a sufficiently long `CODEX_HOME`, the generated `proxy-route-*.sock` path exceeds Linux's 107-byte pathname limit. The host bridge child exits before writing its readiness byte, so the parent reports the indirect error `failed to prepare host proxy routing bridge: failed to fill whole buffer`. ## Validation - reproduced the original error with a long `CODEX_HOME` using `codex-cli 0.138.0-alpha.4` - `cargo clippy -p codex-linux-sandbox --all-targets` - `just fix -p codex-linux-sandbox` - `just fmt` The Linux-only unit test could not execute locally: the arm64 Docker build was repeatedly OOM-killed by `rustc` while compiling an unrelated `codex-app-server-protocol` dependency, before reaching the test. --------- Co-authored-by: Codex --- codex-rs/linux-sandbox/src/proxy_routing.rs | 45 +++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/codex-rs/linux-sandbox/src/proxy_routing.rs b/codex-rs/linux-sandbox/src/proxy_routing.rs index d28b8646660c..f171d91745d8 100644 --- a/codex-rs/linux-sandbox/src/proxy_routing.rs +++ b/codex-rs/linux-sandbox/src/proxy_routing.rs @@ -14,6 +14,7 @@ use std::net::SocketAddr; use std::net::TcpListener; use std::net::TcpStream; use std::os::fd::FromRawFd; +use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::DirBuilderExt; use std::os::unix::fs::PermissionsExt; use std::os::unix::net::UnixListener; @@ -43,6 +44,8 @@ const PROXY_ENV_KEYS: &[&str] = &[ const PROXY_SOCKET_DIR_PREFIX: &str = "codex-linux-sandbox-proxy-"; const HOST_BRIDGE_READY: u8 = 1; const LOOPBACK_INTERFACE_NAME: &[u8] = b"lo"; +// Linux sockaddr_un.sun_path allows 108 bytes, including the trailing NUL. +const UNIX_SOCKET_PATH_MAX_BYTES: usize = 107; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub(crate) struct ProxyRouteSpec { @@ -278,8 +281,9 @@ fn rewrite_proxy_env_value(proxy_url: &str, local_port: u16) -> Option { fn create_proxy_socket_dir() -> io::Result { let temp_dir = proxy_socket_parent_dir(); let pid = std::process::id(); + let uid = unsafe { libc::geteuid() }; for attempt in 0..128 { - let candidate = temp_dir.join(format!("{PROXY_SOCKET_DIR_PREFIX}{pid}-{attempt}")); + let candidate = temp_dir.join(format!("{PROXY_SOCKET_DIR_PREFIX}{pid}-{uid}-{attempt}")); // The bridge UDS paths live under a shared temp root, so the per-run // directory should not be traversable by other processes. let mut dir_builder = DirBuilder::new(); @@ -302,11 +306,29 @@ fn create_proxy_socket_dir() -> io::Result { fn proxy_socket_parent_dir() -> PathBuf { if let Some(codex_home) = std::env::var_os("CODEX_HOME") { let candidate = PathBuf::from(codex_home).join("tmp"); - if ensure_private_proxy_socket_parent_dir(candidate.as_path()).is_ok() { + if proxy_socket_paths_fit(candidate.as_path()) + && ensure_private_proxy_socket_parent_dir(candidate.as_path()).is_ok() + { return candidate; } } - std::env::temp_dir() + let temp_dir = std::env::temp_dir(); + if proxy_socket_paths_fit(temp_dir.as_path()) { + temp_dir + } else { + PathBuf::from("/tmp") + } +} + +fn proxy_socket_paths_fit(parent: &Path) -> bool { + let socket_path = parent + .join(format!( + "{PROXY_SOCKET_DIR_PREFIX}{}-{}-127", + u32::MAX, + libc::uid_t::MAX + )) + .join(format!("proxy-route-{}.sock", usize::MAX)); + socket_path.as_os_str().as_bytes().len() <= UNIX_SOCKET_PATH_MAX_BYTES } fn ensure_private_proxy_socket_parent_dir(path: &Path) -> io::Result<()> { @@ -661,6 +683,7 @@ mod tests { use super::parse_loopback_proxy_endpoint; use super::parse_proxy_socket_dir_owner_pid; use super::plan_proxy_routes; + use super::proxy_socket_paths_fit; use super::rewrite_proxy_env_value; use pretty_assertions::assert_eq; use std::collections::HashMap; @@ -735,6 +758,18 @@ mod tests { assert_eq!(default_proxy_port("socks5h"), 1080); } + #[test] + fn proxy_socket_paths_enforce_linux_path_limit() { + assert_eq!( + proxy_socket_paths_fit(PathBuf::from("/tmp").as_path()), + true + ); + assert_eq!( + proxy_socket_paths_fit(PathBuf::from(format!("/tmp/{}", "a".repeat(96))).as_path()), + false + ); + } + #[test] fn cleanup_proxy_socket_dir_removes_bridge_artifacts() { let root = tempfile::tempdir().expect("tempdir should create"); @@ -770,6 +805,10 @@ mod tests { parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-1234-0"), Some(1234) ); + assert_eq!( + parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-1234-1000-0"), + Some(1234) + ); assert_eq!( parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-x"), None From 3acd71fedb1284e0f27f400e2d354a942b273421 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 5 Jun 2026 08:32:07 -0700 Subject: [PATCH 23/59] Surface TUI config write error causes (#26537) ## Summary TUI config writes currently wrap app-server failures with local context like `config/batchWrite failed in TUI`, but several user-visible paths only render the outer error. That hides the actionable app-server message, such as validation constraints or read-only `CODEX_HOME` failures, leaving users with a dead-end diagnostic. This change adds a small formatter next to the TUI config write helpers that renders the error source chain, then uses it for model persistence, feature persistence, project trust, status line writes, hook trust, and hook enablement. Fixes #26077 --- codex-rs/tui/src/app/background_requests.rs | 12 ++++-- codex-rs/tui/src/app/config_persistence.rs | 5 ++- codex-rs/tui/src/app/event_dispatch.rs | 11 +++-- ...onfig_error_wraps_in_history_snapshot.snap | 7 ++++ codex-rs/tui/src/chatwidget/tests.rs | 2 + .../chatwidget/tests/config_errors_tests.rs | 25 ++++++++++++ codex-rs/tui/src/config_update.rs | 31 ++++---------- codex-rs/tui/src/config_update_tests.rs | 40 +++++++++++++++++++ .../tui/src/onboarding/onboarding_screen.rs | 4 +- ...sts__renders_snapshot_for_trust_error.snap | 19 +++++++++ .../tui/src/onboarding/trust_directory.rs | 38 +++++++++++++----- ...w__tests__startup_hooks_review_prompt.snap | 18 ++++----- ..._hooks_review_prompt_with_trust_error.snap | 24 ++++++----- codex-rs/tui/src/startup_hooks_review.rs | 17 +++++--- 14 files changed, 185 insertions(+), 68 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chained_config_error_wraps_in_history_snapshot.snap create mode 100644 codex-rs/tui/src/chatwidget/tests/config_errors_tests.rs create mode 100644 codex-rs/tui/src/config_update_tests.rs create mode 100644 codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_trust_error.snap diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index d90f511d6f9e..456676f7963e 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -7,6 +7,7 @@ use super::plugin_mentions::fetch_plugin_mentions; use super::*; use crate::app_event::ConnectorsSnapshot; +use crate::config_update::format_config_error; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::MarketplaceAddParams; @@ -357,7 +358,12 @@ impl App { let result = write_hook_enabled(request_handle, key, enabled) .await .map(|_| ()) - .map_err(|err| format!("Failed to update hook config: {err}")); + .map_err(|err| { + format!( + "Failed to update hook config: {}", + format_config_error(&err) + ) + }); app_event_tx.send(AppEvent::HookEnabledSet { key: key_for_event, enabled, @@ -378,7 +384,7 @@ impl App { let result = write_hook_trust(request_handle, key, current_hash) .await .map(|_| ()) - .map_err(|err| format!("Failed to trust hook: {err}")); + .map_err(|err| format!("Failed to trust hook: {}", format_config_error(&err))); app_event_tx.send(AppEvent::HookTrusted { result }); }); } @@ -394,7 +400,7 @@ impl App { let result = write_hook_trusts(request_handle, updates) .await .map(|_| ()) - .map_err(|err| format!("Failed to trust hooks: {err}")); + .map_err(|err| format!("Failed to trust hooks: {}", format_config_error(&err))); app_event_tx.send(AppEvent::HookTrusted { result }); }); } diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 946223ec3e69..319830b82a03 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -482,9 +482,10 @@ impl App { { Ok(response) => response, Err(err) => { - tracing::error!(error = %err, "failed to persist feature flags"); + let error = crate::config_update::format_config_error(&err); + tracing::error!(error = %error, "failed to persist feature flags"); self.chat_widget - .add_error_message(format!("Failed to update experimental features: {err}")); + .add_error_message(format!("Failed to update experimental features: {error}")); return; } }; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 58f090e4c79b..548aa5442781 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -5,6 +5,7 @@ use super::resize_reflow::trailing_run_start; use super::*; +use crate::config_update::format_config_error; #[cfg(target_os = "windows")] use codex_config::types::WindowsSandboxModeToml; @@ -1321,12 +1322,13 @@ impl App { self.chat_widget.add_info_message(message, /*hint*/ None); } Err(err) => { + let error = format_config_error(&err); tracing::error!( - error = %err, + error = %error, "failed to persist model selection" ); self.chat_widget - .add_error_message(format!("Failed to save default model: {err}")); + .add_error_message(format!("Failed to save default model: {error}")); } } } @@ -1945,9 +1947,10 @@ impl App { self.chat_widget.setup_status_line(items, use_theme_colors); } Err(err) => { - tracing::error!(error = %err, "failed to persist status line settings; keeping previous selection"); + let error = format_config_error(&err); + tracing::error!(error = %error, "failed to persist status line settings; keeping previous selection"); self.chat_widget.add_error_message(format!( - "Failed to save status line settings: {err}" + "Failed to save status line settings: {error}" )); } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chained_config_error_wraps_in_history_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chained_config_error_wraps_in_history_snapshot.snap new file mode 100644 index 000000000000..a3a7e21365e8 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chained_config_error_wraps_in_history_snapshot.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests/config_errors_tests.rs +expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) +--- +■ Failed to save default model: config/batchWrite failed +in TUI: Invalid configuration: features.fast_mode=true +is not supported; allowed set [fast_mode=false] diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 2da524910aab..ba89379e734d 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -219,6 +219,8 @@ macro_rules! assert_chatwidget_snapshot { mod app_server; mod approval_requests; mod composer_submission; +#[path = "tests/config_errors_tests.rs"] +mod config_errors; mod exec_flow; mod goal_menu; mod goal_validation; diff --git a/codex-rs/tui/src/chatwidget/tests/config_errors_tests.rs b/codex-rs/tui/src/chatwidget/tests/config_errors_tests.rs new file mode 100644 index 000000000000..645c01f0f412 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/config_errors_tests.rs @@ -0,0 +1,25 @@ +use super::*; + +#[tokio::test] +async fn chained_config_error_wraps_in_history_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.add_error_message( + "Failed to save default model: config/batchWrite failed in TUI: Invalid configuration: features.fast_mode=true is not supported; allowed set [fast_mode=false]" + .to_string(), + ); + + let width = 56; + let height = 8; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + term.set_viewport_area(ratatui::layout::Rect::new(0, 0, width, height)); + for lines in drain_insert_history(&mut rx) { + crate::insert_history::insert_history_lines(&mut term, lines) + .expect("insert history lines"); + } + + assert_chatwidget_snapshot!( + "chained_config_error_wraps_in_history_snapshot", + normalize_snapshot_paths(term.backend().vt100().screen().contents()) + ); +} diff --git a/codex-rs/tui/src/config_update.rs b/codex-rs/tui/src/config_update.rs index a882b21231c1..a71f34b52bc4 100644 --- a/codex-rs/tui/src/config_update.rs +++ b/codex-rs/tui/src/config_update.rs @@ -23,6 +23,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use serde_json::Value as JsonValue; +use std::fmt::Display; use std::path::Path; use uuid::Uuid; @@ -43,6 +44,10 @@ pub(crate) fn app_scoped_key_path(app_id: &str, key_path: &str) -> String { format!("apps.{app_id}.{key_path}") } +pub(crate) fn format_config_error(err: &impl Display) -> String { + format!("{err:#}") +} + fn trusted_project_edit(project_path: &Path) -> ConfigEdit { let project_key = project_trust_key(project_path) .replace('\\', "\\\\") @@ -203,27 +208,5 @@ pub(crate) async fn write_skill_enabled( } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn app_scoped_key_path_quotes_dotted_app_ids() { - assert_eq!( - app_scoped_key_path("plugin.linear", "enabled"), - "apps.\"plugin.linear\".enabled" - ); - } - - #[test] - fn trusted_project_edit_targets_project_trust_level() { - assert_eq!( - trusted_project_edit(Path::new("/workspace/team.project")), - ConfigEdit { - key_path: "projects.\"/workspace/team.project\".trust_level".to_string(), - value: serde_json::json!("trusted"), - merge_strategy: MergeStrategy::Replace, - } - ); - } -} +#[path = "config_update_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/config_update_tests.rs b/codex-rs/tui/src/config_update_tests.rs new file mode 100644 index 000000000000..ee7508862c1b --- /dev/null +++ b/codex-rs/tui/src/config_update_tests.rs @@ -0,0 +1,40 @@ +use super::*; +use color_eyre::eyre::WrapErr; +use pretty_assertions::assert_eq; +use std::path::Path; + +#[test] +fn app_scoped_key_path_quotes_dotted_app_ids() { + assert_eq!( + app_scoped_key_path("plugin.linear", "enabled"), + "apps.\"plugin.linear\".enabled" + ); +} + +#[test] +fn trusted_project_edit_targets_project_trust_level() { + assert_eq!( + trusted_project_edit(Path::new("/workspace/team.project")), + ConfigEdit { + key_path: "projects.\"/workspace/team.project\".trust_level".to_string(), + value: serde_json::json!("trusted"), + merge_strategy: MergeStrategy::Replace, + } + ); +} + +#[test] +fn format_config_error_preserves_server_validation_message() { + let err = Err::<(), _>(color_eyre::eyre::eyre!( + "config/batchWrite failed: Invalid configuration: features.fast_mode=true violates \ + managed requirements; allowed set [fast_mode=false]" + )) + .wrap_err("config/batchWrite failed in TUI") + .unwrap_err(); + + assert_eq!( + format_config_error(&err), + "config/batchWrite failed in TUI: config/batchWrite failed: Invalid configuration: \ + features.fast_mode=true violates managed requirements; allowed set [fast_mode=false]" + ); +} diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 5291934a31f1..e4c4b346d50d 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -32,6 +32,7 @@ use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; use crate::app_server_session::AppServerSession; +use crate::config_update::format_config_error; use crate::config_update::write_trusted_project; use crate::key_hint::KeyBindingListExt; use crate::legacy_core::config::Config; @@ -605,8 +606,9 @@ async fn persist_selected_trust( match result { Ok(()) => true, Err(error) => { + let error = format_config_error(&error); tracing::error!( - "failed to persist trusted project state for {}: {error:?}", + "failed to persist trusted project state for {}: {error}", trust_target.display() ); if let Step::TrustDirectory(widget) = &mut onboarding_screen.steps[trust_step_index] { diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_trust_error.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_trust_error.snap new file mode 100644 index 000000000000..6f7a495d1a5c --- /dev/null +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_trust_error.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/onboarding/trust_directory.rs +expression: terminal.backend() +--- +> You are in /workspace/project + + Do you trust the contents of this directory? Working with untrusted + contents comes with higher risk of prompt injection. Trusting the + directory allows project-local config, hooks, and exec policies to + load. + +› 1. Yes, continue + 2. No, quit + + Failed to set trust for /workspace/project: config/batchWrite failed + in TUI: Invalid configuration: features.fast_mode=true is not + supported; allowed set [fast_mode=false] + + Press enter to continue diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index 36d13e5a5a10..8a98cbca6bb9 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -191,6 +191,18 @@ mod tests { use ratatui::Terminal; use std::path::PathBuf; + fn widget(error: Option) -> TrustDirectoryWidget { + TrustDirectoryWidget { + cwd: PathBuf::from("/workspace/project"), + trust_target: PathBuf::from("/workspace/project"), + show_windows_create_sandbox_hint: false, + should_quit: false, + selection: None, + highlighted: TrustDirectorySelection::Trust, + error, + } + } + #[test] fn release_event_does_not_change_selection() { let mut widget = TrustDirectoryWidget { @@ -217,15 +229,7 @@ mod tests { #[test] fn renders_snapshot_for_git_repo() { - let widget = TrustDirectoryWidget { - cwd: PathBuf::from("/workspace/project"), - trust_target: PathBuf::from("/workspace/project"), - show_windows_create_sandbox_hint: false, - should_quit: false, - selection: None, - highlighted: TrustDirectorySelection::Trust, - error: None, - }; + let widget = widget(/*error*/ None); let mut terminal = Terminal::new(VT100Backend::new(/*width*/ 70, /*height*/ 14)).expect("terminal"); @@ -235,4 +239,20 @@ mod tests { insta::assert_snapshot!(terminal.backend()); } + + #[test] + fn renders_snapshot_for_trust_error() { + let widget = widget(Some( + "Failed to set trust for /workspace/project: config/batchWrite failed in TUI: Invalid configuration: features.fast_mode=true is not supported; allowed set [fast_mode=false]" + .to_string(), + )); + + let mut terminal = + Terminal::new(VT100Backend::new(/*width*/ 70, /*height*/ 18)).expect("terminal"); + terminal + .draw(|f| (&widget).render_ref(f.area(), f.buffer_mut())) + .expect("draw"); + + insta::assert_snapshot!(terminal.backend()); + } } diff --git a/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt.snap index 038534e98a49..49106a3f5b00 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt.snap @@ -2,13 +2,13 @@ source: tui/src/startup_hooks_review.rs expression: "render_lines(&view, 80)" --- - - Hooks need review - 2 hooks are new or changed. - Hooks can run outside the sandbox after you trust them. - -› 1. Review hooks - 2. Trust all and continue - 3. Continue without trusting (hooks won't run) - + + Hooks need review + 2 hooks are new or changed. + Hooks can run outside the sandbox after you trust them. + +› 1. Review hooks + 2. Trust all and continue + 3. Continue without trusting (hooks won't run) + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt_with_trust_error.snap b/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt_with_trust_error.snap index 340c0d233c52..574c31b497d8 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt_with_trust_error.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt_with_trust_error.snap @@ -1,15 +1,17 @@ --- source: tui/src/startup_hooks_review.rs -expression: "render_lines(&view, 80)" +expression: "render_lines(&view, 62)" --- - - Hooks need review - 2 hooks are new or changed. - Hooks can run outside the sandbox after you trust them. - Failed to trust hooks: disk full - -› 1. Review hooks - 2. Trust all and continue - 3. Continue without trusting (hooks won't run) - + + Hooks need review + 2 hooks are new or changed. + Hooks can run outside the sandbox after you trust them. + Failed to trust hooks: config/batchWrite failed in TUI: + Invalid configuration: features.fast_mode=true is not + supported; allowed set [fast_mode=false] + +› 1. Review hooks + 2. Trust all and continue + 3. Continue without trusting (hooks won't run) + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/startup_hooks_review.rs b/codex-rs/tui/src/startup_hooks_review.rs index 559c796aa0d7..67d6133a105f 100644 --- a/codex-rs/tui/src/startup_hooks_review.rs +++ b/codex-rs/tui/src/startup_hooks_review.rs @@ -5,7 +5,9 @@ use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; use tokio::sync::mpsc::unbounded_channel; use tokio_stream::StreamExt; @@ -17,6 +19,7 @@ use crate::bottom_pane::ListSelectionView; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap; +use crate::config_update::format_config_error; use crate::hooks_rpc::HookTrustUpdate; use crate::hooks_rpc::fetch_hooks_list; use crate::hooks_rpc::hook_needs_review; @@ -130,7 +133,9 @@ async fn run_startup_hooks_review_app( ) .await .map(|_| ()) - .map_err(|err| format!("Failed to trust hooks: {err}")); + .map_err(|err| { + format!("Failed to trust hooks: {}", format_config_error(&err)) + }); match result { Ok(()) => return Ok(StartupHooksReviewOutcome::Continue), Err(err) => { @@ -199,7 +204,7 @@ fn selection_view_params( "Hooks can run outside the sandbox after you trust them.".dim(), )); if let Some(error) = trust_all_error { - header.push(Line::from(error.to_string()).red()); + header.push(Paragraph::new(Line::from(error.to_string()).red()).wrap(Wrap { trim: false })); } else if trusting_all { header.push(Line::from("Trusting hooks...".dim())); } @@ -333,7 +338,7 @@ mod tests { } }) .collect::(); - format!("{rendered:width$}", width = area.width as usize) + rendered.trim_end().to_string() }) .collect::>() .join("\n") @@ -373,7 +378,9 @@ mod tests { let keymap = RuntimeKeymap::defaults(); let view = selection_view( &entry(), - Some("Failed to trust hooks: disk full"), + Some( + "Failed to trust hooks: config/batchWrite failed in TUI: Invalid configuration: features.fast_mode=true is not supported; allowed set [fast_mode=false]", + ), /*trusting_all*/ false, AppEventSender::new(tx_raw), &keymap, @@ -381,7 +388,7 @@ mod tests { assert_snapshot!( "startup_hooks_review_prompt_with_trust_error", - render_lines(&view, /*width*/ 80) + render_lines(&view, /*width*/ 62) ); } } From e781816eadf1986c6ccb382b34a8d5bb4c23fb70 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 5 Jun 2026 08:32:42 -0700 Subject: [PATCH 24/59] Open Windows app workspaces via deep link (#26500) ## Summary Fixes #26423. On Windows, `codex app PATH` detected Codex Desktop and launched the app shell target, then only printed a manual instruction to open the workspace. The Desktop app already supports `codex://threads/new?path=...`, so the CLI can open the requested workspace directly. This updates the Windows launcher to normalize the workspace path, encode it into a `codex://threads/new` deep link, and open that URL when Codex Desktop is installed. The installer fallback still opens the Windows installer and prints the workspace path for after installation. --- codex-rs/cli/src/desktop_app/windows.rs | 64 ++++++++++++------------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/codex-rs/cli/src/desktop_app/windows.rs b/codex-rs/cli/src/desktop_app/windows.rs index 932ca00cf2b7..717c54dda48a 100644 --- a/codex-rs/cli/src/desktop_app/windows.rs +++ b/codex-rs/cli/src/desktop_app/windows.rs @@ -11,13 +11,11 @@ pub async fn run_windows_app_open_or_install( workspace: PathBuf, download_url_override: Option, ) -> anyhow::Result<()> { - if let Some(app_id) = find_codex_app_id().await? { - eprintln!("Opening Codex Desktop..."); - open_installed_codex_app(&app_id).await?; - eprintln!( - "In Codex Desktop, open workspace {workspace}.", - workspace = display_workspace_path(&workspace) - ); + let workspace_path = workspace.display().to_string(); + let display_workspace = display_workspace_path(&workspace); + if codex_app_is_installed().await? { + eprintln!("Opening Codex Desktop workspace {display_workspace}..."); + open_url(&codex_new_thread_url(&workspace_path)).await?; return Ok(()); } @@ -28,14 +26,11 @@ pub async fn run_windows_app_open_or_install( if open_url(download_url).await.is_err() && download_url_override.is_none() { open_url(CODEX_MICROSOFT_STORE_WEB_URL).await?; } - eprintln!( - "After installing Codex Desktop, open workspace {workspace}.", - workspace = display_workspace_path(&workspace) - ); + eprintln!("After installing Codex Desktop, open workspace {display_workspace}."); Ok(()) } -async fn find_codex_app_id() -> anyhow::Result> { +async fn codex_app_is_installed() -> anyhow::Result { let output = Command::new("powershell.exe") .arg("-NoProfile") .arg("-Command") @@ -45,20 +40,10 @@ async fn find_codex_app_id() -> anyhow::Result> { .context("failed to invoke `powershell.exe`")?; if !output.status.success() { - return Ok(None); + return Ok(false); } - let app_id = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if app_id.is_empty() { - Ok(None) - } else { - Ok(Some(app_id)) - } -} - -async fn open_installed_codex_app(app_id: &str) -> anyhow::Result<()> { - let target = format!("shell:AppsFolder\\{app_id}"); - open_shell_target(&target).await + Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty()) } async fn open_url(url: &str) -> anyhow::Result<()> { @@ -78,15 +63,11 @@ async fn open_url(url: &str) -> anyhow::Result<()> { } } -async fn open_shell_target(target: &str) -> anyhow::Result<()> { - // Explorer can successfully hand off shell targets and still return exit code 1. - let _status = Command::new("explorer.exe") - .arg(target) - .status() - .await - .with_context(|| format!("failed to open {target}"))?; - - Ok(()) +fn codex_new_thread_url(workspace: &str) -> String { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + serializer.append_pair("path", workspace); + let query = serializer.finish(); + format!("codex://threads/new?{query}") } fn display_workspace_path(workspace: &Path) -> String { @@ -102,6 +83,7 @@ fn display_workspace_path(workspace: &Path) -> String { #[cfg(test)] mod tests { + use super::codex_new_thread_url; use super::display_workspace_path; use pretty_assertions::assert_eq; use std::path::Path; @@ -129,4 +111,20 @@ mod tests { r"C:\Users\fcoury\code\codex" ); } + + #[test] + fn codex_new_thread_url_encodes_windows_workspace_path() { + assert_eq!( + codex_new_thread_url(r"C:\Users\akuma\repos\koba"), + r"codex://threads/new?path=C%3A%5CUsers%5Cakuma%5Crepos%5Ckoba" + ); + } + + #[test] + fn codex_new_thread_url_preserves_verbatim_workspace_path() { + assert_eq!( + codex_new_thread_url(r"\\?\C:\Users\akuma\repos\koba"), + r"codex://threads/new?path=%5C%5C%3F%5CC%3A%5CUsers%5Cakuma%5Crepos%5Ckoba" + ); + } } From fb0993dd3b68315408eaa109924c63f434e85382 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 5 Jun 2026 08:32:53 -0700 Subject: [PATCH 25/59] Fix `/goal` usage text for control commands (#26551) ## Why The TUI's `/goal` usage text only advertised the objective form even though `/goal clear`, `/goal edit`, `/goal pause`, and `/goal resume` are implemented. This made the lifecycle controls difficult to discover and allowed the duplicated help text to drift from actual behavior. Fixes #25530. ## What changed - Show the complete `/goal [|clear|edit|pause|resume]` syntax in usage messages. - Share one usage string across slash-command dispatch and goal-related app messages. - Add inline snapshot coverage for the control-command usage path. --- codex-rs/tui/src/app/thread_goal_actions.rs | 5 +++-- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 2 +- .../tui/src/chatwidget/tests/slash_commands.rs | 15 +++++++++++++++ codex-rs/tui/src/goal_display.rs | 2 ++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/app/thread_goal_actions.rs b/codex-rs/tui/src/app/thread_goal_actions.rs index 591180175502..b1a1bd367866 100644 --- a/codex-rs/tui/src/app/thread_goal_actions.rs +++ b/codex-rs/tui/src/app/thread_goal_actions.rs @@ -6,6 +6,7 @@ use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::goal_display::GOAL_USAGE; use crate::goal_display::goal_status_label; use crate::goal_display::goal_usage_summary; use codex_app_server_protocol::ThreadGoal; @@ -39,7 +40,7 @@ impl App { let Some(goal) = response.goal else { self.chat_widget.add_info_message( - "Usage: /goal ".to_string(), + GOAL_USAGE.to_string(), Some("No goal is currently set.".to_string()), ); return; @@ -280,7 +281,7 @@ impl App { self.chat_widget .add_error_message("No goal is currently set.".to_string()); self.chat_widget.add_info_message( - "Usage: /goal ".to_string(), + GOAL_USAGE.to_string(), Some("Create a goal before editing it.".to_string()), ); } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 08c6db56dc47..48caf8f54d21 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -13,6 +13,7 @@ use crate::bottom_pane::slash_commands::BuiltinCommandFlags; use crate::bottom_pane::slash_commands::ServiceTierCommand; use crate::bottom_pane::slash_commands::SlashCommandItem; use crate::bottom_pane::slash_commands::find_slash_command; +use crate::goal_display::GOAL_USAGE; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SlashCommandDispatchSource { @@ -32,7 +33,6 @@ struct PreparedSlashCommandArgs { const SIDE_STARTING_CONTEXT_LABEL: &str = "Side starting..."; const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str = "Press Ctrl+C to return to the main thread first."; -const GOAL_USAGE: &str = "Usage: /goal "; const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage"; const RAW_USAGE: &str = "Usage: /raw [on|off]"; diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 099ae1d76d82..9c2802fb6041 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -784,6 +784,21 @@ async fn goal_control_slash_commands_emit_goal_events() { } } +#[tokio::test] +async fn goal_control_slash_command_without_thread_shows_full_usage() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + + submit_composer_text(&mut chat, "/goal pause"); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected goal usage message"); + insta::assert_snapshot!( + lines_to_single_string(&cells[0]), + @"• Usage: /goal [|clear|edit|pause|resume] The session must start before you can change a goal." + ); +} + #[tokio::test] async fn goal_edit_slash_command_opens_goal_editor() { for thread_id in [Some(ThreadId::new()), None] { diff --git a/codex-rs/tui/src/goal_display.rs b/codex-rs/tui/src/goal_display.rs index 961b87f16eb1..210bac107678 100644 --- a/codex-rs/tui/src/goal_display.rs +++ b/codex-rs/tui/src/goal_display.rs @@ -2,6 +2,8 @@ use crate::status::format_tokens_compact; use codex_app_server_protocol::ThreadGoal; use codex_app_server_protocol::ThreadGoalStatus; +pub(crate) const GOAL_USAGE: &str = "Usage: /goal [|clear|edit|pause|resume]"; + pub(crate) fn format_goal_elapsed_seconds(seconds: i64) -> String { let seconds = seconds.max(0) as u64; if seconds < 60 { From e5af672d730aa2a11c5e0ca5bc933f8c5195317a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 5 Jun 2026 08:34:34 -0700 Subject: [PATCH 26/59] Render code comment directives in TUI replay (#26554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Resumed Codex App or VS Code review sessions can contain `::code-comment` directives that the TUI previously displayed verbatim because only rich clients interpret them. This change rewrites valid line-start directives into readable Markdown during assistant-message parsing, using the session working directory for relative file paths. The fallback is applied consistently to live messages, replayed transcripts, and resume previews while preserving malformed directives and existing `::git-*` parsing. ## Before The TUI exposed the raw client directive: ```text ::code-comment{title="Fix body= parsing" body="Keep role=\"tab\", ::git-stage{cwd=/tmp}, file=, and \n literal." file="/repo/src/app.ts" start=10 end=12 priority="P2"} ``` ## After The same directive is rendered as readable review feedback: ```text - [P2] Fix body= parsing — src/app.ts:10-12 Keep role="tab", ::git-stage{cwd=/tmp}, file=, and \n literal. ``` Fixes #25658 --- codex-rs/tui/src/chatwidget/streaming.rs | 5 +- codex-rs/tui/src/chatwidget/turn_runtime.rs | 6 +- codex-rs/tui/src/git_action_directives.rs | 151 +++++++++++++++++- codex-rs/tui/src/resume_picker.rs | 3 +- codex-rs/tui/src/resume_picker/transcript.rs | 2 +- ...ests__code_comment_directive_fallback.snap | 11 ++ 6 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__git_action_directives__tests__code_comment_directive_fallback.snap diff --git a/codex-rs/tui/src/chatwidget/streaming.rs b/codex-rs/tui/src/chatwidget/streaming.rs index 3dd9f26ee998..848b0c2ef332 100644 --- a/codex-rs/tui/src/chatwidget/streaming.rs +++ b/codex-rs/tui/src/chatwidget/streaming.rs @@ -38,7 +38,8 @@ impl ChatWidget { // Consolidate the run of streaming AgentMessageCells into a single AgentMarkdownCell // that can re-render from source on resize. if let Some(source) = source { - let source = parse_assistant_markdown(&source).visible_markdown; + let source = + parse_assistant_markdown(&source, self.config.cwd.as_path()).visible_markdown; self.app_event_tx.send(AppEvent::ConsolidateAgentMessage { source, cwd: self.config.cwd.to_path_buf(), @@ -261,7 +262,7 @@ impl ChatWidget { AgentMessageContent::Text { text } => message.push_str(text), } } - let parsed = parse_assistant_markdown(&message); + let parsed = parse_assistant_markdown(&message, self.config.cwd.as_path()); self.finalize_completed_assistant_message( (!parsed.visible_markdown.is_empty()).then_some(parsed.visible_markdown.as_str()), ); diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index bbea783cf205..24674d710e53 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -89,9 +89,9 @@ impl ChatWidget { // source only when no earlier item-level event (AgentMessageItem, plan // commit, review output) already recorded markdown for this turn. This // prevents the final summary from overwriting a more specific source. - let sanitized_last_agent_message = last_agent_message - .as_deref() - .map(|message| parse_assistant_markdown(message).visible_markdown); + let sanitized_last_agent_message = last_agent_message.as_deref().map(|message| { + parse_assistant_markdown(message, self.config.cwd.as_path()).visible_markdown + }); if let Some(message) = sanitized_last_agent_message .as_ref() .filter(|message| !message.is_empty()) diff --git a/codex-rs/tui/src/git_action_directives.rs b/codex-rs/tui/src/git_action_directives.rs index 4dda4d451c3b..c54744c84502 100644 --- a/codex-rs/tui/src/git_action_directives.rs +++ b/codex-rs/tui/src/git_action_directives.rs @@ -1,6 +1,8 @@ -//! Codex App git action directives embedded in assistant markdown. +//! Codex App directives embedded in assistant markdown. +use std::collections::HashMap; use std::collections::HashSet; +use std::path::Path; #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub(crate) enum GitActionDirective { @@ -50,12 +52,16 @@ impl ParsedAssistantMarkdown { } } -pub(crate) fn parse_assistant_markdown(markdown: &str) -> ParsedAssistantMarkdown { +pub(crate) fn parse_assistant_markdown(markdown: &str, cwd: &Path) -> ParsedAssistantMarkdown { let mut git_actions = Vec::new(); let mut seen = HashSet::new(); let mut visible_lines = Vec::new(); for line in markdown.lines() { + if let Some(rewritten) = rewrite_code_comment_line(line, cwd) { + visible_lines.push(rewritten.trim_end().to_string()); + continue; + } let (visible_line, line_actions) = strip_line_directives(line); for action in line_actions { if seen.insert(action.clone()) { @@ -78,6 +84,53 @@ pub(crate) fn parse_assistant_markdown(markdown: &str) -> ParsedAssistantMarkdow } } +fn rewrite_code_comment_line(line: &str, cwd: &Path) -> Option { + let content = line.trim_start_matches([' ', '\t']); + let indent = &line[..line.len() - content.len()]; + let marker_length = content.bytes().take_while(|byte| *byte == b':').count(); + if !(1..=3).contains(&marker_length) { + return None; + } + + let directive = content[marker_length..].strip_prefix("code-comment{")?; + let (attributes, suffix) = directive.rsplit_once('}')?; + let attributes = parse_code_comment_attributes(attributes)?; + let title = attributes.get("title")?; + let body = attributes.get("body")?; + let file = attributes.get("file")?; + let title = title.trim(); + let body = body.trim(); + let file = file.trim(); + (!title.is_empty() && !body.is_empty() && !file.is_empty()).then_some(())?; + + let start = directive_integer(&attributes, "start").unwrap_or(1).max(1); + let end = directive_integer(&attributes, "end") + .unwrap_or(start) + .max(start); + let title = if title_has_priority(title) { + title.to_string() + } else if let Some(priority @ 0..=3) = directive_integer(&attributes, "priority") { + format!("[P{priority}] {title}") + } else { + title.to_string() + }; + let file_path = Path::new(file); + let file = file_path + .strip_prefix(cwd) + .unwrap_or(file_path) + .to_string_lossy() + .replace('\\', "/"); + let location = if start == end { + format!("{file}:{start}") + } else { + format!("{file}:{start}-{end}") + }; + + Some(format!( + "{indent}- {title} — {location}\n{indent} {body}{suffix}" + )) +} + fn strip_line_directives(line: &str) -> (String, Vec) { let mut visible = String::new(); let mut actions = Vec::new(); @@ -106,6 +159,46 @@ fn strip_line_directives(line: &str) -> (String, Vec) { (visible, actions) } +fn directive_integer(attributes: &HashMap, name: &str) -> Option { + attributes + .get(name)? + .trim() + .trim_start_matches(['P', 'p']) + .parse() + .ok() +} + +fn title_has_priority(title: &str) -> bool { + let bytes = title.trim_start().as_bytes(); + bytes.len() >= 4 + && bytes[0] == b'[' + && matches!(bytes[1], b'P' | b'p') + && bytes[2].is_ascii_digit() + && bytes[3] == b']' +} + +fn parse_code_comment_attributes(input: &str) -> Option> { + let mut attributes = HashMap::new(); + let mut rest = input.trim(); + while !rest.is_empty() { + let equals = rest.find('=')?; + let name = rest[..equals].trim(); + if name.is_empty() { + return None; + } + rest = rest[equals + 1..].trim_start(); + let (value, next) = if let Some(quoted) = rest.strip_prefix('"') { + parse_quoted_value(quoted)? + } else { + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + (rest[..end].to_string(), &rest[end..]) + }; + attributes.insert(name.to_string(), value); + rest = next.trim_start(); + } + Some(attributes) +} + fn parse_git_action(name: &str, attributes: &str) -> Option { let attrs = parse_attributes(attributes)?; let cwd = attrs.get("cwd")?.clone(); @@ -153,14 +246,36 @@ fn parse_attributes(input: &str) -> Option Option<(String, &str)> { + let mut value = String::new(); + let mut characters = input.char_indices().peekable(); + + while let Some((index, character)) = characters.next() { + if character == '"' { + return Some((value, &input[index + 1..])); + } + match character { + '\\' if characters.peek().is_some_and(|(_, next)| *next == '"') => { + value.push('"'); + characters.next(); + } + _ => value.push(character), + } + } + + None +} + #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn strips_and_parses_git_action_directives() { let parsed = parse_assistant_markdown( - "Done\n\n::git-stage{cwd=\"/repo\"} ::git-push{cwd=\"/repo\" branch=\"feat/x\"}", + "Done\n\n::git-stage{cwd=\"/repo\"} ::git-push{cwd=\"/repo\" branch=\"feat/x\"} ::git-stage{cwd=\"C:\\repo\\\"}", + Path::new("/repo"), ); assert_eq!(parsed.visible_markdown, "Done"); @@ -174,22 +289,50 @@ mod tests { cwd: "/repo".to_string(), branch: "feat/x".to_string(), }, + GitActionDirective::Stage { + cwd: "C:\\repo\\".to_string(), + }, ] ); } #[test] fn hides_malformed_directives_without_materializing_rows() { - let parsed = parse_assistant_markdown("Done ::git-push{cwd=\"/repo\"}"); + let parsed = parse_assistant_markdown("Done ::git-push{cwd=\"/repo\"}", Path::new("/repo")); assert_eq!(parsed.visible_markdown, "Done"); assert!(parsed.git_actions.is_empty()); } + #[test] + fn renders_code_comment_directives_as_markdown() { + let parsed = parse_assistant_markdown( + concat!( + "Found two issues.\n\n", + r#"::code-comment{title="Fix body= parsing" body="Keep role=\"tab\", ::git-stage{cwd=/tmp}, file=, and \n literal." file="/repo/src/app.ts" start=10 end=12 priority="P2"}"#, + "\n\n", + r#":::code-comment{title="[P1] Clamp the range" body="The line range should match the App." file="codex/src/range.ts" start=8 end=2 priority=3}"#, + ), + Path::new("/repo"), + ); + + insta::assert_snapshot!("code_comment_directive_fallback", parsed.visible_markdown); + assert!(parsed.git_actions.is_empty()); + } + + #[test] + fn preserves_non_directive_and_malformed_code_comment_text() { + let markdown = "Mention `::code-comment{title=\"Example\"}` inline.\n::code-comment{title=\"Missing body\" file=\"/repo/src/app.ts\"}"; + let parsed = parse_assistant_markdown(markdown, Path::new("/repo")); + + assert_eq!(parsed.visible_markdown, markdown); + } + #[test] fn last_created_branch_cwd_uses_the_last_matching_directive() { let parsed = parse_assistant_markdown( "::git-create-branch{cwd=\"/first\" branch=\"first\"}\n::git-push{cwd=\"/repo\" branch=\"first\"}\n::git-create-branch{cwd=\"/second\" branch=\"second\"}", + Path::new("/repo"), ); assert_eq!(parsed.last_created_branch_cwd(), Some("/second")); diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index c261ade7d2e9..de9a46e8ffac 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -774,6 +774,7 @@ async fn load_transcript_preview( .thread_read(thread_id, /*include_turns*/ true) .await .map_err(std::io::Error::other)?; + let cwd = thread.cwd.as_path(); let mut lines = thread .turns .iter() @@ -794,7 +795,7 @@ async fn load_transcript_preview( }), ThreadItem::AgentMessage { text, .. } => Some(TranscriptPreviewLine { speaker: TranscriptPreviewSpeaker::Assistant, - text: parse_assistant_markdown(text).visible_markdown, + text: parse_assistant_markdown(text, cwd).visible_markdown, }), _ => None, }) diff --git a/codex-rs/tui/src/resume_picker/transcript.rs b/codex-rs/tui/src/resume_picker/transcript.rs index abf13bd144c0..e211a7126591 100644 --- a/codex-rs/tui/src/resume_picker/transcript.rs +++ b/codex-rs/tui/src/resume_picker/transcript.rs @@ -67,7 +67,7 @@ pub(crate) fn thread_to_transcript_cells( })); } ThreadItem::AgentMessage { text, .. } => { - let parsed = parse_assistant_markdown(text); + let parsed = parse_assistant_markdown(text, cwd); if !parsed.visible_markdown.trim().is_empty() { cells.push(Arc::new(AgentMarkdownCell::new( parsed.visible_markdown, diff --git a/codex-rs/tui/src/snapshots/codex_tui__git_action_directives__tests__code_comment_directive_fallback.snap b/codex-rs/tui/src/snapshots/codex_tui__git_action_directives__tests__code_comment_directive_fallback.snap new file mode 100644 index 000000000000..630a3a542b87 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__git_action_directives__tests__code_comment_directive_fallback.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/git_action_directives.rs +expression: parsed.visible_markdown +--- +Found two issues. + +- [P2] Fix body= parsing — src/app.ts:10-12 + Keep role="tab", ::git-stage{cwd=/tmp}, file=, and \n literal. + +- [P1] Clamp the range — codex/src/range.ts:8 + The line range should match the App. From d5e4f01af4f7d3937ee4cd54ac27507fa421d755 Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 5 Jun 2026 18:18:29 +0200 Subject: [PATCH 27/59] feat: reload v2 agents on delivery (#26623) ## Summary This is the first small step toward making multi-agent v2 agents durable logical agents whose `ThreadManager` residency is only an implementation detail. This PR adds a narrow v2 reload-on-delivery hook: - If a known v2 agent target is already loaded, delivery is unchanged. - If the target is still registered but missing from `ThreadManager`, delivery reloads that exact v2 thread from durable rollout history before submitting the message. - If the target is unknown, closed, missing from storage, or not a v2 thread, delivery still fails as not found. The reload is wired only into existing-agent delivery paths: v2 `send_message` / `followup_task`, and legacy `send_input` when its target is a known v2 agent. ## Stack 1. **Reload on delivery**: load known unloaded v2 agents before `followup_task`, `send_message`, or `send_input` delivery. This PR. 2. **Residency LRU**: unload idle resident v2 agents from `ThreadManager` without making them closed or unreachable. 3. **Execution concurrency**: count active non-root turns, not logical agents or resident idle threads. 4. **Close semantics**: make v2 close interrupt-only and leave durable agent identity intact. 5. **Resume cleanup**: remove user-facing v2 resume semantics; addressing an unloaded durable agent reloads it implicitly. ## Validation - Ran `just fmt`. - Left broader tests and clippy to CI. --- codex-rs/core/src/agent/control/spawn.rs | 73 ++++++++++++ codex-rs/core/src/agent/control_tests.rs | 107 ++++++++++++++++++ .../tools/handlers/multi_agents/send_input.rs | 13 ++- .../handlers/multi_agents_v2/message_tool.rs | 19 +++- 4 files changed, 206 insertions(+), 6 deletions(-) diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 7cf5d00b4d40..8df3b0a07f82 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -109,6 +109,79 @@ impl AgentControl { .await } + pub(crate) async fn ensure_v2_agent_loaded( + &self, + config: Config, + thread_id: ThreadId, + ) -> CodexResult<()> { + let state = self.upgrade()?; + if state.get_thread(thread_id).await.is_ok() { + return Ok(()); + } + if self.state.agent_metadata_for_thread(thread_id).is_none() { + return Err(CodexErr::ThreadNotFound(thread_id)); + } + + let stored_thread = state + .read_stored_thread(ReadThreadParams { + thread_id, + include_archived: true, + include_history: true, + }) + .await?; + let stored_source = stored_thread.source.clone(); + let stored_parent_thread_id = stored_thread.parent_thread_id; + let history = stored_thread + .history + .ok_or(CodexErr::ThreadNotFound(thread_id))? + .items; + let initial_history = InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history, + rollout_path: stored_thread.rollout_path, + }); + if initial_history.get_multi_agent_version() != Some(MultiAgentVersion::V2) { + return Err(CodexErr::ThreadNotFound(thread_id)); + } + + let (session_source, _) = initial_history + .get_resumed_session_sources() + .unwrap_or((stored_source, None)); + let parent_thread_id = initial_history + .get_resumed_parent_thread_id() + .or(stored_parent_thread_id); + let inherited_shell_snapshot = self + .inherited_shell_snapshot_for_source(&state, Some(&session_source)) + .await; + let inherited_exec_policy = self + .inherited_exec_policy_for_source(&state, Some(&session_source), &config) + .await; + + match state + .resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions { + config, + initial_history, + agent_control: self.clone(), + session_source, + parent_thread_id, + inherited_shell_snapshot, + inherited_exec_policy, + }) + .await + { + Ok(reloaded_thread) => { + state.notify_thread_created(reloaded_thread.thread_id); + Ok(()) + } + Err(err) => { + if state.get_thread(thread_id).await.is_ok() { + return Ok(()); + } + Err(err) + } + } + } + async fn spawn_agent_internal( &self, config: Config, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 3866d22013d0..c9cd44492549 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -527,6 +527,113 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig )); } +#[tokio::test] +async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() { + let (home, mut config) = test_config().await; + let _ = config.features.enable(Feature::MultiAgentV2); + let _ = config.features.enable(Feature::Sqlite); + let state_db = init_state_db(&config).await; + let manager = ThreadManager::with_models_provider_home_and_state_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + state_db.clone(), + ); + let control = manager.agent_control(); + let harness = AgentControlHarness { + _home: home, + config, + state_db, + manager, + control, + }; + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + let agent_path = AgentPath::try_from("/root/worker").expect("agent path"); + let spawned_agent = harness + .control + .spawn_agent_with_metadata( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: Some(agent_path.clone()), + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + parent_thread_id: Some(parent_thread_id), + ..Default::default() + }, + ) + .await + .expect("spawn_agent should succeed"); + let child_thread = harness + .manager + .get_thread(spawned_agent.thread_id) + .await + .expect("child thread should exist"); + child_thread + .inject_response_items(vec![assistant_message( + "child persisted", + Some(MessagePhase::FinalAnswer), + )]) + .await + .expect("child rollout should persist with v2 metadata"); + child_thread + .shutdown_and_wait() + .await + .expect("child thread should shut down"); + + assert!( + harness + .manager + .remove_thread(&spawned_agent.thread_id) + .await + .is_some() + ); + match harness.manager.get_thread(spawned_agent.thread_id).await { + Err(CodexErr::ThreadNotFound(id)) => assert_eq!(id, spawned_agent.thread_id), + Err(err) => panic!("expected ThreadNotFound, got {err:?}"), + Ok(_) => panic!("expected thread to be removed"), + } + + harness + .control + .ensure_v2_agent_loaded(harness.config.clone(), spawned_agent.thread_id) + .await + .expect("known v2 agent should reload"); + let _ = harness + .manager + .get_thread(spawned_agent.thread_id) + .await + .expect("reloaded child thread should exist"); + + let communication = InterAgentCommunication::new( + AgentPath::root(), + agent_path, + Vec::new(), + "hello after reload".to_string(), + /*trigger_turn*/ false, + ); + harness + .control + .send_inter_agent_communication(spawned_agent.thread_id, communication.clone()) + .await + .expect("send_inter_agent_communication should succeed after reload"); + let expected = ( + spawned_agent.thread_id, + Op::InterAgentCommunication { communication }, + ); + let captured = harness + .manager + .captured_ops() + .into_iter() + .find(|entry| *entry == expected); + assert_eq!(captured, Some(expected)); +} + #[tokio::test] async fn encrypted_inter_agent_communication_clears_existing_last_task_message() { let harness = AgentControlHarness::new().await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index 1b7018939e35..6e28d0a1d97e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -42,8 +42,17 @@ impl ToolExecutor for Handler { let receiver_agent = session .services .agent_control - .get_agent_metadata(receiver_thread_id) - .unwrap_or_default(); + .get_agent_metadata(receiver_thread_id); + if receiver_agent.is_some() { + let resume_config = build_agent_resume_config(turn.as_ref())?; + session + .services + .agent_control + .ensure_v2_agent_loaded(resume_config, receiver_thread_id) + .await + .map_err(|err| collab_agent_error(receiver_thread_id, err))?; + } + let receiver_agent = receiver_agent.unwrap_or_default(); if args.interrupt { session .services diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index f23d700e9e49..528fa55264c0 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -75,7 +75,11 @@ pub(crate) async fn handle_message_string_tool( .services .agent_control .get_agent_metadata(receiver_thread_id) - .unwrap_or_default(); + .ok_or_else(|| { + FunctionCallError::RespondToModel(format!( + "agent with id {receiver_thread_id} not found" + )) + })?; if mode == MessageDeliveryMode::TriggerTurn && receiver_agent .agent_path @@ -86,6 +90,16 @@ pub(crate) async fn handle_message_string_tool( "Follow-up tasks can't target the root agent".to_string(), )); } + let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { + FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string()) + })?; + let resume_config = build_agent_resume_config(turn.as_ref())?; + session + .services + .agent_control + .ensure_v2_agent_loaded(resume_config, receiver_thread_id) + .await + .map_err(|err| collab_agent_error(receiver_thread_id, err))?; session .send_event( &turn, @@ -99,9 +113,6 @@ pub(crate) async fn handle_message_string_tool( .into(), ) .await; - let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { - FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string()) - })?; let author = turn .session_source .get_agent_path() From 40c8f1a0072e04f2df049c2233a28543604fc448 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 5 Jun 2026 09:29:15 -0700 Subject: [PATCH 28/59] Require absolute cwd in thread settings (#26532) ## Why Thread settings cwd overrides are expected to be resolved before they enter core. Keeping this boundary as a plain `PathBuf` made it easy for core/session code to keep fallback normalization and relative-path resolution logic in places that should only receive an already-resolved cwd. This is intentionally the absolute-cwd-only slice: it does not change environment selection stickiness or cwd-to-default-environment fallback behavior. ## What changed - Changes `ThreadSettingsOverrides.cwd`, `CodexThreadSettingsOverrides.cwd`, and `SessionSettingsUpdate.cwd` to use `AbsolutePathBuf`. - Removes core-side cwd normalization/resolution from session settings updates. - Updates affected core/app-server test helpers and callsites to pass existing absolute cwd values or use `abs()` helpers. ## Validation Opening as draft so CI can start while local validation continues. --- .../src/request_processors/turn_processor.rs | 18 ++++++++++--- codex-rs/core/src/codex_thread.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 2 +- codex-rs/core/src/session/mod.rs | 1 - codex-rs/core/src/session/session.rs | 16 ++---------- codex-rs/core/src/session/tests.rs | 16 ++++++------ codex-rs/core/tests/common/test_codex.rs | 6 ++++- codex-rs/core/tests/suite/apply_patch_cli.rs | 2 +- codex-rs/core/tests/suite/approvals.rs | 8 +++--- codex-rs/core/tests/suite/auto_review.rs | 3 ++- codex-rs/core/tests/suite/client.rs | 4 +-- codex-rs/core/tests/suite/code_mode.rs | 2 +- .../tests/suite/collaboration_instructions.rs | 6 ++--- codex-rs/core/tests/suite/compact.rs | 6 +++-- codex-rs/core/tests/suite/compact_remote.rs | 5 ++-- .../core/tests/suite/compact_resume_fork.rs | 2 +- codex-rs/core/tests/suite/exec_policy.rs | 4 +-- codex-rs/core/tests/suite/guardian_review.rs | 2 +- codex-rs/core/tests/suite/image_rollout.rs | 5 ++-- codex-rs/core/tests/suite/items.rs | 3 ++- codex-rs/core/tests/suite/json_result.rs | 7 +++--- .../core/tests/suite/mcp_turn_metadata.rs | 2 +- codex-rs/core/tests/suite/model_switching.rs | 2 +- .../core/tests/suite/model_visible_layout.rs | 8 +++++- codex-rs/core/tests/suite/models_cache_ttl.rs | 2 +- .../core/tests/suite/models_etag_responses.rs | 3 ++- codex-rs/core/tests/suite/override_updates.rs | 3 ++- codex-rs/core/tests/suite/pending_input.rs | 2 +- codex-rs/core/tests/suite/personality.rs | 2 +- codex-rs/core/tests/suite/prompt_caching.rs | 10 ++++---- codex-rs/core/tests/suite/remote_env.rs | 2 +- codex-rs/core/tests/suite/remote_models.rs | 5 ++-- .../core/tests/suite/request_permissions.rs | 2 +- .../tests/suite/request_permissions_tool.rs | 2 +- .../core/tests/suite/request_user_input.rs | 7 +++--- .../suite/responses_api_proxy_headers.rs | 2 +- codex-rs/core/tests/suite/review.rs | 2 +- codex-rs/core/tests/suite/rmcp_client.rs | 2 +- .../tests/suite/safety_check_downgrade.rs | 2 +- codex-rs/core/tests/suite/shell_snapshot.rs | 8 +++--- codex-rs/core/tests/suite/skill_approval.rs | 2 +- codex-rs/core/tests/suite/skills.rs | 2 +- codex-rs/core/tests/suite/sqlite_state.rs | 2 +- .../tests/suite/subagent_notifications.rs | 2 +- codex-rs/core/tests/suite/tool_harness.rs | 11 ++++---- codex-rs/core/tests/suite/tool_parallelism.rs | 4 +-- codex-rs/core/tests/suite/truncation.rs | 3 ++- codex-rs/core/tests/suite/unified_exec.rs | 25 ++++++++++--------- codex-rs/core/tests/suite/user_shell_cmd.rs | 2 +- codex-rs/core/tests/suite/view_image.rs | 2 +- .../core/tests/suite/websocket_fallback.rs | 3 ++- codex-rs/protocol/src/protocol.rs | 2 +- 52 files changed, 136 insertions(+), 112 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 8ab6cf7731a0..85342235c96e 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -32,6 +32,14 @@ fn resolve_runtime_workspace_roots( resolved_roots } +fn resolve_request_cwd(cwd: Option) -> Result, JSONRPCErrorError> { + cwd.map(|cwd| { + AbsolutePathBuf::relative_to_current_dir(path_utils::normalize_for_native_workdir(cwd)) + .map_err(|err| invalid_request(format!("invalid cwd: {err}"))) + }) + .transpose() +} + fn map_additional_context( additional_context: Option>, ) -> BTreeMap { @@ -57,7 +65,7 @@ fn map_additional_context( struct ThreadSettingsBuildParams { method: &'static str, - cwd: Option, + cwd: Option, runtime_workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, @@ -419,12 +427,13 @@ impl TurnRequestProcessor { let client_user_message_id = params.client_user_message_id; let additional_context = map_additional_context(params.additional_context); let turn_has_input = !mapped_items.is_empty(); + let cwd = resolve_request_cwd(params.cwd)?; let thread_settings = self .build_thread_settings_overrides( thread.as_ref(), ThreadSettingsBuildParams { method: "turn/start", - cwd: params.cwd, + cwd, runtime_workspace_roots: params.runtime_workspace_roots, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, @@ -572,7 +581,7 @@ impl TurnRequestProcessor { ))); }; let overrides = ConfigOverrides { - cwd: cwd.clone(), + cwd: cwd.as_ref().map(AbsolutePathBuf::to_path_buf), workspace_roots: Some(runtime_workspace_roots_request.clone().unwrap_or_else( || { snapshot @@ -666,12 +675,13 @@ impl TurnRequestProcessor { params: ThreadSettingsUpdateParams, ) -> Result { let (_, thread) = self.load_thread(¶ms.thread_id).await?; + let cwd = resolve_request_cwd(params.cwd)?; let thread_settings = self .build_thread_settings_overrides( thread.as_ref(), ThreadSettingsBuildParams { method: "thread/settings/update", - cwd: params.cwd, + cwd, runtime_workspace_roots: None, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index f9f10881b56b..d87b65e8aea1 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -127,7 +127,7 @@ impl ThreadConfigSnapshot { /// Thread settings overrides that app-server validates before starting a turn. #[derive(Clone, Default)] pub struct CodexThreadSettingsOverrides { - pub cwd: Option, + pub cwd: Option, pub workspace_roots: Option>, pub profile_workspace_roots: Option>, pub approval_policy: Option, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index d81f84590b21..8504690b299a 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -733,7 +733,7 @@ async fn run_review_on_session( additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { #[allow(deprecated)] - cwd: Some(params.parent_turn.cwd.to_path_buf()), + cwd: Some(params.parent_turn.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: None, permission_profile: Some(guardian_permission_profile), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 1b40bd765697..37d55256f5e6 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -32,7 +32,6 @@ use crate::default_skill_metadata_budget; use crate::environment_selection::ResolvedTurnEnvironments; use crate::exec_policy::ExecPolicyManager; use crate::parse_turn_item; -use crate::path_utils::normalize_for_native_workdir; use crate::realtime_conversation::RealtimeConversationManager; use crate::session_prefix::format_subagent_notification_message; use crate::skills::SkillRenderSideEffects; diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 9314a743a59c..e7ec3b98dbd9 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -251,19 +251,7 @@ impl SessionConfiguration { next_configuration.windows_sandbox_level = windows_sandbox_level; } - let absolute_cwd = updates - .cwd - .as_ref() - .map(|cwd| { - AbsolutePathBuf::relative_to_current_dir(normalize_for_native_workdir( - cwd.as_path(), - )) - .unwrap_or_else(|e| { - warn!("failed to normalize update cwd: {cwd:?}: {e}"); - self.cwd.clone() - }) - }) - .unwrap_or_else(|| self.cwd.clone()); + let absolute_cwd = updates.cwd.clone().unwrap_or_else(|| self.cwd.clone()); let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path(); next_configuration.cwd = absolute_cwd; @@ -415,7 +403,7 @@ impl SessionConfiguration { #[derive(Default, Clone)] pub(crate) struct SessionSettingsUpdate { - pub(crate) cwd: Option, + pub(crate) cwd: Option, pub(crate) workspace_roots: Option>, pub(crate) profile_workspace_roots: Option>, pub(crate) approval_policy: Option, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index a2023ea378cc..abe1d8f07a7a 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3932,6 +3932,7 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd let original_cwd = project_root.join("subdir"); let docs_dir = original_cwd.join("docs"); std::fs::create_dir_all(&docs_dir).expect("create docs dir"); + let project_root = project_root.abs(); let docs_dir = docs_dir.abs(); session_configuration.cwd = original_cwd.abs(); @@ -4152,7 +4153,7 @@ async fn session_configuration_apply_retargets_implicit_workspace_root_on_cwd_up let updated = session_configuration .apply(&SessionSettingsUpdate { - cwd: Some(new_root.to_path_buf()), + cwd: Some(new_root.clone()), ..Default::default() }) .expect("cwd-only update should succeed"); @@ -4391,7 +4392,7 @@ async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_upda let updated = session_configuration .apply(&SessionSettingsUpdate { - cwd: Some(project_root.to_path_buf()), + cwd: Some(project_root.clone()), ..Default::default() }) .expect("cwd-only update should succeed"); @@ -4420,6 +4421,7 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up std::fs::create_dir_all(&original_cwd).expect("create original cwd"); std::fs::create_dir_all(&next_cwd).expect("create next cwd"); let original_cwd = original_cwd.abs(); + let next_cwd = next_cwd.abs(); session_configuration.cwd = original_cwd.clone(); let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ @@ -4480,7 +4482,7 @@ async fn session_update_settings_does_not_rewrite_sticky_environment_cwds() { session .update_settings(SessionSettingsUpdate { - cwd: Some(PathBuf::from("project")), + cwd: Some(updated_cwd.clone()), ..Default::default() }) .await @@ -4516,7 +4518,7 @@ async fn relative_cwd_update_without_environments_resolves_under_session_cwd() { session .update_settings(SessionSettingsUpdate { - cwd: Some(PathBuf::from("project")), + cwd: Some(updated_cwd.clone()), ..Default::default() }) .await @@ -4545,7 +4547,7 @@ async fn cwd_update_does_not_rewrite_sticky_environment_cwd() { session .update_settings(SessionSettingsUpdate { - cwd: Some(PathBuf::from("project")), + cwd: Some(updated_cwd.clone()), ..Default::default() }) .await @@ -4572,7 +4574,7 @@ async fn absolute_cwd_update_with_turn_environment_is_allowed() { .new_turn_with_sub_id( "sub-1".to_string(), SessionSettingsUpdate { - cwd: Some(absolute_cwd.to_path_buf()), + cwd: Some(absolute_cwd.clone()), environments: Some(vec![TurnEnvironmentSelection { environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), cwd: absolute_cwd.clone(), @@ -5989,7 +5991,7 @@ async fn user_turn_updates_approvals_reviewer() { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(config.cwd.to_path_buf()), + cwd: Some(config.cwd.clone()), approval_policy: Some(config.permissions.approval_policy.value()), approvals_reviewer: Some(codex_config::types::ApprovalsReviewer::AutoReview), sandbox_policy: Some(config.legacy_sandbox_policy()), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 4fe0609a7154..496bf03f5701 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -772,7 +772,7 @@ impl TestCodex { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(self.config.cwd.to_path_buf()), + cwd: Some(self.config.cwd.clone()), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -846,6 +846,10 @@ impl TestCodexHarness { self.test.config.cwd.as_path() } + pub fn cwd_abs(&self) -> AbsolutePathBuf { + self.test.config.cwd.clone() + } + pub fn path(&self, rel: impl AsRef) -> PathBuf { self.path_abs(rel).into_path_buf() } diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index da138851f55c..1e2493a807f3 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -93,7 +93,7 @@ async fn submit_without_wait_with_turn_permissions( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(harness.cwd().to_path_buf()), + cwd: Some(harness.cwd_abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index aceac7320e43..865aabb46c01 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -663,7 +663,7 @@ async fn submit_turn( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), @@ -2624,7 +2624,7 @@ async fn env_zsh_script_spawned_by_python_can_request_escalation_under_zsh_fork( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), @@ -2769,7 +2769,7 @@ async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), @@ -3361,7 +3361,7 @@ allow_local_binding = true responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(turn_sandbox_policy), diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index 64ae10d6887d..5de8f421b983 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -22,6 +22,7 @@ use codex_protocol::protocol::Op; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -150,7 +151,7 @@ async fn remote_model_override_uses_catalog_model_for_strict_auto_review() -> Re ) .await?; - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::read_only(), cwd_path.as_path()); codex diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 72e415e2be76..7c9e07d230b1 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1799,7 +1799,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(config.cwd.to_path_buf()), + cwd: Some(config.cwd.clone()), approval_policy: Some(config.permissions.approval_policy.value()), sandbox_policy: Some(config.legacy_sandbox_policy()), summary: Some( @@ -1987,7 +1987,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(config.cwd.to_path_buf()), + cwd: Some(config.cwd.clone()), approval_policy: Some(config.permissions.approval_policy.value()), sandbox_policy: Some(config.legacy_sandbox_policy()), summary: Some(ReasoningSummary::Concise), diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index bc311d424553..fca4556972d9 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -3079,7 +3079,7 @@ text( ) .await; - let cwd = test.cwd.path().to_path_buf(); + let cwd = test.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index bbbb0f1aa23e..0d4f4cf6f808 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -174,7 +174,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(test.config.permissions.approval_policy.value()), sandbox_policy: Some(test.config.legacy_sandbox_policy()), summary: Some( @@ -225,7 +225,7 @@ async fn collaboration_instructions_omitted_when_disabled() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(test.config.permissions.approval_policy.value()), sandbox_policy: Some(test.config.legacy_sandbox_policy()), summary: Some( @@ -334,7 +334,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(test.config.permissions.approval_policy.value()), sandbox_policy: Some(test.config.legacy_sandbox_policy()), summary: Some( diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 0e2c02de9170..5fe90bf7a778 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -23,6 +23,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; +use core_test_support::PathBufExt; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::context_snapshot::ContextSnapshotRenderMode; @@ -32,6 +33,7 @@ use core_test_support::responses::mount_models_once; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; +use core_test_support::test_path_buf; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use std::path::PathBuf; @@ -99,7 +101,7 @@ fn disabled_permission_user_turn(text: impl Into, cwd: PathBuf, model: S responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -3697,7 +3699,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess core_test_support::submit_thread_settings( &codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(PathBuf::from(PRETURN_CONTEXT_DIFF_CWD)), + cwd: Some(test_path_buf(PRETURN_CONTEXT_DIFF_CWD).abs()), ..Default::default() }, ) diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index ebb78a017608..2200c7b77823 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -1,7 +1,6 @@ #![allow(clippy::expect_used)] use std::fs; -use std::path::PathBuf; use anyhow::Result; use codex_core::compact::SUMMARY_PREFIX; @@ -24,6 +23,7 @@ use codex_protocol::protocol::RealtimeOutputModality; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; +use core_test_support::PathBufExt; use core_test_support::apps_test_server::configure_search_capable_model; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; @@ -36,6 +36,7 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::TestCodexHarness; use core_test_support::test_codex::test_codex; +use core_test_support::test_path_buf; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use core_test_support::wait_for_event_with_timeout; @@ -3313,7 +3314,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us core_test_support::submit_thread_settings( &codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(PathBuf::from(PRETURN_CONTEXT_DIFF_CWD)), + cwd: Some(test_path_buf(PRETURN_CONTEXT_DIFF_CWD).abs()), ..Default::default() }, ) diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 54056b5b9dbf..47d1d1bb5827 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -551,7 +551,7 @@ async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { core_test_support::submit_thread_settings( &conversation, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(override_cwd.to_path_buf()), + cwd: Some(override_cwd.clone()), collaboration_mode: Some(CollaborationMode { mode: ModeKind::Default, settings: Settings { diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 9e350d27730a..6859c6c884ce 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -56,7 +56,7 @@ async fn submit_user_turn( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -144,7 +144,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index df38fb43539c..32808dc434e8 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -120,7 +120,7 @@ printf '%s\n' "${@: -1}" >> "${payload_path}""#, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::AutoReview), sandbox_policy: Some(sandbox_policy), diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 7018274510e0..bcfa0b739227 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -9,6 +9,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -129,7 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -228,7 +229,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index a56b2ab95f5d..a0010b6ffe53 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -17,6 +17,7 @@ use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::PathBufExt; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_image_generation_call; @@ -47,7 +48,7 @@ fn disabled_plan_turn( _model: String, collaboration_mode: CollaborationMode, ) -> anyhow::Result { - let cwd = std::env::current_dir()?; + let cwd = std::env::current_dir()?.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); Ok(Op::UserInput { diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 67275d314687..e65d290b8786 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -69,9 +69,10 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { }; responses::mount_sse_once_match(&server, match_json_text_param, sse1).await; - let TestCodex { codex, cwd, .. } = test_codex().build(&server).await?; + let TestCodex { codex, config, .. } = test_codex().build(&server).await?; + let cwd = config.cwd.clone(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::Disabled, cwd.path()); + turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); // 1) Normal user input – should hit server once. codex @@ -85,7 +86,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/mcp_turn_metadata.rs b/codex-rs/core/tests/suite/mcp_turn_metadata.rs index 897d8621628a..b0c1910d6091 100644 --- a/codex-rs/core/tests/suite/mcp_turn_metadata.rs +++ b/codex-rs/core/tests/suite/mcp_turn_metadata.rs @@ -78,7 +78,7 @@ async fn submit_user_turn( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index a80c43660c45..b0d4bb558d9f 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -50,7 +50,7 @@ fn read_only_user_turn(test: &TestCodex, items: Vec, model: String) - responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 27d92d27749e..25dd52174d7f 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -11,6 +11,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::PathBufExt; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::context_snapshot::ContextSnapshotRenderMode; @@ -112,7 +113,8 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { let test = builder.build(&server).await?; let preturn_context_diff_cwd = test.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD); fs::create_dir_all(&preturn_context_diff_cwd)?; - let first_turn_cwd = test.cwd_path().to_path_buf(); + let preturn_context_diff_cwd = preturn_context_diff_cwd.abs(); + let first_turn_cwd = test.config.cwd.clone(); let (first_sandbox_policy, first_permission_profile) = turn_permission_fields(PermissionProfile::read_only(), first_turn_cwd.as_path()); @@ -239,6 +241,8 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R cwd_two.join("AGENTS.md"), "# AGENTS two\n\n\nTurn two agents instructions.\n\n", )?; + let cwd_one = cwd_one.abs(); + let cwd_two = cwd_two.abs(); let (first_sandbox_policy, first_permission_profile) = turn_permission_fields(PermissionProfile::read_only(), cwd_one.as_path()); @@ -397,6 +401,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul let resumed = resume_builder.resume(&server, home, rollout_path).await?; let resume_override_cwd = resumed.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD); fs::create_dir_all(&resume_override_cwd)?; + let resume_override_cwd = resume_override_cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields( PermissionProfile::read_only(), resume_override_cwd.as_path(), @@ -508,6 +513,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - let resumed = resume_builder.resume(&server, home, rollout_path).await?; let resume_override_cwd = resumed.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD); fs::create_dir_all(&resume_override_cwd)?; + let resume_override_cwd = resume_override_cwd.abs(); core_test_support::submit_thread_settings( &resumed.codex, codex_protocol::protocol::ThreadSettingsOverrides { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index d27c43cfc611..485a51346d78 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -102,7 +102,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(codex_protocol::protocol::AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index cbd73fffe9cf..11935ca27fbb 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -12,6 +12,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -62,7 +63,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch let codex = Arc::clone(&test.codex); let cwd = Arc::clone(&test.cwd); let session_model = test.session_configured.model.clone(); - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); diff --git a/codex-rs/core/tests/suite/override_updates.rs b/codex-rs/core/tests/suite/override_updates.rs index ce7c87a93b90..e42703dff872 100644 --- a/codex-rs/core/tests/suite/override_updates.rs +++ b/codex-rs/core/tests/suite/override_updates.rs @@ -6,6 +6,7 @@ use codex_protocol::config_types::Settings; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use core_test_support::TempDirExt; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; @@ -67,7 +68,7 @@ async fn thread_settings_update_without_user_turn_does_not_record_environment_up core_test_support::submit_thread_settings( &test.codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(new_cwd.path().to_path_buf()), + cwd: Some(new_cwd.abs()), ..Default::default() }, ) diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 96b89caec84a..8a6443b6ce7f 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -124,7 +124,7 @@ async fn submit_danger_full_access_user_turn(test: &TestCodex, text: &str) { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index bf411d9cd171..4bd335bb354c 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -70,7 +70,7 @@ fn read_only_text_turn_with_personality( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index ea646a4dbc4c..dddfd4af683b 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -767,7 +767,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(new_cwd.path().to_path_buf()), + cwd: Some(new_cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -884,7 +884,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.to_path_buf()), + cwd: Some(default_cwd.clone()), approval_policy: Some(default_approval_policy), sandbox_policy: Some(default_sandbox_policy.clone()), summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -913,7 +913,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.to_path_buf()), + cwd: Some(default_cwd.clone()), approval_policy: Some(default_approval_policy), sandbox_policy: Some(default_sandbox_policy.clone()), summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -1027,7 +1027,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.to_path_buf()), + cwd: Some(default_cwd.clone()), approval_policy: Some(default_approval_policy), sandbox_policy: Some(default_sandbox_policy.clone()), summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -1058,7 +1058,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.to_path_buf()), + cwd: Some(default_cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 0862482f9dc1..9363bdd3bd88 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -81,7 +81,7 @@ async fn submit_turn_with_approval_and_environments( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(SandboxPolicy::new_read_only_policy()), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 0ed39216d04c..76e7a1c29e91 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -23,6 +23,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::load_default_config_for_test; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -573,7 +574,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex @@ -801,7 +802,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { ) .await?; - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index a78e4a016eb8..3beecfc33ec5 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -200,7 +200,7 @@ async fn submit_turn( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index a122cc399b3d..9b8761c85cfb 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -152,7 +152,7 @@ async fn submit_turn( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer, sandbox_policy: Some(sandbox_policy), diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index d09071d72d99..50cabe416e54 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -13,6 +13,7 @@ use codex_protocol::protocol::Op; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::responses; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; @@ -146,7 +147,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -290,7 +291,7 @@ async fn request_user_input_interrupt_emits_deferred_token_count() -> anyhow::Re responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -395,7 +396,7 @@ where responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs index 144feb016fd4..8492f08fddfe 100644 --- a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs +++ b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs @@ -141,7 +141,7 @@ async fn responses_api_parent_and_subagent_requests_include_identity_headers() - async fn submit_turn_with_timeout(test: &TestCodex, prompt: &str) -> Result<()> { let session_model = test.session_configured.model.clone(); - let cwd = test.config.cwd.to_path_buf(); + let cwd = test.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::workspace_write(), cwd.as_path()); test.codex diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 0d3f56489317..5f07d0b39ca0 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -821,7 +821,7 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { core_test_support::submit_thread_settings( &codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(repo_path.to_path_buf()), + cwd: Some(repo_path.to_path_buf().abs()), ..Default::default() }, ) diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 552c4ed52989..97452542726c 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -120,7 +120,7 @@ fn user_turn_with_permission_profile( model: String, permission_profile: PermissionProfile, ) -> Op { - let cwd = fixture.cwd.path().to_path_buf(); + let cwd = fixture.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, cwd.as_path()); Op::UserInput { diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 532f4821d956..d21299b1eaed 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -47,7 +47,7 @@ fn disabled_text_turn(test: &TestCodex, text: &str) -> Op { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 69db30b21b44..61ca2860052d 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -154,7 +154,7 @@ async fn run_snapshot_command_with_options( let codex = test.codex.clone(); let codex_home = test.home.path().to_path_buf(); let session_model = test.session_configured.model.clone(); - let cwd = test.cwd_path().to_path_buf(); + let cwd = test.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); @@ -255,7 +255,7 @@ async fn run_shell_command_snapshot_with_options( let codex = test.codex.clone(); let codex_home = test.home.path().to_path_buf(); let session_model = test.session_configured.model.clone(); - let cwd = test.cwd_path().to_path_buf(); + let cwd = test.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); @@ -337,7 +337,7 @@ async fn run_tool_turn_on_harness( let test = harness.test(); let codex = test.codex.clone(); let session_model = test.session_configured.model.clone(); - let cwd = test.cwd_path().to_path_buf(); + let cwd = test.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); codex @@ -554,7 +554,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { let test = harness.test(); let codex = test.codex.clone(); - let cwd = test.cwd_path().to_path_buf(); + let cwd = test.config.cwd.clone(); let codex_home = test.home.path().to_path_buf(); let target = cwd.join("snapshot-apply.txt"); diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index 2335ba641556..d60d719cc880 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -56,7 +56,7 @@ async fn submit_turn_with_policies( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index e21668fa8428..c559615d4e29 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -90,7 +90,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 94deeb48a0db..9f8c940a0f60 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -462,7 +462,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< wait_for_mcp_server(&test.codex, server_name).await?; let db = test.codex.state_db().expect("state db enabled"); let thread_id = test.session_configured.thread_id; - let cwd = test.cwd_path().to_path_buf(); + let cwd = test.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::read_only(), cwd.as_path()); diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 97d60d67c909..391b6e52365e 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -773,7 +773,7 @@ async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<() responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index a6f808b9d791..d628c8c49d82 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -10,6 +10,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::ResponsesRequest; @@ -97,7 +98,7 @@ async fn shell_command_tool_executes_command_and_streams_output() -> anyhow::Res let second_mock = responses::mount_sse_once(&server, second_response).await; let session_model = session_configured.model.clone(); - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); @@ -179,7 +180,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { let second_mock = responses::mount_sse_once(&server, second_response).await; let session_model = session_configured.model.clone(); - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); @@ -271,7 +272,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { let second_mock = responses::mount_sse_once(&server, second_response).await; let session_model = session_configured.model.clone(); - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); @@ -373,7 +374,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() let second_mock = responses::mount_sse_once(&server, second_response).await; let session_model = session_configured.model.clone(); - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); @@ -512,7 +513,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { let second_mock = responses::mount_sse_once(&server, second_response).await; let session_model = session_configured.model.clone(); - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 76bb27c4230e..f7f046fef77c 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -47,7 +47,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -374,7 +374,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index db90b663e092..35a6b0df6d67 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -10,6 +10,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; @@ -528,7 +529,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(fixture.cwd.path().to_path_buf()), + cwd: Some(fixture.cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile: Some(permission_profile), diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index d86a0f620e18..3eb2c6a6d3bf 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::ExecCommandStatus; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::assert_regex_match; use core_test_support::managed_network_requirements_loader; use core_test_support::process::process_is_alive; @@ -202,7 +203,7 @@ async fn submit_unified_exec_turn( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -278,7 +279,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { let test = harness.test(); let codex = test.codex.clone(); - let cwd = test.cwd_path().to_path_buf(); + let cwd = test.config.cwd.clone(); let session_model = test.session_configured.model.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, &cwd); @@ -2135,9 +2136,9 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); - let turn_cwd = cwd.path().to_path_buf(); + let turn_cwd = cwd.abs(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::Disabled, &turn_cwd); + turn_permission_fields(PermissionProfile::Disabled, turn_cwd.as_path()); codex .submit(Op::UserInput { @@ -2239,9 +2240,9 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); - let turn_cwd = cwd.path().to_path_buf(); + let turn_cwd = cwd.abs(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::Disabled, &turn_cwd); + turn_permission_fields(PermissionProfile::Disabled, turn_cwd.as_path()); codex .submit(Op::UserInput { @@ -2712,9 +2713,9 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); - let turn_cwd = cwd.path().to_path_buf(); + let turn_cwd = cwd.abs(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); + turn_permission_fields(PermissionProfile::read_only(), turn_cwd.as_path()); codex .submit(Op::UserInput { @@ -2836,9 +2837,9 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); - let turn_cwd = cwd.path().to_path_buf(); + let turn_cwd = cwd.abs(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); + turn_permission_fields(PermissionProfile::read_only(), turn_cwd.as_path()); codex .submit(Op::UserInput { items: vec![UserInput::Text { @@ -2974,9 +2975,9 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); - let turn_cwd = cwd.path().to_path_buf(); + let turn_cwd = cwd.abs(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); + turn_permission_fields(PermissionProfile::read_only(), turn_cwd.as_path()); codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 3e2263705ab3..fe9715f61e96 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -168,7 +168,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> ]); let mock = responses::mount_sse_sequence(&server, vec![first, second]).await; - let cwd = fixture.cwd.path().to_path_buf(); + let cwd = fixture.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 19758592c3f8..5d7263d54b2d 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -79,7 +79,7 @@ fn disabled_user_turn(test: &TestCodex, items: Vec, model: String) -> responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index be33467d6a5a..b41f8be9630d 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -5,6 +5,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::responses; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; @@ -162,7 +163,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index ed278d1843de..cd183dae0c61 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -407,7 +407,7 @@ pub struct ConversationTextParams { pub struct ThreadSettingsOverrides { /// Updated `cwd` for sandbox/tool calls. #[serde(skip_serializing_if = "Option::is_none")] - pub cwd: Option, + pub cwd: Option, /// Updated runtime workspace roots used to materialize symbolic /// `:workspace_roots` filesystem permissions. From d40454522e2c6216eeeca5d97d39e991791ec664 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 5 Jun 2026 09:34:36 -0700 Subject: [PATCH 29/59] [codex] Allow socketpair in proxy-routed Linux sandbox (#26625) ## Summary - allow `socketpair(AF_UNIX, ...)` in the proxy-routed Linux seccomp mode - continue denying `socket(AF_UNIX, ...)` so user commands cannot create pathname or abstract Unix sockets - extend the managed-proxy integration test to verify both behaviors ## Root cause `NetworkSeccompMode::ProxyRouted` treated anonymous Unix socket pairs like externally addressable Unix sockets and returned `EPERM`. This breaks tools that use socket pairs for local child-process IPC even though a socket pair cannot connect outside the sandbox or bypass the routed proxy. `dangerously_allow_all_unix_sockets` controls Unix-socket requests forwarded by the managed network proxy; it does not currently configure the Linux seccomp filter. Socket pairs should not require that dangerous setting because they are unnamed, process-local IPC. Related but independent: #26553 fixes host proxy bridge socket path length handling. --------- Co-authored-by: Codex --- codex-rs/linux-sandbox/src/landlock.rs | 15 ++++++++------- .../linux-sandbox/tests/suite/managed_proxy.rs | 6 +++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/codex-rs/linux-sandbox/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs index 3f95224ab619..65ba3ebf1750 100644 --- a/codex-rs/linux-sandbox/src/landlock.rs +++ b/codex-rs/linux-sandbox/src/landlock.rs @@ -217,10 +217,11 @@ fn install_network_seccomp_filter_on_current_thread( } NetworkSeccompMode::ProxyRouted => { // In proxy-routed mode we allow IP sockets in the isolated - // namespace (used to reach the local TCP bridge) but deny all - // other socket families, including AF_UNIX. This prevents - // bypassing the routed bridge via new Unix sockets and narrows the - // socket surface in proxy-only mode. + // namespace (used to reach the local TCP bridge) but deny socket() + // for all other families, including AF_UNIX. Only AF_UNIX + // socketpair() remains available for process-local IPC because it + // cannot connect to a socket outside the sandbox or bypass the + // bridge. let deny_non_ip_socket = SeccompRule::new(vec![ SeccompCondition::new( 0, @@ -235,14 +236,14 @@ fn install_network_seccomp_filter_on_current_thread( libc::AF_INET6 as u64, )?, ])?; - let deny_unix_socketpair = SeccompRule::new(vec![SeccompCondition::new( + let deny_non_unix_socketpair = SeccompRule::new(vec![SeccompCondition::new( 0, SeccompCmpArgLen::Dword, - SeccompCmpOp::Eq, + SeccompCmpOp::Ne, libc::AF_UNIX as u64, )?])?; rules.insert(libc::SYS_socket, vec![deny_non_ip_socket]); - rules.insert(libc::SYS_socketpair, vec![deny_unix_socketpair]); + rules.insert(libc::SYS_socketpair, vec![deny_non_unix_socketpair]); } } diff --git a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs index d1aa6856c41a..71ed97150625 100644 --- a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs +++ b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs @@ -266,7 +266,7 @@ async fn managed_proxy_mode_routes_through_bridge_and_blocks_direct_egress() { } #[tokio::test] -async fn managed_proxy_mode_denies_af_unix_creation_for_user_command() { +async fn managed_proxy_mode_denies_af_unix_socket_but_allows_socketpair() { if let Some(skip_reason) = managed_proxy_skip_reason().await { eprintln!("skipping managed proxy test: {skip_reason}"); return; @@ -292,7 +292,7 @@ async fn managed_proxy_mode_denies_af_unix_creation_for_user_command() { &[ "python3", "-c", - "import socket,sys\ntry:\n socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\nexcept PermissionError:\n sys.exit(0)\nexcept OSError:\n sys.exit(2)\nsys.exit(1)\n", + "import socket,sys\ntry:\n socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\nexcept PermissionError:\n pass\nexcept OSError:\n sys.exit(2)\nelse:\n sys.exit(1)\nleft,right = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)\nleft.sendall(b'ok')\nif right.recv(2) != b'ok':\n sys.exit(3)\n", ], &PermissionProfile::Disabled, /*allow_network_for_proxy*/ true, @@ -304,7 +304,7 @@ async fn managed_proxy_mode_denies_af_unix_creation_for_user_command() { assert_eq!( output.status.code(), Some(0), - "expected AF_UNIX creation to be denied cleanly for user command; status={:?}; stdout={}; stderr={}", + "expected AF_UNIX socket creation to be denied and socketpair to work; status={:?}; stdout={}; stderr={}", output.status.code(), String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) From 9ddb1de633bde6d3d3e9b8012f822eb447db5195 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 5 Jun 2026 09:38:26 -0700 Subject: [PATCH 30/59] [codex] Add /usr/bin/bash shell fallback (#26538) ## Why Some Linux environments expose `bash` at `/usr/bin/bash` instead of `/bin/bash`. The shell detection fallback list should cover both standard locations once PATH/user-shell probing fails. Stacked on #26480. ## What changed - Add `/usr/bin/bash` to the bash fallback path list in `codex-shell-command`. - Extend shell type detection coverage for `/usr/bin/bash`. - Add AGENTS.md testing guidance to avoid tests for statically defined values and negative tests for removed logic. ## Verification - `just test -p codex-shell-command` --- AGENTS.md | 2 ++ codex-rs/shell-command/src/shell_detect.rs | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 88d3fc4eeac3..4666566f9603 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,8 @@ In the codex-rs folder where the rust code lives: - Implementations may still use `async fn foo(&self, ...) -> T` when they satisfy that contract. - Do not use `#[allow(async_fn_in_trait)]` as a shortcut around spelling the future contract explicitly. - When writing tests, prefer comparing the equality of entire objects over fields one by one. +- Do not add tests for values that are statically defined. +- Do not add negative tests for logic that was removed. - Do not add general product or user-facing documentation to the `docs/` folder. The official Codex documentation lives elsewhere. The exception is app-server API documentation, which is covered by the app-server guidance below. - Prefer private modules and explicitly exported public crate API. - If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`. diff --git a/codex-rs/shell-command/src/shell_detect.rs b/codex-rs/shell-command/src/shell_detect.rs index 7068463830b0..69a66f05635e 100644 --- a/codex-rs/shell-command/src/shell_detect.rs +++ b/codex-rs/shell-command/src/shell_detect.rs @@ -174,7 +174,7 @@ fn get_zsh_shell(path: Option<&PathBuf>) -> Option { }) } -const BASH_FALLBACK_PATHS: &[&str] = &["/bin/bash"]; +const BASH_FALLBACK_PATHS: &[&str] = &["/bin/bash", "/usr/bin/bash"]; fn get_bash_shell(path: Option<&PathBuf>) -> Option { let shell_path = get_shell_path(ShellType::Bash, path, "bash", BASH_FALLBACK_PATHS); @@ -327,6 +327,10 @@ mod tests { detect_shell_type(PathBuf::from("/bin/bash")), Some(ShellType::Bash) ); + assert_eq!( + detect_shell_type(PathBuf::from("/usr/bin/bash")), + Some(ShellType::Bash) + ); assert_eq!( detect_shell_type(PathBuf::from("powershell.exe")), Some(ShellType::PowerShell) From da490ba9de80bf83ad04f8db4cf72b793e99967f Mon Sep 17 00:00:00 2001 From: hefuc-oai Date: Fri, 5 Jun 2026 10:07:25 -0700 Subject: [PATCH 31/59] feat(remote-control): add pairing status transport (#26449) ## What Adds transport support for checking remote-control pairing status against the backend. - Adds the normalized `server/pair/status` backend URL. - Adds backend request/response structs for exactly one lookup key: `pairing_code` or `manual_pairing_code`, returning `{ claimed }`. - Adds `RemoteControlEnrollment::pairing_status` and `RemoteControlHandle::pairing_status`. - Preserves auth refresh/retry behavior and backend error mapping. - Adds transport coverage for pending, claimed, manual-code payloads, token refresh, mapped backend errors, malformed responses, and URL normalization. ## Why Desktop needs a host-authenticated way to poll whether a QR or manual pairing code has been claimed. Related backend change: https://github.com/openai/openai/pull/990244 ## Verification - `cargo test --manifest-path app-server-transport/Cargo.toml remote_control::tests::pairing_tests` - `cargo fmt --all --check` - `git diff --check` --- .../src/protocol/v2/remote_control.rs | 17 ++ .../src/transport/remote_control/enroll.rs | 66 ++++++ .../src/transport/remote_control/mod.rs | 114 +++++++++ .../src/transport/remote_control/protocol.rs | 51 +++++ .../src/transport/remote_control/tests.rs | 1 + .../remote_control/tests/pairing_tests.rs | 216 ++++++++++++++++++ 6 files changed, 465 insertions(+) diff --git a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs index f5f004f78422..0e1809805dc3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs @@ -62,6 +62,23 @@ pub struct RemoteControlPairingStartResponse { pub expires_at: i64, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlPairingStatusParams { + #[ts(optional = nullable)] + pub pairing_code: Option, + #[ts(optional = nullable)] + pub manual_pairing_code: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlPairingStatusResponse { + pub claimed: bool, +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs index c491596e6597..b4506a3d4c5e 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs @@ -3,11 +3,14 @@ use super::pairing_unavailable_error; use super::protocol::EnrollRemoteServerRequest; use super::protocol::EnrollRemoteServerResponse; use super::protocol::RefreshRemoteServerRequest; +use super::protocol::RemoteControlPairingStatusRequest; +use super::protocol::RemoteControlPairingStatusResponse as BackendRemoteControlPairingStatusResponse; use super::protocol::RemoteControlTarget; use super::protocol::StartRemoteControlPairingRequest; use super::protocol::StartRemoteControlPairingResponse; use axum::http::HeaderMap; use codex_app_server_protocol::RemoteControlPairingStartResponse; +use codex_app_server_protocol::RemoteControlPairingStatusResponse; use codex_login::default_client::build_reqwest_client; use codex_state::RemoteControlEnrollmentRecord; use codex_state::StateRuntime; @@ -136,6 +139,69 @@ impl RemoteControlEnrollment { }) } + pub(super) async fn pairing_status( + &self, + request: RemoteControlPairingStatusRequest, + ) -> io::Result { + if self.should_refresh_server_token() { + return Err(pairing_unavailable_error()); + } + let remote_control_token = self + .remote_control_token + .as_deref() + .ok_or_else(pairing_unavailable_error)?; + + let response = build_reqwest_client() + .post(&self.remote_control_target.pair_status_url) + .timeout(REMOTE_CONTROL_PAIRING_TIMEOUT) + .bearer_auth(remote_control_token) + .json(&request) + .send() + .await + .map_err(|err| { + io::Error::other(format!( + "failed to check remote control pairing status at `{}`: {err}", + self.remote_control_target.pair_status_url + )) + })?; + let headers = response.headers().clone(); + let status = response.status(); + let body = response.bytes().await.map_err(|err| { + io::Error::other(format!( + "failed to read remote control pairing status response from `{}`: {err}", + self.remote_control_target.pair_status_url + )) + })?; + let body_preview = preview_remote_control_response_body(&body); + if !status.is_success() { + let error_kind = match status.as_u16() { + 401 | 403 => ErrorKind::PermissionDenied, + 404 | 410 => ErrorKind::InvalidInput, + _ => ErrorKind::Other, + }; + return Err(io::Error::new( + error_kind, + format!( + "remote control pairing status failed at `{}`: HTTP {status}, {}, body: {body_preview}", + self.remote_control_target.pair_status_url, + format_headers(&headers) + ), + )); + } + + let response = serde_json::from_slice::(&body) + .map_err(|err| { + io::Error::other(format!( + "failed to parse remote control pairing status response from `{}`: HTTP {status}, {}, body: {body_preview}, decode error: {err}", + self.remote_control_target.pair_status_url, + format_headers(&headers) + )) + })?; + Ok(RemoteControlPairingStatusResponse { + claimed: response.claimed, + }) + } + pub(super) fn should_refresh_server_token(&self) -> bool { self.remote_control_token.is_none() || self.expires_at.is_none_or(|expires_at| { diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index 443dcc897eaa..6e8f31d45d3c 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -18,6 +18,7 @@ use crate::transport::remote_control::websocket::RemoteControlStatusPublisher; use crate::transport::remote_control::websocket::RemoteControlWebsocket; pub use self::protocol::ClientId; +use self::protocol::RemoteControlPairingStatusCode; use self::protocol::ServerEvent; use self::protocol::StreamId; use self::protocol::normalize_remote_control_url; @@ -31,6 +32,8 @@ use codex_app_server_protocol::RemoteControlClientsRevokeResponse; use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlPairingStartParams; use codex_app_server_protocol::RemoteControlPairingStartResponse; +use codex_app_server_protocol::RemoteControlPairingStatusParams; +use codex_app_server_protocol::RemoteControlPairingStatusResponse; use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_login::AuthManager; use codex_state::StateRuntime; @@ -391,6 +394,89 @@ impl RemoteControlHandle { Ok(self.pairing_persistence_key.borrow().clone()) } + pub async fn pairing_status( + &self, + params: RemoteControlPairingStatusParams, + ) -> io::Result { + if !*self.enabled_tx.borrow() { + return Err(Self::pairing_disabled_error()); + } + let mut auth = load_remote_control_auth(&self.auth_manager) + .await + .map_err(|_| pairing_unavailable_error())?; + let app_server_client_name = self.pairing_persistence_key.borrow().clone(); + let app_server_client_name = app_server_client_name.as_deref(); + let mut current_enrollment = self.current_enrollment.lock().await; + let mut enrollment = current_enrollment + .as_ref() + .filter(|enrollment| enrollment.account_id == auth.account_id) + .cloned() + .ok_or_else(pairing_unavailable_error)?; + let installation_id = self.status().installation_id; + if enrollment.should_refresh_server_token() { + refresh_pairing_enrollment( + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, + &self.auth_manager, + &mut auth, + &installation_id, + &mut enrollment, + ) + .await?; + } + let status_code = remote_control_pairing_status_code(¶ms)?; + let pairing_status_request = + || protocol::RemoteControlPairingStatusRequest::from(status_code.clone()); + let pairing_status_response = + match enrollment.pairing_status(pairing_status_request()).await { + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => { + clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?; + refresh_pairing_enrollment( + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, + &self.auth_manager, + &mut auth, + &installation_id, + &mut enrollment, + ) + .await?; + enrollment.pairing_status(pairing_status_request()).await + } + pairing_status_response => pairing_status_response, + }; + if let Err(err) = &pairing_status_response { + match err.kind() { + io::ErrorKind::NotFound => { + clear_pairing_enrollment( + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, + &enrollment, + ) + .await; + return Err(pairing_unavailable_error()); + } + io::ErrorKind::PermissionDenied => { + clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?; + return Err(pairing_unavailable_error()); + } + _ => {} + } + } + if !*self.enabled_tx.borrow() { + return Err(Self::pairing_disabled_error()); + } + let current_auth = load_remote_control_auth(&self.auth_manager) + .await + .map_err(|_| pairing_unavailable_error())?; + if current_auth.account_id != auth.account_id { + return Err(pairing_unavailable_error()); + } + pairing_status_response + } + pub async fn list_clients( &self, params: RemoteControlClientsListParams, @@ -407,6 +493,13 @@ impl RemoteControlHandle { .await } + fn pairing_disabled_error() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + "remote control pairing requires remote control to be enabled", + ) + } + fn publish_status( &self, connection_status: RemoteControlConnectionStatus, @@ -464,6 +557,27 @@ async fn enroll_pairing_server( enroll_remote_control_server(remote_control_target, auth, installation_id, server_name).await } +fn remote_control_pairing_status_code( + params: &RemoteControlPairingStatusParams, +) -> io::Result { + match (¶ms.pairing_code, ¶ms.manual_pairing_code) { + (Some(pairing_code), None) => Ok(RemoteControlPairingStatusCode::PairingCode( + pairing_code.clone(), + )), + (None, Some(manual_pairing_code)) => Ok(RemoteControlPairingStatusCode::ManualPairingCode( + manual_pairing_code.clone(), + )), + (Some(_), Some(_)) => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "remote control pairing status accepts either pairingCode or manualPairingCode, not both", + )), + (None, None) => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "remote control pairing status requires pairingCode or manualPairingCode", + )), + } +} + async fn refresh_pairing_enrollment( current_enrollment: &mut Option, state_db: Option<&StateRuntime>, diff --git a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs index 630fd862c8b8..e3c122c1f7c2 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs @@ -13,6 +13,7 @@ pub(super) struct RemoteControlTarget { pub(super) enroll_url: String, pub(super) refresh_url: String, pub(super) pair_url: String, + pub(super) pair_status_url: String, } #[derive(Debug, Serialize)] @@ -52,6 +53,40 @@ pub(super) struct StartRemoteControlPairingResponse { pub(super) expires_at: String, } +#[derive(Debug, Serialize)] +pub(super) struct RemoteControlPairingStatusRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) pairing_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) manual_pairing_code: Option, +} + +#[derive(Clone)] +pub(super) enum RemoteControlPairingStatusCode { + PairingCode(String), + ManualPairingCode(String), +} + +impl From for RemoteControlPairingStatusRequest { + fn from(code: RemoteControlPairingStatusCode) -> Self { + match code { + RemoteControlPairingStatusCode::PairingCode(pairing_code) => Self { + pairing_code: Some(pairing_code), + manual_pairing_code: None, + }, + RemoteControlPairingStatusCode::ManualPairingCode(manual_pairing_code) => Self { + pairing_code: None, + manual_pairing_code: Some(manual_pairing_code), + }, + } + } +} + +#[derive(Debug, Deserialize)] +pub(super) struct RemoteControlPairingStatusResponse { + pub(super) claimed: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct ClientId(pub String); @@ -194,6 +229,9 @@ pub(super) fn normalize_remote_control_url( let pair_url = remote_control_url .join("wham/remote/control/server/pair") .map_err(map_url_parse_error)?; + let pair_status_url = remote_control_url + .join("wham/remote/control/server/pair/status") + .map_err(map_url_parse_error)?; let mut websocket_url = remote_control_url .join("wham/remote/control/server") .map_err(map_url_parse_error)?; @@ -215,6 +253,7 @@ pub(super) fn normalize_remote_control_url( enroll_url: enroll_url.to_string(), refresh_url: refresh_url.to_string(), pair_url: pair_url.to_string(), + pair_status_url: pair_status_url.to_string(), }) } @@ -269,6 +308,9 @@ mod tests { .to_string(), pair_url: "https://chatgpt.com/backend-api/wham/remote/control/server/pair" .to_string(), + pair_status_url: + "https://chatgpt.com/backend-api/wham/remote/control/server/pair/status" + .to_string(), } ); assert_eq!( @@ -287,6 +329,9 @@ mod tests { pair_url: "https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/pair" .to_string(), + pair_status_url: + "https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/pair/status" + .to_string(), } ); } @@ -305,6 +350,9 @@ mod tests { .to_string(), pair_url: "http://localhost:8080/backend-api/wham/remote/control/server/pair" .to_string(), + pair_status_url: + "http://localhost:8080/backend-api/wham/remote/control/server/pair/status" + .to_string(), } ); assert_eq!( @@ -320,6 +368,9 @@ mod tests { .to_string(), pair_url: "https://localhost:8443/backend-api/wham/remote/control/server/pair" .to_string(), + pair_status_url: + "https://localhost:8443/backend-api/wham/remote/control/server/pair/status" + .to_string(), } ); } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 3e9a911b2d3d..91c13515d3bc 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -21,6 +21,7 @@ use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlPairingStartParams; +use codex_app_server_protocol::RemoteControlPairingStatusParams; use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_app_server_protocol::ServerNotification; use codex_config::types::AuthCredentialsStoreMode; diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs index 7586f0b4d1a9..fcdd5e30f2be 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs @@ -1,6 +1,8 @@ +use super::super::protocol::RemoteControlPairingStatusRequest; use super::super::protocol::StartRemoteControlPairingRequest; use super::*; use pretty_assertions::assert_eq; +use std::io; fn remote_control_enrollment( remote_control_url: &str, @@ -66,6 +68,36 @@ async fn pairing_response_error(body: serde_json::Value) -> String { err.to_string() } +async fn pairing_status_error(status: &'static str, body: &'static str) -> (io::Error, String) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let expected_status_url = normalize_remote_control_url(&remote_control_url) + .expect("target should normalize") + .pair_status_url; + let server_task = tokio::spawn(async move { + let status_request = accept_http_request(&listener).await; + respond_with_status_and_headers( + status_request.stream, + status, + &[("x-request-id", "request-123"), ("cf-ray", "ray-123")], + body, + ) + .await; + }); + + let err = remote_control_enrollment(&remote_control_url, "remote-control-token") + .pairing_status(RemoteControlPairingStatusRequest { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }) + .await + .expect_err("pairing status should fail"); + server_task.await.expect("server task should finish"); + (err, expected_status_url) +} + #[tokio::test] async fn remote_control_handle_starts_pairing_before_websocket_connects() { let listener = TcpListener::bind("127.0.0.1:0") @@ -156,6 +188,190 @@ async fn remote_control_handle_starts_pairing_before_websocket_connects() { ); } +#[tokio::test] +async fn remote_control_pairing_status_returns_pending() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let status_request = accept_http_request(&listener).await; + assert_eq!( + status_request.request_line, + "POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1" + ); + assert_eq!( + status_request.headers.get("authorization"), + Some(&"Bearer remote-control-token".to_string()) + ); + assert_eq!( + serde_json::from_str::(&status_request.body) + .expect("status request body should deserialize"), + json!({ "pairing_code": "pairing-code" }) + ); + respond_with_json(status_request.stream, json!({ "claimed": false })).await; + }); + + let response = remote_control_enrollment(&remote_control_url, "remote-control-token") + .pairing_status(RemoteControlPairingStatusRequest { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }) + .await + .expect("pairing status should succeed"); + server_task.await.expect("server task should finish"); + + assert!(!response.claimed); +} + +#[tokio::test] +async fn remote_control_pairing_status_accepts_manual_pairing_code() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let status_request = accept_http_request(&listener).await; + assert_eq!( + status_request.request_line, + "POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1" + ); + assert_eq!( + serde_json::from_str::(&status_request.body) + .expect("status request body should deserialize"), + json!({ "manual_pairing_code": "ABCD-EFGH" }) + ); + respond_with_json(status_request.stream, json!({ "claimed": false })).await; + }); + + let response = remote_control_enrollment(&remote_control_url, "remote-control-token") + .pairing_status(RemoteControlPairingStatusRequest { + pairing_code: None, + manual_pairing_code: Some("ABCD-EFGH".to_string()), + }) + .await + .expect("pairing status should succeed"); + server_task.await.expect("server task should finish"); + + assert!(!response.claimed); +} + +#[tokio::test] +async fn remote_control_pairing_status_returns_claimed() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let status_request = accept_http_request(&listener).await; + assert_eq!( + status_request.request_line, + "POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1" + ); + respond_with_json(status_request.stream, json!({ "claimed": true })).await; + }); + + let response = remote_control_enrollment(&remote_control_url, "remote-control-token") + .pairing_status(RemoteControlPairingStatusRequest { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }) + .await + .expect("pairing status should succeed"); + server_task.await.expect("server task should finish"); + + assert!(response.claimed); +} + +#[tokio::test] +async fn remote_control_handle_refreshes_after_pairing_status_auth_failure() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let stale_status_request = accept_http_request(&listener).await; + assert_eq!( + stale_status_request.request_line, + "POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1" + ); + assert_eq!( + stale_status_request.headers.get("authorization"), + Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}")) + ); + respond_with_status(stale_status_request.stream, "401 Unauthorized", "").await; + + let refresh_request = accept_http_request(&listener).await; + assert_eq!( + refresh_request.request_line, + "POST /backend-api/wham/remote/control/server/refresh HTTP/1.1" + ); + respond_with_json( + refresh_request.stream, + remote_control_server_token_response( + "srv_e_test", + "env_test", + TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; + + let refreshed_status_request = accept_http_request(&listener).await; + assert_eq!( + refreshed_status_request.request_line, + "POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1" + ); + assert_eq!( + refreshed_status_request.headers.get("authorization"), + Some(&format!( + "Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}" + )) + ); + respond_with_json(refreshed_status_request.stream, json!({ "claimed": true })).await; + }); + let remote_handle = remote_control_handle_with_current_enrollment( + &remote_control_url, + remote_control_auth_manager(), + ); + + let response = remote_handle + .pairing_status(RemoteControlPairingStatusParams { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }) + .await + .expect("pairing status should refresh after server token auth failure"); + server_task.await.expect("server task should finish"); + + assert!(response.claimed); +} + +#[tokio::test] +async fn remote_control_pairing_status_maps_user_actionable_backend_errors() { + for (status, expected_kind) in [ + ("403 Forbidden", io::ErrorKind::PermissionDenied), + ("404 Not Found", io::ErrorKind::InvalidInput), + ("410 Gone", io::ErrorKind::InvalidInput), + ] { + let (err, _expected_status_url) = pairing_status_error(status, "not available").await; + assert_eq!(err.kind(), expected_kind); + } +} + +#[tokio::test] +async fn remote_control_pairing_status_preserves_decode_error_context() { + let (err, expected_status_url) = pairing_status_error("200 OK", "{").await; + let err = err.to_string(); + + assert!(err.contains(&format!( + "failed to parse remote control pairing status response from `{expected_status_url}`: HTTP 200 OK" + ))); + assert!(err.contains("request-id: request-123")); + assert!(err.contains("cf-ray: ray-123")); + assert!(err.contains("body: {")); + assert!(err.contains("decode error:")); +} + #[tokio::test] async fn remote_control_handle_refreshes_after_pairing_auth_failure() { let listener = TcpListener::bind("127.0.0.1:0") From 86a1ddd0282f83f21d2e5743ffd1d6ba36725ed1 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 5 Jun 2026 10:31:22 -0700 Subject: [PATCH 32/59] Make turn diff tracker multi-env aware (#26433) ## Why Turn diffs were tracked as one flat set of absolute paths. In multi-environment turns, local and remote environments can report the same path while representing different filesystems, so a single path key can collapse distinct changes or attribute them to the wrong environment. The environment name is **NOT** included in the generated unified diff. This can come later. --- codex-rs/core/src/session/turn.rs | 37 ++-- codex-rs/core/src/tools/events.rs | 68 +++++-- .../core/src/tools/handlers/apply_patch.rs | 13 +- codex-rs/core/src/turn_diff_tracker.rs | 148 ++++++++------- codex-rs/core/src/turn_diff_tracker_tests.rs | 85 ++++++--- codex-rs/core/tests/suite/apply_patch_cli.rs | 168 ++++++++++++++++++ 6 files changed, 400 insertions(+), 119 deletions(-) diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 63195d9fa768..5853b78c7e66 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::collections::HashSet; +use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::Ordering; @@ -73,7 +74,6 @@ use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_extension_api::TurnInputContext; use codex_extension_api::TurnInputEnvironment; use codex_features::Feature; -use codex_git_utils::get_git_repo_root; use codex_git_utils::get_git_repo_root_with_fs; use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ModeKind; @@ -196,21 +196,11 @@ pub(crate) async fn run_turn( let mut stop_hook_active = false; // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains // many turns, from the perspective of the user, it is a single turn. - #[allow(deprecated)] - let display_root = match turn_context.environments.primary() { - Some(turn_environment) => get_git_repo_root_with_fs( - turn_environment.environment.get_filesystem().as_ref(), - &turn_environment.cwd, - ) - .await - .unwrap_or_else(|| turn_environment.cwd.clone()) - .into_path_buf(), - None => get_git_repo_root(turn_context.cwd.as_path()) - .unwrap_or_else(|| turn_context.cwd.clone().into_path_buf()), - }; - let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::with_display_root( - display_root, - ))); + let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new( + TurnDiffTracker::with_environment_display_roots( + turn_diff_display_roots(turn_context.as_ref()).await, + ), + )); // `ModelClientSession` is turn-scoped and caches WebSocket + sticky routing state, so we reuse // one instance across retries within this turn. @@ -421,6 +411,21 @@ pub(crate) async fn run_turn( last_agent_message } +async fn turn_diff_display_roots(turn_context: &TurnContext) -> Vec<(String, PathBuf)> { + let mut display_roots = Vec::new(); + for turn_environment in &turn_context.environments.turn_environments { + let root = get_git_repo_root_with_fs( + turn_environment.environment.get_filesystem().as_ref(), + &turn_environment.cwd, + ) + .await + .unwrap_or_else(|| turn_environment.cwd.clone()) + .into_path_buf(); + display_roots.push((turn_environment.environment_id.clone(), root)); + } + display_roots +} + async fn run_hooks_and_record_inputs( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 747143ff69ac..e286af53d4ee 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -70,16 +70,25 @@ pub(crate) enum ToolEventFailure<'a> { } enum TurnDiffTrackerUpdate<'a> { - Track(&'a AppliedPatchDelta), + Track { + environment_id: Option, + delta: &'a AppliedPatchDelta, + }, Invalidate, None, } -fn tracker_update_for_known_delta(delta: &AppliedPatchDelta) -> TurnDiffTrackerUpdate<'_> { +fn tracker_update_for_known_delta<'a>( + environment_id: Option<&str>, + delta: &'a AppliedPatchDelta, +) -> TurnDiffTrackerUpdate<'a> { if delta.is_exact() && delta.is_empty() { TurnDiffTrackerUpdate::None } else { - TurnDiffTrackerUpdate::Track(delta) + TurnDiffTrackerUpdate::Track { + environment_id: environment_id.map(str::to_string), + delta, + } } } @@ -120,6 +129,7 @@ pub(crate) enum ToolEmitter { ApplyPatch { changes: HashMap, auto_approved: bool, + environment_id: Option, }, UnifiedExec { command: Vec, @@ -141,10 +151,15 @@ impl ToolEmitter { } } - pub fn apply_patch(changes: HashMap, auto_approved: bool) -> Self { + pub fn apply_patch_for_environment( + changes: HashMap, + auto_approved: bool, + environment_id: String, + ) -> Self { Self::ApplyPatch { changes, auto_approved, + environment_id: Some(environment_id), } } @@ -210,7 +225,11 @@ impl ToolEmitter { .await; } ( - Self::ApplyPatch { changes, .. }, + Self::ApplyPatch { + changes, + environment_id, + .. + }, ToolEventStage::Success { output, applied_patch_delta, @@ -222,7 +241,7 @@ impl ToolEmitter { PatchApplyStatus::Failed }; let tracker_update = applied_patch_delta - .map(tracker_update_for_known_delta) + .map(|delta| tracker_update_for_known_delta(environment_id.as_deref(), delta)) .unwrap_or(TurnDiffTrackerUpdate::Invalidate); emit_patch_end( ctx, @@ -267,7 +286,11 @@ impl ToolEmitter { .await; } ( - Self::ApplyPatch { changes, .. }, + Self::ApplyPatch { + changes, + environment_id, + .. + }, ToolEventStage::Failure(ToolEventFailure::Rejected { message, applied_patch_delta, @@ -280,7 +303,9 @@ impl ToolEmitter { (*message).to_string(), PatchApplyStatus::Declined, applied_patch_delta - .map(tracker_update_for_known_delta) + .map(|delta| { + tracker_update_for_known_delta(environment_id.as_deref(), delta) + }) .unwrap_or(TurnDiffTrackerUpdate::None), ) .await; @@ -565,8 +590,11 @@ async fn emit_patch_end( let mut guard = tracker.lock().await; let previous_diff = guard.get_unified_diff(); let tracker_changed = match tracker_update { - TurnDiffTrackerUpdate::Track(delta) => { - guard.track_delta(delta); + TurnDiffTrackerUpdate::Track { + environment_id, + delta, + } => { + guard.track_delta(environment_id.as_deref().unwrap_or_default(), delta); true } TurnDiffTrackerUpdate::Invalidate => { @@ -627,14 +655,18 @@ mod tests { .await .expect("apply patch"); - ToolEmitter::apply_patch(HashMap::new(), /*auto_approved*/ false) - .finish( - ToolEventCtx::new(session.as_ref(), turn.as_ref(), "call-id", Some(&tracker)), - out, - Some(&delta), - ) - .await - .expect_err("failed patch"); + ToolEmitter::ApplyPatch { + changes: HashMap::new(), + auto_approved: false, + environment_id: None, + } + .finish( + ToolEventCtx::new(session.as_ref(), turn.as_ref(), "call-id", Some(&tracker)), + out, + Some(&delta), + ) + .await + .expect_err("failed patch"); let completed = rx_event.recv().await.expect("item completed event"); assert!(matches!( diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index c12ba0517ed3..66fb1d308092 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -378,8 +378,11 @@ impl ToolExecutor for ApplyPatchHandler { } InternalApplyPatchInvocation::DelegateToRuntime(apply) => { let changes = convert_apply_patch_to_protocol(&apply.action); - let emitter = - ToolEmitter::apply_patch(changes.clone(), apply.auto_approved); + let emitter = ToolEmitter::apply_patch_for_environment( + changes.clone(), + apply.auto_approved, + turn_environment.environment_id.clone(), + ); let event_ctx = ToolEventCtx::new( session.as_ref(), turn.as_ref(), @@ -537,7 +540,11 @@ pub(crate) async fn intercept_apply_patch( } InternalApplyPatchInvocation::DelegateToRuntime(apply) => { let changes = convert_apply_patch_to_protocol(&apply.action); - let emitter = ToolEmitter::apply_patch(changes.clone(), apply.auto_approved); + let emitter = ToolEmitter::apply_patch_for_environment( + changes.clone(), + apply.auto_approved, + turn_environment.environment_id.clone(), + ); let event_ctx = ToolEventCtx::new( session.as_ref(), turn.as_ref(), diff --git a/codex-rs/core/src/turn_diff_tracker.rs b/codex-rs/core/src/turn_diff_tracker.rs index 3835ae234593..388c2210bd2e 100644 --- a/codex-rs/core/src/turn_diff_tracker.rs +++ b/codex-rs/core/src/turn_diff_tracker.rs @@ -13,21 +13,36 @@ const ZERO_OID: &str = "0000000000000000000000000000000000000000"; const DEV_NULL: &str = "/dev/null"; const REGULAR_FILE_MODE: &str = "100644"; +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct TrackedPath { + environment_id: String, + path: PathBuf, +} + +impl TrackedPath { + fn new(environment_id: &str, path: &Path) -> Self { + Self { + environment_id: environment_id.to_string(), + path: path.to_path_buf(), + } + } +} + /// Tracks the net text diff for the current turn from committed apply_patch /// mutations, without rereading the workspace filesystem. pub struct TurnDiffTracker { valid: bool, - display_root: Option, - baseline_by_path: HashMap, - current_by_path: HashMap, - origin_by_current_path: HashMap, + display_roots_by_environment: HashMap, + baseline_by_path: HashMap, + current_by_path: HashMap, + origin_by_current_path: HashMap, } impl Default for TurnDiffTracker { fn default() -> Self { Self { valid: true, - display_root: None, + display_roots_by_environment: HashMap::new(), baseline_by_path: HashMap::new(), current_by_path: HashMap::new(), origin_by_current_path: HashMap::new(), @@ -40,20 +55,22 @@ impl TurnDiffTracker { Self::default() } - pub fn with_display_root(display_root: PathBuf) -> Self { + pub fn with_environment_display_roots( + display_roots: impl IntoIterator, + ) -> Self { let mut tracker = Self::new(); - tracker.display_root = Some(display_root); + tracker.display_roots_by_environment = display_roots.into_iter().collect(); tracker } - pub fn track_delta(&mut self, delta: &AppliedPatchDelta) { + pub fn track_delta(&mut self, environment_id: &str, delta: &AppliedPatchDelta) { if !delta.is_exact() { self.invalidate(); return; } for change in delta.changes() { - self.apply_change(change); + self.apply_change(environment_id, change); } } @@ -106,8 +123,8 @@ impl TurnDiffTracker { (!aggregated.is_empty()).then_some(aggregated) } - fn apply_change(&mut self, change: &AppliedPatchChange) { - let source_path = change.path.as_path(); + fn apply_change(&mut self, environment_id: &str, change: &AppliedPatchChange) { + let source_path = TrackedPath::new(environment_id, change.path.as_path()); match &change.change { AppliedPatchFileChange::Add { content, @@ -119,85 +136,87 @@ impl TurnDiffTracker { old_content, overwritten_move_content, new_content, - } => self.apply_update( - source_path, - move_path.as_deref(), - old_content, - overwritten_move_content.as_deref(), - new_content, - ), + } => { + let move_path = move_path + .as_deref() + .map(|path| TrackedPath::new(environment_id, path)); + self.apply_update( + source_path, + move_path, + old_content, + overwritten_move_content.as_deref(), + new_content, + ) + } } } - fn apply_add(&mut self, path: &Path, content: &str, overwritten_content: Option<&str>) { - self.origin_by_current_path.remove(path); - if !self.current_by_path.contains_key(path) - && !self.baseline_by_path.contains_key(path) + fn apply_add(&mut self, path: TrackedPath, content: &str, overwritten_content: Option<&str>) { + self.origin_by_current_path.remove(&path); + if !self.current_by_path.contains_key(&path) + && !self.baseline_by_path.contains_key(&path) && let Some(overwritten_content) = overwritten_content { self.baseline_by_path - .insert(path.to_path_buf(), overwritten_content.to_string()); + .insert(path.clone(), overwritten_content.to_string()); } - self.current_by_path - .insert(path.to_path_buf(), content.to_string()); + self.current_by_path.insert(path, content.to_string()); } - fn apply_delete(&mut self, path: &Path, content: &str) { - if self.current_by_path.remove(path).is_none() && !self.baseline_by_path.contains_key(path) + fn apply_delete(&mut self, path: TrackedPath, content: &str) { + if self.current_by_path.remove(&path).is_none() + && !self.baseline_by_path.contains_key(&path) { self.baseline_by_path - .insert(path.to_path_buf(), content.to_string()); + .insert(path.clone(), content.to_string()); } - self.origin_by_current_path.remove(path); + self.origin_by_current_path.remove(&path); } fn apply_update( &mut self, - source_path: &Path, - move_path: Option<&Path>, + source_path: TrackedPath, + move_path: Option, old_content: &str, overwritten_move_content: Option<&str>, new_content: &str, ) { - if !self.current_by_path.contains_key(source_path) - && !self.baseline_by_path.contains_key(source_path) + if !self.current_by_path.contains_key(&source_path) + && !self.baseline_by_path.contains_key(&source_path) { self.baseline_by_path - .insert(source_path.to_path_buf(), old_content.to_string()); + .insert(source_path.clone(), old_content.to_string()); } match move_path { Some(dest_path) => { - if !self.current_by_path.contains_key(dest_path) - && !self.baseline_by_path.contains_key(dest_path) + if !self.current_by_path.contains_key(&dest_path) + && !self.baseline_by_path.contains_key(&dest_path) && let Some(overwritten_move_content) = overwritten_move_content { - self.baseline_by_path.insert( - dest_path.to_path_buf(), - overwritten_move_content.to_string(), - ); + self.baseline_by_path + .insert(dest_path.clone(), overwritten_move_content.to_string()); } let origin = self .origin_by_current_path - .remove(source_path) - .unwrap_or_else(|| source_path.to_path_buf()); - self.current_by_path.remove(source_path); + .remove(&source_path) + .unwrap_or_else(|| source_path.clone()); + self.current_by_path.remove(&source_path); self.current_by_path - .insert(dest_path.to_path_buf(), new_content.to_string()); - self.origin_by_current_path.remove(dest_path); - if dest_path != origin.as_path() { - self.origin_by_current_path - .insert(dest_path.to_path_buf(), origin); + .insert(dest_path.clone(), new_content.to_string()); + self.origin_by_current_path.remove(&dest_path); + if dest_path != origin { + self.origin_by_current_path.insert(dest_path, origin); } } None => { self.current_by_path - .insert(source_path.to_path_buf(), new_content.to_string()); + .insert(source_path, new_content.to_string()); } } } - fn rename_pairs(&self) -> HashMap { + fn rename_pairs(&self) -> HashMap { self.origin_by_current_path .iter() .filter_map(|(dest_path, origin_path)| { @@ -215,7 +234,7 @@ impl TurnDiffTracker { .collect() } - fn render_path_diff(&self, path: &Path) -> Option { + fn render_path_diff(&self, path: &TrackedPath) -> Option { self.render_diff( path, self.baseline_by_path.get(path).map(String::as_str), @@ -224,7 +243,11 @@ impl TurnDiffTracker { ) } - fn render_rename_diff(&self, source_path: &Path, dest_path: &Path) -> Option { + fn render_rename_diff( + &self, + source_path: &TrackedPath, + dest_path: &TrackedPath, + ) -> Option { self.render_diff( source_path, self.baseline_by_path.get(source_path).map(String::as_str), @@ -235,9 +258,9 @@ impl TurnDiffTracker { fn render_diff( &self, - left_path: &Path, + left_path: &TrackedPath, left_content: Option<&str>, - right_path: &Path, + right_path: &TrackedPath, right_content: Option<&str>, ) -> Option { if left_content == right_content { @@ -286,13 +309,18 @@ impl TurnDiffTracker { Some(diff) } - fn display_path(&self, path: &Path) -> String { + fn display_path(&self, path: &TrackedPath) -> String { let display = self - .display_root - .as_deref() - .and_then(|root| path.strip_prefix(root).ok()) - .unwrap_or(path); - display.display().to_string().replace('\\', "/") + .display_roots_by_environment + .get(&path.environment_id) + .and_then(|root| path.path.strip_prefix(root).ok()) + .unwrap_or(path.path.as_path()); + let display = display.display().to_string().replace('\\', "/"); + if self.display_roots_by_environment.len() > 1 && !path.environment_id.is_empty() { + format!("{}/{display}", path.environment_id) + } else { + display + } } } diff --git a/codex-rs/core/src/turn_diff_tracker_tests.rs b/codex-rs/core/src/turn_diff_tracker_tests.rs index d25fec2aadd7..645443040436 100644 --- a/codex-rs/core/src/turn_diff_tracker_tests.rs +++ b/codex-rs/core/src/turn_diff_tracker_tests.rs @@ -41,24 +41,28 @@ async fn apply_verified_patch(root: &Path, patch: &str) -> AppliedPatchDelta { .expect("patch should apply") } +fn tracker_with_root(root: &Path) -> TurnDiffTracker { + TurnDiffTracker::with_environment_display_roots([("".to_string(), root.to_path_buf())]) +} + #[tokio::test] async fn accumulates_add_then_update_as_single_add() { let dir = tempdir().expect("tempdir"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let add = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Add File: a.txt\n+foo\n*** End Patch", ) .await; - tracker.track_delta(&add); + tracker.track_delta("", &add); let update = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Update File: a.txt\n@@\n foo\n+bar\n*** End Patch", ) .await; - tracker.track_delta(&update); + tracker.track_delta("", &update); let right_oid = git_blob_sha1_hex("foo\nbar\n"); let expected = format!( @@ -78,32 +82,69 @@ index {ZERO_OID}..{right_oid} #[tokio::test] async fn invalidated_tracker_suppresses_existing_diff() { let dir = tempdir().expect("tempdir"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let add = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Add File: a.txt\n+foo\n*** End Patch", ) .await; - tracker.track_delta(&add); + tracker.track_delta("", &add); tracker.invalidate(); assert_eq!(tracker.get_unified_diff(), None); } +#[tokio::test] +async fn tracks_same_absolute_path_across_multiple_environments() { + let dir = tempdir().expect("tempdir"); + let add = apply_verified_patch( + dir.path(), + "*** Begin Patch\n*** Add File: shared.txt\n+content\n*** End Patch", + ) + .await; + + let mut tracker = TurnDiffTracker::with_environment_display_roots([ + ("local".to_string(), dir.path().to_path_buf()), + ("remote".to_string(), dir.path().to_path_buf()), + ]); + tracker.track_delta("remote", &add); + tracker.track_delta("local", &add); + + let right_oid = git_blob_sha1_hex("content\n"); + let expected = format!( + r#"diff --git a/local/shared.txt b/local/shared.txt +new file mode {REGULAR_FILE_MODE} +index {ZERO_OID}..{right_oid} +--- {DEV_NULL} ++++ b/local/shared.txt +@@ -0,0 +1 @@ ++content +diff --git a/remote/shared.txt b/remote/shared.txt +new file mode {REGULAR_FILE_MODE} +index {ZERO_OID}..{right_oid} +--- {DEV_NULL} ++++ b/remote/shared.txt +@@ -0,0 +1 @@ ++content +"#, + ); + assert_eq!(tracker.get_unified_diff(), Some(expected)); +} + #[tokio::test] async fn accumulates_delete() { let dir = tempdir().expect("tempdir"); fs::write(dir.path().join("b.txt"), "x\n").expect("seed file"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let delete = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Delete File: b.txt\n*** End Patch", ) .await; - tracker.track_delta(&delete); + tracker.track_delta("", &delete); let left_oid = git_blob_sha1_hex("x\n"); let expected = format!( @@ -124,13 +165,13 @@ async fn accumulates_move_and_update() { let dir = tempdir().expect("tempdir"); fs::write(dir.path().join("src.txt"), "line\n").expect("seed file"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let update = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Update File: src.txt\n*** Move to: dst.txt\n@@\n-line\n+line2\n*** End Patch", ) .await; - tracker.track_delta(&update); + tracker.track_delta("", &update); let left_oid = git_blob_sha1_hex("line\n"); let right_oid = git_blob_sha1_hex("line2\n"); @@ -152,13 +193,13 @@ async fn pure_rename_yields_no_diff() { let dir = tempdir().expect("tempdir"); fs::write(dir.path().join("old.txt"), "same\n").expect("seed file"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let rename = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n same\n*** End Patch", ) .await; - tracker.track_delta(&rename); + tracker.track_delta("", &rename); assert_eq!(tracker.get_unified_diff(), None); } @@ -168,13 +209,13 @@ async fn add_over_existing_file_becomes_update() { let dir = tempdir().expect("tempdir"); fs::write(dir.path().join("dup.txt"), "before\n").expect("seed file"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let add = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Add File: dup.txt\n+after\n*** End Patch", ) .await; - tracker.track_delta(&add); + tracker.track_delta("", &add); let left_oid = git_blob_sha1_hex("before\n"); let right_oid = git_blob_sha1_hex("after\n"); @@ -196,20 +237,20 @@ async fn delete_then_readd_same_path_becomes_update() { let dir = tempdir().expect("tempdir"); fs::write(dir.path().join("cycle.txt"), "before\n").expect("seed file"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let delete = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Delete File: cycle.txt\n*** End Patch", ) .await; - tracker.track_delta(&delete); + tracker.track_delta("", &delete); let add = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Add File: cycle.txt\n+after\n*** End Patch", ) .await; - tracker.track_delta(&add); + tracker.track_delta("", &add); let left_oid = git_blob_sha1_hex("before\n"); let right_oid = git_blob_sha1_hex("after\n"); @@ -232,13 +273,13 @@ async fn move_over_existing_destination_without_content_change_deletes_source_on fs::write(dir.path().join("a.txt"), "same\n").expect("seed source"); fs::write(dir.path().join("b.txt"), "same\n").expect("seed destination"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let move_overwrite = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Update File: a.txt\n*** Move to: b.txt\n@@\n same\n*** End Patch", ) .await; - tracker.track_delta(&move_overwrite); + tracker.track_delta("", &move_overwrite); let left_oid = git_blob_sha1_hex("same\n"); let expected = format!( @@ -261,13 +302,13 @@ async fn move_over_existing_destination_with_content_change_deletes_source_and_u fs::write(dir.path().join("a.txt"), "from\n").expect("seed source"); fs::write(dir.path().join("b.txt"), "existing\n").expect("seed destination"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let move_overwrite = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Update File: a.txt\n*** Move to: b.txt\n@@\n-from\n+new\n*** End Patch", ) .await; - tracker.track_delta(&move_overwrite); + tracker.track_delta("", &move_overwrite); let left_oid_a = git_blob_sha1_hex("from\n"); let left_oid_b = git_blob_sha1_hex("existing\n"); @@ -298,13 +339,13 @@ async fn preserves_committed_change_order_with_delete_then_move_overwrite() { fs::write(dir.path().join("a.txt"), "from\n").expect("seed source"); fs::write(dir.path().join("b.txt"), "existing\n").expect("seed destination"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let ordered_patch = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Delete File: b.txt\n*** Update File: a.txt\n*** Move to: b.txt\n@@\n-from\n+new\n*** End Patch", ) .await; - tracker.track_delta(&ordered_patch); + tracker.track_delta("", &ordered_patch); let left_oid_a = git_blob_sha1_hex("from\n"); let left_oid_b = git_blob_sha1_hex("existing\n"); diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 1e2493a807f3..db301e39b40e 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -8,11 +8,18 @@ use core_test_support::responses::ev_apply_patch_shell_command_call_via_heredoc; use core_test_support::responses::ev_shell_command_call; use core_test_support::test_codex::ApplyPatchModelOutput; use pretty_assertions::assert_eq; +use std::fs; +use std::path::PathBuf; use std::sync::atomic::AtomicI32; use std::sync::atomic::Ordering; use std::time::Duration; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::LOCAL_ENVIRONMENT_ID; +use codex_exec_server::REMOTE_ENVIRONMENT_ID; +use codex_exec_server::RemoveOptions; use codex_features::Feature; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; @@ -25,11 +32,14 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::user_input::UserInput; #[cfg(target_os = "linux")] use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::PathBufExt; use core_test_support::assert_regex_match; +use core_test_support::get_remote_test_env; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -37,11 +47,13 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_shell_command_call_with_args; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_remote; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::TestCodexHarness; use core_test_support::test_codex::test_codex; +use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use core_test_support::wait_for_event_with_timeout; use serde_json::json; @@ -1553,6 +1565,162 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_turn_diff_tracks_local_and_remote_environment_paths() -> Result<()> { + skip_if_no_network!(Ok(())); + let Some(_remote_env) = get_remote_test_env() else { + return Ok(()); + }; + + let server = start_mock_server().await; + let mut builder = test_codex(); + let test = builder.build_with_remote_and_local_env(&server).await?; + let file_name = "shared-turn-diff.txt"; + let shared_cwd = PathBuf::from(format!( + "/tmp/codex-remote-turn-diff-{}", + SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() + )) + .abs(); + let _ = fs::remove_dir_all(shared_cwd.as_path()); + test.fs() + .remove( + &shared_cwd, + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + fs::create_dir_all(shared_cwd.as_path())?; + test.fs() + .create_directory( + &shared_cwd, + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await?; + + let local_patch = format!( + "*** Begin Patch\n*** Environment ID: {LOCAL_ENVIRONMENT_ID}\n*** Add File: {file_name}\n+local\n*** End Patch" + ); + let remote_patch = format!( + "*** Begin Patch\n*** Environment ID: {REMOTE_ENVIRONMENT_ID}\n*** Add File: {file_name}\n+remote\n*** End Patch" + ); + mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-local"), + ev_apply_patch_custom_tool_call("call-local", &local_patch), + ev_completed("resp-local"), + ]), + sse(vec![ + ev_response_created("resp-remote"), + ev_apply_patch_custom_tool_call("call-remote", &remote_patch), + ev_completed("resp-remote"), + ]), + sse(vec![ + ev_response_created("resp-done"), + ev_assistant_message("msg-done", "done"), + ev_completed("resp-done"), + ]), + ], + ) + .await; + + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "apply matching patches to local and remote environments".into(), + text_elements: Vec::new(), + }], + environments: Some(vec![ + TurnEnvironmentSelection { + environment_id: LOCAL_ENVIRONMENT_ID.to_string(), + cwd: shared_cwd.clone(), + }, + TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: shared_cwd.clone(), + }, + ]), + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.config.cwd.clone()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, + }) + .await?; + + let mut last_diff = None; + wait_for_event(&test.codex, |event| match event { + EventMsg::TurnDiff(ev) => { + last_diff = Some(ev.unified_diff.clone()); + false + } + EventMsg::TurnComplete(_) => true, + _ => false, + }) + .await; + + assert_eq!(fs::read_to_string(shared_cwd.join(file_name))?, "local\n"); + assert_eq!( + test.fs() + .read_file_text(&shared_cwd.join(file_name), /*sandbox*/ None) + .await?, + "remote\n" + ); + let diff = last_diff.expect("expected TurnDiff event"); + assert_eq!( + diff, + r#"diff --git a/local/shared-turn-diff.txt b/local/shared-turn-diff.txt +new file mode 100644 +index 0000000000000000000000000000000000000000..40830374235df1c19661a2901b7ca73cc9499f3d +--- /dev/null ++++ b/local/shared-turn-diff.txt +@@ -0,0 +1 @@ ++local +diff --git a/remote/shared-turn-diff.txt b/remote/shared-turn-diff.txt +new file mode 100644 +index 0000000000000000000000000000000000000000..9c998f7b995a7327177b38a90d1385170df2b94b +--- /dev/null ++++ b/remote/shared-turn-diff.txt +@@ -0,0 +1 @@ ++remote +"# + ); + + let _ = fs::remove_dir_all(shared_cwd.as_path()); + test.fs() + .remove( + &shared_cwd, + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> { skip_if_no_network!(Ok(())); From 841f057f2d2647441cfd3fa586f3f5d06f89d599 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 5 Jun 2026 14:33:31 -0300 Subject: [PATCH 33/59] fix(tui): avoid doubled blank rows while streaming (#26636) ## Summary During assistant-message streaming, blank markdown lines in the transient active tail were prefixed with two spaces. Ratatui measured those whitespace-only lines as two viewport rows, so list- and table-heavy answers showed doubled vertical gaps while streaming and then visibly compacted when finalized into scrollback. - keep whitespace-only `StreamingAgentTailCell` lines structurally empty while preserving nonblank message prefixes - clear impossible hyperlink metadata when normalizing a blank tail line - add an inline snapshot and height regression proving one blank markdown line occupies one viewport row Related to #26618, but fixes a separate live-tail row-height issue rather than stale committed markdown content. ## How to Test Recommended before/after reproduction: 1. Start the latest Codex build without this change. 2. Submit this exact prompt: > Send 20 different lists: bullets vs numbered, simple vs complex with paragraphs in between items, etc. Intertwine them with some tables and some paragraphs. 3. While the answer streams, observe duplicated vertical gaps around list items and paragraphs. When the answer finishes, observe the spacing compact. 4. Start this branch with `just c` and submit the same prompt. 5. Confirm each intended blank markdown line occupies one terminal row throughout streaming and that the spacing does not compact or jump when the answer finishes. 6. As a focused regression, verify the sections after the first table, especially loose lists with paragraphs between items; those blank rows should remain stable throughout streaming. Targeted tests: - `just test -p codex-tui streaming_agent_tail_blank_line_uses_one_viewport_row` - `just test -p codex-tui history_cell::tests` ## Test Notes - Verified the exact prompt above in a real tmux TUI using latest Codex and this branch as the before/after comparison. - The full `just test -p codex-tui` run completed 2,782 of 2,784 tests successfully. Two unrelated guardian feature-flag tests fail reproducibly in isolation because the expected `OverrideTurnContext` message is absent. - `just argument-comment-lint` is blocked locally by the existing Bazel `compiler-rt` missing-header glob error; the touched Rust diff was inspected manually for opaque positional literals. --- codex-rs/tui/src/history_cell/messages.rs | 16 ++++++++++++++-- codex-rs/tui/src/history_cell/tests.rs | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index ed899f3b2e8a..787f999cf1ee 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -423,7 +423,7 @@ impl HistoryCell for StreamingAgentTailCell { fn display_hyperlink_lines(&self, _width: u16) -> Vec { // Tail lines are already rendered at the controller's current stream width. // Re-wrapping them here can split table borders and produce malformed in-flight rows. - prefix_hyperlink_lines( + let mut lines = prefix_hyperlink_lines( self.lines.clone(), if self.is_first_line { "• ".dim() @@ -431,7 +431,19 @@ impl HistoryCell for StreamingAgentTailCell { " ".into() }, " ".into(), - ) + ); + for line in &mut lines { + if line + .line + .spans + .iter() + .all(|span| span.content.chars().all(char::is_whitespace)) + { + line.line = Line::default().style(line.line.style); + line.hyperlinks.clear(); + } + } + lines } fn transcript_hyperlink_lines(&self, width: u16) -> Vec { diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 63a04d19d4f1..ec3ae49f2159 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -45,6 +45,24 @@ fn test_cwd() -> PathBuf { std::env::temp_dir() } +#[test] +fn streaming_agent_tail_blank_line_uses_one_viewport_row() { + let cell = StreamingAgentTailCell::new( + vec![ + HyperlinkLine::from("first"), + HyperlinkLine::from(""), + HyperlinkLine::from("second"), + ], + /*is_first_line*/ false, + ); + + let lines = cell.display_lines(/*width*/ 80); + insta::assert_snapshot!(render_lines(&lines).join("\n"), @" first + + second"); + assert_eq!(cell.desired_height(/*width*/ 80), 3); +} + fn stdio_server_config( command: &str, args: Vec<&str>, From 0177231ca055955bd553ac5f57d59f5df9639dd4 Mon Sep 17 00:00:00 2001 From: hefuc-oai Date: Fri, 5 Jun 2026 10:33:56 -0700 Subject: [PATCH 34/59] feat(app-server): add remote control pairing status RPC (#26450) ## What Exposes the pairing status transport as experimental app-server v2 RPC `remoteControl/pairing/status`. - Adds request/response protocol types for exactly one lookup key: `pairingCode` or `manualPairingCode`, returning `{ claimed }`. - Registers the RPC with `global_shared_read("remote-control-pairing")`. - Wires the method through `MessageProcessor` and `RemoteControlRequestProcessor`. - Validates missing/conflicting pairing-code params as invalid requests. - Documents the RPC in `app-server/README.md`. - Adds processor, protocol export, and JSON-RPC integration coverage for both code paths. ## Why This is the app-server surface the desktop app can poll while the QR/manual pairing modal is active. Depends on https://github.com/openai/codex/pull/26449 Related backend change: https://github.com/openai/openai/pull/990244 ## Verification - `cargo test --manifest-path app-server-protocol/Cargo.toml remote_control` - `cargo test --manifest-path app-server/Cargo.toml remote_control` - `cargo fmt --all --check` - `git diff --check` --- codex-rs/app-server-protocol/src/export.rs | 3 + .../src/protocol/common.rs | 19 +++++ codex-rs/app-server/README.md | 1 + codex-rs/app-server/src/message_processor.rs | 5 ++ .../remote_control_processor.rs | 27 +++++++ .../remote_control_processor_tests.rs | 53 +++++++++++++ .../tests/common/test_app_server.rs | 11 +++ .../tests/suite/v2/remote_control.rs | 74 +++++++++++++++++++ 8 files changed, 193 insertions(+) diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 20f923f327cf..cd6c7e47b6a9 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -2963,11 +2963,14 @@ permissionProfile?: string | null}; let client_request_json = fs::read_to_string(output_dir.join("ClientRequest.json"))?; assert!(client_request_json.contains("remoteControl/pairing/start")); + assert!(client_request_json.contains("remoteControl/pairing/status")); assert!(client_request_json.contains("remoteControl/client/list")); assert!(client_request_json.contains("remoteControl/client/revoke")); for schema in [ "RemoteControlPairingStartParams.json", "RemoteControlPairingStartResponse.json", + "RemoteControlPairingStatusParams.json", + "RemoteControlPairingStatusResponse.json", "RemoteControlClientsListParams.json", "RemoteControlClientsListResponse.json", "RemoteControlClientsRevokeParams.json", diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index a624387e4d2b..1c996d7cb0e3 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -849,6 +849,12 @@ client_request_definitions! { serialization: global("remote-control-pairing"), response: v2::RemoteControlPairingStartResponse, }, + #[experimental("remoteControl/pairing/status")] + RemoteControlPairingStatus => "remoteControl/pairing/status" { + params: v2::RemoteControlPairingStatusParams, + serialization: global_shared_read("remote-control-pairing"), + response: v2::RemoteControlPairingStatusResponse, + }, #[experimental("remoteControl/client/list")] RemoteControlClientsList => "remoteControl/client/list" { params: v2::RemoteControlClientsListParams, @@ -2014,6 +2020,19 @@ mod tests { "remote-control-pairing" )) ); + let remote_control_pairing_status = ClientRequest::RemoteControlPairingStatus { + request_id: request_id(), + params: v2::RemoteControlPairingStatusParams { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }, + }; + assert_eq!( + remote_control_pairing_status.serialization_scope(), + Some(ClientRequestSerializationScope::GlobalSharedRead( + "remote-control-pairing" + )) + ); let remote_control_clients_list = ClientRequest::RemoteControlClientsList { request_id: request_id(), params: v2::RemoteControlClientsListParams::default(), diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index b7fc6b82a3ed..4e06dc16e352 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -211,6 +211,7 @@ Example with notification opt-out: - `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices. - `remoteControl/status/read` — experimental; read the current remote-control status snapshot. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. - `remoteControl/pairing/start` — experimental; start a short-lived remote-control pairing artifact for the current app-server process. Pass `manualCode: true` to also request a manual pairing code. Returns `pairingCode`, `manualPairingCode`, `environmentId`, and Unix-seconds `expiresAt`; app-server intentionally does not expose the backend `serverId`. +- `remoteControl/pairing/status` — experimental; poll whether a remote-control `pairingCode` or `manualPairingCode` has been claimed. Pass exactly one of the two fields. Returns `claimed`. - `remoteControl/client/list` — experimental; list controller devices granted access to an environment. Pass `environmentId` and optional `cursor`, `limit`, and `order`; returns picker-oriented client metadata plus `nextCursor`. This signed-in account-management operation works while the local relay is disabled or unenrolled. - `remoteControl/client/revoke` — experimental; revoke one controller device's grant for an environment. Pass `environmentId` and `clientId`; returns an empty object. This signed-in account-management operation works while the local relay is disabled or unenrolled. - `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 07bc06218e2b..76f39663a0b4 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -921,6 +921,11 @@ impl MessageProcessor { .pairing_start(params, app_server_client_name.as_deref()) .await .map(|response| Some(response.into())), + ClientRequest::RemoteControlPairingStatus { params, .. } => self + .remote_control_processor + .pairing_status(params) + .await + .map(|response| Some(response.into())), ClientRequest::RemoteControlClientsList { params, .. } => self .remote_control_processor .clients_list(params) diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor.rs b/codex-rs/app-server/src/request_processors/remote_control_processor.rs index 22223687bc13..31b5fd6b749e 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor.rs @@ -11,6 +11,8 @@ use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; use codex_app_server_protocol::RemoteControlPairingStartParams; use codex_app_server_protocol::RemoteControlPairingStartResponse; +use codex_app_server_protocol::RemoteControlPairingStatusParams; +use codex_app_server_protocol::RemoteControlPairingStatusResponse; use codex_app_server_protocol::RemoteControlStatusReadResponse; use std::io; @@ -60,6 +62,17 @@ impl RemoteControlRequestProcessor { .map_err(map_pairing_start_error) } + pub(crate) async fn pairing_status( + &self, + params: RemoteControlPairingStatusParams, + ) -> Result { + validate_pairing_status_params(¶ms)?; + self.handle()? + .pairing_status(params) + .await + .map_err(map_pairing_start_error) + } + pub(crate) async fn clients_list( &self, params: RemoteControlClientsListParams, @@ -99,6 +112,20 @@ fn map_pairing_start_error(err: io::Error) -> JSONRPCErrorError { } } +fn validate_pairing_status_params( + params: &RemoteControlPairingStatusParams, +) -> Result<(), JSONRPCErrorError> { + match (¶ms.pairing_code, ¶ms.manual_pairing_code) { + (Some(_), None) | (None, Some(_)) => Ok(()), + (Some(_), Some(_)) => Err(invalid_request( + "remoteControl/pairing/status accepts either pairingCode or manualPairingCode, not both", + )), + (None, None) => Err(invalid_request( + "remoteControl/pairing/status requires pairingCode or manualPairingCode", + )), + } +} + fn map_client_management_error(err: io::Error) -> JSONRPCErrorError { match err.kind() { io::ErrorKind::InvalidInput diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs index e336491cd3ba..36c60b913e3f 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs @@ -23,6 +23,59 @@ async fn pairing_start_returns_internal_error_when_remote_control_is_unavailable ); } +#[tokio::test] +async fn pairing_status_returns_internal_error_when_remote_control_is_unavailable() { + let err = RemoteControlRequestProcessor::new(/*remote_control_handle*/ None) + .pairing_status(RemoteControlPairingStatusParams { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }) + .await + .expect_err("missing remote control should fail pairing status"); + + assert_eq!( + err, + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + data: None, + message: "remote control is unavailable for this app-server".to_string(), + } + ); +} + +#[test] +fn pairing_status_rejects_missing_pairing_codes() { + assert_eq!( + validate_pairing_status_params(&RemoteControlPairingStatusParams { + pairing_code: None, + manual_pairing_code: None, + }), + Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + data: None, + message: "remoteControl/pairing/status requires pairingCode or manualPairingCode" + .to_string(), + }) + ); +} + +#[test] +fn pairing_status_rejects_conflicting_pairing_codes() { + assert_eq!( + validate_pairing_status_params(&RemoteControlPairingStatusParams { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: Some("ABCD-EFGH".to_string()), + }), + Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + data: None, + message: + "remoteControl/pairing/status accepts either pairingCode or manualPairingCode, not both" + .to_string(), + }) + ); +} + #[test] fn pairing_start_maps_invalid_input_to_invalid_request() { assert_eq!( diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index ec4b30d92f9f..646de6ad2159 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -70,6 +70,7 @@ use codex_app_server_protocol::ProcessWriteStdinParams; use codex_app_server_protocol::RemoteControlClientsListParams; use codex_app_server_protocol::RemoteControlClientsRevokeParams; use codex_app_server_protocol::RemoteControlPairingStartParams; +use codex_app_server_protocol::RemoteControlPairingStatusParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; @@ -656,6 +657,16 @@ impl TestAppServer { .await } + /// Send a `remoteControl/pairing/status` JSON-RPC request. + pub async fn send_remote_control_pairing_status_request( + &mut self, + params: RemoteControlPairingStatusParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("remoteControl/pairing/status", params) + .await + } + /// Send a `remoteControl/client/list` JSON-RPC request. pub async fn send_remote_control_clients_list_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index c48ffda0f634..0229b0e5af24 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -19,12 +19,15 @@ use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; use codex_app_server_protocol::RemoteControlPairingStartParams; use codex_app_server_protocol::RemoteControlPairingStartResponse; +use codex_app_server_protocol::RemoteControlPairingStatusParams; +use codex_app_server_protocol::RemoteControlPairingStatusResponse; use codex_app_server_protocol::RemoteControlStatusReadResponse; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; use tokio::io::BufReader; use tokio::net::TcpListener; @@ -190,6 +193,44 @@ async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()> expires_at: 33_336_362_096, } ); + + let request_id = mcp + .send_remote_control_pairing_status_request(RemoteControlPairingStatusParams { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(response.result.get("serverId"), None); + let received: RemoteControlPairingStatusResponse = to_response(response)?; + + assert_eq!( + received, + RemoteControlPairingStatusResponse { claimed: true } + ); + + let request_id = mcp + .send_remote_control_pairing_status_request(RemoteControlPairingStatusParams { + pairing_code: None, + manual_pairing_code: Some("ABCD-EFGH".to_string()), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(response.result.get("serverId"), None); + let received: RemoteControlPairingStatusResponse = to_response(response)?; + + assert_eq!( + received, + RemoteControlPairingStatusResponse { claimed: true } + ); Ok(()) } @@ -429,6 +470,25 @@ impl PairingRemoteControlBackend { }), ) .await?; + for expected_body in [ + serde_json::json!({ "pairing_code": "pairing-code" }), + serde_json::json!({ "manual_pairing_code": "ABCD-EFGH" }), + ] { + let status_http_request = read_http_request(&listener).await?; + assert_eq!( + status_http_request.request_line, + "POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1" + ); + assert_eq!( + serde_json::from_str::(&status_http_request.body)?, + expected_body + ); + respond_with_json( + status_http_request.reader.into_inner(), + serde_json::json!({ "claimed": true }), + ) + .await?; + } std::future::pending::<()>().await; Ok::<(), anyhow::Error>(()) } @@ -476,6 +536,7 @@ impl Drop for ClientManagementRemoteControlBackend { struct HttpRequest { request_line: String, + body: String, reader: BufReader, } @@ -508,16 +569,29 @@ async fn read_http_request(listener: &TcpListener) -> Result { let mut request_line = String::new(); reader.read_line(&mut request_line).await?; + let mut content_length = 0; loop { let mut line = String::new(); reader.read_line(&mut line).await?; if line == "\r\n" { break; } + if let Some(value) = line + .trim_end() + .strip_prefix("content-length:") + .or_else(|| line.trim_end().strip_prefix("Content-Length:")) + { + content_length = value.trim().parse::()?; + } + } + let mut body = vec![0; content_length]; + if content_length > 0 { + reader.read_exact(&mut body).await?; } Ok(HttpRequest { request_line: request_line.trim_end().to_string(), + body: String::from_utf8(body)?, reader, }) } From 78eba34b4148ec14493684ffc3a866415cbbbbd1 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Fri, 5 Jun 2026 10:36:14 -0700 Subject: [PATCH 35/59] Clean up Rust release workflow (#26335) ## Why PR #26252 moved macOS release signing into the tag-triggered `rust-release` workflow through the protected `codesigning` environment and Azure Key Vault. That leaves the old manual unsigned-build / signed-promotion handoff as dead compatibility scaffolding: it makes the release DAG harder to reason about and keeps paths around that the current release process no longer intends to operate. ## What changed - Remove the manual `workflow_dispatch` inputs and validation for `build_unsigned`, `promote_signed`, and the deprecated `sign_macos` flag. - Drop the `stage-signed-macos` job and the promotion-specific artifact download, re-upload, pruning, and cleanup logic. - Make tag-pushed releases always follow the signed release path: build, sign, package, finalize, publish, and then run downstream release jobs from `release` success. - Remove stale `SIGN_MACOS` / `sign_macos` conditions and outputs, including downstream gates for npm, DotSlash, WinGet, dev website deploy, and `latest-alpha-cli` branch updates. ## Verification - `ruby -e 'require "yaml"; YAML.load_file(ARGV.fetch(0)); puts "yaml ok"' .github/workflows/rust-release.yml` - `git diff --check` - `rg -n "workflow_dispatch|inputs\\.|release_mode|build_unsigned|SIGN_MACOS|outputs\\.sign_macos|sign_macos\\b" .github/workflows/rust-release.yml` returned no matches --- .github/workflows/rust-release.yml | 556 +---------------------------- 1 file changed, 13 insertions(+), 543 deletions(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 03a0cc57ffae..f95d1216f8a6 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -7,46 +7,12 @@ # # Tag releases sign macOS binaries and DMGs through the protected `codesigning` # GitHub environment and Azure Key Vault before final verification on macOS. -# -# To use external macOS signing, manually dispatch `release_mode=build_unsigned`, -# sign the unsigned macOS artifacts in a secure enclave, upload the signed handoff -# archive as a GitHub Release asset, then manually dispatch -# `release_mode=promote_signed` with `unsigned_run_id` and `signed_macos_asset`. -# The signed handoff archive should contain target or artifact directories such -# as `aarch64-apple-darwin/` with signed binaries. name: rust-release on: push: tags: - "rust-v*.*.*" - workflow_dispatch: - inputs: - release_mode: - description: "build_unsigned creates unsigned macOS handoff artifacts; promote_signed finishes a release from signed macOS handoff artifacts." - required: false - type: choice - default: build_unsigned - options: - - build_unsigned - - promote_signed - sign_macos: - description: "Deprecated compatibility input; use release_mode instead." - required: false - type: boolean - default: false - unsigned_run_id: - description: "For promote_signed: workflow run id from the build_unsigned run." - required: false - type: string - signed_macos_asset: - description: "For promote_signed: exact GitHub Release asset name containing signed macOS handoff artifacts." - required: false - type: string - signed_macos_sha256: - description: "For promote_signed: optional SHA-256 of signed_macos_asset." - required: false - type: string concurrency: group: ${{ github.workflow }} @@ -62,61 +28,11 @@ jobs: - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - name: Validate tag matches Cargo.toml version shell: bash - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - REQUESTED_SIGN_MACOS: ${{ inputs.sign_macos }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} run: | set -euo pipefail echo "::group::Tag validation" - case "${RELEASE_MODE}" in - signed) - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - echo "❌ Manual rust-release runs must use release_mode=build_unsigned or release_mode=promote_signed" - exit 1 - fi - ;; - build_unsigned) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=build_unsigned is only valid for manual runs" - exit 1 - fi - ;; - promote_signed) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=promote_signed is only valid for manual runs" - exit 1 - fi - if [[ ! "${UNSIGNED_RUN_ID}" =~ ^[0-9]+$ ]]; then - echo "❌ release_mode=promote_signed requires unsigned_run_id to be a workflow run id" - exit 1 - fi - if [[ -z "${SIGNED_MACOS_ASSET}" ]]; then - echo "❌ release_mode=promote_signed requires signed_macos_asset" - exit 1 - fi - if [[ "${SIGNED_MACOS_ASSET}" == */* || "${SIGNED_MACOS_ASSET}" == *"*"* || "${SIGNED_MACOS_ASSET}" == *"?"* || "${SIGNED_MACOS_ASSET}" == *"["* ]]; then - echo "❌ signed_macos_asset must be an exact release asset name, not a path or glob" - exit 1 - fi - if [[ "${UNSIGNED_RUN_ID}" == "${GITHUB_RUN_ID}" ]]; then - echo "❌ unsigned_run_id must refer to the earlier build_unsigned run, not this run" - exit 1 - fi - ;; - *) - echo "❌ Unknown release_mode '${RELEASE_MODE}'" - exit 1 - ;; - esac - - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${REQUESTED_SIGN_MACOS}" == "true" ]]; then - echo "::warning title=Deprecated sign_macos input ignored::Use release_mode=build_unsigned or release_mode=promote_signed instead." - fi - - # All release modes must run from a tag. + # Release runs must come from a tag. [[ "${GITHUB_REF_TYPE}" == "tag" ]] \ || { echo "❌ Not a tag ref"; exit 1; } @@ -135,7 +51,6 @@ jobs: echo "::endgroup::" build: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} needs: tag-check name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} runs-on: ${{ matrix.runs_on || matrix.runner }} @@ -514,7 +429,6 @@ jobs: codex-rs/dist/${{ matrix.target }}/* sign-macos-binaries: - if: ${{ github.event_name != 'workflow_dispatch' }} needs: build name: Sign macOS binaries - ${{ matrix.target }} - ${{ matrix.bundle }} runs-on: ubuntu-latest @@ -634,7 +548,6 @@ jobs: if-no-files-found: warn package-macos: - if: ${{ github.event_name != 'workflow_dispatch' }} needs: sign-macos-binaries name: Package macOS artifacts - ${{ matrix.target }} - ${{ matrix.bundle }} runs-on: macos-15-xlarge @@ -824,7 +737,6 @@ jobs: if-no-files-found: error sign-macos-dmg: - if: ${{ github.event_name != 'workflow_dispatch' }} needs: package-macos name: Sign macOS DMG - ${{ matrix.target }} runs-on: ubuntu-latest @@ -921,7 +833,6 @@ jobs: if-no-files-found: warn finalize-macos: - if: ${{ github.event_name != 'workflow_dispatch' }} needs: - package-macos - sign-macos-dmg @@ -1083,253 +994,12 @@ jobs: path: codex-rs/dist/${{ matrix.target }}/* if-no-files-found: error - stage-signed-macos: - if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode == 'promote_signed' }} - needs: tag-check - name: Stage signed macOS handoff - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: macos-15-xlarge - timeout-minutes: 30 - permissions: - contents: read - defaults: - run: - working-directory: codex-rs - - strategy: - fail-fast: false - matrix: - include: - - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Download signed macOS handoff - shell: bash - env: - GH_TOKEN: ${{ github.token }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - SIGNED_MACOS_SHA256: ${{ inputs.signed_macos_sha256 }} - run: | - set -euo pipefail - - download_dir="${RUNNER_TEMP}/signed-macos-download" - handoff_dir="${RUNNER_TEMP}/signed-macos-handoff" - rm -rf "$download_dir" "$handoff_dir" - mkdir -p "$download_dir" "$handoff_dir" - - gh release download "$GITHUB_REF_NAME" \ - --repo "$GITHUB_REPOSITORY" \ - --pattern "$SIGNED_MACOS_ASSET" \ - --dir "$download_dir" - - asset_count="$(find "$download_dir" -maxdepth 1 -type f | wc -l | tr -d '[:space:]')" - if [[ "$asset_count" != "1" ]]; then - echo "Expected exactly one signed macOS handoff asset named ${SIGNED_MACOS_ASSET}; found ${asset_count}" - find "$download_dir" -maxdepth 1 -type f -print - exit 1 - fi - - asset_path="$(find "$download_dir" -maxdepth 1 -type f -print -quit)" - if [[ -n "${SIGNED_MACOS_SHA256}" ]]; then - expected_sha="$(printf '%s' "$SIGNED_MACOS_SHA256" | tr '[:upper:]' '[:lower:]')" - actual_sha="$(shasum -a 256 "$asset_path" | awk '{print $1}')" - if [[ "$actual_sha" != "$expected_sha" ]]; then - echo "signed_macos_sha256 mismatch for ${SIGNED_MACOS_ASSET}" - echo "expected: ${expected_sha}" - echo "actual: ${actual_sha}" - exit 1 - fi - fi - - asset_name="$(basename "$asset_path")" - case "$asset_name" in - *.tar.zst) - zstd -dc "$asset_path" | tar -C "$handoff_dir" -xf - - ;; - *.tar.gz|*.tgz) - tar -C "$handoff_dir" -xzf "$asset_path" - ;; - *.zip) - ditto -x -k "$asset_path" "$handoff_dir" - ;; - *) - echo "Unsupported signed macOS handoff archive format: ${asset_name}" - exit 1 - ;; - esac - - echo "SIGNED_MACOS_HANDOFF_DIR=$handoff_dir" >> "$GITHUB_ENV" - - - name: Stage signed macOS artifacts - shell: bash - run: | - set -euo pipefail - - target="${{ matrix.target }}" - artifact_name="${{ matrix.artifact_name }}" - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${target}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - fi - if [[ ! -d "$source_dir" ]]; then - echo "Signed macOS handoff is missing ${artifact_name}/" - echo "Expected either:" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${target}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - find "$SIGNED_MACOS_HANDOFF_DIR" -maxdepth 3 -type f -print - exit 1 - fi - - dest="dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - source_path="${source_dir}/${binary}" - if [[ ! -f "$source_path" ]]; then - source_path="${source_dir}/${binary}-${target}" - fi - if [[ ! -f "$source_path" ]]; then - echo "Signed macOS handoff is missing ${binary} for ${artifact_name}" - exit 1 - fi - - release_path="${dest}/${binary}-${target}" - ditto "$source_path" "$release_path" - chmod 0755 "$release_path" - codesign --verify --strict --verbose=2 "$release_path" - done - - # DMG staging is disabled for signed promotion because we no longer - # distribute DMGs from this release path. Keep the branch here so the - # handoff can opt back in by flipping matrix.build_dmg if needed. - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then - dmg_name="codex-${target}.dmg" - dmg_source="${source_dir}/${dmg_name}" - if [[ ! -f "$dmg_source" ]]; then - echo "Signed macOS handoff is missing ${dmg_name} for ${artifact_name}" - exit 1 - fi - - codesign --verify --strict --verbose=2 "$dmg_source" - xcrun stapler validate "$dmg_source" - cp "$dmg_source" "$dest/$dmg_name" - fi - - - name: Build Codex package archive - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "dist/${TARGET}" \ - --archive-dir "dist/${TARGET}" \ - --target-suffixed-entrypoint - - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - python3 \ - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ - stage-runtime \ - "$stage_dir" \ - "dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz" \ - --codex-version "${GITHUB_REF_NAME}" \ - --platform-tag "$platform_tag" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error - - - name: Compress artifacts - shell: bash - run: | - set -euo pipefail - - dest="dist/${{ matrix.target }}" - for f in "$dest"/*; do - base="$(basename "$f")" - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then - continue - fi - - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }} - path: | - codex-rs/dist/${{ matrix.target }}/* - build-windows: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'build_unsigned' }} needs: tag-check uses: ./.github/workflows/rust-release-windows.yml secrets: inherit argument-comment-lint-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'build_unsigned' }} name: argument-comment-lint release assets needs: tag-check uses: ./.github/workflows/rust-release-argument-comment-lint.yml @@ -1337,7 +1007,6 @@ jobs: publish: true zsh-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'build_unsigned' }} name: zsh release assets needs: tag-check uses: ./.github/workflows/rust-release-zsh.yml @@ -1347,7 +1016,6 @@ jobs: - tag-check - build - finalize-macos - - stage-signed-macos - build-windows - argument-comment-lint-release-assets - zsh-release-assets @@ -1355,52 +1023,20 @@ jobs: ${{ always() && needs.tag-check.result == 'success' && - ( - ( - github.event_name == 'workflow_dispatch' && - inputs.release_mode == 'promote_signed' && - needs.stage-signed-macos.result == 'success' && - needs.build.result == 'skipped' && - needs.finalize-macos.result == 'skipped' && - needs.build-windows.result == 'skipped' && - needs.argument-comment-lint-release-assets.result == 'skipped' && - needs.zsh-release-assets.result == 'skipped' - ) || - ( - (github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed') && - needs.build.result == 'success' && - ( - ( - github.event_name == 'workflow_dispatch' && - inputs.release_mode == 'build_unsigned' && - needs.finalize-macos.result == 'skipped' - ) || - ( - github.event_name != 'workflow_dispatch' && - needs.finalize-macos.result == 'success' - ) - ) && - needs.stage-signed-macos.result == 'skipped' && - needs.build-windows.result == 'success' && - needs.argument-comment-lint-release-assets.result == 'success' && - needs.zsh-release-assets.result == 'success' - ) - ) + needs.build.result == 'success' && + needs.finalize-macos.result == 'success' && + needs.build-windows.result == 'success' && + needs.argument-comment-lint-release-assets.result == 'success' && + needs.zsh-release-assets.result == 'success' }} name: release runs-on: ubuntu-latest permissions: contents: write actions: read - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'promote_signed' }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} outputs: version: ${{ steps.release_name.outputs.name }} tag: ${{ github.ref_name }} - sign_macos: ${{ steps.release_mode.outputs.sign_macos }} should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} @@ -1410,12 +1046,6 @@ jobs: with: persist-credentials: false - - name: Define release mode - id: release_mode - run: | - echo "release_mode=${RELEASE_MODE}" >> "$GITHUB_OUTPUT" - echo "sign_macos=${SIGN_MACOS}" >> "$GITHUB_OUTPUT" - - name: Generate release notes from tag commit message id: release_notes shell: bash @@ -1440,121 +1070,9 @@ jobs: with: path: dist - - name: Validate unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - run_summary="$(gh run view "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --json conclusion,event,headBranch,headSha,status,workflowName,url \ - --jq '[.workflowName, .event, .headBranch, .headSha, .status, .conclusion, .url] | @tsv')" - IFS=$'\t' read -r workflow_name event head_branch head_sha status conclusion run_url <<< "$run_summary" - expected_head_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" - - if [[ "$workflow_name" != "$GITHUB_WORKFLOW" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is for workflow '${workflow_name}', expected '${GITHUB_WORKFLOW}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$event" != "workflow_dispatch" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} was triggered by '${event}', expected 'workflow_dispatch'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_branch" != "$GITHUB_REF_NAME" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used ref '${head_branch}', expected '${GITHUB_REF_NAME}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_sha" != "$expected_head_sha" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used head SHA '${head_sha}', expected '${expected_head_sha}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$status" != "completed" || "$conclusion" != "success" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is ${status}/${conclusion}, expected completed/success" - echo "Run URL: ${run_url}" - exit 1 - fi - - - name: Download artifacts from unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh run download "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --dir dist - - - name: Remove unsigned macOS staging artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - run: | - set -euo pipefail - find dist -mindepth 1 -maxdepth 1 -type d \ - -name '*-apple-darwin*-unsigned' \ - -exec rm -rf {} + - - - name: Re-upload promoted Linux x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-unknown-linux-musl - path: dist/x86_64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Linux arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-unknown-linux-musl - path: dist/aarch64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Windows x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-pc-windows-msvc - path: dist/x86_64-pc-windows-msvc/* - if-no-files-found: error - - - name: Re-upload promoted Windows arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-pc-windows-msvc - path: dist/aarch64-pc-windows-msvc/* - if-no-files-found: error - - name: List run: ls -R dist/ - - name: Prune artifacts excluded from unsigned macOS release - if: ${{ env.SIGN_MACOS == 'false' }} - run: | - find dist -mindepth 1 -maxdepth 1 -type d \ - ! -name '*-apple-darwin*-unsigned' \ - ! -name 'aarch64-unknown-linux-musl' \ - ! -name 'aarch64-unknown-linux-musl-app-server' \ - ! -name 'x86_64-unknown-linux-musl' \ - ! -name 'x86_64-unknown-linux-musl-app-server' \ - ! -name 'aarch64-pc-windows-msvc' \ - ! -name 'x86_64-pc-windows-msvc' \ - -exec rm -rf {} + - - if ! find dist -type f -name '*-apple-darwin*-unsigned*' | grep -q .; then - echo "No unsigned macOS artifacts found in downloaded workflow artifacts." - exit 1 - fi - - name: Delete entries from dist/ that should not go in the release run: | rm -rf dist/windows-binaries* @@ -1564,9 +1082,7 @@ jobs: rm -rf dist/*-apple-darwin*-signed-dmg rm -rf dist/*-apple-darwin*-binary-signing-verification rm -rf dist/*-apple-darwin*-dmg-signing-verification - if [[ "${SIGN_MACOS}" == "true" ]]; then - rm -rf dist/*-apple-darwin*-unsigned - fi + rm -rf dist/*-apple-darwin*-unsigned # cargo-timing.html appears under multiple target-specific directories. # If included in files: dist/**, release upload races on duplicate # asset names and can fail with 404s. @@ -1618,12 +1134,6 @@ jobs: set -euo pipefail version="${VERSION}" - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - echo "npm_tag=" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "npm_tag=" >> "$GITHUB_OUTPUT" @@ -1636,23 +1146,19 @@ jobs: fi - name: Setup pnpm - if: ${{ env.SIGN_MACOS == 'true' }} uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: run_install: false - name: Setup Node.js for npm packaging - if: ${{ env.SIGN_MACOS == 'true' }} uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 22 - name: Install dependencies - if: ${{ env.SIGN_MACOS == 'true' }} run: pnpm install --frozen-lockfile - name: Stage npm packages - if: ${{ env.SIGN_MACOS == 'true' }} env: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ steps.release_name.outputs.name }} @@ -1666,7 +1172,6 @@ jobs: --package codex-sdk - name: Stage installer scripts - if: ${{ env.SIGN_MACOS == 'true' }} run: | cp scripts/install/install.sh dist/install.sh cp scripts/install/install.ps1 dist/install.ps1 @@ -1679,55 +1184,26 @@ jobs: body_path: ${{ steps.release_notes.outputs.path }} files: dist/** overwrite_files: true - make_latest: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} + make_latest: ${{ !contains(steps.release_name.outputs.name, '-') }} # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - - name: Clean up signed promotion handoff assets - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" --jq '.id')" - gh api --paginate "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets" \ - --jq '.[] | [.id, .name] | @tsv' | - while IFS=$'\t' read -r asset_id asset_name; do - if [[ -z "$asset_id" || -z "$asset_name" ]]; then - continue - fi - - delete_asset=false - if [[ "$asset_name" == *unsigned* || "$asset_name" == "$SIGNED_MACOS_ASSET" ]]; then - delete_asset=true - fi - - if [[ "$delete_asset" == "true" ]]; then - echo "Deleting release asset ${asset_name}" - gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" - fi - done - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-config.json - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-zsh-config.json - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -1739,9 +1215,6 @@ jobs: # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - # promote_signed intentionally skips build jobs that are ancestors of release; - # include the !cancelled() status function so Actions does not apply its implicit - # success() check to the whole dependency chain before evaluating release outputs. if: >- ${{ !cancelled() && @@ -1902,13 +1375,12 @@ jobs: deploy-dev-website: name: Trigger developers.openai.com deploy needs: release - # Only trigger the deploy for a stable signed release. + # Only trigger the deploy for a stable release. # The deploy updates developers.openai.com with the new config schema json file. if: >- ${{ !cancelled() && needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' && !contains(needs.release.outputs.version, '-') }} runs-on: ubuntu-latest @@ -1938,7 +1410,6 @@ jobs: ${{ !cancelled() && needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' && !contains(needs.release.outputs.version, '-') }} # This job only invokes a GitHub Action to open/update the winget-pkgs PR; @@ -1966,8 +1437,7 @@ jobs: if: >- ${{ !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' + needs.release.result == 'success' }} permissions: contents: write From a8c95309111f1bb8b4864102a306d7e60a729ad0 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 5 Jun 2026 10:37:38 -0700 Subject: [PATCH 36/59] [1 of 2] Align goal extension with core behavior (#26547) ## Stack 1. [#26547](https://github.com/openai/codex/pull/26547) - [1 of 2] Align goal extension with core behavior 2. [#26548](https://github.com/openai/codex/pull/26548) - [2 of 2] Move goal runtime to extension ## Why The goal runtime is moving out of `codex-core` and into `codex-goal-extension`. This first PR brings the extension back in line with the current core behavior before the follow-up PR switches app-server sessions over to the extension, so that review can focus on ownership and wiring rather than hidden behavior drift. ## What Changed - Updates the extension `create_goal` and `update_goal` tool schemas/descriptions to match the current core wording for explicit token budgets, blocked-goal audits, resumed blocked goals, and system-owned budget/usage-limit transitions. - Marks `codex-goal-extension` as the live `/goal` extension crate rather than an unwired sketch. - Looks up the live thread before reading goal state for idle continuation, so continuation setup exits early when no live thread can accept the automatic turn. --- codex-rs/ext/goal/src/api.rs | 30 +++++++++++++++++++++++++++-- codex-rs/ext/goal/src/lib.rs | 6 +----- codex-rs/ext/goal/src/runtime.rs | 33 +++++++++++++++++++++++--------- codex-rs/ext/goal/src/spec.rs | 14 +++++++++----- 4 files changed, 62 insertions(+), 21 deletions(-) diff --git a/codex-rs/ext/goal/src/api.rs b/codex-rs/ext/goal/src/api.rs index 0e23c295a219..5123e6a5ceb7 100644 --- a/codex-rs/ext/goal/src/api.rs +++ b/codex-rs/ext/goal/src/api.rs @@ -124,7 +124,19 @@ impl GoalService { .map_err(GoalServiceError::InvalidRequest)?; } - if let Some(runtime) = self.runtime_for_thread(thread_id) + let runtime = self.runtime_for_thread(thread_id); + // Hold this through the prepare/write window so idle continuation cannot + // launch from goal state that this external mutation is about to change. + let _goal_state_permit = match runtime.as_ref() { + Some(runtime) => Some( + runtime + .goal_state_permit() + .await + .map_err(GoalServiceError::Internal)?, + ), + None => None, + }; + if let Some(runtime) = runtime.as_ref() && let Err(err) = runtime.prepare_external_goal_mutation().await { tracing::warn!("failed to prepare external goal mutation: {err}"); @@ -229,7 +241,19 @@ impl GoalService { state_db: &codex_state::StateRuntime, thread_id: ThreadId, ) -> Result { - if let Some(runtime) = self.runtime_for_thread(thread_id) + let runtime = self.runtime_for_thread(thread_id); + // Hold this through the prepare/write window so idle continuation cannot + // launch from goal state that this external mutation is about to change. + let goal_state_permit = match runtime.as_ref() { + Some(runtime) => Some( + runtime + .goal_state_permit() + .await + .map_err(GoalServiceError::Internal)?, + ), + None => None, + }; + if let Some(runtime) = runtime.as_ref() && let Err(err) = runtime.prepare_external_goal_mutation().await { tracing::warn!("failed to prepare external goal mutation: {err}"); @@ -242,6 +266,8 @@ impl GoalService { .map_err(|err| { GoalServiceError::Internal(format!("failed to clear thread goal: {err}")) })?; + drop(goal_state_permit); + drop(runtime); if cleared && let Some(runtime) = self.runtime_for_thread(thread_id) diff --git a/codex-rs/ext/goal/src/lib.rs b/codex-rs/ext/goal/src/lib.rs index 8433e1f7df76..bb640c6ce899 100644 --- a/codex-rs/ext/goal/src/lib.rs +++ b/codex-rs/ext/goal/src/lib.rs @@ -1,8 +1,4 @@ -//! Extension crate sketch for the `/goal` feature. -//! -//! This crate is intentionally not wired into the host yet. It contains the -//! goal tool specs, extension registration shape, and the parts of runtime -//! accounting that can be represented with today's extension API. +//! Extension crate for the `/goal` feature. mod accounting; mod api; diff --git a/codex-rs/ext/goal/src/runtime.rs b/codex-rs/ext/goal/src/runtime.rs index b58d1244e6ba..9818282cae25 100644 --- a/codex-rs/ext/goal/src/runtime.rs +++ b/codex-rs/ext/goal/src/runtime.rs @@ -15,6 +15,8 @@ use crate::metrics::GoalMetrics; use crate::steering::continuation_steering_item; use crate::steering::objective_updated_steering_item; use crate::tool::protocol_goal_from_state; +use tokio::sync::Semaphore; +use tokio::sync::SemaphorePermit; #[derive(Clone)] pub struct GoalRuntimeHandle { @@ -35,6 +37,7 @@ struct GoalRuntimeInner { accounting_state: Arc, enabled: AtomicBool, tools_available_for_thread: bool, + goal_state_lock: Semaphore, } pub(crate) struct AccountedGoalProgress { @@ -85,6 +88,7 @@ impl GoalRuntimeHandle { accounting_state, enabled: AtomicBool::new(config.enabled), tools_available_for_thread: config.tools_available_for_thread, + goal_state_lock: Semaphore::new(/*permits*/ 1), }), } } @@ -109,6 +113,14 @@ impl GoalRuntimeHandle { Arc::clone(&self.inner.accounting_state) } + pub(crate) async fn goal_state_permit(&self) -> Result, String> { + self.inner + .goal_state_lock + .acquire() + .await + .map_err(|err| err.to_string()) + } + pub async fn prepare_external_goal_mutation(&self) -> Result<(), String> { if !self.is_enabled() { return Ok(()); @@ -280,6 +292,18 @@ impl GoalRuntimeHandle { self.inner.accounting_state.clear_active_goal(); return Ok(()); } + // Hold this through the read/start window so external set/clear cannot + // change the goal after we read it but before the continuation launches. + let _goal_state_permit = self.goal_state_permit().await?; + + let Some(thread_manager) = self.inner.thread_manager.upgrade() else { + tracing::debug!("skipping goal continuation because thread manager is unavailable"); + return Ok(()); + }; + let Ok(thread) = thread_manager.get_thread(self.inner.thread_id).await else { + tracing::debug!("skipping goal continuation because live thread is unavailable"); + return Ok(()); + }; let Some(goal) = self .inner @@ -296,16 +320,7 @@ impl GoalRuntimeHandle { self.inner.accounting_state.clear_active_goal(); return Ok(()); } - let item = continuation_steering_item(&protocol_goal_from_state(goal)); - let Some(thread_manager) = self.inner.thread_manager.upgrade() else { - tracing::debug!("skipping goal continuation because thread manager is unavailable"); - return Ok(()); - }; - let Ok(thread) = thread_manager.get_thread(self.inner.thread_id).await else { - tracing::debug!("skipping goal continuation because live thread is unavailable"); - return Ok(()); - }; if let Err(err) = thread.try_start_turn_if_idle(vec![item]).await { let reason = err.reason(); diff --git a/codex-rs/ext/goal/src/spec.rs b/codex-rs/ext/goal/src/spec.rs index 70e89e4fbb6c..2c92c0384814 100644 --- a/codex-rs/ext/goal/src/spec.rs +++ b/codex-rs/ext/goal/src/spec.rs @@ -34,7 +34,8 @@ pub fn create_create_goal_tool() -> ToolSpec { ( "token_budget".to_string(), JsonSchema::integer(Some( - "Optional positive token budget for the new active goal.".to_string(), + "Positive token budget for the new goal. Omit unless explicitly requested." + .to_string(), )), ), ]); @@ -62,7 +63,7 @@ pub fn create_update_goal_tool() -> ToolSpec { JsonSchema::string_enum( vec![json!("complete"), json!("blocked")], Some( - "Required. Set to complete only when the objective is achieved and no required work remains. Set to blocked only when the goal cannot currently proceed without a user decision, missing dependency, or external unblock." + "Required. Set to `complete` only when the objective is achieved and no required work remains. Set to `blocked` only after the same blocking condition has recurred for at least three consecutive goal turns and the agent is at an impasse. After a previously blocked goal is resumed, the resumed run starts a fresh blocked audit." .to_string(), ), ), @@ -71,11 +72,14 @@ pub fn create_update_goal_tool() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: UPDATE_GOAL_TOOL_NAME.to_string(), description: r#"Update the existing goal. -Use this tool only to mark the goal achieved or blocked. +Use this tool only to mark the goal achieved or genuinely blocked. Set status to `complete` only when the objective has actually been achieved and no required work remains. -Set status to `blocked` only when the goal cannot currently proceed until something external changes. +Set status to `blocked` only when the same blocking condition has repeated for at least three consecutive goal turns, counting the original/user-triggered turn and any automatic continuations, and the agent cannot make meaningful progress without user input or an external-state change. +If the user resumes a goal that was previously marked `blocked`, treat the resumed run as a fresh blocked audit. If the same blocking condition then repeats for at least three consecutive resumed goal turns, set status to `blocked` again. +Once the blocked threshold is satisfied, do not keep reporting that you are still blocked while leaving the goal active; set status to `blocked`. +Do not use `blocked` merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification. Do not mark a goal complete merely because its budget is nearly exhausted or because you are stopping work. -You cannot use this tool to pause, resume, or budget-limit a goal; those status changes are controlled by the user or system. +You cannot use this tool to pause, resume, budget-limit, or usage-limit a goal; those status changes are controlled by the user or system. When marking a budgeted goal achieved with status `complete`, report the final token usage from the tool result to the user."# .to_string(), strict: false, From 713192381b74f09f4b92f3ed410da067461d5cd0 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 5 Jun 2026 15:05:46 -0300 Subject: [PATCH 37/59] fix(tui): Windows composer background (#26181) ## Why On Windows, the TUI could not shade the composer against the terminal background because `terminal_palette::default_colors()` always fell back to `None`. That preserved safety, but it also meant terminals that do support OSC 10/11 default color replies had no path to report their real background color. This keeps the existing fallback behavior for unsupported terminals while allowing capable Windows terminals to report their default foreground/background colors during startup. | Before | After | |---|---| | win-before | win-after | ## What Changed - Moved the OSC 10/11 default color parser in `tui/src/terminal_probe.rs` out of the Unix-only implementation so it can be reused by Windows. - Added a Windows-only bounded OSC 10/11 probe using raw console handles and the existing `windows-sys` dependency. - Added Windows palette caching in `tui/src/terminal_palette.rs` so startup probe results, including `None`, are reused instead of probing again later. - Wired the Windows color probe into TUI startup after the existing non-Unix crossterm cursor and keyboard checks. - Added parser coverage for malformed, partial, and noisy OSC color replies. If the probe fails, times out, receives only one color, or receives malformed data, the cache stores `None` and the composer keeps the current behavior. ## How to Test 1. On Windows, start Codex in a terminal that supports OSC 10/11 default color replies. 2. Open the TUI composer. 3. Confirm the composer/status area is painted using the terminal's reported default background, instead of leaving the background unshaded. 4. Start Codex in a terminal that does not answer OSC 10/11, or otherwise blocks terminal color replies. 5. Confirm startup still succeeds and the composer uses the existing fallback behavior. Targeted tests: - `CARGO_TARGET_DIR=/private/tmp/codex-windows-osc-default-colors-target just test -p codex-tui terminal_probe` Additional local verification: - `CARGO_TARGET_DIR=/private/tmp/codex-windows-osc-default-colors-target just test -p codex-tui` was run; 2774 tests passed, and two unrelated Guardian feature-flag tests failed reproducibly when isolated. - `just argument-comment-lint` was attempted but blocked by the local Bazel/LLVM `include/sanitizer/*.h` empty glob issue. Touched Rust literal callsites were inspected manually. - `cargo check -p codex-tui --target x86_64-pc-windows-msvc` was attempted after installing the target, but local macOS cross-checking is blocked by missing Windows C SDK headers in native dependencies (`ring`/`aws-lc-sys`). --------- Co-authored-by: Kevin Bond --- codex-rs/tui/src/terminal_palette.rs | 187 +++++++++- codex-rs/tui/src/terminal_probe.rs | 536 +++++++++++++++++++++------ codex-rs/tui/src/tui.rs | 29 +- 3 files changed, 616 insertions(+), 136 deletions(-) diff --git a/codex-rs/tui/src/terminal_palette.rs b/codex-rs/tui/src/terminal_palette.rs index 53c68d96e776..1b539f94cb78 100644 --- a/codex-rs/tui/src/terminal_palette.rs +++ b/codex-rs/tui/src/terminal_palette.rs @@ -1,4 +1,6 @@ use crate::color::perceptual_distance; +use codex_terminal_detection::TerminalName; +use codex_terminal_detection::terminal_info; use ratatui::style::Color; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -30,19 +32,49 @@ pub fn indexed_color(index: u8) -> Color { /// Returns the closest color to the target color that the terminal can display. pub fn best_color(target: (u8, u8, u8)) -> Color { - let color_level = stdout_color_level(); - if color_level == StdoutColorLevel::TrueColor { - rgb_color(target) - } else if color_level == StdoutColorLevel::Ansi256 - && let Some((i, _)) = xterm_fixed_colors().min_by(|(_, a), (_, b)| { - perceptual_distance(*a, target) - .partial_cmp(&perceptual_distance(*b, target)) - .unwrap_or(std::cmp::Ordering::Equal) - }) + best_color_for_color_level(target, effective_stdout_color_level()) +} + +fn effective_stdout_color_level() -> StdoutColorLevel { + stdout_color_level_for_terminal( + stdout_color_level(), + terminal_info().name, + std::env::var_os("WT_SESSION").is_some(), + std::env::var_os("FORCE_COLOR").is_some(), + ) +} + +fn stdout_color_level_for_terminal( + stdout_level: StdoutColorLevel, + terminal_name: TerminalName, + has_wt_session: bool, + has_force_color_override: bool, +) -> StdoutColorLevel { + if has_wt_session && !has_force_color_override { + return StdoutColorLevel::TrueColor; + } + + if stdout_level == StdoutColorLevel::Ansi16 + && terminal_name == TerminalName::WindowsTerminal + && !has_force_color_override { - indexed_color(i as u8) + StdoutColorLevel::TrueColor } else { - Color::default() + stdout_level + } +} + +fn best_color_for_color_level(target: (u8, u8, u8), color_level: StdoutColorLevel) -> Color { + match color_level { + StdoutColorLevel::TrueColor => rgb_color(target), + StdoutColorLevel::Ansi256 => xterm_fixed_colors() + .min_by(|(_, a), (_, b)| { + perceptual_distance(*a, target) + .partial_cmp(&perceptual_distance(*b, target)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map_or_else(Color::default, |(i, _)| indexed_color(i as u8)), + StdoutColorLevel::Ansi16 | StdoutColorLevel::Unknown => Color::default(), } } @@ -68,7 +100,7 @@ pub fn default_bg() -> Option<(u8, u8, u8)> { default_colors().map(|c| c.bg) } -#[cfg(unix)] +#[cfg(any(unix, windows))] pub(crate) fn set_default_colors_from_startup_probe( colors: Option, ) { @@ -177,7 +209,73 @@ mod imp { } } -#[cfg(not(all(unix, not(test))))] +#[cfg(windows)] +mod imp { + use super::DefaultColors; + use std::sync::Mutex; + use std::sync::OnceLock; + + struct Cache { + attempted: bool, + value: Option, + } + + impl Default for Cache { + fn default() -> Self { + Self { + attempted: false, + value: None, + } + } + } + + impl Cache { + fn get_or_init_with(&mut self, mut init: impl FnMut() -> Option) -> Option { + if !self.attempted { + self.value = init(); + self.attempted = true; + } + self.value + } + } + + fn default_colors_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(Cache::default())) + } + + pub(super) fn default_colors() -> Option { + let cache = default_colors_cache(); + let mut cache = cache.lock().ok()?; + cache.get_or_init_with(query_default_colors) + } + + pub(super) fn set_default_colors_from_startup_probe( + colors: Option, + ) { + if let Ok(mut cache) = default_colors_cache().lock() { + cache.value = colors.map(|colors| DefaultColors { + fg: colors.fg, + bg: colors.bg, + }); + cache.attempted = true; + } + } + + pub(super) fn requery_default_colors() {} + + fn query_default_colors() -> Option { + crate::terminal_probe::default_colors(crate::terminal_probe::DEFAULT_TIMEOUT) + .ok() + .flatten() + .map(|colors| DefaultColors { + fg: colors.fg, + bg: colors.bg, + }) + } +} + +#[cfg(not(any(all(unix, not(test)), windows)))] mod imp { use super::DefaultColors; @@ -185,7 +283,7 @@ mod imp { None } - #[cfg(unix)] + #[cfg(any(unix, windows))] pub(super) fn set_default_colors_from_startup_probe( _colors: Option, ) { @@ -461,3 +559,64 @@ pub const XTERM_COLORS: [(u8, u8, u8); 256] = [ (228, 228, 228), // 254 Grey89 (238, 238, 238), // 255 Grey93 ]; + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn best_color_uses_truecolor_without_quantization() { + assert_eq!( + best_color_for_color_level((12, 34, 56), StdoutColorLevel::TrueColor), + rgb_color((12, 34, 56)) + ); + } + + #[test] + fn best_color_resets_for_ansi16() { + assert_eq!( + best_color_for_color_level((12, 34, 56), StdoutColorLevel::Ansi16), + Color::Reset + ); + } + + #[test] + fn windows_terminal_wt_session_promotes_to_truecolor() { + assert_eq!( + stdout_color_level_for_terminal( + StdoutColorLevel::Ansi16, + TerminalName::Unknown, + /*has_wt_session*/ true, + /*has_force_color_override*/ false, + ), + StdoutColorLevel::TrueColor + ); + } + + #[test] + fn windows_terminal_name_promotes_ansi16_to_truecolor() { + assert_eq!( + stdout_color_level_for_terminal( + StdoutColorLevel::Ansi16, + TerminalName::WindowsTerminal, + /*has_wt_session*/ false, + /*has_force_color_override*/ false, + ), + StdoutColorLevel::TrueColor + ); + } + + #[test] + fn force_color_keeps_reported_stdout_level() { + assert_eq!( + stdout_color_level_for_terminal( + StdoutColorLevel::Ansi16, + TerminalName::WindowsTerminal, + /*has_wt_session*/ true, + /*has_force_color_override*/ true, + ), + StdoutColorLevel::Ansi16 + ); + } +} diff --git a/codex-rs/tui/src/terminal_probe.rs b/codex-rs/tui/src/terminal_probe.rs index d9927ffa2085..bea195083b30 100644 --- a/codex-rs/tui/src/terminal_probe.rs +++ b/codex-rs/tui/src/terminal_probe.rs @@ -12,9 +12,25 @@ //! startup. A future input-preservation layer would need to replay unrelated bytes through the same //! parser that normal TUI input uses. +use std::time::Duration; + +/// Default wall-clock budget for each startup probe group. +pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100); + +/// Default terminal foreground and background colors reported by OSC 10 and OSC 11. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) struct DefaultColors { + /// Default foreground color as an 8-bit RGB tuple. + pub(crate) fg: (u8, u8, u8), + /// Default background color as an 8-bit RGB tuple. + pub(crate) bg: (u8, u8, u8), +} + #[cfg(unix)] #[cfg_attr(test, allow(dead_code))] mod imp { + use super::DefaultColors; + use super::parse_default_colors; use std::fs::File; use std::fs::OpenOptions; use std::io; @@ -27,18 +43,6 @@ mod imp { use crossterm::event::KeyboardEnhancementFlags; use ratatui::layout::Position; - /// Default wall-clock budget for each startup probe group. - pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100); - - /// Default terminal foreground and background colors reported by OSC 10 and OSC 11. - #[derive(Debug, Clone, Copy, Eq, PartialEq)] - pub(crate) struct DefaultColors { - /// Default foreground color as an 8-bit RGB tuple. - pub(crate) fg: (u8, u8, u8), - /// Default background color as an 8-bit RGB tuple. - pub(crate) bg: (u8, u8, u8), - } - /// Results from the TUI's one-shot startup terminal probe. #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub(crate) struct StartupProbe { @@ -389,60 +393,6 @@ mod imp { None } - fn parse_osc_color(buffer: &[u8], slot: u8) -> Option<(u8, u8, u8)> { - let prefix = format!("\x1B]{slot};"); - let start = find_subslice(buffer, prefix.as_bytes())?; - let payload_start = start + prefix.len(); - let rest = &buffer[payload_start..]; - let (payload_end, _terminator_len) = osc_payload_end(rest)?; - let payload = std::str::from_utf8(&rest[..payload_end]).ok()?; - parse_osc_rgb(payload) - } - - fn parse_default_colors(buffer: &[u8]) -> Option { - let fg = parse_osc_color(buffer, /*slot*/ 10)?; - let bg = parse_osc_color(buffer, /*slot*/ 11)?; - Some(DefaultColors { fg, bg }) - } - - fn osc_payload_end(buffer: &[u8]) -> Option<(usize, usize)> { - let mut idx = 0; - while idx < buffer.len() { - match buffer[idx] { - 0x07 => return Some((idx, 1)), - 0x1B if buffer.get(idx + 1) == Some(&b'\\') => return Some((idx, 2)), - _ => idx += 1, - } - } - None - } - - fn parse_osc_rgb(payload: &str) -> Option<(u8, u8, u8)> { - let (prefix, values) = payload.trim().split_once(':')?; - if !prefix.eq_ignore_ascii_case("rgb") && !prefix.eq_ignore_ascii_case("rgba") { - return None; - } - - let mut parts = values.split('/'); - let r = parse_osc_component(parts.next()?)?; - let g = parse_osc_component(parts.next()?)?; - let b = parse_osc_component(parts.next()?)?; - if prefix.eq_ignore_ascii_case("rgba") { - parse_osc_component(parts.next()?)?; - } - parts.next().is_none().then_some((r, g, b)) - } - - fn parse_osc_component(component: &str) -> Option { - match component.len() { - 2 => u8::from_str_radix(component, 16).ok(), - 4 => u16::from_str_radix(component, 16) - .ok() - .map(|value| (value / 257) as u8), - _ => None, - } - } - /// Parser state for the keyboard enhancement probe. /// /// `UnsupportedFallback` records that a primary-device-attributes response arrived without @@ -517,12 +467,6 @@ mod imp { None } - fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { - haystack - .windows(needle.len()) - .position(|window| window == needle) - } - fn find_all_subslices<'a>( haystack: &'a [u8], needle: &'a [u8], @@ -550,53 +494,6 @@ mod imp { ); } - #[test] - fn parses_osc_colors_with_bel_and_st() { - assert_eq!( - parse_osc_color(b"\x1B]10;rgb:ffff/8000/0000\x07", /*slot*/ 10), - Some((255, 127, 0)) - ); - assert_eq!( - parse_osc_color(b"\x1B]11;rgba:00/80/ff/ff\x1B\\", /*slot*/ 11), - Some((0, 128, 255)) - ); - } - - #[test] - fn parses_two_and_four_digit_color_components() { - assert_eq!(parse_osc_rgb("rgb:00/80/ff"), Some((0, 128, 255))); - assert_eq!( - parse_osc_rgb("rgba:ffff/8000/0000/ffff"), - Some((255, 127, 0)) - ); - } - - #[test] - fn parses_default_colors_from_one_buffer() { - assert_eq!( - parse_default_colors( - b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:1111/1111/1111\x07" - ), - Some(DefaultColors { - fg: (238, 238, 238), - bg: (17, 17, 17) - }) - ); - assert_eq!( - parse_default_colors( - b"\x1B]11;rgb:1111/1111/1111\x07\x1B]10;rgb:eeee/eeee/eeee\x1B\\" - ), - Some(DefaultColors { - fg: (238, 238, 238), - bg: (17, 17, 17) - }) - ); - assert_eq!( - parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\"), - None - ); - } - #[test] fn parses_keyboard_enhancement_flags_and_pda_fallback() { assert_eq!( @@ -655,5 +552,404 @@ mod imp { } } -#[cfg(unix)] +#[cfg(windows)] +mod imp { + use super::DefaultColors; + use super::parse_default_colors; + use std::io; + use std::io::ErrorKind; + use std::time::Duration; + use std::time::Instant; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::Foundation::WAIT_OBJECT_0; + use windows_sys::Win32::Foundation::WAIT_TIMEOUT; + use windows_sys::Win32::Storage::FileSystem::ReadFile; + use windows_sys::Win32::Storage::FileSystem::WriteFile; + use windows_sys::Win32::System::Console::CONSOLE_SCREEN_BUFFER_INFOEX; + use windows_sys::Win32::System::Console::ENABLE_VIRTUAL_TERMINAL_INPUT; + use windows_sys::Win32::System::Console::GetConsoleMode; + use windows_sys::Win32::System::Console::GetConsoleScreenBufferInfoEx; + use windows_sys::Win32::System::Console::GetStdHandle; + use windows_sys::Win32::System::Console::STD_INPUT_HANDLE; + use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE; + use windows_sys::Win32::System::Console::SetConsoleMode; + use windows_sys::Win32::System::Threading::WaitForSingleObject; + + /// Queries OSC 10 and OSC 11 default colors under one shared deadline. + /// + /// The Windows path uses raw console handles because crossterm's public color query helper is + /// currently Unix-only. Failures and missing responses are reported as `Ok(None)` by callers so + /// terminals without OSC 10/11 support keep the existing conservative palette fallback. + pub(crate) fn default_colors(timeout: Duration) -> io::Result> { + let Ok(output) = std_handle(STD_OUTPUT_HANDLE) else { + return Ok(None); + }; + + if let Ok(input) = std_handle(STD_INPUT_HANDLE) + && let Ok(Some(colors)) = query_osc_default_colors(input, output, timeout) + { + return Ok(Some(colors)); + } + + Ok(query_console_default_colors(output).ok().flatten()) + } + + fn query_osc_default_colors( + input: HANDLE, + output: HANDLE, + timeout: Duration, + ) -> io::Result> { + let _vt_input = VirtualTerminalInputMode::enable(input)?; + write_all(output, b"\x1B]10;?\x1B\\\x1B]11;?\x1B\\")?; + read_until(input, timeout, parse_default_colors) + } + + fn query_console_default_colors(output: HANDLE) -> io::Result> { + let mut info = unsafe { std::mem::zeroed::() }; + info.cbSize = std::mem::size_of::() as u32; + if unsafe { GetConsoleScreenBufferInfoEx(output, &mut info) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(Some(decode_console_default_colors( + info.wAttributes, + &info.ColorTable, + ))) + } + + fn decode_console_default_colors(attributes: u16, color_table: &[u32; 16]) -> DefaultColors { + let fg_index = (attributes & 0x0f) as usize; + let bg_index = ((attributes >> 4) & 0x0f) as usize; + // COMMON_LVB_REVERSE_VIDEO changes how cells render, but this probe is discovering the + // configured default colors for palette blending. Keep the attribute fg/bg indices as-is. + DefaultColors { + fg: decode_color_ref(color_table[fg_index]), + bg: decode_color_ref(color_table[bg_index]), + } + } + + fn decode_color_ref(color_ref: u32) -> (u8, u8, u8) { + ( + (color_ref & 0xff) as u8, + ((color_ref >> 8) & 0xff) as u8, + ((color_ref >> 16) & 0xff) as u8, + ) + } + + fn std_handle(kind: u32) -> io::Result { + let handle = unsafe { GetStdHandle(kind) }; + if handle == 0 || handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + Ok(handle) + } + + struct VirtualTerminalInputMode { + handle: HANDLE, + original_mode: u32, + } + + impl VirtualTerminalInputMode { + fn enable(handle: HANDLE) -> io::Result { + let mut original_mode = 0; + if unsafe { GetConsoleMode(handle, &mut original_mode) } == 0 { + return Err(io::Error::last_os_error()); + } + + let requested_mode = original_mode | ENABLE_VIRTUAL_TERMINAL_INPUT; + if unsafe { SetConsoleMode(handle, requested_mode) } == 0 { + return Err(io::Error::last_os_error()); + } + + Ok(Self { + handle, + original_mode, + }) + } + } + + impl Drop for VirtualTerminalInputMode { + fn drop(&mut self) { + unsafe { + SetConsoleMode(self.handle, self.original_mode); + } + } + } + + fn write_all(handle: HANDLE, mut bytes: &[u8]) -> io::Result<()> { + while !bytes.is_empty() { + let mut written = 0; + let ok = unsafe { + WriteFile( + handle, + bytes.as_ptr().cast(), + bytes.len().min(u32::MAX as usize) as u32, + &mut written, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + if written == 0 { + return Err(io::Error::from(ErrorKind::WriteZero)); + } + bytes = &bytes[written as usize..]; + } + Ok(()) + } + + fn read_until( + handle: HANDLE, + timeout: Duration, + mut parse: impl FnMut(&[u8]) -> Option, + ) -> io::Result> { + let deadline = Instant::now() + timeout; + let mut buffer = Vec::new(); + loop { + if let Some(value) = parse(&buffer) { + return Ok(Some(value)); + } + + let now = Instant::now(); + if now >= deadline { + return Ok(None); + } + let timeout_ms = deadline + .saturating_duration_since(now) + .as_millis() + .min(u32::MAX as u128) as u32; + match unsafe { WaitForSingleObject(handle, timeout_ms) } { + WAIT_OBJECT_0 => read_once(handle, &mut buffer)?, + WAIT_TIMEOUT => return Ok(None), + _ => return Err(io::Error::last_os_error()), + } + } + } + + fn read_once(handle: HANDLE, buffer: &mut Vec) -> io::Result<()> { + let mut chunk = [0_u8; 256]; + let mut read = 0; + let ok = unsafe { + ReadFile( + handle, + chunk.as_mut_ptr().cast(), + chunk.len() as u32, + &mut read, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + buffer.extend_from_slice(&chunk[..read as usize]); + Ok(()) + } + + #[cfg(test)] + mod tests { + use super::*; + use pretty_assertions::assert_eq; + use windows_sys::Win32::System::Console::COMMON_LVB_REVERSE_VIDEO; + + fn color_table() -> [u32; 16] { + [ + 0x00000000, 0x00000080, 0x00008000, 0x00008080, 0x00800000, 0x00800080, 0x00808000, + 0x00c0c0c0, 0x00808080, 0x000000ff, 0x0000ff00, 0x0000ffff, 0x00ff0000, 0x00ff00ff, + 0x00ffff00, 0x00ffffff, + ] + } + + #[test] + fn decodes_console_color_attribute_indices() { + assert_eq!( + decode_console_default_colors(/*attributes*/ 0x21, &color_table()), + DefaultColors { + fg: (128, 0, 0), + bg: (0, 128, 0), + } + ); + } + + #[test] + fn decodes_console_color_intensity_indices() { + assert_eq!( + decode_console_default_colors(/*attributes*/ 0xe9, &color_table()), + DefaultColors { + fg: (255, 0, 0), + bg: (0, 255, 255), + } + ); + } + + #[test] + fn decodes_console_color_ref_byte_order() { + let mut colors = color_table(); + colors[3] = 0x00112233; + colors[4] = 0x00aabbcc; + + assert_eq!( + decode_console_default_colors(/*attributes*/ 0x43, &colors), + DefaultColors { + fg: (0x33, 0x22, 0x11), + bg: (0xcc, 0xbb, 0xaa), + } + ); + } + + #[test] + fn ignores_reverse_video_when_decoding_default_colors() { + assert_eq!( + decode_console_default_colors( + /*attributes*/ COMMON_LVB_REVERSE_VIDEO | 0x21, + &color_table(), + ), + DefaultColors { + fg: (128, 0, 0), + bg: (0, 128, 0), + } + ); + } + } +} + +fn parse_osc_color(buffer: &[u8], slot: u8) -> Option<(u8, u8, u8)> { + let prefix = format!("\x1B]{slot};"); + let start = find_subslice(buffer, prefix.as_bytes())?; + let payload_start = start + prefix.len(); + let rest = &buffer[payload_start..]; + let (payload_end, _terminator_len) = osc_payload_end(rest)?; + let payload = std::str::from_utf8(&rest[..payload_end]).ok()?; + parse_osc_rgb(payload) +} + +fn parse_default_colors(buffer: &[u8]) -> Option { + let fg = parse_osc_color(buffer, /*slot*/ 10)?; + let bg = parse_osc_color(buffer, /*slot*/ 11)?; + Some(DefaultColors { fg, bg }) +} + +fn osc_payload_end(buffer: &[u8]) -> Option<(usize, usize)> { + let mut idx = 0; + while idx < buffer.len() { + match buffer[idx] { + 0x07 => return Some((idx, 1)), + 0x1B if buffer.get(idx + 1) == Some(&b'\\') => return Some((idx, 2)), + _ => idx += 1, + } + } + None +} + +fn parse_osc_rgb(payload: &str) -> Option<(u8, u8, u8)> { + let (prefix, values) = payload.trim().split_once(':')?; + if !prefix.eq_ignore_ascii_case("rgb") && !prefix.eq_ignore_ascii_case("rgba") { + return None; + } + + let mut parts = values.split('/'); + let r = parse_osc_component(parts.next()?)?; + let g = parse_osc_component(parts.next()?)?; + let b = parse_osc_component(parts.next()?)?; + if prefix.eq_ignore_ascii_case("rgba") { + parse_osc_component(parts.next()?)?; + } + parts.next().is_none().then_some((r, g, b)) +} + +fn parse_osc_component(component: &str) -> Option { + match component.len() { + 2 => u8::from_str_radix(component, 16).ok(), + 4 => u16::from_str_radix(component, 16) + .ok() + .map(|value| (value / 257) as u8), + _ => None, + } +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +#[cfg(any(unix, windows))] pub(crate) use imp::*; + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn parses_osc_colors_with_bel_and_st() { + assert_eq!( + parse_osc_color(b"\x1B]10;rgb:ffff/8000/0000\x07", /*slot*/ 10), + Some((255, 127, 0)) + ); + assert_eq!( + parse_osc_color(b"\x1B]11;rgba:00/80/ff/ff\x1B\\", /*slot*/ 11), + Some((0, 128, 255)) + ); + } + + #[test] + fn parses_two_and_four_digit_color_components() { + assert_eq!(parse_osc_rgb("rgb:00/80/ff"), Some((0, 128, 255))); + assert_eq!( + parse_osc_rgb("rgba:ffff/8000/0000/ffff"), + Some((255, 127, 0)) + ); + } + + #[test] + fn parses_default_colors_from_one_buffer() { + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:1111/1111/1111\x07"), + Some(DefaultColors { + fg: (238, 238, 238), + bg: (17, 17, 17) + }) + ); + assert_eq!( + parse_default_colors(b"\x1B]11;rgb:1111/1111/1111\x07\x1B]10;rgb:eeee/eeee/eeee\x1B\\"), + Some(DefaultColors { + fg: (238, 238, 238), + bg: (17, 17, 17) + }) + ); + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\"), + None + ); + } + + #[test] + fn ignores_malformed_or_partial_default_color_responses() { + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:nope\x07"), + None + ); + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:11/11/11/11\x07"), + None + ); + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:1111/1111/1111"), + None + ); + } + + #[test] + fn parses_default_colors_with_unrelated_bytes() { + assert_eq!( + parse_default_colors( + b"typed\x1B]10;rgb:eeee/eeee/eeee\x1B\\noise\x1B]11;rgb:1111/1111/1111\x07" + ), + Some(DefaultColors { + fg: (238, 238, 238), + bg: (17, 17, 17), + }) + ); + } +} diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index b9055f5d6b51..681e0cb3977f 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -438,6 +438,9 @@ pub(crate) fn init() -> Result { let enhanced_keys_supported = !keyboard_modes::keyboard_enhancement_disabled() && detect_keyboard_enhancement_supported(); + #[cfg(windows)] + probe_windows_default_colors(); + let tui = CustomTerminal::with_options_and_cursor_position(backend, cursor_pos)?; let stderr_guard = terminal_stderr::TerminalStderrGuard::install()?; Ok(InitializedTerminal { @@ -457,11 +460,33 @@ fn cursor_position_with_crossterm(backend: &mut CrosstermBackend) -> Pos #[cfg(not(unix))] fn detect_keyboard_enhancement_supported() -> bool { - // Non-Unix startup keeps the existing crossterm path because the bounded probe implementation - // relies on Unix file descriptors and `/dev/tty` semantics. + // Non-Unix startup keeps the existing crossterm keyboard probe path because it already knows + // how to interpret platform-specific event sources. supports_keyboard_enhancement().unwrap_or(/*default*/ false) } +#[cfg(windows)] +fn probe_windows_default_colors() { + let started_at = std::time::Instant::now(); + match crate::terminal_probe::default_colors(crate::terminal_probe::DEFAULT_TIMEOUT) { + Ok(colors) => { + tracing::info!( + duration_ms = %started_at.elapsed().as_millis(), + default_colors = colors.is_some(), + "terminal default color probe completed" + ); + crate::terminal_palette::set_default_colors_from_startup_probe(colors); + } + Err(err) => { + tracing::warn!( + duration_ms = %started_at.elapsed().as_millis(), + "terminal default color probe failed: {err}" + ); + crate::terminal_palette::set_default_colors_from_startup_probe(/*colors*/ None); + } + } +} + fn set_panic_hook() { let hook = panic::take_hook(); panic::set_hook(Box::new(move |panic_info| { From 679a944dbcdd5de1e745172268eb9e050d0d6034 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 5 Jun 2026 15:10:13 -0300 Subject: [PATCH 38/59] fix(tui): restore cancelled prompt cursor at end (#26457) ## Why Pressing `Esc` on a turn that produced no visible output restores the submitted prompt so the user can keep editing it. That restore path preserved the prompt content, images, and mention bindings, but left the composer cursor at the start of the restored text. The next edit therefore inserted at the beginning instead of continuing from the end of the prompt. ## What Changed - Move the cursor to the end after `BottomPane::set_composer_text_with_mention_bindings` rehydrates a restored draft. - Add test-only cursor accessors so restore tests can assert the composer state directly. - Extend the queued restore regression to assert the restored composer cursor is positioned at `text.len()`. ## How to Test Manual reviewer flow: 1. Start Codex in the TUI. 2. Submit a prompt that will take long enough to interrupt. 3. Press `Esc` before any visible assistant output appears. 4. Confirm the prompt is restored into the composer and the cursor is at the end, so typing appends to the prompt. 5. Repeat with a prompt that includes an attached image or resolved mention and confirm the restored content remains intact. Targeted tests: - `just test -p codex-tui chatwidget::tests::composer_submission::queued_restore_with_remote_images_keeps_local_placeholder_mapping` Lint note: - `just argument-comment-lint` is blocked locally by the existing Bazel `compiler-rt` empty glob failure before analyzing touched code. The touched Rust diff was manually inspected and adds no new opaque positional literal callsites. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 5 +++++ codex-rs/tui/src/bottom_pane/mod.rs | 6 ++++++ codex-rs/tui/src/chatwidget/tests/composer_submission.rs | 1 + 3 files changed, 12 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 5a39b53aa94e..6ebfac43357f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1258,6 +1258,11 @@ impl ChatComposer { self.draft.textarea.cursor() + if self.draft.is_bash_mode { 1 } else { 0 } } + #[cfg(test)] + pub(crate) fn cursor(&self) -> usize { + self.current_cursor() + } + fn history_navigation_cursor(&self) -> usize { if self.draft.is_bash_mode && self.draft.textarea.cursor() == 0 { 0 diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 9bada615f614..cdd4c752d3f7 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -786,6 +786,7 @@ impl BottomPane { local_image_paths, mention_bindings, ); + self.composer.move_cursor_to_end(); self.request_redraw(); } @@ -824,6 +825,11 @@ impl BottomPane { self.composer.current_text() } + #[cfg(test)] + pub(crate) fn composer_cursor(&self) -> usize { + self.composer.cursor() + } + pub(crate) fn composer_draft_snapshot(&self) -> chat_composer::ComposerDraftSnapshot { self.composer.draft_snapshot() } diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index f3e32b2eb42a..154add1d6709 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -730,6 +730,7 @@ async fn queued_restore_with_remote_images_keeps_local_placeholder_mapping() { }); assert_eq!(chat.bottom_pane.composer_text(), text); + assert_eq!(chat.bottom_pane.composer_cursor(), text.len()); assert_eq!(chat.bottom_pane.composer_text_elements(), text_elements); assert_eq!(chat.bottom_pane.composer_local_images(), local_images); assert_eq!(chat.remote_image_urls(), remote_image_urls); From 82b15b65e2303255e3e1a740d994c7e00250344a Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Fri, 5 Jun 2026 11:20:52 -0700 Subject: [PATCH 39/59] [codex] Respect Windows sandbox backend in exec policy (#26307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Windows managed filesystem permissions can now be backed by a real Windows sandbox. `exec-policy` was still treating the managed read-only policy shape as if there were never a sandbox backend, so benign unmatched commands such as PowerShell directory listings could be rejected with `blocked by policy` even when `windows.sandbox` was enabled. The inverse case still needs to stay conservative: when the Windows sandbox backend is disabled, managed filesystem restrictions are only configuration intent, not an enforced filesystem boundary. That applies to writable-root restricted profiles too, not just read-only profiles. ## What Changed - Thread the effective `WindowsSandboxLevel` into exec-policy approval decisions for shell, unified exec, and intercepted shell exec paths. - Treat managed restricted filesystem profiles as lacking sandbox protection only on Windows when `WindowsSandboxLevel::Disabled`. - Exclude full-disk-write profiles from that no-backend path because they do not rely on filesystem sandbox enforcement. - Remove the cwd-sensitive read-only heuristic and the now-stale cwd plumbing from exec-policy approval contexts. - Add Windows coverage for both enabled-sandbox and disabled-backend behavior, including a writable-root managed profile. ## Validation - Added/updated `exec_policy` coverage for managed filesystem restrictions, full-disk-write exclusion, enabled Windows sandbox behavior, and disabled-backend read-only/writable-root behavior. - `just test -p codex-core exec_policy` — 100 passed, 10 leaky - Empirical local `codex exec` probe with `--sandbox read-only -c 'windows.sandbox="unelevated"'`: PowerShell directory listing completed successfully. - Disabled-backend control with Windows sandbox cleared: the same command was rejected with `blocked by policy`. --- codex-rs/core/src/exec_policy.rs | 34 ++++---- codex-rs/core/src/exec_policy_tests.rs | 64 +++++++++----- .../core/src/exec_policy_windows_tests.rs | 87 ++++++++++++++++++- codex-rs/core/src/session/tests.rs | 3 +- codex-rs/core/src/tools/handlers/shell.rs | 3 +- .../tools/runtimes/shell/unix_escalation.rs | 16 ++-- .../runtimes/shell/unix_escalation_tests.rs | 21 ++--- .../core/src/unified_exec/process_manager.rs | 4 +- codex-rs/core/tests/suite/exec_policy.rs | 71 +++++++++++++++ 9 files changed, 229 insertions(+), 74 deletions(-) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index cc2f18c4fb18..7b33a196b086 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -20,6 +20,7 @@ use codex_execpolicy::RuleMatch; use codex_execpolicy::blocking_append_allow_prefix_rule; use codex_execpolicy::blocking_append_network_rule; use codex_protocol::approvals::ExecPolicyAmendment; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::protocol::AskForApproval; @@ -120,7 +121,7 @@ pub(crate) enum ExecPolicyCommandOrigin { pub(crate) struct UnmatchedCommandContext<'a> { pub(crate) approval_policy: AskForApproval, pub(crate) permission_profile: &'a PermissionProfile, - pub(crate) sandbox_cwd: &'a Path, + pub(crate) windows_sandbox_level: WindowsSandboxLevel, pub(crate) sandbox_permissions: SandboxPermissions, pub(crate) used_complex_parsing: bool, pub(crate) command_origin: ExecPolicyCommandOrigin, @@ -240,7 +241,7 @@ pub(crate) struct ExecApprovalRequest<'a> { pub(crate) command: &'a [String], pub(crate) approval_policy: AskForApproval, pub(crate) permission_profile: PermissionProfile, - pub(crate) sandbox_cwd: &'a Path, + pub(crate) windows_sandbox_level: WindowsSandboxLevel, pub(crate) sandbox_permissions: SandboxPermissions, pub(crate) prefix_rule: Option>, } @@ -274,7 +275,7 @@ impl ExecPolicyManager { command, approval_policy, permission_profile, - sandbox_cwd, + windows_sandbox_level, sandbox_permissions, prefix_rule, } = req; @@ -294,7 +295,7 @@ impl ExecPolicyManager { UnmatchedCommandContext { approval_policy, permission_profile: &permission_profile, - sandbox_cwd, + windows_sandbox_level, sandbox_permissions, used_complex_parsing, command_origin, @@ -631,7 +632,7 @@ pub(crate) fn render_decision_for_unmatched_command( let UnmatchedCommandContext { approval_policy, permission_profile, - sandbox_cwd, + windows_sandbox_level, sandbox_permissions, used_complex_parsing, command_origin, @@ -645,15 +646,18 @@ pub(crate) fn render_decision_for_unmatched_command( } }; - // On Windows, ReadOnly sandbox is not a real sandbox, so special-case it - // here. - let environment_lacks_sandbox_protections = - cfg!(windows) && profile_is_managed_read_only(permission_profile, sandbox_cwd); + // When the Windows sandbox backend is disabled, managed filesystem + // restrictions are only a policy shape; there is no platform sandbox to + // enforce the boundary. Keep that legacy case conservative while still + // relying on the real Windows sandbox when it is enabled. + let windows_managed_fs_restrictions_without_sandbox_backend = cfg!(windows) + && windows_sandbox_level == WindowsSandboxLevel::Disabled + && profile_has_managed_filesystem_restrictions(permission_profile); if is_known_safe && !used_complex_parsing && (approval_policy == AskForApproval::UnlessTrusted - || environment_lacks_sandbox_protections) + || windows_managed_fs_restrictions_without_sandbox_backend) { return Decision::Allow; } @@ -671,7 +675,7 @@ pub(crate) fn render_decision_for_unmatched_command( codex_shell_command::is_dangerous_command::is_dangerous_powershell_words(command) } }; - if command_is_dangerous || environment_lacks_sandbox_protections { + if command_is_dangerous || windows_managed_fs_restrictions_without_sandbox_backend { return match approval_policy { AskForApproval::Never => { let sandbox_is_explicitly_disabled = matches!( @@ -740,10 +744,7 @@ pub(crate) fn render_decision_for_unmatched_command( } } -fn profile_is_managed_read_only( - permission_profile: &PermissionProfile, - sandbox_cwd: &Path, -) -> bool { +fn profile_has_managed_filesystem_restrictions(permission_profile: &PermissionProfile) -> bool { let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); matches!(permission_profile, PermissionProfile::Managed { .. }) && matches!( @@ -751,9 +752,6 @@ fn profile_is_managed_read_only( FileSystemSandboxKind::Restricted ) && !file_system_sandbox_policy.has_full_disk_write_access() - && file_system_sandbox_policy - .get_writable_roots_with_cwd(sandbox_cwd) - .is_empty() } fn default_policy_path(codex_home: &Path) -> PathBuf { diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index 1adf6d471b1c..a74cd5d2dd32 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -15,6 +15,7 @@ use codex_config::Sourced; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_protocol::config_types::TrustLevel; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -1088,7 +1089,7 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() { mcp_elicitations: true, }), permission_profile: &PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, command_origin: ExecPolicyCommandOrigin::Generic, @@ -1108,7 +1109,7 @@ fn unmatched_on_request_uses_permission_profile_file_system_policy_for_escalatio UnmatchedCommandContext { approval_policy: AskForApproval::OnRequest, permission_profile: &PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, command_origin: ExecPolicyCommandOrigin::Generic, @@ -1128,7 +1129,7 @@ fn known_safe_on_request_still_prompts_for_restricted_sandbox_escalation() { UnmatchedCommandContext { approval_policy: AskForApproval::OnRequest, permission_profile: &PermissionProfile::workspace_write(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, command_origin: ExecPolicyCommandOrigin::Generic, @@ -1138,7 +1139,7 @@ fn known_safe_on_request_still_prompts_for_restricted_sandbox_escalation() { } #[test] -fn managed_cwd_write_profile_is_not_read_only() { +fn managed_cwd_write_profile_has_filesystem_restrictions() { let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -1158,14 +1159,13 @@ fn managed_cwd_write_profile_is_not_read_only() { NetworkSandboxPolicy::Restricted, ); - assert!(!profile_is_managed_read_only( - &permission_profile, - Path::new("/tmp/project") + assert!(profile_has_managed_filesystem_restrictions( + &permission_profile )); } #[test] -fn managed_unresolvable_write_profile_is_still_read_only() { +fn managed_unresolvable_write_profile_has_filesystem_restrictions() { let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -1188,9 +1188,27 @@ fn managed_unresolvable_write_profile_is_still_read_only() { NetworkSandboxPolicy::Restricted, ); - assert!(profile_is_managed_read_only( - &permission_profile, - Path::new("/tmp/project") + assert!(profile_has_managed_filesystem_restrictions( + &permission_profile + )); +} + +#[test] +fn managed_full_disk_write_profile_has_no_filesystem_restrictions() { + let file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + NetworkSandboxPolicy::Restricted, + ); + + assert!(!profile_has_managed_filesystem_restrictions( + &permission_profile )); } @@ -1317,7 +1335,7 @@ async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision() mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }) @@ -1354,7 +1372,7 @@ async fn mixed_rule_and_sandbox_prompt_rejects_when_granular_rules_are_disabled( mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }) @@ -1378,7 +1396,7 @@ async fn exec_approval_requirement_falls_back_to_heuristics() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1403,7 +1421,7 @@ async fn empty_bash_lc_script_falls_back_to_original_command() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1432,7 +1450,7 @@ async fn whitespace_bash_lc_script_falls_back_to_original_command() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1461,7 +1479,7 @@ async fn request_rule_uses_prefix_rule() { command: &command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), }) @@ -1493,7 +1511,7 @@ async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands( command: &command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), }) @@ -1532,7 +1550,7 @@ async fn heuristics_apply_when_other_commands_match_policy() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::Disabled, - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -2013,7 +2031,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &sneaky_command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: permissions, prefix_rule: None, }) @@ -2037,7 +2055,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &dangerous_command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: permissions, prefix_rule: None, }) @@ -2057,7 +2075,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &dangerous_command, approval_policy: AskForApproval::Never, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: permissions, prefix_rule: None, }) @@ -2152,7 +2170,7 @@ async fn exec_approval_requirement_for_command( command: &command, approval_policy, permission_profile, - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, sandbox_permissions, prefix_rule, }) diff --git a/codex-rs/core/src/exec_policy_windows_tests.rs b/codex-rs/core/src/exec_policy_windows_tests.rs index 3fba240b4155..735cdd4ddcea 100644 --- a/codex-rs/core/src/exec_policy_windows_tests.rs +++ b/codex-rs/core/src/exec_policy_windows_tests.rs @@ -1,6 +1,5 @@ use super::*; use pretty_assertions::assert_eq; -use std::path::Path; #[tokio::test] async fn evaluates_powershell_inner_commands_against_prompt_rules() { @@ -79,7 +78,7 @@ fn unmatched_safe_powershell_words_are_allowed() { UnmatchedCommandContext { approval_policy: AskForApproval::UnlessTrusted, permission_profile: &PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, used_complex_parsing: false, command_origin: ExecPolicyCommandOrigin::PowerShell, @@ -88,6 +87,90 @@ fn unmatched_safe_powershell_words_are_allowed() { ); } +#[test] +fn read_only_windows_sandbox_runs_unmatched_commands_under_sandbox() { + let command = vec!["cmd.exe".to_string(), "/c".to_string(), "dir".to_string()]; + + for windows_sandbox_level in [ + WindowsSandboxLevel::RestrictedToken, + WindowsSandboxLevel::Elevated, + ] { + assert_eq!( + Decision::Allow, + render_decision_for_unmatched_command( + &command, + UnmatchedCommandContext { + approval_policy: AskForApproval::Never, + permission_profile: &PermissionProfile::read_only(), + windows_sandbox_level, + sandbox_permissions: SandboxPermissions::UseDefault, + used_complex_parsing: false, + command_origin: ExecPolicyCommandOrigin::Generic, + }, + ) + ); + } +} + +#[test] +fn read_only_windows_policy_without_sandbox_backend_still_requires_approval() { + let command = vec!["cmd.exe".to_string(), "/c".to_string(), "dir".to_string()]; + + assert_eq!( + Decision::Forbidden, + render_decision_for_unmatched_command( + &command, + UnmatchedCommandContext { + approval_policy: AskForApproval::Never, + permission_profile: &PermissionProfile::read_only(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, + sandbox_permissions: SandboxPermissions::UseDefault, + used_complex_parsing: false, + command_origin: ExecPolicyCommandOrigin::Generic, + }, + ), + "command is forbidden because approval policy is never and there is no Windows sandbox to rely on" + ); +} + +#[test] +fn writable_windows_policy_without_sandbox_backend_still_requires_approval() { + let command = vec!["cmd.exe".to_string(), "/c".to_string(), "dir".to_string()]; + let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }, + ]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + NetworkSandboxPolicy::Restricted, + ); + + assert_eq!( + Decision::Forbidden, + render_decision_for_unmatched_command( + &command, + UnmatchedCommandContext { + approval_policy: AskForApproval::Never, + permission_profile: &permission_profile, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + sandbox_permissions: SandboxPermissions::UseDefault, + used_complex_parsing: false, + command_origin: ExecPolicyCommandOrigin::Generic, + }, + ) + ); +} + #[tokio::test] async fn unmatched_dangerous_powershell_inner_commands_require_approval() { let inner_command = vec![ diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index abe1d8f07a7a..cf9c7c48d3eb 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -10881,8 +10881,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { command: &command, approval_policy: turn_context.approval_policy.value(), permission_profile: turn_context.permission_profile(), - #[allow(deprecated)] - sandbox_cwd: turn_context.cwd.as_path(), + windows_sandbox_level: turn_context.windows_sandbox_level, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index b3c955595aa3..b978a2f577a8 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -167,8 +167,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result, @@ -622,7 +618,7 @@ impl EscalationPolicy for CoreShellActionProvider { InterceptedExecPolicyContext { approval_policy: self.approval_policy, permission_profile: self.permission_profile.clone(), - sandbox_cwd: self.sandbox_policy_cwd.as_path(), + windows_sandbox_level: self.turn.windows_sandbox_level, sandbox_permissions: self.approval_sandbox_permissions, enable_shell_wrapper_parsing: ENABLE_INTERCEPTED_EXEC_POLICY_SHELL_WRAPPER_PARSING, @@ -671,12 +667,12 @@ fn evaluate_intercepted_exec_policy( policy: &Policy, program: &AbsolutePathBuf, argv: &[String], - context: InterceptedExecPolicyContext<'_>, + context: InterceptedExecPolicyContext, ) -> Evaluation { let InterceptedExecPolicyContext { approval_policy, permission_profile, - sandbox_cwd, + windows_sandbox_level, sandbox_permissions, enable_shell_wrapper_parsing, } = context; @@ -703,7 +699,7 @@ fn evaluate_intercepted_exec_policy( crate::exec_policy::UnmatchedCommandContext { approval_policy, permission_profile: &permission_profile, - sandbox_cwd, + windows_sandbox_level, sandbox_permissions, used_complex_parsing, command_origin: crate::exec_policy::ExecPolicyCommandOrigin::Generic, @@ -721,10 +717,10 @@ fn evaluate_intercepted_exec_policy( } #[derive(Clone)] -struct InterceptedExecPolicyContext<'a> { +struct InterceptedExecPolicyContext { approval_policy: AskForApproval, permission_profile: PermissionProfile, - sandbox_cwd: &'a Path, + windows_sandbox_level: WindowsSandboxLevel, sandbox_permissions: SandboxPermissions, enable_shell_wrapper_parsing: bool, } diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index ecdf015fd519..8cfbd0736ddb 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -16,6 +16,7 @@ use codex_execpolicy::PolicyParser; use codex_execpolicy::RuleMatch; use codex_hooks::Hooks; use codex_hooks::HooksConfig; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; @@ -456,7 +457,6 @@ async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Resul approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), - sandbox_policy_cwd: workdir.clone(), sandbox_permissions: SandboxPermissions::RequireEscalated, approval_sandbox_permissions: SandboxPermissions::RequireEscalated, prompt_permissions: None, @@ -508,7 +508,6 @@ fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_pars parser.parse("test.rules", policy_src).unwrap(); let policy = parser.build(); let program = AbsolutePathBuf::try_from(host_absolute_path(&["bin", "zsh"])).unwrap(); - let sandbox_cwd = test_sandbox_cwd(); let enable_intercepted_exec_policy_shell_wrapper_parsing = false; let evaluation = evaluate_intercepted_exec_policy( @@ -522,7 +521,7 @@ fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_pars InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: sandbox_cwd.as_path(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing, }, @@ -560,7 +559,6 @@ fn evaluate_intercepted_exec_policy_matches_inner_shell_commands_when_enabled() parser.parse("test.rules", policy_src).unwrap(); let policy = parser.build(); let program = AbsolutePathBuf::try_from(host_absolute_path(&["bin", "bash"])).unwrap(); - let sandbox_cwd = test_sandbox_cwd(); let enable_intercepted_exec_policy_shell_wrapper_parsing = true; let evaluation = evaluate_intercepted_exec_policy( @@ -574,7 +572,7 @@ fn evaluate_intercepted_exec_policy_matches_inner_shell_commands_when_enabled() InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: sandbox_cwd.as_path(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing, }, @@ -608,7 +606,6 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) parser.parse("test.rules", &policy_src).unwrap(); let policy = parser.build(); let program = AbsolutePathBuf::try_from(git_path).unwrap(); - let sandbox_cwd = test_sandbox_cwd(); let evaluation = evaluate_intercepted_exec_policy( &policy, @@ -617,7 +614,7 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: sandbox_cwd.as_path(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: false, }, @@ -670,7 +667,6 @@ prefix_rule(pattern = ["{cat_path_literal}"], decision = "allow") approval_policy: AskForApproval::OnRequest, permission_profile, file_system_sandbox_policy, - sandbox_policy_cwd: workdir.clone(), sandbox_permissions: SandboxPermissions::UseDefault, approval_sandbox_permissions: SandboxPermissions::UseDefault, prompt_permissions: None, @@ -713,7 +709,6 @@ async fn denied_reads_keep_granular_sandbox_rejection_for_escalation() -> anyhow }), permission_profile, file_system_sandbox_policy, - sandbox_policy_cwd: workdir.clone(), sandbox_permissions: SandboxPermissions::RequireEscalated, approval_sandbox_permissions: SandboxPermissions::RequireEscalated, prompt_permissions: None, @@ -744,7 +739,6 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( let argv = ["printf".to_string(), "hello".to_string()]; let approval_policy = AskForApproval::OnRequest; let permission_profile = PermissionProfile::workspace_write(); - let sandbox_cwd = test_sandbox_cwd(); let preapproved = evaluate_intercepted_exec_policy( &policy, @@ -753,7 +747,7 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( InterceptedExecPolicyContext { approval_policy, permission_profile: permission_profile.clone(), - sandbox_cwd: sandbox_cwd.as_path(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: super::approval_sandbox_permissions( SandboxPermissions::WithAdditionalPermissions, /*additional_permissions_preapproved*/ true, @@ -768,7 +762,7 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( InterceptedExecPolicyContext { approval_policy, permission_profile, - sandbox_cwd: sandbox_cwd.as_path(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, enable_shell_wrapper_parsing: false, }, @@ -793,7 +787,6 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"]) parser.parse("test.rules", &policy_src).unwrap(); let policy = parser.build(); let program = AbsolutePathBuf::try_from(other_git.clone()).unwrap(); - let sandbox_cwd = test_sandbox_cwd(); let evaluation = evaluate_intercepted_exec_policy( &policy, @@ -802,7 +795,7 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"]) InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: sandbox_cwd.as_path(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: false, }, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index a4493cf11458..cb455b113b6d 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1019,9 +1019,7 @@ impl UnifiedExecProcessManager { command: &request.command, approval_policy: context.turn.approval_policy.value(), permission_profile: context.turn.permission_profile(), - // The process cwd may be model-controlled. Policy resolution - // stays anchored to the selected turn environment cwd instead. - sandbox_cwd: request.sandbox_cwd.as_path(), + windows_sandbox_level: context.turn.windows_sandbox_level, sandbox_permissions: if request.additional_permissions_preapproved { crate::sandboxing::SandboxPermissions::UseDefault } else { diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 6859c6c884ce..25292149a0a6 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -87,6 +87,77 @@ fn assert_no_matched_rules_invariant(output_item: &Value) { ); } +#[cfg(windows)] +#[tokio::test] +async fn unified_exec_disabled_windows_sandbox_rejects_managed_read_only_command() -> Result<()> { + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config + .features + .disable(Feature::WindowsSandbox) + .expect("test config should allow feature update"); + config + .features + .disable(Feature::WindowsSandboxElevated) + .expect("test config should allow feature update"); + config.set_windows_sandbox_enabled(false); + config.set_windows_elevated_sandbox_enabled(false); + }); + let test = builder.build(&server).await?; + let call_id = "unified-exec-disabled-windows-sandbox-read-only"; + let args = json!({ + "cmd": "cmd.exe /c dir", + "yield_time_ms": 1_000, + }); + + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-disabled-windows-sandbox-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-disabled-windows-sandbox-1"), + ]), + ) + .await; + let results_mock = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-disabled-windows-sandbox-1", "done"), + ev_completed("resp-disabled-windows-sandbox-2"), + ]), + ) + .await; + + submit_user_turn( + &test, + "run unified exec with disabled Windows sandbox", + AskForApproval::Never, + PermissionProfile::read_only(), + None, + ) + .await?; + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let output_item = results_mock.single_request().function_call_output(call_id); + let Some(output) = output_item.get("output").and_then(Value::as_str) else { + panic!("function_call_output should include string output payload: {output_item:?}"); + }; + assert!( + output.contains("cmd.exe /c dir") && output.contains("rejected: blocked by policy"), + "unexpected output: {output}", + ); + + Ok(()) +} + #[tokio::test] async fn execpolicy_blocks_shell_invocation() -> Result<()> { let mut builder = test_codex().with_config(|config| { From 8d72fb6de92bf332b2ef4a4ccaf992021fbdf4ea Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 5 Jun 2026 11:27:10 -0700 Subject: [PATCH 40/59] [codex] Add turn profiling analytics (#26484) ## Summary Add flat profiling fields to `codex_turn_event` so analytics can explain where turn wall-clock time is spent without changing tool execution behavior. The profile reports: - time before the first sampling request - sampling time across all attempts and follow-ups - overhead between sampling requests - time blocked in the post-sampling tool drain - time after the final sampling request - sampling request and retry counts ## Implementation - Extend the existing turn timing state with constant-memory phase accounting and one RAII phase guard. - Observe sampling and the existing post-sampling drain only at turn orchestration boundaries. - Keep tool runtime, tool futures, response item handling, and turn lifecycle values unchanged. - Add the profiling fields directly to the existing analytics turn event without changing app-server protocol or rollout persistence. - Use the existing turn `status` to distinguish completed, failed, and interrupted profiles. Exact sampling/tool overlap is intentionally omitted because measuring tool completion accurately would require hooks in the tool execution path. ## Validation - Add app-server end-to-end coverage for a single-sampling turn with no blocking tool work. - Add app-server end-to-end coverage for `request_user_input` blocking followed by a second sampling request. - CI is running on the PR; tests were not executed locally per repository guidance. --- .../analytics/src/analytics_client_tests.rs | 40 ++++ codex-rs/analytics/src/client.rs | 7 + codex-rs/analytics/src/events.rs | 7 + codex-rs/analytics/src/facts.rs | 18 ++ codex-rs/analytics/src/lib.rs | 2 + codex-rs/analytics/src/reducer.rs | 151 +++++--------- .../app-server/tests/suite/v2/turn_start.rs | 152 +++++++++++++- codex-rs/core/src/session/turn.rs | 9 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/turn_timing.rs | 191 ++++++++++++++++++ codex-rs/core/src/turn_timing_tests.rs | 41 ++++ 11 files changed, 530 insertions(+), 101 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 7177952c3702..f4cd18da1e91 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -63,6 +63,8 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnCodexErrorFact; +use crate::facts::TurnProfile; +use crate::facts::TurnProfileFact; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerRequestError; @@ -396,6 +398,18 @@ fn sample_turn_resolved_config(thread_id: &str, turn_id: &str) -> TurnResolvedCo } } +fn sample_turn_profile() -> TurnProfile { + TurnProfile { + before_first_sampling_ms: 100, + sampling_ms: 700, + between_sampling_overhead_ms: 50, + tool_blocking_ms: 250, + after_last_sampling_ms: 134, + sampling_request_count: 2, + sampling_retry_count: 1, + } +} + fn sample_turn_steer_request( thread_id: &str, expected_turn_id: &str, @@ -649,6 +663,18 @@ async fn ingest_turn_prerequisites( ) .await; } + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnProfile(Box::new( + TurnProfileFact { + turn_id: "turn-2".to_string(), + profile: sample_turn_profile(), + }, + ))), + out, + ) + .await; } async fn ingest_review_prerequisites( @@ -3300,6 +3326,13 @@ fn turn_event_serializes_expected_shape() { output_tokens: None, reasoning_output_tokens: None, total_tokens: None, + before_first_sampling_ms: 100, + sampling_ms: 700, + between_sampling_overhead_ms: 50, + tool_blocking_ms: 250, + after_last_sampling_ms: 134, + sampling_request_count: 2, + sampling_retry_count: 1, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -3366,6 +3399,13 @@ fn turn_event_serializes_expected_shape() { "output_tokens": null, "reasoning_output_tokens": null, "total_tokens": null, + "before_first_sampling_ms": 100, + "sampling_ms": 700, + "between_sampling_overhead_ms": 50, + "tool_blocking_ms": 250, + "after_last_sampling_ms": 134, + "sampling_request_count": 2, + "sampling_retry_count": 1, "duration_ms": 1234, "started_at": 455, "completed_at": 456 diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index bd0726b28eed..b99a2ec86fc1 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -19,6 +19,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnCodexErrorFact; +use crate::facts::TurnProfileFact; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; @@ -257,6 +258,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_profile(&self, fact: TurnProfileFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnProfile( + Box::new(fact), + ))); + } + pub fn track_turn_codex_error(&self, fact: TurnCodexErrorFact) { self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnCodexError( Box::new(fact), diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index bdc2c996dcbc..f0fcddd27fe5 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -817,6 +817,13 @@ pub(crate) struct CodexTurnEventParams { pub(crate) output_tokens: Option, pub(crate) reasoning_output_tokens: Option, pub(crate) total_tokens: Option, + pub(crate) before_first_sampling_ms: u64, + pub(crate) sampling_ms: u64, + pub(crate) between_sampling_overhead_ms: u64, + pub(crate) tool_blocking_ms: u64, + pub(crate) after_last_sampling_ms: u64, + pub(crate) sampling_request_count: u32, + pub(crate) sampling_retry_count: u32, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1dff5de89dea..38af5ed8b827 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -104,6 +104,23 @@ pub struct TurnTokenUsageFact { pub token_usage: TokenUsage, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct TurnProfile { + pub before_first_sampling_ms: u64, + pub sampling_ms: u64, + pub between_sampling_overhead_ms: u64, + pub tool_blocking_ms: u64, + pub after_last_sampling_ms: u64, + pub sampling_request_count: u32, + pub sampling_retry_count: u32, +} + +#[derive(Clone)] +pub struct TurnProfileFact { + pub turn_id: String, + pub profile: TurnProfile, +} + #[derive(Clone)] pub struct TurnCodexErrorFact { pub(crate) turn_id: String, @@ -476,6 +493,7 @@ pub(crate) enum CustomAnalyticsFact { GuardianReview(Box), TurnResolvedConfig(Box), TurnTokenUsage(Box), + TurnProfile(Box), TurnCodexError(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index c227f4daf185..e2e16dfacd84 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -39,6 +39,8 @@ pub use facts::SubAgentThreadStartedInput; pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnCodexErrorFact; +pub use facts::TurnProfile; +pub use facts::TurnProfileFact; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; pub use facts::TurnSteerRejectionReason; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 877000928f6a..66caaafe01fa 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -72,6 +72,8 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TurnCodexError; use crate::facts::TurnCodexErrorFact; +use crate::facts::TurnProfile; +use crate::facts::TurnProfileFact; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -316,6 +318,7 @@ struct CompletedTurnState { duration_ms: Option, } +#[derive(Default)] struct TurnState { connection_id: Option, thread_id: Option, @@ -323,6 +326,7 @@ struct TurnState { resolved_config: Option, started_at: Option, token_usage: Option, + profile: Option, completed: Option, codex_error: Option, latest_diff: Option, @@ -464,6 +468,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnTokenUsage(input) => { self.ingest_turn_token_usage(*input, out).await; } + CustomAnalyticsFact::TurnProfile(input) => { + self.ingest_turn_profile(*input, out).await; + } CustomAnalyticsFact::TurnCodexError(input) => { self.ingest_turn_codex_error(*input); } @@ -604,19 +611,7 @@ impl AnalyticsReducer { let turn_id = input.turn_id.clone(); let thread_id = input.thread_id.clone(); let num_input_images = input.num_input_images; - let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(turn_id.clone()).or_default(); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); turn_state.resolved_config = Some(input); @@ -629,43 +624,30 @@ impl AnalyticsReducer { out: &mut Vec, ) { let turn_id = input.turn_id.clone(); - let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(turn_id.clone()).or_default(); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); self.maybe_emit_turn_event(&turn_id, out).await; } + async fn ingest_turn_profile( + &mut self, + input: TurnProfileFact, + out: &mut Vec, + ) { + let TurnProfileFact { turn_id, profile } = input; + let turn_state = self.turns.entry(turn_id.clone()).or_default(); + turn_state.profile = Some(profile); + self.maybe_emit_turn_event(&turn_id, out).await; + } + fn ingest_turn_codex_error(&mut self, input: TurnCodexErrorFact) { let TurnCodexErrorFact { turn_id, thread_id, error, } = input; - let turn_state = self.turns.entry(turn_id).or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(turn_id).or_default(); turn_state.thread_id.get_or_insert(thread_id); turn_state.codex_error = Some(error); } @@ -818,19 +800,7 @@ impl AnalyticsReducer { else { return; }; - let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(turn_id.clone()).or_default(); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); turn_state.num_input_images = Some(pending_request.num_input_images); @@ -1178,61 +1148,19 @@ impl AnalyticsReducer { self.ingest_guardian_review_completed(notification, out); } ServerNotification::TurnStarted(notification) => { - let turn_state = self.turns.entry(notification.turn.id).or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(notification.turn.id).or_default(); turn_state.started_at = notification .turn .started_at .and_then(|started_at| u64::try_from(started_at).ok()); } ServerNotification::TurnDiffUpdated(notification) => { - let turn_state = - self.turns - .entry(notification.turn_id.clone()) - .or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(notification.turn_id.clone()).or_default(); turn_state.thread_id = Some(notification.thread_id); turn_state.latest_diff = Some(notification.diff); } ServerNotification::TurnCompleted(notification) => { - let turn_state = - self.turns - .entry(notification.turn.id.clone()) - .or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(notification.turn.id.clone()).or_default(); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), turn_error: notification @@ -1511,6 +1439,7 @@ impl AnalyticsReducer { if turn_state.thread_id.is_none() || turn_state.num_input_images.is_none() || turn_state.resolved_config.is_none() + || turn_state.profile.is_none() || turn_state.completed.is_none() { return; @@ -2457,12 +2386,20 @@ fn codex_turn_event_params( turn_state: &TurnState, thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { - let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + let ( + Some(thread_id), + Some(num_input_images), + Some(resolved_config), + Some(profile), + Some(completed), + ) = ( turn_state.thread_id.clone(), turn_state.num_input_images, turn_state.resolved_config.clone(), + turn_state.profile.clone(), turn_state.completed.clone(), - ) else { + ) + else { unreachable!("turn event params require a fully populated turn state"); }; let started_at = turn_state.started_at; @@ -2488,6 +2425,15 @@ fn codex_turn_event_params( workspace_kind, is_first_turn, } = resolved_config; + let TurnProfile { + before_first_sampling_ms, + sampling_ms, + between_sampling_overhead_ms, + tool_blocking_ms, + after_last_sampling_ms, + sampling_request_count, + sampling_retry_count, + } = profile; let token_usage = turn_state.token_usage.clone(); let codex_error = turn_state.codex_error.as_ref(); CodexTurnEventParams { @@ -2550,6 +2496,13 @@ fn codex_turn_event_params( total_tokens: token_usage .as_ref() .map(|token_usage| token_usage.total_tokens), + before_first_sampling_ms, + sampling_ms, + between_sampling_overhead_ms, + tool_blocking_ms, + after_last_sampling_ms, + sampling_request_count, + sampling_retry_count, duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 9f6a027a75eb..b4da5cddeadd 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -9,6 +9,7 @@ use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::create_request_user_input_sse_response; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; @@ -78,6 +79,7 @@ use std::collections::HashMap; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::ResponseTemplate; use super::analytics::mount_analytics_capture; use super::analytics::wait_for_analytics_event; @@ -819,8 +821,20 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> #[tokio::test] async fn turn_start_tracks_turn_event_analytics() -> Result<()> { - let responses = vec![create_final_assistant_message_sse_response("Done")?]; - let server = create_mock_responses_server_sequence_unchecked(responses).await; + let server = responses::start_mock_server().await; + let response_mock = responses::mount_response_sequence( + &server, + vec![ + ResponseTemplate::new(500).set_body_json(json!({ + "error": { + "type": "server_error", + "message": "synthetic retryable error" + } + })), + responses::sse_response(create_final_assistant_message_sse_response("Done")?), + ], + ) + .await; let codex_home = TempDir::new()?; write_mock_responses_config_toml_with_chatgpt_base_url( @@ -828,6 +842,10 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { &server.uri(), &server.uri(), )?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)? + .replace("stream_max_retries = 0", "stream_max_retries = 1"); + std::fs::write(config_path, config)?; mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; @@ -908,6 +926,136 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["output_tokens"], 0); assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); assert_eq!(event["event_params"]["total_tokens"], 0); + let params = &event["event_params"]; + let timings_are_numbers = [ + "before_first_sampling_ms", + "sampling_ms", + "between_sampling_overhead_ms", + "tool_blocking_ms", + "after_last_sampling_ms", + ] + .into_iter() + .all(|field| params[field].as_u64().is_some()); + assert_eq!( + json!({ + "timingsAreNumbers": timings_are_numbers, + "toolBlockingMs": params["tool_blocking_ms"], + "samplingRequestCount": params["sampling_request_count"], + "samplingRetryCount": params["sampling_retry_count"], + "responseRequestCount": response_mock.requests().len(), + }), + json!({ + "timingsAreNumbers": true, + "toolBlockingMs": 0, + "samplingRequestCount": 2, + "samplingRetryCount": 1, + "responseRequestCount": 2, + }) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn turn_profile_tracks_blocking_tool_and_follow_up_sampling() -> Result<()> { + let responses = vec![ + create_request_user_input_sse_response("call1")?, + create_final_assistant_message_sse_response("Done")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "ask something".to_string(), + text_elements: Vec::new(), + }], + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: "mock-model".to_string(), + reasoning_effort: Some(ReasoningEffort::Medium), + developer_instructions: None, + }, + }), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::ToolRequestUserInput { request_id, .. } = server_req else { + panic!("expected ToolRequestUserInput request, got: {server_req:?}"); + }; + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + mcp.send_response( + request_id, + json!({ + "answers": { + "confirm_path": { "answers": ["yes"] } + } + }), + ) + .await?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + let params = &event["event_params"]; + assert_eq!( + json!({ + "toolBlockingIsPositive": params["tool_blocking_ms"] + .as_u64() + .is_some_and(|duration| duration > 0), + "samplingRequestCount": params["sampling_request_count"], + "samplingRetryCount": params["sampling_retry_count"], + "status": params["status"], + }), + json!({ + "toolBlockingIsPositive": true, + "samplingRequestCount": 2, + "samplingRetryCount": 0, + "status": "completed", + }) + ); Ok(()) } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 5853b78c7e66..76303345a722 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1088,6 +1088,7 @@ async fn run_sampling_request( ResponsesStreamRequest::Sampling, ) .await?; + turn_context.turn_timing_state.record_sampling_retry(); } } @@ -1800,6 +1801,7 @@ async fn try_run_sampling_request( turn_context.model_info.slug.as_str(), turn_context.provider.info().name.as_str(), ); + let sampling_timing_guard = turn_context.turn_timing_state.begin_sampling(); let mut stream = client_session .stream( prompt, @@ -2213,6 +2215,7 @@ async fn try_run_sampling_request( } } }; + drop(sampling_timing_guard); flush_assistant_text_segments_all( &sess, @@ -2222,7 +2225,13 @@ async fn try_run_sampling_request( ) .await; + let tool_blocking_timing_guard = if in_flight.is_empty() { + None + } else { + Some(turn_context.turn_timing_state.begin_tool_blocking()) + }; drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?; + drop(tool_blocking_timing_guard); if should_emit_token_count { // A tool call such as request_user_input can intentionally pause the turn. Emit token diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index ef2ba0f16d1a..70466c9c2f0f 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -33,6 +33,7 @@ use crate::session::turn_context::TurnContext; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnProfileFact; use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::SharedModelsManager; @@ -751,6 +752,12 @@ impl Session { .turn_timing_state .time_to_first_token_ms() .await; + self.services + .analytics_events_client + .track_turn_profile(TurnProfileFact { + turn_id: turn_context.sub_id.clone(), + profile: turn_context.turn_timing_state.complete_profile(), + }); self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref()) .await; if let Err(err) = self @@ -868,6 +875,12 @@ impl Session { .turn_timing_state .completed_at_and_duration_ms() .await; + self.services + .analytics_events_client + .track_turn_profile(TurnProfileFact { + turn_id: task.turn_context.sub_id.clone(), + profile: task.turn_context.turn_timing_state.complete_profile(), + }); let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 445fe16214ad..380e246fae0b 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,8 +1,11 @@ +use std::sync::Arc; +use std::sync::Mutex as StdMutex; use std::time::Duration; use std::time::Instant; use std::time::SystemTime; use std::time::UNIX_EPOCH; +use codex_analytics::TurnProfile; use codex_otel::TURN_TTFM_DURATION_METRIC; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; @@ -39,6 +42,7 @@ pub(crate) async fn record_turn_ttfm_metric(turn_context: &TurnContext, item: &T #[derive(Debug, Default)] pub(crate) struct TurnTimingState { state: Mutex, + profile: StdMutex, } #[derive(Debug, Default)] @@ -49,6 +53,35 @@ struct TurnTimingStateInner { first_message_at: Option, } +#[derive(Debug, Default)] +struct TurnProfileState { + started_at: Option, + last_transition_at: Option, + active_phase: Option, + seen_sampling: bool, + before_first_sampling: Duration, + sampling: Duration, + between_sampling_overhead: Duration, + tool_blocking: Duration, + pending_idle_after_sampling: Duration, + sampling_request_count: u32, + sampling_retry_count: u32, + completed_profile: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TurnProfilePhase { + Sampling, + ToolBlocking, +} + +#[must_use] +pub(crate) struct TurnProfileTimingGuard { + timing: Arc, + phase: TurnProfilePhase, + active: bool, +} + impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) -> i64 { let started_at_unix_ms = now_unix_timestamp_ms(); @@ -57,6 +90,7 @@ impl TurnTimingState { state.started_at_unix_secs = Some(started_at_unix_ms / 1000); state.first_token_at = None; state.first_message_at = None; + self.profile_state().start(started_at); started_at_unix_ms } @@ -80,6 +114,32 @@ impl TurnTimingState { .map(|duration| i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)) } + pub(crate) fn complete_profile(&self) -> TurnProfile { + self.profile_state().complete(Instant::now()) + } + + pub(crate) fn begin_sampling(self: &Arc) -> TurnProfileTimingGuard { + let active = self.profile_state().begin_sampling(Instant::now()); + TurnProfileTimingGuard { + timing: Arc::clone(self), + phase: TurnProfilePhase::Sampling, + active, + } + } + + pub(crate) fn record_sampling_retry(&self) { + self.profile_state().record_sampling_retry(); + } + + pub(crate) fn begin_tool_blocking(self: &Arc) -> TurnProfileTimingGuard { + let active = self.profile_state().begin_tool_blocking(Instant::now()); + TurnProfileTimingGuard { + timing: Arc::clone(self), + phase: TurnProfilePhase::ToolBlocking, + active, + } + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -98,6 +158,22 @@ impl TurnTimingState { let mut state = self.state.lock().await; state.record_turn_ttfm() } + + fn profile_state(&self) -> std::sync::MutexGuard<'_, TurnProfileState> { + self.profile + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl Drop for TurnProfileTimingGuard { + fn drop(&mut self) { + if self.active { + self.timing + .profile_state() + .end_phase(Instant::now(), self.phase); + } + } } fn now_unix_timestamp_secs() -> i64 { @@ -111,6 +187,121 @@ pub(crate) fn now_unix_timestamp_ms() -> i64 { i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) } +fn duration_to_u64_ms(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +impl TurnProfileState { + fn start(&mut self, started_at: Instant) { + *self = Self { + started_at: Some(started_at), + last_transition_at: Some(started_at), + ..Self::default() + }; + } + + fn begin_sampling(&mut self, now: Instant) -> bool { + if self.completed_profile.is_some() + || self.started_at.is_none() + || self.active_phase.is_some() + { + return false; + } + self.advance(now); + if self.seen_sampling { + self.between_sampling_overhead += std::mem::take(&mut self.pending_idle_after_sampling); + } + self.seen_sampling = true; + self.active_phase = Some(TurnProfilePhase::Sampling); + self.sampling_request_count = self.sampling_request_count.saturating_add(1); + true + } + + fn record_sampling_retry(&mut self) { + if self.completed_profile.is_none() && self.started_at.is_some() { + self.sampling_retry_count = self.sampling_retry_count.saturating_add(1); + } + } + + fn begin_tool_blocking(&mut self, now: Instant) -> bool { + if self.completed_profile.is_some() + || self.started_at.is_none() + || self.active_phase.is_some() + { + return false; + } + self.advance(now); + self.active_phase = Some(TurnProfilePhase::ToolBlocking); + true + } + + fn end_phase(&mut self, now: Instant, phase: TurnProfilePhase) { + if self.completed_profile.is_some() || self.active_phase != Some(phase) { + return; + } + self.advance(now); + self.active_phase = None; + } + + fn advance(&mut self, now: Instant) { + let Some(previous) = self.last_transition_at.replace(now) else { + return; + }; + let elapsed = now.saturating_duration_since(previous); + match self.active_phase { + Some(TurnProfilePhase::Sampling) => self.sampling += elapsed, + Some(TurnProfilePhase::ToolBlocking) => self.tool_blocking += elapsed, + None if self.seen_sampling => self.pending_idle_after_sampling += elapsed, + None => self.before_first_sampling += elapsed, + } + } + + fn complete(&mut self, now: Instant) -> TurnProfile { + if let Some(profile) = self.completed_profile.as_ref() { + return profile.clone(); + } + + let final_phase = self.active_phase; + self.advance(now); + let after_last_sampling = if self.seen_sampling { + std::mem::take(&mut self.pending_idle_after_sampling) + } else { + Duration::ZERO + }; + + let mut profile = TurnProfile { + before_first_sampling_ms: duration_to_u64_ms(self.before_first_sampling), + sampling_ms: duration_to_u64_ms(self.sampling), + between_sampling_overhead_ms: duration_to_u64_ms(self.between_sampling_overhead), + tool_blocking_ms: duration_to_u64_ms(self.tool_blocking), + after_last_sampling_ms: duration_to_u64_ms(after_last_sampling), + sampling_request_count: self.sampling_request_count, + sampling_retry_count: self.sampling_retry_count, + }; + let total_ms = self + .started_at + .map(|started_at| duration_to_u64_ms(now.saturating_duration_since(started_at))) + .unwrap_or_default(); + let classified_ms = profile + .before_first_sampling_ms + .saturating_add(profile.sampling_ms) + .saturating_add(profile.between_sampling_overhead_ms) + .saturating_add(profile.tool_blocking_ms) + .saturating_add(profile.after_last_sampling_ms); + let rounding_ms = total_ms.saturating_sub(classified_ms); + match final_phase { + Some(TurnProfilePhase::Sampling) => profile.sampling_ms += rounding_ms, + Some(TurnProfilePhase::ToolBlocking) => profile.tool_blocking_ms += rounding_ms, + None if self.seen_sampling => profile.after_last_sampling_ms += rounding_ms, + None => profile.before_first_sampling_ms += rounding_ms, + } + + self.active_phase = None; + self.completed_profile = Some(profile.clone()); + profile + } +} + impl TurnTimingStateInner { fn time_to_first_token(&self) -> Option { Some(self.first_token_at?.duration_since(self.started_at?)) diff --git a/codex-rs/core/src/turn_timing_tests.rs b/codex-rs/core/src/turn_timing_tests.rs index a6675aea8eb6..7146f537be6f 100644 --- a/codex-rs/core/src/turn_timing_tests.rs +++ b/codex-rs/core/src/turn_timing_tests.rs @@ -1,13 +1,17 @@ +use codex_analytics::TurnProfile; use codex_protocol::items::AgentMessageItem; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseItem; use pretty_assertions::assert_eq; +use std::time::Duration; use std::time::Instant; use std::time::SystemTime; use std::time::UNIX_EPOCH; +use super::TurnProfilePhase; +use super::TurnProfileState; use super::TurnTimingState; use super::response_item_records_turn_ttft; use crate::ResponseEvent; @@ -146,3 +150,40 @@ fn response_item_records_turn_ttft_ignores_empty_non_output_items() { } )); } + +#[test] +fn turn_profile_breaks_down_sampling_blocking_and_retry_overhead() { + let started_at = Instant::now(); + let mut state = TurnProfileState::default(); + state.start(started_at); + + let _ = state.begin_sampling(started_at + Duration::from_millis(100)); + state.end_phase( + started_at + Duration::from_millis(600), + TurnProfilePhase::Sampling, + ); + let _ = state.begin_tool_blocking(started_at + Duration::from_millis(600)); + state.end_phase( + started_at + Duration::from_millis(900), + TurnProfilePhase::ToolBlocking, + ); + state.record_sampling_retry(); + let _ = state.begin_sampling(started_at + Duration::from_millis(1_000)); + state.end_phase( + started_at + Duration::from_millis(1_200), + TurnProfilePhase::Sampling, + ); + + assert_eq!( + state.complete(started_at + Duration::from_millis(1_300)), + TurnProfile { + before_first_sampling_ms: 100, + sampling_ms: 700, + between_sampling_overhead_ms: 100, + tool_blocking_ms: 300, + after_last_sampling_ms: 100, + sampling_request_count: 2, + sampling_retry_count: 1, + } + ); +} From 76c0a5379c79cc6eafc3a041791201e849ad4c7f Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 5 Jun 2026 11:36:53 -0700 Subject: [PATCH 41/59] Make runtime workspace roots absolute in app-server API (#26552) Stacked on #26532. ## Why #26532 moves cwd normalization to the app-server/core boundary. `runtimeWorkspaceRoots` still accepted raw paths in v2 requests and in `ConfigOverrides`, which left core responsible for interpreting those roots later. This makes runtime workspace roots follow the same absolute-path boundary as cwd. ## What - Change v2 `runtimeWorkspaceRoots` request fields for `thread/start`, `thread/resume`, `thread/fork`, and `turn/start` to `AbsolutePathBuf`. - Deduplicate already-absolute runtime roots in app-server handlers and pass them through `ConfigOverrides.workspace_roots` as `AbsolutePathBuf`. - Update TUI and exec client request builders to pass absolute runtime roots directly. - Update app-server docs, schema fixtures, and focused tests for absolute runtime roots. ## Testing - `just test -p codex-app-server-protocol` - `just test -p codex-app-server runtime_workspace_roots` - `just test -p codex-core session_permission_profile_rebinds_runtime_workspace_roots` - `just test -p codex-tui app_server_session` - `just test -p codex-exec` --- .../schema/json/v2/ThreadForkParams.json | 4 ++ .../schema/json/v2/ThreadResumeParams.json | 4 ++ .../src/protocol/v2/thread.rs | 15 ++--- .../src/protocol/v2/turn.rs | 5 +- codex-rs/app-server/README.md | 4 +- codex-rs/app-server/src/request_processors.rs | 18 ++++++ .../request_processors/thread_processor.rs | 17 ++---- .../src/request_processors/turn_processor.rs | 58 +++---------------- .../tests/suite/v2/thread_resume.rs | 5 +- .../app-server/tests/suite/v2/thread_start.rs | 13 ++--- .../app-server/tests/suite/v2/turn_start.rs | 2 + codex-rs/core/src/config/mod.rs | 13 ++--- codex-rs/exec/src/lib.rs | 16 +---- codex-rs/tui/src/app_server_session.rs | 47 ++------------- 14 files changed, 73 insertions(+), 148 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index 9d2f834dd8c6..d9a543e9394a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -1,6 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index a6abcc566576..dc5ef5cdaa1d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1,6 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "AgentMessageInputContent": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 9dab60a0467b..486e121cf708 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -107,11 +107,10 @@ pub struct ThreadStartParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, - /// Replace the thread's runtime workspace roots. Relative paths are - /// resolved against the effective cwd for the thread. + /// Replace the thread's runtime workspace roots. Paths must be absolute. #[experimental("thread/start.runtimeWorkspaceRoots")] #[ts(optional = nullable)] - pub runtime_workspace_roots: Option>, + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -358,11 +357,10 @@ pub struct ThreadResumeParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, - /// Replace the thread's runtime workspace roots. Relative paths are - /// resolved against the effective cwd for the thread. + /// Replace the thread's runtime workspace roots. Paths must be absolute. #[experimental("thread/resume.runtimeWorkspaceRoots")] #[ts(optional = nullable)] - pub runtime_workspace_roots: Option>, + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -509,11 +507,10 @@ pub struct ThreadForkParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, - /// Replace the thread's runtime workspace roots. Relative paths are - /// resolved against the effective cwd for the thread. + /// Replace the thread's runtime workspace roots. Paths must be absolute. #[experimental("thread/fork.runtimeWorkspaceRoots")] #[ts(optional = nullable)] - pub runtime_workspace_roots: Option>, + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index f9bfe21479d1..3bac7bf7ec05 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -88,11 +88,10 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub cwd: Option, /// Replace the thread's runtime workspace roots for this turn and - /// subsequent turns. Relative paths are resolved against the effective - /// cwd for the turn. + /// subsequent turns. Paths must be absolute. #[experimental("turn/start.runtimeWorkspaceRoots")] #[ts(optional = nullable)] - pub runtime_workspace_roots: Option>, + pub runtime_workspace_roots: Option>, /// Override the approval policy for this turn and subsequent turns. #[experimental(nested)] #[ts(optional = nullable)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4e06dc16e352..a12144238aaf 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -130,7 +130,7 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective thread cwd. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. - `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. @@ -158,7 +158,7 @@ Example with notification opt-out: - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective turn cwd. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 00468b0a999a..bfcf328c4369 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -513,6 +513,24 @@ use crate::thread_state::ThreadStateManager; use token_usage_replay::latest_token_usage_turn_id_from_rollout_items; use token_usage_replay::send_thread_token_usage_update_to_connection; +fn resolve_request_cwd(cwd: Option) -> Result, JSONRPCErrorError> { + cwd.map(|cwd| { + AbsolutePathBuf::relative_to_current_dir(path_utils::normalize_for_native_workdir(cwd)) + .map_err(|err| invalid_request(format!("invalid cwd: {err}"))) + }) + .transpose() +} + +fn resolve_runtime_workspace_roots(workspace_roots: Vec) -> Vec { + let mut resolved_roots = Vec::new(); + for root in workspace_roots { + if !resolved_roots.iter().any(|existing| existing == &root) { + resolved_roots.push(root); + } + } + resolved_roots +} + mod config_errors; mod request_errors; mod thread_goal_processor; diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index efff8e7cd851..eab606bf6258 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -56,17 +56,7 @@ fn collect_resume_override_mismatches( } } if let Some(requested_runtime_workspace_roots) = request.runtime_workspace_roots.as_ref() { - let base_cwd = request - .cwd - .as_deref() - .map(|cwd| { - AbsolutePathBuf::resolve_path_against_base(cwd, config_snapshot.cwd.as_path()) - }) - .unwrap_or_else(|| config_snapshot.cwd.clone()); - let requested_runtime_workspace_roots = requested_runtime_workspace_roots - .iter() - .map(|path| AbsolutePathBuf::resolve_path_against_base(path, base_cwd.as_path())) - .collect::>(); + let requested_runtime_workspace_roots = requested_runtime_workspace_roots.to_vec(); if requested_runtime_workspace_roots != config_snapshot.workspace_roots { mismatch_details.push(format!( "runtime_workspace_roots requested={requested_runtime_workspace_roots:?} active={:?}", @@ -846,6 +836,7 @@ impl ThreadRequestProcessor { )); } let environment_selections = self.parse_environment_selections(environments)?; + let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots); let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, @@ -1215,7 +1206,7 @@ impl ThreadRequestProcessor { model_provider: Option, service_tier: Option>, cwd: Option, - runtime_workspace_roots: Option>, + runtime_workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, sandbox: Option, @@ -2483,6 +2474,7 @@ impl ThreadRequestProcessor { }; let history_cwd = thread_history.session_cwd(); + let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots); let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, @@ -3201,6 +3193,7 @@ impl ThreadRequestProcessor { } else { Some(cli_overrides) }; + let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots); let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 85342235c96e..ce98a2e8e020 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -18,28 +18,6 @@ pub(crate) struct TurnRequestProcessor { skills_watcher: Arc, } -fn resolve_runtime_workspace_roots( - workspace_roots: Vec, - base_cwd: &AbsolutePathBuf, -) -> Vec { - let mut resolved_roots = Vec::new(); - for path in workspace_roots { - let root = AbsolutePathBuf::resolve_path_against_base(path, base_cwd.as_path()); - if !resolved_roots.iter().any(|existing| existing == &root) { - resolved_roots.push(root); - } - } - resolved_roots -} - -fn resolve_request_cwd(cwd: Option) -> Result, JSONRPCErrorError> { - cwd.map(|cwd| { - AbsolutePathBuf::relative_to_current_dir(path_utils::normalize_for_native_workdir(cwd)) - .map_err(|err| invalid_request(format!("invalid cwd: {err}"))) - }) - .transpose() -} - fn map_additional_context( additional_context: Option>, ) -> BTreeMap { @@ -66,7 +44,7 @@ fn map_additional_context( struct ThreadSettingsBuildParams { method: &'static str, cwd: Option, - runtime_workspace_roots: Option>, + runtime_workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, sandbox_policy: Option, @@ -533,7 +511,7 @@ impl TurnRequestProcessor { // `thread/settings/update` only acknowledges that the update was queued. // Clients that send dependent partial updates should wait for // `thread/settings/updated` or combine the fields in one request. - let snapshot = if permissions.is_some() || runtime_workspace_roots_request.is_some() { + let snapshot = if permissions.is_some() { Some(thread.config_snapshot().await) } else { None @@ -552,22 +530,8 @@ impl TurnRequestProcessor { || collaboration_mode.is_some() || personality.is_some(); - let runtime_workspace_roots = if let Some(workspace_roots) = - runtime_workspace_roots_request.clone() - { - let Some(snapshot) = snapshot.as_ref() else { - return Err(internal_error(format!( - "{method} runtime workspace roots missing thread snapshot" - ))); - }; - let base_cwd = cwd - .as_ref() - .map(|cwd| AbsolutePathBuf::resolve_path_against_base(cwd, snapshot.cwd.as_path())) - .unwrap_or_else(|| snapshot.cwd.clone()); - Some(resolve_runtime_workspace_roots(workspace_roots, &base_cwd)) - } else { - None - }; + let runtime_workspace_roots = + runtime_workspace_roots_request.map(resolve_runtime_workspace_roots); let approval_policy = approval_policy.map(codex_app_server_protocol::AskForApproval::to_core); let approvals_reviewer = @@ -582,15 +546,11 @@ impl TurnRequestProcessor { }; let overrides = ConfigOverrides { cwd: cwd.as_ref().map(AbsolutePathBuf::to_path_buf), - workspace_roots: Some(runtime_workspace_roots_request.clone().unwrap_or_else( - || { - snapshot - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect() - }, - )), + workspace_roots: Some( + runtime_workspace_roots + .clone() + .unwrap_or_else(|| snapshot.workspace_roots.clone()), + ), default_permissions: Some(permissions), codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 4ae68b0e7141..582177ee7da7 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -366,7 +366,10 @@ async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Resul text: "Hello".to_string(), text_elements: Vec::new(), }], - runtime_workspace_roots: Some(vec![extra_root.clone(), extra_root.join(".")]), + runtime_workspace_roots: Some(vec![ + AbsolutePathBuf::from_absolute_path(&extra_root)?, + AbsolutePathBuf::from_absolute_path(extra_root.join("."))?, + ]), ..Default::default() }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index a2da95545a34..5954342b9cef 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -197,15 +197,15 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { } #[tokio::test] -async fn thread_start_resolves_runtime_workspace_roots_against_cwd() -> Result<()> { +async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; let cwd_tmp = TempDir::new()?; let cwd = cwd_tmp.path().to_path_buf(); - let relative_root = PathBuf::from("extra-root"); - std::fs::create_dir_all(cwd.join(&relative_root))?; + let extra_root = cwd.join("extra-root"); + std::fs::create_dir_all(&extra_root)?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -213,7 +213,7 @@ async fn thread_start_resolves_runtime_workspace_roots_against_cwd() -> Result<( let req_id = mcp .send_thread_start_request(ThreadStartParams { cwd: Some(cwd.to_string_lossy().to_string()), - runtime_workspace_roots: Some(vec![relative_root.clone()]), + runtime_workspace_roots: Some(vec![extra_root.abs()]), ..Default::default() }) .await?; @@ -230,10 +230,7 @@ async fn thread_start_resolves_runtime_workspace_roots_against_cwd() -> Result<( } = to_response::(resp)?; assert_eq!(response_cwd, cwd.abs()); - assert_eq!( - runtime_workspace_roots, - vec![cwd_tmp.path().join(relative_root).abs()] - ); + assert_eq!(runtime_workspace_roots, vec![extra_root.abs()]); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index b4da5cddeadd..aed3c58697de 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -2449,6 +2449,8 @@ async fn turn_start_permission_profile_rebinds_runtime_workspace_roots_between_t std::fs::create_dir(&new_root)?; let old_root_text = old_root.to_string_lossy().into_owned(); let new_root_text = new_root.to_string_lossy().into_owned(); + let old_root = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(old_root)?; + let new_root = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(new_root)?; let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_sequence( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b74f2feefee2..e319f3b39623 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2245,9 +2245,9 @@ pub struct ConfigOverrides { pub bypass_hook_trust: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, - /// Explicit runtime workspace roots for this session. When set, this is - /// the full runtime root list rather than an additive override. - pub workspace_roots: Option>, + /// Explicit absolute runtime workspace roots for this session. When set, + /// this is the full runtime root list rather than an additive override. + pub workspace_roots: Option>, } fn dedupe_absolute_paths(paths: &mut Vec) { @@ -2821,12 +2821,7 @@ impl Config { || !requested_additional_writable_roots.is_empty() || legacy_workspace_roots_explicit; let mut workspace_roots = match workspace_roots_override { - Some(workspace_roots) => workspace_roots - .into_iter() - .map(|path| { - AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path()) - }) - .collect(), + Some(workspace_roots) => workspace_roots, None => { let mut workspace_roots = vec![resolved_cwd.clone()]; workspace_roots.extend(requested_additional_writable_roots.clone()); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 797e168dc5ea..b8b87a85eca6 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1044,13 +1044,7 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), - runtime_workspace_roots: Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect(), - ), + runtime_workspace_roots: Some(config.workspace_roots.clone()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox: sandbox.flatten(), @@ -1075,13 +1069,7 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), - runtime_workspace_roots: Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect(), - ), + runtime_workspace_roots: Some(config.workspace_roots.clone()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox: sandbox.flatten(), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index ccd6304d76b5..438ee21ca59f 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -708,12 +708,7 @@ impl AppServerSession { additional_context: None, environments: None, cwd: Some(cwd), - runtime_workspace_roots: Some( - workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect(), - ), + runtime_workspace_roots: Some(workspace_roots.to_vec()), approval_policy: Some(approval_policy), approvals_reviewer: Some(approvals_reviewer.into()), sandbox_policy, @@ -1403,13 +1398,7 @@ fn thread_start_params_from_config( model_provider: thread_params_mode.model_provider_from_config(config), service_tier: service_tier_override_from_config(config), cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override), - runtime_workspace_roots: Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect(), - ), + runtime_workspace_roots: Some(config.workspace_roots.clone()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox, @@ -1444,13 +1433,7 @@ fn thread_resume_params_from_config( model_provider: thread_params_mode.model_provider_from_config(&config), service_tier: service_tier_override_from_config(&config), cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), - runtime_workspace_roots: Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect(), - ), + runtime_workspace_roots: Some(config.workspace_roots.clone()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox, @@ -1482,13 +1465,7 @@ fn thread_fork_params_from_config( model_provider: thread_params_mode.model_provider_from_config(&config), service_tier: service_tier_override_from_config(&config), cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), - runtime_workspace_roots: Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect(), - ), + runtime_workspace_roots: Some(config.workspace_roots.clone()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox, @@ -1885,13 +1862,7 @@ mod tests { assert_eq!(params.cwd, Some(config.cwd.to_string_lossy().to_string())); assert_eq!( params.runtime_workspace_roots, - Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect() - ) + Some(config.workspace_roots.clone()) ); assert_eq!(params.sandbox, None); assert_eq!( @@ -2009,13 +1980,7 @@ mod tests { &config.permissions.effective_permission_profile(), config.cwd.as_path(), ); - let expected_runtime_workspace_roots = Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect::>(), - ); + let expected_runtime_workspace_roots = Some(config.workspace_roots.clone()); let start = thread_start_params_from_config( &config, From 345cf6e8d06b0d6890e68dc5fd80227f2fbb4e71 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 5 Jun 2026 14:58:09 -0400 Subject: [PATCH 42/59] Use state DB first for `resume --last` (#26462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `codex resume --last` currently lists sessions by updated time using scan-and-repair. Updated-time filesystem listing must stat every rollout before applying the cwd, provider, and source filters, so startup scales with the entire local session history... This change queries the state DB first for the latest matching session. For local workspaces, we only accept the indexed result when its rollout path still exists; otherwise we retry with scan-and-repair. The same lookup path is shared by `fork --last`. I benchmarked the same `thread/list` request used by `resume --last` in my local `ruff` checkout against a Codex home with 2,599 active rollouts totaling 3.7 GiB, including 90 Ruff threads. Across five fresh release app-server processes with warm filesystem caches, the state-DB-only lookup had median latency of 0.37-0.44 ms, while scan-and-repair had median latency of 139-162 ms. First-request latency was 0.7-1.7 ms versus 142-185 ms. So this **removes roughly 140-160 ms from the `resume --last` lookup** on this machine, and makes that lookup over 300x faster. The tradeoff is that this does leave two correctness gaps: - If a newer matching rollout is missing from SQLite but an older matching row exists, the fast path resumes the older thread and never falls back to the filesystem scan. - If an existing row has stale filter or ordering metadata, the fast path can select a different thread from scan-and-repair. The rollout tests already demonstrate this for stale cwd metadata: state-DB-only returns the stale match, while scan-and-repair removes and repairs it. So you could end up seeing the "wrong" result in cases like... 1. A crash or SQLite error occurs between Codex writing the conversation file and updating SQLite, leaving the newer file unindexed. 2. An older Codex version, restore, or manual copy adds a conversation file after SQLite’s one-time backfill completed. These seem pretty rare though (and sessions can always be recovered via other mechanisms -- `--last` is just a convenience feature), and I think the tradeoffs are good here? --- codex-rs/tui/src/lib.rs | 280 ++++++++++++++++++++++++++-------------- 1 file changed, 185 insertions(+), 95 deletions(-) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index b53ec0f83da8..ebaccf08fc24 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -744,18 +744,37 @@ async fn lookup_latest_session_target_with_app_server( cwd_filter: Option<&Path>, include_non_interactive: bool, ) -> color_eyre::Result> { - let response = app_server - .thread_list(latest_session_lookup_params( - app_server.uses_remote_workspace(), - config, - cwd_filter, - include_non_interactive, - )) - .await?; - Ok(response - .data - .into_iter() - .find_map(session_target_from_app_server_thread)) + let uses_remote_workspace = app_server.uses_remote_workspace(); + for lookup_mode in [ + LatestSessionLookupMode::StateDbOnly, + LatestSessionLookupMode::ScanAndRepair, + ] { + let response = app_server + .thread_list(latest_session_lookup_params( + uses_remote_workspace, + config, + cwd_filter, + include_non_interactive, + lookup_mode, + )) + .await?; + let target = response + .data + .into_iter() + .find_map(session_target_from_app_server_thread); + if target.as_ref().is_some_and(|target| { + uses_remote_workspace || target.path.as_deref().is_some_and(std::path::Path::exists) + }) { + return Ok(target); + } + } + Ok(None) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LatestSessionLookupMode { + StateDbOnly, + ScanAndRepair, } fn latest_session_lookup_params( @@ -763,6 +782,7 @@ fn latest_session_lookup_params( config: &Config, cwd_filter: Option<&Path>, include_non_interactive: bool, + lookup_mode: LatestSessionLookupMode, ) -> ThreadListParams { ThreadListParams { cursor: None, @@ -777,7 +797,10 @@ fn latest_session_lookup_params( source_kinds: Some(resume_source_kinds(include_non_interactive)), archived: Some(false), cwd: cwd_filter.map(|cwd| ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())), - use_state_db_only: false, + use_state_db_only: match lookup_mode { + LatestSessionLookupMode::StateDbOnly => true, + LatestSessionLookupMode::ScanAndRepair => false, + }, search_term: None, } } @@ -2040,6 +2063,85 @@ mod tests { .await } + fn write_session_rollout( + codex_home: &Path, + filename_ts: &str, + meta_rfc3339: &str, + preview: &str, + model_provider: &str, + cwd: &Path, + ) -> color_eyre::Result { + let uuid = Uuid::new_v4(); + let uuid_str = uuid.to_string(); + let thread_id = ThreadId::from_string(&uuid_str)?; + let year = &filename_ts[0..4]; + let month = &filename_ts[5..7]; + let day = &filename_ts[8..10]; + let rollout_path = codex_home + .join("sessions") + .join(year) + .join(month) + .join(day) + .join(format!("rollout-{filename_ts}-{uuid_str}.jsonl")); + let parent = rollout_path + .parent() + .ok_or_else(|| color_eyre::eyre::eyre!("rollout path is missing a parent directory"))?; + std::fs::create_dir_all(parent)?; + + let session_meta = codex_protocol::protocol::SessionMeta { + id: thread_id, + timestamp: meta_rfc3339.to_string(), + cwd: cwd.to_path_buf(), + originator: "codex".to_string(), + cli_version: "0.0.0".to_string(), + source: codex_protocol::protocol::SessionSource::Cli, + model_provider: Some(model_provider.to_string()), + ..Default::default() + }; + let session_meta = serde_json::to_value(codex_protocol::protocol::SessionMetaLine { + meta: session_meta, + git: None, + })?; + let lines = [ + serde_json::json!({ + "timestamp": meta_rfc3339, + "type": "session_meta", + "payload": session_meta, + }) + .to_string(), + serde_json::json!({ + "timestamp": meta_rfc3339, + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": preview}], + }, + }) + .to_string(), + serde_json::json!({ + "timestamp": meta_rfc3339, + "type": "event_msg", + "payload": { + "type": "user_message", + "message": preview, + "kind": "plain", + }, + }) + .to_string(), + ]; + std::fs::write(&rollout_path, lines.join("\n") + "\n")?; + let updated_at = + chrono::DateTime::parse_from_rfc3339(meta_rfc3339)?.with_timezone(&chrono::Utc); + let times = std::fs::FileTimes::new().set_modified(updated_at.into()); + std::fs::OpenOptions::new() + .append(true) + .open(rollout_path)? + .set_times(times)?; + + Ok(thread_id) + } + #[test] fn startup_removes_legacy_tui_log_file() -> std::io::Result<()> { let temp_dir = TempDir::new()?; @@ -2328,13 +2430,27 @@ mod tests { &config, Some(cwd.as_path()), /*include_non_interactive*/ false, + LatestSessionLookupMode::StateDbOnly, ); - assert_eq!(params.model_providers, Some(vec![config.model_provider_id])); + assert_eq!( + params.model_providers, + Some(vec![config.model_provider_id.clone()]) + ); assert_eq!( params.cwd, Some(ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())) ); + assert!(params.use_state_db_only); + + let scan_params = latest_session_lookup_params( + /*uses_remote_workspace*/ false, + &config, + Some(cwd.as_path()), + /*include_non_interactive*/ false, + LatestSessionLookupMode::ScanAndRepair, + ); + assert!(!scan_params.use_state_db_only); Ok(()) } @@ -2355,6 +2471,7 @@ mod tests { &config, Some(cwd.as_path()), /*include_non_interactive*/ false, + LatestSessionLookupMode::StateDbOnly, ); assert_eq!(params.model_providers, Some(vec![config.model_provider_id])); @@ -2372,8 +2489,11 @@ mod tests { let config = build_config(&temp_dir).await?; let params = latest_session_lookup_params( - /*uses_remote_workspace*/ true, &config, /*cwd_filter*/ None, + /*uses_remote_workspace*/ true, + &config, + /*cwd_filter*/ None, /*include_non_interactive*/ false, + LatestSessionLookupMode::StateDbOnly, ); assert_eq!(params.model_providers, None); @@ -2388,8 +2508,11 @@ mod tests { let config = build_config(&temp_dir).await?; let params = latest_session_lookup_params( - /*uses_remote_workspace*/ true, &config, /*cwd_filter*/ None, + /*uses_remote_workspace*/ true, + &config, + /*cwd_filter*/ None, /*include_non_interactive*/ true, + LatestSessionLookupMode::StateDbOnly, ); assert_eq!( @@ -2416,6 +2539,7 @@ mod tests { &config, Some(cwd), /*include_non_interactive*/ false, + LatestSessionLookupMode::StateDbOnly, ); assert_eq!(params.model_providers, None); @@ -2455,85 +2579,6 @@ mod tests { #[tokio::test] async fn fork_last_filters_latest_session_by_cwd_unless_show_all() -> color_eyre::Result<()> { - fn write_session_rollout( - codex_home: &Path, - filename_ts: &str, - meta_rfc3339: &str, - preview: &str, - model_provider: &str, - cwd: &Path, - ) -> color_eyre::Result { - let uuid = Uuid::new_v4(); - let uuid_str = uuid.to_string(); - let thread_id = ThreadId::from_string(&uuid_str)?; - let year = &filename_ts[0..4]; - let month = &filename_ts[5..7]; - let day = &filename_ts[8..10]; - let rollout_path = codex_home - .join("sessions") - .join(year) - .join(month) - .join(day) - .join(format!("rollout-{filename_ts}-{uuid_str}.jsonl")); - let parent = rollout_path.parent().ok_or_else(|| { - color_eyre::eyre::eyre!("rollout path is missing a parent directory") - })?; - std::fs::create_dir_all(parent)?; - - let session_meta = codex_protocol::protocol::SessionMeta { - id: thread_id, - timestamp: meta_rfc3339.to_string(), - cwd: cwd.to_path_buf(), - originator: "codex".to_string(), - cli_version: "0.0.0".to_string(), - source: codex_protocol::protocol::SessionSource::Cli, - model_provider: Some(model_provider.to_string()), - ..Default::default() - }; - let session_meta = serde_json::to_value(codex_protocol::protocol::SessionMetaLine { - meta: session_meta, - git: None, - })?; - let lines = [ - serde_json::json!({ - "timestamp": meta_rfc3339, - "type": "session_meta", - "payload": session_meta, - }) - .to_string(), - serde_json::json!({ - "timestamp": meta_rfc3339, - "type": "response_item", - "payload": { - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": preview}], - }, - }) - .to_string(), - serde_json::json!({ - "timestamp": meta_rfc3339, - "type": "event_msg", - "payload": { - "type": "user_message", - "message": preview, - "kind": "plain", - }, - }) - .to_string(), - ]; - std::fs::write(&rollout_path, lines.join("\n") + "\n")?; - let updated_at = - chrono::DateTime::parse_from_rfc3339(meta_rfc3339)?.with_timezone(&chrono::Utc); - let times = std::fs::FileTimes::new().set_modified(updated_at.into()); - std::fs::OpenOptions::new() - .append(true) - .open(rollout_path)? - .set_times(times)?; - - Ok(thread_id) - } - let temp_dir = TempDir::new()?; let project_cwd = temp_dir.path().join("project"); let other_cwd = temp_dir.path().join("other-project"); @@ -2603,6 +2648,51 @@ mod tests { Ok(()) } + #[tokio::test] + async fn latest_session_lookup_falls_back_for_rollout_missing_from_state_db() + -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let project_cwd = temp_dir.path().join("project"); + std::fs::create_dir_all(&project_cwd)?; + let config = ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(project_cwd.clone()), + ..Default::default() + }) + .build() + .await?; + let mut app_server = AppServerSession::new( + codex_app_server_client::AppServerClient::InProcess( + start_test_embedded_app_server(config.clone()).await?, + ), + ThreadParamsMode::Embedded, + ); + + // Simulate a legacy writer creating a rollout after the state DB backfill completed. + let thread_id = write_session_rollout( + temp_dir.path(), + "2025-01-02T10-00-00", + "2025-01-02T10:00:00Z", + "legacy writer session", + config.model_provider_id.as_str(), + &project_cwd, + )?; + + let target = lookup_latest_session_target_with_app_server( + &mut app_server, + &config, + Some(project_cwd.as_path()), + /*include_non_interactive*/ false, + ) + .await? + .expect("expected scan-and-repair fallback to find the rollout"); + app_server.shutdown().await?; + + assert_eq!(target.thread_id, thread_id); + Ok(()) + } + #[tokio::test] async fn config_cwd_for_app_server_target_omits_cwd_for_remote_sessions() -> std::io::Result<()> { From 055c7a7c53d46517e95f24147980b4523da279db Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 5 Jun 2026 15:32:43 -0400 Subject: [PATCH 43/59] Speed up TUI startup by reusing plugin discovery (#26469) ## Summary TUI startup loads related plugin data from `hooks/list`, session MCP initialization, and plugin skill warmup. These paths repeated filesystem discovery and emitted the same plugin warnings, while `hooks/list` and account/model bootstrap ran serially. This change: - Reuses one immutable plugin load outcome across startup consumers. - Keys the cache only on plugin-relevant configuration. - Single-flights concurrent plugin loads and prevents invalidated loads from repopulating the cache. - Runs hook discovery and account/model bootstrap concurrently. - Preserves configuration-migration ordering, hook review behavior, and accurate startup telemetry. In 10 alternating release-build launches in the Ruff repository with the existing `~/.codex` configuration, median time to the first editable composer decreased from 833ms to 504ms. The branch was faster in 9 of 10 pairs, with a paired median improvement of 312ms. --- .../request_processors/catalog_processor.rs | 8 +- .../app-server/tests/suite/v2/hooks_list.rs | 78 ++++++++++++++++ codex-rs/core-plugins/src/manager.rs | 86 ++++++++++++++---- codex-rs/core-plugins/src/manager_tests.rs | 91 +++++++++++++++++++ codex-rs/tui/src/app.rs | 13 ++- codex-rs/tui/src/app_server_session.rs | 5 + ...external_agent_config_migration_startup.rs | 2 +- codex-rs/tui/src/lib.rs | 26 ++++++ codex-rs/tui/src/startup_hooks_review.rs | 31 +++++-- 9 files changed, 303 insertions(+), 37 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index bbba0bae6210..9c0cc1c8b517 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -647,9 +647,11 @@ impl CatalogRequestProcessor { config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled; let plugin_hooks = if plugins_enabled { let plugins_input = config.plugins_config_input(); - plugins_manager - .plugin_hooks_for_layer_stack(&config.config_layer_stack, &plugins_input) - .await + let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await; + codex_core_plugins::PluginHookLoadOutcome { + hook_sources: plugin_outcome.effective_plugin_hook_sources(), + hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(), + } } else { codex_core_plugins::PluginHookLoadOutcome::default() }; diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index b7270f317edb..1fd1340a6c47 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -264,6 +264,84 @@ async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> { Ok(()) } +#[tokio::test] +async fn hooks_list_warms_plugin_capabilities_for_thread_start() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + write_plugin_hook_config( + codex_home.path(), + r#"{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "echo plugin hook" + } + ] + } + ] + } +}"#, + )?; + let plugin_mcp_path = codex_home + .path() + .join("plugins/cache/test/demo/local/.mcp.json"); + std::fs::write( + &plugin_mcp_path, + r#"{ + "mcpServers": { + "plugin-server": { + "url": "http://127.0.0.1:1/mcp" + } + } +}"#, + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let hooks_list_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![cwd.path().to_path_buf()], + }) + .await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(hooks_list_id)), + ) + .await??; + + std::fs::remove_file(plugin_mcp_path)?; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let _: ThreadStartResponse = to_response( + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??, + )?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_matching_notification("plugin MCP server starting", |notification| { + notification.method == "mcpServer/startupStatus/updated" + && notification + .params + .as_ref() + .and_then(|params| params.get("name")) + .and_then(serde_json::Value::as_str) + == Some("plugin-server") + }), + ) + .await??; + + Ok(()) +} + #[tokio::test] async fn hooks_list_shows_plugin_hook_load_warnings() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 08f668d13074..845b8f3f321a 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -57,8 +57,9 @@ use codex_config::apply_user_plugin_config_edits; use codex_config::clear_user_plugin; use codex_config::set_user_plugin_enabled; use codex_config::types::PluginConfig; -use codex_config::version_for_toml; use codex_core_skills::SkillMetadata; +use codex_core_skills::config_rules::SkillConfigRules; +use codex_core_skills::config_rules::skill_config_rules_from_stack; use codex_hooks::plugin_hook_declarations; use codex_login::AuthManager; use codex_login::CodexAuth; @@ -403,7 +404,8 @@ pub struct PluginsManager { featured_plugin_ids_cache: RwLock>, configured_marketplace_upgrade_state: RwLock, non_curated_cache_refresh_state: RwLock, - cached_enabled_outcome: RwLock>, + enabled_outcome_cache: RwLock, + enabled_outcome_load_semaphore: Semaphore, remote_installed_plugins_cache: RwLock>>, remote_installed_plugins_cache_refresh_state: RwLock, remote_sync_lock: Semaphore, @@ -413,10 +415,23 @@ pub struct PluginsManager { #[derive(Clone)] struct CachedPluginLoadOutcome { - config_version: String, + key: PluginLoadCacheKey, outcome: PluginLoadOutcome, } +#[derive(Default)] +struct EnabledOutcomeCache { + generation: u64, + outcome: Option, +} + +#[derive(Clone, PartialEq, Eq)] +struct PluginLoadCacheKey { + configured_plugins: HashMap, + skill_config_rules: SkillConfigRules, + remote_plugin_enabled: bool, +} + impl PluginsManager { pub fn new(codex_home: PathBuf) -> Self { Self::new_with_restriction_product(codex_home, Some(Product::Codex)) @@ -441,7 +456,8 @@ impl PluginsManager { ConfiguredMarketplaceUpgradeState::default(), ), non_curated_cache_refresh_state: RwLock::new(NonCuratedCacheRefreshState::default()), - cached_enabled_outcome: RwLock::new(None), + enabled_outcome_cache: RwLock::new(EnabledOutcomeCache::default()), + enabled_outcome_load_semaphore: Semaphore::new(/*permits*/ 1), remote_installed_plugins_cache: RwLock::new(None), remote_installed_plugins_cache_refresh_state: RwLock::new( RemoteInstalledPluginsCacheRefreshState::default(), @@ -484,11 +500,23 @@ impl PluginsManager { return PluginLoadOutcome::default(); } - let config_version = version_for_toml(&config.config_layer_stack.effective_config()); - if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&config_version) { + let cache_key = PluginLoadCacheKey { + configured_plugins: configured_plugins_from_stack(&config.config_layer_stack), + skill_config_rules: skill_config_rules_from_stack(&config.config_layer_stack), + remote_plugin_enabled: config.remote_plugin_enabled, + }; + if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&cache_key) { return outcome; } + let Ok(_load_permit) = self.enabled_outcome_load_semaphore.acquire().await else { + warn!("plugin load semaphore closed"); + return PluginLoadOutcome::default(); + }; + if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&cache_key) { + return outcome; + } + let cache_generation = self.enabled_outcome_cache_generation(); let outcome = load_plugins_from_layer_stack( &config.config_layer_stack, self.remote_installed_plugin_configs(), @@ -498,14 +526,7 @@ impl PluginsManager { ) .await; log_plugin_load_errors(&outcome); - let mut cache = match self.cached_enabled_outcome.write() { - Ok(cache) => cache, - Err(err) => err.into_inner(), - }; - *cache = Some(CachedPluginLoadOutcome { - config_version, - outcome: outcome.clone(), - }); + self.cache_enabled_outcome_if_current(cache_generation, cache_key, outcome.clone()); outcome } @@ -519,11 +540,12 @@ impl PluginsManager { } fn clear_enabled_outcome_cache(&self) { - let mut cached_enabled_outcome = match self.cached_enabled_outcome.write() { + let mut cache = match self.enabled_outcome_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), }; - *cached_enabled_outcome = None; + cache.generation = cache.generation.wrapping_add(1); + cache.outcome = None; } /// Load plugins for a config layer stack without touching the plugins cache. @@ -574,20 +596,44 @@ impl PluginsManager { .effective_plugin_skill_roots() } - fn cached_enabled_outcome(&self, config_version: &str) -> Option { - match self.cached_enabled_outcome.read() { + fn cached_enabled_outcome(&self, key: &PluginLoadCacheKey) -> Option { + match self.enabled_outcome_cache.read() { Ok(cache) => cache + .outcome .as_ref() - .filter(|cached| cached.config_version == config_version) + .filter(|cached| cached.key == *key) .map(|cached| cached.outcome.clone()), Err(err) => err .into_inner() + .outcome .as_ref() - .filter(|cached| cached.config_version == config_version) + .filter(|cached| cached.key == *key) .map(|cached| cached.outcome.clone()), } } + fn enabled_outcome_cache_generation(&self) -> u64 { + match self.enabled_outcome_cache.read() { + Ok(cache) => cache.generation, + Err(err) => err.into_inner().generation, + } + } + + fn cache_enabled_outcome_if_current( + &self, + generation: u64, + key: PluginLoadCacheKey, + outcome: PluginLoadOutcome, + ) { + let mut cache = match self.enabled_outcome_cache.write() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + if cache.generation == generation { + cache.outcome = Some(CachedPluginLoadOutcome { key, outcome }); + } + } + fn remote_installed_plugin_configs(&self) -> HashMap { let cache = match self.remote_installed_plugins_cache.read() { Ok(cache) => cache, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index d6c13b1fe0b2..f036ed40a5a0 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -1300,6 +1300,97 @@ async fn load_plugins_returns_empty_when_feature_disabled() { assert_eq!(outcome, PluginLoadOutcome::default()); } +#[tokio::test] +async fn plugin_cache_ignores_unrelated_session_overrides() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + write_plugin( + codex_home.path().join("plugins/cache/test").as_path(), + "sample/local", + "sample", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "url": "https://sample.example/mcp" + } + } +}"#, + ); + + let user_file = codex_home.path().join(CONFIG_TOML_FILE).abs(); + let user_config: toml::Value = toml::from_str(&plugin_config_toml( + /*enabled*/ true, /*plugins_feature_enabled*/ true, + )) + .expect("user config should parse"); + let stack = |session_config: &str| { + ConfigLayerStack::new( + vec![ + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: user_file.clone(), + profile: None, + }, + user_config.clone(), + ), + ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str(session_config).expect("session config should parse"), + ), + ], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack should build") + }; + let config = |session_config| { + PluginsConfigInput::new( + stack(session_config), + /*plugins_enabled*/ true, + /*remote_plugin_enabled*/ false, + "https://chatgpt.com".to_string(), + ) + }; + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let first = manager + .plugins_for_config(&config(r#"model = "first""#)) + .await; + std::fs::remove_file(plugin_root.join(".mcp.json")).unwrap(); + let second = manager + .plugins_for_config(&config(r#"model = "second""#)) + .await; + + assert_eq!(second, first); + assert_eq!(second.plugins()[0].mcp_servers.len(), 1); +} + +#[test] +fn plugin_cache_invalidation_rejects_stale_load_completion() { + let codex_home = TempDir::new().unwrap(); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let cache_key = PluginLoadCacheKey { + configured_plugins: HashMap::new(), + skill_config_rules: SkillConfigRules::default(), + remote_plugin_enabled: false, + }; + let stale_generation = manager.enabled_outcome_cache_generation(); + + manager.clear_enabled_outcome_cache(); + manager.cache_enabled_outcome_if_current( + stale_generation, + cache_key.clone(), + PluginLoadOutcome::default(), + ); + + assert_eq!(manager.cached_enabled_outcome(&cache_key), None); +} + #[tokio::test] async fn load_plugins_rejects_invalid_plugin_keys() { let codex_home = TempDir::new().unwrap(); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b1245db3ec35..aae2cba95468 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -16,6 +16,7 @@ use crate::app_event::RealtimeAudioDeviceKind; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; use crate::app_event_sender::AppEventSender; +use crate::app_server_session::AppServerBootstrap; use crate::app_server_session::AppServerSession; use crate::app_server_session::AppServerStartedThread; use crate::app_server_session::TurnPermissionsOverride; @@ -727,6 +728,8 @@ impl App { app_server_target: AppServerTarget, state_db: Option, environment_manager: Arc, + startup_elapsed_before_app: Duration, + startup_bootstrap: Option, startup_hooks_browser: Option, ) -> Result { use tokio_stream::StreamExt; @@ -774,9 +777,11 @@ impl App { }); } }; - let bootstrap_started_at = Instant::now(); - let bootstrap = app_server.bootstrap(&config).await?; - let bootstrap_ms = bootstrap_started_at.elapsed().as_millis(); + let bootstrap = match startup_bootstrap { + Some(bootstrap) => bootstrap, + None => app_server.bootstrap(&config).await?, + }; + let bootstrap_ms = bootstrap.duration.as_millis(); let mut model = bootstrap.default_model; let available_models = bootstrap.available_models; let remote_connection = crate::status::remote_connection::remote_connection_status_value( @@ -1085,7 +1090,7 @@ See the Codex keymap documentation for supported actions and examples." tui.frame_requester().schedule_frame(); tracing::info!( - duration_ms = %startup_started_at.elapsed().as_millis(), + duration_ms = %(startup_elapsed_before_app + startup_started_at.elapsed()).as_millis(), bootstrap_ms = %bootstrap_ms, runtime_model_provider_ms = %runtime_model_provider_ms, thread_and_widget_ms = %thread_and_widget_ms, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 438ee21ca59f..a04faa526187 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -127,6 +127,8 @@ use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use std::collections::HashMap; use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; use uuid::Uuid; const JSONRPC_INVALID_REQUEST: i64 = -32600; @@ -150,6 +152,7 @@ fn is_thread_settings_update_unsupported(source: &JSONRPCErrorError) -> bool { /// fetched asynchronously after bootstrap returns so that the TUI can render /// its first frame without waiting for the rate-limit round-trip. pub(crate) struct AppServerBootstrap { + pub(crate) duration: Duration, pub(crate) account_email: Option, pub(crate) auth_mode: Option, pub(crate) status_account_display: Option, @@ -239,6 +242,7 @@ impl AppServerSession { } pub(crate) async fn bootstrap(&mut self, config: &Config) -> Result { + let started_at = Instant::now(); let account = self.read_account().await?; let model_request_id = self.next_request_id(); let models: ModelListResponse = self @@ -314,6 +318,7 @@ impl AppServerSession { None => (None, None, None, None, FeedbackAudience::External, false), }; Ok(AppServerBootstrap { + duration: started_at.elapsed(), account_email, auth_mode, status_account_display, diff --git a/codex-rs/tui/src/external_agent_config_migration_startup.rs b/codex-rs/tui/src/external_agent_config_migration_startup.rs index 3ec2ca390337..ce184303c273 100644 --- a/codex-rs/tui/src/external_agent_config_migration_startup.rs +++ b/codex-rs/tui/src/external_agent_config_migration_startup.rs @@ -25,7 +25,7 @@ pub(crate) enum ExternalAgentConfigMigrationStartupOutcome { ExitRequested, } -fn should_show_external_agent_config_migration_prompt( +pub(crate) fn should_show_external_agent_config_migration_prompt( config: &Config, entered_trust_nux: bool, ) -> bool { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index ebaccf08fc24..5e83c0746117 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -71,6 +71,7 @@ use std::fs::OpenOptions; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::time::Instant; pub use token_usage::TokenUsage; use tracing::Level; use tracing::error; @@ -276,6 +277,7 @@ pub(crate) mod test_support; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::onboarding::onboarding_screen::run_onboarding_app; use crate::startup_hooks_review::StartupHooksReviewOutcome; +use crate::startup_hooks_review::load_startup_hooks_review_entry; use crate::startup_hooks_review::maybe_run_startup_hooks_review; use crate::tui::Tui; pub use cli::Cli; @@ -1804,11 +1806,33 @@ async fn run_ratatui_app( resume_picker::SessionSelection::Resume(_) ); let bypass_hook_trust_for_startup_review = config.bypass_hook_trust && !is_persistent_resume; + let hooks_request_handle = app_server.request_handle(); + let hooks_cwd = config.cwd.to_path_buf(); + let startup_prefetch_started_at = Instant::now(); + let should_defer_bootstrap = + external_agent_config_migration_startup::should_show_external_agent_config_migration_prompt( + &config, + should_show_trust_screen_flag, + ); + let (startup_bootstrap, startup_hooks_entry) = if should_defer_bootstrap { + ( + None, + load_startup_hooks_review_entry(hooks_request_handle, hooks_cwd).await, + ) + } else { + let (bootstrap, entry) = tokio::join!( + app_server.bootstrap(&config), + load_startup_hooks_review_entry(hooks_request_handle, hooks_cwd), + ); + (Some(bootstrap?), entry) + }; + let startup_elapsed_before_app = startup_prefetch_started_at.elapsed(); let startup_hooks_browser = match maybe_run_startup_hooks_review( &mut app_server, &mut tui, &config, bypass_hook_trust_for_startup_review, + startup_hooks_entry, ) .await? { @@ -1833,6 +1857,8 @@ async fn run_ratatui_app( app_server_target, state_db, environment_manager, + startup_elapsed_before_app, + startup_bootstrap, startup_hooks_browser, ) .await; diff --git a/codex-rs/tui/src/startup_hooks_review.rs b/codex-rs/tui/src/startup_hooks_review.rs index 67d6133a105f..97c53f714446 100644 --- a/codex-rs/tui/src/startup_hooks_review.rs +++ b/codex-rs/tui/src/startup_hooks_review.rs @@ -31,7 +31,9 @@ use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::tui::Tui; use crate::tui::TuiEvent; +use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::HooksListEntry; +use std::path::PathBuf; pub(crate) enum StartupHooksReviewOutcome { Continue, @@ -45,21 +47,32 @@ enum StartupHooksReviewSelection { ContinueWithoutTrusting, } +pub(crate) async fn load_startup_hooks_review_entry( + request_handle: AppServerRequestHandle, + cwd: PathBuf, +) -> HooksListEntry { + let response = match fetch_hooks_list(request_handle, cwd.clone()).await { + Ok(response) => response, + Err(err) => { + tracing::warn!("failed to load startup hook review state: {err:#}"); + return HooksListEntry { + cwd, + hooks: Vec::new(), + warnings: Vec::new(), + errors: Vec::new(), + }; + } + }; + hooks_list_entry_for_cwd(response, &cwd) +} + pub(crate) async fn maybe_run_startup_hooks_review( app_server: &mut AppServerSession, tui: &mut Tui, config: &Config, bypass_hook_trust: bool, + entry: HooksListEntry, ) -> Result { - let cwd = config.cwd.to_path_buf(); - let response = match fetch_hooks_list(app_server.request_handle(), cwd.clone()).await { - Ok(response) => response, - Err(err) => { - tracing::warn!("failed to load startup hook review state: {err:#}"); - return Ok(StartupHooksReviewOutcome::Continue); - } - }; - let entry = hooks_list_entry_for_cwd(response, &cwd); if !review_is_needed(bypass_hook_trust, &entry) { return Ok(StartupHooksReviewOutcome::Continue); } From bb7d19bc245b9661fbf2391a2d02d9d9139c4fee Mon Sep 17 00:00:00 2001 From: mpc-oai Date: Fri, 5 Jun 2026 14:40:31 -0500 Subject: [PATCH 44/59] Add JSON output for plugin subcommands (#26631) ## Summary - Follow-up to #25330 and #26417 - Add `--json` output for `codex plugin add` and `codex plugin remove` - Add `--json` output for `codex plugin marketplace add/list/upgrade/remove` - Keep existing human-readable output unchanged - Keep existing error handling/stderr behavior unchanged; `--json` changes successful stdout output only - Align marketplace add/remove JSON field names with the existing app-server protocol shape - Add CLI coverage for plugin and marketplace JSON outputs ## Validation - `just fmt` - `just fix -p codex-cli` - `just test -p codex-cli` --- codex-rs/cli/src/marketplace_cmd.rs | 186 +++++++++++++++++++++- codex-rs/cli/src/plugin_cmd.rs | 81 +++++++++- codex-rs/cli/tests/marketplace_add.rs | 37 +++++ codex-rs/cli/tests/marketplace_remove.rs | 29 ++++ codex-rs/cli/tests/marketplace_upgrade.rs | 25 +++ codex-rs/cli/tests/plugin_cli.rs | 89 +++++++++++ 6 files changed, 438 insertions(+), 9 deletions(-) diff --git a/codex-rs/cli/src/marketplace_cmd.rs b/codex-rs/cli/src/marketplace_cmd.rs index 95dba06f17be..fc4d9eef6206 100644 --- a/codex-rs/cli/src/marketplace_cmd.rs +++ b/codex-rs/cli/src/marketplace_cmd.rs @@ -7,11 +7,14 @@ use codex_core::config::find_codex_home; use codex_core_plugins::PluginMarketplaceUpgradeOutcome; use codex_core_plugins::PluginsManager; use codex_core_plugins::marketplace::marketplace_root_dir; +use codex_core_plugins::marketplace_add::MarketplaceAddOutcome; use codex_core_plugins::marketplace_add::MarketplaceAddRequest; use codex_core_plugins::marketplace_add::add_marketplace; +use codex_core_plugins::marketplace_remove::MarketplaceRemoveOutcome; use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest; use codex_core_plugins::marketplace_remove::remove_marketplace; use codex_utils_cli::CliConfigOverrides; +use serde::Serialize; use std::collections::HashSet; use crate::plugin_cmd::configured_marketplace_snapshot_issues; @@ -32,7 +35,7 @@ enum MarketplaceSubcommand { Add(AddMarketplaceArgs), /// List plugin marketplaces Codex is currently considering and their roots. - List, + List(ListMarketplaceArgs), /// Refresh configured Git marketplace snapshots. /// @@ -64,6 +67,18 @@ struct AddMarketplaceArgs { action = clap::ArgAction::Append )] sparse_paths: Vec, + + /// Output add result as JSON. + #[arg(long = "json")] + json: bool, +} + +#[derive(Debug, Parser)] +#[command(bin_name = "codex plugin marketplace list")] +struct ListMarketplaceArgs { + /// Output marketplace list as JSON. + #[arg(long = "json")] + json: bool, } #[derive(Debug, Parser)] @@ -75,6 +90,10 @@ struct UpgradeMarketplaceArgs { /// Optional configured marketplace name to upgrade. Omit to upgrade all Git marketplaces. #[arg(value_name = "MARKETPLACE_NAME")] marketplace_name: Option, + + /// Output upgrade result as JSON. + #[arg(long = "json")] + json: bool, } #[derive(Debug, Parser)] @@ -86,6 +105,10 @@ struct RemoveMarketplaceArgs { /// Configured marketplace name to remove. #[arg(value_name = "MARKETPLACE_NAME")] marketplace_name: String, + + /// Output remove result as JSON. + #[arg(long = "json")] + json: bool, } impl MarketplaceCli { @@ -101,7 +124,7 @@ impl MarketplaceCli { match subcommand { MarketplaceSubcommand::Add(args) => run_add(args).await?, - MarketplaceSubcommand::List => run_list(overrides).await?, + MarketplaceSubcommand::List(args) => run_list(overrides, args).await?, MarketplaceSubcommand::Upgrade(args) => run_upgrade(overrides, args).await?, MarketplaceSubcommand::Remove(args) => run_remove(args).await?, } @@ -115,6 +138,7 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> { source, ref_name, sparse_paths, + json, } = args; let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; @@ -128,6 +152,12 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> { ) .await?; + if json { + let output = JsonMarketplaceAddOutput::from_outcome(outcome); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + if outcome.already_added { println!( "Marketplace `{}` is already added from {}.", @@ -147,7 +177,25 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> { Ok(()) } -async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> { +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceAddOutput { + marketplace_name: String, + installed_root: String, + already_added: bool, +} + +impl JsonMarketplaceAddOutput { + fn from_outcome(outcome: MarketplaceAddOutcome) -> Self { + Self { + marketplace_name: outcome.marketplace_name, + installed_root: outcome.installed_root.as_path().display().to_string(), + already_added: outcome.already_added, + } + } +} + +async fn run_list(overrides: Vec<(String, toml::Value)>, args: ListMarketplaceArgs) -> Result<()> { let config = Config::load_with_cli_overrides(overrides) .await .context("failed to load configuration")?; @@ -191,6 +239,12 @@ async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> { bail!("failed to load marketplace(s):\n{issue_lines}"); } let marketplaces = marketplace_listing.marketplaces; + if args.json { + let output = JsonMarketplaceListOutput::from_marketplaces(marketplaces); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + if marketplaces.is_empty() { println!("No plugin marketplaces in scope."); return Ok(()); @@ -227,11 +281,48 @@ async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> { Ok(()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceListOutput { + marketplaces: Vec, +} + +impl JsonMarketplaceListOutput { + fn from_marketplaces(marketplaces: Vec) -> Self { + let mut seen_roots = HashSet::new(); + let marketplaces = marketplaces + .into_iter() + .filter_map(|marketplace| { + let root = marketplace_root_dir(&marketplace.path).ok()?; + if !seen_roots.insert(root.clone()) { + return None; + } + Some(JsonMarketplaceListEntry { + name: marketplace.name, + root: root.display().to_string(), + }) + }) + .collect(); + + Self { marketplaces } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceListEntry { + name: String, + root: String, +} + async fn run_upgrade( overrides: Vec<(String, toml::Value)>, args: UpgradeMarketplaceArgs, ) -> Result<()> { - let UpgradeMarketplaceArgs { marketplace_name } = args; + let UpgradeMarketplaceArgs { + marketplace_name, + json, + } = args; let config = Config::load_with_cli_overrides(overrides) .await .context("failed to load configuration")?; @@ -241,11 +332,18 @@ async fn run_upgrade( let outcome = manager .upgrade_configured_marketplaces_for_config(&plugins_input, marketplace_name.as_deref()) .map_err(anyhow::Error::msg)?; - print_upgrade_outcome(&outcome, marketplace_name.as_deref()) + if json { + print_upgrade_outcome_json(&outcome) + } else { + print_upgrade_outcome(&outcome, marketplace_name.as_deref()) + } } async fn run_remove(args: RemoveMarketplaceArgs) -> Result<()> { - let RemoveMarketplaceArgs { marketplace_name } = args; + let RemoveMarketplaceArgs { + marketplace_name, + json, + } = args; let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; let outcome = remove_marketplace( codex_home.to_path_buf(), @@ -253,6 +351,12 @@ async fn run_remove(args: RemoveMarketplaceArgs) -> Result<()> { ) .await?; + if json { + let output = JsonMarketplaceRemoveOutput::from_outcome(outcome); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + println!("Removed marketplace `{}`.", outcome.marketplace_name); if let Some(installed_root) = outcome.removed_installed_root { println!( @@ -264,6 +368,76 @@ async fn run_remove(args: RemoveMarketplaceArgs) -> Result<()> { Ok(()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceRemoveOutput { + marketplace_name: String, + installed_root: Option, +} + +impl JsonMarketplaceRemoveOutput { + fn from_outcome(outcome: MarketplaceRemoveOutcome) -> Self { + Self { + marketplace_name: outcome.marketplace_name, + installed_root: outcome + .removed_installed_root + .map(|root| root.as_path().display().to_string()), + } + } +} + +fn print_upgrade_outcome_json(outcome: &PluginMarketplaceUpgradeOutcome) -> Result<()> { + for error in &outcome.errors { + eprintln!( + "Failed to upgrade marketplace `{}`: {}", + error.marketplace_name, error.message + ); + } + if !outcome.all_succeeded() { + bail!("{} upgrade failure(s) occurred.", outcome.errors.len()); + } + + let output = JsonMarketplaceUpgradeOutput::from_outcome(outcome); + println!("{}", serde_json::to_string_pretty(&output)?); + Ok(()) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceUpgradeOutput { + selected_marketplaces: Vec, + upgraded_roots: Vec, + errors: Vec, +} + +impl JsonMarketplaceUpgradeOutput { + fn from_outcome(outcome: &PluginMarketplaceUpgradeOutcome) -> Self { + Self { + selected_marketplaces: outcome.selected_marketplaces.clone(), + upgraded_roots: outcome + .upgraded_roots + .iter() + .map(|root| root.display().to_string()) + .collect(), + errors: outcome + .errors + .iter() + .map(|error| JsonMarketplaceUpgradeError { + marketplace_name: error.marketplace_name.clone(), + message: error.message.clone(), + }) + .collect(), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceUpgradeError { + marketplace_name: String, + message: String, +} + fn print_upgrade_outcome( outcome: &PluginMarketplaceUpgradeOutcome, marketplace_name: Option<&str>, diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index c850556e4d47..7def7283c0fd 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -6,6 +6,7 @@ use codex_core::config::Config; use codex_core::config::find_codex_home; use codex_core_plugins::ConfiguredMarketplace; use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; +use codex_core_plugins::PluginInstallOutcome; use codex_core_plugins::PluginInstallRequest; use codex_core_plugins::PluginsConfigInput; use codex_core_plugins::PluginsManager; @@ -73,6 +74,10 @@ pub struct AddPluginArgs { /// Configured marketplace name to use when PLUGIN does not include @MARKETPLACE. #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] marketplace_name: Option, + + /// Output install result as JSON. + #[arg(long = "json")] + json: bool, } #[derive(Debug, Parser)] @@ -107,6 +112,10 @@ pub struct RemovePluginArgs { /// Marketplace name to use when PLUGIN does not include @MARKETPLACE. #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] marketplace_name: Option, + + /// Output remove result as JSON. + #[arg(long = "json")] + json: bool, } pub async fn run_plugin_add( @@ -118,11 +127,16 @@ pub async fn run_plugin_add( plugins_input, manager, } = load_plugin_command_context(overrides).await?; + let AddPluginArgs { + plugin, + marketplace_name, + json, + } = args; let PluginSelection { plugin_name, marketplace_name, .. - } = parse_plugin_selection(args.plugin, args.marketplace_name)?; + } = parse_plugin_selection(plugin, marketplace_name)?; let marketplace = find_marketplace_for_plugin( &manager, codex_home.as_path(), @@ -137,6 +151,12 @@ pub async fn run_plugin_add( }) .await?; + if json { + let output = JsonPluginAddOutput::from_outcome(outcome); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + println!( "Added plugin `{}` from marketplace `{}`.", outcome.plugin_id.plugin_name, outcome.plugin_id.marketplace_name @@ -149,6 +169,30 @@ pub async fn run_plugin_add( Ok(()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonPluginAddOutput { + plugin_id: String, + name: String, + marketplace_name: String, + version: String, + installed_path: String, + auth_policy: &'static str, +} + +impl JsonPluginAddOutput { + fn from_outcome(outcome: PluginInstallOutcome) -> Self { + Self { + plugin_id: outcome.plugin_id.as_key(), + name: outcome.plugin_id.plugin_name, + marketplace_name: outcome.plugin_id.marketplace_name, + version: outcome.plugin_version, + installed_path: outcome.installed_path.as_path().display().to_string(), + auth_policy: auth_policy_label(outcome.auth_policy), + } + } +} + pub async fn run_plugin_list( overrides: Vec<(String, toml::Value)>, args: ListPluginsArgs, @@ -449,9 +493,22 @@ pub async fn run_plugin_remove( args: RemovePluginArgs, ) -> Result<()> { let PluginCommandContext { manager, .. } = load_plugin_command_context(overrides).await?; - let selection = parse_plugin_selection(args.plugin, args.marketplace_name)?; + let RemovePluginArgs { + plugin, + marketplace_name, + json, + } = args; + let selection = parse_plugin_selection(plugin, marketplace_name)?; + + manager + .uninstall_plugin(selection.plugin_key.clone()) + .await?; + if json { + let output = JsonPluginRemoveOutput::from_selection(selection); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } - manager.uninstall_plugin(selection.plugin_key).await?; println!( "Removed plugin `{}` from marketplace `{}`.", selection.plugin_name, selection.marketplace_name @@ -460,6 +517,24 @@ pub async fn run_plugin_remove( Ok(()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonPluginRemoveOutput { + plugin_id: String, + name: String, + marketplace_name: String, +} + +impl JsonPluginRemoveOutput { + fn from_selection(selection: PluginSelection) -> Self { + Self { + plugin_id: selection.plugin_key, + name: selection.plugin_name, + marketplace_name: selection.marketplace_name, + } + } +} + struct PluginCommandContext { codex_home: PathBuf, plugins_input: PluginsConfigInput, diff --git a/codex-rs/cli/tests/marketplace_add.rs b/codex-rs/cli/tests/marketplace_add.rs index 5ab18e24c48f..0439846a30fc 100644 --- a/codex-rs/cli/tests/marketplace_add.rs +++ b/codex-rs/cli/tests/marketplace_add.rs @@ -1,8 +1,10 @@ use anyhow::Result; use codex_config::CONFIG_TOML_FILE; use codex_core_plugins::installed_marketplaces::marketplace_install_root; +use codex_utils_absolute_path::AbsolutePathBuf; use predicates::str::contains; use pretty_assertions::assert_eq; +use serde_json::json; use std::path::Path; use tempfile::TempDir; @@ -70,6 +72,41 @@ async fn marketplace_add_local_directory_source() -> Result<()> { Ok(()) } +#[tokio::test] +async fn marketplace_add_json_prints_add_outcome() -> Result<()> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + write_marketplace_source(source.path(), "local ref")?; + let source_parent = source.path().parent().unwrap(); + let source_arg = format!("./{}", source.path().file_name().unwrap().to_string_lossy()); + + let assert = codex_command(codex_home.path())? + .current_dir(source_parent) + .args([ + "plugin", + "marketplace", + "add", + source_arg.as_str(), + "--json", + ]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + let expected_installed_root = AbsolutePathBuf::try_from(source.path().canonicalize()?)?; + + assert_eq!( + actual, + json!({ + "marketplaceName": "debug", + "installedRoot": expected_installed_root.as_path().display().to_string(), + "alreadyAdded": false, + }) + ); + + Ok(()) +} + #[tokio::test] async fn marketplace_add_rejects_local_manifest_file_source() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cli/tests/marketplace_remove.rs b/codex-rs/cli/tests/marketplace_remove.rs index 5c8c7a1f9164..b124574106f6 100644 --- a/codex-rs/cli/tests/marketplace_remove.rs +++ b/codex-rs/cli/tests/marketplace_remove.rs @@ -2,7 +2,10 @@ use anyhow::Result; use codex_config::MarketplaceConfigUpdate; use codex_config::record_user_marketplace; use codex_core_plugins::installed_marketplaces::marketplace_install_root; +use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks; use predicates::str::contains; +use pretty_assertions::assert_eq; +use serde_json::json; use std::path::Path; use tempfile::TempDir; @@ -54,6 +57,32 @@ async fn marketplace_remove_deletes_config_and_installed_root() -> Result<()> { Ok(()) } +#[tokio::test] +async fn marketplace_remove_json_prints_remove_outcome() -> Result<()> { + let codex_home = TempDir::new()?; + record_user_marketplace(codex_home.path(), "debug", &configured_marketplace_update())?; + write_installed_marketplace(codex_home.path(), "debug")?; + let installed_root = marketplace_install_root(codex_home.path()).join("debug"); + let normalized_installed_root = canonicalize_existing_preserving_symlinks(&installed_root)?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "marketplace", "remove", "debug", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "marketplaceName": "debug", + "installedRoot": normalized_installed_root.display().to_string(), + }) + ); + + Ok(()) +} + #[tokio::test] async fn marketplace_remove_rejects_unknown_marketplace() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cli/tests/marketplace_upgrade.rs b/codex-rs/cli/tests/marketplace_upgrade.rs index 268d75358e92..a2cf11f4af39 100644 --- a/codex-rs/cli/tests/marketplace_upgrade.rs +++ b/codex-rs/cli/tests/marketplace_upgrade.rs @@ -1,5 +1,7 @@ use anyhow::Result; use predicates::str::contains; +use pretty_assertions::assert_eq; +use serde_json::json; use std::path::Path; use tempfile::TempDir; @@ -22,6 +24,29 @@ async fn marketplace_upgrade_runs_under_plugin() -> Result<()> { Ok(()) } +#[tokio::test] +async fn marketplace_upgrade_json_prints_upgrade_outcome() -> Result<()> { + let codex_home = TempDir::new()?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "marketplace", "upgrade", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "selectedMarketplaces": [], + "upgradedRoots": [], + "errors": [], + }) + ); + + Ok(()) +} + #[tokio::test] async fn marketplace_upgrade_no_longer_runs_at_top_level() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cli/tests/plugin_cli.rs b/codex-rs/cli/tests/plugin_cli.rs index 2625e0b68b28..cda79cf9a180 100644 --- a/codex-rs/cli/tests/plugin_cli.rs +++ b/codex-rs/cli/tests/plugin_cli.rs @@ -338,6 +338,32 @@ async fn marketplace_list_shows_configured_marketplace_names() -> Result<()> { Ok(()) } +#[tokio::test] +async fn marketplace_list_json_prints_configured_marketplaces() -> Result<()> { + let (codex_home, source) = setup_local_marketplace()?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "marketplace", "list", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "marketplaces": [ + { + "name": "debug", + "root": source.path().display().to_string(), + }, + ], + }) + ); + + Ok(()) +} + #[tokio::test] async fn marketplace_list_includes_home_marketplace_when_present() -> Result<()> { let codex_home = TempDir::new()?; @@ -802,6 +828,69 @@ async fn plugin_add_and_remove_updates_installed_plugin_config() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_add_json_prints_install_outcome() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + let installed_path = codex_home.path().join("plugins/cache/debug/sample/1.2.3"); + let normalized_installed_path = canonicalize_existing_preserving_symlinks(&installed_path)?; + + assert_eq!( + actual, + json!({ + "pluginId": "sample@debug", + "name": "sample", + "marketplaceName": "debug", + "version": "1.2.3", + "installedPath": normalized_installed_path.display().to_string(), + "authPolicy": "ON_INSTALL", + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_remove_json_prints_remove_outcome() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .success(); + + let assert = codex_command(codex_home.path())? + .args([ + "plugin", + "remove", + "sample", + "--marketplace", + "debug", + "--json", + ]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "pluginId": "sample@debug", + "name": "sample", + "marketplaceName": "debug", + }) + ); + + Ok(()) +} + #[tokio::test] async fn plugin_add_rejects_unconfigured_repo_local_marketplaces() -> Result<()> { let (codex_home, source) = setup_unconfigured_local_marketplace()?; From 679cc084451e2111ad946d0befe583b79e486ca9 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Fri, 5 Jun 2026 14:09:40 -0700 Subject: [PATCH 45/59] [codex] Bound WSL local curated discovery (#26669) ## Context The installed-app suggestion expansion added in #24996 reads plugin details for trusted file-backed marketplace candidates because the list response does not include app ids. On Windows-backed WSL mounts, the local `openai-curated` checkout lives under `$CODEX_HOME/.tmp/plugins`, and those per-plugin detail reads can be very slow. Remote curated already has cached app ids, so it does not need the same local filesystem traversal. ## Summary - Keep only the WSL Windows-backed local `openai-curated` checkout on the legacy fallback/configured discovery path. - Preserve installed-app expansion for non-WSL file-backed marketplaces and remote curated. - Add focused tests for the WSL local curated path predicate. ## Test - `just test -p codex-core-plugins discoverable` - `just test -p codex-core plugins::discoverable::tests` --- codex-rs/core-plugins/src/discoverable.rs | 43 +++++++++++++ .../core-plugins/src/discoverable_tests.rs | 61 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 codex-rs/core-plugins/src/discoverable_tests.rs diff --git a/codex-rs/core-plugins/src/discoverable.rs b/codex-rs/core-plugins/src/discoverable.rs index ccf093d8166f..f376d099ce22 100644 --- a/codex-rs/core-plugins/src/discoverable.rs +++ b/codex-rs/core-plugins/src/discoverable.rs @@ -4,6 +4,8 @@ use codex_app_server_protocol::PluginInstallPolicy; use codex_login::CodexAuth; use codex_plugin::PluginCapabilitySummary; use std::collections::HashSet; +use std::path::Component; +use std::path::Path; use tracing::warn; use crate::OPENAI_BUNDLED_MARKETPLACE_NAME; @@ -53,6 +55,9 @@ const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[ REMOTE_GLOBAL_MARKETPLACE_NAME, ]; +const OPENAI_CURATED_MARKETPLACE_PATH_SUFFIX: &str = + ".tmp/plugins/.agents/plugins/marketplace.json"; + #[derive(Debug, Clone)] pub struct ToolSuggestPluginDiscoveryInput { pub plugins: PluginsConfigInput, @@ -108,6 +113,10 @@ impl PluginsManager { { continue; } + let use_legacy_local_curated_filter = should_use_legacy_local_curated_discovery_filter( + &marketplace_name, + marketplace.path.as_path(), + ); let is_allowlisted_marketplace = TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST .contains(&marketplace_name.as_str()); @@ -123,6 +132,13 @@ impl PluginsManager { continue; } + // On Windows-backed WSL mounts, keep local curated discovery bounded to the + // legacy fallback/configured set instead of reading every plugin detail for app + // ids. Remote curated has cached app ids and still expands by installed apps. + if use_legacy_local_curated_filter && !is_configured_plugin && !is_fallback_plugin { + continue; + } + let plugin_id = plugin.id.clone(); match self @@ -216,3 +232,30 @@ impl PluginsManager { Ok(discoverable_plugins) } } + +fn should_use_legacy_local_curated_discovery_filter( + marketplace_name: &str, + marketplace_path: &Path, +) -> bool { + marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME + && is_wsl_windows_drive_path(marketplace_path) + && marketplace_path.ends_with(Path::new(OPENAI_CURATED_MARKETPLACE_PATH_SUFFIX)) +} + +fn is_wsl_windows_drive_path(path: &Path) -> bool { + let mut components = path.components(); + if components.next() != Some(Component::RootDir) { + return false; + } + if components.next().and_then(|part| part.as_os_str().to_str()) != Some("mnt") { + return false; + } + let Some(drive) = components.next().and_then(|part| part.as_os_str().to_str()) else { + return false; + }; + drive.len() == 1 && drive.as_bytes()[0].is_ascii_alphabetic() +} + +#[cfg(test)] +#[path = "discoverable_tests.rs"] +mod tests; diff --git a/codex-rs/core-plugins/src/discoverable_tests.rs b/codex-rs/core-plugins/src/discoverable_tests.rs new file mode 100644 index 000000000000..240fd86f1749 --- /dev/null +++ b/codex-rs/core-plugins/src/discoverable_tests.rs @@ -0,0 +1,61 @@ +use std::path::Path; + +use super::is_wsl_windows_drive_path; +use super::should_use_legacy_local_curated_discovery_filter; +use crate::OPENAI_BUNDLED_MARKETPLACE_NAME; +use crate::OPENAI_CURATED_MARKETPLACE_NAME; + +#[test] +fn legacy_local_curated_filter_matches_wsl_windows_backed_curated_checkout() { + let marketplace_path = + Path::new("/mnt/c/Users/user/.codex/.tmp/plugins/.agents/plugins/marketplace.json"); + + assert!(should_use_legacy_local_curated_discovery_filter( + OPENAI_CURATED_MARKETPLACE_NAME, + marketplace_path, + )); +} + +#[test] +fn legacy_local_curated_filter_does_not_match_native_wsl_curated_checkout() { + let marketplace_path = + Path::new("/home/user/.codex/.tmp/plugins/.agents/plugins/marketplace.json"); + + assert!(!should_use_legacy_local_curated_discovery_filter( + OPENAI_CURATED_MARKETPLACE_NAME, + marketplace_path, + )); +} + +#[test] +fn legacy_local_curated_filter_does_not_match_other_wsl_marketplaces() { + let other_marketplace_path = Path::new( + "/mnt/c/Users/user/.codex/.tmp/marketplaces/other/.agents/plugins/marketplace.json", + ); + let local_curated_marketplace_path = + Path::new("/mnt/c/Users/user/.codex/.tmp/plugins/.agents/plugins/marketplace.json"); + + assert!(!should_use_legacy_local_curated_discovery_filter( + OPENAI_CURATED_MARKETPLACE_NAME, + other_marketplace_path, + )); + assert!(!should_use_legacy_local_curated_discovery_filter( + OPENAI_BUNDLED_MARKETPLACE_NAME, + local_curated_marketplace_path, + )); +} + +#[test] +fn wsl_windows_drive_path_matches_only_mnt_drive_paths() { + assert!(is_wsl_windows_drive_path(Path::new( + "/mnt/c/Users/user/.codex/.tmp/plugins", + ))); + assert!(is_wsl_windows_drive_path(Path::new("/mnt/Z/tmp"))); + assert!(!is_wsl_windows_drive_path(Path::new("/home/user/.codex"))); + assert!(!is_wsl_windows_drive_path(Path::new( + "/mnt/codex/Users/user/.codex", + ))); + assert!(!is_wsl_windows_drive_path(Path::new( + "/media/c/Users/user/.codex", + ))); +} From 479a14cf592d5e0ca319942e6ba833ebaa1274e2 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 5 Jun 2026 14:17:30 -0700 Subject: [PATCH 46/59] [2 of 2] Finish moving goal runtime to extension (#26548) ## Stack 1. [#26547](https://github.com/openai/codex/pull/26547) - [1 of 2] Align goal extension with core behavior 2. [#26548](https://github.com/openai/codex/pull/26548) - [2 of 2] Move goal runtime to extension ## Why This PR completes the switch of the goal behavior to the extension-backed runtime and removes the old core goal implementation. ## What Changed - Installs the goal extension for app-server `ThreadManager` sessions. - Routes app-server thread goal `get`, `set`, and `clear` through `GoalService`. - Uses thread-idle lifecycle emission after goal resume and snapshot ordering so the extension can decide whether to continue the goal. - Forwards extension goal updates through a FIFO async app-server notification path so backpressure does not drop them or reorder updates. - Keeps review turns from enabling goal runtime behavior. - Plans extension tools before dynamic tools so built-in goal tool names keep their old precedence when goals are enabled. - Removes the old core goal runtime, core goal tool handlers, and core goal tool specs. - Updates tests that were coupled to the core-owned goal runtime while leaving the legacy `` compatibility path in core for old threads. - Removes the stale cargo-shear ignore now that `codex-goal-extension` is used by the workspace. - Keeps realtime event matching exhaustive after removing the old goal-specific realtime text path. ## Validation - Ran manual `/goal` runs in TUI. Validated time accounting matched wall-clock time and goal lifecycle state transitions. --- codex-rs/Cargo.lock | 1 + codex-rs/Cargo.toml | 1 - codex-rs/app-server/Cargo.toml | 1 + codex-rs/app-server/src/extensions.rs | 156 +- codex-rs/app-server/src/mcp_refresh.rs | 3 + codex-rs/app-server/src/message_processor.rs | 8 +- codex-rs/app-server/src/outgoing_message.rs | 10 - codex-rs/app-server/src/request_processors.rs | 2 - .../thread_goal_processor.rs | 301 +-- .../request_processors/thread_lifecycle.rs | 33 +- codex-rs/app-server/src/thread_state.rs | 39 +- codex-rs/core/src/codex_thread.rs | 48 +- .../context/contextual_user_message_tests.rs | 14 +- codex-rs/core/src/event_mapping_tests.rs | 4 +- codex-rs/core/src/goals.rs | 1622 ----------------- codex-rs/core/src/lib.rs | 3 - codex-rs/core/src/session/review.rs | 2 - codex-rs/core/src/session/session.rs | 3 - codex-rs/core/src/session/tests.rs | 1324 +------------- codex-rs/core/src/session/turn.rs | 30 - codex-rs/core/src/session/turn_context.rs | 10 - codex-rs/core/src/tasks/mod.rs | 42 - codex-rs/core/src/thread_manager.rs | 3 - codex-rs/core/src/thread_manager_tests.rs | 101 - codex-rs/core/src/tools/handlers/goal.rs | 158 -- .../src/tools/handlers/goal/create_goal.rs | 78 - .../core/src/tools/handlers/goal/get_goal.rs | 51 - .../src/tools/handlers/goal/update_goal.rs | 89 - codex-rs/core/src/tools/handlers/goal_spec.rs | 120 -- codex-rs/core/src/tools/handlers/mod.rs | 5 - codex-rs/core/src/tools/registry.rs | 16 +- codex-rs/core/src/tools/spec_plan.rs | 18 +- codex-rs/core/src/tools/spec_plan_tests.rs | 31 +- codex-rs/core/tests/suite/prompt_caching.rs | 3 - 34 files changed, 351 insertions(+), 3979 deletions(-) delete mode 100644 codex-rs/core/src/goals.rs delete mode 100644 codex-rs/core/src/tools/handlers/goal.rs delete mode 100644 codex-rs/core/src/tools/handlers/goal/create_goal.rs delete mode 100644 codex-rs/core/src/tools/handlers/goal/get_goal.rs delete mode 100644 codex-rs/core/src/tools/handlers/goal/update_goal.rs delete mode 100644 codex-rs/core/src/tools/handlers/goal_spec.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b89922238492..a61df434f194 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1926,6 +1926,7 @@ dependencies = [ "codex-file-search", "codex-file-watcher", "codex-git-utils", + "codex-goal-extension", "codex-guardian", "codex-hooks", "codex-image-generation-extension", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 5d6d11604949..5fe010432122 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -483,7 +483,6 @@ unwrap_used = "deny" [workspace.metadata.cargo-shear] ignored = [ "codex-agent-graph-store", - "codex-goal-extension", "icu_provider", "openssl-sys", "codex-v8-poc", diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 37cdf219bfb2..4bdc430a1a90 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -41,6 +41,7 @@ codex-extension-api = { workspace = true } codex-external-agent-migration = { workspace = true } codex-external-agent-sessions = { workspace = true } codex-features = { workspace = true } +codex-goal-extension = { workspace = true } codex-guardian = { workspace = true } codex-git-utils = { workspace = true } codex-file-watcher = { workspace = true } diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index 7b2673ca068c..53f19997cd1f 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use std::sync::Weak; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadGoal; use codex_app_server_protocol::ThreadGoalUpdatedNotification; use codex_core::NewThread; use codex_core::StartThreadOptions; @@ -12,23 +13,40 @@ use codex_extension_api::AgentSpawner; use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistry; use codex_extension_api::ExtensionRegistryBuilder; +use codex_goal_extension::GoalService; use codex_login::AuthManager; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; +use codex_rollout::state_db::StateDbHandle; use crate::outgoing_message::OutgoingMessageSender; +use crate::thread_state::ThreadListenerCommand; +use crate::thread_state::ThreadStateManager; pub(crate) fn thread_extensions( guardian_agent_spawner: S, event_sink: Arc, auth_manager: Arc, + state_db: Option, + thread_manager: Weak, + goal_service: Arc, ) -> Arc> where S: AgentSpawner + 'static, { let mut builder = ExtensionRegistryBuilder::::with_event_sink(event_sink); + if let Some(state_db) = state_db { + codex_goal_extension::install_with_backend( + &mut builder, + state_db, + codex_otel::global(), + thread_manager, + goal_service, + |config: &Config| config.features.enabled(codex_features::Feature::Goals), + ); + } codex_guardian::install(&mut builder, guardian_agent_spawner); codex_memories_extension::install(&mut builder, codex_otel::global()); codex_web_search_extension::install(&mut builder, auth_manager.clone()); @@ -38,26 +56,53 @@ where pub(crate) fn app_server_extension_event_sink( outgoing: Arc, + thread_state_manager: ThreadStateManager, ) -> Arc { - Arc::new(AppServerExtensionEventSink { outgoing }) + Arc::new(AppServerExtensionEventSink { + outgoing, + thread_state_manager, + }) } struct AppServerExtensionEventSink { outgoing: Arc, + thread_state_manager: ThreadStateManager, } impl ExtensionEventSink for AppServerExtensionEventSink { fn emit(&self, event: Event) { match event.msg { EventMsg::ThreadGoalUpdated(thread_goal_event) => { - self.outgoing - .try_send_server_notification(ServerNotification::ThreadGoalUpdated( - ThreadGoalUpdatedNotification { - thread_id: thread_goal_event.thread_id.to_string(), - turn_id: thread_goal_event.turn_id, - goal: thread_goal_event.goal.into(), - }, - )); + let thread_id = thread_goal_event.thread_id; + let turn_id = thread_goal_event.turn_id; + let goal: ThreadGoal = thread_goal_event.goal.into(); + if let Some(listener_command_tx) = self + .thread_state_manager + .current_listener_command_tx(thread_id) + { + let command = ThreadListenerCommand::EmitThreadGoalUpdated { + turn_id: turn_id.clone(), + goal: goal.clone(), + }; + if listener_command_tx.send(command).is_ok() { + return; + } + tracing::warn!( + "failed to enqueue extension goal update for {thread_id}: listener command channel is closed" + ); + } + let outgoing = Arc::clone(&self.outgoing); + tokio::spawn(async move { + outgoing + .send_server_notification(ServerNotification::ThreadGoalUpdated( + ThreadGoalUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id, + goal, + }, + )) + .await; + }); } msg => { tracing::debug!(event_id = %event.id, ?msg, "dropping unsupported extension event"); @@ -89,10 +134,7 @@ mod tests { use std::time::Duration; use codex_analytics::AnalyticsEventsClient; - use codex_app_server_protocol::ServerNotification; - use codex_app_server_protocol::ThreadGoal as AppServerThreadGoal; - use codex_app_server_protocol::ThreadGoalStatus as AppServerThreadGoalStatus; - use codex_protocol::protocol::ThreadGoal; + use codex_protocol::protocol::ThreadGoal as CoreThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadGoalUpdatedEvent; use pretty_assertions::assert_eq; @@ -100,25 +142,61 @@ mod tests { use tokio::time::timeout; use super::*; - use crate::outgoing_message::OutgoingEnvelope; - use crate::outgoing_message::OutgoingMessage; #[tokio::test] - async fn app_server_event_sink_forwards_thread_goal_updates() { - let (outgoing_tx, mut outgoing_rx) = mpsc::channel(4); + async fn app_server_event_sink_uses_listener_fifo_for_goal_updates_and_clears() { + let (outgoing_tx, _outgoing_rx) = mpsc::channel(4); let outgoing = Arc::new(OutgoingMessageSender::new( outgoing_tx, AnalyticsEventsClient::disabled(), )); - let sink = app_server_extension_event_sink(outgoing); + let thread_state_manager = ThreadStateManager::new(); let thread_id = ThreadId::default(); + let (listener_command_tx, mut listener_command_rx) = mpsc::unbounded_channel(); + thread_state_manager.register_listener_command_tx(thread_id, listener_command_tx.clone()); + let sink = app_server_extension_event_sink(outgoing, thread_state_manager); + + for turn_id in ["turn-1", "turn-2"] { + sink.emit(thread_goal_updated_event(thread_id, turn_id)); + } + listener_command_tx + .send(ThreadListenerCommand::EmitThreadGoalCleared) + .expect("listener command channel should be open"); + + let mut observed = Vec::new(); + for _ in 0..3 { + let command = timeout(Duration::from_secs(1), listener_command_rx.recv()) + .await + .expect("timed out waiting for listener command") + .expect("listener command channel closed unexpectedly"); + match command { + ThreadListenerCommand::EmitThreadGoalUpdated { turn_id, .. } => { + observed.push(turn_id.expect("extension goal updates should include turn ids")); + } + ThreadListenerCommand::EmitThreadGoalCleared => { + observed.push("cleared".to_string()) + } + _ => panic!("unexpected listener command"), + } + } + + assert_eq!( + vec![ + "turn-1".to_string(), + "turn-2".to_string(), + "cleared".to_string() + ], + observed + ); + } - sink.emit(Event { - id: "call-1".to_string(), + fn thread_goal_updated_event(thread_id: ThreadId, turn_id: &str) -> Event { + Event { + id: turn_id.to_string(), msg: EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { thread_id, - turn_id: Some("turn-1".to_string()), - goal: ThreadGoal { + turn_id: Some(turn_id.to_string()), + goal: CoreThreadGoal { thread_id, objective: "wire extension events".to_string(), status: ThreadGoalStatus::Active, @@ -129,38 +207,6 @@ mod tests { updated_at: 8, }, }), - }); - - let envelope = timeout(Duration::from_secs(1), outgoing_rx.recv()) - .await - .expect("timed out waiting for forwarded extension event") - .expect("outgoing channel closed unexpectedly"); - let OutgoingEnvelope::Broadcast { message } = envelope else { - panic!("expected broadcast notification"); - }; - let OutgoingMessage::AppServerNotification(ServerNotification::ThreadGoalUpdated( - notification, - )) = message - else { - panic!("expected thread goal updated notification"); - }; - - assert_eq!( - ThreadGoalUpdatedNotification { - thread_id: thread_id.to_string(), - turn_id: Some("turn-1".to_string()), - goal: AppServerThreadGoal { - thread_id: thread_id.to_string(), - objective: "wire extension events".to_string(), - status: AppServerThreadGoalStatus::Active, - token_budget: Some(123), - tokens_used: 45, - time_used_seconds: 6, - created_at: 7, - updated_at: 8, - }, - }, - notification - ); + } } } diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index 0996c409bc26..8c72533ba52a 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -191,6 +191,9 @@ mod tests { guardian_agent_spawner(thread_manager.clone()), Arc::new(NoopExtensionEventSink), auth_manager.clone(), + Some(state_db.clone()), + thread_manager.clone(), + Arc::new(codex_goal_extension::GoalService::new()), ), /*analytics_events_client*/ None, Arc::clone(&thread_store), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 76f39663a0b4..b7e0ff258d37 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -70,6 +70,7 @@ use codex_core::ThreadManager; use codex_core::config::Config; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; +use codex_goal_extension::GoalService; use codex_login::AuthManager; use codex_login::auth::ExternalAuth; use codex_login::auth::ExternalAuthRefreshContext; @@ -305,6 +306,7 @@ impl MessageProcessor { // resumed, or forked threads to a different persistence backend/root. let thread_store = codex_core::thread_store_from_config(config.as_ref(), state_db.clone()); let environment_manager_for_requests = Arc::clone(&environment_manager); + let goal_service = Arc::new(GoalService::new()); let thread_manager = Arc::new_cyclic(|thread_manager| { ThreadManager::new( config.as_ref(), @@ -313,8 +315,11 @@ impl MessageProcessor { environment_manager, thread_extensions( guardian_agent_spawner(thread_manager.clone()), - app_server_extension_event_sink(outgoing.clone()), + app_server_extension_event_sink(outgoing.clone(), thread_state_manager.clone()), auth_manager.clone(), + state_db.clone(), + thread_manager.clone(), + Arc::clone(&goal_service), ), Some(analytics_events_client.clone()), Arc::clone(&thread_store), @@ -416,6 +421,7 @@ impl MessageProcessor { Arc::clone(&config), thread_state_manager.clone(), state_db.clone(), + Arc::clone(&goal_service), ); let thread_processor = ThreadRequestProcessor::new( auth_manager.clone(), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 2e67e233981a..ba75d0afd386 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -555,16 +555,6 @@ impl OutgoingMessageSender { .await; } - pub(crate) fn try_send_server_notification(&self, notification: ServerNotification) { - tracing::trace!("app-server event: {notification}"); - let outgoing_message = OutgoingMessage::AppServerNotification(notification); - if let Err(err) = self.sender.try_send(OutgoingEnvelope::Broadcast { - message: outgoing_message, - }) { - warn!("failed to send server notification to client without waiting: {err:?}"); - } - } - pub(crate) async fn send_server_notification_to_connections( &self, connection_ids: &[ConnectionId], diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index bfcf328c4369..0266cb9d9578 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -279,8 +279,6 @@ use codex_config::loader::project_trust_key; use codex_config::types::McpServerTransportConfig; use codex_core::CodexThread; use codex_core::CodexThreadSettingsOverrides; -use codex_core::ExternalGoalPreviousStatus; -use codex_core::ExternalGoalSet; use codex_core::ForkSnapshot; use codex_core::NewThread; #[cfg(test)] diff --git a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs index c18806ea0385..2f40ce6602f3 100644 --- a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs @@ -1,5 +1,9 @@ use super::*; -use codex_protocol::protocol::validate_thread_goal_objective; +use codex_goal_extension::GoalObjectiveUpdate; +use codex_goal_extension::GoalService; +use codex_goal_extension::GoalServiceError; +use codex_goal_extension::GoalSetRequest; +use codex_goal_extension::GoalTokenBudgetUpdate; #[derive(Clone)] pub(crate) struct ThreadGoalRequestProcessor { @@ -8,6 +12,7 @@ pub(crate) struct ThreadGoalRequestProcessor { config: Arc, thread_state_manager: ThreadStateManager, state_db: Option, + goal_service: Arc, } impl ThreadGoalRequestProcessor { @@ -17,6 +22,7 @@ impl ThreadGoalRequestProcessor { config: Arc, thread_state_manager: ThreadStateManager, state_db: Option, + goal_service: Arc, ) -> Self { Self { thread_manager, @@ -24,6 +30,7 @@ impl ThreadGoalRequestProcessor { config, thread_state_manager, state_db, + goal_service, } } @@ -66,10 +73,8 @@ impl ThreadGoalRequestProcessor { } self.emit_thread_goal_snapshot(thread_id).await; // App-server owns resume response and snapshot ordering, so wait until - // those are sent before letting core start goal continuation. - if let Err(err) = thread.continue_active_goal_if_idle().await { - tracing::warn!("failed to continue active goal after resume: {err}"); - } + // those are sent before letting extensions react to the idle thread. + thread.emit_thread_idle_lifecycle_if_idle().await; } pub(crate) async fn pending_resume_goal_state( @@ -100,140 +105,36 @@ impl ThreadGoalRequestProcessor { let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?; let state_db = self.state_db_for_materialized_thread(thread_id).await?; - let running_thread = self.thread_manager.get_thread(thread_id).await.ok(); - let rollout_path = match running_thread.as_ref() { - Some(thread) => thread.rollout_path().ok_or_else(|| { - invalid_request(format!( - "ephemeral thread does not support goals: {thread_id}" - )) - })?, - None => codex_rollout::find_thread_path_by_id_str( - &self.config.codex_home, - &thread_id.to_string(), - self.state_db.as_deref(), - ) - .await - .map_err(|err| { - internal_error(format!("failed to locate thread id {thread_id}: {err}")) - })? - .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?, - }; - reconcile_rollout( - Some(&state_db), - rollout_path.as_path(), - self.config.model_provider_id.as_str(), - /*builder*/ None, - &[], - /*archived_only*/ None, - /*new_thread_memory_mode*/ None, - ) - .await; + self.reconcile_thread_goal_rollout(thread_id, &state_db) + .await?; let listener_command_tx = { let thread_state = self.thread_state_manager.thread_state(thread_id).await; let thread_state = thread_state.lock().await; thread_state.listener_command_tx() }; - let status = params.status.map(thread_goal_status_to_state); - let objective = params.objective.as_deref().map(str::trim); - - if let Some(objective) = objective { - validate_thread_goal_objective(objective).map_err(invalid_request)?; - } - if objective.is_some() || params.token_budget.is_some() { - validate_goal_budget(params.token_budget.flatten()).map_err(invalid_request)?; - } - - if let Some(thread) = running_thread.as_ref() { - thread.prepare_external_goal_mutation().await; - } + let status = params.status.map(ThreadGoalStatus::to_core); + let objective = params.objective.as_deref(); - let should_set_thread_preview = objective.is_some(); - let (goal, previous_status) = (if let Some(objective) = objective { - let existing_goal = state_db - .thread_goals() - .get_thread_goal(thread_id) - .await - .map_err(|err| invalid_request(err.to_string()))?; - if let Some(goal) = existing_goal.as_ref() { - let previous_status = ExternalGoalPreviousStatus::from(goal); - state_db - .thread_goals() - .update_thread_goal( - thread_id, - codex_state::GoalUpdate { - objective: Some(objective.to_string()), - status, - token_budget: params.token_budget, - expected_goal_id: Some(goal.goal_id.clone()), - }, - ) - .await - .and_then(|goal| { - goal.ok_or_else(|| { - anyhow::anyhow!( - "cannot update goal for thread {thread_id}: no goal exists" - ) - }) - }) - .map(|goal| (goal, previous_status)) - } else { - let previous_status = ExternalGoalPreviousStatus::NewGoal; - state_db - .thread_goals() - .replace_thread_goal( - thread_id, - objective, - status.unwrap_or(codex_state::ThreadGoalStatus::Active), - params.token_budget.flatten(), - ) - .await - .map(|goal| (goal, previous_status)) - } - } else { - let existing_goal = state_db - .thread_goals() - .get_thread_goal(thread_id) - .await - .map_err(|err| invalid_request(err.to_string()))?; - let Some(existing_goal) = existing_goal else { - return Err(invalid_request(format!( - "cannot update goal for thread {thread_id}: no goal exists" - ))); - }; - let previous_status = ExternalGoalPreviousStatus::from(&existing_goal); - state_db - .thread_goals() - .update_thread_goal( + let outcome = self + .goal_service + .set_thread_goal( + &state_db, + GoalSetRequest { thread_id, - codex_state::GoalUpdate { - objective: None, - status, - token_budget: params.token_budget, - expected_goal_id: None, + objective: objective + .map(GoalObjectiveUpdate::Set) + .unwrap_or(GoalObjectiveUpdate::Keep), + status, + token_budget: match params.token_budget { + Some(token_budget) => GoalTokenBudgetUpdate::Set(token_budget), + None => GoalTokenBudgetUpdate::Keep, }, - ) - .await - .and_then(|goal| { - goal.ok_or_else(|| { - anyhow::anyhow!("cannot update goal for thread {thread_id}: no goal exists") - }) - }) - .map(|goal| (goal, previous_status)) - }) - .map_err(|err| invalid_request(err.to_string()))?; - if should_set_thread_preview - && let Err(err) = state_db - .set_thread_preview_if_empty(thread_id, goal.objective.as_str()) - .await - { - warn!("failed to set empty thread preview from goal objective for {thread_id}: {err}"); - } - let external_goal_set = ExternalGoalSet { - goal: goal.clone(), - previous_status, - }; - let goal = api_thread_goal_from_state(goal); + }, + ) + .await + .map_err(goal_service_error)?; + let goal = ThreadGoal::from(outcome.goal.clone()); self.outgoing .send_response( request_id.clone(), @@ -242,9 +143,7 @@ impl ThreadGoalRequestProcessor { .await; self.emit_thread_goal_updated_ordered(thread_id, goal, listener_command_tx) .await; - if let Some(thread) = running_thread.as_ref() { - thread.apply_external_goal_set(external_goal_set).await; - } + outcome.apply_runtime_effects(&self.goal_service).await; Ok(()) } @@ -258,12 +157,12 @@ impl ThreadGoalRequestProcessor { let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?; let state_db = self.state_db_for_materialized_thread(thread_id).await?; - let goal = state_db - .thread_goals() - .get_thread_goal(thread_id) + let goal = self + .goal_service + .get_thread_goal(&state_db, thread_id) .await - .map_err(|err| internal_error(format!("failed to read thread goal: {err}")))? - .map(api_thread_goal_from_state); + .map_err(goal_service_error)? + .map(ThreadGoal::from); Ok(ThreadGoalGetResponse { goal }) } @@ -278,53 +177,19 @@ impl ThreadGoalRequestProcessor { let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?; let state_db = self.state_db_for_materialized_thread(thread_id).await?; - let running_thread = self.thread_manager.get_thread(thread_id).await.ok(); - let rollout_path = match running_thread.as_ref() { - Some(thread) => thread.rollout_path().ok_or_else(|| { - invalid_request(format!( - "ephemeral thread does not support goals: {thread_id}" - )) - })?, - None => codex_rollout::find_thread_path_by_id_str( - &self.config.codex_home, - &thread_id.to_string(), - self.state_db.as_deref(), - ) - .await - .map_err(|err| { - internal_error(format!("failed to locate thread id {thread_id}: {err}")) - })? - .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?, - }; - reconcile_rollout( - Some(&state_db), - rollout_path.as_path(), - self.config.model_provider_id.as_str(), - /*builder*/ None, - &[], - /*archived_only*/ None, - /*new_thread_memory_mode*/ None, - ) - .await; - - if let Some(thread) = running_thread.as_ref() { - thread.prepare_external_goal_mutation().await; - } + self.reconcile_thread_goal_rollout(thread_id, &state_db) + .await?; let listener_command_tx = { let thread_state = self.thread_state_manager.thread_state(thread_id).await; let thread_state = thread_state.lock().await; thread_state.listener_command_tx() }; - let cleared = state_db - .thread_goals() - .delete_thread_goal(thread_id) + let cleared = self + .goal_service + .clear_thread_goal(&state_db, thread_id) .await - .map_err(|err| internal_error(format!("failed to clear thread goal: {err}")))?; - - if cleared && let Some(thread) = running_thread.as_ref() { - thread.apply_external_goal_clear().await; - } + .map_err(goal_service_error)?; self.outgoing .send_response(request_id, ThreadGoalClearResponse { cleared }) @@ -367,6 +232,42 @@ impl ThreadGoalRequestProcessor { .ok_or_else(|| internal_error("sqlite state db unavailable for thread goals")) } + async fn reconcile_thread_goal_rollout( + &self, + thread_id: ThreadId, + state_db: &StateDbHandle, + ) -> Result<(), JSONRPCErrorError> { + let running_thread = self.thread_manager.get_thread(thread_id).await.ok(); + let rollout_path = match running_thread.as_ref() { + Some(thread) => thread.rollout_path().ok_or_else(|| { + invalid_request(format!( + "ephemeral thread does not support goals: {thread_id}" + )) + })?, + None => codex_rollout::find_thread_path_by_id_str( + &self.config.codex_home, + &thread_id.to_string(), + self.state_db.as_deref(), + ) + .await + .map_err(|err| { + internal_error(format!("failed to locate thread id {thread_id}: {err}")) + })? + .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?, + }; + reconcile_rollout( + Some(state_db), + rollout_path.as_path(), + self.config.model_provider_id.as_str(), + /*builder*/ None, + &[], + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, + ) + .await; + Ok(()) + } + async fn emit_thread_goal_snapshot(&self, thread_id: ThreadId) { let state_db = match self.state_db_for_materialized_thread(thread_id).await { Ok(state_db) => state_db, @@ -405,6 +306,7 @@ impl ThreadGoalRequestProcessor { ) { if let Some(listener_command_tx) = listener_command_tx { let command = crate::thread_state::ThreadListenerCommand::EmitThreadGoalUpdated { + turn_id: None, goal: goal.clone(), }; if listener_command_tx.send(command).is_ok() { @@ -449,27 +351,20 @@ impl ThreadGoalRequestProcessor { } } -fn validate_goal_budget(value: Option) -> Result<(), String> { - if let Some(value) = value - && value <= 0 - { - return Err("goal budgets must be positive when provided".to_string()); - } - Ok(()) -} - -fn thread_goal_status_to_state(status: ThreadGoalStatus) -> codex_state::ThreadGoalStatus { - match status { - ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active, - ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused, - ThreadGoalStatus::Blocked => codex_state::ThreadGoalStatus::Blocked, - ThreadGoalStatus::UsageLimited => codex_state::ThreadGoalStatus::UsageLimited, - ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited, - ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete, +pub(super) fn api_thread_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal { + ThreadGoal { + thread_id: goal.thread_id.to_string(), + objective: goal.objective, + status: api_thread_goal_status_from_state(goal.status), + token_budget: goal.token_budget, + tokens_used: goal.tokens_used, + time_used_seconds: goal.time_used_seconds, + created_at: goal.created_at.timestamp(), + updated_at: goal.updated_at.timestamp(), } } -fn thread_goal_status_from_state(status: codex_state::ThreadGoalStatus) -> ThreadGoalStatus { +fn api_thread_goal_status_from_state(status: codex_state::ThreadGoalStatus) -> ThreadGoalStatus { match status { codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active, codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, @@ -480,16 +375,10 @@ fn thread_goal_status_from_state(status: codex_state::ThreadGoalStatus) -> Threa } } -pub(super) fn api_thread_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal { - ThreadGoal { - thread_id: goal.thread_id.to_string(), - objective: goal.objective, - status: thread_goal_status_from_state(goal.status), - token_budget: goal.token_budget, - tokens_used: goal.tokens_used, - time_used_seconds: goal.time_used_seconds, - created_at: goal.created_at.timestamp(), - updated_at: goal.updated_at.timestamp(), +fn goal_service_error(err: GoalServiceError) -> JSONRPCErrorError { + match err { + GoalServiceError::InvalidRequest(message) => invalid_request(message), + GoalServiceError::Internal(message) => internal_error(message), } } diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index f72cfb5a626e..8bfb282ace4d 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -244,12 +244,22 @@ pub(super) async fn ensure_listener_task_running( if thread_state.listener_matches(&conversation) { return Ok(()); } - thread_state.set_listener( + let (listener_command_rx, listener_generation) = thread_state.set_listener( cancel_tx, &conversation, watch_registration, thread_settings_baseline, - ) + ); + let Some(listener_command_tx) = thread_state.listener_command_tx() else { + tracing::warn!( + "thread listener command sender missing immediately after listener registration" + ); + return Ok(()); + }; + listener_task_context + .thread_state_manager + .register_listener_command_tx(conversation_id, listener_command_tx); + (listener_command_rx, listener_generation) }; let ListenerTaskContext { outgoing, @@ -378,6 +388,7 @@ pub(super) async fn ensure_listener_task_running( let mut thread_state = thread_state.lock().await; if thread_state.listener_generation == listener_generation { + thread_state_manager.unregister_listener_command_tx(conversation_id); thread_state.clear_listener(); } }); @@ -471,12 +482,12 @@ pub(super) async fn handle_thread_listener_command( ) .await; } - ThreadListenerCommand::EmitThreadGoalUpdated { goal } => { + ThreadListenerCommand::EmitThreadGoalUpdated { turn_id, goal } => { outgoing .send_server_notification(ServerNotification::ThreadGoalUpdated( ThreadGoalUpdatedNotification { thread_id: conversation_id.to_string(), - turn_id: None, + turn_id, goal, }, )) @@ -616,12 +627,6 @@ pub(super) async fn handle_pending_thread_resume_request( } } - if pending.emit_thread_goal_update - && let Err(err) = conversation.apply_goal_resume_runtime_effects().await - { - tracing::warn!("failed to apply goal resume runtime effects: {err}"); - } - let ThreadConfigSnapshot { model, model_provider_id, @@ -691,11 +696,9 @@ pub(super) async fn handle_pending_thread_resume_request( .replay_requests_to_connection_for_thread(connection_id, conversation_id) .await; // App-server owns resume response and snapshot ordering, so wait until - // replay completes before letting core start goal continuation. - if pending.emit_thread_goal_update - && let Err(err) = conversation.continue_active_goal_if_idle().await - { - tracing::warn!("failed to continue active goal after running-thread resume: {err}"); + // replay completes before letting extensions react to the idle thread. + if pending.emit_thread_goal_update { + conversation.emit_thread_idle_lifecycle_if_idle().await; } } diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 2d932f03266c..6d2b48a4c88b 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -17,6 +17,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; +use std::sync::Mutex as StdMutex; use std::sync::Weak; use tokio::sync::Mutex; use tokio::sync::mpsc; @@ -44,8 +45,9 @@ pub(crate) struct PendingThreadResumeRequest { pub(crate) enum ThreadListenerCommand { // SendThreadResumeResponse is used to resume an already running thread by sending the thread's history to the client and atomically subscribing for new updates. SendThreadResumeResponse(Box), - // EmitThreadGoalUpdated is used to order app-server goal updates with running-thread resume responses. + // EmitThreadGoalUpdated is used to order goal updates with running-thread resume responses and goal clears. EmitThreadGoalUpdated { + turn_id: Option, goal: ThreadGoal, }, // EmitThreadGoalCleared is used to order app-server goal clears with running-thread resume responses. @@ -284,6 +286,10 @@ pub(crate) struct ConnectionCapabilities { #[derive(Clone, Default)] pub(crate) struct ThreadStateManager { state: Arc>, + // Extension event sinks are synchronous, so they need an await-free way to + // enqueue work on the active per-thread listener. + listener_commands: + Arc>>>, } impl ThreadStateManager { @@ -337,6 +343,35 @@ impl ThreadStateManager { state.threads.entry(thread_id).or_default().state.clone() } + pub(crate) fn current_listener_command_tx( + &self, + thread_id: ThreadId, + ) -> Option> { + self.listener_commands + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&thread_id) + .cloned() + } + + pub(crate) fn register_listener_command_tx( + &self, + thread_id: ThreadId, + tx: mpsc::UnboundedSender, + ) { + self.listener_commands + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(thread_id, tx); + } + + pub(crate) fn unregister_listener_command_tx(&self, thread_id: ThreadId) { + self.listener_commands + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&thread_id); + } + pub(crate) async fn remove_thread_state(&self, thread_id: ThreadId) { let thread_state = { let mut state = self.state.lock().await; @@ -350,6 +385,7 @@ impl ThreadStateManager { }); thread_state }; + self.unregister_listener_command_tx(thread_id); if let Some(thread_state) = thread_state { let mut thread_state = thread_state.lock().await; @@ -375,6 +411,7 @@ impl ThreadStateManager { }; for (thread_id, thread_state) in thread_states { + self.unregister_listener_command_tx(thread_id); let mut thread_state = thread_state.lock().await; tracing::debug!( thread_id = %thread_id, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index d87b65e8aea1..b3f9cb0e2e5b 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1,7 +1,5 @@ use crate::agent::AgentStatus; use crate::config::ConstraintResult; -use crate::goals::ExternalGoalSet; -use crate::goals::GoalRuntimeEvent; use crate::session::Codex; use crate::session::SessionSettingsUpdate; use crate::session::SteerInputError; @@ -205,51 +203,11 @@ impl CodexThread { } } - pub async fn apply_goal_resume_runtime_effects(&self) -> anyhow::Result<()> { + pub async fn emit_thread_idle_lifecycle_if_idle(&self) { self.codex .session - .goal_runtime_apply(GoalRuntimeEvent::ThreadResumed) - .await - } - - pub async fn continue_active_goal_if_idle(&self) -> anyhow::Result<()> { - self.codex - .session - .goal_runtime_apply(GoalRuntimeEvent::MaybeContinueIfIdle) - .await - } - - pub async fn prepare_external_goal_mutation(&self) { - if let Err(err) = self - .codex - .session - .goal_runtime_apply(GoalRuntimeEvent::ExternalMutationStarting) - .await - { - tracing::warn!("failed to prepare external goal mutation: {err}"); - } - } - - pub async fn apply_external_goal_set(&self, external_set: ExternalGoalSet) { - if let Err(err) = self - .codex - .session - .goal_runtime_apply(GoalRuntimeEvent::ExternalSet { external_set }) - .await - { - tracing::warn!("failed to apply external goal status runtime effects: {err}"); - } - } - - pub async fn apply_external_goal_clear(&self) { - if let Err(err) = self - .codex - .session - .goal_runtime_apply(GoalRuntimeEvent::ExternalClear) - .await - { - tracing::warn!("failed to apply external goal clear runtime effects: {err}"); - } + .emit_thread_idle_lifecycle_if_idle() + .await; } #[doc(hidden)] diff --git a/codex-rs/core/src/context/contextual_user_message_tests.rs b/codex-rs/core/src/context/contextual_user_message_tests.rs index dc7cb0f5347f..c3f360772ab6 100644 --- a/codex-rs/core/src/context/contextual_user_message_tests.rs +++ b/codex-rs/core/src/context/contextual_user_message_tests.rs @@ -33,14 +33,14 @@ fn detects_subagent_notification_fragment_case_insensitively() { #[test] fn detects_internal_model_context_fragment() { let text = InternalModelContextFragment::new( - InternalContextSource::from_static("goal"), - "Continue working toward the active thread goal.", + InternalContextSource::from_static("extension"), + "Internal steering.", ) .render(); assert_eq!( text, - "\nContinue working toward the active thread goal.\n" + "\nInternal steering.\n" ); assert!(is_contextual_user_fragment(&ContentItem::InputText { text @@ -65,7 +65,7 @@ fn does_not_hide_arbitrary_context_tags() { #[test] fn rejects_invalid_internal_model_context_source() { assert!(!is_contextual_user_fragment(&ContentItem::InputText { - text: "\nbody\n" + text: "\nbody\n" .to_string(), })); } @@ -73,13 +73,13 @@ fn rejects_invalid_internal_model_context_source() { #[test] fn contextual_user_fragment_is_dyn_compatible() { let fragment: Box = Box::new(InternalModelContextFragment::new( - InternalContextSource::from_static("goal"), - "Continue working toward the active thread goal.", + InternalContextSource::from_static("extension"), + "Internal steering.", )); assert_eq!( fragment.render(), - "\nContinue working toward the active thread goal.\n" + "\nInternal steering.\n" ); } diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index 4cf1b72d5d65..5f2ed6683041 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -324,8 +324,8 @@ fn internal_model_context_does_not_parse_as_visible_turn_item() { role: "user".to_string(), content: vec![ContentItem::InputText { text: InternalModelContextFragment::new( - InternalContextSource::from_static("goal"), - "Continue working toward the active thread goal.", + InternalContextSource::from_static("extension"), + "Internal steering.", ) .render(), }], diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs deleted file mode 100644 index 6e0663655c92..000000000000 --- a/codex-rs/core/src/goals.rs +++ /dev/null @@ -1,1622 +0,0 @@ -//! Core support for persisted thread goals. -//! -//! This module bridges core sessions and the state-db goal table. It validates -//! goal mutations, converts between state and protocol shapes, emits goal-update -//! events, and owns helper hooks used by goal lifecycle behavior. - -use crate::StateDbHandle; -use crate::context::ContextualUserFragment; -use crate::context::InternalContextSource; -use crate::context::InternalModelContextFragment; -use crate::session::TurnInput; -use crate::session::session::Session; -use crate::session::turn_context::TurnContext; -use crate::state::ActiveTurn; -use crate::state::TurnState; -use crate::tasks::RegularTask; -use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME; -use anyhow::Context; -use codex_features::Feature; -use codex_otel::GOAL_BLOCKED_METRIC; -use codex_otel::GOAL_BUDGET_LIMITED_METRIC; -use codex_otel::GOAL_COMPLETED_METRIC; -use codex_otel::GOAL_CREATED_METRIC; -use codex_otel::GOAL_DURATION_SECONDS_METRIC; -use codex_otel::GOAL_RESUMED_METRIC; -use codex_otel::GOAL_TOKEN_COUNT_METRIC; -use codex_otel::GOAL_USAGE_LIMITED_METRIC; -use codex_prompts::budget_limit_prompt; -use codex_prompts::continuation_prompt; -use codex_prompts::objective_updated_prompt; -use codex_protocol::ThreadId; -use codex_protocol::config_types::ModeKind; -use codex_protocol::models::ResponseItem; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ThreadGoal; -use codex_protocol::protocol::ThreadGoalStatus; -use codex_protocol::protocol::ThreadGoalUpdatedEvent; -use codex_protocol::protocol::TokenUsage; -use codex_protocol::protocol::validate_thread_goal_objective; -use codex_rollout::state_db::reconcile_rollout; -use codex_thread_store::LocalThreadStore; -use futures::future::BoxFuture; -use std::sync::Arc; -use std::time::Duration; -use std::time::Instant; -use tokio::sync::Mutex; -use tokio::sync::Semaphore; -use tokio::sync::SemaphorePermit; - -pub(crate) struct SetGoalRequest { - pub(crate) objective: Option, - pub(crate) status: Option, - pub(crate) token_budget: Option>, -} - -pub(crate) struct CreateGoalRequest { - pub(crate) objective: String, - pub(crate) token_budget: Option, -} - -#[derive(Clone, Copy)] -enum BudgetLimitSteering { - Allowed, - Suppressed, -} - -#[derive(Clone, Copy)] -enum TerminalMetricEmission { - Emit, - Suppress, -} - -/// Describes whether an external goal mutation created a new logical goal or -/// updated an existing one. -#[derive(Clone)] -pub enum ExternalGoalPreviousStatus { - NewGoal, - Existing(ExternalGoalPreviousGoal), -} - -#[derive(Clone)] -pub struct ExternalGoalPreviousGoal { - goal_id: String, - status: codex_state::ThreadGoalStatus, - objective: String, -} - -impl From<&codex_state::ThreadGoal> for ExternalGoalPreviousStatus { - fn from(goal: &codex_state::ThreadGoal) -> Self { - Self::Existing(ExternalGoalPreviousGoal::from(goal)) - } -} - -impl From<&codex_state::ThreadGoal> for ExternalGoalPreviousGoal { - fn from(goal: &codex_state::ThreadGoal) -> Self { - Self { - goal_id: goal.goal_id.clone(), - status: goal.status, - objective: goal.objective.clone(), - } - } -} - -/// Runtime effects for an externally persisted goal mutation. -#[derive(Clone)] -pub struct ExternalGoalSet { - pub goal: codex_state::ThreadGoal, - pub previous_status: ExternalGoalPreviousStatus, -} - -/// Runtime lifecycle events that can affect goal accounting, scheduling, or -/// model-visible steering. -/// -/// Callers report the session event they observed; this module owns the policy -/// for how that event changes goal runtime state. -pub(crate) enum GoalRuntimeEvent<'a> { - TurnStarted { - turn_context: &'a TurnContext, - token_usage: TokenUsage, - }, - ToolCompleted { - turn_context: &'a TurnContext, - tool_name: &'a str, - }, - ToolCompletedGoal { - turn_context: &'a TurnContext, - }, - TurnFinished { - turn_context: &'a TurnContext, - turn_completed: bool, - }, - MaybeContinueIfIdle, - TaskAborted { - turn_context: Option<&'a TurnContext>, - }, - UsageLimitReached { - turn_context: &'a TurnContext, - }, - ExternalMutationStarting, - ExternalSet { - external_set: ExternalGoalSet, - }, - ExternalClear, - ThreadResumed, -} - -pub(crate) struct GoalRuntimeState { - pub(crate) state_db: Mutex>, - pub(crate) budget_limit_reported_goal_id: Mutex>, - accounting_lock: Semaphore, - accounting: Mutex, - pub(crate) continuation_lock: Semaphore, -} - -struct GoalContinuationCandidate { - goal_id: String, - items: Vec, -} - -impl GoalRuntimeState { - pub(crate) fn new() -> Self { - Self { - state_db: Mutex::new(None), - budget_limit_reported_goal_id: Mutex::new(None), - accounting_lock: Semaphore::new(/*permits*/ 1), - accounting: Mutex::new(GoalAccountingSnapshot::new()), - continuation_lock: Semaphore::new(/*permits*/ 1), - } - } -} - -#[derive(Debug)] -struct GoalAccountingSnapshot { - turn: Option, - wall_clock: GoalWallClockAccountingSnapshot, -} - -#[derive(Debug)] -struct GoalTurnAccountingSnapshot { - turn_id: String, - last_accounted_token_usage: TokenUsage, - active_goal_id: Option, -} - -impl GoalRuntimeState { - async fn accounting_permit(&self) -> anyhow::Result> { - self.accounting_lock - .acquire() - .await - .context("goal accounting semaphore closed") - } -} - -impl GoalAccountingSnapshot { - fn new() -> Self { - Self { - turn: None, - wall_clock: GoalWallClockAccountingSnapshot::new(), - } - } -} - -impl GoalTurnAccountingSnapshot { - fn new(turn_id: impl Into, token_usage: TokenUsage) -> Self { - Self { - turn_id: turn_id.into(), - last_accounted_token_usage: token_usage, - active_goal_id: None, - } - } - - fn mark_active_goal(&mut self, goal_id: impl Into) { - self.active_goal_id = Some(goal_id.into()); - } - - fn active_this_turn(&self) -> bool { - self.active_goal_id.is_some() - } - - fn active_goal_id(&self) -> Option { - self.active_goal_id.clone() - } - - fn clear_active_goal(&mut self) { - self.active_goal_id = None; - } - - fn reset_baseline(&mut self, token_usage: TokenUsage) { - self.last_accounted_token_usage = token_usage; - } - - fn token_delta_since_last_accounting(&self, current: &TokenUsage) -> i64 { - let last = &self.last_accounted_token_usage; - let delta = TokenUsage { - input_tokens: current.input_tokens.saturating_sub(last.input_tokens), - cached_input_tokens: current - .cached_input_tokens - .saturating_sub(last.cached_input_tokens), - output_tokens: current.output_tokens.saturating_sub(last.output_tokens), - reasoning_output_tokens: current - .reasoning_output_tokens - .saturating_sub(last.reasoning_output_tokens), - total_tokens: current.total_tokens.saturating_sub(last.total_tokens), - }; - goal_token_delta_for_usage(&delta) - } - - fn mark_accounted(&mut self, current: TokenUsage) { - self.last_accounted_token_usage = current; - } -} - -#[derive(Debug)] -struct GoalWallClockAccountingSnapshot { - last_accounted_at: Instant, - active_goal_id: Option, -} - -impl GoalWallClockAccountingSnapshot { - fn new() -> Self { - Self { - last_accounted_at: Instant::now(), - active_goal_id: None, - } - } - - fn time_delta_since_last_accounting(&self) -> i64 { - let last = self.last_accounted_at; - i64::try_from(last.elapsed().as_secs()).unwrap_or(i64::MAX) - } - - fn mark_accounted(&mut self, accounted_seconds: i64) { - if accounted_seconds <= 0 { - return; - } - let advance = Duration::from_secs(u64::try_from(accounted_seconds).unwrap_or(u64::MAX)); - self.last_accounted_at = self - .last_accounted_at - .checked_add(advance) - .unwrap_or_else(Instant::now); - } - - fn reset_baseline(&mut self) { - self.last_accounted_at = Instant::now(); - } - - fn mark_active_goal(&mut self, goal_id: impl Into) { - let goal_id = goal_id.into(); - if self.active_goal_id.as_deref() != Some(goal_id.as_str()) { - self.reset_baseline(); - self.active_goal_id = Some(goal_id); - } - } - - fn clear_active_goal(&mut self) { - self.active_goal_id = None; - self.reset_baseline(); - } - - fn active_goal_id(&self) -> Option { - self.active_goal_id.clone() - } -} - -impl Session { - /// Applies runtime policy for a goal lifecycle event. - /// - /// Goal data methods validate and persist state; this dispatcher owns the - /// cross-cutting runtime behavior: plan mode ignores continuations, turn - /// starts capture the active goal and token baseline, tool completions - /// account usage and may inject budget steering, completion accounting - /// suppresses that steering, external mutations account best-effort before - /// changing state, thread resumes restore runtime state for already-active - /// goals, explicit maybe-continue events - /// start idle goal continuation turns, and continuation turns with no counted - /// autonomous activity suppress the next automatic continuation until - /// user/tool/external activity resets it. - pub(crate) fn goal_runtime_apply<'a>( - self: &'a Arc, - event: GoalRuntimeEvent<'a>, - ) -> BoxFuture<'a, anyhow::Result<()>> { - match event { - GoalRuntimeEvent::TurnStarted { - turn_context, - token_usage, - } => Box::pin(async move { - self.mark_thread_goal_turn_started(turn_context, token_usage) - .await; - Ok(()) - }), - GoalRuntimeEvent::ToolCompleted { - turn_context, - tool_name, - } => Box::pin(async move { - if tool_name != UPDATE_GOAL_TOOL_NAME { - self.account_thread_goal_progress( - turn_context, - BudgetLimitSteering::Allowed, - TerminalMetricEmission::Emit, - ) - .await?; - } - Ok(()) - }), - GoalRuntimeEvent::ToolCompletedGoal { turn_context } => Box::pin(async move { - self.account_thread_goal_progress( - turn_context, - BudgetLimitSteering::Suppressed, - TerminalMetricEmission::Suppress, - ) - .await?; - Ok(()) - }), - GoalRuntimeEvent::TurnFinished { - turn_context, - turn_completed, - } => Box::pin(async move { - self.finish_thread_goal_turn(turn_context, turn_completed) - .await; - Ok(()) - }), - GoalRuntimeEvent::MaybeContinueIfIdle => Box::pin(async move { - self.maybe_continue_goal_if_idle_runtime().await; - Ok(()) - }), - GoalRuntimeEvent::TaskAborted { turn_context } => Box::pin(async move { - self.handle_thread_goal_task_abort(turn_context).await; - Ok(()) - }), - GoalRuntimeEvent::UsageLimitReached { turn_context } => Box::pin(async move { - self.usage_limit_active_thread_goal_for_turn(turn_context) - .await?; - Ok(()) - }), - GoalRuntimeEvent::ExternalMutationStarting => Box::pin(async move { - if let Err(err) = self.account_thread_goal_before_external_mutation().await { - tracing::warn!( - "failed to account thread goal progress before external mutation: {err}" - ); - } - Ok(()) - }), - GoalRuntimeEvent::ExternalSet { external_set } => Box::pin(async move { - self.apply_external_thread_goal_status(external_set).await; - Ok(()) - }), - GoalRuntimeEvent::ExternalClear => Box::pin(async move { - self.clear_stopped_thread_goal_runtime_state().await; - Ok(()) - }), - GoalRuntimeEvent::ThreadResumed => Box::pin(async move { - self.restore_thread_goal_runtime_after_resume().await?; - Ok(()) - }), - } - } - - pub(crate) async fn get_thread_goal(&self) -> anyhow::Result> { - if !self.enabled(Feature::Goals) { - anyhow::bail!("goals feature is disabled"); - } - - let state_db = self.require_state_db_for_thread_goals().await?; - state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await - .map(|goal| goal.map(protocol_goal_from_state)) - } - - pub(crate) async fn set_thread_goal( - &self, - turn_context: &TurnContext, - request: SetGoalRequest, - ) -> anyhow::Result { - if !self.enabled(Feature::Goals) { - anyhow::bail!("goals feature is disabled"); - } - - let SetGoalRequest { - objective, - status, - token_budget, - } = request; - validate_goal_budget(token_budget.flatten())?; - let state_db = self.require_state_db_for_thread_goals().await?; - let objective = objective.map(|objective| objective.trim().to_string()); - if let Some(objective) = objective.as_deref() - && let Err(err) = validate_thread_goal_objective(objective) - { - anyhow::bail!("{err}"); - } - - self.account_thread_goal_wall_clock_usage( - &state_db, - codex_state::GoalAccountingMode::ActiveOnly, - TerminalMetricEmission::Emit, - ) - .await?; - let mut replacing_goal = false; - let previous_status; - let goal = if let Some(objective) = objective.as_deref() { - let existing_goal = state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await?; - previous_status = existing_goal.as_ref().map(|goal| goal.status); - if let Some(existing_goal) = existing_goal.as_ref() { - state_db - .thread_goals() - .update_thread_goal( - self.thread_id, - codex_state::GoalUpdate { - objective: Some(objective.to_string()), - status: status.map(state_goal_status_from_protocol), - token_budget, - expected_goal_id: Some(existing_goal.goal_id.clone()), - }, - ) - .await? - .ok_or_else(|| { - anyhow::anyhow!( - "cannot update goal for thread {}: no goal exists", - self.thread_id - ) - })? - } else { - replacing_goal = true; - state_db - .thread_goals() - .replace_thread_goal( - self.thread_id, - objective, - status - .map(state_goal_status_from_protocol) - .unwrap_or(codex_state::ThreadGoalStatus::Active), - token_budget.flatten(), - ) - .await? - } - } else { - let existing_goal = state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await?; - previous_status = existing_goal.as_ref().map(|goal| goal.status); - let expected_goal_id = existing_goal.map(|goal| goal.goal_id); - let status = status.map(state_goal_status_from_protocol); - state_db - .thread_goals() - .update_thread_goal( - self.thread_id, - codex_state::GoalUpdate { - objective: None, - status, - token_budget, - expected_goal_id, - }, - ) - .await? - .ok_or_else(|| { - anyhow::anyhow!( - "cannot update goal for thread {}: no goal exists", - self.thread_id - ) - })? - }; - - if objective.is_some() { - set_thread_preview_from_goal_objective( - &state_db, - self.thread_id, - goal.objective.as_str(), - ) - .await; - } - let goal_status = goal.status; - let goal_id = goal.goal_id.clone(); - let previous_status_for_goal = if replacing_goal { - None - } else { - previous_status - }; - if replacing_goal { - self.emit_goal_created_metric(); - } - self.emit_goal_resumed_metric_if_status_changed(previous_status_for_goal, goal_status); - self.emit_goal_terminal_metrics_if_status_changed(previous_status_for_goal, &goal); - let goal = protocol_goal_from_state(goal); - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; - let newly_active_goal = goal_status == codex_state::ThreadGoalStatus::Active - && (replacing_goal - || previous_status - .is_some_and(|status| status != codex_state::ThreadGoalStatus::Active)); - if newly_active_goal { - let current_token_usage = self.total_token_usage().await.unwrap_or_default(); - self.mark_active_goal_accounting( - goal_id, - Some(turn_context.sub_id.clone()), - current_token_usage, - ) - .await; - } else if goal_status != codex_state::ThreadGoalStatus::Active { - self.clear_active_goal_accounting(turn_context).await; - } - self.send_event( - turn_context, - EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { - thread_id: self.thread_id, - turn_id: Some(turn_context.sub_id.clone()), - goal: goal.clone(), - }), - ) - .await; - Ok(goal) - } - - pub(crate) async fn create_thread_goal( - &self, - turn_context: &TurnContext, - request: CreateGoalRequest, - ) -> anyhow::Result { - if !self.enabled(Feature::Goals) { - anyhow::bail!("goals feature is disabled"); - } - - let CreateGoalRequest { - objective, - token_budget, - } = request; - validate_goal_budget(token_budget)?; - let objective = objective.trim(); - validate_thread_goal_objective(objective).map_err(anyhow::Error::msg)?; - - let state_db = self.require_state_db_for_thread_goals().await?; - self.account_thread_goal_wall_clock_usage( - &state_db, - codex_state::GoalAccountingMode::ActiveOnly, - TerminalMetricEmission::Emit, - ) - .await?; - let goal = state_db - .thread_goals() - .insert_thread_goal( - self.thread_id, - objective, - codex_state::ThreadGoalStatus::Active, - token_budget, - ) - .await? - .ok_or_else(|| { - anyhow::anyhow!( - "cannot create a new goal because thread {} already has a goal", - self.thread_id - ) - })?; - - set_thread_preview_from_goal_objective(&state_db, self.thread_id, goal.objective.as_str()) - .await; - let goal_id = goal.goal_id.clone(); - self.emit_goal_created_metric(); - let goal = protocol_goal_from_state(goal); - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; - - let current_token_usage = self.total_token_usage().await.unwrap_or_default(); - self.mark_active_goal_accounting( - goal_id, - Some(turn_context.sub_id.clone()), - current_token_usage, - ) - .await; - - self.send_event( - turn_context, - EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { - thread_id: self.thread_id, - turn_id: Some(turn_context.sub_id.clone()), - goal: goal.clone(), - }), - ) - .await; - Ok(goal) - } - - async fn apply_external_thread_goal_status(self: &Arc, external_set: ExternalGoalSet) { - let ExternalGoalSet { - goal, - previous_status, - } = external_set; - let previous_goal = match previous_status { - ExternalGoalPreviousStatus::NewGoal => None, - ExternalGoalPreviousStatus::Existing(goal) => Some(goal), - }; - let replaced_existing_goal = previous_goal - .as_ref() - .is_some_and(|previous_goal| previous_goal.goal_id != goal.goal_id); - if previous_goal.is_none() || replaced_existing_goal { - self.emit_goal_created_metric(); - } - let objective_changed = previous_goal - .as_ref() - .is_some_and(|previous_goal| previous_goal.objective != goal.objective); - let previous_status = previous_goal - .as_ref() - .and_then(|previous_goal| (!replaced_existing_goal).then_some(previous_goal.status)); - self.emit_goal_resumed_metric_if_status_changed(previous_status, goal.status); - self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); - let goal_for_steering = objective_changed.then(|| protocol_goal_from_state(goal.clone())); - let goal_id = goal.goal_id; - let status = goal.status; - match status { - codex_state::ThreadGoalStatus::Active => { - let turn_id = self - .active_turn_context() - .await - .map(|turn_context| turn_context.sub_id.clone()); - let current_token_usage = self.total_token_usage().await.unwrap_or_default(); - self.mark_active_goal_accounting(goal_id, turn_id, current_token_usage) - .await; - if let Some(goal) = goal_for_steering { - let item = goal_context_input_item(objective_updated_prompt(&goal)); - if self.inject_if_running(vec![item]).await.is_err() { - tracing::debug!( - "skipping objective-updated goal steering because no turn is active" - ); - } - } - self.maybe_continue_goal_if_idle_runtime().await; - } - codex_state::ThreadGoalStatus::BudgetLimited => { - if self.active_turn_context().await.is_none() { - self.clear_stopped_thread_goal_runtime_state().await; - } - } - codex_state::ThreadGoalStatus::Paused - | codex_state::ThreadGoalStatus::Blocked - | codex_state::ThreadGoalStatus::UsageLimited - | codex_state::ThreadGoalStatus::Complete => { - self.clear_stopped_thread_goal_runtime_state().await; - } - } - } - - async fn clear_stopped_thread_goal_runtime_state(&self) { - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; - let mut accounting = self.goal_runtime.accounting.lock().await; - if let Some(turn) = accounting.turn.as_mut() { - turn.clear_active_goal(); - } - accounting.wall_clock.clear_active_goal(); - } - - async fn clear_active_goal_accounting(&self, turn_context: &TurnContext) { - let mut accounting = self.goal_runtime.accounting.lock().await; - if let Some(turn) = accounting.turn.as_mut() - && turn.turn_id == turn_context.sub_id - { - turn.clear_active_goal(); - } - accounting.wall_clock.clear_active_goal(); - } - - async fn mark_active_goal_accounting( - &self, - goal_id: String, - turn_id: Option, - token_usage: TokenUsage, - ) { - let mut accounting = self.goal_runtime.accounting.lock().await; - if let Some(turn_id) = turn_id { - match accounting.turn.as_mut() { - Some(turn) if turn.turn_id == turn_id => { - turn.reset_baseline(token_usage); - turn.mark_active_goal(goal_id.clone()); - } - _ => { - let mut turn = GoalTurnAccountingSnapshot::new(turn_id, token_usage); - turn.mark_active_goal(goal_id.clone()); - accounting.turn = Some(turn); - } - } - } - accounting.wall_clock.mark_active_goal(goal_id); - } - - fn emit_goal_created_metric(&self) { - self.services - .session_telemetry - .counter(GOAL_CREATED_METRIC, /*inc*/ 1, &[]); - } - - fn emit_goal_resumed_metric(&self) { - self.services - .session_telemetry - .counter(GOAL_RESUMED_METRIC, /*inc*/ 1, &[]); - } - - fn emit_goal_resumed_metric_if_status_changed( - &self, - previous_status: Option, - goal_status: codex_state::ThreadGoalStatus, - ) { - if goal_status == codex_state::ThreadGoalStatus::Active - && matches!( - previous_status, - Some( - codex_state::ThreadGoalStatus::Paused - | codex_state::ThreadGoalStatus::Blocked - | codex_state::ThreadGoalStatus::UsageLimited - ) - ) - { - self.emit_goal_resumed_metric(); - } - } - - fn emit_goal_terminal_metrics_if_status_changed( - &self, - previous_status: Option, - goal: &codex_state::ThreadGoal, - ) { - if previous_status == Some(goal.status) { - return; - } - - let counter = match goal.status { - codex_state::ThreadGoalStatus::Blocked => GOAL_BLOCKED_METRIC, - codex_state::ThreadGoalStatus::UsageLimited => GOAL_USAGE_LIMITED_METRIC, - codex_state::ThreadGoalStatus::BudgetLimited => GOAL_BUDGET_LIMITED_METRIC, - codex_state::ThreadGoalStatus::Complete => GOAL_COMPLETED_METRIC, - codex_state::ThreadGoalStatus::Active | codex_state::ThreadGoalStatus::Paused => { - return; - } - }; - let status_tag = [("status", goal.status.as_str())]; - self.services - .session_telemetry - .counter(counter, /*inc*/ 1, &[]); - self.services.session_telemetry.histogram( - GOAL_TOKEN_COUNT_METRIC, - goal.tokens_used, - &status_tag, - ); - self.services.session_telemetry.histogram( - GOAL_DURATION_SECONDS_METRIC, - goal.time_used_seconds, - &status_tag, - ); - } - - async fn current_goal_status_for_metrics( - &self, - state_db: &StateDbHandle, - expected_goal_id: Option<&str>, - ) -> anyhow::Result> { - let goal = state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await?; - Ok(goal.and_then(|goal| { - expected_goal_id - .is_none_or(|expected_goal_id| goal.goal_id == expected_goal_id) - .then_some(goal.status) - })) - } - - async fn active_turn_context(&self) -> Option> { - let active = self.active_turn.lock().await; - active - .as_ref() - .and_then(|active_turn| active_turn.task.as_ref()) - .map(|task| Arc::clone(&task.turn_context)) - } - - async fn mark_thread_goal_turn_started( - &self, - turn_context: &TurnContext, - token_usage: TokenUsage, - ) { - self.goal_runtime.accounting.lock().await.turn = Some(GoalTurnAccountingSnapshot::new( - turn_context.sub_id.clone(), - token_usage, - )); - - if !self.enabled(Feature::Goals) { - return; - } - if should_ignore_goal_for_mode(turn_context.collaboration_mode.mode) { - self.clear_active_goal_accounting(turn_context).await; - return; - } - let state_db = match self.state_db_for_thread_goals().await { - Ok(Some(state_db)) => state_db, - Ok(None) => return, - Err(err) => { - tracing::warn!("failed to open state db at turn start: {err}"); - return; - } - }; - match state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await - { - Ok(Some(goal)) - if matches!( - goal.status, - codex_state::ThreadGoalStatus::Active - | codex_state::ThreadGoalStatus::BudgetLimited - ) => - { - let mut accounting = self.goal_runtime.accounting.lock().await; - if let Some(turn) = accounting.turn.as_mut() - && turn.turn_id == turn_context.sub_id - { - turn.mark_active_goal(goal.goal_id.clone()); - } - accounting.wall_clock.mark_active_goal(goal.goal_id); - } - Ok(Some(_)) | Ok(None) => { - self.goal_runtime - .accounting - .lock() - .await - .wall_clock - .clear_active_goal(); - } - Err(err) => { - tracing::warn!("failed to read thread goal at turn start: {err}"); - } - } - } - - async fn clear_reserved_goal_continuation_turn(&self, turn_state: &Arc>) { - let mut active_turn_guard = self.active_turn.lock().await; - if let Some(active_turn) = active_turn_guard.as_ref() - && active_turn.task.is_none() - && Arc::ptr_eq(&active_turn.turn_state, turn_state) - { - *active_turn_guard = None; - } - } - - async fn finish_thread_goal_turn( - self: &Arc, - turn_context: &TurnContext, - turn_completed: bool, - ) { - if turn_completed - && let Err(err) = self - .account_thread_goal_progress( - turn_context, - BudgetLimitSteering::Suppressed, - TerminalMetricEmission::Emit, - ) - .await - { - tracing::warn!("failed to account thread goal progress at turn end: {err}"); - } - - if turn_completed { - let mut accounting = self.goal_runtime.accounting.lock().await; - if accounting - .turn - .as_ref() - .is_some_and(|turn| turn.turn_id == turn_context.sub_id) - { - accounting.turn = None; - } - } - } - - async fn handle_thread_goal_task_abort(&self, turn_context: Option<&TurnContext>) { - if let Some(turn_context) = turn_context { - if let Err(err) = self - .account_thread_goal_progress( - turn_context, - BudgetLimitSteering::Suppressed, - TerminalMetricEmission::Emit, - ) - .await - { - tracing::warn!("failed to account thread goal progress after abort: {err}"); - } - let mut accounting = self.goal_runtime.accounting.lock().await; - if accounting - .turn - .as_ref() - .is_some_and(|turn| turn.turn_id == turn_context.sub_id) - { - accounting.turn = None; - } - } - } - - async fn account_thread_goal_progress( - &self, - turn_context: &TurnContext, - budget_limit_steering: BudgetLimitSteering, - terminal_metric_emission: TerminalMetricEmission, - ) -> anyhow::Result<()> { - if !self.enabled(Feature::Goals) { - return Ok(()); - } - if should_ignore_goal_for_mode(turn_context.collaboration_mode.mode) { - return Ok(()); - } - let Some(state_db) = self.state_db_for_thread_goals().await? else { - return Ok(()); - }; - let _accounting_permit = self.goal_runtime.accounting_permit().await?; - let current_token_usage = self.total_token_usage().await.unwrap_or_default(); - let (token_delta, expected_goal_id, time_delta_seconds) = { - let accounting = self.goal_runtime.accounting.lock().await; - let Some(turn) = accounting - .turn - .as_ref() - .filter(|turn| turn.turn_id == turn_context.sub_id) - else { - return Ok(()); - }; - if !turn.active_this_turn() { - return Ok(()); - } - ( - turn.token_delta_since_last_accounting(¤t_token_usage), - turn.active_goal_id(), - accounting.wall_clock.time_delta_since_last_accounting(), - ) - }; - if time_delta_seconds == 0 && token_delta <= 0 { - return Ok(()); - } - let previous_status = self - .current_goal_status_for_metrics(&state_db, expected_goal_id.as_deref()) - .await?; - let outcome = state_db - .thread_goals() - .account_thread_goal_usage( - self.thread_id, - time_delta_seconds, - token_delta, - codex_state::GoalAccountingMode::ActiveOnly, - expected_goal_id.as_deref(), - ) - .await?; - let budget_limit_was_already_reported = { - let reported_goal_id = self.goal_runtime.budget_limit_reported_goal_id.lock().await; - expected_goal_id - .as_deref() - .is_some_and(|goal_id| reported_goal_id.as_deref() == Some(goal_id)) - }; - let goal = match outcome { - codex_state::GoalAccountingOutcome::Updated(goal) => { - let clear_active_goal = match goal.status { - codex_state::ThreadGoalStatus::Active => false, - codex_state::ThreadGoalStatus::BudgetLimited => { - matches!(budget_limit_steering, BudgetLimitSteering::Suppressed) - } - codex_state::ThreadGoalStatus::Paused - | codex_state::ThreadGoalStatus::Blocked - | codex_state::ThreadGoalStatus::UsageLimited - | codex_state::ThreadGoalStatus::Complete => true, - }; - { - let mut accounting = self.goal_runtime.accounting.lock().await; - if let Some(turn) = accounting - .turn - .as_mut() - .filter(|turn| turn.turn_id == turn_context.sub_id) - { - turn.mark_accounted(current_token_usage); - if clear_active_goal { - turn.clear_active_goal(); - } - } - accounting.wall_clock.mark_accounted(time_delta_seconds); - if clear_active_goal { - accounting.wall_clock.clear_active_goal(); - } - } - if matches!(terminal_metric_emission, TerminalMetricEmission::Emit) { - self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); - } - goal - } - codex_state::GoalAccountingOutcome::Unchanged(_) => return Ok(()), - }; - let should_steer_budget_limit = - matches!(budget_limit_steering, BudgetLimitSteering::Allowed) - && goal.status == codex_state::ThreadGoalStatus::BudgetLimited - && !budget_limit_was_already_reported; - let goal_status = goal.status; - let goal_id = goal.goal_id.clone(); - if goal_status != codex_state::ThreadGoalStatus::BudgetLimited { - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; - } - let goal = protocol_goal_from_state(goal); - self.send_event( - turn_context, - EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { - thread_id: self.thread_id, - turn_id: Some(turn_context.sub_id.clone()), - goal: goal.clone(), - }), - ) - .await; - if should_steer_budget_limit { - let item = budget_limit_steering_item(&goal); - if self.inject_if_running(vec![item]).await.is_err() { - tracing::debug!("skipping budget-limit goal steering because no turn is active"); - } - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = Some(goal_id); - } - Ok(()) - } - - async fn account_thread_goal_before_external_mutation(&self) -> anyhow::Result<()> { - if let Some(turn_context) = self.active_turn_context().await { - return self - .account_thread_goal_progress( - turn_context.as_ref(), - BudgetLimitSteering::Suppressed, - TerminalMetricEmission::Emit, - ) - .await; - } - - let Some(state_db) = self.state_db_for_thread_goals().await? else { - return Ok(()); - }; - self.account_thread_goal_wall_clock_usage( - &state_db, - codex_state::GoalAccountingMode::ActiveOnly, - TerminalMetricEmission::Suppress, - ) - .await?; - Ok(()) - } - - async fn account_thread_goal_wall_clock_usage( - &self, - state_db: &StateDbHandle, - mode: codex_state::GoalAccountingMode, - terminal_metric_emission: TerminalMetricEmission, - ) -> anyhow::Result> { - let _accounting_permit = self.goal_runtime.accounting_permit().await?; - let (time_delta_seconds, expected_goal_id) = { - let accounting = self.goal_runtime.accounting.lock().await; - ( - accounting.wall_clock.time_delta_since_last_accounting(), - accounting.wall_clock.active_goal_id(), - ) - }; - if time_delta_seconds == 0 { - return Ok(None); - } - let previous_status = self - .current_goal_status_for_metrics(state_db, expected_goal_id.as_deref()) - .await?; - - match state_db - .thread_goals() - .account_thread_goal_usage( - self.thread_id, - time_delta_seconds, - /*token_delta*/ 0, - mode, - expected_goal_id.as_deref(), - ) - .await? - { - codex_state::GoalAccountingOutcome::Updated(goal) => { - if matches!(terminal_metric_emission, TerminalMetricEmission::Emit) { - self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); - } - self.goal_runtime - .accounting - .lock() - .await - .wall_clock - .mark_accounted(time_delta_seconds); - let goal = protocol_goal_from_state(goal); - Ok(Some(goal)) - } - codex_state::GoalAccountingOutcome::Unchanged(goal) => { - { - let mut accounting = self.goal_runtime.accounting.lock().await; - accounting.wall_clock.reset_baseline(); - accounting.wall_clock.clear_active_goal(); - } - if let Some(goal) = goal { - let goal = protocol_goal_from_state(goal); - return Ok(Some(goal)); - } - Ok(None) - } - } - } - - async fn usage_limit_active_thread_goal_for_turn( - &self, - turn_context: &TurnContext, - ) -> anyhow::Result<()> { - if should_ignore_goal_for_mode(turn_context.collaboration_mode.mode) { - return Ok(()); - } - - if !self.enabled(Feature::Goals) { - return Ok(()); - } - - let _continuation_guard = self - .goal_runtime - .continuation_lock - .acquire() - .await - .context("goal continuation semaphore closed")?; - let Some(state_db) = self.state_db_for_thread_goals().await? else { - return Ok(()); - }; - self.account_thread_goal_progress( - turn_context, - BudgetLimitSteering::Suppressed, - TerminalMetricEmission::Emit, - ) - .await?; - let previous_status = self - .current_goal_status_for_metrics(&state_db, /*expected_goal_id*/ None) - .await?; - let Some(goal) = state_db - .thread_goals() - .usage_limit_active_thread_goal(self.thread_id) - .await? - else { - return Ok(()); - }; - self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); - let goal = protocol_goal_from_state(goal); - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; - self.clear_active_goal_accounting(turn_context).await; - self.send_event( - turn_context, - EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { - thread_id: self.thread_id, - turn_id: Some(turn_context.sub_id.clone()), - goal, - }), - ) - .await; - Ok(()) - } - - async fn restore_thread_goal_runtime_after_resume(&self) -> anyhow::Result<()> { - if !self.enabled(Feature::Goals) { - return Ok(()); - } - if should_ignore_goal_for_mode(self.collaboration_mode().await.mode) { - tracing::debug!( - "skipping goal runtime restore while current collaboration mode ignores goals" - ); - return Ok(()); - } - - let _continuation_guard = self - .goal_runtime - .continuation_lock - .acquire() - .await - .context("goal continuation semaphore closed")?; - let Some(state_db) = self.state_db_for_thread_goals().await? else { - return Ok(()); - }; - let Some(goal) = state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await? - else { - self.clear_stopped_thread_goal_runtime_state().await; - return Ok(()); - }; - match goal.status { - codex_state::ThreadGoalStatus::Active => { - self.goal_runtime - .accounting - .lock() - .await - .wall_clock - .mark_active_goal(goal.goal_id); - self.emit_goal_resumed_metric(); - } - codex_state::ThreadGoalStatus::Paused - | codex_state::ThreadGoalStatus::Blocked - | codex_state::ThreadGoalStatus::UsageLimited - | codex_state::ThreadGoalStatus::BudgetLimited - | codex_state::ThreadGoalStatus::Complete => { - self.clear_stopped_thread_goal_runtime_state().await; - } - } - Ok(()) - } - - async fn maybe_continue_goal_if_idle_runtime(self: &Arc) { - self.maybe_start_turn_for_pending_work().await; - self.maybe_start_goal_continuation_turn().await; - } - - async fn maybe_start_goal_continuation_turn(self: &Arc) { - let Ok(_continuation_guard) = self.goal_runtime.continuation_lock.acquire().await else { - tracing::warn!("goal continuation semaphore closed"); - return; - }; - let Some(candidate) = self.goal_continuation_candidate_if_active().await else { - return; - }; - - let turn_state = { - let mut active_turn = self.active_turn.lock().await; - if active_turn.is_some() { - return; - } - let active_turn = active_turn.get_or_insert_with(ActiveTurn::default); - Arc::clone(&active_turn.turn_state) - }; - let goal_is_current = match self.state_db_for_thread_goals().await { - Ok(Some(state_db)) => match state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await - { - Ok(Some(goal)) - if goal.goal_id == candidate.goal_id - && goal.status == codex_state::ThreadGoalStatus::Active => - { - true - } - Ok(Some(_)) | Ok(None) => { - tracing::debug!( - "skipping active goal continuation because the goal changed before launch" - ); - false - } - Err(err) => { - tracing::warn!("failed to re-read thread goal before continuation: {err}"); - false - } - }, - Ok(None) => { - tracing::debug!("skipping active goal continuation for ephemeral thread"); - false - } - Err(err) => { - tracing::warn!("failed to open state db before goal continuation: {err}"); - false - } - }; - if !goal_is_current { - self.clear_reserved_goal_continuation_turn(&turn_state) - .await; - return; - } - self.input_queue - .extend_pending_input_for_turn_state( - turn_state.as_ref(), - candidate - .items - .into_iter() - .map(TurnInput::ResponseItem) - .collect(), - ) - .await; - - let turn_context = self - .new_default_turn_with_sub_id(uuid::Uuid::new_v4().to_string()) - .await; - self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) - .await; - let still_reserved = { - let active_turn = self.active_turn.lock().await; - active_turn.as_ref().is_some_and(|active_turn| { - active_turn.task.is_none() && Arc::ptr_eq(&active_turn.turn_state, &turn_state) - }) - }; - if !still_reserved { - self.clear_reserved_goal_continuation_turn(&turn_state) - .await; - return; - } - self.start_task(turn_context, Vec::new(), RegularTask::new()) - .await; - } - - async fn goal_continuation_candidate_if_active( - self: &Arc, - ) -> Option { - if !self.enabled(Feature::Goals) { - return None; - } - if should_ignore_goal_for_mode(self.collaboration_mode().await.mode) { - tracing::debug!("skipping active goal continuation while plan mode is active"); - return None; - } - if self.active_turn.lock().await.is_some() { - tracing::debug!("skipping active goal continuation because a turn is already active"); - return None; - } - if self.input_queue.has_trigger_turn_mailbox_items().await { - tracing::debug!( - "skipping active goal continuation because trigger-turn mailbox input is pending" - ); - return None; - } - let state_db = match self.state_db_for_thread_goals().await { - Ok(Some(state_db)) => state_db, - Ok(None) => { - tracing::debug!("skipping active goal continuation for ephemeral thread"); - return None; - } - Err(err) => { - tracing::warn!("failed to open state db for goal continuation: {err}"); - return None; - } - }; - let goal = match state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await - { - Ok(Some(goal)) => goal, - Ok(None) => { - tracing::debug!("skipping active goal continuation because no goal is set"); - return None; - } - Err(err) => { - tracing::warn!("failed to read thread goal for continuation: {err}"); - return None; - } - }; - if goal.status != codex_state::ThreadGoalStatus::Active { - tracing::debug!(status = ?goal.status, "skipping inactive thread goal"); - return None; - } - if self.active_turn.lock().await.is_some() - || self.input_queue.has_trigger_turn_mailbox_items().await - { - tracing::debug!("skipping active goal continuation because pending work appeared"); - return None; - } - let goal_id = goal.goal_id.clone(); - let goal = protocol_goal_from_state(goal); - Some(GoalContinuationCandidate { - goal_id, - items: vec![goal_context_input_item(continuation_prompt(&goal))], - }) - } -} - -impl Session { - async fn state_db_for_thread_goals(&self) -> anyhow::Result> { - let config = self.get_config().await; - if config.ephemeral { - return Ok(None); - } - - self.try_ensure_rollout_materialized() - .await - .context("failed to materialize rollout before opening state db for thread goals")?; - - let state_db = if let Some(state_db) = self.state_db() { - state_db - } else if let Some(state_db) = self.goal_runtime.state_db.lock().await.clone() { - state_db - } else if let Some(local_store) = self - .services - .thread_store - .as_any() - .downcast_ref::() - { - local_store.state_db().await.ok_or_else(|| { - anyhow::anyhow!( - "thread goals require a local persisted thread with a state database" - ) - })? - } else { - anyhow::bail!("thread goals require a local persisted thread with a state database"); - }; - - let thread_metadata_present = state_db - .get_thread(self.thread_id) - .await - .context("failed to read thread metadata before reconciling thread goals")? - .is_some(); - if !thread_metadata_present { - let rollout_path = self - .current_rollout_path() - .await - .context("failed to locate rollout before reconciling thread goals")? - .ok_or_else(|| { - anyhow::anyhow!("thread goals require materialized thread metadata") - })?; - reconcile_rollout( - Some(&state_db), - rollout_path.as_path(), - config.model_provider_id.as_str(), - /*builder*/ None, - &[], - /*archived_only*/ None, - /*new_thread_memory_mode*/ None, - ) - .await; - let thread_metadata_present = state_db - .get_thread(self.thread_id) - .await - .context("failed to read thread metadata after reconciling thread goals")? - .is_some(); - if !thread_metadata_present { - anyhow::bail!("thread metadata is unavailable after reconciling thread goals"); - } - } - - *self.goal_runtime.state_db.lock().await = Some(state_db.clone()); - Ok(Some(state_db)) - } - - async fn require_state_db_for_thread_goals(&self) -> anyhow::Result { - self.state_db_for_thread_goals().await?.ok_or_else(|| { - anyhow::anyhow!("thread goals require a persisted thread; this thread is ephemeral") - }) - } -} - -async fn set_thread_preview_from_goal_objective( - state_db: &StateDbHandle, - thread_id: ThreadId, - objective: &str, -) { - if let Err(err) = state_db - .set_thread_preview_if_empty(thread_id, objective) - .await - { - tracing::warn!( - "failed to set empty thread preview from goal objective for {thread_id}: {err}" - ); - } -} - -fn should_ignore_goal_for_mode(mode: ModeKind) -> bool { - mode == ModeKind::Plan -} - -fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseItem { - goal_context_input_item(budget_limit_prompt(goal)) -} - -fn goal_context_input_item(prompt: String) -> ResponseItem { - ContextualUserFragment::into(InternalModelContextFragment::new( - InternalContextSource::from_static("goal"), - prompt, - )) -} - -pub(crate) fn protocol_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal { - ThreadGoal { - thread_id: goal.thread_id, - objective: goal.objective, - status: protocol_goal_status_from_state(goal.status), - token_budget: goal.token_budget, - tokens_used: goal.tokens_used, - time_used_seconds: goal.time_used_seconds, - created_at: goal.created_at.timestamp(), - updated_at: goal.updated_at.timestamp(), - } -} - -pub(crate) fn protocol_goal_status_from_state( - status: codex_state::ThreadGoalStatus, -) -> ThreadGoalStatus { - match status { - codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active, - codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, - codex_state::ThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked, - codex_state::ThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited, - codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, - codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete, - } -} - -pub(crate) fn state_goal_status_from_protocol( - status: ThreadGoalStatus, -) -> codex_state::ThreadGoalStatus { - match status { - ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active, - ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused, - ThreadGoalStatus::Blocked => codex_state::ThreadGoalStatus::Blocked, - ThreadGoalStatus::UsageLimited => codex_state::ThreadGoalStatus::UsageLimited, - ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited, - ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete, - } -} - -pub(crate) fn validate_goal_budget(value: Option) -> anyhow::Result<()> { - if let Some(value) = value - && value <= 0 - { - anyhow::bail!("goal budgets must be positive when provided"); - } - Ok(()) -} - -pub(crate) fn goal_token_delta_for_usage(usage: &TokenUsage) -> i64 { - usage - .non_cached_input() - .saturating_add(usage.output_tokens.max(0)) -} - -#[cfg(test)] -mod tests { - use super::goal_context_input_item; - use super::goal_token_delta_for_usage; - use super::should_ignore_goal_for_mode; - use codex_protocol::config_types::ModeKind; - use codex_protocol::models::ContentItem; - use codex_protocol::models::ResponseItem; - use codex_protocol::protocol::TokenUsage; - use std::time::Duration; - use std::time::Instant; - - #[test] - fn goal_continuation_is_ignored_only_in_plan_mode() { - assert!(should_ignore_goal_for_mode(ModeKind::Plan)); - assert!(!should_ignore_goal_for_mode(ModeKind::Default)); - assert!(!should_ignore_goal_for_mode(ModeKind::PairProgramming)); - assert!(!should_ignore_goal_for_mode(ModeKind::Execute)); - } - - #[test] - fn goal_token_delta_excludes_cached_input_and_does_not_double_count_reasoning() { - let usage = TokenUsage { - input_tokens: 900, - cached_input_tokens: 400, - output_tokens: 80, - reasoning_output_tokens: 20, - total_tokens: 1_000, - }; - - assert_eq!(580, goal_token_delta_for_usage(&usage)); - } - - #[test] - fn wall_clock_accounting_advances_by_persisted_seconds() { - let mut snapshot = super::GoalWallClockAccountingSnapshot::new(); - let original = Instant::now() - Duration::from_millis(1500); - snapshot.last_accounted_at = original; - - snapshot.mark_accounted(/*accounted_seconds*/ 1); - assert_eq!( - original + Duration::from_secs(1), - snapshot.last_accounted_at - ); - - let token_only_original = snapshot.last_accounted_at; - snapshot.mark_accounted(/*accounted_seconds*/ 0); - assert_eq!(token_only_original, snapshot.last_accounted_at); - } - - #[test] - fn goal_context_input_item_is_hidden_user_context() { - let item = goal_context_input_item("Continue working.".to_string()); - - assert_eq!( - item, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "\nContinue working.\n".to_string(), - }], - phase: None, - } - ); - } -} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 4dc19de7e674..400c80368084 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -39,9 +39,6 @@ pub mod exec_env; mod exec_policy; #[cfg(test)] mod git_info_tests; -mod goals; -pub use goals::ExternalGoalPreviousStatus; -pub use goals::ExternalGoalSet; mod guardian; mod hook_runtime; mod installation_id; diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index ff4c77aab9f2..a3b23c6a78b5 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -26,7 +26,6 @@ pub(super) async fn spawn_review_thread( let _ = review_features.disable(Feature::WebSearchCached); let _ = review_features.disable(Feature::Goals); let review_web_search_mode = WebSearchMode::Disabled; - let goal_tools_supported = !config.ephemeral && parent_turn_context.goal_tools_enabled(); let available_models = sess .services .models_manager @@ -126,7 +125,6 @@ pub(super) async fn spawn_review_thread( environments: parent_turn_context.environments.clone(), available_models, unified_exec_shell_mode, - goal_tools_supported, features: review_features, ghost_snapshot: parent_turn_context.ghost_snapshot.clone(), current_date: parent_turn_context.current_date.clone(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index e7ec3b98dbd9..122ff6aeb9d1 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -2,7 +2,6 @@ use super::input_queue::InputQueue; use super::*; use crate::agents_md::LoadedAgentsMd; use crate::config::ConstraintError; -use crate::goals::GoalRuntimeState; use crate::skills::SkillError; use crate::state::ActiveTurn; use codex_protocol::SessionId; @@ -37,7 +36,6 @@ pub(crate) struct Session { pub(crate) conversation: Arc, pub(crate) active_turn: Mutex>, pub(crate) input_queue: InputQueue, - pub(crate) goal_runtime: GoalRuntimeState, pub(crate) guardian_review_session: GuardianReviewSessionManager, pub(crate) services: SessionServices, pub(super) next_internal_sub_id: AtomicU64, @@ -1059,7 +1057,6 @@ impl Session { conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), input_queue: InputQueue::new(), - goal_runtime: GoalRuntimeState::new(), guardian_review_session: GuardianReviewSessionManager::default(), services, next_internal_sub_id: AtomicU64::new(0), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index cf9c7c48d3eb..366cf6f22ac1 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -57,11 +57,6 @@ use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use tracing::Span; -use crate::goals::CreateGoalRequest; -use crate::goals::ExternalGoalPreviousStatus; -use crate::goals::ExternalGoalSet; -use crate::goals::GoalRuntimeEvent; -use crate::goals::SetGoalRequest; use crate::rollout::recorder::RolloutRecorder; use crate::state::ActiveTurn; use crate::state::TaskKind; @@ -72,11 +67,9 @@ use crate::tasks::execute_user_shell_command; use crate::tools::ToolRouter; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; -use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::ExecCommandHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::ShellCommandHandler; -use crate::tools::handlers::UpdateGoalHandler; use crate::tools::registry::ToolExecutor; use crate::tools::router::ToolCallSource; use crate::turn_diff_tracker::TurnDiffTracker; @@ -128,7 +121,6 @@ use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::Submission; -use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TokenCountEvent; @@ -139,28 +131,21 @@ use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use codex_protocol::protocol::W3cTraceContext; -use codex_protocol::request_user_input::RequestUserInputAnswer; -use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationAction; use core_test_support::PathBufExt; use core_test_support::PathExt; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::context_snapshot::ContextSnapshotRenderMode; -use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_completed_with_tokens; -use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; -use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::test_codex; use core_test_support::test_path_buf; use core_test_support::tracing::install_test_tracing; use core_test_support::wait_for_event; -use core_test_support::wait_for_event_match; use opentelemetry::trace::TraceContextExt; use opentelemetry::trace::TraceId; use opentelemetry_sdk::metrics::InMemoryMetricExporter; @@ -4898,7 +4883,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { session_configuration.cwd.clone(), "turn_id".to_string(), skills_outcome, - /*goal_tools_supported*/ true, ); let session = Session { @@ -4915,7 +4899,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), input_queue: super::input_queue::InputQueue::new(), - goal_runtime: crate::goals::GoalRuntimeState::new(), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, next_internal_sub_id: AtomicU64::new(0), @@ -6782,18 +6765,7 @@ where let (tx_event, rx_event) = async_channel::unbounded(); let mut config = build_test_config(codex_home).await; configure_config(&mut config); - let state_db = if config.features.enabled(Feature::Goals) { - Some( - codex_state::StateRuntime::init( - config.sqlite_home.clone(), - config.model_provider_id.clone(), - ) - .await - .expect("goal tests should initialize sqlite state db"), - ) - } else { - None - }; + let state_db = None; let config = Arc::new(config); let thread_id = ThreadId::default(); let auth_manager = AuthManager::from_auth_for_testing(auth); @@ -6988,7 +6960,6 @@ where session_configuration.cwd.clone(), "turn_id".to_string(), skills_outcome, - /*goal_tools_supported*/ true, )); let session = Arc::new(Session { @@ -7005,7 +6976,6 @@ where conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), input_queue: super::input_queue::InputQueue::new(), - goal_runtime: crate::goals::GoalRuntimeState::new(), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, next_internal_sub_id: AtomicU64::new(0), @@ -7029,52 +6999,6 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( .await } -async fn make_goal_session_and_context_with_rx() -> ( - Arc, - Arc, - async_channel::Receiver, - tempfile::TempDir, -) { - let codex_home = tempfile::tempdir().expect("create temp dir"); - let (session, turn_context, rx) = make_session_and_context_with_auth_config_home_and_rx( - CodexAuth::from_api_key("Test API Key"), - Vec::new(), - codex_home.path(), - |config| { - config - .features - .enable(Feature::Goals) - .expect("goal mode should be enableable in tests"); - }, - ) - .await; - upsert_goal_test_thread(session.as_ref()).await; - (session, turn_context, rx, codex_home) -} - -async fn upsert_goal_test_thread(session: &Session) { - let config = session.get_config().await; - let state_db = session - .state_db() - .expect("goal test session should have a state db"); - let mut builder = codex_state::ThreadMetadataBuilder::new( - session.thread_id, - config - .codex_home - .join("goal-test-rollout.jsonl") - .to_path_buf(), - chrono::Utc::now(), - SessionSource::Cli, - ); - builder.cwd = config.cwd.to_path_buf(); - builder.model_provider = Some(config.model_provider_id.clone()); - let metadata = builder.build(config.model_provider_id.as_str()); - state_db - .upsert_thread(&metadata) - .await - .expect("goal test thread should be upserted"); -} - // Like make_session_and_context, but returns Arc and the event receiver // so tests can assert on emitted events. pub(crate) async fn make_session_and_context_with_rx() -> ( @@ -9131,926 +9055,113 @@ async fn abort_empty_active_turn_preserves_pending_input() { ); } -#[tokio::test] -async fn interrupt_accounts_active_goal_without_pausing() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.set_thread_goal( - tc.as_ref(), - SetGoalRequest { - objective: Some("Keep improving the benchmark".to_string()), - status: None, - token_budget: None, - }, - ) - .await?; +async fn set_total_token_usage(sess: &Session, total_token_usage: TokenUsage) { + let mut state = sess.state.lock().await; + state.set_token_info(Some(TokenUsageInfo { + total_token_usage, + last_token_usage: TokenUsage::default(), + model_context_window: None, + })); +} +#[tokio::test] +async fn queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary() { + let (sess, tc, _rx) = make_session_and_context_with_rx().await; + let communication = InterAgentCommunication::new( + AgentPath::try_from("/root/worker").expect("worker path should parse"), + AgentPath::root(), + Vec::new(), + "late queue-only update".to_string(), + /*trigger_turn*/ false, + ); sess.spawn_task( Arc::clone(&tc), Vec::new(), NeverEndingTask { kind: TaskKind::Regular, - listen_to_cancellation_token: false, + listen_to_cancellation_token: true, }, ) .await; - set_total_token_usage(&sess, post_goal_token_usage()).await; - sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + sess.input_queue + .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) + .await; + sess.input_queue + .enqueue_mailbox_communication(communication.clone()) + .await; - let goal = sess - .get_thread_goal() - .await? - .expect("goal should remain persisted after interrupt"); + assert!( + !sess.input_queue.has_pending_input(&sess.active_turn).await, + "queue-only mailbox mail should stay buffered once the current turn emitted its answer" + ); assert_eq!( - codex_protocol::protocol::ThreadGoalStatus::Active, - goal.status + sess.input_queue.get_pending_input(&sess.active_turn).await, + Vec::new() ); - assert_eq!(70, goal.tokens_used); - - assert!(sess.active_turn.lock().await.is_none()); - - Ok(()) -} - -#[tokio::test] -async fn shutdown_without_active_turn_keeps_active_goal_active() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.set_thread_goal( - tc.as_ref(), - SetGoalRequest { - objective: Some("Keep improving the benchmark".to_string()), - status: None, - token_budget: None, - }, - ) - .await?; - assert!(sess.active_turn.lock().await.is_none()); - assert!(handlers::shutdown(&sess, "shutdown".to_string()).await); + sess.abort_all_tasks(TurnAbortReason::Replaced).await; - let goal = sess - .get_thread_goal() - .await? - .expect("goal should remain persisted after shutdown"); assert_eq!( - codex_protocol::protocol::ThreadGoalStatus::Active, - goal.status + sess.input_queue.get_pending_input(&sess.active_turn).await, + vec![TurnInput::ResponseItem(ResponseItem::from( + communication.to_response_input_item() + ))], ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn active_goal_continuation_runs_again_after_no_tool_turn() -> anyhow::Result<()> { - let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::Goals) - .expect("goal mode should be enableable in tests"); - }); - let test = builder.build(&server).await?; - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - "call-create-goal", - "create_goal", - r#"{"objective":"write a benchmark note"}"#, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "Draft ready."), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_assistant_message("msg-2", "I am still working on the benchmark note."), - ev_completed("resp-3"), - ]), - sse(vec![ - ev_response_created("resp-4"), - ev_function_call( - "call-complete-goal", - "update_goal", - r#"{"status":"complete"}"#, - ), - ev_completed("resp-4"), - ]), - sse(vec![ - ev_assistant_message("msg-3", "Goal complete."), - ev_completed("resp-5"), - ]), - ], - ) - .await; - - test.codex - .submit(Op::UserInput { - environments: None, - items: vec![UserInput::Text { - text: "write a benchmark note".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) - .await?; - - let mut completed_turns = 0; - tokio::time::timeout(std::time::Duration::from_secs(8), async { - loop { - let event = test.codex.next_event().await?; - if matches!(event.msg, EventMsg::TurnComplete(_)) { - completed_turns += 1; - if completed_turns == 3 { - return anyhow::Ok(()); - } - } - } - }) - .await??; - - let goal_context_text = responses - .requests() - .into_iter() - .flat_map(|request| request.message_input_texts("user")) - .find(|text| text.contains("")) - .expect("goal context message should be present"); - assert!(goal_context_text.contains("Continue working toward the active thread goal.")); - - Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn pending_request_user_input_does_not_spawn_extra_goal_continuation() -> anyhow::Result<()> { - let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::Goals) - .expect("goal mode should be enableable in tests"); - config - .features - .enable(Feature::DefaultModeRequestUserInput) - .expect("default-mode request_user_input should be enableable in tests"); - }); - let test = builder.build(&server).await?; - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - "call-create-goal", - "create_goal", - r#"{"objective":"write a benchmark note"}"#, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "Draft ready."), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_response_created("resp-3"), - ev_function_call( - "call-ask-user", - "request_user_input", - r#"{"questions":[{"header":"Choice","id":"next_step","question":"Pick one","options":[{"label":"Outline","description":"Start with an outline."},{"label":"Draft","description":"Write a full draft."}]}]}"#, - ), - ev_completed("resp-3"), - ]), - sse(vec![ - ev_response_created("resp-4"), - ev_function_call( - "call-complete-goal", - "update_goal", - r#"{"status":"complete"}"#, - ), - ev_completed("resp-4"), - ]), - sse(vec![ - ev_assistant_message("msg-2", "Goal complete."), - ev_completed("resp-5"), - ]), - ], +#[tokio::test] +async fn trigger_turn_mailbox_mail_waits_for_next_turn_after_answer_boundary() { + let (sess, tc, _rx) = make_session_and_context_with_rx().await; + sess.spawn_task( + Arc::clone(&tc), + Vec::new(), + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: true, + }, ) .await; - test.codex - .submit(Op::UserInput { - environments: None, - items: vec![UserInput::Text { - text: "write a benchmark note".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) - .await?; + sess.input_queue + .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) + .await; + sess.input_queue + .enqueue_mailbox_communication(InterAgentCommunication::new( + AgentPath::try_from("/root/worker").expect("worker path should parse"), + AgentPath::root(), + Vec::new(), + "late trigger update".to_string(), + /*trigger_turn*/ true, + )) + .await; - let request_user_input_event = wait_for_event_match(&test.codex, |event| match event { - EventMsg::RequestUserInput(event) => Some(event.clone()), - _ => None, - }) - .await; - assert_eq!(3, responses.requests().len()); assert!( - timeout(Duration::from_millis(200), test.codex.next_event()) - .await - .is_err(), - "waiting for request_user_input should keep the turn open without emitting more events" + !sess.input_queue.has_pending_input(&sess.active_turn).await, + "trigger-turn mailbox mail should not extend the current turn after its answer boundary" ); - assert_eq!( - 3, - responses.requests().len(), - "waiting for request_user_input should not start another continuation request" - ); - - test.codex - .submit(Op::UserInputAnswer { - id: request_user_input_event.turn_id, - response: RequestUserInputResponse { - answers: std::collections::HashMap::from([( - "next_step".to_string(), - RequestUserInputAnswer { - answers: vec!["Outline".to_string()], - }, - )]), - }, - }) - .await?; - - let mut completed_turns = 0; - timeout(Duration::from_secs(8), async { - loop { - let event = test.codex.next_event().await?; - if matches!(event.msg, EventMsg::TurnComplete(_)) { - completed_turns += 1; - if completed_turns == 1 { - return anyhow::Ok(()); - } - } - } - }) - .await??; - - assert_eq!(5, responses.requests().len()); - - Ok(()) -} - -async fn set_total_token_usage(sess: &Session, total_token_usage: TokenUsage) { - let mut state = sess.state.lock().await; - state.set_token_info(Some(TokenUsageInfo { - total_token_usage, - last_token_usage: TokenUsage::default(), - model_context_window: None, - })); -} -fn post_goal_token_usage() -> TokenUsage { - TokenUsage { - input_tokens: 50, - cached_input_tokens: 10, - output_tokens: 30, - reasoning_output_tokens: 5, - total_tokens: 75, - } -} + sess.abort_all_tasks(TurnAbortReason::Replaced).await; -async fn goal_test_state_db(sess: &Session) -> anyhow::Result { - if let Some(state_db) = sess.state_db() { - return Ok(state_db); - } - let config = sess.get_config().await; - codex_state::StateRuntime::init(config.sqlite_home.clone(), config.model_provider_id.clone()) - .await + assert!(sess.input_queue.has_trigger_turn_mailbox_items().await); } #[tokio::test] -async fn create_thread_goal_fills_empty_thread_preview() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - let state_db = goal_test_state_db(sess.as_ref()).await?; - - let page = state_db - .list_threads( - /*page_size*/ 10, - codex_state::ThreadFilterOptions { - archived_only: false, - allowed_sources: &[], - model_providers: None, - cwd_filters: None, - anchor: None, - sort_key: codex_state::SortKey::UpdatedAt, - sort_direction: codex_state::SortDirection::Desc, - search_term: None, - }, - ) - .await?; - assert!(page.items.is_empty()); - - sess.create_thread_goal( - tc.as_ref(), - CreateGoalRequest { - objective: "Keep improving the benchmark".to_string(), - token_budget: None, - }, - ) - .await?; - - let page = state_db - .list_threads( - /*page_size*/ 10, - codex_state::ThreadFilterOptions { - archived_only: false, - allowed_sources: &[], - model_providers: None, - cwd_filters: None, - anchor: None, - sort_key: codex_state::SortKey::UpdatedAt, - sort_direction: codex_state::SortDirection::Desc, - search_term: None, - }, - ) - .await?; - let ids = page - .items - .iter() - .map(|thread| thread.id) - .collect::>(); - assert_eq!(vec![sess.thread_id], ids); - assert_eq!( - Some("Keep improving the benchmark"), - page.items[0].preview.as_deref() +async fn steered_input_reopens_mailbox_delivery_for_current_turn() { + let (sess, tc, _rx) = make_session_and_context_with_rx().await; + let communication = InterAgentCommunication::new( + AgentPath::try_from("/root/worker").expect("worker path should parse"), + AgentPath::root(), + Vec::new(), + "queued child update".to_string(), + /*trigger_turn*/ false, ); - - Ok(()) -} - -#[tokio::test] -async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyhow::Result<()> { - let (sess, tc, rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.set_thread_goal( - tc.as_ref(), - SetGoalRequest { - objective: Some("Keep improving the benchmark".to_string()), - status: None, - token_budget: Some(Some(10)), - }, - ) - .await?; - sess.goal_runtime_apply(GoalRuntimeEvent::TurnStarted { - turn_context: tc.as_ref(), - token_usage: TokenUsage::default(), - }) - .await?; sess.spawn_task( Arc::clone(&tc), Vec::new(), NeverEndingTask { kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - while rx.try_recv().is_ok() {} - - set_total_token_usage( - &sess, - TokenUsage { - input_tokens: 20, - cached_input_tokens: 0, - output_tokens: 5, - reasoning_output_tokens: 0, - total_tokens: 25, - }, - ) - .await; - - sess.goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { - turn_context: tc.as_ref(), - tool_name: "shell_command", - }) - .await?; - - let pending_input = sess.input_queue.get_pending_input(&sess.active_turn).await; - let [TurnInput::ResponseItem(ResponseItem::Message { role, content, .. })] = - pending_input.as_slice() - else { - panic!("expected one budget-limit steering message, got {pending_input:#?}"); - }; - assert_eq!("user", role); - let [ContentItem::InputText { text }] = content.as_slice() else { - panic!("expected one text span in budget-limit steering message, got {content:#?}"); - }; - assert!(text.starts_with("")); - assert!(text.trim_end().ends_with("")); - assert!(text.contains("budget_limited")); - assert!(text.to_lowercase().contains("wrap up this turn soon")); - assert!(sess.active_turn.lock().await.is_some()); - while let Ok(event) = rx.try_recv() { - assert!( - !matches!(event.msg, EventMsg::TurnAborted(_)), - "budget limit should steer the active turn instead of aborting it" - ); - } - - let state_db = goal_test_state_db(sess.as_ref()).await?; - let goal = state_db - .thread_goals() - .get_thread_goal(sess.thread_id) - .await? - .expect("goal should remain persisted after accounting"); - assert_eq!(codex_state::ThreadGoalStatus::BudgetLimited, goal.status); - assert_eq!(25, goal.tokens_used); - - set_total_token_usage( - &sess, - TokenUsage { - input_tokens: 30, - cached_input_tokens: 0, - output_tokens: 10, - reasoning_output_tokens: 0, - total_tokens: 40, - }, - ) - .await; - sess.goal_runtime_apply(GoalRuntimeEvent::ToolCompletedGoal { - turn_context: tc.as_ref(), - }) - .await?; - - let goal = state_db - .thread_goals() - .get_thread_goal(sess.thread_id) - .await? - .expect("goal should remain persisted after follow-up accounting"); - assert_eq!(codex_state::ThreadGoalStatus::BudgetLimited, goal.status); - assert_eq!(40, goal.tokens_used); - - sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn usage_limit_runtime_stops_active_goal_and_prevents_idle_continuation() -> anyhow::Result<()> -{ - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.set_thread_goal( - tc.as_ref(), - SetGoalRequest { - objective: Some("Keep improving the benchmark".to_string()), - status: None, - token_budget: Some(Some(50)), - }, - ) - .await?; - sess.goal_runtime_apply(GoalRuntimeEvent::TurnStarted { - turn_context: tc.as_ref(), - token_usage: TokenUsage::default(), - }) - .await?; - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - set_total_token_usage(&sess, post_goal_token_usage()).await; - - sess.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { - turn_context: tc.as_ref(), - }) - .await?; - - let state_db = goal_test_state_db(sess.as_ref()).await?; - let goal = state_db - .thread_goals() - .get_thread_goal(sess.thread_id) - .await? - .expect("goal should remain persisted after usage limiting"); - assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status); - assert_eq!(70, goal.tokens_used); - - sess.abort_all_tasks(TurnAbortReason::Replaced).await; - sess.goal_runtime_apply(GoalRuntimeEvent::MaybeContinueIfIdle) - .await?; - assert!(sess.active_turn.lock().await.is_none()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn external_goal_mutation_accounts_active_turn_before_status_change() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.set_thread_goal( - tc.as_ref(), - SetGoalRequest { - objective: Some("Keep improving the benchmark".to_string()), - status: None, - token_budget: None, - }, - ) - .await?; - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - set_total_token_usage(&sess, post_goal_token_usage()).await; - - sess.goal_runtime_apply(GoalRuntimeEvent::ExternalMutationStarting) - .await?; - - let state_db = goal_test_state_db(sess.as_ref()).await?; - let goal = state_db - .thread_goals() - .get_thread_goal(sess.thread_id) - .await? - .expect("goal should remain persisted"); - assert_eq!(70, goal.tokens_used); - - let previous_goal = goal.clone(); - let goal_id = goal.goal_id.clone(); - let updated_goal = state_db - .thread_goals() - .update_thread_goal( - sess.thread_id, - codex_state::GoalUpdate { - objective: None, - status: Some(codex_state::ThreadGoalStatus::Complete), - token_budget: None, - expected_goal_id: Some(goal_id), - }, - ) - .await? - .expect("goal status update should succeed"); - sess.goal_runtime_apply(GoalRuntimeEvent::ExternalSet { - external_set: ExternalGoalSet { - goal: updated_goal, - previous_status: ExternalGoalPreviousStatus::from(&previous_goal), - }, - }) - .await?; - - assert!(sess.active_turn.lock().await.is_some()); - let goal = state_db - .thread_goals() - .get_thread_goal(sess.thread_id) - .await? - .expect("goal should remain persisted"); - assert_eq!(codex_state::ThreadGoalStatus::Complete, goal.status); - assert_eq!(70, goal.tokens_used); - - sess.abort_all_tasks(TurnAbortReason::Replaced).await; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn external_objective_change_steers_active_turn() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - - let state_db = goal_test_state_db(sess.as_ref()).await?; - let old_goal = state_db - .thread_goals() - .replace_thread_goal( - sess.thread_id, - "Keep improving the benchmark", - codex_state::ThreadGoalStatus::Active, - /*token_budget*/ Some(10_000), - ) - .await?; - let new_goal = state_db - .thread_goals() - .replace_thread_goal( - sess.thread_id, - "Write a concise benchmark summary", - codex_state::ThreadGoalStatus::Active, - /*token_budget*/ Some(10_000), - ) - .await?; - - sess.goal_runtime_apply(GoalRuntimeEvent::ExternalSet { - external_set: ExternalGoalSet { - goal: new_goal, - previous_status: ExternalGoalPreviousStatus::from(&old_goal), - }, - }) - .await?; - - let pending_input = sess.input_queue.get_pending_input(&sess.active_turn).await; - assert!( - pending_input.iter().any(|item| { - matches!( - item, - TurnInput::ResponseItem(ResponseItem::Message { role, content, .. }) - if role == "user" - && content.iter().any(|content| matches!( - content, - ContentItem::InputText { text } - if text.starts_with("") - && text.trim_end().ends_with("") - && text.contains("The active thread goal objective was edited") - && text.contains("Write a concise benchmark summary") - )) - ) - }), - "expected objective-updated steering prompt in pending input: {pending_input:?}" - ); - - sess.abort_all_tasks(TurnAbortReason::Replaced).await; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - set_total_token_usage(&sess, post_goal_token_usage()).await; - - let state_db = goal_test_state_db(sess.as_ref()).await?; - let goal = state_db - .thread_goals() - .replace_thread_goal( - sess.thread_id, - "Keep improving the benchmark", - codex_state::ThreadGoalStatus::Active, - /*token_budget*/ None, - ) - .await?; - sess.goal_runtime_apply(GoalRuntimeEvent::ExternalSet { - external_set: ExternalGoalSet { - goal, - previous_status: ExternalGoalPreviousStatus::NewGoal, - }, - }) - .await?; - - set_total_token_usage( - &sess, - TokenUsage { - input_tokens: 65, - cached_input_tokens: 10, - output_tokens: 40, - reasoning_output_tokens: 5, - total_tokens: 110, - }, - ) - .await; - sess.goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { - turn_context: tc.as_ref(), - tool_name: "shell_command", - }) - .await?; - - let goal = state_db - .thread_goals() - .get_thread_goal(sess.thread_id) - .await? - .expect("goal should remain persisted"); - assert_eq!(codex_state::ThreadGoalStatus::Active, goal.status); - assert_eq!(25, goal.tokens_used); - - sess.abort_all_tasks(TurnAbortReason::Replaced).await; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn completed_goal_accounts_current_turn_tokens_before_tool_response() -> anyhow::Result<()> { - let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::Goals) - .expect("goal mode should be enableable in tests"); - }); - let test = builder.build(&server).await?; - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - "call-create-goal", - "create_goal", - r#"{"objective":"write a report","token_budget":500}"#, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_function_call( - "call-complete-goal", - "update_goal", - r#"{"status":"complete"}"#, - ), - ev_completed_with_tokens("resp-2", /*total_tokens*/ 580), - ]), - sse(vec![ - ev_assistant_message("msg-1", "Goal complete."), - ev_completed("resp-3"), - ]), - ], - ) - .await; - - test.codex - .submit(Op::UserInput { - environments: None, - items: vec![UserInput::Text { - text: "write a report".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) - .await?; - - tokio::time::timeout(std::time::Duration::from_secs(8), async { - loop { - let event = test.codex.next_event().await?; - if matches!(event.msg, EventMsg::TurnComplete(_)) { - return anyhow::Ok(()); - } - } - }) - .await??; - - let complete_output = responses - .function_call_output_text("call-complete-goal") - .expect("complete tool output should be sent to the model"); - let complete_output: serde_json::Value = serde_json::from_str(&complete_output)?; - assert_eq!(complete_output["goal"]["tokensUsed"], 580); - assert_eq!(complete_output["goal"]["status"], "complete"); - assert_eq!(complete_output["remainingTokens"], 0); - assert_eq!( - complete_output["completionBudgetReport"], - "Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language." - ); - let requests = responses.requests(); - let completion_followup_request = requests - .last() - .expect("completion tool output should be sent in a follow-up request"); - assert!( - !completion_followup_request.body_contains_text("budget_limited"), - "completion follow-up should not include budget-limit steering" - ); - - let state_db = codex_state::StateRuntime::init( - test.config.sqlite_home.clone(), - test.config.model_provider_id.clone(), - ) - .await?; - let persisted_goal = state_db - .thread_goals() - .get_thread_goal(test.session_configured.thread_id) - .await? - .expect("goal should be persisted"); - assert_eq!( - codex_state::ThreadGoalStatus::Complete, - persisted_goal.status - ); - assert_eq!(580, persisted_goal.tokens_used); - - Ok(()) -} - -#[tokio::test] -async fn queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary() { - let (sess, tc, _rx) = make_session_and_context_with_rx().await; - let communication = InterAgentCommunication::new( - AgentPath::try_from("/root/worker").expect("worker path should parse"), - AgentPath::root(), - Vec::new(), - "late queue-only update".to_string(), - /*trigger_turn*/ false, - ); - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: true, - }, - ) - .await; - - sess.input_queue - .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) - .await; - sess.input_queue - .enqueue_mailbox_communication(communication.clone()) - .await; - - assert!( - !sess.input_queue.has_pending_input(&sess.active_turn).await, - "queue-only mailbox mail should stay buffered once the current turn emitted its answer" - ); - assert_eq!( - sess.input_queue.get_pending_input(&sess.active_turn).await, - Vec::new() - ); - - sess.abort_all_tasks(TurnAbortReason::Replaced).await; - - assert_eq!( - sess.input_queue.get_pending_input(&sess.active_turn).await, - vec![TurnInput::ResponseItem(ResponseItem::from( - communication.to_response_input_item() - ))], - ); -} - -#[tokio::test] -async fn trigger_turn_mailbox_mail_waits_for_next_turn_after_answer_boundary() { - let (sess, tc, _rx) = make_session_and_context_with_rx().await; - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: true, - }, - ) - .await; - - sess.input_queue - .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) - .await; - sess.input_queue - .enqueue_mailbox_communication(InterAgentCommunication::new( - AgentPath::try_from("/root/worker").expect("worker path should parse"), - AgentPath::root(), - Vec::new(), - "late trigger update".to_string(), - /*trigger_turn*/ true, - )) - .await; - - assert!( - !sess.input_queue.has_pending_input(&sess.active_turn).await, - "trigger-turn mailbox mail should not extend the current turn after its answer boundary" - ); - - sess.abort_all_tasks(TurnAbortReason::Replaced).await; - - assert!(sess.input_queue.has_trigger_turn_mailbox_items().await); -} - -#[tokio::test] -async fn steered_input_reopens_mailbox_delivery_for_current_turn() { - let (sess, tc, _rx) = make_session_and_context_with_rx().await; - let communication = InterAgentCommunication::new( - AgentPath::try_from("/root/worker").expect("worker path should parse"), - AgentPath::root(), - Vec::new(), - "queued child update".to_string(), - /*trigger_turn*/ false, - ); - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: true, + listen_to_cancellation_token: true, }, ) .await; @@ -10504,297 +9615,6 @@ async fn sample_rollout( ) } -#[tokio::test] -async fn create_goal_tool_rejects_existing_goal() { - let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let handler = CreateGoalHandler; - - handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker: Arc::clone(&tracker), - call_id: "create-goal-1".to_string(), - tool_name: codex_tools::ToolName::plain("create_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "objective": "Keep the watcher alive", - "token_budget": 123, - }) - .to_string(), - }, - }) - .await - .expect("initial create_goal should succeed"); - - let response = handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker, - call_id: "create-goal-2".to_string(), - tool_name: codex_tools::ToolName::plain("create_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "objective": "Replace the watcher", - "token_budget": 456, - }) - .to_string(), - }, - }) - .await; - - let Err(FunctionCallError::RespondToModel(output)) = response else { - panic!("expected create_goal to reject an existing goal"); - }; - assert_eq!( - output, - "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete" - ); - - let goal = session - .get_thread_goal() - .await - .expect("read thread goal") - .expect("goal should still exist"); - assert_eq!(goal.objective, "Keep the watcher alive"); - assert_eq!(goal.token_budget, Some(123)); -} - -#[tokio::test] -async fn update_goal_tool_rejects_pausing_goal() { - let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let create_handler = CreateGoalHandler; - let update_handler = UpdateGoalHandler; - - create_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker: Arc::clone(&tracker), - call_id: "create-goal".to_string(), - tool_name: codex_tools::ToolName::plain("create_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "objective": "Keep the watcher alive", - "token_budget": 123, - }) - .to_string(), - }, - }) - .await - .expect("initial create_goal should succeed"); - - let response = update_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker, - call_id: "pause-goal".to_string(), - tool_name: codex_tools::ToolName::plain("update_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "status": "paused", - }) - .to_string(), - }, - }) - .await; - - let Err(FunctionCallError::RespondToModel(output)) = response else { - panic!("expected update_goal to reject pausing a goal"); - }; - assert_eq!( - output, - "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system" - ); - - let goal = session - .get_thread_goal() - .await - .expect("read thread goal") - .expect("goal should still exist"); - assert_eq!(goal.status, ThreadGoalStatus::Active); -} - -#[tokio::test] -async fn update_goal_tool_marks_goal_blocked() { - let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let create_handler = CreateGoalHandler; - let update_handler = UpdateGoalHandler; - - create_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker: Arc::clone(&tracker), - call_id: "create-goal".to_string(), - tool_name: codex_tools::ToolName::plain("create_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "objective": "Keep the watcher alive", - "token_budget": 123, - }) - .to_string(), - }, - }) - .await - .expect("initial create_goal should succeed"); - - update_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker, - call_id: "block-goal".to_string(), - tool_name: codex_tools::ToolName::plain("update_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "status": "blocked", - }) - .to_string(), - }, - }) - .await - .expect("update_goal should mark the goal blocked"); - - let goal = session - .get_thread_goal() - .await - .expect("read thread goal") - .expect("goal should still exist"); - assert_eq!(goal.status, ThreadGoalStatus::Blocked); -} - -#[tokio::test] -async fn update_goal_tool_rejects_usage_limited_goal() { - let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let create_handler = CreateGoalHandler; - let update_handler = UpdateGoalHandler; - - create_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker: Arc::clone(&tracker), - call_id: "create-goal".to_string(), - tool_name: codex_tools::ToolName::plain("create_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "objective": "Keep the watcher alive", - }) - .to_string(), - }, - }) - .await - .expect("initial create_goal should succeed"); - - let response = update_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker, - call_id: "usage-limit-goal".to_string(), - tool_name: codex_tools::ToolName::plain("update_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "status": "usageLimited", - }) - .to_string(), - }, - }) - .await; - - let Err(FunctionCallError::RespondToModel(output)) = response else { - panic!("expected update_goal to reject usage-limiting a goal"); - }; - assert_eq!( - output, - "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system" - ); - - let goal = session - .get_thread_goal() - .await - .expect("read thread goal") - .expect("goal should still exist"); - assert_eq!(goal.status, ThreadGoalStatus::Active); -} - -#[tokio::test] -async fn update_goal_tool_marks_goal_complete() { - let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let create_handler = CreateGoalHandler; - let update_handler = UpdateGoalHandler; - - create_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker: Arc::clone(&tracker), - call_id: "create-goal".to_string(), - tool_name: codex_tools::ToolName::plain("create_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "objective": "Keep the watcher alive", - "token_budget": 123, - }) - .to_string(), - }, - }) - .await - .expect("initial create_goal should succeed"); - - update_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker, - call_id: "complete-goal".to_string(), - tool_name: codex_tools::ToolName::plain("update_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "status": "complete", - }) - .to_string(), - }, - }) - .await - .expect("update_goal should mark the goal complete"); - - let goal = session - .get_thread_goal() - .await - .expect("read thread goal") - .expect("goal should still exist"); - assert_eq!(goal.status, ThreadGoalStatus::Complete); -} - #[tokio::test] async fn rejects_escalated_permissions_when_policy_not_on_request() { use crate::exec_policy::ExecApprovalRequest; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 76303345a722..9d4e7d1e8288 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -18,7 +18,6 @@ use crate::compact_remote_v2::run_inline_remote_auto_compact_task as run_inline_ use crate::connectors; use crate::context::ContextualUserFragment; use crate::feedback_tags; -use crate::goals::GoalRuntimeEvent; use crate::hook_runtime::inspect_pending_input; use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::record_pending_input; @@ -151,15 +150,6 @@ pub(crate) async fn run_turn( let error = err.to_codex_protocol_error(); sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) .await; - if error == CodexErrorInfo::UsageLimitExceeded - && let Err(err) = sess - .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { - turn_context: turn_context.as_ref(), - }) - .await - { - warn!("failed to usage-limit active goal after usage-limit error: {err}"); - } error!("Failed to run pre-sampling compact"); return None; } @@ -294,17 +284,6 @@ pub(crate) async fn run_turn( let error = err.to_codex_protocol_error(); sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) .await; - if error == CodexErrorInfo::UsageLimitExceeded - && let Err(err) = sess - .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { - turn_context: turn_context.as_ref(), - }) - .await - { - warn!( - "failed to usage-limit active goal after usage-limit error: {err}" - ); - } return None; } can_drain_pending_input = !model_needs_follow_up; @@ -390,15 +369,6 @@ pub(crate) async fn run_turn( let error = e.to_codex_protocol_error(); sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) .await; - if error == CodexErrorInfo::UsageLimitExceeded - && let Err(err) = sess - .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { - turn_context: turn_context.as_ref(), - }) - .await - { - warn!("failed to usage-limit active goal after usage-limit error: {err}"); - } sess.track_turn_codex_error(turn_context.as_ref(), &e); let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); sess.send_event(&turn_context, event).await; diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 66a32f2aa6bd..5f52912d59a8 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -91,7 +91,6 @@ pub struct TurnContext { pub(crate) shell_environment_policy: ShellEnvironmentPolicy, pub(crate) available_models: Vec, pub(crate) unified_exec_shell_mode: UnifiedExecShellMode, - pub(crate) goal_tools_supported: bool, pub features: ManagedFeatures, pub(crate) ghost_snapshot: GhostSnapshotConfig, pub(crate) final_output_json_schema: Option, @@ -170,10 +169,6 @@ impl TurnContext { ToolEnvironmentMode::from_count(self.environments.turn_environments.len()) } - pub(crate) fn goal_tools_enabled(&self) -> bool { - self.goal_tools_supported && self.features.get().enabled(Feature::Goals) - } - pub(crate) async fn with_model( &self, model: String, @@ -264,7 +259,6 @@ impl TurnContext { shell_environment_policy: self.shell_environment_policy.clone(), available_models, unified_exec_shell_mode: self.unified_exec_shell_mode.clone(), - goal_tools_supported: self.goal_tools_supported, features, ghost_snapshot: self.ghost_snapshot.clone(), final_output_json_schema: self.final_output_json_schema.clone(), @@ -477,7 +471,6 @@ impl Session { cwd: AbsolutePathBuf, sub_id: String, skills_outcome: Arc, - goal_tools_supported: bool, ) -> TurnContext { let reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); let reasoning_summary = session_configuration @@ -568,7 +561,6 @@ impl Session { shell_environment_policy: per_turn_config.permissions.shell_environment_policy.clone(), available_models, unified_exec_shell_mode, - goal_tools_supported, features: per_turn_config.features.clone(), ghost_snapshot: per_turn_config.ghost_snapshot.clone(), final_output_json_schema: None, @@ -781,7 +773,6 @@ impl Session { .skills_for_config(&skills_input, fs) .await, ); - let goal_tools_supported = !per_turn_config.ephemeral && self.state_db().is_some(); let mut turn_context: TurnContext = Self::make_turn_context( self.thread_id(), self.session_id(), @@ -810,7 +801,6 @@ impl Session { cwd, sub_id, skills_outcome, - goal_tools_supported, ); turn_context.realtime_active = self.conversation.running_state().await.is_some(); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 70466c9c2f0f..a9a664a50510 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -23,7 +23,6 @@ use tracing::warn; use crate::config::Config; use crate::context::ContextualUserFragment; -use crate::goals::GoalRuntimeEvent; use crate::hook_runtime::inspect_pending_input; use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::record_pending_input; @@ -342,15 +341,6 @@ impl Session { .await .clear_turn(&turn_context.sub_id); - if let Err(err) = self - .goal_runtime_apply(GoalRuntimeEvent::TurnStarted { - turn_context: turn_context.as_ref(), - token_usage: token_usage_at_turn_start.clone(), - }) - .await - { - warn!("failed to apply goal runtime turn-start event: {err}"); - } let pending_items = self.input_queue.get_pending_input(&self.active_turn).await; let turn_state = { let mut active = self.active_turn.lock().await; @@ -504,15 +494,6 @@ impl Session { self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()) .await; } - if (aborted_turn || reason == TurnAbortReason::Interrupted) - && let Err(err) = self - .goal_runtime_apply(GoalRuntimeEvent::TaskAborted { - turn_context: turn_context.as_deref(), - }) - .await - { - warn!("failed to apply goal runtime abort event: {err}"); - } if let Some(active_turn) = active_turn_to_clear { // Let interrupted tasks observe cancellation before dropping pending approvals, or an // in-flight approval wait can surface as a model-visible rejection before TurnAborted. @@ -553,14 +534,6 @@ impl Session { self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()) .await; } - if let Err(err) = self - .goal_runtime_apply(GoalRuntimeEvent::TaskAborted { - turn_context: turn_context.as_deref(), - }) - .await - { - warn!("failed to apply goal runtime abort event: {err}"); - } // Let interrupted tasks observe cancellation before dropping pending approvals, or an // in-flight approval wait can surface as a model-visible rejection before TurnAborted. self.input_queue.clear_pending(&active_turn).await; @@ -760,15 +733,6 @@ impl Session { }); self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref()) .await; - if let Err(err) = self - .goal_runtime_apply(GoalRuntimeEvent::TurnFinished { - turn_context: turn_context.as_ref(), - turn_completed: true, - }) - .await - { - warn!("failed to apply goal runtime turn-finished event: {err}"); - } let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, @@ -798,12 +762,6 @@ impl Session { if !cleared_active_turn { return; } - if let Err(err) = self - .goal_runtime_apply(GoalRuntimeEvent::MaybeContinueIfIdle) - .await - { - warn!("failed to apply goal runtime maybe-continue event: {err}"); - } self.emit_thread_idle_lifecycle_if_idle().await; } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index d130a358158d..1cde8c932058 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1343,9 +1343,6 @@ impl ThreadManagerState { .await?; if is_resumed_thread { new_thread.thread.emit_thread_resume_lifecycle().await; - if let Err(err) = new_thread.thread.apply_goal_resume_runtime_effects().await { - warn!("failed to apply goal resume runtime effects: {err}"); - } } Ok(new_thread) } diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 52a0f8eb3bdc..34ec68211059 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -8,7 +8,6 @@ use crate::session::tests::make_session_and_context; use crate::tasks::InterruptedTurnHistoryMarker; use crate::tasks::interrupted_turn_history_marker; use codex_extension_api::empty_extension_registry; -use codex_features::Feature; use codex_models_manager::manager::RefreshStrategy; use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemReasoningSummary; @@ -1501,103 +1500,3 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ 1, ); } - -#[tokio::test] -async fn resumed_thread_keeps_paused_goal_paused() -> anyhow::Result<()> { - let temp_dir = tempdir().expect("tempdir"); - let mut config = test_config().await; - config.codex_home = temp_dir.path().join("codex-home").abs(); - config.cwd = config.codex_home.abs(); - config - .features - .enable(Feature::Goals) - .expect("goals should be enableable in tests"); - std::fs::create_dir_all(&config.codex_home).expect("create codex home"); - - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - let state_db = init_state_db(&config).await; - let manager = ThreadManager::new( - &config, - auth_manager.clone(), - SessionSource::Exec, - Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), - empty_extension_registry(), - /*analytics_events_client*/ None, - thread_store_from_config(&config, state_db.clone()), - state_db.clone(), - TEST_INSTALLATION_ID.to_string(), - /*attestation_provider*/ None, - ); - - let source = manager - .resume_thread_with_history( - config.clone(), - InitialHistory::Forked(vec![RolloutItem::ResponseItem(user_msg("keep working"))]), - auth_manager.clone(), - /*parent_trace*/ None, - ) - .await - .expect("create source thread"); - let source_path = source - .thread - .rollout_path() - .expect("source rollout path should exist"); - source.thread.flush_rollout().await?; - let state_db = source - .thread - .state_db() - .expect("source thread should have a state db"); - state_db - .thread_goals() - .replace_thread_goal( - source.thread_id, - "Keep working until the task is done", - codex_state::ThreadGoalStatus::Paused, - /*token_budget*/ None, - ) - .await?; - source.thread.shutdown_and_wait().await?; - manager.remove_thread(&source.thread_id).await; - - let resumed = manager - .resume_thread_from_rollout( - config.clone(), - source_path, - auth_manager, - /*parent_trace*/ None, - ) - .await - .expect("resume source thread"); - let goal = state_db - .thread_goals() - .get_thread_goal(resumed.thread_id) - .await? - .expect("goal should still exist after resume"); - assert_eq!(codex_state::ThreadGoalStatus::Paused, goal.status); - assert!( - resumed - .thread - .codex - .session - .active_turn - .lock() - .await - .is_none() - ); - - resumed.thread.continue_active_goal_if_idle().await?; - assert!( - resumed - .thread - .codex - .session - .active_turn - .lock() - .await - .is_none() - ); - - resumed.thread.shutdown_and_wait().await?; - Ok(()) -} diff --git a/codex-rs/core/src/tools/handlers/goal.rs b/codex-rs/core/src/tools/handlers/goal.rs deleted file mode 100644 index 694eafca6585..000000000000 --- a/codex-rs/core/src/tools/handlers/goal.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Built-in model tool handlers for persisted thread goals. -//! -//! The public tool contract intentionally splits goal creation from stopped -//! status updates: `create_goal` starts an active objective, while -//! `update_goal` can only mark the existing goal complete or blocked. - -use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; -use codex_protocol::protocol::ThreadGoal; -use codex_protocol::protocol::ThreadGoalStatus; -use serde::Deserialize; -use serde::Serialize; -use std::fmt::Write as _; - -mod create_goal; -mod get_goal; -mod update_goal; - -pub use create_goal::CreateGoalHandler; -pub use get_goal::GetGoalHandler; -pub use update_goal::UpdateGoalHandler; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -struct CreateGoalArgs { - objective: String, - token_budget: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -struct UpdateGoalArgs { - status: ThreadGoalStatus, -} - -#[derive(Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct GoalToolResponse { - goal: Option, - remaining_tokens: Option, - completion_budget_report: Option, -} - -#[derive(Clone, Copy)] -enum CompletionBudgetReport { - Include, - Omit, -} - -impl GoalToolResponse { - fn new(goal: Option, report_mode: CompletionBudgetReport) -> Self { - let remaining_tokens = goal.as_ref().and_then(|goal| { - goal.token_budget - .map(|budget| (budget - goal.tokens_used).max(0)) - }); - let completion_budget_report = match report_mode { - CompletionBudgetReport::Include => goal - .as_ref() - .filter(|goal| goal.status == ThreadGoalStatus::Complete) - .and_then(completion_budget_report), - CompletionBudgetReport::Omit => None, - }; - Self { - goal, - remaining_tokens, - completion_budget_report, - } - } -} - -fn format_goal_error(err: anyhow::Error) -> String { - let mut message = err.to_string(); - for cause in err.chain().skip(1) { - let _ = write!(message, ": {cause}"); - } - message -} - -fn goal_response( - goal: Option, - completion_budget_report: CompletionBudgetReport, -) -> Result { - let response = - serde_json::to_string_pretty(&GoalToolResponse::new(goal, completion_budget_report)) - .map_err(|err| FunctionCallError::Fatal(err.to_string()))?; - Ok(FunctionToolOutput::from_text(response, Some(true))) -} - -fn completion_budget_report(goal: &ThreadGoal) -> Option { - if goal.token_budget.is_none() && goal.time_used_seconds <= 0 { - None - } else { - Some( - "Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language." - .to_string(), - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use codex_protocol::ThreadId; - use pretty_assertions::assert_eq; - - #[test] - fn completed_budgeted_goal_response_reports_final_usage() { - let goal = ThreadGoal { - thread_id: ThreadId::new(), - objective: "Keep optimizing".to_string(), - status: ThreadGoalStatus::Complete, - token_budget: Some(10_000), - tokens_used: 3_250, - time_used_seconds: 75, - created_at: 1, - updated_at: 2, - }; - - let response = GoalToolResponse::new(Some(goal.clone()), CompletionBudgetReport::Include); - - assert_eq!( - response, - GoalToolResponse { - goal: Some(goal), - remaining_tokens: Some(6_750), - completion_budget_report: Some( - "Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language." - .to_string() - ), - } - ); - } - - #[test] - fn completed_unbudgeted_goal_response_omits_budget_report() { - let goal = ThreadGoal { - thread_id: ThreadId::new(), - objective: "Write a poem".to_string(), - status: ThreadGoalStatus::Complete, - token_budget: None, - tokens_used: 120, - time_used_seconds: 0, - created_at: 1, - updated_at: 2, - }; - - let response = GoalToolResponse::new(Some(goal.clone()), CompletionBudgetReport::Include); - - assert_eq!( - response, - GoalToolResponse { - goal: Some(goal), - remaining_tokens: None, - completion_budget_report: None, - } - ); - } -} diff --git a/codex-rs/core/src/tools/handlers/goal/create_goal.rs b/codex-rs/core/src/tools/handlers/goal/create_goal.rs deleted file mode 100644 index 1791a5e85f9b..000000000000 --- a/codex-rs/core/src/tools/handlers/goal/create_goal.rs +++ /dev/null @@ -1,78 +0,0 @@ -use crate::function_tool::FunctionCallError; -use crate::goals::CreateGoalRequest; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::context::boxed_tool_output; -use crate::tools::handlers::goal_spec::CREATE_GOAL_TOOL_NAME; -use crate::tools::handlers::goal_spec::create_create_goal_tool; -use crate::tools::handlers::parse_arguments; -use crate::tools::registry::CoreToolRuntime; -use crate::tools::registry::ToolExecutor; -use codex_tools::ToolName; -use codex_tools::ToolSpec; - -use super::CompletionBudgetReport; -use super::CreateGoalArgs; -use super::format_goal_error; -use super::goal_response; - -pub struct CreateGoalHandler; - -#[async_trait::async_trait] -impl ToolExecutor for CreateGoalHandler { - fn tool_name(&self) -> ToolName { - ToolName::plain(CREATE_GOAL_TOOL_NAME) - } - - fn spec(&self) -> ToolSpec { - create_create_goal_tool() - } - - async fn handle( - &self, - invocation: ToolInvocation, - ) -> Result, FunctionCallError> { - let ToolInvocation { - session, - turn, - payload, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "goal handler received unsupported payload".to_string(), - )); - } - }; - - let args: CreateGoalArgs = parse_arguments(&arguments)?; - let goal = session - .create_thread_goal( - turn.as_ref(), - CreateGoalRequest { - objective: args.objective, - token_budget: args.token_budget, - }, - ) - .await - .map_err(|err| { - if err - .chain() - .any(|cause| cause.to_string().contains("already has a goal")) - { - FunctionCallError::RespondToModel( - "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete" - .to_string(), - ) - } else { - FunctionCallError::RespondToModel(format_goal_error(err)) - } - })?; - goal_response(Some(goal), CompletionBudgetReport::Omit).map(boxed_tool_output) - } -} - -impl CoreToolRuntime for CreateGoalHandler {} diff --git a/codex-rs/core/src/tools/handlers/goal/get_goal.rs b/codex-rs/core/src/tools/handlers/goal/get_goal.rs deleted file mode 100644 index ff460706759d..000000000000 --- a/codex-rs/core/src/tools/handlers/goal/get_goal.rs +++ /dev/null @@ -1,51 +0,0 @@ -use crate::function_tool::FunctionCallError; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::context::boxed_tool_output; -use crate::tools::handlers::goal_spec::GET_GOAL_TOOL_NAME; -use crate::tools::handlers::goal_spec::create_get_goal_tool; -use crate::tools::registry::CoreToolRuntime; -use crate::tools::registry::ToolExecutor; -use codex_tools::ToolName; -use codex_tools::ToolSpec; - -use super::CompletionBudgetReport; -use super::format_goal_error; -use super::goal_response; - -pub struct GetGoalHandler; - -#[async_trait::async_trait] -impl ToolExecutor for GetGoalHandler { - fn tool_name(&self) -> ToolName { - ToolName::plain(GET_GOAL_TOOL_NAME) - } - - fn spec(&self) -> ToolSpec { - create_get_goal_tool() - } - - async fn handle( - &self, - invocation: ToolInvocation, - ) -> Result, FunctionCallError> { - let ToolInvocation { - session, payload, .. - } = invocation; - - match payload { - ToolPayload::Function { .. } => { - let goal = session - .get_thread_goal() - .await - .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - goal_response(goal, CompletionBudgetReport::Omit).map(boxed_tool_output) - } - _ => Err(FunctionCallError::RespondToModel( - "get_goal handler received unsupported payload".to_string(), - )), - } - } -} - -impl CoreToolRuntime for GetGoalHandler {} diff --git a/codex-rs/core/src/tools/handlers/goal/update_goal.rs b/codex-rs/core/src/tools/handlers/goal/update_goal.rs deleted file mode 100644 index 373c849fc2a8..000000000000 --- a/codex-rs/core/src/tools/handlers/goal/update_goal.rs +++ /dev/null @@ -1,89 +0,0 @@ -use crate::function_tool::FunctionCallError; -use crate::goals::GoalRuntimeEvent; -use crate::goals::SetGoalRequest; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::context::boxed_tool_output; -use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME; -use crate::tools::handlers::goal_spec::create_update_goal_tool; -use crate::tools::handlers::parse_arguments; -use crate::tools::registry::CoreToolRuntime; -use crate::tools::registry::ToolExecutor; -use codex_protocol::protocol::ThreadGoalStatus; -use codex_tools::ToolName; -use codex_tools::ToolSpec; - -use super::CompletionBudgetReport; -use super::UpdateGoalArgs; -use super::format_goal_error; -use super::goal_response; - -pub struct UpdateGoalHandler; - -#[async_trait::async_trait] -impl ToolExecutor for UpdateGoalHandler { - fn tool_name(&self) -> ToolName { - ToolName::plain(UPDATE_GOAL_TOOL_NAME) - } - - fn spec(&self) -> ToolSpec { - create_update_goal_tool() - } - - async fn handle( - &self, - invocation: ToolInvocation, - ) -> Result, FunctionCallError> { - let ToolInvocation { - session, - turn, - payload, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "update_goal handler received unsupported payload".to_string(), - )); - } - }; - - let args: UpdateGoalArgs = parse_arguments(&arguments)?; - if !matches!( - args.status, - ThreadGoalStatus::Complete | ThreadGoalStatus::Blocked - ) { - return Err(FunctionCallError::RespondToModel( - "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system" - .to_string(), - )); - } - session - .goal_runtime_apply(GoalRuntimeEvent::ToolCompletedGoal { - turn_context: turn.as_ref(), - }) - .await - .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - let goal = session - .set_thread_goal( - turn.as_ref(), - SetGoalRequest { - objective: None, - status: Some(args.status), - token_budget: None, - }, - ) - .await - .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - let completion_budget_report = if args.status == ThreadGoalStatus::Complete { - CompletionBudgetReport::Include - } else { - CompletionBudgetReport::Omit - }; - goal_response(Some(goal), completion_budget_report).map(boxed_tool_output) - } -} - -impl CoreToolRuntime for UpdateGoalHandler {} diff --git a/codex-rs/core/src/tools/handlers/goal_spec.rs b/codex-rs/core/src/tools/handlers/goal_spec.rs deleted file mode 100644 index da8e23d3053d..000000000000 --- a/codex-rs/core/src/tools/handlers/goal_spec.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! Responses API tool definitions for persisted thread goals. -//! -//! These specs expose goal read/update primitives to the model while keeping -//! usage accounting system-managed. - -use codex_tools::JsonSchema; -use codex_tools::ResponsesApiTool; -use codex_tools::ToolSpec; -use serde_json::json; -use std::collections::BTreeMap; - -pub const GET_GOAL_TOOL_NAME: &str = "get_goal"; -pub const CREATE_GOAL_TOOL_NAME: &str = "create_goal"; -pub const UPDATE_GOAL_TOOL_NAME: &str = "update_goal"; - -pub fn create_get_goal_tool() -> ToolSpec { - ToolSpec::Function(ResponsesApiTool { - name: GET_GOAL_TOOL_NAME.to_string(), - description: "Get the current goal for this thread, including status, budgets, token and elapsed-time usage, and remaining token budget." - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::object(BTreeMap::new(), Some(Vec::new()), Some(false.into())), - output_schema: None, - }) -} - -pub fn create_create_goal_tool() -> ToolSpec { - let properties = BTreeMap::from([ - ( - "objective".to_string(), - JsonSchema::string(Some( - "Required. The concrete objective to start pursuing. This starts a new active goal only when no goal is currently defined; if a goal already exists, this tool fails." - .to_string(), - )), - ), - ( - "token_budget".to_string(), - JsonSchema::integer(Some( - "Positive token budget for the new goal. Omit unless explicitly requested." - .to_string(), - )), - ), - ]); - - ToolSpec::Function(ResponsesApiTool { - name: CREATE_GOAL_TOOL_NAME.to_string(), - description: format!( - r#"Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. -Set token_budget only when an explicit token budget is requested. Fails if a goal exists; use {UPDATE_GOAL_TOOL_NAME} only for status."# - ), - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - properties, - /*required*/ Some(vec!["objective".to_string()]), - Some(false.into()), - ), - output_schema: None, - }) -} - -pub fn create_update_goal_tool() -> ToolSpec { - let properties = BTreeMap::from([( - "status".to_string(), - JsonSchema::string_enum( - vec![json!("complete"), json!("blocked")], - Some( - "Required. Set to `complete` only when the objective is achieved and no required work remains. Set to `blocked` only after the same blocking condition has recurred for at least three consecutive goal turns and the agent is at an impasse. After a previously blocked goal is resumed, the resumed run starts a fresh blocked audit." - .to_string(), - ), - ), - )]); - - ToolSpec::Function(ResponsesApiTool { - name: UPDATE_GOAL_TOOL_NAME.to_string(), - description: r#"Update the existing goal. -Use this tool only to mark the goal achieved or genuinely blocked. -Set status to `complete` only when the objective has actually been achieved and no required work remains. -Set status to `blocked` only when the same blocking condition has repeated for at least three consecutive goal turns, counting the original/user-triggered turn and any automatic continuations, and the agent cannot make meaningful progress without user input or an external-state change. -If the user resumes a goal that was previously marked `blocked`, treat the resumed run as a fresh blocked audit. If the same blocking condition then repeats for at least three consecutive resumed goal turns, set status to `blocked` again. -Once the blocked threshold is satisfied, do not keep reporting that you are still blocked while leaving the goal active; set status to `blocked`. -Do not use `blocked` merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification. -Do not mark a goal complete merely because its budget is nearly exhausted or because you are stopping work. -You cannot use this tool to pause, resume, budget-limit, or usage-limit a goal; those status changes are controlled by the user or system. -When marking a budgeted goal achieved with status `complete`, report the final token usage from the tool result to the user."# - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - properties, - /*required*/ Some(vec!["status".to_string()]), - Some(false.into()), - ), - output_schema: None, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn update_goal_tool_exposes_complete_and_blocked_statuses() { - let ToolSpec::Function(tool) = create_update_goal_tool() else { - panic!("update_goal should be a function tool"); - }; - let status = tool - .parameters - .properties - .as_ref() - .and_then(|properties| properties.get("status")) - .expect("status property should exist"); - - assert_eq!( - status.enum_values, - Some(vec![json!("complete"), json!("blocked")]) - ); - } -} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index d6963f07617b..fdfb80996557 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -4,8 +4,6 @@ pub(crate) mod apply_patch; pub(crate) mod apply_patch_spec; mod dynamic; pub(crate) mod extension_tools; -mod goal; -pub(crate) mod goal_spec; mod list_available_plugins_to_install; pub(crate) mod list_available_plugins_to_install_spec; mod mcp; @@ -53,9 +51,6 @@ pub use apply_patch::ApplyPatchHandler; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::AskForApproval; pub use dynamic::DynamicToolHandler; -pub use goal::CreateGoalHandler; -pub use goal::GetGoalHandler; -pub use goal::UpdateGoalHandler; pub use list_available_plugins_to_install::ListAvailablePluginsToInstallHandler; pub use mcp::McpHandler; pub use mcp_resource::ListMcpResourceTemplatesHandler; diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index b2a0b7704ad7..1cc5e67eb56f 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -5,7 +5,6 @@ use std::sync::atomic::Ordering; use std::time::Duration; use crate::function_tool::FunctionCallError; -use crate::goals::GoalRuntimeEvent; use crate::hook_runtime::PreToolUseHookResult; use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::run_post_tool_use_hooks; @@ -34,7 +33,6 @@ use codex_tools::ToolSearchInfo; use codex_tools::ToolSpec; use futures::future::BoxFuture; use serde_json::Value; -use tracing::warn; pub(crate) type ToolTelemetryTags = Vec<(&'static str, String)>; @@ -649,25 +647,13 @@ impl ToolRegistry { handler_executed: true, }, }; - let finished = notify_tool_finish_if_unclaimed( + notify_tool_finish_if_unclaimed( &invocation, terminal_outcome_reached.as_deref(), lifecycle_outcome, ) .await; - if finished - && let Err(err) = invocation - .session - .goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { - turn_context: invocation.turn.as_ref(), - tool_name: tool_name.name.as_str(), - }) - .await - { - warn!("failed to account thread goal progress after tool call: {err}"); - } - match result { Ok(_) => { let mut guard = response_cell.lock().await; diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 47d2ac6726c5..514432c880a1 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -6,11 +6,9 @@ use crate::tools::context::ToolInvocation; use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; -use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::DynamicToolHandler; use crate::tools::handlers::ExecCommandHandler; use crate::tools::handlers::ExecCommandHandlerOptions; -use crate::tools::handlers::GetGoalHandler; use crate::tools::handlers::ListAvailablePluginsToInstallHandler; use crate::tools::handlers::ListMcpResourceTemplatesHandler; use crate::tools::handlers::ListMcpResourcesHandler; @@ -24,7 +22,6 @@ use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::ShellCommandHandlerOptions; use crate::tools::handlers::TestSyncHandler; use crate::tools::handlers::ToolSearchHandler; -use crate::tools::handlers::UpdateGoalHandler; use crate::tools::handlers::ViewImageHandler; use crate::tools::handlers::WriteStdinHandler; use crate::tools::handlers::agent_jobs::ReportAgentJobResultHandler; @@ -305,14 +302,6 @@ fn collab_tools_enabled(turn_context: &TurnContext) -> bool { } } -fn goal_tools_enabled(turn_context: &TurnContext) -> bool { - turn_context.goal_tools_enabled() - && !matches!( - turn_context.session_source, - SessionSource::SubAgent(SubAgentSource::Review) - ) -} - fn agent_jobs_tools_enabled(turn_context: &TurnContext) -> bool { turn_context.features.get().enabled(Feature::SpawnCsv) && collab_tools_enabled(turn_context) } @@ -558,8 +547,8 @@ fn add_tool_sources(context: &CoreToolPlanContext<'_>, planned_tools: &mut Plann add_core_utility_tools(context, planned_tools); add_collaboration_tools(context, planned_tools); add_mcp_runtime_tools(context, planned_tools); - add_dynamic_tools(context, planned_tools); add_extension_tools(context, planned_tools); + add_dynamic_tools(context, planned_tools); for spec in hosted_model_tool_specs(context) { planned_tools.add_hosted_spec(spec); } @@ -639,11 +628,6 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut let environment_mode = turn_context.tool_environment_mode(); planned_tools.add(PlanHandler); - if goal_tools_enabled(turn_context) { - planned_tools.add(GetGoalHandler); - planned_tools.add(CreateGoalHandler); - planned_tools.add(UpdateGoalHandler); - } if turn_context.config.experimental_request_user_input_enabled { planned_tools.add(RequestUserInputHandler { diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 17dfdc7a74f0..5fe811b19e82 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -619,36 +619,7 @@ async fn environment_count_controls_environment_backed_tools() { } #[tokio::test] -async fn host_context_gates_goal_and_agent_job_tools() { - let feature_disabled = probe(|turn| { - set_feature(turn, Feature::Goals, /*enabled*/ false); - turn.goal_tools_supported = true; - }) - .await; - feature_disabled.assert_visible_lacks(&["get_goal", "create_goal", "update_goal"]); - - let host_disabled = probe(|turn| { - set_feature(turn, Feature::Goals, /*enabled*/ true); - turn.goal_tools_supported = false; - }) - .await; - host_disabled.assert_visible_lacks(&["get_goal", "create_goal", "update_goal"]); - - let enabled = probe(|turn| { - set_feature(turn, Feature::Goals, /*enabled*/ true); - turn.goal_tools_supported = true; - }) - .await; - enabled.assert_visible_contains(&["get_goal", "create_goal", "update_goal"]); - - let review_thread = probe(|turn| { - set_feature(turn, Feature::Goals, /*enabled*/ true); - turn.goal_tools_supported = true; - turn.session_source = SessionSource::SubAgent(SubAgentSource::Review); - }) - .await; - review_thread.assert_visible_lacks(&["get_goal", "create_goal", "update_goal"]); - +async fn host_context_gates_agent_job_tools() { let normal_agent_job = probe(|turn| { set_feature(turn, Feature::SpawnCsv, /*enabled*/ true); }) diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index dddfd4af683b..640c1c1db1aa 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -188,9 +188,6 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { }; expected_tools_names.extend([ "update_plan", - "get_goal", - "create_goal", - "update_goal", "request_user_input", "apply_patch", "view_image", From 470c20bf98ce35c8bcaa021e722f0a7074c4530b Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 5 Jun 2026 15:41:13 -0700 Subject: [PATCH 47/59] protocol: remove submission-side serde from Op (#26674) ## Why Submission-side `Op` payloads are now an internal handoff inside the Rust codebase, so keeping a stable serde contract there adds complexity without a real wire consumer. ## What changed - remove serde/schema annotations from `Submission`, `Op`, and submission-only payload types like thread settings overrides, additional context, realtime conversation params, `TurnEnvironmentSelection`, and `RequestUserInputResponse` - delete the `Op` serialization tests and the now-unused double-option prompt serde helper - keep event/API-facing serialization where it is still required, and serialize the `request_user_input` tool output from its wire payload instead of the core response struct - update `protocol_v1.md` to call out that events remain the serialized transport surface while submission payloads are implementation details ## Testing - `just test -p codex-protocol` - `cargo check -p codex-core -p codex-app-server -p codex-thread-store` - `just test -p codex-core request_user_input` --- codex-rs/docs/protocol_v1.md | 3 +- codex-rs/protocol/src/protocol.rs | 322 +----------------------------- 2 files changed, 13 insertions(+), 312 deletions(-) diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 464e8187b47a..f16da209f60a 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -54,6 +54,7 @@ Since only 1 `Task` can be run at a time, for parallel tasks it is recommended t - These are messages sent on the `SQ` (UI -> `Codex`) - Has an string ID provided by the UI, referred to as `sub_id` - `Op` refers to the enum of all possible `Submission` payloads + - In the current codebase these are primarily in-process Rust types rather than a stable serde wire contract - This enum is `non_exhaustive`; variants can be added at future dates - `Event` - These are messages sent on the `EQ` (`Codex` -> UI) @@ -103,7 +104,7 @@ The `response_id` returned from each turn matches the OpenAI `response_id` store Can operate over any transport that supports bi-directional streaming. - cross-thread channels - IPC channels - stdin/stdout - TCP - HTTP2 - gRPC -Non-framed transports, such as stdin/stdout and TCP, should use newline-delimited JSON in sending messages. +Events still serialize cleanly to newline-delimited JSON for non-framed transports, such as stdin/stdout and TCP. Submission payloads should be treated as implementation details unless a specific transport owns an explicit adapter. ## Example Flows diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index cd183dae0c61..c9e2e8bb286a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -106,7 +106,7 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] +#[derive(Debug, Clone, PartialEq)] pub struct TurnEnvironmentSelection { pub environment_id: String, pub cwd: AbsolutePathBuf, @@ -124,17 +124,15 @@ impl GitSha { } /// Submission Queue Entry - requests from user -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Clone)] pub struct Submission { /// Unique id for this Submission to correlate with Events pub id: String, /// Payload pub op: Op, /// Client-provided id for the user message represented by `Op::UserInput`. - #[serde(default, skip_serializing_if = "Option::is_none")] pub client_user_message_id: Option, /// Optional W3C trace carrier propagated across async submission handoffs. - #[serde(default, skip_serializing_if = "Option::is_none")] pub trace: Option, } @@ -149,34 +147,23 @@ pub struct W3cTraceContext { } /// Config payload for refreshing MCP servers. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] +#[derive(Debug, Clone, PartialEq)] pub struct McpServerRefreshConfig { pub mcp_servers: Value, pub mcp_oauth_credentials_store_mode: Value, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq)] pub struct ConversationStartParams { /// Selects whether the realtime session should produce text or audio output. pub output_modality: RealtimeOutputModality, - #[serde( - default, - deserialize_with = "conversation_start_prompt_serde::deserialize", - serialize_with = "conversation_start_prompt_serde::serialize", - skip_serializing_if = "Option::is_none" - )] pub prompt: Option>, - #[serde(skip_serializing_if = "Option::is_none")] pub realtime_session_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub transport: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub voice: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "snake_case")] -#[ts(tag = "type")] +#[derive(Debug, Clone, PartialEq)] pub enum ConversationStartTransport { Websocket, Webrtc { sdp: String }, @@ -189,28 +176,6 @@ pub enum RealtimeOutputModality { Audio, } -mod conversation_start_prompt_serde { - use serde::Deserializer; - use serde::Serializer; - - pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> - where - D: Deserializer<'de>, - { - serde_with::rust::double_option::deserialize(deserializer) - } - - pub(crate) fn serialize( - value: &Option>, - serializer: S, - ) -> Result - where - S: Serializer, - { - serde_with::rust::double_option::serialize(value, serializer) - } -} - #[derive( Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, TS, Ord, PartialOrd, )] @@ -391,109 +356,92 @@ pub enum RealtimeEvent { Error(String), } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq)] pub struct ConversationAudioParams { pub frame: RealtimeAudioFrame, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq)] pub struct ConversationTextParams { pub text: String, } /// Persistent thread-settings overrides that can be applied before user input or /// on their own. -#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, JsonSchema)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct ThreadSettingsOverrides { /// Updated `cwd` for sandbox/tool calls. - #[serde(skip_serializing_if = "Option::is_none")] pub cwd: Option, /// Updated runtime workspace roots used to materialize symbolic /// `:workspace_roots` filesystem permissions. - #[serde(skip_serializing_if = "Option::is_none")] pub workspace_roots: Option>, /// Updated profile-defined workspace roots for status summaries and /// per-turn config reconstruction. - #[serde(skip_serializing_if = "Option::is_none")] pub profile_workspace_roots: Option>, /// Updated command approval policy. - #[serde(skip_serializing_if = "Option::is_none")] pub approval_policy: Option, /// Updated approval reviewer for future approval prompts. - #[serde(skip_serializing_if = "Option::is_none")] pub approvals_reviewer: Option, /// Updated sandbox policy for tool calls. - #[serde(skip_serializing_if = "Option::is_none")] pub sandbox_policy: Option, /// Updated permissions profile for tool calls. - #[serde(skip_serializing_if = "Option::is_none")] pub permission_profile: Option, /// Named or built-in profile that produced `permission_profile`, if the /// update selected a profile rather than supplying raw permissions. - #[serde(skip_serializing_if = "Option::is_none")] pub active_permission_profile: Option, /// Updated Windows sandbox mode for tool execution. - #[serde(skip_serializing_if = "Option::is_none")] pub windows_sandbox_level: Option, /// Updated model slug. When set, the model info is derived automatically. - #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, /// Updated reasoning effort (honored only for reasoning-capable models). /// /// Use `Some(Some(_))` to set a specific effort, `Some(None)` to clear the /// effort, or `None` to leave the existing value unchanged. - #[serde(skip_serializing_if = "Option::is_none")] pub effort: Option>, /// Updated reasoning summary preference (honored only for reasoning-capable models). - #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, /// Updated service tier preference for future turns. /// /// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the /// preference, or `None` to leave the existing value unchanged. - #[serde(skip_serializing_if = "Option::is_none")] pub service_tier: Option>, /// EXPERIMENTAL - set a pre-set collaboration mode. /// Takes precedence over model, effort, and developer instructions if set. - #[serde(skip_serializing_if = "Option::is_none")] pub collaboration_mode: Option, /// Updated personality preference. - #[serde(skip_serializing_if = "Option::is_none")] pub personality: Option, } /// Source classification for client-supplied context. -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AdditionalContextKind { Untrusted, Application, } /// Client-supplied context keyed by an opaque source identifier. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct AdditionalContextEntry { pub value: String, pub kind: AdditionalContextKind, } /// Submission operation -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case")] +#[derive(Debug, Clone, PartialEq)] #[allow(clippy::large_enum_variant)] #[non_exhaustive] pub enum Op { @@ -525,20 +473,15 @@ pub enum Op { /// User input items, see `InputItem` items: Vec, /// Optional turn-scoped environments. - #[serde(default, skip_serializing_if = "Option::is_none")] environments: Option>, /// Optional JSON Schema used to constrain the final assistant message for this turn. - #[serde(skip_serializing_if = "Option::is_none")] final_output_json_schema: Option, /// Optional turn-scoped Responses API `client_metadata`. - #[serde(default, skip_serializing_if = "Option::is_none")] responsesapi_client_metadata: Option>, /// Client-supplied context fragments keyed by an opaque source identifier. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] additional_context: BTreeMap, /// Persistent thread-settings overrides to apply before the input. - #[serde(default, flatten)] thread_settings: ThreadSettingsOverrides, }, @@ -548,7 +491,6 @@ pub enum Op { /// preserve caller order between both kinds of mutation. ThreadSettings { /// Persistent thread-settings overrides to apply. - #[serde(flatten)] thread_settings: ThreadSettingsOverrides, }, @@ -563,7 +505,6 @@ pub enum Op { /// The id of the submission we are approving id: String, /// Turn id associated with the approval event, when available. - #[serde(default, skip_serializing_if = "Option::is_none")] turn_id: Option, /// The user's decision in response to the request. decision: ReviewDecision, @@ -586,15 +527,12 @@ pub enum Op { /// User's decision for the request. decision: ElicitationAction, /// Structured user input supplied for accepted elicitations. - #[serde(default, skip_serializing_if = "Option::is_none")] content: Option, /// Optional client metadata associated with the elicitation response. - #[serde(default, skip_serializing_if = "Option::is_none")] meta: Option, }, /// Resolve a request_user_input tool call. - #[serde(rename = "user_input_answer", alias = "request_user_input_response")] UserInputAnswer { /// Turn id for the in-flight request. id: String, @@ -4950,146 +4888,6 @@ mod tests { assert!(event.affects_turn_status()); } - #[test] - fn conversation_op_serializes_as_unnested_variants() { - let audio = Op::RealtimeConversationAudio(ConversationAudioParams { - frame: RealtimeAudioFrame { - data: "AQID".to_string(), - sample_rate: 24_000, - num_channels: 1, - samples_per_channel: Some(480), - item_id: None, - }, - }); - let start = Op::RealtimeConversationStart(ConversationStartParams { - output_modality: RealtimeOutputModality::Audio, - prompt: Some(Some("be helpful".to_string())), - realtime_session_id: Some("conv_1".to_string()), - transport: None, - voice: None, - }); - let webrtc_start = Op::RealtimeConversationStart(ConversationStartParams { - output_modality: RealtimeOutputModality::Audio, - prompt: Some(Some("be helpful".to_string())), - realtime_session_id: Some("conv_1".to_string()), - transport: Some(ConversationStartTransport::Webrtc { - sdp: "v=offer\r\n".to_string(), - }), - voice: Some(RealtimeVoice::Cove), - }); - let text = Op::RealtimeConversationText(ConversationTextParams { - text: "hello".to_string(), - }); - let close = Op::RealtimeConversationClose; - let default_prompt_start = Op::RealtimeConversationStart(ConversationStartParams { - output_modality: RealtimeOutputModality::Audio, - prompt: None, - realtime_session_id: None, - transport: None, - voice: None, - }); - let null_prompt_start = Op::RealtimeConversationStart(ConversationStartParams { - output_modality: RealtimeOutputModality::Audio, - prompt: Some(None), - realtime_session_id: None, - transport: None, - voice: None, - }); - let list_voices = Op::RealtimeConversationListVoices; - - assert_eq!( - serde_json::to_value(&start).unwrap(), - json!({ - "type": "realtime_conversation_start", - "output_modality": "audio", - "prompt": "be helpful", - "realtime_session_id": "conv_1" - }) - ); - assert_eq!( - serde_json::to_value(&default_prompt_start).unwrap(), - json!({ - "type": "realtime_conversation_start", - "output_modality": "audio" - }) - ); - assert_eq!( - serde_json::to_value(&null_prompt_start).unwrap(), - json!({ - "type": "realtime_conversation_start", - "output_modality": "audio", - "prompt": null - }) - ); - assert_eq!( - serde_json::from_value::(json!({ - "type": "realtime_conversation_start", - "output_modality": "audio" - })) - .unwrap(), - default_prompt_start - ); - assert_eq!( - serde_json::from_value::(json!({ - "type": "realtime_conversation_start", - "output_modality": "audio", - "prompt": null - })) - .unwrap(), - null_prompt_start - ); - assert_eq!( - serde_json::to_value(&audio).unwrap(), - json!({ - "type": "realtime_conversation_audio", - "frame": { - "data": "AQID", - "sample_rate": 24000, - "num_channels": 1, - "samples_per_channel": 480 - } - }) - ); - assert_eq!( - serde_json::from_value::(serde_json::to_value(&text).unwrap()).unwrap(), - text - ); - assert_eq!( - serde_json::to_value(&close).unwrap(), - json!({ - "type": "realtime_conversation_close" - }) - ); - assert_eq!( - serde_json::from_value::(serde_json::to_value(&close).unwrap()).unwrap(), - close - ); - assert_eq!( - serde_json::to_value(&list_voices).unwrap(), - json!({ - "type": "realtime_conversation_list_voices" - }) - ); - assert_eq!( - serde_json::from_value::(serde_json::to_value(&list_voices).unwrap()).unwrap(), - list_voices - ); - assert_eq!( - serde_json::to_value(&webrtc_start).unwrap(), - json!({ - "type": "realtime_conversation_start", - "output_modality": "audio", - "prompt": "be helpful", - "realtime_session_id": "conv_1", - "transport": { - "type": "webrtc", - "sdp": "v=offer\r\n" - }, - "voice": "cove" - }) - ); - } - #[test] fn realtime_conversation_started_event_uses_realtime_session_id() { let event = RealtimeConversationStartedEvent { @@ -5140,104 +4938,6 @@ mod tests { ); } - #[test] - fn user_input_serialization_omits_final_output_json_schema_when_none() -> Result<()> { - let op = Op::UserInput { - environments: None, - items: Vec::new(), - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }; - - let json_op = serde_json::to_value(op)?; - assert_eq!(json_op, json!({ "type": "user_input", "items": [] })); - - Ok(()) - } - - #[test] - fn user_input_deserializes_without_final_output_json_schema_field() -> Result<()> { - let op: Op = serde_json::from_value(json!({ "type": "user_input", "items": [] }))?; - - assert_eq!( - op, - Op::UserInput { - environments: None, - items: Vec::new(), - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - } - ); - - Ok(()) - } - - #[test] - fn user_input_serialization_includes_final_output_json_schema_when_some() -> Result<()> { - let schema = json!({ - "type": "object", - "properties": { - "answer": { "type": "string" } - }, - "required": ["answer"], - "additionalProperties": false - }); - let op = Op::UserInput { - environments: None, - items: Vec::new(), - final_output_json_schema: Some(schema.clone()), - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }; - - let json_op = serde_json::to_value(op)?; - assert_eq!( - json_op, - json!({ - "type": "user_input", - "items": [], - "final_output_json_schema": schema, - }) - ); - - Ok(()) - } - - #[test] - fn user_input_with_responsesapi_client_metadata_round_trips() -> Result<()> { - let op = Op::UserInput { - environments: None, - items: Vec::new(), - final_output_json_schema: None, - responsesapi_client_metadata: Some(HashMap::from([( - "fiber_run_id".to_string(), - "fiber-123".to_string(), - )])), - additional_context: Default::default(), - thread_settings: Default::default(), - }; - - let json_op = serde_json::to_value(&op)?; - assert_eq!( - json_op, - json!({ - "type": "user_input", - "items": [], - "responsesapi_client_metadata": { - "fiber_run_id": "fiber-123", - } - }) - ); - assert_eq!(serde_json::from_value::(json_op)?, op); - - Ok(()) - } - #[test] fn user_input_text_serializes_empty_text_elements() -> Result<()> { let input = UserInput::Text { From c62d79259d0ccd83ad515d3444840e0e0e18059a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 5 Jun 2026 15:54:46 -0700 Subject: [PATCH 48/59] Block active goals after terminal turn errors (#26690) ## Why Terminal turn errors can leave a goal active. Automatic goal continuation may then repeatedly hit a permanent failure, including compaction requests rejected with HTTP 400, and consume excessive tokens. This PR changes the goal extension to treat all turn-ending errors (including non-retryable errors and retryable errors that have exceeded their retry count) as "blocking" for the goal. The downside to this change is that there are some errors that may eventually succeed (e.g. a 429 due to a service outage), and previously the goal runtime would have kept the agent going in these situations. ## What changed - Block the current active goal when a turn ends with an error other than a usage-limit error. - Preserve the existing `usage_limited` transition for usage-limit errors. - Share progress accounting, guarded state updates, metrics, and event emission in the goal runtime. --- codex-rs/ext/goal/src/extension.rs | 19 ++++-- codex-rs/ext/goal/src/runtime.rs | 63 ++++++++++++++++--- .../ext/goal/tests/goal_extension_backend.rs | 60 ++++++++++++++---- 3 files changed, 118 insertions(+), 24 deletions(-) diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index 563c40b50337..78ae0b5b443e 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -36,6 +36,7 @@ use crate::accounting::GoalAccountingState; use crate::api::GoalService; use crate::events::GoalEventEmitter; use crate::metrics::GoalMetrics; +use crate::runtime::ActiveGoalStopReason; use crate::runtime::GoalRuntimeConfig; use crate::runtime::GoalRuntimeHandle; use crate::spec::UPDATE_GOAL_TOOL_NAME; @@ -278,18 +279,26 @@ where } async fn on_turn_error(&self, input: TurnErrorInput<'_>) { - if input.error != CodexErrorInfo::UsageLimitExceeded { - return; - } let Some(runtime) = goal_runtime_handle(input.thread_store) else { return; }; + let reason = match input.error { + CodexErrorInfo::UsageLimitExceeded => ActiveGoalStopReason::UsageLimit, + // The turn has ended because the error was non-retryable or its + // retries were exhausted. Block the goal to prevent automatic + // continuation from looping and consuming tokens, as can happen + // with compaction errors. + _ => ActiveGoalStopReason::TurnError, + }; if let Err(err) = runtime - .usage_limit_active_goal_for_turn(input.turn_id) + .stop_active_goal_for_turn(input.turn_id, reason) .await { - tracing::warn!("failed to usage-limit active goal after usage-limit error: {err}"); + tracing::warn!( + error = ?input.error, + "failed to stop active goal after turn error: {err}" + ); } } } diff --git a/codex-rs/ext/goal/src/runtime.rs b/codex-rs/ext/goal/src/runtime.rs index 9818282cae25..2641dfb949a6 100644 --- a/codex-rs/ext/goal/src/runtime.rs +++ b/codex-rs/ext/goal/src/runtime.rs @@ -28,6 +28,11 @@ pub(crate) struct GoalRuntimeConfig { pub(crate) tools_available_for_thread: bool, } +pub(crate) enum ActiveGoalStopReason { + TurnError, + UsageLimit, +} + struct GoalRuntimeInner { thread_id: ThreadId, state_dbs: Arc, @@ -216,10 +221,23 @@ impl GoalRuntimeHandle { } pub async fn usage_limit_active_goal_for_turn(&self, turn_id: &str) -> Result<(), String> { + self.stop_active_goal_for_turn(turn_id, ActiveGoalStopReason::UsageLimit) + .await + } + + /// Accounts the ending turn and stops its active goal after a terminal error. + pub(crate) async fn stop_active_goal_for_turn( + &self, + turn_id: &str, + reason: ActiveGoalStopReason, + ) -> Result<(), String> { if !self.is_enabled() { return Ok(()); } + // Hold this through accounting and the status update so external goal + // mutations and idle continuation cannot interleave between them. + let _goal_state_permit = self.goal_state_permit().await?; if !self .inner .accounting_state @@ -228,23 +246,54 @@ impl GoalRuntimeHandle { return Ok(()); } - let progress_event_id = format!("{turn_id}:usage-limit-progress"); + let (event_name, status) = match reason { + ActiveGoalStopReason::TurnError => { + ("turn-error", codex_state::ThreadGoalStatus::Blocked) + } + ActiveGoalStopReason::UsageLimit => { + ("usage-limit", codex_state::ThreadGoalStatus::UsageLimited) + } + }; self.account_active_goal_progress( turn_id, - progress_event_id.as_str(), + &format!("{turn_id}:{event_name}-progress"), codex_state::GoalAccountingMode::ActiveOnly, BudgetLimitedGoalDisposition::ClearActive, ) .await?; - let previous_status = self - .current_goal_status_for_metrics(/*expected_goal_id*/ None) - .await?; + let Some(active_goal) = self + .inner + .state_dbs + .thread_goals() + .get_thread_goal(self.thread_id()) + .await + .map_err(|err| err.to_string())? + else { + self.inner.accounting_state.clear_active_goal(); + return Ok(()); + }; + let can_stop = active_goal.status == codex_state::ThreadGoalStatus::Active + || (active_goal.status == codex_state::ThreadGoalStatus::BudgetLimited + && status == codex_state::ThreadGoalStatus::UsageLimited); + if !can_stop { + self.inner.accounting_state.clear_active_goal(); + return Ok(()); + } + let previous_status = Some(active_goal.status); let Some(goal) = self .inner .state_dbs .thread_goals() - .usage_limit_active_thread_goal(self.thread_id()) + .update_thread_goal( + self.thread_id(), + codex_state::GoalUpdate { + objective: None, + status: Some(status), + token_budget: None, + expected_goal_id: Some(active_goal.goal_id), + }, + ) .await .map_err(|err| err.to_string())? else { @@ -256,7 +305,7 @@ impl GoalRuntimeHandle { self.inner.accounting_state.clear_active_goal(); let goal = protocol_goal_from_state(goal); self.inner.event_emitter.thread_goal_updated( - format!("{turn_id}:usage-limit"), + format!("{turn_id}:{event_name}"), Some(turn_id.to_string()), goal, ); diff --git a/codex-rs/ext/goal/tests/goal_extension_backend.rs b/codex-rs/ext/goal/tests/goal_extension_backend.rs index 3c437a088965..f89a08ebbb20 100644 --- a/codex-rs/ext/goal/tests/goal_extension_backend.rs +++ b/codex-rs/ext/goal/tests/goal_extension_backend.rs @@ -494,18 +494,9 @@ async fn turn_error_usage_limit_accounts_progress_and_clears_accounting() -> any ), ) .await; - let turn_store = ExtensionData::new("turn-1"); - for contributor in harness.registry.turn_lifecycle_contributors() { - contributor - .on_turn_error(TurnErrorInput { - turn_id: "turn-1", - error: CodexErrorInfo::UsageLimitExceeded, - session_store: &harness.session_store, - thread_store: &harness.thread_store, - turn_store: &turn_store, - }) - .await; - } + harness + .notify_turn_error("turn-1", CodexErrorInfo::UsageLimitExceeded) + .await; let goal = runtime .thread_goals() @@ -557,6 +548,36 @@ async fn turn_error_usage_limit_accounts_progress_and_clears_accounting() -> any Ok(()) } +#[tokio::test] +async fn turn_error_blocks_goal() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; + harness.start_turn("turn-1", &TokenUsage::default()).await; + + let tools = harness.tools(); + tool_by_name(&tools, "create_goal") + .handle(tool_call( + "create_goal", + "call-create-goal", + json!({ "objective": "ship goal extension backend" }), + )) + .await?; + + harness + .notify_turn_error("turn-1", CodexErrorInfo::Other) + .await; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(codex_state::ThreadGoalStatus::Blocked, goal.status); + Ok(()) +} + #[tokio::test] async fn usage_limit_budget_limited_goal_accounts_remaining_progress() -> anyhow::Result<()> { let runtime = test_runtime().await?; @@ -1255,6 +1276,21 @@ impl GoalExtensionHarness { } } + async fn notify_turn_error(&self, turn_id: &str, error: CodexErrorInfo) { + let turn_store = ExtensionData::new(turn_id); + for contributor in self.registry.turn_lifecycle_contributors() { + contributor + .on_turn_error(TurnErrorInput { + turn_id, + error: error.clone(), + session_store: &self.session_store, + thread_store: &self.thread_store, + turn_store: &turn_store, + }) + .await; + } + } + fn runtime_handle(&self) -> Arc { self.thread_store .get::() From 4f655bc3b740bb1fec3744df4851f7eb1b95fbe9 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Fri, 5 Jun 2026 16:33:01 -0700 Subject: [PATCH 49/59] [codex] Remove legacy remote plugin startup sync (#25936) ## Summary - Remove the legacy startup remote plugin sync path that called `/plugins/list` and reconciled curated plugin cache/config. - Remove the `sync_plugins_from_remote` API, its result/error types, startup marker task, and tests that expected the legacy request. - Keep the current remote installed bundle sync and remote catalog flows (`/ps/plugins/installed` and `/ps/plugins/list`) intact. ## Validation - `just fmt` - `git diff --check` - `env HOME=/private/tmp/codex-xin-build-home USERPROFILE=/private/tmp/codex-xin-build-home just test -p codex-core-plugins` - Searched for legacy `/plugins/list` sync references; remaining matches are `/ps/plugins/list` catalog tests/code. ## Notes - `just test -p codex-app-server plugin_list` is currently blocked before running filtered tests by an unrelated compile error in `app-server/tests/suite/v2/image_generation.rs`: `app_test_support::McpProcess` is not exported. --- .../app-server/tests/suite/v2/plugin_list.rs | 89 ---- codex-rs/core-plugins/src/lib.rs | 3 - codex-rs/core-plugins/src/manager.rs | 333 ------------- codex-rs/core-plugins/src/manager_tests.rs | 444 ------------------ codex-rs/core-plugins/src/remote_legacy.rs | 69 +-- .../core-plugins/src/startup_remote_sync.rs | 100 ---- .../src/startup_remote_sync_tests.rs | 91 ---- codex-rs/core-plugins/src/test_support.rs | 4 - 8 files changed, 3 insertions(+), 1130 deletions(-) delete mode 100644 codex-rs/core-plugins/src/startup_remote_sync.rs delete mode 100644 codex-rs/core-plugins/src/startup_remote_sync_tests.rs diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index ae622db8a87d..49ad3c418e90 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -38,7 +38,6 @@ use wiremock::matchers::query_param; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; -const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1"; const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str = "CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS"; const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json"; @@ -1460,94 +1459,6 @@ enabled = true Ok(()) } -#[tokio::test] -async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { - let codex_home = TempDir::new()?; - let server = MockServer::start().await; - write_plugin_sync_config(codex_home.path(), &format!("{}/backend-api/", server.uri()))?; - write_chatgpt_auth( - codex_home.path(), - ChatGptAuthFixture::new("chatgpt-token") - .account_id("account-123") - .chatgpt_user_id("user-123") - .chatgpt_account_id("account-123"), - AuthCredentialsStoreMode::File, - )?; - write_openai_curated_marketplace(codex_home.path(), &["linear"])?; - - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/featured")) - .and(query_param("platform", "codex")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#)) - .mount(&server) - .await; - - let marker_path = codex_home - .path() - .join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE); - - { - let mut mcp = TestAppServer::new_with_plugin_startup_tasks(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - - wait_for_path_exists(&marker_path).await?; - wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1) - .await?; - let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - marketplace_kinds: None, - }) - .await?; - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let response: PluginListResponse = to_response(response)?; - let curated_marketplace = response - .marketplaces - .into_iter() - .find(|marketplace| marketplace.name == "openai-curated") - .expect("expected openai-curated marketplace entry"); - assert_eq!( - curated_marketplace - .plugins - .into_iter() - .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) - .collect::>(), - vec![("linear@openai-curated".to_string(), true, true)] - ); - wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1) - .await?; - } - - let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - - { - let mut mcp = TestAppServer::new_with_plugin_startup_tasks(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - } - - tokio::time::sleep(Duration::from_millis(250)).await; - wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1).await?; - Ok(()) -} - #[tokio::test] async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 98cb73e1276b..8cfb9092f62d 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -11,7 +11,6 @@ mod plugin_bundle_archive; pub mod remote; pub mod remote_bundle; pub mod remote_legacy; -pub(crate) mod startup_remote_sync; pub mod startup_sync; pub mod store; #[cfg(test)] @@ -37,10 +36,8 @@ pub use manager::PluginInstallOutcome; pub use manager::PluginInstallRequest; pub use manager::PluginReadOutcome; pub use manager::PluginReadRequest; -pub use manager::PluginRemoteSyncError; pub use manager::PluginUninstallError; pub use manager::PluginsConfigInput; pub use manager::PluginsManager; -pub use manager::RemotePluginSyncResult; pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError; pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome; diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 845b8f3f321a..1839b06e0235 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -1,5 +1,4 @@ use super::PluginLoadOutcome; -use super::startup_remote_sync::start_startup_remote_plugin_sync_once; use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack; use crate::loader::PluginHookLoadOutcome; @@ -32,7 +31,6 @@ use crate::marketplace::ResolvedMarketplacePlugin; use crate::marketplace::find_installable_marketplace_plugin; use crate::marketplace::find_marketplace_plugin; use crate::marketplace::list_marketplaces; -use crate::marketplace::load_marketplace; use crate::marketplace::plugin_interface_with_marketplace_category; use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeError; use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome; @@ -52,8 +50,6 @@ use crate::store::PluginStore; use crate::store::PluginStoreError; use codex_analytics::AnalyticsEventsClient; use codex_config::ConfigLayerStack; -use codex_config::PluginConfigEdit; -use codex_config::apply_user_plugin_config_edits; use codex_config::clear_user_plugin; use codex_config::set_user_plugin_enabled; use codex_config::types::PluginConfig; @@ -81,7 +77,6 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Instant; use tokio::sync::Semaphore; -use tracing::info; use tracing::warn; static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false); @@ -297,107 +292,6 @@ impl From for PluginCapabilitySummary { } } -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct RemotePluginSyncResult { - /// Plugin ids newly installed into the local plugin cache. - pub installed_plugin_ids: Vec, - /// Plugin ids whose local config was changed to enabled. - pub enabled_plugin_ids: Vec, - /// Plugin ids whose local config was changed to disabled. - /// This is not populated by `sync_plugins_from_remote`. - pub disabled_plugin_ids: Vec, - /// Plugin ids removed from local cache or plugin config. - pub uninstalled_plugin_ids: Vec, -} - -#[derive(Debug, thiserror::Error)] -pub enum PluginRemoteSyncError { - #[error("chatgpt authentication required to sync remote plugins")] - AuthRequired, - - #[error( - "chatgpt authentication required to sync remote plugins; api key auth is not supported" - )] - UnsupportedAuthMode, - - #[error("failed to read auth token for remote plugin sync: {0}")] - AuthToken(#[source] std::io::Error), - - #[error("failed to send remote plugin sync request to {url}: {source}")] - Request { - url: String, - #[source] - source: reqwest::Error, - }, - - #[error("remote plugin sync request to {url} failed with status {status}: {body}")] - UnexpectedStatus { - url: String, - status: reqwest::StatusCode, - body: String, - }, - - #[error("failed to parse remote plugin sync response from {url}: {source}")] - Decode { - url: String, - #[source] - source: serde_json::Error, - }, - - #[error("local curated marketplace is not available")] - LocalMarketplaceNotFound, - - #[error("remote marketplace `{marketplace_name}` is not available locally")] - UnknownRemoteMarketplace { marketplace_name: String }, - - #[error("duplicate remote plugin `{plugin_name}` in sync response")] - DuplicateRemotePlugin { plugin_name: String }, - - #[error( - "remote plugin `{plugin_name}` was not found in local marketplace `{marketplace_name}`" - )] - UnknownRemotePlugin { - plugin_name: String, - marketplace_name: String, - }, - - #[error("{0}")] - InvalidPluginId(#[from] PluginIdError), - - #[error("{0}")] - Marketplace(#[from] MarketplaceError), - - #[error("{0}")] - Store(#[from] PluginStoreError), - - #[error("{0}")] - Config(#[from] anyhow::Error), - - #[error("failed to join remote plugin sync task: {0}")] - Join(#[from] tokio::task::JoinError), -} - -impl PluginRemoteSyncError { - fn join(source: tokio::task::JoinError) -> Self { - Self::Join(source) - } -} - -impl From for PluginRemoteSyncError { - fn from(value: RemotePluginFetchError) -> Self { - match value { - RemotePluginFetchError::AuthRequired => Self::AuthRequired, - RemotePluginFetchError::UnsupportedAuthMode => Self::UnsupportedAuthMode, - RemotePluginFetchError::AuthToken(source) => Self::AuthToken(source), - RemotePluginFetchError::Request { url, source } => Self::Request { url, source }, - RemotePluginFetchError::UnexpectedStatus { url, status, body } => { - Self::UnexpectedStatus { url, status, body } - } - RemotePluginFetchError::Decode { url, source } => Self::Decode { url, source }, - } - } -} - pub struct PluginsManager { codex_home: PathBuf, store: PluginStore, @@ -408,7 +302,6 @@ pub struct PluginsManager { enabled_outcome_load_semaphore: Semaphore, remote_installed_plugins_cache: RwLock>>, remote_installed_plugins_cache_refresh_state: RwLock, - remote_sync_lock: Semaphore, restriction_product: Option, analytics_events_client: RwLock>, } @@ -462,7 +355,6 @@ impl PluginsManager { remote_installed_plugins_cache_refresh_state: RwLock::new( RemoteInstalledPluginsCacheRefreshState::default(), ), - remote_sync_lock: Semaphore::new(/*permits*/ 1), restriction_product, analytics_events_client: RwLock::new(None), } @@ -1051,224 +943,6 @@ impl PluginsManager { Ok(()) } - pub async fn sync_plugins_from_remote( - &self, - config: &PluginsConfigInput, - auth: Option<&CodexAuth>, - additive_only: bool, - ) -> Result { - let _remote_sync_guard = self.remote_sync_lock.acquire().await.map_err(|_| { - PluginRemoteSyncError::Config(anyhow::anyhow!("remote plugin sync semaphore closed")) - })?; - - if !config.plugins_enabled { - return Ok(RemotePluginSyncResult::default()); - } - - info!("starting remote plugin sync"); - let remote_plugins = crate::remote_legacy::fetch_remote_plugin_status( - &remote_plugin_service_config(config), - auth, - ) - .await - .map_err(PluginRemoteSyncError::from)?; - let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack); - let curated_marketplace_root = curated_plugins_repo_path(self.codex_home.as_path()); - let curated_marketplace_path = AbsolutePathBuf::try_from( - curated_marketplace_root.join(".agents/plugins/marketplace.json"), - ) - .map_err(|_| PluginRemoteSyncError::LocalMarketplaceNotFound)?; - let curated_marketplace = match load_marketplace(&curated_marketplace_path) { - Ok(marketplace) => marketplace, - Err(MarketplaceError::MarketplaceNotFound { .. }) => { - return Err(PluginRemoteSyncError::LocalMarketplaceNotFound); - } - Err(err) => return Err(err.into()), - }; - - let marketplace_name = curated_marketplace.name.clone(); - let curated_plugin_version = read_curated_plugins_sha(self.codex_home.as_path()) - .ok_or_else(|| { - PluginStoreError::Invalid( - "local curated marketplace sha is not available".to_string(), - ) - })?; - let cache_plugin_version = curated_plugin_cache_version(&curated_plugin_version); - let mut local_plugins = Vec::<( - String, - PluginId, - AbsolutePathBuf, - Option, - Option, - bool, - )>::new(); - let mut local_plugin_names = HashSet::new(); - for plugin in curated_marketplace.plugins { - let plugin_name = plugin.name; - if !local_plugin_names.insert(plugin_name.clone()) { - warn!( - plugin = plugin_name, - marketplace = %marketplace_name, - "ignoring duplicate local plugin entry during remote sync" - ); - continue; - } - - let plugin_id = PluginId::new(plugin_name.clone(), marketplace_name.clone())?; - let plugin_key = plugin_id.as_key(); - let source_path = match plugin.source { - MarketplacePluginSource::Local { path } => path, - MarketplacePluginSource::Git { .. } => { - warn!( - plugin = plugin_name, - marketplace = %marketplace_name, - "skipping remote plugin source during remote sync" - ); - continue; - } - }; - let current_enabled = configured_plugins - .get(&plugin_key) - .map(|plugin| plugin.enabled); - let installed_version = self.store.active_plugin_version(&plugin_id); - let product_allowed = - self.restriction_product_matches(plugin.policy.products.as_deref()); - local_plugins.push(( - plugin_name, - plugin_id, - source_path, - current_enabled, - installed_version, - product_allowed, - )); - } - - let mut missing_remote_plugins = Vec::::new(); - let mut remote_installed_plugin_names = HashSet::::new(); - for plugin in remote_plugins { - if plugin.marketplace_name != marketplace_name { - return Err(PluginRemoteSyncError::UnknownRemoteMarketplace { - marketplace_name: plugin.marketplace_name, - }); - } - if !local_plugin_names.contains(&plugin.name) { - missing_remote_plugins.push(plugin.name); - continue; - } - // For now, sync treats remote `enabled = false` as uninstall rather than a distinct - // disabled state. - // TODO: Switch sync to `plugins/installed` so install and enable states stay distinct. - if !plugin.enabled { - continue; - } - if !remote_installed_plugin_names.insert(plugin.name.clone()) { - return Err(PluginRemoteSyncError::DuplicateRemotePlugin { - plugin_name: plugin.name, - }); - } - } - - let mut config_edits = Vec::new(); - let mut installs = Vec::new(); - let mut uninstalls = Vec::new(); - let mut result = RemotePluginSyncResult::default(); - let remote_plugin_count = remote_installed_plugin_names.len(); - let local_plugin_count = local_plugins.len(); - if !missing_remote_plugins.is_empty() { - let sample_missing_plugins = missing_remote_plugins - .iter() - .take(10) - .cloned() - .collect::>(); - warn!( - marketplace = %marketplace_name, - missing_remote_plugin_count = missing_remote_plugins.len(), - missing_remote_plugin_examples = ?sample_missing_plugins, - "ignoring remote plugins missing from local marketplace during sync" - ); - } - - for ( - plugin_name, - plugin_id, - source_path, - current_enabled, - installed_version, - product_allowed, - ) in local_plugins - { - let plugin_key = plugin_id.as_key(); - let is_installed = installed_version.is_some(); - if !product_allowed { - continue; - } - if remote_installed_plugin_names.contains(&plugin_name) { - if !is_installed { - installs.push((source_path, plugin_id.clone(), cache_plugin_version.clone())); - } - if !is_installed { - result.installed_plugin_ids.push(plugin_key.clone()); - } - - if current_enabled != Some(true) { - result.enabled_plugin_ids.push(plugin_key.clone()); - config_edits.push(PluginConfigEdit::SetEnabled { - plugin_key, - enabled: true, - }); - } - } else if !additive_only { - if is_installed { - uninstalls.push(plugin_id); - } - if is_installed || current_enabled.is_some() { - result.uninstalled_plugin_ids.push(plugin_key.clone()); - } - if current_enabled.is_some() { - config_edits.push(PluginConfigEdit::Clear { plugin_key }); - } - } - } - - let store = self.store.clone(); - let store_result = tokio::task::spawn_blocking(move || { - for (source_path, plugin_id, plugin_version) in installs { - store.install_with_version(source_path, plugin_id, plugin_version)?; - } - for plugin_id in uninstalls { - store.uninstall(&plugin_id)?; - } - Ok::<(), PluginStoreError>(()) - }) - .await - .map_err(PluginRemoteSyncError::join)?; - if let Err(err) = store_result { - self.clear_cache(); - return Err(err.into()); - } - - let config_result = if config_edits.is_empty() { - Ok(()) - } else { - apply_user_plugin_config_edits(&self.codex_home, config_edits).await - }; - self.clear_cache(); - config_result.map_err(anyhow::Error::from)?; - - info!( - marketplace = %marketplace_name, - remote_plugin_count, - local_plugin_count, - installed_plugin_ids = ?result.installed_plugin_ids, - enabled_plugin_ids = ?result.enabled_plugin_ids, - disabled_plugin_ids = ?result.disabled_plugin_ids, - uninstalled_plugin_ids = ?result.uninstalled_plugin_ids, - "completed remote plugin sync" - ); - - Ok(result) - } - pub fn list_marketplaces_for_config( &self, config: &PluginsConfigInput, @@ -1619,13 +1293,6 @@ impl PluginsManager { warn!("failed to start configured marketplace auto-upgrade task: {err}"); } } - start_startup_remote_plugin_sync_once( - Arc::clone(self), - self.codex_home.clone(), - config.clone(), - auth_manager.clone(), - ); - let config_for_remote_sync = config.clone(); let manager = Arc::clone(self); let auth_manager_for_remote_sync = auth_manager.clone(); diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index f036ed40a5a0..b1be37655bf2 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -2416,25 +2416,6 @@ enabled = true ); } -#[tokio::test] -async fn sync_plugins_from_remote_returns_default_when_feature_disabled() { - let tmp = tempfile::tempdir().unwrap(); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = false -"#, - ); - - let config = load_config(tmp.path(), tmp.path()).await; - let outcome = PluginsManager::new(tmp.path().to_path_buf()) - .sync_plugins_from_remote(&config, /*auth*/ None, /*additive_only*/ false) - .await - .unwrap(); - - assert_eq!(outcome, RemotePluginSyncResult::default()); -} - #[tokio::test] async fn list_marketplaces_includes_curated_repo_marketplace() { let tmp = tempfile::tempdir().unwrap(); @@ -2925,431 +2906,6 @@ enabled = true ); } -#[tokio::test] -async fn sync_plugins_from_remote_reconciles_cache_and_config() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "linear/local", - "linear", - ); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "gmail/local", - "gmail", - ); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "calendar/local", - "calendar", - ); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false - -[plugins."gmail@openai-curated"] -enabled = false - -[plugins."calendar@openai-curated"] -enabled = true -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .and(header("authorization", "Bearer Access Token")) - .and(header("chatgpt-account-id", "account_id")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}, - {"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let result = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ false, - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginSyncResult { - installed_plugin_ids: Vec::new(), - enabled_plugin_ids: vec!["linear@openai-curated".to_string()], - disabled_plugin_ids: Vec::new(), - uninstalled_plugin_ids: vec![ - "gmail@openai-curated".to_string(), - "calendar@openai-curated".to_string(), - ], - } - ); - - assert!( - tmp.path() - .join("plugins/cache/openai-curated/linear/local") - .is_dir() - ); - assert!( - !tmp.path() - .join("plugins/cache/openai-curated/gmail") - .exists() - ); - assert!( - !tmp.path() - .join("plugins/cache/openai-curated/calendar") - .exists() - ); - - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(config.contains("enabled = true")); - assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#)); - assert!(!config.contains(r#"[plugins."calendar@openai-curated"]"#)); - - let synced_config = load_config(tmp.path(), tmp.path()).await; - let curated_marketplace = manager - .list_marketplaces_for_config(&synced_config, &[]) - .unwrap() - .marketplaces - .into_iter() - .find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) - .unwrap(); - assert_eq!( - curated_marketplace - .plugins - .into_iter() - .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) - .collect::>(), - vec![ - ("linear@openai-curated".to_string(), true, true), - ("gmail@openai-curated".to_string(), false, false), - ("calendar@openai-curated".to_string(), false, false), - ] - ); -} - -#[tokio::test] -async fn sync_plugins_from_remote_additive_only_keeps_existing_plugins() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "linear/local", - "linear", - ); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "gmail/local", - "gmail", - ); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "calendar/local", - "calendar", - ); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false - -[plugins."gmail@openai-curated"] -enabled = false - -[plugins."calendar@openai-curated"] -enabled = true -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .and(header("authorization", "Bearer Access Token")) - .and(header("chatgpt-account-id", "account_id")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}, - {"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let result = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ true, - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginSyncResult { - installed_plugin_ids: Vec::new(), - enabled_plugin_ids: vec!["linear@openai-curated".to_string()], - disabled_plugin_ids: Vec::new(), - uninstalled_plugin_ids: Vec::new(), - } - ); - - assert!( - tmp.path() - .join("plugins/cache/openai-curated/linear/local") - .is_dir() - ); - assert!( - tmp.path() - .join("plugins/cache/openai-curated/gmail/local") - .is_dir() - ); - assert!( - tmp.path() - .join("plugins/cache/openai-curated/calendar/local") - .is_dir() - ); - - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(config.contains(r#"[plugins."gmail@openai-curated"]"#)); - assert!(config.contains(r#"[plugins."calendar@openai-curated"]"#)); - assert!(config.contains("enabled = true")); -} - -#[tokio::test] -async fn sync_plugins_from_remote_ignores_unknown_remote_plugins() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear"]); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"plugin-one","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let result = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ false, - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginSyncResult { - installed_plugin_ids: Vec::new(), - enabled_plugin_ids: Vec::new(), - disabled_plugin_ids: Vec::new(), - uninstalled_plugin_ids: vec!["linear@openai-curated".to_string()], - } - ); - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(!config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!( - !tmp.path() - .join("plugins/cache/openai-curated/linear") - .exists() - ); -} - -#[tokio::test] -async fn sync_plugins_from_remote_keeps_existing_plugins_when_install_fails() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear", "gmail"]); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - fs::remove_dir_all(curated_root.join("plugins/gmail")).unwrap(); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "linear/local", - "linear", - ); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let err = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ false, - ) - .await - .unwrap_err(); - - assert!(matches!( - err, - PluginRemoteSyncError::Store(PluginStoreError::Invalid(ref message)) - if message.contains("plugin source path is not a directory") - )); - assert!( - tmp.path() - .join("plugins/cache/openai-curated/linear/local") - .is_dir() - ); - assert!( - !tmp.path() - .join("plugins/cache/openai-curated/gmail") - .exists() - ); - - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#)); - assert!(config.contains("enabled = false")); -} - -#[tokio::test] -async fn sync_plugins_from_remote_uses_first_duplicate_local_plugin_entry() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap(); - fs::write( - curated_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "openai-curated", - "plugins": [ - { - "name": "gmail", - "source": { - "source": "local", - "path": "./plugins/gmail-first" - } - }, - { - "name": "gmail", - "source": { - "source": "local", - "path": "./plugins/gmail-second" - } - } - ] -}"#, - ) - .unwrap(); - write_plugin(&curated_root, "plugins/gmail-first", "gmail"); - write_plugin(&curated_root, "plugins/gmail-second", "gmail"); - fs::write(curated_root.join("plugins/gmail-first/marker.txt"), "first").unwrap(); - fs::write( - curated_root.join("plugins/gmail-second/marker.txt"), - "second", - ) - .unwrap(); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let result = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ false, - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginSyncResult { - installed_plugin_ids: vec!["gmail@openai-curated".to_string()], - enabled_plugin_ids: vec!["gmail@openai-curated".to_string()], - disabled_plugin_ids: Vec::new(), - uninstalled_plugin_ids: Vec::new(), - } - ); - assert_eq!( - fs::read_to_string(tmp.path().join(format!( - "plugins/cache/openai-curated/gmail/{TEST_CURATED_PLUGIN_CACHE_VERSION}/marker.txt" - ))) - .unwrap(), - "first" - ); -} - #[tokio::test] async fn featured_plugin_ids_for_config_uses_restriction_product_query_param() { let tmp = tempfile::tempdir().unwrap(); diff --git a/codex-rs/core-plugins/src/remote_legacy.rs b/codex-rs/core-plugins/src/remote_legacy.rs index dcf9f79eb856..137c33753b72 100644 --- a/codex-rs/core-plugins/src/remote_legacy.rs +++ b/codex-rs/core-plugins/src/remote_legacy.rs @@ -6,19 +6,9 @@ use serde::Deserialize; use std::time::Duration; use url::Url; -const DEFAULT_REMOTE_MARKETPLACE_NAME: &str = "openai-curated"; -const REMOTE_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(30); const REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(10); const REMOTE_PLUGIN_MUTATION_TIMEOUT: Duration = Duration::from_secs(30); -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -pub struct RemotePluginStatusSummary { - pub name: String, - #[serde(default = "default_remote_marketplace_name")] - pub marketplace_name: String, - pub enabled: bool, -} - #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] struct RemotePluginMutationResponse { @@ -83,32 +73,21 @@ pub enum RemotePluginMutationError { #[derive(Debug, thiserror::Error)] pub enum RemotePluginFetchError { - #[error("chatgpt authentication required to sync remote plugins")] - AuthRequired, - - #[error( - "chatgpt authentication required to sync remote plugins; api key auth is not supported" - )] - UnsupportedAuthMode, - - #[error("failed to read auth token for remote plugin sync: {0}")] - AuthToken(#[source] std::io::Error), - - #[error("failed to send remote plugin sync request to {url}: {source}")] + #[error("failed to send remote featured plugin request to {url}: {source}")] Request { url: String, #[source] source: reqwest::Error, }, - #[error("remote plugin sync request to {url} failed with status {status}: {body}")] + #[error("remote featured plugin request to {url} failed with status {status}: {body}")] UnexpectedStatus { url: String, status: reqwest::StatusCode, body: String, }, - #[error("failed to parse remote plugin sync response from {url}: {source}")] + #[error("failed to parse remote featured plugin response from {url}: {source}")] Decode { url: String, #[source] @@ -116,44 +95,6 @@ pub enum RemotePluginFetchError { }, } -pub async fn fetch_remote_plugin_status( - config: &RemotePluginServiceConfig, - auth: Option<&CodexAuth>, -) -> Result, RemotePluginFetchError> { - let Some(auth) = auth else { - return Err(RemotePluginFetchError::AuthRequired); - }; - if !auth.uses_codex_backend() { - return Err(RemotePluginFetchError::UnsupportedAuthMode); - } - - let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let url = format!("{base_url}/plugins/list"); - let client = build_reqwest_client(); - let request = client - .get(&url) - .timeout(REMOTE_PLUGIN_FETCH_TIMEOUT) - .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()); - - let response = request - .send() - .await - .map_err(|source| RemotePluginFetchError::Request { - url: url.clone(), - source, - })?; - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - if !status.is_success() { - return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body }); - } - - serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode { - url: url.clone(), - source, - }) -} - pub async fn fetch_remote_featured_plugin_ids( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, @@ -224,10 +165,6 @@ fn ensure_codex_backend_auth( Ok(auth) } -fn default_remote_marketplace_name() -> String { - DEFAULT_REMOTE_MARKETPLACE_NAME.to_string() -} - async fn post_remote_plugin_mutation( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, diff --git a/codex-rs/core-plugins/src/startup_remote_sync.rs b/codex-rs/core-plugins/src/startup_remote_sync.rs deleted file mode 100644 index 90c0e119c0cb..000000000000 --- a/codex-rs/core-plugins/src/startup_remote_sync.rs +++ /dev/null @@ -1,100 +0,0 @@ -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; - -use crate::manager::PluginsConfigInput; -use crate::manager::PluginsManager; -use crate::startup_sync::has_local_curated_plugins_snapshot; -use codex_login::AuthManager; -use tracing::info; -use tracing::warn; - -const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1"; -const STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT: Duration = Duration::from_secs(10); - -pub(crate) fn start_startup_remote_plugin_sync_once( - manager: Arc, - codex_home: PathBuf, - config: PluginsConfigInput, - auth_manager: Arc, -) { - let marker_path = startup_remote_plugin_sync_marker_path(codex_home.as_path()); - if marker_path.is_file() { - return; - } - - tokio::spawn(async move { - if marker_path.is_file() { - return; - } - - if !wait_for_startup_remote_plugin_sync_prerequisites(codex_home.as_path()).await { - warn!( - codex_home = %codex_home.display(), - "skipping startup remote plugin sync because curated marketplace is not ready" - ); - return; - } - - let auth = auth_manager.auth().await; - match manager - .sync_plugins_from_remote(&config, auth.as_ref(), /*additive_only*/ true) - .await - { - Ok(sync_result) => { - info!( - installed_plugin_ids = ?sync_result.installed_plugin_ids, - enabled_plugin_ids = ?sync_result.enabled_plugin_ids, - disabled_plugin_ids = ?sync_result.disabled_plugin_ids, - uninstalled_plugin_ids = ?sync_result.uninstalled_plugin_ids, - "completed startup remote plugin sync" - ); - if let Err(err) = - write_startup_remote_plugin_sync_marker(codex_home.as_path()).await - { - warn!( - error = %err, - path = %marker_path.display(), - "failed to persist startup remote plugin sync marker" - ); - } - } - Err(err) => { - warn!( - error = %err, - "startup remote plugin sync failed; will retry on next app-server start" - ); - } - } - }); -} - -fn startup_remote_plugin_sync_marker_path(codex_home: &Path) -> PathBuf { - codex_home.join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE) -} - -async fn wait_for_startup_remote_plugin_sync_prerequisites(codex_home: &Path) -> bool { - let deadline = tokio::time::Instant::now() + STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT; - loop { - if has_local_curated_plugins_snapshot(codex_home) { - return true; - } - if tokio::time::Instant::now() >= deadline { - return false; - } - tokio::time::sleep(Duration::from_millis(50)).await; - } -} - -async fn write_startup_remote_plugin_sync_marker(codex_home: &Path) -> std::io::Result<()> { - let marker_path = startup_remote_plugin_sync_marker_path(codex_home); - if let Some(parent) = marker_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - tokio::fs::write(marker_path, b"ok\n").await -} - -#[cfg(test)] -#[path = "startup_remote_sync_tests.rs"] -mod tests; diff --git a/codex-rs/core-plugins/src/startup_remote_sync_tests.rs b/codex-rs/core-plugins/src/startup_remote_sync_tests.rs deleted file mode 100644 index bdbc5e1a7a13..000000000000 --- a/codex-rs/core-plugins/src/startup_remote_sync_tests.rs +++ /dev/null @@ -1,91 +0,0 @@ -use super::*; -use crate::PluginsManager; -use crate::startup_sync::curated_plugins_repo_path; -use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION; -use crate::test_support::load_plugins_config; -use crate::test_support::write_curated_plugin_sha; -use crate::test_support::write_file; -use crate::test_support::write_openai_curated_marketplace; -use codex_config::CONFIG_TOML_FILE; -use codex_login::AuthManager; -use codex_login::CodexAuth; -use pretty_assertions::assert_eq; -use std::sync::Arc; -use std::time::Duration; -use tempfile::tempdir; -use wiremock::Mock; -use wiremock::MockServer; -use wiremock::ResponseTemplate; -use wiremock::matchers::header; -use wiremock::matchers::method; -use wiremock::matchers::path; - -#[tokio::test] -async fn startup_remote_plugin_sync_writes_marker_and_reconciles_state() { - let tmp = tempdir().expect("tempdir"); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear"]); - write_curated_plugin_sha(tmp.path()); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .and(header("authorization", "Bearer Access Token")) - .and(header("chatgpt-account-id", "account_id")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_plugins_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = Arc::new(PluginsManager::new(tmp.path().to_path_buf())); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - - start_startup_remote_plugin_sync_once( - Arc::clone(&manager), - tmp.path().to_path_buf(), - config, - auth_manager, - ); - - let marker_path = tmp.path().join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE); - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if marker_path.is_file() { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("marker should be written"); - - assert!( - tmp.path() - .join(format!( - "plugins/cache/openai-curated/linear/{TEST_CURATED_PLUGIN_CACHE_VERSION}" - )) - .is_dir() - ); - let config = - std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("config should exist"); - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(config.contains("enabled = true")); - - let marker_contents = std::fs::read_to_string(marker_path).expect("marker should be readable"); - assert_eq!(marker_contents, "ok\n"); -} diff --git a/codex-rs/core-plugins/src/test_support.rs b/codex-rs/core-plugins/src/test_support.rs index 07f1fd4f26f7..2da64f967480 100644 --- a/codex-rs/core-plugins/src/test_support.rs +++ b/codex-rs/core-plugins/src/test_support.rs @@ -88,10 +88,6 @@ pub(crate) fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str } } -pub(crate) fn write_curated_plugin_sha(codex_home: &Path) { - write_curated_plugin_sha_with(codex_home, TEST_CURATED_PLUGIN_SHA); -} - pub(crate) fn write_curated_plugin_sha_with(codex_home: &Path, sha: &str) { write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n")); } From ffe90cb5c37d4cf6ccfa9de0126a5ff61cb959e5 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Fri, 5 Jun 2026 17:23:40 -0700 Subject: [PATCH 50/59] [codex] Use standalone tools for Responses Lite (#26490) ## Summary Responses Lite does not execute hosted Responses tools, so models using it must route web search and image generation through Codex-owned executors & standalone Response's API endpoints. This PR is stacked on #26487. ## Validation - `cargo test -p codex-core responses_lite_ --lib` - `cargo test -p codex-core standalone_executors_remain_hidden_without_flags_or_responses_lite --lib` - `cargo test -p codex-core hosted_tools_follow_provider_auth_model_and_config_gates --lib` - `cargo test -p codex-web-search-extension -p codex-image-generation-extension` - `cargo test -p codex-app-server --test all standalone_` - `cargo fmt --all -- --check` --- codex-rs/Cargo.lock | 4 +- codex-rs/core/Cargo.toml | 2 + codex-rs/core/src/tools/spec_plan.rs | 64 ++++--- codex-rs/core/tests/common/test_codex.rs | 31 +++- codex-rs/core/tests/suite/client.rs | 16 +- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/responses_lite.rs | 167 ++++++++++++++++++ codex-rs/ext/image-generation/Cargo.toml | 1 - .../ext/image-generation/src/extension.rs | 11 +- codex-rs/ext/web-search/Cargo.toml | 1 - codex-rs/ext/web-search/src/extension.rs | 11 +- 11 files changed, 259 insertions(+), 50 deletions(-) create mode 100644 codex-rs/core/tests/suite/responses_lite.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a61df434f194..d67e418298f4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2549,6 +2549,7 @@ dependencies = [ "codex-feedback", "codex-git-utils", "codex-hooks", + "codex-image-generation-extension", "codex-install-context", "codex-login", "codex-mcp", @@ -2584,6 +2585,7 @@ dependencies = [ "codex-utils-pty", "codex-utils-stream-parser", "codex-utils-string", + "codex-web-search-extension", "codex-windows-sandbox", "core_test_support", "csv", @@ -3048,7 +3050,6 @@ dependencies = [ "codex-api", "codex-core", "codex-extension-api", - "codex-features", "codex-login", "codex-model-provider", "codex-model-provider-info", @@ -4208,7 +4209,6 @@ dependencies = [ "codex-api", "codex-core", "codex-extension-api", - "codex-features", "codex-login", "codex-model-provider", "codex-model-provider-info", diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 97e104464fa8..352e8051e4d6 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -133,9 +133,11 @@ codex-shell-escalation = { workspace = true } [dev-dependencies] assert_cmd = { workspace = true } assert_matches = { workspace = true } +codex-image-generation-extension = { workspace = true } codex-otel = { workspace = true } codex-test-binary-support = { workspace = true } codex-utils-cargo-bin = { workspace = true } +codex-web-search-extension = { workspace = true } core_test_support = { workspace = true } ctor = { workspace = true } insta = { workspace = true } diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 514432c880a1..b1a01cd7aad4 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -56,6 +56,7 @@ use crate::tools::router::ToolRouterParams; use codex_features::Feature; use codex_login::AuthManager; use codex_mcp::ToolInfo; +use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; @@ -246,22 +247,31 @@ fn spec_for_model_request( fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec { let turn_context = context.turn_context; + // Responses Lite accepts schemas for client-executed tools, not hosted Responses tools. + if turn_context.model_info.use_responses_lite { + return Vec::new(); + } + let mut specs = Vec::new(); - let provider_capabilities = turn_context.provider.capabilities(); - let web_search_mode = (!standalone_web_run_available(context.extension_tool_executors) - && provider_capabilities.web_search) + let standalone_web_search_available = standalone_web_search_enabled(turn_context) + && context + .extension_tool_executors + .iter() + .any(|executor| executor.tool_name() == ToolName::namespaced("web", "run")); + // `Some(Cached/Live/Disabled)` are the options for mode when standalone search is unavailable + // and the provider supports hosted search. `None` prevents emitting a hosted search tool. + let web_search_mode = (!standalone_web_search_available + && turn_context.provider.capabilities().web_search) .then_some(turn_context.config.web_search_mode.value()); - let web_search_config = if provider_capabilities.web_search { - turn_context.config.web_search_config.as_ref() - } else { - None - }; - if let Some(web_search_tool) = create_web_search_tool(WebSearchToolOptions { + let web_search_config = web_search_mode + .as_ref() + .and(turn_context.config.web_search_config.as_ref()); + if let Some(hosted_web_search_tool) = create_web_search_tool(WebSearchToolOptions { web_search_mode, web_search_config, web_search_tool_type: turn_context.model_info.web_search_tool_type, }) { - specs.push(web_search_tool); + specs.push(hosted_web_search_tool); } // TODO: Remove hosted image generation once the standalone extension is ready. if image_generation_tool_enabled(turn_context) @@ -336,9 +346,15 @@ fn image_generation_runtime_enabled(turn_context: &TurnContext) -> bool { } fn standalone_image_generation_model_visible(turn_context: &TurnContext) -> bool { - image_generation_runtime_enabled(turn_context) - && turn_context.features.get().enabled(Feature::ImageGenExt) - && namespace_tools_enabled(turn_context) + if !image_generation_runtime_enabled(turn_context) || !namespace_tools_enabled(turn_context) { + return false; + } + + if turn_context.model_info.use_responses_lite { + return true; + } + + turn_context.features.get().enabled(Feature::ImageGenExt) } fn standalone_image_generation_available( @@ -554,13 +570,13 @@ fn add_tool_sources(context: &CoreToolPlanContext<'_>, planned_tools: &mut Plann } } -fn standalone_web_run_available( - extension_tools: &[Arc>], -) -> bool { - let web_run = ToolName::namespaced("web", "run"); - extension_tools - .iter() - .any(|executor| executor.tool_name() == web_run) +fn standalone_web_search_enabled(turn_context: &TurnContext) -> bool { + namespace_tools_enabled(turn_context) + && (turn_context.model_info.use_responses_lite + || turn_context + .features + .get() + .enabled(Feature::StandaloneWebSearch)) } fn add_shell_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { @@ -883,8 +899,16 @@ fn append_extension_tool_executors( reserved_tool_names.insert(ToolName::plain(TOOL_SEARCH_TOOL_NAME)); } + let standalone_web_search_enabled = standalone_web_search_enabled(turn_context); + let web_search_mode_on = turn_context.config.web_search_mode.value() != WebSearchMode::Disabled; + for executor in executors.iter().cloned() { let tool_name = executor.tool_name(); + if tool_name == ToolName::namespaced("web", "run") + && (!standalone_web_search_enabled || !web_search_mode_on) + { + continue; + } if tool_name == ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME) && !standalone_image_generation_model_visible(turn_context) { diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 496bf03f5701..1e4313590023 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -23,12 +23,14 @@ use codex_core::thread_store_from_config; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::RemoveOptions; +use codex_extension_api::ExtensionRegistry; use codex_extension_api::empty_extension_registry; use codex_login::CodexAuth; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::built_in_model_providers; use codex_models_manager::bundled_models_response; use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -216,6 +218,7 @@ pub struct TestCodexBuilder { cloud_config_bundle: Option, user_shell_override: Option, exec_server_url: Option, + extensions: Arc>, } impl TestCodexBuilder { @@ -239,6 +242,26 @@ impl TestCodexBuilder { }) } + pub fn with_model_info_override(self, model: &str, override_model_info: T) -> Self + where + T: FnOnce(&mut ModelInfo) + Send + 'static, + { + let model = model.to_string(); + self.with_config(move |config| { + let model_catalog = config.model_catalog.get_or_insert_with(|| { + bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")) + }); + let model_info = model_catalog + .models + .iter_mut() + .find(|model_info| model_info.slug == model) + .unwrap_or_else(|| panic!("{model} should exist in the configured model catalog")); + override_model_info(model_info); + config.model = Some(model); + }) + } + pub fn with_pre_build_hook(mut self, hook: F) -> Self where F: FnOnce(&Path) + Send + 'static, @@ -280,6 +303,11 @@ impl TestCodexBuilder { self } + pub fn with_extensions(mut self, extensions: Arc>) -> Self { + self.extensions = extensions; + self + } + pub fn with_windows_cmd_shell(self) -> Self { if cfg!(windows) { self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe"))) @@ -473,7 +501,7 @@ impl TestCodexBuilder { codex_core::test_support::auth_manager_from_auth(auth.clone()), SessionSource::Exec, Arc::clone(&environment_manager), - empty_extension_registry(), + Arc::clone(&self.extensions), /*analytics_events_client*/ None, thread_store, state_db.clone(), @@ -1041,6 +1069,7 @@ pub fn test_codex() -> TestCodexBuilder { cloud_config_bundle: None, user_shell_override: None, exec_server_url: None, + extensions: empty_extension_registry(), } } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 7c9e07d230b1..2fcb73e25a20 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1894,20 +1894,10 @@ async fn responses_lite_sets_all_turns_context_and_disables_parallel_tool_calls( ) .await; - let mut model_catalog = bundled_models_response() - .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); - let model = model_catalog - .models - .iter_mut() - .find(|model| model.slug == "gpt-5.4") - .expect("gpt-5.4 exists in bundled models.json"); - model.use_responses_lite = true; - model.supports_parallel_tool_calls = true; - let TestCodex { codex, .. } = test_codex() - .with_model("gpt-5.4") - .with_config(move |config| { - config.model_catalog = Some(model_catalog); + .with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + model_info.supports_parallel_tool_calls = true; }) .build(&server) .await?; diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 1e4a5f9501e1..ce853e810f58 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -91,6 +91,7 @@ mod request_permissions_tool; mod request_plugin_install; mod request_user_input; mod responses_api_proxy_headers; +mod responses_lite; mod resume; mod resume_warning; mod review; diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs new file mode 100644 index 000000000000..c9cbcbffdd38 --- /dev/null +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -0,0 +1,167 @@ +use std::sync::Arc; + +use anyhow::Context; +use anyhow::Result; +use codex_core::config::Config; +use codex_extension_api::ExtensionRegistry; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_features::Feature; +use codex_image_generation_extension::install as install_image_generation_extension; +use codex_login::CodexAuth; +use codex_protocol::config_types::WebSearchMode; +use codex_protocol::openai_models::InputModality; +use codex_web_search_extension::install as install_web_search_extension; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use serde_json::Value; + +fn responses_extensions(auth: &CodexAuth) -> Arc> { + let auth_manager = codex_core::test_support::auth_manager_from_auth(auth.clone()); + let mut extension_builder = ExtensionRegistryBuilder::::new(); + install_web_search_extension(&mut extension_builder, Arc::clone(&auth_manager)); + install_image_generation_extension(&mut extension_builder, auth_manager); + Arc::new(extension_builder.build()) +} + +fn configure_responses_tools(config: &mut Config) { + assert!(config.web_search_mode.set(WebSearchMode::Live).is_ok()); + assert!( + config + .features + .disable(Feature::StandaloneWebSearch) + .is_ok() + ); + assert!(config.features.enable(Feature::ImageGeneration).is_ok()); + assert!(config.features.disable(Feature::ImageGenExt).is_ok()); +} + +fn configure_image_capable_model(model_info: &mut codex_protocol::openai_models::ModelInfo) { + model_info.input_modalities = vec![InputModality::Text, InputModality::Image]; +} + +fn has_hosted_tool(tools: &[Value], tool_type: &str) -> bool { + tools + .iter() + .any(|tool| tool.get("type").and_then(Value::as_str) == Some(tool_type)) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_uses_standalone_web_search_and_image_generation() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let extensions = responses_extensions(&auth); + + let mut builder = test_codex() + .with_auth(auth) + .with_extensions(extensions) + .with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + configure_image_capable_model(model_info); + }) + .with_config(configure_responses_tools); + let test = builder.build(&server).await?; + + test.submit_turn("Use standalone tools").await?; + + let request = response_mock.single_request(); + request + .tool_by_name("web", "run") + .context("Responses Lite should expose standalone web search")?; + request + .tool_by_name("image_gen", "imagegen") + .context("Responses Lite should expose standalone image generation")?; + + let body = request.body_json(); + let tools = body["tools"] + .as_array() + .context("Responses request tools should be an array")?; + assert!(!has_hosted_tool(tools, "web_search")); + assert!(!has_hosted_tool(tools, "image_generation")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_omits_hosted_tools_without_standalone_extensions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + configure_image_capable_model(model_info); + }) + .with_config(configure_responses_tools); + let test = builder.build(&server).await?; + + test.submit_turn("Do not use hosted tools").await?; + + let body = response_mock.single_request().body_json(); + let tools = body["tools"] + .as_array() + .context("Responses request tools should be an array")?; + assert!(!has_hosted_tool(tools, "web_search")); + assert!(!has_hosted_tool(tools, "image_generation")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn non_lite_uses_hosted_tools_when_standalone_features_are_disabled() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let extensions = responses_extensions(&auth); + let mut builder = test_codex() + .with_auth(auth) + .with_extensions(extensions) + .with_model_info_override("gpt-5.4", configure_image_capable_model) + .with_config(configure_responses_tools); + let test = builder.build(&server).await?; + + test.submit_turn("Use hosted tools").await?; + + let request = response_mock.single_request(); + assert!(request.tool_by_name("web", "run").is_none()); + assert!(request.tool_by_name("image_gen", "imagegen").is_none()); + let body = request.body_json(); + let tools = body["tools"] + .as_array() + .context("Responses request tools should be an array")?; + assert!(has_hosted_tool(tools, "web_search")); + assert!(has_hosted_tool(tools, "image_generation")); + + Ok(()) +} diff --git a/codex-rs/ext/image-generation/Cargo.toml b/codex-rs/ext/image-generation/Cargo.toml index 3f22bd3fffa9..c6b87e1d4fd0 100644 --- a/codex-rs/ext/image-generation/Cargo.toml +++ b/codex-rs/ext/image-generation/Cargo.toml @@ -17,7 +17,6 @@ async-trait = { workspace = true } codex-api = { workspace = true } codex-core = { workspace = true } codex-extension-api = { workspace = true } -codex-features = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } codex-model-provider-info = { workspace = true } diff --git a/codex-rs/ext/image-generation/src/extension.rs b/codex-rs/ext/image-generation/src/extension.rs index 6f6a7c7b1bc6..2c68f614f5a2 100644 --- a/codex-rs/ext/image-generation/src/extension.rs +++ b/codex-rs/ext/image-generation/src/extension.rs @@ -9,7 +9,6 @@ use codex_extension_api::ThreadStartInput; use codex_extension_api::ToolCall; use codex_extension_api::ToolContributor; use codex_extension_api::ToolExecutor; -use codex_features::Feature; use codex_login::AuthManager; use codex_model_provider::create_model_provider; use codex_model_provider_info::ModelProviderInfo; @@ -25,7 +24,7 @@ struct ImageGenerationExtension { #[derive(Clone)] struct ImageGenerationExtensionConfig { - enabled: bool, + available: bool, provider: ModelProviderInfo, codex_home: AbsolutePathBuf, } @@ -34,8 +33,8 @@ impl From<&Config> for ImageGenerationExtensionConfig { /// Resolves whether standalone image generation should be available for a thread. fn from(config: &Config) -> Self { Self { - enabled: config.features.enabled(Feature::ImageGenExt) - && config.model_provider.is_openai(), + // Core selects this executor per turn using the feature flag or model metadata. + available: config.model_provider.is_openai(), provider: config.model_provider.clone(), codex_home: config.codex_home.clone(), } @@ -75,7 +74,7 @@ impl ToolContributor for ImageGenerationExtension { let Some(config) = thread_store.get::() else { return Vec::new(); }; - if !config.enabled || !self.auth_manager.current_auth_uses_codex_backend() { + if !config.available || !self.auth_manager.current_auth_uses_codex_backend() { return Vec::new(); } @@ -90,7 +89,7 @@ impl ToolContributor for ImageGenerationExtension { } } -/// Installs the feature-gated standalone image-generation extension contributors. +/// Installs the standalone image-generation extension contributors. pub fn install(registry: &mut ExtensionRegistryBuilder, auth_manager: Arc) { let extension = Arc::new(ImageGenerationExtension { auth_manager }); registry.thread_lifecycle_contributor(extension.clone()); diff --git a/codex-rs/ext/web-search/Cargo.toml b/codex-rs/ext/web-search/Cargo.toml index 0faabf21a86b..954ac3dac9e6 100644 --- a/codex-rs/ext/web-search/Cargo.toml +++ b/codex-rs/ext/web-search/Cargo.toml @@ -17,7 +17,6 @@ async-trait = { workspace = true } codex-api = { workspace = true } codex-core = { workspace = true } codex-extension-api = { workspace = true } -codex-features = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } codex-model-provider-info = { workspace = true } diff --git a/codex-rs/ext/web-search/src/extension.rs b/codex-rs/ext/web-search/src/extension.rs index 30fc48961e41..d081d4fb29b9 100644 --- a/codex-rs/ext/web-search/src/extension.rs +++ b/codex-rs/ext/web-search/src/extension.rs @@ -13,7 +13,6 @@ use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::ThreadLifecycleContributor; use codex_extension_api::ThreadStartInput; use codex_extension_api::ToolContributor; -use codex_features::Feature; use codex_login::AuthManager; use codex_model_provider::create_model_provider; use codex_model_provider_info::ModelProviderInfo; @@ -29,7 +28,7 @@ struct WebSearchExtension { #[derive(Clone)] struct WebSearchExtensionConfig { - enabled: bool, + available: bool, provider: ModelProviderInfo, settings: SearchSettings, } @@ -38,8 +37,8 @@ impl From<&Config> for WebSearchExtensionConfig { fn from(config: &Config) -> Self { let web_search_mode = config.web_search_mode.value(); Self { - enabled: config.features.enabled(Feature::StandaloneWebSearch) - && config.model_provider.is_openai() + // Core selects this executor per turn using the feature flag or model metadata. + available: config.model_provider.is_openai() && web_search_mode != WebSearchMode::Disabled, provider: config.model_provider.clone(), settings: search_settings(config, web_search_mode), @@ -111,7 +110,7 @@ impl ToolContributor for WebSearchExtension { let Some(config) = thread_store.get::() else { return Vec::new(); }; - if !config.enabled { + if !config.available { return Vec::new(); } @@ -160,7 +159,7 @@ mod tests { let session_store = ExtensionData::new("session"); let thread_store = ExtensionData::new("11111111-1111-4111-8111-111111111111"); thread_store.insert(WebSearchExtensionConfig { - enabled: true, + available: true, provider: ModelProviderInfo::create_openai_provider(/*base_url*/ None), settings: Default::default(), }); From 61a913d9c83928335b9503ad530caa9851bf5955 Mon Sep 17 00:00:00 2001 From: vie-oai Date: Fri, 5 Jun 2026 17:23:45 -0700 Subject: [PATCH 51/59] [codex] Gate terminal visualization instructions in TUI (#26013) ## Summary - add `Feature::TerminalVisualizationInstructions` as `UnderDevelopment`, disabled by default - keep terminal visualization instructions inside the TUI package - append them to existing developer instructions for TUI start, resume, and fork flows only when enabled - intentionally do not apply them to `codex exec` ## Rollout Control behavior is unchanged. TUI dogfooders can enable `terminal_visualization_instructions`; no default user receives the new terminal-specific instructions. The shared visualization-selection rule is supplied separately through the `codex_proxy_model_3` Statsig layer for every target Codex model slug in the gated cohort. This TUI feature determines how to render an appropriate visualization on the terminal surface; the model-layer treatment determines when to use one. ## Validation - `cargo test -p codex-tui terminal_visualization_instructions_are_gated_for_all_tui_thread_flows --lib` - `cargo test -p codex-features --lib` - `cargo fmt --all -- --check` - `git diff --check` - GPT-5.4 and GPT-5.5 real prompt-pipeline smoke tests: both visualized the positive mapping case, abstained on the negative route case, and passed exact prompt-stack verification on CLI and App - refreshed onto current `main` with a clean merge and reran the focused validation The full 53-probe all-model treatment comparison and requested production coding evals remain rollout gates before broadening beyond the initial employee cohort. This PR remains open for normal human review. --- codex-rs/core/config.schema.json | 6 ++ codex-rs/features/src/lib.rs | 8 ++ codex-rs/tui/src/app_server_session.rs | 86 ++++++++++++++++++- codex-rs/tui/src/lib.rs | 1 + .../terminal_visualization_instructions.rs | 29 +++++++ 5 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 codex-rs/tui/src/terminal_visualization_instructions.rs diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index f5a97b142a15..b30b868bc7b9 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -614,6 +614,9 @@ "terminal_resize_reflow": { "type": "boolean" }, + "terminal_visualization_instructions": { + "type": "boolean" + }, "tool_call_mcp_elicitation": { "type": "boolean" }, @@ -4737,6 +4740,9 @@ "terminal_resize_reflow": { "type": "boolean" }, + "terminal_visualization_instructions": { + "type": "boolean" + }, "tool_call_mcp_elicitation": { "type": "boolean" }, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 3ba973b80427..8ee05aed21d8 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -99,6 +99,8 @@ pub enum Feature { UnifiedExecZshFork, /// Reflow transcript scrollback when the terminal is resized. TerminalResizeReflow, + /// Add terminal-specific visualization guidance to TUI developer instructions. + TerminalVisualizationInstructions, /// Stream structured progress while apply_patch input is being generated. ApplyPatchStreamingEvents, /// Allow exec tools to request additional permissions while staying sandboxed. @@ -1118,6 +1120,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::TerminalVisualizationInstructions, + key: "terminal_visualization_instructions", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::GuardianApproval, key: "guardian_approval", diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index a04faa526187..f5836f4c251e 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -11,6 +11,7 @@ use crate::session_state::MessageHistoryMetadata; use crate::session_state::ThreadSessionState; use crate::status::StatusAccountDisplay; use crate::status::plan_type_display_name; +use crate::terminal_visualization_instructions::with_terminal_visualization_instructions; use codex_app_server_client::AppServerClient; use codex_app_server_client::AppServerEvent; use codex_app_server_client::AppServerRequestHandle; @@ -1412,6 +1413,9 @@ fn thread_start_params_from_config( ephemeral: Some(config.ephemeral), session_start_source, thread_source: Some(ThreadSource::User), + developer_instructions: with_terminal_visualization_instructions( + config, /*control_instructions*/ None, + ), ..ThreadStartParams::default() } } @@ -1444,6 +1448,9 @@ fn thread_resume_params_from_config( sandbox, permissions, config: config_request_overrides_from_config(&config), + developer_instructions: with_terminal_visualization_instructions( + &config, /*control_instructions*/ None, + ), ..ThreadResumeParams::default() } } @@ -1477,7 +1484,10 @@ fn thread_fork_params_from_config( permissions, config: config_request_overrides_from_config(&config), base_instructions: config.base_instructions.clone(), - developer_instructions: config.developer_instructions.clone(), + developer_instructions: with_terminal_visualization_instructions( + &config, + config.developer_instructions.clone(), + ), ephemeral: config.ephemeral, thread_source: Some(ThreadSource::User), ..ThreadForkParams::default() @@ -1748,6 +1758,7 @@ mod tests { use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; + use codex_features::Feature; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; @@ -2235,6 +2246,79 @@ mod tests { ); } + #[tokio::test] + async fn terminal_visualization_instructions_are_gated_for_all_tui_thread_flows() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let mut config = build_config(&temp_dir).await; + config.developer_instructions = Some("Developer override.".to_string()); + let thread_id = ThreadId::new(); + + let control_start = thread_start_params_from_config( + &config, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + /*session_start_source*/ None, + ); + let control_resume = thread_resume_params_from_config( + config.clone(), + thread_id, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + ); + let control_fork = thread_fork_params_from_config( + config.clone(), + thread_id, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + ); + + assert_eq!(control_start.developer_instructions, None); + assert_eq!(control_resume.developer_instructions, None); + assert_eq!( + control_fork.developer_instructions.as_deref(), + Some("Developer override.") + ); + + let _ = config + .features + .enable(Feature::TerminalVisualizationInstructions); + let treatment_start = thread_start_params_from_config( + &config, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + /*session_start_source*/ None, + ); + let treatment_resume = thread_resume_params_from_config( + config.clone(), + thread_id, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + ); + let treatment_fork = thread_fork_params_from_config( + config, + thread_id, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + ); + let expected = format!( + "Developer override.\n\n{}", + crate::terminal_visualization_instructions::TERMINAL_VISUALIZATION_INSTRUCTIONS + ); + + assert_eq!( + treatment_start.developer_instructions.as_deref(), + Some(expected.as_str()) + ); + assert_eq!( + treatment_resume.developer_instructions.as_deref(), + Some(expected.as_str()) + ); + assert_eq!( + treatment_fork.developer_instructions.as_deref(), + Some(expected.as_str()) + ); + } + #[tokio::test] async fn resume_response_restores_turns_from_thread_items() { let temp_dir = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5e83c0746117..5e6b6cf81ca8 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -188,6 +188,7 @@ mod terminal_hyperlinks; mod terminal_palette; mod terminal_probe; mod terminal_title; +mod terminal_visualization_instructions; mod text_formatting; mod theme_picker; mod token_usage; diff --git a/codex-rs/tui/src/terminal_visualization_instructions.rs b/codex-rs/tui/src/terminal_visualization_instructions.rs new file mode 100644 index 000000000000..eb59f2d15719 --- /dev/null +++ b/codex-rs/tui/src/terminal_visualization_instructions.rs @@ -0,0 +1,29 @@ +use crate::legacy_core::config::Config; +use codex_features::Feature; + +pub(crate) const TERMINAL_VISUALIZATION_INSTRUCTIONS: &str = "\ +- This surface is a terminal. When the formatting rules require a visual, include one in the final answer using compact ASCII diagrams, trees, timelines, or tables. +- Use tables for exact mappings or comparisons rather than collapsing known mappings into prose. +- Use trees for hierarchy or one-to-many relationships, and diagrams or timelines for sequence, change, or state transferred between records across event order. +- Use only ASCII characters in visuals."; + +pub(crate) fn with_terminal_visualization_instructions( + config: &Config, + control_instructions: Option, +) -> Option { + if !config + .features + .enabled(Feature::TerminalVisualizationInstructions) + { + return control_instructions; + } + + let existing_instructions = + control_instructions.or_else(|| config.developer_instructions.clone()); + Some(match existing_instructions.as_deref() { + Some(existing) if !existing.trim().is_empty() => { + format!("{existing}\n\n{TERMINAL_VISUALIZATION_INSTRUCTIONS}") + } + _ => TERMINAL_VISUALIZATION_INSTRUCTIONS.to_string(), + }) +} From df7818c7d138ad081f5bd1fa584646c116da7022 Mon Sep 17 00:00:00 2001 From: cooper-oai Date: Fri, 5 Jun 2026 17:36:18 -0700 Subject: [PATCH 52/59] [codex-rs] support v2 personal access tokens (#25731) ## Summary - add v2 personal access token support for `codex login --with-access-token` and `CODEX_ACCESS_TOKEN` - classify opaque `at-` tokens separately from legacy Agent Identity JWTs - hydrate required ChatGPT account metadata through AuthAPI `/v1/user-auth-credential/whoami` - use PATs directly as bearer tokens while preserving existing ChatGPT account surfaces - expose PAT-backed auth as the explicit `personalAccessToken` app-server auth mode ## Implementation PAT auth is intentionally small and stateless. Loading a PAT performs one AuthAPI metadata request, stores the hydrated metadata in the in-memory auth object, and redacts the secret from debug output. Legacy Agent Identity JWT handling remains unchanged. The shared access-token classifier lives in a private neutral module because it dispatches between both credential types. PAT hydration fails closed when AuthAPI omits any required metadata, including email. Hydrated metadata is intentionally not persisted: startup performs a live `whoami` preflight so revoked tokens or changed account metadata are not accepted from a stale cache. ## Workspace restriction scope This change intentionally does **not** apply `forced_chatgpt_workspace_id` to PAT authentication. The setting is a client-side config guardrail, not an authorization boundary, and PAT does not currently require workspace-ID parity. The PAT login and `CODEX_ACCESS_TOKEN` paths therefore validate through AuthAPI without threading workspace-restriction state through access-token loading. Existing workspace checks for non-PAT auth remain on their established paths. ## App-server compatibility The public app-server `AuthMode` is shared across v1 and v2, and PAT-backed auth reports `personalAccessToken` through both APIs. Following human review, this intentionally removes the temporary v1 compatibility mapping that reported PATs as `chatgpt`; the deprecated v1 API is kept in parity with v2 rather than maintaining a separate closed enum. Clients with exhaustive auth-mode handling in either API version must add the new case and should generally treat it as ChatGPT-backed unless they need PAT-specific behavior. The v1 auth-status response still omits the raw PAT when `includeToken` is requested because that response cannot carry the account metadata needed to reuse the credential safely. Persisted PAT auth also omits the new enum value so older Codex builds can deserialize `auth.json` and infer PAT auth from the credential field after a rollback. ## Validation Latest review-fix validation: - `CARGO_INCREMENTAL=0 just test -p codex-login` (126 passed) - `CARGO_INCREMENTAL=0 just test -p codex-cli` (263 passed) - `CARGO_INCREMENTAL=0 just test -p codex-cli stored_auth_validation_handles_personal_access_token` - `CARGO_INCREMENTAL=0 just test -p codex-app-server-protocol` (226 passed) - `CARGO_INCREMENTAL=0 just test -p codex-models-manager refresh_available_models_uses_remote_only_catalog_for_chatgpt_auth` - `CARGO_INCREMENTAL=0 just test -p codex-tui existing_non_oauth_chatgpt_login_counts_as_signed_in` - `CARGO_INCREMENTAL=0 just fix -p codex-login -p codex-app-server-protocol -p codex-models-manager -p codex-tui -p codex-cli` - `just fmt` - `git diff --check` The broader `codex-tui` suite previously compiled and ran 2,834 tests. Three unrelated environment-sensitive guardian/IDE-socket tests failed after retries; the PAT-relevant TUI coverage passed. --- .../schema/json/ServerNotification.json | 7 + .../codex_app_server_protocol.schemas.json | 7 + .../codex_app_server_protocol.v2.schemas.json | 7 + .../json/v2/AccountUpdatedNotification.json | 7 + .../schema/typescript/AuthMode.ts | 2 +- .../src/protocol/common.rs | 15 ++ .../src/transport/remote_control/tests.rs | 1 + .../src/transport/remote_control/websocket.rs | 1 + codex-rs/app-server/README.md | 3 +- codex-rs/app-server/src/request_processors.rs | 1 - .../request_processors/account_processor.rs | 44 ++--- .../app-server/tests/common/auth_fixtures.rs | 1 + codex-rs/app-server/tests/suite/auth.rs | 59 ++++++ .../app-server/tests/suite/v2/app_list.rs | 1 + codex-rs/chatgpt/src/workspace_settings.rs | 12 +- codex-rs/cli/src/doctor.rs | 41 +++- codex-rs/cli/src/login.rs | 4 + codex-rs/core/src/client.rs | 7 +- codex-rs/core/tests/suite/cli_stream.rs | 125 ++++++++++++ codex-rs/login/src/auth/access_token.rs | 18 ++ codex-rs/login/src/auth/access_token_tests.rs | 13 ++ codex-rs/login/src/auth/auth_tests.rs | 184 ++++++++++++++++++ codex-rs/login/src/auth/manager.rs | 160 ++++++++++++--- codex-rs/login/src/auth/mod.rs | 2 + .../login/src/auth/personal_access_token.rs | 112 +++++++++++ .../src/auth/personal_access_token_tests.rs | 71 +++++++ codex-rs/login/src/auth/storage.rs | 3 + codex-rs/login/src/auth/storage_tests.rs | 28 +++ codex-rs/login/src/server.rs | 16 ++ codex-rs/login/tests/suite/auth_refresh.rs | 25 +++ codex-rs/login/tests/suite/logout.rs | 1 + codex-rs/model-provider-info/src/lib.rs | 7 +- .../src/model_provider_info_tests.rs | 9 + codex-rs/model-provider/src/auth.rs | 15 +- codex-rs/model-provider/src/provider.rs | 18 +- codex-rs/models-manager/src/manager.rs | 7 +- codex-rs/models-manager/src/manager_tests.rs | 1 + codex-rs/otel/src/lib.rs | 3 +- codex-rs/tui/src/app/app_server_events.rs | 7 +- codex-rs/tui/src/app_server_session.rs | 3 +- codex-rs/tui/src/local_chatgpt_auth.rs | 2 + codex-rs/tui/src/onboarding/auth.rs | 31 +-- 42 files changed, 982 insertions(+), 99 deletions(-) create mode 100644 codex-rs/login/src/auth/access_token.rs create mode 100644 codex-rs/login/src/auth/access_token_tests.rs create mode 100644 codex-rs/login/src/auth/personal_access_token.rs create mode 100644 codex-rs/login/src/auth/personal_access_token_tests.rs diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 010ec33e0862..96b24f9324b9 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -525,6 +525,13 @@ "agentIdentity" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a personal access token.", + "enum": [ + "personalAccessToken" + ], + "type": "string" } ] }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a5578e007266..15ed98e356aa 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -6677,6 +6677,13 @@ "agentIdentity" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a personal access token.", + "enum": [ + "personalAccessToken" + ], + "type": "string" } ] }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 36b95b4f641a..ebf8796cd6f0 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -999,6 +999,13 @@ "agentIdentity" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a personal access token.", + "enum": [ + "personalAccessToken" + ], + "type": "string" } ] }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json index e7546f5570e9..013592c9ed67 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json @@ -31,6 +31,13 @@ "agentIdentity" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a personal access token.", + "enum": [ + "personalAccessToken" + ], + "type": "string" } ] }, diff --git a/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts b/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts index 210e54c4a5fe..1cb6ccb6b3b7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts +++ b/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts @@ -5,4 +5,4 @@ /** * Authentication mode for OpenAI-backed providers. */ -export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity"; +export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity" | "personalAccessToken"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 1c996d7cb0e3..d3c8eae22b35 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -36,6 +36,21 @@ pub enum AuthMode { #[ts(rename = "agentIdentity")] #[strum(serialize = "agentIdentity")] AgentIdentity, + /// Programmatic Codex auth backed by a personal access token. + #[serde(rename = "personalAccessToken")] + #[ts(rename = "personalAccessToken")] + #[strum(serialize = "personalAccessToken")] + PersonalAccessToken, +} + +impl AuthMode { + /// Returns whether this mode represents an authenticated human ChatGPT account. + pub fn has_chatgpt_account(self) -> bool { + match self { + Self::Chatgpt | Self::ChatgptAuthTokens | Self::PersonalAccessToken => true, + Self::ApiKey | Self::AgentIdentity => false, + } + } } macro_rules! experimental_reason_expr { diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 91c13515d3bc..69659e84a1e5 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -115,6 +115,7 @@ fn remote_control_auth_dot_json(account_id: Option<&str>) -> AuthDotJson { }), last_refresh: Some(chrono::Utc::now()), agent_identity: None, + personal_access_token: None, } } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 43fce8ed3605..86b72849d3ca 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -1769,6 +1769,7 @@ mod tests { }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, } } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a12144238aaf..3e294d4cf394 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1770,6 +1770,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - **API key (`apiKey`)**: Caller supplies an OpenAI API key via `account/login/start` with `type: "apiKey"`. The API key is saved and used for API requests. - **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"` for the browser flow or `type: "chatgptDeviceCode"` for device code; Codex persists tokens to disk and refreshes them automatically. +- **Personal access token (`personalAccessToken`)**: Codex uses a ChatGPT-backed personal access token loaded outside the app-server login RPCs, such as with `codex login --with-access-token` or `CODEX_ACCESS_TOKEN`. ### API Overview @@ -1778,7 +1779,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `account/login/completed` (notify) — emitted when a login attempt finishes (success or error). - `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`. - `account/logout` — sign out; triggers `account/updated`. -- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available. +- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available. - `account/rateLimits/read` — fetch ChatGPT rate limits and an optional effective monthly credit limit; updates arrive via `account/rateLimits/updated` (notify). - `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets. - `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 0266cb9d9578..57205798b23c 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -357,7 +357,6 @@ use codex_mcp::discover_supported_scopes; use codex_mcp::read_mcp_resource as read_mcp_resource_without_thread; use codex_mcp::resolve_oauth_scopes; use codex_memories_write::clear_memory_roots_contents; -use codex_model_provider::ProviderAccountError; use codex_model_provider::create_model_provider; use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; use codex_protocol::ThreadId; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 21160d6810b4..a4bbe42d6139 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -776,24 +776,28 @@ impl AccountRequestProcessor { let permanent_refresh_failure = self.auth_manager.refresh_failure_for_auth(&auth).is_some(); let auth_mode = auth.api_auth_mode(); - let (reported_auth_method, token_opt) = - if matches!(auth, CodexAuth::AgentIdentity(_)) - || include_token && permanent_refresh_failure - { - (Some(auth_mode), None) - } else { - match auth.get_token() { - Ok(token) if !token.is_empty() => { - let tok = if include_token { Some(token) } else { None }; - (Some(auth_mode), tok) - } - Ok(_) => (None, None), - Err(err) => { - tracing::warn!("failed to get token for auth status: {err}"); - (None, None) - } + let (reported_auth_method, token_opt) = if matches!( + auth, + CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) + ) || include_token + && permanent_refresh_failure + { + // This response cannot represent the metadata needed to reuse these + // credentials. + (Some(auth_mode), None) + } else { + match auth.get_token() { + Ok(token) if !token.is_empty() => { + let tok = if include_token { Some(token) } else { None }; + (Some(auth_mode), tok) + } + Ok(_) => (None, None), + Err(err) => { + tracing::warn!("failed to get token for auth status: {err}"); + (None, None) } - }; + } + }; GetAuthStatusResponse { auth_method: reported_auth_method, auth_token: token_opt, @@ -825,11 +829,7 @@ impl AccountRequestProcessor { ); let account_state = match provider.account_state() { Ok(account_state) => account_state, - Err(ProviderAccountError::MissingChatgptAccountDetails) => { - return Err(invalid_request( - "email and plan type are required for chatgpt authentication", - )); - } + Err(err) => return Err(invalid_request(err.to_string())), }; let account = account_state.account.map(Account::from); diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index 86f0fb456ddb..cf78e788de74 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -164,6 +164,7 @@ pub fn write_chatgpt_auth( tokens: Some(tokens), last_refresh, agent_identity: None, + personal_access_token: None, }; save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json") diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index b1ef99cb71fd..6e88866c7d4a 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -21,6 +21,7 @@ use tokio::time::timeout; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; +use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; @@ -160,6 +161,64 @@ async fn get_auth_status_with_api_key() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .and(header("Authorization", "Bearer at-test-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "email": "user@example.com", + "chatgpt_user_id": "user-123", + "chatgpt_account_id": "account-123", + "chatgpt_plan_type": "pro", + "chatgpt_account_is_fedramp": false, + }))) + .expect(1..) + .mount(&server) + .await; + + let authapi_base_url = server.uri(); + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ("CODEX_ACCESS_TOKEN", Some("at-test-token")), + ("CODEX_AUTHAPI_BASE_URL", Some(authapi_base_url.as_str())), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(false), + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; + assert_eq!( + status, + GetAuthStatusResponse { + auth_method: Some(AuthMode::PersonalAccessToken), + auth_token: None, + requires_openai_auth: Some(true), + } + ); + + server.verify().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_auth_status_with_api_key_when_auth_not_required() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 2177106f8f77..c9615d3a2761 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -118,6 +118,7 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }, AuthCredentialsStoreMode::File, )?; diff --git a/codex-rs/chatgpt/src/workspace_settings.rs b/codex-rs/chatgpt/src/workspace_settings.rs index a17721551820..d5875adcf827 100644 --- a/codex-rs/chatgpt/src/workspace_settings.rs +++ b/codex-rs/chatgpt/src/workspace_settings.rs @@ -3,7 +3,6 @@ use std::sync::RwLock; use std::time::Duration; use std::time::Instant; -use anyhow::Context; use codex_core::config::Config; use codex_login::CodexAuth; use serde::Deserialize; @@ -93,20 +92,17 @@ pub async fn codex_plugins_enabled_for_workspace( return Ok(true); } - let token_data = auth - .get_token_data() - .context("ChatGPT token data is not available")?; - if !token_data.id_token.is_workspace_account() { + if !auth.is_workspace_account() { return Ok(true); } - let Some(account_id) = token_data.account_id.as_deref().filter(|id| !id.is_empty()) else { + let Some(account_id) = auth.get_account_id().filter(|id| !id.is_empty()) else { return Ok(true); }; let cache_key = WorkspaceSettingsCacheKey { chatgpt_base_url: config.chatgpt_base_url.clone(), - account_id: account_id.to_string(), + account_id: account_id.clone(), }; if let Some(cache) = cache && let Some(enabled) = cache.get_codex_plugins_enabled(&cache_key) @@ -114,7 +110,7 @@ pub async fn codex_plugins_enabled_for_workspace( return Ok(enabled); } - let encoded_account_id = encode_path_segment(account_id); + let encoded_account_id = encode_path_segment(&account_id); let settings: WorkspaceSettingsResponse = chatgpt_get_request_with_timeout( config, format!("/accounts/{encoded_account_id}/settings"), diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 2e53d5d51f15..dde6fad4702e 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -1308,6 +1308,7 @@ fn stored_auth_mode(auth: &codex_login::AuthDotJson) -> &'static str { codex_app_server_protocol::AuthMode::Chatgpt => "chatgpt", codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity", + codex_app_server_protocol::AuthMode::PersonalAccessToken => "personal_access_token", } } @@ -1317,6 +1318,8 @@ fn stored_auth_mode_value(auth: &AuthDotJson) -> codex_app_server_protocol::Auth } if auth.openai_api_key.is_some() { codex_app_server_protocol::AuthMode::ApiKey + } else if auth.personal_access_token.is_some() { + codex_app_server_protocol::AuthMode::PersonalAccessToken } else { codex_app_server_protocol::AuthMode::Chatgpt } @@ -1380,6 +1383,15 @@ fn stored_auth_issues( issues.push("agent identity auth is missing an agent identity token"); } } + codex_app_server_protocol::AuthMode::PersonalAccessToken => { + if auth + .personal_access_token + .as_deref() + .is_none_or(|token| token.trim().is_empty()) + { + issues.push("personal access token auth is missing a personal access token"); + } + } } issues } @@ -2408,6 +2420,7 @@ fn auth_mode_name(auth: &CodexAuth) -> &'static str { codex_app_server_protocol::AuthMode::Chatgpt => "chatgpt", codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity", + codex_app_server_protocol::AuthMode::PersonalAccessToken => "personal_access_token", } } @@ -2541,7 +2554,8 @@ fn provider_auth_reachability_mode_from_auth( Some( codex_app_server_protocol::AuthMode::Chatgpt | codex_app_server_protocol::AuthMode::ChatgptAuthTokens - | codex_app_server_protocol::AuthMode::AgentIdentity, + | codex_app_server_protocol::AuthMode::AgentIdentity + | codex_app_server_protocol::AuthMode::PersonalAccessToken, ) | None => ProviderAuthReachabilityMode::Chatgpt, } @@ -3401,6 +3415,7 @@ mod tests { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; assert_eq!( @@ -3418,6 +3433,7 @@ mod tests { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; assert_eq!( @@ -3429,6 +3445,28 @@ mod tests { ); } + #[test] + fn stored_auth_validation_handles_personal_access_token() { + let mut auth = AuthDotJson { + auth_mode: None, + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + personal_access_token: Some("at-test".to_string()), + }; + + assert_eq!(stored_auth_mode(&auth), "personal_access_token"); + assert!(stored_auth_issues(&auth, |_| false).is_empty()); + + auth.auth_mode = Some(codex_app_server_protocol::AuthMode::PersonalAccessToken); + auth.personal_access_token = None; + assert_eq!( + stored_auth_issues(&auth, |_| false), + vec!["personal access token auth is missing a personal access token"] + ); + } + #[test] fn provider_reachability_mode_uses_api_key_auth() { let api_key_auth = AuthDotJson { @@ -3437,6 +3475,7 @@ mod tests { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; assert_eq!( diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 6fdc62fdb12c..b3fc1bd544e4 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -391,6 +391,10 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { eprintln!("Logged in using access token"); std::process::exit(0); } + AuthMode::PersonalAccessToken => { + eprintln!("Logged in using personal access token"); + std::process::exit(0); + } }, Ok(None) => { eprintln!("Not logged in"); diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index ee8ea3619f44..682e84c7ff4c 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1950,9 +1950,10 @@ impl AuthRequestTelemetryContext { Self { auth_mode: auth_mode.map(|mode| match mode { AuthMode::ApiKey => "ApiKey", - AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity => { - "Chatgpt" - } + AuthMode::Chatgpt + | AuthMode::ChatgptAuthTokens + | AuthMode::AgentIdentity + | AuthMode::PersonalAccessToken => "Chatgpt", }), auth_header_attached: auth_telemetry.attached, auth_header_name: auth_telemetry.name, diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 361096943e1f..fc9b7d2569ad 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -1,14 +1,27 @@ use assert_cmd::Command as AssertCommand; use codex_git_utils::collect_git_info; +use codex_login::CODEX_ACCESS_TOKEN_ENV_VAR; use codex_login::CODEX_API_KEY_ENV_VAR; use codex_protocol::protocol::GitInfo; use core_test_support::fs_wait; use core_test_support::responses; use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; use std::time::Duration; use tempfile::TempDir; use uuid::Uuid; +use wiremock::Mock; use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const PERSONAL_ACCESS_TOKEN: &str = "at-cli-test"; +const PERSONAL_ACCESS_TOKEN_AUTHORIZATION: &str = "Bearer at-cli-test"; +const PERSONAL_ACCESS_TOKEN_ACCOUNT_ID: &str = "account-pat"; +const WHOAMI_PATH: &str = "/v1/user-auth-credential/whoami"; +const CLOUD_CONFIG_BUNDLE_PATH: &str = "/backend-api/wham/config/bundle"; fn repo_root() -> std::path::PathBuf { #[expect(clippy::expect_used)] @@ -23,6 +36,118 @@ fn cli_sse_response() -> String { ]) } +async fn mount_personal_access_token_startup(server: &MockServer) { + Mock::given(method("GET")) + .and(path(WHOAMI_PATH)) + .and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION)) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "email": "user@example.com", + "chatgpt_user_id": "user-pat", + "chatgpt_account_id": PERSONAL_ACCESS_TOKEN_ACCOUNT_ID, + "chatgpt_plan_type": "enterprise", + "chatgpt_account_is_fedramp": true, + }))) + .expect(1..) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(CLOUD_CONFIG_BUNDLE_PATH)) + .and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION)) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .expect(1) + .mount(server) + .await; +} + +#[expect(clippy::unwrap_used)] +fn personal_access_token_exec_command(server: &MockServer, home: &TempDir) -> AssertCommand { + let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap(); + let mut cmd = AssertCommand::new(bin); + cmd.timeout(Duration::from_secs(30)); + cmd.arg("exec") + .arg("--skip-git-repo-check") + .arg("-c") + .arg(format!("openai_base_url=\"{}/api/codex\"", server.uri())) + .arg("-c") + .arg(format!("chatgpt_base_url=\"{}/backend-api\"", server.uri())) + .arg("-C") + .arg(repo_root()) + .arg("hello?"); + cmd.env("CODEX_HOME", home.path()) + .env(CODEX_ACCESS_TOKEN_ENV_VAR, PERSONAL_ACCESS_TOKEN) + .env("CODEX_AUTHAPI_BASE_URL", server.uri()) + .env_remove(CODEX_API_KEY_ENV_VAR) + .env_remove("OPENAI_API_KEY"); + cmd +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_mode_stream_cli_supports_personal_access_tokens() { + skip_if_no_network!(); + + let server = MockServer::start().await; + mount_personal_access_token_startup(&server).await; + let resp_mock = responses::mount_sse_once(&server, cli_sse_response()).await; + let home = TempDir::new().unwrap(); + + let output = personal_access_token_exec_command(&server, &home) + .output() + .unwrap(); + + assert!( + output.status.success(), + "codex-cli exec failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let request = resp_mock.single_request(); + assert_eq!(request.path(), "/api/codex/responses"); + assert_eq!( + request.header("authorization").as_deref(), + Some("Bearer at-cli-test") + ); + assert_eq!( + request.header("chatgpt-account-id").as_deref(), + Some(PERSONAL_ACCESS_TOKEN_ACCOUNT_ID) + ); + assert_eq!(request.header("x-openai-fedramp").as_deref(), Some("true")); + server.verify().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_mode_stream_cli_does_not_attempt_oauth_refresh_for_personal_access_tokens_after_401() + { + skip_if_no_network!(); + + let server = MockServer::start().await; + mount_personal_access_token_startup(&server).await; + Mock::given(method("POST")) + .and(path("/api/codex/responses")) + .and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION)) + .and(header( + "chatgpt-account-id", + PERSONAL_ACCESS_TOKEN_ACCOUNT_ID, + )) + .and(header("x-openai-fedramp", "true")) + .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized")) + .expect(1..) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&server) + .await; + let home = TempDir::new().unwrap(); + + let output = personal_access_token_exec_command(&server, &home) + .output() + .unwrap(); + + assert!(!output.status.success()); + server.verify().await; +} + /// Tests streaming the Responses API through the CLI using a mock server. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_mode_stream_cli() { diff --git a/codex-rs/login/src/auth/access_token.rs b/codex-rs/login/src/auth/access_token.rs new file mode 100644 index 000000000000..5859ab1c252a --- /dev/null +++ b/codex-rs/login/src/auth/access_token.rs @@ -0,0 +1,18 @@ +const PERSONAL_ACCESS_TOKEN_PREFIX: &str = "at-"; + +pub(super) enum CodexAccessToken<'a> { + PersonalAccessToken(&'a str), + AgentIdentityJwt(&'a str), +} + +pub(super) fn classify_codex_access_token(access_token: &str) -> CodexAccessToken<'_> { + if access_token.starts_with(PERSONAL_ACCESS_TOKEN_PREFIX) { + CodexAccessToken::PersonalAccessToken(access_token) + } else { + CodexAccessToken::AgentIdentityJwt(access_token) + } +} + +#[cfg(test)] +#[path = "access_token_tests.rs"] +mod tests; diff --git a/codex-rs/login/src/auth/access_token_tests.rs b/codex-rs/login/src/auth/access_token_tests.rs new file mode 100644 index 000000000000..d734d149d5e8 --- /dev/null +++ b/codex-rs/login/src/auth/access_token_tests.rs @@ -0,0 +1,13 @@ +use super::*; + +#[test] +fn classifies_personal_access_tokens_by_prefix() { + assert!(matches!( + classify_codex_access_token("at-example"), + CodexAccessToken::PersonalAccessToken("at-example") + )); + assert!(matches!( + classify_codex_access_token("header.payload.signature"), + CodexAccessToken::AgentIdentityJwt("header.payload.signature") + )); +} diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 63ab3e0c424a..e7b5502ebcd6 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -19,6 +19,7 @@ use tempfile::tempdir; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; +use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; @@ -126,6 +127,84 @@ async fn login_with_access_token_writes_only_token() { server.verify().await; } +#[tokio::test] +#[serial(codex_auth_env)] +async fn login_with_access_token_writes_only_personal_access_token() { + let dir = tempdir().unwrap(); + let auth_path = dir.path().join("auth.json"); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .and(header("authorization", "Bearer at-login-test")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)), + ) + .expect(1) + .mount(&server) + .await; + let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); + super::login_with_access_token( + dir.path(), + "at-login-test", + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .expect("personal access token login should succeed"); + + let storage = FileAuthStorage::new(dir.path().to_path_buf()); + let auth = storage + .try_read_auth_json(&auth_path) + .expect("auth.json should parse"); + assert_eq!( + auth, + AuthDotJson { + auth_mode: None, + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + personal_access_token: Some("at-login-test".to_string()), + } + ); + assert_eq!(auth.resolved_mode(), AuthMode::PersonalAccessToken); + let persisted: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(auth_path).unwrap()).unwrap(); + assert!(persisted.get("auth_mode").is_none()); + server.verify().await; +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn login_with_access_token_rejects_invalid_personal_access_token() { + let dir = tempdir().unwrap(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .respond_with(ResponseTemplate::new(403)) + .expect(1) + .mount(&server) + .await; + let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); + + let err = super::login_with_access_token( + dir.path(), + "at-invalid-login", + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .expect_err("invalid personal access token should fail"); + + assert_eq!(err.kind(), std::io::ErrorKind::Other); + assert!( + !get_auth_file(dir.path()).exists(), + "invalid personal access token should not write auth.json" + ); + server.verify().await; +} + #[tokio::test] async fn login_with_access_token_rejects_invalid_jwt() { let dir = tempdir().unwrap(); @@ -245,6 +324,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { }), last_refresh: Some(last_refresh), agent_identity: None, + personal_access_token: None, }, auth_dot_json ); @@ -286,6 +366,7 @@ fn logout_removes_auth_file() -> Result<(), std::io::Error> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?; let auth_file = get_auth_file(dir.path()); @@ -762,6 +843,98 @@ async fn load_auth_reads_access_token_from_env() { server.verify().await; } +#[tokio::test] +#[serial(codex_auth_env)] +async fn load_auth_reads_personal_access_token_from_env() { + let codex_home = tempdir().unwrap(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .and(header("authorization", "Bearer at-env-test")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)), + ) + .expect(2) + .mount(&server) + .await; + let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); + let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-env-test"); + + for auth_credentials_store_mode in [ + AuthCredentialsStoreMode::File, + AuthCredentialsStoreMode::Ephemeral, + ] { + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + auth_credentials_store_mode, + /*chatgpt_base_url*/ None, + ) + .await + .expect("env auth should load") + .expect("env auth should be present"); + + assert_eq!(auth.api_auth_mode(), AuthMode::PersonalAccessToken); + assert_eq!( + auth.get_token() + .expect("personal access token should be exposed"), + "at-env-test" + ); + assert_eq!(auth.get_account_id().as_deref(), Some(WORKSPACE_ID_ALLOWED)); + assert_eq!(auth.get_chatgpt_user_id().as_deref(), Some("user-123")); + assert_eq!( + auth.get_account_email().as_deref(), + Some("user@example.com") + ); + assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Business)); + assert!(auth.is_fedramp_account()); + } + assert!( + !get_auth_file(codex_home.path()).exists(), + "env auth should not write auth.json" + ); + server.verify().await; +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn personal_access_token_does_not_offer_unauthorized_recovery() { + let codex_home = tempdir().unwrap(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)), + ) + .expect(1) + .mount(&server) + .await; + let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); + let _access_token_guard = + EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-no-unauthorized-recovery"); + let manager = Arc::new( + AuthManager::new( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + let recovery = manager.unauthorized_recovery(); + + assert!(!recovery.has_next()); + assert_eq!(recovery.unavailable_reason(), "not_refreshable_auth"); + manager + .refresh_token_from_authority() + .await + .expect("personal access tokens do not use OAuth refresh"); + server.verify().await; +} + #[tokio::test] #[serial(codex_auth_env)] async fn load_auth_keeps_codex_api_key_env_precedence() { @@ -938,6 +1111,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat tokens: None, last_refresh: None, agent_identity: Some(agent_identity), + personal_access_token: None, }, AuthCredentialsStoreMode::File, ) @@ -1091,6 +1265,16 @@ fn test_jwks_body() -> serde_json::Value { }) } +fn personal_access_token_whoami(account_id: &str) -> serde_json::Value { + json!({ + "email": "user@example.com", + "chatgpt_user_id": "user-123", + "chatgpt_account_id": account_id, + "chatgpt_plan_type": "business", + "chatgpt_account_is_fedramp": true, + }) +} + const TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM: &[u8] = br#"-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWpAXYypOsYAwO bvBduMk/mxaoYDze0AZSzaSzLuIlcsl2EKDgC3AabhIWXh/qTGEJLOU3VB1e5mO9 diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 22e2c9f3b9e2..9e255a99a3eb 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -24,9 +24,12 @@ use codex_app_server_protocol::AuthMode as ApiAuthMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ModelProviderAuthInfo; +use super::access_token::CodexAccessToken; +use super::access_token::classify_codex_access_token; use super::external_bearer::BearerTokenRefresher; use super::revoke::revoke_auth_tokens; pub use crate::auth::agent_identity::AgentIdentityAuth; +pub use crate::auth::personal_access_token::PersonalAccessTokenAuth; pub use crate::auth::storage::AgentIdentityAuthRecord; pub use crate::auth::storage::AuthDotJson; use crate::auth::storage::AuthStorageBackend; @@ -53,11 +56,15 @@ pub enum CodexAuth { Chatgpt(ChatgptAuth), ChatgptAuthTokens(ChatgptAuthTokens), AgentIdentity(AgentIdentityAuth), + PersonalAccessToken(PersonalAccessTokenAuth), } impl PartialEq for CodexAuth { fn eq(&self, other: &Self) -> bool { - self.api_auth_mode() == other.api_auth_mode() + match (self, other) { + (Self::PersonalAccessToken(a), Self::PersonalAccessToken(b)) => a == b, + _ => self.api_auth_mode() == other.api_auth_mode(), + } } } @@ -220,6 +227,14 @@ impl CodexAuth { }; return Self::from_agent_identity_jwt(&agent_identity, chatgpt_base_url).await; } + if auth_mode == ApiAuthMode::PersonalAccessToken { + let Some(personal_access_token) = auth_dot_json.personal_access_token.as_deref() else { + return Err(std::io::Error::other( + "personal access token auth is missing a personal access token.", + )); + }; + return Self::from_personal_access_token(personal_access_token).await; + } let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); let state = ChatgptAuthState { @@ -237,6 +252,9 @@ impl CodexAuth { } ApiAuthMode::ApiKey => unreachable!("api key mode is handled above"), ApiAuthMode::AgentIdentity => unreachable!("agent identity mode is handled above"), + ApiAuthMode::PersonalAccessToken => { + unreachable!("personal access token mode is handled above") + } } } @@ -266,11 +284,18 @@ impl CodexAuth { Ok(Self::AgentIdentity(AgentIdentityAuth::load(record).await?)) } + pub async fn from_personal_access_token(access_token: &str) -> std::io::Result { + Ok(Self::PersonalAccessToken( + PersonalAccessTokenAuth::load(access_token).await?, + )) + } + pub fn auth_mode(&self) -> AuthMode { match self { Self::ApiKey(_) => AuthMode::ApiKey, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => AuthMode::Chatgpt, Self::AgentIdentity(_) => AuthMode::AgentIdentity, + Self::PersonalAccessToken(_) => AuthMode::PersonalAccessToken, } } @@ -280,6 +305,7 @@ impl CodexAuth { Self::Chatgpt(_) => ApiAuthMode::Chatgpt, Self::ChatgptAuthTokens(_) => ApiAuthMode::ChatgptAuthTokens, Self::AgentIdentity(_) => ApiAuthMode::AgentIdentity, + Self::PersonalAccessToken(_) => ApiAuthMode::PersonalAccessToken, } } @@ -287,14 +313,21 @@ impl CodexAuth { self.auth_mode() == AuthMode::ApiKey } + pub fn is_personal_access_token_auth(&self) -> bool { + self.auth_mode() == AuthMode::PersonalAccessToken + } + pub fn is_chatgpt_auth(&self) -> bool { - matches!(self, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_)) + self.api_auth_mode().has_chatgpt_account() } pub fn uses_codex_backend(&self) -> bool { matches!( self, - Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::AgentIdentity(_) + Self::Chatgpt(_) + | Self::ChatgptAuthTokens(_) + | Self::AgentIdentity(_) + | Self::PersonalAccessToken(_) ) } @@ -302,11 +335,18 @@ impl CodexAuth { matches!(self, Self::ChatgptAuthTokens(_)) } + fn supports_unauthorized_recovery(&self) -> bool { + matches!(self, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_)) + } + /// Returns `None` if `auth_mode() != AuthMode::ApiKey`. pub fn api_key(&self) -> Option<&str> { match self { Self::ApiKey(auth) => Some(auth.api_key.as_str()), - Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::AgentIdentity(_) => None, + Self::Chatgpt(_) + | Self::ChatgptAuthTokens(_) + | Self::AgentIdentity(_) + | Self::PersonalAccessToken(_) => None, } } @@ -334,6 +374,7 @@ impl CodexAuth { Self::AgentIdentity(_) => Err(std::io::Error::other( "agent identity auth does not expose a bearer token", )), + Self::PersonalAccessToken(auth) => Ok(auth.access_token().to_string()), } } @@ -341,6 +382,7 @@ impl CodexAuth { pub fn get_account_id(&self) -> Option { match self { Self::AgentIdentity(auth) => Some(auth.account_id().to_string()), + Self::PersonalAccessToken(auth) => Some(auth.account_id().to_string()), _ => self.get_current_token_data().and_then(|t| t.account_id), } } @@ -349,6 +391,7 @@ impl CodexAuth { pub fn is_fedramp_account(&self) -> bool { match self { Self::AgentIdentity(auth) => auth.is_fedramp_account(), + Self::PersonalAccessToken(auth) => auth.is_fedramp_account(), _ => self .get_current_token_data() .is_some_and(|t| t.id_token.is_fedramp_account()), @@ -359,6 +402,7 @@ impl CodexAuth { pub fn get_account_email(&self) -> Option { match self { Self::AgentIdentity(auth) => Some(auth.email().to_string()), + Self::PersonalAccessToken(auth) => Some(auth.email().to_string()), _ => self.get_current_token_data().and_then(|t| t.id_token.email), } } @@ -367,6 +411,7 @@ impl CodexAuth { pub fn get_chatgpt_user_id(&self) -> Option { match self { Self::AgentIdentity(auth) => Some(auth.chatgpt_user_id().to_string()), + Self::PersonalAccessToken(auth) => Some(auth.chatgpt_user_id().to_string()), _ => self .get_current_token_data() .and_then(|t| t.id_token.chatgpt_user_id), @@ -380,6 +425,9 @@ impl CodexAuth { if let Self::AgentIdentity(auth) = self { return Some(auth.plan_type()); } + if let Self::PersonalAccessToken(auth) = self { + return Some(auth.plan_type()); + } self.get_current_token_data().map(|t| { t.id_token @@ -399,7 +447,7 @@ impl CodexAuth { let state = match self { Self::Chatgpt(auth) => &auth.state, Self::ChatgptAuthTokens(auth) => &auth.state, - Self::ApiKey(_) | Self::AgentIdentity(_) => return None, + Self::ApiKey(_) | Self::AgentIdentity(_) | Self::PersonalAccessToken(_) => return None, }; #[expect(clippy::unwrap_used)] state.auth_dot_json.lock().unwrap().clone() @@ -423,6 +471,7 @@ impl CodexAuth { }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; let client = create_client(); @@ -539,6 +588,7 @@ pub fn login_with_api_key( tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) } @@ -550,17 +600,35 @@ pub async fn login_with_access_token( auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, ) -> std::io::Result<()> { - let base_url = chatgpt_base_url - .unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL) - .trim_end_matches('/') - .to_string(); - verified_agent_identity_record(access_token, &base_url).await?; - let auth_dot_json = AuthDotJson { - auth_mode: Some(ApiAuthMode::AgentIdentity), - openai_api_key: None, - tokens: None, - last_refresh: None, - agent_identity: Some(access_token.to_string()), + let auth_dot_json = match classify_codex_access_token(access_token) { + CodexAccessToken::PersonalAccessToken(access_token) => { + PersonalAccessTokenAuth::load(access_token).await?; + AuthDotJson { + // Infer PAT auth from the credential field so older Codex builds can still + // deserialize auth.json after a rollback. + auth_mode: None, + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + personal_access_token: Some(access_token.to_string()), + } + } + CodexAccessToken::AgentIdentityJwt(jwt) => { + let base_url = chatgpt_base_url + .unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL) + .trim_end_matches('/') + .to_string(); + verified_agent_identity_record(jwt, &base_url).await?; + AuthDotJson { + auth_mode: Some(ApiAuthMode::AgentIdentity), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(jwt.to_string()), + personal_access_token: None, + } + } }; save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) } @@ -633,10 +701,12 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< (ForcedLoginMethod::Api, AuthMode::ApiKey) => None, (ForcedLoginMethod::Chatgpt, AuthMode::Chatgpt) | (ForcedLoginMethod::Chatgpt, AuthMode::ChatgptAuthTokens) - | (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity) => None, + | (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity) + | (ForcedLoginMethod::Chatgpt, AuthMode::PersonalAccessToken) => None, (ForcedLoginMethod::Api, AuthMode::Chatgpt) | (ForcedLoginMethod::Api, AuthMode::ChatgptAuthTokens) - | (ForcedLoginMethod::Api, AuthMode::AgentIdentity) => Some( + | (ForcedLoginMethod::Api, AuthMode::AgentIdentity) + | (ForcedLoginMethod::Api, AuthMode::PersonalAccessToken) => Some( "API key login is required, but ChatGPT is currently being used. Logging out." .to_string(), ), @@ -657,7 +727,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< if let Some(expected_account_ids) = config.forced_chatgpt_workspace_id.as_deref() { let chatgpt_account_id = match &auth { - CodexAuth::ApiKey(_) => return Ok(()), + CodexAuth::ApiKey(_) | CodexAuth::PersonalAccessToken(_) => return Ok(()), CodexAuth::AgentIdentity(_) => auth.get_account_id(), CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => { let token_data = match auth.get_token_data() { @@ -758,17 +828,26 @@ async fn load_auth( return Ok(Some(auth)); } + if let Some(access_token) = read_codex_access_token_from_env() { + return match classify_codex_access_token(&access_token) { + CodexAccessToken::PersonalAccessToken(access_token) => { + CodexAuth::from_personal_access_token(access_token) + .await + .map(Some) + } + CodexAccessToken::AgentIdentityJwt(jwt) => { + CodexAuth::from_agent_identity_jwt(jwt, chatgpt_base_url) + .await + .map(Some) + } + }; + } + // If the caller explicitly requested ephemeral auth, there is no persisted fallback. if auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral { return Ok(None); } - if let Some(agent_identity) = read_codex_access_token_from_env() { - return CodexAuth::from_agent_identity_jwt(&agent_identity, chatgpt_base_url) - .await - .map(Some); - } - // Fall back to the configured persistent store (file/keyring/auto) for managed auth. let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); let auth_dot_json = match storage.load()? { @@ -963,6 +1042,7 @@ impl AuthDotJson { tokens: Some(tokens), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }) } @@ -983,6 +1063,9 @@ impl AuthDotJson { if let Some(mode) = self.auth_mode { return mode; } + if self.personal_access_token.is_some() { + return ApiAuthMode::PersonalAccessToken; + } if self.openai_api_key.is_some() { return ApiAuthMode::ApiKey; } @@ -1126,7 +1209,7 @@ impl UnauthorizedRecovery { .manager .auth_cached() .as_ref() - .is_some_and(CodexAuth::is_chatgpt_auth) + .is_some_and(CodexAuth::supports_unauthorized_recovery) { return false; } @@ -1147,11 +1230,20 @@ impl UnauthorizedRecovery { }; } + if self + .manager + .auth_cached() + .as_ref() + .is_some_and(CodexAuth::is_personal_access_token_auth) + { + return "not_refreshable_auth"; + } + if !self .manager .auth_cached() .as_ref() - .is_some_and(CodexAuth::is_chatgpt_auth) + .is_some_and(CodexAuth::supports_unauthorized_recovery) { return "not_chatgpt_auth"; } @@ -1497,6 +1589,7 @@ impl AuthManager { } _ => false, }, + (ApiAuthMode::PersonalAccessToken, ApiAuthMode::PersonalAccessToken) => a == b, _ => false, }, _ => false, @@ -1690,7 +1783,7 @@ impl AuthManager { let auth_before_reload = self.auth_cached(); if auth_before_reload .as_ref() - .is_some_and(CodexAuth::is_api_key_auth) + .is_some_and(|auth| auth.is_api_key_auth() || auth.is_personal_access_token_auth()) { return Ok(()); } @@ -1756,7 +1849,9 @@ impl AuthManager { self.refresh_and_persist_chatgpt_token(&chatgpt_auth, token_data.refresh_token) .await } - CodexAuth::ApiKey(_) | CodexAuth::AgentIdentity(_) => Ok(()), + CodexAuth::ApiKey(_) + | CodexAuth::AgentIdentity(_) + | CodexAuth::PersonalAccessToken(_) => Ok(()), }; if let Err(RefreshTokenError::Permanent(error)) = &result { self.record_permanent_refresh_failure_if_unchanged(&attempted_auth, error); @@ -1805,7 +1900,12 @@ impl AuthManager { pub fn current_auth_uses_codex_backend(&self) -> bool { matches!( self.auth_mode(), - Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity) + Some( + AuthMode::Chatgpt + | AuthMode::ChatgptAuthTokens + | AuthMode::AgentIdentity + | AuthMode::PersonalAccessToken + ) ) } diff --git a/codex-rs/login/src/auth/mod.rs b/codex-rs/login/src/auth/mod.rs index 167ccd3f75bd..9bf6c350f7fc 100644 --- a/codex-rs/login/src/auth/mod.rs +++ b/codex-rs/login/src/auth/mod.rs @@ -1,6 +1,8 @@ +mod access_token; mod agent_identity; pub mod default_client; pub mod error; +mod personal_access_token; mod storage; mod util; diff --git a/codex-rs/login/src/auth/personal_access_token.rs b/codex-rs/login/src/auth/personal_access_token.rs new file mode 100644 index 000000000000..b99092f51f19 --- /dev/null +++ b/codex-rs/login/src/auth/personal_access_token.rs @@ -0,0 +1,112 @@ +use codex_client::CodexHttpClient; +use codex_protocol::account::PlanType as AccountPlanType; +use codex_protocol::auth::PlanType as InternalPlanType; +use serde::Deserialize; +use std::env; +use std::fmt; + +use crate::default_client::create_client; + +const PROD_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts"; +const CODEX_AUTHAPI_BASE_URL_ENV_VAR: &str = "CODEX_AUTHAPI_BASE_URL"; +const WHOAMI_PATH: &str = "/v1/user-auth-credential/whoami"; + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +struct PersonalAccessTokenMetadata { + email: String, + chatgpt_user_id: String, + chatgpt_account_id: String, + chatgpt_plan_type: String, + chatgpt_account_is_fedramp: bool, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct PersonalAccessTokenAuth { + access_token: String, + metadata: PersonalAccessTokenMetadata, +} + +impl fmt::Debug for PersonalAccessTokenAuth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PersonalAccessTokenAuth") + .field("access_token", &"") + .field("metadata", &self.metadata) + .finish() + } +} + +impl PersonalAccessTokenAuth { + pub(super) async fn load(access_token: &str) -> std::io::Result { + let authapi_base_url = env::var(CODEX_AUTHAPI_BASE_URL_ENV_VAR) + .ok() + .map(|base_url| base_url.trim().trim_end_matches('/').to_string()) + .filter(|base_url| !base_url.is_empty()) + .unwrap_or_else(|| PROD_AUTHAPI_BASE_URL.to_string()); + hydrate_personal_access_token(&create_client(), &authapi_base_url, access_token).await + } + + pub fn access_token(&self) -> &str { + &self.access_token + } + + pub fn account_id(&self) -> &str { + &self.metadata.chatgpt_account_id + } + + pub fn chatgpt_user_id(&self) -> &str { + &self.metadata.chatgpt_user_id + } + + pub fn email(&self) -> &str { + &self.metadata.email + } + + pub fn plan_type(&self) -> AccountPlanType { + InternalPlanType::from_raw_value(&self.metadata.chatgpt_plan_type).into() + } + + pub fn is_fedramp_account(&self) -> bool { + self.metadata.chatgpt_account_is_fedramp + } +} + +async fn hydrate_personal_access_token( + client: &CodexHttpClient, + authapi_base_url: &str, + access_token: &str, +) -> std::io::Result { + let endpoint = format!("{}{WHOAMI_PATH}", authapi_base_url.trim_end_matches('/')); + let response = client + .get(&endpoint) + .bearer_auth(access_token) + .send() + .await + .map_err(|err| { + std::io::Error::other(format!( + "failed to request personal access token metadata: {err}" + )) + })?; + if !response.status().is_success() { + return Err(std::io::Error::other(format!( + "personal access token metadata request failed with status {}", + response.status() + ))); + } + + let metadata = response + .json::() + .await + .map_err(|err| { + std::io::Error::other(format!( + "failed to decode personal access token metadata: {err}" + )) + })?; + Ok(PersonalAccessTokenAuth { + access_token: access_token.to_string(), + metadata, + }) +} + +#[cfg(test)] +#[path = "personal_access_token_tests.rs"] +mod tests; diff --git a/codex-rs/login/src/auth/personal_access_token_tests.rs b/codex-rs/login/src/auth/personal_access_token_tests.rs new file mode 100644 index 000000000000..ac6ee12265eb --- /dev/null +++ b/codex-rs/login/src/auth/personal_access_token_tests.rs @@ -0,0 +1,71 @@ +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +fn response(email: Option<&str>) -> serde_json::Value { + json!({ + "email": email, + "chatgpt_user_id": "user-123", + "chatgpt_account_id": "account-123", + "chatgpt_plan_type": "enterprise", + "chatgpt_account_is_fedramp": true, + }) +} + +#[tokio::test] +async fn hydrate_sends_bearer_token_and_preserves_metadata() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(WHOAMI_PATH)) + .and(header("authorization", "Bearer at-example")) + .respond_with(ResponseTemplate::new(200).set_body_json(response(Some("user@example.com")))) + .expect(1) + .mount(&server) + .await; + + let auth = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example") + .await + .expect("personal access token hydration should succeed"); + + assert_eq!( + auth, + PersonalAccessTokenAuth { + access_token: "at-example".to_string(), + metadata: PersonalAccessTokenMetadata { + email: "user@example.com".to_string(), + chatgpt_user_id: "user-123".to_string(), + chatgpt_account_id: "account-123".to_string(), + chatgpt_plan_type: "enterprise".to_string(), + chatgpt_account_is_fedramp: true, + }, + } + ); + server.verify().await; +} + +#[tokio::test] +async fn hydrate_rejects_missing_email() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(WHOAMI_PATH)) + .respond_with(ResponseTemplate::new(200).set_body_json(response(/*email*/ None))) + .expect(1) + .mount(&server) + .await; + + let err = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example") + .await + .expect_err("personal access token hydration should reject missing email"); + + assert!( + err.to_string() + .contains("failed to decode personal access token metadata") + ); + server.verify().await; +} diff --git a/codex-rs/login/src/auth/storage.rs b/codex-rs/login/src/auth/storage.rs index 3a1c8ae6aa53..d0e606a8cc12 100644 --- a/codex-rs/login/src/auth/storage.rs +++ b/codex-rs/login/src/auth/storage.rs @@ -45,6 +45,9 @@ pub struct AuthDotJson { #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_identity: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub personal_access_token: Option, } #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] diff --git a/codex-rs/login/src/auth/storage_tests.rs b/codex-rs/login/src/auth/storage_tests.rs index b5646ef53e83..debe0e18d37a 100644 --- a/codex-rs/login/src/auth/storage_tests.rs +++ b/codex-rs/login/src/auth/storage_tests.rs @@ -19,6 +19,7 @@ async fn file_storage_load_returns_auth_dot_json() -> anyhow::Result<()> { tokens: None, last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; storage @@ -40,6 +41,7 @@ async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> { tokens: None, last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; let file = get_auth_file(codex_home.path()); @@ -73,6 +75,27 @@ async fn file_storage_round_trips_agent_identity_auth() -> anyhow::Result<()> { tokens: None, last_refresh: None, agent_identity: Some(agent_identity), + personal_access_token: None, + }; + + storage.save(&auth_dot_json)?; + + let loaded = storage.load()?; + assert_eq!(Some(auth_dot_json), loaded); + Ok(()) +} + +#[tokio::test] +async fn file_storage_round_trips_personal_access_token_auth() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::PersonalAccessToken), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + personal_access_token: Some("at-example".to_string()), }; storage.save(&auth_dot_json)?; @@ -122,6 +145,7 @@ fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File); storage.save(&auth_dot_json)?; @@ -146,6 +170,7 @@ fn ephemeral_storage_save_load_delete_is_in_memory_only() -> anyhow::Result<()> tokens: None, last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; storage.save(&auth_dot_json)?; @@ -245,6 +270,7 @@ fn auth_with_prefix(prefix: &str) -> AuthDotJson { }), last_refresh: None, agent_identity: None, + personal_access_token: None, } } @@ -270,6 +296,7 @@ fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; seed_keyring_with_auth( &mock_keyring, @@ -313,6 +340,7 @@ fn keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Res }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; storage.save(&auth)?; diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index b72bc946f279..5a1833b58c57 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -822,6 +822,7 @@ pub(crate) async fn persist_tokens_async( tokens: Some(tokens), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; save_auth(&codex_home, &auth, auth_credentials_store_mode)?; Ok::<_, io::Error>((previous_auth, auth)) @@ -940,6 +941,20 @@ pub(crate) fn ensure_workspace_allowed( return Err("Login is restricted to a specific workspace, but the token did not include an chatgpt_account_id claim.".to_string()); }; + ensure_workspace_account_allowed(Some(expected), actual) +} + +/// Validates an already known ChatGPT account ID against an optional workspace restriction. +/// +/// PAT login calls this directly because `/whoami` supplies the account ID without an ID token. +pub(crate) fn ensure_workspace_account_allowed( + expected: Option<&[String]>, + actual: &str, +) -> Result<(), String> { + let Some(expected) = expected else { + return Ok(()); + }; + if expected.iter().any(|workspace_id| workspace_id == actual) { Ok(()) } else { @@ -1311,6 +1326,7 @@ mod tests { }), last_refresh: None, agent_identity: None, + personal_access_token: None, } } diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index 83897a0d38bd..b30d738cd4f4 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -55,6 +55,7 @@ async fn refresh_token_succeeds_updates_storage() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -119,6 +120,7 @@ async fn refresh_token_refreshes_when_auth_is_unchanged() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -184,6 +186,7 @@ async fn auth_refreshes_when_access_token_is_near_expiry() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -234,6 +237,7 @@ async fn auth_skips_access_token_outside_refresh_window() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -270,6 +274,7 @@ async fn refresh_token_skips_refresh_when_auth_changed() -> Result<()> { tokens: Some(initial_tokens), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -280,6 +285,7 @@ async fn refresh_token_skips_refresh_when_auth_changed() -> Result<()> { tokens: Some(disk_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -335,6 +341,7 @@ async fn refresh_token_errors_on_account_mismatch() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -346,6 +353,7 @@ async fn refresh_token_errors_on_account_mismatch() -> Result<()> { tokens: Some(disk_tokens), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -405,6 +413,7 @@ async fn returns_fresh_tokens_as_is() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(stale_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -453,6 +462,7 @@ async fn refreshes_token_when_access_token_is_expired() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(fresh_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -503,6 +513,7 @@ async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> { tokens: Some(initial_tokens), last_refresh: Some(stale_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -514,6 +525,7 @@ async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> { tokens: Some(disk_tokens.clone()), last_refresh: Some(fresh_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -566,6 +578,7 @@ async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Resul tokens: Some(initial_tokens), last_refresh: Some(stale_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -577,6 +590,7 @@ async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Resul tokens: Some(disk_tokens.clone()), last_refresh: Some(fresh_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -627,6 +641,7 @@ async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Re tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -680,6 +695,7 @@ async fn refresh_token_does_not_retry_after_permanent_failure() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -747,6 +763,7 @@ async fn refresh_token_does_not_retry_after_bad_request_reused_failure() -> Resu tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -814,6 +831,7 @@ async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result< tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -836,6 +854,7 @@ async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result< tokens: Some(disk_tokens.clone()), last_refresh: Some(fresh_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -895,6 +914,7 @@ async fn refresh_token_returns_transient_error_on_server_failure() -> Result<()> tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -948,6 +968,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -958,6 +979,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> { tokens: Some(disk_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -1042,6 +1064,7 @@ async fn unauthorized_recovery_errors_on_account_mismatch() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -1053,6 +1076,7 @@ async fn unauthorized_recovery_errors_on_account_mismatch() -> Result<()> { tokens: Some(disk_tokens), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -1111,6 +1135,7 @@ async fn unauthorized_recovery_requires_chatgpt_auth() -> Result<()> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&auth).await?; diff --git a/codex-rs/login/tests/suite/logout.rs b/codex-rs/login/tests/suite/logout.rs index 2364ee7f54a5..2dd1bef83fdb 100644 --- a/codex-rs/login/tests/suite/logout.rs +++ b/codex-rs/login/tests/suite/logout.rs @@ -195,6 +195,7 @@ fn chatgpt_auth_with_refresh_token(refresh_token: &str) -> AuthDotJson { }), last_refresh: None, agent_identity: None, + personal_access_token: None, } } diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index c20a51a3aa6a..5f0f840508f5 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -237,7 +237,12 @@ impl ModelProviderInfo { pub fn to_api_provider(&self, auth_mode: Option) -> CodexResult { let default_base_url = if matches!( auth_mode, - Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity) + Some( + AuthMode::Chatgpt + | AuthMode::ChatgptAuthTokens + | AuthMode::AgentIdentity + | AuthMode::PersonalAccessToken + ) ) { CHATGPT_CODEX_BASE_URL } else { diff --git a/codex-rs/model-provider-info/src/model_provider_info_tests.rs b/codex-rs/model-provider-info/src/model_provider_info_tests.rs index abfa40a36a49..a303050dc137 100644 --- a/codex-rs/model-provider-info/src/model_provider_info_tests.rs +++ b/codex-rs/model-provider-info/src/model_provider_info_tests.rs @@ -139,6 +139,15 @@ fn test_supports_remote_compaction_for_openai() { assert!(provider.supports_remote_compaction()); } +#[test] +fn test_personal_access_token_uses_chatgpt_codex_base_url() { + let api_provider = ModelProviderInfo::create_openai_provider(/*base_url*/ None) + .to_api_provider(Some(AuthMode::PersonalAccessToken)) + .expect("OpenAI provider should build API provider"); + + assert_eq!(api_provider.base_url, CHATGPT_CODEX_BASE_URL); +} + #[test] fn test_supports_remote_compaction_for_azure_name() { let provider = ModelProviderInfo { diff --git a/codex-rs/model-provider/src/auth.rs b/codex-rs/model-provider/src/auth.rs index 3c7f4dbd0fc2..4f87d46baec9 100644 --- a/codex-rs/model-provider/src/auth.rs +++ b/codex-rs/model-provider/src/auth.rs @@ -109,13 +109,14 @@ pub fn auth_provider_from_auth(auth: &CodexAuth) -> SharedAuthProvider { CodexAuth::AgentIdentity(auth) => { Arc::new(AgentIdentityAuthProvider { auth: auth.clone() }) } - CodexAuth::ApiKey(_) | CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => { - Arc::new(BearerAuthProvider { - token: auth.get_token().ok(), - account_id: auth.get_account_id(), - is_fedramp_account: auth.is_fedramp_account(), - }) - } + CodexAuth::ApiKey(_) + | CodexAuth::Chatgpt(_) + | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::PersonalAccessToken(_) => Arc::new(BearerAuthProvider { + token: auth.get_token().ok(), + account_id: auth.get_account_id(), + is_fedramp_account: auth.is_fedramp_account(), + }), } } diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index e2ef3f3650e2..afd07065d4ec 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -212,7 +212,8 @@ impl ModelProvider for ConfiguredModelProvider { CodexAuth::ApiKey(_) => Ok(ProviderAccount::ApiKey), CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) - | CodexAuth::AgentIdentity(_) => { + | CodexAuth::AgentIdentity(_) + | CodexAuth::PersonalAccessToken(_) => { let email = auth.get_account_email(); let plan_type = auth.account_plan_type(); @@ -453,6 +454,21 @@ mod tests { ); } + #[test] + fn openai_provider_rejects_chatgpt_account_state_without_email() { + let provider = create_model_provider( + ModelProviderInfo::create_openai_provider(/*base_url*/ None), + Some(AuthManager::from_auth_for_testing( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + )), + ); + + assert_eq!( + provider.account_state(), + Err(ProviderAccountError::MissingChatgptAccountDetails) + ); + } + #[test] fn custom_non_openai_provider_returns_no_account_state() { let provider = create_model_provider( diff --git a/codex-rs/models-manager/src/manager.rs b/codex-rs/models-manager/src/manager.rs index ac4044ac202a..b21911a4d68d 100644 --- a/codex-rs/models-manager/src/manager.rs +++ b/codex-rs/models-manager/src/manager.rs @@ -328,10 +328,9 @@ impl OpenAiModelsManager { .iter() .any(|model| model.visibility == ModelVisibility::List) && self.auth_manager.as_ref().is_some_and(|auth_manager| { - matches!( - auth_manager.auth_mode(), - Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens) - ) + auth_manager + .auth_mode() + .is_some_and(AuthMode::has_chatgpt_account) }); if should_use_remote_models_only { *self.remote_models.write().await = models; diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index ede7cfe79580..2e39ef3160d0 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -211,6 +211,7 @@ c2ln", }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; std::fs::create_dir_all(codex_home).expect("codex home should be created"); std::fs::write( diff --git a/codex-rs/otel/src/lib.rs b/codex-rs/otel/src/lib.rs index 1a689d3684f4..586dfa15a7de 100644 --- a/codex-rs/otel/src/lib.rs +++ b/codex-rs/otel/src/lib.rs @@ -57,7 +57,8 @@ impl From for TelemetryAuthMode { codex_app_server_protocol::AuthMode::ApiKey => Self::ApiKey, codex_app_server_protocol::AuthMode::Chatgpt | codex_app_server_protocol::AuthMode::ChatgptAuthTokens - | codex_app_server_protocol::AuthMode::AgentIdentity => Self::Chatgpt, + | codex_app_server_protocol::AuthMode::AgentIdentity + | codex_app_server_protocol::AuthMode::PersonalAccessToken => Self::Chatgpt, } } } diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 4aeba59aab2d..dea630876059 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -87,10 +87,9 @@ impl App { notification.plan_type, ), notification.plan_type, - matches!( - notification.auth_mode, - Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens) - ), + notification + .auth_mode + .is_some_and(AuthMode::has_chatgpt_account), ); return; } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index f5836f4c251e..32db3d2cf796 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1192,7 +1192,8 @@ pub(crate) fn status_account_display_from_auth_mode( Some(AuthMode::ApiKey) => Some(StatusAccountDisplay::ApiKey), Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens) - | Some(AuthMode::AgentIdentity) => Some(StatusAccountDisplay::ChatGpt { + | Some(AuthMode::AgentIdentity) + | Some(AuthMode::PersonalAccessToken) => Some(StatusAccountDisplay::ChatGpt { email: None, plan: plan_type.map(plan_type_display_name), }), diff --git a/codex-rs/tui/src/local_chatgpt_auth.rs b/codex-rs/tui/src/local_chatgpt_auth.rs index 270d64f5f7fb..d7ca01f618ba 100644 --- a/codex-rs/tui/src/local_chatgpt_auth.rs +++ b/codex-rs/tui/src/local_chatgpt_auth.rs @@ -109,6 +109,7 @@ mod tests { }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; save_auth(codex_home, &auth, AuthCredentialsStoreMode::File) .expect("chatgpt auth should save"); @@ -156,6 +157,7 @@ mod tests { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }, AuthCredentialsStoreMode::File, ) diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 0d8a1fa77371..e10700ed823b 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -10,6 +10,7 @@ use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; +#[cfg(test)] use codex_app_server_protocol::AuthMode as AppServerAuthMode; use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::ClientRequest; @@ -845,8 +846,7 @@ impl AuthModeWidget { fn handle_existing_chatgpt_login(&mut self) -> bool { if matches!( self.login_status, - LoginStatus::AuthMode(AppServerAuthMode::Chatgpt) - | LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens) + LoginStatus::AuthMode(auth_mode) if auth_mode.has_chatgpt_account() ) { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; self.request_frame.schedule_frame(); @@ -1106,17 +1106,22 @@ mod tests { } #[tokio::test] - async fn existing_chatgpt_auth_tokens_login_counts_as_signed_in() { - let (mut widget, _tmp) = widget_forced_chatgpt().await; - widget.login_status = LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens); - - let handled = widget.handle_existing_chatgpt_login(); - - assert_eq!(handled, true); - assert!(matches!( - &*widget.sign_in_state.read().unwrap(), - SignInState::ChatGptSuccess - )); + async fn existing_non_oauth_chatgpt_login_counts_as_signed_in() { + for auth_mode in [ + AppServerAuthMode::ChatgptAuthTokens, + AppServerAuthMode::PersonalAccessToken, + ] { + let (mut widget, _tmp) = widget_forced_chatgpt().await; + widget.login_status = LoginStatus::AuthMode(auth_mode); + + let handled = widget.handle_existing_chatgpt_login(); + + assert_eq!(handled, true); + assert!(matches!( + &*widget.sign_in_state.read().unwrap(), + SignInState::ChatGptSuccess + )); + } } #[tokio::test] From 954e2878bbd735839c13a5e4e7bd45c74648f549 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Fri, 5 Jun 2026 18:01:20 -0700 Subject: [PATCH 53/59] [codex] Send Responses Lite transport header (#26542) ## Summary - send `X-OpenAI-Internal-Codex-Responses-Lite: true` on HTTP Responses requests and WebSocket upgrade requests when model metadata enables Responses Lite - use client metadata when sending it over the websocket This PR is stacked on #26490. ## Why The Responses Lite marker is request-scoped for HTTP but connection-scoped for Responses-over-WebSocket because it is carried on the upgrade request. Reusing a cached socket opened for the opposite mode would therefore send the wrong transport contract. ## Validation - `just test -p codex-core responses_lite` - `just test -p codex-core responses_websocket_reconnects_when_responses_lite_mode_changes` - `just fix -p codex-core` - `just fmt` --- codex-rs/core/src/client.rs | 40 +++++- codex-rs/core/src/client_tests.rs | 5 +- .../core/tests/suite/client_websockets.rs | 115 ++++++++++++++++++ codex-rs/core/tests/suite/responses_lite.rs | 62 ++++++++++ 4 files changed, 218 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 682e84c7ff4c..dd7b9f400c28 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -143,7 +143,11 @@ pub const X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER: &str = "x-responsesapi-include-timing-metrics"; const X_CODEX_WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY: &str = "x-codex-ws-stream-request-start-ms"; +const WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY: &str = + "ws_request_header_x_openai_internal_codex_responses_lite"; const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; +const X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER: &str = + "x-openai-internal-codex-responses-lite"; const RESPONSES_ENDPOINT: &str = "/responses"; const RESPONSES_COMPACT_ENDPOINT: &str = "/responses/compact"; // `/responses/compact` is unary, so the timeout covers the full response rather than one idle @@ -526,6 +530,7 @@ impl ModelClient { if let Some(header_value) = self.generate_attestation_header_for().await { extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value); } + add_responses_lite_header(&mut extra_headers, model_info.use_responses_lite); let compact_request_timeout = client_setup .api_provider .stream_idle_timeout @@ -655,6 +660,7 @@ impl ModelClient { fn build_ws_client_metadata( &self, turn_metadata_header: Option<&str>, + use_responses_lite: bool, ) -> HashMap { let mut client_metadata = HashMap::new(); client_metadata.insert( @@ -682,6 +688,12 @@ impl ModelClient { turn_metadata.to_string(), ); } + if use_responses_lite { + client_metadata.insert( + WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY.to_string(), + "true".to_string(), + ); + } client_metadata } @@ -982,6 +994,7 @@ impl ModelClientSession { &self, turn_metadata_header: Option<&str>, compression: Compression, + use_responses_lite: bool, ) -> ApiResponsesOptions { let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header); let session_id = self.client.state.session_id.to_string(); @@ -1000,6 +1013,7 @@ impl ModelClientSession { if let Some(header_value) = self.client.generate_attestation_header_for().await { headers.insert(X_OAI_ATTESTATION_HEADER, header_value); } + add_responses_lite_header(&mut headers, use_responses_lite); headers }, compression, @@ -1267,7 +1281,11 @@ impl ModelClientSession { ); let compression = self.responses_request_compression(client_setup.auth.as_ref()); let mut options = self - .build_responses_options(turn_metadata_header, compression) + .build_responses_options( + turn_metadata_header, + compression, + model_info.use_responses_lite, + ) .await; let request = self.client.build_responses_request( @@ -1377,7 +1395,11 @@ impl ModelClientSession { let compression = self.responses_request_compression(client_setup.auth.as_ref()); let options = self - .build_responses_options(turn_metadata_header, compression) + .build_responses_options( + turn_metadata_header, + compression, + model_info.use_responses_lite, + ) .await; let request = self.client.build_responses_request( &client_setup.api_provider, @@ -1389,7 +1411,10 @@ impl ModelClientSession { )?; let mut ws_payload = ResponseCreateWsRequest { client_metadata: response_create_client_metadata( - Some(self.client.build_ws_client_metadata(turn_metadata_header)), + Some(self.client.build_ws_client_metadata( + turn_metadata_header, + model_info.use_responses_lite, + )), request_trace.as_ref(), ), ..ResponseCreateWsRequest::from(&request) @@ -1707,6 +1732,15 @@ fn build_responses_headers( headers } +fn add_responses_lite_header(headers: &mut ApiHeaderMap, use_responses_lite: bool) { + if use_responses_lite { + headers.insert( + X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, + HeaderValue::from_static("true"), + ); + } +} + fn subagent_header_value(session_source: &SessionSource) -> Option { match session_source { SessionSource::SubAgent(subagent_source) => match subagent_source { diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index f8d904036afe..2ebffe392284 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -293,7 +293,10 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() { client.advance_window_generation(); - let client_metadata = client.build_ws_client_metadata(Some(r#"{"turn_id":"turn-123"}"#)); + let client_metadata = client.build_ws_client_metadata( + Some(r#"{"turn_id":"turn-123"}"#), + /*use_responses_lite*/ false, + ); let thread_id = client.state.thread_id; assert_eq!( client_metadata, diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index b28f820569ae..3e1ed2326149 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -62,6 +62,8 @@ const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; const USER_AGENT_HEADER: &str = "user-agent"; const WS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; const X_CLIENT_REQUEST_ID_HEADER: &str = "x-client-request-id"; +const WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY: &str = + "ws_request_header_x_openai_internal_codex_responses_lite"; const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; const X_CODEX_WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY: &str = "x-codex-ws-stream-request-start-ms"; @@ -512,6 +514,85 @@ async fn responses_websocket_reuses_connection_after_session_drop() { server.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_sends_responses_lite_metadata_per_request() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![ + vec![ev_response_created("normal-1"), ev_completed("normal-1")], + vec![ev_response_created("lite-1"), ev_completed("lite-1")], + vec![ev_response_created("normal-2"), ev_completed("normal-2")], + ]]) + .await; + + let harness = websocket_harness(&server).await; + let mut normal_model_info = harness.model_info.clone(); + normal_model_info.supports_reasoning_summaries = true; + let mut lite_model_info = normal_model_info.clone(); + lite_model_info.use_responses_lite = true; + let mut session = harness.client.new_session(); + + stream_until_complete_with_model_info( + &mut session, + &harness, + &prompt_with_input(vec![message_item("normal one")]), + &normal_model_info, + "normal-1", + ) + .await; + stream_until_complete_with_model_info( + &mut session, + &harness, + &prompt_with_input(vec![message_item("lite")]), + &lite_model_info, + "lite-1", + ) + .await; + stream_until_complete_with_model_info( + &mut session, + &harness, + &prompt_with_input(vec![message_item("normal two")]), + &normal_model_info, + "normal-2", + ) + .await; + + let connection = server.single_connection(); + assert_eq!( + connection + .iter() + .map(|request| { + let body = request.body_json(); + json!({ + "responses_lite": body["client_metadata"] + .get(WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY), + "reasoning_context": body["reasoning"].get("context"), + "parallel_tool_calls": body["parallel_tool_calls"], + }) + }) + .collect::>(), + vec![ + json!({ + "responses_lite": null, + "reasoning_context": null, + "parallel_tool_calls": false, + }), + json!({ + "responses_lite": "true", + "reasoning_context": "all_turns", + "parallel_tool_calls": false, + }), + json!({ + "responses_lite": null, + "reasoning_context": null, + "parallel_tool_calls": false, + }), + ] + ); + + server.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_websocket_preconnect_is_reused_even_with_header_changes() { skip_if_no_network!(); @@ -2028,6 +2109,40 @@ async fn stream_until_complete( .await; } +async fn stream_until_complete_with_model_info( + client_session: &mut ModelClientSession, + harness: &WebsocketTestHarness, + prompt: &Prompt, + model_info: &ModelInfo, + expected_response_id: &str, +) { + let mut stream = client_session + .stream( + prompt, + model_info, + &harness.session_telemetry, + harness.effort.clone(), + harness.summary, + /*service_tier*/ None, + /*turn_metadata_header*/ None, + &codex_rollout_trace::InferenceTraceContext::disabled(), + ) + .await + .expect("websocket stream failed"); + + while let Some(event) = stream.next().await { + match event { + Ok(ResponseEvent::Completed { response_id, .. }) => { + assert_eq!(response_id, expected_response_id); + return; + } + Ok(_) => {} + Err(err) => panic!("websocket stream failed: {err}"), + } + } + panic!("websocket stream ended before completion"); +} + async fn stream_until_complete_with_service_tier( client_session: &mut ModelClientSession, harness: &WebsocketTestHarness, diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index c9cbcbffdd38..60db4c064dad 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -10,12 +10,18 @@ use codex_image_generation_extension::install as install_image_generation_extens use codex_login::CodexAuth; use codex_protocol::config_types::WebSearchMode; use codex_protocol::openai_models::InputModality; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; use codex_web_search_extension::install as install_web_search_extension; use core_test_support::responses; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; use serde_json::Value; +const RESPONSES_LITE_HEADER: &str = "x-openai-internal-codex-responses-lite"; + fn responses_extensions(auth: &CodexAuth) -> Arc> { let auth_manager = codex_core::test_support::auth_manager_from_auth(auth.clone()); let mut extension_builder = ExtensionRegistryBuilder::::new(); @@ -76,6 +82,10 @@ async fn responses_lite_uses_standalone_web_search_and_image_generation() -> Res test.submit_turn("Use standalone tools").await?; let request = response_mock.single_request(); + assert_eq!( + request.header(RESPONSES_LITE_HEADER).as_deref(), + Some("true") + ); request .tool_by_name("web", "run") .context("Responses Lite should expose standalone web search")?; @@ -93,6 +103,57 @@ async fn responses_lite_uses_standalone_web_search_and_image_generation() -> Res Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_compact_request_uses_lite_transport_contract() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + let compact_mock = + responses::mount_compact_json_once(&server, serde_json::json!({ "output": [] })).await; + + let mut builder = test_codex().with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + model_info.supports_parallel_tool_calls = true; + }); + let test = builder.build(&server).await?; + + test.submit_turn("Compact this conversation").await?; + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + response_mock.single_request(); + let compact_request = compact_mock.single_request(); + assert_eq!( + compact_request.header(RESPONSES_LITE_HEADER).as_deref(), + Some("true") + ); + let compact_body = compact_request.body_json(); + assert_eq!( + compact_body + .get("reasoning") + .and_then(|reasoning| reasoning.get("context")) + .and_then(Value::as_str), + Some("all_turns") + ); + assert_eq!( + compact_body.get("parallel_tool_calls"), + Some(&Value::Bool(false)) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_lite_omits_hosted_tools_without_standalone_extensions() -> Result<()> { skip_if_no_network!(Ok(())); @@ -154,6 +215,7 @@ async fn non_lite_uses_hosted_tools_when_standalone_features_are_disabled() -> R test.submit_turn("Use hosted tools").await?; let request = response_mock.single_request(); + assert_eq!(request.header(RESPONSES_LITE_HEADER), None); assert!(request.tool_by_name("web", "run").is_none()); assert!(request.tool_by_name("image_gen", "imagegen").is_none()); let body = request.body_json(); From 2f108f9fd970ed73df7e984d3a661acc02f33abc Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 5 Jun 2026 18:06:29 -0700 Subject: [PATCH 54/59] permissions: enforce managed permission profile allowlists (#24852) ## Why Permission profile allowlists are an enterprise security boundary, but they also need to compose across the managed requirements layers added in #24620. A map representation lets each requirements layer add, allow, or revoke individual profiles without replacing an entire array. ## Managed Contract Administrators configure the mergeable allow map with `allowed_permission_profiles`. A recommended enterprise configuration explicitly lists every built-in and custom profile users should be able to select: ```toml default_permissions = "review_only" [allowed_permission_profiles] ":read-only" = true ":workspace" = true review_only = true # ":danger-full-access" is intentionally omitted, so it is denied. [permissions.review_only] extends = ":read-only" ``` - Profiles whose effective merged value is `true` are allowed. - Missing profiles and profiles set to `false` are denied. - This is a closed allowlist: built-in profiles and profiles introduced in future versions are denied unless explicitly allowed. - Explicitly list each built-in profile the enterprise wants to make available. Omit built-ins such as `:danger-full-access` when they should remain unavailable. - Set `default_permissions` explicitly to the allowed profile users should receive when they have no local selection. - Higher-precedence layers override only the profile keys they define. - `false` is only needed when a higher-precedence layer must revoke a `true` inherited from a lower layer. - Explicit keys must refer to known built-in or managed profiles. A custom or narrowed allowlist requires an allowed `default_permissions`. For compatibility, if both `:workspace` and `:read-only` are explicitly allowed, an omitted default resolves to `:workspace`; customer configurations should still set the intended default explicitly. When `allowed_permission_profiles` is absent, existing implicit permission and legacy `sandbox_mode` behavior is unchanged. ## What Changed - Add `allowed_permission_profiles` as a `BTreeMap` that merges per profile across requirements layers. - Enforce managed defaults, strict denial of omitted profiles, and the explicitly allowed standard-pair fallback. - Expose `allowedPermissionProfiles` through `configRequirements/read` and regenerate its schemas. - Add regression coverage for map composition and revocation, managed defaults, strict denial of omitted built-ins, and API output. ## Verification - Focused `codex-config` coverage for layered map composition and revocation - Focused `codex-core` coverage for managed defaults, invalid defaults, strict denial of omitted built-ins, and the standard built-in pair - Focused `codex-app-server` coverage for requirements API output - Scoped Clippy for `codex-config`, `codex-core`, `codex-app-server-protocol`, and `codex-app-server` ## Documentation The managed `requirements.toml` documentation should introduce `allowed_permission_profiles` as a closed permission-profile allowlist before this setting is published on developers.openai.com. --------- Co-authored-by: Codex --- .../codex_app_server_protocol.schemas.json | 14 +- .../codex_app_server_protocol.v2.schemas.json | 14 +- .../v2/ConfigRequirementsReadResponse.json | 14 +- .../typescript/v2/ConfigRequirements.ts | 2 +- .../src/protocol/v2/config.rs | 3 +- .../src/protocol/v2/tests.rs | 3 +- codex-rs/app-server/README.md | 2 +- .../request_processors/config_processor.rs | 39 ++- codex-rs/config/src/config_requirements.rs | 65 ++-- .../config/src/requirements_layers/stack.rs | 9 +- .../src/requirements_layers/stack_tests.rs | 16 + .../core/src/config/config_loader_tests.rs | 281 ++++++++++++++++-- codex-rs/core/src/config/config_tests.rs | 3 +- codex-rs/core/src/config/mod.rs | 110 +++++-- codex-rs/tui/src/debug_config.rs | 6 +- 15 files changed, 465 insertions(+), 116 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 15ed98e356aa..0c29f4a097d0 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -8020,12 +8020,12 @@ "null" ] }, - "allowedPermissions": { - "items": { - "type": "string" + "allowedPermissionProfiles": { + "additionalProperties": { + "type": "boolean" }, "type": [ - "array", + "object", "null" ] }, @@ -8066,6 +8066,12 @@ } ] }, + "defaultPermissions": { + "type": [ + "string", + "null" + ] + }, "enforceResidency": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index ebf8796cd6f0..b378a54d16b8 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4362,12 +4362,12 @@ "null" ] }, - "allowedPermissions": { - "items": { - "type": "string" + "allowedPermissionProfiles": { + "additionalProperties": { + "type": "boolean" }, "type": [ - "array", + "object", "null" ] }, @@ -4408,6 +4408,12 @@ } ] }, + "defaultPermissions": { + "type": [ + "string", + "null" + ] + }, "enforceResidency": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 0b8170d659c3..def89d64e36a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -94,12 +94,12 @@ "null" ] }, - "allowedPermissions": { - "items": { - "type": "string" + "allowedPermissionProfiles": { + "additionalProperties": { + "type": "boolean" }, "type": [ - "array", + "object", "null" ] }, @@ -140,6 +140,12 @@ } ] }, + "defaultPermissions": { + "type": [ + "string", + "null" + ] + }, "enforceResidency": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index 3e60e78da53f..29704982ff53 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -8,4 +8,4 @@ import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; -export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissions: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; +export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index f40192ddc662..227adffc30b2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -375,7 +375,8 @@ pub struct ConfigRequirements { pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, pub allowed_windows_sandbox_implementations: Option>, - pub allowed_permissions: Option>, + pub allowed_permission_profiles: Option>, + pub default_permissions: Option, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, pub allow_appshots: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 1c1e92b36f34..4a683be9904d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1674,7 +1674,8 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_windows_sandbox_implementations: None, - allowed_permissions: None, + allowed_permission_profiles: None, + default_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, allow_appshots: None, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 3e294d4cf394..d918f70b7343 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -231,7 +231,7 @@ Example with notification opt-out: - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish). - `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. -- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`, `allowedPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. ### Example: Start or resume a thread diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 37535509252a..bdc801395097 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -350,7 +350,8 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR .collect() }) }), - allowed_permissions: requirements.allowed_permissions, + allowed_permission_profiles: requirements.allowed_permission_profiles, + default_permissions: requirements.default_permissions, allowed_web_search_modes: requirements.allowed_web_search_modes.map(|modes| { let mut normalized = modes .into_iter() @@ -569,29 +570,43 @@ mod tests { use codex_config::ConfigRequirementsToml; use codex_config::WindowsRequirementsToml; use pretty_assertions::assert_eq; + use std::collections::BTreeMap; #[test] fn requirements_api_includes_allow_managed_hooks_only() { let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { - allowed_permissions: Some(vec![ - "managed-standard".to_string(), - "managed-build".to_string(), - ]), allow_managed_hooks_only: Some(true), ..ConfigRequirementsToml::default() }); - assert_eq!( - mapped.allowed_permissions, - Some(vec![ - "managed-standard".to_string(), - "managed-build".to_string(), - ]) - ); assert_eq!(mapped.allow_managed_hooks_only, Some(true)); assert_eq!(mapped.hooks, None); } + #[test] + fn requirements_api_includes_permission_default_and_allowlist() { + let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + allowed_permission_profiles: Some(BTreeMap::from([ + ("managed-build".to_string(), false), + ("managed-standard".to_string(), true), + ])), + default_permissions: Some("managed-standard".to_string()), + ..ConfigRequirementsToml::default() + }); + + assert_eq!( + mapped.allowed_permission_profiles, + Some(BTreeMap::from([ + ("managed-build".to_string(), false), + ("managed-standard".to_string(), true), + ])) + ); + assert_eq!( + mapped.default_permissions, + Some("managed-standard".to_string()) + ); + } + #[test] fn requirements_api_includes_allow_appshots() { let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index dcaefb8dbead..1fbdebbd0a34 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -822,7 +822,8 @@ pub struct ConfigRequirementsToml { pub allowed_approval_policies: Option>, pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, - pub allowed_permissions: Option>, + pub allowed_permission_profiles: Option>, + pub default_permissions: Option, pub remote_sandbox_config: Option>, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, @@ -876,7 +877,8 @@ pub struct ConfigRequirementsWithSources { pub allowed_approval_policies: Option>>, pub allowed_approvals_reviewers: Option>>, pub allowed_sandbox_modes: Option>>, - pub allowed_permissions: Option>>, + pub allowed_permission_profiles: Option>>, + pub default_permissions: Option>, pub allowed_web_search_modes: Option>>, pub allow_managed_hooks_only: Option>, pub allow_appshots: Option>, @@ -916,7 +918,8 @@ impl ConfigRequirementsWithSources { allowed_approval_policies: _, allowed_approvals_reviewers: _, allowed_sandbox_modes: _, - allowed_permissions: _, + allowed_permission_profiles: _, + default_permissions: _, remote_sandbox_config: _, allowed_web_search_modes: _, allow_managed_hooks_only: _, @@ -951,7 +954,8 @@ impl ConfigRequirementsWithSources { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, - allowed_permissions, + allowed_permission_profiles, + default_permissions, allowed_web_search_modes, allow_managed_hooks_only, allow_appshots, @@ -983,7 +987,8 @@ impl ConfigRequirementsWithSources { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, - allowed_permissions, + allowed_permission_profiles, + default_permissions, allowed_web_search_modes, allow_managed_hooks_only, allow_appshots, @@ -1004,7 +1009,8 @@ impl ConfigRequirementsWithSources { allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value), allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value), allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), - allowed_permissions: allowed_permissions.map(|sourced| sourced.value), + allowed_permission_profiles: allowed_permission_profiles.map(|sourced| sourced.value), + default_permissions: default_permissions.map(|sourced| sourced.value), remote_sandbox_config: None, allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value), @@ -1092,7 +1098,8 @@ impl ConfigRequirementsToml { self.allowed_approval_policies.is_none() && self.allowed_approvals_reviewers.is_none() && self.allowed_sandbox_modes.is_none() - && self.allowed_permissions.is_none() + && self.allowed_permission_profiles.is_none() + && self.default_permissions.is_none() && self.remote_sandbox_config.is_none() && self.allowed_web_search_modes.is_none() && self.allow_managed_hooks_only.is_none() @@ -1144,7 +1151,8 @@ impl TryFrom for ConfigRequirements { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, - allowed_permissions: _, + allowed_permission_profiles: _, + default_permissions: _, allowed_web_search_modes, allow_managed_hooks_only, allow_appshots, @@ -1511,7 +1519,8 @@ mod tests { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, - allowed_permissions, + allowed_permission_profiles, + default_permissions, remote_sandbox_config: _, allowed_web_search_modes, allow_managed_hooks_only, @@ -1536,7 +1545,9 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_sandbox_modes: allowed_sandbox_modes .map(|value| Sourced::new(value, RequirementSource::Unknown)), - allowed_permissions: allowed_permissions + allowed_permission_profiles: allowed_permission_profiles + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + default_permissions: default_permissions .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_web_search_modes: allowed_web_search_modes .map(|value| Sourced::new(value, RequirementSource::Unknown)), @@ -1592,7 +1603,11 @@ mod tests { fn deserialize_managed_permission_profiles() -> Result<()> { let requirements: ConfigRequirementsToml = from_str( r#" - allowed_permissions = ["managed-standard", "managed-build"] + default_permissions = "managed-standard" + + [allowed_permission_profiles] + managed-standard = true + managed-build = true [permissions.managed-standard] extends = ":workspace" @@ -1603,11 +1618,15 @@ mod tests { )?; assert_eq!( - requirements.allowed_permissions, - Some(vec![ - "managed-standard".to_string(), - "managed-build".to_string(), - ]) + requirements.allowed_permission_profiles, + Some(BTreeMap::from([ + ("managed-build".to_string(), true), + ("managed-standard".to_string(), true), + ])) + ); + assert_eq!( + requirements.default_permissions, + Some("managed-standard".to_string()) ); let permissions = requirements .permissions @@ -1720,7 +1739,8 @@ mod tests { allowed_approval_policies: Some(allowed_approval_policies.clone()), allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()), allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()), - allowed_permissions: Some(vec!["managed".to_string()]), + allowed_permission_profiles: Some(BTreeMap::from([("managed".to_string(), true)])), + default_permissions: Some("managed".to_string()), remote_sandbox_config: None, allowed_web_search_modes: Some(allowed_web_search_modes.clone()), allow_managed_hooks_only: Some(true), @@ -1753,10 +1773,11 @@ mod tests { source.clone(), )), allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source.clone(),)), - allowed_permissions: Some(Sourced::new( - vec!["managed".to_string()], + allowed_permission_profiles: Some(Sourced::new( + BTreeMap::from([("managed".to_string(), true)]), source.clone(), )), + default_permissions: Some(Sourced::new("managed".to_string(), source.clone(),)), allowed_web_search_modes: Some(Sourced::new( allowed_web_search_modes, enforce_source.clone(), @@ -1809,7 +1830,8 @@ mod tests { )), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, - allowed_permissions: None, + allowed_permission_profiles: None, + default_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, allow_appshots: None, @@ -1861,7 +1883,8 @@ mod tests { )), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, - allowed_permissions: None, + allowed_permission_profiles: None, + default_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, allow_appshots: None, diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index e93dd1c1da4c..0396cda237a5 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -176,7 +176,8 @@ fn populate_merged_regular_fields_with_sources( allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, - allowed_permissions, + allowed_permission_profiles, + default_permissions, remote_sandbox_config: _, allowed_web_search_modes, allow_managed_hooks_only, @@ -201,7 +202,11 @@ fn populate_merged_regular_fields_with_sources( &["allowed_approvals_reviewers"] ); set_sourced!(allowed_sandbox_modes, &["allowed_sandbox_modes"]); - set_sourced!(allowed_permissions, &["allowed_permissions"]); + set_sourced!( + allowed_permission_profiles, + &["allowed_permission_profiles"] + ); + set_sourced!(default_permissions, &["default_permissions"]); set_sourced!(allowed_web_search_modes, &["allowed_web_search_modes"]); set_sourced!(allow_managed_hooks_only, &["allow_managed_hooks_only"]); set_sourced!(allow_appshots, &["allow_appshots"]); diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs index 8acfd5f0d5bd..82a99a7f0ac9 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -62,6 +62,11 @@ fn top_level_values_use_toml_priority() { r#" allowed_approval_policies = ["on-request"] allowed_sandbox_modes = ["workspace-write"] +default_permissions = ":workspace" + +[allowed_permission_profiles] +":read-only" = true +":workspace" = true "#, ), layer( @@ -70,6 +75,11 @@ allowed_sandbox_modes = ["workspace-write"] r#" allowed_approval_policies = ["never"] allowed_sandbox_modes = ["read-only"] +default_permissions = ":read-only" + +[allowed_permission_profiles] +":danger-full-access" = false +":workspace" = false "#, ), ]) @@ -82,6 +92,12 @@ allowed_sandbox_modes = ["read-only"] r#" allowed_approval_policies = ["never"] allowed_sandbox_modes = ["read-only"] +default_permissions = ":read-only" + +[allowed_permission_profiles] +":danger-full-access" = false +":read-only" = true +":workspace" = false "# ) ); diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 5edecebec03f..87a46fd14a52 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -31,7 +31,7 @@ use codex_config::test_support::CloudConfigBundleFixture; use codex_exec_server::LOCAL_FS; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchMode; -use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -1375,7 +1375,10 @@ default_permissions = "managed-standard" tokio::fs::write( &requirements_path, r#" -allowed_permissions = ["managed-standard"] +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-standard = true [permissions.managed-standard] extends = ":workspace" @@ -1397,8 +1400,8 @@ extends = ":workspace" config .config_layer_stack .requirements_toml() - .allowed_permissions, - Some(vec!["managed-standard".to_string()]) + .allowed_permission_profiles, + Some(BTreeMap::from([("managed-standard".to_string(), true)])) ); assert_eq!( config @@ -1411,26 +1414,9 @@ extends = ":workspace" } #[tokio::test] -async fn system_allowed_permissions_keep_builtin_permission_fallbacks() -> anyhow::Result<()> { - for (trust_level, expected_profile) in [ - ( - Some(TrustLevel::Trusted), - if cfg!(target_os = "windows") { - BUILT_IN_PERMISSION_PROFILE_READ_ONLY - } else { - BUILT_IN_PERMISSION_PROFILE_WORKSPACE - }, - ), - ( - Some(TrustLevel::Untrusted), - if cfg!(target_os = "windows") { - BUILT_IN_PERMISSION_PROFILE_READ_ONLY - } else { - BUILT_IN_PERMISSION_PROFILE_WORKSPACE - }, - ), - (None, BUILT_IN_PERMISSION_PROFILE_READ_ONLY), - ] { +async fn system_allowed_permission_profiles_select_managed_default_without_local_default() +-> anyhow::Result<()> { + for trust_level in [Some(TrustLevel::Trusted), Some(TrustLevel::Untrusted), None] { let tmp = tempdir()?; let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; @@ -1447,10 +1433,17 @@ async fn system_allowed_permissions_keep_builtin_permission_fallbacks() -> anyho tokio::fs::write( &requirements_path, r#" -allowed_permissions = ["managed-standard"] +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-build = true +managed-standard = true [permissions.managed-standard.filesystem] ":workspace_roots" = "read" + +[permissions.managed-build] +extends = ":workspace" "#, ) .await?; @@ -1470,15 +1463,225 @@ allowed_permissions = ["managed-standard"] .permissions .active_permission_profile() .map(|profile| profile.id), - Some(expected_profile.to_string()), + Some("managed-standard".to_string()), "trust level {trust_level:?}", ); + assert!( + !config.startup_warnings.iter().any(|warning| warning + .contains("Configured value for `permission_profile` is disallowed")), + "{:?}", + config.startup_warnings + ); } Ok(()) } #[tokio::test] -async fn system_allowed_permissions_keep_explicit_builtin_defaults() -> anyhow::Result<()> { +async fn system_allowed_permission_profiles_require_managed_default() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +[permissions.managed-standard] +extends = ":read-only" + +[allowed_permission_profiles] +managed-standard = true +"#, + ) + .await?; + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let err = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(overrides) + .build() + .await + .expect_err("allowed_permission_profiles without default_permissions should fail"); + + assert!( + err.to_string().contains( + "default_permissions must be set unless allowed_permission_profiles allows both" + ), + "{err}" + ); + Ok(()) +} + +#[tokio::test] +async fn system_allowed_permission_profiles_standard_pair_defaults_to_workspace() +-> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +[allowed_permission_profiles] +":read-only" = true +":workspace" = true +"#, + ) + .await?; + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(overrides) + .build() + .await?; + + assert_eq!( + config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()) + ); + Ok(()) +} + +#[tokio::test] +async fn system_managed_default_must_be_allowed() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +default_permissions = "managed-build" + +[allowed_permission_profiles] +managed-standard = true + +[permissions.managed-standard] +extends = ":read-only" + +[permissions.managed-build] +extends = ":workspace" +"#, + ) + .await?; + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let err = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(overrides) + .build() + .await + .expect_err("managed default outside allowed_permission_profiles should fail"); + + assert!( + err.to_string().contains( + "default_permissions `managed-build` must be allowed by allowed_permission_profiles" + ), + "{err}" + ); + Ok(()) +} + +#[tokio::test] +async fn system_managed_default_requires_allowed_permission_profiles() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +default_permissions = ":read-only" +"#, + ) + .await?; + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let err = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(overrides) + .build() + .await + .expect_err("managed default without allowed_permission_profiles should fail"); + + assert!( + err.to_string() + .contains("default_permissions requires allowed_permission_profiles"), + "{err}" + ); + Ok(()) +} + +#[tokio::test] +async fn system_allowed_permission_profiles_fall_back_from_disallowed_danger_full_access() +-> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + format!( + r#" +default_permissions = "{BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS}" +"# + ), + ) + .await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-standard = true + +[permissions.managed-standard.filesystem] +":workspace_roots" = "read" +"#, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(cwd.to_path_buf())) + .loader_overrides(overrides) + .build() + .await?; + + assert_eq!( + config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + Some("managed-standard".to_string()) + ); + assert!( + config.startup_warnings.iter().any(|warning| warning + .contains("Configured value for `permission_profile` is disallowed by requirements")), + "{:?}", + config.startup_warnings + ); + Ok(()) +} + +#[tokio::test] +async fn system_allowed_permission_profiles_fall_back_from_disallowed_workspace() +-> anyhow::Result<()> { let tmp = tempdir()?; let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; @@ -1493,7 +1696,10 @@ default_permissions = ":workspace" tokio::fs::write( &requirements_path, r#" -allowed_permissions = ["managed-standard"] +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-standard = true [permissions.managed-standard.filesystem] ":workspace_roots" = "read" @@ -1516,7 +1722,13 @@ allowed_permissions = ["managed-standard"] .permissions .active_permission_profile() .map(|profile| profile.id), - Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()) + Some("managed-standard".to_string()) + ); + assert!( + config.startup_warnings.iter().any(|warning| warning + .contains("Configured value for `permission_profile` is disallowed by requirements")), + "{:?}", + config.startup_warnings ); Ok(()) } @@ -1538,7 +1750,11 @@ default_permissions = "managed-build" tokio::fs::write( &requirements_path, r#" -allowed_permissions = ["managed-standard", "managed-build"] +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-build = true +managed-standard = true [permissions.managed-standard] extends = ":read-only" @@ -1579,7 +1795,10 @@ async fn system_requirements_warn_for_disallowed_explicit_permission_override() tokio::fs::write( &requirements_path, r#" -allowed_permissions = ["managed-standard"] +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-standard = true [permissions.managed-standard] extends = ":workspace" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 588eb7ac1b4b..3a3b490d697c 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -8351,7 +8351,8 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, - allowed_permissions: None, + allowed_permission_profiles: None, + default_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]), allow_managed_hooks_only: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index e319f3b39623..fd74fcf2d251 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -119,6 +119,7 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use crate::config::permissions::BUILT_IN_READ_ONLY_PROFILE; use crate::config::permissions::BUILT_IN_WORKSPACE_PROFILE; use crate::config::permissions::apply_network_proxy_feature_config; use crate::config::permissions::builtin_permission_profile; @@ -3835,7 +3836,9 @@ fn resolve_effective_permission_selection<'a>( Ok(EffectivePermissionSelection { profiles, selected_profile_id, - requirements_force_profile_selection: requirements_toml.allowed_permissions.is_some(), + requirements_force_profile_selection: requirements_toml + .allowed_permission_profiles + .is_some(), }) } @@ -3845,28 +3848,36 @@ fn resolve_default_permissions<'a>( requirements_toml: &'a ConfigRequirementsToml, startup_warnings: &mut Vec, ) -> std::io::Result> { - let allowed_permissions = requirements_toml.allowed_permissions.as_ref(); - let mut default_permissions = default_permissions_override.or(configured_default_permissions); - if let (Some(selected_permissions), Some(allowed_permissions)) = - (default_permissions, allowed_permissions) - && !is_builtin_permission_profile_name(selected_permissions) - && !allowed_permissions - .iter() - .any(|allowed_permission| allowed_permission == selected_permissions) - { - let Some(fallback_permissions) = allowed_permissions.first().map(String::as_str) else { - return Err(std::io::Error::new( - ErrorKind::InvalidInput, - "requirements.toml allowed_permissions must include at least one profile", - )); - }; - startup_warnings.push(format!( - "Configured value for `permission_profile` is disallowed by requirements; falling back from `{selected_permissions}` to required value `{fallback_permissions}`." + let selected_permissions = default_permissions_override.or(configured_default_permissions); + let Some(allowed_permission_profiles) = requirements_toml.allowed_permission_profiles.as_ref() + else { + return Ok(selected_permissions); + }; + let Some(fallback_permissions) = requirements_toml + .default_permissions + .as_deref() + .or_else(|| implicit_default_permissions(allowed_permission_profiles)) + else { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + "requirements.toml default_permissions must be set unless allowed_permission_profiles allows both `:workspace` and `:read-only`", )); - default_permissions = Some(fallback_permissions); - } + }; - Ok(default_permissions) + match selected_permissions { + None => Ok(Some(fallback_permissions)), + Some(selected_permissions) + if is_permission_allowed(allowed_permission_profiles, selected_permissions) => + { + Ok(Some(selected_permissions)) + } + Some(selected_permissions) => { + startup_warnings.push(format!( + "Configured value for `permission_profile` is disallowed by requirements; falling back from `{selected_permissions}` to required value `{fallback_permissions}`." + )); + Ok(Some(fallback_permissions)) + } + } } fn validate_required_permission_profile_catalog( @@ -3880,30 +3891,67 @@ fn validate_required_permission_profile_catalog( .is_some_and(|permissions| permissions.entries.contains_key(profile_id)) }; - let Some(allowed_permissions) = requirements_toml.allowed_permissions.as_ref() else { + let Some(allowed_permission_profiles) = requirements_toml.allowed_permission_profiles.as_ref() + else { + if requirements_toml.default_permissions.is_some() { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + "requirements.toml default_permissions requires allowed_permission_profiles", + )); + } return Ok(()); }; - if allowed_permissions.is_empty() { - return Err(std::io::Error::new( - ErrorKind::InvalidInput, - "requirements.toml allowed_permissions must include at least one profile", - )); - } - - for profile_id in allowed_permissions { + for profile_id in allowed_permission_profiles.keys() { if !is_known_profile(profile_id) { return Err(std::io::Error::new( ErrorKind::InvalidInput, format!( - "requirements.toml allowed_permissions refers to undefined profile `{profile_id}`" + "requirements.toml allowed_permission_profiles refers to undefined profile `{profile_id}`" ), )); } } + let Some(default_permissions) = requirements_toml + .default_permissions + .as_deref() + .or_else(|| implicit_default_permissions(allowed_permission_profiles)) + else { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + "requirements.toml default_permissions must be set unless allowed_permission_profiles allows both `:workspace` and `:read-only`", + )); + }; + if !is_permission_allowed(allowed_permission_profiles, default_permissions) { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!( + "requirements.toml default_permissions `{default_permissions}` must be allowed by allowed_permission_profiles" + ), + )); + } + Ok(()) } +fn implicit_default_permissions( + allowed_permission_profiles: &BTreeMap, +) -> Option<&'static str> { + (is_permission_allowed(allowed_permission_profiles, BUILT_IN_WORKSPACE_PROFILE) + && is_permission_allowed(allowed_permission_profiles, BUILT_IN_READ_ONLY_PROFILE)) + .then_some(BUILT_IN_WORKSPACE_PROFILE) +} + +fn is_permission_allowed( + allowed_permission_profiles: &BTreeMap, + profile_id: &str, +) -> bool { + allowed_permission_profiles + .get(profile_id) + .copied() + .unwrap_or(false) +} + fn normalize_guardian_policy_config(value: Option<&str>) -> Option { value.and_then(|value| { let trimmed = value.trim(); diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 562df95989aa..de5f79a6767d 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -702,7 +702,8 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::OnRequest.to_core()]), allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]), - allowed_permissions: None, + allowed_permission_profiles: None, + default_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]), allow_managed_hooks_only: Some(true), @@ -973,7 +974,8 @@ approval_policy = "never" allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, - allowed_permissions: None, + allowed_permission_profiles: None, + default_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: Some(Vec::new()), allow_managed_hooks_only: None, From cbac22dabec0dd043c670b54de397a3be5fc0133 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Fri, 5 Jun 2026 18:37:47 -0700 Subject: [PATCH 55/59] [codex] Deduplicate skill load warnings (#26698) Skill reloads can get noisy when the watcher keeps triggering `skills/list` and the same invalid `SKILL.md` error comes back each time. This keeps the first warning visible, then suppresses repeats while the same `(path, message)` is still active. If the error clears and later comes back, or if the message changes, it will show again. Validation: - `just fmt` - `just test -p codex-tui skill_load_warning_state` --- codex-rs/tui/src/app.rs | 3 + codex-rs/tui/src/app/history_ui.rs | 1 + codex-rs/tui/src/app/startup_prompts.rs | 163 ++++++++++++++++++++++++ codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 30 +++++ codex-rs/tui/src/app/thread_routing.rs | 1 + 6 files changed, 199 insertions(+) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index aae2cba95468..4b86eda89852 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -513,6 +513,8 @@ pub(crate) struct App { status_line_invalid_items_warned: Arc, // Shared across ChatWidget instances so invalid terminal-title config warnings only emit once. terminal_title_invalid_items_warned: Arc, + // Tracks active skill-load warnings so refreshes do not duplicate history cells. + skill_load_warnings: SkillLoadWarningState, // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, @@ -1015,6 +1017,7 @@ See the Codex keymap documentation for supported actions and examples." commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(), + skill_load_warnings: SkillLoadWarningState::default(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: feedback.clone(), diff --git a/codex-rs/tui/src/app/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index 4cb5072daf00..0aff920e6ddd 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -113,6 +113,7 @@ impl App { self.initial_history_replay_buffer = None; self.backtrack = BacktrackState::default(); self.backtrack_render_pending = false; + self.skill_load_warnings.clear(); } } diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index a027dcd5056b..4834656617e9 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -4,6 +4,45 @@ //! catalog state into one-time TUI prompts or warning cells without owning the main event loop. use super::*; +use std::collections::HashSet; +use std::path::PathBuf; + +#[derive(Debug, PartialEq, Eq, Hash)] +struct SkillLoadWarningKey { + path: PathBuf, + message: String, +} + +#[derive(Debug, Default)] +pub(super) struct SkillLoadWarningState { + active: HashSet, +} + +impl SkillLoadWarningState { + pub(super) fn clear(&mut self) { + self.active.clear(); + } + + pub(super) fn newly_active_errors(&mut self, errors: &[SkillErrorInfo]) -> Vec { + let previous = std::mem::take(&mut self.active); + let mut current = HashSet::new(); + let mut newly_active = Vec::new(); + + for error in errors { + let key = SkillLoadWarningKey { + path: error.path.clone(), + message: error.message.clone(), + }; + let was_active = previous.contains(&key); + if current.insert(key) && !was_active { + newly_active.push(error.clone()); + } + } + + self.active = current; + newly_active + } +} pub(super) fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorInfo]) { if errors.is_empty() { @@ -336,8 +375,10 @@ mod tests { use super::*; use crate::test_support::PathBufExt; use pretty_assertions::assert_eq; + use ratatui::text::Line; use std::path::PathBuf; use tempfile::tempdir; + use tokio::sync::mpsc::unbounded_channel; #[test] fn normalize_harness_overrides_resolves_relative_add_dirs() -> Result<()> { @@ -357,4 +398,126 @@ mod tests { ); Ok(()) } + + fn skill_error(path: &str, message: &str) -> SkillErrorInfo { + SkillErrorInfo { + path: PathBuf::from(path), + message: message.to_string(), + } + } + + fn render_line_text(line: &Line<'static>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + fn render_skill_load_warning_cells(errors: &[SkillErrorInfo]) -> String { + let (tx, mut rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx); + + emit_skill_load_warnings(&app_event_tx, errors); + + let mut rendered = Vec::new(); + while let Ok(AppEvent::InsertHistoryCell(cell)) = rx.try_recv() { + rendered.extend( + cell.display_lines(/*width*/ 120) + .iter() + .map(render_line_text), + ); + } + rendered.join("\n") + } + + #[test] + fn skill_load_warning_state_suppresses_repeated_active_errors() { + let mut state = SkillLoadWarningState::default(); + let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); + + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + vec![error.clone()] + ); + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + Vec::::new() + ); + } + + #[test] + fn skill_load_warning_state_reemits_after_error_clears() { + let mut state = SkillLoadWarningState::default(); + let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); + + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + vec![error.clone()] + ); + assert_eq!(state.newly_active_errors(&[]), Vec::::new()); + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + vec![error] + ); + } + + #[test] + fn skill_load_warning_state_displays_new_message_for_active_path() { + let mut state = SkillLoadWarningState::default(); + let initial = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); + let changed = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid frontmatter"); + + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&initial)), + vec![initial] + ); + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&changed)), + vec![changed] + ); + } + + #[test] + fn skill_load_warning_state_clear_allows_active_error_again() { + let mut state = SkillLoadWarningState::default(); + let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); + + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + vec![error.clone()] + ); + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + Vec::::new() + ); + + state.clear(); + + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + vec![error] + ); + } + + #[test] + fn repeated_active_skill_load_warning_renders_once() { + let mut state = SkillLoadWarningState::default(); + let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); + + let first_errors = state.newly_active_errors(std::slice::from_ref(&error)); + let repeated_errors = state.newly_active_errors(std::slice::from_ref(&error)); + let rendered = [ + render_skill_load_warning_cells(&first_errors), + render_skill_load_warning_cells(&repeated_errors), + ] + .into_iter() + .filter(|output| !output.is_empty()) + .collect::>() + .join("\n"); + + insta::assert_snapshot!(rendered, @r" +⚠ Skipped loading 1 skill(s) due to invalid SKILL.md files. +⚠ /repo/.codex/skills/abc/SKILL.md: invalid description +"); + } } diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 9bbe0c60b478..c59102fa6d8e 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -39,6 +39,7 @@ pub(super) async fn make_test_app() -> App { commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), + skill_load_warnings: SkillLoadWarningState::default(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index b18726004e01..2e3e1b8c906f 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3788,6 +3788,7 @@ async fn make_test_app() -> App { commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), + skill_load_warnings: SkillLoadWarningState::default(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), @@ -3851,6 +3852,7 @@ async fn make_test_app_with_channels() -> ( commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), + skill_load_warnings: SkillLoadWarningState::default(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), @@ -5576,6 +5578,34 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt"); } +#[tokio::test] +async fn clear_only_ui_reset_allows_active_skill_warning_to_render_again() { + let mut app = make_test_app().await; + let error = SkillErrorInfo { + path: test_path_buf("/tmp/project/.codex/skills/abc/SKILL.md"), + message: "invalid description".to_string(), + }; + + assert_eq!( + app.skill_load_warnings + .newly_active_errors(std::slice::from_ref(&error)), + vec![error.clone()] + ); + assert_eq!( + app.skill_load_warnings + .newly_active_errors(std::slice::from_ref(&error)), + Vec::::new() + ); + + app.reset_app_ui_state_after_clear(); + + assert_eq!( + app.skill_load_warnings + .newly_active_errors(std::slice::from_ref(&error)), + vec![error] + ); +} + #[tokio::test] async fn backtrack_esc_does_not_steal_empty_vim_insert_escape() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index c53cfc05e3e2..7e9f91befdaa 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1342,6 +1342,7 @@ impl App { pub(super) fn handle_skills_list_response(&mut self, response: SkillsListResponse) { let cwd = self.chat_widget.config_ref().cwd.clone(); let errors = errors_for_cwd(&cwd, &response); + let errors = self.skill_load_warnings.newly_active_errors(&errors); emit_skill_load_warnings(&self.app_event_tx, &errors); self.chat_widget.handle_skills_list_response(response); } From 3ea9e983334ce187b295359d19b17706b7dd1144 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Fri, 5 Jun 2026 18:53:12 -0700 Subject: [PATCH 56/59] Remove `just bench-smoke` from `just test`. (#26716) ## Why `just test` should run the test suite without also compiling and executing benchmark smoke tests. Keeping benchmark validation explicit avoids adding unrelated work to every project-specific test invocation. ## What changed - Remove the `just bench-smoke` step from the Unix and Windows `test` recipes. - Document `just bench` and `just bench-smoke` as the explicit benchmark commands in `AGENTS.md`. ## Validation - `just test -p codex-arg0` - `just --dry-run test` - `just --dry-run bench-smoke` --- AGENTS.md | 6 ++++++ justfile | 2 -- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4666566f9603..46d2626e321d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,6 +192,12 @@ If you don’t have the tool: - `cargo install --locked cargo-insta` +### Benchmarks + +cargo benchmarks can be run with `just bench`, use the divan crate to write new ones. + +Use `just bench-smoke` to dry-run the benchmark for a single iteration to ensure it works. + ### Test assertions - Tests should use pretty_assertions::assert_eq for clearer diffs. Import this at the top of the test module if it isn't already. diff --git a/justfile b/justfile index fe7e7349b314..3b993b767d7d 100644 --- a/justfile +++ b/justfile @@ -76,12 +76,10 @@ install: [unix] test *args: RUST_MIN_STACK={{ rust_min_stack }} cargo nextest run --no-fail-fast "$@" - just bench-smoke [windows] test *args: $env:RUST_MIN_STACK = "{{ rust_min_stack }}"; cargo nextest run --no-fail-fast @($args | Select-Object -Skip 1) - just bench-smoke # Run from the repository root so scripts that resolve paths from `cwd` see # the same layout they use in GitHub Actions. From 8404d0e0036b1626f97bbdac808a98323cc0b261 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 5 Jun 2026 19:05:04 -0700 Subject: [PATCH 57/59] Release 0.138.0-alpha.6 --- codex-rs/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 5fe010432122..56b4d124677b 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -120,7 +120,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.0.0" +version = "0.138.0-alpha.6" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 From ab1d0188f02250774796766bbf38442a751024e4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 6 Jun 2026 04:42:40 +0000 Subject: [PATCH 58/59] Seed Termux release automation --- .github/workflows/rust-release.yml | 1984 ++++++++--------- .github/workflows/shell-tool-mcp.yml | 461 ++++ .../workflows/termux-release-checkpoint.yml | 103 + .github/workflows/termux-release-deploy.yml | 243 ++ .github/workflows/termux-release-promote.yml | 135 ++ scripts/termux-configure-git.sh | 40 + scripts/termux-create-checkpoint-pr.sh | 275 +++ ...ermux-create-or-update-mirrored-release.sh | 111 + scripts/termux-download-release-artifact.sh | 78 + scripts/termux-find-release-pr.sh | 53 + scripts/termux-read-release-metadata.sh | 77 + scripts/termux-release-asset-state.sh | 37 + scripts/termux-release-paths.sh | 55 + scripts/termux-resolve-release-ref.sh | 36 + scripts/termux-validate-gh-env.sh | 16 + 15 files changed, 2603 insertions(+), 1101 deletions(-) create mode 100644 .github/workflows/shell-tool-mcp.yml create mode 100644 .github/workflows/termux-release-checkpoint.yml create mode 100644 .github/workflows/termux-release-deploy.yml create mode 100644 .github/workflows/termux-release-promote.yml create mode 100755 scripts/termux-configure-git.sh create mode 100755 scripts/termux-create-checkpoint-pr.sh create mode 100755 scripts/termux-create-or-update-mirrored-release.sh create mode 100755 scripts/termux-download-release-artifact.sh create mode 100755 scripts/termux-find-release-pr.sh create mode 100755 scripts/termux-read-release-metadata.sh create mode 100755 scripts/termux-release-asset-state.sh create mode 100755 scripts/termux-release-paths.sh create mode 100755 scripts/termux-resolve-release-ref.sh create mode 100755 scripts/termux-validate-gh-env.sh diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index f95d1216f8a6..78162e867146 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -4,169 +4,497 @@ # git tag -a rust-v0.1.0 -m "Release 0.1.0" # git push origin rust-v0.1.0 # ``` -# -# Tag releases sign macOS binaries and DMGs through the protected `codesigning` -# GitHub environment and Azure Key Vault before final verification on macOS. name: rust-release on: - push: - tags: - - "rust-v*.*.*" - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: true + pull_request: + branches: + - "release/**" + workflow_dispatch: + inputs: + source_ref: + description: "Branch, tag, or commit SHA to build (code only; keeps .github from dispatched ref)" + required: false + type: string + default: "" + build_target: + description: "Target to build" + required: true + type: choice + default: all + options: + - all + - aarch64-apple-darwin + - x86_64-apple-darwin + - x86_64-unknown-linux-musl + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-musl + - aarch64-unknown-linux-gnu + - aarch64-linux-android + - x86_64-pc-windows-msvc + - aarch64-pc-windows-msvc + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read jobs: + cancel-superseded-pr-runs: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + actions: write + contents: read + steps: + - name: Cancel older runs for this PR branch + env: + GH_TOKEN: ${{ github.token }} + HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + CURRENT_RUN_ID: ${{ github.run_id }} + shell: bash + run: | + set -euo pipefail + + echo "Cancelling older ${GITHUB_WORKFLOW} runs for ${HEAD_BRANCH}" + gh run list \ + --repo "${GITHUB_REPOSITORY}" \ + --workflow "${GITHUB_WORKFLOW}" \ + --limit 100 \ + --json databaseId,event,headBranch,status,url \ + --jq ' + .[] + | select(.event == "pull_request") + | select(.headBranch == env.HEAD_BRANCH) + | select(.databaseId < (env.CURRENT_RUN_ID | tonumber)) + | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "requested" or .status == "pending") + | "\(.databaseId) \(.url)" + ' \ + | while read -r run_id run_url; do + [[ -n "${run_id}" ]] || continue + echo "Cancelling superseded run ${run_id}: ${run_url}" + gh run cancel "${run_id}" --repo "${GITHUB_REPOSITORY}" || true + done + tag-check: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - uses: dtolnay/rust-toolchain@1.92 - name: Validate tag matches Cargo.toml version shell: bash run: | set -euo pipefail echo "::group::Tag validation" - # Release runs must come from a tag. + # 1. Must be a tag and match the regex [[ "${GITHUB_REF_TYPE}" == "tag" ]] \ - || { echo "❌ Not a tag ref"; exit 1; } - - # Release tags must match the version in Cargo.toml. - [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \ + || { echo "❌ Not a tag push"; exit 1; } + [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?(-termux)?$ ]] \ || { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; } + # 2. Extract versions tag_ver="${GITHUB_REF_NAME#rust-v}" cargo_ver="$(grep -m1 '^version' codex-rs/Cargo.toml \ | sed -E 's/version *= *"([^"]+)".*/\1/')" + # 3. Compare [[ "${tag_ver}" == "${cargo_ver}" ]] \ || { echo "❌ Tag ${tag_ver} ≠ Cargo.toml ${cargo_ver}"; exit 1; } echo "✅ Tag and Cargo.toml agree (${tag_ver})" echo "::endgroup::" - build: + select-build-matrix: + if: always() && (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') needs: tag-check - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - # Release builds can take a long time, so leave some headroom to avoid - # having to restart the full workflow due to a timeout. - timeout-minutes: 90 - permissions: - contents: read - id-token: write + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.select.outputs.matrix }} + build_version: ${{ steps.select.outputs.build_version }} + steps: + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - id: select + shell: bash + env: + REPO_OWNER: ${{ github.repository_owner }} + EVENT_NAME: ${{ github.event_name }} + BUILD_TARGET: ${{ inputs.build_target }} + run: | + set -euo pipefail + + matrix='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04","target":"aarch64-linux-android"}, + {"runner":"windows-latest","target":"x86_64-pc-windows-msvc"}, + {"runner":"windows-11-arm","target":"aarch64-pc-windows-msvc"} + ]' + + if [[ "${REPO_OWNER}" != "openai" ]]; then + matrix="$(jq -c '[.[] | select(.runner | startswith("macos") | not)]' <<< "${matrix}")" + fi + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + matrix="$(jq -c '[.[] | select(.target == "aarch64-linux-android")]' <<< "${matrix}")" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" && -n "${BUILD_TARGET}" && "${BUILD_TARGET}" != "all" ]]; then + matrix="$(jq -c --arg build_target "${BUILD_TARGET}" '[.[] | select(.target == $build_target)]' <<< "${matrix}")" + fi + + if [[ "$(jq 'length' <<< "${matrix}")" -eq 0 ]]; then + echo "No build targets selected after applying owner/dispatch filters." >&2 + exit 1 + fi + + echo "matrix={\"include\":${matrix}}" >> "$GITHUB_OUTPUT" + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + metadata=".github/termux-release.json" + if [[ ! -f "${metadata}" ]]; then + echo "${metadata} is required for release train PR builds" >&2 + exit 1 + fi + upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" + if [[ -z "${upstream_tag}" || "${upstream_tag}" != rust-v* ]]; then + echo "Unable to determine upstream rust tag from ${metadata}" >&2 + exit 1 + fi + build_version="${upstream_tag#rust-v}" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then + latest_release_tag="$( + GH_TOKEN="${{ github.token }}" gh api repos/openai/codex/releases/latest --jq '.tag_name' + )" + + if [[ -z "${latest_release_tag}" || "${latest_release_tag}" != rust-v* ]]; then + echo "Unable to determine latest stable release tag from openai/codex" >&2 + exit 1 + fi + + latest_release_version="${latest_release_tag#rust-v}" + build_version="${latest_release_version}-dev" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + fi + + build: + if: >- + always() && + (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') && + (github.event_name != 'pull_request' || needs.cancel-superseded-pr-runs.result == 'success') && + needs.select-build-matrix.result == 'success' + needs: + - cancel-superseded-pr-runs + - tag-check + - select-build-matrix + name: Build - ${{ matrix.runner }} - ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + permissions: write-all + timeout-minutes: 120 defaults: run: working-directory: codex-rs env: - # Use the git CLI instead of Cargo's libgit2 path for git dependencies. - # macOS release runners have intermittently failed to fetch nested - # submodules through SecureTransport/libgit2, especially libwebrtc's - # libyuv submodule from chromium.googlesource.com. - CARGO_NET_GIT_FETCH_WITH_CLI: "true" + CODEX_BWRAP_ENABLE_FFI: ${{ contains(matrix.target, 'unknown-linux') && '1' || '0' }} + CODEX_BUILD_VERSION: ${{ needs.select-build-matrix.outputs.build_version }} + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" strategy: fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - # Release artifacts intentionally ship MUSL-linked Linux binaries. - - runner: codex-linux-x64-xl - target: x86_64-unknown-linux-musl - bundle: primary - artifact_name: x86_64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: codex-linux-x64-xl - target: x86_64-unknown-linux-musl - bundle: app-server - artifact_name: x86_64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: codex-linux-arm64 - target: aarch64-unknown-linux-musl - bundle: primary - artifact_name: aarch64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: codex-linux-arm64 - target: aarch64-unknown-linux-musl - bundle: app-server - artifact_name: aarch64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" + matrix: ${{ fromJson(needs.select-build-matrix.outputs.matrix) }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main with: - persist-credentials: false - - name: Print runner specs (Linux) - if: ${{ runner.os == 'Linux' }} + verbose: true + - name: Checkout selected source ref + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + uses: actions/checkout@v6 + with: + ref: ${{ inputs.source_ref }} + path: __source__ + fetch-depth: 1 + - name: Overlay selected source ref (excluding .github) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + shell: bash + working-directory: ${{ github.workspace }} + run: | + set -euo pipefail + echo "Using source ref: ${{ inputs.source_ref }}" + + # Keep workflow/actions from the dispatch ref while replacing all other files. + find . -mindepth 1 -maxdepth 1 \ + ! -name .git \ + ! -name .github \ + ! -name __source__ \ + -exec rm -rf -- {} + + tar --exclude='.github' -C __source__ -cf - . | tar -xf - + rm -rf __source__ + + - name: Apply selected build version + if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} shell: bash + working-directory: ${{ github.workspace }} run: | set -euo pipefail - cpu_model="$(lscpu | awk -F: '/Model name/ {gsub(/^[ \t]+/, "", $2); print $2; exit}')" - total_ram="$(awk '/MemTotal/ {printf "%.1f GiB\n", $2 / 1024 / 1024}' /proc/meminfo)" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(uname -a)" - echo "CPU model: ${cpu_model}" - echo "Logical CPUs: $(nproc)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . - - name: Print runner specs (macOS) - if: ${{ runner.os == 'macOS' }} + if [[ -z "${CODEX_BUILD_VERSION}" ]]; then + echo "CODEX_BUILD_VERSION is empty for workflow_dispatch" >&2 + exit 1 + fi + echo "Using build version: ${CODEX_BUILD_VERSION}" + BUILD_VERSION="${CODEX_BUILD_VERSION}" perl -0777 -i -pe \ + 's/(\[workspace\.package\][^\[]*?version = ")([^"]+)(")/${1}$ENV{BUILD_VERSION}$3/s' \ + codex-rs/Cargo.toml + + grep -n "^\[workspace.package\]\|^version = " codex-rs/Cargo.toml | head -n 4 + + - name: Select Android NDK + if: ${{ matrix.target == 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail - total_ram="$(sysctl -n hw.memsize | awk '{printf "%.1f GiB\n", $1 / 1024 / 1024 / 1024}')" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(sw_vers -productName) $(sw_vers -productVersion)" - echo "Hardware model: $(sysctl -n hw.model)" - echo "CPU architecture: $(uname -m)" - echo "Logical CPUs: $(sysctl -n hw.logicalcpu)" - echo "Physical CPUs: $(sysctl -n hw.physicalcpu)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . + + fallback_version="29.0.13113456" + ndk_root="${ANDROID_HOME}/ndk" + selected_ndk="" + + if [[ -d "${ndk_root}" ]]; then + while IFS= read -r candidate; do + toolchain="${candidate}/toolchains/llvm/prebuilt/linux-x86_64" + if [[ -x "${toolchain}/bin/aarch64-linux-android24-clang" ]]; then + selected_ndk="${candidate}" + break + fi + done < <(find "${ndk_root}" -mindepth 1 -maxdepth 1 -type d | sort -Vr) + fi + + if [[ -z "${selected_ndk}" ]]; then + echo "No usable preinstalled Android NDK found; installing ${fallback_version}" + yes | sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --licenses || true + sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" "ndk;${fallback_version}" + selected_ndk="${ndk_root}/${fallback_version}" + fi + + if [[ ! -x "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang" ]]; then + echo "Selected Android NDK does not contain a usable aarch64 API 24 clang: ${selected_ndk}" >&2 + exit 1 + fi + + echo "Selected Android NDK: ${selected_ndk}" + "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" --version + echo "ANDROID_NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" + echo "NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" - name: Install Linux bwrap build dependencies - if: ${{ runner.os == 'Linux' }} + if: ${{ runner.os == 'Linux' && matrix.target != 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail sudo apt-get update -y sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + - name: Install UBSan runtime (musl) + if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 + fi + - uses: dtolnay/rust-toolchain@1.93 with: targets: ${{ matrix.target }} + - name: Set up sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + uses: mozilla-actions/sccache-action@main + - name: Enable sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + shell: bash + run: | + set -euo pipefail + echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_VERSION=android-release-${{ matrix.target }}" >> "$GITHUB_ENV" + echo "SCCACHE_CACHE_SIZE=5G" >> "$GITHUB_ENV" + - name: Ensure Rust target is installed + shell: bash + run: | + set -euo pipefail + rustup target add "${{ matrix.target }}" + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android build environment + shell: bash + run: | + set -euo pipefail + ndk="${ANDROID_NDK_HOME}" + target="${{ matrix.target }}" + + # Set up the Android toolchain + toolchain="${ndk}/toolchains/llvm/prebuilt/linux-x86_64" + + # Use API level 24 to follow termux-build convention + api_level="24" + + # Configure Cargo to use the NDK linker + cargo_target_var="CARGO_TARGET_${target^^}_LINKER" + cargo_target_var="${cargo_target_var//-/_}" + echo "${cargo_target_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + # Set CC and AR for the target + target_cc_var="CC_${target}" + target_cc_var="${target_cc_var//-/_}" + echo "${target_cc_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + target_cxx_var="CXX_${target}" + target_cxx_var="${target_cxx_var//-/_}" + echo "${target_cxx_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang++" >> "$GITHUB_ENV" + + target_ar_var="AR_${target}" + target_ar_var="${target_ar_var//-/_}" + echo "${target_ar_var}=${toolchain}/bin/llvm-ar" >> "$GITHUB_ENV" + + # Add toolchain to PATH + echo "${toolchain}/bin" >> "$GITHUB_PATH" + + tls_align_source="${RUNNER_TEMP}/codex-android-tls-align.S" + tls_align_object="${RUNNER_TEMP}/codex-android-tls-align.o" + cat > "${tls_align_source}" <<'EOF' + .section .tdata.codex_android_tls_align,"awT",%progbits + .p2align 6 + .globl __codex_android_tls_align + .hidden __codex_android_tls_align + .type __codex_android_tls_align, %object + __codex_android_tls_align: + .byte 0 + .size __codex_android_tls_align, 1 + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${tls_align_source}" -o "${tls_align_object}" + + v8_compat_source="${RUNNER_TEMP}/codex-android-v8-compat.c" + v8_compat_object="${RUNNER_TEMP}/codex-android-v8-compat.o" + cat > "${v8_compat_source}" <<'EOF' + #include + #include + + extern int posix_memalign(void **memptr, size_t alignment, size_t size); + extern double strtod(const char *nptr, char **endptr); + extern float strtof(const char *nptr, char **endptr); + + void *aligned_alloc(size_t alignment, size_t size) { + void *ptr = 0; + if (posix_memalign(&ptr, alignment, size) != 0) { + return 0; + } + return ptr; + } + + double strtod_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtod(nptr, endptr); + } + + float strtof_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtof(nptr, endptr); + } + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${v8_compat_source}" -o "${v8_compat_object}" + + builtins_archive="$( + find "${toolchain}/lib/clang" \ + -path "*/lib/linux/libclang_rt.builtins-aarch64-android.a" \ + -print -quit + )" + if [[ -z "${builtins_archive}" ]]; then + echo "Could not find Android compiler-rt builtins archive under ${toolchain}/lib/clang" >&2 + exit 1 + fi + builtins_dir="$(dirname "${builtins_archive}")" + + # Provide compatibility symbols and C++ ABI needed by the Android rusty_v8 archive. + rustflags_var="CARGO_TARGET_${target^^}_RUSTFLAGS" + rustflags_var="${rustflags_var//-/_}" + echo "${rustflags_var}=-C link-arg=${tls_align_object} -C link-arg=${v8_compat_object} -C link-arg=-Wl,-u,__codex_android_tls_align -C link-arg=-L${builtins_dir} -C link-arg=-lclang_rt.builtins-aarch64-android -C link-arg=-lc++abi -C link-arg=-Wl,-z,max-page-size=65536" >> "$GITHUB_ENV" + + + # Configure for vendored OpenSSL build + echo "CARGO_BUILD_TARGET_APPLIES_TO_HOST=false" >> "$GITHUB_ENV" + echo "CARGO_BUILD_TARGET=${{ matrix.target }}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android rusty_v8 artifact overrides + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + binding_dir="${RUNNER_TEMP}/rusty_v8" + archive="${binding_dir}/librusty_v8_release_${TARGET}.a.gz" + binding_path="${binding_dir}/src_binding_release_${TARGET}.rs" + checksums_path="${binding_dir}/rusty_v8_release_${TARGET}.sha256" + artifact_repository="wallentx/codex-termux" + release_tag="rusty-v8-v147.4.0" + mkdir -p "${binding_dir}" + echo "Downloading Android rusty_v8 artifacts from ${artifact_repository}@${release_tag}" + gh release download "${release_tag}" \ + --repo "${artifact_repository}" \ + --dir "${binding_dir}" \ + --pattern "librusty_v8_release_${TARGET}.a.gz" \ + --pattern "src_binding_release_${TARGET}.rs" \ + --pattern "rusty_v8_release_${TARGET}.sha256" + (cd "${binding_dir}" && sha256sum -c "$(basename "${checksums_path}")") + echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV" + echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Install termux-elf-cleaner + shell: bash + run: | + set -euo pipefail + version="v3.0.1" + src_dir="${RUNNER_TEMP}/termux-elf-cleaner-src" + build_dir="${RUNNER_TEMP}/termux-elf-cleaner-build" + bin_dir="${RUNNER_TEMP}/termux-elf-cleaner-bin" + + rm -rf "${src_dir}" "${build_dir}" "${bin_dir}" + mkdir -p "${src_dir}" "${build_dir}" "${bin_dir}" + + curl -fsSL "https://github.com/termux/termux-elf-cleaner/archive/refs/tags/${version}.tar.gz" \ + | tar -xzf - --strip-components=1 -C "${src_dir}" + + cmake -S "${src_dir}" -B "${build_dir}" -DCMAKE_BUILD_TYPE=Release + cmake --build "${build_dir}" --parallel + + install "${build_dir}/termux-elf-cleaner" "${bin_dir}/termux-elf-cleaner" + echo "${bin_dir}" >> "$GITHUB_PATH" - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Use hermetic Cargo home (musl) @@ -179,12 +507,25 @@ jobs: echo "${cargo_home}/bin" >> "$GITHUB_PATH" : > "${cargo_home}/config.toml" + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ${{ github.workspace }}/.cargo-home/bin/ + ${{ github.workspace }}/.cargo-home/registry/index/ + ${{ github.workspace }}/.cargo-home/registry/cache/ + ${{ github.workspace }}/.cargo-home/git/db/ + ${{ github.workspace }}/codex-rs/target/ + key: cargo-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }} + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + uses: mlugg/setup-zig@v2 with: version: 0.14.0 - use-cache: false - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools @@ -193,208 +534,248 @@ jobs: run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Disable aws-lc jitter entropy (musl) + name: Configure rustc UBSan wrapper (musl host) shell: bash run: | set -euo pipefail - # Avoid problematic aws-lc jitter entropy code path on musl builders. - echo "AWS_LC_SYS_NO_JITTER_ENTROPY=1" >> "$GITHUB_ENV" - target_no_jitter="AWS_LC_SYS_NO_JITTER_ENTROPY_${{ matrix.target }}" - target_no_jitter="${target_no_jitter//-/_}" - echo "${target_no_jitter}=1" >> "$GITHUB_ENV" - - - name: Configure rusty_v8 artifact overrides and verify checksums - uses: ./.github/actions/setup-rusty-v8 - with: - target: ${{ matrix.target }} + ubsan="" + if command -v ldconfig >/dev/null 2>&1; then + ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" + fi + wrapper_root="${RUNNER_TEMP:-/tmp}" + wrapper="${wrapper_root}/rustc-ubsan-wrapper" + cat > "${wrapper}" <> "$GITHUB_ENV" + echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} + name: Clear sanitizer flags (musl) + shell: bash + run: | + set -euo pipefail + # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" + # Override any runner-level Cargo config rustflags as well. + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + + sanitize_flags() { + local input="$1" + input="${input//-fsanitize=undefined/}" + input="${input//-fno-sanitize-recover=undefined/}" + input="${input//-fno-sanitize-trap=undefined/}" + echo "$input" + } + + cflags="$(sanitize_flags "${CFLAGS-}")" + cxxflags="$(sanitize_flags "${CXXFLAGS-}")" + echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" + echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - if: ${{ contains(matrix.target, 'linux') }} - name: Build bwrap and export digest + - name: Cargo build shell: bash run: | set -euo pipefail - target="${{ matrix.target }}" - cargo build --target "$target" --release --timings --bin bwrap - bwrap_path="target/${target}/release/bwrap" - if [[ ! -f "$bwrap_path" ]]; then - echo "bwrap binary ${bwrap_path} not found" - exit 1 + if [[ "${{ matrix.target }}" == 'aarch64-linux-android' ]]; then + export CARGO_BUILD_JOBS=4 + export CARGO_PROFILE_RELEASE_LTO=thin + export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8 + cargo build --target ${{ matrix.target }} --release --bin codex + exit 0 fi - digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" - echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" - echo "Built bwrap ${bwrap_path} with sha256:${digest}" + if [[ "${{ contains(matrix.target, 'windows') }}" == 'true' ]]; then + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy --bin codex-windows-sandbox-setup --bin codex-command-runner + else + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy + fi + - name: sccache stats (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + shell: bash + run: ${SCCACHE_PATH:-sccache} --show-stats || true - - name: Cargo build + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Normalize Android ELF for Termux shell: bash run: | - target="${{ matrix.target }}" - if [[ "$target" == "x86_64-pc-windows-msvc" ]]; then - export LIBSQLITE3_FLAGS=SQLITE_DISABLE_INTRINSIC - fi - build_args=() - for binary in ${{ matrix.binaries }}; do - build_args+=(--bin "$binary") + set -euo pipefail + for binary in codex codex-responses-api-proxy; do + binary_path="target/${{ matrix.target }}/release/${binary}" + if [[ -f "${binary_path}" ]]; then + termux-elf-cleaner --api-level 24 "${binary_path}" + chmod +x "${binary_path}" + # if [[ "${binary}" == "codex" ]]; then + # # Patch PT_TLS (0x7) to PT_NULL (0x0) at offset 400 to bypass Bionic alignment checks. + # printf '\x00\x00\x00\x00' | dd of="${binary_path}" bs=1 seek=400 count=4 conv=notrunc + # fi + fi done - cargo build --target "$target" --release --timings "${build_args[@]}" - - name: Upload Cargo timings - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + - if: ${{ contains(matrix.target, 'linux') && !contains(matrix.target, 'android') && github.repository_owner == 'openai' }} + name: Cosign Linux artifacts + uses: ./.github/actions/linux-code-sign with: - name: cargo-timings-rust-release-${{ matrix.target }}-${{ matrix.bundle }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html - if-no-files-found: warn + target: ${{ matrix.target }} + artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - - if: ${{ runner.os == 'macOS' }} - name: Stage unsigned macOS artifacts + - if: ${{ contains(matrix.target, 'windows') && github.repository_owner == 'openai' }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} + name: MacOS code signing (binaries) + uses: ./.github/actions/macos-code-sign + with: + target: ${{ matrix.target }} + sign-binaries: "true" + sign-dmg: "false" + apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} + apple-certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + apple-notarization-key-p8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} + apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} + name: Build macOS dmg shell: bash run: | set -euo pipefail target="${{ matrix.target }}" release_dir="target/${target}/release" - dest="unsigned-dist/${target}" - mkdir -p "$dest" + dmg_root="${RUNNER_TEMP}/codex-dmg-root" + volname="Codex (${target})" + dmg_path="${release_dir}/codex-${target}.dmg" - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - unsigned_name="${binary}-${target}-unsigned" - unsigned_path="${dest}/${unsigned_name}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 - fi + # The previous "MacOS code signing (binaries)" step signs + notarizes the + # built artifacts in `${release_dir}`. This step packages *those same* + # signed binaries into a dmg. + codex_binary_path="${release_dir}/codex" + proxy_binary_path="${release_dir}/codex-responses-api-proxy" - cp "${binary_path}" "${unsigned_path}" - tar -C "$dest" -czf "${unsigned_path}.tar.gz" "${unsigned_name}" - zstd -T0 -19 --rm "${unsigned_path}" - done + rm -rf "$dmg_root" + mkdir -p "$dmg_root" - - if: ${{ runner.os == 'macOS' }} - name: Upload unsigned macOS artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-unsigned - path: codex-rs/unsigned-dist/${{ matrix.target }}/* - if-no-files-found: error + if [[ ! -f "$codex_binary_path" ]]; then + echo "Binary $codex_binary_path not found" + exit 1 + fi + if [[ ! -f "$proxy_binary_path" ]]; then + echo "Binary $proxy_binary_path not found" + exit 1 + fi - - if: ${{ contains(matrix.target, 'linux') }} - name: Cosign Linux artifacts - uses: ./.github/actions/linux-code-sign + ditto "$codex_binary_path" "${dmg_root}/codex" + ditto "$proxy_binary_path" "${dmg_root}/codex-responses-api-proxy" + + rm -f "$dmg_path" + hdiutil create \ + -volname "$volname" \ + -srcfolder "$dmg_root" \ + -format UDZO \ + -ov \ + "$dmg_path" + + if [[ ! -f "$dmg_path" ]]; then + echo "dmg $dmg_path not found after build" + exit 1 + fi + + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} + name: MacOS code signing (dmg) + uses: ./.github/actions/macos-code-sign with: target: ${{ matrix.target }} - artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - binaries: ${{ matrix.binaries }} + sign-binaries: "false" + sign-dmg: "true" + apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} + apple-certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + apple-notarization-key-p8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} + apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - name: Stage artifacts - if: ${{ runner.os != 'macOS' }} shell: bash run: | dest="dist/${{ matrix.target }}" mkdir -p "$dest" - for binary in ${{ matrix.binaries }}; do - cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}" - if [[ "${{ matrix.target }}" == *linux* ]]; then - cp "target/${{ matrix.target }}/release/${binary}.sigstore" \ - "$dest/${binary}-${{ matrix.target }}.sigstore" + if [[ "${{ matrix.runner }}" == windows* ]]; then + cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-command-runner.exe "$dest/codex-command-runner-${{ matrix.target }}.exe" + else + # For Android, we want the binary to be named just 'codex' in the archive. + if [[ "${{ matrix.target }}" == "aarch64-linux-android" ]]; then + cp target/${{ matrix.target }}/release/codex "$dest/codex" + else + cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" fi - done + if [[ -f target/${{ matrix.target }}/release/codex-responses-api-proxy ]]; then + cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" + fi + fi - if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then - bundle_root="${RUNNER_TEMP}/codex-${{ matrix.target }}-bundle" - rm -rf "$bundle_root" - mkdir -p "$bundle_root/codex-resources" - cp "$dest/codex-${{ matrix.target }}" "$bundle_root/codex" - cp "$dest/bwrap-${{ matrix.target }}" "$bundle_root/codex-resources/bwrap" - chmod 0755 "$bundle_root/codex" "$bundle_root/codex-resources/bwrap" - tar -C "$bundle_root" -cf - codex codex-resources/bwrap | - zstd -T0 -19 -o "$dest/codex-${{ matrix.target }}-bundle.tar.zst" + if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.target }}" != *android* ]]; then + cp target/${{ matrix.target }}/release/codex.sigstore "$dest/codex-${{ matrix.target }}.sigstore" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore" fi - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then + if [[ "${{ matrix.target }}" == *apple-darwin ]]; then cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi - - name: Build Codex package archive - if: ${{ runner.os != 'macOS' }} - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "target/${TARGET}/release" \ - --archive-dir "dist/${TARGET}" - - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' && runner.os != 'macOS' }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - aarch64-unknown-linux-musl) - platform_tag="manylinux_2_17_aarch64" - ;; - x86_64-unknown-linux-musl) - platform_tag="manylinux_2_17_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - # Do not install into the runner's system Python; macOS runners mark - # the Homebrew Python as externally managed under PEP 668. - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - stage_runtime_args=( - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" - stage-runtime - "$stage_dir" - "dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz" - --codex-version "${GITHUB_REF_NAME}" - --platform-tag "$platform_tag" - ) - python3 "${stage_runtime_args[@]}" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' && runner.os != 'macOS' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error + - if: ${{ matrix.runner == 'windows-11-arm' }} + name: Install zstd + shell: powershell + run: choco install -y zstandard - name: Compress artifacts - if: ${{ runner.os != 'macOS' }} shell: bash run: | # Path that contains the uncompressed binaries for the current # ${{ matrix.target }} dest="dist/${{ matrix.target }}" + repo_root=$PWD + + # We want to ship the raw Windows executables in the GitHub Release + # in addition to the compressed archives. Keep the originals for + # Windows targets; remove them elsewhere to limit the number of + # artifacts that end up in the GitHub Release. + keep_originals=false + if [[ "${{ matrix.runner }}" == windows* ]]; then + keep_originals=true + fi # For compatibility with environments that lack the `zstd` tool we - # additionally create a `.tar.gz` alongside every binary we publish. - # The end result is: + # additionally create a `.tar.gz` for all platforms and `.zip` for + # Windows alongside every single binary that we publish. The end result is: # codex-.zst (existing) # codex-.tar.gz (new) + # codex-.zip (only for Windows) # 1. Produce a .tar.gz for every file in the directory *before* we # run `zstd --rm`, because that flag deletes the original files. @@ -402,7 +783,7 @@ jobs: base="$(basename "$f")" # Skip files that are already archives (shouldn't happen, but be # safe). - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then + if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then continue fi @@ -412,623 +793,219 @@ jobs: fi # Create per-binary tar.gz - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - - # Also create .zst and remove the uncompressed binaries to keep - # non-Windows artifact directories small. - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - if: ${{ runner.os != 'macOS' }} - with: - name: ${{ matrix.artifact_name }} - # Upload the per-binary .zst files, .tar.gz equivalents, and any - # prebuilt archives staged above. - path: | - codex-rs/dist/${{ matrix.target }}/* - - sign-macos-binaries: - needs: build - name: Sign macOS binaries - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: ubuntu-latest - timeout-minutes: 45 - environment: - name: codesigning - deployment: false - permissions: - contents: read - id-token: write - - strategy: - fail-fast: false - matrix: - include: - - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Download unsigned macOS binaries - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ${{ matrix.artifact_name }}-unsigned - path: ${{ runner.temp }}/unsigned-macos - - - name: Set up AKV PKCS11 macOS signing - uses: ./.github/actions/setup-akv-pkcs11-codesigning - with: - rcodesign-blob-uri: ${{ secrets.AKV_CODESIGN_RCODESIGN_BLOB_URI }} - rcodesign-sha256: ${{ secrets.AKV_CODESIGN_RCODESIGN_SHA256 }} - akv-pkcs11-library-blob-uri: ${{ secrets.AKV_CODESIGN_PKCS11_LIBRARY_BLOB_URI }} - akv-pkcs11-library-sha256: ${{ secrets.AKV_CODESIGN_PKCS11_LIBRARY_SHA256 }} - azure-client-id: ${{ secrets.AKV_CODESIGN_AZURE_CLIENT_ID }} - azure-tenant-id: ${{ secrets.AKV_CODESIGN_TENANT }} - azure-subscription-id: ${{ secrets.AKV_CODESIGN_SUBSCRIPTION }} - key-vault-name: ${{ secrets.AKV_CODESIGN_KEY_VAULT_NAME }} - key-name: ${{ secrets.AKV_CODESIGN_KEY_NAME }} - key-version: ${{ secrets.AKV_CODESIGN_KEY_VERSION || '' }} - certificate-sha256: ${{ secrets.AKV_CODESIGN_CERTIFICATE_SHA256 || '' }} - - - name: Sign and notarize macOS binaries - shell: bash - env: - TARGET: ${{ matrix.target }} - BINARIES: ${{ matrix.binaries }} - APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} - APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} - APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - run: | - set -euo pipefail - - input_dir="${RUNNER_TEMP}/unsigned-macos" - output_dir="${GITHUB_WORKSPACE}/signed-macos/${TARGET}" - report_dir="${GITHUB_WORKSPACE}/macos-binary-signing-verification/${TARGET}" - mkdir -p "$output_dir" "$report_dir" - - for binary in ${BINARIES}; do - unsigned_path="${input_dir}/${binary}-${TARGET}-unsigned.zst" - signed_path="${output_dir}/${binary}" - if [[ ! -f "$unsigned_path" ]]; then - echo "Unsigned binary $unsigned_path not found" - exit 1 + # For Android, we want the archive to have the target suffix even though the binary is just 'codex'. + archive_name="${base}" + if [[ "${{ matrix.target }}" == "aarch64-linux-android" && "${base}" == "codex" ]]; then + archive_name="codex-${{ matrix.target }}" fi - - zstd -d --stdout "$unsigned_path" >"$signed_path" - chmod 0755 "$signed_path" - - .github/scripts/macos-signing/sign_macos_code.sh \ - --target "$signed_path" \ - --identity unused \ - --deep false \ - --identifier "$binary" \ - --options runtime \ - --timestamp true \ - --entitlements .github/scripts/macos-signing/codex.entitlements.plist - - mkdir -p "${report_dir}/${binary}" - rcodesign print-signature-info "$signed_path" \ - >"${report_dir}/${binary}/signature-info.yaml" - - .github/scripts/macos-signing/notarize_macos_binary_with_rcodesign.sh \ - --binary "$signed_path" \ - --report-dir "${report_dir}/${binary}" - done - - - name: Upload signed macOS binaries - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-signed-binaries - path: signed-macos/${{ matrix.target }}/* - if-no-files-found: error - - - name: Upload binary signing verification - if: ${{ always() }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-binary-signing-verification - path: macos-binary-signing-verification/${{ matrix.target }}/ - if-no-files-found: warn - - package-macos: - needs: sign-macos-binaries - name: Package macOS artifacts - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: macos-15-xlarge - timeout-minutes: 45 - permissions: - contents: read - defaults: - run: - working-directory: codex-rs - - strategy: - fail-fast: false - matrix: - include: - - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Download signed macOS binaries - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ${{ matrix.artifact_name }}-signed-binaries - path: codex-rs/target/${{ matrix.target }}/release - - - name: Verify signed macOS binaries - shell: bash - run: | - set -euo pipefail - for binary in ${{ matrix.binaries }}; do - binary_path="target/${{ matrix.target }}/release/${binary}" - chmod 0755 "$binary_path" - codesign --verify --strict --verbose=2 "$binary_path" - done - - - name: Build unsigned macOS DMG - if: ${{ matrix.build_dmg == 'true' }} - shell: bash - run: | - set -euo pipefail - - target="${{ matrix.target }}" - release_dir="target/${target}/release" - dmg_root="${RUNNER_TEMP}/codex-dmg-root-${target}" - volname="Codex (${target})" - dmg_path="${release_dir}/codex-${target}.dmg" - - rm -rf "$dmg_root" - mkdir -p "$dmg_root" - - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - if [[ ! -f "$binary_path" ]]; then - echo "Binary $binary_path not found" - exit 1 + tar -C "$dest" -czf "$dest/${archive_name}.tar.gz" "$base" + + # Create zip archive for Windows binaries + # Must run from inside the dest dir so 7z won't + # embed the directory path inside the zip. + if [[ "${{ matrix.runner }}" == windows* ]]; then + if [[ "$base" == "codex-${{ matrix.target }}.exe" ]]; then + # Bundle the sandbox helper binaries into the main codex zip so + # WinGet installs include the required helpers next to codex.exe. + # Fall back to the single-binary zip if the helpers are missing + # to avoid breaking releases. + bundle_dir="$(mktemp -d)" + runner_src="$dest/codex-command-runner-${{ matrix.target }}.exe" + setup_src="$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + if [[ -f "$runner_src" && -f "$setup_src" ]]; then + cp "$dest/$base" "$bundle_dir/$base" + cp "$runner_src" "$bundle_dir/codex-command-runner.exe" + cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe" + # Use an absolute path so bundle zips land in the real dist + # dir even when 7z runs from a temp directory. + (cd "$bundle_dir" && 7z a "$repo_root/$dest/${base}.zip" .) + else + echo "warning: missing sandbox binaries; falling back to single-binary zip" + echo "warning: expected $runner_src and $setup_src" + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + rm -rf "$bundle_dir" + else + (cd "$dest" && 7z a "${base}.zip" "$base") + fi fi - ditto "$binary_path" "${dmg_root}/${binary}" - done - - rm -f "$dmg_path" - hdiutil create \ - -volname "$volname" \ - -srcfolder "$dmg_root" \ - -format UDZO \ - -ov \ - "$dmg_path" - - if [[ ! -f "$dmg_path" ]]; then - echo "DMG $dmg_path not found after build" - exit 1 - fi - - - name: Upload unsigned macOS DMG - if: ${{ matrix.build_dmg == 'true' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-unsigned-dmg - path: codex-rs/target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg - if-no-files-found: error - - name: Stage macOS artifacts - shell: bash - run: | - set -euo pipefail - dest="dist/${{ matrix.target }}" - mkdir -p "$dest" + # Also create .zst (existing behaviour) *and* remove the original + # uncompressed binary to keep the directory small. + zstd_args=(-T0 -19) + if [[ "${keep_originals}" == false ]]; then + zstd_args+=(--rm) + fi - for binary in ${{ matrix.binaries }}; do - cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}" + if [[ "${archive_name}" != "${base}" ]]; then + zstd "${zstd_args[@]}" "$dest/$base" -o "$dest/${archive_name}.zst" + else + zstd "${zstd_args[@]}" "$dest/$base" + fi done - - name: Build Codex package archive - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "target/${TARGET}/release" \ - --archive-dir "dist/${TARGET}" - - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - python3 \ - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ - stage-runtime \ - "$stage_dir" \ - "dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz" \ - --codex-version "${GITHUB_REF_NAME}" \ - --platform-tag "$platform_tag" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error - - - name: Compress artifacts + - name: Add Termux release metadata + if: ${{ (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && matrix.target == 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail + if [[ ! -f "${GITHUB_WORKSPACE}/.github/termux-release.json" ]]; then + echo "No Termux release metadata found; skipping metadata attachment." + exit 0 + fi dest="dist/${{ matrix.target }}" - for f in "$dest"/*; do - base="$(basename "$f")" - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then - continue - fi - - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - zstd -T0 -19 --rm "$dest/$base" - done - - - name: Upload packaged macOS artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-packaged - path: codex-rs/dist/${{ matrix.target }}/* - if-no-files-found: error - - sign-macos-dmg: - needs: package-macos - name: Sign macOS DMG - ${{ matrix.target }} - runs-on: ubuntu-latest - timeout-minutes: 45 - environment: - name: codesigning - deployment: false - permissions: - contents: read - id-token: write - - strategy: - fail-fast: false - matrix: - include: - - target: aarch64-apple-darwin - artifact_name: aarch64-apple-darwin - - target: x86_64-apple-darwin - artifact_name: x86_64-apple-darwin - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + cp "${GITHUB_WORKSPACE}/.github/termux-release.json" "${dest}/termux-release.json" + ( + cd "${dest}" + sha256sum ./* > SHA256SUMS + ) - - name: Download unsigned macOS DMG - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - id: upload-artifact + uses: actions/upload-artifact@v6 with: - name: ${{ matrix.artifact_name }}-unsigned-dmg - path: ${{ runner.temp }}/unsigned-dmg + name: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + # Upload the per-binary .zst files as well as the new .tar.gz + # equivalents we generated in the previous step. + path: | + codex-rs/dist/${{ matrix.target }}/* - - name: Set up AKV PKCS11 macOS signing - uses: ./.github/actions/setup-akv-pkcs11-codesigning - with: - rcodesign-blob-uri: ${{ secrets.AKV_CODESIGN_RCODESIGN_BLOB_URI }} - rcodesign-sha256: ${{ secrets.AKV_CODESIGN_RCODESIGN_SHA256 }} - akv-pkcs11-library-blob-uri: ${{ secrets.AKV_CODESIGN_PKCS11_LIBRARY_BLOB_URI }} - akv-pkcs11-library-sha256: ${{ secrets.AKV_CODESIGN_PKCS11_LIBRARY_SHA256 }} - azure-client-id: ${{ secrets.AKV_CODESIGN_AZURE_CLIENT_ID }} - azure-tenant-id: ${{ secrets.AKV_CODESIGN_TENANT }} - azure-subscription-id: ${{ secrets.AKV_CODESIGN_SUBSCRIPTION }} - key-vault-name: ${{ secrets.AKV_CODESIGN_KEY_VAULT_NAME }} - key-name: ${{ secrets.AKV_CODESIGN_KEY_NAME }} - key-version: ${{ secrets.AKV_CODESIGN_KEY_VERSION || '' }} - certificate-sha256: ${{ secrets.AKV_CODESIGN_CERTIFICATE_SHA256 || '' }} - - - name: Sign, notarize, and staple macOS DMG + - name: Publish Termux artifact notification + if: ${{ github.event_name == 'pull_request' && startsWith(github.base_ref, 'release/') && matrix.target == 'aarch64-linux-android' }} shell: bash env: - TARGET: ${{ matrix.target }} - APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} - APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} - APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ARTIFACT_ID: ${{ steps.upload-artifact.outputs.artifact-id }} + ARTIFACT_NAME: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + PR_NUMBER: ${{ github.event.pull_request.number }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} run: | set -euo pipefail - - dmg_path="${RUNNER_TEMP}/unsigned-dmg/codex-${TARGET}.dmg" - report_dir="${GITHUB_WORKSPACE}/macos-dmg-signing-verification/${TARGET}" - if [[ ! -f "$dmg_path" ]]; then - echo "Unsigned DMG $dmg_path not found" + if [[ -z "${ARTIFACT_ID}" ]]; then + echo "upload-artifact did not return an artifact id" >&2 exit 1 fi - .github/scripts/macos-signing/sign_macos_code.sh \ - --target "$dmg_path" \ - --identity unused \ - --deep false \ - --timestamp true - - mkdir -p "$report_dir" - rcodesign print-signature-info "$dmg_path" \ - >"${report_dir}/signature-info-before-notarization.yaml" - - .github/scripts/macos-signing/notarize_macos_dmg_with_rcodesign.sh \ - --dmg "$dmg_path" \ - --report-dir "$report_dir" - - rcodesign print-signature-info "$dmg_path" \ - >"${report_dir}/signature-info.yaml" - - - name: Upload signed macOS DMG - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-signed-dmg - path: ${{ runner.temp }}/unsigned-dmg/codex-${{ matrix.target }}.dmg - if-no-files-found: error - - - name: Upload DMG signing verification - if: ${{ always() }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-dmg-signing-verification - path: macos-dmg-signing-verification/${{ matrix.target }}/ - if-no-files-found: warn - - finalize-macos: - needs: - - package-macos - - sign-macos-dmg - name: Verify macOS artifacts - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: macos-15-xlarge - timeout-minutes: 30 - permissions: - contents: read - defaults: - run: - working-directory: codex-rs - - strategy: - fail-fast: false - matrix: - include: - - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - verify_dmg: "true" - - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - verify_dmg: "false" - - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - verify_dmg: "true" - - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - verify_dmg: "false" - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Download packaged macOS artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ${{ matrix.artifact_name }}-packaged - path: codex-rs/dist/${{ matrix.target }} - - - name: Download signed macOS binaries - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ${{ matrix.artifact_name }}-signed-binaries - path: ${{ runner.temp }}/signed-binaries - - - name: Download signed macOS DMG - if: ${{ matrix.verify_dmg == 'true' }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ${{ matrix.artifact_name }}-signed-dmg - path: ${{ runner.temp }}/signed-dmg - - - name: Verify signed macOS artifacts - shell: bash - run: | - set -euo pipefail - - target="${{ matrix.target }}" - packaged_dir="dist/${target}" - expected_entitlements="${GITHUB_WORKSPACE}/.github/scripts/macos-signing/codex.entitlements.plist" - - verify_signed_binary() { - local path="$1" - local actual_entitlements normalized_actual normalized_expected - - chmod 0755 "$path" - codesign --verify --strict --verbose=2 "$path" - - actual_entitlements="$(mktemp)" - normalized_actual="$(mktemp)" - normalized_expected="$(mktemp)" - codesign -d --entitlements :- "$path" >"$actual_entitlements" - plutil -convert xml1 -o "$normalized_actual" "$actual_entitlements" - plutil -convert xml1 -o "$normalized_expected" "$expected_entitlements" - diff -u "$normalized_expected" "$normalized_actual" - rm -f "$actual_entitlements" "$normalized_actual" "$normalized_expected" - } - - for binary in ${{ matrix.binaries }}; do - binary_path="${RUNNER_TEMP}/signed-binaries/${binary}" - verify_signed_binary "$binary_path" - - direct_archive_dir="${RUNNER_TEMP}/direct-archive-${binary}-${target}" - rm -rf "$direct_archive_dir" - mkdir -p "$direct_archive_dir" - tar -xzf "${packaged_dir}/${binary}-${target}.tar.gz" -C "$direct_archive_dir" - verify_signed_binary "${direct_archive_dir}/${binary}-${target}" - - direct_zstd_path="${RUNNER_TEMP}/${binary}-${target}-from-zstd" - zstd -d --stdout "${packaged_dir}/${binary}-${target}.zst" >"$direct_zstd_path" - verify_signed_binary "$direct_zstd_path" - done - - case "${{ matrix.bundle }}" in - primary) - package_stem="codex-package" - package_entrypoint="codex" - ;; - app-server) - package_stem="codex-app-server-package" - package_entrypoint="codex-app-server" - ;; - *) - echo "Unexpected macOS bundle: ${{ matrix.bundle }}" - exit 1 - ;; - esac - - package_dir="${RUNNER_TEMP}/${package_stem}-${target}" - rm -rf "$package_dir" - mkdir -p "$package_dir" - tar -xzf "${packaged_dir}/${package_stem}-${target}.tar.gz" -C "$package_dir" - verify_signed_binary "${package_dir}/bin/${package_entrypoint}" - - if [[ "${{ matrix.verify_dmg }}" != "true" ]]; then - exit 0 + marker="" + artifact_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts/${ARTIFACT_ID}" + run_url="${GH_WORKFLOW_URL:-https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + body_path="${RUNNER_TEMP}/termux-artifact-comment.md" + { + echo "${marker}" + echo "Termux Android artifact ready for testing:" + echo + echo "- Artifact: [${ARTIFACT_NAME}](${artifact_url})" + echo "- Workflow run: [${GITHUB_RUN_ID}](${run_url})" + echo + echo "You must be signed in to GitHub with repository access to download Actions artifacts." + } > "${body_path}" + + existing_comment_id="$( + gh pr view "${PR_NUMBER}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json comments \ + --jq ".comments | map(select(.author.is_bot == true and (.body | contains(\"${marker}\")))) | .[-1].id // \"\"" + )" + if [[ -n "${existing_comment_id}" ]]; then + gh api graphql \ + -f query=' + mutation($id: ID!, $body: String!) { + updateIssueComment(input: {id: $id, body: $body}) { + issueComment { + id + } + } + } + ' \ + -f id="${existing_comment_id}" \ + -f body="$(cat "${body_path}")" \ + >/dev/null + else + gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --body-file "${body_path}" fi - dmg_path="${RUNNER_TEMP}/signed-dmg/codex-${target}.dmg" - mount_dir="${RUNNER_TEMP}/codex-dmg-mount-${target}" - if [[ ! -f "$dmg_path" ]]; then - echo "Signed DMG $dmg_path not found" - exit 1 + gh label create binary-ready \ + --repo "${GITHUB_REPOSITORY}" \ + --color 0e8a16 \ + --description "Android binary is ready for testing" \ + --force + gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --add-label binary-ready + + if [[ -n "${SLACK_WEBHOOK_URL:-}" ]]; then + slack_payload_path="${RUNNER_TEMP}/termux-artifact-slack.json" + pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}" + jq -n \ + --arg artifact_name "${ARTIFACT_NAME}" \ + --arg artifact_url "${artifact_url}" \ + --arg pr_number "${PR_NUMBER}" \ + --arg pr_url "${pr_url}" \ + --arg run_id "${GITHUB_RUN_ID}" \ + --arg run_url "${run_url}" \ + '{ + text: ("Termux Android artifact ready for testing: " + $artifact_url), + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: ("*Termux Android artifact ready for testing*\n" + $artifact_url) + } + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: ("*Artifact:*\n<" + $artifact_url + "|" + $artifact_name + ">") + }, + { + type: "mrkdwn", + text: ("*Pull request:*\n<" + $pr_url + "|#" + $pr_number + ">") + }, + { + type: "mrkdwn", + text: ("*Workflow run:*\n<" + $run_url + "|" + $run_id + ">") + } + ] + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: "You must be signed in to GitHub with repository access to download Actions artifacts." + } + ] + } + ] + }' > "${slack_payload_path}" + + if ! curl -fsS \ + -X POST \ + -H "Content-type: application/json" \ + --data @"${slack_payload_path}" \ + "${SLACK_WEBHOOK_URL}"; then + echo "::warning::Failed to send Slack artifact notification" + fi fi - hdiutil verify "$dmg_path" - codesign --verify --strict --verbose=2 "$dmg_path" - xcrun stapler validate "$dmg_path" - - rm -rf "$mount_dir" - mkdir -p "$mount_dir" - hdiutil attach "$dmg_path" -nobrowse -readonly -mountpoint "$mount_dir" - cleanup_mount() { - hdiutil detach "$mount_dir" >/dev/null - } - trap cleanup_mount EXIT - - for binary in ${{ matrix.binaries }}; do - verify_signed_binary "${mount_dir}/${binary}" - done - - cleanup_mount - trap - EXIT - cp "$dmg_path" "dist/${target}/codex-${target}.dmg" - - - name: Upload verified macOS artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }} - path: codex-rs/dist/${{ matrix.target }}/* - if-no-files-found: error - - build-windows: + shell-tool-mcp: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.repository_owner == 'openai' + name: shell-tool-mcp needs: tag-check - uses: ./.github/workflows/rust-release-windows.yml - secrets: inherit - - argument-comment-lint-release-assets: - name: argument-comment-lint release assets - needs: tag-check - uses: ./.github/workflows/rust-release-argument-comment-lint.yml + permissions: + contents: read + id-token: write + uses: ./.github/workflows/shell-tool-mcp.yml with: + release-tag: ${{ github.ref_name }} publish: true - - zsh-release-assets: - name: zsh release assets - needs: tag-check - uses: ./.github/workflows/rust-release-zsh.yml + secrets: inherit release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') needs: - - tag-check - build - - finalize-macos - - build-windows - - argument-comment-lint-release-assets - - zsh-release-assets - if: >- - ${{ - always() && - needs.tag-check.result == 'success' && - needs.build.result == 'success' && - needs.finalize-macos.result == 'success' && - needs.build-windows.result == 'success' && - needs.argument-comment-lint-release-assets.result == 'success' && - needs.zsh-release-assets.result == 'success' - }} + - shell-tool-mcp name: release runs-on: ubuntu-latest permissions: @@ -1042,9 +1019,13 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + uses: actions/checkout@v6 + + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main - name: Generate release notes from tag commit message id: release_notes @@ -1066,54 +1047,21 @@ jobs: echo "path=${notes_path}" >> "${GITHUB_OUTPUT}" - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@v7 with: path: dist - name: List run: ls -R dist/ + # This is a temporary fix: we should modify shell-tool-mcp.yml so these + # files do not end up in dist/ in the first place. - name: Delete entries from dist/ that should not go in the release run: | - rm -rf dist/windows-binaries* - rm -rf dist/*-apple-darwin*-signed-binaries - rm -rf dist/*-apple-darwin*-packaged - rm -rf dist/*-apple-darwin*-unsigned-dmg - rm -rf dist/*-apple-darwin*-signed-dmg - rm -rf dist/*-apple-darwin*-binary-signing-verification - rm -rf dist/*-apple-darwin*-dmg-signing-verification - rm -rf dist/*-apple-darwin*-unsigned - # cargo-timing.html appears under multiple target-specific directories. - # If included in files: dist/**, release upload races on duplicate - # asset names and can fail with 404s. - find dist -type f -name 'cargo-timing.html' -delete - find dist -type d -empty -delete + rm -rf dist/shell-tool-mcp* ls -R dist/ - - name: Add Codex package checksum manifest - run: | - set -euo pipefail - - manifest="dist/codex-package_SHA256SUMS" - tmp_manifest="$(mktemp)" - find dist -type f \ - \( -name 'codex-package-*.tar.gz' -o -name 'codex-app-server-package-*.tar.gz' \) \ - -print | - sort | - while IFS= read -r archive; do - sha256sum "$archive" | - awk -v name="$(basename "$archive")" '{ print $1 " " name }' - done > "$tmp_manifest" - - if [[ ! -s "$tmp_manifest" ]]; then - echo "No Codex package archives found for checksum manifest" - exit 1 - fi - - mv "$tmp_manifest" "$manifest" - cat "$manifest" - - name: Add config schema release asset run: | cp codex-rs/core/config.schema.json dist/config-schema.json @@ -1146,81 +1094,69 @@ jobs: fi - name: Setup pnpm - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 + uses: pnpm/action-setup@v4 with: run_install: false - name: Setup Node.js for npm packaging - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: node-version: 22 - name: Install dependencies run: pnpm install --frozen-lockfile + # stage_npm_packages.py requires DotSlash when staging releases. + - uses: facebook/install-dotslash@v2 - name: Stage npm packages + if: github.repository_owner == 'openai' env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ steps.release_name.outputs.name }} run: | - workflow_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" ./scripts/stage_npm_packages.py \ - --release-version "$RELEASE_VERSION" \ - --workflow-url "$workflow_url" \ + --release-version "${{ steps.release_name.outputs.name }}" \ --package codex \ --package codex-responses-api-proxy \ --package codex-sdk - - name: Stage installer scripts - run: | - cp scripts/install/install.sh dist/install.sh - cp scripts/install/install.ps1 dist/install.ps1 - - name: Create GitHub Release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + uses: softprops/action-gh-release@v2 with: name: ${{ steps.release_name.outputs.name }} tag_name: ${{ github.ref_name }} body_path: ${{ steps.release_notes.outputs.path }} files: dist/** - overwrite_files: true - make_latest: ${{ !contains(steps.release_name.outputs.name, '-') }} # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - if: github.repository_owner == 'openai' + uses: facebook/dotslash-publish-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-config.json - - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-zsh-config.json - - - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - name: Trigger developers.openai.com deploy + # Only trigger the deploy if the release is not a pre-release. + # The deploy is used to update the developers.openai.com website with the new config schema json file. + if: ${{ !contains(steps.release_name.outputs.name, '-') && github.repository_owner == 'openai' }} + continue-on-error: true env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-argument-comment-lint-config.json + DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} + run: | + if ! curl -sS -f -o /dev/null -X POST "$DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL"; then + echo "::warning title=developers.openai.com deploy hook failed::Vercel deploy hook POST failed for ${GITHUB_REF_NAME}" + exit 1 + fi # Publish to npm using OIDC authentication. # July 31, 2025: https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/ # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_npm == 'true' - }} + if: ${{ needs.release.outputs.should_publish_npm == 'true' && github.repository_owner == 'openai' }} name: publish-npm needs: release runs-on: ubuntu-latest @@ -1230,37 +1166,36 @@ jobs: steps: - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: - # Node 24 bundles npm >= 11.5.1, which trusted publishing requires. - node-version: 24 + node-version: 22 registry-url: "https://registry.npmjs.org" scope: "@openai" + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + - name: Download npm tarballs from release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} run: | set -euo pipefail - version="$RELEASE_VERSION" - tag="$RELEASE_TAG" + version="${{ needs.release.outputs.version }}" + tag="${{ needs.release.outputs.tag }}" mkdir -p dist/npm - patterns=( - "codex-npm-${version}.tgz" - "codex-npm-linux-*-${version}.tgz" - "codex-npm-darwin-*-${version}.tgz" - "codex-npm-win32-*-${version}.tgz" - "codex-responses-api-proxy-npm-${version}.tgz" - "codex-sdk-npm-${version}.tgz" - ) - for pattern in "${patterns[@]}"; do - gh release download "$tag" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "$pattern" \ - --dir dist/npm - done + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-responses-api-proxy-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-sdk-npm-${version}.tgz" \ + --dir dist/npm # No NODE_AUTH_TOKEN needed because we use OIDC. - name: Publish to npm @@ -1269,176 +1204,23 @@ jobs: NPM_TAG: ${{ needs.release.outputs.npm_tag }} run: | set -euo pipefail - prefix="" + tag_args=() if [[ -n "${NPM_TAG}" ]]; then - prefix="${NPM_TAG}-" + tag_args+=(--tag "${NPM_TAG}") fi - root_tarball="dist/npm/codex-npm-${VERSION}.tgz" - sdk_tarball="dist/npm/codex-sdk-npm-${VERSION}.tgz" - # Keep this list in sync with CODEX_PLATFORM_PACKAGES in - # codex-cli/scripts/build_npm_package.py. The root wrapper advances - # @openai/codex@latest as soon as it publishes, so every platform - # package it aliases must already exist in the registry first. - platform_tarballs=( - "dist/npm/codex-npm-linux-x64-${VERSION}.tgz" - "dist/npm/codex-npm-linux-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-x64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-x64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-arm64-${VERSION}.tgz" - ) - - for required_tarball in "${platform_tarballs[@]}" "${root_tarball}"; do - if [[ ! -f "${required_tarball}" ]]; then - echo "Missing npm tarball: ${required_tarball}" - exit 1 - fi - done - - shopt -s nullglob - other_tarballs=() - for tarball in dist/npm/*-"${VERSION}".tgz; do - if [[ "${tarball}" == "${root_tarball}" || "${tarball}" == "${sdk_tarball}" ]]; then - continue - fi - - is_platform_tarball=false - for platform_tarball in "${platform_tarballs[@]}"; do - if [[ "${tarball}" == "${platform_tarball}" ]]; then - is_platform_tarball=true - break - fi - done - if [[ "${is_platform_tarball}" == true ]]; then - continue - fi - - other_tarballs+=("${tarball}") - done - - # Publish the platform packages before the root CLI wrapper. The root - # wrapper advances @openai/codex@latest, so it should only publish - # after the optional dependency versions it references exist. tarballs=( - "${platform_tarballs[@]}" - "${other_tarballs[@]}" - "${root_tarball}" + "codex-npm-${VERSION}.tgz" + "codex-responses-api-proxy-npm-${VERSION}.tgz" + "codex-sdk-npm-${VERSION}.tgz" ) - if [[ -f "${sdk_tarball}" ]]; then - tarballs+=("${sdk_tarball}") - fi for tarball in "${tarballs[@]}"; do - filename="$(basename "${tarball}")" - tag="" - - case "${filename}" in - codex-npm-linux-*-"${VERSION}".tgz|codex-npm-darwin-*-"${VERSION}".tgz|codex-npm-win32-*-"${VERSION}".tgz) - platform="${filename#codex-npm-}" - platform="${platform%-${VERSION}.tgz}" - tag="${prefix}${platform}" - ;; - codex-npm-"${VERSION}".tgz|codex-responses-api-proxy-npm-"${VERSION}".tgz|codex-sdk-npm-"${VERSION}".tgz) - tag="${NPM_TAG}" - ;; - *) - echo "Unexpected npm tarball: ${filename}" - exit 1 - ;; - esac - - publish_cmd=(npm publish "${GITHUB_WORKSPACE}/${tarball}") - if [[ -n "${tag}" ]]; then - publish_cmd+=(--tag "${tag}") - fi - - echo "+ ${publish_cmd[*]}" - set +e - publish_output="$("${publish_cmd[@]}" 2>&1)" - publish_status=$? - set -e - - echo "${publish_output}" - if [[ ${publish_status} -eq 0 ]]; then - continue - fi - - if grep -qiE "previously published|cannot publish over|version already exists" <<< "${publish_output}"; then - echo "Skipping already-published package version for ${filename}" - continue - fi - - exit "${publish_status}" + npm publish "${GITHUB_WORKSPACE}/dist/npm/${tarball}" "${tag_args[@]}" done - deploy-dev-website: - name: Trigger developers.openai.com deploy - needs: release - # Only trigger the deploy for a stable release. - # The deploy updates developers.openai.com with the new config schema json file. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - !contains(needs.release.outputs.version, '-') - }} - runs-on: ubuntu-latest - continue-on-error: true - permissions: {} - environment: - name: dev-website-vercel-deploy - deployment: false - - steps: - - name: Trigger developers.openai.com deploy - continue-on-error: true - env: - DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} - run: | - if ! curl -sS -f -o /dev/null -X POST "$DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL"; then - echo "::warning title=developers.openai.com deploy hook failed::Vercel deploy hook POST failed for ${GITHUB_REF_NAME}" - exit 1 - fi - - winget: - name: winget - needs: release - # Only publish stable/mainline releases to WinGet; pre-releases include a - # '-' in the semver string (e.g., 1.2.3-alpha.1). - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - !contains(needs.release.outputs.version, '-') - }} - # This job only invokes a GitHub Action to open/update the winget-pkgs PR; - # it does not execute Windows-only tooling, so Linux is sufficient. - runs-on: ubuntu-latest - permissions: - contents: read - environment: - name: mainline-release-winget - deployment: false - - steps: - - name: Publish to WinGet - uses: vedantmgoyal9/winget-releaser@7bd472be23763def6e16bd06cc8b1cdfab0e2fd5 - with: - identifier: OpenAI.Codex - version: ${{ needs.release.outputs.version }} - release-tag: ${{ needs.release.outputs.tag }} - fork-user: openai-oss-forks - installers-regex: '^codex-(?:x86_64|aarch64)-pc-windows-msvc\.exe\.zip$' - token: ${{ secrets.WINGET_PUBLISH_PAT }} - update-branch: name: Update latest-alpha-cli branch - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' - }} permissions: contents: write needs: release diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml new file mode 100644 index 000000000000..66a76aa4d938 --- /dev/null +++ b/.github/workflows/shell-tool-mcp.yml @@ -0,0 +1,461 @@ +name: shell-tool-mcp + +on: + workflow_call: + inputs: + release-version: + description: Version to publish (x.y.z or x.y.z-alpha.N). Defaults to GITHUB_REF_NAME when it starts with rust-v. + required: false + type: string + release-tag: + description: Tag name to use when downloading release artifacts (defaults to rust-v). + required: false + type: string + publish: + description: Whether to publish to npm when the version is releasable. + required: false + default: true + type: boolean + +env: + NODE_VERSION: 22 + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.compute.outputs.version }} + release_tag: ${{ steps.compute.outputs.release_tag }} + should_publish: ${{ steps.compute.outputs.should_publish }} + npm_tag: ${{ steps.compute.outputs.npm_tag }} + steps: + - name: Compute version and tags + id: compute + run: | + set -euo pipefail + + version="${{ inputs.release-version }}" + release_tag="${{ inputs.release-tag }}" + + if [[ -z "$version" ]]; then + if [[ -n "$release_tag" && "$release_tag" =~ ^rust-v.+ ]]; then + version="${release_tag#rust-v}" + elif [[ "${GITHUB_REF_NAME:-}" =~ ^rust-v.+ ]]; then + version="${GITHUB_REF_NAME#rust-v}" + release_tag="${GITHUB_REF_NAME}" + else + echo "release-version is required when GITHUB_REF_NAME is not a rust-v tag." + exit 1 + fi + fi + + if [[ -z "$release_tag" ]]; then + release_tag="rust-v${version}" + fi + + npm_tag="" + should_publish="false" + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + should_publish="true" + elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then + should_publish="true" + npm_tag="alpha" + fi + + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" + echo "npm_tag=${npm_tag}" >> "$GITHUB_OUTPUT" + echo "should_publish=${should_publish}" >> "$GITHUB_OUTPUT" + + matrix-setup: + runs-on: ubuntu-latest + outputs: + rust-binaries: ${{ steps.compute.outputs.rust-binaries }} + bash-linux: ${{ steps.compute.outputs.bash-linux }} + bash-darwin: ${{ steps.compute.outputs.bash-darwin }} + steps: + - name: Compute matrices + id: compute + shell: bash + run: | + set -euo pipefail + is_openai="${{ github.repository_owner == 'openai' }}" + + # rust-binaries: always include x86_64-musl; conditionally include paid/fork runners + if [[ "$is_openai" == "true" ]]; then + rust='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","install_musl":true} + ]' + else + rust='[{"runner":"macos-latest","target":"aarch64-apple-darwin"}]' + fi + rust=$(echo "$rust" | jq -c '. + [{"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","install_musl":true}]') + echo "rust-binaries={\"include\":$rust}" >> "$GITHUB_OUTPUT" + + # bash-linux: always include x86_64 variants; add arm64 on openai + bash_linux='[ + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-24.04","image":"ubuntu:24.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-22.04","image":"ubuntu:22.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-12","image":"debian:12"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-11","image":"debian:11"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]' + if [[ "$is_openai" == "true" ]]; then + bash_linux=$(echo "$bash_linux" | jq -c '. + [ + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-24.04","image":"arm64v8/ubuntu:24.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-22.04","image":"arm64v8/ubuntu:22.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-20.04","image":"arm64v8/ubuntu:20.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-12","image":"arm64v8/debian:12"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-11","image":"arm64v8/debian:11"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]') + fi + echo "bash-linux={\"include\":$(echo "$bash_linux" | jq -c)}" >> "$GITHUB_OUTPUT" + + # bash-darwin: always include macos-14; add macos-15-xlarge on openai + bash_darwin='[{"runner":"macos-14","target":"aarch64-apple-darwin","variant":"macos-14"}]' + if [[ "$is_openai" == "true" ]]; then + bash_darwin=$(echo "$bash_darwin" | jq -c '. + [ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin","variant":"macos-15"} + ]') + fi + echo "bash-darwin={\"include\":$(echo "$bash_darwin" | jq -c)}" >> "$GITHUB_OUTPUT" + + rust-binaries: + name: Build Rust - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + defaults: + run: + working-directory: codex-rs + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.rust-binaries) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install UBSan runtime (musl) + if: ${{ matrix.install_musl }} + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 + fi + + - uses: dtolnay/rust-toolchain@1.93 + with: + targets: ${{ matrix.target }} + + - if: ${{ matrix.install_musl }} + name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.14.0 + + - if: ${{ matrix.install_musl }} + name: Install musl build dependencies + env: + TARGET: ${{ matrix.target }} + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" + + - if: ${{ matrix.install_musl }} + name: Configure rustc UBSan wrapper (musl host) + shell: bash + run: | + set -euo pipefail + ubsan="" + if command -v ldconfig >/dev/null 2>&1; then + ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" + fi + wrapper_root="${RUNNER_TEMP:-/tmp}" + wrapper="${wrapper_root}/rustc-ubsan-wrapper" + cat > "${wrapper}" <> "$GITHUB_ENV" + echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" + + - if: ${{ matrix.install_musl }} + name: Clear sanitizer flags (musl) + shell: bash + run: | + set -euo pipefail + # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" + # Override any runner-level Cargo config rustflags as well. + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + + sanitize_flags() { + local input="$1" + input="${input//-fsanitize=undefined/}" + input="${input//-fno-sanitize-recover=undefined/}" + input="${input//-fno-sanitize-trap=undefined/}" + echo "$input" + } + + cflags="$(sanitize_flags "${CFLAGS-}")" + cxxflags="$(sanitize_flags "${CXXFLAGS-}")" + echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" + echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" + + - name: Build exec server binaries + run: cargo build --release --target ${{ matrix.target }} --bin codex-exec-mcp-server --bin codex-execve-wrapper + + - name: Stage exec server binaries + run: | + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}" + mkdir -p "$dest" + cp "target/${{ matrix.target }}/release/codex-exec-mcp-server" "$dest/" + cp "target/${{ matrix.target }}/release/codex-execve-wrapper" "$dest/" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-rust-${{ matrix.target }} + path: artifacts/** + if-no-files-found: error + + bash-linux: + name: Build Bash (Linux) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + container: + image: ${{ matrix.image }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-linux) }} + steps: + - name: Install build prerequisites + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext + elif command -v dnf >/dev/null 2>&1; then + dnf install -y git gcc gcc-c++ make bison autoconf gettext + elif command -v yum >/dev/null 2>&1; then + yum install -y git gcc gcc-c++ make bison autoconf gettext + else + echo "Unsupported package manager in container" + exit 1 + fi + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + bash-darwin: + name: Build Bash (macOS) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-darwin) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + package: + name: Package npm module + needs: + - metadata + - rust-binaries + - bash-linux + - bash-darwin + runs-on: ubuntu-latest + env: + PACKAGE_VERSION: ${{ needs.metadata.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install JavaScript dependencies + run: pnpm install --frozen-lockfile + + - name: Build (shell-tool-mcp) + run: pnpm --filter @openai/codex-shell-tool-mcp run build + + - name: Download build artifacts + uses: actions/download-artifact@v7 + with: + path: artifacts + + - name: Assemble staging directory + id: staging + shell: bash + run: | + set -euo pipefail + staging="${STAGING_DIR}" + mkdir -p "$staging" "$staging/vendor" + cp shell-tool-mcp/README.md "$staging/" + cp shell-tool-mcp/package.json "$staging/" + cp -R shell-tool-mcp/bin "$staging/" + + found_vendor="false" + shopt -s nullglob + for vendor_dir in artifacts/*/vendor; do + rsync -av "$vendor_dir/" "$staging/vendor/" + found_vendor="true" + done + if [[ "$found_vendor" == "false" ]]; then + echo "No vendor payloads were downloaded." + exit 1 + fi + + node - <<'NODE' + import fs from "node:fs"; + import path from "node:path"; + + const stagingDir = process.env.STAGING_DIR; + const version = process.env.PACKAGE_VERSION; + const pkgPath = path.join(stagingDir, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + pkg.version = version; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + NODE + + echo "dir=$staging" >> "$GITHUB_OUTPUT" + env: + STAGING_DIR: ${{ runner.temp }}/shell-tool-mcp + + - name: Ensure binaries are executable + run: | + set -euo pipefail + staging="${{ steps.staging.outputs.dir }}" + chmod +x \ + "$staging"/vendor/*/codex-exec-mcp-server \ + "$staging"/vendor/*/codex-execve-wrapper \ + "$staging"/vendor/*/bash/*/bash + + - name: Create npm tarball + shell: bash + run: | + set -euo pipefail + mkdir -p dist/npm + staging="${{ steps.staging.outputs.dir }}" + pack_info=$(cd "$staging" && npm pack --ignore-scripts --json --pack-destination "${GITHUB_WORKSPACE}/dist/npm") + filename=$(PACK_INFO="$pack_info" node -e 'const data = JSON.parse(process.env.PACK_INFO); console.log(data[0].filename);') + mv "dist/npm/${filename}" "dist/npm/codex-shell-tool-mcp-npm-${PACKAGE_VERSION}.tgz" + + - uses: actions/upload-artifact@v6 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm/codex-shell-tool-mcp-npm-${{ env.PACKAGE_VERSION }}.tgz + if-no-files-found: error + + publish: + name: Publish npm package + needs: + - metadata + - package + if: ${{ inputs.publish && needs.metadata.outputs.should_publish == 'true' }} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org + scope: "@openai" + + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + + - name: Download npm tarball + uses: actions/download-artifact@v7 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm + + - name: Publish to npm + if: github.repository == 'openai/codex' + env: + NPM_TAG: ${{ needs.metadata.outputs.npm_tag }} + VERSION: ${{ needs.metadata.outputs.version }} + shell: bash + run: | + set -euo pipefail + tag_args=() + if [[ -n "${NPM_TAG}" ]]; then + tag_args+=(--tag "${NPM_TAG}") + fi + npm publish "dist/npm/codex-shell-tool-mcp-npm-${VERSION}.tgz" "${tag_args[@]}" diff --git a/.github/workflows/termux-release-checkpoint.yml b/.github/workflows/termux-release-checkpoint.yml new file mode 100644 index 000000000000..0347e6f1b3d6 --- /dev/null +++ b/.github/workflows/termux-release-checkpoint.yml @@ -0,0 +1,103 @@ +name: termux-release-checkpoint + +on: + workflow_dispatch: + inputs: + source_branch: + description: "Release branch to checkpoint from, for example release/0.123.0" + required: false + type: string + default: "" + source_sha: + description: "Specific source commit SHA to checkpoint; defaults to the source branch tip" + required: false + type: string + default: "" + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + type: string + default: "wallentx/termux-target" + reviewer: + description: "GitHub username to request as reviewer" + required: false + type: string + default: "wallentx" + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-checkpoint-${{ inputs.source_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + checkpoint: + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + env: + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + REQUESTED_SOURCE_BRANCH: ${{ inputs.source_branch }} + REQUESTED_SOURCE_SHA: ${{ inputs.source_sha }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ inputs.source_branch || github.ref_name }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + source_branch="${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${source_branch}" + + - name: Create checkpoint PR + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" diff --git a/.github/workflows/termux-release-deploy.yml b/.github/workflows/termux-release-deploy.yml new file mode 100644 index 000000000000..2071611dacc4 --- /dev/null +++ b/.github/workflows/termux-release-deploy.yml @@ -0,0 +1,243 @@ +name: termux-release-deploy + +on: + push: + branches: + - "release/**" + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to deploy, for example release/0.124.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to deploy. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged release PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged release PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + default: "wallentx/termux-target" + type: string + reviewer: + description: "GitHub username to request as reviewer on the checkpoint PR" + required: false + default: "wallentx" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-deploy-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + deploy: + runs-on: ubuntu-24.04 + if: ${{ github.event_name == 'workflow_dispatch' || !startsWith(github.event.head_commit.message, 'Seed Termux release automation') }} + permissions: + actions: read + contents: write + deployments: write + issues: write + pull-requests: write + env: + REQUESTED_RELEASE_BRANCH: ${{ inputs.release_branch }} + REQUESTED_RELEASE_SHA: ${{ inputs.release_sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + release_branch="${REQUESTED_RELEASE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${release_branch}" + + - name: Resolve release ref + id: release-ref + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: deploy + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Create deployment + if: steps.metadata.outputs.deploy == 'true' + id: deployment + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + deployment_id="$( + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments" \ + -f ref="${RELEASE_SHA}" \ + -f environment="termux-release" \ + -F auto_merge=false \ + -F required_contexts[] \ + -f description="Termux release deployment for ${TERMUX_TAG}" \ + --jq '.id' + )" + echo "id=${deployment_id}" >> "$GITHUB_OUTPUT" + + - name: Mark deployment in progress + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="in_progress" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Promoting Termux release artifact and preparing checkpoint PR" \ + >/dev/null + + - name: Locate merged pull request + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" + + - name: Ensure checkpoint PR + if: steps.metadata.outputs.deploy == 'true' + id: checkpoint + env: + SOURCE_BRANCH: ${{ steps.release-ref.outputs.branch }} + SOURCE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" + + - name: Mark deployment success + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="success" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment completed for ${TERMUX_TAG}" \ + >/dev/null + + - name: Mark deployment failure + if: failure() && steps.deployment.outputs.id != '' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="failure" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment failed" \ + >/dev/null diff --git a/.github/workflows/termux-release-promote.yml b/.github/workflows/termux-release-promote.yml new file mode 100644 index 000000000000..22c277c21a70 --- /dev/null +++ b/.github/workflows/termux-release-promote.yml @@ -0,0 +1,135 @@ +name: termux-release-promote + +on: + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to promote, for example release/0.122.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to promote. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-promote-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + promote: + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: write + pull-requests: read + env: + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Resolve release ref + id: release-ref + env: + INPUT_RELEASE_BRANCH: ${{ inputs.release_branch }} + INPUT_RELEASE_SHA: ${{ inputs.release_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: promote + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Locate merged pull request + if: steps.metadata.outputs.promote == 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.promote == 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.promote == 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" diff --git a/scripts/termux-configure-git.sh b/scripts/termux-configure-git.sh new file mode 100755 index 000000000000..fae3abcbab48 --- /dev/null +++ b/scripts/termux-configure-git.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# Configure the explicit bot identity used by release automation and fetch any +# refs requested by the calling workflow. + +set -euo pipefail + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + +while (($#)); do + case "$1" in + --origin) + shift + origin_refs=() + while (($#)) && [[ "$1" != --* ]]; do + origin_refs+=("$1") + shift + done + if ((${#origin_refs[@]})); then + git fetch --prune origin "${origin_refs[@]}" + fi + ;; + --upstream-tag) + if (($# < 3)); then + echo "--upstream-tag requires ." >&2 + exit 1 + fi + upstream_repo="$2" + upstream_tag="$3" + git remote add upstream "https://github.com/${upstream_repo}.git" 2>/dev/null || true + git fetch --prune --no-tags upstream "+refs/tags/${upstream_tag}:refs/tags/${upstream_tag}" + shift 3 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done diff --git a/scripts/termux-create-checkpoint-pr.sh b/scripts/termux-create-checkpoint-pr.sh new file mode 100755 index 000000000000..f315a5b91822 --- /dev/null +++ b/scripts/termux-create-checkpoint-pr.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/termux-release-paths.sh +source "${script_dir}/termux-release-paths.sh" + +source_branch="${SOURCE_BRANCH:-${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}}" +source_sha="${SOURCE_SHA:-${REQUESTED_SOURCE_SHA:-}}" +if [[ -z "${source_sha}" ]]; then + if [[ "${GITHUB_EVENT_NAME:-}" == "push" && "${source_branch}" == "${GITHUB_REF_NAME:-}" ]]; then + source_sha="${GITHUB_SHA}" + else + source_sha="$(git rev-parse "origin/${source_branch}")" + fi +fi + +if [[ -z "${DESTINATION_BRANCH:-}" ]]; then + echo "DESTINATION_BRANCH is required." >&2 + exit 1 +fi + +release_only_checkpoint_paths() { + printf '%s\n' "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +} + +resolve_source_version_conflicts() { + local path="$1" + local resolved_path + resolved_path="$(mktemp)" + + if ! awk ' + function normalize_versions(text) { + gsub(/version = "[^"]+"/, "version = \"\"", text) + return text + } + + BEGIN { + in_block = 0 + side = "" + ours = "" + theirs = "" + blocks = 0 + } + + /^<<<<<<< / { + if (in_block) { + exit 1 + } + in_block = 1 + side = "ours" + ours = "" + theirs = "" + blocks++ + next + } + + /^=======$/ && in_block { + side = "theirs" + next + } + + /^>>>>>>> / && in_block { + if (normalize_versions(ours) != normalize_versions(theirs)) { + exit 1 + } + printf "%s", theirs + in_block = 0 + side = "" + next + } + + { + if (!in_block) { + print + } else if (side == "ours") { + ours = ours $0 ORS + } else if (side == "theirs") { + theirs = theirs $0 ORS + } else { + exit 1 + } + } + + END { + if (in_block || blocks == 0) { + exit 1 + } + } + ' "${path}" > "${resolved_path}"; then + rm -f "${resolved_path}" + return 1 + fi + + cp "${resolved_path}" "${path}" + rm -f "${resolved_path}" +} + +short_sha="${source_sha:0:12}" +source_slug="${source_branch//\//_}" +dest_slug="${DESTINATION_BRANCH//\//_}" +checkpoint_branch="checkpoint/${dest_slug}_from_${source_slug}_${short_sha}" +pr_title="checkpoint: into ${DESTINATION_BRANCH} from ${source_branch} @ ${short_sha}" +merge_conflicted=false +conflict_summary="" + +existing_pr="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --head "${checkpoint_branch}" \ + --state all \ + --json number,state,mergedAt,url \ + --jq '[.[] | select(.state == "OPEN" or .mergedAt != null)] | .[0] // empty' +)" +if [[ -n "${existing_pr}" ]]; then + existing_url="$(jq -r '.url' <<< "${existing_pr}")" + existing_state="$(jq -r '.state' <<< "${existing_pr}")" + echo "Checkpoint PR already exists for ${checkpoint_branch}: ${existing_url} (${existing_state})." + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${existing_url}" >> "${GITHUB_OUTPUT}" + fi + exit 0 +fi + +git checkout -B "${checkpoint_branch}" "origin/${DESTINATION_BRANCH}" + +if ! git merge --no-ff --no-edit "${source_sha}"; then + mapfile -t conflicted_paths < <(git diff --name-only --diff-filter=U) + for conflicted_path in "${conflicted_paths[@]}"; do + if termux_is_checkpoint_release_only_path "${conflicted_path}"; then + echo "Auto-resolving release-only checkpoint conflict in ${conflicted_path} by keeping ${DESTINATION_BRANCH}." + if git cat-file -e "HEAD:${conflicted_path}" 2>/dev/null; then + git checkout --ours -- "${conflicted_path}" + git add "${conflicted_path}" + else + git rm -f --ignore-unmatch "${conflicted_path}" + fi + fi + done + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if ((${#remaining_conflicts[@]})); then + cargo_version_conflicts=true + for remaining_conflict in "${remaining_conflicts[@]}"; do + case "${remaining_conflict}" in + codex-rs/Cargo.toml|codex-rs/Cargo.lock) + ;; + *) + cargo_version_conflicts=false + ;; + esac + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + for remaining_conflict in "${remaining_conflicts[@]}"; do + if ! resolve_source_version_conflicts "${remaining_conflict}"; then + cargo_version_conflicts=false + break + fi + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + echo "Auto-resolving recurring Cargo version checkpoint conflicts by keeping ${source_branch} versions." + git add -- "${remaining_conflicts[@]}" + fi + fi + fi + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if [[ "${#remaining_conflicts[@]}" -eq 0 ]]; then + git commit --no-edit + else + merge_conflicted=true + conflict_summary="$( + printf '%s\n' "${remaining_conflicts[@]}" | awk '{ print "- `" $0 "`" }' + )" + echo "Automatic checkpoint merge failed; creating a manual-resolution PR instead." >&2 + if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + git merge --abort + fi + git checkout -B "${checkpoint_branch}" "${source_sha}" + fi +fi + +if git cat-file -e "origin/${DESTINATION_BRANCH}:.github" 2>/dev/null; then + git checkout "origin/${DESTINATION_BRANCH}" -- .github + mapfile -t added_github_paths < <( + git diff --name-only --diff-filter=A "origin/${DESTINATION_BRANCH}" -- .github + ) + if ((${#added_github_paths[@]})); then + git rm -f --ignore-unmatch -- "${added_github_paths[@]}" + fi +else + git rm -r --ignore-unmatch .github +fi + +while IFS= read -r release_only_path; do + if git cat-file -e "origin/${DESTINATION_BRANCH}:${release_only_path}" 2>/dev/null; then + git checkout "origin/${DESTINATION_BRANCH}" -- "${release_only_path}" + else + git rm -f --ignore-unmatch -- "${release_only_path}" + fi +done < <(release_only_checkpoint_paths) + +if ! git diff --quiet || ! git diff --cached --quiet; then + if [[ -e .github || -L .github ]] || git ls-files --error-unmatch -- .github >/dev/null 2>&1; then + git add -A .github + fi + while IFS= read -r release_only_path; do + if [[ -e "${release_only_path}" || -L "${release_only_path}" ]] || git ls-files --error-unmatch -- "${release_only_path}" >/dev/null 2>&1; then + git add -A -- "${release_only_path}" + fi + done < <(release_only_checkpoint_paths) + if [[ "${merge_conflicted}" == "true" ]]; then + git commit -m "checkpoint: prepare ${source_branch} for ${DESTINATION_BRANCH}" + else + git commit --amend --no-edit + fi +fi + +if git diff --quiet "origin/${DESTINATION_BRANCH}" HEAD; then + echo "Checkpoint merge produced no destination changes after release-only files were restored." + exit 0 +fi + +git push --force-with-lease origin "${checkpoint_branch}" + +remaining="$( + git log --first-parent --pretty=format:%H "${source_sha}..origin/${source_branch}" | wc -w +)" + +body_path="${RUNNER_TEMP}/termux-checkpoint-pr.md" +{ + echo "## Termux release checkpoint" + echo + echo "- Source branch: \`${source_branch}\`" + echo "- Source hash: \`${source_sha}\`" + echo "- Destination branch: \`${DESTINATION_BRANCH}\`" + echo "- Remaining first-parent commits on source: ${remaining}" + echo + echo "This PR carries release-train conflict fixes and follow-up changes back into the reusable Termux patch branch." + if [[ "${merge_conflicted}" == "true" ]]; then + echo + echo "## Merge conflicts" + echo + echo "GitHub Actions could not create the checkpoint merge commit automatically, so this PR was created from the source branch state for manual conflict resolution." + echo + echo "Conflicted paths from the failed merge attempt:" + if [[ -n "${conflict_summary}" ]]; then + printf '%s\n' "${conflict_summary}" + else + echo "- Conflict details unavailable" + fi + fi + echo + echo "Release-only workflow files and metadata under \`.github\` were restored to the destination branch versions before opening this PR." +} > "${body_path}" + +pr_url="$( + gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${DESTINATION_BRANCH}" \ + --head "${checkpoint_branch}" \ + --title "${pr_title}" \ + --body-file "${body_path}" +)" +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-reviewer "${REVIEWER}" || true +gh label create checkpoint --repo "${GITHUB_REPOSITORY}" --color c5def5 --description "Checkpoint merge" --force +gh label create termux-release --repo "${GITHUB_REPOSITORY}" --color 0e8a16 --description "Termux release automation" --force +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-label "checkpoint" --add-label "termux-release" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${pr_url}" >> "${GITHUB_OUTPUT}" +fi diff --git a/scripts/termux-create-or-update-mirrored-release.sh b/scripts/termux-create-or-update-mirrored-release.sh new file mode 100755 index 000000000000..5787e894582b --- /dev/null +++ b/scripts/termux-create-or-update-mirrored-release.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +# Create a mirrored Termux release or repair an existing release that is missing +# the Android tarball. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" +: "${RUNNER_TEMP:?RUNNER_TEMP is required}" +: "${UPSTREAM_TAG:?UPSTREAM_TAG is required}" +: "${UPSTREAM_REPO:?UPSTREAM_REPO is required}" +: "${UPSTREAM_NAME?UPSTREAM_NAME is required}" +: "${TERMUX_TAG:?TERMUX_TAG is required}" +: "${UPSTREAM_PRERELEASE:?UPSTREAM_PRERELEASE is required}" +: "${UPSTREAM_HTML_URL?UPSTREAM_HTML_URL is required}" +: "${RELEASE_TRAIN?RELEASE_TRAIN is required}" +: "${RELEASE_EXISTS:?RELEASE_EXISTS is required}" +: "${PR_NUMBER:?PR_NUMBER is required}" +: "${HEAD_SHA:?HEAD_SHA is required}" +: "${RELEASE_SHA:?RELEASE_SHA is required}" + +promoted_dir="${PROMOTED_DIR:-promoted}" +asset_path="${promoted_dir}/codex-aarch64-linux-android.tar.gz" + +body_path="${RUNNER_TEMP}/release-body.md" +upstream_body_path="${RUNNER_TEMP}/upstream-release-body.md" +upstream_body_without_changelog_path="${RUNNER_TEMP}/upstream-release-body-without-changelog.md" +if gh release view "${UPSTREAM_TAG}" \ + --repo "${UPSTREAM_REPO}" \ + --json body \ + --jq '.body // ""' > "${upstream_body_path}"; then + awk ' + function heading_level(line, text) { + if (match(line, /^(#{1,6})[[:space:]]+(.+)$/, parts)) { + text = tolower(parts[2]) + sub(/[[:space:]]+#+[[:space:]]*$/, "", text) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", text) + if (text == "changelog" || text == "change log") { + return length(parts[1]) + } + } + return 0 + } + + { + if (!skip) { + level = heading_level($0) + if (level > 0) { + skip = 1 + skip_level = level + next + } + print + next + } + + if (match($0, /^(#{1,6})[[:space:]]+/, parts) && length(parts[1]) <= skip_level) { + skip = 0 + print + } + } + ' "${upstream_body_path}" > "${upstream_body_without_changelog_path}" +else + echo "::warning title=Upstream release notes unavailable::Could not read ${UPSTREAM_REPO} release ${UPSTREAM_TAG}." + : > "${upstream_body_without_changelog_path}" +fi + +{ + echo "Termux Android build for ${UPSTREAM_TAG}." + echo + echo "- Upstream release: ${UPSTREAM_HTML_URL}" + echo "- Release train: \`${RELEASE_TRAIN}\`" + echo "- Promoted PR: #${PR_NUMBER}" + echo "- Promoted PR head SHA: \`${HEAD_SHA}\`" +} > "${body_path}" +if [[ -s "${upstream_body_without_changelog_path}" ]]; then + { + echo + echo "## Upstream release notes" + echo + cat "${upstream_body_without_changelog_path}" + } >> "${body_path}" +fi + +release_title="${UPSTREAM_NAME}" +if [[ -z "${release_title}" || "${release_title}" == "null" ]]; then + release_title="${TERMUX_TAG}" +fi + +if [[ "${RELEASE_EXISTS}" == "true" ]]; then + gh release upload \ + "${TERMUX_TAG}" \ + "${asset_path}#codex-termux" \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber + exit 0 +fi + +release_args=( + gh release create "${TERMUX_TAG}" + --repo "${GITHUB_REPOSITORY}" + --target "${RELEASE_SHA}" + --title "${release_title}" + --notes-file "${body_path}" +) +if [[ "${UPSTREAM_PRERELEASE}" == "true" ]]; then + release_args+=(--prerelease) +fi +release_args+=("${asset_path}#codex-termux") +"${release_args[@]}" diff --git a/scripts/termux-download-release-artifact.sh b/scripts/termux-download-release-artifact.sh new file mode 100755 index 000000000000..2acdba9025a0 --- /dev/null +++ b/scripts/termux-download-release-artifact.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -z "${HEAD_SHA:-}" ]]; then + echo "HEAD_SHA is required." >&2 + exit 1 +fi + +if [[ -z "${PR_ARTIFACT_NAME:-}" ]]; then + echo "PR_ARTIFACT_NAME is required." >&2 + exit 1 +fi + +promoted_dir="${PROMOTED_DIR:-promoted}" + +find_run_id() { + local event="$1" + gh run list \ + --repo "${GITHUB_REPOSITORY}" \ + --workflow rust-release.yml \ + --event "${event}" \ + --status success \ + --commit "${HEAD_SHA}" \ + --limit 1 \ + --json databaseId \ + --jq '.[0].databaseId // empty' +} + +find_artifact_run_id() { + local artifact_name="$1" + + gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${artifact_name}" \ + | jq -rs \ + --arg artifact_name "${artifact_name}" \ + --arg head_sha "${HEAD_SHA}" \ + ' + [ + .[].artifacts[] + | select(.name == $artifact_name) + | select(.expired == false) + | select(.workflow_run.head_sha == $head_sha) + ] + | sort_by(.created_at) + | reverse + | .[0].workflow_run.id // empty + ' +} + +artifact_name="${PR_ARTIFACT_NAME}" +run_id="$(find_artifact_run_id "${artifact_name}")" +mkdir -p "${promoted_dir}" +if [[ -n "${run_id}" ]] && gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}"; then + : +else + artifact_name="aarch64-linux-android" + run_id="$(find_run_id workflow_dispatch)" + if [[ -z "${run_id}" ]]; then + echo "No successful rust-release run found for ${HEAD_SHA}" >&2 + exit 1 + fi + gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}" +fi + +ls -la "${promoted_dir}" +if [[ -f "${promoted_dir}/SHA256SUMS" ]]; then + (cd "${promoted_dir}" && sha256sum -c SHA256SUMS) +fi +if [[ ! -f "${promoted_dir}/codex-aarch64-linux-android.tar.gz" ]]; then + echo "Expected ${promoted_dir}/codex-aarch64-linux-android.tar.gz in the downloaded artifact." >&2 + exit 1 +fi diff --git a/scripts/termux-find-release-pr.sh b/scripts/termux-find-release-pr.sh new file mode 100755 index 000000000000..3e35fa42eabe --- /dev/null +++ b/scripts/termux-find-release-pr.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -n "${INPUT_PR_NUMBER:-}" || -n "${INPUT_PR_HEAD_SHA:-}" ]]; then + if [[ -z "${INPUT_PR_NUMBER:-}" || -z "${INPUT_PR_HEAD_SHA:-}" ]]; then + echo "workflow_dispatch inputs pr_number and pr_head_sha must be provided together." >&2 + exit 1 + fi + + pr_number="${INPUT_PR_NUMBER}" + head_sha="${INPUT_PR_HEAD_SHA}" + head_ref="" +else + if [[ -z "${RELEASE_BRANCH:-}" || -z "${RELEASE_SHA:-}" ]]; then + echo "RELEASE_BRANCH and RELEASE_SHA are required." >&2 + exit 1 + fi + + pr_json="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${RELEASE_BRANCH}" \ + --state merged \ + --limit 100 \ + --json number,headRefOid,headRefName,mergeCommit \ + --jq "[.[] | select(.mergeCommit.oid == \"${RELEASE_SHA}\")] | sort_by(.number) | reverse | .[0] // empty" + )" + if [[ -z "${pr_json}" ]]; then + echo "Unable to find merged PR for ${RELEASE_SHA} into ${RELEASE_BRANCH}" >&2 + exit 1 + fi + + pr_number="$(jq -r '.number' <<< "${pr_json}")" + head_sha="$(jq -r '.headRefOid // .head.sha' <<< "${pr_json}")" + head_ref="$(jq -r '.headRefName // .head.ref' <<< "${pr_json}")" +fi + +artifact_name="termux-android-pr-${pr_number}-${head_sha}" +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "number=${pr_number}" + echo "head_sha=${head_sha}" + echo "head_ref=${head_ref}" + echo "artifact_name=${artifact_name}" + } >> "${GITHUB_OUTPUT}" +else + printf 'number=%s\nhead_sha=%s\nhead_ref=%s\nartifact_name=%s\n' \ + "${pr_number}" \ + "${head_sha}" \ + "${head_ref}" \ + "${artifact_name}" +fi diff --git a/scripts/termux-read-release-metadata.sh b/scripts/termux-read-release-metadata.sh new file mode 100755 index 000000000000..519a0635a368 --- /dev/null +++ b/scripts/termux-read-release-metadata.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# Parse .github/termux-release.json and emit workflow outputs for deploy or +# promote jobs. TERMUX_RELEASE_ACTION must be "deploy" or "promote". + +set -euo pipefail + +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +metadata=".github/termux-release.json" +action="${TERMUX_RELEASE_ACTION:-deploy}" +case "${action}" in + deploy) + missing_context="deployment" + ;; + promote) + missing_context="promotion" + ;; + *) + echo "TERMUX_RELEASE_ACTION must be deploy or promote." >&2 + exit 1 + ;; +esac + +if [[ ! -f "${metadata}" ]]; then + echo "No ${metadata}; this push is not a Termux release ${missing_context}." + echo "${action}=false" >> "${GITHUB_OUTPUT}" + exit 0 +fi + +upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" +upstream_name="$(jq -r '.upstream_name // .upstream_tag // empty' "${metadata}")" +termux_tag="$(jq -r '.termux_tag // empty' "${metadata}")" +upstream_version="${upstream_tag#rust-v}" +upstream_version="${upstream_version%-termux}" +upstream_prerelease=false +if [[ "${upstream_version}" == *-* ]]; then + upstream_prerelease=true +fi +upstream_html_url="$(jq -r '.upstream_html_url // ""' "${metadata}")" +upstream_repo="$(jq -r '.upstream_repo // "openai/codex"' "${metadata}")" +release_train="$(jq -r '.release_train // ""' "${metadata}")" +if [[ -z "${upstream_tag}" || -z "${termux_tag}" ]]; then + echo "Missing upstream_tag or termux_tag in ${metadata}" >&2 + exit 1 +fi + +release_state="$(TERMUX_TAG="${termux_tag}" "${script_dir}/termux-release-asset-state.sh")" +release_exists="$(awk -F= '$1 == "release_exists" { print $2 }' <<< "${release_state}")" +asset_exists="$(awk -F= '$1 == "asset_exists" { print $2 }' <<< "${release_state}")" + +if [[ "${action}" == "promote" ]]; then + if [[ "${asset_exists}" == "true" ]]; then + echo "${termux_tag} already exists with codex-aarch64-linux-android.tar.gz; skipping promotion." + echo "promote=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + if [[ "${release_exists}" == "true" ]]; then + echo "${termux_tag} exists but is missing codex-aarch64-linux-android.tar.gz; repairing promotion." + fi +fi + +{ + echo "${action}=true" + echo "upstream_tag=${upstream_tag}" + echo "upstream_name=${upstream_name}" + echo "termux_tag=${termux_tag}" + echo "upstream_prerelease=${upstream_prerelease}" + echo "upstream_html_url=${upstream_html_url}" + echo "upstream_repo=${upstream_repo}" + echo "release_train=${release_train}" + echo "release_exists=${release_exists}" + echo "asset_exists=${asset_exists}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-release-asset-state.sh b/scripts/termux-release-asset-state.sh new file mode 100755 index 000000000000..8694480b6a36 --- /dev/null +++ b/scripts/termux-release-asset-state.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +termux_tag="${1:-${TERMUX_TAG:-}}" +release_repo="${RELEASE_REPO:-${GITHUB_REPOSITORY:-}}" +asset_name="${ASSET_NAME:-codex-aarch64-linux-android.tar.gz}" + +if [[ -z "${termux_tag}" ]]; then + echo "TERMUX_TAG or tag argument is required." >&2 + exit 1 +fi + +if [[ -z "${release_repo}" ]]; then + echo "GITHUB_REPOSITORY or RELEASE_REPO is required." >&2 + exit 1 +fi + +release_exists=false +asset_exists=false + +if gh release view "${termux_tag}" --repo "${release_repo}" >/dev/null 2>&1; then + release_exists=true + release_asset_exists="$( + gh release view "${termux_tag}" \ + --repo "${release_repo}" \ + --json assets \ + | jq -r --arg asset_name "${asset_name}" \ + '.assets | map(.name) | any(. == $asset_name)' + )" + if [[ "${release_asset_exists}" == "true" ]]; then + asset_exists=true + fi +fi + +printf 'release_exists=%s\n' "${release_exists}" +printf 'asset_exists=%s\n' "${asset_exists}" diff --git a/scripts/termux-release-paths.sh b/scripts/termux-release-paths.sh new file mode 100755 index 000000000000..6990f323ec44 --- /dev/null +++ b/scripts/termux-release-paths.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +# Sourceable path lists for files owned by the Termux release automation. + +set -euo pipefail + +readonly -a TERMUX_RELEASE_WORKFLOW_PATHS=( + .github/workflows/rust-release.yml + .github/workflows/shell-tool-mcp.yml + .github/workflows/termux-release-checkpoint.yml + .github/workflows/termux-release-deploy.yml + .github/workflows/termux-release-promote.yml +) + +readonly -a TERMUX_RELEASE_BRANCH_SCRIPT_PATHS=( + scripts/termux-configure-git.sh + scripts/termux-create-checkpoint-pr.sh + scripts/termux-create-or-update-mirrored-release.sh + scripts/termux-download-release-artifact.sh + scripts/termux-find-release-pr.sh + scripts/termux-read-release-metadata.sh + scripts/termux-release-asset-state.sh + scripts/termux-release-paths.sh + scripts/termux-resolve-release-ref.sh + scripts/termux-validate-gh-env.sh +) + +readonly -a TERMUX_RELEASE_AUTOMATION_PATHS=( + "${TERMUX_RELEASE_WORKFLOW_PATHS[@]}" + "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +) + +readonly -a TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS=( + "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" + .github/termux-release.json +) + +termux_path_in_list() { + local candidate="$1" + shift + local listed_path + + for listed_path in "$@"; do + [[ "${candidate}" != "${listed_path}" ]] || return 0 + done + return 1 +} + +termux_is_release_automation_path() { + termux_path_in_list "$1" "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" +} + +termux_is_checkpoint_release_only_path() { + termux_path_in_list "$1" "${TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS[@]}" +} diff --git a/scripts/termux-resolve-release-ref.sh b/scripts/termux-resolve-release-ref.sh new file mode 100755 index 000000000000..194773d45bed --- /dev/null +++ b/scripts/termux-resolve-release-ref.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +# Resolve the release branch and SHA for deploy/promote workflows. + +set -euo pipefail + +: "${GITHUB_EVENT_NAME:?GITHUB_EVENT_NAME is required}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" + +input_release_branch="${INPUT_RELEASE_BRANCH:-${REQUESTED_RELEASE_BRANCH:-}}" +input_release_sha="${INPUT_RELEASE_SHA:-${REQUESTED_RELEASE_SHA:-}}" + +if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + if [[ -z "${input_release_branch}" ]]; then + echo "release_branch is required for workflow_dispatch." >&2 + exit 1 + fi + + release_branch="${input_release_branch}" + if [[ -n "${input_release_sha}" ]]; then + git checkout --detach "${input_release_sha}" + release_sha="${input_release_sha}" + else + release_sha="$(git rev-parse HEAD)" + fi +else + : "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" + : "${GITHUB_SHA:?GITHUB_SHA is required}" + release_branch="${GITHUB_REF_NAME}" + release_sha="${GITHUB_SHA}" +fi + +{ + echo "branch=${release_branch}" + echo "sha=${release_sha}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-validate-gh-env.sh b/scripts/termux-validate-gh-env.sh new file mode 100755 index 000000000000..02581fdebceb --- /dev/null +++ b/scripts/termux-validate-gh-env.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Lightweight post-toolbox check for jobs that rely on authenticated gh calls. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +command -v gh +gh auth status --hostname github.com + +printf 'GITHUB_REPOSITORY=%s\n' "${GITHUB_REPOSITORY}" +printf 'REPO=%s\n' "${REPO:-}" +printf 'GH_REPO_URL=%s\n' "${GH_REPO_URL:-}" +printf 'GH_WORKFLOW_URL=%s\n' "${GH_WORKFLOW_URL:-}" From d1cf6fb4510c37d59295d81bd4bf7c6bc47ac4bc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 6 Jun 2026 04:42:42 +0000 Subject: [PATCH 59/59] Prepare Termux rust-v0.138.0-alpha.6 --- .github/termux-release.json | 15 + .github/workflows/rust-release.yml | 1840 +++++++---------- .github/workflows/shell-tool-mcp.yml | 461 +++++ .../workflows/termux-release-checkpoint.yml | 103 + .github/workflows/termux-release-deploy.yml | 243 +++ .github/workflows/termux-release-promote.yml | 135 ++ codex-rs/Cargo.toml | 2 +- scripts/termux-configure-git.sh | 40 + scripts/termux-create-checkpoint-pr.sh | 275 +++ ...ermux-create-or-update-mirrored-release.sh | 111 + scripts/termux-download-release-artifact.sh | 78 + scripts/termux-find-release-pr.sh | 53 + scripts/termux-read-release-metadata.sh | 77 + scripts/termux-release-asset-state.sh | 37 + scripts/termux-release-paths.sh | 55 + scripts/termux-resolve-release-ref.sh | 36 + scripts/termux-validate-gh-env.sh | 16 + 17 files changed, 2498 insertions(+), 1079 deletions(-) create mode 100644 .github/termux-release.json create mode 100644 .github/workflows/shell-tool-mcp.yml create mode 100644 .github/workflows/termux-release-checkpoint.yml create mode 100644 .github/workflows/termux-release-deploy.yml create mode 100644 .github/workflows/termux-release-promote.yml create mode 100755 scripts/termux-configure-git.sh create mode 100755 scripts/termux-create-checkpoint-pr.sh create mode 100755 scripts/termux-create-or-update-mirrored-release.sh create mode 100755 scripts/termux-download-release-artifact.sh create mode 100755 scripts/termux-find-release-pr.sh create mode 100755 scripts/termux-read-release-metadata.sh create mode 100755 scripts/termux-release-asset-state.sh create mode 100755 scripts/termux-release-paths.sh create mode 100755 scripts/termux-resolve-release-ref.sh create mode 100755 scripts/termux-validate-gh-env.sh diff --git a/.github/termux-release.json b/.github/termux-release.json new file mode 100644 index 000000000000..643f74cbe9a8 --- /dev/null +++ b/.github/termux-release.json @@ -0,0 +1,15 @@ +{ + "upstream_repo": "openai/codex", + "upstream_tag": "rust-v0.138.0-alpha.6", + "upstream_name": "0.138.0-alpha.6", + "upstream_html_url": "https://github.com/openai/codex/releases/tag/rust-v0.138.0-alpha.6", + "upstream_target": "main", + "upstream_release_id": "335282492", + "upstream_prerelease": true, + "release_train": "0.138.0", + "release_branch": "release/0.138.0", + "work_branch": "upstream/rust-v0.138.0", + "patch_branch": "wallentx/termux-target", + "patch_source_sha": "4fc183da636ce8fa4b37ed4bd0b170cd69b3e02c", + "termux_tag": "rust-v0.138.0-alpha.6-termux" +} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c55337ecfe6e..78162e867146 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -4,119 +4,108 @@ # git tag -a rust-v0.1.0 -m "Release 0.1.0" # git push origin rust-v0.1.0 # ``` -# -# To use external macOS signing, manually dispatch `release_mode=build_unsigned`, -# sign the unsigned macOS artifacts in a secure enclave, upload the signed handoff -# archive as a GitHub Release asset, then manually dispatch -# `release_mode=promote_signed` with `unsigned_run_id` and `signed_macos_asset`. -# The signed handoff archive should contain target or artifact directories such -# as `aarch64-apple-darwin/` with signed binaries. name: rust-release on: - push: - tags: - - "rust-v*.*.*" + pull_request: + branches: + - "release/**" workflow_dispatch: inputs: - release_mode: - description: "build_unsigned creates unsigned macOS handoff artifacts; promote_signed finishes a release from signed macOS handoff artifacts." + source_ref: + description: "Branch, tag, or commit SHA to build (code only; keeps .github from dispatched ref)" required: false + type: string + default: "" + build_target: + description: "Target to build" + required: true type: choice - default: build_unsigned + default: all options: - - build_unsigned - - promote_signed - sign_macos: - description: "Deprecated compatibility input; use release_mode instead." - required: false - type: boolean - default: false - unsigned_run_id: - description: "For promote_signed: workflow run id from the build_unsigned run." - required: false - type: string - signed_macos_asset: - description: "For promote_signed: exact GitHub Release asset name containing signed macOS handoff artifacts." - required: false - type: string - signed_macos_sha256: - description: "For promote_signed: optional SHA-256 of signed_macos_asset." - required: false - type: string - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: true + - all + - aarch64-apple-darwin + - x86_64-apple-darwin + - x86_64-unknown-linux-musl + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-musl + - aarch64-unknown-linux-gnu + - aarch64-linux-android + - x86_64-pc-windows-msvc + - aarch64-pc-windows-msvc + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read jobs: + cancel-superseded-pr-runs: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + actions: write + contents: read + steps: + - name: Cancel older runs for this PR branch + env: + GH_TOKEN: ${{ github.token }} + HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + CURRENT_RUN_ID: ${{ github.run_id }} + shell: bash + run: | + set -euo pipefail + + echo "Cancelling older ${GITHUB_WORKFLOW} runs for ${HEAD_BRANCH}" + gh run list \ + --repo "${GITHUB_REPOSITORY}" \ + --workflow "${GITHUB_WORKFLOW}" \ + --limit 100 \ + --json databaseId,event,headBranch,status,url \ + --jq ' + .[] + | select(.event == "pull_request") + | select(.headBranch == env.HEAD_BRANCH) + | select(.databaseId < (env.CURRENT_RUN_ID | tonumber)) + | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "requested" or .status == "pending") + | "\(.databaseId) \(.url)" + ' \ + | while read -r run_id run_url; do + [[ -n "${run_id}" ]] || continue + echo "Cancelling superseded run ${run_id}: ${run_url}" + gh run cancel "${run_id}" --repo "${GITHUB_REPOSITORY}" || true + done + tag-check: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - uses: dtolnay/rust-toolchain@1.92 - name: Validate tag matches Cargo.toml version shell: bash - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - REQUESTED_SIGN_MACOS: ${{ inputs.sign_macos }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} run: | set -euo pipefail echo "::group::Tag validation" - case "${RELEASE_MODE}" in - signed) - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - echo "❌ Manual rust-release runs must use release_mode=build_unsigned or release_mode=promote_signed" - exit 1 - fi - ;; - build_unsigned) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=build_unsigned is only valid for manual runs" - exit 1 - fi - ;; - promote_signed) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=promote_signed is only valid for manual runs" - exit 1 - fi - if [[ ! "${UNSIGNED_RUN_ID}" =~ ^[0-9]+$ ]]; then - echo "❌ release_mode=promote_signed requires unsigned_run_id to be a workflow run id" - exit 1 - fi - if [[ -z "${SIGNED_MACOS_ASSET}" ]]; then - echo "❌ release_mode=promote_signed requires signed_macos_asset" - exit 1 - fi - if [[ "${SIGNED_MACOS_ASSET}" == */* || "${SIGNED_MACOS_ASSET}" == *"*"* || "${SIGNED_MACOS_ASSET}" == *"?"* || "${SIGNED_MACOS_ASSET}" == *"["* ]]; then - echo "❌ signed_macos_asset must be an exact release asset name, not a path or glob" - exit 1 - fi - if [[ "${UNSIGNED_RUN_ID}" == "${GITHUB_RUN_ID}" ]]; then - echo "❌ unsigned_run_id must refer to the earlier build_unsigned run, not this run" - exit 1 - fi - ;; - *) - echo "❌ Unknown release_mode '${RELEASE_MODE}'" - exit 1 - ;; - esac - - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${REQUESTED_SIGN_MACOS}" == "true" ]]; then - echo "::warning title=Deprecated sign_macos input ignored::Use release_mode=build_unsigned or release_mode=promote_signed instead." - fi - # 1. Must be a tag and match the regex [[ "${GITHUB_REF_TYPE}" == "tag" ]] \ || { echo "❌ Not a tag push"; exit 1; } - [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \ + [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?(-termux)?$ ]] \ || { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; } # 2. Extract versions @@ -131,115 +120,200 @@ jobs: echo "✅ Tag and Cargo.toml agree (${tag_ver})" echo "::endgroup::" - build: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} + select-build-matrix: + if: always() && (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') needs: tag-check - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - # Release builds can take a long time, so leave some headroom to avoid - # having to restart the full workflow due to a timeout. - timeout-minutes: 90 - permissions: - contents: read - id-token: write + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.select.outputs.matrix }} + build_version: ${{ steps.select.outputs.build_version }} + steps: + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - id: select + shell: bash + env: + REPO_OWNER: ${{ github.repository_owner }} + EVENT_NAME: ${{ github.event_name }} + BUILD_TARGET: ${{ inputs.build_target }} + run: | + set -euo pipefail + + matrix='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04","target":"aarch64-linux-android"}, + {"runner":"windows-latest","target":"x86_64-pc-windows-msvc"}, + {"runner":"windows-11-arm","target":"aarch64-pc-windows-msvc"} + ]' + + if [[ "${REPO_OWNER}" != "openai" ]]; then + matrix="$(jq -c '[.[] | select(.runner | startswith("macos") | not)]' <<< "${matrix}")" + fi + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + matrix="$(jq -c '[.[] | select(.target == "aarch64-linux-android")]' <<< "${matrix}")" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" && -n "${BUILD_TARGET}" && "${BUILD_TARGET}" != "all" ]]; then + matrix="$(jq -c --arg build_target "${BUILD_TARGET}" '[.[] | select(.target == $build_target)]' <<< "${matrix}")" + fi + + if [[ "$(jq 'length' <<< "${matrix}")" -eq 0 ]]; then + echo "No build targets selected after applying owner/dispatch filters." >&2 + exit 1 + fi + + echo "matrix={\"include\":${matrix}}" >> "$GITHUB_OUTPUT" + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + metadata=".github/termux-release.json" + if [[ ! -f "${metadata}" ]]; then + echo "${metadata} is required for release train PR builds" >&2 + exit 1 + fi + upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" + if [[ -z "${upstream_tag}" || "${upstream_tag}" != rust-v* ]]; then + echo "Unable to determine upstream rust tag from ${metadata}" >&2 + exit 1 + fi + build_version="${upstream_tag#rust-v}" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then + latest_release_tag="$( + GH_TOKEN="${{ github.token }}" gh api repos/openai/codex/releases/latest --jq '.tag_name' + )" + + if [[ -z "${latest_release_tag}" || "${latest_release_tag}" != rust-v* ]]; then + echo "Unable to determine latest stable release tag from openai/codex" >&2 + exit 1 + fi + + latest_release_version="${latest_release_tag#rust-v}" + build_version="${latest_release_version}-dev" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + fi + + build: + if: >- + always() && + (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') && + (github.event_name != 'pull_request' || needs.cancel-superseded-pr-runs.result == 'success') && + needs.select-build-matrix.result == 'success' + needs: + - cancel-superseded-pr-runs + - tag-check + - select-build-matrix + name: Build - ${{ matrix.runner }} - ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + permissions: write-all + timeout-minutes: 120 defaults: run: working-directory: codex-rs env: - # 2026-03-04: temporarily change releases to use thin LTO because - # Ubuntu ARM is timing out at 60 minutes. - CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' }} + CODEX_BWRAP_ENABLE_FFI: ${{ contains(matrix.target, 'unknown-linux') && '1' || '0' }} + CODEX_BUILD_VERSION: ${{ needs.select-build-matrix.outputs.build_version }} + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" strategy: fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - # Release artifacts intentionally ship MUSL-linked Linux binaries. - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - bundle: primary - artifact_name: x86_64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - bundle: app-server - artifact_name: x86_64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - bundle: primary - artifact_name: aarch64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - bundle: app-server - artifact_name: aarch64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" + matrix: ${{ fromJson(needs.select-build-matrix.outputs.matrix) }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + with: + verbose: true + - name: Checkout selected source ref + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + uses: actions/checkout@v6 with: - persist-credentials: false - - name: Print runner specs (Linux) - if: ${{ runner.os == 'Linux' }} + ref: ${{ inputs.source_ref }} + path: __source__ + fetch-depth: 1 + - name: Overlay selected source ref (excluding .github) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + shell: bash + working-directory: ${{ github.workspace }} + run: | + set -euo pipefail + echo "Using source ref: ${{ inputs.source_ref }}" + + # Keep workflow/actions from the dispatch ref while replacing all other files. + find . -mindepth 1 -maxdepth 1 \ + ! -name .git \ + ! -name .github \ + ! -name __source__ \ + -exec rm -rf -- {} + + tar --exclude='.github' -C __source__ -cf - . | tar -xf - + rm -rf __source__ + + - name: Apply selected build version + if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} shell: bash + working-directory: ${{ github.workspace }} run: | set -euo pipefail - cpu_model="$(lscpu | awk -F: '/Model name/ {gsub(/^[ \t]+/, "", $2); print $2; exit}')" - total_ram="$(awk '/MemTotal/ {printf "%.1f GiB\n", $2 / 1024 / 1024}' /proc/meminfo)" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(uname -a)" - echo "CPU model: ${cpu_model}" - echo "Logical CPUs: $(nproc)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . - - name: Print runner specs (macOS) - if: ${{ runner.os == 'macOS' }} + if [[ -z "${CODEX_BUILD_VERSION}" ]]; then + echo "CODEX_BUILD_VERSION is empty for workflow_dispatch" >&2 + exit 1 + fi + echo "Using build version: ${CODEX_BUILD_VERSION}" + BUILD_VERSION="${CODEX_BUILD_VERSION}" perl -0777 -i -pe \ + 's/(\[workspace\.package\][^\[]*?version = ")([^"]+)(")/${1}$ENV{BUILD_VERSION}$3/s' \ + codex-rs/Cargo.toml + + grep -n "^\[workspace.package\]\|^version = " codex-rs/Cargo.toml | head -n 4 + + - name: Select Android NDK + if: ${{ matrix.target == 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail - total_ram="$(sysctl -n hw.memsize | awk '{printf "%.1f GiB\n", $1 / 1024 / 1024 / 1024}')" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(sw_vers -productName) $(sw_vers -productVersion)" - echo "Hardware model: $(sysctl -n hw.model)" - echo "CPU architecture: $(uname -m)" - echo "Logical CPUs: $(sysctl -n hw.logicalcpu)" - echo "Physical CPUs: $(sysctl -n hw.physicalcpu)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . + + fallback_version="29.0.13113456" + ndk_root="${ANDROID_HOME}/ndk" + selected_ndk="" + + if [[ -d "${ndk_root}" ]]; then + while IFS= read -r candidate; do + toolchain="${candidate}/toolchains/llvm/prebuilt/linux-x86_64" + if [[ -x "${toolchain}/bin/aarch64-linux-android24-clang" ]]; then + selected_ndk="${candidate}" + break + fi + done < <(find "${ndk_root}" -mindepth 1 -maxdepth 1 -type d | sort -Vr) + fi + + if [[ -z "${selected_ndk}" ]]; then + echo "No usable preinstalled Android NDK found; installing ${fallback_version}" + yes | sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --licenses || true + sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" "ndk;${fallback_version}" + selected_ndk="${ndk_root}/${fallback_version}" + fi + + if [[ ! -x "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang" ]]; then + echo "Selected Android NDK does not contain a usable aarch64 API 24 clang: ${selected_ndk}" >&2 + exit 1 + fi + + echo "Selected Android NDK: ${selected_ndk}" + "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" --version + echo "ANDROID_NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" + echo "NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" - name: Install Linux bwrap build dependencies - if: ${{ runner.os == 'Linux' }} + if: ${{ runner.os == 'Linux' && matrix.target != 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail @@ -254,9 +328,173 @@ jobs: sudo apt-get update -y sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 fi - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@1.93 with: targets: ${{ matrix.target }} + - name: Set up sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + uses: mozilla-actions/sccache-action@main + - name: Enable sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + shell: bash + run: | + set -euo pipefail + echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_VERSION=android-release-${{ matrix.target }}" >> "$GITHUB_ENV" + echo "SCCACHE_CACHE_SIZE=5G" >> "$GITHUB_ENV" + - name: Ensure Rust target is installed + shell: bash + run: | + set -euo pipefail + rustup target add "${{ matrix.target }}" + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android build environment + shell: bash + run: | + set -euo pipefail + ndk="${ANDROID_NDK_HOME}" + target="${{ matrix.target }}" + + # Set up the Android toolchain + toolchain="${ndk}/toolchains/llvm/prebuilt/linux-x86_64" + + # Use API level 24 to follow termux-build convention + api_level="24" + + # Configure Cargo to use the NDK linker + cargo_target_var="CARGO_TARGET_${target^^}_LINKER" + cargo_target_var="${cargo_target_var//-/_}" + echo "${cargo_target_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + # Set CC and AR for the target + target_cc_var="CC_${target}" + target_cc_var="${target_cc_var//-/_}" + echo "${target_cc_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + target_cxx_var="CXX_${target}" + target_cxx_var="${target_cxx_var//-/_}" + echo "${target_cxx_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang++" >> "$GITHUB_ENV" + + target_ar_var="AR_${target}" + target_ar_var="${target_ar_var//-/_}" + echo "${target_ar_var}=${toolchain}/bin/llvm-ar" >> "$GITHUB_ENV" + + # Add toolchain to PATH + echo "${toolchain}/bin" >> "$GITHUB_PATH" + + tls_align_source="${RUNNER_TEMP}/codex-android-tls-align.S" + tls_align_object="${RUNNER_TEMP}/codex-android-tls-align.o" + cat > "${tls_align_source}" <<'EOF' + .section .tdata.codex_android_tls_align,"awT",%progbits + .p2align 6 + .globl __codex_android_tls_align + .hidden __codex_android_tls_align + .type __codex_android_tls_align, %object + __codex_android_tls_align: + .byte 0 + .size __codex_android_tls_align, 1 + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${tls_align_source}" -o "${tls_align_object}" + + v8_compat_source="${RUNNER_TEMP}/codex-android-v8-compat.c" + v8_compat_object="${RUNNER_TEMP}/codex-android-v8-compat.o" + cat > "${v8_compat_source}" <<'EOF' + #include + #include + + extern int posix_memalign(void **memptr, size_t alignment, size_t size); + extern double strtod(const char *nptr, char **endptr); + extern float strtof(const char *nptr, char **endptr); + + void *aligned_alloc(size_t alignment, size_t size) { + void *ptr = 0; + if (posix_memalign(&ptr, alignment, size) != 0) { + return 0; + } + return ptr; + } + + double strtod_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtod(nptr, endptr); + } + + float strtof_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtof(nptr, endptr); + } + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${v8_compat_source}" -o "${v8_compat_object}" + + builtins_archive="$( + find "${toolchain}/lib/clang" \ + -path "*/lib/linux/libclang_rt.builtins-aarch64-android.a" \ + -print -quit + )" + if [[ -z "${builtins_archive}" ]]; then + echo "Could not find Android compiler-rt builtins archive under ${toolchain}/lib/clang" >&2 + exit 1 + fi + builtins_dir="$(dirname "${builtins_archive}")" + + # Provide compatibility symbols and C++ ABI needed by the Android rusty_v8 archive. + rustflags_var="CARGO_TARGET_${target^^}_RUSTFLAGS" + rustflags_var="${rustflags_var//-/_}" + echo "${rustflags_var}=-C link-arg=${tls_align_object} -C link-arg=${v8_compat_object} -C link-arg=-Wl,-u,__codex_android_tls_align -C link-arg=-L${builtins_dir} -C link-arg=-lclang_rt.builtins-aarch64-android -C link-arg=-lc++abi -C link-arg=-Wl,-z,max-page-size=65536" >> "$GITHUB_ENV" + + + # Configure for vendored OpenSSL build + echo "CARGO_BUILD_TARGET_APPLIES_TO_HOST=false" >> "$GITHUB_ENV" + echo "CARGO_BUILD_TARGET=${{ matrix.target }}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android rusty_v8 artifact overrides + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + binding_dir="${RUNNER_TEMP}/rusty_v8" + archive="${binding_dir}/librusty_v8_release_${TARGET}.a.gz" + binding_path="${binding_dir}/src_binding_release_${TARGET}.rs" + checksums_path="${binding_dir}/rusty_v8_release_${TARGET}.sha256" + artifact_repository="wallentx/codex-termux" + release_tag="rusty-v8-v147.4.0" + mkdir -p "${binding_dir}" + echo "Downloading Android rusty_v8 artifacts from ${artifact_repository}@${release_tag}" + gh release download "${release_tag}" \ + --repo "${artifact_repository}" \ + --dir "${binding_dir}" \ + --pattern "librusty_v8_release_${TARGET}.a.gz" \ + --pattern "src_binding_release_${TARGET}.rs" \ + --pattern "rusty_v8_release_${TARGET}.sha256" + (cd "${binding_dir}" && sha256sum -c "$(basename "${checksums_path}")") + echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV" + echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Install termux-elf-cleaner + shell: bash + run: | + set -euo pipefail + version="v3.0.1" + src_dir="${RUNNER_TEMP}/termux-elf-cleaner-src" + build_dir="${RUNNER_TEMP}/termux-elf-cleaner-build" + bin_dir="${RUNNER_TEMP}/termux-elf-cleaner-bin" + + rm -rf "${src_dir}" "${build_dir}" "${bin_dir}" + mkdir -p "${src_dir}" "${build_dir}" "${bin_dir}" + + curl -fsSL "https://github.com/termux/termux-elf-cleaner/archive/refs/tags/${version}.tar.gz" \ + | tar -xzf - --strip-components=1 -C "${src_dir}" + + cmake -S "${src_dir}" -B "${build_dir}" -DCMAKE_BUILD_TYPE=Release + cmake --build "${build_dir}" --parallel + + install "${build_dir}/termux-elf-cleaner" "${bin_dir}/termux-elf-cleaner" + echo "${bin_dir}" >> "$GITHUB_PATH" - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Use hermetic Cargo home (musl) @@ -269,12 +507,25 @@ jobs: echo "${cargo_home}/bin" >> "$GITHUB_PATH" : > "${cargo_home}/config.toml" + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ${{ github.workspace }}/.cargo-home/bin/ + ${{ github.workspace }}/.cargo-home/registry/index/ + ${{ github.workspace }}/.cargo-home/registry/cache/ + ${{ github.workspace }}/.cargo-home/git/db/ + ${{ github.workspace }}/codex-rs/target/ + key: cargo-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }} + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + uses: mlugg/setup-zig@v2 with: version: 0.14.0 - use-cache: false - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools @@ -310,12 +561,6 @@ jobs: shell: bash run: | set -euo pipefail - # Avoid problematic aws-lc jitter entropy code path on musl builders. - echo "AWS_LC_SYS_NO_JITTER_ENTROPY=1" >> "$GITHUB_ENV" - target_no_jitter="AWS_LC_SYS_NO_JITTER_ENTROPY_${{ matrix.target }}" - target_no_jitter="${target_no_jitter//-/_}" - echo "${target_no_jitter}=1" >> "$GITHUB_ENV" - # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. echo "RUSTFLAGS=" >> "$GITHUB_ENV" echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" @@ -340,94 +585,70 @@ jobs: echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} - name: Configure musl rusty_v8 artifact overrides and verify checksums - uses: ./.github/actions/setup-rusty-v8-musl - with: - target: ${{ matrix.target }} - - - if: ${{ contains(matrix.target, 'linux') && matrix.bundle == 'primary' }} - name: Build bwrap and export digest + - name: Cargo build shell: bash run: | set -euo pipefail - target="${{ matrix.target }}" - cargo build --target "$target" --release --timings --bin bwrap - bwrap_path="target/${target}/release/bwrap" - if [[ ! -f "$bwrap_path" ]]; then - echo "bwrap binary ${bwrap_path} not found" - exit 1 + if [[ "${{ matrix.target }}" == 'aarch64-linux-android' ]]; then + export CARGO_BUILD_JOBS=4 + export CARGO_PROFILE_RELEASE_LTO=thin + export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8 + cargo build --target ${{ matrix.target }} --release --bin codex + exit 0 fi - digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" - echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" - echo "Built bwrap ${bwrap_path} with sha256:${digest}" - - - name: Cargo build + if [[ "${{ contains(matrix.target, 'windows') }}" == 'true' ]]; then + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy --bin codex-windows-sandbox-setup --bin codex-command-runner + else + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy + fi + - name: sccache stats (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} shell: bash - run: | - build_args=() - for binary in ${{ matrix.binaries }}; do - build_args+=(--bin "$binary") - done - echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" - cargo build --target ${{ matrix.target }} --release --timings "${build_args[@]}" - - - name: Upload Cargo timings - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: cargo-timings-rust-release-${{ matrix.target }}-${{ matrix.bundle }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html - if-no-files-found: warn + run: ${SCCACHE_PATH:-sccache} --show-stats || true - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Stage unsigned macOS artifacts + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Normalize Android ELF for Termux shell: bash run: | set -euo pipefail - - target="${{ matrix.target }}" - release_dir="target/${target}/release" - dest="unsigned-dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - unsigned_name="${binary}-${target}-unsigned" - unsigned_path="${dest}/${unsigned_name}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 + for binary in codex codex-responses-api-proxy; do + binary_path="target/${{ matrix.target }}/release/${binary}" + if [[ -f "${binary_path}" ]]; then + termux-elf-cleaner --api-level 24 "${binary_path}" + chmod +x "${binary_path}" + # if [[ "${binary}" == "codex" ]]; then + # # Patch PT_TLS (0x7) to PT_NULL (0x0) at offset 400 to bypass Bionic alignment checks. + # printf '\x00\x00\x00\x00' | dd of="${binary_path}" bs=1 seek=400 count=4 conv=notrunc + # fi fi - - cp "${binary_path}" "${unsigned_path}" - tar -C "$dest" -czf "${unsigned_path}.tar.gz" "${unsigned_name}" - zstd -T0 -19 --rm "${unsigned_path}" done - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Upload unsigned macOS artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-unsigned - path: codex-rs/unsigned-dist/${{ matrix.target }}/* - if-no-files-found: error - - - if: ${{ contains(matrix.target, 'linux') }} + - if: ${{ contains(matrix.target, 'linux') && !contains(matrix.target, 'android') && github.repository_owner == 'openai' }} name: Cosign Linux artifacts uses: ./.github/actions/linux-code-sign with: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - binaries: ${{ matrix.binaries }} - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS == 'true' }} + - if: ${{ contains(matrix.target, 'windows') && github.repository_owner == 'openai' }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (binaries) uses: ./.github/actions/macos-code-sign with: target: ${{ matrix.target }} - binaries: ${{ matrix.binaries }} sign-binaries: "true" sign-dmg: "false" apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} @@ -436,7 +657,7 @@ jobs: apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: Build macOS dmg shell: bash run: | @@ -451,17 +672,23 @@ jobs: # The previous "MacOS code signing (binaries)" step signs + notarizes the # built artifacts in `${release_dir}`. This step packages *those same* # signed binaries into a dmg. + codex_binary_path="${release_dir}/codex" + proxy_binary_path="${release_dir}/codex-responses-api-proxy" + rm -rf "$dmg_root" mkdir -p "$dmg_root" - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 - fi - ditto "${binary_path}" "${dmg_root}/${binary}" - done + if [[ ! -f "$codex_binary_path" ]]; then + echo "Binary $codex_binary_path not found" + exit 1 + fi + if [[ ! -f "$proxy_binary_path" ]]; then + echo "Binary $proxy_binary_path not found" + exit 1 + fi + + ditto "$codex_binary_path" "${dmg_root}/codex" + ditto "$proxy_binary_path" "${dmg_root}/codex-responses-api-proxy" rm -f "$dmg_path" hdiutil create \ @@ -476,7 +703,7 @@ jobs: exit 1 fi - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (dmg) uses: ./.github/actions/macos-code-sign with: @@ -490,121 +717,65 @@ jobs: apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - name: Stage artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | dest="dist/${{ matrix.target }}" mkdir -p "$dest" - for binary in ${{ matrix.binaries }}; do - cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}" - if [[ "${{ matrix.target }}" == *linux* ]]; then - cp "target/${{ matrix.target }}/release/${binary}.sigstore" \ - "$dest/${binary}-${{ matrix.target }}.sigstore" + if [[ "${{ matrix.runner }}" == windows* ]]; then + cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-command-runner.exe "$dest/codex-command-runner-${{ matrix.target }}.exe" + else + # For Android, we want the binary to be named just 'codex' in the archive. + if [[ "${{ matrix.target }}" == "aarch64-linux-android" ]]; then + cp target/${{ matrix.target }}/release/codex "$dest/codex" + else + cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" + fi + if [[ -f target/${{ matrix.target }}/release/codex-responses-api-proxy ]]; then + cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" fi - done - - if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then - bundle_root="${RUNNER_TEMP}/codex-${{ matrix.target }}-bundle" - rm -rf "$bundle_root" - mkdir -p "$bundle_root/codex-resources" - cp "$dest/codex-${{ matrix.target }}" "$bundle_root/codex" - cp "$dest/bwrap-${{ matrix.target }}" "$bundle_root/codex-resources/bwrap" - chmod 0755 "$bundle_root/codex" "$bundle_root/codex-resources/bwrap" - tar -C "$bundle_root" -cf - codex codex-resources/bwrap | - zstd -T0 -19 -o "$dest/codex-${{ matrix.target }}-bundle.tar.zst" fi - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then - cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" + if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.target }}" != *android* ]]; then + cp target/${{ matrix.target }}/release/codex.sigstore "$dest/codex-${{ matrix.target }}.sigstore" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore" fi - - name: Build Codex package archive - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "target/${TARGET}/release" \ - --archive-dir "dist/${TARGET}" - - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - aarch64-unknown-linux-musl) - platform_tag="manylinux_2_17_aarch64" - ;; - x86_64-unknown-linux-musl) - platform_tag="manylinux_2_17_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - # Do not install into the runner's system Python; macOS runners mark - # the Homebrew Python as externally managed under PEP 668. - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - stage_runtime_args=( - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" - stage-runtime - "$stage_dir" - "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex" - --codex-version "${GITHUB_REF_NAME}" - --platform-tag "$platform_tag" - ) - if [[ "${{ matrix.target }}" == *linux* ]]; then - # Keep bwrap in the runtime wheel so Linux sandbox fallback behavior - # matches the standalone release bundle on hosts without system bwrap. - stage_runtime_args+=( - --resource-binary - "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/bwrap" - ) + if [[ "${{ matrix.target }}" == *apple-darwin ]]; then + cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi - python3 "${stage_runtime_args[@]}" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error + - if: ${{ matrix.runner == 'windows-11-arm' }} + name: Install zstd + shell: powershell + run: choco install -y zstandard - name: Compress artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | # Path that contains the uncompressed binaries for the current # ${{ matrix.target }} dest="dist/${{ matrix.target }}" + repo_root=$PWD + + # We want to ship the raw Windows executables in the GitHub Release + # in addition to the compressed archives. Keep the originals for + # Windows targets; remove them elsewhere to limit the number of + # artifacts that end up in the GitHub Release. + keep_originals=false + if [[ "${{ matrix.runner }}" == windows* ]]; then + keep_originals=true + fi # For compatibility with environments that lack the `zstd` tool we - # additionally create a `.tar.gz` alongside every binary we publish. - # The end result is: + # additionally create a `.tar.gz` for all platforms and `.zip` for + # Windows alongside every single binary that we publish. The end result is: # codex-.zst (existing) # codex-.tar.gz (new) + # codex-.zip (only for Windows) # 1. Produce a .tar.gz for every file in the directory *before* we # run `zstd --rm`, because that flag deletes the original files. @@ -612,7 +783,7 @@ jobs: base="$(basename "$f")" # Skip files that are already archives (shouldn't happen, but be # safe). - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then + if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then continue fi @@ -622,344 +793,239 @@ jobs: fi # Create per-binary tar.gz - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - - # Also create .zst and remove the uncompressed binaries to keep - # non-Windows artifact directories small. - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} - with: - name: ${{ matrix.artifact_name }} - # Upload the per-binary .zst files, .tar.gz equivalents, and any - # prebuilt archives staged above. - path: | - codex-rs/dist/${{ matrix.target }}/* - - stage-signed-macos: - if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode == 'promote_signed' }} - needs: tag-check - name: Stage signed macOS handoff - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: macos-15-xlarge - timeout-minutes: 30 - permissions: - contents: read - defaults: - run: - working-directory: codex-rs + # For Android, we want the archive to have the target suffix even though the binary is just 'codex'. + archive_name="${base}" + if [[ "${{ matrix.target }}" == "aarch64-linux-android" && "${base}" == "codex" ]]; then + archive_name="codex-${{ matrix.target }}" + fi + tar -C "$dest" -czf "$dest/${archive_name}.tar.gz" "$base" + + # Create zip archive for Windows binaries + # Must run from inside the dest dir so 7z won't + # embed the directory path inside the zip. + if [[ "${{ matrix.runner }}" == windows* ]]; then + if [[ "$base" == "codex-${{ matrix.target }}.exe" ]]; then + # Bundle the sandbox helper binaries into the main codex zip so + # WinGet installs include the required helpers next to codex.exe. + # Fall back to the single-binary zip if the helpers are missing + # to avoid breaking releases. + bundle_dir="$(mktemp -d)" + runner_src="$dest/codex-command-runner-${{ matrix.target }}.exe" + setup_src="$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + if [[ -f "$runner_src" && -f "$setup_src" ]]; then + cp "$dest/$base" "$bundle_dir/$base" + cp "$runner_src" "$bundle_dir/codex-command-runner.exe" + cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe" + # Use an absolute path so bundle zips land in the real dist + # dir even when 7z runs from a temp directory. + (cd "$bundle_dir" && 7z a "$repo_root/$dest/${base}.zip" .) + else + echo "warning: missing sandbox binaries; falling back to single-binary zip" + echo "warning: expected $runner_src and $setup_src" + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + rm -rf "$bundle_dir" + else + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + fi - strategy: - fail-fast: false - matrix: - include: - - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" + # Also create .zst (existing behaviour) *and* remove the original + # uncompressed binary to keep the directory small. + zstd_args=(-T0 -19) + if [[ "${keep_originals}" == false ]]; then + zstd_args+=(--rm) + fi - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + if [[ "${archive_name}" != "${base}" ]]; then + zstd "${zstd_args[@]}" "$dest/$base" -o "$dest/${archive_name}.zst" + else + zstd "${zstd_args[@]}" "$dest/$base" + fi + done - - name: Download signed macOS handoff + - name: Add Termux release metadata + if: ${{ (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && matrix.target == 'aarch64-linux-android' }} shell: bash - env: - GH_TOKEN: ${{ github.token }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - SIGNED_MACOS_SHA256: ${{ inputs.signed_macos_sha256 }} run: | set -euo pipefail - - download_dir="${RUNNER_TEMP}/signed-macos-download" - handoff_dir="${RUNNER_TEMP}/signed-macos-handoff" - rm -rf "$download_dir" "$handoff_dir" - mkdir -p "$download_dir" "$handoff_dir" - - gh release download "$GITHUB_REF_NAME" \ - --repo "$GITHUB_REPOSITORY" \ - --pattern "$SIGNED_MACOS_ASSET" \ - --dir "$download_dir" - - asset_count="$(find "$download_dir" -maxdepth 1 -type f | wc -l | tr -d '[:space:]')" - if [[ "$asset_count" != "1" ]]; then - echo "Expected exactly one signed macOS handoff asset named ${SIGNED_MACOS_ASSET}; found ${asset_count}" - find "$download_dir" -maxdepth 1 -type f -print - exit 1 - fi - - asset_path="$(find "$download_dir" -maxdepth 1 -type f -print -quit)" - if [[ -n "${SIGNED_MACOS_SHA256}" ]]; then - expected_sha="$(printf '%s' "$SIGNED_MACOS_SHA256" | tr '[:upper:]' '[:lower:]')" - actual_sha="$(shasum -a 256 "$asset_path" | awk '{print $1}')" - if [[ "$actual_sha" != "$expected_sha" ]]; then - echo "signed_macos_sha256 mismatch for ${SIGNED_MACOS_ASSET}" - echo "expected: ${expected_sha}" - echo "actual: ${actual_sha}" - exit 1 - fi + if [[ ! -f "${GITHUB_WORKSPACE}/.github/termux-release.json" ]]; then + echo "No Termux release metadata found; skipping metadata attachment." + exit 0 fi + dest="dist/${{ matrix.target }}" + cp "${GITHUB_WORKSPACE}/.github/termux-release.json" "${dest}/termux-release.json" + ( + cd "${dest}" + sha256sum ./* > SHA256SUMS + ) - asset_name="$(basename "$asset_path")" - case "$asset_name" in - *.tar.zst) - zstd -dc "$asset_path" | tar -C "$handoff_dir" -xf - - ;; - *.tar.gz|*.tgz) - tar -C "$handoff_dir" -xzf "$asset_path" - ;; - *.zip) - ditto -x -k "$asset_path" "$handoff_dir" - ;; - *) - echo "Unsupported signed macOS handoff archive format: ${asset_name}" - exit 1 - ;; - esac - - echo "SIGNED_MACOS_HANDOFF_DIR=$handoff_dir" >> "$GITHUB_ENV" + - id: upload-artifact + uses: actions/upload-artifact@v6 + with: + name: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + # Upload the per-binary .zst files as well as the new .tar.gz + # equivalents we generated in the previous step. + path: | + codex-rs/dist/${{ matrix.target }}/* - - name: Stage signed macOS artifacts + - name: Publish Termux artifact notification + if: ${{ github.event_name == 'pull_request' && startsWith(github.base_ref, 'release/') && matrix.target == 'aarch64-linux-android' }} shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ARTIFACT_ID: ${{ steps.upload-artifact.outputs.artifact-id }} + ARTIFACT_NAME: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + PR_NUMBER: ${{ github.event.pull_request.number }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} run: | set -euo pipefail - - target="${{ matrix.target }}" - artifact_name="${{ matrix.artifact_name }}" - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${target}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - fi - if [[ ! -d "$source_dir" ]]; then - echo "Signed macOS handoff is missing ${artifact_name}/" - echo "Expected either:" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${target}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - find "$SIGNED_MACOS_HANDOFF_DIR" -maxdepth 3 -type f -print + if [[ -z "${ARTIFACT_ID}" ]]; then + echo "upload-artifact did not return an artifact id" >&2 exit 1 fi - dest="dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - source_path="${source_dir}/${binary}" - if [[ ! -f "$source_path" ]]; then - source_path="${source_dir}/${binary}-${target}" - fi - if [[ ! -f "$source_path" ]]; then - echo "Signed macOS handoff is missing ${binary} for ${artifact_name}" - exit 1 - fi - - release_path="${dest}/${binary}-${target}" - ditto "$source_path" "$release_path" - chmod 0755 "$release_path" - codesign --verify --strict --verbose=2 "$release_path" - done - - # DMG staging is disabled for signed promotion because we no longer - # distribute DMGs from this release path. Keep the branch here so the - # handoff can opt back in by flipping matrix.build_dmg if needed. - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then - dmg_name="codex-${target}.dmg" - dmg_source="${source_dir}/${dmg_name}" - if [[ ! -f "$dmg_source" ]]; then - echo "Signed macOS handoff is missing ${dmg_name} for ${artifact_name}" - exit 1 - fi - - codesign --verify --strict --verbose=2 "$dmg_source" - xcrun stapler validate "$dmg_source" - cp "$dmg_source" "$dest/$dmg_name" + marker="" + artifact_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts/${ARTIFACT_ID}" + run_url="${GH_WORKFLOW_URL:-https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + body_path="${RUNNER_TEMP}/termux-artifact-comment.md" + { + echo "${marker}" + echo "Termux Android artifact ready for testing:" + echo + echo "- Artifact: [${ARTIFACT_NAME}](${artifact_url})" + echo "- Workflow run: [${GITHUB_RUN_ID}](${run_url})" + echo + echo "You must be signed in to GitHub with repository access to download Actions artifacts." + } > "${body_path}" + + existing_comment_id="$( + gh pr view "${PR_NUMBER}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json comments \ + --jq ".comments | map(select(.author.is_bot == true and (.body | contains(\"${marker}\")))) | .[-1].id // \"\"" + )" + if [[ -n "${existing_comment_id}" ]]; then + gh api graphql \ + -f query=' + mutation($id: ID!, $body: String!) { + updateIssueComment(input: {id: $id, body: $body}) { + issueComment { + id + } + } + } + ' \ + -f id="${existing_comment_id}" \ + -f body="$(cat "${body_path}")" \ + >/dev/null + else + gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --body-file "${body_path}" fi - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - python3 \ - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ - stage-runtime \ - "$stage_dir" \ - "${GITHUB_WORKSPACE}/codex-rs/dist/${{ matrix.target }}/codex-${{ matrix.target }}" \ - --codex-version "${GITHUB_REF_NAME}" \ - --platform-tag "$platform_tag" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - - name: Build Codex package archive - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "dist/${TARGET}" \ - --archive-dir "dist/${TARGET}" \ - --target-suffixed-entrypoint - - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error - - - name: Compress artifacts - shell: bash - run: | - set -euo pipefail - - dest="dist/${{ matrix.target }}" - for f in "$dest"/*; do - base="$(basename "$f")" - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then - continue + gh label create binary-ready \ + --repo "${GITHUB_REPOSITORY}" \ + --color 0e8a16 \ + --description "Android binary is ready for testing" \ + --force + gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --add-label binary-ready + + if [[ -n "${SLACK_WEBHOOK_URL:-}" ]]; then + slack_payload_path="${RUNNER_TEMP}/termux-artifact-slack.json" + pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}" + jq -n \ + --arg artifact_name "${ARTIFACT_NAME}" \ + --arg artifact_url "${artifact_url}" \ + --arg pr_number "${PR_NUMBER}" \ + --arg pr_url "${pr_url}" \ + --arg run_id "${GITHUB_RUN_ID}" \ + --arg run_url "${run_url}" \ + '{ + text: ("Termux Android artifact ready for testing: " + $artifact_url), + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: ("*Termux Android artifact ready for testing*\n" + $artifact_url) + } + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: ("*Artifact:*\n<" + $artifact_url + "|" + $artifact_name + ">") + }, + { + type: "mrkdwn", + text: ("*Pull request:*\n<" + $pr_url + "|#" + $pr_number + ">") + }, + { + type: "mrkdwn", + text: ("*Workflow run:*\n<" + $run_url + "|" + $run_id + ">") + } + ] + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: "You must be signed in to GitHub with repository access to download Actions artifacts." + } + ] + } + ] + }' > "${slack_payload_path}" + + if ! curl -fsS \ + -X POST \ + -H "Content-type: application/json" \ + --data @"${slack_payload_path}" \ + "${SLACK_WEBHOOK_URL}"; then + echo "::warning::Failed to send Slack artifact notification" fi + fi - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }} - path: | - codex-rs/dist/${{ matrix.target }}/* - - build-windows: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - needs: tag-check - uses: ./.github/workflows/rust-release-windows.yml - with: - release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} - secrets: inherit - - argument-comment-lint-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: argument-comment-lint release assets + shell-tool-mcp: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.repository_owner == 'openai' + name: shell-tool-mcp needs: tag-check - uses: ./.github/workflows/rust-release-argument-comment-lint.yml + permissions: + contents: read + id-token: write + uses: ./.github/workflows/shell-tool-mcp.yml with: + release-tag: ${{ github.ref_name }} publish: true - - zsh-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: zsh release assets - needs: tag-check - uses: ./.github/workflows/rust-release-zsh.yml + secrets: inherit release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') needs: - - tag-check - build - - stage-signed-macos - - build-windows - - argument-comment-lint-release-assets - - zsh-release-assets - if: >- - ${{ - always() && - needs.tag-check.result == 'success' && - ( - ( - github.event_name == 'workflow_dispatch' && - inputs.release_mode == 'promote_signed' && - needs.stage-signed-macos.result == 'success' && - needs.build.result == 'skipped' && - needs.build-windows.result == 'skipped' && - needs.argument-comment-lint-release-assets.result == 'skipped' && - needs.zsh-release-assets.result == 'skipped' - ) || - ( - (github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed') && - needs.build.result == 'success' && - needs.stage-signed-macos.result == 'skipped' && - needs.build-windows.result == 'success' && - needs.argument-comment-lint-release-assets.result == 'success' && - needs.zsh-release-assets.result == 'success' - ) - ) - }} + - shell-tool-mcp name: release runs-on: ubuntu-latest permissions: contents: write actions: read - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'promote_signed' }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} outputs: version: ${{ steps.release_name.outputs.name }} tag: ${{ github.ref_name }} - sign_macos: ${{ steps.release_mode.outputs.sign_macos }} should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} - should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }} steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + uses: actions/checkout@v6 - - name: Define release mode - id: release_mode - run: | - echo "release_mode=${RELEASE_MODE}" >> "$GITHUB_OUTPUT" - echo "sign_macos=${SIGN_MACOS}" >> "$GITHUB_OUTPUT" + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main - name: Generate release notes from tag commit message id: release_notes @@ -981,138 +1047,18 @@ jobs: echo "path=${notes_path}" >> "${GITHUB_OUTPUT}" - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@v7 with: path: dist - - name: Validate unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - run_summary="$(gh run view "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --json conclusion,event,headBranch,headSha,status,workflowName,url \ - --jq '[.workflowName, .event, .headBranch, .headSha, .status, .conclusion, .url] | @tsv')" - IFS=$'\t' read -r workflow_name event head_branch head_sha status conclusion run_url <<< "$run_summary" - expected_head_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" - - if [[ "$workflow_name" != "$GITHUB_WORKFLOW" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is for workflow '${workflow_name}', expected '${GITHUB_WORKFLOW}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$event" != "workflow_dispatch" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} was triggered by '${event}', expected 'workflow_dispatch'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_branch" != "$GITHUB_REF_NAME" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used ref '${head_branch}', expected '${GITHUB_REF_NAME}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_sha" != "$expected_head_sha" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used head SHA '${head_sha}', expected '${expected_head_sha}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$status" != "completed" || "$conclusion" != "success" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is ${status}/${conclusion}, expected completed/success" - echo "Run URL: ${run_url}" - exit 1 - fi - - - name: Download artifacts from unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh run download "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --dir dist - - - name: Remove unsigned macOS staging artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - run: | - set -euo pipefail - find dist -mindepth 1 -maxdepth 1 -type d \ - -name '*-apple-darwin*-unsigned' \ - -exec rm -rf {} + - - - name: Re-upload promoted Linux x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-unknown-linux-musl - path: dist/x86_64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Linux arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-unknown-linux-musl - path: dist/aarch64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Windows x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-pc-windows-msvc - path: dist/x86_64-pc-windows-msvc/* - if-no-files-found: error - - - name: Re-upload promoted Windows arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-pc-windows-msvc - path: dist/aarch64-pc-windows-msvc/* - if-no-files-found: error - - name: List run: ls -R dist/ - - name: Prune artifacts excluded from unsigned macOS release - if: ${{ env.SIGN_MACOS == 'false' }} - run: | - find dist -mindepth 1 -maxdepth 1 -type d \ - ! -name '*-apple-darwin*-unsigned' \ - ! -name 'aarch64-unknown-linux-musl' \ - ! -name 'aarch64-unknown-linux-musl-app-server' \ - ! -name 'x86_64-unknown-linux-musl' \ - ! -name 'x86_64-unknown-linux-musl-app-server' \ - ! -name 'aarch64-pc-windows-msvc' \ - ! -name 'x86_64-pc-windows-msvc' \ - -exec rm -rf {} + - - if ! find dist -type f -name '*-apple-darwin*-unsigned*' | grep -q .; then - echo "No unsigned macOS artifacts found in downloaded workflow artifacts." - exit 1 - fi - + # This is a temporary fix: we should modify shell-tool-mcp.yml so these + # files do not end up in dist/ in the first place. - name: Delete entries from dist/ that should not go in the release run: | - rm -rf dist/windows-binaries* - # cargo-timing.html appears under multiple target-specific directories. - # If included in files: dist/**, release upload races on duplicate - # asset names and can fail with 404s. - find dist -type f -name 'cargo-timing.html' -delete - # Keep package-builder sidecar archives as workflow artifacts only - # until distribution channels are ready to consume them. - find dist -type f \ - \( -name 'codex-package-*' -o -name 'codex-app-server-package-*' \) \ - -delete - find dist -type d -empty -delete + rm -rf dist/shell-tool-mcp* ls -R dist/ @@ -1136,12 +1082,6 @@ jobs: set -euo pipefail version="${VERSION}" - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - echo "npm_tag=" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "npm_tag=" >> "$GITHUB_OUTPUT" @@ -1153,132 +1093,55 @@ jobs: echo "npm_tag=" >> "$GITHUB_OUTPUT" fi - - name: Determine Python runtime publish settings - id: python_runtime_publish_settings - env: - VERSION: ${{ steps.release_name.outputs.name }} - run: | - set -euo pipefail - version="${VERSION}" - - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - else - echo "should_publish=false" >> "$GITHUB_OUTPUT" - fi - - name: Setup pnpm - if: ${{ env.SIGN_MACOS == 'true' }} - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 + uses: pnpm/action-setup@v4 with: run_install: false - name: Setup Node.js for npm packaging - if: ${{ env.SIGN_MACOS == 'true' }} - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: node-version: 22 - name: Install dependencies - if: ${{ env.SIGN_MACOS == 'true' }} run: pnpm install --frozen-lockfile # stage_npm_packages.py requires DotSlash when staging releases. - - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 + - uses: facebook/install-dotslash@v2 - name: Stage npm packages - if: ${{ env.SIGN_MACOS == 'true' }} + if: github.repository_owner == 'openai' env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ steps.release_name.outputs.name }} run: | - workflow_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" ./scripts/stage_npm_packages.py \ - --release-version "$RELEASE_VERSION" \ - --workflow-url "$workflow_url" \ + --release-version "${{ steps.release_name.outputs.name }}" \ --package codex \ --package codex-responses-api-proxy \ --package codex-sdk - - name: Stage installer scripts - if: ${{ env.SIGN_MACOS == 'true' }} - run: | - cp scripts/install/install.sh dist/install.sh - cp scripts/install/install.ps1 dist/install.ps1 - - name: Create GitHub Release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + uses: softprops/action-gh-release@v2 with: name: ${{ steps.release_name.outputs.name }} tag_name: ${{ github.ref_name }} body_path: ${{ steps.release_notes.outputs.path }} files: dist/** - overwrite_files: true - make_latest: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - - name: Clean up signed promotion handoff assets - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" --jq '.id')" - gh api --paginate "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets" \ - --jq '.[] | [.id, .name] | @tsv' | - while IFS=$'\t' read -r asset_id asset_name; do - if [[ -z "$asset_id" || -z "$asset_name" ]]; then - continue - fi - - delete_asset=false - if [[ "$asset_name" == *unsigned* || "$asset_name" == "$SIGNED_MACOS_ASSET" ]]; then - delete_asset=true - fi - - if [[ "$delete_asset" == "true" ]]; then - echo "Deleting release asset ${asset_name}" - gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" - fi - done - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - if: github.repository_owner == 'openai' + uses: facebook/dotslash-publish-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-config.json - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-zsh-config.json - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-argument-comment-lint-config.json - - name: Trigger developers.openai.com deploy # Only trigger the deploy if the release is not a pre-release. # The deploy is used to update the developers.openai.com website with the new config schema json file. - if: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} + if: ${{ !contains(steps.release_name.outputs.name, '-') && github.repository_owner == 'openai' }} continue-on-error: true env: DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} @@ -1293,15 +1156,7 @@ jobs: # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - # promote_signed intentionally skips build jobs that are ancestors of release; - # include the !cancelled() status function so Actions does not apply its implicit - # success() check to the whole dependency chain before evaluating release outputs. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_npm == 'true' - }} + if: ${{ needs.release.outputs.should_publish_npm == 'true' && github.repository_owner == 'openai' }} name: publish-npm needs: release runs-on: ubuntu-latest @@ -1311,37 +1166,36 @@ jobs: steps: - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: - # Node 24 bundles npm >= 11.5.1, which trusted publishing requires. - node-version: 24 + node-version: 22 registry-url: "https://registry.npmjs.org" scope: "@openai" + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + - name: Download npm tarballs from release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} run: | set -euo pipefail - version="$RELEASE_VERSION" - tag="$RELEASE_TAG" + version="${{ needs.release.outputs.version }}" + tag="${{ needs.release.outputs.tag }}" mkdir -p dist/npm - patterns=( - "codex-npm-${version}.tgz" - "codex-npm-linux-*-${version}.tgz" - "codex-npm-darwin-*-${version}.tgz" - "codex-npm-win32-*-${version}.tgz" - "codex-responses-api-proxy-npm-${version}.tgz" - "codex-sdk-npm-${version}.tgz" - ) - for pattern in "${patterns[@]}"; do - gh release download "$tag" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "$pattern" \ - --dir dist/npm - done + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-responses-api-proxy-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-sdk-npm-${version}.tgz" \ + --dir dist/npm # No NODE_AUTH_TOKEN needed because we use OIDC. - name: Publish to npm @@ -1350,193 +1204,23 @@ jobs: NPM_TAG: ${{ needs.release.outputs.npm_tag }} run: | set -euo pipefail - prefix="" + tag_args=() if [[ -n "${NPM_TAG}" ]]; then - prefix="${NPM_TAG}-" + tag_args+=(--tag "${NPM_TAG}") fi - root_tarball="dist/npm/codex-npm-${VERSION}.tgz" - sdk_tarball="dist/npm/codex-sdk-npm-${VERSION}.tgz" - # Keep this list in sync with CODEX_PLATFORM_PACKAGES in - # codex-cli/scripts/build_npm_package.py. The root wrapper advances - # @openai/codex@latest as soon as it publishes, so every platform - # package it aliases must already exist in the registry first. - platform_tarballs=( - "dist/npm/codex-npm-linux-x64-${VERSION}.tgz" - "dist/npm/codex-npm-linux-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-x64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-x64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-arm64-${VERSION}.tgz" - ) - - for required_tarball in "${platform_tarballs[@]}" "${root_tarball}"; do - if [[ ! -f "${required_tarball}" ]]; then - echo "Missing npm tarball: ${required_tarball}" - exit 1 - fi - done - - shopt -s nullglob - other_tarballs=() - for tarball in dist/npm/*-"${VERSION}".tgz; do - if [[ "${tarball}" == "${root_tarball}" || "${tarball}" == "${sdk_tarball}" ]]; then - continue - fi - - is_platform_tarball=false - for platform_tarball in "${platform_tarballs[@]}"; do - if [[ "${tarball}" == "${platform_tarball}" ]]; then - is_platform_tarball=true - break - fi - done - if [[ "${is_platform_tarball}" == true ]]; then - continue - fi - - other_tarballs+=("${tarball}") - done - - # Publish the platform packages before the root CLI wrapper. The root - # wrapper advances @openai/codex@latest, so it should only publish - # after the optional dependency versions it references exist. tarballs=( - "${platform_tarballs[@]}" - "${other_tarballs[@]}" - "${root_tarball}" + "codex-npm-${VERSION}.tgz" + "codex-responses-api-proxy-npm-${VERSION}.tgz" + "codex-sdk-npm-${VERSION}.tgz" ) - if [[ -f "${sdk_tarball}" ]]; then - tarballs+=("${sdk_tarball}") - fi for tarball in "${tarballs[@]}"; do - filename="$(basename "${tarball}")" - tag="" - - case "${filename}" in - codex-npm-linux-*-"${VERSION}".tgz|codex-npm-darwin-*-"${VERSION}".tgz|codex-npm-win32-*-"${VERSION}".tgz) - platform="${filename#codex-npm-}" - platform="${platform%-${VERSION}.tgz}" - tag="${prefix}${platform}" - ;; - codex-npm-"${VERSION}".tgz|codex-responses-api-proxy-npm-"${VERSION}".tgz|codex-sdk-npm-"${VERSION}".tgz) - tag="${NPM_TAG}" - ;; - *) - echo "Unexpected npm tarball: ${filename}" - exit 1 - ;; - esac - - publish_cmd=(npm publish "${GITHUB_WORKSPACE}/${tarball}") - if [[ -n "${tag}" ]]; then - publish_cmd+=(--tag "${tag}") - fi - - echo "+ ${publish_cmd[*]}" - set +e - publish_output="$("${publish_cmd[@]}" 2>&1)" - publish_status=$? - set -e - - echo "${publish_output}" - if [[ ${publish_status} -eq 0 ]]; then - continue - fi - - if grep -qiE "previously published|cannot publish over|version already exists" <<< "${publish_output}"; then - echo "Skipping already-published package version for ${filename}" - continue - fi - - exit "${publish_status}" + npm publish "${GITHUB_WORKSPACE}/dist/npm/${tarball}" "${tag_args[@]}" done - # Publish the platform-specific Python runtime wheels using PyPI trusted publishing. - # PyPI project configuration must trust this workflow and job. Keep this - # non-blocking while the Python runtime publishing path is new; failures still - # need release follow-up, but should not invalidate the Rust release itself. - publish-python-runtime: - # Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_python_runtime == 'true' - }} - name: publish-python-runtime - needs: release - runs-on: ubuntu-latest - continue-on-error: true - environment: pypi - permissions: - id-token: write # Required for PyPI trusted publishing. - contents: read - - steps: - - name: Download Python runtime wheels from release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} - run: | - set -euo pipefail - python_version="$RELEASE_VERSION" - python_version="${python_version/-alpha./a}" - python_version="${python_version/-beta./b}" - python_version="${python_version/-rc./rc}" - - mkdir -p dist/python-runtime - gh release download "$RELEASE_TAG" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "openai_codex_cli_bin-${python_version}-*.whl" \ - --dir dist/python-runtime - ls -lh dist/python-runtime - - - name: Publish Python runtime wheels to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 - with: - packages-dir: dist/python-runtime - skip-existing: true - - winget: - name: winget - needs: release - # Only publish stable/mainline releases to WinGet; pre-releases include a - # '-' in the semver string (e.g., 1.2.3-alpha.1). - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' && - !contains(needs.release.outputs.version, '-') - }} - # This job only invokes a GitHub Action to open/update the winget-pkgs PR; - # it does not execute Windows-only tooling, so Linux is sufficient. - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Publish to WinGet - uses: vedantmgoyal9/winget-releaser@7bd472be23763def6e16bd06cc8b1cdfab0e2fd5 - with: - identifier: OpenAI.Codex - version: ${{ needs.release.outputs.version }} - release-tag: ${{ needs.release.outputs.tag }} - fork-user: openai-oss-forks - installers-regex: '^codex-(?:x86_64|aarch64)-pc-windows-msvc\.exe\.zip$' - token: ${{ secrets.WINGET_PUBLISH_PAT }} - update-branch: name: Update latest-alpha-cli branch - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' - }} permissions: contents: write needs: release diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml new file mode 100644 index 000000000000..66a76aa4d938 --- /dev/null +++ b/.github/workflows/shell-tool-mcp.yml @@ -0,0 +1,461 @@ +name: shell-tool-mcp + +on: + workflow_call: + inputs: + release-version: + description: Version to publish (x.y.z or x.y.z-alpha.N). Defaults to GITHUB_REF_NAME when it starts with rust-v. + required: false + type: string + release-tag: + description: Tag name to use when downloading release artifacts (defaults to rust-v). + required: false + type: string + publish: + description: Whether to publish to npm when the version is releasable. + required: false + default: true + type: boolean + +env: + NODE_VERSION: 22 + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.compute.outputs.version }} + release_tag: ${{ steps.compute.outputs.release_tag }} + should_publish: ${{ steps.compute.outputs.should_publish }} + npm_tag: ${{ steps.compute.outputs.npm_tag }} + steps: + - name: Compute version and tags + id: compute + run: | + set -euo pipefail + + version="${{ inputs.release-version }}" + release_tag="${{ inputs.release-tag }}" + + if [[ -z "$version" ]]; then + if [[ -n "$release_tag" && "$release_tag" =~ ^rust-v.+ ]]; then + version="${release_tag#rust-v}" + elif [[ "${GITHUB_REF_NAME:-}" =~ ^rust-v.+ ]]; then + version="${GITHUB_REF_NAME#rust-v}" + release_tag="${GITHUB_REF_NAME}" + else + echo "release-version is required when GITHUB_REF_NAME is not a rust-v tag." + exit 1 + fi + fi + + if [[ -z "$release_tag" ]]; then + release_tag="rust-v${version}" + fi + + npm_tag="" + should_publish="false" + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + should_publish="true" + elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then + should_publish="true" + npm_tag="alpha" + fi + + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" + echo "npm_tag=${npm_tag}" >> "$GITHUB_OUTPUT" + echo "should_publish=${should_publish}" >> "$GITHUB_OUTPUT" + + matrix-setup: + runs-on: ubuntu-latest + outputs: + rust-binaries: ${{ steps.compute.outputs.rust-binaries }} + bash-linux: ${{ steps.compute.outputs.bash-linux }} + bash-darwin: ${{ steps.compute.outputs.bash-darwin }} + steps: + - name: Compute matrices + id: compute + shell: bash + run: | + set -euo pipefail + is_openai="${{ github.repository_owner == 'openai' }}" + + # rust-binaries: always include x86_64-musl; conditionally include paid/fork runners + if [[ "$is_openai" == "true" ]]; then + rust='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","install_musl":true} + ]' + else + rust='[{"runner":"macos-latest","target":"aarch64-apple-darwin"}]' + fi + rust=$(echo "$rust" | jq -c '. + [{"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","install_musl":true}]') + echo "rust-binaries={\"include\":$rust}" >> "$GITHUB_OUTPUT" + + # bash-linux: always include x86_64 variants; add arm64 on openai + bash_linux='[ + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-24.04","image":"ubuntu:24.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-22.04","image":"ubuntu:22.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-12","image":"debian:12"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-11","image":"debian:11"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]' + if [[ "$is_openai" == "true" ]]; then + bash_linux=$(echo "$bash_linux" | jq -c '. + [ + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-24.04","image":"arm64v8/ubuntu:24.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-22.04","image":"arm64v8/ubuntu:22.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-20.04","image":"arm64v8/ubuntu:20.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-12","image":"arm64v8/debian:12"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-11","image":"arm64v8/debian:11"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]') + fi + echo "bash-linux={\"include\":$(echo "$bash_linux" | jq -c)}" >> "$GITHUB_OUTPUT" + + # bash-darwin: always include macos-14; add macos-15-xlarge on openai + bash_darwin='[{"runner":"macos-14","target":"aarch64-apple-darwin","variant":"macos-14"}]' + if [[ "$is_openai" == "true" ]]; then + bash_darwin=$(echo "$bash_darwin" | jq -c '. + [ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin","variant":"macos-15"} + ]') + fi + echo "bash-darwin={\"include\":$(echo "$bash_darwin" | jq -c)}" >> "$GITHUB_OUTPUT" + + rust-binaries: + name: Build Rust - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + defaults: + run: + working-directory: codex-rs + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.rust-binaries) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install UBSan runtime (musl) + if: ${{ matrix.install_musl }} + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 + fi + + - uses: dtolnay/rust-toolchain@1.93 + with: + targets: ${{ matrix.target }} + + - if: ${{ matrix.install_musl }} + name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.14.0 + + - if: ${{ matrix.install_musl }} + name: Install musl build dependencies + env: + TARGET: ${{ matrix.target }} + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" + + - if: ${{ matrix.install_musl }} + name: Configure rustc UBSan wrapper (musl host) + shell: bash + run: | + set -euo pipefail + ubsan="" + if command -v ldconfig >/dev/null 2>&1; then + ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" + fi + wrapper_root="${RUNNER_TEMP:-/tmp}" + wrapper="${wrapper_root}/rustc-ubsan-wrapper" + cat > "${wrapper}" <> "$GITHUB_ENV" + echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" + + - if: ${{ matrix.install_musl }} + name: Clear sanitizer flags (musl) + shell: bash + run: | + set -euo pipefail + # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" + # Override any runner-level Cargo config rustflags as well. + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + + sanitize_flags() { + local input="$1" + input="${input//-fsanitize=undefined/}" + input="${input//-fno-sanitize-recover=undefined/}" + input="${input//-fno-sanitize-trap=undefined/}" + echo "$input" + } + + cflags="$(sanitize_flags "${CFLAGS-}")" + cxxflags="$(sanitize_flags "${CXXFLAGS-}")" + echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" + echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" + + - name: Build exec server binaries + run: cargo build --release --target ${{ matrix.target }} --bin codex-exec-mcp-server --bin codex-execve-wrapper + + - name: Stage exec server binaries + run: | + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}" + mkdir -p "$dest" + cp "target/${{ matrix.target }}/release/codex-exec-mcp-server" "$dest/" + cp "target/${{ matrix.target }}/release/codex-execve-wrapper" "$dest/" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-rust-${{ matrix.target }} + path: artifacts/** + if-no-files-found: error + + bash-linux: + name: Build Bash (Linux) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + container: + image: ${{ matrix.image }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-linux) }} + steps: + - name: Install build prerequisites + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext + elif command -v dnf >/dev/null 2>&1; then + dnf install -y git gcc gcc-c++ make bison autoconf gettext + elif command -v yum >/dev/null 2>&1; then + yum install -y git gcc gcc-c++ make bison autoconf gettext + else + echo "Unsupported package manager in container" + exit 1 + fi + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + bash-darwin: + name: Build Bash (macOS) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-darwin) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + package: + name: Package npm module + needs: + - metadata + - rust-binaries + - bash-linux + - bash-darwin + runs-on: ubuntu-latest + env: + PACKAGE_VERSION: ${{ needs.metadata.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install JavaScript dependencies + run: pnpm install --frozen-lockfile + + - name: Build (shell-tool-mcp) + run: pnpm --filter @openai/codex-shell-tool-mcp run build + + - name: Download build artifacts + uses: actions/download-artifact@v7 + with: + path: artifacts + + - name: Assemble staging directory + id: staging + shell: bash + run: | + set -euo pipefail + staging="${STAGING_DIR}" + mkdir -p "$staging" "$staging/vendor" + cp shell-tool-mcp/README.md "$staging/" + cp shell-tool-mcp/package.json "$staging/" + cp -R shell-tool-mcp/bin "$staging/" + + found_vendor="false" + shopt -s nullglob + for vendor_dir in artifacts/*/vendor; do + rsync -av "$vendor_dir/" "$staging/vendor/" + found_vendor="true" + done + if [[ "$found_vendor" == "false" ]]; then + echo "No vendor payloads were downloaded." + exit 1 + fi + + node - <<'NODE' + import fs from "node:fs"; + import path from "node:path"; + + const stagingDir = process.env.STAGING_DIR; + const version = process.env.PACKAGE_VERSION; + const pkgPath = path.join(stagingDir, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + pkg.version = version; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + NODE + + echo "dir=$staging" >> "$GITHUB_OUTPUT" + env: + STAGING_DIR: ${{ runner.temp }}/shell-tool-mcp + + - name: Ensure binaries are executable + run: | + set -euo pipefail + staging="${{ steps.staging.outputs.dir }}" + chmod +x \ + "$staging"/vendor/*/codex-exec-mcp-server \ + "$staging"/vendor/*/codex-execve-wrapper \ + "$staging"/vendor/*/bash/*/bash + + - name: Create npm tarball + shell: bash + run: | + set -euo pipefail + mkdir -p dist/npm + staging="${{ steps.staging.outputs.dir }}" + pack_info=$(cd "$staging" && npm pack --ignore-scripts --json --pack-destination "${GITHUB_WORKSPACE}/dist/npm") + filename=$(PACK_INFO="$pack_info" node -e 'const data = JSON.parse(process.env.PACK_INFO); console.log(data[0].filename);') + mv "dist/npm/${filename}" "dist/npm/codex-shell-tool-mcp-npm-${PACKAGE_VERSION}.tgz" + + - uses: actions/upload-artifact@v6 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm/codex-shell-tool-mcp-npm-${{ env.PACKAGE_VERSION }}.tgz + if-no-files-found: error + + publish: + name: Publish npm package + needs: + - metadata + - package + if: ${{ inputs.publish && needs.metadata.outputs.should_publish == 'true' }} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org + scope: "@openai" + + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + + - name: Download npm tarball + uses: actions/download-artifact@v7 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm + + - name: Publish to npm + if: github.repository == 'openai/codex' + env: + NPM_TAG: ${{ needs.metadata.outputs.npm_tag }} + VERSION: ${{ needs.metadata.outputs.version }} + shell: bash + run: | + set -euo pipefail + tag_args=() + if [[ -n "${NPM_TAG}" ]]; then + tag_args+=(--tag "${NPM_TAG}") + fi + npm publish "dist/npm/codex-shell-tool-mcp-npm-${VERSION}.tgz" "${tag_args[@]}" diff --git a/.github/workflows/termux-release-checkpoint.yml b/.github/workflows/termux-release-checkpoint.yml new file mode 100644 index 000000000000..0347e6f1b3d6 --- /dev/null +++ b/.github/workflows/termux-release-checkpoint.yml @@ -0,0 +1,103 @@ +name: termux-release-checkpoint + +on: + workflow_dispatch: + inputs: + source_branch: + description: "Release branch to checkpoint from, for example release/0.123.0" + required: false + type: string + default: "" + source_sha: + description: "Specific source commit SHA to checkpoint; defaults to the source branch tip" + required: false + type: string + default: "" + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + type: string + default: "wallentx/termux-target" + reviewer: + description: "GitHub username to request as reviewer" + required: false + type: string + default: "wallentx" + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-checkpoint-${{ inputs.source_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + checkpoint: + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + env: + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + REQUESTED_SOURCE_BRANCH: ${{ inputs.source_branch }} + REQUESTED_SOURCE_SHA: ${{ inputs.source_sha }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ inputs.source_branch || github.ref_name }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + source_branch="${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${source_branch}" + + - name: Create checkpoint PR + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" diff --git a/.github/workflows/termux-release-deploy.yml b/.github/workflows/termux-release-deploy.yml new file mode 100644 index 000000000000..2071611dacc4 --- /dev/null +++ b/.github/workflows/termux-release-deploy.yml @@ -0,0 +1,243 @@ +name: termux-release-deploy + +on: + push: + branches: + - "release/**" + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to deploy, for example release/0.124.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to deploy. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged release PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged release PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + default: "wallentx/termux-target" + type: string + reviewer: + description: "GitHub username to request as reviewer on the checkpoint PR" + required: false + default: "wallentx" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-deploy-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + deploy: + runs-on: ubuntu-24.04 + if: ${{ github.event_name == 'workflow_dispatch' || !startsWith(github.event.head_commit.message, 'Seed Termux release automation') }} + permissions: + actions: read + contents: write + deployments: write + issues: write + pull-requests: write + env: + REQUESTED_RELEASE_BRANCH: ${{ inputs.release_branch }} + REQUESTED_RELEASE_SHA: ${{ inputs.release_sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + release_branch="${REQUESTED_RELEASE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${release_branch}" + + - name: Resolve release ref + id: release-ref + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: deploy + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Create deployment + if: steps.metadata.outputs.deploy == 'true' + id: deployment + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + deployment_id="$( + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments" \ + -f ref="${RELEASE_SHA}" \ + -f environment="termux-release" \ + -F auto_merge=false \ + -F required_contexts[] \ + -f description="Termux release deployment for ${TERMUX_TAG}" \ + --jq '.id' + )" + echo "id=${deployment_id}" >> "$GITHUB_OUTPUT" + + - name: Mark deployment in progress + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="in_progress" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Promoting Termux release artifact and preparing checkpoint PR" \ + >/dev/null + + - name: Locate merged pull request + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" + + - name: Ensure checkpoint PR + if: steps.metadata.outputs.deploy == 'true' + id: checkpoint + env: + SOURCE_BRANCH: ${{ steps.release-ref.outputs.branch }} + SOURCE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" + + - name: Mark deployment success + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="success" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment completed for ${TERMUX_TAG}" \ + >/dev/null + + - name: Mark deployment failure + if: failure() && steps.deployment.outputs.id != '' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="failure" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment failed" \ + >/dev/null diff --git a/.github/workflows/termux-release-promote.yml b/.github/workflows/termux-release-promote.yml new file mode 100644 index 000000000000..22c277c21a70 --- /dev/null +++ b/.github/workflows/termux-release-promote.yml @@ -0,0 +1,135 @@ +name: termux-release-promote + +on: + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to promote, for example release/0.122.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to promote. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-promote-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + promote: + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: write + pull-requests: read + env: + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Resolve release ref + id: release-ref + env: + INPUT_RELEASE_BRANCH: ${{ inputs.release_branch }} + INPUT_RELEASE_SHA: ${{ inputs.release_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: promote + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Locate merged pull request + if: steps.metadata.outputs.promote == 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.promote == 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.promote == 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index f77a581ac102..7d4b119f090e 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -121,7 +121,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.138.0-alpha.4" +version = "0.138.0-alpha.6" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 diff --git a/scripts/termux-configure-git.sh b/scripts/termux-configure-git.sh new file mode 100755 index 000000000000..fae3abcbab48 --- /dev/null +++ b/scripts/termux-configure-git.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# Configure the explicit bot identity used by release automation and fetch any +# refs requested by the calling workflow. + +set -euo pipefail + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + +while (($#)); do + case "$1" in + --origin) + shift + origin_refs=() + while (($#)) && [[ "$1" != --* ]]; do + origin_refs+=("$1") + shift + done + if ((${#origin_refs[@]})); then + git fetch --prune origin "${origin_refs[@]}" + fi + ;; + --upstream-tag) + if (($# < 3)); then + echo "--upstream-tag requires ." >&2 + exit 1 + fi + upstream_repo="$2" + upstream_tag="$3" + git remote add upstream "https://github.com/${upstream_repo}.git" 2>/dev/null || true + git fetch --prune --no-tags upstream "+refs/tags/${upstream_tag}:refs/tags/${upstream_tag}" + shift 3 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done diff --git a/scripts/termux-create-checkpoint-pr.sh b/scripts/termux-create-checkpoint-pr.sh new file mode 100755 index 000000000000..f315a5b91822 --- /dev/null +++ b/scripts/termux-create-checkpoint-pr.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/termux-release-paths.sh +source "${script_dir}/termux-release-paths.sh" + +source_branch="${SOURCE_BRANCH:-${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}}" +source_sha="${SOURCE_SHA:-${REQUESTED_SOURCE_SHA:-}}" +if [[ -z "${source_sha}" ]]; then + if [[ "${GITHUB_EVENT_NAME:-}" == "push" && "${source_branch}" == "${GITHUB_REF_NAME:-}" ]]; then + source_sha="${GITHUB_SHA}" + else + source_sha="$(git rev-parse "origin/${source_branch}")" + fi +fi + +if [[ -z "${DESTINATION_BRANCH:-}" ]]; then + echo "DESTINATION_BRANCH is required." >&2 + exit 1 +fi + +release_only_checkpoint_paths() { + printf '%s\n' "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +} + +resolve_source_version_conflicts() { + local path="$1" + local resolved_path + resolved_path="$(mktemp)" + + if ! awk ' + function normalize_versions(text) { + gsub(/version = "[^"]+"/, "version = \"\"", text) + return text + } + + BEGIN { + in_block = 0 + side = "" + ours = "" + theirs = "" + blocks = 0 + } + + /^<<<<<<< / { + if (in_block) { + exit 1 + } + in_block = 1 + side = "ours" + ours = "" + theirs = "" + blocks++ + next + } + + /^=======$/ && in_block { + side = "theirs" + next + } + + /^>>>>>>> / && in_block { + if (normalize_versions(ours) != normalize_versions(theirs)) { + exit 1 + } + printf "%s", theirs + in_block = 0 + side = "" + next + } + + { + if (!in_block) { + print + } else if (side == "ours") { + ours = ours $0 ORS + } else if (side == "theirs") { + theirs = theirs $0 ORS + } else { + exit 1 + } + } + + END { + if (in_block || blocks == 0) { + exit 1 + } + } + ' "${path}" > "${resolved_path}"; then + rm -f "${resolved_path}" + return 1 + fi + + cp "${resolved_path}" "${path}" + rm -f "${resolved_path}" +} + +short_sha="${source_sha:0:12}" +source_slug="${source_branch//\//_}" +dest_slug="${DESTINATION_BRANCH//\//_}" +checkpoint_branch="checkpoint/${dest_slug}_from_${source_slug}_${short_sha}" +pr_title="checkpoint: into ${DESTINATION_BRANCH} from ${source_branch} @ ${short_sha}" +merge_conflicted=false +conflict_summary="" + +existing_pr="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --head "${checkpoint_branch}" \ + --state all \ + --json number,state,mergedAt,url \ + --jq '[.[] | select(.state == "OPEN" or .mergedAt != null)] | .[0] // empty' +)" +if [[ -n "${existing_pr}" ]]; then + existing_url="$(jq -r '.url' <<< "${existing_pr}")" + existing_state="$(jq -r '.state' <<< "${existing_pr}")" + echo "Checkpoint PR already exists for ${checkpoint_branch}: ${existing_url} (${existing_state})." + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${existing_url}" >> "${GITHUB_OUTPUT}" + fi + exit 0 +fi + +git checkout -B "${checkpoint_branch}" "origin/${DESTINATION_BRANCH}" + +if ! git merge --no-ff --no-edit "${source_sha}"; then + mapfile -t conflicted_paths < <(git diff --name-only --diff-filter=U) + for conflicted_path in "${conflicted_paths[@]}"; do + if termux_is_checkpoint_release_only_path "${conflicted_path}"; then + echo "Auto-resolving release-only checkpoint conflict in ${conflicted_path} by keeping ${DESTINATION_BRANCH}." + if git cat-file -e "HEAD:${conflicted_path}" 2>/dev/null; then + git checkout --ours -- "${conflicted_path}" + git add "${conflicted_path}" + else + git rm -f --ignore-unmatch "${conflicted_path}" + fi + fi + done + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if ((${#remaining_conflicts[@]})); then + cargo_version_conflicts=true + for remaining_conflict in "${remaining_conflicts[@]}"; do + case "${remaining_conflict}" in + codex-rs/Cargo.toml|codex-rs/Cargo.lock) + ;; + *) + cargo_version_conflicts=false + ;; + esac + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + for remaining_conflict in "${remaining_conflicts[@]}"; do + if ! resolve_source_version_conflicts "${remaining_conflict}"; then + cargo_version_conflicts=false + break + fi + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + echo "Auto-resolving recurring Cargo version checkpoint conflicts by keeping ${source_branch} versions." + git add -- "${remaining_conflicts[@]}" + fi + fi + fi + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if [[ "${#remaining_conflicts[@]}" -eq 0 ]]; then + git commit --no-edit + else + merge_conflicted=true + conflict_summary="$( + printf '%s\n' "${remaining_conflicts[@]}" | awk '{ print "- `" $0 "`" }' + )" + echo "Automatic checkpoint merge failed; creating a manual-resolution PR instead." >&2 + if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + git merge --abort + fi + git checkout -B "${checkpoint_branch}" "${source_sha}" + fi +fi + +if git cat-file -e "origin/${DESTINATION_BRANCH}:.github" 2>/dev/null; then + git checkout "origin/${DESTINATION_BRANCH}" -- .github + mapfile -t added_github_paths < <( + git diff --name-only --diff-filter=A "origin/${DESTINATION_BRANCH}" -- .github + ) + if ((${#added_github_paths[@]})); then + git rm -f --ignore-unmatch -- "${added_github_paths[@]}" + fi +else + git rm -r --ignore-unmatch .github +fi + +while IFS= read -r release_only_path; do + if git cat-file -e "origin/${DESTINATION_BRANCH}:${release_only_path}" 2>/dev/null; then + git checkout "origin/${DESTINATION_BRANCH}" -- "${release_only_path}" + else + git rm -f --ignore-unmatch -- "${release_only_path}" + fi +done < <(release_only_checkpoint_paths) + +if ! git diff --quiet || ! git diff --cached --quiet; then + if [[ -e .github || -L .github ]] || git ls-files --error-unmatch -- .github >/dev/null 2>&1; then + git add -A .github + fi + while IFS= read -r release_only_path; do + if [[ -e "${release_only_path}" || -L "${release_only_path}" ]] || git ls-files --error-unmatch -- "${release_only_path}" >/dev/null 2>&1; then + git add -A -- "${release_only_path}" + fi + done < <(release_only_checkpoint_paths) + if [[ "${merge_conflicted}" == "true" ]]; then + git commit -m "checkpoint: prepare ${source_branch} for ${DESTINATION_BRANCH}" + else + git commit --amend --no-edit + fi +fi + +if git diff --quiet "origin/${DESTINATION_BRANCH}" HEAD; then + echo "Checkpoint merge produced no destination changes after release-only files were restored." + exit 0 +fi + +git push --force-with-lease origin "${checkpoint_branch}" + +remaining="$( + git log --first-parent --pretty=format:%H "${source_sha}..origin/${source_branch}" | wc -w +)" + +body_path="${RUNNER_TEMP}/termux-checkpoint-pr.md" +{ + echo "## Termux release checkpoint" + echo + echo "- Source branch: \`${source_branch}\`" + echo "- Source hash: \`${source_sha}\`" + echo "- Destination branch: \`${DESTINATION_BRANCH}\`" + echo "- Remaining first-parent commits on source: ${remaining}" + echo + echo "This PR carries release-train conflict fixes and follow-up changes back into the reusable Termux patch branch." + if [[ "${merge_conflicted}" == "true" ]]; then + echo + echo "## Merge conflicts" + echo + echo "GitHub Actions could not create the checkpoint merge commit automatically, so this PR was created from the source branch state for manual conflict resolution." + echo + echo "Conflicted paths from the failed merge attempt:" + if [[ -n "${conflict_summary}" ]]; then + printf '%s\n' "${conflict_summary}" + else + echo "- Conflict details unavailable" + fi + fi + echo + echo "Release-only workflow files and metadata under \`.github\` were restored to the destination branch versions before opening this PR." +} > "${body_path}" + +pr_url="$( + gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${DESTINATION_BRANCH}" \ + --head "${checkpoint_branch}" \ + --title "${pr_title}" \ + --body-file "${body_path}" +)" +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-reviewer "${REVIEWER}" || true +gh label create checkpoint --repo "${GITHUB_REPOSITORY}" --color c5def5 --description "Checkpoint merge" --force +gh label create termux-release --repo "${GITHUB_REPOSITORY}" --color 0e8a16 --description "Termux release automation" --force +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-label "checkpoint" --add-label "termux-release" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${pr_url}" >> "${GITHUB_OUTPUT}" +fi diff --git a/scripts/termux-create-or-update-mirrored-release.sh b/scripts/termux-create-or-update-mirrored-release.sh new file mode 100755 index 000000000000..5787e894582b --- /dev/null +++ b/scripts/termux-create-or-update-mirrored-release.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +# Create a mirrored Termux release or repair an existing release that is missing +# the Android tarball. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" +: "${RUNNER_TEMP:?RUNNER_TEMP is required}" +: "${UPSTREAM_TAG:?UPSTREAM_TAG is required}" +: "${UPSTREAM_REPO:?UPSTREAM_REPO is required}" +: "${UPSTREAM_NAME?UPSTREAM_NAME is required}" +: "${TERMUX_TAG:?TERMUX_TAG is required}" +: "${UPSTREAM_PRERELEASE:?UPSTREAM_PRERELEASE is required}" +: "${UPSTREAM_HTML_URL?UPSTREAM_HTML_URL is required}" +: "${RELEASE_TRAIN?RELEASE_TRAIN is required}" +: "${RELEASE_EXISTS:?RELEASE_EXISTS is required}" +: "${PR_NUMBER:?PR_NUMBER is required}" +: "${HEAD_SHA:?HEAD_SHA is required}" +: "${RELEASE_SHA:?RELEASE_SHA is required}" + +promoted_dir="${PROMOTED_DIR:-promoted}" +asset_path="${promoted_dir}/codex-aarch64-linux-android.tar.gz" + +body_path="${RUNNER_TEMP}/release-body.md" +upstream_body_path="${RUNNER_TEMP}/upstream-release-body.md" +upstream_body_without_changelog_path="${RUNNER_TEMP}/upstream-release-body-without-changelog.md" +if gh release view "${UPSTREAM_TAG}" \ + --repo "${UPSTREAM_REPO}" \ + --json body \ + --jq '.body // ""' > "${upstream_body_path}"; then + awk ' + function heading_level(line, text) { + if (match(line, /^(#{1,6})[[:space:]]+(.+)$/, parts)) { + text = tolower(parts[2]) + sub(/[[:space:]]+#+[[:space:]]*$/, "", text) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", text) + if (text == "changelog" || text == "change log") { + return length(parts[1]) + } + } + return 0 + } + + { + if (!skip) { + level = heading_level($0) + if (level > 0) { + skip = 1 + skip_level = level + next + } + print + next + } + + if (match($0, /^(#{1,6})[[:space:]]+/, parts) && length(parts[1]) <= skip_level) { + skip = 0 + print + } + } + ' "${upstream_body_path}" > "${upstream_body_without_changelog_path}" +else + echo "::warning title=Upstream release notes unavailable::Could not read ${UPSTREAM_REPO} release ${UPSTREAM_TAG}." + : > "${upstream_body_without_changelog_path}" +fi + +{ + echo "Termux Android build for ${UPSTREAM_TAG}." + echo + echo "- Upstream release: ${UPSTREAM_HTML_URL}" + echo "- Release train: \`${RELEASE_TRAIN}\`" + echo "- Promoted PR: #${PR_NUMBER}" + echo "- Promoted PR head SHA: \`${HEAD_SHA}\`" +} > "${body_path}" +if [[ -s "${upstream_body_without_changelog_path}" ]]; then + { + echo + echo "## Upstream release notes" + echo + cat "${upstream_body_without_changelog_path}" + } >> "${body_path}" +fi + +release_title="${UPSTREAM_NAME}" +if [[ -z "${release_title}" || "${release_title}" == "null" ]]; then + release_title="${TERMUX_TAG}" +fi + +if [[ "${RELEASE_EXISTS}" == "true" ]]; then + gh release upload \ + "${TERMUX_TAG}" \ + "${asset_path}#codex-termux" \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber + exit 0 +fi + +release_args=( + gh release create "${TERMUX_TAG}" + --repo "${GITHUB_REPOSITORY}" + --target "${RELEASE_SHA}" + --title "${release_title}" + --notes-file "${body_path}" +) +if [[ "${UPSTREAM_PRERELEASE}" == "true" ]]; then + release_args+=(--prerelease) +fi +release_args+=("${asset_path}#codex-termux") +"${release_args[@]}" diff --git a/scripts/termux-download-release-artifact.sh b/scripts/termux-download-release-artifact.sh new file mode 100755 index 000000000000..2acdba9025a0 --- /dev/null +++ b/scripts/termux-download-release-artifact.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -z "${HEAD_SHA:-}" ]]; then + echo "HEAD_SHA is required." >&2 + exit 1 +fi + +if [[ -z "${PR_ARTIFACT_NAME:-}" ]]; then + echo "PR_ARTIFACT_NAME is required." >&2 + exit 1 +fi + +promoted_dir="${PROMOTED_DIR:-promoted}" + +find_run_id() { + local event="$1" + gh run list \ + --repo "${GITHUB_REPOSITORY}" \ + --workflow rust-release.yml \ + --event "${event}" \ + --status success \ + --commit "${HEAD_SHA}" \ + --limit 1 \ + --json databaseId \ + --jq '.[0].databaseId // empty' +} + +find_artifact_run_id() { + local artifact_name="$1" + + gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${artifact_name}" \ + | jq -rs \ + --arg artifact_name "${artifact_name}" \ + --arg head_sha "${HEAD_SHA}" \ + ' + [ + .[].artifacts[] + | select(.name == $artifact_name) + | select(.expired == false) + | select(.workflow_run.head_sha == $head_sha) + ] + | sort_by(.created_at) + | reverse + | .[0].workflow_run.id // empty + ' +} + +artifact_name="${PR_ARTIFACT_NAME}" +run_id="$(find_artifact_run_id "${artifact_name}")" +mkdir -p "${promoted_dir}" +if [[ -n "${run_id}" ]] && gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}"; then + : +else + artifact_name="aarch64-linux-android" + run_id="$(find_run_id workflow_dispatch)" + if [[ -z "${run_id}" ]]; then + echo "No successful rust-release run found for ${HEAD_SHA}" >&2 + exit 1 + fi + gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}" +fi + +ls -la "${promoted_dir}" +if [[ -f "${promoted_dir}/SHA256SUMS" ]]; then + (cd "${promoted_dir}" && sha256sum -c SHA256SUMS) +fi +if [[ ! -f "${promoted_dir}/codex-aarch64-linux-android.tar.gz" ]]; then + echo "Expected ${promoted_dir}/codex-aarch64-linux-android.tar.gz in the downloaded artifact." >&2 + exit 1 +fi diff --git a/scripts/termux-find-release-pr.sh b/scripts/termux-find-release-pr.sh new file mode 100755 index 000000000000..3e35fa42eabe --- /dev/null +++ b/scripts/termux-find-release-pr.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -n "${INPUT_PR_NUMBER:-}" || -n "${INPUT_PR_HEAD_SHA:-}" ]]; then + if [[ -z "${INPUT_PR_NUMBER:-}" || -z "${INPUT_PR_HEAD_SHA:-}" ]]; then + echo "workflow_dispatch inputs pr_number and pr_head_sha must be provided together." >&2 + exit 1 + fi + + pr_number="${INPUT_PR_NUMBER}" + head_sha="${INPUT_PR_HEAD_SHA}" + head_ref="" +else + if [[ -z "${RELEASE_BRANCH:-}" || -z "${RELEASE_SHA:-}" ]]; then + echo "RELEASE_BRANCH and RELEASE_SHA are required." >&2 + exit 1 + fi + + pr_json="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${RELEASE_BRANCH}" \ + --state merged \ + --limit 100 \ + --json number,headRefOid,headRefName,mergeCommit \ + --jq "[.[] | select(.mergeCommit.oid == \"${RELEASE_SHA}\")] | sort_by(.number) | reverse | .[0] // empty" + )" + if [[ -z "${pr_json}" ]]; then + echo "Unable to find merged PR for ${RELEASE_SHA} into ${RELEASE_BRANCH}" >&2 + exit 1 + fi + + pr_number="$(jq -r '.number' <<< "${pr_json}")" + head_sha="$(jq -r '.headRefOid // .head.sha' <<< "${pr_json}")" + head_ref="$(jq -r '.headRefName // .head.ref' <<< "${pr_json}")" +fi + +artifact_name="termux-android-pr-${pr_number}-${head_sha}" +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "number=${pr_number}" + echo "head_sha=${head_sha}" + echo "head_ref=${head_ref}" + echo "artifact_name=${artifact_name}" + } >> "${GITHUB_OUTPUT}" +else + printf 'number=%s\nhead_sha=%s\nhead_ref=%s\nartifact_name=%s\n' \ + "${pr_number}" \ + "${head_sha}" \ + "${head_ref}" \ + "${artifact_name}" +fi diff --git a/scripts/termux-read-release-metadata.sh b/scripts/termux-read-release-metadata.sh new file mode 100755 index 000000000000..519a0635a368 --- /dev/null +++ b/scripts/termux-read-release-metadata.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# Parse .github/termux-release.json and emit workflow outputs for deploy or +# promote jobs. TERMUX_RELEASE_ACTION must be "deploy" or "promote". + +set -euo pipefail + +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +metadata=".github/termux-release.json" +action="${TERMUX_RELEASE_ACTION:-deploy}" +case "${action}" in + deploy) + missing_context="deployment" + ;; + promote) + missing_context="promotion" + ;; + *) + echo "TERMUX_RELEASE_ACTION must be deploy or promote." >&2 + exit 1 + ;; +esac + +if [[ ! -f "${metadata}" ]]; then + echo "No ${metadata}; this push is not a Termux release ${missing_context}." + echo "${action}=false" >> "${GITHUB_OUTPUT}" + exit 0 +fi + +upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" +upstream_name="$(jq -r '.upstream_name // .upstream_tag // empty' "${metadata}")" +termux_tag="$(jq -r '.termux_tag // empty' "${metadata}")" +upstream_version="${upstream_tag#rust-v}" +upstream_version="${upstream_version%-termux}" +upstream_prerelease=false +if [[ "${upstream_version}" == *-* ]]; then + upstream_prerelease=true +fi +upstream_html_url="$(jq -r '.upstream_html_url // ""' "${metadata}")" +upstream_repo="$(jq -r '.upstream_repo // "openai/codex"' "${metadata}")" +release_train="$(jq -r '.release_train // ""' "${metadata}")" +if [[ -z "${upstream_tag}" || -z "${termux_tag}" ]]; then + echo "Missing upstream_tag or termux_tag in ${metadata}" >&2 + exit 1 +fi + +release_state="$(TERMUX_TAG="${termux_tag}" "${script_dir}/termux-release-asset-state.sh")" +release_exists="$(awk -F= '$1 == "release_exists" { print $2 }' <<< "${release_state}")" +asset_exists="$(awk -F= '$1 == "asset_exists" { print $2 }' <<< "${release_state}")" + +if [[ "${action}" == "promote" ]]; then + if [[ "${asset_exists}" == "true" ]]; then + echo "${termux_tag} already exists with codex-aarch64-linux-android.tar.gz; skipping promotion." + echo "promote=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + if [[ "${release_exists}" == "true" ]]; then + echo "${termux_tag} exists but is missing codex-aarch64-linux-android.tar.gz; repairing promotion." + fi +fi + +{ + echo "${action}=true" + echo "upstream_tag=${upstream_tag}" + echo "upstream_name=${upstream_name}" + echo "termux_tag=${termux_tag}" + echo "upstream_prerelease=${upstream_prerelease}" + echo "upstream_html_url=${upstream_html_url}" + echo "upstream_repo=${upstream_repo}" + echo "release_train=${release_train}" + echo "release_exists=${release_exists}" + echo "asset_exists=${asset_exists}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-release-asset-state.sh b/scripts/termux-release-asset-state.sh new file mode 100755 index 000000000000..8694480b6a36 --- /dev/null +++ b/scripts/termux-release-asset-state.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +termux_tag="${1:-${TERMUX_TAG:-}}" +release_repo="${RELEASE_REPO:-${GITHUB_REPOSITORY:-}}" +asset_name="${ASSET_NAME:-codex-aarch64-linux-android.tar.gz}" + +if [[ -z "${termux_tag}" ]]; then + echo "TERMUX_TAG or tag argument is required." >&2 + exit 1 +fi + +if [[ -z "${release_repo}" ]]; then + echo "GITHUB_REPOSITORY or RELEASE_REPO is required." >&2 + exit 1 +fi + +release_exists=false +asset_exists=false + +if gh release view "${termux_tag}" --repo "${release_repo}" >/dev/null 2>&1; then + release_exists=true + release_asset_exists="$( + gh release view "${termux_tag}" \ + --repo "${release_repo}" \ + --json assets \ + | jq -r --arg asset_name "${asset_name}" \ + '.assets | map(.name) | any(. == $asset_name)' + )" + if [[ "${release_asset_exists}" == "true" ]]; then + asset_exists=true + fi +fi + +printf 'release_exists=%s\n' "${release_exists}" +printf 'asset_exists=%s\n' "${asset_exists}" diff --git a/scripts/termux-release-paths.sh b/scripts/termux-release-paths.sh new file mode 100755 index 000000000000..6990f323ec44 --- /dev/null +++ b/scripts/termux-release-paths.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +# Sourceable path lists for files owned by the Termux release automation. + +set -euo pipefail + +readonly -a TERMUX_RELEASE_WORKFLOW_PATHS=( + .github/workflows/rust-release.yml + .github/workflows/shell-tool-mcp.yml + .github/workflows/termux-release-checkpoint.yml + .github/workflows/termux-release-deploy.yml + .github/workflows/termux-release-promote.yml +) + +readonly -a TERMUX_RELEASE_BRANCH_SCRIPT_PATHS=( + scripts/termux-configure-git.sh + scripts/termux-create-checkpoint-pr.sh + scripts/termux-create-or-update-mirrored-release.sh + scripts/termux-download-release-artifact.sh + scripts/termux-find-release-pr.sh + scripts/termux-read-release-metadata.sh + scripts/termux-release-asset-state.sh + scripts/termux-release-paths.sh + scripts/termux-resolve-release-ref.sh + scripts/termux-validate-gh-env.sh +) + +readonly -a TERMUX_RELEASE_AUTOMATION_PATHS=( + "${TERMUX_RELEASE_WORKFLOW_PATHS[@]}" + "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +) + +readonly -a TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS=( + "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" + .github/termux-release.json +) + +termux_path_in_list() { + local candidate="$1" + shift + local listed_path + + for listed_path in "$@"; do + [[ "${candidate}" != "${listed_path}" ]] || return 0 + done + return 1 +} + +termux_is_release_automation_path() { + termux_path_in_list "$1" "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" +} + +termux_is_checkpoint_release_only_path() { + termux_path_in_list "$1" "${TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS[@]}" +} diff --git a/scripts/termux-resolve-release-ref.sh b/scripts/termux-resolve-release-ref.sh new file mode 100755 index 000000000000..194773d45bed --- /dev/null +++ b/scripts/termux-resolve-release-ref.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +# Resolve the release branch and SHA for deploy/promote workflows. + +set -euo pipefail + +: "${GITHUB_EVENT_NAME:?GITHUB_EVENT_NAME is required}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" + +input_release_branch="${INPUT_RELEASE_BRANCH:-${REQUESTED_RELEASE_BRANCH:-}}" +input_release_sha="${INPUT_RELEASE_SHA:-${REQUESTED_RELEASE_SHA:-}}" + +if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + if [[ -z "${input_release_branch}" ]]; then + echo "release_branch is required for workflow_dispatch." >&2 + exit 1 + fi + + release_branch="${input_release_branch}" + if [[ -n "${input_release_sha}" ]]; then + git checkout --detach "${input_release_sha}" + release_sha="${input_release_sha}" + else + release_sha="$(git rev-parse HEAD)" + fi +else + : "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" + : "${GITHUB_SHA:?GITHUB_SHA is required}" + release_branch="${GITHUB_REF_NAME}" + release_sha="${GITHUB_SHA}" +fi + +{ + echo "branch=${release_branch}" + echo "sha=${release_sha}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-validate-gh-env.sh b/scripts/termux-validate-gh-env.sh new file mode 100755 index 000000000000..02581fdebceb --- /dev/null +++ b/scripts/termux-validate-gh-env.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Lightweight post-toolbox check for jobs that rely on authenticated gh calls. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +command -v gh +gh auth status --hostname github.com + +printf 'GITHUB_REPOSITORY=%s\n' "${GITHUB_REPOSITORY}" +printf 'REPO=%s\n' "${REPO:-}" +printf 'GH_REPO_URL=%s\n' "${GH_REPO_URL:-}" +printf 'GH_WORKFLOW_URL=%s\n' "${GH_WORKFLOW_URL:-}"